Skip to main content

The NVIC

The vector table answers where. The Nested Vectored Interrupt Controller answers whether, when, and in what order โ€” and it is the only part of the exception model you actively program. Every interrupt line on the chip arrives at the NVIC; the NVIC decides whether that line is enabled, records it as pending if it is not yet serviceable, compares its priority against whatever the processor is currently doing, and hands the winner to the core.

The mental model that makes the rest of this page fall into place: the NVIC holds three bits of state per interrupt โ€” enabled, pending, active โ€” and one priority byte. Everything you do to an interrupt is a manipulation of those four things. Enabling is not clearing. Pending is not active. A priority number is not a priority level. Most NVIC bugs are one of those three sentences learned the hard way.

The controller is Arm's, not ST's, so it sits at the same address and behaves the same way on every Cortex-M. What the vendor chooses is how many lines are wired to it and โ€” the detail that catches most people โ€” how many bits of the priority byte actually exist.

Prerequisites

Exceptions and the Vector Table covers what happens once an exception wins arbitration: the stack frame, the vector fetch, tail-chaining and late arrival as mechanisms. This page is about the arbitration itself. I/O and Interrupts owns the general interrupt-versus-polling model; The Register Model covers PRIMASK, FAULTMASK and BASEPRI, which mask what the NVIC is offering.

The register setโ€‹

Every NVIC register lives in the System Control Space inside the Private Peripheral Bus, at fixed architectural addresses (Armv7-M ARM DDI 0403E.e ยงB3.4, "Nested Vectored Interrupt Controller, NVIC").

RegisterAddressAccessWhat a write does
NVIC_ISER0โ€“ISER70xE000_E100 + 4nRWWrite 1 to enable. Writing 0 does nothing. Reads back the current enable state.
NVIC_ICER0โ€“ICER70xE000_E180 + 4nRWWrite 1 to disable. Writing 0 does nothing. Reads back the enable state, same as ISER.
NVIC_ISPR0โ€“ISPR70xE000_E200 + 4nRWWrite 1 to force pending โ€” a software-raised interrupt.
NVIC_ICPR0โ€“ICPR70xE000_E280 + 4nRWWrite 1 to clear pending.
NVIC_IABR0โ€“IABR70xE000_E300 + 4nROActive bits. Set while a handler is executing, including while it is preempted.
NVIC_IPR0โ€“IPR590xE000_E400 + 4nRWFour byte-wide priority fields per word, one byte per interrupt.
NVIC_STIR0xE000_EF00WOWrite an interrupt number to make it pending. Unprivileged access is gated by CCR.USERSETMPEND.

Two structural points about that table.

The set/clear register pairs exist so that enabling one interrupt is not a read-modify-write. NVIC->ISER[0] = (1u << 6) enables interrupt 6 and touches nothing else โ€” no read, no mask, no race with an interrupt that fires in between. This is the same design as the GPIO BSRR register and for the same reason. Code that does NVIC->ISER[0] |= (1u << 6) works, but it is a read-modify-write for no reason, and the read returns the enable state, which is not what a naive reader of that line expects.

The priority registers are the documented exception to the word-access rule. The PPB otherwise requires word accesses (The Cortex-M Memory Map covers why), but NVIC_IPRn is explicitly byte-addressable so a single interrupt's priority can be written without disturbing its three neighbours. CMSIS exposes it as NVIC->IP[IRQn], a byte array.

On the STM32F411 the interrupt table runs to position 85 (RM0383 Rev 4, Table 37), so only ISER0โ€“ISER2 and IPR0โ€“IPR21 have any bits wired up. Registers beyond that exist architecturally and read as zero.

Enabled, pending, activeโ€‹

These three bits are independent, and every combination is reachable.

  • Pending is sticky and survives being disabled. An interrupt that fires while its ISER bit is 0 still sets its pending bit. Enable it later and it fires immediately โ€” for something that was disabled during initialisation, that "immediately" is the first instruction after the enable, which is usually not what was intended. Clear the pending bit before enabling: write ICPR first, then ISER.
  • The pending bit is cleared by hardware at exception entry, not at handler exit. So a source that re-asserts while its own handler is running sets pending again, and the handler is re-entered by tail-chaining after it returns. That is the correct behaviour for a device that produced a second event โ€” and a hang for a device whose flag was never cleared.
  • Active means "on the stack", not "running". A preempted handler is still active. Both IABR bits are set when a high-priority interrupt has preempted a low-priority one. This is the bit a fault handler reads to reconstruct what was in flight.

PRIMASK and BASEPRI sit downstream of all of this: they prevent an exception from activating, so a masked interrupt accumulates in the pending bit and is delivered the moment the mask is lifted. Masking loses nothing except ordering; disabling in ICER also loses nothing, because the pending bit still latches. Neither is a way to discard an event.

The priority byte that is not a byteโ€‹

Architecturally each interrupt gets an 8-bit priority field, so 256 levels. Almost no implementation provides them.

