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:
- Peripheral sets a pending bit in the NVIC
- If its priority beats the current one and it is enabled, the CPU stacks R0–R3, R12, LR, PC, xPSR (8 words)
- Fetches the ISR address from the vector table, sets LR to an EXC_RETURN code
- ISR runs as a normal C function
- On return, registers are unstacked — or the next pending ISR is tail-chained
Rules for good ISRs
Keep them short, deterministic and safe:
- Clear the interrupt flag (or the ISR re-enters forever)
- Do minimal work: copy data, set a flag, give a semaphore
- No blocking calls, no printf, no malloc, avoid floating point on cores that lazy-stack FPU
- Declare shared variables volatile and protect multi-byte data
- Use RTOS 'FromISR' APIs inside ISRs
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
- Entry latency (Cortex-M3/M4, zero wait-state):
t_lat ≈ 12 cycles (72 MHz → 167 ns) - Tail-chaining:
Back-to-back ISRs: ≈ 6 cycles between them (no unstack/restack) - ISR CPU load:
U_ISR = f_IRQ × (C_ISR + C_entry/exit) / f_CPU - Priority levels:
Levels = 2^(implemented bits); STM32: 4 bits → 16 levels (0 = highest) - Worst-case response:
R = own latency + execution + blocking by higher-priority ISRs + critical sections
Worked example: Latency in time
Given: Cortex-M4 at 72 MHz; 12-cycle entry
Find: Latency.
- 12 / 72×10⁶ s
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.
- 10 000 × 200 = 2 M cycles/s
- 2 M / 72 M
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?
- Without FIFO the RX register holds 1 byte
- 120 µs > 86.8 µs → next byte arrives before the first is read
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
- Wrong: Forgetting to clear the peripheral flag. — Right: The ISR fires continuously and the main loop starves.
- Wrong: Long processing or printf inside an ISR. — Right: Set a flag or queue data; process in main or a task.
- Wrong: Shared variable not volatile / not protected. — Right: volatile + atomic access or critical section.
- Wrong: Wrong handler name (typo) → default handler loop. — Right: Match the vector-table name exactly.
Applications
- Motor control: PWM-synchronous ADC interrupts at 10–20 kHz.
- Communications: UART/CAN RX interrupts with buffers or DMA.
- User input: EXTI pin-change interrupts with debounce timers.
- Low power: Wake-up from sleep on RTC or pin interrupts.
Key takeaways
- Hardware stacks context; ISR is a normal C function.
- Short ISRs, clear flags, defer work.
- Shortest deadline → highest priority.
- Protect shared data; minimise masked time.
Going further
- Zero-latency interrupts: RTOSes leave the highest priorities outside kernel masking.
- Nested interrupt stack usage: Each preemption level adds a frame — size the MSP accordingly.
- Interrupt storms: Chattering inputs; rate-limit or disable and re-enable via timer.