Part 4 · Professional Firmware28 min read
Testing Embedded Software
Firmware that “works on my bench” is firmware that has been tested once, by hand, under one set of conditions. Professional teams ship code that has been exercised thousands of times by machines: on the host in milliseconds, in a simulator in seconds, and on real boards overnight. This lesson shows you how to build that machinery for an STM32F407 project, how to structure code so it can be tested at all, and how to decide what each layer of testing is for.
The firmware test pyramid
Section titled “The firmware test pyramid”Testing firmware is harder than testing a web service because the code under test talks to hardware you cannot easily control. The answer is not one kind of test but four, arranged by speed and cost.
/\ / \ System / field tests /----\ (real product, real users, days) / \ / HIL \ Hardware-in-the-loop /----------\ (real board + test rig, minutes) / Simulation \ Renode / QEMU running the real ELF /--------------\ (seconds per test) / Host unit \ gcc on your PC, thousands of tests / tests \ (milliseconds per test) /--------------------\| Layer | Runs on | Catches | Misses |
|---|---|---|---|
| Host unit tests | Your PC, compiled with gcc | Logic errors, parser bugs, edge cases, regressions | Register setup, timing, real peripheral behaviour |
| Simulation | Renode or QEMU, the real ARM binary | Startup, linker, integration, UART protocol | Analog behaviour, exact timing, silicon errata |
| Hardware-in-the-loop | A real board on a test rig | Peripheral configuration, timing, electrical issues | Field conditions (temperature, EMI, user behaviour) |
| System and field | Finished product | Everything else | Nothing, but it is slow and expensive |
The shape matters. Most of your tests should be at the bottom because they are cheap, deterministic, and give you an answer before you have finished your coffee. A team with 30 HIL tests and no unit tests will find every bug late and debug it with a probe. A team with 2,000 unit tests and 30 HIL tests finds most bugs in seconds and uses HIL only to confirm the hardware glue.
Designing for testability
Section titled “Designing for testability”You cannot unit test code that writes GPIOD->ODR directly in the middle of its decision logic. On the host there is no 0x40020C14: dereferencing that address segfaults or, worse, scribbles over some random part of your test process. Testability is a design property you build in, not something you add later.
Four rules get you most of the way:
- Hardware sits behind interfaces. Only the HAL and driver layers touch registers. Everything above them calls functions like
gpio_write()orbus_read_reg(). - No direct register access above the HAL. If application code needs a register, the abstraction is missing a function.
- Inject dependencies. A module receives the things it depends on (a bus, a clock, a flash device) through a function pointer table, a handle, or link-time substitution, instead of reaching for a global.
- Keep logic pure where you can. A function that takes a raw ADC count and returns millivolts, with no side effects, is trivially testable. Pull calculations out of drivers into such functions.
There are three common ways to swap a real dependency for a test double:
| Technique | How | Cost |
|---|---|---|
| Link-time substitution | Test build links fake_bus.c instead of i2c_bus.c; both define bus_read_reg() | Zero runtime cost, one implementation per test executable |
| Function pointer interface | Module holds a const struct bus_ops * | One indirect call, swap per test |
| Preprocessor | #ifdef UNIT_TEST around register access | Avoid: the code you test is not the code you ship |
Link-time substitution is the default for embedded C because it costs nothing on target. The function pointer approach is better when one firmware image genuinely needs several implementations, such as a sensor on SPI in one product and I2C in another. Design Patterns for Embedded C covers that interface style in depth.
The worked example: a sensor driver
Section titled “The worked example: a sensor driver”Throughout this lesson we test a small driver for a generic I2C temperature sensor at address 0x48. Register 0x00 holds a 16-bit big-endian two’s complement reading in units of 1/128 °C. The driver depends on one bus function:
#ifndef BUS_H#define BUS_H
#include <stddef.h>#include <stdint.h>
/* Read len bytes starting at register reg of the 7-bit device addr. * Returns 0 on success, negative on bus error. */int bus_read_reg(uint8_t addr, uint8_t reg, uint8_t *buf, size_t len);
#endif#include "sensor.h"#include "bus.h"
#define SENSOR_ADDR 0x48u#define SENSOR_REG_TEMP 0x00u
sensor_err_t sensor_read_centi_c(int32_t *out){ uint8_t raw[2];
if (bus_read_reg(SENSOR_ADDR, SENSOR_REG_TEMP, raw, sizeof raw) != 0) { return SENSOR_ERR_BUS; }
int32_t counts = (int32_t)(((uint32_t)raw[0] << 8) | raw[1]); if (counts >= 0x8000) { counts -= 0x10000; /* sign-extend 16-bit value */ }
int32_t centi = (counts * 100) / 128; if (centi < -4000 || centi > 12500) { return SENSOR_ERR_RANGE; /* outside -40..125 C */ }
*out = centi; return SENSOR_OK;}#ifndef SENSOR_H#define SENSOR_H
#include <stdint.h>
typedef enum { SENSOR_OK = 0, SENSOR_ERR_BUS, SENSOR_ERR_RANGE} sensor_err_t;
sensor_err_t sensor_read_centi_c(int32_t *out);
#endifNote what the driver does not contain: no register addresses, no I2C event sequence, no printf. It is pure logic over one dependency, which makes it testable on any machine. The sign extension is done arithmetically rather than by casting a uint16_t to int16_t, because that cast of a value above 32767 is implementation-defined.
Unit tests with Unity
Section titled “Unit tests with Unity”Unity is a small C unit test framework (one .c file and two headers) built for embedded work. A test is a void function with no arguments; the runner calls each one between setUp() and tearDown().
The structure of a good test is arrange, act, assert: set up inputs and doubles, call one function, check the result. Name the test after the behaviour, not the function: test_sensor_rejects_out_of_range_reading tells you what broke when it fails; test_sensor_3 does not.
Mocks generated by CMock
Section titled “Mocks generated by CMock”CMock reads a header such as bus.h and generates mock_bus.c and mock_bus.h, which implement bus_read_reg() as a recording, checking double. For each function it creates helpers such as bus_read_reg_ExpectAndReturn(). Your test declares which calls it expects, with which arguments, in which order, and what each should return. If the code under test makes a call you did not expect, or passes a different argument, the test fails with a message naming the call.
#include "unity.h"#include "sensor.h"#include "mock_bus.h"
void setUp(void) {}void tearDown(void) {}
void test_sensor_converts_positive_reading(void){ uint8_t raw[2] = { 0x0C, 0x80 }; /* 3200 counts = 25.00 C */ int32_t centi = 0;
bus_read_reg_ExpectAndReturn(0x48, 0x00, NULL, 2, 0); bus_read_reg_IgnoreArg_buf(); bus_read_reg_ReturnArrayThruPtr_buf(raw, 2);
TEST_ASSERT_EQUAL(SENSOR_OK, sensor_read_centi_c(¢i)); TEST_ASSERT_EQUAL_INT32(2500, centi);}
void test_sensor_sign_extends_negative_reading(void){ uint8_t raw[2] = { 0xFB, 0x00 }; /* -1280 counts = -10.00 C */ int32_t centi = 0;
bus_read_reg_ExpectAndReturn(0x48, 0x00, NULL, 2, 0); bus_read_reg_IgnoreArg_buf(); bus_read_reg_ReturnArrayThruPtr_buf(raw, 2);
TEST_ASSERT_EQUAL(SENSOR_OK, sensor_read_centi_c(¢i)); TEST_ASSERT_EQUAL_INT32(-1000, centi);}
void test_sensor_reports_bus_error_and_leaves_output(void){ int32_t centi = 12345;
bus_read_reg_ExpectAndReturn(0x48, 0x00, NULL, 2, -1); bus_read_reg_IgnoreArg_buf();
TEST_ASSERT_EQUAL(SENSOR_ERR_BUS, sensor_read_centi_c(¢i)); TEST_ASSERT_EQUAL_INT32(12345, centi);}The three lines before each act step are the interesting part. ExpectAndReturn records that bus_read_reg(0x48, 0x00, ..., 2) must be called once and should return the last argument. IgnoreArg_buf tells CMock not to compare the buffer pointer, since the driver passes a stack address the test cannot know. ReturnArrayThruPtr_buf copies raw into whatever buffer the driver passed, which is how you feed data through an output parameter. The last two helpers come from CMock plugins that you enable in its YAML configuration:
:cmock: :mock_prefix: mock_ :plugins: - :ignore_arg - :return_thru_ptr :treat_externs: :includeThe third test checks a property that is easy to break during refactoring: on error the driver must not touch the caller’s output. Tests like this document the contract better than a comment does.
Running it
Section titled “Running it”CMock-generated mocks need a test runner that initializes and verifies them (calls such as mock_bus_Init() and mock_bus_Verify() around each test). Unity ships a Ruby script, generate_test_runner.rb, that scans your test file for test_ functions and writes that runner. Most teams use Ceedling, a build tool that wraps Unity and CMock: it finds #include "mock_bus.h", generates the mock, generates the runner, compiles, links, and runs.
ceedling test:allTest 'test_sensor.c'--------------------Generating runner for test_sensor.c...Compiling test_sensor_runner.c...Compiling mock_bus.c...Linking test_sensor.out...Running test_sensor.out...
--------------------OVERALL TEST SUMMARY--------------------TESTED: 3PASSED: 3FAILED: 0IGNORED: 0If you break the sign extension (delete the counts -= 0x10000 line), the second test fails with Expected 0 Was 2: the unextended reading is 502.00 °C, so the driver returns SENSOR_ERR_RANGE instead of SENSOR_OK, which points you straight at the conversion.
GoogleTest for C code
Section titled “GoogleTest for C code”Some teams already use GoogleTest for host tools and want one framework everywhere. GoogleTest is C++, but it tests C code fine as long as the C headers are wrapped in extern "C" so the linker finds unmangled names. The tests themselves are C++ files.
extern "C" {#include "crc16.h"}#include <gtest/gtest.h>
TEST(Crc16Ccitt, MatchesStandardCheckValue){ const uint8_t msg[] = { '1','2','3','4','5','6','7','8','9' }; EXPECT_EQ(0x29B1u, crc16_ccitt(msg, sizeof msg));}The value 0x29B1 is the published check value for CRC-16/CCITT-FALSE over the ASCII string 123456789. Testing against a published check value is how you know your CRC matches every other implementation, not just your own expectations.
Choose Unity when the same tests might one day run on the target, because Unity compiles with any C compiler and needs almost no RAM. Choose GoogleTest when tests are host-only and you want its richer matchers and fixtures.
Fakes, mocks, and stubs
Section titled “Fakes, mocks, and stubs”These three words get used interchangeably. They mean different things, and picking the right one keeps tests readable.
| Double | What it does | Use when |
|---|---|---|
| Stub | Returns canned values, checks nothing | The dependency is incidental to the test |
| Mock | Checks that specific calls happen with specific arguments in order | The interaction is the behaviour (the driver must write register X then Y) |
| Fake | A working, simplified implementation | The dependency has state that matters (flash, a clock, a file system) |
Mocks are powerful but brittle: a test that expects exactly three bus calls breaks when you legitimately change the driver to use two. Use mocks for protocol sequences that must not change, and fakes for everything stateful.
A fake flash device
Section titled “A fake flash device”A fake NOR flash is a byte array plus the physical rules: erase sets bytes to 0xFF, and programming can only clear bits. A fake that enforces those rules catches the classic bug of writing to flash without erasing first, a bug that a plain memcpy fake hides.
#include <stdbool.h>#include <stdint.h>#include <string.h>#include "flash_dev.h"
#define FAKE_SECTOR_SIZE 4096u#define FAKE_SECTORS 16u
static uint8_t mem[FAKE_SECTOR_SIZE * FAKE_SECTORS];static bool program_violation;
void fake_flash_reset(void){ memset(mem, 0xFF, sizeof mem); program_violation = false;}
bool fake_flash_had_violation(void) { return program_violation; }
int flash_dev_erase(uint32_t sector){ if (sector >= FAKE_SECTORS) { return -1; } memset(&mem[sector * FAKE_SECTOR_SIZE], 0xFF, FAKE_SECTOR_SIZE); return 0;}
int flash_dev_program(uint32_t addr, const uint8_t *data, size_t len){ if (addr > sizeof mem || len > sizeof mem - addr) { return -1; } for (size_t i = 0; i < len; i++) { if ((data[i] & ~mem[addr + i]) != 0) { program_violation = true; /* tried to turn a 0 into a 1 */ } mem[addr + i] &= data[i]; /* NOR: program can only clear */ } return 0;}
int flash_dev_read(uint32_t addr, uint8_t *data, size_t len){ if (addr > sizeof mem || len > sizeof mem - addr) { return -1; } memcpy(data, &mem[addr], len); return 0;}The line mem[addr + i] &= data[i] is the whole physics of NOR flash. The check before it records any attempt to set a bit that is already 0. Your config-store tests then end with TEST_ASSERT_FALSE(fake_flash_had_violation()). The bounds checks are written as len > sizeof mem - addr rather than addr + len > sizeof mem so a huge len cannot wrap the sum around.
A fake clock
Section titled “A fake clock”Time-dependent code (timeouts, debouncing, retry back-off) is the hardest logic to test on hardware because you cannot make the real clock jump. On the host, you replace it.
#include <stdint.h>#include "clock.h" /* declares uint32_t clock_now_ms(void); */#include "fake_clock.h"
static uint32_t now_ms;
uint32_t clock_now_ms(void) { return now_ms; }void fake_clock_set(uint32_t t) { now_ms = t; }void fake_clock_advance(uint32_t d) { now_ms += d; }#include "unity.h"#include "debounce.h"#include "fake_clock.h"
void setUp(void) { fake_clock_set(0xFFFFFFF0u); debounce_init(); }void tearDown(void) {}
void test_press_reported_after_stable_20ms_across_wraparound(void){ debounce_sample(true); fake_clock_advance(19); TEST_ASSERT_FALSE(debounce_sample(true)); fake_clock_advance(1); TEST_ASSERT_TRUE(debounce_sample(true));}Starting the clock 16 ms before the 32-bit wrap is deliberate: the test’s 20 ms cross it. A debouncer that compares now > start + 20 instead of (int32_t)(now - start) >= 20 passes on the bench for 49 days and then fails in the field. With a fake clock, that bug shows up in the first test run.
Testing ISR-driven code
Section titled “Testing ISR-driven code”An interrupt handler is just a C function with no arguments. On the host you can call it directly. The trick is making the registers it reads point at something you control. Structure the driver so the IRQ handler is a thin wrapper around a function that takes a register block pointer:
#include "uart_rx.h"
#define USART_SR_RXNE (1u << 5)#define USART_SR_ORE (1u << 3)
uint32_t uart_rx_dropped; /* extern in uart_rx.h */
void uart_rx_isr(usart_regs_t *u, ringbuf_t *rb){ uint32_t sr = u->SR; if (sr & (USART_SR_RXNE | USART_SR_ORE)) { uint8_t byte = (uint8_t)u->DR; /* reading DR clears RXNE and ORE */ if (!ringbuf_put(rb, byte)) { uart_rx_dropped++; } }}
/* Target-only file wires the real hardware in: *//* void USART2_IRQHandler(void) { uart_rx_isr(USART2, &console_rx); } */usart_regs_t is the struct-overlay type from your register header with volatile uint32_t members. On the target USART2 points at 0x40004400; in the test it points at an ordinary struct:
#include "unity.h"#include "uart_rx.h"
static usart_regs_t fake_usart;static ringbuf_t rb;
void setUp(void) { ringbuf_init(&rb); uart_rx_dropped = 0; }void tearDown(void) {}
void test_isr_pushes_received_byte(void){ uint8_t out; fake_usart.SR = (1u << 5); fake_usart.DR = 'A';
uart_rx_isr(&fake_usart, &rb);
TEST_ASSERT_TRUE(ringbuf_get(&rb, &out)); TEST_ASSERT_EQUAL_HEX8('A', out);}
void test_isr_counts_drop_when_buffer_full(void){ fake_usart.SR = (1u << 5); fake_usart.DR = 'x'; for (uint32_t i = 0; i < RINGBUF_CAPACITY + 1u; i++) { uart_rx_isr(&fake_usart, &rb); } TEST_ASSERT_EQUAL_UINT32(1, uart_rx_dropped);}The fake does not model “reading DR clears RXNE”, so the test sets SR explicitly before each call. That is fine for logic tests. What you cannot test this way is true concurrency: the ISR preempting main halfway through a ringbuf_get(). Host unit tests check logic; races are found by design review, by the techniques in Concurrency, Atomics, and Memory Ordering, and by HIL stress tests.
Fuzzing parsers
Section titled “Fuzzing parsers”Every parser that reads bytes from outside the chip (UART frames, BLE writes, USB control requests, config blobs) will eventually see input you never imagined. Fuzzing generates millions of malformed inputs automatically and watches for crashes. On the host, compiled with sanitizers, a single out-of-bounds read becomes an immediate, reported crash instead of silent corruption.
libFuzzer, built into Clang, needs one function:
#include <stddef.h>#include <stdint.h>#include "frame.h"
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size){ frame_parser_t p; frame_t f;
frame_parser_init(&p); for (size_t i = 0; i < size; i++) { if (frame_parser_feed(&p, data[i], &f) == FRAME_COMPLETE) { /* Invariant: a completed frame never claims more payload * than the buffer can hold. */ if (f.len > FRAME_MAX_PAYLOAD) { __builtin_trap(); } } } return 0;}clang -g -O1 -fsanitize=fuzzer,address,undefined \ fuzz_frame.c frame.c crc16.c -o fuzz_framemkdir -p corpus && ./fuzz_frame corpus -max_total_time=300The harness feeds the input byte by byte, exactly as the UART ISR would. AddressSanitizer catches any read or write outside an object, UBSan catches signed overflow and bad shifts, and the explicit invariant check catches logic errors that are not memory errors. libFuzzer uses coverage feedback: inputs that reach new branches are kept in corpus/ and mutated further, so it quickly learns to produce valid start bytes, lengths, and even correct CRCs if the code paths reward it.
A typical first finding looks like this:
==4182==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffd5c1e4a91WRITE of size 1 at 0x7ffd5c1e4a91 thread T0 #0 0x55d1 in frame_parser_feed frame.c:47:28 #1 0x55d1 in LLVMFuzzerTestOneInput fuzz_frame.c:13:13...artifact_prefix='./'; Test unit written to ./crash-3f1a9c0e...Line 47 is where the parser stores a payload byte without checking the length field against FRAME_MAX_PAYLOAD. The crash-... file holds the exact input; turn it into a Unity regression test so the bug never returns. AFL++ works the same way with a different driver; use whichever your team already has. Run the fuzzer for a few minutes in CI on every change to a parser and for hours on a nightly job.
Simulation with Renode
Section titled “Simulation with Renode”Host tests compile your logic with gcc for x86 or ARM64. They never run your startup code, your linker script, or your actual Thumb-2 instructions. Renode fills that gap: it emulates the STM32F4 Discovery board (CPU, memory map, many peripherals) and runs your real firmware.elf.
A Renode machine is described by a platform description (.repl file) that lists peripherals, their addresses, and their interrupt connections. Renode ships one for the Discovery kit, so you rarely write your own; you extend it when you need an extra device, such as a simulated I2C sensor.
Renode integrates with Robot Framework, a keyword-driven test runner. A test boots the firmware and makes assertions about UART output:
*** Variables ***${UART} sysbus.usart2
*** Keywords ***Boot Firmware Execute Command mach create Execute Command machine LoadPlatformDescription @platforms/boards/stm32f4_discovery-kit.repl Execute Command sysbus LoadELF @${CURDIR}/build/firmware.elf Create Terminal Tester ${UART}
*** Test Cases ***Should Print Banner On Boot Boot Firmware Start Emulation Wait For Line On Uart firmware-path v1.2.0 timeout=5
Should Answer Status Command Boot Firmware Start Emulation Wait For Prompt On Uart > Write Line To Uart status Wait For Line On Uart led=off timeout=2renode-test firmware.robotEach test case creates a fresh machine, so tests do not leak state into each other. The boilerplate around the keywords (suite setup, resource imports) has changed between Renode releases; copy the header from an example in the tests/ directory of the version you install.
Simulation catches a class of bugs host tests cannot: a vector table in the wrong section, .data not copied, a stack placed off the end of RAM, a UART baud divisor that the firmware computes from the wrong clock. It is also deterministic: virtual time advances only as instructions execute, so a test that passes once passes every time.
Know the limits. Peripheral models implement the documented behaviour, not every quirk; silicon errata are usually absent; analog peripherals return plausible values, not physics. Timing is instruction-count based, not cycle-accurate with flash wait states. QEMU’s netduinoplus2 machine is useful for UART-only programs but models far fewer STM32 peripherals than Renode.
Hardware-in-the-loop
Section titled “Hardware-in-the-loop”Hardware-in-the-loop (HIL) testing runs the real firmware on a real board, driven by a test computer. A minimal rig is a Discovery board connected by USB (ST-Link for flashing and a USB-serial adapter on USART2), plus optionally a second board or a cheap logic analyzer to stimulate inputs and observe outputs.
The firmware side is a set of self-test commands: shell commands, compiled into a test build or always present behind a factory-mode flag, that exercise one function and print a machine-readable result.
> selftest i2cSELFTEST i2c PASS addr=0x4A id=0xE3> selftest flashSELFTEST flash PASS sector=11 bytes=131072> selftest led 12SELFTEST led12 PASSThe host side is a Python script using pyserial, run by pytest in CI on a machine with the board attached:
import serialimport subprocessimport pytest
PORT = "/dev/ttyUSB0"
@pytest.fixture(scope="module")def dut(): subprocess.run( ["openocd", "-f", "board/stm32f4discovery.cfg", "-c", "program build/firmware.elf verify reset exit"], check=True) with serial.Serial(PORT, 115200, timeout=2) as s: s.reset_input_buffer() yield s
def command(dut, text): dut.write((text + "\r\n").encode()) for _ in range(20): line = dut.readline().decode(errors="replace").strip() if line.startswith("SELFTEST"): return line pytest.fail(f"no SELFTEST reply to {text!r}")
def test_i2c_codec_responds(dut): assert command(dut, "selftest i2c").startswith("SELFTEST i2c PASS")
def test_flash_erase_program_verify(dut): assert "PASS" in command(dut, "selftest flash")The fixture flashes the board once per module, then each test sends a command and parses the reply. Prefixing every machine-readable line with a fixed token (SELFTEST) lets the script ignore log noise. The same commands later become your manufacturing test firmware, so the work pays off twice.
For timing requirements, add a logic analyzer driven from the script. sigrok’s sigrok-cli can capture a few hundred milliseconds of a pin and decode a UART or I2C bus, and the test then asserts on the decoded output: “the PWM period on PD12 is 1.000 ms plus or minus 1%”, or “the sensor read transaction completes in under 400 µs”.
Integration, regression, and flaky tests
Section titled “Integration, regression, and flaky tests”Integration tests exercise several real modules together: the protocol parser feeding the command dispatcher feeding the config store on a fake flash. They catch interface mismatches that unit tests with doubles cannot. Regression tests are the tests you add when a bug is found: reproduce the bug in a test first, watch it fail, then fix the code. Every field bug should end its life as a test.
A flaky test is one that sometimes passes and sometimes fails with no code change. In firmware the usual causes are real: an unhandled race, a timeout too tight for a slow USB hub, a board that is not power cycled between runs. The discipline is simple and strict. A flaky test is either fixed or quarantined (moved to a non-blocking job with a ticket) within a day. A suite that is “usually green” trains everyone to ignore red, and then real failures slip through.
Coverage
Section titled “Coverage”Coverage tools tell you which lines and branches your tests executed. On the host, GCC’s gcov instruments the build:
gcc -std=c11 -O0 --coverage -Iinc src/sensor.c test/fake_bus.c test/test_main.c -o test_main./test_mainlcov --capture --directory . --output-file coverage.infogenhtml coverage.info --output-directory coverage_html--coverage adds counters to every basic block and writes .gcda files when the program exits. lcov collects them and genhtml produces a browsable report with uncovered lines in red.
What number is meaningful? Line coverage of 80 to 90% on logic modules (parsers, state machines, calculations) is a reasonable bar. Chasing 100% on drivers that are mostly register writes produces tests that assert “the code writes what the code writes” and break on every change. Branch coverage matters more than line coverage: an if with no test for its else path is where error handling bugs live. Coverage measures what was executed, never whether it was checked; a test with no assertions gives full coverage and proves nothing.
Regulated industries are specific. DO-178C Level A avionics software requires MC/DC (modified condition/decision coverage): every condition in every decision must be shown to independently affect the outcome. IEC 61508 and ISO 26262 recommend branch or MC/DC coverage at higher integrity levels. Safety, Standards, and Coding Guidelines covers what those standards ask for.
Traceability and what to automate
Section titled “Traceability and what to automate”In a regulated product (medical, automotive, industrial safety), every requirement must trace to at least one test, and every test to a requirement. The simplest workable method is to tag tests with requirement IDs in their names or in a comment, and have a script generate the matrix:
/* REQ-TEMP-004: Out-of-range readings shall be reported as errors. */void test_sensor_rejects_reading_above_125c(void);An auditor asks, “show me the test for REQ-TEMP-004 and its last result”. With tagged tests and archived CI reports you answer in a minute.
Automate everything that is repeatable and has a clear pass criterion: logic, protocols, peripheral self-tests, boot and update flows, power-on behaviour. Keep a short manual checklist for what machines judge poorly: does the LED look right, does the button feel right, does the device behave sensibly when you unplug it mid-update while holding it in your hand. Run the manual list before every release, and promote any item to automation once it has failed twice.
Putting it together
Section titled “Putting it together”A complete test setup for a firmware project usually looks like this:
project/ src/ firmware sources test/ unit/ Unity tests, one test_*.c per module fakes/ fake_flash.c, fake_clock.c, fake_bus.c fuzz/ libFuzzer harnesses for every parser renode/ .robot files and any extra .repl hil/ pytest scripts, rig configuration project.yml Ceedling configurationCI runs unit tests, a short fuzz pass, and Renode tests on every push (a few minutes). A nightly job runs long fuzz sessions and the HIL suite on attached boards. Releases require all of it green plus the manual checklist.
Common mistakes
Section titled “Common mistakes”- Register access buried in application logic, so nothing can be tested off-target. Fix: move every register touch into a driver behind a function.
#ifdef UNIT_TESTbranches that change behaviour under test. Fix: use link-time substitution so the tested code is byte-for-byte the shipped code.- Mocks used for stateful dependencies, producing tests that break on every refactor. Fix: write a fake (flash, clock, file system) and assert on resulting state.
- A fake flash that is a plain array and accepts writes without erase. Fix: model erase-to-
0xFFand program-clears-bits, and flag violations. - Timeout logic tested only with small timestamps. Fix: start the fake clock just before
0xFFFFFFFFin every time-related test. - Fuzzing without sanitizers, so memory errors go unnoticed. Fix: always build harnesses with
-fsanitize=fuzzer,address,undefined. - Treating 100% line coverage as proof of correctness. Fix: review assertions and branch coverage, especially on error paths.
- Tolerating flaky HIL tests. Fix: fix or quarantine within a day, and power cycle boards between suites.
Exercises
Section titled “Exercises”- Write the three Unity tests for the sensor driver using a hand-written stub instead of CMock:
fake_bus.cdefinesbus_read_reg()and copies two bytes from a global set by the test. Make the tests pass, then delete the sign-extension lines and confirm the negative-reading test fails with a clear message. - Add a test proving the driver returns
SENSOR_ERR_RANGEfor a reading of+130 °C(counts16640, bytes0x41 0x00) and accepts exactly+125 °C. Rungcovonsensor.cand confirm every branch shows as taken. The report should list zero unexecuted lines. - Implement the fake flash above, then write a tiny config store (
config_save()erases sector 0 and writes a struct with a CRC;config_load()validates it). Write a test that callsconfig_save()twice without the erase and confirmfake_flash_had_violation()catches it. - Write a frame parser for
0x7E, len, payload[len], crc8and fuzz it with libFuzzer for five minutes. Deliberately remove the length check, rebuild, and record how many seconds the fuzzer takes to find the overflow and which line ASan reports. - Boot your blinky or UART shell ELF in Renode with a Robot test that waits for the banner. Then break the linker script (change the RAM origin to
0x20020000) and observe how the test fails. Compare with whatarm-none-eabi-sizeand the map file report for the broken build.
Check yourself
Section titled “Check yourself”Question 1:Your config module calls flash_dev_program() on a sector it never erased. Which test double catches this bug?
Only a fake that models NOR physics (erase sets 1s, program clears bits) can notice an attempt to turn a 0 back into a 1. Stubs and call-count mocks accept the write without checking flash state.
Question 2:A debounce test starts the fake clock at 0 and passes. In the field, the button stops working after 49.7 days. What should the test have done?
The bug is a comparison that fails across counter wraparound. Starting the fake clock just before the wrap exercises that path in milliseconds of test time.
Question 3:Why is link-time substitution usually preferred over #ifdef UNIT_TEST for swapping hardware dependencies?
With link-time substitution only the dependency changes; the code being tested contains no test-only branches, so you test what you ship.
Question 4:Renode tests pass but the product's I2C sensor reads garbage on real boards. What is the most likely explanation?
Renode runs the real ARM binary, but its peripheral models are idealized. Electrical and silicon-specific problems are exactly what HIL testing exists to catch.
Question 5:A libFuzzer harness for the frame parser runs for an hour without reporting anything. It was built with clang -fsanitize=fuzzer only. What is the weakness?
Without ASan, a small overflow often lands in valid memory and never crashes. Sanitizers turn silent corruption into an immediate, reported failure.
Key takeaways
Section titled “Key takeaways”- Most firmware tests should run on the host in milliseconds; simulation and HIL confirm the hardware glue, they do not replace unit tests.
- Testability is designed in: registers only in drivers, dependencies injected, logic in pure functions.
- Use mocks for interactions that must happen in a specific order, fakes for stateful dependencies, stubs for incidental ones.
- Fake clocks and fake flash let you test wraparound and erase-before-write bugs that take weeks to appear on hardware.
- Every external-input parser should be fuzzed on the host with AddressSanitizer and UBSan enabled.
- Coverage shows what ran, not what was checked; branch coverage on error paths is the number worth watching.
- Every field bug ends as a regression test, and flaky tests are fixed or quarantined immediately.
Next: Communication Stacks, where the device starts talking USB, CAN, Ethernet, and BLE.