从 delay() 到非阻塞:为什么需要状态机?

Arduino 初学者常使用 delay() 实现延时,但它在延时期间完全阻塞 CPU,无法响应按键、传感器或通信事件。例如,一个需要同时闪烁 LED 和读取按钮的程序,若用 delay(1000) 闪烁,按钮响应会延迟 1 秒,体验极差。

非阻塞编程的核心是:不等待,而是定期检查时间或事件millis() 函数返回系统上电以来的毫秒数,通过比较时间差实现延时,同时保持 CPU 空闲。

1. 基础:用 millis() 实现非阻塞延时

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 闪烁 + 按键消抖

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. 调度器:封装状态机

将每个任务封装为函数,使用结构体保存状态和定时器,通过简单的调度器统一管理。

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 示例

#include <pt.h>

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++)

#include <AsyncDelay.h>

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