ESP32

Master ESP32 Interrupts: 5 Safe Atomic Tips for 2025

Unlock stable and reliable ESP32 projects in 2025. Learn 5 essential, safe atomic tips for mastering interrupts, from critical sections to FreeRTOS queues.

A

Alex Gutierrez

Embedded systems engineer with a passion for IoT and real-time operating systems.

7 min read20 views

Master ESP32 Interrupts: 5 Safe Atomic Tips for 2025

The ESP32 is a powerhouse. Its dual-core processor and rich set of peripherals let us build incredibly complex IoT devices, from smart home hubs to real-time sensor networks. But as our projects grow, we quickly hit a wall. How do you read a sensor, blink an LED, and listen for a button press all at the same time without your code turning into a tangled, unresponsive mess?

The answer is interrupts. Interrupts are the secret to responsive and efficient embedded systems. They allow your ESP32 to pause its current task, handle an urgent event—like a button press—and then seamlessly resume what it was doing.

But with great power comes great responsibility. When your main code and an Interrupt Service Routine (ISR) try to modify the same variable, you enter a danger zone filled with race conditions, data corruption, and phantom bugs that are impossible to trace. The key to taming this chaos is understanding and using atomic operations—actions that are guaranteed to complete without being interrupted.

Forget the mysterious crashes and unpredictable behavior. Let’s dive into five practical, safe, and atomic tips to make you an ESP32 interrupt master in 2025.

Tip 1: Always Use `volatile` for Shared Variables

This is the first rule of interrupt club. If you have a global variable that is modified within an ISR and read in your main `loop()`, you must declare it as `volatile`.

Why? The compiler is smart—sometimes too smart. To make your code faster, it aggressively optimizes. It might see your `loop()` constantly checking a variable and decide to cache that variable's value in a CPU register instead of re-reading it from memory every time.

Normally, this is great for performance. But if an ISR changes that variable's value in memory, your main loop, looking at its cached copy, will never see the update! The `volatile` keyword is a direct order to the compiler: "Hey, this variable can change unexpectedly. Don't make any assumptions. Every time I access it, read its value directly from memory."

// A flag to signal that the interrupt has occurred
volatile bool buttonPressedFlag = false;

// The Interrupt Service Routine (ISR)
// IRAM_ATTR ensures the ISR code is in fast internal RAM
void IRAM_ATTR handleButtonPress() {
  buttonPressedFlag = true;
}

void setup() {
  Serial.begin(115200);
  // Attach interrupt to GPIO 0, trigger on a RISING edge
  pinMode(0, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(0), handleButtonPress, RISING);
}

void loop() {
  if (buttonPressedFlag) {
    Serial.println("Button was pressed! Handling it now.");
    // Reset the flag after handling
    buttonPressedFlag = false; 
  }
  // ... your main code can continue doing other things ...
}

Without `volatile`, the `if (buttonPressedFlag)` condition in the `loop()` might never become true, even after the button is pressed.

Tip 2: Keep Your ISRs Short and Fast

An Interrupt Service Routine should be like a ninja: get in, do the job, and get out before anyone even knows you were there. While an ISR is running, most other interrupts are disabled. If your ISR takes too long, your ESP32 could miss other important events, like incoming data on a serial port or another button press.

A good ISR should do the absolute minimum required. The best practice is to set a flag, update a counter, or push data into a queue, and let the main `loop()` do the heavy lifting.

Advertisement

Things you should NEVER do inside an ISR:

  • `delay()`: This is a complete show-stopper. It will hang your system.
  • `Serial.print()`: Serial communication is slow and often relies on its own interrupts. Calling it from an ISR can cause deadlocks and crashes.
  • Anything that allocates memory: Functions like `String` manipulation or creating complex objects can fail in unpredictable ways.

Example: The Wrong Way vs. The Right Way

// --- BAD ISR --- (Too slow!)
void IRAM_ATTR handleEncoderTurn_BAD() {
  // This logic is too complex and slow for an ISR
  int reading = digitalRead(ENCODER_A);
  if (reading != lastState) {
    if (digitalRead(ENCODER_B) != reading) {
      encoderPos++;
    } else {
      encoderPos--;
    }
  }
  Serial.print("Position: "); // BIG NO! This will crash.
  Serial.println(encoderPos);
}

// --- GOOD ISR --- (Fast and lean)
volatile bool encoderActivity = false;

void IRAM_ATTR handleEncoderTurn_GOOD() {
  encoderActivity = true; // Just set a flag!
}

void loop() {
  if (encoderActivity) {
    // Now do the heavy processing in the main loop
    // ... read encoder pins and calculate position ...
    Serial.println("Encoder moved!"); 
    encoderActivity = false; // Reset for next time
  }
}

Tip 3: Use Critical Sections for True Atomicity

What if you need to modify a variable that's larger than a single byte, like a `long`, `float`, or a `struct`? An 8-bit microcontroller might read a 32-bit `long` in four separate instructions. An interrupt could fire right in the middle of that read, leading to a value that is a garbage mix of old and new data. This is a classic race condition.

The solution is a critical section. This is a block of code that you protect from being interrupted. On the ESP32, which uses the FreeRTOS operating system, we do this using a "spinlock" and a pair of macros.

