Skip to content
Chiplab logo
Chiplab logo
Back to Now

An agent tried to fix a HardFault. There was no HardFault to fix.

The plan was simple: stage a Cortex-M HardFault debugging session on a virtual board, break it in a way that crashes real silicon, let an agent read the fault dump, fix the bug, re-run.

I got the "before." The fault never fired.

The bug I planted

I copied examples/bare-metal/stm32f4-discovery from the Chiplab repo into a scratch directory — never touched the repo itself — and added a HardFault handler that reads the real Cortex-M fault registers and prints them over UART before halting:

#[exception]
unsafe fn HardFault(ef: &ExceptionFrame) -> ! {
    const CFSR: *const u32 = 0xE000_ED28 as *const u32;
    const HFSR: *const u32 = 0xE000_ED2C as *const u32;
    const MMFAR: *const u32 = 0xE000_ED34 as *const u32;
    const BFAR: *const u32 = 0xE000_ED38 as *const u32;

    print_str("\n[HardFault]\n");
    print_str("CFSR  = "); print_hex32(core::ptr::read_volatile(CFSR));
    print_str("\nHFSR  = "); print_hex32(core::ptr::read_volatile(HFSR));
    print_str("\nMMFAR = "); print_hex32(core::ptr::read_volatile(MMFAR));
    print_str("\nBFAR  = "); print_hex32(core::ptr::read_volatile(BFAR));
    print_str("\nstacked PC = "); print_hex32(ef.pc());
    loop {}
}

CFSR and HFSR at those exact addresses are the standard Cortex-M fault status registers — the same ones every HardFault debugging guide walks through.1 Then I added a bug: a raw pointer write straight into flash, at an address no provisioning step had prepared.

// A config record that a provisioning step is supposed to have written into
// flash before this firmware ever runs. Provisioning never ran, so this is a
// write straight into ROM through a bad pointer.
let cfg_ptr = 0x0801_0000 as *mut u32;
unsafe { core::ptr::write_volatile(cfg_ptr, 0x1234_5678) };

On a real STM32F407, writing to flash content outside an unlock-and-erase sequence is a textbook way to earn a bus fault. I built it, uploaded the ELF, and ran it on stm32f4_discovery.

Attempt one: nothing happens

08:55:12.3397 [INFO] machine-0: Machine started.
08:55:12.3868 [WARNING] flash_controller: Unhandled write to offset 0x0. ...
08:55:12.3970 [INFO] usart2: [host: 0.19s|virt: 0s] Hello world!
08:55:17.3435 [INFO] machine-0: Machine paused.

No [HardFault]. No CFSR. The firmware printed its banner, executed the bad write, and idled quietly until the run's 5-second virtual-time budget expired. Not a hang, not a crash — just silence where a fault should have been.

I tried three more standard ways to fault a Cortex-M core: a branch to an odd/invalid address (INVSTATE), an explicit udf undefined-instruction trap, and a read from a definitely-unmapped address (0x3000_0000) with alignment trapping enabled. Same result each time: Hello world!, then five seconds of silence, then the run ends.

The unmapped-read attempt is the interesting one, because it's the one place the simulator's own log admits it saw the bad access:

08:52:24.3132 [INFO] machine-0: Machine started.
08:52:24.3612 [WARNING] flash_controller: Unhandled write to offset 0x0. ...
08:52:24.3715 [INFO] usart2: [host: 0.2s|virt: 0s] Hello world!
08:52:24.5966 [WARNING] sysbus: [cpu: 0x8000604] ReadDoubleWord from non
    existing peripheral at 0x30000000.
08:52:29.3181 [INFO] machine-0: Machine paused.

The run that logged the crime and let it go

The bus model noticed the access was bogus, logged it, and returned zero anyway. The CPU never took the fault vector.

What the runs told us

Observation (real, from the runs above)Conclusion
Hello world! prints identically in every runThe bug sits after the working code path; boot and UART are unaffected
bx to an odd address (INVSTATE) produces no fault or log lineThe core doesn't check Thumb-state validity on branch
udf #0xde produces no fault or log lineUndefined-instruction trapping isn't wired to the exception table
Read from 0x3000_0000 logs a bus warning but returns 0, no faultThe bus model detects invalid accesses; the CPU model doesn't act on them
SCB->CCR.UNALIGN_TRP set, then an unaligned read: no faultAlignment trapping isn't enforced either
Every run ends at the 5-second virtual-time budget, not on a crashThe simulator treats "ran to the time limit" as success, same as a healthy loop {}

Four architecturally distinct ways to fault a Cortex-M core. Zero faults.

