ESP32 Interrupts Complete Guide: attachInterrupt, ISR Rules and Debouncing
Prerequisite: in Your First Program we polled a button inside
loop(). Imagine cooking while checking the door for a delivery every 10 seconds — exhausting and slow. Interrupts are the doorbell: the event announces itself. This guide clears every interrupt trap in one pass.
1. Polling vs interrupts: why interrupts exist
| Aspect | Polling (read in loop) | Interrupt (event-driven) |
|---|---|---|
| Response latency | Depends on loop period | Microseconds |
| CPU cost | Constant | Nearly zero |
| During long tasks | Events get missed (a delay swallows the press) | Unaffected |
| Best for | Simple cases, steady states | Instant events (buttons, encoders, pulses) |
Classic case: a rotary encoder can emit hundreds of pulses per second — a loop with delay(100) will lose steps; only interrupts count accurately.
2. attachInterrupt in five minutes
#define BTN_PIN 4
volatile int pressCount = 0; // ⚠️ volatile: see §4
void IRAM_ATTR onButtonPress() { // ⚠️ IRAM_ATTR: see §4
pressCount++;
}
void setup() {
Serial.begin(115200);
pinMode(BTN_PIN, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(BTN_PIN), onButtonPress, FALLING);
}
void loop() {
Serial.printf("presses: %d\n", pressCount);
delay(500);
}
Trigger modes:
| Mode | Fires on | Typical use |
|---|---|---|
RISING | low→high edge | Detecting release |
FALLING | high→low edge | Press (active-low with INPUT_PULLUP) |
CHANGE | any edge | Encoder counting |
digitalPinToInterrupt(pin)is identity on most classic-ESP32 pins but is board-dependent elsewhere — always write it the canonical way.
3. The five ISR rules (break them and meet weird bugs)
An ISR (Interrupt Service Routine) runs in a special preempted context with strict discipline:
- Keep it tiny: set flags / bump counters; do heavy work in loop
- No
delay(): no scheduler to yield to — instant deadlock - Beware
Serial.print: blocking; high-frequency interrupts will freeze the system — debug-only, remove in production - No
malloc/new: heap operations aren’t reentrant - No shared-resource access: globals need
volatile, plus critical sections when necessary (see §5)
Canonical pattern: the ISR signals, loop works.
volatile bool buttonFlag = false;
void IRAM_ATTR onButtonPress() {
buttonFlag = true; // do only this
}
void loop() {
if (buttonFlag) {
buttonFlag = false;
handleButton(); // heavy work here: printing, network, state machines
}
}
4. What the two modifiers really mean
volatile
The compiler caches variables in registers when it believes nothing changed between reads. An interrupt changes them behind your back — volatile forces a real memory read every time.
The classic symptom of forgetting it: works in debug, breaks under optimization — the loop never sees the ISR’s update. Brutally hard to diagnose.
IRAM_ATTR
ESP32 code lives in external Flash, executed through a cache. Flash writes (NVS commits, OTA updates) temporarily disable that cache — if an interrupt fires then and its ISR lives in Flash, the chip crashes.
void IRAM_ATTR onButtonPress() { ... } // ISR runs from internal RAM, always executable
Iron rule: every ISR gets
IRAM_ATTR— and everything it calls must also be in IRAM, so call nothing fancy from ISRs.
5. Dual-core critical sections: portMUX
On the dual-core classic ESP32, two cores can touch the same variable simultaneously (an interrupt on Core0 vs your loop on Core1) — read-modify-write races corrupt data:
portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED;
volatile int counter = 0;
void IRAM_ATTR onPulse() {
portENTER_CRITICAL(&mux);
counter++;
portEXIT_CRITICAL(&mux);
}
void loop() {
portENTER_CRITICAL(&mux);
int local = counter;
portEXIT_CRITICAL(&mux);
// work with local
}
Single-core C3 technically can’t race itself, but the habit makes your code portable across every ESP32.
6. Debouncing: hardware RC + software timestamps
Mechanical contacts bounce 5–20ms — one press can trigger dozens of interrupts. Two defenses:
Option A: RC hardware filter (set-and-forget)
GPIO4 ──[button]──GND
GPIO4 ──[10kΩ pull-up to 3.3V] (optional with INPUT_PULLUP)
GPIO4 ──[100nF to GND] ← the filter cap
τ = 10kΩ × 100nF = 1ms — glitches are absorbed by the cap, leaving a clean edge worth exactly one interrupt. One capacitor, strongly recommended.
Option B: software timestamp debounce (zero parts)
volatile uint32_t lastIsrTime = 0;
void IRAM_ATTR onButtonPress() {
uint32_t now = millis();
if (now - lastIsrTime > 50) { // re-triggers within 50ms are bounce
pressCount++;
lastIsrTime = now;
}
}
Belt and braces: RC kills most glitches + the 50ms stamp catches the rest — industrial-grade reliability.
7. Pitfall cheat sheet (bookmark this)
| Symptom / scenario | Cause and fix |
|---|---|
| One press counts +N | No debounce (RC or 50ms timestamp) |
| Random reboots, no serial output | ISR missing IRAM_ATTR, fired during NVS/OTA |
| Loop never sees the update | Missing volatile (shows up with optimizations) |
| print in ISR freezes | Blocking serial — move to loop |
| Counter occasionally jumps | Dual-core race — portENTER_CRITICAL |
| Pin never fires | Check pinMode and mode (INPUT_PULLUP → FALLING) |
| Woken from sleep by the pin | The interrupt pin doubles as a deep-sleep wake source (see the upcoming deep-sleep article) |
8. Further reading
- 🧭 Previous: PWM & LEDC — the output side of “switch fast”
- ⏭️ Next: timers — periodic tasks without busy-looping (coming soon)
- 🔍 Tool: Pinout Explorer — verify interrupt-capable pins
License: CC BY-SA 4.0. Repost & remix with attribution: ESPDIY Maker Lab - espdiy.com