Hardware-in-the-loop (HIL) testing validates embedded firmware by stimulating real hardware inputs and measuring real outputs. Unlike pure simulation, HIL tests run on the actual target hardware with real peripherals. This catches bugs that simulation misses: timing issues, ADC noise, interrupt conflicts, and peripheral initialization problems. This guide shows you how to build a Python-based HIL test system with OpenHTF and TofuPilot.
HIL vs SIL vs MIL
| Method | Hardware | Software | Fidelity | Speed | Cost |
|---|---|---|---|---|---|
| MIL (Model-in-the-Loop) | None | Simulated plant + controller | Low | Fast (seconds) | Low |
| SIL (Software-in-the-Loop) | None | Real controller code on host | Medium | Fast (seconds) | Low |
| HIL (Hardware-in-the-Loop) | Real target board | Real firmware | High | Real-time | Medium |
| Physical test | Real target + real plant | Real firmware | Highest | Real-time | High |
HIL sits between SIL and physical testing. You test real firmware on real hardware, but simulate the environment (sensors, actuators, external systems).
When to Use HIL Testing
- Firmware regression testing. Every firmware build gets the same physical tests. Catches regressions that unit tests miss.
- Peripheral validation. GPIO, ADC, DAC, PWM, UART, SPI, I2C. Real silicon behaves differently than simulation models.
- Safety-critical systems. Automotive, medical, aerospace require HIL testing for certification (ISO 26262, IEC 62304).
- Pre-production validation. Validate firmware on real hardware before committing to production.
HIL Test Architecture
A typical Python HIL setup:
| Component | Role | Example |
|---|---|---|
| Target board (DUT) | Runs firmware under test | STM32 dev board, ESP32, Raspberry Pi Pico |
| Test host | Runs Python test scripts | PC or Raspberry Pi |
| DAQ / GPIO adapter | Stimulate inputs, read outputs | Arduino, LabJack, NI DAQ |
| Power supply | Controlled power to DUT | Bench PSU (programmable) |
| Serial/debug | Flash firmware, send commands | USB-to-serial, J-Link, ST-Link |
| Instruments | Precision measurements | DMM, oscilloscope |
Step 1: Create Hardware Interface Plugs
Each piece of test hardware gets an OpenHTF Plug. This keeps the test phases clean and instrument-agnostic.
import openhtf as htf
from openhtf.plugs import BasePlug
class GpioPlug(BasePlug):
"""GPIO interface for stimulating DUT inputs and reading outputs.
Replace with real GPIO adapter (e.g., LabJack, Arduino, RPi.GPIO).
"""
def setUp(self):
self._pin_states = {}
def set_pin(self, pin: int, state: bool):
"""Set a GPIO pin on the test fixture (DUT input)."""
self._pin_states[pin] = state
def read_pin(self, pin: int) -> bool:
"""Read a GPIO pin from the DUT (DUT output)."""
return self._pin_states.get(pin, False)
def tearDown(self):
self._pin_states.clear()
class AdcPlug(BasePlug):
"""ADC interface for reading analog outputs from DUT.
Replace with real DAQ (e.g., NI DAQmx, LabJack, ADS1115).
"""
def setUp(self):
self._channels = {0: 1.65, 1: 2.50, 2: 0.33}
def read_channel(self, channel: int) -> float:
"""Read an ADC channel voltage."""
return self._channels.get(channel, 0.0)
def tearDown(self):
pass
class PwmPlug(BasePlug):
"""PWM measurement interface.
Replace with real frequency counter or oscilloscope.
"""
def setUp(self):
pass
def measure_frequency(self) -> float:
"""Measure PWM output frequency in Hz."""
return 1000.0
def measure_duty_cycle(self) -> float:
"""Measure PWM duty cycle in percent."""
return 50.0
def tearDown(self):
passStep 2: Write GPIO Tests
The simplest HIL test: set an input, check the firmware drives the correct output.
import openhtf as htf
@htf.measures(
htf.Measurement("gpio_loopback")
.equals(True)
.doc("GPIO pin loopback: set input, verify output follows"),
)
@htf.plug(gpio=GpioPlug)
def test_gpio_loopback(test, gpio):
"""Verify firmware routes GPIO input to output correctly."""
gpio.set_pin(1, True) # Stimulate DUT input
readback = gpio.read_pin(1) # Read DUT output
test.measurements.gpio_loopback = readbackStep 3: Write ADC/Sensor Tests
Simulate sensor inputs and verify the DUT reads them correctly.
import openhtf as htf
from openhtf.util import units
@htf.measures(
htf.Measurement("adc_channel_0")
.in_range(1.5, 1.8)
.with_units(units.VOLT)
.doc("ADC ch0: 1.65V reference voltage"),
htf.Measurement("adc_channel_1")
.in_range(2.3, 2.7)
.with_units(units.VOLT)
.doc("ADC ch1: 2.5V sensor output"),
)
@htf.plug(adc=AdcPlug)
def test_adc_readings(test, adc):
"""Verify ADC channels read expected voltages."""
test.measurements.adc_channel_0 = adc.read_channel(0)
test.measurements.adc_channel_1 = adc.read_channel(1)Step 4: Write PWM Tests
Verify the firmware generates the correct PWM signal (frequency and duty cycle).
import openhtf as htf
from openhtf.util import units
@htf.measures(
htf.Measurement("pwm_frequency")
.in_range(950, 1050)
.with_units(units.HERTZ)
.doc("PWM output frequency (target: 1kHz)"),
htf.Measurement("pwm_duty_cycle")
.in_range(48, 52)
.doc("PWM duty cycle percentage (target: 50%)"),
)
@htf.plug(pwm=PwmPlug)
def test_pwm_output(test, pwm):
"""Verify PWM output frequency and duty cycle."""
test.measurements.pwm_frequency = pwm.measure_frequency()
test.measurements.pwm_duty_cycle = pwm.measure_duty_cycle()Step 5: Assemble the HIL Test
Connect all phases with TofuPilot for tracking firmware version regressions over time.
import openhtf as htf
from tofupilot.openhtf import TofuPilot
def main():
test = htf.Test(
test_gpio_loopback,
test_adc_readings,
test_pwm_output,
procedure_id="HIL-001",
part_number="ECU-100",
)
with TofuPilot(test):
test.execute(test_start=lambda: input("Firmware version: "))
if __name__ == "__main__":
main()Use the serial number prompt to enter the firmware version. This lets TofuPilot track which firmware builds pass or fail each test, building a regression history.
HIL in CI/CD
The real power of HIL testing comes from running it automatically on every firmware build.
| CI/CD Step | Action |
|---|---|
| 1. Code push | Developer pushes firmware change |
| 2. Build | CI compiles firmware binary |
| 3. Flash | CI flashes binary to target board (via J-Link/ST-Link) |
| 4. HIL test | CI runs OpenHTF test script |
| 5. Results | TofuPilot logs pass/fail per measurement |
| 6. Gate | CI fails the build if any measurement is out of spec |
This requires a dedicated test station connected to your CI runner. The test station has the target board, GPIO adapter, DAQ, and instruments. The CI runner (Jenkins, GitHub Actions self-hosted runner, etc.) triggers the test after each build.
Common HIL Test Categories
| Category | What to Test | Measurements |
|---|---|---|
| GPIO | Input/output routing, interrupt response | Pin state, response time |
| ADC | Sensor reading accuracy, noise | Voltage values, noise floor |
| DAC | Output voltage accuracy | Voltage values |
| PWM | Frequency, duty cycle, resolution | Frequency, duty cycle |
| Communication | UART, SPI, I2C, CAN data integrity | Bit error rate, response time |
| Timing | Interrupt latency, task scheduling | Response time in microseconds |
| Power | Sleep mode current, wake-up time | Current draw, wake-up latency |
| Watchdog | Recovery from hang | Reset detection, recovery time |
HIL vs Production FCT
HIL and FCT test different things at different stages:
| HIL | FCT | |
|---|---|---|
| When | Development, every firmware build | Production, every manufactured board |
| What | Firmware behavior on real hardware | Board assembly and system function |
| DUT | Dev board or prototype | Production board |
| Changes between tests | Firmware (code changes) | Hardware (manufacturing variation) |
| Failure means | Firmware bug (code fix) | Assembly defect (rework or scrap) |
| Run frequency | Every CI build | Every unit manufactured |
| TofuPilot use | Track regressions across firmware versions | Track FPY, Cpk, control charts |
Many teams use the same OpenHTF test framework for both HIL and FCT. The plugs change (dev board GPIO vs. production fixture), but the test structure is the same.