Arduino 无 RTOS 协作式调度器:基于状态机的 5 个陷阱与规避

引言

在 Arduino 这类资源受限的 MCU 上,RTOS 往往显得笨重(RAM 占用大、上下文切换开销高)。协作式调度器(Cooperative Scheduler)配合状态机(State Machine)成为轻量级替代方案:任务主动让出 CPU,通过状态机管理非阻塞流程。然而,这种模式在实践中存在多个隐蔽陷阱,稍不留神就会导致系统卡死、响应迟钝或数据错乱。本文基于实际经验,总结 5 个高频陷阱,并提供可落地的规避方案。

陷阱 1:在状态机中调用阻塞函数

问题描述

状态机要求每个状态执行时间极短,但开发者常习惯在状态内调用 delay()Serial.readString()while(flag) 等待硬件。这些阻塞调用会独占 CPU,导致其他任务饿死,协作调度器名存实亡。

规避方案

  • 将阻塞操作拆解为子状态:例如等待串口数据,改为 STATE_WAIT_DATA 中非阻塞检查 Serial.available()
  • 使用超时机制:为每个状态设置最大等待时间,超时后强制跳转,避免死等。
  • 利用 Arduino 的 millis() 实现非阻塞延时:记录起始时间,在状态中轮询差值。
// 非阻塞延时示例
uint32_t startTime;
bool waitFor(uint32_t duration) {
    if (millis() - startTime >= duration) {
        startTime = millis();
        return true;
    }
    return false;
}

陷阱 2:全局变量滥用导致状态耦合

问题描述

状态机常通过全局变量共享数据,但多个任务同时修改同一变量,会引发数据竞争和逻辑混乱。例如一个任务更新 sensorValue,另一个任务读取并处理,若没有同步机制,状态机可能基于过时数据做出错误决策。

规避方案

  • 封装数据访问:使用 getter/setter 函数,并在内部添加临界区保护(如 noInterrupts()/interrupts())。
  • 采用事件队列:任务间通过队列传递消息,而非直接共享变量。Arduino 可使用 QueueArray 库或自实现环形缓冲区。
  • 状态机内部变量尽量局部化:将状态机上下文封装在结构体中,通过指针传递。
// 封装示例
typedef struct {
    int sensorValue;
} SharedData;

void setSensorValue(SharedData* data, int val) {
    noInterrupts();
    data->sensorValue = val;
    interrupts();
}

int getSensorValue(SharedData* data) {
    int val;
    noInterrupts();
    val = data->sensorValue;
    interrupts();
    return val;
}

陷阱 3:状态爆炸与重复代码

问题描述

随着功能增加,状态机可能膨胀到几十个状态,每个状态内包含大量重复的检查代码,导致维护困难、逻辑难以追踪。例如一个 LED 控制任务,可能需要 LED_ONLED_BLINK_FASTLED_BLINK_SLOW 等多个状态,但核心操作相同。

规避方案

  • 使用状态表驱动:将状态转移条件定义为表项,用查表代替 switch-case
  • 引入子状态机:将复杂状态拆分为独立的小状态机,通过父状态机调度。
  • 复用公共代码:将通用操作(如读取传感器、更新显示)提取为独立函数,在多个状态中调用。
// 状态表驱动示例
typedef void (*StateHandler)(void);
StateHandler stateTable[] = {stateInit, stateRun, stateError};

void scheduler() {
    while (1) {
        stateTable[currentState]();
        // 根据事件更新 currentState
    }
}

陷阱 4:时间基准漂移

问题描述

协作式调度器常依赖 millis()micros() 作为时间基准。但若在状态机中频繁调用 delay() 或执行长耗时操作,会导致时间基准累积误差,影响定时任务(如每 100ms 采样一次)。

规避方案

  • 统一使用单调时钟:在调度器主循环中维护一个 tick 计数器,基于 millis() 增量更新,避免各任务直接调用 millis()
  • 避免在状态机中修改系统时间:如 setTime() 等操作会破坏基准。
  • 使用硬件定时器:对于高精度需求,使用 Timer1 或 Timer2 产生中断,在中断中设置标志位,主循环处理。
// 调度器 tick 示例
uint32_t lastTick = 0;
uint32_t tick = 0;

void updateTick() {
    uint32_t now = millis();
    if (now - lastTick >= 1) {
        tick++;
        lastTick = now;
    }
}

陷阱 5:中断与状态机竞争

问题描述

Arduino 的 attachInterrupt() 或定时器中断可能在任何时刻触发,若中断服务程序(ISR)中修改了状态机使用的变量,而主循环正在执行状态转移,就会产生竞争条件,导致状态错乱或数据损坏。

规避方案

  • ISR 中只做标记:在 ISR 中仅设置一个 volatile 标志,主循环中检测并处理。
  • 使用临界区保护关键代码:在状态机修改共享数据时,用 noInterrupts()/interrupts() 包裹,但注意临界区时间要短。
  • 采用双缓冲:ISR 写入缓冲区 A,主循环处理缓冲区 B,然后交换。
volatile bool isrFlag = false;

void isrHandler() {
    isrFlag = true;  // 仅置位
}

void loop() {
    if (isrFlag) {
        noInterrupts();
        isrFlag = false;
        interrupts();
        // 处理事件
    }
}

总结

协作式调度器 + 状态机在 Arduino 上是一种高效、省资源的方案,但必须警惕上述 5 个陷阱。核心原则是:非阻塞、数据隔离、时间统一、中断安全。通过合理设计状态表、封装数据访问、使用单调时钟和中断标志,您可以构建出稳定可靠的嵌入式系统。记住,状态机不是银弹,但掌握这些规避技巧,它将成为您嵌入式工具箱中的利器。