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

AspectPolling (read in loop)Interrupt (event-driven)
Response latencyDepends on loop periodMicroseconds
CPU costConstantNearly zero
During long tasksEvents get missed (a delay swallows the press)Unaffected
Best forSimple cases, steady statesInstant 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:

ModeFires onTypical use
RISINGlow→high edgeDetecting release
FALLINGhigh→low edgePress (active-low with INPUT_PULLUP)
CHANGEany edgeEncoder 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:

  1. Keep it tiny: set flags / bump counters; do heavy work in loop
  2. No delay(): no scheduler to yield to — instant deadlock
  3. Beware Serial.print: blocking; high-frequency interrupts will freeze the system — debug-only, remove in production
  4. No malloc/new: heap operations aren’t reentrant
  5. 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 / scenarioCause and fix
One press counts +NNo debounce (RC or 50ms timestamp)
Random reboots, no serial outputISR missing IRAM_ATTR, fired during NVS/OTA
Loop never sees the updateMissing volatile (shows up with optimizations)
print in ISR freezesBlocking serial — move to loop
Counter occasionally jumpsDual-core race — portENTER_CRITICAL
Pin never firesCheck pinMode and mode (INPUT_PULLUP → FALLING)
Woken from sleep by the pinThe interrupt pin doubles as a deep-sleep wake source (see the upcoming deep-sleep article)

8. Further reading


License: CC BY-SA 4.0. Repost & remix with attribution: ESPDIY Maker Lab - espdiy.com