00 The interrupt signal path
On the STM32L4R5 (Cortex-M4F core, RM0432 reference manual) a GPIO pin cannot interrupt the CPU directly. The edge travels through four blocks. Understanding this chain is the whole game — every bug is a missing step in it.
GPIO pin edge → SYSCFG_EXTICR (pin→line mux) → EXTI (edge detect + mask + pending) → NVIC (enable + priority) → Cortex-M4 core → your *_IRQHandler
RCC->AHB2ENR.RCC->APB2ENR.Two independent switches must both be on for an ISR to fire: the EXTI mask (IMR1) and the NVIC enable (ISER). Miss either and nothing happens; the pending flag may still latch in PR1, so once you finally unmask, a stale edge can fire immediately.
This section
- Edge path: GPIO → SYSCFG mux → EXTI → NVIC → core → ISR.
- Two enables required: EXTI
IMR1bit and NVICISERbit. - Three clocks to enable: the GPIO port (AHB2), SYSCFG (APB2), and — implicitly — the core is always clocked.
01 NVIC: enable, priority & grouping
The NVIC is memory-mapped inside the Cortex-M4 System Control Space. You rarely poke its registers directly — CMSIS (core_cm4.h, pulled in by stm32l4r5xx.h) gives you inline helpers. But knowing the registers explains exactly what those helpers write.
The CMSIS helpers you actually call
/* Order matters: set priority BEFORE enabling, so the first
* interrupt is already at the priority you intended. */
NVIC_SetPriority(EXTI0_IRQn, 5); /* 0 = highest, 15 = lowest */
NVIC_EnableIRQ(EXTI0_IRQn); /* unmask in the NVIC */
/* Mirror operations */
NVIC_DisableIRQ(EXTI0_IRQn);
NVIC_ClearPendingIRQ(EXTI0_IRQn); /* clears the NVIC latch, NOT EXTI->PR1 */
uint32_t p = NVIC_GetPriority(EXTI0_IRQn);
What those calls write, register-level
#define IRQ EXTI0_IRQn /* = 6 */
/* Enable: 32 IRQs per 32-bit word */
NVIC->ISER[IRQ >> 5] = (1u << (IRQ & 0x1F)); /* ISER[0], bit 6 */
/* Priority: one 8-bit IPR byte per IRQ; STM32 uses the TOP 4 bits,
* so the value is left-shifted by (8 - __NVIC_PRIO_BITS) = 4. */
NVIC->IPR[IRQ] = (uint8_t)(5u << (8 - __NVIC_PRIO_BITS)); /* 5<<4 = 0x50 */
/* Disable / clear-pending use the mirror arrays */
NVIC->ICER[IRQ >> 5] = (1u << (IRQ & 0x1F)); /* Clear-Enable */
NVIC->ICPR[IRQ >> 5] = (1u << (IRQ & 0x1F)); /* Clear-Pending */
__NVIC_PRIO_BITS is 4U on the STM32L4R5. Because only the four most-significant bits are implemented, raw IPR writes must be pre-shifted; NVIC_SetPriority() does that shift for you, so you pass the plain 0..15 value.
| NVIC register | CMSIS access | Function |
|---|---|---|
| ISER[] | NVIC->ISER[n] | Set-Enable (write 1 to enable) |
| ICER[] | NVIC->ICER[n] | Clear-Enable (write 1 to disable) |
| ISPR[] | NVIC->ISPR[n] | Set-Pending (force pending) |
| ICPR[] | NVIC->ICPR[n] | Clear-Pending |
| IABR[] | NVIC->IABR[n] | Active bit (read-only) |
| IPR[] | NVIC->IPR[irq] | 8-bit priority per IRQ (top 4 bits used) |
Priority grouping (pre-emption vs sub-priority)
The 4 priority bits are split by the PRIGROUP field in SCB->AIRCR[10:8] into a pre-emption (group) part and a sub-priority part. Only pre-emption priority decides whether one ISR can interrupt another; sub-priority only orders two pending IRQs of the same group. Higher pre-emption (lower number) wins.
| CMSIS / HAL group | PRIGROUP | Pre-empt bits | Sub bits | Pre-empt levels | Sub levels |
|---|---|---|---|---|---|
| NVIC_PRIORITYGROUP_0 | 0b111 (7) | 0 | 4 | 1 | 16 |
| NVIC_PRIORITYGROUP_1 | 0b110 (6) | 1 | 3 | 2 | 8 |
| NVIC_PRIORITYGROUP_2 | 0b101 (5) | 2 | 2 | 4 | 4 |
| NVIC_PRIORITYGROUP_3 | 0b100 (4) | 3 | 1 | 8 | 2 |
| NVIC_PRIORITYGROUP_4 | 0b011 (3) | 4 | 0 | 16 | 1 |
Key fact: HAL_Init() selects NVIC_PRIORITYGROUP_4 — all four bits are pre-emption, zero sub-priority. Under HAL the sub-priority argument you pass is silently ignored.
/* Raw CMSIS: choose 2 group bits + 2 sub bits (PRIGROUP = 5) */
NVIC_SetPriorityGrouping(5u);
uint32_t prio = NVIC_EncodePriority(NVIC_GetPriorityGrouping(),
2, /* pre-emption level 0..3 */
1); /* sub-priority 0..3 */
NVIC_SetPriority(EXTI0_IRQn, prio);
/* HAL: HAL_Init() already forced group 4, so 'sub' does nothing */
HAL_NVIC_SetPriority(EXTI0_IRQn, 5, 0); /* (IRQn, preempt, sub) */
HAL_NVIC_EnableIRQ(EXTI0_IRQn);
02 Vector table & ISR naming
The Cortex-M4 fetches the ISR address from a table at the base of flash (0x0800_0000, pointed to by SCB->VTOR). Entry N holds the address for IRQ N. ST's startup_stm32l4r5xx.s fills that table with weak symbols aliased to Default_Handler (an infinite loop). You override one by defining a plain C function with the exact same name.
g_pfnVectors:
.word _estack
.word Reset_Handler
/* ... core exceptions ... */
.word EXTI0_IRQHandler /* IRQ 6 */
.word EXTI1_IRQHandler /* IRQ 7 */
/* ... */
.word EXTI9_5_IRQHandler /* IRQ 23 */
/* ... */
.word EXTI15_10_IRQHandler /* IRQ 40 */
/* Each handler is a WEAK alias of Default_Handler: */
.weak EXTI0_IRQHandler
.thumb_set EXTI0_IRQHandler, Default_Handler
Define a non-weak (strong) symbol of the same name in your C and the linker uses yours instead. A typo (EXTI_0_IRQHandler, wrong case, missing underscore) does not error — it silently leaves the weak Default_Handler in the table, and your interrupt appears to "do nothing" while the core spins in the default loop.
| GPIO pin(s) | EXTI line | EXTICR reg | IRQn (value) | ISR symbol name |
|---|---|---|---|---|
| Px0 | EXTI0 | EXTICR[0] | EXTI0_IRQn (6) | EXTI0_IRQHandler |
| Px1 | EXTI1 | EXTICR[0] | EXTI1_IRQn (7) | EXTI1_IRQHandler |
| Px2 | EXTI2 | EXTICR[0] | EXTI2_IRQn (8) | EXTI2_IRQHandler |
| Px3 | EXTI3 | EXTICR[0] | EXTI3_IRQn (9) | EXTI3_IRQHandler |
| Px4 | EXTI4 | EXTICR[1] | EXTI4_IRQn (10) | EXTI4_IRQHandler |
| Px5 – Px9 | EXTI5 – EXTI9 | EXTICR[1..2] | EXTI9_5_IRQn (23) | EXTI9_5_IRQHandler |
| Px10 – Px15 | EXTI10 – EXTI15 | EXTICR[2..3] | EXTI15_10_IRQn (40) | EXTI15_10_IRQHandler |
Lines 0–4 each own a dedicated vector; lines 5–9 share EXTI9_5 and lines 10–15 share EXTI15_10. Shared handlers must demultiplex via PR1 (see section 06). IRQn values above come straight from stm32l4r5xx.h.
03 EXTI: edges, line mux & pending
The EXTI controller owns the edge detection. On the STM32L4R5, configurable GPIO lines are 0–15; higher line numbers are internal sources (PVD, RTC, USB wakeup, COMP, …). Lines 0–31 use the *1 registers; lines 32+ use the *2 registers.
| Register (lines 0–31) | Meaning | Behaviour |
|---|---|---|
EXTI_IMR1 | Interrupt mask | 1 = line delivers an IRQ to the NVIC |
EXTI_EMR1 | Event mask | 1 = line generates a CPU event/wakeup, no ISR |
EXTI_RTSR1 | Rising trigger select | 1 = detect 0→1 transition |
EXTI_FTSR1 | Falling trigger select | 1 = detect 1→0 transition |
EXTI_SWIER1 | Software interrupt event | write 1 = force the line pending (self-test) |
EXTI_PR1 | Pending register | 1 = selected edge occurred; write 1 to clear |
The matching bit macros in stm32l4r5xx.h are EXTI_IMR1_IMx, EXTI_RTSR1_RTx, EXTI_FTSR1_FTx, EXTI_SWIER1_SWIx and EXTI_PR1_PIFx (for line x). Set both RTSR1 and FTSR1 for either-edge triggering.
Muxing the pin to the line: SYSCFG_EXTICR
Line number y can be driven by PAy, PBy, … but only one at a time. The 4-bit selector lives in SYSCFG->EXTICR[k], where k = y / 4 and the field starts at bit 4 * (y % 4).
| EXTICR value | Port | EXTICR value | Port |
|---|---|---|---|
| 0x0 | PA | 0x5 | PF |
| 0x1 | PB | 0x6 | PG |
| 0x2 | PC | 0x7 | PH |
| 0x3 | PD | 0x8 | PI |
| 0x4 | PE | (availability depends on package pin-out) | |
Mapping which register holds which line: EXTICR[0] → lines 0–3, EXTICR[1] → 4–7, EXTICR[2] → 8–11, EXTICR[3] → 12–15. The header also provides ready-made value macros such as SYSCFG_EXTICR4_EXTI13_PC (= 0x20) for the common cases.
Interrupt vs event. Unmask via IMR1 to run an ISR. Unmask via EMR1 instead and the edge sets a CPU event (wakes a __WFE() / pulses a peripheral trigger) but never vectors to a handler. Using EMR1 when you wanted a callback is a classic silent failure.
04 Bare-metal button example (PA0)
A complete, compilable register-level program (CMSIS device header only, no HAL). Wiring: a push-button from PA0 to GND (internal pull-up, so idle = high, press = falling edge), toggling the green LED LD1 (PC7) on the NUCLEO-L4R5ZI. PA0 → EXTI0 → dedicated EXTI0_IRQn.
#include "stm32l4r5xx.h" /* CMSIS: EXTI, GPIOx, RCC, SYSCFG, NVIC_*, IRQn */
/* Shared with main loop -> MUST be volatile or the compiler may cache it */
volatile uint32_t g_presses = 0;
/* Vector-table symbol: name MUST match startup_stm32l4r5xx.s EXACTLY */
void EXTI0_IRQHandler(void)
{
if (EXTI->PR1 & EXTI_PR1_PIF0) { /* did line 0 fire? */
EXTI->PR1 = EXTI_PR1_PIF0; /* clear pending: write 1 */
GPIOC->ODR ^= GPIO_ODR_OD7; /* toggle LD1 */
g_presses++;
}
}
int main(void)
{
/* 1) Clocks: GPIOA + GPIOC on AHB2, SYSCFG on APB2 */
RCC->AHB2ENR |= RCC_AHB2ENR_GPIOAEN | RCC_AHB2ENR_GPIOCEN;
RCC->APB2ENR |= RCC_APB2ENR_SYSCFGEN; /* WITHOUT this, EXTICR stays PA */
(void)RCC->APB2ENR; /* read-back: let the clock settle */
/* 2) PA0 = input (MODER 00) with pull-up (PUPDR 01) */
GPIOA->MODER &= ~GPIO_MODER_MODE0;
GPIOA->PUPDR = (GPIOA->PUPDR & ~GPIO_PUPDR_PUPD0)
| (1u << GPIO_PUPDR_PUPD0_Pos);
/* 3) PC7 = general-purpose output (MODER 01), push-pull default */
GPIOC->MODER = (GPIOC->MODER & ~GPIO_MODER_MODE7)
| (1u << GPIO_MODER_MODE7_Pos);
/* 4) Route EXTI0 to port A (EXTICR1 field EXTI0 = 0x0 = PA) */
SYSCFG->EXTICR[0] &= ~SYSCFG_EXTICR1_EXTI0;
/* 5) EXTI line 0: falling edge only, clear stale, then unmask */
EXTI->RTSR1 &= ~EXTI_RTSR1_RT0; /* no rising edge */
EXTI->FTSR1 |= EXTI_FTSR1_FT0; /* falling edge */
EXTI->PR1 = EXTI_PR1_PIF0; /* clear stale flag BEFORE unmask */
EXTI->IMR1 |= EXTI_IMR1_IM0; /* unmask -> reaches NVIC */
/* 6) NVIC: priority (0..15) THEN enable */
NVIC_SetPriority(EXTI0_IRQn, 5);
NVIC_EnableIRQ(EXTI0_IRQn);
while (1) {
__WFI(); /* sleep until any interrupt wakes the core */
}
}
Every write above maps 1:1 to the signal path in section 00. Drop the identical logic in with a different pin by changing the MODE/PUPD/EXTICR/IM/FT index and the IRQn/handler name from the table in section 02.
05 The HAL path & callbacks
STM32Cube HAL hides the registers behind three moving parts: HAL_GPIO_Init() (with an IT mode) programs SYSCFG + EXTI; the *_IRQHandler in stm32l4xx_it.c calls HAL_GPIO_EXTI_IRQHandler() which clears PR1 and dispatches; and you override the weak HAL_GPIO_EXTI_Callback(). Same result as section 04 — but you must not forget to wire all three.
void MX_GPIO_Init(void)
{
GPIO_InitTypeDef g = {0};
__HAL_RCC_GPIOA_CLK_ENABLE();
__HAL_RCC_GPIOC_CLK_ENABLE();
__HAL_RCC_SYSCFG_CLK_ENABLE(); /* required for EXTI line muxing */
/* PA0 -> external interrupt, falling edge, pull-up */
g.Pin = GPIO_PIN_0;
g.Mode = GPIO_MODE_IT_FALLING; /* IT = interrupt (EVT = event, no ISR) */
g.Pull = GPIO_PULLUP;
HAL_GPIO_Init(GPIOA, &g);
/* PC7 -> push-pull output (LED) */
g.Pin = GPIO_PIN_7;
g.Mode = GPIO_MODE_OUTPUT_PP;
g.Pull = GPIO_NOPULL;
g.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOC, &g);
/* NVIC (HAL_Init selected NVIC_PRIORITYGROUP_4 -> sub-priority ignored) */
HAL_NVIC_SetPriority(EXTI0_IRQn, 5, 0);
HAL_NVIC_EnableIRQ(EXTI0_IRQn);
}
void EXTI0_IRQHandler(void)
{
/* Clears EXTI->PR1 for pin 0 AND calls HAL_GPIO_EXTI_Callback() */
HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0);
}
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if (GPIO_Pin == GPIO_PIN_0) {
HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_7); /* toggle LD1 */
}
}
SYSCFG_EXTICR, RTSR1/FTSR1 and unmasks IMR1. It does not touch the NVIC.HAL_GPIO_Init.*_IRQHandler body should contain; it clears the pending flag for you.GPIO_Pin to tell lines apart.06 Pending, clearing & shared handlers
There are two separate pending latches. Confusing them causes the two most common EXTI bugs: an interrupt that fires forever, and a shared handler that reacts to the wrong pin.
| Latch | Set when | Cleared by | If you forget |
|---|---|---|---|
EXTI->PR1 bit | the selected edge occurs | writing 1 to that bit | ISR re-enters immediately → interrupt storm |
NVIC pending (ISPR) | the EXTI request asserts | entering the ISR (or NVIC_ClearPendingIRQ) | usually auto-handled; only matters after disabling |
The rule: clear the EXTI source (PR1) inside the handler. The NVIC latch clears itself on ISR entry, but the EXTI flag is the persistent source — leave it set and the core re-vectors the moment the handler returns.
Shared handlers must demultiplex
Lines 10–15 all vector to EXTI15_10_IRQHandler. Inside it you must test each PR1 bit you care about and clear only the ones you handled. Example: the NUCLEO-L4R5ZI user button B1 is on PC13 → EXTI13 → EXTI15_10_IRQn.
/* Mux EXTI13 -> port C. Line 13 lives in EXTICR[3], field bits [7:4]. */
SYSCFG->EXTICR[3] = (SYSCFG->EXTICR[3] & ~SYSCFG_EXTICR4_EXTI13)
| SYSCFG_EXTICR4_EXTI13_PC; /* 0x20 selects PC */
EXTI->FTSR1 |= EXTI_FTSR1_FT13;
EXTI->PR1 = EXTI_PR1_PIF13; /* clear stale */
EXTI->IMR1 |= EXTI_IMR1_IM13;
NVIC_SetPriority(EXTI15_10_IRQn, 6);
NVIC_EnableIRQ(EXTI15_10_IRQn);
/* One handler serves lines 10..15 -> you MUST check which line fired */
void EXTI15_10_IRQHandler(void)
{
if (EXTI->PR1 & EXTI_PR1_PIF13) {
EXTI->PR1 = EXTI_PR1_PIF13; /* clear ONLY the line handled */
/* ... service the button ... */
}
/* other lines (10,11,12,14,15) would each test their own PIF bit */
}
EXTI->SWIER1 = EXTI_SWIER1_SWI0; /* sets PR1 bit 0 -> runs EXTI0_IRQHandler */
07 Gotchas & common mistakes
Almost every "my interrupt doesn't fire" (or "fires forever") report on the ST forums is one of these. Scan the table first.
| Mistake | Symptom | Fix |
|---|---|---|
| SYSCFG clock not enabled | EXTICR writes have no effect; line stays mapped to PA — a different port's pin appears "dead" or PA fires unexpectedly | RCC->APB2ENR |= RCC_APB2ENR_SYSCFGEN; before writing EXTICR |
Not clearing EXTI->PR1 in the ISR | Handler re-enters endlessly; app appears frozen (interrupt storm) | Write 1 to the pending bit: EXTI->PR1 = EXTI_PR1_PIFx; |
| Handler name typo / wrong case | Interrupt "does nothing"; core silently spins in Default_Handler | Copy the exact symbol from section 02; it is case-sensitive |
| Two pins, same line number | PA0 and PB0 can't both use EXTI0 — only the last EXTICR port wins | Use different pin numbers; one EXTI line = one port at a time |
| Wrong priority direction | ISR never pre-empts as expected | Lower number = higher priority; only 0..15 are meaningful (4 bits) |
Raw IPR write not shifted | Priority looks off by 16× | NVIC->IPR[n] = prio << 4; — or just use NVIC_SetPriority() |
| Expecting sub-priority under HAL | Two ISRs at same preempt level don't order as hoped | HAL uses group 4 (0 sub bits); use a different NVIC_SetPriorityGrouping if you need sub-priority |
Used EMR1 instead of IMR1 | CPU wakes / peripheral triggers but no ISR runs | Set IMR1 for an interrupt; EMR1 is for events only |
| GPIO port clock off | Pin config writes are ignored; pin stays analog/input | Enable RCC->AHB2ENR bit for the port first |
Shared flag not volatile | Main loop never sees the ISR's update (optimized away) | Declare cross-ISR variables volatile |
| Switch bounce | One press → many interrupts / double-toggle | Debounce: ignore edges within ~10–20 ms, or read the pin after a short delay |
RTOS ISR calls *FromISR at too-high priority | Hard fault / assert inside FreeRTOS | Keep numeric priority ≥ configMAX_SYSCALL_INTERRUPT_PRIORITY (i.e. logically lower) |
Checklist for a working GPIO interrupt
- Enable GPIO port clock (AHB2) and SYSCFG clock (APB2).
- Pin = input, with pull-up/down or an external reference.
SYSCFG->EXTICR[y/4]selects the correct port for line y.- Set
RTSR1and/orFTSR1, clear stalePR1, unmaskIMR1. NVIC_SetPriority()thenNVIC_EnableIRQ()with the rightIRQn.- Define the exact
*_IRQHandler; clearPR1first thing inside it.