中断与定时
别再用 delay 卡死程序:用外部中断第一时间响应事件,用 millis() 让多件事同时进行。
别再用 delay 卡死程序:用外部中断第一时间响应事件,用 millis() 让多件事同时进行。
attachInterrupt 注册它。ISR 有严格规矩:要极短、极快,不能用 delay()、不能用 Serial.print()(它内部靠中断)、不能返回值也不能带参数。通常只在里面置个标志位或加个计数,真正的活儿留给 loop 干。attachInterrupt(digitalPinToInterrupt(pin), ISR函数, 模式)。Uno 只有 D2、D3 两个引脚支持外部中断。用 digitalPinToInterrupt(2) 把引脚号转成中断号,比直接写 0/1 更通用、可移植。RISING 上升沿(低→高)、FALLING 下降沿(高→低)、CHANGE 任意变化都触发、LOW 只要为低就持续触发。按键接 INPUT_PULLUP 时按下是高→低,常用 FALLING。volatile。它告诉编译器"这个变量随时可能被中断偷偷改掉,别做优化缓存,每次都从内存重新读"。漏写 volatile 会导致 loop 读到过期的旧值,是中断编程头号玄学 bug。delay(1000) 会让 CPU 空转傻等 1 秒,这 1 秒内程序什么都做不了——不能读按键、不能刷屏、不能响应串口。小项目无所谓,但一旦要"同时"做多件事,delay 就成了绊脚石。中断不受 delay 影响(仍能触发),但 ISR 里也绝不能用 delay。millis() 返回开机以来的毫秒数(unsigned long)。用"记下上次动作时间,每轮 loop 检查是否够间隔"的模式,就能不阻塞地定时——loop 飞速空转,时间一到才干活,其余时间还能干别的。这是 Arduino 多任务的基石,取代 delay。now - last >= interval 判断能天然处理溢出(无符号减法环绕正确),别用 now >= last + interval。想要更精细的硬件定时(如精确 1kHz 中断),可用 TimerOne、MsTimer2 等库直接操作芯片定时器。下面这段"想让 LED 闪烁、同时又能立刻响应按键"的代码,用 delay 写就是失败的:
// ❌ 反例:delay 期间按键完全没反应 void loop() { digitalWrite(13, HIGH); delay(1000); // 这 1 秒里 CPU 空转,读不了按键 digitalWrite(13, LOW); delay(1000); // 又是 1 秒发呆 // 想在这儿读按键?对不起,大部分时间程序都卡在上面的 delay 里 if (digitalRead(2) == LOW) { /* 极大概率错过按下的瞬间 */ } }
同一个"闪灯"需求,用 millis() 改写后,loop 不再卡住,还能顺带干别的:
// ✅ millis 非阻塞:闪灯 + 同时能做别的事 const int ledPin = 13; unsigned long lastToggle = 0; // 上次翻转的时刻 const long interval = 1000; // 闪烁间隔 ms bool ledState = false; void setup() { pinMode(ledPin, OUTPUT); } void loop() { unsigned long now = millis(); // 够了一个间隔就翻转,否则直接掠过 if (now - lastToggle >= interval) { lastToggle = now; // 记下这次的时间 ledState = !ledState; digitalWrite(ledPin, ledState); } // 这里的代码每轮都会执行,不被"等待"阻塞 // 可以读按键、刷屏、跑别的定时任务…… }
给每个任务配一组自己的 lastXxx 变量和 intervalXxx,在 loop 里各自判断,就能让 LED 每 500ms 闪、传感器每 2s 读、屏幕每 100ms 刷——互不干扰、"看似并行"。这就是 Arduino 版的"协作式多任务",比 delay 强大得多。任务多了可封装成状态机或用 Scheduler/TaskScheduler 库管理。
把按键接 D2(Uno 中断脚),用 INPUT_PULLUP,下降沿触发。ISR 里只置标志,活儿丢给 loop:
// 按键中断计数:按一下 count 加一 const int buttonPin = 2; // 必须是 D2 或 D3 volatile int count = 0; // 被 ISR 改,必须 volatile volatile bool pressed = false; // ISR:又短又快,只置标志 void onButton() { count++; pressed = true; } void setup() { Serial.begin(9600); pinMode(buttonPin, INPUT_PULLUP); // 注册中断:引脚, ISR, 下降沿触发 attachInterrupt(digitalPinToInterrupt(buttonPin), onButton, FALLING); } void loop() { // 真正的活儿放这里,不要放 ISR 里 if (pressed) { pressed = false; Serial.print("按下次数: "); Serial.println(count); // Serial.print 只能在 loop 用 } }
① 别在 ISR 里用 delay()——中断期间其他中断被屏蔽,delay 靠 millis 计时也会失准;② 别在 ISR 里用 Serial.print()——它内部依赖中断,可能死锁;③ ISR 要尽量短,只置标志/计数,复杂逻辑丢给 loop。另外共享变量务必 volatile,多字节变量(如 long)在 loop 读取时还需短暂关中断以防读到"半新半旧"的值。
机械按键抖动会让一次按下触发好几次中断。在 ISR 里用 millis() 做时间间隔过滤:
volatile unsigned long lastISR = 0; volatile int count = 0; void onButton() { unsigned long now = millis(); // ISR 里读 millis 是安全的 // 距上次触发不足 50ms 视为抖动,忽略 if (now - lastISR > 50) { count++; lastISR = now; } }
注意:进入 ISR 后中断被暂时关闭,millis() 的计数在 ISR 执行期间不再累加(它靠定时器中断增长)。所以 ISR 里读到的 millis 是进入那一刻的值——用来做消抖间隔判断没问题,但别指望在 ISR 里用它计时一段较长的延时。
| 方式 | 会阻塞吗 | 响应速度 | 适用场景 | 难度 |
|---|---|---|---|---|
| delay() | 会,完全卡死 | 差 | 只做一件事的简单示例 | 最简单 |
| millis() | 不阻塞 | 取决于 loop 周期 | 同时跑多个定时任务 | 中等 |
| 外部中断 | 不阻塞 | 即时(微秒级) | 不能错过的瞬时事件(编码器、脉冲计数) | 较高 |
| 定时器库 | 不阻塞 | 精确周期 | 固定频率采样、PWM、精密计时 | 较高 |
新手最大的坏习惯是滥用 delay()——它让 CPU 空转傻等,期间读不了按键、刷不了屏,无法"同时"做多件事。替代方案是 millis() 非阻塞定时:记下上次动作时刻,每轮 loop 检查 now - last >= interval(用减法天然处理 49.7 天溢出),时间到才干活,其余时间还能干别的;给每个任务配独立的计时变量即可实现协作式多任务。对于"不能错过的瞬时事件",用外部中断 attachInterrupt(digitalPinToInterrupt(pin), ISR, 模式),Uno 仅 D2/D3 支持,模式选 RISING/FALLING/CHANGE/LOW。ISR 三铁律:极短极快、禁用 delay 和 Serial.print、只置标志位把活儿丢给 loop;凡是 ISR 与 loop 共享的变量必须加 volatile,否则会读到过期旧值。中断也要消抖,在 ISR 里用 millis 间隔过滤即可。需要精确固定频率时用 TimerOne/MsTimer2 等定时器库。记住这条主线:delay 教学用、millis 日常用、中断抢时效。