Hello! Welcome back to our embedded systems course.
In our last lesson, you successfully configured an external interrupt (EXTI) for a button press. You learned the five essential register-level steps to make the hardware react to an external event, and you encountered the most important rule of interrupt handling on the STM32: always clear the pending flag.
Today, we're going to dive deep into the heart of the interrupt mechanism: the Interrupt Service Routine (ISR) itself. Our learning outcome is to write a safe and efficient ISR, including clearing pending flags. We'll move beyond just making it work and focus on making it professional. This means writing code that is reliable, fast, and won't introduce subtle, hard-to-debug problems into your system—a non-negotiable skill for any firmware engineer, especially in safety-critical automotive applications.
1. The Anatomy of an Interrupt Handler
When an interrupt fires, the CPU automatically pauses its current task, saves its state, and jumps to a specific function—the ISR. For our button on pin PB1, this function is EXTI1_IRQHandler. But how does this "magic" jump happen, and what does the CPU do behind the scenes?
To solidify this mental model, let's watch a detailed breakdown of the interrupt process.
Microcontroller Interrupts | Embedded System Project Series #17
This segment from the Artful Bytes channel provides an excellent animation of the entire interrupt sequence, from the hardware signal to the execution of your ISR code. It clearly explains the role of the Interrupt Vector Table.
Watch the video from 09:47 to 14:38. Focus on these key steps: Saving State: The CPU automatically saves the program counter and other core registers onto the stack. The Interrupt Vector Table: The CPU uses this table in flash memory to find the address of the correct ISR to execute. Executing the ISR: The CPU jumps to your EXTI1_IRQHandler function. Restoring State: After the ISR completes, the CPU restores the saved registers from the stack and resumes the main program exactly where it left off.
This automatic context switching is what makes interrupts so powerful, but it also comes with a small-time cost, which leads us to the single most important principle of ISR design.
2. The Golden Rule: Keep Your ISRs Short and Fast
An ISR is an interruption. By definition, it stops your main program from running. Therefore, an ISR should do the absolute minimum work necessary and return control to the main program as quickly as possible.
A great way to think about this is the "jigsaw puzzle" analogy.
Introduction to Microcontrollers - Interrupts
This article from EmbeddedRelated.com offers some of the best high-level advice on writing ISRs. We'll focus on a particularly effective analogy.
Read the short section titled "What Should An ISR Do?". The analogy of answering the doorbell while carving an ice sculpture perfectly captures the philosophy of ISR design.
In this analogy:
- Carving the sculpture is your main
while(1)loop. - The doorbell ringing is the interrupt request from the button.
- Answering the door is the ISR.
The correct action is to take the delivery (i.e., set a flag or capture a value), put it aside, and immediately return to your time-critical work before the ice melts. You don't assemble the 2000-piece puzzle at the door.
For an embedded system, especially in an automotive context, "long" ISRs can cause missed deadlines for other critical events, like reading another sensor or responding to a CAN bus message.
This diagram summarizes the best practices that stem from this philosophy.
3. Writing a "Safe" ISR
A "safe" ISR is one that is reliable and doesn't cause the system to crash or get stuck. There are two primary components to ensuring safety in our context.
The Mandatory Step: Clearing the Pending Flag
We covered this in the last lesson, but it is so critical that we must reinforce it. The hardware sets a "pending" bit in the EXTI->PR register to signal an interrupt. Your ISR must clear this bit by writing a '1' to it.
If you fail to clear the flag, the interrupt will trigger again immediately upon exiting the ISR, locking your CPU in an infinite interrupt loop.
Let's see a real-world demonstration of this exact bug and how to fix it.
Microcontroller Interrupts | Embedded System Project Series #17
In this clip, the author of the Artful Bytes video intentionally demonstrates what happens when the interrupt flag isn't cleared. This is one of the most common bugs for beginners, and seeing it live is an invaluable lesson.
Watch from 21:46 to 25:16. The microcontroller used is different (MSP430), but the principle is identical to STM32. Observe how the program gets stuck, re-entering the ISR endlessly, until the line of code to clear the flag is added.
The code to do this on the STM32F103 for EXTI line 1 is:
// Inside your EXTI1_IRQHandler
EXTI->PR |= (1 << 1); // Clear pending bit for line 1 by writing '1'
Handling Shared Data: The volatile Keyword
The most common ISR pattern is to set a global flag variable inside the ISR, which is then checked by the main loop.
// ISR
void EXTI1_IRQHandler(void) {
g_button_pressed = 1;
//... clear pending flag
}
// main loop
while (1) {
if (g_button_pressed == 1) {
// handle the button press...
}
}
However, a smart compiler might look at the while(1) loop and think: "The variable g_button_pressed is never changed inside this loop. I can optimize my code by assuming its value is always what it started as." This optimization would break our program, as the if condition would never be correctly evaluated.
To prevent this, we must declare any variable shared between an ISR and the main code as volatile.
volatile uint8_t g_button_pressed = 0;
This keyword tells the compiler: "The value of this variable can change at any time, in ways you cannot predict. Do not apply any optimizations to it. Every time the code reads this variable, fetch its value directly from memory."
4. Writing an "Efficient" ISR
Keeping an ISR short is the main path to efficiency. But it's also useful to understand the hidden costs. Before a single line of your C code runs, the CPU hardware saves core registers. If your ISR uses local variables, the compiler adds extra instructions to save and restore even more registers.
Microcontroller Interrupts | Embedded System Project Series #17
This final clip from Artful Bytes uses the debugger's disassembly view to show you the assembly code generated by the compiler for a simple vs. a more complex ISR. This makes the 'cost' of a complex ISR tangible.
Watch from 28:42 to 30:43. Notice how adding local variables to the function causes the compiler to insert push and pop instructions to save and restore registers on the stack. This is the overhead we want to minimize.
This overhead is another strong argument for the "set a flag and exit" strategy. A simple ISR that only writes to a global variable has minimal context-switching overhead, making it extremely fast and efficient.
5. Your Turn: Implementing a Professional ISR Pattern
Let's refactor the code from the previous lesson to use this safe and efficient pattern. You will modify your project so the ISR only sets a flag, and the main loop acts on it.
-
Declare a Global Flag: At the top of your
main.c, outside of any function, declare avolatileglobal variable.volatile uint8_t g_button_interrupt_flag = 0; -
Modify Your ISR: Update your
EXTI1_IRQHandlerto perform only two actions: set the flag and clear the pending bit.void EXTI1_IRQHandler(void) { // Check if the interrupt is from the correct line (this is good practice) if (EXTI->PR & (1 << 1)) { // 1. Do the minimal work: set the global flag g_button_interrupt_flag = 1; // 2. CRITICAL: Clear the pending register flag EXTI->PR |= (1 << 1); } } -
Modify Your Main Loop: Update your
while(1)loop to check for the flag. When the flag is set, it should perform the action (toggle the LED) and then reset the flag.int main(void) { // ... all your peripheral configuration calls ... while (1) { // Check if the interrupt has occurred if (g_button_interrupt_flag == 1) { // IMPORTANT: Immediately clear the flag to be ready for the next event g_button_interrupt_flag = 0; // Now, perform the "heavy" work here in the main loop // For us, that's just toggling the LED on PC13 GPIOC->ODR ^= (1 << 13); // Note: We still haven't addressed debouncing. // If you press the button, you might see the LED flicker. // We will solve this in the next lesson! } // The CPU is now free to do other tasks here, // or we could put it into a low-power sleep mode. } }
Compile and flash this new code. The external behavior should be the same as before, but now your firmware architecture is much more robust, efficient, and professional.
Conclusion
You have now mastered the principles behind writing professional-grade Interrupt Service Routines. This is a significant step up from simply making an interrupt work. You've learned not just what to do, but why you're doing it.
Key Takeaways:
- An ISR's primary goal is to be short and fast to minimize disruption to the main program. The "jigsaw puzzle" analogy is a helpful guide.
- The single most critical action within an STM32 EXTI ISR is clearing the pending flag in the
EXTI->PRregister to prevent the system from locking up. - Any variable shared between an ISR and other code must be declared
volatileto prevent incorrect compiler optimizations. - The most robust pattern is to set a flag in the ISR and process the event in the main loop. This decouples event detection from event handling.
Preview of the Next Lesson:
We now have all the necessary components in our toolbox: GPIO input configuration, a software debouncing algorithm (from Module 2, Lesson 4), and now a professional interrupt handling structure. In the next lesson, we will put it all together to build a project that controls an LED's state using a fully debounced button press, triggered by an interrupt. This will be the capstone project for this module.