Get the Professional Embedded Starter Kit: Production-ready templates and architectural cheat sheets for your firmware projects.
ARM Cortex-M Exception Frame Explained: How the CPU Saves State on Every Interrupt
1.
Introduction
An interrupt can fire between any two instructions. The CPU has no way to ask the interrupted code to save its registers first it must act immediately. And yet when the interrupt handler finishes and execution resumes, the interrupted code must continue as if nothing happened. Every register it was using must have the same value it had before the interrupt.
The Cortex-M solution is the hardware exception frame: on every exception entry the CPU automatically saves eight registers onto the stack before transferring control to the handler. When the handler returns, the CPU restores those eight registers and resumes the interrupted code transparently.
This mechanism is not just a hardware detail. It is designed to be fully compatible with the ARM calling convention the same convention that governs every C function call. Understanding the exception frame is what lets you write correct interrupt handlers, understand what EXC_RETURN actually encodes, and build more advanced mechanisms on top. We will use the HardFault handler as our concrete example: the exception frame is not just a save-and-restore mechanism here it is a diagnostic tool that tells you exactly where the fault happened and what the CPU was doing at the time
2.
The Hardware Exception Frame
When any Cortex-M exception fires whether an SVC, a peripheral IRQ, or a HardFault the CPU pushes eight registers onto the active stack before transferring control to the handler:
Offset |
Register |
Why saved |
SP+0 |
r0 |
Argument 1 / return value |
SP+4 |
r1 |
Argument 2 |
SP+8 |
r2 |
Argument 3 |
SP+12 |
r3 |
Argument 4 |
SP+16 |
r12 |
Caller-saved scratch |
SP+20 |
lr |
Return address of interrupted code |
SP+24 |
pc |
Address of the next instruction to execute when the handler returns |
SP+28 |
xPSR |
Processor status: flags, Thumb bit, ISR number |
These are exactly the caller-saved registers from the ARM calling convention r0r3, r12, lr plus pc and xPSR which the CPU needs to resume execution. The callee-saved registers r4r11 are not touched by the hardware. The handler must leave them unchanged, exactly as any C function must.
This alignment between the exception frame and the calling convention is intentional. It means that an interrupt handler written in C requires no special prologue to save registers the compiler already knows r0r3 and r12 are scratch registers and generates the handler body accordingly.
/* A correct bare-metal IRQ handler no special attribute needed */ void TIM2_IRQHandler(void) { TIM2->SR &= ~TIM_SR_UIF; /* clear update interrupt flag */ counter++; } |
The compiler generates this as a normal function body. It does not need to save r0r3 because the hardware already did. It saves r4r11 only if it uses them. On exit, bx lr triggers the exception return.
Stack selection: The CPU uses MSP in Handler mode and may use PSP in Thread mode. The handler can always find the saved frame by reading the correct stack pointer register.
Fig
1. The hardware exception frame pushed automatically by the CPU on
every exception entry.
Struggling to implement this for a professional project?
If you need to master full-scale firmware architecture, security, and build automation, join our 1-D or 4-Day Live Implemnetation Workshops. I'll show you the exact direct path to production-ready firmware without the trial and error.
3.
EXC_RETURN: How the CPU Knows How to Return
On exception entry the CPU loads a special value into LR called EXC_RETURN. It is not a normal return address it is a magic constant that tells the CPU what state to restore when the handler executes bx lr:
EXC_RETURN value |
Meaning |
0xFFFFFFF1 |
Return to Handler mode, use MSP |
0xFFFFFFF9 |
Return to Thread mode, use MSP |
0xFFFFFFFD |
Return to Thread mode, use PSP |
The most important bit is bit 2: it tells the handler which stack holds the saved frame.
@ Determine which stack holds the exception frame tst lr, #4 @ test bit 2 of EXC_RETURN ite eq mrseq r0, msp @ bit2 = 0: frame is on MSP mrsne r0, psp @ bit2 = 1: frame is on PSP |
In a bare-metal system without an RTOS, code typically runs on MSP and bit 2 is 0. In a system with an RTOS, tasks run in Thread mode using PSP while the kernel uses MSP. Reading bit 2 makes your handler correct in both cases.
Fig 2. EXC_RETURN values and what each encodes. Bit 2 selects the stack that holds the exception frame. The assembly reads this before passing the frame pointer to the C handler.
4. Reading the HardFault Frame to Diagnose the Fault
A HardFault is the Cortex-M catch-all exception: it fires when the CPU encounters an invalid memory access, an unaligned access on a strict-alignment bus, an undefined instruction, or a fault escalated from another exception. Without a proper handler, your firmware hits the default infinite loop and you have no idea what went wrong.
With a handler that reads the exception frame, you know exactly:
Where the fault happened the stacked pc is the address of the faulting instruction
What the CPU was doing the stacked r0r3 are the values those registers held at the exact moment the fault fired, which may or may not correspond to function arguments depending on where in the code the fault occurred
Which mode was active bits 80 of xPSR (IPSR) tell you whether the fault happened in Thread mode (0) or inside another exception handler (non-zero), which indicates a fault escalation
typedef struct { uint32_t r0; uint32_t r1; uint32_t r2; uint32_t r3; uint32_t r12; uint32_t lr; uint32_t pc; uint32_t xpsr; } ExceptionFrame_t; |
The assembly entry point
The assembly entry reads EXC_RETURN to locate the correct frame, then passes a pointer to it as the first argument to the C handler:
/* hardfault_entry.s */ .syntax unified .thumb
.global HardFault_Handler .type HardFault_Handler, %function
HardFault_Handler: /* Check bit 2 of EXC_RETURN to find which stack holds the frame */ tst lr, #4 ite eq mrseq r0, msp @ bit2=0: frame is on MSP mrsne r0, psp @ bit2=1: frame is on PSP
/* r0 = pointer to ExceptionFrame_t. Tail call to C handler LR still holds EXC_RETURN so the CPU performs exception return when the C function returns. */ b hard_fault_handler_c |
The C handler
void hard_fault_handler_c(ExceptionFrame_t *frame) {
/* The stacked PC is the address of the faulting instruction. This is the most important field it tells you exactly which instruction caused the fault. */ uint32_t fault_pc = frame->pc; /* The stacked LR is the return address of the function that was executing when the fault fired. Together with fault_pc you can reconstruct the call chain. */ uint32_t fault_lr = frame->lr; uint32_t fault_r0 = frame->r0; uint32_t fault_r1 = frame->r1; /* xPSR bits 8--0 (IPSR): 0 = fault in Thread mode, non-zero = fault inside another exception handler (fault escalation) */ uint32_t fault_xpsr = frame->xpsr; /* Log or print all fields before halting */ DEBUG_PRINT("HardFault at PC=0x%08X LR=0x%08X\n", fault_pc, fault_lr); DEBUG_PRINT(" r0=0x%08X r1=0x%08X xPSR=0x%08X\n",fault_r0, fault_r1, fault_xpsr); /* Halt do not return, the faulting code cannot continue */ while (1); } |
A
real fault scenario
void buggy_function(uint32_t *ptr, uint32_t value) { *ptr = value; /* fault fires here if ptr = NULL */ } int main(void) { buggy_function(NULL, 0xDEADBEEF); /* triggers HardFault */ while (1); } |
When this runs the handler prints:
HardFault at PC=0x08000154 LR=0x08000162 r0=0x00000000 r1=0xDEADBEEF xPSR=0x0100000 |
PC=0x08000154 is the address of *ptr = value inside buggy_function
LR=0x08000162 is the return address back into main
r0=0x00000000 confirms the pointer was NULL
r1=0xDEADBEEF is the value that was being written
You can paste PC=0x08000154 into arm-none-eabi-addr2line -e firmware.elf 0x08000154 and it will show you the exact source file and line number.
Fig 3. HardFault handler flow. The CPU saves the exception frame on fault entry
5.
Conclusion
The hardware exception frame is the bridge between interrupts and the ARM calling convention. The CPU saves the caller-saved registers on every exception entry making any C function a valid handler and restores them on exit, making the return transparent to interrupted code.
Three things to remember:
The exception frame saves r0--r3, r12, lr, pc and xPSR automatically. Any interrupt handler written in C can use those registers freely without saving them first.
EXC_RETURN in LR encodes which stack holds the frame. Always check bit 2 before reading the saved frame.
The stacked pc is the address of the faulting instruction. Reading it in a HardFault handler turns a silent crash into a precise diagnosis exact source line, exact call chain via lr, and the register values r0--r3 held at the moment of the fault.
The HardFault example shows how far the exception frame properties extend. The same mechanism that makes every peripheral ISR safe to write in C also gives you a complete snapshot of CPU state at the moment of a fault with no extra instrumentation, no debugger attached, just the frame the hardware pushed automatically.
