引言

在嵌入式开发中,多任务处理是常见需求。然而,Arduino 这类 8 位或 32 位低资源平台,运行完整 RTOS 可能带来内存占用、调度开销和调试复杂度。协作式调度器通过非抢占式任务切换,在无 RTOS 的情况下实现多任务并发,其中状态机和定时器轮询是两种核心实现策略。本文将剖析其原理、对比优劣,并给出可落地的代码。

原理:协作式调度与状态机

协作式调度器依赖任务主动让出 CPU。每个任务在有限时间内执行,然后返回调度器,由调度器决定下一个运行的任务。状态机将任务分解为离散状态,每个状态对应一段非阻塞代码,通过事件或时间条件触发状态迁移。例如,一个 LED 闪烁任务可拆分为 ONOFF 两个状态,每次进入状态仅更新引脚并记录时间,不阻塞循环。

定时器轮询则利用 millis() 或硬件定时器产生时间基准,在 loop() 中周期检查各任务的时间片是否到期,到期则执行对应任务代码。这本质上是时间触发的状态机,但任务内部可以包含阻塞操作(需谨慎),因为调度器不会强制抢占。

方法一:状态机实现多任务

状态机适合任务逻辑清晰、状态可枚举的场景。优点是代码可读性强、易于调试,且天然非阻塞。缺点是需要手动管理状态变量,复杂任务状态图可能膨胀。

配置步骤

  1. 定义任务状态枚举和上下文结构体。
  2. 为每个任务编写状态处理函数,函数内根据当前状态执行操作并返回下一个状态。
  3. loop() 中顺序调用所有任务的状态机函数,形成协作调度。

代码示例

// 任务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(),其内部检查并执行到期任务。

代码示例

#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 的过度设计。