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