armhub.dev
Architecture · 15 pages
Architecture

The exception model

How Cortex-M handles interrupts, faults, and supervisor calls — vector tables, exception priorities, stack frames, and tail-chaining.

3 min read · 590 wordsupdated · May 12, 2026

On Cortex-M, everything that isn't your main thread is an exception. Reset, hard faults, system calls (SVC), interrupts from the NVIC, the SysTick, even the debug monitor — they all enter the processor through the same exception machinery. Understanding it pays off every time you debug a HardFault.

Exception numbers

Every exception has a number, and that number indexes into the vector table:

#NameClassNotes
1ResetsystemFirst instruction after power-up
2NMIsystemNon-maskable, vendor wires source
3HardFaultfaultCatch-all when other faults disabled
4MemManagefaultMPU access violation
5BusFaultfaultBus error
6UsageFaultfaultUndefined instruction, div-by-zero, etc.
7–10reserved
11SVCallsystemSVC #imm — supervisor call
12DebugMonitordebugSoftware debug
13reserved
14PendSVsystemUsed by RTOSes for context switch
15SysTicksystem24-bit downcounter
16+External IRQsNVICUp to 240, vendor-defined

The vector table

At reset, the Cortex-M reads two 32-bit words from the start of memory:

  1. Word 0 — the initial main stack pointer (MSP).
  2. Word 1 — the address of the Reset handler.

Then it fetches handler addresses for each subsequent exception from words 2..N. By default the vector table lives at 0x00000000, but you can relocate it at runtime by writing the VTOR (Vector Table Offset Register) at 0xE000_ED08.

// Typical Cortex-M startup — the vector table sits at the start of flash
__attribute__((section(".isr_vector")))
void (* const vector_table[])(void) = {
    (void(*)(void)) &_estack,   // 0: initial MSP
    Reset_Handler,              // 1: reset
    NMI_Handler,                // 2
    HardFault_Handler,          // 3
    MemManage_Handler,          // 4
    BusFault_Handler,           // 5
    UsageFault_Handler,         // 6
    0, 0, 0, 0,                 // 7-10 reserved
    SVC_Handler,                // 11
    DebugMon_Handler,           // 12
    0,                          // 13
    PendSV_Handler,             // 14
    SysTick_Handler,            // 15
    // External IRQs follow, vendor-specific
};

The exception entry sequence

When an exception fires, the processor automatically:

  1. Stacks 8 words onto the current stack (xPSR, PC, LR, R12, R3, R2, R1, R0). If an FPU is present and active, it also stacks S0–S15 + FPSCR.
  2. Loads the handler address from the vector table.
  3. Updates LR with EXC_RETURN — a magic value (0xFFFF_FFFx) encoding which stack to unstack from and which mode to return to.
  4. Sets IPSR to the exception number.
  5. Branches to the handler.

This entire process is hardware-driven and takes ~12 cycles on most Cortex-M cores. There is no ABI prologue — your C handler is called with a normal AAPCS calling convention, because the stacked R0–R3 + R12 cover the caller-saved registers.

Basic 8-word exception stack frame (no FPU)
r032r132r232r332r1232LR32PC32xPSR32256 bits total
Hardware pushes these eight words to the active stack (MSP or PSP) on entry, low address first. With an FPU active and lazy stacking disabled, 17 more words (s0-s15, FPSCR, alignment) follow above the basic frame.
Thread mode vs Handler mode transitions
exception taken (push frame, LR := EXC_RETURN)BX EXC_RETURN (pop frame)tail-chain / preemptThread modeHandler mode
Reset starts in Thread mode using the MSP. Any exception flips to Handler mode (always MSP); the magic EXC_RETURN in LR encodes which stack and which mode to restore when the handler does its bx lr.

Priority and preemption

Cortex-M priorities are inverted: smaller number = higher priority. Reset, NMI, and HardFault have fixed priorities (-3, -2, -1 respectively — they always preempt everything). All other exceptions take their priority from configurable registers.

Each priority byte is split into preemption priority (upper bits) and subpriority (lower bits) by SCB_AIRCR.PRIGROUP. Same-preemption-priority exceptions don't interrupt each other; subpriority only breaks ties between simultaneous pending exceptions.

The number of priority bits actually implemented is vendor-defined (typically 3–4 on Cortex-M3/M4, up to 8 on M7). CMSIS exposes it as __NVIC_PRIO_BITS.

NVIC_IPR0 · 0xE000_E400
32-bit · reset 0x0000_0000
28
24
20
16
12
8
4
0
b24
b16
b8
b0
BitsNameAccResetDescription
31:24PRI_3RW0x00Priority of IRQ 3. Only the upper __NVIC_PRIO_BITS bits are implemented.
23:16PRI_2RW0x00Priority of IRQ 2.
15:8PRI_1RW0x00Priority of IRQ 1.
7:0PRI_0RW0x00Priority of IRQ 0.
There are NVIC_IPR0..NVIC_IPR59 — 240 IRQs × 8 bits each = 1920 bits = 60 × 32-bit registers.

Returning from an exception

You don't write bx lr like a regular function. You branch to EXC_RETURN — the magic value the hardware left in LR. The bottom byte encodes:

  • bit 0: reserved
  • bit 2: return stack (0 = MSP, 1 = PSP)
  • bit 3: return mode (0 = handler, 1 = thread)
  • bit 4: FPU frame present (ARMv7E-M+)

In C, you almost never see this — the compiler emits bx lr and it Just Works because LR still holds the EXC_RETURN value. In hand-written assembly, don't clobber LR before returning.

Debugging HardFaults

When a fault you didn't enable fires, it escalates to HardFault. The Configurable Fault Status Register (SCB_CFSR at 0xE000_ED28) tells you why. The minimum-effort debugger trick:

void HardFault_Handler(void) {
    volatile uint32_t cfsr = SCB->CFSR;   // what happened
    volatile uint32_t hfsr = SCB->HFSR;   // hardfault flags
    volatile uint32_t mmfar = SCB->MMFAR; // mem-fault address (if valid)
    volatile uint32_t bfar  = SCB->BFAR;  // bus-fault address (if valid)
    __asm volatile ("BKPT #0");           // halt in debugger
    while (1) {}
}

When you hit the breakpoint, inspect those volatiles and decode them against ARMv7-M ARM §B3.2.15.

Where to go from here