STM32 NVIC Priority Grouping: Why Your Interrupt Preemption Is Not Working as Expected
Interrupt priority on the ARM Cortex-M NVIC looks straightforward until a USART interrupt with a numerically lower priority value preempts a higher-priority task and breaks your real-time system. The root cause is almost never a wrong number in the priority field. It is a misunderstanding of how the PRIGROUP field in SCB->AIRCR splits the priority register into preempt priority and subpriority, combined with the fact that only 4 bits are implemented in most STM32 parts. This article explains the encoding and gives you the exact sequence to configure it correctly.
The priority register: only 4 bits, not 8
Every ARM Cortex-M interrupt—from SysTick to the highest-numbered peripheral IRQ—has an associated 8-bit priority register in the NVIC. The Cortex-M3/M4/M7 architecture supports 8 bits of priority, but silicon implementations are free to use fewer. On the STM32F0, F1, F3, F4, G0, G4, L0, L4, and most of the mainstream families, only the upper 4 bits of each priority register are implemented. The lower 4 bits read as zero and writes are ignored. On the STM32F7, H7, and U5, the same is true unless otherwise stated in the reference manual.
This means that on a typical STM32F401 or STM32F411, an interrupt priority of 0 is the highest, 15 is the lowest usable value, and any value above 15 aliases down to the equivalent 4-bit value. CubeMX and the HAL do not hide this: HAL_NVIC_SetPriority(IRQn, 0, 0) produces priority register 0x00, while HAL_NVIC_SetPriority(IRQn, 5, 0) produces 0x50 if the preempt priority is placed in the upper nibble. That nibble placement is controlled by PRIGROUP.
PRIGROUP: what the register actually does
The PRIGROUP field lives in bits [10:8] of the Application Interrupt and Reset Control Register (SCB->AIRCR), located at address 0xE000ED0C. Writing to it requires the VECTKEY key (0x05FA in the upper 16 bits), otherwise the write is ignored. This is the single most common reason why a PRIGROUP change appears to have no effect in the debugger register view: the VECTKEY was missing.
The CMSIS function __NVIC_SetPriorityGrouping() handles the unlock sequence correctly. The parameter is a value from 0 to 7, but the mapping to actual preempt/subpriority split is non-intuitive and is where most engineers get tripped up. Here is the full table for an STM32F4 where __NVIC_PRIO_BITS = 4:
PRIGROUP (param) | Preempt bits | Subpriority bits | Available preempt levels
0 | 0 | 4 | 1 (all equal)
1 | 1 | 3 | 2
2 | 2 | 2 | 4
3 | 3 | 1 | 8
4 | 4 | 0 | 16
5 | 4 | 0 | 16 (same as 4)
6 | 4 | 0 | 16 (same as 4)
7 | 4 | 0 | 16 (same as 4)
The formula is: preempt_bits = min(PRIO_BITS, 7 - PRIGROUP). For PRIGROUP 4 and above on a 4-bit part, all 4 bits are preempt and there is no subpriority at all. For PRIGROUP 3, you get 3 preempt bits and 1 subpriority bit.
This is where the HAL adds its own layer of confusion. The HAL macro NVIC_PriorityGroup_4 does not mean PRIGROUP = 4. It means 4 bits of preempt priority, which maps to PRIGROUP = 3 (for 4-bit parts) or PRIGROUP = 4 (for 8-bit parts). The HAL enumeration is:
NVIC_PriorityGroup_0 -> PRIGROUP 7 (0 preempt bits, 4 subpriority bits)
NVIC_PriorityGroup_1 -> PRIGROUP 6 (1 preempt bit, 3 subpriority bits)
NVIC_PriorityGroup_2 -> PRIGROUP 5 (2 preempt bits, 2 subpriority bits)
NVIC_PriorityGroup_3 -> PRIGROUP 4 (3 preempt bits, 1 subpriority bit)
NVIC_PriorityGroup_4 -> PRIGROUP 3 (4 preempt bits, 0 subpriority bits)
If you call HAL_NVIC_SetPriorityGrouping(NVIC_PriorityGroup_4) and then read back SCB->AIRCR, you will see PRIGROUP = 3, not 4. That is correct. The HAL abstracts the hardware PRIGROUP value so that the argument always means "number of preempt priority bits."
How NVIC_SetPriority interprets the priority value
When you call HAL_NVIC_SetPriority(IRQn, PreemptPriority, SubPriority), the HAL combines the two values into a single 8-bit field using the currently configured priority group. The encoding works as follows: the preempt priority is shifted left by the number of subpriority bits, then the subpriority is ORed into the lower bits. The result is stored in the NVIC priority register.
For PRIGROUP 3 (NVIC_PriorityGroup_4 on a 4-bit part), there are 4 preempt bits and 0 subpriority bits, so the value is simply the preempt priority shifted into the upper nibble. HAL_NVIC_SetPriority(TIM2_IRQn, 2, 0) writes 0x20 into the priority register, which means the interrupt priority value is 2 in the NVIC comparison logic.
For PRIGROUP 4 (NVIC_PriorityGroup_3), there are 3 preempt bits and 1 subpriority bit. HAL_NVIC_SetPriority(TIM2_IRQn, 2, 1) encodes as: preempt=2 (binary 010), subpriority=1 (binary 1). The preempt goes into the upper 3 bits of the implemented nibble, the subpriority goes into bit 0. Result: 0b0101 = 0x50 in the register. The NVIC compares only the preempt portion for preemption decisions.
The preemption rule that engineers forget
Here is the specific rule that causes most field bugs: the NVIC does not preempt based on the raw numeric value of the priority register. It compares only the preempt priority portion. If two interrupts have the same preempt priority, a pending interrupt of higher subpriority does not preempt the running handler. The second interrupt must wait until the first handler finishes, then the NVIC tail-chains to it.
This means that if you configure PRIGROUP = 3 (NVIC_PriorityGroup_4) and set TIM2_IRQn to preempt 2 and USART1_IRQn to preempt 5, USART1 will never preempt TIM2. That is the expected behaviour. But here is the tricky scenario: if you configure PRIGROUP = 4 (NVIC_PriorityGroup_3) and set TIM2_IRQn to preempt 2, subpriority 0 and USART1_IRQn to preempt 2, subpriority 1, USART1 will not preempt TIM2 even though its subpriority is higher. The two interrupts are at the same preempt level; they serialise. If you actually need USART1 to break into TIM2, they need different preempt priority values.
/* Wrong: both at preempt 2, USART1 will NOT preempt TIM2 */
HAL_NVIC_SetPriorityGrouping(NVIC_PriorityGroup_3);
HAL_NVIC_SetPriority(TIM2_IRQn, 2, 0);
HAL_NVIC_SetPriority(USART1_IRQn, 2, 1);
/* Correct: TIM2 at preempt 2, USART1 at preempt 1 (higher urgency) */
HAL_NVIC_SetPriorityGrouping(NVIC_PriorityGroup_4);
HAL_NVIC_SetPriority(TIM2_IRQn, 2, 0);
HAL_NVIC_SetPriority(USART1_IRQn, 1, 0);
SysTick and PendSV: the FreeRTOS trap
The FreeRTOS port for Cortex-M assigns the lowest possible priority to PendSV and SysTick, typically priority 15 on a 4-bit part. This guarantees that no peripheral interrupt can be blocked by the RTOS tick or context switch. The port expects that SysTick and PendSV share the same preempt priority and that no subpriority distinction can delay PendSV.
If you configure PRIGROUP to anything other than full 4-bit preempt (NVIC_PriorityGroup_4), SysTick and PendSV will have subpriority bits that fragment the comparison. If PendSV is at preempt 15, subpriority 0 and SysTick is at preempt 15, subpriority 1, PendSV still runs because the preempt portion is the same—but you are wasting a subpriority level for no benefit. Worse, if you accidentally assign a peripheral interrupt to the same preempt level as PendSV but with a higher subpriority, the peripheral can delay the context switch even though it should not.
The fix is simple: always use NVIC_PriorityGroup_4 when running FreeRTOS on a 4-bit STM32 part. This gives 16 distinct preempt levels and zero subpriority bits, exactly what the FreeRTOS port expects.
/* Called once during startup, before any HAL_NVIC_SetPriority */
HAL_NVIC_SetPriorityGrouping(NVIC_PriorityGroup_4);
/* The FreeRTOS config for Cortex-M expects this configuration */
#define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 5
/* Interrupts with priority >= 5 are masked inside critical sections.
Interrupts with priority 0-4 can preempt critical sections. */
Practical example: a mixed-criticality sensor system
Consider a product with an STM32F401 that reads a high-speed SPI gyroscope at 10 kHz, communicates with a sensor over I2C at 100 kHz, publishes results over USB CDC at 1 MHz, and handles a push-button interrupt for a safety shutdown. The priorities must be:
- SPI DMA TC (gyroscope): highest latency sensitivity. If not serviced within the next rotation, the gyroscope FIFO overflows. Assign preempt 0.
- EXTI (safety shutdown): must be serviced fast but calling it "highest" is wrong—it only runs when the user presses the button. Assign preempt 1. This still preempts everything except the SPI TC.
- USB CDC: needs timely service to avoid SOF jitter but is framebuffered. Assign preempt 2.
- I2C (sensor polling): can tolerate the most latency. Assign preempt 3.
- PendSV, SysTick, and idle-level peripherals: preempt 4 and below, masked during critical sections by setting
configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY = 5.
void SystemClock_Config(void); /* RCC setup */
void MX_GPIO_Init(void);
void InterruptPriorities_Init(void)
{
/* 4 preempt bits, 0 subpriority -- clean FreeRTOS port */
HAL_NVIC_SetPriorityGrouping(NVIC_PriorityGroup_4);
/* SPI1: gyroscope DMA -- highest urgency */
HAL_NVIC_SetPriority(SPI1_IRQn, 0, 0);
HAL_NVIC_SetPriority(DMA2_Stream0_IRQn, 0, 0);
/* EXTI0: safety button */
HAL_NVIC_SetPriority(EXTI0_IRQn, 1, 0);
/* USB CDC */
HAL_NVIC_SetPriority(OTG_FS_IRQn, 2, 0);
/* I2C1: sensor polling */
HAL_NVIC_SetPriority(I2C1_EV_IRQn, 3, 0);
HAL_NVIC_SetPriority(I2C1_ER_IRQn, 3, 0);
}
Notice that the SPI and DMA TX streams share the same preempt level. If the SPI DMA TC and the SPI transfer-complete interrupt both fire, they serialise correctly because they are at the same preempt level—there is no nested SPI interrupt storm.
Verifying PRIGROUP at runtime
The single most useful debug step when interrupt behaviour seems wrong is to read back the actual PRIGROUP value and the priority register of the two IRQs that are not preempting each other as expected.
uint32_t aircr = SCB->AIRCR;
uint32_t prigroup = (aircr >> 8) & 0x07;
printf("PRIGROUP: %lu (0x%lx)\n", prigroup, aircr);
/* Read priority of a specific NVIC interrupt */
uint32_t prio_spi = NVIC_GetPriority(SPI1_IRQn);
uint32_t prio_usart = NVIC_GetPriority(USART1_IRQn);
printf("SPI1 priority register: 0x%02lx\n", prio_spi);
printf("USART1 priority register: 0x%02lx\n", prio_usart);
If PRIGROUP is 0, then all interrupts share the same preempt level regardless of what you wrote into their priority registers. No preemption happens. The system serialises all interrupt handlers. This is the default reset value on some STM32 parts and it is the most common production bug I see in codebases that skip the priority group setup entirely.
Porting between STM32 families: PRIO_BITS changes
When moving firmware from an STM32F4 (4 bits) to an STM32H730 (4 bits on the NVIC, but with additional priority bits configurable through the System Control Block), the PRIGROUP encoding widens. On an STM32U5 or STM32H7, __NVIC_PRIO_BITS is still 4 for the standard NVIC unless the part is configured for 8-bit priority via the SCB->CCR register (the H7 allows this). If you move to an 8-bit part, PRIGROUP 4 gives 4 preempt and 4 subpriority bits instead of 4 preempt and 0 subpriority, and the same NVIC_PriorityGroup_4 macro produces a completely different interrupt behaviour.
The HAL hides this as long as you use the macros, because NVIC_PriorityGroup_4 means "4 bits of preempt priority" regardless of the underlying PRIGROUP. But the subpriority count changes silently. If your codebase has platform-specific sections that write PRIGROUP directly (SCB->AIRCR = 0x05FA0300), you must review them when porting between families because PRIGROUP 3 on a 4-bit part is not the same as PRIGROUP 3 on an 8-bit part.
Practical checklist
- Call
HAL_NVIC_SetPriorityGrouping()exactly once before anyHAL_NVIC_SetPriority(). The first call after SysTick is initialised is the right moment. - For FreeRTOS projects, always use
NVIC_PriorityGroup_4on 4-bit STM32 parts to match the PendSV/SysTick assumptions in the RTOS port. - Verify PRIGROUP at runtime during board bring-up by reading SCB->AIRCR bits [10:8] and checking the VECTKEY was accepted.
- Never rely on subpriority for actual preemption. Two interrupts at the same preempt level do not nest even if one has a higher subpriority.
- When debugging a preemption failure, read the actual NVIC priority register of both IRQs—do not trust what you wrote in CubeMX or in the HAL init call.
- Be explicit about preempt priority assignments in a dedicated init function. Scattered
HAL_NVIC_SetPrioritycalls in peripheral init code are the leading cause of inconsistent priority assignment. - When porting between STM32 families, check
__NVIC_PRIO_BITSin the CMSIS header or reference manual and verify that the PRIGROUP produces the expected split. - Use
configMAX_SYSCALL_INTERRUPT_PRIORITYin FreeRTOS to protect critical sections from all interrupts at or below that priority, not just PendSV/SysTick.
How I would approach this on a client project
On the first day of a new firmware project, before writing any peripheral init code, I write a single InterruptPriorities_Init() function that calls HAL_NVIC_SetPriorityGrouping(NVIC_PriorityGroup_4) and then assigns preempt priorities to every interrupt the system will use. I keep this function in its own source file (interrupts.c) with a table comment that lists each IRQ and its assigned preempt level, the reasoning, and the FreeRTOS masked priority. This becomes the reference document for every developer on the project.
During integration testing, I instrument each non-trivial ISR with a GPIO toggle on an oscilloscope channel, including a second toggle at the start and end of the interrupt handler to measure the real preemption depth. I have found PRIGROUP misconfiguration this way more than once—the oscilloscope shows interrupt A running inside interrupt B even though A was supposed to be lower priority, because PRIGROUP was set to 0 and all interrupts were at the same preempt level.
In code review, the first thing I look for in a non-FreeRTOS bare-metal project is whether the priority group has been set at all. The second thing is whether any direct writes to SCB->AIRCR bypass the VECTKEY sequence. These two checks catch about 80% of the NVIC priority bugs I see in inherited code.
Sources consulted
- ARM Cortex-M4 Technical Reference Manual — NVIC chapter
- CMSIS-Core core_cm4.h — NVIC_SetPriorityGrouping, NVIC_EncodePriority, NVIC_DecodePriority
- STM32F4 Reference Manual (RM0090) — System and memory overview / NVIC
- STM32H7 Reference Manual (RM0399) — NVIC priority bits configuration
- FreeRTOS Reference Manual — Cortex-M port implementation notes
- STM32CubeF4 example projects — HAL_NVIC_SetPriorityGrouping usage

Comments
Have a real-world NVIC priority war story where a PRIGROUP default value ruined a field trial? Send me a short note by email.