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
- Why driving firmware input through a debugger (async-interrupt + poke) is fragile — and how it produces HardFaults that have nothing to do with your test
- Why writing input bytes straight into RAM over the system bus silently does nothing on a DMA + IDLE-line UART receiver
- How to probe a Renode peripheral model to discover why an interrupt never fires
- Why a
sealedperipheral model pushes you from inheritance to composition - How to register a replacement peripheral in a platform overlay without forking Renode's base platform file
- How to turn the result into a Robot Framework suite that publishes a JUnit report on every pull request
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
sealedclass 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:
| Option | Verdict |
|---|---|
| Keep using synchronous gdb calls | Works, but stays gdb-bound and never reaches a pure, scriptable suite |
| Subclass the USART model to add IDLE | Impossible — the model is sealed |
| Fork Renode's base platform file and swap the model | Works, but carries a maintenance fork forever and isn't cleanly committable |
| Wrap the stock model and add the one missing capability | Chosen — 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: noneis a syntax error and you cannot re-typeusart3across aninclude.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:
| Approach | Per-step cost after boot | Stability |
|---|---|---|
| Async-interrupt + debugger poke | A full attach / interrupt / read / detach round-trip — seconds each, multiplied by every assertion | Fragile — each async interrupt could HardFault |
| Buffer poke, no interrupt | Cheap, but zero progress — nothing processes the input | n/a — inert |
| Synchronous gdb inferior calls | Safer than async, but still a debugger round-trip per input and per assertion | Stable, but slow and gdb-bound |
| Composition wrapper + monitor | In-process bus read/write — effectively free per step | Stable — 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
- Driving input through an asynchronous debugger interrupt destabilises the target; the HardFaults it caused were an artefact of the method, not the firmware.
- A DMA + IDLE-line UART receiver is gated by the interrupt, not the bytes — writing a buffer in RAM reproduces the data but not the event that processes it.
- When a model's state is tagged (read-zero, write-ignored) and the class is sealed, both "poke the bit" and "subclass it" are dead ends — reach for composition.
- A wrapping peripheral that forwards every access to the stock model keeps boot
byte-identical and adds only the one missing capability (
SignalIdle). - Register the replacement with
@none+ a new name at the same address, so the base platform is never forked and the overlay is committable and CI-ready. - Collapsing per-assertion debugger round-trips into in-process monitor reads makes a multi-step journey cost little more than the one-time boot — and, by removing the async interrupt, makes it deterministic, which is what CI actually needs.
- Convert Renode's Robot output to JUnit and publish it report-only to keep slow system tests visible without making them a merge gate.