Sharing data with an ISR
The interrupt fires, the flag is set, and the main loop never notices. One keyword fixes it, and understanding why is the difference between fixing this bug and moving it.
The bug is in the compiler's favour
The code is obviously correct. The ISR writes the flag, the loop reads it. What the compiler sees is a loop that reads a variable nothing in the loop writes — so it reads it once, keeps it in a register, and spins on the register forever.
That is not a bug in the compiler. It is a correct optimisation of a program that never told it the value could change from outside.
volatile means "load it every time"
One keyword, one extra memory load per pass, and the optimisation is off. Put it on anything an ISR writes and the main code reads, in either direction.
What it does not do is make the access atomic. A 32-bit read on a 32-bit chip
happens in one instruction and is safe by accident; a uint64_t, a struct, or a
pair of variables that have to agree with each other is not. For those, take the
critical section — it disables interrupts on that core for the few instructions
in between, and it must be as short as you can make it.
What an ISR may do
Almost nothing, and less than you would guess:
- Set a flag, increment a counter, read a pin, write a pin.
- Not
Serial.print, notdelay, notString, notnew, not anything that waits on a lock. - Not anything that touches flash, unless the handler itself lives in RAM —
which is what
IRAM_ATTRis for.
The pattern that survives all of this is the one above: the interrupt records that something happened, and the loop decides what it means.
The code
A button on GPIO 12, an ISR that records the press, and a loop that reports it. Everything shared between the two is volatile, and the counter is also atomic.
volatile bool pressed = false;
volatile uint32_t lastUs = 0;
portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED;
void IRAM_ATTR onPress() {
uint32_t now = micros();
if (now - lastUs < 40000) return; // 40 ms of debounce
portENTER_CRITICAL_ISR(&mux);
lastUs = now;
pressed = true;
portEXIT_CRITICAL_ISR(&mux);
}
void setup() {
Serial.begin(115200);
pinMode(12, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(12), onPress, FALLING);
}
void loop() {
if (!pressed) return;
portENTER_CRITICAL(&mux);
pressed = false;
portEXIT_CRITICAL(&mux);
Serial.println("button"); // printing happens here, not in the ISR
}IRAM_ATTR puts the handler in RAM instead of flash. Without it the ISR can fire while flash is busy — during an OTA write, for instance — and the board crashes with a cache error rather than a stack trace.
MicroPython has no volatile keyword because it has no optimiser to defeat — but it has the other half of the problem, which is that an ISR must not allocate memory.
from machine import Pin
import micropython, time
micropython.alloc_emergency_exception_buf(128)
state = {"pressed": False, "last": 0}
def report(_):
print("button")
def on_press(pin):
now = time.ticks_ms()
if time.ticks_diff(now, state["last"]) < 40:
return
state["last"] = now
micropython.schedule(report, None)
Pin(12, Pin.IN, Pin.PULL_UP).irq(trigger=Pin.IRQ_FALLING, handler=on_press)micropython.schedule() is the escape hatch: it asks the runtime to call a normal function at the next safe moment, so the printing and the allocating happen outside interrupt context.
When it does not work
The variable is not volatile. The compiler proved that nothing inside loop() writes it, cached it in a register, and the ISR's write to memory is now invisible. Nothing errors and nothing times out — it simply never fires.
Switch bounce. A mechanical contact makes and breaks several times in the first few milliseconds, and an interrupt is fast enough to see every one. The micros() guard above is the cheapest fix; a capacitor across the switch is the other one.
Something in the ISR is not allowed there. Serial.print, delay, anything that takes a mutex, and any call that touches flash can all fault. Set a flag and do the work in loop.
The loop read it halfway through the ISR's write. Volatile stops the caching, it does not make the access atomic — wrap both sides in a critical section, or use one of the atomic types.
The other source of interrupts, and the one that fires whether or not anything is connected. Same rules about what an ISR may touch.
Hardware timers →Edit this page — content/esp32/sharing-data-with-an-isr.mdx
Discuss this article
Ask about this page. The answer stays here, on the page it belongs to, for whoever hits the same wall next.