A menu-driven firmware UI is miserable to regression-test: the interesting logic lives behind a physical control panel, a UART link, and a boot sequence that only runs on the target. You want a CI job that boots the real firmware, "presses" keys, and asserts the menu landed where it should — with no board on anyone's desk. This is the story of getting there in Renode: the naive approaches, the HardFault that killed the first one, the probes that explained the second, and the composition-based peripheral that finally made it work.

What you'll learn

The goal: a navigation test with no hardware

The target is an STM32H7 firmware whose UI is driven by key and touch events that arrive as framed messages over a UART link from a detachable control panel. A "test" is a short journey: boot, enter a submenu, go one level deeper, press back, and assert the on-screen menu matches each step. On hardware that means a human and a panel. The ambition was to run exactly that journey in Renode, headless, in CI.

Renode boots the unmodified firmware ELF against a modelled STM32H7, so the firmware itself is under test — its real input parser, its real menu state machine. The only question was how to deliver input the way the firmware actually consumes it.

The unit under test

The unit under test is the firmware ELF running on an STM32H7 MCU — not a host-compiled stand-in. On hardware it sits between two devices: a touch LCD it drives over a display link, and a separate keyboard MCU that scans the physical keys and the LCD's touch surface, debounces both, and forwards clean events over a UART link. The firmware never sees raw switch bounce or raw touch samples; it consumes ready-made, framed key and touch events from a single input stream. Renode models the MCU and its LCD so the display path renders as usual, which leaves exactly one input seam to reproduce: the stream of debounced key and touch events arriving on the UART. (The event framing is the firmware's own private contract and is deliberately left out of this post.)

%%{init: {'theme': 'base', 'themeVariables': {
  'primaryColor':       '#edb059',
  'primaryTextColor':   '#222222',
  'primaryBorderColor': '#c8951f',
  'lineColor':          '#5a5450',
  'secondaryColor':     '#ede9e4',
  'secondaryTextColor': '#2d2a26',
  'tertiaryColor':      '#222222',
  'tertiaryTextColor':  '#edb059',
  'background':         '#faf8f5',
  'mainBkg':            '#faf8f5',
  'noteBkgColor':       '#f2c880',
  'noteTextColor':      '#222222',
  'clusterBkg':         '#faf8f5',
  'clusterBorder':      '#ede9e4',
  'titleColor':         '#2d2a26'
}}}%%
flowchart LR
    KEYS["Physical keys"]:::hw
    TOUCH["LCD touch surface"]:::hw
    KBD["Keyboard MCU\nscan + debounce"]:::hw
    UUT["Firmware ELF\non STM32H7\n(unit under test)"]:::accent
    LCD["Touch LCD panel"]:::hw

    KEYS -->|key presses| KBD
    TOUCH -->|touch points| KBD
    KBD -->|"debounced key + touch events (UART)"| UUT
    UUT -->|"display link"| LCD

    classDef accent fill:#edb059,color:#222222,stroke:#c8951f
    classDef hw fill:#5a5450,color:#faf8f5,stroke:#5a5450

The unit under test is the STM32H7 firmware itself. A dedicated keyboard MCU scans both the physical keys and the LCD's touch surface, debounces them, and hands the firmware a single stream of key and touch events over UART; the firmware drives the LCD. Renode supplies the MCU and LCD — only that UART event stream has to be synthesised.

Attempt one: drive it through the debugger — HardFault

The first instinct is the debugger. Attach gdb to Renode's gdb stub, asynchronously interrupt the core, and either poke the input state directly or call a firmware input routine. It worked a few times, then started throwing HardFaults that had nothing to do with the test logic.

The cause is timing, not code. An asynchronous debugger interrupt stops the core at an arbitrary instruction — frequently while it is already inside an interrupt handler. Resuming after a poke or an injected call corrupts the saved context, and the firmware faults on return. The test was perturbing the system so hard that it broke the thing it was trying to observe.

