Arduino 非阻塞式多任务状态机:从 millis() 到协程库的演进
👁 1 阅读 · 2026-08-27 · 嵌入式
在嵌入式开发中,阻塞式 delay() 会浪费 CPU 并导致系统响应迟缓。本文从最基础的 millis() 轮询开始,逐步演进到状态机、简易调度器,最终引入协程库(如 Protothreads 或 AsyncDelay),揭示非阻塞编程的核心思想与实现技巧。通过对比不同方案的优缺点,帮助开发者根据项目复杂度选择合适架构,提升系统实时性与可维护性。
# 从 delay() 到非阻塞:为什么需要状态机?
Arduino 初学者常使用 `delay()` 实现延时,但它在延时期间完全阻塞 CPU,无法响应按键、传感器或通信事件。例如,一个需要同时闪烁 LED 和读取按钮的程序,若用 `delay(1000)` 闪烁,按钮响应会延迟 1 秒,体验极差。
**非阻塞编程**的核心是:**不等待,而是定期检查时间或事件**。`millis()` 函数返回系统上电以来的毫秒数,通过比较时间差实现延时,同时保持 CPU 空闲。
## 1. 基础:用 millis() 实现非阻塞延时
```c
const int ledPin = 13;
unsigned long previousMillis = 0;
const long interval = 1000;
void setup() {
pinMode(ledPin, OUTPUT);
}
void loop() {
unsigned long currentMillis = millis();
if (currentMillis - previousMillis >= interval) {
previousMillis = currentMillis;
digitalWrite(ledPin, !digitalRead(ledPin));
}
// 此处可执行其他任务,如读取按钮
}
```
**要点:**
- 使用 `unsigned long` 避免溢出问题(约 50 天后归零,但差值计算仍正确)。
- 时间差 `currentMillis - previousMillis` 在溢出时依然有效,因为无符号减法按模运算。
- 此方法适合单任务,但多任务时 `loop()` 会变得杂乱。
## 2. 状态机:管理多任务的基础
当有多个独立任务时,每个任务可建模为状态机,用枚举变量表示状态,在 `loop()` 中轮询各状态。
**示例:LED 闪烁 + 按键消抖**
```c
enum LedState { LED_OFF, LED_ON };
LedState ledState = LED_OFF;
unsigned long ledTimer = 0;
const long ledInterval = 500;
enum ButtonState { BTN_IDLE, BTN_DEBOUNCE, BTN_PRESSED };
ButtonState btnState = BTN_IDLE;
unsigned long btnTimer = 0;
const int btnPin = 2;
void setup() {
pinMode(ledPin, OUTPUT);
pinMode(btnPin, INPUT_PULLUP);
}
void loop() {
// LED 状态机
switch (ledState) {
case LED_OFF:
if (millis() - ledTimer >= ledInterval) {
digitalWrite(ledPin, HIGH);
ledState = LED_ON;
ledTimer = millis();
}
break;
case LED_ON:
if (millis() - ledTimer >= ledInterval) {
digitalWrite(ledPin, LOW);
ledState = LED_OFF;
ledTimer = millis();
}
break;
}
// 按键状态机(消抖)
int btnRead = digitalRead(btnPin);
switch (btnState) {
case BTN_IDLE:
if (btnRead == LOW) {
btnState = BTN_DEBOUNCE;
btnTimer = millis();
}
break;
case BTN_DEBOUNCE:
if (millis() - btnTimer >= 20) {
if (digitalRead(btnPin) == LOW) {
btnState = BTN_PRESSED;
// 处理按键事件
} else {
btnState = BTN_IDLE;
}
}
break;
case BTN_PRESSED:
if (btnRead == HIGH) {
btnState = BTN_IDLE;
}
break;
}
}
```
**优点:** 逻辑清晰,每个任务独立。**缺点:** 状态变量和计时器需手动管理,任务增多时代码膨胀。
## 3. 调度器:封装状态机
将每个任务封装为函数,使用结构体保存状态和定时器,通过简单的调度器统一管理。
```c
typedef struct {
void (*func)();
unsigned long interval;
unsigned long lastRun;
} Task;
Task tasks[] = {
{blinkLED, 500, 0},
{readButton, 20, 0}
};
const int taskCount = sizeof(tasks) / sizeof(Task);
void setup() {
// 初始化
}
void loop() {
unsigned long now = millis();
for (int i = 0; i < taskCount; i++) {
if (now - tasks[i].lastRun >= tasks[i].interval) {
tasks[i].lastRun = now;
tasks[i].func();
}
}
}
void blinkLED() {
static bool state = false;
digitalWrite(ledPin, state = !state);
}
void readButton() {
// 读取并处理按键
}
```
**优点:** 代码复用性高,添加任务只需增加数组项。**缺点:** 任务函数必须快速返回,不能有阻塞操作;状态内部仍需手动管理。
## 4. 协程库:Protothreads 与 AsyncDelay
协程(Coroutine)允许在函数内暂停和恢复,无需状态机枚举。Arduino 上常用 **Protothreads**(轻量级宏)或 **AsyncDelay**(C++ 类)。
### 4.1 Protothreads 示例
```c
#include
static struct pt pt_led, pt_btn;
static int ledThread(struct pt *pt) {
PT_BEGIN(pt);
while(1) {
digitalWrite(ledPin, HIGH);
PT_DELAY(pt, 500);
digitalWrite(ledPin, LOW);
PT_DELAY(pt, 500);
}
PT_END(pt);
}
static int btnThread(struct pt *pt) {
PT_BEGIN(pt);
while(1) {
if (digitalRead(btnPin) == LOW) {
PT_DELAY(pt, 20); // 消抖
if (digitalRead(btnPin) == LOW) {
// 处理事件
}
}
PT_YIELD(pt); // 让出 CPU
}
PT_END(pt);
}
void setup() {
PT_INIT(&pt_led);
PT_INIT(&pt_btn);
}
void loop() {
ledThread(&pt_led);
btnThread(&pt_btn);
}
```
**原理:** 宏利用 switch-case 和局部静态变量保存断点,实现非抢占式协作调度。
### 4.2 AsyncDelay 类(C++)
```c
#include
AsyncDelay ledDelay(500);
AsyncDelay btnDelay(20);
void loop() {
if (ledDelay.isExpired()) {
ledDelay.restart();
digitalWrite(ledPin, !digitalRead(ledPin));
}
// 类似处理按键
}
```
**优点:** 代码接近顺序编程,可读性强。**缺点:** 需要额外库,且协程内不能有阻塞调用(如 delay())。
## 5. 演进对比与选型建议
| 方案 | 复杂度 | 可维护性 | 适用场景 |
|------|--------|----------|----------|
| millis() 轮询 | 低 | 低(任务多时混乱) | 简单单任务 |
| 状态机 | 中 | 中 | 2-3 个任务,状态明确 |
| 调度器 | 中高 | 高 | 多个独立周期性任务 |
| 协程库 | 高 | 很高 | 复杂逻辑,顺序思维 |
**选型建议:**
- 任务少于 3 个,用状态机即可。
- 任务多且周期固定,用调度器。
- 逻辑复杂(如通信协议),协程库能显著简化代码。
## 6. 注意事项
- **避免阻塞调用**:任何任务中都不能使用 `delay()`、`while` 等待等,否则会阻塞整个系统。
- **变量作用域**:状态机中的状态变量建议用 `static` 或全局,避免每次调用重置。
- **时间精度**:`millis()` 基于定时器中断,精度约 1ms,适合大多数场景;若需微秒级,可用 `micros()`。
- **内存开销**:协程库每个协程需要少量 RAM(Protothreads 约 2 字节),但比 RTOS 轻量得多。
- **调试技巧**:使用 `Serial.print` 输出状态变化,但注意串口打印本身可能阻塞,建议用非阻塞打印或缓冲。
## 7. 总结
从 `millis()` 到状态机、调度器、协程库,本质是**将时间管理从代码中抽象出来**,让 CPU 始终响应外部事件。对于 Arduino 这类资源受限平台,非阻塞架构是提高实时性的关键。理解这些演进,不仅能写出更高效的 Arduino 程序,也为学习 RTOS 打下基础。