NVIC and Interrupt Priorities on Cortex-M: Preemption and Grouping

A real system has many interrupt sources competing for the CPU: a UART receiving bytes, a timer driving a motor, an ADC finishing conversions. The Nested Vectored Interrupt Controller (NVIC) inside every Cortex-M core decides which one runs, and whether one handler may interrupt another. Priority bugs cause some of the strangest field failures, such as lost serial bytes only when the motor runs, so it is worth understanding the NVIC properly. This article explains interrupt states, priority numbers, grouping, masking with BASEPRI, and finishes with a test program that proves preemption on real hardware.

Enable, Pending and Active

Each interrupt line in the NVIC has three independent bits. Enabled means the NVIC will act on the interrupt. Pending means the interrupt has been requested but has not started yet. Active means its handler is running or has been preempted and is waiting to resume. A peripheral sets the pending bit; if the interrupt is enabled and its priority is high enough, the core starts the handler, the pending bit clears and the active bit sets.

Register arrayBase addressPurpose
NVIC_ISER0xE000E100Write 1 to enable an interrupt
NVIC_ICER0xE000E180Write 1 to disable an interrupt
NVIC_ISPR0xE000E200Write 1 to set pending (software trigger)
NVIC_ICPR0xE000E280Write 1 to clear pending
NVIC_IABR0xE000E300Read active status (not on M0/M0+)
NVIC_IPR0xE000E400One priority byte per interrupt

Because enabling and disabling use separate set and clear registers, you can change one interrupt without a read-modify-write. CMSIS wraps these as NVIC_EnableIRQ, NVIC_DisableIRQ, NVIC_SetPendingIRQ, NVIC_ClearPendingIRQ, NVIC_GetActive and NVIC_SetPriority.

How Priority Numbers Work

On Cortex-M, a lower number means a higher priority. Reset (-3), NMI (-2) and HardFault (-1) have fixed priorities above everything else. All other exceptions and interrupts have a configurable priority stored in an 8-bit field, but vendors implement only the most significant bits. The LPC1768 implements 5 bits, giving 32 levels; many other chips implement 3 or 4 bits; Cortex-M0 and M0+ always implement 2 bits.

LPC17xx: 5 implemented bits, priority byte = [P4 P3 P2 P1 P0 0 0 0]
NVIC_SetPriority(IRQn, 6)  ->  byte written = 6 << 3 = 0x30

Priority 0  = most urgent configurable level
Priority 31 = least urgent (LPC17xx)

The priority field is split into two parts according to the PRIGROUP field (bits 10:8) of the AIRCR register in the System Control Block. The group priority (also called preempt priority) decides whether one handler can interrupt another. The subpriority only breaks ties when two interrupts of the same group priority are pending at the same moment. If group and subpriority are both equal, the lower IRQ number wins.

PRIGROUPGroup priority bits (5-bit device)Subpriority bitsPreemption levels
0 to 25032
34116
4328
5234
6142
7051 (no preemption)

What the Hardware Does for You

On exception entry, the core automatically pushes R0 to R3, R12, LR, PC and xPSR onto the stack, fetches the handler address from the vector table in parallel, and loads LR with a special EXC_RETURN value. This is why handlers are ordinary C functions. On Cortex-M3 and M4 the entry takes 12 clock cycles with zero wait-state memory.

  • Tail chaining: if another interrupt is pending when a handler ends, the core jumps straight to the next handler without unstacking and restacking registers.
  • Late arrival: if a higher-priority interrupt arrives while the core is still stacking for a lower one, the higher one is serviced first using the same stack frame.
  • Masking: PRIMASK blocks all configurable interrupts; BASEPRI blocks only those with priority number equal to or greater than its value.

Test Program: Proving Preemption

This LPC1768 program uses the Timer0 and Timer1 interrupt vectors without configuring the timers at all. Setting an interrupt's pending bit from software runs its handler exactly as if the peripheral had requested it. Timer1 gets low priority and triggers Timer0, which has high priority. The log array records the order of events. Run it in the debugger and inspect log_buf.

main.c: demonstrate preemption, BASEPRI masking and pending state on LPC1768
#include "LPC17xx.h"

volatile char     log_buf[16];
volatile uint32_t log_idx = 0;

static void log_event(char c)
{
    if (log_idx < sizeof(log_buf))
        log_buf[log_idx++] = c;
}

/* High priority handler */
void TIMER0_IRQHandler(void)
{
    log_event('H');
}

/* Low priority handler: triggers the high priority one */
void TIMER1_IRQHandler(void)
{
    log_event('a');                      /* low priority started */
    NVIC_SetPendingIRQ(TIMER0_IRQn);     /* should preempt right here */
    __DSB();
    __ISB();
    log_event('b');                      /* low priority finished */
}

int main(void)
{
    NVIC_SetPriority(TIMER0_IRQn, 2);    /* more urgent */
    NVIC_SetPriority(TIMER1_IRQn, 20);   /* less urgent */
    NVIC_EnableIRQ(TIMER0_IRQn);
    NVIC_EnableIRQ(TIMER1_IRQn);

    /* Test 1: nesting. Expected log: a H b */
    NVIC_SetPendingIRQ(TIMER1_IRQn);
    __DSB(); __ISB();
    log_event('|');

    /* Test 2: BASEPRI masks priority 2 and below (numbers >= 2) */
    __set_BASEPRI(2u << (8u - __NVIC_PRIO_BITS));
    NVIC_SetPendingIRQ(TIMER0_IRQn);
    __DSB(); __ISB();
    log_event(NVIC_GetPendingIRQ(TIMER0_IRQn) ? 'P' : '?');  /* still pending */
    __set_BASEPRI(0);                    /* unmask: H runs now */
    __DSB(); __ISB();
    log_event('|');

    /* Expected final log_buf: "aHb|PH|" */
    while (1)
        __WFI();
}

In test 1 the log shows a, then H, then b, proving Timer0 interrupted Timer1 in the middle of its handler. If you swap the two priorities, the log becomes a b H, because the pending Timer0 interrupt waits until Timer1 finishes and then tail-chains. In test 2, BASEPRI blocks Timer0 even though it is enabled and pending, and the handler runs immediately when BASEPRI returns to zero. Note that __set_BASEPRI takes the raw byte, which is why the value is shifted by the number of unimplemented bits.

Priority Guidelines and Mistakes

  • Give the most time-critical, shortest handlers the most urgent priorities, for example motor commutation or UART receive without a FIFO.
  • Keep handlers short; set a flag or push data to a buffer and do the processing in the main loop.
  • With FreeRTOS on Cortex-M3/M4, any ISR that calls FreeRTOS API functions must have a priority number equal to or greater than configMAX_SYSCALL_INTERRUPT_PRIORITY.
  • Writing the shifted value to NVIC_SetPriority (for example 0x30) gives a completely different priority because CMSIS shifts again.
  • Forgetting to clear the peripheral's interrupt flag makes the handler re-enter forever and starve lower priorities.
  • Leaving all interrupts at the default priority 0 means none can preempt another, which is fine until one handler takes too long.

Summary

The NVIC tracks each interrupt as enabled, pending and active. Lower priority numbers are more urgent, only the top implemented bits of each priority byte matter, and PRIGROUP splits them into preempting group priority and tie-breaking subpriority. Hardware stacking, tail chaining and late arrival keep latency low, and BASEPRI lets you mask only the less urgent interrupts during critical sections.