In short: On ARM Cortex-M the NVIC splits each interrupt's 8-bit (hardware-implemented) priority field into a preemption group and a sub-priority using AIRCR.PRIGROUP — preemption decides who can interrupt whom, sub-priority only breaks ties among equal-preemption pending exceptions, and tail-chaining then lets back-to-back ISRs run without a full pop/push of the stack.
Interrupt priority is where a lot of embedded bugs are born: a sensor ISR that can never preempt a slow comms handler, a watchdog that fires too late, or a race that only appears under load. The Nested Vectored Interrupt Controller (NVIC) on ARM Cortex-M gives you fine control over all of this — but only if you understand the difference between preemption priority and sub-priority, and how PRIGROUP carves them out of a single priority byte.
One priority field, two meanings
Every exception on a Cortex-M core (except Reset, NMI, and HardFault, which are fixed negative priorities) has an 8-bit priority register. Crucially, silicon vendors rarely implement all 8 bits. An STM32 (Cortex-M4) typically implements the top 4 bits — 16 priority levels. A low-cost Cortex-M0+ may implement only the top 2 bits — 4 levels. The unimplemented low bits read as zero. So before writing any priority value, check your device's __NVIC_PRIO_BITS (CMSIS defines it per part).
Lower numeric value = higher urgency. Priority 0 beats priority 5. This trips up newcomers constantly: setting a "high priority 15" on an STM32F4 actually gives you the lowest level.
The NVIC then splits those implemented bits into two subfields, controlled by the PRIGROUP field inside SCB->AIRCR:
- Preemption (group) priority — the upper subfield. A pending interrupt whose preemption priority is numerically lower than the one currently executing will preempt it (nested interrupt).
- Sub-priority — the lower subfield. If two interrupts are pending at the same preemption level, the one with the lower sub-priority is serviced first — but it never preempts the running handler. Sub-priority is purely a tie-breaker in the pending queue.
How PRIGROUP draws the line
On a 4-bit device, PRIGROUP picks the split point. CMSIS gives you NVIC_SetPriorityGrouping(n). The value n is the number of sub-priority bits counted from the full 8-bit field, which is the classic source of confusion.
A common, safe default on Cortex-M4/M7 running an RTOS (FreeRTOS does this in port.c) is NVIC_SetPriorityGrouping(0) — all implemented bits become preemption bits, zero sub-priority bits. That makes reasoning simple: every priority level can preempt every lower one, and there is no hidden tie-breaking behaviour. If your design needs ISRs that share an urgency tier but a deterministic service order, that is when sub-priority earns its place.
A concrete scenario
Say you have three interrupts on an STM32F4 (4 bits, grouping 2 → 2 preemption bits + 2 sub bits):
| IRQ | Preemption | Sub | Behaviour |
|---|---|---|---|
| TIM2 (control loop) | 0 | 0 | Preempts everything below |
| SPI1 (sensor DMA) | 1 | 0 | Preempts UART; preempted by TIM2 |
| UART4 (telemetry) | 1 | 1 | Same group as SPI1 — will not preempt it; if both pend, SPI1 goes first |
Here TIM2 is the hard-real-time control loop and must preempt. SPI1 and UART4 share preemption level 1, so they never interrupt each other — but if both become pending during a TIM2 ISR, the sub-priority guarantees the sensor data (SPI1) is handled before telemetry. This is exactly how you keep a motor-control or power-converter loop deterministic while still draining lower-value traffic in a sensible order. Getting this wrong — e.g. giving UART a lower preemption number than the control loop — is a classic cause of jitter that only shows up under traffic.
Tail-chaining: the latency win
When an ISR finishes and another exception of equal-or-lower-than-current but still-pending priority is waiting, the Cortex-M core does not pop the 8-register stack frame and then immediately push it again. Instead it tail-chains: it skips the unstack/restack and fetches the next vector directly. On a Cortex-M4, a normal exception entry costs 12 cycles; a tail-chain transition is roughly 6 cycles. That halving matters when interrupts arrive in bursts.
A related optimisation is late-arriving preemption: if a higher-priority interrupt arrives during the stacking for a lower one, the core redirects to the higher handler using the same already-stacked frame. Together, tail-chaining and late arrival are why Cortex-M interrupt latency is both low and highly deterministic — and why you should measure it. See our companion piece on interrupt latency on Cortex-M for how to instrument entry time with DWT cycle counters, and RTOS priority inversion for how NVIC levels interact with scheduler priorities.
Practical pitfalls
- RTOS boundary: under FreeRTOS, ISRs that call
FromISRAPIs must have a preemption priority numerically at or above (i.e. >=)configMAX_SYSCALL_INTERRUPT_PRIORITY. A faster ISR (lower number) that calls an RTOS API will corrupt the kernel. - Grouping is global: changing
PRIGROUPre-interprets every interrupt's priority byte at once. Set it once at boot and never touch it again. - Verification: teams validating IP or SoC interrupt controllers do this at RTL with UVM testbenches in Synopsys VCS or Siemens Questa, checking nested-preemption and tail-chain corner cases in simulation before silicon. If you are building custom logic around a core, firms like Avecas VLSI design services run exactly these verification flows.
Want to go from register bits to a working interrupt-driven driver on real hardware? Our CourseTron Embedded Systems track walks you through NVIC configuration, RTOS integration, and latency measurement on an STM32 board, with graded labs. For a VLSI-side view of how these controllers are designed and verified, see ChipXpert's VLSI training.
FAQ
Does sub-priority ever cause preemption?
No. Sub-priority only orders interrupts that are pending at the same preemption level; it never interrupts a running handler. Only a lower preemption number preempts.
Why do my priority values seem to jump in steps of 16?
Because your device implements only the top 4 bits of the 8-bit field. The register stores the value left-aligned, so levels land at 0x00, 0x10, 0x20… Use CMSIS NVIC_SetPriority() with logical values and let it shift for you.
What is a safe default grouping?
Grouping 0 (all implemented bits as preemption, no sub-priority) is simplest to reason about and is what FreeRTOS expects. Add sub-priority only when you deliberately need ordered servicing within one tier.
Is tail-chaining something I configure?
No — it is automatic hardware behaviour in the Cortex-M exception model. You benefit from it simply by using the NVIC; you cannot and need not enable it.