Embedded Systems Testing Guide: C/C++, QEMU, and Hardware Simulation
Embedded systems testing requires a different mindset than application testing — hardware may not be available, timing is critical, and failures can be physically dangerous. This guide covers unit testing embedded C/C++ with Unity and CppUTest, mocking hardware registers, emulating targets with QEMU, and wiring everything into a CI pipeline that runs without any physical hardware on the desk.
Key Takeaways
Hardware-independent tests run faster and catch more bugs early. By abstracting hardware access behind thin HAL interfaces, you can run the bulk of your test suite on a host machine with zero firmware flashing.
QEMU can emulate entire microcontroller families. ARM Cortex-M targets, RISC-V boards, and many others run in QEMU, letting you execute firmware binaries in CI without physical devices.
Mock hardware registers with writable function pointers or link-time substitution. These two techniques cover the vast majority of embedded HAL mocking without needing a full OS or OS-level mocking framework.
Unity is the standard for bare-metal C; CppUTest works well for C++ and mixed codebases. Both produce TAP or JUnit XML output compatible with every major CI system.
A layered architecture is a prerequisite for testability. Application logic that directly bangs hardware registers is nearly impossible to test; a HAL layer with a clean interface boundary makes mocking trivial.
Why Embedded Testing Is Hard
Embedded software runs on devices that may cost thousands of dollars, exist in single-digit quantities during development, live on a factory floor, or control safety-critical machinery. The constraints that make embedded development interesting also make testing difficult:
- Hardware scarcity. You may have one evaluation board shared across a team of six engineers.
- Target diversity. The same codebase may ship on three different MCU families with different peripherals.
- No OS abstractions. There is no malloc safety net, no virtual memory, no file system for test output.
- Timing sensitivity. An interrupt handler that takes 10 µs too long is a real bug that only manifests on hardware at load.
- Flashing latency. A compile-flash-run cycle on a microcontroller can take 30–90 seconds. Multiply by hundreds of tests and CI becomes unusable.
The solution is a testing pyramid where the fat base is host-based unit tests, the middle layer is emulation (QEMU), and only the thin top layer requires physical hardware.
Setting Up a Hardware Abstraction Layer
The single most important enabler for embedded unit testing is a clean Hardware Abstraction Layer (HAL). If your application logic calls HAL_GPIO_WritePin() instead of directly writing to GPIOA->ODR, you can swap the HAL for a fake in tests.
A minimal HAL for a GPIO driver looks like this:
/* hal_gpio.h */
#ifndef HAL_GPIO_H
#define HAL_GPIO_H
#include <stdint.h>
typedef enum { GPIO_LOW = 0, GPIO_HIGH = 1 } gpio_level_t;
void hal_gpio_write(uint32_t port, uint32_t pin, gpio_level_t level);
gpio_level_t hal_gpio_read(uint32_t port, uint32_t pin);
#endifThe production implementation writes to real registers. The test implementation writes to a struct you can inspect:
/* hal_gpio_fake.c */
#include "hal_gpio.h"
#include <string.h>
static gpio_level_t fake_pins[4][32]; /* 4 ports, 32 pins each */
void hal_gpio_write(uint32_t port, uint32_t pin, gpio_level_t level) {
fake_pins[port][pin] = level;
}
gpio_level_t hal_gpio_read(uint32_t port, uint32_t pin) {
return fake_pins[port][pin];
}
/* Test helper — reset all pin state */
void hal_gpio_fake_reset(void) {
memset(fake_pins, 0, sizeof(fake_pins));
}
gpio_level_t hal_gpio_fake_get(uint32_t port, uint32_t pin) {
return fake_pins[port][pin];
}Your application tests link against hal_gpio_fake.c instead of the real peripheral driver. No hardware needed.
Unit Testing with Unity
Unity is a minimal C unit testing framework consisting of three files (unity.c, unity.h, unity_internals.h). It was built for embedded targets and compiles with nearly any C89-compatible compiler.
Install via CMake FetchContent or just vendor the three files into your repository.
# CMakeLists.txt (test target)
cmake_minimum_required(VERSION 3.20)
project(firmware_tests C)
add_subdirectory(vendor/unity)
add_executable(test_led_driver
tests/test_led_driver.c
src/led_driver.c
fakes/hal_gpio_fake.c
)
target_include_directories(test_led_driver PRIVATE
src
fakes
vendor/unity/src
)
target_link_libraries(test_led_driver unity)A complete test file for an LED driver:
/* tests/test_led_driver.c */
#include "unity.h"
#include "led_driver.h"
#include "hal_gpio_fake.h"
#define LED_PORT 0
#define LED_PIN 5
void setUp(void) {
hal_gpio_fake_reset();
led_driver_init(LED_PORT, LED_PIN);
}
void tearDown(void) {}
void test_led_on_sets_pin_high(void) {
led_on();
TEST_ASSERT_EQUAL(GPIO_HIGH, hal_gpio_fake_get(LED_PORT, LED_PIN));
}
void test_led_off_sets_pin_low(void) {
led_on();
led_off();
TEST_ASSERT_EQUAL(GPIO_LOW, hal_gpio_fake_get(LED_PORT, LED_PIN));
}
void test_led_toggle_changes_state(void) {
led_on();
led_toggle();
TEST_ASSERT_EQUAL(GPIO_LOW, hal_gpio_fake_get(LED_PORT, LED_PIN));
led_toggle();
TEST_ASSERT_EQUAL(GPIO_HIGH, hal_gpio_fake_get(LED_PORT, LED_PIN));
}
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_led_on_sets_pin_high);
RUN_TEST(test_led_off_sets_pin_low);
RUN_TEST(test_led_toggle_changes_state);
return UNITY_END();
}Run with:
cmake -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build
./build/test_led_driverOutput:
tests/test_led_driver.c:18:test_led_on_sets_pin_high:PASS
tests/test_led_driver.c:24:test_led_off_sets_pin_low:PASS
tests/test_led_driver.c:30:test_led_toggle_changes_state:PASS
-----------------------
3 Tests 0 Failures 0 Ignored
OKUnit Testing with CppUTest
For C++ firmware or mixed C/C++ projects, CppUTest provides a richer assertion library and built-in memory leak detection — critical for embedded systems where heap corruption is silent and deadly.
/* tests/TestSensorFilter.cpp */
#include "CppUTest/TestHarness.h"
#include "sensor_filter.h"
TEST_GROUP(SensorFilter) {
SensorFilter filter;
void setup() {
filter.init(5); /* window size = 5 samples */
}
void teardown() {
filter.reset();
}
};
TEST(SensorFilter, AveragesWindowCorrectly) {
filter.push(10);
filter.push(20);
filter.push(30);
filter.push(40);
filter.push(50);
LONGS_EQUAL(30, filter.average());
}
TEST(SensorFilter, IgnoresOutliers) {
filter.push(100); /* outlier */
filter.push(10);
filter.push(10);
filter.push(10);
filter.push(10);
CHECK(filter.filtered_average() < 20);
}CppUTest's memory leak detection runs automatically between tests — if your code allocates in setup() and forgets to free in teardown(), the test fails with a detailed allocation trace.
Mocking Hardware Registers
Some code is harder to abstract — interrupt service routines, DMA descriptors, linker-placed register structs. Two techniques cover most cases.
Technique 1: Redefinable register structs
The CMSIS headers define peripherals as structs placed at fixed addresses via linker symbols. For testing, you can redefine those addresses to point at a local struct:
/* In test build only — override the real GPIOA address */
#ifdef UNIT_TEST
static GPIO_TypeDef fake_GPIOA;
#define GPIOA (&fake_GPIOA)
#endif
#include "gpio_driver.c" /* compile the driver into the test */
void test_output_mode_sets_moder_bits(void) {
gpio_set_output(GPIOA, 5);
uint32_t mode = (fake_GPIOA.MODER >> (5 * 2)) & 0x3;
TEST_ASSERT_EQUAL(0x1, mode); /* 0b01 = output */
}This technique requires no changes to production code at all.
Technique 2: Function pointer HAL
Replace direct register access with function pointers, defaulting to the real implementation:
/* hal_uart.h */
typedef void (*uart_send_byte_fn)(uint8_t);
extern uart_send_byte_fn hal_uart_send_byte;/* hal_uart.c */
static void real_uart_send(uint8_t byte) {
while (!(USART1->SR & USART_SR_TXE));
USART1->DR = byte;
}
uart_send_byte_fn hal_uart_send_byte = real_uart_send;In tests, swap the pointer:
static uint8_t captured_bytes[256];
static int byte_count = 0;
static void fake_uart_send(uint8_t byte) {
captured_bytes[byte_count++] = byte;
}
void setUp(void) {
byte_count = 0;
hal_uart_send_byte = fake_uart_send;
}QEMU for Firmware Emulation
QEMU supports dozens of ARM Cortex-M machines. The mps2-an385 machine emulates an ARM Cortex-M3 with enough peripherals to run FreeRTOS and many real firmware stacks.
Install QEMU with ARM support:
# Ubuntu/Debian
<span class="hljs-built_in">sudo apt install qemu-system-arm
<span class="hljs-comment"># macOS
brew install qemuBuild your firmware for QEMU's machine:
set(CMAKE_SYSTEM_NAME Generic)
set(CMAKE_SYSTEM_PROCESSOR arm)
set(CMAKE_C_COMPILER arm-none-eabi-gcc)
add_compile_options(-mcpu=cortex-m3 -mthumb -mfloat-abi=soft)
add_link_options(-T ${CMAKE_SOURCE_DIR}/linker/mps2_m3.ld
-nostartfiles --specs=rdimon.specs)Run in QEMU with semihosting output (printf goes to your terminal):
qemu-system-arm \
-machine mps2-an385 \
-cpu cortex-m3 \
-kernel build/firmware.elf \
-nographic \
-semihosting \
-semihosting-config enable=on,target=nativeFor automated testing, capture the exit code:
qemu-system-arm \
-machine mps2-an385 \
-kernel build/test_suite.elf \
-nographic \
-semihosting \
-semihosting-config enable=on,target=native \
-serial stdio 2>&1 <span class="hljs-pipe">| <span class="hljs-built_in">tee test_output.txtUse exit() from your test runner to propagate pass/fail to the QEMU process exit code, which CI can then read directly.
CI Pipeline for Firmware
A complete GitHub Actions pipeline that runs host-native tests and QEMU tests in parallel:
# .github/workflows/firmware-tests.yml
name: Firmware Tests
on: [push, pull_request]
jobs:
host-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: sudo apt install -y cmake gcc
- name: Build host tests
run: |
cmake -B build-host \
-DCMAKE_BUILD_TYPE=Debug \
-DTARGET=host
cmake --build build-host
- name: Run host tests
run: |
cd build-host
ctest --output-on-failure --output-junit ../results/host-tests.xml
qemu-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install toolchain and QEMU
run: |
sudo apt install -y qemu-system-arm
wget -q https://developer.arm.com/-/media/Files/downloads/gnu/13.2.rel1/binrel/arm-gnu-toolchain-13.2.rel1-x86_64-arm-none-eabi.tar.xz
tar -xf arm-gnu-toolchain-*.tar.xz -C /opt
echo "/opt/arm-gnu-toolchain-13.2.Rel1-x86_64-arm-none-eabi/bin" >> $GITHUB_PATH
- name: Build firmware tests for QEMU
run: |
cmake -B build-qemu \
-DCMAKE_BUILD_TYPE=Debug \
-DTARGET=qemu-cm3 \
-DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi.cmake
cmake --build build-qemu
- name: Run QEMU tests
run: |
timeout 60 qemu-system-arm \
-machine mps2-an385 \
-kernel build-qemu/test_suite.elf \
-nographic \
-semihosting \
-semihosting-config enable=on,target=nativeStructuring Your Repository for Testability
A directory layout that keeps tests clean and compilation fast:
firmware/
├── src/
│ ├── app/ # application logic (no direct register access)
│ ├── drivers/ # hardware drivers (thin, HAL-based)
│ └── hal/ # HAL interface headers only
├── platform/
│ ├── stm32f4/ # real HAL implementations for STM32F4
│ └── qemu-cm3/ # QEMU HAL implementations
├── fakes/
│ ├── hal_gpio_fake.c
│ ├── hal_uart_fake.c
│ └── hal_timer_fake.c
├── tests/
│ ├── unit/ # host-native, fastest
│ ├── integration/ # QEMU, medium speed
│ └── hardware/ # real target, slowest
└── CMakeLists.txtThe platform/ directory contains the only code that differs between host, QEMU, and real hardware builds. Everything in src/app/ and src/drivers/ compiles identically everywhere.
Measuring Code Coverage
For host-native tests, GCC's --coverage flag produces gcov data that lcov can turn into an HTML report:
cmake -B build-cov \
-DCMAKE_C_FLAGS="--coverage" \
-DCMAKE_EXE_LINKER_FLAGS=<span class="hljs-string">"--coverage"
cmake --build build-cov
./build-cov/test_suite
lcov --capture --directory build-cov --output-file coverage.info
lcov --remove coverage.info <span class="hljs-string">'*/vendor/*' <span class="hljs-string">'*/tests/*' --output-file coverage-filtered.info
genhtml coverage-filtered.info --output-directory coverage-report
<span class="hljs-built_in">echo <span class="hljs-string">"Coverage report: coverage-report/index.html"Aim for >80% line coverage on src/app/ and src/drivers/. The HAL implementation files in platform/stm32f4/ are acceptable to leave uncovered by unit tests — they are exercised by hardware integration tests.
Common Pitfalls
Endianness assumptions. Host machines are typically x86 (little-endian); your target may be big-endian. Use fixed-width types (uint32_t, not unsigned long) and __builtin_bswap32() where byte order matters. Test both byte orders explicitly.
Volatile stripping. The host compiler may optimize away reads to volatile registers that your fakes replace with plain memory. Add #define volatile in test builds only if needed, but prefer proper fake implementations.
Integer promotion differences. Some ARM compilers promote uint8_t arithmetic differently than GCC on x86. Test boundary values (255, 0, 127) explicitly to catch sign-extension bugs.
Missing startup code. QEMU tests need a minimal startup.s that initializes .data and .bss. Many off-the-shelf startup files work; --specs=rdimon.specs provides one for Cortex-M.
Conclusion
Embedded testing is tractable when you approach it systematically. Start with a clean HAL boundary, write host-native unit tests with Unity or CppUTest, graduate to QEMU integration tests for firmware-level verification, and reserve physical hardware for the final layer. This pyramid gives you fast feedback at the unit level, reasonable confidence at the integration level, and targeted validation at the hardware level — without requiring a lab bench for every developer or CI agent.