Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C

Type-Safe Multicast Callbacks in C

4.50/5 (11 votes)
13 Oct 2024CPOL5 min read 14.2K   302  
A type-safe multicast callback library used for anonymous function invocation implemented in C

Introduction

Function pointers are used to reduce coupling between two pieces of code. A publisher defines a callback function signature and allows anonymous registration of a function pointer. A subscriber creates a function implementation conforming to the publisher’s callback signature and registers a pointer to the function with the publisher at runtime. The publisher code knows nothing about the subscriber code – the registration and callback invocation is anonymous.

Multicast callbacks permit two or more subscribers to register for notification via a callback. When a publisher invokes the callback, all registered subscriber functions get sequentially invoked.

This article provides a simple, type-safe multicast callback module implemented in C.

See GitHub for latest source code:

See related GitHub repositories:

Callbacks Background

Software systems are organized into different software modules. A module’s incoming interface is declared within an interface file. An outgoing interface can be expressed using function pointers registered and invoked at runtime. The publisher module notifies subscribers by invoking anonymous functions via a function pointer. “Anonymous” means the publisher code doesn’t include any subscriber header files. The subscriber knows about the publisher, but not vice versa. In this way, the publisher code module doesn’t change when a new subscriber wants to receive callback notifications.

For instance, say our system has an alarm module. It’s responsible for handling detected alarms. Now, other modules within the system may be interested in receiving alarm notifications. Maybe the GUI needs to display the alarm to the user. A logging module might save the alarm to a persistent storage. And an actuators module may need to stop motor movements. Ideally, the alarm module should not know about the GUI, logger or actuator modules. Instead, subscribers register with the alarm module for notifications so that when an alarm occurs, each subscriber alarm handler function is called.

Using the Code

I’ll first present how to use the code, and then get into the implementation details.

The MULTICASTX_DECLARE macro exposes a multicast callback interface within a publisher header file. The “X” is a numeric value indicating the number of function arguments within the callback. For instance, use MULTICAST2_DECLARE if the callback function has two function arguments. From 0 to 5 arguments are supported by the library.

The first argument is the callback name. The other arguments are the function argument types. The macro below defines a function signature of void MyFunc(int, float).

C++
MULTICAST2_DECLARE(MyCallback, int, float)

The MULTICASTX_DEFINE macro implements the multicast callback interface within a publisher source file. The macro is placed at file scope. The macro arguments are the callback name, function argument types, and the maximum number of allowed registrars. In the example below, up to 5 function pointers can be registered.

C++
MULTICAST2_DEFINE(MyCallback, int, float, 5)

The two macros fully implement a type-safe multicast callback interface. The macros automatically create three functions based on the macro arguments provided.

C++
void MyCallback_Register(MyCallbackCallbackType callback);
void MyCallback_Unregister(MyCallbackCallbackType callback);
void MyCallback_Invoke(int val1, float val2);

A subscriber registers a callback using the Register() function:

C++
MyCallback_Register(&NotificationCallback);

Similarly a subscriber unregisters using the Unregister() function:

C++
MyCallback_Unregister(&NotificationCallback);

The publisher sequentially invokes all registered callbacks using the Invoke() function.

C++
MyCallback_Invoke(123, 3.21f);

SysData Example

SysData is a simple module showing how to expose an outgoing multicast callback interface. SysData stores system data and provided subscriber notification when the mode changes. The interface is shown below:

C++
#ifndef _SYSDATA_H
#define _SYSDATA_H

#include "multicast.h"

typedef enum
{
   MODE_STARTING,
   MODE_NORMAL,
   MODE_ALARM
} ModeType;

// Publisher declares a multicast callback called SysData_SetModeCallback.
// Subscribers register for callbacks with function signature: void MyFunc(ModeType mode)
MULTICAST1_DECLARE(SysData_SetModeCallback, ModeType)

// Set a new system mode and callback any clients registered with SysData_SetModeCallback.
void SysData_SetMode(ModeType mode);

#endif // _SYSDATA_H

The SysData implementation is shown below:

C++
#include "sysdata.h"

// Define the multicast callback for up to 3 registered clients
MULTICAST1_DEFINE(SysData_SetModeCallback, ModeType, 3)

static ModeType _mode = MODE_STARTING;

void SysData_SetMode(ModeType mode)
{
   // Update the private _mode value
   _mode = mode;
   
   // Invoke callbacks on all registered clients
   SysData_SetModeCallback_Invoke(_mode);
}

A subscriber connects to SysData by creating a callback function and registering the function pointer at runtime.

C++
void SysDataCallback1(ModeType mode)
{
   printf("ModeCallback1: %d\n", mode);
}

int main(void)
{
   // Register with SysData for callbacks
   SysData_SetModeCallback_Register(&SysDataCallback1);

   // Call SysData to change modes
   SysData_SetMode(MODE_STARTING);
   SysData_SetMode(MODE_NORMAL);

   return 0;
}

Notice that SysDataCallback1() is called each time SysData_SetMode() is called. Also note that SysData doesn’t know about the subscriber as the registration is anonymous.

Implementation

