Test Types & Methods

HIL Testing for Embedded Systems

Set up a HIL bench for embedded systems with Python: fixture plugs, firmware version tracking, CI integration and unattended runs.

JJulien Buteau
intermediate12 min readMarch 14, 2026

Hardware-in-the-loop testing runs your real firmware on the real target, with the environment around it simulated. This guide is the practical setup: what the bench needs, how to structure the test so it survives firmware churn, and how to run it unattended in CI.

For the conceptual comparison against SIL and MIL, see HIL vs SIL vs MIL.

What the Bench Needs

ComponentRoleTypical choice
Target boardRuns the firmware under testSTM32, ESP32, RP2040 dev board or prototype
Debug probeFlash and reset the targetJ-Link, ST-Link, CMSIS-DAP
GPIO adapterStimulate inputs, read outputsLabJack, Arduino, RPi GPIO
DAQAnalog stimulus and captureNI DAQ, LabJack, ADS1115
Programmable supplyPower cycling, current measurementRigol DP800, Keysight E36xx
Bus interfaceCAN, LIN, I2C, SPI as neededPCAN, Kvaser, FTDI
Test hostRuns the procedureMini PC or Raspberry Pi at the bench

The host lives at the bench permanently. That is what makes HIL a CI target rather than a manual exercise.

Structuring the Test

A HIL suite has a shape that differs from production FCT: the unit under test is the same board every time, and what changes is the firmware. Everything follows from that.

Plugs for the bench hardware

plugs/programmer.py
import subprocessclass Programmer:    """Flashes the target and reports what is on it."""    def flash(self, image: str) -> bool:        result = subprocess.run(            ["pyocd", "flash", "--target", "stm32f407vg", image],            capture_output=True,        )        return result.returncode == 0    def reset(self):        subprocess.run(["pyocd", "reset", "--target", "stm32f407vg"])
plugs/gpio.py
class Gpio:    """GPIO adapter. Replace with LabJack, Arduino or RPi.GPIO calls."""    def __init__(self):        self._state = {}    def set_pin(self, pin: int, value: bool):        self._state[pin] = value    def read_pin(self, pin: int) -> bool:        return self._state.get(pin, False)
plugs/dut_console.py
class DutConsole:    """Serial console on the target."""    def query_version(self) -> str:        return "2.1.0"  # Replace with a real serial exchange    def measure_boot_ms(self) -> int:        return 840    def free_heap_bytes(self) -> int:        return 48120

Phases

phases/flash_firmware.py
def flash_firmware(measurements, programmer):    measurements.flash_ok = programmer.flash("build/firmware.bin")    programmer.reset()
phases/boot_check.py
def boot_check(measurements, dut_console):    measurements.firmware_version = dut_console.query_version()    measurements.boot_time = dut_console.measure_boot_ms()
phases/gpio_loopback.py
def gpio_loopback(measurements, gpio):    gpio.set_pin(1, True)    measurements.gpio_loopback = gpio.read_pin(1)
phases/memory_check.py
def memory_check(measurements, dut_console):    measurements.free_heap = dut_console.free_heap_bytes()

The procedure

procedure.yaml
59 lines
name: ECU HIL Regressionversion: 1.0.0description: Per-build HIL regression on the bench targetunit:  serial_number:    default_value: "BENCH-01"  part_number:    default_value: "ECU-100"plugs:  - name: programmer    python: plugs.programmer:Programmer  - name: gpio    python: plugs.gpio:Gpio  - name: dut_console    python: plugs.dut_console:DutConsolemain:  - name: Flash Firmware    python: phases.flash_firmware    measurements:      - name: Flash Ok        validators:          - operator: "=="            expected_value: true  - name: Boot Check    python: phases.boot_check    depends_on: [flash_firmware]    measurements:      - name: Firmware Version        validators:          - operator: "=="            expected_value: "2.1.0"      - name: Boot Time        unit: ms        validators:          - operator: "<="            expected_value: 2000  - name: GPIO Loopback    python: phases.gpio_loopback    depends_on: [boot_check]    measurements:      - name: GPIO Loopback        validators:          - operator: "=="            expected_value: true  - name: Memory Check    python: phases.memory_check    depends_on: [boot_check]    measurements:      - name: Free Heap        unit: bytes        validators:          - operator: ">="            expected_value: 32768

