# 引言 在嵌入式开发中,多任务处理是常见需求。然而,Arduino 这类 8 位或 32 位低资源平台,运行完整 RTOS 可能带来内存占用、调度开销和调试复杂度。协作式调度器通过非抢占式任务切换,在无 RTOS 的情况下实现多任务并发,其中状态机和定时器轮询是两种核心实现策略。本文将剖析其原理、对比优劣,并给出可落地的代码。 # 原理:协作式调度与状态机 协作式调度器依赖任务主动让出 CPU。每个任务在有限时间内执行,然后返回调度器,由调度器决定下一个运行的任务。状态机将任务分解为离散状态,每个状态对应一段非阻塞代码,通过事件或时间条件触发状态迁移。例如,一个 LED 闪烁任务可拆分为 `ON` 和 `OFF` 两个状态,每次进入状态仅更新引脚并记录时间,不阻塞循环。 定时器轮询则利用 `millis()` 或硬件定时器产生时间基准,在 `loop()` 中周期检查各任务的时间片是否到期,到期则执行对应任务代码。这本质上是时间触发的状态机,但任务内部可以包含阻塞操作(需谨慎),因为调度器不会强制抢占。 # 方法一:状态机实现多任务 状态机适合任务逻辑清晰、状态可枚举的场景。优点是代码可读性强、易于调试,且天然非阻塞。缺点是需要手动管理状态变量,复杂任务状态图可能膨胀。 ## 配置步骤 1. 定义任务状态枚举和上下文结构体。 2. 为每个任务编写状态处理函数,函数内根据当前状态执行操作并返回下一个状态。 3. 在 `loop()` 中顺序调用所有任务的状态机函数,形成协作调度。 ## 代码示例 ```c // 任务1:LED 闪烁(状态机) enum LEDState { LED_ON, LED_OFF }; struct LEDTask { enum LEDState state; unsigned long lastToggle; int pin; }; void ledTaskRun(struct LEDTask *task) { unsigned long now = millis(); switch (task->state) { case LED_ON: digitalWrite(task->pin, HIGH); if (now - task->lastToggle >= 500) { task->state = LED_OFF; task->lastToggle = now; } break; case LED_OFF: digitalWrite(task->pin, LOW); if (now - task->lastToggle >= 500) { task->state = LED_ON; task->lastToggle = now; } break; } } // 任务2:串口打印(状态机) enum PrintState { PRINT_IDLE, PRINT_WAIT }; struct PrintTask { enum PrintState state; unsigned long lastPrint; }; void printTaskRun(struct PrintTask *task) { switch (task->state) { case PRINT_IDLE: Serial.println("Hello"); task->state = PRINT_WAIT; task->lastPrint = millis(); break; case PRINT_WAIT: if (millis() - task->lastPrint >= 1000) { task->state = PRINT_IDLE; } break; } } // 全局任务实例 struct LEDTask led1 = { LED_OFF, 0, 13 }; struct PrintTask printer = { PRINT_IDLE, 0 }; void setup() { pinMode(13, OUTPUT); Serial.begin(9600); } void loop() { ledTaskRun(&led1); printTaskRun(&printer); } ``` # 方法二:定时器轮询调度器 定时器轮询更通用,适合任务执行时间不确定或需要周期性调用的场景。调度器维护一个任务列表,每个任务包含周期、上次执行时间和回调函数。在 `loop()` 中遍历列表,若当前时间减去上次执行时间超过周期,则调用回调。 ## 配置步骤 1. 定义任务控制块(TCB)结构体,包含周期、上次时间、回调指针。 2. 初始化任务数组,注册各任务。 3. 在 `loop()` 中调用调度器 `schedulerRun()`,其内部检查并执行到期任务。 ## 代码示例 ```c #define MAX_TASKS 5 typedef void (*TaskCallback)(); struct Task { unsigned long period; unsigned long lastRun; TaskCallback callback; bool active; }; struct Task tasks[MAX_TASKS]; int taskCount = 0; bool addTask(unsigned long period, TaskCallback cb) { if (taskCount >= MAX_TASKS) return false; tasks[taskCount].period = period; tasks[taskCount].lastRun = 0; tasks[taskCount].callback = cb; tasks[taskCount].active = true; taskCount++; return true; } void schedulerRun() { unsigned long now = millis(); for (int i = 0; i < taskCount; i++) { if (tasks[i].active && (now - tasks[i].lastRun >= tasks[i].period)) { tasks[i].lastRun = now; tasks[i].callback(); } } } // 任务回调 void blinkLED() { static bool on = false; digitalWrite(13, on ? HIGH : LOW); on = !on; } void printMsg() { Serial.println("Tick"); } void setup() { pinMode(13, OUTPUT); Serial.begin(9600); addTask(500, blinkLED); addTask(1000, printMsg); } void loop() { schedulerRun(); } ``` # 取舍与注意事项 - **状态机**:适合状态明确、事件驱动的任务,如按键扫描、通信协议解析。缺点:任务间耦合需手动管理,状态爆炸时维护困难。 - **定时器轮询**:适合周期性任务,代码复用性好,易于添加/删除任务。缺点:回调中不能有阻塞操作(如 `delay()`),否则会阻塞其他任务;需注意 `millis()` 溢出(约 49 天),但使用无符号长整型差值可自动处理。 - **协作式调度**:所有任务必须快速返回,避免长时间占用 CPU。若任务需要等待外部事件,应使用非阻塞方式(如检查标志位)。 - **内存**:状态机通常更省内存,因为无需任务控制块数组;定时器轮询需额外存储任务元数据。 - **调试**:状态机可通过打印状态名轻松跟踪;定时器轮询则需记录任务执行时间。 # 总结 在无 RTOS 的 Arduino 上,状态机和定时器轮询都是实现协作式多任务的有效手段。状态机适合逻辑复杂但状态有限的任务,定时器轮询适合周期性调度。实际项目中可混合使用:用定时器轮询作为主调度,内部任务再用状态机分解。关键在于保持任务非阻塞,并合理设计时间基准。掌握这些技巧,即可在低资源平台上实现高效多任务,避免 RTOS 的过度设计。