QEMU Firmware Testing: Emulate Embedded Targets Without Physical Hardware

QEMU Firmware Testing: Emulate Embedded Targets Without Physical Hardware

Waiting for physical hardware to run firmware tests is a bottleneck that slows embedded development to a crawl. Hardware is expensive, limited in quantity, and can't run in CI. QEMU emulation changes that — you can run and test ARM Cortex-M firmware on any Linux machine, including cloud CI runners.

This guide covers practical QEMU-based firmware testing: how to emulate your target, run unit tests, and integrate everything into a CI pipeline.

Why Emulate for Testing?

Before hardware arrives, you need to validate logic. Even after hardware arrives, emulation is better for:

  • Unit tests — run hundreds of tests in seconds without flashing
  • CI/CD — every PR runs firmware tests automatically
  • Debugging — QEMU's GDB integration is better than many hardware debuggers
  • Edge cases — inject hardware faults that are hard to reproduce physically
  • Regression testing — never find out a working feature broke in production

QEMU for ARM Cortex-M

QEMU supports a growing list of ARM Cortex-M targets. Common ones:

# List supported ARM machine types
qemu-system-arm -machine help | grep -E "mps2|stm32|lm3s|virt"

# Common targets:
# mps2-an385    — Cortex-M3 (ARM MPS2+ board)
# mps2-an500    — Cortex-M7  
# stm32vldiscovery — STM32F100
# netduinoplus2  — STM32F405

Running Firmware in QEMU

Basic Execution

