中断与定时器
中断和定时器是 Arduino 高级编程的核心概念。掌握它们可以实现精确的时间控制、响应外部事件而不阻塞主程序,以及实现多任务处理。
什么是中断
中断是一种机制,当特定事件发生时,处理器会暂停当前正在执行的程序,转而去执行一个特殊的函数(中断服务程序),执行完毕后再返回原来的位置继续执行。
为什么需要中断
传统的事件检测方式是"轮询"——在主循环中不断检查某个条件:
// 轮询方式:一直在检查按钮状态
void loop() {
if (digitalRead(BUTTON_PIN) == LOW) {
// 处理按钮按下
}
// 其他代码...
}
轮询的问题在于:如果按钮在 delay() 期间被按下,程序会错过这个事件。而中断可以立即响应,不受 delay() 影响。
中断的优势
- 实时响应:事件发生时立即处理
- 不阻塞主程序:主循环可以专注做其他事情
- 精确计时:定时器中断可以实现微秒级精度
- 低功耗:可以让 CPU 休眠,等待中断唤醒
外部中断
外部中断由外部引脚的电平变化触发。Arduino Uno 有两个外部中断引脚:D2(中断 0)和 D3(中断 1)。
attachInterrupt() 函数
attachInterrupt(interrupt, ISR, mode);
| 参数 | 说明 |
|---|---|
interrupt | 中断号(0 或 1)或使用 digitalPinToInterrupt(pin) |
ISR | 中断服务程序函数名 |
mode | 触发模式 |
触发模式
| 模式 | 说明 |
|---|---|
LOW | 低电平时触发(持续触发) |
CHANGE | 电平变化时触发(上升沿或下降沿) |
RISING | 上升沿触发(从低到高) |
FALLING | 下降沿触发(从高到低) |
基本示例:按钮中断
const int BUTTON_PIN = 2; // 中断引脚 D2
const int LED_PIN = 13;
volatile bool ledState = false; // volatile 关键字很重要!
void setup() {
pinMode(LED_PIN, OUTPUT);
pinMode(BUTTON_PIN, INPUT_PULLUP);
// 附加中断:下降沿触发(按钮按下时)
attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), toggleLED, FALLING);
Serial.begin(9600);
Serial.println("中断测试:按下按钮切换 LED");
}
void loop() {
// 主循环可以做其他事情
Serial.println("主循环运行中...");
delay(1000);
}
// 中断服务程序
void toggleLED() {
ledState = !ledState;
digitalWrite(LED_PIN, ledState);
}
volatile 关键字
在中断中使用的变量必须声明为 volatile。这告诉编译器该变量可能在任何时候被改变(由中断),因此编译器不会对其进行优化。
// 正确:使用 volatile
volatile int counter = 0;
void ISR() {
counter++; // 安全
}
// 错误:未使用 volatile
int counter = 0; // 编译器可能优化掉这个变量
void ISR() {
counter++; // 可能出现问题
}
中断服务程序的规则
中断服务程序(ISR)必须遵循以下规则:
- 要短要快:ISR 应该尽快执行完毕,通常只设置标志位
- 不能用 delay():
delay()在中断中不起作用 - 慎用 Serial:串口通信可能造成问题
- 变量要 volatile:共享变量必须声明为
volatile
改进的按钮中断示例
const int BUTTON_PIN = 2;
const int LED_PIN = 13;
volatile bool buttonPressed = false; // 标志位
void setup() {
pinMode(LED_PIN, OUTPUT);
pinMode(BUTTON_PIN, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), buttonISR, FALLING);
Serial.begin(9600);
}
void loop() {
// 在主循环中处理事件
if (buttonPressed) {
buttonPressed = false; // 清除标志
// 在这里处理按钮事件
digitalWrite(LED_PIN, !digitalRead(LED_PIN));
Serial.println("按钮被按下");
}
// 其他任务...
}
void buttonISR() {
buttonPressed = true; // 只设置标志,不做其他事情
}
分离中断
使用 detachInterrupt() 可以禁用中断:
// 禁用中断 0(D2)
detachInterrupt(digitalPinToInterrupt(2));
// 或使用中断号
detachInterrupt(0);
各开发板的中断引脚
| 开发板 | 可用中断引脚 |
|---|---|
| Uno, Nano, Mini | D2, D3 |
| Mega | D2, D3, D18, D19, D20, D21 |
| Leonardo | D0, D1, D2, D3, D7 |
| Due | 所有数字引脚 |
定时器中断
定时器中断由内部定时器产生,可以按固定时间间隔触发。这比 delay() 更加精确和灵活。
Arduino Uno 的定时器
Arduino Uno(ATmega328P)有三个定时器:
| 定时器 | 位数 | 用途 |
|---|---|---|
| Timer0 | 8位 | millis()、delay()、PWM D5/D6 |
| Timer1 | 16位 | PWM D9/D10 |
| Timer2 | 8位 | PWM D3/D11 |
使用定时器中断可能会影响相关的 PWM 引脚和延时函数。Timer0 控制 millis() 和 delay(),修改它会导致时间函数失效。
使用 TimerOne 库
TimerOne 库简化了 Timer1 的使用。安装方法:项目 → 加载库 → 管理库 → 搜索 "TimerOne"。
#include <TimerOne.h>
const int LED_PIN = 13;
volatile bool ledState = false;
void setup() {
pinMode(LED_PIN, OUTPUT);
// 初始化 Timer1,周期 1 秒(单位:微秒)
Timer1.initialize(1000000); // 1,000,000 微秒 = 1 秒
// 附加中断服务程序
Timer1.attachInterrupt(timerISR);
Serial.begin(9600);
Serial.println("定时器中断测试");
}
void loop() {
// 主循环可以做其他事情
Serial.print("运行时间: ");
Serial.print(millis() / 1000);
Serial.println(" 秒");
delay(500);
}
// 定时器中断服务程序
void timerISR() {
ledState = !ledState;
digitalWrite(LED_PIN, ledState);
}
改变定时器周期
Timer1.setPeriod(500000); // 设置周期为 0.5 秒
产生 PWM 信号
TimerOne 可以在 D9 和 D10 产生高精度 PWM:
#include <TimerOne.h>
void setup() {
Timer1.initialize(20000); // 20ms 周期(适合舵机)
// 在 D9 产生 PWM,占空比 10%(舵机中间位置)
Timer1.pwm(9, 512); // 0-1023 范围
}
void loop() {
// 改变占空比
for (int i = 100; i <= 512; i++) {
Timer1.setPwmDuty(9, i);
delay(10);
}
}
直接操作定时器寄存器
对于更精确的控制,可以直接操作寄存器。以下代码设置 Timer2 产生 1kHz 的中断:
void setup() {
Serial.begin(9600);
// 禁用全局中断
cli();
// 重置 Timer2 控制寄存器
TCCR2A = 0;
TCCR2B = 0;
TCNT2 = 0; // 计数器初值
// 设置比较匹配值
// 16MHz / 64 预分频 / 250 = 1000Hz
OCR2A = 249;
// CTC 模式
TCCR2A |= (1 << WGM21);
// 64 预分频
TCCR2B |= (1 << CS22);
// 启用比较匹配中断
TIMSK2 |= (1 << OCIE2A);
// 启用全局中断
sei();
Serial.println("Timer2 配置完成,1kHz 中断");
}
void loop() {
// 主程序
}
// Timer2 比较匹配中断服务程序
ISR(TIMER2_COMPA_vect) {
// 每 1ms 执行一次
// 在这里执行定时任务
}
定时器中断频率计算
定时器中断频率公式:
对于 Arduino Uno(16MHz):
| 预分频 | 比较值 | 中断频率 |
|---|---|---|
| 64 | 249 | 1000 Hz (1ms) |
| 64 | 124 | 2000 Hz (0.5ms) |
| 256 | 62499 | 1 Hz (1s) |
引脚变化中断
除了外部中断,Arduino 还支持引脚变化中断(Pin Change Interrupt),可以在更多引脚上检测变化。
PCINT 引脚
Arduino Uno 的引脚变化中断分组:
| PCINT 组 | 引脚 | 中断向量 |
|---|---|---|
| PCINT0 | D8-D13 | PCINT0_vect |
| PCINT1 | A0-A5 | PCINT1_vect |
| PCINT2 | D0-D7 | PCINT2_vect |
启用引脚变化中断
// 在 D8 上启用引脚变化中断
void setup() {
pinMode(8, INPUT_PULLUP);
// 启用引脚变化中断
PCICR |= (1 << PCIE0); // 启用 PCINT0 组(D8-D13)
PCMSK0 |= (1 << PCINT0); // 启用 D8 的引脚变化检测
Serial.begin(9600);
}
void loop() {
// 主循环
}
// 引脚变化中断服务程序
ISR(PCINT0_vect) {
Serial.println("D8 状态变化");
}
综合示例:精确频率计
使用定时器中断和外部中断制作频率计:
const int INPUT_PIN = 2; // 测量信号输入
volatile unsigned long pulseCount = 0;
volatile unsigned long frequency = 0;
unsigned long lastUpdate = 0;
void setup() {
Serial.begin(9600);
pinMode(INPUT_PIN, INPUT);
// 配置外部中断(每个脉冲计数)
attachInterrupt(digitalPinToInterrupt(INPUT_PIN), countPulse, RISING);
// 配置 Timer1 每秒触发一次
cli();
TCCR1A = 0;
TCCR1B = 0;
TCNT1 = 0;
OCR1A = 15624; // 16MHz / 1024 / 15625 = 1Hz
TCCR1B |= (1 << WGM12) | (1 << CS12) | (1 << CS10);
TIMSK1 |= (1 << OCIE1A);
sei();
Serial.println("频率计已启动");
}
void loop() {
if (millis() - lastUpdate > 1000) {
Serial.print("频率: ");
Serial.print(frequency);
Serial.println(" Hz");
lastUpdate = millis();
}
}
// 脉冲计数中断
void countPulse() {
pulseCount++;
}
// Timer1 比较匹配中断(每秒)
ISR(TIMER1_COMPA_vect) {
frequency = pulseCount; // 保存计数值
pulseCount = 0; // 重置计数器
}
综合示例:多任务调度器
使用定时器中断实现简单的多任务调度:
#include <TimerOne.h>
// 任务结构体
struct Task {
void (*func)(); // 任务函数
unsigned int period; // 执行周期(ms)
unsigned int counter; // 计数器
};
// 任务列表
Task tasks[] = {
{task1, 100, 0}, // 每 100ms 执行
{task2, 500, 0}, // 每 500ms 执行
{task3, 1000, 0}, // 每 1000ms 执行
};
const int NUM_TASKS = 3;
volatile bool tick = false;
void setup() {
Serial.begin(9600);
// 初始化 Timer1,1ms 中断
Timer1.initialize(1000);
Timer1.attachInterrupt(timerTick);
Serial.println("多任务调度器启动");
}
void loop() {
if (tick) {
tick = false;
scheduler();
}
}
// 定时器中断(1ms)
void timerTick() {
tick = true;
}
// 任务调度器
void scheduler() {
for (int i = 0; i < NUM_TASKS; i++) {
tasks[i].counter++;
if (tasks[i].counter >= tasks[i].period) {
tasks[i].counter = 0;
tasks[i].func();
}
}
}
// 任务 1:读取传感器
void task1() {
int value = analogRead(A0);
// 处理传感器数据...
}
// 任务 2:更新显示
void task2() {
Serial.print("时间: ");
Serial.print(millis() / 1000);
Serial.println("s");
}
// 任务 3:检查状态
void task3() {
// 检查系统状态...
}
中断优先级
当多个中断同时发生时,需要了解优先级:
- 复位(最高优先级)
- 外部中断
- 定时器中断
- 串口中断
- 其他中断
中断不能被同优先级的中断打断,但可以被更高优先级的中断打断。
常见问题
1. 中断中使用 Serial 打印异常
原因:Serial 本身使用中断,在中断中使用可能导致冲突。
解决:在 ISR 中只设置标志,在主循环中打印。
2. 变量值不正确
原因:共享变量未声明为 volatile。
解决:在 ISR 中使用的变量必须声明为 volatile。
3. 定时器中断影响 millis()
原因:修改了 Timer0。
解决:避免修改 Timer0,使用 Timer1 或 Timer2。
4. 中断丢失
原因:中断服务程序执行时间太长。
解决:保持 ISR 简短,只做必要的操作。
调试技巧
测量中断响应时间
const int TEST_PIN = 9;
void setup() {
pinMode(TEST_PIN, OUTPUT);
attachInterrupt(digitalPinToInterrupt(2), testISR, FALLING);
}
void loop() {
// 触发测试:D2 连接到 GND
}
void testISR() {
// 立即拉高测试引脚
digitalWrite(TEST_PIN, HIGH); // 用示波器测量延迟
digitalWrite(TEST_PIN, LOW);
}
统计中断频率
volatile unsigned long isrCount = 0;
unsigned long lastPrint = 0;
void setup() {
Serial.begin(9600);
// 配置中断...
}
void loop() {
if (millis() - lastPrint > 1000) {
Serial.print("中断频率: ");
Serial.print(isrCount);
Serial.println(" Hz");
isrCount = 0;
lastPrint = millis();
}
}
void myISR() {
isrCount++;
}
下一步
掌握了中断和定时器后,你已经具备了开发复杂 Arduino 项目的能力。接下来我们将学习如何持久化存储数据。