The puzzle
A motor controller has a fault input that must shut the power stage off within 10 µs. It also has a UART, a millisecond tick and a display. Under test, the shutdown usually takes 3 µs and occasionally 27. Nothing is wrong with the fault handler; it just had to wait. What decides how long an interrupt waits, and how do you guarantee the important one never waits long?
STEP 1
Priority numbers
Every interrupt has a priority, and lower numbers are more urgent: 0 beats 1. A more urgent interrupt preempts a less urgent handler (lesson 2); equal priorities never preempt each other. When several interrupts of equal priority are pending, the NVIC takes the one with the lowest exception (IRQ) number first, not the one that arrived first.
The NVIC stores 8 bits per interrupt but implements only the top few: 2 on the Cortex-M0+ (4 levels), 4 on the STM32F4 (16 levels). The unimplemented low bits read as zero.
STEP 2
The encoding trap
Two common APIs disagree on what the number means. CMSIS’s NVIC_SetPriority(irq, p) takes a level in the implemented range and shifts it into the top bits; the pico-sdk’s irq_set_priority(irq, p) takes the raw 8-bit value. On a Cortex-M0+:
so NVIC_SetPriority(irq, 0x80) writes (0x80 << 6) & 0xFF = 0x00: the most urgent level, the opposite of the SDK’s default of 0x80, which is level 2 of 4.
The NVIC keeps an 8-bit priority field per interrupt but implements only its top bits: 2 on the Cortex-M0+, as on the RP2040. CMSIS’s NVIC_SetPriority() takes a number in the implemented range and shifts it into place; the pico-sdk’s irq_set_priority() takes the raw 8-bit value (its default is 0x80). Passing a raw value to the CMSIS function silently becomes a different priority.
STEP 3
Nesting and grouping
Preemption nests: a level-0 handler can interrupt a level-1 handler that interrupted a level-2 one, each stacking its own frame, so the stack must hold one frame per level in use at once (unit 3, lesson 5). Armv7-M cores (Cortex-M3, M4) can also split the priority bits into a preemption part and a sub-priority part (NVIC_SetPriorityGrouping): only the preemption part decides preemption, while the sub-priority orders pending interrupts of equal preemption level.
STEP 4
Worst-case latency
The time from an event to the first instruction of its handler is at most:
- entry: the core’s fixed exception-entry time (in the core’s technical reference manual), plus wait states if the vector table or handler is in slow flash;
- masked: the longest stretch with interrupts disabled (critical sections, lesson 5);
- handlers: the longest handler of equal priority that may be running, plus every more urgent handler that can arrive meanwhile.
To guarantee the fault input, give it the most urgent priority, keep critical sections short, and keep more urgent handlers (there should be none) short.
STEP 5
Masking selectively: PRIMASK and BASEPRI
PRIMASK masks all configurable interrupts: simple, and on the Cortex-M0+ the only option. The pico-sdk’s save_and_disable_interrupts() reads PRIMASK and sets it, and restore_interrupts() puts the old value back, so critical sections nest correctly. Armv7-M adds BASEPRI, which masks only interrupts at or below a chosen urgency: a critical section can then keep the fault handler alive while blocking the UART and tick handlers that share its data. RTOSes use this to keep their kernel data safe without delaying the most urgent interrupts.
STEP 6
Worked example: the 27 µs shutdown
With all three sources at the same priority (the figure’s defaults), a UART receive arrives at t = 10 µs (8 µs handler), a tick at t = 12 µs (20 µs) and the fault at t = 15 µs (4 µs). When the UART handler ends at 18 µs, the tick and the fault are both pending; the tick has the lower IRQ number here, so it goes first:
Had the fault had the lower IRQ number, it would have finished at 22 µs, 7 µs after it happened: IRQ numbers are a tie-breaker, not a design tool. Give the fault priority 0, the UART 1 and the tick 2: the fault preempts whatever is running and is done 4 µs after it happens (plus the entry time). The UART and tick handlers finish 4 µs later than before, which they can afford.
MYTHS AND FACTS
Common misconceptions
Higher number, higher priority
On Cortex-M it is the reverse: 0 is the most urgent.
All 256 priority values are distinct
Only the implemented top bits count: four levels on a Cortex-M0+.
NVIC_SetPriority takes the same numbers as the SDK
CMSIS shifts a level; the pico-sdk takes the raw byte.
Disabling interrupts briefly is harmless
Every masked microsecond adds directly to every interrupt’s worst-case latency.
Check yourself
Answer in your head, then open the card.
On a Cortex-M0+, what register value does NVIC_SetPriority(irq, 3) write, and what does irq_set_priority(irq, 3) write?
CMSIS: 3 << 6 = 0xC0, the least urgent of four levels. pico-sdk: 0x03, which reads back as 0x00 (only the top two bits exist): the most urgent level.
On an STM32F4 (4 priority bits), what does NVIC_SetPriority(irq, 5) write?
5 << 4 = 0x50.
The longest critical section is 12 µs, the most urgent handler is 3 µs, and entry takes about 0.3 µs. What is the worst-case latency of an interrupt that has that most urgent priority?
About 0.3 + 12 + 3 = 15.3 µs (one equal-priority handler may already be running); the critical section dominates.
Why can BASEPRI give better latency than PRIMASK for a critical section?
BASEPRI masks only interrupts at or below a chosen urgency, so the most urgent handlers keep running during the critical section; PRIMASK masks all of them.
Sources (4)
- Raspberry Pi Ltd, pico-sdk 1.5.1, hardware_irq/irq.h — irq_set_priority: “Numerically-lower values indicate a higher priority. Hardware priorities range from 0 (highest priority) to 255 (lowest priority) though only the top 2 bits are significant on ARM Cortex-M0+”; PICO_DEFAULT_IRQ_PRIORITY 0x80, set for every IRQ at start-up
- Arm, CMSIS 6, CMSIS/Core/Include/core_cm0plus.h, core_cm4.h and m-profile/cmsis_gcc_m.h — __NVIC_SetPriority writes (priority << (8U − __NVIC_PRIO_BITS)) & 0xFF into IPR; __NVIC_PRIO_BITS defaults to 2 for the Cortex-M0+; core_cm4.h adds NVIC_SetPriorityGrouping (preemption and sub-priority); __set_PRIMASK and __set_BASEPRI (“Assigns the given value to the Base Priority register”)
- STMicroelectronics, cmsis-device-f4, stm32f407xx.h — “#define __NVIC_PRIO_BITS 4U /*!< STM32F4XX uses 4 Bits for the Priority Levels */”
- Raspberry Pi Ltd, pico-sdk 1.5.1, hardware_sync/sync.h — save_and_disable_interrupts: mrs PRIMASK, then cpsid i; restore_interrupts writes the saved PRIMASK back