Most firmware teams test by flashing the board and watching an LED blink. That works until your test matrix is 200 cases, your CI pipeline needs to run on every commit, and the dev kit is on someone else's desk.
Here's how I test firmware without hardware for roughly 80% of the test surface. The remaining 20% — timing-critical code, analog peripherals, RF — still needs real silicon. But 80% is enough to catch the bugs that matter.
The Testing Pyramid for Firmware
Borrow the concept from web development but adapt it:
/\
/ \ Hardware-in-the-loop (HIL)
/ \ Real board, real peripherals
/------\
/ \ Integration tests on target
/ \ QEMU or native_sim
/------------\
/ \ Unit tests on host (x86)
/________________\ Mocked HAL, no hardware
Most of your tests should be at the bottom. Fast, cheap, run on any machine.
Level 1: Unit Tests on Host (x86)
This is the highest-ROI testing strategy for firmware. Compile your application code for your development machine, mock the hardware interfaces, test with any C test framework.
The Setup
Your code needs to be structured so that application logic doesn't directly call vendor HAL functions:
// application/sensor_reader.c
#include "hal/spi.h"
#include "hal/gpio.h"
#define SENSOR_CS_PIN ((gpio_pin_t){.port = 0, .pin = 4})
int sensor_read_temperature(int16_t *temp_out) {
uint8_t cmd = 0x80; // Read temperature register
uint8_t rx[2] = {0};
gpio_write(SENSOR_CS_PIN, 0);
int err = spi_transfer(SPI_BUS_0, &cmd, rx, 2);
gpio_write(SENSOR_CS_PIN, 1);
if (err != 0) return err;
*temp_out = (int16_t)((rx[0] << 8) | rx[1]) / 16;
return 0;
}
Now mock the HAL for host testing:
// test/mocks/mock_spi.c
#include "hal/spi.h"
#include <string.h>
static uint8_t spi_rx_buffer[256];
static size_t spi_rx_len = 0;
static int spi_fail_next = 0;
void mock_spi_set_rx_data(const uint8_t *data, size_t len) {
memcpy(spi_rx_buffer, data, len);
spi_rx_len = len;
}
void mock_spi_set_fail(int fail) {
spi_fail_next = fail;
}
int spi_transfer(spi_bus_t bus, const uint8_t *tx, uint8_t *rx, size_t len) {
if (spi_fail_next) {
spi_fail_next = 0;
return -1;
}
if (rx && spi_rx_len >= len) {
memcpy(rx, spi_rx_buffer, len);
}
return 0;
}
And the test:
// test/test_sensor_reader.c
#include "unity.h" // or any C test framework
#include "application/sensor_reader.h"
#include "test/mocks/mock_spi.h"
void test_sensor_read_temperature_normal(void) {
// 25.0°C = 400 raw = 0x0190
uint8_t fake_data[] = {0x01, 0x90};
mock_spi_set_rx_data(fake_data, 2);
int16_t temp;
int err = sensor_read_temperature(&temp);
TEST_ASSERT_EQUAL(0, err);
TEST_ASSERT_EQUAL(25, temp);
}
void test_sensor_read_temperature_spi_failure(void) {
mock_spi_set_fail(1);
int16_t temp;
int err = sensor_read_temperature(&temp);
TEST_ASSERT_NOT_EQUAL(0, err);
}
void test_sensor_read_negative_temperature(void) {
// -10.0°C = -160 raw = 0xFF60
uint8_t fake_data[] = {0xFF, 0x60};
mock_spi_set_rx_data(fake_data, 2);
int16_t temp;
int err = sensor_read_temperature(&temp);
TEST_ASSERT_EQUAL(0, err);
TEST_ASSERT_EQUAL(-10, temp);
}
Compile and run on your laptop:
gcc -o test_sensor test/test_sensor_reader.c \
application/sensor_reader.c \
test/mocks/mock_spi.c test/mocks/mock_gpio.c \
-Iinclude -Itest/frameworks/unity/src \
test/frameworks/unity/src/unity.c
./test_sensor
Runs in milliseconds. No hardware. Catches logic bugs, edge cases, error handling.
What You Can Test This Way
- Data parsing and protocol decoding
- State machines
- Command handlers
- Configuration validation
- Error handling paths
- Math and algorithms
- Buffer management
- Anything that doesn't depend on timing or real peripherals
What You CAN'T Test This Way
- Real-time behavior (ISR latency, DMA timing)
- Peripheral initialization sequences
- Power management
- Analog signal paths
- RF communication
- Boot sequences
Level 2: QEMU for ARM Targets
QEMU emulates ARM Cortex-M processors well enough to run firmware images. It won't emulate your specific board's peripherals, but it handles the CPU, memory map, NVIC, and SysTick.
Zephyr + QEMU
Zephyr has first-class QEMU support:
# Build for QEMU Cortex-M3
west build -b qemu_cortex_m3 samples/hello_world
west build -t run
# Output:
# *** Booting Zephyr OS build v3.x.0 ***
# Hello World! qemu_cortex_m3
This runs your Zephyr application in QEMU — including the kernel, scheduler, and any drivers that have QEMU backends.
What QEMU Gives You
- RTOS task scheduling and synchronization testing
- Memory allocation and stack overflow detection
- Kernel API correctness
- Multi-threaded logic bugs
What QEMU Doesn't Give You
- Real peripheral behavior (SPI, I2C, GPIO are stubs or absent)
- Real timing (QEMU runs faster or slower than real hardware)
- Board-specific initialization
Level 3: Zephyr native_sim (Best of Both Worlds)
This is my favorite approach. Zephyr's native_sim target compiles your firmware as a native Linux/macOS executable. It uses POSIX threads to simulate Zephyr's threading model, and you can link against host-side libraries.
west build -b native_sim samples/hello_world
./build/zephyr/zephyr.exe
Why this is powerful:
// Your Zephyr application
#include <zephyr/kernel.h>
#include <zephyr/drivers/gpio.h>
void main(void) {
const struct device *gpio = DEVICE_DT_GET(DT_NODELABEL(gpio0));
gpio_pin_configure(gpio, 13, GPIO_OUTPUT);
while (1) {
gpio_pin_toggle(gpio, 13);
k_msleep(500);
}
}
On native_sim, this compiles to a normal executable. The GPIO driver is a stub that logs calls. You can add assertions, inject faults, and run under Valgrind or AddressSanitizer:
west build -b native_sim -DCONFIG_ASAN=y my_app
./build/zephyr/zephyr.exe
# AddressSanitizer catches buffer overflows, use-after-free, etc.
Level 4: Hardware-in-the-Loop (HIL)
For the 20% that needs real hardware, automate it:
┌──────────┐ USB/SWD ┌──────────┐
│ CI Host │ ──────────────── │ Dev Kit │
│ (RPi) │ Serial │ (DUT) │
│ │ ──────────────── │ │
└──────────┘ └──────────┘
A Raspberry Pi (or any Linux machine) connected to your dev kit via SWD (for flashing) and serial (for output). The CI pipeline:
- Flashes the firmware via OpenOCD / pyOCD / nrfjprog
- Resets the board
- Reads serial output
- Asserts on expected output
- Reports pass/fail
#!/bin/bash
# hil_test.sh
pyocd flash build/firmware.hex
pyocd reset
timeout 10 cat /dev/ttyACM0 | grep -q "SELF_TEST: PASS"
if [ $? -eq 0 ]; then
echo "HIL test PASSED"
else
echo "HIL test FAILED"
exit 1
fi
CI Pipeline Example
# .github/workflows/firmware-test.yml
name: Firmware Tests
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build and run unit tests
run: |
mkdir build && cd build
cmake .. -DTARGET=host_test
make
ctest --output-on-failure
zephyr-native:
runs-on: ubuntu-latest
container: ghcr.io/zephyrproject-rtos/ci:latest
steps:
- uses: actions/checkout@v4
- name: Build for native_sim
run: |
west build -b native_sim app
timeout 30 ./build/zephyr/zephyr.exe || true
- name: Run with ASAN
run: |
west build -b native_sim app -- -DCONFIG_ASAN=y
timeout 30 ./build/zephyr/zephyr.exe
# HIL tests run on self-hosted runner with physical board
hil-tests:
runs-on: self-hosted # RPi with connected dev kit
needs: [unit-tests, zephyr-native]
steps:
- uses: actions/checkout@v4
- name: Flash and test
run: ./scripts/hil_test.sh
Unit tests and native_sim run on every commit (free, fast). HIL tests run on merge to main (needs hardware, slower).
Practical Advice
Start with unit tests on host. If your code can't compile for x86 because of vendor HAL dependencies, that's the first problem to fix. Introduce a HAL interface, mock it, get your application code compiling on the host.
Use Unity or CMock for C testing. They're lightweight, embedded-friendly, and widely used. Unity is just a single .c and .h file.
Don't mock too much. If you're mocking 15 interfaces to test one function, your function is too coupled. Refactor.
Keep tests fast. All host-side tests should complete in under 10 seconds. If they don't, something is wrong.
Test error paths. The happy path usually works. The bugs are in: SPI timeout handling, buffer overflow on unexpected response length, negative temperature values, config validation edge cases.
Measure coverage, but don't worship it. 80% coverage with meaningful tests beats 100% coverage with trivial assertions.
The Payoff
On a recent project, we had 340 unit tests running on x86, 20 integration tests on native_sim, and 12 HIL tests on a self-hosted runner. The unit tests caught 90% of bugs before they ever touched hardware. The average debug cycle went from "flash, observe, wonder, flash again" (15 minutes) to "run test, see failure, fix, run test" (30 seconds).
It's more work upfront. But firmware debugging is expensive — an hour saved per bug, across hundreds of bugs, across the life of a project, is measured in weeks.
Pranav Jain is an embedded systems engineer focused on middleware, abstraction layers, and developer tooling for firmware teams. Find him on GitHub.
Top comments (0)