Writing bare-metal STM32 firmware is a little like cooking without a recipe: wonderfully educational, occasionally smoky, and guaranteed to reveal whether you really understand what is happening under the hood. Among the first “aha!” moments comes when you stop polling buttons in a loop and let hardware interrupts do the work. Then comes the second moment: the microcontroller wakes up, jumps into your handler, and behaves as if it has had three cups of coffee and a grudge.
The culprit is often not the interrupt itself. It is the event around the interrupt: the EXTI line, pending flag, edge trigger, NVIC state, low-power wakeup behavior, or a tiny ordering mistake that turns a clean signal into a firmware soap opera. In bare-metal STM32 programming, an interrupt is not just “a thing happened.” It is a chain reaction involving GPIO, SYSCFG, EXTI, NVIC, the Cortex-M core, and your interrupt service routine. Miss one link, and the chain becomes a jump rope.
This guide explains how STM32 interrupt events work at register level, why EXTI deserves respect, how WFI and WFE differ, and what practical mistakes cause mysterious wakeups, repeated interrupts, or handlers that never fire. No HAL safety rails herejust registers, flags, and the faint smell of debugging at midnight.
What “Bare-Metal STM32” Really Means
Bare-metal STM32 development means writing firmware directly against the microcontroller hardware instead of leaning heavily on abstraction layers. You may still use CMSIS header files for register definitions and core functions, but the logic is yours. You decide which peripheral clocks are enabled, which bits go into which registers, and when the NVIC is allowed to interrupt the CPU.
This approach is excellent for learning because it removes the fog between your C code and the silicon. It also removes the cushions. In a HAL-based project, a function call may configure GPIO mode, map an EXTI source, enable the interrupt line, set priority, and clear pending bits. In bare metal, each of those steps is your responsibility. Forget one, and the board does not forgive you; it simply stares back through the debugger.
Interrupts vs. Events: Similar Words, Different Trouble
In STM32 documentation, the phrase “external interrupt/event controller,” commonly shortened to EXTI, is important. It does not say “interrupt controller only.” EXTI can generate interrupt requests, events, or both, depending on how its registers are configured.
An interrupt usually causes the CPU to suspend normal code execution and jump to an interrupt service routine, assuming the interrupt is enabled and not masked. An event can wake the processor or signal that something happened without necessarily running an ISR. That distinction matters when you start using low-power instructions like WFI, meaning Wait For Interrupt, or WFE, meaning Wait For Event.
For many beginner STM32 projects, the first EXTI example is a button connected to a GPIO pin. Press the button, create an edge, set a pending flag, notify NVIC, enter the ISR, clear the flag, toggle an LED. Easy, right? Yesuntil you enable the wrong mask, forget SYSCFG mapping, clear the pending bit incorrectly, or configure both rising and falling edges on a bouncing mechanical button. Then your “one button press” becomes a tiny percussion instrument.
The STM32 EXTI Path: From Pin Edge to ISR
Consider a typical external interrupt on a GPIO pin such as PA0, PC13, or PB5. The journey from electrical signal to C function usually follows this path:
- Enable the GPIO peripheral clock.
- Configure the pin as input, with suitable pull-up or pull-down settings.
- Enable the SYSCFG peripheral clock on STM32 families that require SYSCFG for EXTI routing.
- Map the GPIO port to the correct EXTI line using SYSCFG external interrupt configuration registers.
- Configure rising-edge, falling-edge, or both-edge triggering in EXTI trigger selection registers.
- Unmask the EXTI interrupt line using the interrupt mask register.
- Clear any stale pending flag before enabling NVIC.
- Set NVIC priority if needed.
- Enable the corresponding NVIC IRQ.
- Write the ISR with the exact vector-table handler name.
That order is not decorative. It is the difference between a controlled interrupt and a surprise party hosted by your own firmware. If a pending bit is already set before NVIC is enabled, the interrupt may fire immediately. That is not the chip being haunted; it is the chip doing exactly what you asked.
Example: A Minimal EXTI Interrupt Setup
The exact register names differ across STM32 families, so always check the reference manual for your device. Still, the rough shape of a bare-metal GPIO interrupt setup often looks like this:
Two details deserve applause. First, the ISR checks the pending bit because EXTI lines 10 through 15 often share one IRQ handler. Second, the pending flag is cleared by writing a 1, not by writing 0. This is one of those embedded details that feels backward until you have seen enough peripheral registers to stop expecting emotional consistency.
The Pending Bit: Tiny Flag, Big Drama
The EXTI pending register is a common source of confusion. It records that a configured edge occurred. Once set, it remains set until cleared in software, unless the specific STM32 line behaves differently according to the device manual. If you enable NVIC while EXTI already has a pending request, the CPU may immediately enter the handler.
This is why a robust initialization sequence clears the pending bit before enabling the NVIC interrupt. It is also why your ISR must clear the flag after confirming that the line caused the interrupt. On shared handlers such as EXTI9_5 or EXTI15_10, never assume your pin was the source. Check first, clear only what you handled, and leave the rest alone.
Interrupt Mask vs. Event Mask
STM32 EXTI generally provides separate controls for interrupt masking and event masking. The interrupt mask register allows a line to request an interrupt. The event mask register allows a line to generate an event. A line may be configured for one, the other, or both, depending on the microcontroller family and register layout.
This matters in low-power firmware. If you expect an ISR to run, enabling only the event path is not enough. If you expect a low-power wakeup without servicing an ISR, enabling the interrupt path may be unnecessary or even annoying. In other words, “the button wakes the chip” and “the button runs my handler” are related, but not identical.
WFI and WFE: The Sleepy Twins Are Not Identical
Cortex-M cores provide instructions that let firmware pause execution until hardware activity occurs. WFI waits for an interrupt. WFE waits for an event. In practical STM32 firmware, WFI is often the simpler choice when you want an enabled interrupt to wake the CPU and run its handler.
WFE is more subtle. It uses an internal event register. If that event register is already set, WFE may clear it and return immediately instead of sleeping. That behavior is useful when understood and maddening when discovered by accident. A common pattern before sleeping with WFE is to send and consume events intentionally so the event register is in a known state. Otherwise, your firmware may appear to “refuse to sleep,” which is relatable but not acceptable for battery life.
The first instruction sets an event. The first WFE consumes it and returns. The second WFE can then sleep until a new event occurs. This pattern is not magic seasoning; it is event-register housekeeping.
NVIC Priority: Lower Number, Higher Urgency
The NVIC manages interrupt priorities on Cortex-M. One detail catches newcomers quickly: numerically lower priority values represent higher urgency. Priority 0 is more urgent than priority 5. Yes, that feels like a scoreboard designed by a committee of cats, but it is standard Cortex-M behavior.
Another trap is that not all implemented priority bits are equal across devices. A Cortex-M microcontroller may implement only part of the 8-bit priority field. CMSIS functions such as NVIC_SetPriority help hide the left-shift details, but mixing raw register writes, vendor libraries, and custom macros can make priorities look correct while behaving strangely.
The practical advice is simple: use CMSIS priority functions unless you have a strong reason not to, keep your priority scheme boring, and document it. Reserve high urgency for truly time-sensitive work such as motor control, communication deadlines, or safety-related timing. Your button interrupt does not need to outrank the entire kingdom.
Keep Interrupt Handlers Short
A good ISR is like a polite houseguest: it arrives, handles the immediate issue, and leaves before everyone starts checking the clock. In bare-metal STM32 firmware, interrupt handlers should usually do the minimum required work:
- Check the interrupt source.
- Clear the peripheral pending flag.
- Store data or set a volatile flag.
- Exit quickly.
Avoid long delays, blocking loops, heavy printf calls, dynamic allocation, or complex state machines inside ISRs. These habits increase latency, block lower-priority interrupts, and make timing bugs difficult to reproduce. If the ISR needs to start a bigger job, set a flag and let the main loop handle it.
This style keeps the handler fast while still allowing the application to respond. It also improves power use because the CPU can sleep when no work is pending.
Volatile Is Necessary, Not Magical
When a variable is shared between an ISR and main code, mark it volatile. This tells the compiler that the value can change unexpectedly and should not be optimized as if only the current code path modifies it.
However, volatile does not make multi-step operations atomic. If both main code and an ISR modify a multi-byte counter or shared structure, you may need a critical section. On Cortex-M, that often means briefly disabling interrupts or using priority masking carefully. The goal is not to panic and wrap half the program in interrupt locks. The goal is to protect only the small section that must not be interrupted.
Mechanical Buttons: The Bounce Is Real
Many first EXTI experiments use a pushbutton. Mechanical switches bounce, meaning one press can produce several rapid transitions. If you configure both rising and falling edges, your interrupt handler may run multiple times for what your finger insists was one event. Your finger is not lying; the switch is just enthusiastic.
Debouncing can be handled in hardware with RC filters or in software using a timer. A simple strategy is to record the time of the last accepted press and ignore new edges until a debounce interval has passed. A better strategy for serious firmware is to let the EXTI interrupt wake the system, then sample the pin after a short delay using a timer-based state machine.
Debugging STM32 Interrupt Problems
When an interrupt misbehaves, resist the urge to randomly flip bits while whispering “maybe now.” Debug systematically.
1. Confirm the vector name
The ISR name must match the startup file vector table. If the vector expects EXTI15_10_IRQHandler but your function is named EXTI15_10_Handler, the default handler may catch it instead. The compiler will not always warn you, because both names are valid C functions. Betrayal, but technically legal.
2. Check peripheral clocks
If GPIO or SYSCFG clocks are disabled, writes to configuration registers may do nothing useful. Always enable peripheral clocks before configuring the peripheral.
3. Inspect EXTI registers
Look at the interrupt mask, event mask, rising trigger, falling trigger, and pending registers. Verify the exact line number. Remember that EXTI13 is line 13 regardless of whether the physical pin is PA13, PB13, or PC13; routing decides which port feeds that line.
4. Inspect NVIC state
Verify that the IRQ is enabled and that priority does not conflict with masking settings. If global interrupts are disabled, the cleanest EXTI setup in the world will sit there like a sports car without fuel.
5. Clear pending flags deliberately
Clear stale pending bits before enabling interrupts and clear handled flags inside the ISR. Do not clear unrelated bits casually. Shared IRQ handlers require manners.
Common Bare-Metal STM32 Interrupt Mistakes
The most common beginner mistake is enabling NVIC before the peripheral is fully configured. That allows an interrupt to occur before your system is ready. Another is forgetting to clear the EXTI pending bit, causing the handler to run repeatedly. A third is using the wrong IRQ number for shared EXTI lines. EXTI0 through EXTI4 may have individual IRQs on many STM32 families, while EXTI5 through EXTI9 and EXTI10 through EXTI15 are commonly grouped.
Developers also forget that EXTI line mapping is exclusive by line number. You cannot simultaneously map PA0 and PB0 to EXTI0 as separate external interrupts on the same EXTI controller. The line number is shared across ports. Choose the route intentionally.
Finally, there is the classic low-power mistake: expecting WFE to behave exactly like WFI. If you are waiting for an interrupt, WFI is usually clearer. If you are building an event-driven sleep design, WFE is powerful, but it asks you to understand the event register. Ignore it, and the CPU may pop awake like it remembered an embarrassing email from 2017.
Practical Pattern: Event Flag in ISR, Work in Main Loop
For many bare-metal STM32 applications, the best architecture is a small event-driven superloop. Interrupts collect facts. The main loop makes decisions. For example, a UART RX interrupt places received bytes into a ring buffer and sets a flag. A timer interrupt increments a system tick. An EXTI interrupt records that a button edge occurred. The main loop reads those flags and runs the application logic.
This pattern is simple, testable, and surprisingly capable. It also teaches an important embedded lesson: real-time firmware is often not about doing everything immediately. It is about doing the right thing at the right priority with predictable timing.
Why This Matters Beyond Button Blinking
Interrupt-event discipline becomes critical in serious STM32 projects. In a motor controller, incorrect interrupt priority can add jitter to a current-control loop. In a low-power sensor node, confusing an event with an interrupt can drain a battery. In a communication stack, a slow ISR can drop bytes. In a data logger, failing to clear a pending flag can create duplicate samples or lock the system in an interrupt storm.
That is why learning EXTI and NVIC at bare-metal level pays off even if you later use HAL, LL, Zephyr, FreeRTOS, or another framework. Abstractions are useful, but they are not magic. When something breaks, the bug eventually descends through the layers and lands on a register. You might as well know the neighborhood.
Field Notes: Real Experiences With STM32 Interrupt Events
One of the most memorable STM32 interrupt lessons comes from the “it fires immediately” bug. The setup looks perfect: GPIO configured, EXTI mapped, falling edge selected, NVIC enabled. Then the handler runs before anyone touches the button. The first instinct is to blame hardware, the board, the compiler, the moon phase, or the USB cable. The actual reason is often simple: the EXTI pending bit was already set. Maybe the pin changed during configuration. Maybe the button line floated briefly before the pull resistor was configured. Maybe a previous debug session left the system in a state that made the first edge appear earlier than expected. Clearing the pending flag before enabling NVIC usually solves it.
Another common experience is the “my button pressed itself ten times” problem. This usually teaches debounce more effectively than any textbook. A mechanical button can produce several transitions in a few milliseconds. If the firmware toggles an LED inside the ISR, the LED may appear random because every bounce toggles it again. Moving the real action out of the ISR and adding a debounce timer makes the system feel civilized. The button did not become better; the firmware became less gullible.
A third lesson appears when using WFE for low-power behavior. Developers may expect the CPU to sleep, but it returns immediately. This is often because the event register was already set. The familiar SEV-WFE-WFE sequence feels odd at first, but once you understand that the first WFE clears the stale event and the second WFE waits for a new one, it becomes a useful tool. It is like checking whether someone already rang the doorbell before you sit down to wait for the next visitor.
Shared EXTI handlers also create excellent character-building moments. Suppose PC13 uses EXTI13, which belongs to EXTI15_10_IRQHandler. Later, another signal is added on PB10. Suddenly, one handler must distinguish line 10 from line 13. If the ISR clears all pending bits without checking, one event can accidentally erase another. This is the embedded equivalent of answering every phone in the office and hanging them all up at once. The disciplined method is to test each pending bit, service only that source, and clear only the bit that was handled.
Interrupt priorities provide another lesson. A developer may assign “priority 10” to an important interrupt because ten sounds bigger than two. On Cortex-M, lower numerical values are higher urgency. That misunderstanding can invert the system’s timing behavior. The fix is not only changing the number; it is writing down a priority policy. For example, keep hard real-time timers at the highest urgency, communication receive paths in the middle, and human-interface events lower. Humans are slow. UART bytes are not. The firmware should know the difference.
Finally, there is the experience of discovering that an ISR should not do everything. Printing from inside an interrupt handler may work during a demo and collapse during real traffic. Delays inside an ISR may seem convenient until they block another time-sensitive interrupt. Calling complex application logic from an ISR may pass a quick test and then fail once two events happen close together. The mature pattern is to let the ISR capture the event, clear the hardware condition, and notify the main loop. That simple discipline prevents many bugs that otherwise look mystical.
In the end, bare-metal STM32 interrupt work teaches humility. The microcontroller is usually not wrong. The datasheet is usually not joking. The pending bit is usually trying to tell you something. Mind the interrupt event, and the firmware becomes cleaner, faster, and more predictable. Ignore it, and your board may still runbut it will run like a squirrel with a scheduler.
Conclusion
Bare-metal STM32 programming rewards developers who pay attention to details. Interrupts are not just function calls triggered by hardware. They are coordinated events involving GPIO configuration, EXTI routing, trigger selection, pending flags, NVIC enable bits, priority levels, and sometimes low-power CPU instructions. Understanding that chain helps you write firmware that responds quickly without becoming chaotic.
The golden rules are simple: configure before enabling, clear stale pending flags, keep ISRs short, use volatile for shared flags, respect NVIC priority rules, debounce real-world inputs, and choose WFI or WFE intentionally. Do those things, and STM32 interrupt programming becomes less mysterious and much more enjoyable. You may still debug at midnight, but at least you will know which register brought snacks.