Writing Your First ISR — Rules, Pitfalls, and the volatile Keyword


            

In Part 1 we established what interrupts are and why they exist. Now it is time to write actual interrupt service routines (ISRs) and learn the rules that keep them working correctly. This is where many beginners run into mysterious bugs — code that works fine until an interrupt fires, then behaves unpredictably. Almost always, those bugs come from breaking a small set of well-known rules. Learn these rules now and you will save yourself hours of debugging later.

The Golden Rules of ISR Writing

Before we look at any code, here are the rules every embedded developer must follow when writing an ISR. We will explain each one as we go.

  1. Keep ISRs as short and fast as possible.
  2. Never use blocking calls or delays inside an ISR.
  3. Always declare shared variables as volatile.
  4. Be careful with data types larger than the processor’s native word size.
  5. Do not call functions that are not interrupt-safe.
  6. Clear interrupt flags when required by the hardware.

Rule 1 and 2: Keep It Short. Never Block.

An ISR interrupts your main program. While the ISR is running, your main program is frozen. If your ISR takes a long time — say, 50 milliseconds — then your main program is stuck for 50 milliseconds every time that interrupt fires. On a system where many interrupts can fire, a slow ISR creates a cascade of problems: missed interrupts, timing errors, and unresponsive behavior.

The correct pattern is: do the absolute minimum inside the ISR, and defer real work to the main loop. The most common technique is to set a flag inside the ISR and check that flag in the main loop.

// WRONG: Doing too much work inside the ISR
void myISR()
{
    readSensor();        // might take milliseconds
    processSensorData(); // more time
    sendOverUART();      // could block waiting for TX buffer
}
// CORRECT: Set a flag, do the work in the main loop
volatile bool sensorReady = false;

void myISR()
{
    sensorReady = true; // fast, done in nanoseconds
}

void loop()
{
    if (sensorReady)
    {
        sensorReady = false;
        readSensor();
        processSensorData();
        sendOverUART();
    }
}

Never use delay(), HAL_Delay(), or any function that waits for time to pass inside an ISR. On most platforms these rely on timer interrupts or busy-loops that will either deadlock or produce completely wrong timing inside an ISR context.

Rule 3: The volatile Keyword — The Most Misunderstood Rule

This is the one that trips up almost every beginner, and quite a few experienced developers too. Let us understand it properly.

Modern C compilers are very good at optimising your code. One of their tricks is to look at a variable, notice that nothing in the current function changes it, and decide to cache its value in a CPU register instead of reading it from RAM every time it is needed. This is normally a great optimisation — it makes code faster.

But it breaks completely when an ISR can change that variable behind the compiler’s back. From the compiler’s perspective, the main loop is the only thing running. It cannot see that an ISR might fire at any moment and change a variable. So it happily caches the old value in a register and never re-reads it from memory — even after the ISR has changed it.

The volatile keyword tells the compiler: “Do not cache this variable. Every time you read it, go back to RAM and fetch the actual current value. Every time you write it, write it to RAM immediately.”

// WITHOUT volatile — compiler may cache this and never see the ISR's update
bool dataReady = false;

// WITH volatile — compiler always reads from RAM, sees the ISR's update
volatile bool dataReady = false;

The rule is simple: any variable that is written in an ISR and read in the main program (or vice versa) must be declared volatile.

Note that volatile does not make operations atomic (thread-safe). For variables larger than the processor’s native word size — like a 32-bit integer on an 8-bit AVR — you need additional protection. We cover that in Rule 4.

Rule 4: Be Careful With Multi-Byte Variables on 8-bit Processors

An 8-bit processor like the AVR in older Arduino boards can only read or write one byte at a time. A 16-bit or 32-bit variable takes multiple read/write operations. If an interrupt fires in the middle of those operations, you can get corrupted data — the high byte from before the interrupt and the low byte from after, or vice versa.

Consider a 16-bit counter updated in an ISR and read in the main loop on an AVR:

volatile uint16_t counter = 0;