ℹ️ What this means — if you don't live in debuggers: a HardFault is the ARM Cortex-M "something went badly wrong" trap — a bad memory access, a stack problem, a faulted exception return. Here it wasn't a firmware bug; the measurement method was creating it. A test harness that destabilises the target is worse than no test.

Synchronous, well-fenced debugger calls (attach, call at a known-safe point, detach) are far safer and have their place. But they stay bound to gdb, and the end goal was a clean, gdb-free suite. So: skip the debugger entirely.

Attempt two: write the bytes into RAM — nothing happens

If the firmware receives into a RAM buffer, why not write a valid input frame straight into that buffer over Renode's system bus and let the firmware pick it up? No debugger, no async interrupt, no HardFault. Clean.

And completely inert. The bytes sat in RAM; the firmware never reacted.

The reason is how a high-throughput STM32 UART receiver actually works. It does not interrupt per byte. Bytes are moved into a buffer by DMA, and the firmware is woken only when the UART raises an IDLE-line interrupt — the "the line went quiet, a frame just ended" signal. Writing bytes into RAM reproduces the DMA's side effect but never raises the IDLE event that gates processing. Without that interrupt, the receive path never runs.

ℹ️ What this means: DMA copies peripheral data into memory without CPU involvement; the IDLE line interrupt fires when the UART sees an idle gap after activity — the standard STM32 idiom for "a variable-length frame has arrived." The firmware's logic hangs off that interrupt, so the interrupt — not the bytes — is what you must produce.

The probe: why the interrupt can't be raised

So raise the IDLE interrupt. The obvious move is to set the UART's IDLE status bit over the system bus. It did nothing — reads kept returning zero. Probing Renode's USART model explained why: in this model the IDLE status flag and its enable bit are modelled as tagged fields — placeholders that always read back zero and ignore writes:

// Illustrative of the stock model: IDLE is a placeholder, not real state.
// A bus write to this bit is silently dropped; a read always returns 0.
.WithTaggedFlag("IDLE", 4)
.WithTaggedFlag("IDLEIE", 4)

No bus poke can move a tagged flag, because there is no backing state to move. The honest fix is to make the model implement IDLE. Which ran straight into the next wall:

public sealed class Stm32Usart : ...   // sealed: cannot be subclassed

The model is sealed. You cannot subclass it to add the missing behaviour.

ℹ️ What this means: a sealed class in C# forbids inheritance — no subclass can extend or override it. Combined with the tagged flags, both obvious doors (poke the bit, subclass the model) are locked.

Options on the table

Four ways forward, weighed honestly:

OptionVerdict
Keep using synchronous gdb callsWorks, but stays gdb-bound and never reaches a pure, scriptable suite
Subclass the USART model to add IDLEImpossible — the model is sealed
Fork Renode's base platform file and swap the modelWorks, but carries a maintenance fork forever and isn't cleanly committable
Wrap the stock model and add the one missing capabilityChosen — no fork, boot stays byte-identical, minimal surface

The last option is composition instead of inheritance: if you cannot extend the peripheral, wrap it.

The resolution: a wrapping peripheral

The fix is a thin peripheral model that owns an instance of the stock USART and forwards every bus access to it — so from the firmware's point of view the device behaves exactly as before and boot is byte-for-byte identical. The wrapper adds one thing the stock model lacks: a SignalIdle() method that raises the IDLE condition and pends the UART interrupt in the NVIC.

// Sanitised sketch — a wrapper that composes the stock USART and adds SignalIdle().
public sealed class InjectableUsart : IDoubleWordPeripheral, IKnownSize
{
    private readonly Stm32Usart inner;   // the real model, untouched
    private readonly IMachine machine;
    private bool injectIdle;

    public uint ReadDoubleWord(long offset)
    {
        var value = inner.ReadDoubleWord(offset);
        // While an injected frame is pending, report IDLE as set on the
        // status/control registers the firmware polls — the one gap in the model.
        if(injectIdle && (offset == StatusReg || offset == ControlReg))
            value |= (1u << IdleBit);
        return value;
    }