One NVIC_IPRn priority byte on the STM32F411
BitsFieldResetMeaning
7:4Priority0b0000The four implemented bits. 16 levels, 0 the most urgent.
3:0โ€”0b0000Not implemented on this part. Writes are ignored, reads return zero.

RM0383 Rev 4 ยง10.1.1 states the count for this device: "16 programmable priority levels (4 bits of interrupt priority are used)." The Armv7-M architecture allows an implementation to provide between 3 and 8 bits, always the most significant ones, with the unimplemented low bits reading as zero (Armv7-M ARM ยงB3.4.5). Three bits is common on small parts, four on most STM32s, and eight on essentially nothing.

Two consequences that produce real bugs:

The value you write is not the level you get. Writing 3 to NVIC->IP[irq] sets bits [1:0] โ€” both unimplemented โ€” so the register reads back 0, the most urgent level. Write 1, 2 and 3 to three interrupts and all three end up at priority 0, ordered by interrupt number, with no error and no warning. CMSIS's NVIC_SetPriority() does the shift for you:

/* CMSIS core_cm4.h, paraphrased. __NVIC_PRIO_BITS is 4 on the STM32F4 family. */
NVIC->IP[irq] = (uint8_t)((priority << (8u - __NVIC_PRIO_BITS)) & 0xFFu);

So NVIC_SetPriority(TIM2_IRQn, 3) writes 0x30. Use the CMSIS function and think in 0โ€“15; write the register directly and you must pre-shift.

BASEPRI takes the register-shaped value, not the CMSIS-shaped one. __set_BASEPRI(5) is a mistake: 5 lands in the unimplemented nibble and masks nothing. __set_BASEPRI(5 << (8 - __NVIC_PRIO_BITS)) โ€” that is, 0x50 โ€” masks everything at level 5 and below. The two APIs look symmetric and are not.

Priority groupingโ€‹

The priority byte is split by a binary point into a group (preempt) part and a sub part. Only the group part decides preemption; the sub part only breaks ties between two interrupts that are pending simultaneously at the same group priority. The split is set once, globally, by AIRCR.PRIGROUP[10:8] (PM0214 Rev 10 ยง4.4.5, AIRCR).

PRIGROUPBinary pointGroup bitsSub bitsGroup prioritiesSubprioritiesEffective on the STM32F411 (4 bits)
0b0000bxxxxxxx.y[7:1][0]128216 groups, 1 sub โ€” the sub bit does not exist
0b0010bxxxxxx.yy[7:2][1:0]64416 groups, 1 sub
0b0100bxxxxx.yyy[7:3][2:0]32816 groups, 1 sub
0b0110bxxxx.yyyy[7:4][3:0]161616 groups, 1 sub
0b1000bxxx.yyyyy[7:5][4:0]8328 groups, 2 subs
0b1010bxx.yyyyyy[7:6][5:0]4644 groups, 4 subs
0b1100bx.yyyyyyy[7][6:0]21282 groups, 8 subs
0b1110b.yyyyyyyynone[7:0]12561 group, 16 subs โ€” nothing preempts anything

AIRCR resets to PRIGROUP = 0b000, and writes to it require the key: bits [31:16] must be 0x5FA or the write is ignored entirely (PM0214 Rev 10 ยง4.4.5). NVIC_SetPriorityGrouping() handles that.

The right-hand column is the part worth internalising. With only four implemented bits, PRIGROUP values 0b000 through 0b011 are indistinguishable โ€” the sub-priority bits they nominate are all unimplemented, so you get 16 preemption levels and no sub-priorities. ST's HAL calls 0b011 "NVIC_PRIORITYGROUP_4" (four bits of preemption) and CubeMX selects it by default, which is a sensible choice; it just means the "sub priority" field in the CubeMX NVIC dialog does nothing at all on that setting. If you genuinely want sub-priorities on this part you must give up preemption levels for them: 0b101 buys four preemption groups and four subpriorities each.

Nesting, and the two optimisations that make it cheapโ€‹

Preemption happens when a newly pending exception's group priority is numerically lower than the processor's current execution priority. Equal group priorities do not preempt โ€” the second one waits and tail-chains. PM0214 Rev 10 ยง2.3.5 gives the direction of the comparison: "A lower priority value indicating a higher priority."

The cost of that is what the NVIC's design is optimised around. Arm quotes 12 cycles from the interrupt signal to the first instruction of the handler on a Cortex-M4 with zero-wait-state memory, and 6 cycles for a tail-chained transition from one handler to the next (Arm, Cortex-M4 Technical Reference Manual, DDI 0439, exception-handling chapter). The saving is exactly the push and the pop that tail-chaining skips.

Two back-to-back interrupts: tail-chaining versus the pop-and-push it replaces

The bottom row is a counterfactual โ€” the hardware never does it โ€” but it is the shape most people picture, and the gap at the right-hand end is what the optimisation is worth. Two more behaviours in the same family:

  • Late arrival. A higher-priority exception that arrives while the frame for a lower-priority one is still being stacked takes over the vector fetch. The stacking is not restarted, because the frame is identical either way.
  • Pop preemption. An exception that arrives while a frame is being unstacked causes the pop to be abandoned and the new handler to be tail-chained instead. The frame is still on the stack, so there is nothing to redo.