ISR(TIMER1_OVF_vect)
{
    counter++;
}

void loop()
{
    // UNSAFE on 8-bit AVR — reading 16-bit value takes 2 instructions
    // ISR could fire between them
    uint16_t value = counter;

    // SAFE — disable interrupts while reading multi-byte variable
    noInterrupts(); // Arduino: disable global interrupts
    uint16_t value = counter;
    interrupts(); // re-enable
}

On 32-bit processors (STM32, ESP32, PIC32, MSP430 with 16-bit or 32-bit peripherals) this is less often a problem for native-width variables, but the habit of protecting shared data is still good practice.

Rule 5: Interrupt-Safe Functions Only

Many standard library functions are not safe to call from an ISR. Functions like printf, malloc, Serial.print() on Arduino, or anything that uses locks, buffers, or hardware peripherals in a non-reentrant way can cause deadlocks or data corruption when called from an ISR.

As a general rule: if you did not write the function yourself and you are not certain it is ISR-safe, do not call it from an ISR. Set a flag instead and call it from the main loop.

Rule 6: Clear Interrupt Flags

On many microcontrollers, when an interrupt fires, a hardware flag is set in a register. The processor uses this flag to know an interrupt is pending. On some platforms the flag is cleared automatically when the ISR runs. On others — particularly PIC microcontrollers and some STM32 peripherals — you must clear the flag manually inside your ISR, or the interrupt will fire again immediately after the ISR exits, in an infinite loop.

Always check your datasheet or HAL documentation to know whether your platform clears flags automatically.

Platform Examples: External Interrupt on a Pin

Let us now see how a simple external pin interrupt is set up across different platforms. The goal in each case is the same: detect a rising edge on a pin and set a flag.

Arduino (AVR — Uno, Nano, Mega)

Arduino makes external interrupts very straightforward. On the Uno and Nano, only pins 2 and 3 support external interrupts natively.

volatile bool buttonPressed = false;

void buttonISR()
{
    buttonPressed = true;
}

void setup()
{
    pinMode(2, INPUT_PULLUP);
    // Attach ISR to pin 2, trigger on FALLING edge (button pulls pin LOW)
    attachInterrupt(digitalPinToInterrupt(2), buttonISR, FALLING);
    Serial.begin(9600);
}

void loop()
{
    if (buttonPressed)
    {
        buttonPressed = false;
        Serial.println("Button was pressed!");
    }
}

The attachInterrupt() function hides all the register configuration. digitalPinToInterrupt() converts a pin number to the correct interrupt number for your board. Simple and clean for beginners.

STM32 (using STM32 HAL / CubeMX)

On STM32, any GPIO pin can be configured as an external interrupt through the EXTI controller. In CubeMX you configure the pin as GPIO_EXTI and set the trigger edge. The HAL generates a callback function you fill in.

// In main.c — HAL configures EXTI in MX_GPIO_Init(), generated by CubeMX
// You implement this callback — HAL calls it automatically from the EXTI IRQ handler

volatile uint8_t buttonPressed = 0;

void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    if (GPIO_Pin == BUTTON_Pin)
    {
        buttonPressed = 1;
    }
}

// In your main loop:
while (1)
{
    if (buttonPressed)
    {
        buttonPressed = 0;
        // Do something
    }
}

STM32 automatically handles clearing the EXTI pending flag inside the IRQ handler that HAL generates. The callback is a higher-level abstraction on top of that handler. The NVIC (Nested Vectored Interrupt Controller) must also have the interrupt enabled, which CubeMX does for you when you configure the pin.

ESP32 (Arduino framework)

The ESP32 supports interrupts on almost any GPIO pin. The syntax is similar to Arduino, but there is an important extra rule: ISR functions on ESP32 must be placed in RAM, not flash, because flash access can be suspended during certain operations (especially when Wi-Fi is active). You do this with the IRAM_ATTR attribute.

volatile bool buttonPressed = false;

// IRAM_ATTR forces this function into RAM for safe ISR execution
void IRAM_ATTR buttonISR()
{
    buttonPressed = true;
}

