Testing Embedded Code on Host: Unity, CMock and Hardware Abstraction

For years I treated testing embedded code as something you did after flashing the board — step through with a debugger, toggle a few pins, and hope your manual checks caught the edge cases. That approach broke down completely when I started working on a battery-powered sensor node where the business logic for power management and sensor fusion was tangled directly with STM32 register writes. Every change required a flash cycle, and bugs that only showed up after hours of runtime were nearly impossible to reproduce. Moving that logic to host-based testing with Unity and CMock fundamentally changed how I build firmware. By introducing a clean hardware abstraction layer and running tests natively on my laptop, I can now validate thousands of cases in seconds, catch regressions before they hit hardware, and keep peripheral drivers isolated from application code. This workflow isn’t about replacing on-target testing — it’s about making it so that when you do test on hardware, you’re verifying integration, not hunting for logic errors.

Decoupling Hardware Registers Behind a Testable HAL Interface

In my experience, the single biggest obstacle to host-based testing is not the test framework — it’s the lack of a seam between your application and the hardware. If your sensor driver directly dereferences GPIOA->ODR or calls HAL_SPI_Transmit() inline with your state machine, you have no way to substitute behavior on the host. The fix is to design a HAL that is thin, explicit, and owned by you, not just the vendor SDK.

I structure HALs as a set of interfaces defined in header files with no register includes. The implementation for the target includes stm32f4xx.h or driver headers, while the host build links against mocks or fakes. The key is to keep the interface at the level of intent, not registers. Instead of exposing write_register(0x2D, 0x08), expose accel_set_power_mode(ACCEL_LOW_POWER). This keeps tests readable and prevents them from mirroring register details.

What Makes a HAL Testable in Practice

I've found that three rules keep a HAL testable without adding excessive abstraction overhead. First, never let application code include CMSIS or vendor HAL headers directly — only the HAL implementation files should. Second, avoid returning raw hardware status flags; translate them to domain-specific error codes like HAL_OK, HAL_TIMEOUT, HAL_BUS_ERROR. Third, inject dependencies through function pointers or link-time substitution rather than global singletons. For simple peripherals like GPIO or I2C, link-time substitution is sufficient and keeps runtime cost at zero, which matters for interrupt-sensitive code discussed in Interrupt Handling Best Practices: Priority, Latency and ISR Design.

// hal_gpio.h - Pure interface, no vendor includes
#ifndef HAL_GPIO_H
#define HAL_GPIO_H

#include <stdbool.h>
#include <stdint.h>

typedef enum {
    GPIO_PIN_RESET = 0,
    GPIO_PIN_SET   = 1
} gpio_state_t;

typedef enum {
    HAL_GPIO_OK = 0,
    HAL_GPIO_ERR_INVALID_PIN
} hal_gpio_status_t;

// These functions are implemented for target in hal_gpio_stm32.c
// and mocked/faked for host tests
hal_gpio_status_t hal_gpio_write(uint8_t port, uint8_t pin, gpio_state_t state);
hal_gpio_status_t hal_gpio_read(uint8_t port, uint8_t pin, gpio_state_t *state);
hal_gpio_status_t hal_gpio_configure_output(uint8_t port, uint8_t pin);

#endif

// app/led_controller.c - Depends only on the abstraction
#include "hal_gpio.h"

typedef struct {
    uint8_t port;
    uint8_t pin;
} led_t;

hal_gpio_status_t led_turn_on(const led_t *led) {
    if (!led) return HAL_GPIO_ERR_INVALID_PIN;
    return hal_gpio_write(led->port, led->pin, GPIO_PIN_SET);
}

On the target, hal_gpio_stm32.c will map hal_gpio_write to HAL_GPIO_WritePin() or direct register writes. On the host, that same symbol is provided by a CMock-generated mock, allowing your application code to be compiled with a standard GCC or Clang toolchain with no cross-compiler needed. This separation also pays off for memory management — keeping HAL buffers and handles statically allocated and opaque to the application avoids heap fragmentation, a pattern I cover more in Memory Management in Embedded C: Static Allocation, Pool and Arena Patterns.

Compiling and Executing Unity Tests Natively on Your Host Machine

Unity is intentionally minimal — it’s a single C file and a header that compiles anywhere. That simplicity is why it works so well for host testing. In my projects I don’t cross-compile tests at all; I compile them with the native host compiler and run them as a normal executable. A failing test returns a non-zero exit code and prints exactly which assertion failed, which makes CI integration trivial.

