Part 3 · Firmware Systems34 min read
Driver Design and Hardware Abstraction
In Part 2 you wrote code that pokes registers directly from main. That works for one board and one programmer. It stops working the moment a second engineer joins, a second board revision appears, or the sensor moves from SPI to I2C. This lesson is about the shape of driver code: how to split firmware into layers, what a driver API must promise its callers, how to make one sensor driver run over two different buses, how to live with vendor HALs, and how to test a driver on your laptop with no hardware attached.
Layers and the dependency rule
Section titled “Layers and the dependency rule”Every maintainable firmware codebase ends up with roughly the same layers, whether or not anyone drew them:
+--------------------------------------------------------------+| Application what the product does (modes, UI, logic) |+--------------------------------------------------------------+| Services logging, config storage, protocol, shell |+--------------------------------------------------------------+| Device drivers th_sensor, spi_flash, display (chip-level) |+--------------------------------------------------------------+| Peripheral HAL uart, spi, i2c, gpio, timer (MCU-level) |+--------------------------------------------------------------+| Registers stm32f407_regs.h / CMSIS device header |+--------------------------------------------------------------+ ^ board config (bsp/) sits at the edge and wires concrete instances into the layers aboveThe dependency rule: a layer may call down, never up, and should only call the layer directly below it. The application asks the sensor driver for a temperature. The sensor driver asks a bus for bytes. The bus implementation touches SPI1->DR. Nothing above the HAL includes a register header, and nothing below the application knows what the product is.
Why be strict about it? Because each violation is a future bug or a future rewrite:
- An application that writes
GPIOD->BSRRdirectly cannot be tested on the host and breaks when the LED moves to another pin. - A UART driver that calls
app_handle_command()from its ISR cannot be reused in the next product, and now the driver’s timing depends on application code. - A sensor driver that includes
stm32f4xx.his tied to one MCU family forever.
Calls go down. Information comes back up through return values, callbacks registered by the upper layer, or events placed in a queue. The upper layer always initiates the relationship.
The board support package (BSP) is the one place allowed to know everything: which UART instance is the console, which pin is the sensor’s chip select, what the clock speeds are. It is not a layer in the stack. It is the wiring harness that connects concrete instances when the system boots.
What a good driver API promises
Section titled “What a good driver API promises”A driver API is a contract. Callers should be able to use it correctly from the header alone. Here is the header for a UART driver that follows the rules this section explains.
#ifndef ERR_H#define ERR_H
typedef enum { ERR_OK = 0, ERR_INVALID_ARG = -1, /* caller passed something nonsensical */ ERR_TIMEOUT = -2, /* hardware did not respond in time */ ERR_BUSY = -3, /* try again later; nothing was done */ ERR_NO_DEVICE = -4, /* device absent or wrong ID */ ERR_IO = -5, /* bus or peripheral reported an error */ ERR_STATE = -6, /* call not valid in the current state */ ERR_NO_MEM = -7, /* no free instance, buffer or slot */} err_t;
#endif#ifndef UART_H#define UART_H
#include <stddef.h>#include <stdint.h>#include "err.h"
typedef enum { UART_ID_2 = 0, UART_ID_COUNT } uart_id_t;typedef enum { UART_PARITY_NONE, UART_PARITY_EVEN, UART_PARITY_ODD } uart_parity_t;
typedef struct { uint32_t baud; uart_parity_t parity; uint8_t stop_bits; /* 1 or 2 */} uart_config_t;
#define UART_CONFIG_DEFAULT { .baud = 115200u, .parity = UART_PARITY_NONE, .stop_bits = 1u }
typedef struct uart uart_t; /* opaque: fields live in uart.c */
err_t uart_open(uart_id_t id, const uart_config_t *cfg, uart_t **out);err_t uart_close(uart_t *u);
/* Blocking: returns when all bytes are written to the data register or on timeout. */err_t uart_write(uart_t *u, const uint8_t *data, size_t len, uint32_t timeout_ms);
#endifEach design choice in that header is deliberate.
Explicit init and deinit
Section titled “Explicit init and deinit”uart_open does all the work: enables the peripheral clock, configures pins through the BSP, programs BRR and CR1. Nothing happens as a side effect of the first uart_write. Lazy initialization inside other calls hides timing (the first call is mysteriously slow) and makes the order of initialization impossible to reason about. uart_close exists so low-power code can turn the peripheral off and a bootloader can hand the hardware back in reset state.
Configuration structs with defaults
Section titled “Configuration structs with defaults”A struct instead of five positional parameters means a new field can be added without breaking every call site. The UART_CONFIG_DEFAULT designated initializer gives the common case in one line, and fields not mentioned are zero:
uart_config_t cfg = UART_CONFIG_DEFAULT;cfg.baud = 921600u; /* override only what differs */uart_t *console = NULL;err_t e = uart_open(UART_ID_2, &cfg, &console);Design the zero value of each field to mean “the sensible default” where you can, so a caller who forgets a field gets reasonable behavior instead of a divide by zero in the baud calculation.
Handles, opaque where possible
Section titled “Handles, opaque where possible”typedef struct uart uart_t; declares a type whose contents the caller cannot see. They hold a pointer and pass it back. That stops application code from reaching into u->regs and quietly depending on the STM32 register layout.
Without malloc, where does the struct live? Inside the driver, in a static pool sized for the hardware:
#include <stdbool.h>#include "stm32f4xx.h" /* CMSIS device header: USART_TypeDef, USART2 */#include "uart.h"
struct uart { USART_TypeDef *regs; bool in_use; uart_config_t cfg;};
static struct uart instances[UART_ID_COUNT] = { [UART_ID_2] = { .regs = USART2 },};
err_t uart_open(uart_id_t id, const uart_config_t *cfg, uart_t **out){ if (id >= UART_ID_COUNT || cfg == NULL || out == NULL) { return ERR_INVALID_ARG; } struct uart *u = &instances[id]; if (u->in_use) { return ERR_STATE; } u->cfg = *cfg; /* clock enable, pin mux via BSP, BRR and CR1 setup go here (UART lesson) */ u->in_use = true; *out = u; return ERR_OK;}There is one hardware USART2, so there is exactly one slot for it. The pool cannot run out unless someone opens the same instance twice, which is a bug the driver reports.
The alternative is the caller-allocated handle: the header exposes the struct, the caller declares th_sensor_t sensor;, and a comment says the fields are private. You give up enforcement but gain unlimited instances with no pool. For chip-level drivers (you might have four identical sensors) this is often the better trade. You will see it in the sensor driver later in this lesson.
Status codes, one convention
Section titled “Status codes, one convention”Every function that can fail returns err_t. Data comes back through out-parameters. Zero is success and negative values are errors, so if (e != ERR_OK) is always the check. One project-wide enum means a sensor driver can return the bus error it received without translating it, and a log line can print a single number that means the same thing everywhere.
The distinction between ERR_BUSY and ERR_TIMEOUT matters. Busy means “nothing happened, call me again”; timeout means “I tried and the hardware did not answer”, which usually means something is broken.
No hidden blocking, and a timeout on every wait
Section titled “No hidden blocking, and a timeout on every wait”A driver must never spin forever. Every loop that waits for a hardware flag gets a deadline:
#include "port.h" /* uint32_t port_millis(void): monotonic ms tick */
err_t uart_write(uart_t *u, const uint8_t *data, size_t len, uint32_t timeout_ms){ if (u == NULL || !u->in_use || (data == NULL && len > 0u)) { return ERR_INVALID_ARG; } const uint32_t start = port_millis(); for (size_t i = 0; i < len; i++) { while ((u->regs->SR & USART_SR_TXE) == 0u) { if ((uint32_t)(port_millis() - start) >= timeout_ms) { return ERR_TIMEOUT; } } u->regs->DR = data[i]; } return ERR_OK;}If the USART clock was never enabled, TXE never sets. Without the timeout, the system hangs in the driver with no indication why; the watchdog bites and you get a reset loop. With it, the caller gets ERR_TIMEOUT and can log it. The (uint32_t)(now - start) subtraction is the wraparound-safe comparison from Clocks, reset, and timing.
“No hidden blocking” also means the header states which functions block. A function named uart_write that sometimes waits 100 ms because an internal buffer is full is a trap for whoever calls it from a time-critical loop.
No printf, no globals beyond the handle
Section titled “No printf, no globals beyond the handle”A driver that prints diagnostics assumes there is a console, that it is initialized, that printing is fast, and that the caller is not in an ISR. All four are false somewhere. Return an error code and let the upper layer decide whether to log it.
All driver state belongs in the handle or in the instance pool. A hidden static uint8_t last_error; at file scope means two instances share one variable, and nobody reading the header knows it exists.
Four API styles
Section titled “Four API styles”The uart_write above is blocking: it returns when the job is done. There are three other styles, and choosing between them is the most important decision in a driver’s design.
| Style | Call returns | Caller learns completion by | Good for | Bad for |
|---|---|---|---|---|
| Blocking | when done or timed out | return value | init code, simple tools, RTOS tasks that can sleep | superloops, anything with deadlines |
| Non-blocking | immediately, with partial progress | calling again / polling | superloops, cooperative schedulers | very large transfers |
| Callback | immediately, after starting | a function called on completion, often from ISR context | DMA, high throughput, event-driven code | code that cannot tolerate ISR-context callbacks |
| Event queue | immediately, after starting | an event posted to the owner’s queue | state machines, active objects, RTOS tasks | tiny systems with no queue |
Blocking is not evil. In an RTOS task that blocks on a semaphore while DMA runs, blocking is the cleanest API there is: the CPU does other work while the task sleeps. Blocking is wrong when it busy-waits in a system with no scheduler, because then the whole system stops.
One UART, three drivers
Section titled “One UART, three drivers”Here is the same transmit operation designed three ways. The blocking version is above. The other two share a TX path driven by the TXE interrupt, and struct uart grows the fields they need (a TX ring, and the pointer, length, position and callback of the current async transfer).
Non-blocking
Section titled “Non-blocking”/* Queues as many bytes as fit in the TX ring. Returns how many were taken. */size_t uart_write_nb(uart_t *u, const uint8_t *data, size_t len){ size_t n = 0; while (n < len && ring_push(&u->tx_ring, data[n])) { n++; } if (n > 0u) { u->regs->CR1 |= USART_CR1_TXEIE; /* ISR drains the ring */ } return n;}
void uart_irq(uart_t *u) /* called from USART2_IRQHandler */{ if ((u->regs->CR1 & USART_CR1_TXEIE) && (u->regs->SR & USART_SR_TXE)) { uint8_t b; if (ring_pop(&u->tx_ring, &b)) { u->regs->DR = b; } else { u->regs->CR1 &= ~USART_CR1_TXEIE; /* nothing left: stop interrupting */ } }}The caller gets a count back and is responsible for retrying the remainder later. That makes backpressure explicit: a logging service that sees n < len can count dropped bytes instead of stalling. The ring is a single-producer single-consumer buffer (you built one in UART: serial communication; the lock-free version is in Concurrency, atomics, and memory ordering).
The CR1 |= TXEIE read-modify-write in thread context races with the ISR clearing the same bit. Here the race is harmless: the worst interleaving leaves TXEIE set with an empty ring, which costs one extra interrupt that clears it again. That is only true because the ISR touches no other CR1 bit. If it did, the update would need a critical section.
Callback
Section titled “Callback”typedef void (*uart_done_cb_t)(void *ctx, err_t result);
/* Starts sending. `data` must stay valid until `cb` runs. cb runs in ISR context. */err_t uart_write_async(uart_t *u, const uint8_t *data, size_t len, uart_done_cb_t cb, void *ctx){ if (u->tx_busy) { return ERR_BUSY; } u->tx_busy = true; u->tx_buf = data; u->tx_len = len; u->tx_pos = 0; u->tx_cb = cb; u->tx_ctx = ctx; u->regs->CR1 |= USART_CR1_TXEIE; return ERR_OK;}In the ISR, each TXE sends tx_buf[tx_pos++]; after the last byte it clears TXEIE, sets tx_busy = false, and calls u->tx_cb(u->tx_ctx, ERR_OK). No copy is made, so this style scales to DMA transfers of kilobytes. The price is in the comment: the buffer must outlive the transfer (a local array in the caller’s stack frame is a classic bug), and the callback runs in interrupt context, so it must be short and must not block.
The void *ctx parameter is what makes callbacks reusable. The driver does not know or care what it points to; the caller passes its own object and gets it back.
Event queue
Section titled “Event queue”The event-queue style is the callback style with a fixed, safe callback: the driver (or a thin adapter) posts an event to the owner’s queue, and the owner handles it later in thread context.
static void on_tx_done(void *ctx, err_t result){ event_queue_t *q = ctx; event_post(q, (event_t){ .type = EV_UART_TX_DONE, .err = result });}
/* in application setup */uart_write_async(console, frame, frame_len, on_tx_done, &app_queue);The application’s state machine receives EV_UART_TX_DONE alongside button presses and timeouts, in order, without ever running in an ISR. State machines and event-driven firmware builds this queue.
From ISR to application
Section titled “From ISR to application”Data received from hardware crosses three ownership boundaries on its way up. Being explicit about each one is how you avoid races:
hardware ISR context thread context --------- --------------------------------- --------------------------------- USART2 RXNE --> USART2_IRQHandler() main loop / task | | v v uart_irq(u) -- push byte --> [ rx ring ] -- uart_read() --> parser (driver, ISR half) shared, (driver, (service) SPSC thread half) | v applicationRules that fall out of this picture:
- The ISR half does the minimum: read
DR(which also clearsRXNE), push to the ring, note overruns. No parsing, no callbacks into application code. - The ring is the only shared state. It has exactly one writer (ISR) and one reader (thread), which is why it needs no lock.
- The thread half (
uart_read) is non-blocking and returns what is available. - The parser and application never see the ISR or the registers.
The vector table entry USART2_IRQHandler lives in board or driver glue, and it does nothing but call uart_irq(&instances[UART_ID_2]). That keeps the driver logic free of hard-coded instance names.
Abstracting the bus with an ops table
Section titled “Abstracting the bus with an ops table”A temperature sensor that speaks both SPI and I2C has the same registers either way. Only the transport differs. If the driver calls spi_transfer directly, you write it twice. Instead, give it a bus interface: a struct of function pointers plus a context pointer.
#ifndef BUS_H#define BUS_H
#include <stddef.h>#include <stdint.h>#include "err.h"
typedef struct { err_t (*read)(void *ctx, uint8_t reg, uint8_t *buf, size_t len); err_t (*write)(void *ctx, uint8_t reg, const uint8_t *buf, size_t len);} bus_ops_t;
typedef struct { const bus_ops_t *ops; /* shared, const, lives in flash */ void *ctx; /* per-device state: which bus, which address or CS */} bus_t;
static inline err_t bus_read(const bus_t *b, uint8_t reg, uint8_t *buf, size_t len){ return b->ops->read(b->ctx, reg, buf, len);}
static inline err_t bus_write(const bus_t *b, uint8_t reg, const uint8_t *buf, size_t len){ return b->ops->write(b->ctx, reg, buf, len);}
#endifThe interface is at the level of register reads and writes, not raw bytes, because register-addressed devices frame them differently on each bus. On I2C, a register read is “write the register address, repeated start, read N bytes”. On SPI, many devices set bit 7 of the address byte to mean read. Those details belong in the adapter, not in the sensor driver.
The ops table is const and shared by every device on that kind of bus, so it costs flash, not RAM. Each call costs one extra indirect branch (ldr the pointer, blx), a few cycles that are invisible next to a bus transaction.
The adapters
Section titled “The adapters”#include <string.h>#include "bus_i2c.h" /* declares i2c_dev_t and i2c_bus_ops */#include "i2c.h" /* your I2C peripheral driver from Part 2 */
/* typedef struct { i2c_t *i2c; uint8_t addr7; } i2c_dev_t; (in bus_i2c.h) */
static err_t i2c_dev_read(void *ctx, uint8_t reg, uint8_t *buf, size_t len){ i2c_dev_t *d = ctx; return i2c_write_read(d->i2c, d->addr7, ®, 1u, buf, len, 10u);}
static err_t i2c_dev_write(void *ctx, uint8_t reg, const uint8_t *buf, size_t len){ i2c_dev_t *d = ctx; uint8_t frame[1u + 16u]; if (len > 16u) { return ERR_INVALID_ARG; } frame[0] = reg; memcpy(&frame[1], buf, len); return i2c_write(d->i2c, d->addr7, frame, len + 1u, 10u);}
const bus_ops_t i2c_bus_ops = { .read = i2c_dev_read, .write = i2c_dev_write };#include <stdbool.h>#include "bus_spi.h" /* declares spi_dev_t and spi_bus_ops */#include "spi.h" /* spi_transfer(spi, tx, rx, len): NULL tx sends 0xFF, NULL rx discards */#include "gpio.h"
/* typedef struct { spi_t *spi; gpio_pin_t cs; uint8_t read_flag; } spi_dev_t; */
static err_t spi_dev_read(void *ctx, uint8_t reg, uint8_t *buf, size_t len){ spi_dev_t *d = ctx; const uint8_t cmd = (uint8_t)(reg | d->read_flag); gpio_write(d->cs, false); /* CS active low */ err_t e = spi_transfer(d->spi, &cmd, NULL, 1u); if (e == ERR_OK) { e = spi_transfer(d->spi, NULL, buf, len); } gpio_write(d->cs, true); /* always release CS */ return e;}
static err_t spi_dev_write(void *ctx, uint8_t reg, const uint8_t *buf, size_t len){ spi_dev_t *d = ctx; gpio_write(d->cs, false); err_t e = spi_transfer(d->spi, ®, NULL, 1u); if (e == ERR_OK) { e = spi_transfer(d->spi, buf, NULL, len); } gpio_write(d->cs, true); return e;}
const bus_ops_t spi_bus_ops = { .read = spi_dev_read, .write = spi_dev_write };Note that the SPI read releases chip select even when the first transfer fails. A driver that returns early with CS still asserted leaves the device mid-transaction, and every later access to that bus is garbage.
Composition over inheritance
Section titled “Composition over inheritance”The sensor driver does not “derive from” an SPI device. It contains a bus_t. Whoever builds the system decides what bus that is:
static i2c_dev_t th_i2c = { .i2c = &i2c1, .addr7 = 0x48u };static const bus_t th_bus = { .ops = &i2c_bus_ops, .ctx = &th_i2c };
th_sensor_t sensor;err_t e = th_sensor_init(&sensor, &th_bus);Change two lines to spi_bus_ops and an spi_dev_t, and the sensor driver is untouched. Swap in a fake bus and the sensor driver runs on your laptop, which is the subject of the testing section below. Building objects out of small interchangeable parts, instead of deep type hierarchies, is the dominant style in C firmware. Design patterns for embedded C covers the full object-in-C pattern with embedded base structs and container_of.
The board support package
Section titled “The board support package”The BSP answers board questions so drivers do not have to:
#ifndef BSP_H#define BSP_H
#include "gpio.h"#include "uart.h"
#define BSP_HSE_HZ 8000000u /* crystal on the Discovery board */#define BSP_SYSCLK_HZ 168000000u
#define BSP_LED_GREEN GPIO_PIN(GPIO_PORT_D, 12)#define BSP_LED_ORANGE GPIO_PIN(GPIO_PORT_D, 13)#define BSP_BUTTON_USER GPIO_PIN(GPIO_PORT_A, 0)
#define BSP_CONSOLE_UART UART_ID_2 /* PA2 TX / PA3 RX, AF7 */#define BSP_CONSOLE_BAUD 115200u
void bsp_init(void); /* clocks, pins, power rails */uart_t *bsp_console(void); /* opened by bsp_init */
#endifEverything here would change on a different board, and nothing here would change if you rewrote the application. When revision B of the board moves the LED, you edit one line. Larger projects keep one directory per board (bsp/disco_f407/, bsp/custom_rev_b/) and select it in the build system.
Vendor HALs: use, wrap, or replace
Section titled “Vendor HALs: use, wrap, or replace”Every MCU vendor ships a HAL: ST has the STM32Cube HAL and the lighter LL (low-layer) drivers, Nordic has nrfx, and Zephyr provides a portable driver model on top of device tree. You will use them professionally. You should know what they cost.
What they are good at:
- Correct handling of peripheral quirks and errata you have not discovered yet.
- Coverage: USB, Ethernet MAC, SDIO and other peripherals that take weeks to write from the reference manual.
- Bring-up speed: a code generator (CubeMX) produces working clock and pin setup in minutes.
What they cost:
- Code size. A full-featured HAL UART init with all its options and callbacks can run to several kilobytes. The LL layer is close to raw register writes.
- Hidden blocking. Many HAL calls spin on flags with timeouts measured by
HAL_GetTick(). - The
HAL_Delayin an interrupt trap.HAL_Delaywaits for a tick counter incremented inSysTick_Handler. Call it from an ISR whose priority is equal to or higher than SysTick’s and the counter never advances: the system hangs forever inside the delay. - Their architecture leaks into yours. HAL handles (
UART_HandleTypeDef) and weak callback functions likeHAL_UART_TxCpltCallbackspread through application code unless you stop them.
The answer is not “never use the HAL”. It is “never let the HAL above your HAL line”. Implement your own interface with the vendor code underneath:
#include "stm32f4xx_hal.h"#include "uart.h"
static UART_HandleTypeDef huart2; /* configured by HAL_UART_Init in uart_open */
static err_t from_hal(HAL_StatusTypeDef st){ switch (st) { case HAL_OK: return ERR_OK; case HAL_TIMEOUT: return ERR_TIMEOUT; case HAL_BUSY: return ERR_BUSY; default: return ERR_IO; }}
err_t uart_write(uart_t *u, const uint8_t *data, size_t len, uint32_t timeout_ms){ (void)u; /* single instance in this port */ while (len > 0u) { const uint16_t chunk = (len > 0xFFFFu) ? 0xFFFFu : (uint16_t)len; const err_t e = from_hal(HAL_UART_Transmit(&huart2, (uint8_t *)data, chunk, timeout_ms)); if (e != ERR_OK) { return e; } data += chunk; len -= chunk; } return ERR_OK;}The adapter handles two mismatches you would otherwise discover in production. HAL_UART_Transmit takes a uint16_t size, so a size_t length above 65535 must be split. Older HAL versions declare the buffer as uint8_t * rather than const uint8_t *, hence the cast. Application code never sees either problem, and if you later replace the HAL with register code, only this file changes.
Portability and the port layer
Section titled “Portability and the port layer”When firmware moves from an STM32F4 to an nRF52 or an RP2040, these things change:
- register layouts and peripheral feature sets
- the clock tree and how peripheral clocks are enabled
- interrupt numbers, priorities and the number of priority bits
- pin multiplexing (alternate functions vs a crossbar vs fixed pins)
- DMA request mapping and capabilities
- errata and their workarounds
- the vendor’s startup code, linker script and SDK
These do not change: the sensor driver, the protocol parser, the state machines, the storage format, the application.
The port layer is the thin set of files that implement your HAL interfaces for one MCU family: port/stm32f4/uart.c, port/nrf52/uart.c, all implementing the same uart.h. The build selects one directory. A good test of your architecture: count the files that include a vendor header. If the answer is “most of them”, porting is a rewrite. If it is “the port directory and the BSP”, porting is a few weeks of work.
Testing a driver on the host
Section titled “Testing a driver on the host”Because the sensor driver talks only to a bus_t, you can hand it a fake bus backed by an array and test it with the host gcc. Here is a complete, runnable example for a hypothetical temperature sensor with this register map:
| Register | Address | Meaning |
|---|---|---|
ID | 0x00 | reads 0x5A |
CTRL | 0x01 | bit 0 EN: start measuring |
STATUS | 0x02 | bit 0 DRDY: new sample available |
TEMP_H, TEMP_L | 0x03, 0x04 | signed 16-bit big-endian, 1 LSB = 1/256 °C |
#ifndef TH_SENSOR_H#define TH_SENSOR_H
#include <stdbool.h>#include <stdint.h>#include "bus.h"#include "err.h"
#define TH_SENSOR_API_VERSION_MAJOR 1#define TH_SENSOR_API_VERSION_MINOR 0
/* Caller-allocated handle. Treat the fields as private. */typedef struct { bus_t bus; bool enabled;} th_sensor_t;
/** * @brief Probe the sensor and start continuous measurement. * @param s handle to initialize (caller-owned storage) * @param bus bus the sensor is attached to; copied into the handle * @return ERR_OK, ERR_NO_DEVICE if the ID does not match, or a bus error. * @note Thread context only. Performs two bus transactions; does not wait. */err_t th_sensor_init(th_sensor_t *s, const bus_t *bus);
/** * @brief Read the latest temperature in hundredths of a degree Celsius. * @return ERR_OK, ERR_BUSY if no new sample is ready yet, ERR_STATE if not * initialized, or a bus error. Never blocks waiting for a sample. */err_t th_sensor_read_centi_c(th_sensor_t *s, int32_t *out);
#endif#include <stddef.h>#include "th_sensor.h"
#define REG_ID 0x00u#define REG_CTRL 0x01u#define REG_STATUS 0x02u#define REG_TEMP_H 0x03u#define ID_VALUE 0x5Au#define CTRL_EN 0x01u#define STATUS_DRDY 0x01u
err_t th_sensor_init(th_sensor_t *s, const bus_t *bus){ if (s == NULL || bus == NULL || bus->ops == NULL) { return ERR_INVALID_ARG; } s->bus = *bus; s->enabled = false;
uint8_t id = 0; err_t e = bus_read(&s->bus, REG_ID, &id, 1u); if (e != ERR_OK) { return e; } if (id != ID_VALUE) { return ERR_NO_DEVICE; }
const uint8_t ctrl = CTRL_EN; e = bus_write(&s->bus, REG_CTRL, &ctrl, 1u); if (e != ERR_OK) { return e; } s->enabled = true; return ERR_OK;}
err_t th_sensor_read_centi_c(th_sensor_t *s, int32_t *out){ if (s == NULL || out == NULL) { return ERR_INVALID_ARG; } if (!s->enabled) { return ERR_STATE; } uint8_t status = 0; err_t e = bus_read(&s->bus, REG_STATUS, &status, 1u); if (e != ERR_OK) { return e; } if ((status & STATUS_DRDY) == 0u) { return ERR_BUSY; } uint8_t raw[2]; e = bus_read(&s->bus, REG_TEMP_H, raw, sizeof raw); /* TEMP_H then TEMP_L */ if (e != ERR_OK) { return e; } const int16_t t = (int16_t)(uint16_t)((raw[0] << 8) | raw[1]); *out = ((int32_t)t * 100) / 256; return ERR_OK;}The driver reads both temperature bytes in one burst so the high and low byte come from the same sample; two separate single-byte reads could straddle a sensor update. The (int16_t)(uint16_t) conversion turns the raw bits into a signed value. Converting an out-of-range value to a signed type is implementation-defined in C, and GCC, Clang and every Arm compiler define it as two’s complement wraparound, which is what you want here. When the sensor has no new sample, the function returns ERR_BUSY instead of polling DRDY in a loop: no hidden blocking.
Now the fake. It is a 256-byte register file with a switch to inject bus errors:
#ifndef FAKE_BUS_H#define FAKE_BUS_H
#include <stdint.h>#include "bus.h"
typedef struct { uint8_t regs[256]; err_t fail_with; /* if not ERR_OK, every call fails with this */ uint32_t reads; uint32_t writes;} fake_bus_t;
extern const bus_ops_t fake_bus_ops;
#endif#include "fake_bus.h"
static err_t fake_read(void *ctx, uint8_t reg, uint8_t *buf, size_t len){ fake_bus_t *f = ctx; f->reads++; if (f->fail_with != ERR_OK) { return f->fail_with; } for (size_t i = 0; i < len; i++) { buf[i] = f->regs[(uint8_t)(reg + i)]; } return ERR_OK;}
static err_t fake_write(void *ctx, uint8_t reg, const uint8_t *buf, size_t len){ fake_bus_t *f = ctx; f->writes++; if (f->fail_with != ERR_OK) { return f->fail_with; } for (size_t i = 0; i < len; i++) { f->regs[(uint8_t)(reg + i)] = buf[i]; } return ERR_OK;}
const bus_ops_t fake_bus_ops = { .read = fake_read, .write = fake_write };And the tests. A small CHECK macro is used instead of assert because assert compiles to nothing under NDEBUG, and with it would vanish the driver calls inside the condition.
#include <stdio.h>#include <string.h>#include "fake_bus.h"#include "th_sensor.h"
static int failures;#define CHECK(cond) \ do { \ if (!(cond)) { \ printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ failures++; \ } \ } while (0)
static fake_bus_t fake;static const bus_t bus = { .ops = &fake_bus_ops, .ctx = &fake };
static void reset_fake(void){ memset(&fake, 0, sizeof fake); fake.fail_with = ERR_OK; fake.regs[0x00] = 0x5A; /* correct ID */}
static void test_init_enables_measurement(void){ th_sensor_t s; reset_fake(); CHECK(th_sensor_init(&s, &bus) == ERR_OK); CHECK(fake.regs[0x01] == 0x01);}
static void test_init_rejects_wrong_id(void){ th_sensor_t s; reset_fake(); fake.regs[0x00] = 0x00; CHECK(th_sensor_init(&s, &bus) == ERR_NO_DEVICE); CHECK(fake.writes == 0u); /* never touched CTRL on a wrong chip */}
static void test_read_converts_both_signs(void){ th_sensor_t s; int32_t t = 0; reset_fake(); CHECK(th_sensor_init(&s, &bus) == ERR_OK); fake.regs[0x02] = 0x01; /* DRDY */ fake.regs[0x03] = 0x19; fake.regs[0x04] = 0x00; /* 6400 / 256 = 25.00 C */ CHECK(th_sensor_read_centi_c(&s, &t) == ERR_OK); CHECK(t == 2500); fake.regs[0x03] = 0xFF; fake.regs[0x04] = 0x80; /* -128 / 256 = -0.50 C */ CHECK(th_sensor_read_centi_c(&s, &t) == ERR_OK); CHECK(t == -50);}
static void test_read_is_busy_without_drdy(void){ th_sensor_t s; int32_t t = 12345; reset_fake(); CHECK(th_sensor_init(&s, &bus) == ERR_OK); CHECK(th_sensor_read_centi_c(&s, &t) == ERR_BUSY); CHECK(t == 12345); /* output untouched on failure */}
static void test_bus_error_propagates(void){ th_sensor_t s; int32_t t = 0; reset_fake(); CHECK(th_sensor_init(&s, &bus) == ERR_OK); fake.fail_with = ERR_IO; CHECK(th_sensor_read_centi_c(&s, &t) == ERR_IO);}
int main(void){ test_init_enables_measurement(); test_init_rejects_wrong_id(); test_read_converts_both_signs(); test_read_is_busy_without_drdy(); test_bus_error_propagates(); if (failures == 0) { puts("all th_sensor tests passed"); } return failures == 0 ? 0 : 1;}gcc -std=c11 -Wall -Wextra -Wpedantic -I. th_sensor.c fake_bus.c test_th_sensor.c -o test_th_sensor./test_th_sensorall th_sensor tests passedThese five tests run in milliseconds and check things that are painful to test on hardware: a wrong chip ID, a negative temperature, a bus failure halfway through. The test for ERR_NO_DEVICE also verifies a safety property, that the driver never writes to a device it has not identified. Testing embedded software extends this to mocks that verify exact call sequences.
Documenting and versioning an API
Section titled “Documenting and versioning an API”The doc comments in th_sensor.h answer the questions a caller cannot get from the signature:
- Context: may this be called from an ISR? From multiple tasks at once?
- Blocking: does it wait, and for how long at most?
- Ownership: who owns the buffer, and how long must it stay valid?
- Errors: which codes can come back, and what does each mean here?
- Units: hundredths of a degree, milliseconds, bytes.
Doxygen-style tags (@brief, @param, @return, @note) are the common convention, and tooling can turn them into reference pages.
Version the API with a major and minor number in the header, following semantic versioning: bump the minor for backwards-compatible additions (a new function, a new config field whose zero value preserves old behavior), and the major for anything that breaks a caller (changed signature, changed units, removed function). Callers that depend on a feature can check at compile time:
#include "th_sensor.h"
_Static_assert(TH_SENSOR_API_VERSION_MAJOR == 1, "th_sensor API changed: review callers");This matters most when drivers are shared between products or teams, which in a company is the normal case.
Common mistakes
Section titled “Common mistakes”- Waiting on a flag with no timeout. One missing clock enable hangs the whole system; give every wait loop a deadline and return
ERR_TIMEOUT. - Application code including register headers. It makes the app untestable and unportable; route every hardware access through a driver interface.
printfinside drivers. It assumes a console and thread context; return an error code and let the caller log it.- Callback runs in ISR context and nobody said so. The caller blocks or prints inside it; document the context in the header and keep ISR-context callbacks trivial.
- Async buffer on the caller’s stack. The function returns, the frame is reused, and the DMA sends garbage; require buffers that outlive the transfer and say so in the doc comment.
- Early return with chip select asserted. Every later transaction on that bus fails; release CS on all paths, success or error.
- Vendor HAL types in application headers. Replacing the HAL becomes a rewrite; wrap the HAL behind your own interface in one port file.
Exercises
Section titled “Exercises”- Build and run the
th_sensorhost test exactly as shown. Then change/ 256to>> 8in the driver and run again: every test still passes, because -12800 is an exact multiple of 256. Add a test for raw0xFFFF(-1/256 °C). Division truncates toward zero and gives 0; the arithmetic shift rounds toward minus infinity and gives -1. Decide which you want and make the test say so. - Add
th_sensor_deinit()that writes 0 toCTRLand marks the handle disabled. Write two host tests: deinit clearsCTRL, and reading after deinit returnsERR_STATE. - Write
bus_spi.cagainst your SPI driver from SPI and I2C and connect the LIS3DSH on the Discovery board through it. ReadWHO_AM_I(0x0F, expect0x3F) throughbus_readinstead of raw SPI calls. The samespi_dev_twithread_flag = 0x80should work unmodified. - Compile
th_sensor.cfor the target (arm-none-eabi-gccwith the course flags and-c), then runarm-none-eabi-objdump -d th_sensor.o. Find the indirect calls through the ops table (blxto a register) and count how many instructions eachbus_readcosts beyond a direct call. - Wrap the ST HAL’s
HAL_UART_Transmit_ITbehind theuart_write_asyncinterface from this lesson, including the completion callback. Decide what to do withHAL_UART_TxCpltCallbackso that no HAL type appears in any header outside the port directory.
Check yourself
Section titled “Check yourself”Question 1:A UART driver's ISR calls app_on_line_received(), a function in the application. Which rule does this break, and why does it matter?
Lower layers must not call up. Information flows up through return values, registered callbacks, or queued events that the upper layer set up.
Question 2:A sensor driver's read function loops on the DRDY bit until it sets, with no timeout. On the bench the sensor is unplugged. What happens?
An unbounded wait on hardware turns any hardware fault into a hang. Every wait needs a deadline and an error code.
Question 3:You call uart_write_async(u, buf, 64, cb, NULL) where buf is a local array, then return from the function. What is the bug?
The callback-style API keeps a pointer to the caller's buffer to avoid copying. The buffer must stay valid until the completion callback runs.
Question 4:Why is the bus_ops_t interface defined in terms of register reads and writes rather than raw byte transfers?
The same device register map is reached by different byte sequences on each bus. Putting that difference in the adapter keeps the sensor driver bus-agnostic.
Question 5:An ISR at NVIC priority 2 calls HAL_Delay(5). SysTick is at priority 15. What happens?
HAL_Delay waits for a counter incremented in SysTick_Handler. An ISR of equal or higher priority blocks SysTick, so the counter is frozen.
Key takeaways
Section titled “Key takeaways”- Calls go down, information comes up: application, services, device drivers, peripheral HAL, registers, with the BSP wiring concrete instances at the edge.
- A driver API is a contract: explicit init and deinit, config structs with defaults, handles, one error enum, a timeout on every wait, documented blocking and context.
- Choose blocking, non-blocking, callback or event-queue deliberately; the same operation suits different callers in different styles.
- A
constops table plus avoid *ctxlets one device driver run over any bus, and lets a fake bus test it on the host. - Wrap vendor HALs behind your own interfaces so their types, blocking behavior and quirks stay inside one port file.
- Portability is measured by how few files include vendor headers.
Next: State machines and event-driven firmware, where the events your drivers produce become the input to explicit, testable control logic.