ESP32 中断完全入门:attachInterrupt、ISR 铁律与软硬件去抖实战

前置知识:第一个程序里我们用 loop() 反复读按键——这叫轮询。想象你正在炒菜,每 10 秒跑去门口看一次快递到了没:累且低效。中断就是门铃——快递到了它自己响,你随时能响应。本文把中断的所有坑一次讲透。


一、轮询 vs 中断:为什么要有中断

维度轮询(loop 里反复读)中断(事件触发)
响应速度取决于 loop 周期微秒级
CPU 占用持续消耗几乎为零
长任务阻塞时漏检(delay 期间按键被松开就丢了)不受影响
适用简单场景、状态持续量瞬时事件(按键、编码器、传感器脉冲)

典型案例:旋转编码器每秒可产生上百个脉冲,delay(100) 的轮询必然丢步——只有中断能精确计数。


二、attachInterrupt 五分钟上手

#define BTN_PIN 4

volatile int pressCount = 0;        // ⚠️ volatile 见第四节

void IRAM_ATTR onButtonPress() {    // ⚠️ IRAM_ATTR 见第四节
  pressCount++;
}

void setup() {
  Serial.begin(115200);
  pinMode(BTN_PIN, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(BTN_PIN), onButtonPress, FALLING);
}

void loop() {
  Serial.printf("按键次数: %d\n", pressCount);
  delay(500);
}

三种触发模式:

模式触发时机典型用途
RISING低→高的瞬间检测释放
FALLING高→低的瞬间按下(INPUT_PULLUP 低有效)
CHANGE任意跳变编码器计数

digitalPinToInterrupt(pin) 在经典 ESP32 上多数引脚就是本身,但在部分板卡/核心上存在映射,规范写法总是带上它。


三、ISR 五条铁律(违反必出诡异 Bug)

ISR(Interrupt Service Routine,中断服务函数)运行在「抢进来」的特殊上下文里,有严格纪律:

  1. 越短越好:只置标志位/累加变量,复杂处理放 loop
  2. 禁止 delay():中断里没有调度可让出,会锁死
  3. 慎用 Serial.print:串口是阻塞操作,高频中断下会卡死系统——调试期短用可以,量产代码移除
  4. 禁止 malloc/new:堆操作不可重入
  5. 不碰共享资源:读写全局变量必须配合 volatile,必要时上临界区(见第五节)

标准范式:ISR 只留信号,loop 做重活

volatile bool buttonFlag = false;

void IRAM_ATTR onButtonPress() {
  buttonFlag = true;               // 只做这一件事
}

void loop() {
  if (buttonFlag) {
    buttonFlag = false;
    handleButton();                // 重活在这里:打印、网络、状态机……
  }
}

四、两个关键修饰符的真实含义

volatile

编译器会优化:如果它认为某变量在两次读取之间没人改过,直接复用寄存器缓存。而中断会在你不知情时改写变量——volatile 告诉编译器「每次都去内存真读」。

忘写 volatile 的典型症状:单独调试正常,开了编译优化(release)后 loop 读不到中断里的更新——极难排查。

IRAM_ATTR

ESP32 的代码默认放在外部 Flash,通过 Cache 加载执行。擦写 Flash(写 NVS、OTA 更新)期间 Cache 会被临时禁用——此时若中断发生且 ISR 在 Flash 里,芯片直接崩溃重启。

void IRAM_ATTR onButtonPress() { ... }   // ISR 放进内部 RAM,永远可执行

铁律:所有 ISR 一律加 IRAM_ATTR,且 ISR 内调用的函数(含库函数)也需在 IRAM——用不到的库函数就别在 ISR 里调。


五、双核临界区:portMUX

经典 ESP32 双核上,两个核可能同时碰同一个变量(Core0 的中断 vs Core1 的 loop),读改写竞争会产生脏数据。保护武器:

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);
  // 用 local 干活
}

单核心 C3 理论上无并发竞争,但保持这个习惯可以让代码在任意 ESP32 间无缝移植。


六、去抖:硬件 RC 与软件时间戳双保险

机械触点闭合瞬间抖动 5~20ms,一次按下可能触发几十次中断。两套方案:

方案 A:硬件 RC 滤波(一劳永逸)

GPIO4 ──[按键]──GND
GPIO4 ──[10kΩ 上拉到 3.3V](INPUT_PULLUP 可省)
GPIO4 ──[100nF 到 GND]      ← 滤波电容

RC 时间常数 τ = 10kΩ × 100nF = 1ms,抖动毛刺被电容吸收,波形干净到中断只需数一次。成本一颗电容,强烈推荐。

方案 B:软件时间戳去抖(零器件)

volatile uint32_t lastIsrTime = 0;

void IRAM_ATTR onButtonPress() {
  uint32_t now = millis();
  if (now - lastIsrTime > 50) {   // 50ms 内的重复触发视为抖动
    pressCount++;
    lastIsrTime = now;
  }
}

双保险策略:硬件 RC 清掉大部分毛刺 + 软件 50ms 时间戳兜底——工业级可靠性。


七、避坑速查表(收藏级)

症状/场景根因与对策
一次按键计数 +N没去抖(硬件 RC 或软件 50ms 时间戳)
偶发崩溃重启,串口无输出ISR 没加 IRAM_ATTR,写 NVS/OTA 时触发
loop 读不到中断更新变量忘 volatile(开优化后必现)
ISR 里 print 死机串口阻塞,移到 loop 处理
中断计数偶发跳变双核竞争,加 portENTER_CRITICAL
引脚不触发中断检查 pinMode 与模式(INPUT_PULLUP 是 FALLING)
想睡眠时被唤醒中断引脚同时是深睡唤醒源(RTC GPIO),见后续深睡文章

八、延伸阅读

  • 🧭 前作:PWM 与 LEDC——输出侧的「快速开关」
  • ⏭️ 下一篇:定时器——周期性任务不用 loop 空转(即将上线)
  • 🔍 工具:Pinout 交互式引脚查询器——确认中断引脚能力

版权声明:本文采用 CC BY-SA 4.0 协议授权。转载与二创请注明出处:ESPDIY 创客工坊 - espdiy.com

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

  • 🧭 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