Arduino 上实现无 RTOS 的多任务协作式调度器:状态机与定时器轮询的取舍
👁 1 阅读 · 2026-08-27 · 嵌入式
在资源受限的 Arduino 平台上,RTOS 往往显得笨重。本文深入探讨如何通过状态机与定时器轮询实现轻量级协作式调度器,分析两种方法的原理、适用场景及取舍,并提供完整代码示例与工程注意事项,帮助开发者在不引入 RTOS 的情况下高效管理多任务。
# 引言
在嵌入式开发中,多任务处理是常见需求。然而,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 的过度设计。