First, define a `portMUX_TYPE` variable:

portMUX_TYPE myMux = portMUX_INITIALIZER_UNLOCKED;

Then, wrap your sensitive code with `portENTER_CRITICAL()` and `portEXIT_CRITICAL()`:

volatile unsigned long eventCounter = 0;

// ISR to increment the counter
void IRAM_ATTR onEvent() {
  portENTER_CRITICAL_ISR(&myMux);
  eventCounter++;
  portEXIT_CRITICAL_ISR(&myMux);
}

void loop() {
  unsigned long localCounter;

  // Enter a critical section to safely copy the value
  portENTER_CRITICAL(&myMux);
  localCounter = eventCounter;
  portEXIT_CRITICAL(&myMux);

  Serial.print("Events so far: ");
  Serial.println(localCounter);
  delay(1000);
}

By entering a critical section, you're telling the system: "Do not interrupt me until I'm done with this section." This guarantees that the read or write operation is atomic—it happens all at once from your code's perspective, even on the dual-core ESP32. Notice the use of `_ISR` versions inside the interrupt context. Keep critical sections as short as possible!

Tip 4: Embrace FreeRTOS Queues for Robust Communication

While critical sections are powerful, an even cleaner and more scalable approach is to avoid sharing variables directly. Instead, use a FreeRTOS queue to pass data from your ISR to your main task.

A queue is a thread-safe data structure. The ISR acts as a "producer," safely adding data to the end of the queue. Your main `loop()` (or a dedicated task) acts as a "consumer," pulling data from the front of the queue when it's ready.

This pattern beautifully decouples your interrupt logic from your main application logic, making your code easier to read, debug, and maintain.

#include <Arduino.h>

// A queue to hold integer values from the ISR
QueueHandle_t interruptQueue;

struct SensorData {
  int id;
  float value;
};

void IRAM_ATTR onSensorTrigger() {
  // Create a data packet to send
  SensorData data = {1, 123.45};
  
  // Send data to the queue from the ISR
  // The last parameter is for a higher priority task wakeup, not needed here
  xQueueSendFromISR(interruptQueue, &data, NULL);
}

void setup() {
  Serial.begin(115200);
  
  // Create a queue that can hold 10 SensorData structs
  interruptQueue = xQueueCreate(10, sizeof(SensorData));

  pinMode(2, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(2), onSensorTrigger, FALLING);
}

void loop() {
  SensorData receivedData;

  // Check if there is data in the queue. 0 means don't wait.
  if (xQueueReceive(interruptQueue, &receivedData, 0) == pdTRUE) {
    Serial.print("Received data from ISR! ID: ");
    Serial.print(receivedData.id);
    Serial.print(", Value: ");
    Serial.println(receivedData.value);
  }

  // Your main loop is not blocked and can do other things
}

Tip 5: Leverage Hardware Timers for Precision Tasks

Interrupts aren't just for external events like button presses. The ESP32 has built-in hardware timers that can trigger interrupts at precise, reliable intervals. This is far superior to using `delay()` (which blocks everything) or relying on `millis()` (which can drift and is less precise).

Using a timer interrupt is perfect for tasks like sampling a sensor at an exact frequency, updating a display periodically, or running a control loop in a robotics project.

// Pointer to the timer object
hw_timer_t *myTimer = NULL;

volatile int interruptCounter = 0;

void IRAM_ATTR onTimer() {
  // This ISR will be called every second
  interruptCounter++;
}

void setup() {
  Serial.begin(115200);

  // Use the first hardware timer (0) with a prescaler of 80
  // ESP32's clock is 80MHz, so 80 prescaler gives us 1 million ticks/sec
  myTimer = timerBegin(0, 80, true);
  
  // Attach our onTimer function to the timer's interrupt
  timerAttachInterrupt(myTimer, &onTimer, true);
  
  // Set the alarm to trigger every 1,000,000 ticks (1 second)
  // The 'true' argument means the timer will auto-reload
  timerAlarmWrite(myTimer, 1000000, true);
  
  // Start the timer
  timerAlarmEnable(myTimer);
}

void loop() {
  if (interruptCounter > 0) {
    portENTER_CRITICAL(&myMux); // Use a critical section to safely access the counter
    interruptCounter--;
    portEXIT_CRITICAL(&myMux);

    Serial.print("One second has passed! Toggling LED. Time: ");
    Serial.println(millis() / 1000);
  }
}

This setup guarantees your `onTimer` function is called with microsecond accuracy, independent of whatever else your `loop()` is doing.

Conclusion: Write Code That Just Works

Interrupts transform your ESP32 from a simple, sequential script-runner into a powerful, event-driven machine. By mastering these five atomic techniques, you're not just fixing bugs; you're fundamentally changing how you write embedded code.

  • Use `volatile` to ensure you're always seeing the latest data.
  • Keep ISRs lean to maintain system stability.
  • Protect multi-byte data with critical sections.
  • Decouple your code with FreeRTOS queues for scalability.
  • Use hardware timers for rock-solid periodic tasks.

Start applying these principles to your projects today. You'll spend less time hunting down mysterious bugs and more time building robust, reliable, and professional-grade devices. Happy coding!

Tags

You May Also Like