Skip to content

Interrupts & the NVIC

Level 1's attachInterrupt() and Level 2's ISR-safe FreeRTOS calls both sit on top of the Cortex-M NVIC (Nested Vectored Interrupt Controller). This module covers what the NVIC actually does — priority levels, nesting rules, and the specific ways shared state between an ISR and normal code goes wrong — using bare registers instead of a framework's attachInterrupt.

Enabling and prioritizing an interrupt

#define NVIC_ISER0  (*(volatile uint32_t *)0xE000E100)  /* Set-Enable */
#define NVIC_IPR0   (*(volatile uint32_t *)0xE000E400)  /* Priority, 4 regs/word on many parts */

#define EXTI0_IRQn  6   /* example: EXTI line 0 on many STM32F4 parts */

void enable_button_interrupt(void) {
    NVIC_ISER0 |= (1u << EXTI0_IRQn);          /* unmask the interrupt */

    /* priority register layout is chip/family specific — this pattern sets
       IRQ6's priority byte within IPR1 (IRQ 4-7), upper bits used depending
       on how many priority bits the implementation supports */
    volatile uint8_t *pri_byte = (volatile uint8_t *)(0xE000E400 + EXTI0_IRQn);
    *pri_byte = (2 << 4);   /* priority 2, assuming 4 implemented priority bits */
}

void EXTI0_IRQHandler(void) {
    EXTI_PR |= (1u << 0);        /* clear pending bit FIRST — see trap below */
    button_pressed_flag = 1;      /* keep the handler itself short */
}

Nesting: why priority numbers are backwards

On Cortex-M, a lower priority number means higher urgency — priority 0 can preempt priority 5, not the other way around. A running ISR can be interrupted by a higher-priority (lower-numbered) one; it cannot be interrupted by an equal or lower-priority one, which instead waits and runs after. This inversion trips up nearly everyone the first time: "priority 1" sounds low-urgency in everyday language but is one of the most urgent levels on the chip.

The trap: clearing the pending flag after reading data, not before

/* WRONG — a new edge between the read and the clear is lost */
void EXTI0_IRQHandler_buggy(void) {
    int level = read_pin_level();
    EXTI_PR |= (1u << 0);      /* if another edge arrived during read_pin_level(),
                                   its pending bit is now cleared without ever
                                   having been serviced */
}

The fix in the earlier example — clear-then-read for a level, or track a counter that's incremented rather than a level that's overwritten — depends on what the ISR actually needs, but the ordering question ("could a new event arrive between my read and my clear?") has to be asked for every ISR that clears its own pending bit.

ISR/main-loop shared state needs volatile, and often more

volatile uint32_t edge_count = 0;    /* volatile: main loop must re-read, not cache */

void EXTI0_IRQHandler(void) {
    EXTI_PR |= (1u << 0);
    edge_count++;                     /* NOT atomic on most cores for >8-bit values
                                          in general, but a single 32-bit RMW that
                                          only the ISR ever writes is safe here */
}

uint32_t read_edge_count_safely(void) {
    /* if main-loop code also WROTE edge_count, this would need to disable
       the interrupt around the read — reading a volatile is not the same
       as reading it atomically with respect to a concurrent writer */
    __disable_irq();
    uint32_t snapshot = edge_count;
    __enable_irq();
    return snapshot;
}

volatile guarantees the compiler won't cache or reorder the access — it says nothing about atomicity. A 32-bit read on a 32-bit bus is naturally atomic on Cortex-M if it's a single instruction and nothing splits it, but a 64-bit counter or a multi-field struct shared with an ISR is not, and needs an explicit critical section (__disable_irq()/__enable_irq(), kept as short as possible) around any access from normal code.

Modeling the priority-preemption rule in portable C

The nesting rule itself — lower number preempts higher, equal doesn't — is pure logic, checkable without a real NVIC. Compiled and run with gcc:

#include <stdio.h>
#include <assert.h>

/* returns 1 if a request at new_priority may preempt a handler
   currently running at current_priority */
int can_preempt(int current_priority, int new_priority) {
    return new_priority < current_priority;   /* lower number = higher urgency */
}

int main(void) {
    assert(can_preempt(5, 2) == 1);    /* priority 2 preempts priority 5 */
    assert(can_preempt(2, 5) == 0);    /* priority 5 does NOT preempt priority 2 */
    assert(can_preempt(3, 3) == 0);    /* equal priority never preempts */
    printf("NVIC preemption model OK\n");
    return 0;
}

