跳转至

中断与定时器

中断让 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() 使用

attachInterrupt(digitalPinToInterrupt(pin), ISR, mode);
// pin:  中断引脚
// ISR:  中断服务函数
// mode: 触发方式
触发模式 说明
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 编写规则

  1. 必须使用 volatile:ISR 中修改的变量必须用 volatile 修饰,防止编译器优化
  2. 越短越好:ISR 中不要放耗时操作(如 Serial.printdelay
  3. 不要用 delay()delay() 依赖中断,而 ISR 内中断被禁止
  4. 不要用 millis():ISR 内 millis() 不会更新(Timer0 中断被屏蔽)
  5. 共享变量用 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 中更新的多字节变量(如 longfloat)时,需要关中断防止读取到不一致的数据:

noInterrupts();
long safeCopy = volatileCounter;  // 原子性读取
interrupts();


三、引脚变化中断(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 库:

#include <PinChangeInterrupt.h>
attachPCINT(digitalPinToPCINT(4), myISR, FALLING);


四、定时器基础

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]

定时器频率公式:

\[f_{定时} = \frac{f_{CLK}}{预分频 \times (TOP + 1)}\]
\[T_{定时} = \frac{1}{f_{定时}} = \frac{预分频 \times (TOP + 1)}{f_{CLK}}\]

手动配置 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 库)