Embedded Systems

ESP32 Atomics in ISRs: 5 Rules for 2025 Safety Guide

Tired of random crashes in your ESP32 projects? Master concurrent programming with our 2025 guide to using atomics safely in Interrupt Service Routines (ISRs).

D

David Sterling

An embedded systems architect specializing in real-time operating systems and concurrent programming.

7 min read25 views

Your ESP32 project is humming along. The sensors are reading, the Wi-Fi is connected, the display is updating. And then... it isn't. You're hit with a random reboot, a Guru Meditation Error, or data that's just plain wrong. You spend hours debugging, but the bug is a ghost—it disappears the moment you add a printf. Sound familiar? There's a good chance you're dealing with a race condition, a nasty bug that loves to hide in the space between your main loop and your Interrupt Service Routines (ISRs).

ISRs are the lifeblood of responsive embedded systems. They let your ESP32 react to the real world in real-time, like a button press or a data-ready signal from a sensor. But when an ISR needs to share data with the rest of your code, chaos can ensue. The main loop might be halfway through reading a variable when—BAM!—the ISR interrupts it and changes the value underneath. This is where C++ atomics come in. They are your primary tool for building a digital peace treaty between your ISRs and your main code, ensuring data is shared safely and predictably. But like any powerful tool, you need to use them correctly.

