Chapter 09 · Arduino 入门

中断与定时

别再用 delay 卡死程序:用外部中断第一时间响应事件,用 millis() 让多件事同时进行。

课程进度75%

关键概念

中断 (Interrupt)
当某个事件发生时(如引脚电平跳变),CPU 立刻暂停当前正在跑的 loop,转去执行一段专门的处理函数,处理完再回到原处继续。好比你在看书(loop),门铃一响(中断)就放下书去开门(ISR),开完门回来接着看。它保证事件被即时、可靠地捕获,不会因为 loop 忙别的而漏掉。
ISR (中断服务函数)
Interrupt Service Routine。中断触发时自动调用的函数,你用 attachInterrupt 注册它。ISR 有严格规矩:要极短、极快,不能用 delay()、不能用 Serial.print()(它内部靠中断)、不能返回值也不能带参数。通常只在里面置个标志位或加个计数,真正的活儿留给 loop 干。
attachInterrupt()
注册外部中断的函数:attachInterrupt(digitalPinToInterrupt(pin), ISR函数, 模式)。Uno 只有 D2、D3 两个引脚支持外部中断。用 digitalPinToInterrupt(2) 把引脚号转成中断号,比直接写 0/1 更通用、可移植。
触发模式 (RISING/FALLING/CHANGE/LOW)
决定何时触发中断:RISING 上升沿(低→高)、FALLING 下降沿(高→低)、CHANGE 任意变化都触发、LOW 只要为低就持续触发。按键接 INPUT_PULLUP 时按下是高→低,常用 FALLING。
volatile 关键字
凡是既被 ISR 修改、又被 loop 读取的全局变量,必须声明为 volatile。它告诉编译器"这个变量随时可能被中断偷偷改掉,别做优化缓存,每次都从内存重新读"。漏写 volatile 会导致 loop 读到过期的旧值,是中断编程头号玄学 bug。
delay() 的阻塞本质
delay(1000) 会让 CPU 空转傻等 1 秒,这 1 秒内程序什么都做不了——不能读按键、不能刷屏、不能响应串口。小项目无所谓,但一旦要"同时"做多件事,delay 就成了绊脚石。中断不受 delay 影响(仍能触发),但 ISR 里也绝不能用 delay。
millis() 非阻塞定时
millis() 返回开机以来的毫秒数(unsigned long)。用"记下上次动作时间,每轮 loop 检查是否够间隔"的模式,就能不阻塞地定时——loop 飞速空转,时间一到才干活,其余时间还能干别的。这是 Arduino 多任务的基石,取代 delay。
millis() 溢出与定时器库
millis() 约 49.7 天后会溢出归零。用减法 now - last >= interval 判断能天然处理溢出(无符号减法环绕正确),别用 now >= last + interval。想要更精细的硬件定时(如精确 1kHz 中断),可用 TimerOneMsTimer2 等库直接操作芯片定时器。

delay 的问题:一个反例

下面这段"想让 LED 闪烁、同时又能立刻响应按键"的代码,用 delay 写就是失败的:

// ❌ 反例:delay 期间按键完全没反应
void loop() {
  digitalWrite(13, HIGH);
  delay(1000);   // 这 1 秒里 CPU 空转,读不了按键
  digitalWrite(13, LOW);
  delay(1000);   // 又是 1 秒发呆

  // 想在这儿读按键?对不起,大部分时间程序都卡在上面的 delay 里
  if (digitalRead(2) == LOW) { /* 极大概率错过按下的瞬间 */ }
}
delay 阻塞 vs millis 非阻塞的时间轴 delay 模式(CPU 大部分时间在发呆): ┃闪灯┃━━━━ 空转 1000ms ━━━━┃闪灯┃━━━━ 空转 1000ms ━━━━ ↑ 按键在这段按下 → 漏掉! millis 模式(loop 飞速空转,时间到才干活): ┃闪┃读键┃闪┃读键┃闪┃读键┃闪┃读键┃闪┃读键┃闪┃读键┃ 每轮都能读键 → 按键随时都能捕获 中断模式(硬件级,无视 loop 在干嘛): loop: ┃━━━━━忙自己的事━━━━━┃ 按键↓ ─────────┐ └→CPU 立刻跳去 ISR 处理,再回来

用 millis() 非阻塞闪烁

同一个"闪灯"需求,用 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 三大禁忌

别在 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;
  }
}
millis() 在 ISR 里不会增长,但能读

注意:进入 ISR 后中断被暂时关闭,millis() 的计数在 ISR 执行期间不再累加(它靠定时器中断增长)。所以 ISR 里读到的 millis 是进入那一刻的值——用来做消抖间隔判断没问题,但别指望在 ISR 里用它计时一段较长的延时。

delay vs millis vs 中断 怎么选

方式会阻塞吗响应速度适用场景难度
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 日常用、中断抢时效。

⚒️ 配套开发者工具箱 · 17 款免费在线工具