引言:从阻塞到非阻塞的思维跃迁
Arduino 生态的简易性常让开发者忽视其背后的实时性挑战。传统 delay() 会暂停整个 CPU,导致 LED 闪烁与按键扫描无法并行。而状态机 + 非阻塞调度是嵌入式系统的经典解法。本文提出一种模板化封装,将状态机的通用逻辑抽象为可复用组件,让您只需关注业务状态转换,而无需重复编写调度代码。
核心原理:状态机与时间片轮转
状态机基础
状态机由状态、事件和转移组成。在非阻塞场景下,每个任务维护一个当前状态,并在 loop() 中周期性检查时间戳,决定是否执行状态转移。例如,LED 闪烁任务可拆分为 ON 和 OFF 两个状态,每个状态持续固定时间后切换。
模板化封装思路
模板允许我们定义类型无关的任务基类。核心思想:
- 每个任务继承自
Task基类,重写update()方法。 - 基类管理
lastRunTime和interval,通过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 闪烁、按键扫描,还可扩展到传感器读取、通信协议解析等复杂场景。掌握它,您将迈入嵌入式实时编程的门槛,为更复杂的系统设计打下坚实基础。