Hello! Welcome back to your interview preparation bootcamp.
In our last lesson, we saw how function pointer arrays can create highly efficient and scalable state machines. This pattern is powerful because it allows a system's behavior to be determined at runtime by changing which function gets called.
Today, we'll explore another cornerstone pattern that leverages function pointers: the callback mechanism. Our goal for this lesson is to implement a callback-driven event handler for event-driven embedded programming. This is a fundamental concept in any layered software architecture, especially in driver development, RTOS-based systems, and asynchronous communication, making it a frequent topic in senior embedded engineering interviews.
What is a Callback?
A callback is a function passed as an argument to another function, which is then "called back" when a specific event occurs. Think of it like giving your phone number (the callback) to a restaurant (the event source); they don't need to know who you are, they just need the number to call you when your table is ready (the event).
The primary purpose of a callback is to achieve decoupling. A lower-level module (like a hardware driver) can notify a higher-level module (the application) of an event without having any hardcoded knowledge of the application's functions. This separation is crucial for building modular, reusable, and maintainable firmware.

The Basic Mechanism: Passing Function Pointers
At its heart, a callback is simply a function pointer passed as an argument. Let's refresh our understanding of the syntax and see a simple example of this in action.
Function Pointers | C Programming Tutorial
This video from Portfolio Courses provides a clear, step-by-step guide to the fundamentals of function pointers and their use in creating callbacks.
Please watch these two segments: What are Function Pointers? (0:00 - 2:24): A quick recap of the basic syntax and concept of storing a function's memory address in a pointer. Callback Functions (10:08 - 15:13): This is the core of the concept. It demonstrates exactly how to pass a function pointer as an argument to another function, allowing the receiving function's behavior to be customized. The is_freezing example is a perfect illustration of the callback pattern.
As the video shows, the function is_freezing is decoupled from the specific logic of checking for freezing temperatures in Celsius or Fahrenheit. It only knows that it will be given some function that fits the required signature (bool (*freeze_check)(int)), and its job is to call it.
Designing a System-Level Event Handler
A single callback is useful, but real-world systems often need to be more flexible. An interviewer might ask you to design an event handler with more complex requirements. This is a classic system design question.
Let's analyze a typical problem and two common solutions.
System Design C Linux - Kernel Blog
Now, let's tackle a realistic system design problem. This article from Kernel Blog walks through designing a multi-module event handling system capable of notifying multiple listeners for multiple events.
Read the first part of the article, focusing on the problem statement and the two proposed implementations: Problem Statement & Data Structures: Understand the requirements and the EventListener and Event structs. Notice the void* context member, which is a key part of this pattern. Array-based Implementation: Study the code for register_event_handler and event_dispatch using fixed-size arrays. Note the pros and cons discussed (simplicity, fixed memory vs. poor scalability). Linked-List based Implementation: Review the alternative implementation that uses malloc and a linked list. This allows for a dynamic number of listeners but introduces complexity in memory management.
The article highlights several key design points you should be ready to discuss in an interview:
- 1-to-Many Notification: Real systems often need multiple modules to react to the same event. Both the array-of-listeners and linked-list approaches solve this.
- Passing Context: The callback function often needs access to data from the module that registered it. The
void* contextpointer is the classic C way to achieve this. The registering module passes a pointer to its own state, which the event system passes back to the callback. The callback then casts thevoid*back to its original type to access its data. - Trade-offs:
- Static Array: Simple, fast O(1) lookup if you have an
event_id, and has deterministic memory usage. However, it's inflexible and limited by compile-time constants (MAX_EVENTS,MAX_MODULES). - Linked List: Highly flexible, allowing any number of listeners to be registered dynamically. The downsides are the overhead of dynamic memory allocation (
malloc/free) and slower O(N) traversal to dispatch events or unregister a specific callback.
- Static Array: Simple, fast O(1) lookup if you have an
An Advanced, Type-Safe Callback Pattern
The void* context pattern is extremely common, but it has a significant drawback: it completely bypasses the C type system. This can lead to subtle bugs if the pointer is cast incorrectly.
For a top-tier interview, you should be aware of a more robust and type-safe pattern used extensively in projects like the Linux kernel. Instead of passing a generic void*, this pattern uses a clever macro (container_of) to derive context.
Embedded C Programming Design Patterns: Callback
The void* method works, but it's not the most elegant solution. A more advanced pattern, heavily used in professional embedded C projects, provides type safety and better encapsulation. This video from Martin K. Schröder is an excellent guide to this technique.
This video is dense with valuable information. Focus on these parts: Introduction to the Pattern (0:57 - 3:55): Understand why this pattern is used to decouple objects and provide notifications in a type-safe way, avoiding arbitrary void* user data. Core Implementation (6:40 - 11:51): This is the critical section. Pay close attention to how the callback handler is part of a struct, which is itself embedded within the 'observer' object. See how the container_of macro is used to get a pointer to the containing observer from within the callback function itself. This is the key takeaway. Extending to Multiple Listeners (11:51 - 14:02): See how this elegant pattern is extended to support multiple listeners using a standard linked-list implementation. Best Practices & Pitfalls (14:02 - 19:03): This summarizes the key design rules and common bugs, such as the observer going out of scope. This is prime interview material.
The container_of pattern is a significant step up. Here's the core idea:
- The
observer(the module that wants to listen for events) embeds acallback_structinside its own state structure. - During registration, the observer provides a pointer to its embedded
callback_struct. - When the event occurs, the
dispatchercalls the function pointer within thecallback_struct, passing a pointer to that samecallback_structas an argument. - Inside the callback handler,
container_of(ptr, type, member)is used. It takes theptrto thecallback_struct, knows thetypeof the parent observer struct, and knows themembername of the callback struct within the parent, and from that, calculates the address of the parent observer struct.
This is type-safe, elegant, and is a hallmark of high-quality, reusable C code.
Practical Implementation
Now, let's put this advanced pattern into practice. Your task is to implement a simple event handler for a simulated UART driver using the container_of pattern.
Scenario:
A UART driver needs to notify two different modules—a command parser and a data logger—when a complete line of text (terminated by \n) has been received.
Requirements:
- Define the necessary data structures based on the
container_ofpattern shown in the video (e.g., a callback struct with a function pointer, an observer struct that embeds it). - Implement an event dispatcher that can maintain a list of registered callbacks (a simple singly-linked list is sufficient).
- Implement
register_event_handler()andunregister_event_handler()functions. - Create two "observer" modules:
command_parseranddata_logger. Each should have its own state and its own callback handler function. - In
main, initialize both modules and register their callbacks with the dispatcher. - Create a function
simulate_uart_line_reception()that calls the dispatcher to notify all registered listeners. - The callback handlers for the parser and logger should simply print a message, for example:
"Command Parser: Received new line!".
This exercise will solidify your understanding of how to build a flexible, multi-subscriber, and type-safe event system in C.
Click here for a solution skeleton to get you started.
#include <stdio.h>
#include <stddef.h> // For offsetof
// The magical container_of macro
#define container_of(ptr, type, member) ({ \
const typeof( ((type *)0)->member ) *__mptr = (ptr); \
(type *)( (char *)__mptr - offsetof(type,member) );})
// Forward declarations
struct my_callback;
// Callback function pointer type
// It receives a pointer to the callback struct itself
typedef void (*my_callback_handler_t)(struct my_callback *cb);
// The callback structure to be embedded in observers
typedef struct my_callback {
struct my_callback *next; // For linking into a list
my_callback_handler_t handler;
} my_callback_t;
// --- Dispatcher ---
static my_callback_t *listener_list_head = NULL;
void register_event_handler(my_callback_t *cb) {
// TODO: Add the callback to the head of the listener_list
}
void unregister_event_handler(my_callback_t *cb) {
// TODO: Find and remove the callback from the list
}
void dispatch_event(void) {
printf("--- Dispatching Event ---\n");
// TODO: Iterate through the list and call each handler
}
// --- Observers ---
// 1. Command Parser Module
typedef struct {
const char *name;
my_callback_t line_received_cb; // Embedded callback struct
} command_parser_t;
void parser_cb_handler(my_callback_t *cb) {
// TODO: Use container_of to get the parent command_parser_t
// Then print a message like "parser->name: Received new line!"
}
void parser_init(command_parser_t *parser) {
parser->name = "Command Parser";
parser->line_received_cb.handler = parser_cb_handler;
parser->line_received_cb.next = NULL;
register_event_handler(&parser->line_received_cb);
}
// 2. Data Logger Module
typedef struct {
int lines_logged;
my_callback_t line_received_cb; // Embedded callback struct
} data_logger_t;
void logger_cb_handler(my_callback_t *cb) {
// TODO: Use container_of to get the parent data_logger_t
// Then print a message and increment lines_logged
}
void logger_init(data_logger_t *logger) {
logger->lines_logged = 0;
logger->line_received_cb.handler = logger_cb_handler;
logger->line_received_cb.next = NULL;
register_event_handler(&logger->line_received_cb);
}
// --- Main Application ---
int main() {
command_parser_t my_parser;
data_logger_t my_logger;
parser_init(&my_parser);
logger_init(&my_logger);
dispatch_event();
dispatch_event();
// TODO: Test unregistering one of them
// unregister_event_handler(&my_parser.line_received_cb);
// dispatch_event();
return 0;
}
Conclusion
Mastering callbacks is non-negotiable for an advanced embedded software role. They are the glue that holds layered and asynchronous systems together.
Key Takeaways for Your Interview:
- Purpose: Callbacks decouple software layers, enabling modular and reusable code. The caller (e.g., driver) doesn't need to know the implementation of the callee (e.g., application).
- Mechanism: Implemented using function pointers passed as arguments.
- System Design: Be prepared to design a 1-to-many event dispatcher. Discuss the trade-offs between static arrays (deterministic, but rigid) and linked lists (flexible, but requires dynamic memory or a pool allocator).
- Context Passing: The
void* contextmethod is common but not type-safe. Thecontainer_ofpattern is a superior, type-safe alternative that demonstrates deep knowledge of C idioms used in professional-grade software like the Linux kernel. - Pitfalls: The most common bug is a dangling callback pointer. If an observer is deallocated but its callback remains registered in a dispatcher list, the system will crash when the event is next dispatched. Always unregister callbacks during deinitialization.
In our next lesson, we will dive deep into the mechanics of dynamic memory, which we touched on today with the linked-list implementation. We will explore how malloc, calloc, and free work under the hood, investigating the roles of sbrk, free lists, and the challenges of memory fragmentation.