引言:告别 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_OFFLED_ON 间切换,每次切换更新 timestamp
  • 按键消抖:通过检查下降沿(从高到低)来检测按下,简单有效。

扩展:状态机的高级用法

对于更复杂的任务(如菜单导航、通信协议),状态机可进一步细化:

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上实现多任务并发,同时保持代码清晰可维护。掌握这一模式,你的嵌入式开发能力将更上一层楼。