Part 1 · The C Language37 min read
Pointers II — Function Pointers, Declarations, and Bugs
In Pointers I you learned what a pointer is and how to move it around. This lesson covers the pointers that make firmware architecture possible and the bugs that make firmware debugging hard. Function pointers are how drivers call back into your application, how a command shell dispatches led on, and how the Cortex-M vector table finds your interrupt handlers. Complex declarations are what you meet in vendor headers and need to read without guessing. And pointer bugs on a microcontroller are uniquely nasty: there is no MMU and no segfault, so a bad write just changes some other byte and the program keeps running. By the end you will be able to build and use function pointer tables, read any declaration with the right-left rule, explain what restrict promises, and track down a memory corruption with a watchpoint and the fault registers.
Functions have addresses
Section titled “Functions have addresses”Code lives in memory just like data. On the STM32F407 your functions are in flash, starting near 0x08000000, and each one begins at some address. A function pointer is a variable that holds such an address, and calling through it jumps there.
The syntax mirrors a function declaration with (*name) in place of the name:
#include <inttypes.h>#include <stdio.h>
static uint32_t add(uint32_t a, uint32_t b) { return a + b; }static uint32_t sub(uint32_t a, uint32_t b) { return a - b; }
int main(void){ uint32_t (*op)(uint32_t, uint32_t) = add; /* op points to add */
printf("%" PRIu32 "\n", op(7u, 5u)); op = sub; /* now it points to sub */ printf("%" PRIu32 "\n", op(7u, 5u)); printf("%" PRIu32 "\n", (*op)(7u, 5u)); /* same call, older style */ return 0;}1222uint32_t (*op)(uint32_t, uint32_t) reads as “op is a pointer to a function taking two uint32_t and returning uint32_t”. The parentheses around *op are essential: without them, uint32_t *op(uint32_t, uint32_t) declares a function that returns a uint32_t *.
A function name decays to a pointer to the function, just as an array name decays to a pointer to its first element. So op = add and op = &add are identical, and so are op(7u, 5u) and (*op)(7u, 5u). Modern code uses the shorter forms.
In memory, the pointer is an ordinary 4-byte variable in RAM whose value is a flash address:
SRAM Flash +-------------+ +----------------------------+ | op | | 0x08000134 add: | | 0x08000135 |------------------------->| adds r0, r0, r1 | +-------------+ | bx lr | bit 0 set: | 0x08000138 sub: | "Thumb code" | subs r0, r0, r1 | | bx lr | +----------------------------+Notice the stored value is 0x08000135, one more than where add actually starts. Cortex-M only executes Thumb instructions, and the Arm architecture uses bit 0 of a branch target to mean “Thumb state”. The linker sets that bit in every function address it hands you. The instruction that calls through a pointer, blx (or bx for a tail call), strips bit 0 to get the real address and checks it to choose the instruction set:
#include <stdint.h>
uint32_t apply(uint32_t (*op)(uint32_t, uint32_t), uint32_t a, uint32_t b){ return op(a, b);}apply: mov r3, r0 @ r3 = op mov r0, r1 @ a becomes the first argument mov r1, r2 @ b becomes the second argument bx r3 @ tail call through the pointerThis has a practical consequence. If you call a null function pointer, bx or blx jumps to address 0 with bit 0 clear, which asks the core to switch to the Arm instruction set that Cortex-M does not have. The result is a UsageFault with the INVSTATE bit set in CFSR, escalated to HardFault if the UsageFault handler is not enabled. When you see INVSTATE in a fault dump, suspect a null or corrupted function pointer first.
typedefs for function pointers
Section titled “typedefs for function pointers”Function pointer syntax gets unreadable fast, especially as parameters or return types. A typedef gives the type a name:
#include <stdint.h>
typedef uint32_t (*binop_fn)(uint32_t a, uint32_t b); /* pointer type */
uint32_t apply_twice(binop_fn op, uint32_t x){ return op(op(x, x), x);}There is a second style worth knowing: typedef the function type and write the * at each use.
#include <stdint.h>
typedef void irq_handler(void); /* a function type, not a pointer */
irq_handler *current_handler; /* pointer to such a function */The function-type style makes the pointer visible at the point of use, which some teams prefer because hiding a * inside a typedef can mislead readers. Both styles compile to the same thing. Pick one per codebase. Parameter names inside the typedef are optional but document intent.
Tables of function pointers
Section titled “Tables of function pointers”The most common use of function pointers in firmware is a table: an array or struct array where data selects behaviour. You have already met one without knowing it. The Cortex-M vector table is an array of addresses in flash, and on reset and on every interrupt the hardware reads an entry and jumps to it (Startup code and the vector table builds one).
A command table
Section titled “A command table”Here is the core of a command shell. Each entry pairs a name with a handler:
#include <stddef.h>#include <stdio.h>#include <string.h>
typedef void (*cmd_handler_fn)(const char *args);
struct command { const char *name; cmd_handler_fn handler;};
static void cmd_led(const char *args) { printf("led -> %s\n", args); }static void cmd_status(const char *args) { (void)args; printf("status -> ok\n"); }
static const struct command commands[] = { { "led", cmd_led }, { "status", cmd_status },};
static int dispatch(const char *name, const char *args){ for (size_t i = 0; i < sizeof commands / sizeof commands[0]; i++) { if (strcmp(commands[i].name, name) == 0) { commands[i].handler(args); return 0; } } return -1; /* unknown command */}
int main(void){ dispatch("led", "on"); dispatch("status", ""); if (dispatch("reboot", "") != 0) { printf("unknown command\n"); } return 0;}led -> onstatus -> okunknown commandAdding a command means writing a function and adding one line to the table; the dispatch logic never changes. And because the table is static const, the linker places it in flash (.rodata), costing no RAM:
Flash (.rodata) Flash (.rodata / .text) commands[0] +------------+------------+ | name | handler | | 0x08001200 | 0x08000201 |---------> cmd_led (code at 0x08000200) +-----|------+------------+ commands[1] | 0x08001204 | 0x08000221 |---------> cmd_status (code at 0x08000220) +-----|------+------------+ | +----> "led\0" at 0x08001200, "status\0" at 0x08001204Each entry is 8 bytes on the target: a 4-byte pointer to the name string and a 4-byte function pointer.
A table indexed by an enum
Section titled “A table indexed by an enum”When the selector is already a small integer, index the table directly instead of searching:
#include <stdint.h>
typedef enum { MODE_IDLE, MODE_SAMPLE, MODE_REPORT, MODE_COUNT } app_mode_t;
static void do_idle(void) { /* ... */ }static void do_sample(void) { /* ... */ }static void do_report(void) { /* ... */ }
static void (*const mode_handlers[MODE_COUNT])(void) = { [MODE_IDLE] = do_idle, [MODE_SAMPLE] = do_sample, [MODE_REPORT] = do_report,};
void run_mode(app_mode_t mode){ if ((uint32_t)mode < (uint32_t)MODE_COUNT) { /* never index unchecked */ mode_handlers[mode](); }}The designated initializers ([MODE_IDLE] = ...) keep each handler next to its enum name, so reordering the enum cannot silently misalign the table. The bounds check matters: mode could arrive corrupted, from a message, or from a cast, and an out-of-range index reads a “function pointer” from whatever follows the table and jumps there. The cast to uint32_t makes one comparison reject negative values too, since an enum’s underlying type may be signed. The full state machine designs built on this idea are in State machines and event-driven firmware.
Callbacks with a context pointer
Section titled “Callbacks with a context pointer”A callback is a function you hand to some other code so it can call you back later: a driver calls you when a transfer finishes, a timer service calls you when a period elapses. The driver cannot know about your types, so the callback signature uses void *, and you register a context pointer alongside the function. The driver stores both and passes the context back on every call.
#include <stdint.h>#include <stdio.h>
typedef void (*button_cb_fn)(void *ctx, uint32_t press_ms);
struct button { uint32_t pin; button_cb_fn cb; void *ctx;};
static void button_init(struct button *b, uint32_t pin, button_cb_fn cb, void *ctx){ b->pin = pin; b->cb = cb; b->ctx = ctx;}
/* The driver calls this when it detects a release; simulated here. */static void button_report(const struct button *b, uint32_t press_ms){ if (b->cb != NULL) { b->cb(b->ctx, press_ms); }}
struct led { const char *name; uint32_t toggles;};
static void toggle_led(void *ctx, uint32_t press_ms){ struct led *led = ctx; /* recover the real type */
led->toggles++; printf("%s toggled after %u ms (count %u)\n", led->name, (unsigned)press_ms, (unsigned)led->toggles);}
int main(void){ struct led green = { "green", 0u }; struct led red = { "red", 0u }; struct button user; struct button aux;
button_init(&user, 0u, toggle_led, &green); button_init(&aux, 1u, toggle_led, &red);
button_report(&user, 120u); button_report(&aux, 80u); button_report(&user, 300u); return 0;}green toggled after 120 ms (count 1)red toggled after 80 ms (count 1)green toggled after 300 ms (count 2)One function, toggle_led, serves two buttons and two LEDs, because each registration carries its own context:
user (struct button) green (struct led) +-------------------+ +------------------+ | pin = 0 | +----->| name = "green" | | cb = toggle_led -|--+ | | toggles = 2 | | ctx = &green -----|--|---+ +------------------+ +-------------------+ | +-------> toggle_led (flash) aux (struct button) | +-------------------+ | red (struct led) | pin = 1 | | +------------------+ | cb = toggle_led -|--+ +----->| name = "red" | | ctx = &red -------|------+ | toggles = 1 | +-------------------+ +------------------+Without the context pointer, the callback would have to find its data through a global, and you could not have two instances. The standard library’s qsort is the textbook example of an API that forgot the context parameter: its comparison function cannot receive any extra state.
Two rules keep callbacks safe:
- Write the callback with the exact registered signature and convert
void *ctxinside it, astoggle_leddoes. Do not writevoid toggle_led(struct led *led, uint32_t ms)and cast it tobutton_cb_fn. Calling a function through a pointer of a different type is undefined behavior; it happens to work on Arm today, and the compiler is still allowed to break it. - The context must outlive the registration. If you pass the address of a local variable and the function returns, the driver holds a dangling pointer. More on that below.
Vtables: a struct of function pointers
Section titled “Vtables: a struct of function pointers”Group several related function pointers in a struct and you have an interface. Each implementation provides a const instance of that struct, and code that uses the interface calls through it without knowing which implementation it has. C++ calls this table a vtable; in C you build it by hand.
#include <stddef.h>#include <stdint.h>#include <stdio.h>
struct serial_ops { int (*write)(void *self, const uint8_t *data, size_t len);};
struct serial { const struct serial_ops *ops; /* which implementation */ void *self; /* that implementation's state */};
static int serial_write(const struct serial *s, const uint8_t *data, size_t len){ return s->ops->write(s->self, data, len);}
/* Implementation 1: capture bytes in memory (useful for host tests) */struct capture { uint8_t buf[16]; size_t used;};
static int capture_write(void *self, const uint8_t *data, size_t len){ struct capture *c = self;
if (len > sizeof c->buf - c->used) { return -1; } for (size_t i = 0; i < len; i++) { c->buf[c->used++] = data[i]; } return (int)len;}
static const struct serial_ops capture_ops = { .write = capture_write };
/* Implementation 2: print to the console */static int console_write(void *self, const uint8_t *data, size_t len){ (void)self; return (int)fwrite(data, 1, len, stdout);}
static const struct serial_ops console_ops = { .write = console_write };
int main(void){ struct capture cap = { .used = 0 }; struct serial ports[2] = { { &capture_ops, &cap }, { &console_ops, NULL }, }; const uint8_t msg[] = "hi\n";
for (size_t i = 0; i < 2; i++) { serial_write(&ports[i], msg, sizeof msg - 1); } printf("captured %zu bytes\n", cap.used); return 0;}hicaptured 3 bytess->ops->write(s->self, data, len) is two loads and an indirect call: load ops from the serial object, load write from the ops table, call it. The ops tables are const and live in flash; only the small serial objects and the implementation state live in RAM.
RAM Flash Flash ports[0] +------------+ capture_ops +----------+ | ops ------|-----> | write ---|---> capture_write | self ---+ | +----------+ +---------|--+ +--> cap (struct capture, RAM) ports[1] +------------+ console_ops +----------+ | ops ------|-----> | write ---|---> console_write | self = NULL| +----------+ +------------+This is the foundation of driver design: a sensor driver written against a “bus” interface works over SPI on the board and over a fake bus in a host test. Driver design and hardware abstraction and Design patterns for embedded C develop the full pattern.
Pointers to structs and the arrow operator
Section titled “Pointers to structs and the arrow operator”You have been using -> throughout this lesson. Here is precisely what it means. Given a pointer p to a struct, p->member is defined as (*p).member. The parentheses are needed because . binds tighter than unary *: *p.member means *(p.member), which only makes sense if member is itself a pointer.
#include <stdint.h>
struct sensor { uint8_t addr; /* offset 0 */ uint8_t reg; /* offset 1 */ uint16_t last; /* offset 2 */ uint32_t reads; /* offset 4 */};
void sensor_count_read(struct sensor *s, uint16_t value){ s->last = value; s->reads++;}sensor_count_read: ldr r3, [r0, #4] @ r3 = s->reads (base + offset 4) strh r1, [r0, #2] @ s->last = value (halfword store at offset 2) adds r3, r3, #1 str r3, [r0, #4] @ write back s->reads bx lr s = 0x20000080 +------+------+-------------+0x20000080 | addr | reg | last | offset 0, 1, 2..3 +------+------+-------------+0x20000084 | reads | offset 4..7 +---------------------------+At the machine level, -> is just “base address plus a constant offset”. The compiler knows every member’s offset at compile time, so a member access through a pointer costs nothing extra. Offsets, padding and layout are the subject of Structs, unions, enums, and memory layout.
You can also point directly at a member. &s->reads has type uint32_t * and value 0x20000084. That pointer can be handed to any function that takes a uint32_t *, such as an atomic increment helper. Chains like dev->bus->ops->write are common in layered drivers; each -> is one more load.
Array of pointers versus pointer to array
Section titled “Array of pointers versus pointer to array”These two declarations differ only by parentheses and mean completely different things:
#include <stdint.h>
uint8_t row0[4], row1[4], row2[4];
uint8_t *rows[3] = { row0, row1, row2 }; /* array of 3 pointers to uint8_t */
uint8_t grid[3][4];uint8_t (*row)[4] = grid; /* pointer to an array of 4 uint8_t */ rows: array of 3 pointers (12 bytes on target) +------------+------------+------------+ | 0x20000010 | 0x20000020 | 0x20000030 | +-----|------+-----|------+-----|------+ v v v row0[4] row1[4] row2[4] (separate arrays, anywhere in memory)
row: one pointer (4 bytes), to a whole 4-byte row +------------+ | 0x20000040 |--+ +------------+ | v grid +----+----+----+----+----+----+----+----+----+----+----+----+ | grid[0] | grid[1] | grid[2] | +----+----+----+----+----+----+----+----+----+----+----+----+ ^ row ^ row + 1 ^ row + 2 0x20000040 0x20000044 0x20000048rows[1] is a pointer you must follow to reach the data; the rows can be different lengths and live anywhere. row points at a complete array of 4 bytes, so row + 1 advances 4 bytes to the next row, and row[1][2] is the byte at 0x20000046. A pointer to an array is what a 2D array decays to, which is why a function that takes a 2D array is declared void draw(uint8_t (*grid)[4], size_t rows) (or equivalently uint8_t grid[][4]). The full story of multidimensional arrays is in Arrays, strings, and tables.
The array-of-pointers form is common for string tables: static const char *const names[] = { "idle", "sample", "report" }; is an array of const pointers to const characters, all of it in flash.
Reading complex declarations: the right-left rule
Section titled “Reading complex declarations: the right-left rule”C declarations follow a small grammar, and every declaration can be read mechanically with the right-left rule:
- Start at the identifier being declared. Say “name is”.
- Look right. While you see
[N]say “array of N”, while you see(params)say “function taking params returning”. Stop at a)or the end. - Look left. While you see
*say “pointer to”; aconstorvolatileimmediately left of a*applies to that pointer. Stop at a(. - If you stopped at parentheses, step outside them and go back to step 2.
- Finally read the base type on the far left, including its qualifiers.
Eight worked examples, each from code you will actually meet:
1. const uint8_t *p[4];
Start at p. Right: [4] “array of 4”. Right: ; end. Left: * “pointer to”. Left: const uint8_t. p is an array of 4 pointers to const uint8_t.
2. uint8_t (*p)[4];
Start at p. Right: ), stop. Left: * “pointer to”. Left: (, step out. Right: [4] “array of 4”. Left: uint8_t. p is a pointer to an array of 4 uint8_t.
3. volatile uint32_t *const reg;
Start at reg. Right: end. Left: const then *: “const pointer to”. Left: volatile uint32_t. reg is a const pointer to a volatile uint32_t: the classic hardware register pointer. The address never changes; every access to the register is performed.
4. int32_t *parse(const char *s);
Start at parse. Right: (const char *s) “function taking a pointer to const char, returning”. Left: * “pointer to”. Left: int32_t. parse is a function returning a pointer to int32_t.
5. int32_t (*parse)(const char *s);
Start at parse. Right: ), stop. Left: * “pointer to”. Step out. Right: (const char *s) “function taking a pointer to const char, returning”. Left: int32_t. parse is a pointer to a function returning int32_t. Compare with example 4: the parentheses are the whole difference.
6. void (*const vectors[16])(void);
Start at vectors. Right: [16] “array of 16”. Right: ), stop. Left: const *, read as “const pointers to”. Step out. Right: (void) “functions taking nothing, returning”. Left: void. vectors is an array of 16 const pointers to functions taking no arguments and returning nothing. That is the shape of a vector table, and the const puts it in flash.
7. void (*signal(int sig, void (*func)(int)))(int);
The famous one from the C standard library. Start at signal. Right: (int sig, void (*func)(int)) “function taking an int and a pointer to a function taking int returning void, returning”. Right: ), stop. Left: * “pointer to”. Step out. Right: (int) “function taking int, returning”. Left: void. signal is a function that takes an int and a handler pointer, and returns a pointer to a function taking int and returning void (the previous handler).
8. uint32_t (*(*get_table)(void))[16];
Start at get_table. Right: ), stop. Left: * “pointer to”. Step out. Right: (void) “function taking nothing, returning”. Right: ), stop. Left: * “pointer to”. Step out. Right: [16] “array of 16”. Left: uint32_t. get_table is a pointer to a function taking nothing and returning a pointer to an array of 16 uint32_t.
You read declarations like 7 and 8 so you can understand them, not so you can write them. Rewrite them with typedefs:
#include <stdint.h>
typedef void sig_handler_fn(int);sig_handler_fn *set_handler(int sig, sig_handler_fn *func); /* same shape as example 7 */
typedef uint32_t table16_t[16];typedef table16_t *table_getter_fn(void);table_getter_fn *get_table; /* same as example 8 */Each line now says one thing. The command-line tool cdecl translates between C declarations and English if you want to check your reading.
restrict: a promise to the optimizer
Section titled “restrict: a promise to the optimizer”restrict (C99) qualifies a pointer and promises the compiler: for the lifetime of this pointer, the object it points to is accessed only through this pointer (or pointers derived from it). If you break the promise, the behavior is undefined. In return the compiler may assume stores through one pointer do not change what another pointer reads.
Why that matters:
#include <stddef.h>#include <stdint.h>
void scale(int32_t *dst, const int32_t *src, const int32_t *gain, size_t n){ for (size_t i = 0; i < n; i++) { dst[i] = src[i] * *gain; }}Without restrict, the compiler must assume gain might point into the dst array. If it did, the store to dst[i] could change *gain, so the compiler reloads *gain on every iteration. Its inner loop looks roughly like this (simplified; registers will differ):
.Lloop: ldr r4, [r2] @ reload *gain every iteration ldr r5, [r1], #4 @ src[i], then advance src mul r4, r5, r4 str r4, [r0], #4 @ dst[i], then advance dst cmp r0, r3 bne .LloopNow add the promise:
#include <stddef.h>#include <stdint.h>
void scale(int32_t *restrict dst, const int32_t *restrict src, const int32_t *restrict gain, size_t n){ for (size_t i = 0; i < n; i++) { dst[i] = src[i] * *gain; }} ldr r2, [r2] @ load *gain once, before the loop.Lloop: ldr r4, [r1], #4 mul r4, r2, r4 str r4, [r0], #4 cmp r0, r3 bne .LloopOne load fewer per iteration, and on bigger loops restrict also unlocks vectorisation and reordering. You have used restrict indirectly for years: the standard declares memcpy with restrict parameters, which is exactly why memcpy on overlapping buffers is undefined and memmove (no restrict) exists. The compiler does not check the promise. Use restrict on hot, simple functions where you control every caller, and see Performance and code size optimization for where it pays.
The pointer bugs
Section titled “The pointer bugs”Every pointer bug comes down to the same thing: using an address that does not refer to the object you think it does. On a PC the MMU often turns that into an immediate crash. On a Cortex-M4 without an MPU configured, every address from 0x00000000 to 0xFFFFFFFF is either real memory, a peripheral, or a bus error, and the first two do not complain. Here are the bugs, roughly in order of how often they appear in firmware.
Uninitialized pointer: a random write
Section titled “Uninitialized pointer: a random write”#include <stdint.h>
void log_sample(uint16_t value){ uint16_t *slot; /* never initialized */ *slot = value; /* stores to whatever address was in that register */}A local variable without an initializer holds whatever was left in its register or stack slot by previous code. GCC and Clang flag this simple case with -Wall (“slot is used uninitialized”). The version where the pointer is set on only some paths is caught less reliably: -Wmaybe-uninitialized depends on optimization being enabled, and it has both false positives and misses.
On the MCU, the store goes somewhere determined by stale data:
Possible destinations of *slot = value, depending on the leftover value:
0x0800xxxx flash -> write ignored or BusFault (flash is not written by stores) 0x2000xxxx SRAM -> silently overwrites another variable or a stack frame 0x4002xxxx peripheral -> silently writes a register: a GPIO mode, an RCC enable... 0x2003xxxx unmapped -> BusFault, often imprecise (reported after the fact)The SRAM case is the most common and the worst. If the write lands on a saved return address in a stack frame, the program crashes later, in a different function, when that function returns. The peripheral case can do physical damage: a write into a GPIO MODER can turn an input pin into an output that fights an external driver. Initialize every pointer at its declaration, to a valid object or to NULL.
Dangling pointer: returning the address of a local
Section titled “Dangling pointer: returning the address of a local”#include <stdint.h>
uint8_t *make_header(void){ uint8_t hdr[4] = {0xAAu, 0x55u, 0x01u, 0x00u}; return hdr; /* warning: function returns address of local variable */}hdr lives in make_header’s stack frame. When the function returns, that frame is released and the next function call reuses the same bytes. GCC warns about this direct case by default, and at -O2 some GCC versions go further and return a null pointer instead. The dangerous versions are the indirect ones the compiler cannot see:
#include <stddef.h>#include <stdint.h>
void uart_start_tx(const uint8_t *data, size_t len); /* stores pointer, returns at once */
void send_status(void){ uint8_t msg[4] = {0x02u, 0x10u, 0x00u, 0x03u}; uart_start_tx(msg, sizeof msg); /* driver keeps &msg[0] for its ISR */} /* msg's storage is released here */ During send_status After send_status returns, main calls other code +-------------------------+ +-------------------------+ | send_status frame | | other_func frame | | msg: 02 10 00 03 <---|--+ | locals: 7F 3A 9C 00 <-|--+ same addresses, +-------------------------+ | +-------------------------+ | new contents | main frame | | | main frame | | +-------------------------+ | +-------------------------+ | | | UART driver: tx_ptr ---------+ UART driver: tx_ptr ---------+ ISR transmits garbageNo warning, and it works in testing if nothing else runs before the transmission finishes. The same bug happens when you register a callback context that is a local variable, or start a DMA transfer into a stack buffer. The rule: any pointer that a driver or ISR keeps after the call returns must point to static storage (a global, a static local, or a pool block whose lifetime you manage explicitly).
Use-after-free and double free
Section titled “Use-after-free and double free”When memory comes from an allocator, whether malloc on a host or a fixed-block pool in firmware, returning it ends your right to use it, but every copy of the pointer still holds the old address:
#include <stdint.h>#include <stdlib.h>
void bad(void){ uint8_t *buf = malloc(16); if (buf == NULL) { return; } free(buf); buf[0] = 1u; /* use-after-free: the allocator may have reused this block */ free(buf); /* double free: corrupts the allocator's free list */}After free, the allocator may hand the block to someone else, or use its first bytes for its own bookkeeping (free lists usually store a “next” pointer inside free blocks). A use-after-free write corrupts another owner’s data or the allocator itself. A double free puts the block on the free list twice, so two later allocations return the same memory. The symptoms appear far from the cause.
Setting the pointer to NULL after freeing it helps only for that one variable; it does nothing for copies held elsewhere. The real fix is ownership: at every moment, exactly one module owns each block and is responsible for releasing it, and nobody keeps a pointer to a block they do not own. In firmware the classic version is a buffer released to a pool by the main loop while an ISR or DMA engine still holds its address. On the host, AddressSanitizer catches all of these immediately; Testing and debugging C on the host shows how.
Off-by-one
Section titled “Off-by-one”#include <stddef.h>#include <stdint.h>
struct rx_state { uint8_t buf[8]; uint32_t count;};
void rx_clear(struct rx_state *rx){ for (size_t i = 0; i <= sizeof rx->buf; i++) { /* BUG: <= should be < */ rx->buf[i] = 0u; }} +----+----+----+----+----+----+----+----+-------------------+0x20000040 | b0 | b1 | b2 | b3 | b4 | b5 | b6 | b7 | count (4 bytes) | +----+----+----+----+----+----+----+----+-------------------+ i=0 i=7 i=8 writes HERE: the low byte of countThe loop runs nine times and the ninth write lands on the first byte of count, which here is its least significant byte. GCC at -O2 may warn “iteration 8 invokes undefined behavior” and is then entitled to do anything with the loop. Off-by-one errors live in loop conditions (<= instead of <), in “make room for the terminator” arithmetic, and in ring buffer index wrap. Write loops as i < count over a half-open range, and use sizeof or an ARRAY_SIZE macro instead of typed numbers.
Wild pointers in general
Section titled “Wild pointers in general”A wild pointer is any pointer whose value is not the address of a live object: uninitialized, dangling, freed, computed out of bounds, or overwritten by one of the other bugs. They all produce the same symptom on an MCU: some unrelated variable changes value, or the system faults at a place that has nothing to do with the bug. That is why the best defence is structural: initialize everything, prefer static allocation, keep buffers and their lengths together, and check indices before use.
Provenance and aliasing
Section titled “Provenance and aliasing”So far a pointer has been “just an address”. The C standard, and the optimizer, see more: a pointer carries provenance, the identity of the object it was derived from. Arithmetic keeps you within that object. Two pointers can hold the same numeric address and still not be interchangeable.
#include <stdio.h>
int main(void){ int a[1] = {1}; int b[1] = {2}; int *p = a + 1; /* one past the end of a: legal to form */ int *q = b;
if (p == q) { /* may be true if b happens to follow a in memory */ *p = 10; /* UB: p is derived from a and may not access b */ printf("b[0] = %d\n", b[0]); } return 0;}If b happens to sit right after a, the comparison is true. But *p = 10 is undefined, because p came from a and one-past-the-end may not be dereferenced. Compilers have been observed to print b[0] = 2 here with optimization on: the optimizer reasons that a store through a pointer derived from a cannot change b, so it keeps using the value 2 it already knows. In real code this shows up when someone walks off the end of one buffer “into” the next on purpose.
Aliasing is the related question of which pointers may refer to the same memory. The compiler assumes that pointers to incompatible types (say uint32_t * and float *) never alias, except that character types may alias anything. That assumption is the strict aliasing rule. It lets the optimizer keep values in registers across stores, and it breaks code that reinterprets memory through casted pointers. Undefined behavior, volatile, and what the optimizer assumes covers the rule and its fixes. The practical point here: the compiler reasons about objects, not addresses, so code that only works “because of how memory is laid out” is code the optimizer may break.
Debugging a bad pointer
Section titled “Debugging a bad pointer”When a variable changes and you do not know why, or the system faults somewhere surprising, three tools find the culprit quickly. All three are covered in depth later (Watchdogs, faults, and recovering from them and Debugging and tracing on real hardware); here is how they apply to pointer bugs.
Data watchpoints. The Cortex-M4 has four hardware comparators in the DWT unit that can halt the core when a specific address is written. The debugger stops at the instruction that did the write, with the culprit’s call stack intact. This turns “something corrupts count” into a single line number:
(gdb) watch rx.countHardware watchpoint 2: rx.count(gdb) continueContinuing.
Hardware watchpoint 2: rx.count
Old value = 5New value = 0rx_clear (rx=0x20000040 <rx>) at rx.c:1212 rx->buf[i] = 0u;The watchpoint fires inside rx_clear on the out-of-bounds write. Because the comparison is done in hardware, the program runs at full speed until then. (A watchpoint on a stack variable is only valid while that frame exists.)
Fault status and address registers. When a bad pointer causes a fault, the System Control Block records why and, often, where. CFSR at 0xE000ED28 holds the cause bits; MMFAR at 0xE000ED34 and BFAR at 0xE000ED38 hold the faulting data address when the corresponding valid bit is set (MMARVALID is CFSR bit 7, BFARVALID is bit 15). For example, reading through a wild pointer to 0x20030000, an address past the end of SRAM that the bus does not decode:
(gdb) x/wx 0xE000ED280xe000ed28: 0x00008200(gdb) x/wx 0xE000ED380xe000ed38: 0x200300000x00008200 decodes as PRECISERR (bit 9) and BFARVALID (bit 15): a precise bus fault on a data access, and BFAR tells you the exact bad address. The stacked program counter in the exception frame tells you which instruction made the access. Writes are different: the Cortex-M4 buffers stores, so a bad write often produces IMPRECISERR (bit 10) with no valid address, reported a few instructions after the store.
Canaries. Put a known value (for example 0xDEADBEEF) at the end of a buffer or a stack, and check it periodically or when the bug appears. If the canary has changed, something wrote past the end. A watchpoint on the canary then finds who.
Common mistakes
Section titled “Common mistakes”- Writing
void *fp(void)when you meant a function pointer. That declares a function returningvoid *. Function pointers need(*fp). - Calling an unset callback. A null function pointer call produces an
INVSTATEUsageFault on Cortex-M. Check forNULLor install a default no-op handler. - Casting a callback to a different signature. Undefined behavior even when it works. Make the callback take
void *ctxand convert inside. - Indexing a function pointer table with an unchecked value. Out of range means jumping to whatever bytes follow the table. Bounds-check, and keep the table
const. - Registering a local variable as a callback context or DMA buffer. The pointer dangles as soon as the function returns. Use static storage for anything a driver keeps.
- Declaring a pointer without initializing it. Random write on first use. Initialize to an object or
NULLat the declaration. - Two owners of one allocated block. Leads to use-after-free and double free. Decide one owner per block and document it.
- Adding
restrictto pointers that can overlap. Silent wrong results under optimization. Only promise what every caller guarantees.
Exercises
Section titled “Exercises”- Calculator table. Write
static const binop_fn ops[] = { add, sub, mul, div_safe };wherediv_safereturns 0 for division by zero, and a functionuint32_t calc(uint32_t op, uint32_t a, uint32_t b, bool *ok)that bounds-checksop. Calling withop = 4must set*ok = falseand return 0 without touching the table. - Read declarations. Without a compiler, translate these into English using the right-left rule, then check with
cdecl, or by writing a typedef version and assigning a variable of one type to a variable of the other (the compiler warns if the types differ):char *(*fp)(const char *, int);,const char *const names[3];,int (*(*tbl)[4])(void);,void (*register_cb(void (*cb)(void *), void *ctx))(void *);. - Context callbacks. Extend the button example with a timer service:
timer_start(struct timer *t, uint32_t period, timer_cb_fn cb, void *ctx)andtimer_tick(struct timer *t)that callscb(ctx)everyperiodticks. Use one callback function with two different counters as contexts and show they count independently after 10 ticks with periods 2 and 5 (results: 5 and 2). - See restrict in assembly. Compile both versions of
scalewitharm-none-eabi-gcc -O2 -mcpu=cortex-m4 -mthumb -S. Find the inner loop in each and confirm that therestrictversion loads*gainonce, outside the loop. Then call therestrictversion withgainpointing atdst[1]on the host and explain the output you get. - Hunt a corruption. On the host or in Renode, reproduce the
rx_clearoff-by-one with a known value incount. Usegdbwithwatchto stop on the write that corrupts it and record the line and the value ofi. Correct result: the watchpoint triggers insiderx_clearwithi == 8.
Check yourself
Section titled “Check yourself”Question 1:What does int *f(void); declare?
Without parentheses around *f, the () binds first: f is a function, and the * belongs to the return type. A function pointer would be int (*f)(void);.
Question 2:A Cortex-M4 HardFault dump shows INVSTATE set in CFSR. Which bug is most likely?
INVSTATE means the core tried to execute with bit 0 of the target address clear, that is in Arm state. Function addresses from the linker always have bit 0 set, so a target like 0x00000000 points to a bad function pointer.
Question 3:void send(void) { uint8_t m[4] = {1,2,3,4}; uart_start_tx(m, 4); } and the driver transmits from an ISR after send returns. What is wrong?
Passing an array passes only its address. Once send returns, m's stack storage is reused by later calls, so the ISR transmits whatever overwrites it. The buffer must have static storage duration.
Question 4:In void f(int32_t *restrict a, int32_t *restrict b), what does restrict allow the compiler to assume?
restrict promises that each object accessed through one of the pointers is not accessed through the other during the call. The compiler can then keep values loaded through b in registers across stores through a.
Question 5:Given uint8_t grid[3][4]; at 0x20000040 and uint8_t (*row)[4] = grid;, what address does row + 2 hold?
row points to whole 4-byte arrays, so row + 2 advances by 2 times 4 bytes, to the start of grid[2] at 0x20000048.
Question 6:Why should a table of command handlers be declared static const?
A const object with static storage is placed in .rodata in flash. It costs no RAM and a wild write cannot redirect a handler, which also removes a common attack vector.
Key takeaways
Section titled “Key takeaways”- A function pointer holds the address of code; on Cortex-M that address has bit 0 set for Thumb, and calling a null function pointer gives an
INVSTATEfault. - Use
typedefs for function pointer types and keep function pointer tablesstatic constso they live in flash. - Callbacks take a
void *ctxthat the caller registers and the driver passes back, so one function can serve many instances without globals. - A struct of function pointers is an interface (a vtable). It is how drivers stay independent of the hardware or fake behind them.
- Read any declaration with the right-left rule: start at the name, go right through
[]and(), then left through*, stepping out of parentheses. restrictis a promise of no overlap that lets the optimizer keep values in registers; breaking it is undefined behavior.- On an MCU, wild, dangling and freed pointers corrupt memory silently. Initialize every pointer, give anything a driver keeps static storage, and use watchpoints and
CFSR/BFARto find the culprit.
Next: Arrays, strings, and tables puts these pointer skills to work on fixed-size buffers, C strings, safe copying and parsing, and lookup tables in flash.