The typical layout I use with Ceedling or a plain Makefile looks like this: src/ holds application code, src/hal/ holds interfaces and target implementations, test/ holds Unity test files, and test/support/ holds fakes for things that are hard to mock, like EEPROM. The host build defines UNIT_TEST and excludes the hal_*_stm32.c files, linking instead against mocks.

Structuring Unity Test Files for Embedded Modules

Each Unity test file follows a strict pattern: setUp() and tearDown() for isolation, and test functions prefixed with test_. I make heavy use of TEST_ASSERT_EQUAL, TEST_ASSERT_TRUE, and TEST_ASSERT_EQUAL_MEMORY for buffer checks. For embedded code, I always test both the happy path and the error translation path — what happens when the HAL returns HAL_TIMEOUT or when a sensor responds with an out-of-range value. The Zephyr Project Documentation (https://docs.zephyrproject.org/) has excellent examples of this same approach with its ztest and mocking frameworks, which reinforce why separating logic from drivers is essential regardless of RTOS.

#include "unity.h"
#include "mock_hal_gpio.h"  // Generated by CMock
#include "led_controller.h"

void setUp(void) {
    mock_hal_gpio_Init();
}

void tearDown(void) {
    mock_hal_gpio_Verify();
    mock_hal_gpio_Destroy();
}

void test_led_turn_on_writes_correct_pin(void) {
    led_t status_led = { .port = 0, .pin = 5 };
    
    // Expect the HAL to be called exactly once with these args
    hal_gpio_write_ExpectAndReturn(0, 5, GPIO_PIN_SET, HAL_GPIO_OK);

    hal_gpio_status_t result = led_turn_on(&status_led);

    TEST_ASSERT_EQUAL(HAL_GPIO_OK, result);
}

void test_led_turn_on_handles_null_pointer(void) {
    // No HAL expectation - should return before touching hardware
    hal_gpio_status_t result = led_turn_on(NULL);
    
    TEST_ASSERT_EQUAL(HAL_GPIO_ERR_INVALID_PIN, result);
}

void test_led_turn_on_propagates_hal_error(void) {
    led_t status_led = { .port = 0, .pin = 5 };
    hal_gpio_write_ExpectAndReturn(0, 5, GPIO_PIN_SET, HAL_GPIO_ERR_INVALID_PIN);

    hal_gpio_status_t result = led_turn_on(&status_led);

    TEST_ASSERT_EQUAL(HAL_GPIO_ERR_INVALID_PIN, result);
}

When I run this with ceedling test:led_controller or gcc test_led_controller.c unity.c mock_hal_gpio.c -o run_tests && ./run_tests, I get sub-millisecond execution. Compare that to flashing and stepping through the same three cases on hardware. The speed is what enables test-driven development for embedded: I can write the expectation, see it fail, implement the controller logic, and get instant feedback without touching a board. If you are evaluating scheduling and timing implications of this code later, the patterns described in RTOS Fundamentals: FreeRTOS vs Zephyr for Embedded Projects provide useful context for how host-tested logic will behave under a real scheduler.

Generating Peripheral Mocks Automatically with CMock for Real Drivers

Writing mocks by hand for a peripheral with 20 functions is tedious and error-prone. CMock solves this by parsing your HAL headers and generating complete mock implementations with expectation APIs. In my experience, CMock is most valuable for I2C, SPI, UART, and sensor driver interfaces where call order and argument values matter.

CMock reads a YAML configuration and your header, then emits mock_hal_i2c.h and mock_hal_i2c.c. Each function gets helpers like hal_i2c_write_ExpectAndReturn(), hal_i2c_read_IgnoreAndReturn(), and hal_i2c_write_ExpectWithArray() for buffer arguments. The generated code tracks call counts and arguments using Unity’s internals, so you don’t need to maintain state manually.

A practical tip: keep CMock headers simple. It doesn’t handle complex macros, bitfields, or inline assembly well — which is another reason to keep those details inside the .c implementation, not the mocked header. I configure CMock to treat hal_ prefixed headers as mocks and to strip vendor-specific attributes.

Testing Approach When to Use Host Execution Maintenance Cost
Hand-written Fake Stateful peripherals (EEPROM, Flash, RTC) Full functional simulation in RAM Medium — you maintain behavior
CMock Generated Mock Stateless driver calls (GPIO, SPI, I2C) Strict call/argument verification Low — auto-generated from header
Stub (return canned values) Simple queries or legacy code Minimal verification Very Low — but weak checks
On-Target Hardware Test Timing, DMA, interrupt integration Runs on real MCU via JTAG/SWD High — requires hardware + flashing

For stateful peripherals, I prefer a hand-written fake over a strict mock. For example, an I2C EEPROM fake might maintain a 4KB RAM array and implement hal_eeprom_write() and hal_eeprom_read() with realistic page boundaries and write-cycle delays. That lets me test wear-leveling logic on the host without setting up exhaustive CMock expectations for every byte.

// hal_i2c.h - Interface to be mocked
hal_i2c_status_t hal_i2c_write(uint8_t addr, const uint8_t *data, uint16_t len);
hal_i2c_status_t hal_i2c_read(uint8_t addr, uint8_t *data, uint16_t len);

// test_sensor.c - Testing retry logic with CMock
#include "unity.h"
#include "mock_hal_i2c.h"
#include "sensor_bme280.h"

void test_bme280_init_retries_on_bus_error(void) {
    uint8_t chip_id = 0x60;
    // First transaction fails, second succeeds
    hal_i2c_write_ExpectAndReturn(0x76, NULL, 0, HAL_I2C_ERR_BUS);
    hal_i2c_write_ExpectAndReturn(0x76, NULL, 0, HAL_I2C_OK);
    hal_i2c_read_ExpectAndReturn(0x76, NULL, 1, HAL_I2C_OK);
    hal_i2c_read_ReturnArrayThruPtr_data(&chip_id, 1);

    sensor_status_t result = sensor_bme280_init();

    TEST_ASSERT_EQUAL(SENSOR_OK, result);
}

void test_bme280_init_fails_after_three_retries(void) {
    hal_i2c_write_ExpectAndReturn(0x76, NULL, 0, HAL_I2C_ERR_BUS);
    hal_i2c_write_ExpectAndReturn(0x76, NULL, 0, HAL_I2C_ERR_BUS);
    hal_i2c_write_ExpectAndReturn(0x76, NULL, 0, HAL_I2C_ERR_BUS);

    sensor_status_t result = sensor_bme280_init();

    TEST_ASSERT_EQUAL(SENSOR_ERR_COMM, result);
}

This test verifies retry logic that would be painful to force on real hardware — you’d need to inject bus errors with a glitchy cable or debugger script. On the host, it’s two lines of mock configuration.

Taming Time, Interrupts and Volatile State in Host Execution

The area where host tests most often mislead developers is time and concurrency. On the host, there is no SysTick, no DMA completion interrupt, and no volatile hardware flag that changes asynchronously. If your code polls a flag or relies on HAL_GetTick(), you need to make time controllable.

Abstracting the System Tick and Delays

I never call a vendor delay directly from application code. Instead, I define hal_time_ms_t hal_time_now(void) and void hal_time_delay_ms(uint32_t ms) in the HAL. On target, these map to FreeRTOS xTaskGetTickCount() or a bare-metal SysTick. On host, the fake implementation is a simple uint32_t g_fake_millis that tests can advance manually. This lets me test timeouts deterministically. For example, to verify that a radio driver times out after 100 ms, the test advances fake time by 101 ms and then calls the poll function. The FreeRTOS Documentation (https://www.freertos.org/Documentation/) describes similar abstractions for tick handling that translate well to this host fake.

Simulating Interrupt-Driven Work Without Threads

Interrupt Service Routines should be kept as short as possible — ideally just setting a flag or pushing to a queue. I’ve found the most testable pattern is to have the ISR call a handler function that is also callable from tests. For example, uart_rx_isr_handler(uint8_t byte) processes a byte and updates a ring buffer. The real ISR, defined in target-only code, just reads the UART data register and calls that handler. In host tests, I call the handler directly in a loop to simulate a burst of received data, then assert that the parser state is correct. There is no need to spawn host threads to simulate interrupts; direct function calls give you deterministic, repeatable coverage. For more complex interrupt arbitration, refer to the strategies in Interrupt Handling Best Practices: Priority, Latency and ISR Design and test the non-ISR logic thoroughly on the host, leaving only the vector setup for on-target verification.

Handling Volatile and Memory-Mapped State

Code that checks while(!(USART1->SR & USART_SR_TXE)) cannot run on the host. The HAL encapsulation removes those volatile reads from application code, but the HAL implementation itself still needs testing. For that, I maintain a separate set of unit tests for the HAL implementation that compile with a fake register block — a struct that mimics the peripheral registers in RAM. Those tests verify that the driver sets the correct bits for a given API call, without needing the actual MCU.

Wiring Host Tests into Your CI Pipeline for Reliable Firmware Releases

The return on investment for host testing appears when it runs automatically. In my current project, every pull request triggers a GitHub Actions job that installs Ceedling, builds all host tests, and runs them with address sanitizer and coverage. The whole suite of over 800 Unity tests executes in under 4 seconds, compared to 15 minutes for a full on-target flash-and-test cycle on our farm of boards.

The Makefile target is simple: a test:host rule that invokes ceedling gcov:all or make -f host.mk. I fail the build if coverage on new application code drops below 85% or if any Unity assertion fails. I also run the host build with -Wall -Wextra -Werror and -fsanitize=undefined,address — undefined behavior that is silent on an ARM Cortex-M often crashes immediately on x86 with sanitizers, catching shift overflows and out-of-bounds accesses early.

Keeping Host and Target Builds in Sync

A common failure mode is letting the host and target builds diverge — adding a file to one Makefile but not the other. I avoid this by using a single source list variable in CMake or by having Ceedling’s project.yml include the same src/ glob as the target build. The only difference is the HAL implementation selection: host links hal_*_fake.c and mocks, target links hal_*_stm32.c. This guarantees that application code compiled in CI is identical to what ships.

Host tests do not replace on-target validation. I still maintain a smaller suite of hardware-in-the-loop tests that verify clock configuration, DMA throughput, and low-power modes — things that cannot be meaningfully faked. But because all the logic and error handling is already proven on the host, those hardware tests become smoke tests rather than debug sessions. When a hardware test does fail, I know the issue is in the HAL implementation or integration, not in the business logic, which dramatically narrows the search.

Starting small is effective. Pick one module with clear hardware dependencies — a button debounce, a packet parser, or a power manager — create a HAL for it, write three Unity tests with one CMock mock, and run it on your host. Once that loop is working, expanding to the rest of the codebase becomes incremental rather than overwhelming.

Frequently Asked Questions

Can I use Unity and CMock if my firmware already depends heavily on vendor HAL headers?

Yes, but you will need to introduce an intermediate layer. Leave vendor HAL calls inside your HAL implementation files and create your own header with clean function prototypes for the application to include. You can then run CMock on your headers, not the vendor ones. In my experience, refactoring one peripheral at a time — starting with GPIO or UART — limits risk and lets you prove the workflow before touching more complex drivers.

How do I test code that uses FreeRTOS APIs like queues and task delays on the host?

Abstract the RTOS calls behind your own interface or use the FreeRTOS host port. For simple cases, I define wrappers like hal_queue_send() and fake them on the host with a ring buffer. For more faithful scheduling tests, FreeRTOS provides a Windows and POSIX simulator that runs on the host, which is useful but slower. For pure logic tests, I prefer fakes with controllable time so tests remain deterministic and fast without requiring a full scheduler.

Should every HAL function be mocked with CMock, or are fakes better for some peripherals?

Use CMock for stateless, call-verification scenarios and fakes for stateful peripherals. I mock GPIO, SPI, and I2C transactions where I care about exact arguments and order. I write manual fakes for flash, EEPROM, filesystems, and timers where the test needs persistent state across multiple calls. A fake that stores written data in a RAM buffer lets you write a byte and read it back in the same test, which is cumbersome with strict mocks.

How do I handle timing-sensitive code and 32-bit tick rollover in host tests?

Make time injectable via a hal_time_now() fake and use unsigned arithmetic for interval checks: (now - start) >= timeout works correctly across rollover. In host tests, initialize the fake time near 0xFFFFFFF0 and advance past zero to verify rollover handling. This catches the classic signed-comparison bug where now > (start + timeout) fails after overflow, a bug that is nearly impossible to reproduce on hardware without waiting 49 days.

Related Articles

References & Standards: FreeRTOS Documentation · Zephyr Project Documentation · MQTT Specification