The implementation uses macros and token pasting to provide a type-safe interface for using multicast callbacks. The token pasting operator (##) is used to merge two tokens when the preprocessor expands the macro. The MULTICAST1_DECLARE macro is shown below.

C++
#define MULTICAST1_DECLARE(name, arg1) \
    typedef void(*name##CallbackType)(arg1 val1); \
    void name##_Register(name##CallbackType callback); \
    void name##_Unregister(name##CallbackType callback);

In the SysData example used above, macro expands to:

C++
typedef void(*SysData_SetModeCallbackCallbackType)(ModeType val1);
void SysData_SetModeCallback_Register(SysData_SetModeCallbackCallbackType callback);
void SysData_SetModeCallback_Unregister(SysData_SetModeCallbackCallbackType callback);

Notice every name## location is replaced by the macro name argument, in this case, being SysData_SetModeCallback from the declaration below.

C++
MULTICAST1_DECLARE(SysData_SetModeCallback, ModeType)

The implementation macro is shown below:

C++
#define MULTICAST1_DEFINE(name, arg1, max) \
   static CB_Data name##Multicast[max]; \
      void name##_Register(name##CallbackType callback) { \
   CB_MulticastAddCallback(&name##Multicast[0], max, (CB_CallbackType)callback); } \
      void name##_Unregister(name##CallbackType callback) { \
   CB_MulticastRemoveCallback(&name##Multicast[0], max, (CB_CallbackType)callback); } \
       void name##_Invoke(arg1 val1) { \
           for (size_t idx=0; idx<max; idx++) { \
               name##CallbackType callback = (name##CallbackType)CB_MulticastGetCallback
                                             (&name##Multicast[0], max, idx); \
               if (callback != NULL) \
                   callback(val1); } }

The expanded MULTICAST1_DEFINE from the SysData example results in:

C++
static CB_Data SysData_SetModeCallbackMulticast[3];

void SysData_SetModeCallback_Register(SysData_SetModeCallbackCallbackType callback)
{
   CB_MulticastAddCallback(&SysData_SetModeCallbackMulticast[0], 3, (CB_CallbackType)callback);
}

void SysData_SetModeCallback_Unregister(SysData_SetModeCallbackCallbackType callback)
{
   CB_MulticastRemoveCallback(&SysData_SetModeCallbackMulticast[0], 3, (CB_CallbackType)callback);
}

void SysData_SetModeCallback_Invoke(ModeType val1)
{
   for (size_t idx=0; idx<3; idx++)
   {
       SysData_SetModeCallbackCallbackType callback =
          (SysData_SetModeCallbackCallbackType)CB_MulticastGetCallback
                               (&SysData_SetModeCallbackMulticast[0], 3);
   
       if (callback != NULL)
           callback(val1);
   }
}

Notice the macro provides a thin, type-safe wrapper around CB_MulticastAddCallback() and CB_MulticastRemoveCallback() functions. If the wrong function signature is registered, the compiler generates an error or warning. The macros automate the monotonous, boilerplate code that you’d normally write by hand.

The callback functions are simply stored in an array. The Invoke() function just iterates over the callback function array and invokes any element that is not NULL.

The callback add/remove functions just store a generic CB_CallbackType to be extracted and used by the boilerplate macro code.

C++
#include "multicast.h"
#include <stdbool.h>
#include <assert.h>

void CB_MulticastAddCallback(CB_Data* cbData, size_t cbDataLen, CB_CallbackType callback)
{
    bool success = false;

    if (cbData == NULL|| callback == NULL || cbDataLen == 0)
    {
        assert(0);
        return;
    }

    // TODO - software lock

    // Look for an empty registration within the callback array
    for (size_t idx = 0; idx<cbDataLen; idx++)
    {
        if (cbData[idx].callback == NULL)
        {
            // Save callback function pointer
            cbData[idx].callback = callback;
            success = true;
            break;
        }
    }

    // TODO - software unlock

    // All registration locations full?
    if (success == false)
    {
        assert(0);
    }
}

void CB_MulticastRemoveCallback(CB_Data* cbData, size_t cbDataLen, CB_CallbackType callback)
{
    if (cbData == NULL || callback == NULL || cbDataLen == 0)
    {
       assert(0);
       return;
    }

    // TODO - software lock

    // Look for an empty registration within the callback array
    for (size_t idx = 0; idx<cbDataLen; idx++)
    {
        if (cbData[idx].callback == callback)
        {
            // Remove callback function pointer
           cbData[idx].callback = NULL;
           break;
        }
    }

    // TODO - software unlock
}

CB_CallbackType CB_MulticastGetCallback(CB_Data* cbData, size_t cbDataLen, size_t idx)
{
    if (cbData == NULL || cbDataLen == 0 || idx >= cbDataLen)
    {
       assert(0);
       return NULL;
    }

    // TODO - software lock

    CB_CallbackType cb = cbData[idx].callback;

    // TODO - software unlock

    return cb;
}

Multithreading

The multicast library can be used on a single or multithreaded systems. If used on a system with operating system threads, it can be made thread-safe. The multicast.c file contains comments on where to place software locks. Once the locks are in place, the multicast API and macros are safe to deploy on a multithreaded system where subscribers that execute on another thread may register and unregister.

On a multithreaded system, if the subscriber code executes on a separate thread, then typically the subscriber's callback function implementation posts a message to a thread queue to be handled asynchronously. In this way, the publisher thread generating the callbacks doesn’t get blocked and the subscriber code will be able to handle the notification asynchronously via an OS message queue.

Conclusion

Multicast callbacks eliminates unnecessary code coupling between modules. Registering multiple callback function pointers offers a convenient notification system. The article has demonstrated one possible design capable of being uniformly deployed on a system. The implementation was kept to a minimum facilitating usage on any system embedded or otherwise. A small amount of macro code automates boilerplate code and gives the C library type-safety.

History

  • 9th June, 2017: Initial release

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)