Testing Embedded Bootloaders: U-Boot, Custom Bootloaders, OTA Update Verification
```tldr Bootloader testing covers three areas: unit testing the bootloader's logic (image validation, signature verification, boot selection), integration testing in QEMU (does the bootloader actually boot the right image?), and end-to-end OTA testing (does an update succeed, and does rollback work when the new image fails?). Most of this runs without physical hardware. ```
```takeaways **Unit test image validation and boot selection logic on the host.** Functions like validate_firmware_header() and select_boot_slot() are pure logic — test them with Unity/CMock or pytest, no hardware needed.
QEMU tests bootloader execution. Run your bootloader binary in QEMU and assert on serial output — does it boot the correct image? Does it handle corrupted images?
Test rollback explicitly. Flash a "bad" image that panics on boot, run the watchdog timeout cycle, and assert the bootloader falls back to the previous image.
Signature verification tests need both valid and invalid keys. Test that unsigned images are rejected, revoked keys fail, and valid images with correct signatures boot.
OTA update tests must cover power loss at every stage. Interrupted writes during flash are the most common production bootloader failure — simulate them in integration tests. ```
What Makes Bootloaders Hard to Test
- Minimal runtime environment — no OS, no malloc, no libc in many cases
- Hardware dependencies — flash memory, UART, watchdog timer
- State machine complexity — boot slot selection, retry counts, fallback logic
- Destructive tests — testing rollback means deliberately flashing a bad image
- Timing sensitivity — watchdog expiry, flash write timing
The solution: separate pure logic from hardware, test logic on the host, test execution in QEMU.
Unit Testing Boot Slot Selection
// bootloader/include/boot_manager.h
#define SLOT_A 0
#define SLOT_B 1
#define MAX_BOOT_RETRIES 3
typedef struct {
uint32_t magic;
uint32_t version;
uint8_t slot;
uint8_t boot_retries;
uint8_t confirmed;
uint8_t reserved;
uint32_t crc;
} BootConfig;
typedef enum {
BOOT_SLOT_A,
BOOT_SLOT_B,
BOOT_RECOVERY,
} BootDecision;
BootDecision select_boot_slot(const BootConfig *config);
bool validate_firmware_header(const uint8_t *header, size_t header_size);
uint32_t compute_crc32(const uint8_t *data, size_t len);// test/test_boot_manager.c
#include "unity.h"
#include "boot_manager.h"
void test_selects_slot_a_when_confirmed_and_zero_retries(void) {
BootConfig cfg = {
.slot = SLOT_A,
.confirmed = 1,
.boot_retries = 0
};
TEST_ASSERT_EQUAL(BOOT_SLOT_A, select_boot_slot(&cfg));
}
void test_selects_slot_b_when_slot_a_exceeded_retries(void) {
BootConfig cfg = {
.slot = SLOT_A,
.confirmed = 0,
.boot_retries = MAX_BOOT_RETRIES
};
TEST_ASSERT_EQUAL(BOOT_SLOT_B, select_boot_slot(&cfg));
}
void test_selects_recovery_when_both_slots_exhausted(void) {
BootConfig cfg = {
.slot = SLOT_B,
.confirmed = 0,
.boot_retries = MAX_BOOT_RETRIES
};
TEST_ASSERT_EQUAL(BOOT_RECOVERY, select_boot_slot(&cfg));
}
void test_increments_retry_count_on_unconfirmed_boot(void) {
BootConfig cfg = {
.slot = SLOT_A,
.confirmed = 0,
.boot_retries = 1
};
select_boot_slot(&cfg); // side effect: increments retry count
TEST_ASSERT_EQUAL(2, cfg.boot_retries);
}Firmware Image Validation
#define FIRMWARE_MAGIC 0xFE0BADED
#define FIRMWARE_MAX_SIZE (512 * 1024) // 512KB
typedef struct __attribute__((packed)) {
uint32_t magic;
uint32_t version;
uint32_t image_size;
uint32_t crc32;
uint8_t signature[64]; // Ed25519
} FirmwareHeader;
bool validate_firmware_header(const uint8_t *data, size_t data_len) {
if (data_len < sizeof(FirmwareHeader)) return false;
const FirmwareHeader *hdr = (const FirmwareHeader *)data;
if (hdr->magic != FIRMWARE_MAGIC) return false;
if (hdr->image_size > FIRMWARE_MAX_SIZE) return false;
if (hdr->image_size == 0) return false;
// CRC check over header (excluding CRC field itself)
uint32_t computed = compute_crc32(data, offsetof(FirmwareHeader, crc32));
return computed == hdr->crc32;
}void test_valid_header_passes_validation(void) {
FirmwareHeader hdr = {
.magic = FIRMWARE_MAGIC,
.version = 2,
.image_size = 128 * 1024,
};
hdr.crc32 = compute_crc32((uint8_t*)&hdr, offsetof(FirmwareHeader, crc32));
TEST_ASSERT_TRUE(validate_firmware_header((uint8_t*)&hdr, sizeof(hdr)));
}
void test_wrong_magic_fails_validation(void) {
FirmwareHeader hdr = { .magic = 0xDEADBEEF, .image_size = 64*1024 };
TEST_ASSERT_FALSE(validate_firmware_header((uint8_t*)&hdr, sizeof(hdr)));
}
void test_oversized_image_fails_validation(void) {
FirmwareHeader hdr = {
.magic = FIRMWARE_MAGIC,
.image_size = 2 * 1024 * 1024 // over 512KB limit
};
TEST_ASSERT_FALSE(validate_firmware_header((uint8_t*)&hdr, sizeof(hdr)));
}
void test_truncated_header_fails_validation(void) {
uint8_t partial[] = {0xED, 0xAD, 0x0B, 0xFE, 0x01}; // magic only, truncated
TEST_ASSERT_FALSE(validate_firmware_header(partial, sizeof(partial)));
}
void test_corrupted_crc_fails_validation(void) {
FirmwareHeader hdr = {
.magic = FIRMWARE_MAGIC,
.image_size = 64 * 1024,
.crc32 = 0xBADC0FFE // wrong CRC
};
TEST_ASSERT_FALSE(validate_firmware_header((uint8_t*)&hdr, sizeof(hdr)));
}QEMU-Based Bootloader Integration Tests
# test_bootloader_qemu.py
import subprocess
import time
import pytest
import os
QEMU_CMD = "qemu-system-arm"
MACHINE = "lm3s6965evb"
BOOTLOADER_BIN = "build/bootloader.elf"
def run_bootloader_in_qemu(firmware_path, timeout=10):
"""Run bootloader in QEMU and capture serial output."""
proc = subprocess.Popen(
[
QEMU_CMD,
"-M", MACHINE,
"-kernel", BOOTLOADER_BIN,
"-device", f"loader,file={firmware_path}",
"-serial", "stdio",
"-nographic",
"-semihosting"
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
try:
stdout, stderr = proc.communicate(timeout=timeout)
return stdout
except subprocess.TimeoutExpired:
proc.kill()
return proc.stdout.read()
def test_valid_firmware_boots_successfully():
output = run_bootloader_in_qemu("test_images/valid_v2.bin")
assert "Booting slot A" in output
assert "Application started" in output
assert "ERROR" not in output
def test_invalid_signature_rejected():
output = run_bootloader_in_qemu("test_images/unsigned_firmware.bin")
assert "Signature verification failed" in output
assert "Falling back to slot B" in output
def test_corrupted_image_triggers_rollback():
output = run_bootloader_in_qemu("test_images/corrupted_image.bin")
assert "CRC mismatch" in output
assert "Boot retry" in output
def test_recovery_mode_on_all_slots_failed():
output = run_bootloader_in_qemu("test_images/all_bad.bin")
assert "Entering recovery mode" in output
assert "Waiting for DFU upload" in outputOTA Update Rollback Testing
# test_ota_rollback.py
import pytest
import serial
import time
from pathlib import Path
class FirmwareTestFixture:
"""Manages an STM32 device via serial for OTA tests."""
def __init__(self, port="/dev/ttyUSB0", baud=115200):
self.port = port
self.baud = baud
def send_ota_image(self, firmware_path: Path):
"""Initiate OTA update and wait for result."""
with serial.Serial(self.port, self.baud, timeout=30) as ser:
# Send start-OTA command
ser.write(b"OTA_START\r\n")
resp = ser.readline()
assert b"OTA_READY" in resp
# Send firmware in chunks
data = firmware_path.read_bytes()
for i in range(0, len(data), 512):
chunk = data[i:i+512]
ser.write(chunk)
time.sleep(0.01)
ser.write(b"OTA_END\r\n")
result = ser.readline(timeout=60)
return result.decode()
def wait_for_boot(self, expected_version, timeout=30):
with serial.Serial(self.port, self.baud, timeout=timeout) as ser:
start = time.time()
while time.time() - start < timeout:
line = ser.readline().decode(errors='ignore')
if f"version={expected_version}" in line:
return True
return False
def get_current_version(self):
with serial.Serial(self.port, self.baud, timeout=5) as ser:
ser.write(b"VERSION\r\n")
return ser.readline().decode().strip()
@pytest.mark.hardware
def test_ota_rollback_on_bad_image(device: FirmwareTestFixture):
# Verify starting version
assert device.get_current_version() == "1.2.0"
# Flash a "bad" image that panics on boot
result = device.send_ota_image(Path("test_images/bad_v2.bin"))
assert "OTA_SUCCESS" in result
# Device reboots to new image — watchdog should trigger rollback
# Wait for rollback (watchdog timeout * MAX_RETRIES)
time.sleep(15)
# Should be back on v1.2.0
assert device.wait_for_boot("1.2.0", timeout=30)
current = device.get_current_version()
assert current == "1.2.0", f"Expected rollback to 1.2.0, got {current}"
@pytest.mark.hardware
def test_ota_success_and_confirmation(device: FirmwareTestFixture):
# Flash good v2 image
result = device.send_ota_image(Path("test_images/good_v2.bin"))
assert "OTA_SUCCESS" in result
# Device boots v2
assert device.wait_for_boot("2.0.0", timeout=30)
# Confirm boot (mark as stable)
with serial.Serial(device.port, device.baud, timeout=5) as ser:
ser.write(b"CONFIRM_BOOT\r\n")
resp = ser.readline()
assert b"CONFIRMED" in resp
# After reboot, still on v2 (not rolled back)
time.sleep(3)
assert device.get_current_version() == "2.0.0"Testing Power Loss During Flash Write
Simulate power loss by using QEMU's pausing capability:
def test_power_loss_during_ota_write():
"""Simulate power loss mid-write and verify bootloader recovers."""
import subprocess
import socket
# Start QEMU with GDB server
proc = subprocess.Popen([
"qemu-system-arm", "-M", MACHINE, "-kernel", BOOTLOADER_BIN,
"-serial", "stdio", "-nographic",
"-gdb", "tcp::1234", # GDB server
"-S", # Start paused
], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
# Connect via GDB, start execution, pause mid-OTA
import gdb_client
gdb = gdb_client.connect("localhost:1234")
gdb.set_breakpoint("flash_write_page") # break at flash write
gdb.continue_execution()
gdb.wait_for_breakpoint(timeout=30)
# Simulate power loss — kill QEMU
proc.kill()
proc.wait()
# Restart — bootloader should detect incomplete write and use backup slot
output = run_bootloader_in_qemu("test_images/valid_v1.bin")
assert "Incomplete OTA detected" in output or "Using slot B" in output
assert "Application started" in outputU-Boot Testing
For products using U-Boot:
# Build U-Boot for sandbox (runs on host)
make sandbox_defconfig
make -j$(nproc)
# Run U-Boot sandbox
./u-boot
# U-Boot test framework
make sandbox_defconfig
make check # runs built-in U-Boot unit tests
# Python-based U-Boot test suite
pytest test/py/ --bd=sandbox --buildU-Boot has its own Python test framework (test/py/) that spawns U-Boot in a subprocess and exercises commands via a serial-like interface. All standard boot commands, environment variables, and filesystem operations are testable without hardware.
CI Pipeline
name: Bootloader Tests
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: sudo apt-get install -y gcc ruby && gem install ceedling
- run: ceedling test:all
qemu-integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: sudo apt-get install -y qemu-system-arm gcc-arm-none-eabi
- run: make bootloader BOARD=lm3s6965evb
- run: pip install pytest pyserial
- run: pytest test/test_bootloader_qemu.py -v
hardware-tests:
runs-on: self-hosted # needs physical STM32 hardware
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- run: pytest test/test_ota_rollback.py -m hardware -vHardware OTA tests run only on main (on a self-hosted runner with physical hardware). Unit and QEMU tests run on every PR.