Flash runs first and everything waits on it. Boot check follows. GPIO and memory both depend on boot but not on each other, so they run concurrently.

Run it with tofupilot run ./procedure.yaml.

The Unit Is the Bench, the Variable Is the Build

This is the structural decision that makes a HIL suite useful over time. In production the serial number identifies a board; on a HIL bench the same board runs thousands of times, and what you actually want to compare across runs is the firmware build.

Keep the serial as the bench identifier, and put the build on the run as metadata:

phases/record_build.py
def record_build(run):    import os    run.metadata["firmware_version"] = os.environ.get("FW_VERSION", "dev")    run.metadata["git_sha"] = os.environ.get("GIT_SHA", "")    run.metadata["branch"] = os.environ.get("GIT_BRANCH", "")    run.metadata["build_number"] = os.environ.get("BUILD_NUMBER", "")

With that in place, the bench's run history is a regression history: boot time and free heap per build, and a commit to point at when either moves.

Running It in CI

HIL has no operator, so the identify prompt must resolve from the procedure and any UI phases need pre-baked values:

ci/run_hil.sh
set -euo pipefailexport FW_VERSION="$(cat build/version.txt)"export GIT_SHA="${GITHUB_SHA:-}"export GIT_BRANCH="${GITHUB_REF_NAME:-}"export BUILD_NUMBER="${GITHUB_RUN_NUMBER:-}"tofupilot run ./procedure.yaml \  --upload \  --json \  --ui-values ci/ui.json \  --ui-timeout 300

ui.json can be an empty map when there are no operator inputs to answer:

ci/ui.json
{}

The CLI exits non-zero when the run fails, so the job fails on a regression without extra scripting.

The runner must be a self-hosted runner at the bench. A cloud runner has no target board attached.

What to Measure

CategoryMeasurementWhy it catches regressions
BootBoot time in msInitialisation creeping up
MemoryFree heap, stack high-waterLeaks, stack growth
TimingInterrupt latency in µsScheduling regressions
PeripheralsGPIO, ADC, PWM, bus readsDriver or pinmux breakage
PowerSleep current, wake latencyPower management regressions
WatchdogRecovery after induced hangSafety behaviour
Long-runUptime before faultCounter rollovers, slow leaks

Boot time and free heap are the two that pay for themselves fastest. Both drift slowly, both are invisible in unit tests, and both are trivially measurable here.

Keeping the Bench Honest

Power-cycle between runs. A target that was never reset carries state from the previous build, which produces intermittent results nobody can reproduce.

Fail the flash step loudly. If flashing silently fails, every later measurement describes the previous firmware, and the suite reports green on a build that was never tested. That is why flash_ok is a measurement with a validator rather than an unchecked side effect.

Record the bench identity when you have more than one, so a bench-specific fixture problem does not look like a firmware regression.

If You Already Run OpenHTF

Existing suites run natively on the TofuPilot CLI:

hil_main.py
20 lines
import openhtf as htffrom openhtf.util import unitsfrom tofupilot.openhtf import upload@htf.measures(    htf.Measurement("boot_time_ms")    .in_range(maximum=2000)    .with_units(units.MILLISECOND),    htf.Measurement("free_heap").in_range(minimum=32768),)def phase_boot(test):    test.measurements.boot_time_ms = 840    test.measurements.free_heap = 48120    test.metadata["firmware_version"] = "2.1.0"test = htf.Test(phase_boot, part_number="ECU-100")test.add_output_callbacks(upload())test.execute(test_start=lambda: "BENCH-01")

Install with pip install "tofupilot[openhtf]", or skip the callback and run it with tofupilot run.

Common Mistakes

MistakeConsequenceFix
Not verifying the flash succeededGreen results describing the old firmwareMake flash_ok a validated measurement
No power cycle between runsIntermittent, unreproducible failuresReset or power-cycle in a setup phase
Firmware version not recordedNo regression history, only scattered runsPut build metadata on every run
Cloud CI runnerNo hardware attachedSelf-hosted runner at the bench
Prompts left in a CI procedureThe job hangs forever--ui-values plus --ui-timeout
One phase for the whole suiteA single failure hides everything downstreamSplit phases along dependency lines

More Guides

Put this guide into practice