Testing Embedded Bootloaders: U-Boot, Custom Bootloaders, OTA Update Verification

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

  1. Minimal runtime environment — no OS, no malloc, no libc in many cases
  2. Hardware dependencies — flash memory, UART, watchdog timer
  3. State machine complexity — boot slot selection, retry counts, fallback logic
  4. Destructive tests — testing rollback means deliberately flashing a bad image
  5. 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 output

OTA 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 output

U-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 --build

U-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 -v

Hardware OTA tests run only on main (on a self-hosted runner with physical hardware). Unit and QEMU tests run on every PR.

Read more

Start now free