Part 4 · Professional Firmware29 min read
Design Patterns for Embedded C
C has no classes, no exceptions, no destructors, and no garbage collector. Firmware written in C still needs everything those features provide: polymorphism so one sensor driver works over two buses, decoupling so a button handler does not know about the display, cleanup so a failed init does not leak a peripheral, and error handling that survives a code review. This lesson collects the patterns professional firmware teams use for each of those problems, shows the C that implements them, and explains what each costs in flash, RAM, and cycles. It ends with the anti-patterns that most often turn a codebase into something nobody wants to touch.
Object-oriented C
Section titled “Object-oriented C”“Object-oriented C” is not a framework. It is three conventions:
- An object is a struct holding its data.
- Its methods are functions whose first argument is a pointer to that struct, conventionally called
self. - Behaviour that varies between object types is called through a table of function pointers, the vtable, that the object points to.
The base type
Section titled “The base type”Here is a generic sensor interface. Any temperature, pressure, or humidity sensor implements it:
#ifndef SENSOR_H#define SENSOR_H
#include <stdint.h>#include "err.h"
typedef struct sensor sensor_t;
typedef struct { err_t (*init)(sensor_t *self); err_t (*read)(sensor_t *self, int32_t *value); const char *name;} sensor_ops_t;
struct sensor { const sensor_ops_t *ops; /* vtable: shared, const, lives in flash */ uint8_t id;};
static inline err_t sensor_init(sensor_t *s) { return s->ops->init(s); }static inline err_t sensor_read(sensor_t *s, int32_t *v) { return s->ops->read(s, v); }
#endifThe vtable pointer is const sensor_ops_t *, and every concrete type defines exactly one static const ops table. Ten sensor objects of the same type share one table in flash and each pays only 4 bytes of RAM for the pointer. Compare that to storing two function pointers in every object: 8 bytes of RAM each, and the pointers are writable, so a memory corruption bug can redirect a call anywhere.
The inline wrappers give callers a normal-looking API. sensor_read(s, &v) compiles to: load s->ops (one ldr), load the read member (another ldr), and blx to it. That is two loads and an indirect branch compared with a direct call, a few cycles on a Cortex-M4. It also prevents the compiler from inlining the callee, which matters only in very hot loops.
Inheritance by embedding
Section titled “Inheritance by embedding”A concrete sensor embeds the base struct as its first member and adds its own data:
#include <stddef.h>#include <stdint.h>#include "sensor.h"#include "regdev.h"
#define container_of(ptr, type, member) \ ((type *)((char *)(ptr) - offsetof(type, member)))
typedef struct { sensor_t base; /* must be first for the cast-free upcast */ const regdev_t *bus; /* injected: SPI or I2C, we do not care */ uint8_t addr; int32_t offset_centi;} tmp_sensor_t;
static err_t tmp_init(sensor_t *self){ tmp_sensor_t *t = container_of(self, tmp_sensor_t, base); uint8_t id; err_t e = regdev_read(t->bus, t->addr, 0x0F, &id, 1); if (e != ERR_OK) { return e; } return (id == 0xA1) ? ERR_OK : ERR_NOT_FOUND;}
static err_t tmp_read(sensor_t *self, int32_t *value){ tmp_sensor_t *t = container_of(self, tmp_sensor_t, base); uint8_t raw[2]; err_t e = regdev_read(t->bus, t->addr, 0x00, raw, sizeof raw); if (e != ERR_OK) { return e; } int32_t counts = (int32_t)(((uint32_t)raw[0] << 8) | raw[1]); if (counts >= 0x8000) { counts -= 0x10000; } *value = (counts * 100) / 128 + t->offset_centi; return ERR_OK;}
static const sensor_ops_t tmp_ops = { .init = tmp_init, .read = tmp_read, .name = "tmp",};
void tmp_sensor_construct(tmp_sensor_t *t, uint8_t id, const regdev_t *bus, uint8_t addr){ t->base.ops = &tmp_ops; t->base.id = id; t->bus = bus; t->addr = addr; t->offset_centi = 0;}In a real project the tmp_sensor_t definition and the constructor’s prototype go in tmp_sensor.h, so the board file can allocate the object statically; only the methods and the ops table stay private. container_of recovers the derived object from a pointer to its embedded base by subtracting the member’s offset. Because base is the first member, its offset is 0 and the subtraction is free; C guarantees a pointer to a struct and a pointer to its first member have the same address. Using container_of instead of a plain cast still matters: it keeps working if someone later moves base, and it lets one object embed several interfaces (a sensor that is also a power-managed device, with a second embedded base at a non-zero offset).
The application now treats all sensors uniformly:
#include <stddef.h>#include "sensor.h"
extern sensor_t *const g_sensors[];extern const size_t g_sensor_count;
void app_sample_all(int32_t *out){ for (size_t i = 0; i < g_sensor_count; i++) { if (sensor_read(g_sensors[i], &out[i]) != ERR_OK) { out[i] = INT32_MIN; /* sentinel: reading invalid */ } }}INT32_MIN comes from stdint.h, included through sensor.h. The board file creates the concrete objects and fills g_sensors, so the application never names a concrete sensor type. That is the payoff: adding a new sensor model means writing one new file and changing one board table.
Opaque handles and static pools
Section titled “Opaque handles and static pools”Organizing a Real C Codebase introduced the opaque handle: the header declares typedef struct uart uart_t; and the definition stays private to uart.c. Callers cannot touch the fields because they cannot see them. The question is where the objects come from without malloc. The answer is a static pool: a fixed array of objects inside the module, handed out by an open function.
There is a second RAM-saving idea that combines naturally with pools: separate what is fixed about an instance from what changes. The register base, IRQ number, clock-enable bit, and pins never change at runtime, so they go in a const table in flash, the instance descriptor. Only the mutable state goes in RAM.
#include <stdbool.h>#include <stddef.h>#include <stdint.h>#include "uart.h"#include "ringbuf.h"#include "stm32f407_regs.h"
typedef struct { usart_regs_t *regs; uint8_t irqn; uint8_t apb1_bit;} uart_hw_t;
/* ROM: one descriptor per physical USART this board uses */static const uart_hw_t uart_hw[] = { { (usart_regs_t *)0x40004400u, 38u, 17u }, /* USART2 */ { (usart_regs_t *)0x40004800u, 39u, 18u }, /* USART3 */};#define UART_COUNT (sizeof uart_hw / sizeof uart_hw[0])
/* Register-level helpers, written as in the UART lesson (not shown) */void uart_hw_configure(usart_regs_t *r, uint8_t apb1_bit, uint8_t irqn, uint32_t baud);void uart_hw_disable(usart_regs_t *r, uint8_t apb1_bit, uint8_t irqn);
/* RAM: only the state that changes */struct uart { const uart_hw_t *hw; ringbuf_t rx; uint32_t overruns; bool in_use;};static struct uart pool[UART_COUNT];
uart_t *uart_open(uint8_t index, uint32_t baud){ if (index >= UART_COUNT || pool[index].in_use) { return NULL; } struct uart *u = &pool[index]; u->hw = &uart_hw[index]; u->overruns = 0; u->in_use = true; ringbuf_init(&u->rx); uart_hw_configure(u->hw->regs, u->hw->apb1_bit, u->hw->irqn, baud); return u;}
void uart_close(uart_t *u){ if (u != NULL && u->in_use) { uart_hw_disable(u->hw->regs, u->hw->apb1_bit, u->hw->irqn); u->in_use = false; }}The pool has exactly as many objects as the board has hardware instances, so “out of memory” is impossible in a way that matters: asking for a third UART on a board with two is a programming error that uart_open() reports. The descriptor table costs 8 bytes of flash per instance and zero RAM. On a part with 8 USARTs, 12 timers, and 3 SPI buses, moving the fixed data out of RAM this way saves hundreds of bytes, and it puts the board-specific numbers in one table that a reviewer can check against the reference manual. Integer constants cast to pointer type are valid in a static initializer, so the table is built entirely at compile time.
Singletons: the one legitimate case
Section titled “Singletons: the one legitimate case”A singleton is a module with exactly one instance and global access. In application code it is usually a design smell: hidden global state, untestable, and impossible to have two of later. But hardware is genuinely singular. There is one RCC, one NVIC, one SysTick, one flash controller. Modelling those as modules with private static state and plain functions (clock_init(), flash_erase_sector()) is honest and simple.
The test of whether a singleton is legitimate: could the product ever have two? One flash controller per chip: singleton is fine. One “console UART”: that is a board decision, not a hardware fact, so make the UART driver instance-based and let the board file decide which instance is the console.
Observer and publish-subscribe
Section titled “Observer and publish-subscribe”A button press should toggle an LED, log an event, wake the display, and reset an idle timer. If the button driver calls all four, it depends on four modules, and adding a fifth reaction means editing the driver. The observer pattern inverts this: interested modules register callbacks, and the source notifies whoever registered without knowing who they are.
A small event bus generalizes observers to named topics:
#include <stddef.h>#include <stdint.h>#include "evbus.h"
#define MAX_SUBS 4u
typedef struct { evbus_cb_t cb; void *ctx;} sub_t;
static sub_t subs[TOPIC_COUNT][MAX_SUBS];static uint8_t nsubs[TOPIC_COUNT];
err_t evbus_subscribe(topic_t topic, evbus_cb_t cb, void *ctx){ if ((unsigned)topic >= TOPIC_COUNT || cb == NULL) { return ERR_INVALID_ARG; } if (nsubs[topic] >= MAX_SUBS) { return ERR_NO_MEM; } subs[topic][nsubs[topic]] = (sub_t){ .cb = cb, .ctx = ctx }; nsubs[topic]++; return ERR_OK;}
void evbus_publish(topic_t topic, const void *payload){ if ((unsigned)topic >= TOPIC_COUNT) { return; } for (uint8_t i = 0; i < nsubs[topic]; i++) { subs[topic][i].cb(topic, payload, subs[topic][i].ctx); }}#ifndef EVBUS_H#define EVBUS_H
#include "err.h"
typedef enum { TOPIC_BUTTON, TOPIC_TEMP, TOPIC_LOW_BATTERY, TOPIC_COUNT } topic_t;typedef void (*evbus_cb_t)(topic_t topic, const void *payload, void *ctx);
err_t evbus_subscribe(topic_t topic, evbus_cb_t cb, void *ctx);void evbus_publish(topic_t topic, const void *payload);
#endifEach subscriber gets a void *ctx back, so one callback function can serve several objects. The subscription table is fixed-size and static: no allocation, and evbus_subscribe() reports a full table instead of failing silently.
This bus is synchronous: evbus_publish() calls every subscriber before it returns, in the publisher’s context. That has three consequences you must design around. Subscribers run in whatever context published, so if an ISR publishes, every callback runs in the ISR and must be ISR-safe. A slow subscriber delays the publisher and every later subscriber. And subscribing while another context publishes is a data race, so the rule is: subscribe during init only. For anything published from an ISR, publish into a queue instead and let the main loop or a task drain it, which makes the bus asynchronous. That is exactly the event queue from State Machines and Event-Driven Firmware.
Command pattern and command queues
Section titled “Command pattern and command queues”The command pattern turns a request into a data object. Instead of calling led_set(2, true) directly, a caller builds a command { CMD_LED_SET, led=2, on=true } and hands it over. That sounds like extra work; it buys you three things firmware needs constantly:
- Serialization. Commands from a UART shell, a BLE write, and a button all go into one queue and are executed by one task, in order, with no locking inside the handlers.
- Deferral. An ISR can enqueue a command in microseconds and leave the slow part to a task.
- Logging and replay. A command is data, so it can be logged, sent over a link, or replayed in a test.
#include <stdbool.h>#include <stdint.h>#include "commands.h"
typedef enum { CMD_LED_SET, CMD_SET_PERIOD, CMD_SAVE_CONFIG, CMD_COUNT } cmd_id_t;
typedef struct { cmd_id_t id; union { struct { uint8_t led; bool on; } led; struct { uint16_t period_ms; } period; } u;} command_t;
bool cmdq_pop(command_t *out); /* ring buffer of command_t, in cmdq.c */
static void do_led(const command_t *c) { led_set(c->u.led.led, c->u.led.on); }static void do_period(const command_t *c) { sampler_set_period(c->u.period.period_ms); }static void do_save(const command_t *c) { (void)c; config_save(); }
static void (*const handlers[CMD_COUNT])(const command_t *) = { [CMD_LED_SET] = do_led, [CMD_SET_PERIOD] = do_period, [CMD_SAVE_CONFIG] = do_save,};
/* Runs in exactly one context: the command task or the main loop. */void command_task_poll(void){ command_t c; while (cmdq_pop(&c)) { if ((unsigned)c.id < CMD_COUNT && handlers[c.id] != NULL) { handlers[c.id](&c); } }}A command is a tagged union: the id says which union member is valid. The handler table uses designated initializers, so the mapping is explicit and a missing entry is NULL rather than a shifted table. The table is const, which puts it in flash. The id is range-checked before indexing, because commands may come from outside the chip. On an RTOS, cmdq_pop() becomes a blocking xQueueReceive() and the poll loop becomes a task body; RTOS Synchronization and Communication calls this the worker-task pattern. The led_set(), sampler_set_period(), and config_save() functions are declared in commands.h for brevity.
State pattern
Section titled “State pattern”The state pattern gives each state of an object its own behaviour, usually as a function pointer that changes when the state changes. In C it is the function-pointer-per-state machine from State Machines and Event-Driven Firmware, which covers it fully, including entry and exit actions. Use it when the states have substantially different behaviour; use the switch or table form when they do not.
Strategy via function pointers
Section titled “Strategy via function pointers”A strategy is an interchangeable algorithm selected at runtime. Firmware examples: which filter smooths a sensor (none, moving average, IIR), which checksum a protocol version uses, which compression a logger applies. The implementation is a function pointer, optionally with a state pointer:
#include <stdint.h>
typedef struct { int32_t y; /* IIR state, scaled by 2^k */ uint8_t k;} iir_state_t;
typedef int32_t (*filter_fn)(void *state, int32_t x);
static int32_t filter_none(void *state, int32_t x){ (void)state; return x;}
static int32_t filter_iir(void *state, int32_t x){ iir_state_t *s = state; s->y += x - (s->y >> s->k); /* y/2^k tracks x with time constant 2^k */ return s->y >> s->k;}
typedef struct { filter_fn fn; void *state;} filter_t;
static iir_state_t temp_iir = { .y = 0, .k = 3 };
filter_t filter_select(uint8_t cfg_choice){ switch (cfg_choice) { case 1: return (filter_t){ filter_iir, &temp_iir }; default: return (filter_t){ filter_none, NULL }; }}The caller does out = f.fn(f.state, raw) and never knows which filter is active. The IIR stores its state scaled by 2^k so integer division does not throw away the fraction each step; this is the fixed-point filter from ADC, DAC, and Analog Basics. (Right-shifting a negative int32_t is implementation-defined; GCC and Clang on Arm perform an arithmetic shift, which this code relies on.)
If the choice is fixed at build time, a function pointer is unnecessary indirection; select the implementation with the preprocessor or the linker instead. Strategy earns its keep when the choice comes from a runtime config or differs between instances.
Dependency injection
Section titled “Dependency injection”Dependency injection means a module receives its dependencies instead of reaching out for them. tmp_sensor_construct() above takes a const regdev_t *bus: the sensor does not know or care whether that bus is I2C1, SPI1, or a fake in a unit test. That is the whole idea.
Three forms appear in firmware, in increasing flexibility and cost:
| Form | Example | Cost |
|---|---|---|
| Link-time | Test build links fake_clock.c instead of systick_clock.c | None at runtime |
| Construction-time | tmp_sensor_construct(&t, 0, &i2c1_regdev, 0x48) | One stored pointer per object |
| Call-time | crc_compute(data, len, crc_fn) | One argument per call |
Prefer link-time for singletons (clock, flash controller), construction-time for instances (sensors, displays, buses), and call-time only for genuinely per-call choices. Testing Embedded Software shows the testing side of each.
Resource management
Section titled “Resource management”C has no destructors, so every acquired resource needs an explicit release, and every error path must release exactly what was acquired.
Init and deinit pairs
Section titled “Init and deinit pairs”Every module that acquires something (a clock enable, an IRQ, a DMA stream, a pin) has an init and a deinit that undoes it in reverse order. You need deinit more than you might think: before jumping from a bootloader to an application, before entering Stop mode, and when re-initializing after an error.
Reference counts for shared peripherals
Section titled “Reference counts for shared peripherals”When several drivers share one peripheral (an SPI bus with a flash chip, an accelerometer, and a display), none of them owns its clock. A reference count turns it on with the first user and off with the last:
#include <stdint.h>#include "err.h"#include "stm32f4xx.h"#include "critical.h"
static uint8_t spi1_users;
void spi1_hw_configure(void); /* CR1 mode, baud, enable; not shown */
err_t spi1_acquire(void){ uint32_t key = critical_enter(); if (spi1_users == UINT8_MAX) { critical_exit(key); return ERR_NO_MEM; } if (spi1_users++ == 0u) { RCC->APB2ENR |= (1u << 12); /* SPI1EN */ (void)RCC->APB2ENR; /* read back: clock settles */ spi1_hw_configure(); } critical_exit(key); return ERR_OK;}
void spi1_release(void){ uint32_t key = critical_enter(); if (spi1_users > 0u && --spi1_users == 0u) { RCC->APB2ENR &= ~(1u << 12); } critical_exit(key);}The count and the clock enable change together inside a critical section (the PRIMASK save and restore from Interrupts Done Right), so two contexts acquiring at once cannot both see zero. spi1_release() ignores an unbalanced release rather than wrapping the count to 255; in a debug build it should assert instead, because an unbalanced release is a bug.
goto cleanup
Section titled “goto cleanup”When init acquires several resources in sequence, a failure halfway must release the earlier ones in reverse order. The Linux kernel idiom from Control Flow That Holds Up in Production is the cleanest way to write it in C:
#include "err.h"#include "spi1_bus.h"#include "spi_nor.h"#include "storage.h"
err_t logger_start(void){ err_t e = spi1_acquire(); if (e != ERR_OK) { return e; } e = spi_nor_open(); if (e != ERR_OK) { goto fail_spi; } e = storage_mount_err(); if (e != ERR_OK) { goto fail_nor; } return ERR_OK;
fail_nor: spi_nor_close();fail_spi: spi1_release(); return e;}The labels are in reverse order of acquisition, and each failure jumps to the label that releases everything acquired so far. Adding a fourth step means adding one if and one label, with no duplicated cleanup code to keep in sync.
Error handling
Section titled “Error handling”Error handling is where firmware codebases differ most between amateur and professional. The professional version has four parts.
One project-wide error type
Section titled “One project-wide error type”#ifndef ERR_H#define ERR_H
typedef enum { ERR_OK = 0, ERR_TIMEOUT, ERR_BUSY, ERR_INVALID_ARG, ERR_NO_MEM, ERR_IO, ERR_CRC, ERR_NOT_FOUND, ERR_STATE, /* called in the wrong state */} err_t;
#define MUST_CHECK __attribute__((warn_unused_result))
#endifEvery fallible function returns err_t. Results go through out-parameters. Mixing conventions (some functions return -1, some false, some NULL, some a count) guarantees that someone checks the wrong thing. Vendor and library codes (lwIP’s err_t, LittleFS’s negative ints, HAL status) are translated to the project type at the wrapper boundary.
Never ignore a return value
Section titled “Never ignore a return value”Mark functions whose result must be checked:
#include <stddef.h>#include <stdint.h>#include "err.h"
MUST_CHECK err_t flash_write(uint32_t addr, const void *data, size_t len);Calling flash_write(...) without using the result now produces a compiler warning, which -Werror turns into a build failure. Be aware that in GCC a (void) cast does not silence warn_unused_result; that is deliberate, so the escape hatch is to assign the result to a variable and document why it is ignored.
Propagate or handle
Section titled “Propagate or handle”At each call site decide one of two things. Handle the error if this layer can do something meaningful: retry a bus transaction, fall back to a default config, mark a sensor reading invalid. Otherwise propagate it upward unchanged. What you must not do is translate every error into a generic failure, or log it and carry on as if it succeeded.
A macro keeps propagation short and records where it happened:
#ifndef ERR_CTX_H#define ERR_CTX_H
#include <stdint.h>#include "err.h"
void err_record(err_t e, uint8_t module, uint16_t line);
#define RETURN_IF_ERR(expr) \ do { \ err_t e_ = (expr); \ if (e_ != ERR_OK) { \ err_record(e_, MODULE_ID, (uint16_t)__LINE__); \ return e_; \ } \ } while (0)
#endifEach source file defines MODULE_ID before using it. err_record() writes {error, module, line} into a small ring buffer, which the field-diagnostics system can report later. A code and a line number are much cheaper than a string and almost as useful when you have the source.
assert versus error codes, and panics
Section titled “assert versus error codes, and panics”Two kinds of failure need different mechanisms:
| Situation | Example | Mechanism |
|---|---|---|
| Runtime condition that can legitimately happen | Sensor NAKs, CRC fails, queue full, user sends a bad command | Error code, handled or propagated |
| Programmer error: the code is wrong | NULL handle passed, state machine in an impossible state, index out of range from internal code | assert, which ends in a panic |
Never assert on external input: a malformed UART frame is a runtime condition, and an assert on it hands an attacker a reset button. Never return an error code for a broken invariant: the caller cannot fix a bug in your module.
A panic is what a failed assert does in production. The policy has to be decided per product, but the common pattern records context and resets:
#include <stdint.h>#include "panic.h"
#define AIRCR (*(volatile uint32_t *)0xE000ED0Cu)
typedef struct { uint32_t magic; uint32_t pc; uint16_t code; uint16_t line;} panic_record_t;
__attribute__((section(".noinit"))) panic_record_t g_panic;
_Noreturn void panic(uint16_t code, uint16_t line){ __asm volatile ("cpsid i" ::: "memory"); g_panic.magic = 0xDEADC0DEu; g_panic.pc = (uint32_t)(uintptr_t)__builtin_return_address(0); g_panic.code = code; g_panic.line = line; __asm volatile ("dsb" ::: "memory"); AIRCR = (0x5FAu << 16) | (AIRCR & (7u << 8)) | (1u << 2); /* SYSRESETREQ */ __asm volatile ("dsb" ::: "memory"); for (;;) { }}Interrupts are disabled first so nothing else runs on a corrupted system. The record goes in .noinit RAM (set up in Linker Scripts), which survives a software reset; after reboot the application checks the magic value, reports the panic, and clears it. The AIRCR write must include the key 0x5FA in the upper half-word or the write is ignored, and it preserves the PRIGROUP field in bits 8 to 10. The full crash-record design is in Watchdogs, Faults, and Recovering From Them.
Configuration: compile time versus runtime
Section titled “Configuration: compile time versus runtime”Two kinds of configuration exist, and putting a setting in the wrong one is a common design error.
Compile-time configuration selects what code exists: which board, which features, which log level is compiled in. It lives in a config.h (or a generated one from a Kconfig system, as Zephyr does):
#define CONFIG_BOARD_DISCO_F407 1#define CONFIG_FEATURE_BLE 0#define CONFIG_LOG_LEVEL 3 /* 0 off .. 4 debug */#define CONFIG_SENSOR_COUNT 2Test these with #if CONFIG_FEATURE_BLE, not #ifdef, and compile with -Wundef: a misspelled flag then produces a warning instead of silently evaluating as 0. Every flag must always be defined, to 0 or 1.
Runtime configuration holds values that change per device or per installation without rebuilding: sample period, thresholds, calibration offsets, network names. It lives in a struct with defaults in flash and the active copy loaded from non-volatile storage:
#include <stdint.h>#include "app_config.h"
static const app_config_t defaults = { .version = APP_CONFIG_VERSION, .sample_period_ms = 1000, .temp_alarm_centi = 6000, .filter_choice = 1,};
static app_config_t active;
void app_config_load(void){ if (config_store_read(&active, sizeof active) != ERR_OK || active.version != APP_CONFIG_VERSION) { active = defaults; /* first boot or schema change */ }}
const app_config_t *app_config(void) { return &active; }The defaults are const, so they cost flash, not RAM, and cannot be corrupted at runtime. The accessor returns a const pointer, so only the config module can change values, through setters that validate ranges. The rule of thumb: if a field engineer or a customer might need to change it, it is runtime config; if changing it means different hardware or different code, it is compile-time config.
Anti-patterns and their refactors
Section titled “Anti-patterns and their refactors”The god module. One main.c or app.c of 4,000 lines that reads sensors, runs the protocol, drives the display, and handles errors. Every change touches it, merges conflict constantly, and nothing can be tested alone. Refactor: extract one responsibility at a time into a module with an init function and a small API, starting with the most self-contained piece (often the protocol parser), and add tests for it as you go.
Blocking in callbacks. A subscriber to the event bus calls delay_ms(100) or waits for a flash erase. Every other subscriber, and the publisher, stalls with it; if the publisher was an ISR, the system misses interrupts. Refactor: callbacks only record the event or post a command; the slow work runs in a task or the main loop.
Hidden globals. Modules communicating through extern variables, such as extern volatile int g_mode; read and written from six files. Nobody knows who changes it or when, and races are invisible. Refactor: make the variable static inside one owning module, add accessor functions, and route changes through an event or command so they are logged and serialized.
Premature abstraction. A hal_gpio_interface_factory with three layers of vtables for a product that has one board and one MCU. It costs flash, cycles, and every reader’s time, and the “flexibility” usually fails when a real second platform arrives with different needs. Refactor: collapse to direct functions until there is a second implementation; then introduce the interface, shaped by two real cases instead of one imagined one.
Macro magic. Clever macros that generate functions, hide control flow (TRY/CATCH built from setjmp), or require the reader to run the preprocessor in their head. Debuggers step over them and errors point at the wrong line. Refactor: use static inline functions for anything a function can do, keep X-macros for tables where they remove real duplication, and keep control flow visible.
Common mistakes
Section titled “Common mistakes”- Function pointers stored in every object instead of a shared
constvtable. Fix: onestatic constops table per type; objects hold one pointer to it. - Casting a base pointer to the derived type when the base is not the first member. Fix: use
container_of, which works for any member position. - Publishing to a synchronous event bus from an ISR, so every subscriber runs in interrupt context. Fix: ISRs post to a queue; a task publishes.
- Subscribing or unsubscribing while another context publishes. Fix: subscribe during init only, or protect the table.
- Error paths that release resources in the wrong order or leak them. Fix: goto cleanup with labels in reverse acquisition order.
- Ignored return values. Fix:
warn_unused_resulton every fallible function and-Werror. asserton external input. Fix: validate input and return an error code; assert only on internal invariants.#ifdeffeature flags that silently disable features when misspelled. Fix: always-defined 0/1 flags tested with#ifand-Wundef.
Exercises
Section titled “Exercises”- Implement the sensor interface with two concrete types: the
tmp_sensor_tabove and afake_sensor_tthat returns values from an array. Put both in oneg_sensorstable and runapp_sample_all()in a host program. Printsizeof(tmp_sensor_t)and explain each byte, including padding. - Compile
tmp_sensor.candapp.cfor the target with-O2and read the disassembly ofapp_sample_all()witharm-none-eabi-objdump -d. Find the two loads and theblxthat implement the virtual call. Then makesensor_read()calltmp_read()directly and compare instruction counts. - Add an asynchronous mode to the event bus:
evbus_post()copies an event (topic plus up to 8 bytes of payload) into a ring buffer, andevbus_dispatch()called from the main loop publishes queued events. Write a Unity test that posts from a simulated ISR (a plain function call) and verifies the subscriber runs only whenevbus_dispatch()is called. - Write
display_start()that acquires the SPI bus, a DMA stream, a GPIO for the backlight, and a 1 KB frame buffer from a static pool, using goto cleanup. Write host tests with fakes that fail each step in turn, and assert that after every failure all reference counts and pool usage are back to zero. - Take a module from an earlier lesson’s project that uses at least one
externglobal, and refactor it to a static variable with accessors and anerr_t-returning API. Mark the fallible functionsMUST_CHECK, build with-Werror, and fix every call site the compiler flags.
Check yourself
Section titled “Check yourself”Question 1:A codebase has 20 instances of a driver type. Each instance stores four function pointers directly in its struct. What does moving them to a shared static const ops table save on a 32-bit MCU?
Each instance shrinks from 16 bytes of pointers to one 4-byte vtable pointer, saving 12 bytes times 20. The shared table costs 16 bytes of flash once and cannot be corrupted at runtime.
Question 2:struct derived { uint32_t flags; struct base b; }; A callback receives struct base *p. Which expression correctly gets the struct derived *?
The base is not the first member, so the derived object starts offsetof(struct derived, b) bytes before p. A plain cast would point 4 bytes too far into the object.
Question 3:The button ISR calls evbus_publish(TOPIC_BUTTON, ...) on a synchronous bus. A subscriber writes a log record to SPI flash. What is the problem?
A synchronous bus runs every subscriber in the publisher's context. Publishing from an ISR means slow callbacks execute in interrupt context; ISRs should post to a queue instead.
Question 4:init acquires the SPI bus, then a DMA stream, then mounts a filesystem. The mount fails. Using goto cleanup, what must happen?
Resources are released in reverse order of acquisition, and only those actually acquired. The mount failed, so only DMA and SPI are released, DMA first.
Question 5:A UART frame arrives with a length field larger than the receive buffer. Which response is correct?
Malformed external input is expected and must be handled with error codes. Asserting on it lets anyone who can send a frame reset the device.
Key takeaways
Section titled “Key takeaways”- Object-oriented C is a struct plus a
constvtable plus aselfpointer; derived types embed the base and recover themselves withcontainer_of. - Static pools sized to the hardware replace
malloc; split each instance into aconstdescriptor in flash and minimal mutable state in RAM. - Hardware blocks are the one legitimate singleton; board-level choices such as “the console” should be instances.
- Synchronous observers run in the publisher’s context, so ISRs post to queues and tasks publish.
- Commands as tagged unions serialize work from many sources through one queue and one executor.
- Every acquire has a release in reverse order, with goto cleanup on error paths and reference counts for shared peripherals.
- One
err_tfor the project,warn_unused_resulton fallible functions, error codes for runtime conditions,assertand panic for programmer errors.
Next: Firmware Architecture at Scale, where these patterns are organized into layers, modules, and budgets for a whole product.