Exception Stacking and Unstacking
ARM Cortex-M hardware automatically saves and restores a full register context on every exception entry and exit — allowing any plain C function to serve as an interrupt handler. Master the 8-register exception frame, the hardware stacking sequence, SP movement, and practical stack initialisation tips.
Introduction
EmbeddedPathashala — ARM Cortex-M Course Home All Lectures STM32 Projects
Why the Hardware Saves Registers for You
When an interrupt or exception fires, the processor stops executing the current thread and jumps to the handler. From the handler’s perspective, it needs to use the CPU registers freely to do its work. But those registers already contain data belonging to the interrupted thread — corrupting them would make it impossible for the thread to resume correctly after the handler finishes.
In a traditional software calling convention (AAPCS), it is the caller‘s job to save any volatile registers it cares about before a function call. But interrupts have no caller — the processor itself is the entity that invokes the handler. There is no caller code that could run a PUSH sequence before the jump.
Solution: The ARM Cortex-M hardware acts as a “virtual caller.” On every exception entry, the processor automatically pushes the AAPCS caller-saved registers (R0–R3, R12, LR, PC, xPSR) onto the current stack before branching to the handler. On exception exit, it pops them back. This means:
• You can write any ISR as a plain C function — no special assembly prologue needed.
• The interrupted code sees all registers unchanged when it resumes.
• The ISR gets a clean register file (R0–R3 etc. are free to use without saving).
This is why, in a typical STM32 ISR, you never see explicit PUSH/POP for R0–R3 — the hardware has already done that work before your first line of C code executes.
The 8-Register Exception Stack Frame
The hardware pushes exactly 8 registers, in a fixed order, onto the stack. These 8 registers correspond precisely to the AAPCS caller-saved set plus the program counter and processor status, giving the handler everything it needs and giving the interrupted code everything it needs to resume.
| Register | Offset from new SP | Contents / Purpose |
|---|---|---|
| R0 | +0 (new SP) | Arg1 / scratch — saved so ISR can use R0 freely |
| R1 | +4 | Arg2 / scratch |
| R2 | +8 | Arg3 / scratch |
| R3 | +12 | Arg4 / scratch |
| R12 | +16 | Intra-procedure scratch (IP) |
| LR | +20 | Thread’s return address (hardware overwrites live LR with EXC_RETURN magic value) |
| PC | +24 | Address of the instruction that was about to execute when the interrupt fired (the resume point) |
| xPSR | +28 | Processor status (condition flags N/Z/C/V, ISR number, Thumb bit). Bit 9 records 4-byte stack alignment padding if inserted. |
FPU extension: On Cortex-M4F when the FPU is active (FPCA bit set in CONTROL), the hardware optionally pushes an additional 18 words (S0–S15 and FPSCR + reserved word) making the frame 104 bytes. This “lazy stacking” can be disabled for deterministic ISR latency.
Stacking: Exception Entry Sequence
When an exception is accepted, the hardware performs the following steps before the first instruction of the handler executes. This entire sequence is atomic from the programmer’s perspective — the handler always sees a fully committed stack frame.
1. SP decrements by 32 (or 104 with FPU)
2. Writes xPSR, PC, LR, R12, R3, R2, R1, R0 to stack
3. LR is loaded with EXC_RETURN value (e.g., 0xFFFFFFFD)
4. PC is loaded with handler address from vector table
SP movement during stacking
EXC_RETURN value in LR
During stacking, the processor overwrites LR with a special magic value called EXC_RETURN. The ISR can inspect or pass this value. The key encoding for a bare-metal STM32 application (Thread mode with MSP, no FPU) is:
/* EXC_RETURN: bits 31:4 are always 0xFFFFFFF */
0xFFFFFFF1 → return to Handler mode, use MSP (nested interrupt return)
0xFFFFFFF9 → return to Thread mode, use MSP
0xFFFFFFFD → return to Thread mode, use PSP ← typical RTOS case
/* In a bare-metal project (Thread uses MSP): */
/* On IRQ entry, LR is set to 0xFFFFFFF9 by hardware */
/* Handler reads this in LR, writes it to PC (or BX LR) to trigger un-stacking */
Un-stacking: Exception Exit Sequence
When the handler finishes, it executes a return instruction (most commonly BX LR or POP {PC}). The hardware detects that the value being loaded into PC is an EXC_RETURN value (bits 31:4 = 0xFFFFFFF) and performs the un-stacking sequence instead of a normal branch.
BX LR where LR = 0xFFFFFFF9 (EXC_RETURN).1. Pops R0, R1, R2, R3, R12, LR, PC, xPSR from stack
2. SP increments by 32 (back to pre-exception value)
3. Processor returns to Thread mode
4. xPSR condition flags restored to pre-interrupt state
Transparency guarantee: The interrupted thread code has absolutely no knowledge that an interrupt occurred and returned. From the thread’s perspective, time simply paused and resumed. The registers, stack pointer, and condition flags are all restored by hardware — no software involvement required.
Nested interrupts: stacking on the handler’s stack
If a higher-priority interrupt fires while a handler is already running, the processor performs stacking again — this time from the current MSP position (the handler’s stack). Each nesting level consumes another 32 bytes (or 104 bytes with FPU). Un-stacking on exit works in reverse order. The maximum nesting depth is therefore bounded by available MSP stack space.
/* Example: 3-level nesting on MSP (no FPU) */
/* Initial MSP = 0x20020000 */
/* Task A interrupted: MSP → 0x20020000 - 32 = 0x2001FFE0 */
/* IRQ1 interrupted by IRQ2: MSP → 0x2001FFE0 - 32 = 0x2001FFC0 */
/* IRQ2 interrupted by IRQ3: MSP → 0x2001FFC0 - 32 = 0x2001FFA0 */
/* IRQ3 exits: MSP → 0x2001FFC0 (IRQ2 frame popped) */
/* IRQ2 exits: MSP → 0x2001FFE0 (IRQ1 frame popped) */
/* IRQ1 exits: MSP → 0x20020000 (Task A frame popped) */
/* Task A resumes at original SP */
Writing ISRs as Plain C Functions
Because the hardware saves the AAPCS caller-saved registers, the ISR’s C function body runs with a clean calling environment. The compiler can generate a completely normal function body using R0–R3 and R12 freely. The only requirement is that the function is declared with the correct name so the linker places it in the vector table slot.
/* Normal C ISR for USART2 on STM32F411 */
void USART2_IRQHandler(void)
{
/* Hardware has already saved R0-R3, R12, LR, PC, xPSR.
The compiler can use R0-R3 freely inside this function. */
uint32_t sr = USART2->SR; /* uses R0, R1 */
if (sr & USART_SR_RXNE) {
uint8_t byte = USART2->DR; /* uses R0 */
rx_buffer_push(byte); /* call another function — LR is safe because
hardware saved the original LR already */
}
/* Function ends with BX LR — but LR now = EXC_RETURN magic value,
so hardware un-stacks instead of a normal function return */
}
/* If USART2_IRQHandler uses R4-R7 (callee-saved),
the COMPILER generates PUSH {R4-R7, LR} at the start
and POP {R4-R7, PC} at the end.
BUT LR contains EXC_RETURN, so POP PC triggers un-stacking!
The hardware sees POP {PC} with EXC_RETURN in the frame
and performs the full register restoration automatically. */Important: Do not declare ISRs with __attribute__((interrupt)) in GCC for Cortex-M. This attribute is for older ARM cores (pre-Cortex) and generates incorrect return sequences. On Cortex-M, a plain C function with the correct name is always sufficient — the hardware handles everything.
Stack Initialisation: Before and After main()
Initialisation before reaching main()
The MSP is initialised by hardware at reset — the processor reads address 0x00000000 (which maps to Flash via the boot alias) and loads that 32-bit word into MSP. The linker script places the initial stack address there, so no software needs to set MSP before the Reset_Handler runs.
/* Vector table in startup file — word 0 is the initial MSP value */
/* Defined in startup_stm32f411retx.s (or equivalent) */
.section .isr_vector, "a"
.word _estack /* initial SP = top of RAM */
.word Reset_Handler /* PC on reset */
.word NMI_Handler
.word HardFault_Handler
/* ... */
/* In the linker script: */
/* _estack = ORIGIN(RAM) + LENGTH(RAM) = 0x20000000 + 0x20000 = 0x20020000 */Reinitialisation inside main()
You can change the SP after the startup code runs — for example, if you want to move the stack to external SDRAM (faster access on cache-equipped devices, or more space), or to partition it for MSP/PSP use. The key constraint is that the new SP must point to valid, writable memory and you must complete any pending stack operations before switching.
/* Example: move MSP to external SDRAM after initialising the SDRAM controller */
extern uint32_t sdram_stack_top; /* defined in linker script */
__attribute__((naked)) void relocate_stack(void)
{
/* This must be naked — no PUSH before the MSR executes */
__asm volatile (
"LDR R0, =sdram_stack_top\n\t"
"LDR R0, [R0] \n\t"
"MSR MSP, R0 \n\t" /* switch MSP to SDRAM */
"BX LR \n\t"
);
}
int main(void)
{
sdram_controller_init(); /* bring up external SDRAM */
relocate_stack(); /* stack is now in SDRAM */
/* All subsequent PUSH/POP uses the new MSP */
application_init();
while (1) { application_run(); }
}
Stack Initialisation Tips: 7-Point Checklist
Correct stack configuration is critical for reliable firmware. The following checklist covers the key decisions and pitfalls in order of complexity.
1
Evaluate worst-case stack consumption. Profile or calculate the deepest call chain including all ISR nesting levels. Use the formula: MIN_STACK = (call_depth × avg_frame) + (max_nesting × 32) + safety_margin. Underfunding the stack causes silent data corruption far from the stack pointer — one of the hardest bugs to diagnose.
2
Know your stack consumption model. Confirm your processor uses Full Descending (all Cortex-M processors do). Full Descending means SP points to the last written word and decrements before each write. If you ever port code to an unusual core (e.g., some DSPs use Full Ascending), the initialisation address changes.
3
Decide stack placement in RAM. Type 2 (stack at RAM_END, growing down) is the safest default — the unused buffer between stack and heap acts as a natural guard. Type 1 (stack at mid-RAM) requires explicit MPU protection or careful sizing to prevent stack-heap collision.
4
Use two-stage initialisation for external memory. If the final stack lives in external SDRAM or PSRAM, the startup must first use internal SRAM for the MSP (so Reset_Handler can run), initialise the external memory controller, then move SP to the external memory. Never set SP to external memory in the vector table — the controller is not yet up.
5
Ensure vector table word 0 contains the correct initial MSP. The hardware reads it at reset — if it is wrong (e.g., 0x00000000 or not 4-byte aligned), the very first PUSH in Reset_Handler corrupts address 0 and the processor likely HardFaults immediately. Check your linker script defines _estack correctly and that the startup file uses it.
6
Use the linker script for all memory region boundaries. Define _estack, _heap_start, _heap_end in the linker script and reference these symbols in C code via extern uint32_t _estack;. This ensures that if RAM size changes (different MCU variant), you only update the linker script — not scattered defines in C files.
7
In RTOS projects, separate MSP and PSP stacks explicitly. The RTOS kernel and all ISRs use MSP. Each task uses its own PSP-backed stack. Initialise PSP in the RTOS startup (before the scheduler starts). Size MSP for the deepest ISR nesting plus kernel overhead; size each PSP stack for the task’s deepest call chain plus one exception frame.
Stack health monitoring in firmware
/* Paint stack with a known pattern at startup */
#define STACK_CANARY 0xDEADBEEFU
extern uint32_t _estack;
extern uint32_t _Min_Stack_Size; /* from linker script */
void stack_paint(void)
{
uint32_t *p = (uint32_t *)((uint32_t)&_estack - (uint32_t)&_Min_Stack_Size);
uint32_t *top = (uint32_t *)&_estack;
while (p < top) *p++ = STACK_CANARY;
}
uint32_t stack_unused_bytes(void)
{
uint32_t *p = (uint32_t *)((uint32_t)&_estack - (uint32_t)&_Min_Stack_Size);
uint32_t count = 0;
while (*p == STACK_CANARY) { p++; count += 4; }
return count;
}
/* Call stack_paint() at the very start of Reset_Handler (before .data/.bss init),
then call stack_unused_bytes() after the system has run to measure headroom. */
Frequently Asked Questions
Does the hardware stacking happen even if the ISR never uses R0–R3?
Yes — the hardware always pushes all 8 registers unconditionally on exception entry, regardless of what the handler actually uses. There is no “lazy stacking” for the integer core frame (though Cortex-M4F does have lazy FPU stacking). This is the cost of the hardware-transparency guarantee: every ISR entry costs the same 12–16 cycles for stacking.
What is xPSR and why is it saved?
xPSR (execution Program Status Register) is a composite of three status sub-registers: APSR (condition flags N/Z/C/V/Q), IPSR (exception number), and EPSR (Thumb bit, IT state). It is saved so that the interrupted thread’s condition flags and execution state are exactly restored when the handler exits. Without saving xPSR, an interrupt between two instructions that rely on the same flags would silently corrupt the control flow.
Can I read the interrupted task’s PC from inside the ISR?
Yes. The saved PC is at offset +24 from the stack pointer at exception entry. If Thread mode used MSP: uint32_t *frame = (uint32_t *)__get_MSP(); uint32_t resume_pc = frame[6];. If Thread mode used PSP: use __get_PSP(). Fault handlers commonly use this to identify which instruction triggered the fault.
What if the stack overflows during exception entry stacking?
The hardware still attempts to push the frame. If the target addresses are invalid (below the bottom of SRAM, or inside an MPU-protected region), a MemManage or BusFault fires. If the fault handler also overflows during its own stacking, a HardFault fires. If HardFault stacking also overflows, the processor enters Lockup state and asserts the LOCKUP signal, which many MCUs use to trigger a system reset.
Why must I use a naked function to switch MSP at runtime?
A normal C function generates a PUSH prologue using the current SP. If the switch happened inside that function, some words would be on the old stack and some on the new stack, leaving both misbalanced. The naked attribute prevents any compiler-generated stack access, so the MSR instruction is the very first operation affecting the stack pointer.
Exception Stacking ARM Stack Frame Un-stacking EXC_RETURN ISR C Function MSP Initialisation xPSR Register Stack Overflow FreeRTOS Stacking STM32F411
© EmbeddedPathashala — Free Embedded Systems Course — ARM Cortex-M3/M4 Series
EmbeddedPathashala — Embedded Systems Programming on ARM Cortex-M3/M4

2 Comments