Chapter 6b — Callback Interface Pattern
The library uses a consistent dependency injection (DI) pattern across all modules. Instead of modules calling each other directly, the application wires them together through function pointer structs. This chapter explains the pattern and its conventions.
6b.1 The Pattern
Every module that depends on external behavior follows the same three-step pattern:
- Define an interface struct containing function pointers for each external dependency.
- Application creates and populates a static instance of the struct, filling in the pointers.
- Pass the struct to the module's
*_initialize()function, which stores the pointer internally.
// Step 1: Module defines its interface (in module_header.h)
typedef struct {
void (*send_openlcb_msg)(openlcb_msg_t *msg); // Required
void (*on_message_received)(openlcb_node_t *node, // Optional
openlcb_msg_t *msg);
void (*lock_shared_resources)(void); // Required
void (*unlock_shared_resources)(void); // Required
} interface_some_module_t;
// Step 2: Application creates the struct (in main.c)
static interface_some_module_t my_interface = {
.send_openlcb_msg = &platform_send_message,
.on_message_received = &app_handle_message,
.lock_shared_resources = &platform_lock,
.unlock_shared_resources = &platform_unlock,
};
// Step 3: Initialize the module
SomeModule_initialize(&my_interface);
6b.2 Required vs. Optional Callbacks
Interface struct members fall into two categories:
| Category | If NULL | Convention |
|---|---|---|
| Required | Module cannot function; undefined behavior or crash | Must always be set. Documented as required in the header. |
| Optional | Module checks for NULL before calling; uses a default behavior | Can be set to NULL. The default behavior is typically an Optional Interaction Rejected (OIR) response or a no-op. |
When an optional callback is NULL and a message arrives that would trigger it, the library generates an Optional Interaction Rejected (OIR) response automatically. This tells the remote node that this protocol/feature is not supported by this node.
6b.3 Key Callback Contracts
send_openlcb_msg()
The most important callback. Every module that needs to send a message calls through this pointer. The CAN transport layer provides the implementation, which fragments the message into CAN frames and queues them for transmission.
void (*send_openlcb_msg)(openlcb_msg_t *msg);
Contract:
- The callee takes ownership of the message buffer.
- The callee must eventually call
OpenLcbBufferStore_free_buffer()when done. - The caller must not access the message after calling send.
send_can_message()
Used by the CAN transport layer to hand a raw CAN frame to the hardware driver for transmission.
void (*send_can_message)(can_msg_t *msg);
Contract:
- The callee must transmit (or queue) the frame and then free the
can_msg_tbuffer. - The caller must not access the frame after calling send.
lock_shared_resources() / unlock_shared_resources()
Described in detail in Chapter 6. Every module that accesses shared data structures requires these callbacks.
6b.4 Node Enumeration Callbacks
Some callbacks are invoked once per node during the main state machine's node enumeration loop. The callback receives a pointer to the current node and the current message being processed:
void (*on_event_received)(openlcb_node_t *node, openlcb_msg_t *msg);
void (*on_datagram_received)(openlcb_node_t *node, openlcb_msg_t *msg);
void (*on_train_command)(openlcb_node_t *node, openlcb_msg_t *msg);
These are called from within the main dispatcher's node iteration loop, so they are always in main loop context (never from ISR).
6b.5 How Callbacks Wire Modules Together
6b.6 Example: Node Module Interface
The node module has a minimal interface with a single optional callback:
typedef struct {
void (*on_100ms_timer_tick)(void); // Optional: fired every 100ms after node updates
} interface_openlcb_node_t;
// Usage:
static interface_openlcb_node_t node_interface = {
.on_100ms_timer_tick = &my_timer_handler, // or NULL to skip
};
OpenLcbNode_initialize(&node_interface);
6b.7 Design Benefits
- Testability: In unit tests, interfaces are populated with mock functions that record calls and return test data. No hardware needed.
- Portability: The same core code runs on dsPIC, STM32, ESP32, and macOS -- only the interface implementations differ.
- Modularity: Modules are self-contained. Adding a new protocol handler does not require modifying existing modules.
- Zero overhead: Function pointer calls have the same cost as direct calls on most architectures. There is no vtable lookup or dynamic dispatch overhead.