# 引言:告别 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()` 返回自启动以来的毫秒数(无符号长整型)。我们通过**比较时间差**来实现非阻塞延时: ```c unsigned long currentMillis = millis(); if (currentMillis - previousMillis >= interval) { previousMillis = currentMillis; // 执行动作 } ``` 注意:使用 `currentMillis - previousMillis` 而非 `currentMillis > previousMillis + interval`,可避免溢出问题(当 `millis()` 回绕时)。 ## 3. 任务调度器结构 我们定义一个任务结构体,包含状态、时间戳、间隔和函数指针: ```c 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打印一次系统运行时间) # 完整代码示例 ```c // 非阻塞多任务调度示例 #include // 任务状态枚举 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`。 - **按键消抖**:通过检查下降沿(从高到低)来检测按下,简单有效。 # 扩展:状态机的高级用法 对于更复杂的任务(如菜单导航、通信协议),状态机可进一步细化: ```c enum { ST_INIT, ST_WAIT_HEADER, ST_WAIT_DATA, ST_PROCESS }; ``` 每个状态对应一个处理函数,通过事件(如串口接收)触发迁移。这种模式可读性强,易于调试。 # 注意事项 1. **避免阻塞操作**:在任务函数中,严禁使用 `delay()`、`while` 等待等阻塞代码。若需等待外部事件,应使用状态机等待。 2. **时间戳溢出**:使用 `unsigned long` 差值比较,可安全处理 `millis()` 回绕(约49天)。 3. **任务间隔**:间隔过短会导致CPU占用高,建议根据任务需求合理设置(如按键扫描20ms,LED闪烁500ms)。 4. **共享资源**:若多个任务访问同一变量(如全局标志),需考虑原子性。在Arduino上,8位MCU的32位变量读写可能被中断,可临时关闭中断保护。 5. **调试**:使用串口打印状态变化,便于跟踪。但注意串口打印本身可能阻塞,建议仅在调试时使用。 6. **内存优化**:对于大量任务,可考虑使用 `PROGMEM` 存储常量,或精简结构体。 # 总结 基于状态机的非阻塞调度方案,是Arduino开发中提升系统响应性的利器。它无需额外库,代码轻量,易于扩展。通过将任务分解为状态和事件,我们得以在单线程MCU上实现多任务并发,同时保持代码清晰可维护。掌握这一模式,你的嵌入式开发能力将更上一层楼。