2026-06-11 · Davide Carrese

STM32 EXTI at Register Level:
SYSCFG, GPIO Interrupt Mapping, and NVIC Wiring

STM32 · EXTI · Interrupt · GPIO · Register-Level · NVIC

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:

  1. SYSCFG — selects which GPIO port (A, B, C, ...) feeds each EXTI line. This is configured in the SYSCFG_EXTICR1..4 registers, four bits per line.
  2. EXTI — sets edge sensitivity (rising, falling, or both), masks the interrupt, and reports the pending flag. Controlled via EXTI_IMR, EXTI_RTSR, EXTI_FTSR, and EXTI_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.

⚠️ Common pitfall

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:

  1. 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 to IMR.
  2. The GPIO must remain powered — check that the port is in the VDD domain, not VPB (backup domain) unless using RTC wakeup.
  3. 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

StepRegisterWhat to check
GPIO clockRCC->AHB1ENRBit set for the port
SYSCFG clockRCC->APB2ENRSYSCFGEN bit set
GPIO modeGPIOx->MODER0b00 (input)
Port mappingSYSCFG->EXTICR[n]4-bit nibble matches the port code
Interrupt maskEXTI->IMRBit set for the EXTI line
Edge triggerEXTI->RTSR/FTSRAt least one edge enabled
NVIC enableNVIC_ISERIRQ number enabled
PR cleared in ISREXTI->PRWrite 1 to the bit (never |=)
Wakeup eventEXTI->EMRMust 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

📬 Leave a comment

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