5.5.20 · D2Embedded Systems & Real-Time Software

Visual walkthrough — Software testing in embedded — unit tests on host, HIL testing

2,259 words10 min readBack to topic

We build on the parent topic and lean on ideas from Hardware Abstraction Layer (HAL) Design and Mocking and Stubbing in C.


Step 1 — The problem: logic glued to silicon

WHAT. Look at one ordinary firmware function. It reads a sensor, does a calculation, and writes a motor output — all in three lines.

void update_motor(void) {
    uint16_t adc = ADC1->DR;      // (1) read hardware register
    int pwm = compute_pwm(adc);   // (2) pure math
    TIM2->CCR1 = pwm;             // (3) write hardware register
}

Term by term:

  • ADC1->DR ::: the Analog-to-Digital Converter's Data Register — a real address that only exists inside the chip. On your laptop there is no such address.
  • compute_pwm(adc) ::: pure arithmetic — turns a number into a number. This runs anywhere.
  • TIM2->CCR1 ::: a TIMer Capture/Compare Register — again, a physical address only present on the microcontroller (MCU).

WHY. We want to ask: "can I test this on my laptop?" The answer is no — line (1) and line (3) touch memory addresses that do not exist off-chip. The moment we run it, it crashes or reads garbage.

PICTURE. The figure shows the function as a sandwich: two red "hardware" slices with a yellow "logic" filling. The red slices are what nail the code to the silicon.

Figure — Software testing in embedded — unit tests on host, HIL testing

Step 2 — The cut: name the seam between logic and hardware

WHAT. We draw a line — literally — between "code that computes" and "code that touches the chip". Every hardware access gets a name, a function call, instead of a raw register poke.

void update_motor(void) {
    uint16_t adc = hal_adc_read(ADC_CHANNEL_1);   // named door IN
    int pwm = compute_pwm(adc);                    // pure logic
    hal_pwm_set(PWM_CHANNEL_1, pwm);               // named door OUT
}
  • hal_ prefix ::: Hardware Abstraction Layer — a promise: "behind this name lives some implementation; I don't care which."
  • hal_adc_read(...) ::: an input door. On the chip it reads ADC1->DR. On the laptop it can read a variable instead.
  • hal_pwm_set(...) ::: an output door. On the chip it writes TIM2->CCR1. On the laptop it can just record what was written.

WHY use a function name and not the register directly? Because a name is a swap point. A register address is fixed; a function call is a question — "whoever answers hal_adc_read, give me a number." We can now let different people answer in different builds. This is exactly the Hardware Abstraction Layer (HAL) Design idea.

PICTURE. The figure redraws the sandwich with two labelled doors where the red slices used to be. The yellow logic in the middle is now reachable from either side of a door.

Figure — Software testing in embedded — unit tests on host, HIL testing

Step 3 — Two answers for the same door (the two builds)

WHAT. A door needs someone behind it. We write two implementations of the same door name:

// hal_stm32.c  — the REAL answer (compiled for the chip)
uint16_t hal_adc_read(int ch) { return ADC1->DR; }
 
// mock_hal.c   — the FAKE answer (compiled for the laptop)
static uint16_t fake_adc[4];
void     mock_set_adc(int ch, uint16_t v) { fake_adc[ch] = v; }
uint16_t hal_adc_read(int ch)             { return fake_adc[ch]; }
  • The real version reads silicon.
  • The mock version reads a plain array you fill in. This is Mocking and Stubbing in C: a stand-in that lets you inject inputs and inspect outputs freely.

WHY two files, not an if? Because we never want test-only code shipping to the chip, and we never want register pokes attempted on the laptop. We choose the answer at compile time by picking which .c file we link. Same update_motor, two universes.

PICTURE. One door on the left; two candidate hands reaching through it from behind — a red "silicon" hand and a green "mock" hand. A switch labelled "which .c do you link?" selects one.

Figure — Software testing in embedded — unit tests on host, HIL testing

Step 4 — Tier 1 built: the host unit test

WHAT. With the mock door in place, a laptop test writes an input into the mock, runs the real logic, and asserts on the recorded output — no chip involved.

void test_motor_pwm_cap(void) {
    mock_set_adc(ADC_CHANNEL_1, 4095);   // (a) inject max reading
    update_motor();                       // (b) run REAL logic
    assert(hal_pwm_get(PWM_CHANNEL_1) == 1000); // (c) check the cap
}
  • 4095 ::: the largest value a 12-bit ADC returns, since . This is the maximum case.
  • assert(... == 1000) ::: checks that the PWM was clamped at its ceiling (1000), not allowed to overshoot.

WHY is this fast and cheap? Because it's just x86 arithmetic on an array. No flashing, no wires, no oscilloscope. It runs in microseconds, so you can run thousands per second on every commit — the Continuous Integration for Embedded workflow.

What it CATCHES: logic bugs — wrong formulas, missing clamps, off-by-one, integer overflow, state-machine mistakes. What it CANNOT catch: anything about time or real electrons. The mock returns instantly and perfectly; real ADCs are noisy and slow.

PICTURE. The whole loop drawn on the laptop side: test → mock input → logic → mock output → assert, with a stopwatch reading "µs".

Figure — Software testing in embedded — unit tests on host, HIL testing

Step 5 — The gap host tests leave behind

WHAT. List, on one figure, everything the mock pretended away: instant reads, noise-free values, no interrupt latency, no clock drift, one peripheral at a time.

WHY this step exists. If we stopped at Step 4 we'd ship bugs that only appear on real hardware. The mock's biggest lie is time: it answers in zero time, so it can never tell you whether your interrupt actually responds within its deadline.

