Skip to content

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.

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 above

The 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->BSRR directly 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.h is 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.

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.

err.h
#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
uart.h
#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);
#endif

Each design choice in that header is deliberate.

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.

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.

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:

uart.c (excerpt)
#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.

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.

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.

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.

StyleCall returnsCaller learns completion byGood forBad for
Blockingwhen done or timed outreturn valueinit code, simple tools, RTOS tasks that can sleepsuperloops, anything with deadlines
Non-blockingimmediately, with partial progresscalling again / pollingsuperloops, cooperative schedulersvery large transfers
Callbackimmediately, after startinga function called on completion, often from ISR contextDMA, high throughput, event-driven codecode that cannot tolerate ISR-context callbacks
Event queueimmediately, after startingan event posted to the owner’s queuestate machines, active objects, RTOS taskstiny 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.

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).

/* 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.

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.

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.

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
application

Rules that fall out of this picture:

  • The ISR half does the minimum: read DR (which also clears RXNE), 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.

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.

bus.h
#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);
}
#endif

The 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.

bus_i2c.c
#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, &reg, 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 };
bus_spi.c
#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, &reg, 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.

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 BSP answers board questions so drivers do not have to:

bsp.h
#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 */
#endif

Everything 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.

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_Delay in an interrupt trap. HAL_Delay waits for a tick counter incremented in SysTick_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 like HAL_UART_TxCpltCallback spread 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:

uart_sthal.c
#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.

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.

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:

RegisterAddressMeaning
ID0x00reads 0x5A
CTRL0x01bit 0 EN: start measuring
STATUS0x02bit 0 DRDY: new sample available
TEMP_H, TEMP_L0x03, 0x04signed 16-bit big-endian, 1 LSB = 1/256 °C
th_sensor.h
#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
th_sensor.c
#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:

fake_bus.h
#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
fake_bus.c
#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.

test_th_sensor.c
#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;
}
Terminal window
gcc -std=c11 -Wall -Wextra -Wpedantic -I. th_sensor.c fake_bus.c test_th_sensor.c -o test_th_sensor
./test_th_sensor
all th_sensor tests passed

These 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.

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.

  • 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.
  • printf inside 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.
  1. Build and run the th_sensor host test exactly as shown. Then change / 256 to >> 8 in the driver and run again: every test still passes, because -12800 is an exact multiple of 256. Add a test for raw 0xFFFF (-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.
  2. Add th_sensor_deinit() that writes 0 to CTRL and marks the handle disabled. Write two host tests: deinit clears CTRL, and reading after deinit returns ERR_STATE.
  3. Write bus_spi.c against your SPI driver from SPI and I2C and connect the LIS3DSH on the Discovery board through it. Read WHO_AM_I (0x0F, expect 0x3F) through bus_read instead of raw SPI calls. The same spi_dev_t with read_flag = 0x80 should work unmodified.
  4. Compile th_sensor.c for the target (arm-none-eabi-gcc with the course flags and -c), then run arm-none-eabi-objdump -d th_sensor.o. Find the indirect calls through the ops table (blx to a register) and count how many instructions each bus_read costs beyond a direct call.
  5. Wrap the ST HAL’s HAL_UART_Transmit_IT behind the uart_write_async interface from this lesson, including the completion callback. Decide what to do with HAL_UART_TxCpltCallback so that no HAL type appears in any header outside the port directory.

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?

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?

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?

Question 4:Why is the bus_ops_t interface defined in terms of register reads and writes rather than raw byte transfers?

Question 5:An ISR at NVIC priority 2 calls HAL_Delay(5). SysTick is at priority 15. What happens?

  • 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 const ops table plus a void *ctx lets 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.