MODULE 8 · LESSON 8.4 · THEORY
Function Pointers in Embedded C: Callbacks, Dispatch Tables and the Vector Table
Function addresses, declaring and typedef-ing function pointers, indirect calls, callbacks with context, dispatch tables, state machines, the Cortex-M4 vector table and the Thumb bit. Keep this in mind whenever you use function pointers in embedded C.
What You Will Learn
✓ That a function name is an address and a function pointer stores it
✓ How to read and write function pointer declarations, and simplify them with typedef
✓ Calling through a pointer: fp(x) and (*fp)(x)
✓ Callbacks: passing behaviour to another function
✓ Dispatch tables: an array of function pointers instead of a long switch
✓ The Cortex-M4 vector table: interrupt handlers are function pointers
✓ The Thumb bit, NULL pointers and signature mismatches
✓ Registering callbacks with a void * context
Key Terms
Function pointerCallbackDispatch tabletypedefSignatureVector tableHandlerThumb bitContext pointerState machineqsortNULL checkIndirect callReset_Handlerconst table
1. A Function Name Is an Address
Machine code lives in memory (Flash on the STM32F407, starting at 0x08000000), and each function begins at an address. In C the name of a function, used without parentheses, evaluates to that address. A function pointer is a variable that stores such an address, so you can decide at run time which function to call. Function pointers in embedded C are the mechanism behind callbacks, command tables, state machines and every interrupt vector. Many bugs with function pointers in embedded C start here.
fp_basic.c
#include <stdio.h>
static int twice(int x) { return 2 * x; }
static int square(int x) { return x * x; }
int main(void)
{
int (*op)(int); /* op: pointer to a function taking int, returning int */
op = twice; /* the name twice is its address (&twice is the same) */
printf(“op = twice : op(7) = %d\n”, op(7));
op = square; /* same variable, different function */
printf(“op = square : op(7) = %d\n”, op(7));
printf(“twice and &twice are the same address: %s\n”, ((void *)twice == (void *)&twice) ? “yes” : “no”);
return 0;
}
Output (verified by compiling and running)
op = twice : op(7) = 14
op = square : op(7) = 49
twice and &twice are the same address: yes
The variable op holds either address. The call op(7) jumps to whichever function it currently points to; at the machine level the compiler emits an indirect branch, blx r3 on the Cortex-M4, instead of the direct bl twice. This is the core rule for function pointers in embedded C.
One pointer, two possible targets
Not a data pointer: A function pointer cannot be dereferenced to read the code as data, and converting it to void * is outside ISO C (GCC allows it as an extension, which the demonstration above uses). Pointer arithmetic on function pointers is not allowed.
2. Declaring Function Pointers
The declaration mirrors the function’s own prototype with (*name) in place of the function name. The parentheses are essential. Reviewers check function pointers in embedded C closely.
| Declaration | Meaning |
|---|
int (*fp)(int); | fp is a pointer to a function (int) returning int |
int *fp(int); | NOT a pointer: a function returning int * (the * binds to the return type) |
void (*cb)(void); | pointer to a function with no parameters and no result |
int (*tbl[4])(int); | array of 4 pointers to functions (int) returning int |
void (*(*f)(int))(void); | pointer to a function (int) returning a pointer to a function (void) returning void |
The last line shows why you should use typedef: it gives the function type a name and makes everything else readable. Interviewers test function pointers in embedded C on exactly this point.
fp_typedef.c
#include <stdio.h>
typedef int (*binop_t)(int, int); /* binop_t: pointer to int f(int, int) */
static int add(int a, int b) { return a + b; }
static int sub(int a, int b) { return a – b; }
static int mul(int a, int b) { return a * b; }
static binop_t choose(char sign) /* returns a function pointer */
{
switch (sign)
{
case ‘+’: return add;
case ‘-‘: return sub;
case ‘*’: return mul;
default : return NULL;
}
}
int main(void)
{
const char signs[] = { ‘+’, ‘-‘, ‘*’, ‘/’ };
for (int i = 0; i < 4; i++)
{
binop_t f = choose(signs[i]);
if (f) printf(“12 %c 4 = %d\n”, signs[i], f(12, 4));
else printf(“12 %c 4 : unsupported operator\n”, signs[i]);
}
return 0;
}
Output (verified by compiling and running)
12 + 4 = 16
12 – 4 = 8
12 * 4 = 48
12 / 4 : unsupported operator
Reading rule: Start at the name and work outward: fp is (*fp) a pointer, to a function (…), returning int. With a typedef you read binop_t f: f is a binop_t.
3. Calling Through a Pointer
Both fp(args) and (*fp)(args) are valid and identical: the function-to-pointer conversion makes the extra * harmless. Most embedded style guides prefer the shorter form, which reads like an ordinary call. The call passes arguments by value and returns the result just as in Lesson 8.1; the pointer is only a different way to reach the callee. Master function pointers in embedded C by practising this case.
fp_call.c
#include <stdio.h>
static void hello(const char *who) { printf(“hello, %s\n”, who); }
int main(void)
{
void (*say)(const char *) = hello;
say(“world”); /* usual style */
(*say)(“again”); /* equivalent, explicit dereference */
(**say)(“still the same”); /* legal too: *say is again a function designator */
return 0;
}
Output (verified by compiling and running)
hello, world
hello, again
hello, still the same
Check before calling: Calling a NULL function pointer branches to address 0. The address is even, so bit 0 (the Thumb bit) is clear and the Cortex-M4 raises a UsageFault for an invalid state, which normally escalates to a HardFault. Always test optional callbacks with if (cb != NULL).
4. Callbacks: Passing Behaviour to a Function
A callback is a function pointer given to another function that will call it later. The library does not know what the callback does; it only knows its signature. The C library’s qsort is the classic example: it sorts any array, and you supply the comparison.
callback_qsort.c
#include <stdio.h>
#include <stdlib.h>
static int by_value_asc(const void *a, const void *b)
{
int x = *(const int *)a, y = *(const int *)b;
return (x > y) – (x < y);
}
static int by_value_desc(const void *a, const void *b)
{
return by_value_asc(b, a);
}
static void show(const char *label, const int *v, int n)
{
printf(“%-11s”, label);
for (int i = 0; i < n; i++) printf(” %d”, v[i]);
printf(“\n”);
}
int main(void)
{
int data[] = { 42, 7, 19, 3, 88, 25 };
int n = sizeof data / sizeof data[0];
qsort(data, n, sizeof data[0], by_value_asc);
show(“ascending:”, data, n);
qsort(data, n, sizeof data[0], by_value_desc);
show(“descending:”, data, n);
return 0;
}
Output (verified by compiling and running)
ascending: 3 7 19 25 42 88
descending: 88 42 25 19 7 3
Embedded libraries use the same idea for events: you register a function with a driver, and the driver calls it when the transfer is complete or the button is pressed.
Registering and invoking a callback
1. Application
btn_set_handler(on_press)
stores pointer
↓
2. Driver
keeps handler in a variable
event happens
↓
3. Driver calls
handler() runs the application code
callback_register.c
#include <stdio.h>
typedef void (*event_cb_t)(int pin);
static event_cb_t s_handler; /* driver-private storage, NULL at start */
static void btn_set_handler(event_cb_t cb) { s_handler = cb; }
static void btn_irq_simulated(int pin) /* what the ISR would do */
{
if (s_handler != NULL) /* an unset callback is not an error */
s_handler(pin);
else
printf(” pin %d event ignored (no handler)\n”, pin);
}
static void on_press(int pin) { printf(” application: pin %d pressed\n”, pin); }
int main(void)
{
btn_irq_simulated(0); /* before registration */
btn_set_handler(on_press);
btn_irq_simulated(0); /* after registration */
return 0;
}
Output (verified by compiling and running)
pin 0 event ignored (no handler)
application: pin 0 pressed
5. Dispatch Tables Instead of switch
When a program selects among many functions by a number or a command code, an array of function pointers (a dispatch table) replaces a long switch. Adding a command means adding one table row. If the table is static const, it lives in Flash (.rodata), uses no RAM and cannot be corrupted at run time.
dispatch.c
#include <stdio.h>
#include <string.h>
typedef void (*cmd_fn)(int arg);
static void cmd_led(int arg) { printf(” LED mask set to 0x%X\n”, arg); }
static void cmd_delay(int arg) { printf(” delay %d ms\n”, arg); }
static void cmd_reset(int arg) { (void)arg; printf(” system reset requested\n”); }
typedef struct { const char *name; cmd_fn run; } command_t;
static const command_t commands[] = {
{ “led”, cmd_led },
{ “delay”, cmd_delay },
{ “reset”, cmd_reset },
};
#define N_COMMANDS (sizeof commands / sizeof commands[0])
static int dispatch(const char *name, int arg)
{
for (size_t i = 0; i < N_COMMANDS; i++)
if (strcmp(name, commands[i].name) == 0)
{
commands[i].run(arg);
return 0;
}
return –1; /* unknown command */
}
int main(void)
{
const char *input[] = { “led”, “delay”, “bogus”, “reset” };
int args[] = { 5, 250, 0, 0 };
for (int i = 0; i < 4; i++)
{
printf(“> %s %d\n”, input[i], args[i]);
if (dispatch(input[i], args[i]) != 0) printf(” error: unknown command\n”);
}
return 0;
}
Output (verified by compiling and running)
> led 5
LED mask set to 0x5
> delay 250
delay 250 ms
> bogus 0
error: unknown command
> reset 0
system reset requested
| switch | Dispatch table |
|---|
| Adding an entry | Edit code in a function | Add one table row |
| Lookup cost | Compiler picks (compare chain or jump table) | Index or search, then an indirect call |
| Data-driven (names, flags) | Awkward | Natural |
| Placed in Flash | As code | As const data |
| Type safety | Each case can differ | All functions share one signature |
6. State Machines with Function Pointers
A state machine can store the current state as a function pointer: each state function does its work and returns the next state. The main loop is then a single line, state = state();.
state_fp.c
#include <stdio.h>
typedef struct state { const struct state *(*run)(int input); const char *name; } state_t;
static const state_t S_IDLE, S_RUN, S_FAULT;
static const state_t *idle(int in) { return in == 1 ? &S_RUN : &S_IDLE; }
static const state_t *run(int in) { return in == 0 ? &S_IDLE : in == 9 ? &S_FAULT : &S_RUN; }
static const state_t *fault(int in) { (void)in; return &S_FAULT; } /* latched */
static const state_t S_IDLE = { idle, “IDLE” };
static const state_t S_RUN = { run, “RUN” };
static const state_t S_FAULT = { fault, “FAULT” };
int main(void)
{
const int inputs[] = { 0, 1, 1, 0, 1, 9, 1, 0 };
const state_t *s = &S_IDLE;
for (int i = 0; i < 8; i++)
{
s = s->run(inputs[i]);
printf(“input %d -> %s\n”, inputs[i], s->name);
}
return 0;
}
Output (verified by compiling and running)
input 0 -> IDLE
input 1 -> RUN
input 1 -> RUN
input 0 -> IDLE
input 1 -> RUN
input 9 -> FAULT
input 1 -> FAULT
input 0 -> FAULT
Each state is a constant record containing its handler. The structure of the machine is visible as data, and the handler for any state is one indirect call away.
7. Interrupt Handlers Are Function Pointers: The Vector Table
On the Cortex-M4 the first words of Flash form the vector table: an array of 32-bit values. Word 0 is the initial stack pointer; word 1 is the address of Reset_Handler; the next words are the exception handlers and then the device interrupts, in order. When an event occurs, the core reads the entry at 4 × exception number and branches to it. In C the table is just an array of function pointers placed at 0x08000000 by the linker script.
| Offset | Exception | Number | Entry |
|---|
| 0x00 | Initial stack pointer | – | top of SRAM, 0x20020000 for the 128 KB |
| 0x04 | Reset | 1 | Reset_Handler |
| 0x3C | SysTick | 15 | SysTick_Handler |
| 0x58 | EXTI line 0 (IRQ 6) | 22 | EXTI0_IRQHandler |
| 0xD8 | USART2 (IRQ 38) | 54 | USART2_IRQHandler |
The offset follows from the exception number: for interrupt n (IRQ number) the number is 16 + n and the offset 4 × (16 + n). IRQ 6 gives 4 × 22 = 0x58; IRQ 38 gives 4 × 54 = 0xD8.
vector_sim.c
#include <stdio.h>
#include <stdint.h>
typedef void (*handler_t)(void);
static void Reset_Handler(void) { printf(” [Reset_Handler] start-up code runs\n”); }
static void SysTick_Handler(void) { printf(” [SysTick_Handler] 1 ms tick\n”); }
static void EXTI0_IRQHandler(void) { printf(” [EXTI0_IRQHandler] PA0 button\n”); }
static void USART2_IRQHandler(void) { printf(” [USART2_IRQHandler] byte received\n”); }
static void Default_Handler(void) { printf(” [Default_Handler] unexpected interrupt\n”); }
/* simulated table, indexed by exception number (word index = offset / 4) */
static const handler_t vectors[60] = {
[1] = Reset_Handler,
[15] = SysTick_Handler,
[16 + 6] = EXTI0_IRQHandler,
[16 + 38] = USART2_IRQHandler,
};
static void take_exception(unsigned n)
{
handler_t h = vectors[n] ? vectors[n] : Default_Handler;
printf(“exception %2u -> table offset 0x%02X\n”, n, n * 4u);
h();
}
int main(void)
{
take_exception(1);
take_exception(15);
take_exception(16 + 6);
take_exception(16 + 38);
take_exception(16 + 10); /* no handler installed for this IRQ */
return 0;
}
Output (verified by compiling and running)
exception 1 -> table offset 0x04
[Reset_Handler] start-up code runs
exception 15 -> table offset 0x3C
[SysTick_Handler] 1 ms tick
exception 22 -> table offset 0x58
[EXTI0_IRQHandler] PA0 button
exception 54 -> table offset 0xD8
[USART2_IRQHandler] byte received
exception 26 -> table offset 0x68
[Default_Handler] unexpected interrupt
Real start-up files (for example startup_stm32f407xx.s) fill unused entries with weak aliases of Default_Handler, an endless loop, so that an unexpected interrupt is easy to spot in a debugger. You override a handler simply by defining a function with the same name.
The Thumb bit: The Cortex-M4 executes only Thumb code. Every address stored in the vector table (and every function pointer that the compiler creates on this core) has bit 0 set to 1 to signal Thumb state. A function at 0x08000190 appears as 0x08000191. If you ever build a pointer by hand from an integer, set bit 0, or the branch raises a UsageFault.
8. Callbacks with a Context Pointer
A plain callback has no way to know which object triggered it. The remedy is a second parameter, a void * context, chosen by the registrant and passed back unchanged. The callback casts it to the real type.
callback_ctx.c
#include <stdio.h>
typedef void (*timer_cb_t)(void *ctx);
typedef struct { timer_cb_t cb; void *ctx; unsigned period; unsigned left; } soft_timer_t;
static void timer_start(soft_timer_t *t, unsigned period, timer_cb_t cb, void *ctx)
{
t->period = t->left = period;
t->cb = cb;
t->ctx = ctx;
}
static void timer_tick(soft_timer_t *t) /* call every 1 ms */
{
if (t->left && –t->left == 0)
{
t->left = t->period; /* periodic */
if (t->cb) t->cb(t->ctx);
}
}
typedef struct { const char *name; unsigned pin; unsigned toggles; } led_t;
static void led_toggle_cb(void *ctx)
{
led_t *led = (led_t *)ctx; /* recover the object */
led->toggles++;
printf(” %s (PD%u) toggled, count=%u\n”, led->name, led->pin, led->toggles);
}
int main(void)
{
led_t green = { “green”, 12, 0 }, red = { “red”, 14, 0 };
soft_timer_t t1, t2;
timer_start(&t1, 3, led_toggle_cb, &green);
timer_start(&t2, 4, led_toggle_cb, &red);
for (int ms = 1; ms <= 12; ms++)
{
printf(“ms %2d\n”, ms);
timer_tick(&t1);
timer_tick(&t2);
}
return 0;
}
Output (verified by compiling and running)
ms 1
ms 2
ms 3
green (PD12) toggled, count=1
ms 4
red (PD14) toggled, count=1
ms 5
ms 6
green (PD12) toggled, count=2
ms 7
ms 8
red (PD14) toggled, count=2
ms 9
green (PD12) toggled, count=3
ms 10
ms 11
ms 12
green (PD12) toggled, count=4
red (PD14) toggled, count=3
One callback function serves both LEDs; the context tells it which LED to act on. The pattern removes the need for global variables and makes the code reusable, the same trick that HAL libraries use with their void * user-data parameters.
9. Pitfalls
| Pitfall | What happens | Prevention |
|---|
| Calling NULL | Branch to address 0: UsageFault, usually HardFault | Check optional callbacks, initialise tables |
| Wrong signature through a cast | Undefined behavior; wrong registers read | Never cast between function pointer types; use one typedef |
| Function pointer stored as integer | Thumb bit lost (UsageFault) | Keep the pointer type; set bit 0 if you must convert |
| Callback changes state used by the caller | Re-entrancy surprises | Document what the callback may call |
| Callback called from an ISR does slow work | Missed interrupts | Set a flag in the callback; do the work in main |
Table not const | Table in RAM, can be overwritten | static const places it in Flash |
fpcast.c
static int add(int a, int b) { return a + b; }
int main(void)
{
void (*fp)(void) = add; /* the signatures differ */
fp();
return 0;
}
GCC warns about an incompatible function pointer
fpcast.c: In function ‘main’:
fpcast.c:5:24: warning: initialization of ‘void (*)(void)’ from incompatible pointer type ‘int (*)(int, int)’ [-Wincompatible-pointer-types]
5 | void (*fp)(void) = add; /* the signatures differ */
| ^~~
The warning is real: calling add through a void (*)(void) pointer would leave its arguments undefined. Keep the warnings on and treat this one as an error (-Werror=incompatible-pointer-types).
10. Best Practices for Function Pointers in Embedded C
- Give every function pointer type a
typedef ending in _t or _fn. - Initialise pointers; check optional ones for NULL before the call.
- Make dispatch tables
static const so they sit in Flash. - Pass a
void * context with every callback registration. - Keep callbacks short; if called from an ISR, only set flags or copy data.
- Do not cast between function pointer types; write a wrapper with the correct signature instead.
- Document the context in which a callback runs (interrupt or main) and what it may call.
- Limit the depth of callback chains; they hide the control flow from reviewers and from stack analysis.
Key Takeaways — Lesson 8.4
- A function name is its address; a function pointer stores it and allows an indirect call.
- Use
typedef to make declarations such as int (*fp)(int) readable. - Callbacks pass behaviour to a library; add a
void * context to identify the caller’s object. - A
static const dispatch table in Flash replaces a long switch. - The vector table is an array of handler addresses with the Thumb bit set; interrupt entry is an indirect call by the hardware.
- Check for NULL, match signatures exactly and keep ISR callbacks short.
Frequently Asked Questions
What is a function pointer in C?
A variable that holds the address of a function. You can assign different functions with the same signature to it and call whichever it points to through the pointer.
How do you declare a function pointer?
Write the function prototype and replace the name by (*name), for example int (*fp)(int, int). Use a typedef such as typedef int (*binop_t)(int, int); to keep the code readable.
What is a callback function?
A function that you pass (by pointer) to another function or driver so that it is called later, for example when a timer expires or a byte arrives.
How are interrupts related to function pointers?
The vector table at the start of Flash is an array of addresses of the exception and interrupt handlers. When an interrupt occurs the core loads the handler address from the table and branches to it, which is an indirect call.
What happens if I call a NULL function pointer?
The program branches to address 0. Because the Thumb bit of that address is clear, the Cortex-M4 raises a UsageFault, which normally escalates to a HardFault. Always check optional callbacks for NULL.
Ready to practise?
Next: worked examples with command parsers, timers, event handlers, sort callbacks and a simulated vector table.
2 Comments