The puzzle
A handler is running when another interrupt arrives. Does the new one wait, or does it cut in? When the first handler returns, does the processor go back to the main program, or straight into the next handler? And when one interrupt line serves eight GPIO pins, whose job is it to find out which pin it was?
STEP 1
Four states
Each interrupt in the NVIC is in one of four states:
- inactive: nothing requested;
- pending: requested, waiting to be taken;
- active: its handler is running (or has been preempted and will resume);
- active and pending: its handler is running and a new request for the same interrupt has already arrived.
A request makes an interrupt pending; taking it makes it active and clears the pending bit; the exception return makes it inactive. Software can also set or clear the pending bit directly (NVIC_SetPendingIRQ, NVIC_ClearPendingIRQ), which is useful for tests and for triggering deferred work (lesson 6).
STEP 2
From request to return
When a pending interrupt can be taken, the core:
- stacks at least the eight-word basic frame (r0–r3, r12, LR, return address, xPSR) on the current stack, plus 18 floating-point words on a core with an FPU when floating-point context is active, and possibly one alignment word (unit 6, lesson 4);
- fetches the handler address from the vector table (unit 5, lesson 3) and loads LR with an EXC_RETURN value;
- runs the handler, an ordinary C function, since the registers C may clobber have been saved;
- on return (a branch to the EXC_RETURN value in LR), unstacks the frame and resumes where it left off.
STEP 3
When requests overlap
If a new request arrives while a handler runs, priority decides (lesson 3). A more urgent interrupt preempts: the running handler is itself interrupted, its state stacked, and it resumes later. An equal or less urgent one stays pending. When the running handler finishes, the core does not unstack the frame and immediately stack it again: it goes straight into the pending handler. Arm calls this tail-chaining; it saves the time of a full return and re-entry.
STEP 4
Installing handlers
A handler is found through the vector table. Defining a function with the vector’s name (TIM3_IRQHandler, or isr_dma_0 on the RP2040) overrides the weak default at link time (unit 5, lesson 3). SDKs also install handlers at run time: the pico-sdk’s irq_set_exclusive_handler() sets the single handler for an interrupt number, and irq_add_shared_handler() adds one of several handlers for a multiplexed line, called in order of an “order priority”. The pico-sdk notes that the link-time method offers no speed benefit and can cause link conflicts.
STEP 6
Worked example: a UART and a timer at equal priority
A UART receive handler takes 6 µs; a timer interrupt of equal priority arrives 2 µs into it and takes 10 µs.
- The timer request is pending until the UART handler ends, then tail-chains:
so it starts at t = 6 µs and ends at 16 µs.
- The main program resumes only at 16 µs, having been interrupted once with one stack and one unstack, instead of two of each.
- Had the timer been more urgent, it would have started at t = 2 µs and the UART handler would have finished at 6 + 10 = 16 µs, 10 µs later than before.
MYTHS AND FACTS
Common misconceptions
A second interrupt is lost while a handler runs
It stays pending (even a second request for the same interrupt makes it active-and-pending) and runs afterwards. But there is only one pending bit: further requests while it is already pending merge into one, so count events in the peripheral if every one matters.
Every interrupt returns to the main program before the next one starts
Pending interrupts are tail-chained directly.
One interrupt line means one source
Shared lines need handlers that find and clear every source.
Defining the handler by name is faster than installing it at run time
The SDK says it offers no run-time benefit over an exclusive handler; the vector table is used either way. Shared handlers add a short dispatch chain.
Check yourself
Answer in your head, then open the card.
Handler A (priority 1) runs from t = 0 to 50. B (priority 0) arrives at t = 10 and takes 20. When does A finish?
B preempts at 10 and runs until 30; A resumes and finishes at 50 + 20 = 70.
Same, but B has priority 2. When does B start?
At t = 50, tail-chained after A; it waits 40.
A GPIO bank interrupt fires for pins 3 and 12 at once. The handler clears pin 3's event and returns. What happens next?
The line is still asserted by pin 12's event, so the handler is entered again immediately; if it keeps serving only the first pin it finds, busy low-numbered pins can starve high-numbered ones.
A timer interrupt fires again while its own handler is still running. What state is it in, and what happens?
Active and pending. When the handler returns, it is tail-chained into itself and runs again. If the first run cleared the peripheral flag after the second event had already set it, the second run finds nothing to do; handlers must tolerate such empty runs.
Sources (3)
- Arm, CMSIS 6, CMSIS/Core/Include/core_cm4.h and core_cm0plus.h — NVIC registers ISER (enable), ICER, ISPR (set pending), ICPR (clear pending), IABR “Interrupt Active bit Register” (Cortex-M4; absent on the Cortex-M0+), IPR (priority); NVIC_GetActive reads IABR
- Raspberry Pi Ltd, pico-sdk 1.5.1, hardware_irq/irq.h and irq.c — three ways to set handlers: irq_add_shared_handler (“Each handler, should check and clear the relevant hardware interrupt source”; called “in sequence from highest order_priority to lowest”), irq_set_exclusive_handler, or defining e.g. isr_dma_0 at link time (“can cause link conflicts … offers no runtime performance benefit”); one IO interrupt per bank per core
- Arm, Armv7-M Architecture Reference Manual (DDI0403), §B1.5 “ARMv7-M exception model” (exception entry, return, tail-chaining) — pending/active states, preemption by priority, and tail-chaining of a pending exception at exception return; section number from memory, not opened in this session