The vector table: an array of addresses at the bottom of flash
You already met the very first slot of this table two guides ago. When the chip powers on, the reset vector told it where your program starts, and the startup code there set up the stack and memory before calling main(). That reset entry is not special silicon magic — it is just the second word of a plain array sitting at the start of flash, and the whole array is the vector table. Today we read the rest of it. The vector table is the single bridge between hardware events and your functions: one fixed slot per event, each slot holding the address of the code to run when that event fires.
Make it concrete on a typical Arm Cort-M part. The table is an array of 32-bit words. Word 0 is not an address at all — it is the initial main stack pointer value the core loads into the stack pointer register before anything runs. Word 1 is the reset vector (the address of your startup code). Words 2, 3, 4 ... are the addresses of handlers for faults and for each peripheral interrupt: a timer overflow, a UART byte arriving, a GPIO pin changing. Each peripheral has a fixed interrupt number, and that number is its index into the table. The hardware never searches; it computes table_base + 4 * index, reads the address there, and jumps. That is the entire lookup.
flash @ 0x00000000 +-------------------------------------------------+ | word 0 0x20002000 initial stack pointer (SP) | <- not an address to run | word 1 0x080001c1 Reset_Handler (your crt0) | <- reset vector | word 2 0x08000201 NMI_Handler | | word 3 0x08000211 HardFault_Handler | | ... | | word 16 0x08000401 SysTick_Handler (timer) | <- core exceptions, then | word 17 0x08000431 WWDG_IRQHandler | <- peripheral IRQs begin | word 28 0x08000511 TIM2_IRQHandler | | word 38 0x08000605 USART1_IRQHandler | +-------------------------------------------------+ jump target = *(table_base + 4 * vector_number)
From a wire going high to your function running
The function whose address sits in a table slot is an interrupt service routine, an ISR. An ISR looks like an ordinary C function — on Cortex-M it is literally written as a plain void function taking no arguments, e.g. void TIM2_IRQHandler(void) — but it is reached by a path no normal call uses. Recall from the assembly rung that a normal function call is set up by the caller: the caller pushes a return address and arguments, then branches. An interrupt has no caller. The CPU is mid-instruction in some unrelated code when a peripheral pulls its interrupt line high, and the silicon itself must arrange everything a call needs, with no compiler-generated prologue to help.
So the hardware does it. On Cortex-M, when an interrupt is accepted the core automatically pushes a fixed set of registers onto the current stack — the ones a C function is allowed to clobber, the caller-saved set you met earlier — so your ISR can use them freely and the interrupted code finds them intact on return. Then it loads the program counter from the table slot and starts executing your ISR. This automatic save is why an ISR can be a normal C function with no special attribute on this architecture: the registers the C calling convention assumes are saved have already been saved for you, by silicon, before your first instruction runs. The whole sequence — accept, stack registers, fetch vector, jump — is the front edge of interrupt latency: the time from the event happening to your code actually running.
The NVIC: the traffic controller for interrupts
Between the dozens of peripherals shouting for attention and the one CPU that can only run one ISR at a time sits a small piece of hardware: the nested vectored interrupt controller, the NVIC. It is the part of the core that owns the vector table and decides who gets the CPU. "Vectored" means it does the table lookup we just described in hardware. "Nested" means the part this guide is really about — a higher-priority interrupt is allowed to interrupt an ISR that is already running. The NVIC is configured through memory-mapped registers (the subject of the next guide), where for each interrupt you can enable it, see if it is pending, and — crucially — assign it a priority.
Priority is the whole game. Each interrupt carries a small priority number, and on Arm the convention is backwards from intuition: a lower number means higher urgency. Priority 0 is the most urgent; a larger number waits. When several interrupts are pending at once, the NVIC runs the lowest-numbered (most urgent) one first. And while an ISR runs, the NVIC compares any newly-arriving interrupt's priority against the one currently running: if the newcomer is more urgent, it preempts — the running ISR is paused, its registers auto-stacked again on top of the first set, the more urgent ISR runs to completion, and only then does the paused one resume. That stacking-on-top is exactly what "nested" means, and it is why these handlers must keep their stack appetite modest: deep nesting stacks frame upon frame.
Designing priorities: latency, the deadline, and a trap called inversion
Why assign priorities at all? Because in a real system some events truly cannot wait while others comfortably can. A safety interlock or a motor-commutation timer might have a hard deadline — the response is only correct if it happens within, say, 5 microseconds — while a UART byte can tolerate tens of microseconds of delay. Give the deadline-driven event a higher priority (lower number) so it can preempt the relaxed one, and you have bought yourself a guarantee about its worst case. This is the bare-metal seed of real-time thinking: the question is never "is it fast on average?" but "what is the worst-case delay before this specific event is serviced?" — its interrupt latency under the worst possible pile-up of other interrupts.
Now the trap that bites everyone once. Suppose a low-priority ISR grabs a shared resource — disables interrupts briefly, or holds a lock the rest of the system uses. While it holds it, a high-priority interrupt fires that also needs that resource. The high-priority handler, despite being the most urgent thing in the system, is now stuck waiting for the low-priority one to release. That is priority inversion: the priorities are effectively reversed, the urgent task blocked by a humble one. In its worst form a third, medium-priority interrupt that needs neither resource preempts the low one, starving it, so it never releases, and the high-priority task waits unboundedly — the exact failure that froze NASA's Mars Pathfinder in 1997. Treat priority inversion as a design hazard you check for, not a rare accident.
Sharing data with an ISR, and the one keyword you cannot skip
An ISR almost always has to communicate with your main loop: a UART handler fills a buffer the main loop drains, a button handler sets a flag the main loop polls. But the ISR runs at an unpredictable instant, asynchronously, exactly like a second thread of control with no scheduler in sight. That makes any variable shared between an ISR and main-loop code a concurrency problem — a race condition waiting to happen — even on a single-core MCU with no operating system at all. Two distinct hazards live here, and they need two distinct fixes, which beginners constantly confuse.
The first hazard is the compiler. Your main loop says while (!flag) { }, polling a variable an ISR will set. The optimizer, seeing nothing in the loop changes flag, is entitled to read it once into a register and spin forever on that stale copy — the loop never sees the ISR's write. The fix is the qualifier you met as type-qualifiers: declare it volatile sig_atomic_t flag, and volatile forces the compiler to re-read the variable from memory on every access, so it observes the ISR's update. This is the single most common bare-metal bug, and it vanishes at -O0 and reappears at -O2 — a textbook reminder that the optimizer is allowed to assume what your code does not tell it.
The second hazard is not solved by volatile, and believing otherwise is dangerous. If the shared item is bigger than a single atomic access — a 32-bit counter on an 8-bit MCU, or a multi-field struct — the main loop can read it half-updated, because the ISR fired mid-write. volatile guarantees fresh reads, not indivisible ones; it is not a lock. The bare-metal fix is to make the access atomic by hand: briefly disable the relevant interrupt around the read-modify-write of the shared object, then re-enable. Keep that disabled window to a few instructions — the very same "shortest possible critical section" that fends off priority inversion. Next guide takes volatile and the peripheral registers apart in full; for now, hold the two rules: volatile for visibility, a short interrupt-disable for atomicity, and never one where you needed the other.
Putting it together, and where this leads
- A peripheral raises its interrupt line; the NVIC checks the interrupt is enabled and compares its priority to whatever is running — a lower priority number wins and may preempt.
- The core auto-stacks the caller-saved registers, looks up the handler address by interrupt number in the vector table, loads it into the program counter, and your ISR begins — no caller, no compiler prologue.
- The ISR does the minimum urgent work, touches any shared state under a short interrupt-disable, and returns via the special exception-return sentinel, which restores the auto-stacked registers and resumes the interrupted code.
- Across the whole design you assign priorities so deadline-critical events preempt relaxed ones, and you keep every locked window short so priority inversion cannot creep in.
Step back and the vector table is a beautifully simple idea doing a profound job: it is the one table that lets dumb hardware — a wire going high — reach up and run a named C function of yours, with the NVIC arbitrating who gets there first. Everything hard about interrupts is not the mechanism, which is just an array lookup, but the concurrency: an ISR is a second flow of control that can cut in at any instant, and that single fact is the source of the stale-flag bug, the half-read struct, and priority inversion alike.
Two threads of this guide now run forward. The promise we keep deferring — exactly how a memory-mapped peripheral register behaves, why volatile is mandatory and yet not enough — is the whole of the next guide. And the bigger structure, where instead of hand-juggling priorities and shared flags you let a real-time OS schedule tasks with formal deadline guarantees, closes the rung. Interrupts are the rung's foundation: master the vector table and the discipline of the short, priority-aware handler, and every layer above it rests on solid ground.