Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 3

Callback Event Handling

Recommended resources

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.

Callback Implementation in Embedded ISRs
This image illustrates the principle perfectly. Instead of putting application-specific logic (`GPIOA->ODR ^= ...`) directly inside a driver-level Interrupt Service Routine (ISR), the ISR calls a generic `User_Function()`. This user function is the callback, registered by the application layer.

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* context pointer 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 the void* 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.

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:

  1. The observer (the module that wants to listen for events) embeds a callback_struct inside its own state structure.
  2. During registration, the observer provides a pointer to its embedded callback_struct.
  3. When the event occurs, the dispatcher calls the function pointer within the callback_struct, passing a pointer to that same callback_struct as an argument.
  4. Inside the callback handler, container_of(ptr, type, member) is used. It takes the ptr to the callback_struct, knows the type of the parent observer struct, and knows the member name 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:

  1. Define the necessary data structures based on the container_of pattern shown in the video (e.g., a callback struct with a function pointer, an observer struct that embeds it).
  2. Implement an event dispatcher that can maintain a list of registered callbacks (a simple singly-linked list is sufficient).
  3. Implement register_event_handler() and unregister_event_handler() functions.
  4. Create two "observer" modules: command_parser and data_logger. Each should have its own state and its own callback handler function.
  5. In main, initialize both modules and register their callbacks with the dispatcher.
  6. Create a function simulate_uart_line_reception() that calls the dispatcher to notify all registered listeners.
  7. 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* context method is common but not type-safe. The container_of pattern 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.