7а. Exception entry from the inside: the stack frame, EXC_RETURN and the FPU
In section 7 an interrupt handler looked like an ordinary C function. That is by design: the Cortex-M core itself saves what a C function is obliged to save, and itself restores everything afterwards. This section is about what exactly happens on entry and exit: which registers go onto the stack and in what order, what the strange number in LR is, how the core chooses the stack, and why with the FPU enabled the frame is three times larger yet costs almost nothing.
Everything shown is a run in QEMU (machine netduinoplus2, Cortex-M4F core):
the program _verify/exc7a/exc7a.c puts recognisable numbers into the registers,
raises an interrupt through the NVIC, and an assembly handler records LR
and both stack pointers; then the frame is read straight from the stack. The rules were checked
against the Cortex-M4 core manual from the pack. The check is _verify/exc7a_check.py.
Terms used in the documentation
Term Meaning stacking, stack frame saving the state on the stack, and the block saved EXC_RETURN a special value in LR by which the core recognises an exception return MSP, PSP the main and process stack pointers Thread mode, Handler mode the mode of ordinary code and the mode of a handler STKALIGN, aligner aligning the frame to 8 bytes and the padding word lazy stacking, FPCCR.LSPEN deferred saving of the FPU registers FPCAR the address of the space for the FPU registers in the frame late-arriving exception a higher-priority exception that comes during stacking
7а.1 What the core puts on the stack
Before the first instruction of the handler the core itself saves eight words:
| Offset from SP in the handler | Word | In the experiment |
|---|---|---|
| +0x00 | R0 | 0x11111111 |
| +0x04 | R1 | 0x22222222 |
| +0x08 | R2 | 0x33333333 |
| +0x0C | R3 | 0x44444444 |
| +0x10 | R12 | 0xCCCCCCCC |
| +0x14 | LR | the return address of the interrupted function |
| +0x18 | PC | where to return: the next instruction of the interrupted code |
| +0x1C | xPSR | flags and exception number; bit 24 — Thumb |
These are exactly the registers that a C function is not obliged to save — that is why the handler can be an ordinary function: the rest (R4–R11) the compiler will save itself if they are needed.
Where exactly we return. In the experiment the interrupt is raised by an str instruction to an
NVIC register, followed by dsb and isb. In the frame PC = 0x0800011C — the address of the nop
after isb: in QEMU the exception was taken exactly there.
7а.2 Alignment: 32 bytes that turned out to be 36
The core manual describes a frame of eight words — 32 bytes. The experiment measured 36. The reason is alignment: on entry the core aligns the frame to 8 bytes (the STKALIGN bit). If the stack pointer before entry was divisible by 8 only with a remainder of 4, the core inserts a padding word and marks this in bit 9 of xPSR, so as to remove it on exit.
In the experiment xPSR in the frame is 0x21000200: bit 24 (Thumb) and bit 9 — the padding was there. The ARM calling convention expects the stack to be aligned to 8 bytes, and an interrupt can come anywhere — so the core aligns it itself.
⚠️ STM32F1 does not have this alignment after reset. It has a Cortex-M3 core of revision r1p1 (section 2а), and the
STKALIGNbit after reset is 0: the frame is aligned only to 4 bytes. From revision r2p0 the bit is on from the start. On F1 it is enabled in the first line of the program:SCB->CCR |= SCB_CCR_STKALIGN_Msk;— otherwise a handler working withdoubleor 64-bit numbers will get an unaligned stack (section 7в.3).
7а.3 EXC_RETURN: the number in LR
An ordinary function call puts the return address into LR. On exception entry the core
puts EXC_RETURN there — a number of the form 0xFFFFFFxx that never occurs among
code addresses. When the handler executes bx lr (or pop {pc}), the core sees this
number and performs an exception return: it pops the frame from the stack. The low bits
tell which stack and which frame:
| EXC_RETURN | Return to | Stack | Frame | In the experiment |
|---|---|---|---|---|
| 0xFFFFFFF1 | a handler (nested) | MSP | without FPU | — |
| 0xFFFFFFF9 | the main code | MSP | without FPU | main code on MSP |
| 0xFFFFFFFD | the main code | PSP | without FPU | main code on PSP |
| 0xFFFFFFE1 | a handler | MSP | with FPU | — |
| 0xFFFFFFE9 | the main code | MSP | with FPU | after an FPU instruction |
| 0xFFFFFFED | the main code | PSP | with FPU | — |
The experiment obtained three of the six values itself: main code on MSP — 0xFFFFFFF9;
switching the main code to the process stack (CONTROL.SPSEL = 1) — 0xFFFFFFFD, and the frame
went onto PSP; executing one FPU instruction — 0xFFFFFFE9.
Hence the practical rule: a handler must not corrupt LR if it
exits through bx lr. The compiler knows this; assembly handlers
do not. And the same number helps when analysing a fault (section 35): bit 2 of EXC_RETURN
tells which stack to take the frame from.
Why two stacks. The kernel and interrupts always run on MSP. An RTOS (section 24) switches tasks to PSP — each has its own stack — and an interrupt puts the frame on the stack of the interrupted task, while the handler itself runs on MSP. A context switch is a substitution of PSP between entry and exit.
7а.4 With the FPU: 108 bytes that cost almost nothing
If the main code used the FPU, the frame needs space for its registers too: S0–S15, FPSCR and one reserved word — 18 more words, 26 words = 104 bytes in total. The experiment measured 108 — the same 4 bytes of padding.
How the core knows the FPU was used: the very first FPU instruction sets the
FPCA bit in CONTROL — the experiment read CONTROL = 0x00000004 right after vmov.
Without it the frame is the ordinary one, 32 bytes, even if the FPU is enabled.
Lazy stacking. Saving 17 registers on every interrupt is expensive, and most often the handler does not touch the FPU. So by default (FPCCR = 0xC0000000: bits ASPEN and LSPEN) the core only reserves space in the frame and remembers its address in FPCAR; the registers themselves are written there only if the handler executes an FPU instruction.
The experiment showed this. The memory below the stack was filled beforehand with the number 0xDEADBEEF:
| Mode | S0 in the frame | S15 in the frame | FPSCR in the frame |
|---|---|---|---|
| lazy (LSPEN = 1, default) | 0xDEADBEEF — not written | 0xDEADBEEF | 0xDEADBEEF |
| immediate (LSPEN = 0) | 0x3F800000 = 1.0 | 0x40000000 = 2.0 | 0x00000000 |
With lazy stacking the space is there, but there is no write: the handler did not use the FPU. FPCAR = 0x2001FF90, the stack pointer in the handler is 0x2001FF70: the difference is 0x20 = 32 bytes, that is, the space for S0 starts right after the eight words.
The practical outcome: the FPU in the main code makes interrupts hardly more expensive,
as long as the handlers themselves do without floating point. Do one
float multiplication in a handler — and the core will write the 17 registers.
7а.5 Tail-chaining and late-arriving exceptions
Two mechanisms from the core manual that save cycles:
- Tail-chaining: a handler has finished and another exception is already waiting — the core does not pop the frame from the stack and push it again, but goes straight to the next handler.
- Late arrival: while the core was saving the frame for one exception, a more important one came — the core continues saving (the frame is the same) and goes to the handler of the important one. The late one is accepted until the first instruction of the first exception's handler has reached execution.
The emulator does not count cycles honestly, so the numbers come from the core description: exception entry on Cortex-M3/M4 takes 12 cycles (on M0 — 16, section 7.1), and tail-chaining 6 cycles instead of an exit and a new entry; Flash wait states add their own. An important consequence: several interrupts in a row cost less than the same number separately, — and handler latency must be measured under load, not one at a time.
7а.6 Pitfalls
An assembly handler returned "to the wrong place". LR was corrupted — it holds EXC_RETURN, not an address.
HardFault analysis gives garbage. The frame was taken from MSP, but the exception came from a task on PSP. Look at bit 2 of EXC_RETURN.
The frame "shifted" by a word. The padding was not accounted for: bit 9 of xPSR in the frame.
On F1 printf("%f") in an interrupt sometimes prints garbage. The r1p1 core:
STKALIGN is off after reset, the frame is aligned only to 4 bytes. Enable
the bit at the start of the program (7а.2).
Interrupts slowed down after enabling the FPU. The handler (or a function
it calls) uses float — lazy stacking fires
on every interrupt. Move the calculations out of the handler.
Lazy stacking was disabled "for reliability". Now 17 registers are written on every interrupt, even if the handler does not need the FPU.
The stack overflowed only with the FPU. A frame of 104–108 bytes instead of 32–36 — at every nesting level.
7а.7 Summary
- On entry the core itself pushes 8 words: R0–R3, R12, LR, PC, xPSR. Plus a padding word if the stack was not divisible by 8 (bit 9 of xPSR): in the experiment — 36 bytes.
- LR holds EXC_RETURN: 0xFFFFFFF9 (main code, MSP), 0xFFFFFFFD (PSP), 0xFFFFFFE9 (with an FPU frame); all three were obtained in the experiment.
- A frame with the FPU is 26 words (104 bytes), 108 in the experiment; the sign is the FPCA bit in CONTROL.
- Lazy stacking (the default): space in the frame is reserved, the FPU registers are not written — until the handler itself touches the FPU.
- Tail-chaining and late-arriving exceptions save stacking and unstacking of the frame.
Next: section 7б "Cortex-M assembly: Thumb-2 through the eyes of C".