NVIC, Interrupts & ISR Design

Interrupts let hardware demand the CPU's attention instantly. Learn the Cortex-M exception model and vector table, the NVIC (enable, pending, priority, grouping, preemption), automatic stacking and latency, tail-chaining and late arrival, writing correct ISRs, sharing data safely with volatile, critical sections and lock-free ring buffers, and how to budget interrupt CPU load.

Polling a button in a loop wastes CPU and can miss short events. With interrupts, the peripheral raises a request, the CPU finishes its current instruction, saves context automatically, and jumps to your Interrupt Service Routine (ISR) — typically within 12 clock cycles on a Cortex-M3/M4.

Interrupts are also the main source of subtle bugs: shared variables corrupted by races, ISRs too long for their deadlines, priorities set wrongly so a critical interrupt waits behind a slow one. This lesson shows how to design ISRs that are short, safe and predictable.

An analogy

An interrupt is a doorbell. You pause what you are doing (bookmark the page = stack the registers), answer the door quickly (the ISR), and resume reading. If the doorbell conversation takes an hour, everything else stalls — so hand the parcel to someone else (defer work to the main loop or a task).

Polling vs interrupts

Polling repeatedly checks a flag — simple but wastes CPU and has latency up to one loop time. Interrupts react immediately and let the CPU sleep between events. Use polling for very fast, predictable waits (a few µs) and interrupts for asynchronous events (received bytes, timer ticks, pin changes).

What happens on an interrupt

The Cortex-M hardware does a lot automatically:

Rules for good ISRs

Keep them short, deterministic and safe:

Races, atomicity and priority design

Reading a 32-bit counter is atomic on Cortex-M, but a read-modify-write (count++) or a 64-bit timestamp is not — an ISR can fire in between. Protect with a critical section, LDREX/STREX, or C11 atomics. Assign priorities by deadline (rate-monotonic thinking): the shortest deadline gets the highest priority. Measure real latency with a GPIO toggle and a scope, and keep interrupts disabled for as short as possible — every disabled microsecond adds to everyone's worst-case latency.

Formulas

Worked example: Latency in time

Given: Cortex-M4 at 72 MHz; 12-cycle entry

Find: Latency.

Answer: ≈ 167 ns (plus Flash wait states and any masking)

Worked example: ISR CPU load

Given: 10 kHz timer interrupt; ISR 200 cycles incl. entry/exit; 72 MHz

Find: CPU share.

Answer: 2.8% CPU

Worked example: Missing bytes

Given: UART 115 200 baud → 1 byte every 86.8 µs; A higher-priority ISR runs 120 µs

Find: Risk?

Answer: Overrun error — shorten the long ISR, raise UART priority, or use DMA/FIFO

UART RX interrupt feeding a lock-free ring buffer

#include <stdint.h>
#include <stdbool.h>

#define RB_SIZE 8u                       /* power of two */
static volatile uint8_t rb[RB_SIZE];
static volatile uint8_t head, tail;      /* free-running 8-bit indices */

static bool rb_put(uint8_t b)            /* called ONLY from the ISR  */
{
    if ((uint8_t)(head - tail) == RB_SIZE) return false;   /* full */
    rb[head & (RB_SIZE - 1u)] = b;
    head++;                              /* publish after the write   */
    return true;
}

static bool rb_get(uint8_t *b)           /* called ONLY from main     */
{
    if (head == tail) return false;      /* empty */
    *b = rb[tail & (RB_SIZE - 1u)];
    tail++;
    return true;
}

void USART1_IRQHandler(void)             /* name must match vector table */
{
    if (USART1->SR & USART_SR_RXNE) {    /* reading DR clears the flag */
        (void)rb_put((uint8_t)USART1->DR);
    }
}

/* Host test of the buffer logic (same code, bytes pushed directly):
   push 0..9  -> "accepted 8 of 10 bytes"
   get x3     -> "read: 0 1 2"
   remaining  -> "bytes waiting = 5"                                     */

One producer (ISR) and one consumer (main) each own one index, so no lock is needed on a single-core MCU. The ISR does the minimum — move one byte — and all parsing happens in main.

Configuring priorities with CMSIS

NVIC_SetPriorityGrouping(3);                 /* 4 bits preempt, 0 sub (STM32) */
NVIC_SetPriority(TIM1_UP_IRQn, 1);           /* motor control: very high      */
NVIC_SetPriority(USART1_IRQn, 5);            /* comms: medium                 */
NVIC_SetPriority(EXTI15_10_IRQn, 10);        /* button: low                   */
NVIC_EnableIRQ(TIM1_UP_IRQn);
NVIC_EnableIRQ(USART1_IRQn);
NVIC_EnableIRQ(EXTI15_10_IRQn);

/* Mask only priorities >= 5 while touching comms data, motor IRQ still runs */
__set_BASEPRI(5 << (8 - __NVIC_PRIO_BITS));
/* ... critical section ... */
__set_BASEPRI(0);

Lower number = higher urgency. BASEPRI masks interrupts at or below a priority level, so time-critical interrupts keep running during a critical section — better than disabling everything with PRIMASK.

Common mistakes

Applications

Key takeaways

Going further