STM32 EXTI at Register Level:
SYSCFG, GPIO Interrupt Mapping, and NVIC Wiring
Nothing worse than a button that doesn't wake the MCU — or an interrupt that fires on both edges when you only wanted the rising one. Most HAL tutorials gloss over the two-step GPIO→EXTI routing through SYSCFG, leaving you with magic black-box configuration. Let's fix that.
The EXTI (External Interrupt/Event Controller) is the peripheral that brings external signals into the NVIC. On STM32, it supports up to 23 interrupt/event lines. Lines 0–15 are connected to GPIO pins: each line can be mapped to exactly one port pin at a time. Lines 16–22 are fixed to internal peripherals (RTC, PVD, USB, etc.).
This article covers lines 0–15 — the configurable GPIO interrupts — at register level on STM32F4. The same principles apply to STM32G0, G4, L4, H7, and most Cortex-M STM32 families, though the number of EXTI lines and SYSCFG register layout may vary slightly.
Architecture: The Two-Stage Path
An external GPIO interrupt on STM32 goes through exactly two peripheral stages before reaching the NVIC:
- SYSCFG — selects which GPIO port (A, B, C, ...) feeds each EXTI line. This is configured in the
SYSCFG_EXTICR1..4registers, four bits per line. - EXTI — sets edge sensitivity (rising, falling, or both), masks the interrupt, and reports the pending flag. Controlled via
EXTI_IMR,EXTI_RTSR,EXTI_FTSR, andEXTI_PR.
The NVIC simply needs the corresponding IRQ channel enabled. On STM32F4, EXTI lines 0–4 each have a dedicated interrupt vector. Lines 5–9 share EXTI9_5_IRQn, and lines 10–15 share EXTI15_10_IRQn.
Many developers configure only the EXTI registers and the NVIC, then wonder why the interrupt never fires. The missing piece is SYSCFG_EXTICR. Without it, the EXTI line has no port mapped and remains disconnected from any GPIO — the interrupt simply never triggers.
Register-Level Configuration Walkthrough
Let's configure a push-button on PC13 (STM32F4DISCOVERY user button) to generate an interrupt on the falling edge (press). PC13 means: port C, pin 13 → EXTI line 13.
1. Enable peripheral clocks
Both SYSCFG and the GPIO port must be clocked. On STM32F4, SYSCFG is clocked via RCC->APB2ENR, not RCC->AHB1ENR:
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOCEN; /* enable GPIOC clock */ RCC->APB2ENR |= RCC_APB2ENR_SYSCFGEN; /* enable SYSCFG clock */
2. Configure the GPIO pin as input
The GPIO must be in input mode (MODER = 0b00) with no pull-up/pull-down for a button that has an external pull-up. On the DISCOVERY board, PC13 has an internal pull-up enabled in the bootloader, but we set it explicitly:
GPIOC->MODER &= ~(3U << 26); /* PC13 = 0b00 = input */ GPIOC->PUPDR &= ~(3U << 26); GPIOC->PUPDR |= (1U << 26); /* pull-up */
3. Route GPIO port to EXTI via SYSCFG
EXTI line 13 needs to know which port supplies its signal. This is done in SYSCFG_EXTICR4, which handles lines 12–15. Each line uses 4 bits; line 13 occupies bits 4–7 (the second nibble):
/* EXTI13 source = Port C (0b0010) */ SYSCFG->EXTICR[3] &= ~(0xFU << 4); /* clear previous mapping */ SYSCFG->EXTICR[3] |= (2U << 4); /* PCx */
The port code is: PA=0, PB=1, PC=2, PD=3, PE=4, PF=5, PG=6, PH=7, PI=8. The array index maps as EXTICR[0] → lines 0–3, [1] → 4–7, [2] → 8–11, [3] → 12–15.
4. Configure EXTI edge sensitivity
We want the falling edge only (button press goes from high to low):
EXTI->IMR |= (1U << 13); /* unmask interrupt on line 13 */ EXTI->FTSR |= (1U << 13); /* falling-edge trigger */ EXTI->RTSR &= ~(1U << 13); /* rising edge disabled */
For both edges, set both RTSR and FTSR. For event-only (no CPU interrupt), use EXTI->EMR instead of IMR — useful for low-power wakeup without waking the core.
5. Enable the interrupt in the NVIC
Pin 13 belongs to the EXTI15_10_IRQn group:
NVIC_EnableIRQ(EXTI15_10_IRQn); NVIC_SetPriority(EXTI15_10_IRQn, 5); /* priority 5 */
6. Write the ISR
The ISR must clear the pending bit in EXTI->PR, otherwise the interrupt re-fires immediately on exit:
void EXTI15_10_IRQHandler(void)
{
if (EXTI->PR & (1U << 13)) {
/* PC13 button was pressed — handle it */
EXTI->PR = (1U << 13); /* clear by writing 1 */
}
}
Writing a 1 to EXTI_PR clears the pending flag. Writing 0 has no effect. This register is write-1-to-clear (W1C), so never use |= on it — you would clear all pending flags that happen to be 1, including ones from other lines that arrived between the read and the write. Use a direct assignment with only the bit you intend to clear.
Practical Example: Dual-Edge Button with Software Debounce
Sometimes you need both press and release events — for example, detecting how long a button was held. Let's configure PC13 for both edges:
void button_interrupt_init(void)
{
/* Clock enable */
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOCEN;
RCC->APB2ENR |= RCC_APB2ENR_SYSCFGEN;
__DSB(); /* ensure clock is active */
/* PC13 input with pull-up */
GPIOC->MODER &= ~(3U << 26);
GPIOC->PUPDR &= ~(3U << 26);
GPIOC->PUPDR |= (1U << 26);
/* EXTI13 source = Port C */
SYSCFG->EXTICR[3] &= ~(0xFU << 4);
SYSCFG->EXTICR[3] |= (2U << 4);
/* Both edges */
EXTI->IMR |= (1U << 13);
EXTI->RTSR |= (1U << 13); /* rising — button release */
EXTI->FTSR |= (1U << 13); /* falling — button press */
NVIC_EnableIRQ(EXTI15_10_IRQn);
NVIC_SetPriority(EXTI15_10_IRQn, 5);
}
volatile uint32_t last_edge_time;
volatile uint8_t button_pressed;
void EXTI15_10_IRQHandler(void)
{
if (EXTI->PR & (1U << 13)) {
EXTI->PR = (1U << 13);
uint32_t now = DWT->CYCCNT; /* cycle counter for timing */
uint32_t delta = now - last_edge_time;
if (delta > (16000000U / 1000 * 50)) { /* > 50 ms debounce */
last_edge_time = now;
if (GPIOC->IDR & (1U << 13)) {
/* Pin is high — button released */
button_pressed = 0;
} else {
/* Pin is low — button pressed */
button_pressed = 1;
}
}
}
}
The debounce reads the GPIO data register (GPIOC_IDR) after the edge — since we know an edge happened, we can directly determine the raw state. The DWT cycle counter provides microsecond-resolution timing without a dedicated timer.
Wakeup from Stop Mode via EXTI
EXTI lines are the primary way to wake the STM32 from Stop mode. The wakeup path differs slightly from the interrupt path:
- The EXTI line must be configured as an event, not just an interrupt. Set the corresponding bit in
EXTI->EMR(Event Mask Register) in addition toIMR. - The GPIO must remain powered — check that the port is in the
VDDdomain, notVPB(backup domain) unless using RTC wakeup. - After wakeup, the ISR runs normally. The PLL and HSI/HSE may need reconfiguration depending on the Stop mode variant (Stop 0, Stop 1, Stop 2 on newer STM32).
/* Enable both interrupt AND event generation */ EXTI->IMR |= (1U << 13); /* interrupt — for the ISR after wakeup */ EXTI->EMR |= (1U << 13); /* event — wakes the CPU from Stop */ EXTI->FTSR |= (1U << 13); /* falling edge */ /* Enter Stop mode */ PWR->CR |= PWR_CR_LPDS; /* low-power deep sleep */ SCB->SCR |= SCB_SCR_SLEEPDEEP_Msk; __WFI(); /* enter Stop, wake on EXTI event */
Without EXTI_EMR, the falling edge is latched but does not generate the wakeup event needed to exit Stop mode. The interrupt will fire after the core wakes for another reason, which defeats the purpose.
Shared IRQ Lines: EXTI5_9 and EXTI15_10
When multiple pins share an IRQ handler (lines 5–9 or 10–15), the ISR must check which EXTI line triggered by reading EXTI->PR:
void EXTI15_10_IRQHandler(void)
{
uint32_t pending = EXTI->PR;
if (pending & (1U << 13)) {
EXTI->PR = (1U << 13);
/* handle PC13 */
}
if (pending & (1U << 10)) {
EXTI->PR = (1U << 10);
/* handle PA10 */
}
}
Always read the PR register first, clear the specific bit, and dispatch. Never clear all bits with EXTI->PR = 0xFFFF — you may miss a line that triggered just after the pending check.
Practical checklist
| Step | Register | What to check |
|---|---|---|
| GPIO clock | RCC->AHB1ENR | Bit set for the port |
| SYSCFG clock | RCC->APB2ENR | SYSCFGEN bit set |
| GPIO mode | GPIOx->MODER | 0b00 (input) |
| Port mapping | SYSCFG->EXTICR[n] | 4-bit nibble matches the port code |
| Interrupt mask | EXTI->IMR | Bit set for the EXTI line |
| Edge trigger | EXTI->RTSR/FTSR | At least one edge enabled |
| NVIC enable | NVIC_ISER | IRQ number enabled |
| PR cleared in ISR | EXTI->PR | Write 1 to the bit (never |=) |
| Wakeup event | EXTI->EMR | Must be set for Stop mode wakeup |
How I would approach this on a client project
On a production firmware project, I never scatter EXTI configuration across the codebase. I use a small exti_config() struct and a single init function that takes the port, pin, edge flags, and NVIC priority. This makes the configuration auditable in one place and avoids the "why isn't my interrupt firing" debugging session six months later when someone adds a second sensor on the same EXTI line.
For debounce, I prefer the DWT cycle-counter approach shown above over timer-based sampling: no peripheral reservation, zero overhead when no edges occur, and it works from the ISR directly. The only prerequisite is enabling the DWT in CoreDebug->DEMCR:
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
I also add a static assertion that the EXTI line and the NVIC IRQn match. Nothing catches a PC13 button mapped to EXTI0_IRQn faster than a compile-time check.
Sources
- STM32F4 Reference Manual (RM0090), sections 8.2 (SYSCFG) and 13 (EXTI)
- STM32F4xx HAL Driver manual — EXTI peripheral chapter
- ARM Cortex-M3/M4 Generic User Guide (DUI0552) — NVIC and exception model
- ST Application Note AN4088 — EXTI configuration and use cases

Questions or corrections? Email me — I reply to every message.