Traps in interrupt handling

  • Priority-number inversion, as above — a very common source of "why isn't my important interrupt preempting the other one" bugs.
  • Long ISRs: an ISR that does real work (parsing, printing, floating point) instead of setting a flag and returning blocks every equal/lower priority interrupt for its entire duration — keep ISRs to "record the event, wake a task" and do the work in task context.
  • Forgetting to clear the pending bit (or clearing the wrong one) leaves the interrupt re-firing immediately after return, sometimes appearing as an infinite-loop hang with no useful stack trace.
  • Priority Group configuration (SCB_AIRCR) splits the priority field into "preempt priority" and "subpriority" bits; getting this split wrong changes which interrupts can nest versus merely queue in order, in ways that don't show up until two specific interrupts race in the field.

How It Actually Works

What the NVIC physically does when a peripheral signals an event: every interruptible peripheral has a dedicated wire (an internal signal, not a pin) running to the NVIC, which contains, per interrupt number, a pending-status flip-flop, an enable flip-flop, and a stored priority value in a small register file. The moment the peripheral's condition occurs (EXTI0 sees the configured edge), it sets that IRQ's pending bit in hardware, completely independent of what the CPU is doing at that instant. Separately, on every instruction boundary, the NVIC's priority-comparison logic checks whether any enabled and pending interrupt has a priority number lower than whatever the CPU is currently executing at (tracked via a small internal "current execution priority" concept) — if so, it asserts an exception request to the core, which then performs the same automatic register-stacking mechanism from module 3-03's PendSV discussion and vectors to that IRQ's handler address, read directly out of the vector table your Reset_Handler established. This entire chain — from a physical edge on a pin's electronics to your handler function executing — happens with no software polling loop anywhere in the path, which is the whole point of an interrupt versus checking a flag in loop().

Why priority numbers are inverted, mechanically: the NVIC's priority comparison is implemented as an unsigned integer comparator wired to treat smaller register values as "wins arbitration" — this is a deliberate ARM architectural choice (not an accident of naming) because it lets priority 0 double as "the most privileged, cannot be preempted by anything else in the system" in a way that reads naturally as "top priority" once you know the convention, and it dovetails with the reset/NMI/HardFault exceptions occupying the most negative (and therefore always-highest) priority values, architecturally fixed below any priority a peripheral IRQ can be assigned — guaranteeing a fault handler can never be starved out by ordinary interrupt traffic regardless of how application code configures IRQ priorities.

Why clearing the pending bit must be ordered carefully: EXTI_PR (or its chip-specific equivalent) is a genuinely shared piece of hardware state — the peripheral's edge-detect logic sets a pending bit asynchronously to your ISR's execution, and writing to clear it is a normal store instruction that takes a nonzero number of cycles to actually reach the peripheral's register over the bus. If a second qualifying edge arrives on the physical pin between your read_pin_level() call and the clear-bit write, the peripheral's edge detector has already latched that second event into the very same pending bit your write is about to clear — the hardware has no way to distinguish "this pending bit represents an event I haven't serviced yet" from "this pending bit represents a brand-new event that arrived during servicing," because it's a single bit, not a counter. This is precisely why the ordering question is not a style preference — it maps directly onto whether a real, physically-latched hardware event can be silently discarded by a correctly-written-looking clear operation.

Cheat sheet

Concept Detail
NVIC Cortex-M's interrupt controller — enable, priority, and pending state per IRQ
Priority numbers Lower number = higher urgency; can preempt any strictly higher-numbered priority
Equal priority Never preempts — queues and runs after the current handler returns
EXTI_PR / pending bit Must be cleared in the handler, with care about ordering vs. reading data
volatile Prevents compiler caching/reordering; does not by itself guarantee atomicity
__disable_irq()/__enable_irq() Critical section for accesses that must be atomic w.r.t. an ISR
Verification here Preemption-rule logic compiled/run with gcc; real NVIC register behavior reviewed against ARM docs only

Exercise

Write a portable C simulator that models a small NVIC: an array of pending IRQs each with a priority, a "currently running priority" stack (since handlers can nest), and a function next_to_run() that returns the highest-urgency pending IRQ only if it's strictly more urgent than whatever is on top of the running stack (or the first pending IRQ if nothing is running). Simulate a sequence: IRQ at priority 5 starts, IRQ at priority 3 arrives and should preempt it, IRQ at priority 4 arrives during that and should not preempt priority 3, then priority-3 finishes and priority-4 should run next. Assert each step and compile/run with gcc.