Exception Entry Exit Sequence
When two interrupts compete with identical priorities, hardware breaks the tie using the IRQ number. Once the NVIC selects a winner, a precise 7-step entry sequence fires automatically before a single line of your ISR runs. Understanding every step — and the EXC_RETURN value left in LR — is the key to writing safe, nestable interrupt handlers.
IRQ Number Tie-Break
The priority resolution tree has three levels. The NVIC works through them in order whenever two or more interrupts are simultaneously pending:
NVIC Arbitration Decision Tree
The IRQ number tie-break is deterministic and requires no programmer action. It is defined in the ARM Architecture Reference Manual.
With identical pre-empt and sub-priority, TIM2 (IRQ28) is served before I2C1_EV (IRQ31) because 28 < 31.
Priority Configuration Exercise
A practical way to study how the NVIC arbitrates between priorities is to pend interrupts manually using the NVIC Set Pending Register (NVIC_ISPR) — no peripheral hardware is needed. The exercise below pends TIM2 and I2C1_EV simultaneously and observes which ISR fires first.
Setup: STM32F4 IRQ numbers
Both IRQs are in NVIC_ISPR0 because both are below IRQ32.
Experiment A: Same Priority
#include "stm32f4xx.h"
#include <stdio.h>
void TIM2_IRQHandler(void)
{
printf("TIM2 ISR running\r\n");
/* Clear the pending bit — for a manually-pended IRQ just return */
}
void I2C1_EV_IRQHandler(void)
{
printf("I2C1_EV ISR running\r\n");
}
int main(void)
{
/* Configure both ISRs at the same priority */
NVIC_SetPriorityGrouping(4U); /* all bits = pre-empt, STM32F4 */
NVIC_SetPriority(TIM2_IRQn, 5); /* priority 5 → 0x50 in IPR */
NVIC_SetPriority(I2C1_EV_IRQn, 5); /* same priority */
/* Enable both IRQs */
NVIC_EnableIRQ(TIM2_IRQn);
NVIC_EnableIRQ(I2C1_EV_IRQn);
/* Pend both at the same time */
NVIC->ISPR[0] = (1U << 28) | (1U << 31);
/* IRQ28 (TIM2) fires first because 28 < 31 (tie-break by IRQ number) */
while (1) {}
}
Expected output (observe via SWO/UART):
TIM2 ISR running
I2C1_EV ISR running
Experiment B: Different Priority
/* Give I2C1 a higher priority (lower value) */
NVIC_SetPriority(TIM2_IRQn, 5); /* lower urgency */
NVIC_SetPriority(I2C1_EV_IRQn, 3); /* higher urgency */
/* Pend both */
NVIC->ISPR[0] = (1U << 28) | (1U << 31);
/* I2C1 fires first despite having a higher IRQ number */
I2C1_EV ISR running
TIM2 ISR running
Key insight: When priorities differ, urgency (pre-empt priority value) always wins over IRQ number. The IRQ number tie-break only applies when both pre-empt priority AND sub-priority are identical.
Pending Interrupt Behavior
Every IRQ has two status bits in the NVIC: a pending bit (in NVIC_ISPR/ICPR) and an active bit (in NVIC_IABR). Understanding how these two bits change through an interrupt’s lifecycle is essential for writing correct ISRs and avoiding missed-interrupt bugs.
IRQ Lifecycle — Pending and Active Bits
The pending bit is cleared automatically by hardware during entry (step 4 of entry sequence). Software never needs to clear it — only the peripheral’s status flag needs clearing.
Common bug: Forgetting to clear the peripheral’s own interrupt flag (e.g. USART SR.RXNE, TIM SR.UIF). The NVIC pending bit is auto-cleared on entry, but if the peripheral flag is still set, the peripheral immediately re-asserts the IRQ line, which sets the NVIC pending bit again, causing the ISR to re-enter the moment it returns — an infinite loop of ISR calls.
Exception Entry Sequence (7 Steps)
When the NVIC decides to take an exception, the processor executes the following sequence automatically — entirely in hardware, before the first instruction of the ISR runs:
7-Step Exception Entry Sequence
Steps 1–4 happen in hardware automatically. The programmer only controls step 6 (ISR body).
Visualising the Stack During Entry
MSP movement during exception entry (Thread→Handler)
MSP decrements by 32 bytes (8 words). If the pre-interrupt SP was not 8-byte aligned, hardware adds a padding word and sets xPSR bit[9].
Exception Exit and EXC_RETURN
The ARM Cortex-M exception return mechanism does not use a normal function return address. Instead, it uses a special 32-bit value called EXC_RETURN that encodes the target mode and stack pointer to restore.
- Generated on entry: When the processor enters an exception handler, hardware writes an EXC_RETURN value into the Link Register (LR). The old LR value (the caller’s return address) is saved onto the stack as part of the hardware exception frame.
- Stored in LR: While the ISR runs, LR holds the EXC_RETURN value, not a normal function return address. If your ISR calls a sub-function, the compiler automatically saves/restores LR around the call (via PUSH/POP or BL).
- Triggers exit: When any instruction loads EXC_RETURN into the PC (BX LR, POP {PC}, or LDMIA with PC in the register list), the processor recognises the magic pattern and executes the full exception exit (unstacking, mode switch, IPSR clear).
Exception Exit Steps
What happens when EXC_RETURN is loaded into PC
EXC_RETURN Values Table
Hardware generates one of three EXC_RETURN values depending on which stack was active before the exception and whether the FPU (Cortex-M4F) has live registers:
EXC_RETURN — lower 4 bits decode
0xFFFFFFF10xFFFFFFF90xFFFFFFFD0xFFFFFFE10xFFFFFFE90xFFFFFFED The most common value in bare-metal firmware is 0xFFFFFFF9 (interrupted code was in Thread mode using MSP). In an RTOS with PSP per task, 0xFFFFFFFD is used for task context ISRs.
EXC_RETURN bit-field decode
Bit[2]=1 means return to Thread mode. Bit[0]=1 means use PSP for unstacking. Together these two bits select the four main exit scenarios.
Practical Code
Reading EXC_RETURN from Inside an ISR
/* Print EXC_RETURN value from within an ISR.
* The compiler saves LR to the stack on ISR entry (if the ISR
* calls any sub-function). We read it via inline asm. */
void TIM2_IRQHandler(void)
{
uint32_t exc_return;
__asm volatile ("MOV %0, LR" : "=r" (exc_return));
if (exc_return == 0xFFFFFFF9U) {
/* Interrupted code was in Thread mode, using MSP */
} else if (exc_return == 0xFFFFFFFDU) {
/* Interrupted code was in Thread mode, using PSP (RTOS task) */
} else if (exc_return == 0xFFFFFFF1U) {
/* Nested exception — interrupted another handler */
}
/* Clear TIM2 update interrupt flag before returning */
TIM2->SR &= ~TIM_SR_UIF;
}
Observing Active Bits
/* Check whether an IRQ is currently active (its ISR is running) */
int irq_is_active(IRQn_Type irq)
{
if (irq < 0) return 0; /* system exceptions not in IABR */
uint32_t reg = (uint32_t)irq / 32U;
uint32_t bit = (uint32_t)irq % 32U;
return (int)((NVIC->IABR[reg] >> bit) & 1U);
}
/* Check pending bit */
int irq_is_pending(IRQn_Type irq)
{
return (int)((NVIC->ISPR[(uint32_t)irq / 32U] >> ((uint32_t)irq % 32U)) & 1U);
}
Minimal Bare-Metal ISR Template
/* A complete bare-metal ISR for TIM2 on STM32F4.
* Hardware stacks R0-R3, R12, LR, PC, xPSR on entry.
* Hardware unstacks them on exit via EXC_RETURN.
* No __attribute__((interrupt)) needed on Cortex-M. */
volatile uint32_t tim2_tick_count = 0;
void TIM2_IRQHandler(void)
{
/* 1. Clear the peripheral interrupt flag FIRST.
* If you clear it last, there is a window where the flag is
* still set when the ISR returns, causing an immediate re-entry. */
TIM2->SR = ~TIM_SR_UIF; /* RC_W0 field: write 0 to clear */
/* 2. Do the work */
tim2_tick_count++;
/* 3. Return: compiler emits BX LR.
* LR contains EXC_RETURN (e.g. 0xFFFFFFF9).
* Hardware unstacks and returns to interrupted code. */
}
Nested Exception — Reading Active Exception Number
/* IPSR (bits[8:0] of xPSR) holds the current exception number.
* 0 = Thread mode, 1 = Reset, 2 = NMI, 3 = HardFault,
* 4 = MemManage, 5 = BusFault, 6 = UsageFault,
* 11 = SVC, 14 = PendSV, 15 = SysTick, 16+ = IRQ0+ */
uint32_t get_active_exception(void)
{
return __get_xPSR() & 0x1FFU; /* CMSIS */
}
/* Equivalent without CMSIS */
uint32_t get_active_exception_raw(void)
{
uint32_t ipsr;
__asm volatile ("MRS %0, IPSR" : "=r" (ipsr));
return ipsr & 0x1FFU;
}
Frequently Asked Questions
Does hardware always clear the NVIC pending bit on exception entry?
Yes, for level-triggered peripherals the NVIC pending bit is cleared automatically on exception entry (step 4). However, the peripheral’s own interrupt flag is not touched by the NVIC — you must clear it yourself inside the ISR. If the peripheral flag remains set, the peripheral immediately re-asserts the IRQ line, which sets the NVIC pending bit again, leading to an infinite ISR re-entry.
Why is LR overwritten with EXC_RETURN on exception entry?
Normally LR holds a function’s return address. In Handler mode there is no concept of a “caller” in the normal sense — the handler was entered by hardware, not a BL instruction. The old LR value (from the interrupted code) is already saved in the hardware exception frame on the stack. Overwriting LR with EXC_RETURN gives the ISR a clean way to trigger the full exception exit mechanism via BX LR.
Can I manually set EXC_RETURN to change the return mode?
Yes, but only between the valid EXC_RETURN values (0xFFFFFFF1, 0xFFFFFFF9, 0xFFFFFFFD, and their FPU variants). Modifying EXC_RETURN is occasionally done in RTOS context-switch code to switch a task from using MSP to PSP. In general application code this is not needed.
What happens if two IRQs have the same number? (Is that possible?)
No. Each peripheral is assigned a fixed, unique IRQ number by the SoC vendor. There are no duplicate IRQ numbers. The tie-break by IRQ number only applies to the selection order between two simultaneously pending IRQs with identical priority values — both have different IRQ numbers.
How long does the stacking phase take?
On Cortex-M3/M4 with zero wait-state SRAM, the exception entry latency is 12 clock cycles: 1 cycle for the pending decision, 8 cycles for the 8-register push (with bus optimisations), and some pipeline cycles for the vector fetch and branch. This latency is deterministic and specified in the ARM TRM. With flash wait states or cache misses the latency can be higher.
Is __attribute__((interrupt)) needed on Cortex-M?
No. Unlike older ARM architectures (ARM7TDMI), Cortex-M hardware saves and restores the caller-saved registers (R0–R3, R12, LR, PC, xPSR) automatically. A plain C function with the correct name in the vector table works as a complete ISR. The __attribute__((interrupt)) GCC attribute is for other ARM architectures and must not be used on Cortex-M — it generates incorrect prologue/epilogue code.
Suggested Diagrams for This Lecture
- Timeline diagram: IRQ asserted → pending → entry → ISR → exit → Thread resumed
- Stack frame layout: 8 registers pushed, SP movement, new SP position
- EXC_RETURN bit-field breakdown: bits [31:4] = magic, bit[3] = FPU, bit[2] = Thread, bit[0] = PSP
- Nested ISR stack diagram: two consecutive frames on MSP
- Priority arbitration flowchart: pre-empt → sub-priority → IRQ number

2 Comments