Testing, Debugging, and CI
Firmware quality improves when tests run before hardware is available, diagnostics explain failures on the bench, and continuous integration keeps the build reproducible. The goal is not to test everything on a PC; it is to place each check at the cheapest useful level.
Learning Objectives
By the end of this lesson, you should be able to separate unit, integration, hardware-in-loop, and production tests; design useful logs and assertions; use debuggers safely; and define a CI pipeline for embedded firmware.
Test Pyramid for Firmware
Fast checks should run often. Slow hardware tests should protect the most important real-device behavior.
Unit Tests
Host unit tests are valuable for pure logic: parsers, filters, protocol framing, state machines, calibration math, ring buffers, and fault policy decisions.
#include <assert.h>
#include <stdint.h>
uint16_t adc_to_mv(uint16_t code, uint16_t vref_mv) {
return (uint32_t)code * vref_mv / 4095u;
}
int main(void) {
assert(adc_to_mv(0, 3300) == 0);
assert(adc_to_mv(4095, 3300) == 3300);
assert(adc_to_mv(2048, 3300) >= 1649);
return 0;
}
Keep hardware access behind interfaces so logic can be tested without registers.
Integration and Hardware Tests
Target tests verify startup, clocks, interrupts, drivers, DMA, storage, communication, sleep states, and fault recovery. Hardware-in-loop tests can press relays, loop UARTs, inject sensor values, power-cycle the board, and check current draw.
A useful hardware test states setup, fixture wiring, firmware image, stimulus, expected observation, timeout, and cleanup.
Debugging Tools
Use the cheapest tool that answers the question:
- logs for state transitions and rare field faults;
- SWD or JTAG for halt, watchpoints, registers, and memory;
- logic analyzer for digital timing and protocol decoding;
- oscilloscope for edges, analog levels, reset, and power droop;
- current probe or power analyzer for sleep and radio peaks.
Do not leave blocking debug prints in timing-critical code.
Assertions and Fault Capture
Assertions catch impossible states during development. In production, convert critical assertions into fault records that include build ID, reset reason, file or code, line or module, state, and uptime.
#define FW_ASSERT(expr, code) do { \
if (!(expr)) fault_record_and_reset((code), __LINE__); \
} while (0)
CI Pipeline
A practical CI pipeline runs:
- formatting or style check;
- static analysis warnings as errors for selected rules;
- host unit tests;
- cross-compile for all board variants;
- artifact packaging with version metadata;
- optional emulator, simulator, or hardware rack tests;
- release signing only from protected branches.
Worked Example: UART Driver Regression
A previous bug dropped bytes when the RX ring buffer wrapped. Add host tests for wrap-around, overflow policy, and empty reads. Add a target test that loops TX to RX at the required baud rate. Add a CI artifact that stores the map file so RAM changes are visible in reviews.
Common Mistakes
- Testing only with a debugger attached.
- Mocking so much that tests no longer match real behavior.
- No reproducible compiler version.
- Logs without timestamps or state context.
- CI builds one board while releases support several variants.
Practical Checks
Break the build intentionally to confirm CI fails. Run tests from a clean checkout. Verify artifacts include commit ID. Confirm hardware tests reset the board between cases. Review compiler warnings and memory map changes before release.
Summary
Good firmware testing combines fast host checks, meaningful target tests, useful diagnostics, and repeatable CI. Debugging becomes faster when the firmware records context before the failure disappears.
Further Reading
- Ceedling, Unity, CMock, and CppUTest documentation.
- Memfault firmware testing and debugging guides.
- Vendor debug probe documentation for SWD, JTAG, trace, and semihosting.