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,中断服务函数)运行在「抢进来」的特殊上下文里,有严格纪律:
- 越短越好:只置标志位/累加变量,复杂处理放 loop
- 禁止
delay():中断里没有调度可让出,会锁死 - 慎用
Serial.print:串口是阻塞操作,高频中断下会卡死系统——调试期短用可以,量产代码移除 - 禁止
malloc/new:堆操作不可重入 - 不碰共享资源:读写全局变量必须配合
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
| 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