Firmware Fault Handler Decoding on ARM Cortex-M

Decode processor registers and stack frames to turn ARM crash logs into actionable root causes.

Senior Writer · · 8 min read
Cover illustration for “Firmware Fault Handler Decoding on ARM Cortex-M”
Failure Modes · September 23, 2026 · 8 min read · 1,761 words

A Cortex-M fault handler that just logs "HardFault occurred" and resets is throwing away information the processor already collected for you. The core stores fault status in dedicated registers and an exception stack frame the instant the fault happens. Reading them in the right order turns a HardFault into a specific instruction, in a specific function, with specific arguments, that did a specific illegal thing. This is the four-fault taxonomy on Cortex-M, HardFault, MemManage, BusFault, and UsageFault, and the systematic path from "it crashed" to "line 214 dereferenced a null pointer."

HardFault sits at the top of the pile: fixed priority -1, exception number 3, IRQ -13, outranked only by Reset and NMI. It's also the default catch-all. Any MemManage, BusFault, or UsageFault that doesn't have an enabled handler, or that fires at a priority the current handler can't preempt, escalates into a HardFault instead of vanishing. MemManage fires on violations detected by the memory protection unit, both instruction and data side. BusFault fires on memory access failures: bad instruction fetch, bad data read or write, a bad vector fetch, or a failure during register stacking on exception entry or exit. Knowing which of these four actually tripped is the whole game, and it starts before the handler even runs.

Automatic processor actions at fault entry

Before a single line of the fault handler executes, the core has already pushed eight registers onto whichever stack was active, MSP or PSP: R0, R1, R2, R3, R12, LR, PC, and xPSR, in that order, sitting at offsets [0] through [7] from the stack pointer, forming the exception frame. That's the exception frame, and it's automatic, hardware-driven, and non-negotiable.

What it does not save is just as important. R4 through R11 are left alone. If the handler needs them, it has to stack them itself, explicitly, before doing anything else that might clobber them.

At the same moment the frame goes onto the stack, the fault-management hardware fills in its fault status registers: CFSR, HFSR, BFAR, and MMFAR. These registers are the actual record of what went wrong. The stacked frame tells you where execution was; the SCB registers tell you why it stopped being there. Both pieces are needed, and neither substitutes for the other.

Reading EXC_RETURN in the fault handler to find the correct stack pointer

On exception entry, LR doesn't hold a normal return address anymore. It holds EXC_RETURN, a special value the processor uses to figure out how to unwind back to the interrupted code, including which stack pointer, MSP or PSP, was active when the fault hit.

The canonical way to extract this, in assembly, looks something like:

movs R0, #4
mov  R1, LR
tst  R0, R1

That tst instruction checks bit 2 of EXC_RETURN. If it's set, the processor was using PSP; if clear, it was using MSP. Branch accordingly, load the correct pointer with mrs R0, PSP or mrs R0, MSP, and pass R0 into the higher-level fault handler as an argument. The syntax shifts a little across the Cortex-M family, M0 through M7, but the logic is identical everywhere.

Once you've got the right stack pointer, the frame becomes a simple lookup table: R0 at offset [0], R1 at [1], R2 at [2], R3 at [3], R12 at [4], LR at [5], PC at [6], and xPSR at [7]. Get the wrong stack pointer here and every one of those offsets points at garbage, so this step has to happen first, correctly, before any of the register decoding that follows means anything.

Reading HFSR first to determine whether the fault is a direct HardFault or an escalated one

HFSR lives at 0xE000ED2C, and it answers one question before anything else: is this actually a HardFault, or is it something else wearing a HardFault's clothes?

Three bits carry the weight. FORCED, bit 30, means a lower-priority fault (MemManage, BusFault, UsageFault) got escalated because its own handler wasn't available or wasn't enabled. VECTTBL, bit 1, means the fault happened while the core was reading the vector table itself on exception entry, which points at a problem with the vector table itself. DEBUGEVT, bit 31, fires on a debug event, commonly triggered by a debug event such as a breakpoint firing when no debugger is attached.

The decision tree from here is short. FORCED set means the real cause lives in CFSR, so go read it next. VECTTBL set means look at the vector table. DEBUGEVT set means a breakpoint fired somewhere it shouldn't have.

Treating every HardFault as a self-contained event and never checking FORCED is a mistake. If FORCED is set and CFSR goes unread, the actual fault type, MemManage, BusFault, or UsageFault, is lost. The handler resets the board having learned nothing.

Diagram: From Crash to Root Cause: The Four-Register Reading Order. Visualizes: Show the mandatory sequence a fault handler must follow to turn a HardFault into a diagnosable crash.

Decoding CFSR to identify the specific fault type and its validity flags

CFSR is 0xE000ED28, a 32-bit word that's really three registers stitched together: MMFSR (MemManage Fault Status Register) as the byte at 0xE000ED28, BFSR (BusFault Status Register) as the byte at 0xE000ED29, and UFSR (UsageFault Status Register) as the half-word at 0xE000ED2A.