Formally, a real-time system has a promise: an event must produce a reaction within a fixed budget .

  • ::: the actual time from stimulus to response, measured on real silicon.
  • ::: the contract deadline (e.g. an interrupt must fire a pin within ).
  • The strict "" ::: missing the deadline even once is a failure; real-time means every case, not the average.

A host test has no at all — its clock is fake. So the inequality is unmeasurable on the host. That gap is exactly what Tier 2 fills.

PICTURE. Two columns: green "host can see" (math, logic, edge values) vs red "host is blind to" (timing, noise, race conditions, multi-peripheral interaction).

Figure — Software testing in embedded — unit tests on host, HIL testing

Step 6 — Tier 2 built: Hardware-in-the-Loop (HIL)

WHAT. We keep the same firmware, but now link the real HAL and put the board inside a physical loop. A test controller plays the part of the world: it feeds fake sensor voltages in and reads real outputs back.

The five pieces of a HIL rig:

  1. DUTDevice Under Test: your board running production firmware.
  2. Test Controller — a PC running the test script.
  3. Stimulus — a DAC / signal generator that makes analog voltages the DUT's ADC will read.
  4. Capture — a logic analyzer or oscilloscope reading the DUT's pins.
  5. Bus — UART / CAN carrying commands and telemetry.

WHY a real loop? Because now every red thing from Step 5 is actually present: real ADC noise, real interrupt latency, real clock. The controller can also do Fault Injection Testing — glitch a voltage, drop a wire — to test failure paths a mock could never mimic honestly.

PICTURE. The closed loop: Controller → DAC → (DUT ADC → firmware → DUT PWM) → Analyzer → back to Controller, with the RTOS running inside the DUT.

Figure — Software testing in embedded — unit tests on host, HIL testing

Step 7 — HIL measures the one thing host cannot: real time

WHAT. Trigger a change and timestamp the physical response with the analyzer.

def test_motor_response_time():
    start = time.perf_counter()
    rig.set_dac_voltage(ADC_THROTTLE, 0.0)     # command: cut throttle
    while rig.measure_pwm(PWM_MOTOR) > 5.0:     # watch real PWM fall
        pass
    response = time.perf_counter() - start
    assert response < 0.010                      # 10 ms deadline
  • set_dac_voltage(..., 0.0) ::: the stimulus — the "event" in .
  • measure_pwm(...) > 5.0 ::: watching the real reaction cross a threshold.
  • response < 0.010 ::: this is literally from Step 5 — now measurable because the clock is real.

WHY the busy-wait loop? We can't compute when the PWM drops; the RTOS scheduler, interrupts, and I/O all add unpredictable delay. So we watch reality until it happens, and clock it.

Edge cases HIL must cover (each its own test, not shown here): worst-case interrupt storm (highest ), zero input (does PWM go fully off?), max input (does it clamp like the host said?), and a fault-injected sensor short.

PICTURE. A timeline: stimulus edge (yellow) at , response edge (green) at , deadline wall (red) at — pass when green lands left of red.

Figure — Software testing in embedded — unit tests on host, HIL testing

Step 8 — Why not just do everything in HIL? (the degenerate strategy)

WHAT. Imagine skipping Tier 1 entirely and testing every logic branch on hardware.

WHY it fails. HIL is slow (seconds per test), scarce (one physical rig), and hard to debug (a red pin edge doesn't tell you which line is wrong). Push logic bugs there and your feedback loop collapses.

The correct division of labour:

The arrow means order: catch cheap bugs first, spend precious rig time only on what genuinely needs silicon.

PICTURE. A funnel: a wide fast host stage catches most bugs; a narrow slow HIL stage catches the timing/hardware few. Cost-per-test rises left to right.

Figure — Software testing in embedded — unit tests on host, HIL testing

The one-picture summary

Everything above on one canvas: the single update_motor seam splits into two doors; two builds answer those doors; the host tier tests logic in microseconds; the HIL tier tests real time in the closed loop; and a funnel routes bugs cheapest-first.

Figure — Software testing in embedded — unit tests on host, HIL testing
Recall Feynman retelling — say it in plain words

We started with three lines of firmware where a calculation was glued between two hardware pokes, so it couldn't leave the chip. We gave each poke a name (hal_...) — a door instead of a wall. A door can have two people behind it: the real silicon, or a fake we control. On our laptop we put the fake behind the door, hand our code any input we like, run the real math, and check the answer — all in microseconds, thousands of times a second. But the fake lies about time: it answers instantly, so it can never prove our interrupt beats its deadline. So we build a second setup — hardware-in-the-loop — where the real firmware runs on the real board inside a physical loop, a machine plays the sensors, and a logic analyzer times the actual response against the deadline. Host tests are fast, cheap, and catch logic bugs; HIL tests are slow, real, and catch timing and hardware bugs. We do host first, HIL second — a funnel that spends the expensive rig only on what truly needs it.

Recall Quick checks

Why can't update_motor (Step 1) run on a laptop? ::: It reads/writes ADC1->DR and TIM2->CCR1, memory addresses that exist only inside the MCU. What single change makes it testable? ::: Route hardware access through named hal_ functions — a swap point (a door) instead of a fixed register. What does the mock replace, and what does it let you do? ::: It replaces the real hardware door with a plain array; it lets you inject inputs and inspect outputs. Which tier can measure , and why? ::: HIL — the host's clock is faked by the mock (zero-time answers), so only real silicon gives a real timestamp. Why not run all tests on HIL? ::: HIL is slow, scarce, and hard to debug; you'd lose the fast feedback loop. Do cheap host logic tests first.