Why We Test Firmware Before It Runs on Hardware
Hardware is expensive and slow to iterate. Here's how we use host-side unit tests with Unity and Ceedling to catch bugs before they ever reach the board.
There is a moment every embedded engineer knows. The board is flashed. The terminal is open. Nothing moves. The LED that should blink doesn’t. You reach for the oscilloscope, the logic analyser, the debugger. An hour later you discover it was a one-character typo in a register address.
That hour is expensive. On a customer project, it’s billable time nobody wants to explain. On a product with hardware in the field, it’s a firmware update, a test cycle, and sometimes a recall.
We test firmware before it runs on hardware. This is how.
The core idea: separate logic from hardware
Embedded code mixes two things that should be kept apart:
- Business logic — state machines, protocol parsers, calibration math, sensor fusion
- Hardware access — register writes, SPI transactions, GPIO toggles
Business logic runs identically on a Linux laptop and an ARM Cortex-M. Hardware access, by definition, does not. If you structure the code so the two never touch, you can test the first one anywhere.
The pattern is called hardware abstraction and it’s not new. What is sometimes skipped is the test harness that makes it pay off.
Our test stack
We use Ceedling — a Ruby-based build runner that combines three tools:
- Unity for assertions and test runner
- CMock to auto-generate mocks from C header files
- CException for lightweight exception handling
A typical test file looks like this:
#include "unity.h"
#include "mock_hal_gpio.h" /* CMock auto-generates this */
#include "led_controller.h"
void test_led_blinks_on_tick(void) {
hal_gpio_write_Expect(LED_PIN, GPIO_HIGH);
hal_gpio_write_Expect(LED_PIN, GPIO_LOW);
led_tick(500); /* 500 ms elapsed */
}
hal_gpio_write_Expect is generated by CMock from hal_gpio.h. The test runs entirely on the host — no USB cable, no JTAG, no board at all. CMock verifies that led_tick calls hal_gpio_write with exactly the right arguments in exactly the right order.
What this catches
These are the classes of bug host-side tests are built to catch — the kind that hide from an oscilloscope and cost hours on the bench:
- Off-by-one in a CRC table — a single wrong entry that corrupts one byte in a thousand
- A state machine that never exits an error state — fine in the happy path, stuck forever on the unhappy one
- Sensor calibration that diverges at the edges of the measurement range — accurate in the middle, wrong where it matters
None of these are obvious on a scope. Most need hardware reproduction to isolate. With host-side tests they surface as a failing assertion in CI — a pull-request comment, not a field return.
The boundary rule
The rule we enforce in code review: nothing in src/ may #include a hardware register header directly. If you need to write to a register, you write a HAL function. If the HAL function is thin enough (one line that writes a value to an address), it doesn’t need a unit test — it needs an integration test, which we run on actual hardware during bring-up.
Everything above the HAL is fair game for host-side coverage.
Running in CI
The Ceedling test suite runs in GitHub Actions on every push. Configuration is about forty lines of YAML — Node isn’t involved, it’s just Ruby and GCC. A typical run on a mid-size project (20 source files, 40 test cases) takes under ten seconds.
We don’t gate deploys on test coverage percentage. We gate them on zero failures. Coverage is a lagging indicator; a passing test of the right boundary condition is worth more than ninety percent coverage of code that will never change.
When host-side tests aren’t enough
Some things can only be tested on metal:
- Timing-sensitive interrupts
- DMA transfers
- SPI/I²C transactions with real peripherals
- Power consumption under load
For those we use on-hardware integration tests during bring-up and before each release. They run manually — a test fixture, a measurement script, a pass/fail gate in a spreadsheet. Boring, but honest.
The host-side tests buy us the confidence to do those hardware sessions less often and find bugs earlier, when they cost less.
If you’re building a product that involves firmware and want to talk about how we structure testing on a specific project, get in touch.