void setup()
{
    pinMode(4, INPUT_PULLUP);
    attachInterrupt(digitalPinToInterrupt(4), buttonISR, FALLING);
    Serial.begin(115200);
}

void loop()
{
    if (buttonPressed)
    {
        buttonPressed = false;
        Serial.println("Button pressed on ESP32!");
    }
}

Forgetting IRAM_ATTR on ESP32 will often work during testing but cause random crashes in production when the Wi-Fi stack suspends flash access. Always use it.

MSP430 (TI, using Energia or direct register access)

The MSP430 uses a port-based interrupt system. Entire GPIO ports can generate interrupts, and you check which pin caused it inside the ISR. The MSP430 is especially interesting because it is designed to spend most of its time sleeping and wake on interrupts — a pattern we will explore fully in Part 6.

#include <msp430.h>

volatile unsigned char buttonPressed = 0;

int main(void)
{
    WDTCTL = WDTPW | WDTHOLD; // Stop watchdog timer

    P1DIR &= ~BIT3; // P1.3 as input (button)
    P1REN |= BIT3;  // Enable pull-up/pull-down resistor
    P1OUT |= BIT3;  // Pull-up selected
    P1IES |= BIT3;  // Interrupt on high-to-low transition
    P1IFG &= ~BIT3; // Clear interrupt flag
    P1IE |= BIT3;   // Enable interrupt on P1.3

    __bis_SR_register(GIE); // Enable global interrupts

    while (1)
    {
        if (buttonPressed)
        {
            buttonPressed = 0;
            // Do something
        }
    }
}

// Port 1 interrupt service routine
#pragma vector = PORT1_VECTOR
__interrupt void Port1_ISR(void)
{
    if (P1IFG & BIT3)
    {
        buttonPressed = 1;
        P1IFG &= ~BIT3; // Must manually clear the flag!
    }
}

Notice the manual flag clear — P1IFG &= ~BIT3. On MSP430 you must always clear the port interrupt flag inside the ISR or it will re-trigger immediately.

Microchip PIC18 (XC8 compiler)

PIC18 microcontrollers have a two-priority interrupt system: high priority and low priority. The ISR structure is different from C-style callbacks — you define a single ISR and check flags inside it to determine what caused the interrupt.

#include <xc.h>

volatile unsigned char buttonPressed = 0;

void __interrupt() high_priority myHighISR(void)
{
    if (INT0IF)
    { // Check if INT0 (RB0) caused the interrupt
        buttonPressed = 1;
        INT0IF = 0; // Must clear the flag manually
    }
}

void main(void)
{
    TRISBbits.TRISB0 = 1; // RB0 as input
    INT0EDG = 0;          // Trigger on falling edge
    INT0IE = 1;           // Enable INT0 interrupt
    GIE = 1;              // Enable global interrupts

    while (1)
    {
        if (buttonPressed)
        {
            buttonPressed = 0;
            // Do something
        }
    }
}

PIC is very explicit about flag clearing. Forgetting INT0IF = 0 is one of the most common PIC beginner mistakes and results in the ISR firing in an infinite loop.

Summary: What You Have Learned

Across all these platforms the pattern is the same:

  • Configure the pin and trigger condition.
  • Enable the interrupt at the peripheral level and globally.
  • Write a short ISR that sets a flag and (on some platforms) clears the hardware flag.
  • Check and act on the flag in the main loop.
  • Declare all shared variables as volatile.

The differences between platforms are mostly in syntax and register names — the concepts are universal. In Part 3 we move to one of the most powerful and practical uses of interrupts: timer interrupts, which let you execute code at precise, hardware-driven intervals completely independently of your main loop.

What Are Interrupts and Why Do They Exist?

This is first part of an intended series about interrupts. If you are just starting out with microcontrollers or microprocessors, you have probably written a simple program that turns an LED on and off, reads a button, or sends some text over a serial port. These programs usually follow a straight line: do this, then […]

Comments are closed.