# 从 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 打下基础。