引言:告别 delay(),拥抱非阻塞
在Arduino生态中,delay() 函数因其简单直观而被广泛使用,但它会独占CPU,导致其他任务无法执行。例如,一个简单的LED闪烁程序,若在循环中调用 delay(1000),则按键扫描、传感器读取等操作都会被阻塞,系统响应性极差。
非阻塞式编程的核心思想是:任务不等待,而是周期性地检查条件并推进状态。本文将介绍一种基于有限状态机(FSM) 的轻量级调度方案,它无需RTOS,仅用 millis() 和结构体即可实现多任务并发。
原理:状态机 + 时间片
1. 为什么是状态机?
每个任务可以抽象为一系列状态,每个状态对应一段不阻塞的代码(如读取引脚、更新变量)。通过事件(时间到达、输入变化)触发状态迁移。例如,LED闪烁任务可拆分为:
-
LED_ON:点亮LED,记录时间戳,迁移到LED_OFF -
LED_OFF:熄灭LED,记录时间戳,迁移到LED_ON
这样,在任意时刻,任务只执行当前状态的一小段代码,然后立即返回 loop(),让其他任务有机会运行。
2. 时间管理:millis() 非阻塞延时
millis() 返回自启动以来的毫秒数(无符号长整型)。我们通过比较时间差来实现非阻塞延时:
unsigned long currentMillis = millis();
if (currentMillis - previousMillis >= interval) {
previousMillis = currentMillis;
// 执行动作
}
注意:使用 currentMillis - previousMillis 而非 currentMillis > previousMillis + interval,可避免溢出问题(当 millis() 回绕时)。
3. 任务调度器结构
我们定义一个任务结构体,包含状态、时间戳、间隔和函数指针:
typedef struct {
uint8_t state; // 当前状态
unsigned long timestamp; // 上次更新时间
unsigned long interval; // 状态切换间隔
void (*func)(void); // 任务处理函数(可选)
} Task;
在 loop() 中,遍历所有任务,调用各自的处理函数。每个函数内部根据状态和时间决定是否执行动作。
配置步骤
1. 硬件准备
- Arduino Uno(或其他板)
- 一个LED(接D13,内置)
- 一个按键(接D2,带下拉电阻)
- 串口监视器
2. 软件设计
我们将实现三个任务:
- Task1:LED闪烁(每500ms切换状态)
- Task2:按键扫描(每20ms检测一次,按下时打印消息)
- Task3:串口输出(每2s打印一次系统运行时间)
完整代码示例
// 非阻塞多任务调度示例
#include <Arduino.h>
// 任务状态枚举
enum {
LED_OFF,
LED_ON
};
enum {
KEY_IDLE,
KEY_PRESSED
};
// 任务结构体
struct Task {
uint8_t state;
unsigned long timestamp;
unsigned long interval;
void (*func)(struct Task*);
};
// 任务函数声明
void taskLED(struct Task* t);
void taskKey(struct Task* t);
void taskSerial(struct Task* t);
// 定义任务实例
Task tasks[] = {
{LED_OFF, 0, 500, taskLED}, // LED闪烁
{KEY_IDLE, 0, 20, taskKey}, // 按键扫描
{0, 0, 2000, taskSerial} // 串口输出
};
const int numTasks = sizeof(tasks) / sizeof(Task);
// 按键引脚
const int keyPin = 2;
void setup() {
pinMode(LED_BUILTIN, OUTPUT);
pinMode(keyPin, INPUT_PULLUP); // 使用内部上拉,按键接地
Serial.begin(9600);
}
void loop() {
for (int i = 0; i < numTasks; i++) {
tasks[i].func(&tasks[i]);
}
}
// LED闪烁任务
void taskLED(struct Task* t) {
unsigned long now = millis();
if (now - t->timestamp >= t->interval) {
t->timestamp = now;
switch (t->state) {
case LED_OFF:
digitalWrite(LED_BUILTIN, HIGH);
t->state = LED_ON;
break;
case LED_ON:
digitalWrite(LED_BUILTIN, LOW);
t->state = LED_OFF;
break;
}
}
}
// 按键扫描任务(带消抖)
void taskKey(struct Task* t) {
unsigned long now = millis();
if (now - t->timestamp >= t->interval) {
t->timestamp = now;
static uint8_t lastState = HIGH;
uint8_t currentState = digitalRead(keyPin);
if (lastState == HIGH && currentState == LOW) {
// 按下沿
Serial.println("Key pressed!");
}
lastState = currentState;
}
}
// 串口输出任务
void taskSerial(struct Task* t) {
unsigned long now = millis();
if (now - t->timestamp >= t->interval) {
t->timestamp = now;
Serial.print("Uptime: ");
Serial.println(now / 1000.0, 1); // 秒
}
}
代码解析
-
任务结构体:每个任务包含状态、时间戳、间隔和处理函数指针。
loop()中循环调用,实现“伪并发”。 -
时间检查:每个任务独立检查
millis()差值,互不干扰。 -
状态迁移:在
taskLED中,状态在LED_OFF和LED_ON间切换,每次切换更新timestamp。 - 按键消抖:通过检查下降沿(从高到低)来检测按下,简单有效。
扩展:状态机的高级用法
对于更复杂的任务(如菜单导航、通信协议),状态机可进一步细化:
enum { ST_INIT, ST_WAIT_HEADER, ST_WAIT_DATA, ST_PROCESS };
每个状态对应一个处理函数,通过事件(如串口接收)触发迁移。这种模式可读性强,易于调试。
注意事项
-
避免阻塞操作:在任务函数中,严禁使用
delay()、while等待等阻塞代码。若需等待外部事件,应使用状态机等待。 -
时间戳溢出:使用
unsigned long差值比较,可安全处理millis()回绕(约49天)。 - 任务间隔:间隔过短会导致CPU占用高,建议根据任务需求合理设置(如按键扫描20ms,LED闪烁500ms)。
- 共享资源:若多个任务访问同一变量(如全局标志),需考虑原子性。在Arduino上,8位MCU的32位变量读写可能被中断,可临时关闭中断保护。
- 调试:使用串口打印状态变化,便于跟踪。但注意串口打印本身可能阻塞,建议仅在调试时使用。
-
内存优化:对于大量任务,可考虑使用
PROGMEM存储常量,或精简结构体。
总结
基于状态机的非阻塞调度方案,是Arduino开发中提升系统响应性的利器。它无需额外库,代码轻量,易于扩展。通过将任务分解为状态和事件,我们得以在单线程MCU上实现多任务并发,同时保持代码清晰可维护。掌握这一模式,你的嵌入式开发能力将更上一层楼。