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
| Component | Role | Typical choice |
|---|---|---|
| Target board | Runs the firmware under test | STM32, ESP32, RP2040 dev board or prototype |
| Debug probe | Flash and reset the target | J-Link, ST-Link, CMSIS-DAP |
| GPIO adapter | Stimulate inputs, read outputs | LabJack, Arduino, RPi GPIO |
| DAQ | Analog stimulus and capture | NI DAQ, LabJack, ADS1115 |
| Programmable supply | Power cycling, current measurement | Rigol DP800, Keysight E36xx |
| Bus interface | CAN, LIN, I2C, SPI as needed | PCAN, Kvaser, FTDI |
| Test host | Runs the procedure | Mini 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
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"])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)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 48120Phases
def flash_firmware(measurements, programmer): measurements.flash_ok = programmer.flash("build/firmware.bin") programmer.reset()def boot_check(measurements, dut_console): measurements.firmware_version = dut_console.query_version() measurements.boot_time = dut_console.measure_boot_ms()def gpio_loopback(measurements, gpio): gpio.set_pin(1, True) measurements.gpio_loopback = gpio.read_pin(1)def memory_check(measurements, dut_console): measurements.free_heap = dut_console.free_heap_bytes()The procedure
procedure.yaml59 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: 32768Flash 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:
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:
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 300ui.json can be an empty map when there are no operator inputs to answer:
{}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
| Category | Measurement | Why it catches regressions |
|---|---|---|
| Boot | Boot time in ms | Initialisation creeping up |
| Memory | Free heap, stack high-water | Leaks, stack growth |
| Timing | Interrupt latency in µs | Scheduling regressions |
| Peripherals | GPIO, ADC, PWM, bus reads | Driver or pinmux breakage |
| Power | Sleep current, wake latency | Power management regressions |
| Watchdog | Recovery after induced hang | Safety behaviour |
| Long-run | Uptime before fault | Counter 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.py20 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
| Mistake | Consequence | Fix |
|---|---|---|
| Not verifying the flash succeeded | Green results describing the old firmware | Make flash_ok a validated measurement |
| No power cycle between runs | Intermittent, unreproducible failures | Reset or power-cycle in a setup phase |
| Firmware version not recorded | No regression history, only scattered runs | Put build metadata on every run |
| Cloud CI runner | No hardware attached | Self-hosted runner at the bench |
| Prompts left in a CI procedure | The job hangs forever | --ui-values plus --ui-timeout |
| One phase for the whole suite | A single failure hides everything downstream | Split phases along dependency lines |
