Part 2 · C Meets the Metal32 min read
Interrupts Done Right
Interrupts are how firmware reacts to the world without polling everything all the time: a byte arrives, a timer expires, a button is pressed, and the CPU drops what it is doing to run a handler. They are also where most hard-to-reproduce firmware bugs live: data corrupted once a week, a handler that runs twice, a system that works until a second interrupt source is added. This lesson shows how to write a handler in C on Cortex-M, the exact sequence to enable one, how to share data between handlers and the main loop without races, how latency and priorities really work, and the classic bugs with their fixes. It ends with a lab that counts button presses in an interrupt and reports them over UART.
What happens when an interrupt fires
Section titled “What happens when an interrupt fires”You saw the mechanism in The Cortex-M architecture; here is the part you need to write handlers. When a peripheral raises an interrupt request and the NVIC decides it should run (enabled, higher priority than whatever is running, not masked):
- The core finishes or abandons the current instruction.
- Hardware pushes eight words onto the current stack:
r0,r1,r2,r3,r12,lr, the return address, andxPSR. If the interrupted code was using the FPU, space fors0-s15andFPSCRis reserved as well (lazy stacking: the values are only written if the handler also uses the FPU). - It reads the handler address from the vector table, at
VTOR + 0x40 + 4 * IRQnfor device interrupts. - It puts a special
EXC_RETURNvalue (like0xFFFFFFF9) inlrand jumps to the handler, now in Handler mode on the main stack.
When the handler executes bx lr with that magic value in lr, the core recognises an exception return, pops the frame, and resumes the interrupted code exactly where it left off.
interrupted code hardware your handler ---------------- -------- ------------ ldr r3, [r2] adds r3, #1 <-- IRQ push r0-r3,r12,lr, pc,xPSR (8 words) fetch vector lr = 0xFFFFFFF9 --> EXTI0_IRQHandler: ... bx lr pop 8 words <-- str r3, [r2] (resumes)Why a handler is a plain C function
Section titled “Why a handler is a plain C function”The registers the hardware saves (r0-r3, r12, lr) are exactly the caller-saved registers of the Arm calling convention (AAPCS). Any C function is allowed to clobber those and must preserve r4-r11, which the compiler handles in the function’s own prologue. So from the compiler’s point of view, an interrupt handler is an ordinary function with no arguments and no return value, and its normal bx lr return doubles as the exception return. That is why Cortex-M needs no __attribute__((interrupt)) or special keyword, unlike AVR, older ARM7 cores, or many DSPs, where the compiler must generate special save and return sequences.
void EXTI0_IRQHandler(void){ /* ... */}The rules:
- The name must match the vector table exactly. Your startup file (from Startup code and the vector table) defines every handler as a weak alias of
Default_Handler. Defining a strong function with the same name replaces it at link time. Misspell it (EXTI0_IRQhandler,EXTI0_Handler) and the linker happily keeps the weak default; when the interrupt fires, the CPU lands inDefault_Handler’s infinite loop. If your program “hangs” the first time an interrupt fires, halt it in the debugger and check whether you are sitting inDefault_Handler. voidparameters,voidreturn. There is nobody to pass arguments or receive a result.- Never call it from main code as if it were a normal function, except in unit tests on the host.
Enabling an interrupt, in order
Section titled “Enabling an interrupt, in order”There are up to four separate gates between a peripheral event and your handler running, and every one must be open:
peripheral event -> [peripheral IE bit] -> [NVIC enable] -> [priority vs current and masks] -> handler e.g. EXTI_IMR NVIC_ISERx PRIMASK / BASEPRI USART_CR1_RXNEIEThe safe order:
- Enable the peripheral’s clock in RCC. (For EXTI, also SYSCFG’s clock, since
EXTICRlives there.) - Configure the peripheral completely.
- Clear any stale flag in the peripheral. A flag set during configuration, or left over from before a soft reset, fires the instant you enable the interrupt.
- Enable the interrupt in the peripheral (its interrupt enable bit or mask).
- Clear the pending bit in the NVIC (
NVIC_ICPRx), for the same reason as step 3. - Set the priority in
NVIC_IPRx. - Enable it in the NVIC (
NVIC_ISERx). - Make sure interrupts are globally enabled. After reset
PRIMASKis 0, so they are, unless your startup code disabled them.
The NVIC registers you need, with the STM32F407’s 82 interrupts spread over three 32-bit words:
| Register | Address | Access | Purpose |
|---|---|---|---|
NVIC_ISER0..2 | 0xE000E100 | write 1 to enable | IRQ n is bit n % 32 of word n / 32 |
NVIC_ICER0..2 | 0xE000E180 | write 1 to disable | |
NVIC_ISPR0..2 | 0xE000E200 | write 1 to set pending | forces the interrupt |
NVIC_ICPR0..2 | 0xE000E280 | write 1 to clear pending | |
NVIC_IABR0..2 | 0xE000E300 | read-only | 1 while the handler is active |
NVIC_IPR0..20 | 0xE000E400 | byte per IRQ | priority in the top 4 bits |
NVIC_STIR | 0xE000EF00 | write IRQ number | software trigger |
The set/clear register pairs are why you write NVIC->ISER[0] = 1u << 6; and not |=: zeros have no effect, so there is nothing to preserve and no read-modify-write race.
Priority registers are byte-addressable, one byte per IRQ. The STM32F407 implements only the top 4 bits of each byte, so there are 16 levels and the value you write is level << 4. Lower numbers mean higher priority; 0 is the most urgent configurable level. Writing 0x05 to a priority byte gives level 0, because the low bits are not implemented: a classic bug from code written for a part with 8 priority bits.
#ifndef NVIC_HELPERS_H#define NVIC_HELPERS_H
#include <stdint.h>#include "stm32f407_regs.h"
#define NVIC_PRIO_BITS 4u
/* Raw addresses so this works whatever subset of NVIC your header overlays. */#define NVIC_ICER_W(n) (*(volatile uint32_t *)(0xE000E180u + 4u * (n)))#define NVIC_ICPR_W(n) (*(volatile uint32_t *)(0xE000E280u + 4u * (n)))#define NVIC_IPR_B(n) (*(volatile uint8_t *)(0xE000E400u + (n)))#define NVIC_STIR (*(volatile uint32_t *)0xE000EF00u)#define SHPR_B(n) (*(volatile uint8_t *)(0xE000ED18u + (n) - 4u)) /* n = exception number */
static inline void nvic_enable(uint32_t irq) { NVIC->ISER[irq >> 5] = 1u << (irq & 31u); }static inline void nvic_disable(uint32_t irq) { NVIC_ICER_W(irq >> 5) = 1u << (irq & 31u); }static inline void nvic_clear_pending(uint32_t irq) { NVIC_ICPR_W(irq >> 5) = 1u << (irq & 31u); }
/* nvic_set_priority(irq, level) already comes from stm32f407_regs.h: it writes * level << 4 into the IRQ's priority byte, the same thing NVIC_IPR_B(irq) names. * Defining it again here would be a redefinition error. */
/* System exceptions: SysTick is exception 15, PendSV is 14, SVCall is 11. */static inline void sys_set_priority(uint32_t exception, uint32_t level){ SHPR_B(exception) = (uint8_t)((level & 0xFu) << (8u - NVIC_PRIO_BITS));}
#endifSHPR_B maps an exception number to its byte in the System Handler Priority Registers (SHPR1 at 0xE000ED18 starts with exception 4, MemManage), so sys_set_priority(15, 3) writes 0x30 to 0xE000ED23, the SysTick priority byte. Note that nvic_disable followed immediately by code that assumes the handler can no longer run needs a DSB and ISB in between, because the disable takes effect a few cycles later.
Clear the flag, and make sure it is cleared
Section titled “Clear the flag, and make sure it is cleared”Most peripherals keep their interrupt request asserted until software clears the cause: writing 1 to EXTI_PR, writing 0 to a timer’s UIF, reading USART_DR. Forget, and the moment your handler returns, the NVIC sees the request still asserted and re-enters the handler. Forever. Main never runs again. This is the most common interrupt bug of all, and it looks like a hang.
The subtler version is the handler that runs twice even though it clears the flag. It happens when the clear is the last thing the handler does:
void EXTI0_IRQHandler(void){ g_count++; EXTI->PR = 1u << 0; /* clear as the last statement: risky */}The store to EXTI->PR goes into the core’s write buffer and then crosses the AHB-to-APB bridge, which takes several cycles. Meanwhile, the core executes bx lr, and the exception-return logic samples the interrupt line. If the peripheral has not yet seen the write, the line is still high, the NVIC re-pends the interrupt, and the handler tail-chains into itself. g_count goes up by two.
Two fixes, use both:
- Clear the flag early, at the top of the handler, so there are many instructions between the clear and the return.
- Read the register back after clearing. A read from the same peripheral cannot complete until the earlier write to it has, so the core stalls until the clear has landed. A
DSBinstruction waits for the write to leave the core, which is often enough, but on parts where the peripheral sits behind a bus bridge the read-back is the reliable guarantee.
void EXTI0_IRQHandler(void){ EXTI->PR = 1u << 0; /* clear first */ (void)EXTI->PR; /* stall until the write has reached the peripheral */ g_count++;}Also note what clearing early implies: if the event happens again while the handler is running, the flag is set again and the handler runs once more after it returns. You never lose an event that way. If you cleared at the end, the second event would be wiped out by the late clear.
Keep handlers short
Section titled “Keep handlers short”While a handler runs, every interrupt of the same or lower priority waits. A long handler adds its full duration to the worst-case latency of everything below it. So the rule is: do the minimum that cannot wait, and defer the rest to the main loop.
- Set a flag:
g_button_pressed = true;and main checks it. - Push to a queue: a received UART byte goes into a ring buffer (built in UART: serial communication), and main parses lines.
- Timestamp and count: read a timer or
millis(), store the value.
What does not belong in a handler: printf or any formatted output, blocking waits on other peripherals, delay_ms() (which, if SysTick has the same or lower priority, never returns because the tick cannot advance), malloc, floating-point heavy maths you could do later, and anything with an unbounded loop.
Sharing data with the main loop
Section titled “Sharing data with the main loop”The handler and main run concurrently in the sense that matters: main can be interrupted between any two instructions. Every variable they share needs thought.
volatile, and what it does not do
Section titled “volatile, and what it does not do”A variable written by a handler and read by main must be volatile. Without it, the compiler sees that main’s loop never modifies the variable and may load it once and keep it in a register:
bool g_ready; /* missing volatile */
void TIM2_IRQHandler(void) { /* ... */ g_ready = true; }
int main(void){ while (!g_ready) { /* at -O2 compiled as: if (!g_ready) for (;;); */ }}volatile forces every access to actually touch memory. That is all it does. It does not make a read-modify-write atomic, and it does not stop the compiler moving non-volatile accesses around volatile ones. Undefined behavior, volatile, and what the optimizer assumes has the precise rules; Concurrency, atomics, and memory ordering goes further.
What is atomic on Cortex-M
Section titled “What is atomic on Cortex-M”An aligned 8-, 16- or 32-bit load or store is a single instruction and cannot be split by an interrupt. So if main only reads a volatile uint32_t that the handler only writes, main always sees either the old or the new value, never a mix. That is the cheapest safe pattern, and the reason ISR-to-main counters should be 32 bits.
Everything else can tear:
- Read-modify-write.
g_count++in main is load, add, store. If the handler also incrementsg_countbetween main’s load and store, one increment is lost. - 64-bit values. A
uint64_tis two words. The compiler may load them with two separate instructions, and the architecture does not promise that a 64-bit access is atomic. - Structs and multiple variables that must agree. A handler updates
countand thentimestamp. Main reads them with two separate loads and can be interrupted between them, so it sees the newcountpaired with the oldtimestamp.
A torn 64-bit read in practice: a handler increments a volatile uint64_t g_ticks that is at 0x00000000FFFFFFFF. Main loads the low word (0xFFFFFFFF), the interrupt fires and makes the value 0x0000000100000000, then main loads the high word (0x00000001). Main now holds 0x00000001FFFFFFFF, which is about 49 days in the future.
Critical sections with PRIMASK
Section titled “Critical sections with PRIMASK”The fix for tearing is a critical section: a stretch of code during which the handler cannot run. The bluntest tool is PRIMASK: setting it blocks all interrupts with configurable priority (everything except NMI and HardFault).
#ifndef IRQ_H#define IRQ_H
#include <stdint.h>
/* Equivalent to CMSIS __get_PRIMASK() + __disable_irq(). */static inline uint32_t irq_save(void){ uint32_t primask; __asm volatile ("mrs %0, primask\n\tcpsid i" : "=r" (primask) : : "memory"); return primask;}
/* Equivalent to CMSIS __set_PRIMASK(). */static inline void irq_restore(uint32_t primask){ __asm volatile ("msr primask, %0" : : "r" (primask) : "memory");}
/* BASEPRI: mask only priorities numerically >= level (0 disables masking). */static inline uint32_t basepri_get(void){ uint32_t v; __asm volatile ("mrs %0, basepri" : "=r" (v)); return v;}
static inline void basepri_set(uint32_t level){ __asm volatile ("msr basepri, %0" : : "r" (level << 4) : "memory");}
#endifUsing it:
volatile uint64_t g_ticks;
uint64_t ticks_read(void){ uint32_t key = irq_save(); uint64_t t = g_ticks; /* both words read with no interrupt in between */ irq_restore(key); return t;}Why save and restore instead of a simple disable/enable pair? Because critical sections nest. If ticks_read() is called from code that already disabled interrupts, an unconditional cpsie i at the end would re-enable them early and break the caller’s critical section. Saving the old PRIMASK and restoring it returns to whatever state the caller had.
The "memory" clobber matters as much as the instruction. It tells the compiler that memory may have changed, so it must not move loads and stores of shared variables across the cpsid or msr. Without it, the compiler could legally hoist the read of g_ticks above the disable.
Keep critical sections short: a few loads and stores, not a loop. Every cycle with interrupts masked is added to the worst-case latency of every interrupt in the system.
Partial masking with BASEPRI
Section titled “Partial masking with BASEPRI”PRIMASK blocks everything, including a motor-control interrupt that must never be delayed. BASEPRI is the scalpel: writing a nonzero value blocks only interrupts with a priority value equal to or numerically higher than it (that is, equally or less urgent). With basepri_set(5), interrupts at levels 0 to 4 still run; levels 5 to 15 wait.
uint32_t old = basepri_get();basepri_set(5); /* block levels 5..15 *//* ... touch data shared with handlers at levels >= 5 ... */__asm volatile ("msr basepri, %0" : : "r" (old) : "memory");The rule that follows: code inside a BASEPRI critical section can only safely share data with handlers at the masked levels. Handlers at levels 0 to 4 still preempt, so they must not touch that data. This is exactly how FreeRTOS implements its critical sections, and why its “do not call RTOS functions from interrupts above configMAX_SYSCALL_INTERRUPT_PRIORITY” rule exists. Cortex-M0 and M0+ have no BASEPRI; only PRIMASK.
Reentrancy
Section titled “Reentrancy”A function is reentrant if it works correctly when a second call starts before the first one finishes, as happens when main is inside a function and an interrupt handler calls the same function. Functions that use only their arguments and locals are reentrant. Functions with static or global state are not:
strtokkeeps its position in a hidden static.printfand friends may use static buffers and lock a sharedFILEstream.mallocmanipulates a global free list; an interruptedmalloccalled again from a handler corrupts it.- Anything that sets
errno. - Your own driver functions that use a module-level
staticbuffer.
The practical rule: handlers call only functions you know are reentrant, or functions that main never calls.
Latency and jitter
Section titled “Latency and jitter”Interrupt latency is the time from the interrupt request to the first instruction of the handler. On a Cortex-M4 with zero-wait-state memory, the hardware takes 12 cycles: stacking and the vector fetch run in parallel. At 168 MHz that is about 71 ns. Real-world additions:
- Flash wait states. Vector fetch and the first instructions come from flash; with 5 wait states and the ART accelerator, cache misses add cycles. Placing critical handlers and the vector table in RAM removes this.
- Masked time. Any critical section in main or a higher-priority handler delays you by its full length.
- Higher or equal priority handlers already running. You wait for all of them.
- FPU context. With lazy stacking, entry reserves space but only saves the FPU registers if your handler uses the FPU, which then costs about 17 extra memory writes.
- Bus contention with DMA.
Jitter is the variation in latency. A handler that must sample a signal at an exact moment cares more about jitter than average latency. Jitter comes from all of the above varying from one interrupt to the next.
The core also optimizes back-to-back interrupts. Tail-chaining: when a handler finishes and another is pending, the core skips the pop-then-push and goes straight to the next handler in 6 cycles. Late arrival: if a higher-priority interrupt arrives during the stacking for a lower one, the core uses the same stack frame and runs the higher-priority handler first.
Priorities
Section titled “Priorities”Each priority byte is split, at a point set by AIRCR.PRIGROUP, into a preemption priority (upper part) and a subpriority (lower part):
- A pending interrupt can preempt a running handler only if its preemption priority is strictly more urgent (numerically lower).
- Subpriority never causes preemption. It only decides which of several pending interrupts with the same preemption priority runs first.
- If preemption priority and subpriority are equal, the lower IRQ number runs first.
With 4 implemented bits on the STM32F407:
PRIGROUP | Preemption bits | Subpriority bits | Levels |
|---|---|---|---|
| 0 to 3 (reset value 0) | 4 | 0 | 16 preemption levels |
| 4 | 3 | 1 | 8 preemption × 2 sub |
| 5 | 2 | 2 | 4 × 4 |
| 6 | 1 | 3 | 2 × 8 |
| 7 | 0 | 4 | no preemption at all |
Leave PRIGROUP at its reset value (all 4 bits preemption) unless you have a reason. It is the simplest model and the one FreeRTOS expects on STM32.
Two interrupts at the same preemption level never preempt each other: if UART is running, a timer interrupt at the same level waits until UART returns, then tail-chains. That is often what you want for handlers sharing data, because they cannot interrupt each other and need no critical section between them.
Assigning priorities
Section titled “Assigning priorities”Give the most urgent level to the handler with the shortest deadline, not the one that seems most important. A UART at 115200 baud with a 1-byte receive register must be serviced within one character time, about 87 µs, or a byte is lost to overrun. A button handler can wait 10 ms without anyone noticing. So the UART gets the higher priority, even though the button is the user interface. A reasonable starting map:
| Level | Use |
|---|---|
| 0 to 1 | hard real-time: motor commutation, safety cut-offs |
| 2 to 4 | communication receive (UART, SPI, CAN) |
| 5 to 10 | timers, sensor data ready, DMA complete |
| 11 to 14 | buttons, slow housekeeping |
| 15 | PendSV (deferred work, RTOS context switch) |
Nested interrupts and stack cost
Section titled “Nested interrupts and stack cost”Preemption nests: a level-2 handler can interrupt a level-5 handler, which interrupted main. Each level pushes a frame onto the main stack: 32 bytes for the basic frame, 104 bytes if the FPU registers are stacked, plus whatever stack the handler itself uses. Worst-case stack usage is main’s deepest point plus one handler’s frame and locals per distinct preemption level that can be active at once. With 16 levels and FPU stacking, that adds up; it is one reason to use only a handful of distinct levels.
Software-triggered interrupts
Section titled “Software-triggered interrupts”Sometimes a high-priority handler has urgent work and a longer, less urgent follow-up. Instead of doing the follow-up in the handler, it can trigger a lower-priority interrupt that runs as soon as nothing more urgent is pending. Two mechanisms:
NVIC_STIR: write an IRQ number to 0xE000EF00 and that interrupt becomes pending, as if its peripheral had fired. You can use the vector of a peripheral you do not use as a general software interrupt.
PendSV: a system exception meant for exactly this. Set bit 28 (PENDSVSET) in SCB->ICSR and the PendSV_Handler runs once all higher-priority handlers have finished. Give it the lowest priority.
#define SCB_ICSR_PENDSVSET (1u << 28)
volatile uint32_t g_samples_ready;
void ADC_IRQHandler(void) /* high priority: grab the data, nothing more */{ /* ... read the sample into a buffer, clear the flag ... */ g_samples_ready++; SCB->ICSR = SCB_ICSR_PENDSVSET; /* request the deferred part */}
void PendSV_Handler(void) /* lowest priority: the slow part */{ /* ... filter, scale, update state ... */}This is the same mechanism every Cortex-M RTOS uses for its context switch, so on an RTOS PendSV is taken; use the RTOS’s own deferral (a task notification) instead.
Common bugs
Section titled “Common bugs”| Symptom | Cause | Fix |
|---|---|---|
| Handler never runs | Missing NVIC enable, peripheral interrupt enable, or clock | Walk the four gates; check NVIC_ISER and the peripheral’s IE bit in the debugger |
EXTI handler never runs, EXTICR reads 0 | SYSCFG clock not enabled | RCC_APB2ENR bit 14 before writing EXTICR |
| System hangs on first interrupt | Handler name misspelled, CPU in Default_Handler | Halt and look at pc; fix the name |
| Main stops running after first interrupt | Flag never cleared, handler re-enters forever | Clear the source flag in the handler |
| Counter goes up by 2 per event | Flag cleared at the end without read-back | Clear first, read back |
| Main never sees the flag change | Shared variable not volatile | Declare it volatile |
| Rare corrupted value | Torn read or lost read-modify-write | Critical section, or restructure so one side only reads 32-bit values |
| Other interrupts sluggish or data lost | Long handler at high priority | Defer work to main or PendSV |
Handler hangs in delay_ms() | SysTick cannot preempt the handler | Never block in a handler |
| High-priority work delayed by low-priority work | Priority inversion (a low-priority context holds what the high one needs) | Previewed here, covered properly in RTOS synchronization and communication |
Lab: a button counter reported over UART
Section titled “Lab: a button counter reported over UART”The lab ties it together. The EXTI0 handler counts debounced presses of the Discovery user button and records when the last one happened. Main prints the count and timestamp over USART2 whenever they change, reading the pair inside a critical section so they always match. It uses clock.c from Clocks, reset, and timing (168 MHz, SysTick, millis()) plus nvic.h and irq.h from above.
The UART code here is the minimum polled transmitter, so the lab is self-contained: 115200 baud from the 42 MHz APB1 clock (BRR = 0x16D). UART: serial communication explains every line of it. Connect a USB-to-serial adapter’s RX to PA2 and GND to GND, then open a terminal at 115200 8N1.
#include <stdbool.h>#include <stdint.h>#include "stm32f407_regs.h"#include "clock.h"#include "nvic.h"#include "irq.h"
typedef struct { volatile uint32_t IMR, EMR, RTSR, FTSR, SWIER, PR;} EXTI_TypeDef;#define EXTI ((EXTI_TypeDef *)0x40013C00u)
typedef struct { volatile uint32_t MEMRMP, PMC; volatile uint32_t EXTICR[4];} SYSCFG_TypeDef;#define SYSCFG ((SYSCFG_TypeDef *)0x40013800u)
#define EXTI0_IRQn 6u#define SYSTICK_EXCEPTION 15u#define RCC_AHB1ENR_GPIOAEN (1u << 0)#define RCC_APB1ENR_USART2EN (1u << 17)#define RCC_APB2ENR_SYSCFGEN (1u << 14)#define USART_SR_TXE (1u << 7)#define USART_CR1_UE (1u << 13)#define USART_CR1_TE (1u << 3)#define DEBOUNCE_MS 50u
/* Shared between EXTI0_IRQHandler (writer) and main (reader). */static volatile uint32_t s_press_count;static volatile uint32_t s_press_time_ms;static volatile uint32_t s_last_edge_ms;
/* ---------- minimal polled UART TX on PA2 ---------- */static void uart_init(void){ RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN; RCC->APB1ENR |= RCC_APB1ENR_USART2EN; (void)RCC->APB1ENR;
GPIOA->AFR[0] = (GPIOA->AFR[0] & ~(0xFu << 8)) | (7u << 8); /* PA2 = AF7 */ GPIOA->MODER = (GPIOA->MODER & ~(3u << 4)) | (2u << 4); /* AF mode */
USART2->BRR = 0x16Du; /* 42 MHz / 115200 */ USART2->CR1 = USART_CR1_UE | USART_CR1_TE; /* 8N1, transmitter only */}
static void uart_putc(char c){ while (!(USART2->SR & USART_SR_TXE)) { } USART2->DR = (uint32_t)(uint8_t)c;}
static void uart_puts(const char *s){ while (*s != '\0') { uart_putc(*s++); }}
static void uart_put_u32(uint32_t v){ char buf[10]; uint32_t n = 0; do { buf[n++] = (char)('0' + (v % 10u)); v /= 10u; } while (v != 0u); while (n > 0u) { uart_putc(buf[--n]); }}
/* ---------- button interrupt ---------- */static void button_irq_init(void){ RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN; /* PA0 input is the reset state */ RCC->APB2ENR |= RCC_APB2ENR_SYSCFGEN; (void)RCC->APB2ENR;
SYSCFG->EXTICR[0] &= ~(0xFu << 0); /* EXTI0 <- PA0 */ EXTI->RTSR |= 1u << 0; /* rising edge = press */ EXTI->PR = 1u << 0; /* clear stale flag */ EXTI->IMR |= 1u << 0; /* peripheral-level enable */
nvic_clear_pending(EXTI0_IRQn); nvic_set_priority(EXTI0_IRQn, 12u); /* slow human input: low priority */ nvic_enable(EXTI0_IRQn);}
void EXTI0_IRQHandler(void){ EXTI->PR = 1u << 0; /* clear first ... */ (void)EXTI->PR; /* ... and make sure it landed */
uint32_t now = millis(); if ((uint32_t)(now - s_last_edge_ms) >= DEBOUNCE_MS) { s_press_count++; /* only the ISR writes these */ s_press_time_ms = now; } s_last_edge_ms = now; /* every edge restarts lockout */}
int main(void){ (void)clock_init_168mhz(); systick_init_1ms(); sys_set_priority(SYSTICK_EXCEPTION, 2u); /* tick preempts the button ISR */
uart_init(); button_irq_init();
uart_puts("\r\nbutton counter ready\r\n");
uint32_t shown = 0; for (;;) { /* Take a consistent snapshot of the pair. */ uint32_t key = irq_save(); uint32_t count = s_press_count; uint32_t when = s_press_time_ms; irq_restore(key);
if (count != shown) { shown = count; uart_puts("presses: "); uart_put_u32(count); uart_puts(" at ms: "); uart_put_u32(when); uart_puts("\r\n"); } __asm volatile ("wfi"); /* sleep until the next interrupt */ }}Expected terminal output after three presses:
button counter readypresses: 1 at ms: 2140presses: 2 at ms: 3015presses: 3 at ms: 3720The design choices, line by line:
- Only the handler writes the shared variables. Main only reads. That removes the lost-update problem entirely; the only remaining issue is reading
countandwhenas a matching pair, which the shortirq_save/irq_restoresection solves. Readingcountalone would not need it, because it is an aligned 32-bit value. - Debouncing in the handler uses the timestamp lockout from GPIO in depth: an edge is accepted only if 50 ms have passed since the previous edge, and every edge, accepted or not, restarts the lockout so a long bounce burst cannot sneak through.
- SysTick at level 2, the button at level 12. The handler calls
millis(), which is just a read of the tick variable, so the order does not affect correctness here. But giving the tick a higher priority means a long low-priority handler can never stall time. wfiin the loop puts the core to sleep until any interrupt; the SysTick wakes it every millisecond, so the loop still runs often enough. Power management explains why aDSBusually precedesWFIand what deeper sleep modes need.- Printing in main, never in the handler. At 115200 baud a 30-character line takes about 2.6 ms of polled waiting. Done in the handler, that blocks every lower-priority interrupt for 2.6 ms per press.
Run it under the debugger and set a breakpoint in EXTI0_IRQHandler. Then try the bugs on purpose: comment out the EXTI->PR write and watch main stop printing; remove volatile from s_press_count, build at -O2, and see whether the loop still notices presses (the irq_restore asm with its "memory" clobber forces a reload here, so also try removing the critical section).
Common mistakes
Section titled “Common mistakes”- Misspelling the handler name, so the weak
Default_Handlerruns. Fix: copy the name from the startup file’s vector table. - Forgetting one of the enable gates (peripheral clock, peripheral IE bit, NVIC enable). Fix: follow the eight-step order every time.
- Not clearing the interrupt source, so the handler re-enters forever. Fix: clear the flag at the top of the handler.
- Clearing the flag as the last instruction, so the handler runs twice. Fix: clear first and read the register back.
- Sharing a variable without
volatile, or doing read-modify-write on it from both sides. Fix:volatile, single writer, critical sections for multi-word data. - Using
__disable_irq()/__enable_irq()pairs that break when nested. Fix: save and restorePRIMASK. - Writing an 8-bit priority value without shifting. Fix:
level << 4with 4 implemented bits. - Calling
printf,delay_msormallocin a handler. Fix: set a flag or queue data, do the work in main.
Exercises
Section titled “Exercises”- Set the EXTI0 priority byte to
0x05instead of12u << 4and read it back in gdb withx/1xb 0xE000E406. What value do you get and what level does that represent? (Expected:0x00, level 0, the highest.) - Move the
EXTI->PRclear to the end of the handler and remove the read-back. Increment a second counter at the very top of the handler. Over 50 presses, do the two counters disagree? Then put the read-back back in. - Compile the lab at
-O2and disassembleEXTI0_IRQHandlerwitharm-none-eabi-objdump -d. Confirm there is no special prologue or epilogue beyond a normal function, find the store to0x40013C14(EXTI_PR), and find thebx lr(orpop {..., pc}) that performs the exception return. - Add a
volatile uint64_ttick counter incremented by SysTick, and read it in main both with and withoutirq_save(). Look at the disassembly of the unprotected read and explain whether it can tear. - Implement deferred processing: in the button handler only record the timestamp and pend PendSV; in
PendSV_Handler(lowest priority) apply the debounce logic and update the count. Explain why the debounce state no longer needs to be touched by the EXTI handler at all.
Check yourself
Section titled “Check yourself”Question 1:Why does a Cortex-M interrupt handler not need __attribute__((interrupt))?
The core stacks exactly the registers a C function may clobber, and returning to an EXC_RETURN value triggers exception return, so an ordinary function works.
Question 2:Main reads a volatile uint32_t that only an ISR writes, with no critical section. What can main observe?
An aligned 32-bit load is a single instruction on Cortex-M and cannot be split by an interrupt. Volatile ensures it is actually re-read.
Question 3:A level-5 handler is running when a level-5 interrupt and a level-3 interrupt both become pending (4 preemption bits). What happens?
Only a strictly more urgent preemption priority can preempt. Level 3 does; the equal-level one waits and tail-chains later.
Question 4:What is wrong with this nested critical section helper: void lock(void) { __asm volatile("cpsid i"); } void unlock(void) { __asm volatile("cpsie i"); }?
PRIMASK is a single bit, not a counter. Saving the previous PRIMASK and restoring it is what makes nesting safe.
Question 5:With BASEPRI set to level 6 (written as 0x60), which interrupts can still run?
BASEPRI masks interrupts whose priority value is equal to or greater than it, so only numerically lower, more urgent levels get through.
Question 6:An ISR increments a counter and clears its peripheral flag as its very last statement. The counter sometimes increases by two per event. Most likely cause?
Writes to peripherals are buffered and cross a bus bridge. Clearing early and reading the register back ensures the request is gone before the handler returns.
Key takeaways
Section titled “Key takeaways”- A Cortex-M handler is a plain
void name(void)C function whose name must match the vector table; hardware saves the caller-saved registers. - Every interrupt passes four gates: peripheral clock, peripheral interrupt enable, NVIC enable, priority above the current mask. Clear stale flags before enabling.
- Clear the source flag at the top of the handler and read it back, or the handler re-enters or runs twice.
- Shared variables must be
volatile; only aligned 32-bit and smaller loads/stores are atomic. Use a single writer and short save/restorePRIMASKcritical sections for everything else. BASEPRImasks only the less urgent levels, leaving critical interrupts running.- Assign priorities by deadline. Lower number is more urgent; only strictly more urgent preemption priority preempts.
- Keep handlers short. Defer work to main or to PendSV; never print, block or allocate in a handler.
Next: Timers and PWM puts the hardware timers to work, from periodic interrupts to dimming LEDs and measuring pulse widths.