Function Pointers and Callback Pattern
Learn how function pointers and the callback pattern decouple embedded drivers from application code, with Cortex-M performance and safety notes.

Contents & prerequisites
Prerequisites
Why use function pointers instead of direct function calls?
A function pointer lets code invoke a function chosen at runtime instead of one hardwired at compile time, which is what makes drivers reusable across applications that don't yet exist when the driver is written. A direct call — uart_handle_rx(byte) — binds the caller to one specific function forever. A callback — ctx->rx_handler(byte, ctx->user_data) — lets the caller invoke whatever function was registered, by whoever owns that context, at whatever point it was registered. The driver stops needing to know what the application does with a received byte; it only needs to know the signature of the function that will handle it.
Decoupling driver logic from application state machines
A UART driver that calls app_process_packet() directly has coupled itself to one application's state machine, and that driver cannot be reused on the next project without editing its source. Replace the direct call with a registered function pointer and the driver becomes a reusable unit: it knows only that "something matching void (*)(uint8_t, void*) gets called when a byte arrives," not what that something does. This separation matters most in shops that maintain a shared HAL (hardware abstraction layer) across multiple products — the HAL ships once, and each product registers its own handlers.
Enabling runtime selection of ISR handlers
Some MCUs share one interrupt vector across multiple similar peripherals (several UART instances feeding one handler, or several GPIO pins sharing an EXTI line), and a table of function pointers indexed by instance number lets one ISR (interrupt service routine) dispatch to the right handler without a chain of if/else on peripheral ID. This also enables mode switching at runtime — a bootloader can install one callback for firmware-update mode and swap in a different one once the application boots, without recompiling the vector table.
Reducing firmware binary size through indirect dispatch
A dispatch table of function pointers replaces repeated if (mode == A) f_a(); else if (mode == B) f_b(); chains with a single indexed call, which on a flash-constrained MCU (say, 32 KB to 128 KB of flash) can measurably shrink code size when the same dispatch pattern repeats across many peripherals or states. The savings come from eliminating duplicated comparison-and-branch sequences, not from the call instruction itself — an indirect call is not inherently smaller than a direct one. Whether the net effect helps or hurts depends on how many call sites share the table; measure with a map file rather than assuming.
How do you declare and initialize a function pointer in C?
A function pointer is declared by writing the function's return type and parameter list with the pointer name wrapped in parentheses and preceded by *: void (*handler)(uint8_t); declares a pointer to a function taking a uint8_t and returning void. The parentheses are not decorative — void *handler(uint8_t) declares a function returning void *, a completely different type, and the compiler will not warn you cleanly about the mixup.
Pointer-to-function syntax and typedef patterns
Raw function-pointer syntax is unreadable past one argument, so firmware code almost always hides it behind a typedef:
typedef void (*uart_rx_cb_t)(uint8_t byte, void *ctx);
uart_rx_cb_t rx_callback = NULL;
void uart_isr(void) {
uint8_t b = UART_DATA_REG;
if (rx_callback != NULL) {
rx_callback(b, rx_user_ctx);
}
}
This typedef pattern is the same idiom covered for structs and unions in C Language Basics: Pointers, Structs, Unions — a function pointer is just another type that deserves a name, and giving it one turns a cryptic declaration into a self-documenting API.
Matching signature requirements: return type and parameter list
The compiler checks a function pointer's assigned value against its declared signature, but it does so by structural type matching, not by name, and a mismatched signature compiled with relaxed warning flags can produce a callback that corrupts the stack on call. Assigning a function with the wrong parameter count or incompatible pointer types to a typedef'd callback should trigger a warning under -Wall; build with -Wall -Wextra (or your compiler's equivalent) specifically because silent signature mismatches are a classic source of callback-related corruption that only appears in the field.
NULL initialization and validity checks before invocation
Every function pointer that can be invoked before it's deliberately set should be initialized to NULL, and every call site should check for NULL before dereferencing it. The pattern if (rx_callback != NULL) rx_callback(b, ctx); costs one branch and one comparison — negligible next to the cost of calling through garbage. Static analysis tools (MISRA-C checkers, Coverity, cppcheck) flag unchecked function-pointer calls specifically because this bug is both common and silent until the exact code path that exercises the uninitialized pointer runs.
What is the callback pattern and when do you use it in firmware?
The callback pattern is a convention where a lower layer (driver, HAL) holds a function pointer supplied by a higher layer (application) and invokes it when a specific event occurs, which inverts the normal caller-to-callee relationship so the lower layer can notify the higher one without depending on it. You reach for this pattern whenever one piece of code needs to react to an event whose timing it cannot control — which in firmware means almost every peripheral interaction.
Event-driven architecture: timer completion, UART RX, ADC ready
A timer-expiry callback, a UART byte-received callback, and an ADC (analog-to-digital converter) conversion-complete callback are all the same shape: a peripheral driver fires an event, and application code registered to hear about it runs. Structuring firmware this way avoids polling loops that busy-wait on status flags and lets the main loop (or RTOS scheduler) stay idle until real work arrives — important for power budgets on battery-powered MCUs where idle current might be microamps but active current is milliamps.
Separating concerns between HAL and application layers
The HAL's job is to know the registers; the application's job is to know what the data means. A callback is the seam between them: the HAL calls adc_ready_cb(raw_value, ctx) without knowing whether that value feeds a Kalman filter or a logging buffer, and the application never touches a register directly. This separation is also where Static, Inline, and Extern Linkage matters — the HAL's internal state (the registered callback pointer itself) is typically static to its translation unit, exposed only through a registration function, so the application can't accidentally overwrite it by poking a global.
Callback registration at initialization vs. runtime reassignment
Registering a callback once at init() and never touching it again is simpler to reason about and is the right default; reassigning it at runtime (swapping handlers mid-operation, as a bootloader-to-application handoff does) is powerful but demands care that no interrupt fires between "the old handler returns" and "the new pointer is fully written." On a 32-bit MCU a pointer-sized write is atomic with respect to interrupts in practice, but verify this against your compiler and core — don't assume it for structs containing a pointer plus other fields.
How do you pass context data through a callback?
A callback needs context data whenever the function it points to must act on more than its direct arguments, and the two dominant techniques are an opaque void * parameter and a handler object that bundles state with the function pointer. Without a context parameter, a callback can only operate on globals, which defeats the purpose of decoupling the driver from a specific application instance.
Void pointer (void *) for opaque user context
A void * parameter lets the driver pass back exactly the pointer the application gave it at registration time, with no idea what it points to:
typedef void (*timer_cb_t)(void *ctx);
void timer_register(timer_cb_t cb, void *ctx);
typedef struct {
uint32_t pwm_channel;
uint16_t duty_target;
} pwm_ctx_t;
void pwm_timer_cb(void *ctx) {
pwm_ctx_t *p = (pwm_ctx_t *)ctx;
set_duty(p->pwm_channel, p->duty_target);
}
This is C's answer to a closure: the cast back to the concrete type is the application's responsibility, and getting the cast wrong is a silent type-punning bug the compiler cannot catch, because void * throws away the type information on the way in.
Struct containing state and function pointer (handler object)
Bundling the function pointer and its context into one struct — a "handler object" — avoids passing two arguments everywhere and keeps the pairing atomic:
typedef struct {
void (*handler)(uint8_t byte, void *ctx);
void *ctx;
} uart_handler_t;
One uart_handler_t can be swapped as a unit, which is harder to get wrong than updating a callback pointer and its context pointer as two separate globals that can drift out of sync if one update is interrupted halfway.
Using extern static context or thread-local storage trade-offs
A driver with only one instance can skip the context parameter entirely and keep its state in a file-local static variable, referenced directly by the callback — simpler, but it hard-codes "there is exactly one of these," which breaks the day a second UART instance needs the same driver. Thread-local storage solves the equivalent problem on multi-threaded RTOS builds but adds per-access overhead and is overkill for a single-core bare-metal ISR context where the callback and its context are both known at compile time per peripheral instance. Pick the simplest option that matches how many instances of the peripheral actually exist on the board.
What are the stack and performance implications of callbacks?
An indirect call through a function pointer costs a handful of extra cycles over a direct call on Cortex-M, because the core must load the target address from memory or a register before branching instead of encoding it in the instruction, and that cost is rarely the bottleneck compared to what the callback body does. The overhead is real but small enough that it should not drive architectural decisions except in the tightest ISR paths.
Indirect branching cost on Cortex-M processors
A direct BL (branch with link) encodes its target as a PC-relative offset and the pipeline can often prefetch down that path; an indirect call through a register (BLX Rn) requires the address to be loaded first and may cost an extra cycle or two depending on core and pipeline state. The exact cycle counts differ across the M0 through M55 family covered in ARM Cortex-M Pipeline Stages (M0 to M55) — look up the specific core's technical reference manual rather than assuming a number, since branch prediction and pipeline depth both affect the real cost.
Stack depth when callbacks invoke other callbacks
A callback that invokes another callback — an ADC-ready handler that calls a filter callback that calls a logging callback — adds one stack frame per link in that chain, and inside an ISR with a small dedicated interrupt stack (sometimes as little as 512 bytes to 1 KB on a small MCU), three or four chained callbacks plus their local variables can exhaust it without any single function looking suspicious in isolation. Trace the worst-case call depth through registered callbacks explicitly during review; the compiler cannot warn you about stack usage across an indirect call because it doesn't know at compile time which function will be invoked.
Inline hints and volatile semantics for ISR callbacks
A function pointer defeats inlining by definition — the compiler cannot inline a call it can't resolve until runtime — so a hot path that would benefit from inlining (see C const and restrict for Optimization Hints for the broader set of compiler hints) should not go through a callback if it runs on every sample in a tight loop. Mark the function-pointer variable itself volatile only if it can change asynchronously relative to the code reading it — for instance, if a callback pointer is reassigned from a lower-priority context while a higher-priority ISR might read it mid-update — otherwise volatile just blocks optimizations for no benefit.
How do you avoid common callback pitfalls?
Most callback bugs in firmware come down to three failure modes: calling through a pointer that was never set, two contexts racing on the same handler slot, and a context struct that stops being valid before the callback that reads it runs. Each is cheap to guard against and expensive to debug once it ships.
Null-pointer dereferences and uninitialized function pointers
A static function pointer without an explicit initializer is zero-initialized by the C standard, which on most embedded targets means it reads as NULL — but relying on that implicitly is fragile if the variable is ever moved into a struct allocated on the stack or from a pool, where no such guarantee exists. Always initialize explicitly and always check before calling, as shown earlier; the one line of defensive code is cheaper than a hard fault in the field with a stack trace that just says "branch to address 0x00000000."
Re-entrance hazards in nested interrupt contexts
A callback invoked from an ISR that gets preempted by a higher-priority interrupt which calls the same driver — and therefore the same callback — can corrupt shared state if the callback isn't written to be re-entrant (safe to be interrupted mid-execution and called again before the first call finishes). The fix is either to disable the specific interrupt (or raise BASEPRI on Cortex-M) around the non-reentrant section, or to write the callback so it only touches data that's either local or protected, with no partial updates to shared structures left visible between reads and writes.
Memory lifetime of context structs and capture safety
A context struct passed to a callback must outlive every call to that callback, and the most common way to violate this is passing the address of a stack-local struct into a registration function and then returning from the enclosing function while the callback is still registered. The registered ctx pointer now points at stack memory that's been reused by the next function call, and the callback reads garbage — or, worse, reads plausible-looking garbage that fails only intermittently. Context structs backing long-lived callbacks should be static, file-scope, or heap/pool-allocated with an explicit ownership rule, never stack-local unless the registration and every possible invocation are provably within the same stack frame's lifetime.
Real-world callback patterns: timer expiry, peripheral completion, state transitions
Three patterns recur across nearly every MCU peripheral driver, and each maps directly onto the callback mechanics described above.
PWM duty-cycle update via periodic timer callback
A periodic hardware timer fires an interrupt at a fixed interval (say, every 1 ms), and its ISR calls a registered callback that recomputes and writes a new PWM (pulse-width modulation) duty cycle — the timer driver never needs to know it's driving a motor controller or an LED dimmer, only that something wants to run on its tick.
UART TX completion triggering next message chunk
A UART transmit-complete interrupt firing a registered callback lets a driver send a multi-chunk message without the application pre-loading a full buffer: each completion callback checks whether more data remains and, if so, kicks off the next chunk, keeping the transmit buffer small and the application logic out of the ISR's critical path.
ADC conversion ready triggering filter and logging
An ADC conversion-complete interrupt invoking a registered callback with the raw sample as its argument is the cleanest place to apply a running filter (moving average, simple IIR) and append to a logging buffer, all without the ADC driver importing any knowledge of filters or logging — exactly the decoupling that makes this pattern worth the small indirect-call overhead it costs on every sample.
Learning
Sign in to track your progress.
Evidence
Public projects engineers linked to Function Pointers and Callback Pattern.
No engineer has linked a project to this topic yet. Built something that proves it? Add the project and tag it with embedded-firmware-function-pointers-and-callback-pattern — it then shows here and on your public profile.