In GDB, pulling these apart is a handful of print commands:

print/x *(uint32_t *)0xE000ED28   # full CFSR
print/x *(uint16_t *)0xE000ED2A   # UFSR
print/x *(uint8_t *)0xE000ED29    # BFSR
print/x *(uint8_t *)0xE000ED28    # MMFSR

Multiple bits can be set at once if more than one fault condition occurred, and none of them clear on their own. They stay set until the software writes a 1 to clear them or the system resets, so a bit set by a previous fault will still be set if software has not explicitly cleared it.

UFSR is where a surprising amount of real-world debugging time goes. UNDEFINSTR flags an undefined instruction, which sometimes means actual corruption and sometimes means the compiler deliberately emitted a trap instruction for a code path it proved unreachable. INVSTATE flags an invalid EPSR value, often the result of branching into code with an incorrect execution state, a common hand-written-assembly mistake. INVPC flags a failed EXC_RETURN integrity check on exception exit. NOCP flags an attempt to run a coprocessor instruction when none is enabled or present, a common trigger being use of floating-point instructions when the FPU has not been enabled. UNALIGNED flags unaligned access; whether a given unaligned access faults depends on the access type and whether unaligned trapping has been enabled in the configuration control register. DIVBYZERO only fires if the divide-by-zero trap is enabled in the configuration control register, and that trap defaults to off at reset, so a divide-by-zero may not reach the fault handler on a stock configuration.

Using BFAR and MMFAR to pinpoint the faulting address

BFAR, the Bus Fault Address Register, lives at 0xE000ED38 and holds the address that caused a BusFault, but only when BFSR.BFARVALID is set. Check that validity bit first, every time, before trusting anything in BFAR. The address is meaningful when PRECISERR is set; it may not be meaningful even with BFARVALID set if IMPRECISERR is also involved, a distinction covered in more depth below.

MMFAR works the same way for MemManage faults: it holds the offending address, gated by MMFSR.MMARVALID, and the same discipline applies, check validity before trusting the value.

One operational hazard: BFARVALID and MMARVALID can be lost if another fault occurs before the handler reads them. That's the argument for reading BFAR and MMFAR as close to the start of the handler as possible, before doing anything else that might itself trigger a fault.

Cortex-M7 adds one more piece to the puzzle: the Auxiliary Bus Fault Status Register, ABFSR, which records source information for asynchronous bus faults, identifying which bus interface was involved. The ABFSR[4:0] fields hold their values until software explicitly clears them by writing to the register, so they don't get lost the way BFARVALID and MMARVALID can.

Precise vs. imprecise faults and the unreliability of the faulting address

Not every fault points cleanly at its own cause, and this is where a lot of fault analysis goes sideways. Instruction fetches and data loads generate precise, synchronous faults: the stacked PC lands exactly on the instruction that caused the problem, no ambiguity.

Store operations are a different story. Write buffering on the bus means the processor can move several instructions past a write before the bus actually reports the error back. Because write buffering on the bus lets the processor move several instructions past a write before the bus reports the error back, by the time the fault occurs, the PC in the stacked frame may be pointing at code that has nothing to do with the actual bad write, sometimes several instructions downstream of it.

The BFSR.IMPRECISERR bit is the flag that tells you this is happening. On an imprecise fault, BFARVALID may not be set and the address in BFAR may not correspond to whatever instruction sits at the stacked PC. Trusting BFAR blindly in this case leads straight to debugging the wrong function.

Reconstructing the call chain from the stacked frame and LR

With the fault type identified and the address (where reliable) in hand, the last step is putting the crash back into the shape of a call stack. The stacked PC, when the fault is precise, gives the exact faulting instruction; feed that address into the disassembly or the linker map and it resolves to a source file and line number.

The stacked LR, sitting at offset [5] in the frame, gives the return address for whatever function called the one that faulted, which identifies the caller one level up. R0 through R3, also captured in the frame, held the first four arguments to the faulting function at the moment it crashed, often enough to tell you what data it was chewing on when things went wrong.

R4 through R11 are the gap in this picture: since the hardware never stacks them automatically, reconstructing anything about their state at fault time depends entirely on whether the handler was written to save them explicitly before they get overwritten. Without that, the trail for those registers ends at the fault boundary.

None of this replaces a debugger session with full symbol information, and none of it substitutes for actually fixing the underlying bug. But the registers are there, populated by the hardware, waiting to be read in the right order: HFSR to sort direct from escalated, CFSR to name the fault, BFAR or MMFAR to place it (with the precise/imprecise caveat firmly in mind), and the stacked frame to rebuild the path that led there. Skipping any one step in that order is how a fully diagnosable crash turns into an unexplained reset.

Sources

  1. How to debug a HardFault on an ARM Cortex-M MCU
  2. Cortex-M Fault - SEGGER Knowledge Base
  3. Abstract:
  4. Armv8-M Exception Model User Guide
  5. developer.arm.com
  6. developer.arm.com
  7. developer.arm.com
Filed underFailure Modes

More in Failure Modes