Nested Vector Interrupts Control
NVIC stands for Nested Vector Interrupt Controller. It's a dedicated peripheral baked into every ARM Cortex-M core that decides which interrupt runs, when, and at what priority. Understanding it is non-negotiable if you want responsive firmware.
NVIC is a method of prioritizing interrupts, improving the MCU's performance and reducing interrupt latency. It also provides implementation schemes for handling interrupts that occur when others are being executed — or when the CPU is in the process of restoring its previous state and resuming its suspended process. With context switching, the previous interrupt or program continues executing from its suspend point.
Why prioritization matters
On a Cortex-M, every external IRQ and most system exceptions go through the NVIC. Each one carries a configurable priority. When two interrupts arrive close together, the NVIC compares their priorities and lets the more urgent one preempt the less urgent — that's the "nested" part. This is what keeps a fast UART or timer ISR responsive while a slower handler is still running.
How priority is encoded
Each interrupt has an 8-bit priority register. The catch: ARM only mandates that the upper bits be implemented. Most chips give you 3 or 4 bits (8 or 16 distinct levels); the unimplemented low bits read as zero. You write to them, but they have no effect.
| Family | Priority bits | Levels |
|---|---|---|
| Cortex-M0+ | 2 | 4 |
| STM32F1 / F4 / F7 | 4 | 16 |
| nRF52 | 3 | 8 |
| RP2040 (M0+) | 2 | 4 |
Lower number = higher priority. Priority 0 is the most urgent. This trips up everyone at least once.
Preemption priority and sub-priority
ARM splits the priority byte into two fields:
- Preemption priority — whether an incoming IRQ can preempt a running handler.
- Sub-priority — which one runs first if both are pending and have equal preemption priority.
The split point between the two fields is configured once with NVIC_SetPriorityGrouping() from CMSIS. Most projects pick "all preemption, no sub-priority" — it's simpler and rarely the wrong call.
// Configure: 4 bits preemption, 0 bits sub-priority (STM32 default)
NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_4);
// Configure USART2 IRQ: priority 5, then enable
NVIC_SetPriority(USART2_IRQn, 5);
NVIC_EnableIRQ(USART2_IRQn);
Tail-chaining and late arrival
Two micro-architectural tricks the NVIC does for free:
- Tail-chaining: if an ISR finishes and another is pending, the core skips the unstack/restack pair and jumps straight to the next handler. Saves ~6 cycles on every back-to-back ISR.
- Late arrival: if a higher-priority IRQ arrives while the core is still stacking the previous one, the NVIC redirects to the new one without unwinding the partial stack. Same latency as if the late one had arrived first.
You don't have to do anything to enable these — they're always on.
Key NVIC registers (CMSIS names)
| Register | Purpose |
|---|---|
NVIC->ISER[] | Set Enable — write 1 to enable an IRQ |
NVIC->ICER[] | Clear Enable — write 1 to disable |
NVIC->ISPR[] | Set Pending — manually mark an IRQ pending |
NVIC->ICPR[] | Clear Pending |
NVIC->IABR[] | Active flag (read-only) — is this IRQ currently running? |
NVIC->IPR[] | Priority — one byte per IRQ |
You almost never poke these directly — CMSIS gives you NVIC_EnableIRQ, NVIC_DisableIRQ, NVIC_SetPriority, NVIC_GetPriority, NVIC_SetPendingIRQ, NVIC_ClearPendingIRQ, NVIC_GetActive.
Globally enable/disable interrupts
__disable_irq(); // PRIMASK = 1, masks all configurable IRQs
// critical section
__enable_irq(); // PRIMASK = 0
For finer-grained masking — block only IRQs at or below a given priority — use __set_BASEPRI(). RTOS kernels rely heavily on this.
Common pitfalls
- Forgetting to clear the peripheral's interrupt flag inside the ISR — the handler re-enters forever, locking up the chip.
- Confusing the direction of priority — lower number = higher priority. Always.
- Setting priority after enabling. It works, but configure first, enable last.
- Calling FreeRTOS API from an ISR with priority numerically lower than
configMAX_SYSCALL_INTERRUPT_PRIORITY— undefined behaviour. The kernel can't safely defer to its scheduler from a priority above the masking threshold.
The FreeRTOS angle
FreeRTOS port for Cortex-M lets you split the priority space:
- High-priority IRQs (numerically lowest) run with zero kernel involvement — kernel never masks them, so they have lowest possible latency. They cannot call any FreeRTOS API.
- Kernel-aware IRQs (priority ≥
configMAX_SYSCALL_INTERRUPT_PRIORITY) can call...FromISR()APIs. The kernel masks them during critical sections via BASEPRI.
This is why FreeRTOS port docs spend so much ink on priority configuration — get it wrong and you'll see seemingly random crashes.