引言

在 Arduino 开发中,delay() 是初学者最常用的函数,但它会阻塞整个程序,导致无法同时处理多个任务。例如,一个需要同时控制 LED 闪烁和读取按键的程序,如果使用 delay(),按键响应就会变得迟钝。本文将从基础的 millis() 定时器出发,逐步演进到状态机设计,最终引入协程(Protothreads)实现非阻塞式多任务。通过原理讲解、代码示例和注意事项,帮助嵌入式开发者摆脱阻塞困境,构建高效、可扩展的 Arduino 应用。

1. 阻塞式编程的痛点

传统的 Arduino 程序通常使用 delay() 来延时,例如:

void loop() {
  digitalWrite(LED, HIGH);
  delay(1000);
  digitalWrite(LED, LOW);
  delay(1000);
}

这段代码在 LED 闪烁期间,CPU 完全被占用,无法处理其他任务(如按键扫描、传感器读取)。在复杂系统中,这会导致响应迟缓、任务冲突。

2. 非阻塞基础:millis() 定时器

millis() 返回 Arduino 上电以来的毫秒数,通过比较时间差实现非阻塞延时。核心思想是:记录上次执行时间,判断是否达到间隔

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)和按键防抖(检测按下)。

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 库(如 AceRoutineProtothreads)实现轻量级协程,无需操作系统。

4.1 原理

协程利用 C 语言的 static 局部变量和 switch-case 技巧(Duff's Device 变体)保存执行位置。当协程调用 delay() 时,它记录当前行号并返回,主循环再次调用时跳转到该行。

4.2 使用 AceRoutine 库

安装 AceRoutine 库后,代码简洁如下:

#include <AceRoutine.h>

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 开发者能够以接近顺序编程的方式处理多任务,同时保持高效和可维护性。掌握这些技术,将让你的嵌入式项目更上一层楼。