I asked Chiplab why

Chiplab ships a discovery/help tool specifically for "what does this simulation actually cover" questions,2 so I asked it directly, pasting in what I'd seen. The answer was unambiguous:

CPU-level fault escalation (CFSR, HFSR, HardFault handler invocation) is not currently a modeled feature in Chiplab's simulation... the underlying emulator executes instructions and models peripheral register maps, but the Cortex-M fault machinery that sits between "bad thing happened" and "HardFault handler runs" is not wired up.

Chiplab runs on Renode under the hood — the same log format that gives us [INFO] machine-0: Machine started. is Renode's, the emulator Interrupt has written about for exactly this kind of firmware-without-hardware workflow.3 Renode is real, well-used, and good at what it's built for: booting firmware, running peripheral models, capturing UART — we've compared Renode vs. QEMU on the same firmware elsewhere, if you want the fidelity gap laid out board-by-board. Renode fault handling for invalid memory access and illegal instructions is a different, harder piece of CPU fidelity, and on this board it isn't there yet.

Expectation vs. reality

What real Cortex-M HardFault debugging would show

I never dereferenced this pointer on real silicon, so I won't invent register values for it. A write into flash outside an unlock sequence on real Cortex-M hardware raises a bus fault; if BusFault handling isn't explicitly enabled (it isn't, by default), it escalates to HardFault, and the CFSR's BFARVALID bit and the BFAR register would point at the address that caused it.1 Keil's own app note on Cortex-M3/M4/M7 fault exceptions and ST's own debugging guide for this exact family both walk through reading that same trio — CFSR, HFSR, and the faulting address register — as the first move after any fault.4 5

There is no CFSR/HFSR dump to show you here. The run never produced one, because on this board the fault never fires.

That gap is the finding. Not a bug in my firmware. A boundary in the simulation.

Why I'm publishing the miss

We've written before about what a virtual board can and can't prove — a byte-level UART model that let a wrong baud rate sail through undetected,6 and the general case for treating simulation as one layer in a funnel, not a hardware replacement.7 This applies to a sharper edge: CPU-level fault handling is squarely inside "should the virtual layer own this," and today it doesn't. If your firmware's correctness depends on a fault handler actually running — recovery logic, a watchdog kick, a fault log written to flash before reset — a virtual STM32F4 run on Chiplab today will tell you your happy path works and stay silent about whether your unhappy path does. That's worth knowing before you trust the green checkmark, not after.

The four things I broke on purpose — a bad flash write, an invalid branch, an undefined instruction, an unaligned trap — are, right now, four bug classes a real board would catch and Chiplab won't. The UART output, the peripheral state, the "did my firmware actually boot and do the thing" signal are all real and useful, and were exactly right in every one of these runs. But the boundary matters: "cheap layers eat the iteration, silicon gets the final word" only works if you know exactly which failures the cheap layers can't see yet.

This is worth flagging to the Chiplab team, and I will — with these two run IDs attached. If fault escalation lands, I'll rerun this exact bug and post the real dump, with the run ID next to it, same as everything else here.

Why wasn't the HardFault handler called?

Because nothing raised the fault. Renode's bus model logged every bad access — the unhandled flash write, the read from a non-existent peripheral — and returned quietly instead of escalating to a CPU exception. No exception, no vector, and the HardFault handler I wrote was never called. The handler was fine. The simulator never gave it a reason to run.

Sources

Footnotes

  1. Coleman, Chris. "How to debug a HardFault on an ARM Cortex-M MCU." Interrupt by Memfault. https://interrupt.memfault.com/blog/cortex-m-hardfault-debug 2

  2. Chiplab discovery/help tool and AGENTS.md, veecle/chiplab. https://github.com/veecle/chiplab/blob/main/AGENTS.md

  3. Baldassari, François. "Cortex-M MCU Emulation with Renode." Interrupt by Memfault, March 23, 2020. https://interrupt.memfault.com/blog/intro-to-renode

  4. Yiu, Joseph. "Using Cortex-M3/M4/M7 Fault Exceptions." Keil/ARM Application Note. https://www.keil.com/appnotes/files/apnt209.pdf

  5. STMicroelectronics. "How to debug a HardFault on an Arm Cortex-M STM32." ST Community. https://community.st.com/stm32-mcus-60/how-to-debug-a-hardfault-on-an-arm-cortex-m-stm32-132895

  6. "What LLMs get wrong about embedded code." Veecle. What LLMs get wrong about embedded code

  7. "HIL won't scale to the AI era." Veecle. HIL won't scale to the AI era