DEV Community

Pranav Jain
Pranav Jain

Posted on Originally published at pranavhj.github.io

How to Test Firmware Without Physical Hardware

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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)    │
│          │ ──────────────── │           │
└──────────┘                  └──────────┘
Enter fullscreen mode Exit fullscreen mode

A Raspberry Pi (or any Linux machine) connected to your dev kit via SWD (for flashing) and serial (for output). The CI pipeline:

  1. Flashes the firmware via OpenOCD / pyOCD / nrfjprog
  2. Resets the board
  3. Reads serial output
  4. Asserts on expected output
  5. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)