    public void WriteDoubleWord(long offset, uint value)
    {
        // Honour the firmware clearing the IDLE flag, then forward everything.
        if(offset == ClearReg && (value & (1u << IdleBit)) != 0)
            injectIdle = false;
        inner.WriteDoubleWord(offset, value);
    }

    // Called from the test harness after an input frame is staged in RAM.
    public void SignalIdle()
    {
        injectIdle = true;
        // Pend the UART interrupt line so the firmware's receive ISR runs.
        machine.GetSystemBus(this).WriteDoubleWord(NvicPendRegister, UartIrqMask);
    }

    public long Size => 0x400;
}

Everything the firmware does to the UART still hits the real model; only the IDLE poll and the interrupt pend are synthesised, and only while a frame is deliberately staged. The HardFault is gone because nothing interrupts the core asynchronously — the firmware runs its own ISR, on its own terms, in response to a pended line.

Wiring it in without forking the base platform

A replacement peripheral is useless if installing it means editing Renode's shared base .repl. You cannot override a peripheral's type across an include — Renode rejects it. The trick is to cancel the stock registration and register the wrapper at the same address under a new name, entirely in an overlay file:

// overlay.repl — no edit to the base platform
usart3: @none                               // cancel the stock registration
injectableUsart: UART.InjectableUsart @ sysbus <0x40004800, +0x400>
    frequency: 125000000

The base platform is untouched and the overlay is committable as-is. In CI the exact same files run on a stock Renode build.

⚠️ Gotcha: usart3: none is a syntax error and you cannot re-type usart3 across an include. usart3: @none (cancel) plus a new name at the same base address is what Renode accepts.

Injecting an event and asserting the result

With the mechanism in place, one input event is three monitor actions — stage a valid input frame in the receive buffer, tell the DMA how many bytes are "left," then pend the line:

Inject Event
    [Arguments]    ${frame}
    Execute Command    sysbus WriteBytes ${RX_BUFFER} ${frame}
    Execute Command    ${DMA_RX} WriteDoubleWord ${NDTR_OFFSET} ${remaining}
    Execute Command    sysbus.injectableUsart SignalIdle

The frame itself is a valid message the firmware's own parser accepts — synthesised by the test, not captured from a wire. The assertion reads the firmware's menu-state symbol straight from memory and compares it:

Menu State Should Be
    [Arguments]    ${expected}
    ${raw}=    Execute Command    sysbus ReadDoubleWord ${MENU_STATE_SYMBOL}
    Should Be Equal As Integers    ${raw}    ${expected}

Navigate One Level Deep
    Boot To Main
    Inject Event             ${ENTER_SUBMENU}
    Wait Until Keyword Succeeds    15s    200ms    Menu State Should Be    ${SUBMENU}

A handful of timing realities shape good tests here: a tap is one event, not a press/release race; a repeated key needs a real release between presses before the firmware re-arms it; and polling the state symbol beats guessing a fixed delay. None of that requires a debugger.

Below is the full path a single simulated keypress travels — host to firmware and back to the assertion.

