Quick Cap
A function pointer stores the address of a function, letting you decide which function to call at runtime instead of at compile time. This is the mechanism behind callbacks, dispatch tables, interrupt vector tables, HAL layers, and manual polymorphism in C -- patterns you will use (and be asked about) constantly in embedded work.
Interviewers test whether you can read the notoriously tricky syntax, choose typedef to tame it, wire up real callback patterns, and avoid the landmines (null dereference, signature mismatch, stale pointers).
Key Facts:
- Syntax:
return_type (*name)(params)-- the parentheses around*nameare what make it a pointer-to-function rather than a function returning a pointer. - Always typedef:
typedef void (*irq_handler_t)(void);makes declarations, arrays, and struct members readable. - Classic example:
qsorttakes a comparator callback -- the canonical interview demonstration of function pointers. - Dispatch tables: An array of function pointers indexed by command/state/event replaces long
switchchains with O(1) lookup. - HAL abstraction: A struct of function pointers (
init,read,write) gives you driver-level polymorphism without C++. - Safety: Always null-check before calling. Place read-only tables in flash with
const. Mismatched signatures cause undefined behavior, not a compiler error (unless you usetypedef).
Deep Dive
At a Glance
| Concept | Detail |
|---|---|
| Declaration | int (*fp)(int, int); -- pointer to function taking two int args, returning int |
| Typedef form | typedef int (*math_op_t)(int, int); then math_op_t fp = add; |
| Assignment | fp = add; or fp = &add; -- both are valid, the & is optional |
| Invocation | fp(2, 3); or (*fp)(2, 3); -- both are valid, bare name preferred |
| Null safety | Always check if (fp) fp(); or if (fp != NULL) fp(); before calling |
| Const table | static const handler_t table[] = {...}; -- placed in .rodata (flash) |
Function Pointer Syntax
The raw syntax is the single biggest source of confusion. The key rule: parentheses bind the * to the name, telling the compiler "this is a pointer to a function" rather than "a function returning a pointer."
/* Declaring without typedef -- ugly but you must be able to read it */void (*callback)(uint8_t event_id); /* ptr to void f(uint8_t) */int (*compare)(const void *, const void *); /* ptr to int f(const void*...) *//* Declaring WITH typedef -- strongly preferred in real code */typedef void (*event_cb_t)(uint8_t event_id);typedef int (*comparator_t)(const void *, const void *);event_cb_t on_button = NULL; /* readable, self-documenting */comparator_t cmp = NULL;
With typedef, arrays, struct members, and function parameters all become trivial:
typedef void (*cmd_handler_t)(const uint8_t *payload, uint16_t len);/* Array of function pointers -- dispatch table */cmd_handler_t cmd_table[CMD_COUNT];/* Struct member */typedef struct {cmd_handler_t handler;const char *name;} cmd_entry_t;/* Function parameter -- registering a callback */void register_handler(uint8_t cmd_id, cmd_handler_t handler);
If asked to "read this declaration aloud," use the right-left rule: start at the name, go right, then left, alternating. void (*fp)(int) reads as "fp is a pointer to a function taking int and returning void." Interviewers use this to test C fluency.
Callback Patterns
A callback is simply a function pointer that one module stores and another module provides. The three most common embedded callback patterns are event handlers, dispatch tables, and state machines.
Pattern 1 -- Event handler (register/notify)
The driver exposes a registration function. The application passes its handler in. When the event fires, the driver calls back.
typedef void (*button_cb_t)(uint8_t pin, uint8_t state);static button_cb_t user_cb = NULL;void button_register_callback(button_cb_t cb) {user_cb = cb;}/* Called from ISR or polled loop */void button_isr(void) {uint8_t pin = /* ... */;uint8_t state = /* ... */;if (user_cb) { /* null check -- critical */user_cb(pin, state);}}
Pattern 2 -- Dispatch table (command/event router)
Replace a long switch with an array indexed by command ID. Constant-time lookup, easy to extend, and the table can live in flash.
typedef void (*cmd_handler_t)(const uint8_t *payload, uint16_t len);static void cmd_ping(const uint8_t *p, uint16_t len) { /* ... */ }static void cmd_reset(const uint8_t *p, uint16_t len) { /* ... */ }static void cmd_read(const uint8_t *p, uint16_t len) { /* ... */ }/* const -> .rodata (flash), saves RAM */static const cmd_handler_t cmd_table[] = {[CMD_PING] = cmd_ping,[CMD_RESET] = cmd_reset,[CMD_READ] = cmd_read,};void dispatch(uint8_t cmd_id, const uint8_t *payload, uint16_t len) {if (cmd_id < sizeof(cmd_table) / sizeof(cmd_table[0])&& cmd_table[cmd_id] != NULL) {cmd_table[cmd_id](payload, len);}}
Pattern 3 -- State machine driven by function pointers
Each state is a function that returns a pointer to the next state function. The run loop is a single line.
typedef void (*state_fn_t)(void);static void state_idle(void);static void state_sampling(void);static void state_transmit(void);static state_fn_t current_state = state_idle;void state_machine_run(void) {if (current_state) {current_state(); /* each state sets current_state to the next */}}static void state_idle(void) {if (start_requested()) {current_state = state_sampling;}}static void state_sampling(void) {read_sensor();current_state = state_transmit;}static void state_transmit(void) {send_data();current_state = state_idle;}
qsort -- The Classic Interview Example
The C standard library qsort is the textbook demonstration of a callback: you pass it a comparator function pointer so that the same sorting algorithm works on any data type.
#include <stdlib.h>/* Comparator: negative if a < b, zero if equal, positive if a > b */int compare_uint16(const void *a, const void *b) {uint16_t va = *(const uint16_t *)a;uint16_t vb = *(const uint16_t *)b;/* Subtraction trick is UNSAFE for large values (overflow).Use explicit comparison for production code. */return (va > vb) - (va < vb);}uint16_t readings[64];qsort(readings, 64, sizeof(uint16_t), compare_uint16);
Interviewers often ask you to write a comparator on the spot. The key pitfall: using return *(int*)a - *(int*)b; overflows for large values. Use explicit comparison instead.
HAL Abstraction with Function Pointers
In multi-platform embedded projects, a Hardware Abstraction Layer (HAL) uses a struct of function pointers so that higher-level code is completely decoupled from hardware specifics. This is the C equivalent of a C++ interface or abstract class.
typedef struct {int (*init)(uint32_t baud);int (*write)(const uint8_t *buf, uint16_t len);int (*read)(uint8_t *buf, uint16_t len);void (*deinit)(void);} uart_driver_t;/* Platform-specific implementation */static int stm32_uart_init(uint32_t baud) { /* ... */ return 0; }static int stm32_uart_write(const uint8_t *buf, uint16_t len) { /* ... */ return len; }static int stm32_uart_read(uint8_t *buf, uint16_t len) { /* ... */ return len; }static void stm32_uart_deinit(void) { /* ... */ }/* Driver instance -- const places it in flash */const uart_driver_t uart0 = {.init = stm32_uart_init,.write = stm32_uart_write,.read = stm32_uart_read,.deinit = stm32_uart_deinit,};/* Application code -- platform-agnostic */void app_send(const uart_driver_t *drv, const uint8_t *msg, uint16_t len) {if (drv && drv->write) {drv->write(msg, len);}}
This pattern is used by Zephyr RTOS, Linux device drivers, and every serious embedded HAL. The struct of function pointers is the vtable.
Vtable Pattern -- Manual Polymorphism in C
Extending the HAL idea, you can build a full "object-oriented" vtable pattern where each "object" carries a pointer to its vtable:
/* Base "class" */typedef struct sensor sensor_t;typedef struct {int (*init)(sensor_t *self);int16_t (*read)(sensor_t *self);void (*sleep)(sensor_t *self);} sensor_vtable_t;struct sensor {const sensor_vtable_t *vtable; /* pointer to shared vtable in flash */uint8_t i2c_addr;/* ... other common fields ... */};/* Generic API -- works on any sensor */int16_t sensor_read(sensor_t *s) {if (s && s->vtable && s->vtable->read) {return s->vtable->read(s);}return -1;}
Each concrete sensor type provides its own vtable (a const struct in flash) and its own implementation functions. Higher-level code calls the generic API and never knows which sensor it is talking to. This is how Linux's struct file_operations and Zephyr's device model work under the hood.
Safety Considerations
Function pointer bugs are among the hardest to debug because the failure mode is usually a jump to an invalid address -- a hard fault with no useful stack trace.
| Risk | Mitigation |
|---|---|
| Null function pointer call | Always check if (fp) before calling |
| Signature mismatch | Use typedef so the compiler checks types at assignment |
| Stale pointer (module unloaded) | Set pointer to NULL on deregistration; check before call |
| Table in RAM can be corrupted | Declare const to place table in .rodata (flash) |
| ISR context issues | Keep callbacks short; defer work to main loop via flag or queue |
Casting a function to a different signature and calling through the pointer is undefined behavior, not just "wrong results." The compiler will not warn you unless you use a properly typed typedef. A void (*)(void) callback secretly called with a uint32_t argument can silently corrupt the stack.
Debugging Story: The Phantom Hard Fault
An IoT team had a sensor hub that hard-faulted every few hours, always at a different PC address. The fault was a BusFault with an IMPRECISE flag, making the faulting instruction impossible to identify. After weeks of investigation, the root cause was a dispatch table in RAM: a stray DMA transfer occasionally overwrote one entry in the table with a data byte. The next time that command arrived, the dispatcher jumped to address 0x00000048 -- a garbage value that happened to be in the vector table region, causing an immediate fault.
The fix was two lines:
/* Before -- table in .data (RAM), vulnerable to corruption */static cmd_handler_t cmd_table[CMD_COUNT] = { /* ... */ };/* After -- table in .rodata (flash), immune to RAM corruption */static const cmd_handler_t cmd_table[CMD_COUNT] = { /* ... */ };
Moving the table to const placed it in flash (.rodata), where no errant DMA or buffer overflow could touch it. The team also added a bounds check and a null check in the dispatcher. The hard faults never returned.
Lesson: Always ask yourself whether a function pointer table needs to be mutable. If the entries are fixed at compile time, make the table const. It costs zero RAM and eliminates an entire class of corruption bugs.
What interviewers want to hear: You can read and write function pointer declarations fluently, and you default to typedef for anything non-trivial. You know the three core callback patterns (register/notify, dispatch table, state machine) and can sketch one on a whiteboard. You can write a qsort comparator correctly without the overflow bug. You understand the HAL/vtable pattern as C's way of achieving polymorphism. And you instinctively null-check before calling and use const to protect tables in flash.
Interview Focus
Classic Interview Questions
Q1: "Declare a function pointer to a function that takes a uint8_t and returns void, then show the typedef version."
Model Answer Starter: "The raw declaration is void (*fp)(uint8_t);. With typedef: typedef void (*handler_t)(uint8_t); handler_t fp;. I always prefer the typedef form in production code because it makes arrays, struct members, and parameter lists readable -- handler_t table[8]; is much clearer than void (*table[8])(uint8_t);."
Q2: "How does qsort use function pointers, and what is the common pitfall when writing a comparator?"
Model Answer Starter: "qsort takes a comparator callback with signature int (*)(const void*, const void*). It calls this function to compare elements during sorting, so the same algorithm works on any data type. The classic pitfall is using subtraction (return *(int*)a - *(int*)b;) which overflows for large values. The safe approach is explicit comparison: if (a_val > b_val) return 1; if (a_val < b_val) return -1; return 0;."
Q3: "What is a dispatch table and why would you use one instead of a switch statement?"
Model Answer Starter: "A dispatch table is an array of function pointers indexed by a command ID or event type. Instead of a switch with N cases, you do table[id](args) -- that is O(1) with no branch mispredictions. It is also easier to extend: adding a new command means adding one entry to the array and one handler function, without touching the dispatch logic. I declare the table const so it lives in flash and cannot be corrupted at runtime."
Q4: "How would you use function pointers to build a HAL abstraction layer?"
Model Answer Starter: "I define a struct with function pointers for each operation -- init, read, write, deinit -- and a typedef for the struct. Each platform provides a const instance of this struct with its hardware-specific implementations. Higher-level code receives a pointer to the struct and calls through it, completely decoupled from the hardware. This is the same pattern Zephyr and Linux use for device drivers. The const qualifier puts the vtable in flash, costing zero RAM."
Q5: "What safety checks do you always apply when working with function pointers in embedded code?"
Model Answer Starter: "Three things are non-negotiable. First, I always null-check before calling -- a null function pointer dereference is a hard fault with no useful backtrace. Second, I use typedef so the compiler can type-check assignments and prevent signature mismatches, which cause undefined behavior. Third, I declare tables const whenever the entries are fixed at compile time, placing them in flash where buffer overflows and DMA errors cannot corrupt them."
Trap Alerts
- Don't say: "Function pointers and regular pointers are the same thing" -- they point to code, not data, and have different semantics (e.g., you cannot do pointer arithmetic on them portably).
- Don't forget: The null check before calling through a function pointer -- this is the #1 cause of hard faults from function pointer code in embedded systems.
- Don't ignore: The
constqualifier on dispatch tables -- interviewers view this as a sign that you understand the memory map and think about data placement.
Follow-up Questions
- "How does the Cortex-M vector table relate to function pointers?"
- "Can you do pointer arithmetic on function pointers? Why or why not?"
- "How would you unit-test code that uses callbacks?"
- "What is the overhead of calling through a function pointer vs a direct call?"
Practice
❓ What does `typedef void (*handler_t)(uint8_t);` declare?
❓ What happens if you call through a function pointer that is NULL on a Cortex-M microcontroller?
❓ Why should a dispatch table of function pointers be declared `const` in embedded code?
❓ What is the classic bug when writing a qsort comparator using subtraction (return a - b)?
❓ In the HAL pattern `const uart_driver_t uart0 = { .init = stm32_init, ... };`, what does the `const` qualifier achieve?
Real-World Tie-In
Zephyr RTOS Device Model -- Zephyr's entire driver framework is built on structs of function pointers. Each device driver provides a const struct device_api with init, read, write, and device-specific operations. Application code calls generic APIs like uart_poll_out() that internally dereference the device's vtable. This design lets the same application binary run on STM32, nRF52, and ESP32 by swapping only the driver vtable at link time.
Automotive AUTOSAR RTE -- In AUTOSAR-compliant ECUs, the Runtime Environment (RTE) uses function pointer tables to route signals between software components. Each component registers its runnables (callbacks) with the RTE, which dispatches them based on timing events or data-received events. The tables are generated at build time and placed in flash as const arrays, satisfying ISO 26262 requirements for deterministic, non-modifiable dispatch.
Medical Device Firmware Update -- A team building an infusion pump needed to support field-upgradeable sensor drivers without reflashing the entire firmware. They defined a const sensor_vtable_t in a dedicated flash sector. During an update, only that sector was erased and rewritten with the new driver vtable. The application code never changed -- it called through the same generic sensor_read() API. This architecture reduced update size from 256 KB to 4 KB and cut update time from 90 seconds to under 3 seconds.