引言:从阻塞到非阻塞的思维跃迁

Arduino 生态的简易性常让开发者忽视其背后的实时性挑战。传统 delay() 会暂停整个 CPU,导致 LED 闪烁与按键扫描无法并行。而状态机 + 非阻塞调度是嵌入式系统的经典解法。本文提出一种模板化封装,将状态机的通用逻辑抽象为可复用组件,让您只需关注业务状态转换,而无需重复编写调度代码。

核心原理:状态机与时间片轮转

状态机基础

状态机由状态事件转移组成。在非阻塞场景下,每个任务维护一个当前状态,并在 loop() 中周期性检查时间戳,决定是否执行状态转移。例如,LED 闪烁任务可拆分为 ONOFF 两个状态,每个状态持续固定时间后切换。

模板化封装思路

模板允许我们定义类型无关的任务基类。核心思想:

  • 每个任务继承自 Task 基类,重写 update() 方法。
  • 基类管理 lastRunTimeinterval,通过 millis() 比较实现非阻塞延时。
  • 使用模板指定任务数量,避免动态内存分配,适合资源受限的 MCU。

配置步骤:构建模板化任务调度器

1. 定义任务基类

class Task {
public:
  virtual void update() = 0;  // 纯虚函数,子类实现具体逻辑
  bool shouldRun() {
    return (millis() - lastRun >= interval);
  }
  void run() {
    if (shouldRun()) {
      lastRun = millis();
      update();
    }
  }
protected:
  unsigned long lastRun = 0;
  unsigned long interval = 0;
};

2. 模板化调度器

template <int N>
class TaskScheduler {
public:
  void addTask(Task* task, int index) {
    if (index < N) tasks[index] = task;
  }
  void runAll() {
    for (int i = 0; i < N; i++) {
      if (tasks[i]) tasks[i]->run();
    }
  }
private:
  Task* tasks[N] = {nullptr};
};

3. 实现具体状态机任务

以双 LED 交替闪烁为例:

class BlinkTask : public Task {
public:
  BlinkTask(int pin, unsigned long interval) : pin(pin) {
    this->interval = interval;
    pinMode(pin, OUTPUT);
    state = LOW;
  }
  void update() override {
    state = !state;
    digitalWrite(pin, state);
  }
private:
  int pin;
  bool state;
};

完整示例:多任务协同

#include <Arduino.h>

// 基类与调度器定义(如上)...

// 按键扫描任务(状态机:等待->去抖->确认)
class ButtonTask : public Task {
public:
  ButtonTask(int pin) : pin(pin) {
    this->interval = 10; // 10ms 扫描
    pinMode(pin, INPUT_PULLUP);
    state = 0;
  }
  void update() override {
    bool reading = digitalRead(pin);
    switch (state) {
      case 0: // 等待按下
        if (reading == LOW) { state = 1; lastDebounce = millis(); }
        break;
      case 1: // 去抖
        if (reading == LOW && (millis() - lastDebounce > 50)) {
          state = 2;
          Serial.println("Pressed!");
        } else if (reading == HIGH) {
          state = 0;
        }
        break;
      case 2: // 等待释放
        if (reading == HIGH) state = 0;
        break;
    }
  }
private:
  int pin;
  int state;
  unsigned long lastDebounce;
};

TaskScheduler<3> scheduler;
BlinkTask led1(13, 500);
BlinkTask led2(12, 250);
ButtonTask btn(2);

void setup() {
  Serial.begin(9600);
  scheduler.addTask(&led1, 0);
  scheduler.addTask(&led2, 1);
  scheduler.addTask(&btn, 2);
}

void loop() {
  scheduler.runAll();
}

进阶技巧:状态机与模板的深度融合

使用模板参数传递状态类型

若状态较多,可定义 enum class 状态,并通过模板参数传入任务类,实现类型安全:

template <typename StateType>
class StateMachineTask : public Task {
public:
  StateMachineTask(StateType initState) : currentState(initState) {}
protected:
  StateType currentState;
  virtual void onStateChange(StateType newState) = 0;
};

时间片轮转的精度优化

  • 使用 micros() 替代 millis() 获得微秒级精度,但注意溢出周期更短。
  • 对于高频任务,可考虑使用定时器中断,但模板方法更简单且足够应对大多数场景。

注意事项与常见陷阱

  • 避免在 update() 中使用 delay():否则会阻塞整个调度器。
  • 注意 millis() 溢出:约 49 天后归零,但 unsigned long 减法仍正确,无需特殊处理。
  • 任务数量固定:模板参数需在编译期确定,若需动态增减,可改用链表或 std::vector(但避免在 MCU 上使用动态内存)。
  • 状态机内避免长循环:若某状态需要耗时操作,应拆分为多个子状态,逐步执行。
  • 共享资源访问:多任务间共享变量时,需考虑原子性,必要时使用 volatile 或临界区。

总结

通过模板化封装,我们构建了一个轻量级、可扩展的非阻塞任务调度器。它让 Arduino 程序从线性阻塞中解放,实现真正的并发处理。这种模式不仅适用于 LED 闪烁、按键扫描,还可扩展到传感器读取、通信协议解析等复杂场景。掌握它,您将迈入嵌入式实时编程的门槛,为更复杂的系统设计打下坚实基础。