Lesson 4 of 5 · 24 min
Interrupts and the NVIC
Polling a button inside loop() works until the loop does anything slow, at which point you miss presses. An interrupt inverts the arrangement: the hardware stops your code, runs a small function you provided, and puts everything back. This lesson uses the NUCLEO-L476RG, whose blue user button B1 is on PC13 with an external pull-up, so it reads high at rest and low while pressed.
Exceptions and interrupts
On Cortex-M both words describe the same mechanism, and they share one numbering scheme. An exception is anything that diverts the core to a handler through the vector table. Numbers 1 to 15 are built into the core: Reset, NMI, HardFault, the memory, bus and usage faults, SVCall, PendSV and SysTick. Numbers 16 and above are interrupts (IRQs), the peripheral lines ST wired into the core. The vector table offset for IRQ n is 0x40 + 4n. On the L476, EXTI lines 10 to 15 share IRQ 40, so their handler address sits at 0x40 + 4 x 40 = 0xE0.
The NVIC (Nested Vectored Interrupt Controller) is the part of the core that enables each line, tracks which are pending, and decides which runs first. You reach it through the CMSIS functions NVIC_EnableIRQ, NVIC_SetPriority and NVIC_ClearPendingIRQ.
Priorities and preemption
Each interrupt has a priority number, and lower number means more urgent. The STM32 implements 4 priority bits, so there are 16 levels, 0 to 15. If a priority 2 interrupt arrives while a priority 5 handler is running, it preempts it: the core stacks again, runs the urgent handler, then returns to the priority 5 one. An interrupt of equal or lower urgency waits.
A few fixed exceptions, Reset, NMI and HardFault, have negative priorities and always win. Typical assignments follow the rule that the more time-critical and the shorter the handler, the lower the number: a motor-control timer at 0 or 1, a UART at 5, a button at 10.
What the core does on entry is worth knowing, because it sets your latency budget. Hardware pushes eight registers (R0 to R3, R12, LR, PC and xPSR, 32 bytes) onto the stack, fetches the handler address from the table, and starts running. That takes 12 cycles on a Cortex-M4 with zero-wait-state memory: 12 / 80 MHz = 150 ns. Because the registers are saved by hardware, an ISR is an ordinary C function with no special attribute. On return, a special value in LR tells the core to unstack.
A button interrupt via EXTI
The EXTI controller watches pins for edges and requests an interrupt. Wiring PC13 to it takes four steps: configure the pin, route the port with SYSCFG, choose the edge, and enable the NVIC line.
#define BTN_LINE (1u << 13)
volatile uint32_t ms_ticks; // incremented by SysTick
volatile uint8_t button_event; // set by ISR, cleared by main
static uint32_t last_press_ms;
void button_init(void) {
RCC->AHB2ENR |= RCC_AHB2ENR_GPIOCEN;
RCC->APB2ENR |= RCC_APB2ENR_SYSCFGEN; // SYSCFG owns the EXTI routing
GPIOC->MODER &= ~(3u << 26); // PC13 input (00), 2 x 13 = bit 26
// EXTICR[3] covers lines 12 to 15. Line 13 is bits 7:4. Port C = 0b0010.
SYSCFG->EXTICR[3] = (SYSCFG->EXTICR[3] & ~(0xFu << 4)) | (0x2u << 4);
EXTI->FTSR1 |= BTN_LINE; // falling edge = press
EXTI->IMR1 |= BTN_LINE; // unmask line 13
NVIC_SetPriority(EXTI15_10_IRQn, 5);
NVIC_EnableIRQ(EXTI15_10_IRQn);
}
void SysTick_Handler(void) {
ms_ticks++;
}
void EXTI15_10_IRQHandler(void) {
if (EXTI->PR1 & BTN_LINE) {
EXTI->PR1 = BTN_LINE; // write 1 to clear pending
uint32_t now = ms_ticks;
if (now - last_press_ms > 30) { // debounce window
last_press_ms = now;
button_event = 1;
}
}
}
int main(void) {
SysTick_Config(SystemCoreClock / 1000); // 80,000,000 / 1000 = 80000 counts = 1 ms
button_init(); // PA5 is set up as an output, as in lesson 2
for (;;) {
if (button_event) {
button_event = 0;
GPIOA->ODR ^= (1u << 5); // toggle LD2
}
__WFI(); // sleep until the next interrupt
}
}
Two details deserve explanation. First, you must clear the pending bit in EXTI->PR1 by writing a 1 to it. If you forget, the line stays pending, the handler returns, and the core enters it again immediately, forever. Second, the reset mode of PC13 is analog, so the explicit step setting MODER13 to input matters; without it the EXTI line never sees the pin.
Why volatile
button_event is written in the ISR and read in main. The compiler analyses main in isolation, sees nothing in the loop that changes the variable, and is entitled to read it once and cache it in a register, turning the check into an infinite loop that never fires. volatile forbids that optimisation: every access goes to memory. It is required for any variable shared between an ISR and normal code, and for every peripheral register (which is why the CMSIS definitions declare them volatile). Note that volatile gives you visibility, not atomicity. A single byte or aligned 32-bit word read is safe on Cortex-M, but a 64-bit counter or a multi-field structure can be read half-updated unless you briefly disable interrupts.
Keep ISRs short
While an ISR runs, every interrupt of equal or lower priority is blocked. A handler that takes 5 ms to format text delays the UART interrupt long enough to lose characters at 115200 baud, where a byte arrives every 87 microseconds. The pattern in the code above is the standard one: the ISR does the bare minimum (acknowledge the hardware, grab a timestamp or a byte, set a flag) and the main loop does the work. Never call delay, printf or anything that waits inside an ISR.
Debounce
A mechanical button does not make one clean edge. The contacts bounce for typically 1 to 10 ms, producing a burst of edges, and each would trigger the interrupt. The code above ignores any press within 30 ms of the last accepted one, using the SysTick millisecond counter. The subtraction now - last_press_ms also keeps working when the 32-bit counter wraps after about 49.7 days, because unsigned arithmetic wraps consistently. A hardware alternative is an RC filter on the pin, but a few lines of software cost nothing.
Check yourself
Your ISR handles the EXTI line but never clears the pending bit in PR1. What happens?
Check yourself
A UART interrupt has priority 5 and a timer interrupt has priority 2. The timer fires while the UART handler runs. What happens?