All three are the same idea: the exception frame is generic, so the hardware manipulates it as little as possible. Exceptions and the Vector Table shows where they sit in the entry and exit sequence.

Two practical notes on nesting depth. Every level of preemption costs another stack frame โ€” 32 bytes, or 104 with floating-point context (Floating Point and DSP Extensions). With 16 priority levels, the theoretical worst case is 16 nested frames plus the faults; budgeting for it is usually pointless, but budgeting for zero nesting because "my interrupts are short" is how a stack overflow gets discovered in the field. And the 12-cycle figure assumes zero-wait-state memory: on the STM32F411 running at 100 MHz, flash needs 3 wait states, so real entry latency depends on whether the vector and the handler are in the ART accelerator's cache (RM0383 Rev 4 ยง3.4).

Three NVIC mistakes that produce plausible, wrong behaviour

Writing an unshifted priority. NVIC->IP[TIM2_IRQn] = 2; compiles, runs, and sets priority 0 โ€” the highest. Do this for several interrupts with "different" priorities and they all collapse to 0, ordered by interrupt number, so the system appears to have a priority scheme and does not. The symptom arrives weeks later as a timing anomaly under load. The same mistake in the other direction, __set_BASEPRI(3), produces a critical section that protects nothing. Rule: 0โ€“15 goes through CMSIS, register writes are pre-shifted, and if you are unsure which API you are holding, read NVIC->IP[irq] back and check it is not zero.

Clearing the peripheral flag at the end of the handler. The write that clears a device's interrupt flag goes into the write buffer; the handler returns before it reaches the peripheral; the line is still asserted; the NVIC re-pends the interrupt and you re-enter the handler. Usually it happens exactly once, so the handler runs twice per event โ€” one extra byte read from a UART, one extra encoder count, one duplicated packet. Clear the flag first, at the top of the handler, and if it must be last, read the register back or issue a DSB before returning. This one is nasty because the duplicate is real work, not a crash: the system keeps running and the numbers are quietly wrong.

Assuming NVIC_DisableIRQ() takes effect on the next instruction. ICER is written through the same buffer. If the very next thing you do depends on the interrupt being off, insert __DSB(); __ISB(); โ€” Arm's own NVIC design guidance calls for exactly that. Without it, an interrupt can be taken after the instruction that disabled it, which reads as a compiler or hardware bug and is neither.

A fourth, less common but harder to find: enabling an interrupt whose pending bit was set during initialisation. Configuring a peripheral often sets its flag as a side effect โ€” an EXTI line configured for both edges, a timer that overflowed while you were setting it up. The ISER write then delivers an interrupt immediately, before the driver's state is ready. Write ICPR before ISER as a matter of habit; it costs one instruction and removes the whole class.

See alsoโ€‹

Referencesโ€‹

  • STMicroelectronics โ€” PM0214, STM32 Cortex-M4 MCUs and MPUs programming manual, consulted at Rev 10 (March 2020). ยง4.3 "Nested vectored interrupt controller (NVIC)" for the register descriptions, the set/clear semantics, the byte-addressable priority registers and the design hints on disabling interrupts; ยง4.4.5 for AIRCR, the PRIGROUP field and the 0x5FA write key; ยง2.3.5 for the priority ordering rule quoted; ยง2.3.7 for tail-chaining and late arrival.
  • Arm โ€” Armv7-M Architecture Reference Manual, consulted at DDI 0403E.e (ID021621). ยงB3.4 "Nested Vectored Interrupt Controller, NVIC" for the architectural register addresses in the System Control Space and the rule that an implementation provides between 3 and 8 priority bits, always the most significant ones with the remainder RAZ/WI; ยงB3.2 for AIRCR and the priority-grouping definition; ยงB1.5 for the exception-entry preemption rules.
  • Arm โ€” Cortex-M4 Technical Reference Manual (DDI 0439). The source for the cycle figures: 12 cycles of interrupt latency and 6 cycles for a tail-chained transition, both for zero-wait-state memory. These are properties of the processor implementation, not of the architecture โ€” an M0+ or an M7 differs, and so does the same core behind slower memory.
  • STMicroelectronics โ€” RM0383, STM32F411xC/E reference manual, consulted at Rev 4 (May 2025). ยง10.1.1 for the four implemented priority bits and the 52 maskable channels; Table 37 for the interrupt positions that determine how many ISER/IPR words are populated; ยง3.4 for the flash wait states and ART accelerator that make the real-world entry latency longer than the TRM's figure.
  • Arm โ€” CMSIS-Core(M), core_cm4.h. NVIC_SetPriority(), NVIC_EnableIRQ(), NVIC_SetPriorityGrouping() and the __NVIC_PRIO_BITS device macro. Worth reading rather than just calling: the shift in NVIC_SetPriority() is the whole reason the CMSIS priority scale and the register contents differ.