How do you unit test embedded C code that directly accesses hardware registers?
The fundamental challenge in unit testing embedded C is that production code reads and writes hardware registers at fixed memory addresses — registers that do not exist on the host machine where tests run. The solution is hardware abstraction and mocking: separate the logic you want to test from the hardware access, then substitute fake hardware during testing.
The most practical approach uses a Hardware Abstraction Layer (HAL) with function pointers or link-time substitution. For example, instead of writing GPIOB->ODR |= (1 << 5) directly in application code, call gpio_write(GPIO_PORT_B, 5, HIGH), where gpio_write is implemented differently for target and test builds. In the target build, gpio_write accesses the real register. In the test build, it writes to a simulated register variable that the test can inspect. Frameworks like CMock (companion to Unity) auto-generate mock functions from your HAL header files, producing fake implementations that record calls and return preset values.
Popular frameworks for embedded C unit testing include Unity (pure C, minimal footprint, assert-based), CppUTest (C/C++ compatible, built-in memory leak detection, widely used in embedded — the "Test-Driven Development for Embedded C" book by James Grenning is built around it), and Google Test (C++ only, powerful matchers and parameterized tests, heavier dependency). All three run on the host machine (x86/x64) — you compile your application logic with a host compiler (GCC, Clang), link against mock HAL implementations, and run the test binary natively. This gives you fast iteration (millisecond test runs), debugger support, and CI integration.
The key architectural decision is where to draw the HAL boundary. Too low (mocking individual register accesses) makes tests brittle and tightly coupled to the hardware. Too high (mocking entire subsystems) leaves too much untested logic. The sweet spot is a thin HAL that abstracts peripheral operations: adc_read_channel(ch), timer_set_period_us(us), spi_transfer(tx, rx, len). This level is stable across MCU families, testable, and maps naturally to mock functions.
Source: Debugging & Testing Q&A
