中断与定时器¶
中断让 Arduino 能够即时响应外部事件,定时器则提供精确的时间控制。它们是实现实时响应和精确定时的核心机制。
一、中断概述¶
什么是中断?¶
中断是一种==硬件机制==,当特定事件发生时,CPU 暂停当前程序,立即跳转执行中断服务函数(ISR),完成后返回原位置继续执行。
graph LR
A[主循环 loop] -->|中断触发| B[保存现场]
B --> C[执行 ISR]
C --> D[恢复现场]
D --> A
中断类型¶
| 类型 | 来源 | Arduino 支持 |
|---|---|---|
| 外部中断 | 引脚电平变化 | attachInterrupt() |
| 定时器中断 | 定时器溢出/比较匹配 | 需配置寄存器或库 |
| 串口中断 | 数据收发 | Arduino 框架内部使用 |
| ADC 中断 | 转换完成 | 一般不需要手动操作 |
| 看门狗中断 | WDT 超时 | avr/wdt.h |
二、外部中断¶
可用中断引脚¶
| 开发板 | 中断号 0 | 中断号 1 | 更多 |
|---|---|---|---|
| UNO / Nano | D2 | D3 | 仅 2 个 |
| Mega | D2 | D3 | D18, D19, D20, D21(共 6 个) |
| Leonardo | D3 | D2 | D0, D1, D7 |
| Due | 所有数字引脚 | — | — |
attachInterrupt() 使用¶
| 触发模式 | 说明 |
|---|---|
LOW |
低电平时持续触发 |
CHANGE |
电平变化时触发(上升沿+下降沿) |
RISING |
上升沿触发(LOW → HIGH) |
FALLING |
下降沿触发(HIGH → LOW) |
示例:按键中断计数¶
volatile int count = 0; // volatile 修饰!
volatile bool flag = false;
void setup() {
Serial.begin(115200);
pinMode(2, INPUT_PULLUP);
// D2 上升沿/下降沿中断
attachInterrupt(digitalPinToInterrupt(2), buttonISR, FALLING);
}
void loop() {
if (flag) {
flag = false;
Serial.print("按键次数: ");
Serial.println(count);
}
}
// 中断服务函数 —— 越短越好!
void buttonISR() {
count++;
flag = true;
}
ISR 编写规则
- 必须使用
volatile:ISR 中修改的变量必须用volatile修饰,防止编译器优化 - 越短越好:ISR 中不要放耗时操作(如
Serial.print、delay) - 不要用
delay():delay()依赖中断,而 ISR 内中断被禁止 - 不要用
millis():ISR 内millis()不会更新(Timer0 中断被屏蔽) - 共享变量用
volatile:确保主循环每次都从内存读取最新值
中断内去抖¶
volatile unsigned long lastInterruptTime = 0;
void buttonISR() {
unsigned long now = micros(); // micros() 在 ISR 中不更新,但可读取
if (now - lastInterruptTime > 200000) { // 200ms 去抖
count++;
flag = true;
}
lastInterruptTime = now;
}
临时禁用/启用中断¶
noInterrupts(); // 禁用所有中断(关中断)
// ... 临界区代码(操作共享变量)...
interrupts(); // 重新启用中断
// 或禁用/恢复单个中断
detachInterrupt(digitalPinToInterrupt(2)); // 移除 D2 的中断
attachInterrupt(digitalPinToInterrupt(2), buttonISR, FALLING); // 重新绑定
关中断的典型场景
读取 ISR 中更新的多字节变量(如 long 或 float)时,需要关中断防止读取到不一致的数据:
三、引脚变化中断(PCINT)¶
UNO 只有 2 个外部中断引脚(D2、D3),但所有数字引脚都支持==引脚变化中断(Pin Change Interrupt)==:
| 中断组 | 引脚 | 中断向量 |
|---|---|---|
| PCINT0 | D8~D13 | PCINT0_vect |
| PCINT1 | A0~A5 | PCINT1_vect |
| PCINT2 | D0~D7 | PCINT2_vect |
#include <avr/interrupt.h>
volatile bool pinChanged = false;
void setup() {
Serial.begin(115200);
pinMode(4, INPUT_PULLUP); // D4 属于 PCINT2 组
// 手动配置 PCINT
PCICR |= (1 << PCIE2); // 启用 PCINT2 组
PCMSK2 |= (1 << PCINT20); // 启用 D4 的引脚变化中断
sei(); // 全局中断使能
}
void loop() {
if (pinChanged) {
pinChanged = false;
Serial.println("D4 电平变化!");
}
}
ISR(PCINT2_vect) { // PCINT2 组的中断服务函数
pinChanged = true;
}
PinChangeInterrupt 库
手动配置寄存器比较麻烦,推荐使用 PinChangeInterrupt 库:
四、定时器基础¶
ATmega328P 定时器¶
Arduino UNO 有 3 个硬件定时器:
| 定时器 | 位数 | 用途 | PWM 引脚 |
|---|---|---|---|
| Timer0 | 8 位 | millis(), delay(), micros() |
D5, D6 |
| Timer1 | 16 位 | Servo 库 | D9, D10 |
| Timer2 | 8 位 | tone() 函数 | D3, D11 |
修改定时器的副作用
| 修改 | 影响 |
|---|---|
| Timer0 | ⚠️ millis(), delay(), micros() 计时全部失准 |
| Timer1 | Servo 库失效 |
| Timer2 | tone() 函数失效 |
定时器工作原理¶
graph LR
CLK[系统时钟<br>16MHz] --> PSC[预分频器<br>÷1/8/64/256/1024]
PSC --> CNT[计数器<br>TCNT]
CNT -->|溢出| OVF[溢出中断<br>TOV]
CNT -->|= OCR| CMP[比较匹配中断<br>OCF]
定时器频率公式:
手动配置 Timer1(精确 1 秒中断)¶
volatile bool timerFlag = false;
void setup() {
Serial.begin(115200);
// 配置 Timer1 为 CTC 模式,1 秒中断
noInterrupts();
TCCR1A = 0;
TCCR1B = 0;
TCNT1 = 0;
OCR1A = 15624; // 比较值 = 16MHz / (1024 × 1Hz) - 1
TCCR1B |= (1 << WGM12); // CTC 模式(计数到 OCR1A 清零)
TCCR1B |= (1 << CS12) | (1 << CS10); // 预分频 1024
TIMSK1 |= (1 << OCIE1A); // 使能比较匹配中断
interrupts();
}
void loop() {
if (timerFlag) {
timerFlag = false;
Serial.println("1 秒到!");
}
}
ISR(TIMER1_COMPA_vect) {
timerFlag = true;
}
计算过程
- 系统时钟:16 MHz
- 预分频:1024
- 目标频率:1 Hz(1 秒)
- \(OCR1A = \frac{16000000}{1024 \times 1} - 1 = 15624\)
- Timer1 是 16 位(最大 65535),15624 在范围内 ✅
常用定时周期计算¶
| 目标频率 | 预分频 | OCR1A 值 | 定时器 |
|---|---|---|---|
| 1 Hz(1秒) | 1024 | 15624 | Timer1 |
| 10 Hz(100ms) | 1024 | 1561 | Timer1 |
| 100 Hz(10ms) | 64 | 2499 | Timer1 |
| 1 kHz(1ms) | 8 | 1999 | Timer1 |
| 50 Hz(20ms,舵机) | 64 | 4999 | Timer1 |
五、TimerOne 库(推荐)¶
手动配置寄存器较复杂,推荐使用 TimerOne 库:
#include <TimerOne.h>
volatile bool ledState = false;
void setup() {
pinMode(13, OUTPUT);
Timer1.initialize(500000); // 设置周期:500000 μs = 500ms
Timer1.attachInterrupt(timerISR); // 绑定中断回调
}
void loop() {
// 主循环做其他事情
}
void timerISR() {
ledState = !ledState;
digitalWrite(13, ledState); // 每 500ms 翻转 LED
}
安装 TimerOne
Arduino IDE → 工具 → 管理库 → 搜索 "TimerOne" → 安装
TimerOne 常用方法¶
| 方法 | 功能 |
|---|---|
Timer1.initialize(us) |
初始化,设置周期(微秒) |
Timer1.setPeriod(us) |
修改周期 |
Timer1.attachInterrupt(func) |
绑定中断回调 |
Timer1.detachInterrupt() |
移除中断回调 |
Timer1.start() |
启动定时器 |
Timer1.stop() |
停止定时器 |
Timer1.pwm(pin, duty) |
输出 PWM(0~1023) |
六、看门狗定时器(WDT)¶
看门狗用于防止程序"跑飞"(死循环、死机),如果程序没有及时"喂狗",看门狗会==自动复位==芯片。
#include <avr/wdt.h>
void setup() {
Serial.begin(115200);
Serial.println("系统启动");
wdt_enable(WDTO_2S); // 启用看门狗,超时 2 秒
}
void loop() {
wdt_reset(); // 喂狗(每次循环必须调用)
// 正常业务逻辑
Serial.println("运行中...");
delay(500);
// 如果这里发生死循环,看门狗 2 秒后会复位芯片
}
看门狗超时选项¶
| 宏定义 | 超时时间 |
|---|---|
WDTO_15MS |
15 ms |
WDTO_30MS |
30 ms |
WDTO_60MS |
60 ms |
WDTO_120MS |
120 ms |
WDTO_250MS |
250 ms |
WDTO_500MS |
500 ms |
WDTO_1S |
1 秒 |
WDTO_2S |
2 秒 |
WDTO_4S |
4 秒 |
WDTO_8S |
8 秒 |
看门狗注意事项
- 某些 Arduino 引导程序(旧版 Optiboot)在看门狗复位后会进入无限循环,需要更新引导程序
wdt_disable()可以禁用看门狗- 看门狗复位后,可以通过
MCUSR寄存器判断复位原因
七、millis() 非阻塞编程¶
delay() 会阻塞整个程序,在需要同时做多件事时应使用 millis() 实现非阻塞定时:
delay() 的问题¶
// ❌ 坏的做法:delay 阻塞,按键无法及时响应
void loop() {
digitalWrite(LED1, HIGH);
delay(1000); // 这 1 秒内什么都不能做
digitalWrite(LED1, LOW);
delay(1000);
// 按键检测被 delay 阻塞,响应很慢
if (digitalRead(BTN) == LOW) {
doSomething();
}
}
millis() 非阻塞方案¶
// ✅ 好的做法:用 millis() 实现非阻塞
unsigned long previousMillis = 0;
const long interval = 1000; // 间隔 1 秒
bool ledState = false;
void loop() {
unsigned long currentMillis = millis();
// 非阻塞 LED 闪烁
if (currentMillis - previousMillis >= interval) {
previousMillis = currentMillis;
ledState = !ledState;
digitalWrite(LED_BUILTIN, ledState);
}
// 按键可以立即响应
if (digitalRead(BTN) == LOW) {
doSomething();
}
// 还可以同时做其他事情
readSensors();
}
多任务调度示例¶
unsigned long task1Timer = 0;
unsigned long task2Timer = 0;
unsigned long task3Timer = 0;
void loop() {
unsigned long now = millis();
// 任务 1:每 100ms 读取传感器
if (now - task1Timer >= 100) {
task1Timer = now;
readSensor();
}
// 任务 2:每 500ms 更新显示
if (now - task2Timer >= 500) {
task2Timer = now;
updateDisplay();
}
// 任务 3:每 2000ms 发送数据
if (now - task3Timer >= 2000) {
task3Timer = now;
sendData();
}
}
millis() 溢出问题
millis() 返回 unsigned long(32位),约 ==49.7 天==后溢出归零。
使用 currentMillis - previousMillis >= interval 的减法写法可以==自动处理溢出==(无符号减法的数学特性),无需额外处理。
❌ 不要写成 millis() >= previousMillis + interval,这种写法溢出时会出错!
八、常见问题¶
外部中断和 PCINT 有什么区别?
| 特性 | 外部中断 (INT) | PCINT |
|---|---|---|
| 触发方式 | RISING/FALLING/CHANGE/LOW | 仅 CHANGE |
| 引脚数(UNO) | 2 个(D2, D3) | 所有 24 个引脚 |
| 独立 ISR | 每个引脚一个 | 每组共用一个 |
| 优先级 | 高 | 低 |
| 需要分辨引脚 | 不需要 | 需要在 ISR 中判断 |
为什么 ISR 中不能用 Serial.print?
Serial.print() 依赖中断来发送数据(串口发送缓冲区通过中断清空),而 ISR 执行期间中断是==被禁止==的。在 ISR 中调用 Serial.print() 会导致死锁或数据丢失。
正确做法:在 ISR 中设置标志位,在 loop() 中根据标志位打印。
怎么获得比 millis() 更高的精度?
micros():微秒级精度(分辨率 4μs),约 70 分钟溢出- Timer1 CTC 模式:可实现任意精度的定时
- 外部 RTC 模块(DS3231):精确的日历时间
多个 millis 定时任务会互相影响吗?
如果每个任务执行时间很短(< 1ms),基本不会互相影响。但如果某个任务耗时较长(如读取 SD 卡),会导致其他任务延迟。这时需要考虑:
- 拆分耗时任务为多个小步骤
- 使用定时器中断处理时间敏感的任务
- 升级到 FreeRTOS(Arduino 也有 FreeRTOS 库)