How Do You Unit Test Embedded Firmware Without Target Hardware?
Last updated 3 July 2026 · 7 min read
Direct Answer
Embedded firmware can be unit tested on a development machine ("host-based" testing) by compiling business logic against a native compiler instead of the target's cross-compiler, and replacing hardware-touching code — HAL calls, register access, peripheral drivers — with mock implementations that a test asserts against instead of real silicon. The dominant open-source toolchain for C is Unity (a lightweight xUnit-style assertion framework designed for embedded C), Ceedling (a Ruby-based build/test runner that wraps Unity, generates mocks, and drives the whole test suite with one command), and CMock (which auto-generates mock implementations of a header's functions from the header itself). This lets logic like a protocol parser, a state machine, or a sensor calibration calculation run through hundreds of test cases in seconds on a CI server, catching regressions long before the code ever reaches target hardware — a fundamentally different activity from the hardware bring-up and instrument-based debugging covered elsewhere on this site.
Detailed Explanation
Most of this site's Testing coverage is about validating a physical board with an oscilloscope, a logic analyser, or a JTAG/SWD debugger — instruments that need real hardware. Unit testing firmware logic is a different discipline entirely: it runs on a development machine or CI server, with no target board attached, and it tests different things — not "does this board work," but "does this function produce the correct output for every input I can think of, including the inputs I haven't hit yet on the bench."
Why Test on the Host Instead of the Target?
Compiling and flashing to target hardware for every test run is slow — seconds to minutes per cycle, and it requires a board connected and available. Host-based testing compiles the code under test against a native compiler (gcc/clang on the development machine) instead of the target's cross-compiler (arm-none-eabi-gcc, for example), producing a test executable that runs directly on the development machine in milliseconds. A test suite with hundreds of cases can run on every commit in a CI pipeline in under a second, which is simply not achievable running against physical hardware.
The trade-off: only code that doesn't genuinely depend on the target's hardware, instruction set, or memory layout can be tested this way unmodified. A protocol frame parser, a checksum calculation, a PID control-loop calculation, or a state machine's transition logic are all excellent candidates — they're pure logic operating on data. Direct register manipulation, DMA setup, or anything timing-dependent at the hardware level cannot be meaningfully unit tested on the host; that needs the target-hardware testing and instrumentation covered in How Do You Debug Embedded Firmware?.
Unity: The Assertion Framework
Unity is a single-header, dependency-free xUnit-style test framework written specifically for embedded C, where a full framework like Google Test's C++ requirements or a heavyweight runtime aren't a good fit. A Unity test file looks like:
#include "unity.h"
#include "checksum.h"
void setUp(void) {}
void tearDown(void) {}
void test_checksum_of_empty_buffer_is_zero(void)
{
uint8_t buf[0];
TEST_ASSERT_EQUAL_UINT8(0, calculate_checksum(buf, 0));
}
void test_checksum_of_known_buffer(void)
{
uint8_t buf[] = {0x01, 0x02, 0x03};
TEST_ASSERT_EQUAL_UINT8(0x06, calculate_checksum(buf, 3));
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_checksum_of_empty_buffer_is_zero);
RUN_TEST(test_checksum_of_known_buffer);
return UNITY_END();
}
Unity provides a large family of TEST_ASSERT_* macros (equality, ranges, floating-point tolerance, array comparison, string comparison) that produce readable failure output — the line number and expected-vs-actual values — without needing a debugger attached, since the test itself runs as a normal host executable.
Ceedling: Wiring It All Together
Writing and compiling Unity test binaries by hand for every source file doesn't scale. Ceedling is a Ruby-based build and test-runner tool that automates the whole workflow: it discovers test_*.c files, generates the Unity runner code, builds each test as its own executable against the source file(s) it targets, runs every test binary, and reports a consolidated pass/fail summary — all from one command, configured through a project.yml file (abbreviated below):
:project:
:build_root: build/
:paths:
:source:
- src/**
:test:
- test/**
:plugins:
:enabled:
- report_tests_pretty_stdout
ceedling test:all
Ceedling's default project layout separates src/ (production code), test/ (test files), and generated mocks, and it integrates CMock (below) automatically, so mock generation is transparent to the developer.
CMock: Mocking the Hardware Boundary
The hard part of embedded unit testing isn't the pure-logic functions — it's everything that calls into a HAL or driver layer. CMock solves this by parsing a header file's function declarations and auto-generating a mock implementation with the same signatures, plus expectation-setting functions the test uses to specify what the mock should return and verify it was called correctly:
/* Given a header: void i2c_write(uint8_t addr, uint8_t *data, size_t len);
CMock generates: i2c_write_ExpectWithArray(addr, data, len); */
#include "unity.h"
#include "mock_i2c_hal.h"
#include "temperature_sensor.h"
void test_read_temperature_sends_correct_i2c_command(void)
{
uint8_t expected_cmd[] = {0x00}; /* register address to read */
i2c_write_ExpectWithArray(0x48, expected_cmd, 1);
i2c_read_ExpectAndReturn(0x48, NULL, 2, 0); /* CMock ignores buffer content check here */
read_temperature_sensor();
/* CMock automatically verifies all expectations were met at test teardown */
}
This lets a driver-consuming function be tested for correct behaviour — did it send the right I2C address, the right register, in the right sequence — entirely without a real I2C bus or sensor attached, and without the test needing to know anything about the mock's internal implementation beyond the header it was generated from.
Design Considerations
- Design the HAL boundary for testability from the start. Code that calls a driver function (
i2c_write(),spi_transfer()) is straightforward to mock; code that inlines register access directly inside business logic is not. A thin, consistently-used driver interface layer is what makes CMock-based mocking practical — retrofitting it onto a codebase with scattered direct register access is a much larger undertaking than designing for it up front. - Target the highest-value logic first, not 100% coverage. Protocol parsers, checksum/CRC calculations, unit conversions, calibration math, and state machines are the classic high-value unit test targets — they have many edge cases, are easy to get subtly wrong, and are expensive to debug once deployed. Chasing coverage percentage on simple pass-through wrapper functions is a poor use of the same effort.
- Run the suite in CI on every commit. The value of a fast host-based suite is largely lost if it's only run manually before a release. Wire
ceedling test:all(or the equivalent) into the project's CI pipeline so a regression is caught within minutes of the commit that introduced it, not weeks later during hardware bring-up or in the field. - Host-based tests don't replace target validation. See the FAQ below — this is a common misunderstanding worth stating explicitly to a team new to the practice, since it's tempting to treat a green unit test suite as proof the firmware works on real hardware, which it specifically does not prove.
Common Mistakes
- Testing through the HAL instead of testing the logic that calls it. Writing tests that essentially re-verify the mock framework itself, rather than the calling code's decision logic (did it retry on failure? did it send the correct sequence of commands?), produces tests that pass trivially and catch nothing.
- Letting hardware dependencies leak into "pure logic" functions. A function that's supposed to be a pure calculation but reads a global peripheral state variable or calls a delay function internally becomes difficult or impossible to unit test without also mocking that dependency — keep functions intended for unit testing free of direct hardware coupling.
- Treating a failing test suite as optional to fix before merging. A team that routinely merges with known-broken tests trains itself to ignore the suite's output entirely, at which point it stops providing any real regression protection.
- Assuming host-endianness and target-endianness always match. A checksum, packing, or byte-order-sensitive function tested only on a little-endian development host can behave correctly in the test suite while being subtly wrong on a big-endian target (or vice versa) — where byte order matters, test it explicitly rather than relying on the host's native behaviour.
- Skipping mocks and linking real driver source into the test build "to save time." This reintroduces hardware dependencies (and often outright compile failures against target-only headers) into what's supposed to be a fast, hardware-independent test — defeating the reason for host-based testing in the first place.
Frequently Asked Questions
- Can I unit test code that touches hardware registers directly?
- Not the register access itself, but the logic around it — yes, by isolating hardware access behind a function or driver interface and mocking that interface in the test build. Code that directly dereferences a peripheral's memory-mapped register inside a business-logic function is difficult to unit test because there's no register to write to on a development machine; refactoring that access behind a thin driver function (even a one-line wrapper) makes the calling logic testable while leaving the actual register access to be validated separately, on target hardware, via integration or bring-up testing.
- Does host-based unit testing replace testing on real hardware?
- No — it complements it. Unit tests validate that logic behaves correctly in isolation, quickly and on every commit; they cannot catch timing-dependent bugs, real peripheral quirks, silicon errata, or interactions between the actual hardware and firmware. A mature embedded test strategy runs fast host-based unit tests on every commit (via CI) for logic-heavy code, and reserves target hardware testing — using the instruments and bring-up methodology covered in How Do You Debug Embedded Firmware? — for integration, timing, and hardware-interaction validation that cannot be replicated on a development machine.
- Is Unity/Ceedling only for bare-metal C, or does it work with an RTOS project?
- Unity and Ceedling test plain C functions compiled for the host — they have no inherent dependency on bare-metal vs RTOS firmware, because the code under test is typically business logic (parsers, calculations, state machines) rather than the RTOS scheduler itself. A FreeRTOS or Zephyr project's application logic can be unit tested the same way as bare-metal logic, provided RTOS API calls (task creation, queue operations) within the tested function are mocked rather than compiled against the real kernel, which isn't present in a host build.
References
Related Questions
How Do You Debug Embedded Firmware?
Covers JTAG/SWD hardware debugging, printf over UART or SWO trace, and logic analyser use for embedded firmware on STM32, ESP32, and other MCU platforms.
Bare-Metal vs RTOS: Which Should You Use for Your Firmware?
Bare-metal firmware and RTOS suit different embedded projects. Learn the trade-offs — timing, RAM overhead, complexity — and how to choose.
What Is a Linker Script and What Does It Do?
A linker script controls where firmware code and data land in flash and RAM. Covers MEMORY regions, SECTIONS, LMA/VMA, and the startup symbols it exports.
How Do You Debug FreeRTOS Stack Overflows and Deadlocks?
How to debug FreeRTOS stack overflows and deadlocks: stack overflow hooks, vTaskList diagnostics, and SEGGER SystemView timeline tracing.
How Does SEGGER RTT Debug Logging Work?
How SEGGER RTT provides low-overhead debug logging over an existing J-Link connection, how it compares to semihosting and UART, and common pitfalls.
How Does an OTA Firmware Update Work?
OTA firmware updates require dual-bank flash, image verification, and rollback. Covers MCUboot swap, ESP32 OTA API, image signing, and power-loss-safe design.
Related Forum Discussions
Scope showing 200+ mV spikes on my 3.3V rail — is this real or a probe problem?
Probing the 3.3V output of a switching regulator on a new board and I'm seeing large spikes on the scope that don't make sense to me. The wa
STM32 USB not detected by Windows after jumping to bootloader mode
Working on a custom STM32F411 board, trying to jump into the built-in USB DFU bootloader from application code instead of holding BOOT0 on p