Rule 1: Understand Why You Need Atomics (It's All About the Race)

Before we dive into the 'how', let's solidify the 'why'. A race condition occurs when two or more threads (or in our case, the main loop and an ISR) access shared data, and at least one of them modifies it. The outcome depends on the precise sequence of operations, which is often unpredictable.

Consider a simple pulse counter. An ISR increments a counter every time a GPIO pin goes high, and the main loop reads it periodically.

The Broken Way: Using a Plain `int`

// DANGER: This code contains a race condition!
volatile int pulse_count = 0;

void IRAM_ATTR pulse_isr() {
    pulse_count++; // This is not atomic!
}

void app_main() {
    // ... setup GPIO and interrupt ...

    while (1) {
        int current_count = pulse_count;
        printf("Pulses: %d\n", current_count);
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

Why is pulse_count++ a problem? On a 32-bit processor like the ESP32, this single line of C++ code is not a single machine instruction. It's actually three steps:

  1. Read the current value of pulse_count from memory into a CPU register.
  2. Modify the value in the register (increment it).
  3. Write the new value from the register back to memory.

Now, imagine the main loop reads the value between steps 1 and 3. Or worse, two interrupts happen so quickly that the first one's write operation is overwritten by the second. The result? Lost pulses and incorrect data.

The Safe Way: Using `std::atomic`

Atomics solve this by guaranteeing that the read-modify-write operation is indivisible. From the perspective of any other part of your program, it happens all at once or not at all.

Advertisement
#include <atomic>

// SAFE: This operation is now atomic.
std::atomic<int> atomic_pulse_count(0);

void IRAM_ATTR pulse_isr() {
    atomic_pulse_count++; // or atomic_pulse_count.fetch_add(1);
}

void app_main() {
    // ... setup ...
    while (1) {
        int current_count = atomic_pulse_count.load();
        printf("Pulses: %d\n", current_count);
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

By simply changing int to std::atomic<int>, we've eliminated the race condition. The hardware now uses special instructions to ensure that the increment operation is never torn apart by an interruption.

Rule 2: Choose the Right Atomic Type for the Job

C++ offers several atomic types, and picking the right one is key. While std::atomic<int> is a great general-purpose tool, sometimes a more specialized type is better.

Atomic Type Primary Use Case Key Feature
std::atomic_flag Simple binary flag (busy/free, set/not set) The only type guaranteed to be lock-free on all platforms. It's simple but very fast. It can only be cleared or set-and-tested.
std::atomic<bool> A boolean flag that can be freely read, written, and exchanged. More flexible than atomic_flag. Perfect for an ISR setting a "data ready" flag that the main loop checks and clears.
std::atomic<T> (e.g., `int`, `uint32_t`) Counters, accumulators, or storing small integer-based state. Provides a rich set of atomic operations like fetch_add, fetch_sub, exchange, and compare_exchange_strong.
std::atomic<T*> Atomically swapping pointers, often used in lock-free data structures like queues. Essential for more advanced scenarios, like passing ownership of a data buffer from an ISR to a processing task.

For most ISR-to-main-loop communication, std::atomic<bool> (for flags) and std::atomic<int> (for counters/values) will be your go-to choices.

Rule 3: Master Memory Ordering (The Secret Sauce)

This is where things get advanced, but it's crucial for performance and correctness. Memory ordering tells the compiler and CPU how to synchronize memory access around the atomic operation. It prevents the reordering of instructions in a way that would break your logic.

The most common and useful model for ISRs is the Acquire-Release model.

  • Producer (The ISR): When the ISR produces data, it uses memory_order_release. This ensures that all memory writes before the atomic operation are completed before the atomic write itself. It's like saying, "Make sure the new data is fully written before you flip this 'data ready' flag."
  • Consumer (The Main Loop): When the main loop consumes the data, it uses memory_order_acquire. This ensures that all memory reads after the atomic operation happen after the atomic read. It's like saying, "Once I see this 'data ready' flag, I am guaranteed to see the new data that was written before it."

Acquire-Release in Action

#include <atomic>

char data_buffer[64];
std::atomic<bool> data_ready(false);

void IRAM_ATTR sensor_isr() {
    // 1. Fill the buffer with new data from a sensor
    read_sensor_into(data_buffer);

    // 2. Set the flag with RELEASE ordering
    // This ensures data_buffer is fully updated before data_ready becomes true.
    data_ready.store(true, std::memory_order_release);
}

void app_main() {
    // ... setup ...
    while (1) {
        // 3. Check the flag with ACQUIRE ordering
        if (data_ready.load(std::memory_order_acquire)) {
            // If true, we are guaranteed to see the new contents of data_buffer
            process_data(data_buffer);

            // 4. Clear the flag for the next round
            data_ready.store(false);
        }
        // ... do other work ...
    }
}

Using the default, std::memory_order_seq_cst (Sequentially Consistent), is always safe but can be slightly less performant as it provides stronger guarantees than you often need. For ISRs, explicitly using Acquire-Release makes your intent clear and can yield better performance.

Rule 4: Keep Your ISRs Lean, Mean, and Atomic-Focused

An ISR is borrowed time. It steals CPU cycles from your main tasks. Your goal should be to get in, do the bare minimum, and get out. Atomics are fast, but they are not free. More importantly, the code around the atomic operation matters.

  • DO: Set a flag, increment a counter, copy a few bytes into a buffer.
  • DON'T: Call printf, perform complex calculations, access peripherals (like I2C/SPI), or wait for anything.

An ISR's job is to capture an event and signal to the main code that something happened. The main code, running in a regular task, can then do the heavy lifting.

Example: Bad vs. Good ISR Design

// BAD: Doing too much work in the ISR
void IRAM_ATTR bad_isr() {
    int value = read_adc(); // Slow!
    float voltage = (value / 4095.0) * 3.3; // Floating point math!
    printf("Voltage: %.2fV\n", voltage); // Blocking call! Catastrophic in an ISR.
}

// GOOD: Deferring work to the main loop
std::atomic<bool> new_reading_available(false);

void IRAM_ATTR good_isr() {
    // Just set a flag. That's it.
    new_reading_available.store(true, std::memory_order_release);
}

void processing_task(void* pvParameters) {
    while(1) {
        if (new_reading_available.exchange(false, std::memory_order_acquire)) {
            // Now we do the heavy work in a normal task context
            int value = read_adc();
            float voltage = (value / 4095.0) * 3.3;
            printf("Voltage: %.2fV\n", voltage);
        }
        vTaskDelay(pdMS_TO_TICKS(10)); // Yield to other tasks
    }
}

Rule 5: Distrust `volatile` — It's Not Your Friend Here

This is one of the most persistent myths in embedded C/C++. Many developers believe volatile is the key to safe ISR communication. It is not.

volatile has one job: it tells the compiler not to optimize away reads and writes to a variable. It guarantees that every time you access the variable in your code, it will generate a load or store instruction. It makes no guarantees about atomicity.

Feature volatile int x; std::atomic<int> x;
Prevents compiler optimization Yes Yes
Guarantees atomicity (indivisible operations) No. x++ is still read-modify-write. Yes. x++ is a single, uninterruptible operation.
Provides memory ordering control No. The compiler/CPU can still reorder other memory accesses around it. Yes, via memory_order parameters.

Using volatile is like locking your front door but leaving all the windows wide open. For true thread and ISR safety, you need the guarantees that only std::atomic can provide.


Conclusion: Build with Confidence

Interrupts are a non-negotiable part of embedded programming, and sharing data is often a necessity. By embracing C++ atomics and following these five rules, you can move from a world of unpredictable, heisenbug-filled projects to one of robust, reliable, and maintainable firmware.

  1. Acknowledge the Race: Use atomics whenever an ISR and another context share modifiable data.
  2. Select the Right Tool: Choose the atomic type that fits your data (`bool`, `int`, etc.).
  3. Order Your Memory: Use the Acquire-Release model for efficient and correct producer-consumer synchronization.
  4. Keep ISRs Tiny: An ISR's job is to signal, not to process.
  5. Forget `volatile` for Concurrency: It doesn't provide the safety you think it does. Trust `std::atomic`.

Next time you're designing an ESP32 project, make atomics a first-class citizen in your architecture. Your future self—the one not debugging a ghost at 2 AM—will thank you.

Tags

You May Also Like