%%{init: {'theme': 'base', 'themeVariables': {
  'primaryColor':       '#edb059',
  'primaryTextColor':   '#222222',
  'primaryBorderColor': '#c8951f',
  'lineColor':          '#5a5450',
  'secondaryColor':     '#ede9e4',
  'secondaryTextColor': '#2d2a26',
  'tertiaryColor':      '#222222',
  'tertiaryTextColor':  '#edb059',
  'background':         '#faf8f5',
  'mainBkg':            '#faf8f5',
  'noteBkgColor':       '#f2c880',
  'noteTextColor':      '#222222',
  'clusterBkg':         '#faf8f5',
  'clusterBorder':      '#ede9e4',
  'titleColor':         '#2d2a26'
}}}%%
flowchart TB
    subgraph HARNESS["Test harness (host) — no gdb, no HardFault"]
        ROBOT["Robot Framework\nnavigation scenario"]:::dark
        MON["Renode monitor\ncommands"]:::dark
    end

    subgraph EMU["Emulated STM32H7 — Renode"]
        BUF["RX DMA buffer\nsynthetic input frame"]:::hw
        NDTR["DMA transfer counter"]:::hw
        WRAP["Wrapping USART model\nSignalIdle()"]:::dark
        NVIC["NVIC\npend UART IRQ"]:::accent
        ISR["Firmware receive ISR\nIDLE-line + DMA"]:::dark
        PARSE["Input parser"]:::dark
        FSM["Menu state machine"]:::accent
        STATE["menu-state symbol\nin RAM"]:::out
    end

    ROBOT --> MON
    MON -->|write frame| BUF
    MON -->|set bytes remaining| NDTR
    MON -->|SignalIdle| WRAP
    WRAP --> NVIC --> ISR
    BUF --> ISR
    NDTR --> ISR
    ISR --> PARSE --> FSM --> STATE
    STATE -.->|read & assert| ROBOT

    classDef dark fill:#222222,color:#edb059,stroke:#edb059
    classDef accent fill:#edb059,color:#222222,stroke:#c8951f
    classDef out fill:#edb059,color:#222222,stroke:#c8951f
    classDef hw fill:#5a5450,color:#faf8f5,stroke:#5a5450

One simulated keypress: the harness stages a frame in the DMA buffer, adjusts the transfer counter, and calls SignalIdle; the wrapper pends the UART IRQ so the firmware runs its own receive ISR, parser, and menu state machine; the test reads the resulting menu-state symbol back and asserts.

Making it a CI citizen

Renode's test runner drives Robot Framework and emits Robot's native XML. Two small steps turn that into something a reviewer sees on the pull request: convert the XML to JUnit, then publish it as a check.

# Convert Robot output to JUnit, then publish it as a PR check.
- run: python -m robot.rebot --xunit junit.xml --output NONE --log NONE robot_output.xml
- uses: dorny/test-reporter@v3.0.0
  with:
    name: Menu navigation results
    path: junit.xml
    reporter: java-junit

The job is deliberately report-only — it informs reviewers, it never blocks a merge — and gated to run on demand (a label, a schedule) because booting real firmware in emulation is slower than a unit test. Each run lists every navigation case as a pass/fail line on the PR, with the full HTML log attached as an artifact.

ℹ️ What this means — "golden" and report-only: publishing results as a check rather than a hard gate means the signal is visible without becoming a flaky merge blocker — appropriate for slow, emulation-backed system tests while they earn trust.

What it bought: speed and stability

The three approaches did not just differ in whether they worked — they differed in how fast a test runs and how much of that time is wasted. The dominant cost in any of them is the firmware boot, which in emulation is a fixed, one-time price per test (on the order of a minute, tunable via the modelled CPU speed). Everything after boot is where the methods diverge:

ApproachPer-step cost after bootStability
Async-interrupt + debugger pokeA full attach / interrupt / read / detach round-trip — seconds each, multiplied by every assertionFragile — each async interrupt could HardFault
Buffer poke, no interruptCheap, but zero progress — nothing processes the inputn/a — inert
Synchronous gdb inferior callsSafer than async, but still a debugger round-trip per input and per assertionStable, but slow and gdb-bound
Composition wrapper + monitorIn-process bus read/write — effectively free per stepStable — no asynchronous perturbation at all

Collapsing every debugger round-trip into a plain monitor read/write changes the shape of a test's wall time: instead of boot + N round-trips, a multi-step journey costs little more than the boot itself, with the per-step overhead down in the poll-interval noise. Just as important, removing the asynchronous interrupt removed the HardFault entirely — so the suite is not only faster but repeatable, which is the property CI actually depends on. A flaky-but-fast test is worthless; this exercise bought both speed and determinism, and the determinism is what made a report-only CI check worth publishing.

Key takeaways