# Build your firmware (example: ARM GCC for Cortex-M3)
arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb \
  -T linker.ld \
  -o firmware.elf \
  src/*.c

# Run in QEMU (mps2-an385 emulates Cortex-M3)
qemu-system-arm \
  -machine mps2-an385 \
  -cpu cortex-m3 \
  -kernel firmware.elf \
  -nographic \
  -semihosting-config enable=on,target=native

Semihosting for I/O

Semihosting lets your firmware use the host's stdin/stdout during testing — no UART hardware needed:

// In your firmware: use semihosting for test output
#include <stdio.h>  // Links to semihosting when -specs=rdimon.specs

int main(void) {
    printf("Firmware booted\n");
    
    // Run your tests
    int result = run_all_tests();
    
    printf("Tests %s. Result: %d\n", result == 0 ? "PASSED" : "FAILED", result);
    
    // Exit with test result code (semihosting)
    _exit(result);
}

Build with semihosting:

arm-none-eabi-gcc \
  -mcpu=cortex-m3 -mthumb \
  -specs=rdimon.specs \
  -lrdimon \
  -T linker.ld \
  -o test_firmware.elf \
  src/*.c tests/*.c

Unity Test Framework on QEMU

Unity is the standard unit test framework for embedded C. Combine it with QEMU:

// tests/test_sensor_driver.c
#include "unity.h"
#include "sensor_driver.h"

void setUp(void) {
    sensor_init();
}

void tearDown(void) {
    sensor_deinit();
}

void test_sensor_read_returns_valid_range(void) {
    int16_t reading = sensor_read_temperature();
    TEST_ASSERT_INT_WITHIN(50, 0, reading);  // -50 to +50 °C
}

void test_sensor_read_increments_counter(void) {
    uint32_t before = sensor_get_read_count();
    sensor_read_temperature();
    uint32_t after = sensor_get_read_count();
    TEST_ASSERT_EQUAL(before + 1, after);
}

void test_sensor_error_flag_clears_on_read(void) {
    sensor_inject_error(SENSOR_ERR_TIMEOUT);  // Test hook
    TEST_ASSERT_TRUE(sensor_has_error());
    
    sensor_read_temperature();  // Should clear error
    TEST_ASSERT_FALSE(sensor_has_error());
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_sensor_read_returns_valid_range);
    RUN_TEST(test_sensor_read_increments_counter);
    RUN_TEST(test_sensor_error_flag_clears_on_read);
    return UNITY_END();
}

Run on QEMU:

# Build test binary
arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb \
  -specs=rdimon.specs -lrdimon \
  -T tests/test_linker.ld \
  -I src/ -I unity/src/ \
  unity/src/unity.c \
  src/sensor_driver.c \
  tests/test_sensor_driver.c \
  -o test_sensor.elf

# Run on QEMU, capture output
timeout 10 qemu-system-arm \
  -machine mps2-an385 \
  -cpu cortex-m3 \
  -kernel test_sensor.elf \
  -nographic \
  -semihosting-config enable=on,target=native \
  2>&1

# Exit code from QEMU reflects firmware's _exit() code

Renode: A Better Alternative for Complex Targets

Renode from Antmicro is a more powerful emulator for complex embedded systems, especially when you need peripheral simulation:

# Install Renode
wget https://github.com/renode/renode/releases/latest/download/renode-*.linux-portable.tar.gz
tar -xf renode-*.tar.gz

# Create a Renode script for your board
cat > tests/test_platform.resc << 'EOF'
using sysbus

mach create "test-board"
machine LoadPlatformDescription @platforms/boards/stm32f4_discovery.repl

sysbus LoadELF $ORIGIN/test_firmware.elf

# Set up serial output capture
showAnalyzer sysbus.uart2

# Run for up to 10 seconds
machine StartGdbServer 3333

emulation RunFor "00:00:10"
EOF

# Run tests
renode --disable-gui tests/test_platform.resc

Renode Peripheral Mocking

Renode lets you script peripheral behavior in Python:

# tests/mock_sensor.py — Renode peripheral mock
from Antmicro.Renode.Peripherals.SPI import ISPIPeripheral

class MockTemperatureSensor(ISPIPeripheral):
    def __init__(self):
        self.temperature = 25  # degrees C
        self.call_count = 0
    
    def Transmit(self, data):
        self.call_count += 1
        if data == 0x01:  # Read temperature command
            # Return temperature as 2-byte big-endian
            return [(self.temperature >> 8) & 0xFF, self.temperature & 0xFF]
        return [0xFF]  # NACK for unknown commands

GDB-Based Test Debugging

QEMU's GDB server lets you debug firmware tests exactly like you would hardware:

# Start QEMU with GDB server
qemu-system-arm \
  -machine mps2-an385 \
  -cpu cortex-m3 \
  -kernel test_firmware.elf \
  -nographic \
  -semihosting-config enable=on,target=native \
  -S -gdb tcp::3333 &  # -S = start paused

# Connect GDB
arm-none-eabi-gdb test_firmware.elf << 'EOF'
target remote :3333
load
break main
continue
# Set breakpoint on test failure
break unity_fail
continue
quit
EOF

CI/CD Integration

# .github/workflows/firmware-tests.yml
name: Firmware Tests

on:
  push:
    paths: ['src/**', 'tests/**', 'CMakeLists.txt']

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive
      
      - name: Install ARM toolchain
        run: |
          sudo apt-get update
          sudo apt-get install -y \
            gcc-arm-none-eabi \
            binutils-arm-none-eabi \
            qemu-system-arm
      
      - name: Build test firmware
        run: |
          mkdir build && cd build
          cmake -DCMAKE_TOOLCHAIN_FILE=../cmake/arm-none-eabi.cmake \
                -DBUILD_TESTING=ON \
                -DCMAKE_BUILD_TYPE=Debug \
                ..
          make -j$(nproc)
      
      - name: Run tests on QEMU
        run: |
          EXIT_CODE=0
          for test_elf in build/tests/*.elf; do
            echo "Running: $test_elf"
            timeout 30 qemu-system-arm \
              -machine mps2-an385 \
              -cpu cortex-m3 \
              -kernel "$test_elf" \
              -nographic \
              -semihosting-config enable=on,target=native \
              2>&1 | tee /tmp/test_output.txt
            
            TEST_EXIT=$?
            
            if [ $TEST_EXIT -ne 0 ] || grep -q "FAIL" /tmp/test_output.txt; then
              echo "FAILED: $test_elf"
              EXIT_CODE=1
            else
              echo "PASSED: $test_elf"
            fi
          done
          exit $EXIT_CODE

CMake Configuration

# CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(firmware C)

set(CMAKE_C_STANDARD 11)

# Main firmware
add_executable(firmware
    src/main.c
    src/sensor_driver.c
    src/ring_buffer.c
)

target_compile_options(firmware PRIVATE
    -mcpu=cortex-m3
    -mthumb
    -Wall -Wextra
    -Os
)

if(BUILD_TESTING)
    enable_testing()
    add_subdirectory(tests)
endif()

# tests/CMakeLists.txt
add_executable(test_sensor
    test_sensor_driver.c
    ${CMAKE_SOURCE_DIR}/src/sensor_driver.c
    ${CMAKE_SOURCE_DIR}/unity/src/unity.c
)

target_include_directories(test_sensor PRIVATE
    ${CMAKE_SOURCE_DIR}/src
    ${CMAKE_SOURCE_DIR}/unity/src
)

target_compile_options(test_sensor PRIVATE
    -mcpu=cortex-m3 -mthumb
    -specs=rdimon.specs
)

target_link_options(test_sensor PRIVATE
    -T${CMAKE_CURRENT_SOURCE_DIR}/test_linker.ld
    -specs=rdimon.specs
    -lrdimon
)

add_test(
    NAME sensor_driver_tests
    COMMAND qemu-system-arm 
        -machine mps2-an385 
        -cpu cortex-m3
        -kernel test_sensor
        -nographic
        -semihosting-config enable=on,target=native
)

What to Test on QEMU vs. Real Hardware

QEMU is great for testing logic. It's not a substitute for hardware in all cases:

Test Type QEMU Real Hardware
Business logic in firmware ✓ Fast and reliable
State machine transitions
Protocol parsing
Memory layout / alignment
Interrupt timing Approximate ✓ Required
DMA transfers Limited ✓ Required
RF/radio behavior ✓ Required
Power consumption ✓ Required
Analog peripherals (ADC) Limited ✓ Required

The right strategy: use QEMU for the 80% of logic tests that don't require hardware, and reserve physical hardware tests for the 20% that do.

Summary

QEMU enables a proper test-driven workflow for firmware:

  1. Write Unity tests alongside your C code
  2. Build test binaries with the ARM cross-compiler and semihosting
  3. Run on QEMU — fast, no hardware needed, works in CI
  4. Use GDB integration for debugging test failures
  5. Automate with GitHub Actions on every push

The result: instant feedback on firmware regressions, independent of hardware availability.

Read more

Start now free