Arduino 上实现非阻塞式多任务状态机:从 millis() 到协程的演进
👁 1 阅读 · 2026-08-27 · 嵌入式
在 Arduino 开发中,delay() 是初学者最常用的函数,但它会阻塞整个程序,导致无法同时处理多个任务。本文将从基础的 millis() 定时器出发,逐步演进到状态机设计,最终引入协程(Protothreads)实现非阻塞式多任务。通过原理讲解、代码示例和注意事项,帮助嵌入式开发者摆脱阻塞困境,构建高效、可扩展的 Arduino 应用。
# 引言
在 Arduino 开发中,`delay()` 是初学者最常用的函数,但它会阻塞整个程序,导致无法同时处理多个任务。例如,一个需要同时控制 LED 闪烁和读取按键的程序,如果使用 `delay()`,按键响应就会变得迟钝。本文将从基础的 `millis()` 定时器出发,逐步演进到状态机设计,最终引入协程(Protothreads)实现非阻塞式多任务。通过原理讲解、代码示例和注意事项,帮助嵌入式开发者摆脱阻塞困境,构建高效、可扩展的 Arduino 应用。
# 1. 阻塞式编程的痛点
传统的 Arduino 程序通常使用 `delay()` 来延时,例如:
```c
void loop() {
digitalWrite(LED, HIGH);
delay(1000);
digitalWrite(LED, LOW);
delay(1000);
}
```
这段代码在 LED 闪烁期间,CPU 完全被占用,无法处理其他任务(如按键扫描、传感器读取)。在复杂系统中,这会导致响应迟缓、任务冲突。
# 2. 非阻塞基础:millis() 定时器
`millis()` 返回 Arduino 上电以来的毫秒数,通过比较时间差实现非阻塞延时。核心思想是:**记录上次执行时间,判断是否达到间隔**。
```c
unsigned long previousMillis = 0;
const long interval = 1000;
void loop() {
unsigned long currentMillis = millis();
if (currentMillis - previousMillis >= interval) {
previousMillis = currentMillis;
digitalWrite(LED, !digitalRead(LED));
}
// 其他任务可以在这里执行
}
```
注意:`millis()` 会在约 50 天后溢出,但使用 `unsigned long` 减法可以正确处理溢出,因为无符号数减法在溢出时仍能给出正确差值。
# 3. 状态机:管理多任务状态
当有多个任务时,可以引入状态机。每个任务有自己的状态和计时器,通过 `switch-case` 或函数指针管理状态转换。
## 3.1 简单状态机示例
假设有两个任务:LED 闪烁(周期 1s)和按键防抖(检测按下)。
```c
enum LedState { LED_OFF, LED_ON };
LedState ledState = LED_OFF;
unsigned long ledTimer = 0;
enum ButtonState { BTN_IDLE, BTN_DEBOUNCE };
ButtonState btnState = BTN_IDLE;
unsigned long btnTimer = 0;
void updateLed() {
switch (ledState) {
case LED_OFF:
if (millis() - ledTimer >= 1000) {
digitalWrite(LED, HIGH);
ledState = LED_ON;
ledTimer = millis();
}
break;
case LED_ON:
if (millis() - ledTimer >= 1000) {
digitalWrite(LED, LOW);
ledState = LED_OFF;
ledTimer = millis();
}
break;
}
}
void updateButton() {
switch (btnState) {
case BTN_IDLE:
if (digitalRead(BUTTON) == LOW) {
btnState = BTN_DEBOUNCE;
btnTimer = millis();
}
break;
case BTN_DEBOUNCE:
if (millis() - btnTimer >= 50) {
if (digitalRead(BUTTON) == LOW) {
// 确认按下
Serial.println("Pressed");
}
btnState = BTN_IDLE;
}
break;
}
}
void loop() {
updateLed();
updateButton();
}
```
状态机将每个任务封装成独立函数,在 `loop()` 中轮流调用,实现“伪并行”。但状态机代码冗长,状态多时难以维护。
# 4. 协程:更优雅的解决方案
协程(Coroutine)允许函数在执行过程中暂停并让出 CPU,之后从暂停点继续执行。在 Arduino 中,可以使用 Protothreads 库(如 `AceRoutine` 或 `Protothreads`)实现轻量级协程,无需操作系统。
## 4.1 原理
协程利用 C 语言的 `static` 局部变量和 `switch-case` 技巧(Duff's Device 变体)保存执行位置。当协程调用 `delay()` 时,它记录当前行号并返回,主循环再次调用时跳转到该行。
## 4.2 使用 AceRoutine 库
安装 `AceRoutine` 库后,代码简洁如下:
```c
#include
using namespace ace_routine;
Coroutine ledTask;
Coroutine buttonTask;
void setup() {
pinMode(LED, OUTPUT);
pinMode(BUTTON, INPUT_PULLUP);
Serial.begin(9600);
}
void loop() {
ledTask.run();
buttonTask.run();
}
void ledTaskCoroutine() {
while (true) {
digitalWrite(LED, HIGH);
COROUTINE_DELAY(1000);
digitalWrite(LED, LOW);
COROUTINE_DELAY(1000);
}
}
void buttonTaskCoroutine() {
while (true) {
if (digitalRead(BUTTON) == LOW) {
COROUTINE_DELAY(50); // 防抖
if (digitalRead(BUTTON) == LOW) {
Serial.println("Pressed");
while (digitalRead(BUTTON) == LOW) {
COROUTINE_DELAY(10);
}
}
}
COROUTINE_DELAY(10);
}
}
// 注册协程
Coroutine ledTask(ledTaskCoroutine);
Coroutine buttonTask(buttonTaskCoroutine);
```
`COROUTINE_DELAY()` 会暂停当前协程指定毫秒,期间其他协程继续运行。代码结构接近顺序编程,可读性大幅提升。
# 5. 演进对比与选型建议
| 方法 | 优点 | 缺点 | 适用场景 |
|------|------|------|----------|
| delay() | 简单 | 完全阻塞 | 单任务、教学 |
| millis() | 非阻塞,无额外开销 | 逻辑分散,多任务管理困难 | 简单定时任务 |
| 状态机 | 结构化,可扩展 | 代码冗长,状态爆炸 | 中等复杂度 |
| 协程 | 代码简洁,接近顺序 | 依赖库,内存占用稍高 | 多任务、复杂逻辑 |
# 6. 注意事项
- **避免在协程中使用 `delay()`**:会阻塞整个系统,应使用 `COROUTINE_DELAY()`。
- **协程栈空间**:每个协程默认分配少量栈(如 64 字节),避免在协程中定义大型局部变量。
- **共享变量**:多协程访问共享变量时,需确保原子性(如使用 `volatile` 或临界区)。
- **调试**:协程的调用栈不直观,建议使用 `Serial` 打印状态或使用调试器。
- **库选择**:`AceRoutine` 功能丰富,`Protothreads` 更轻量,根据需求选择。
# 结语
从 `millis()` 到状态机,再到协程,体现了嵌入式编程从阻塞到非阻塞、从过程化到结构化的演进。协程提供了一种优雅的解决方案,让 Arduino 开发者能够以接近顺序编程的方式处理多任务,同时保持高效和可维护性。掌握这些技术,将让你的嵌入式项目更上一层楼。