Arduino非阻塞编程实战:基于millis()实现多任务调度与状态机

Arduino非阻塞编程实战:基于millis()实现多任务调度与状态机

1. 项目概述:为什么Arduino需要“周期性任务”?

如果你玩过一阵子Arduino,大概率会写过这样的代码:在loop()函数里,用delay()函数来控制LED闪烁,或者每隔几秒读取一次传感器。这确实能跑起来,但很快你就会遇到一个核心痛点——当你需要让LED以固定频率闪烁,同时又要实时响应一个按钮的按下事件时,delay()就成了最大的绊脚石。因为delay(1000)会让整个程序“傻等”一秒钟,在这期间,CPU什么都干不了,按钮按烂了也没反应。这就是典型的“阻塞式”编程,它让Arduino这个单线程的微控制器显得无比笨拙。

“周期性任务”要解决的,正是这个“一心不能二用”的难题。它的核心思想是非阻塞定时。我们不再让CPU空转等待,而是教会它“看表办事”:记录每个任务上一次执行的时间,然后不断地检查“现在的时间”是否已经超过了“上次执行时间 + 设定的周期”。如果超过了,就执行任务并更新“上次执行时间”;如果没超过,就立刻跳过,去检查下一个任务或者处理其他即时事件(比如读取按钮状态)。这样,多个任务在宏观上就像是在“同时”运行。

这不仅仅是让LED闪烁和按钮响应共存那么简单。在更复杂的项目中,比如我做过的一个智能温室控制器,需要周期性执行的任务包括:每5分钟读取一次温湿度传感器、每1小时记录一次数据到SD卡、每30秒检查一次网络连接状态,同时还要随时准备响应手动浇水的指令。如果只用delay(),这个系统根本无法工作。而通过实现一套清晰的周期性任务管理机制,这些功能都能流畅、稳定地协同运行,代码结构也清晰得像一本操作手册,后期添加光照控制、通风控制等新任务都变得非常容易。

所以,掌握“周期性任务”的编程范式,是你从Arduino新手迈向能构建实用、可靠嵌入式系统的关键一步。它剥离了“并发”和“多线程”这些复杂概念的外衣,用一种在资源受限的单片机上极其高效且易懂的方式,实现了真正的多任务处理。

2. 从millis()到状态机:非阻塞定时的核心原理

实现周期性任务,基石就是millis()函数。这个函数返回Arduino板从上电开始到现在的毫秒数,数据类型是unsigned long。它就像一个永不停止的毫秒级跑表。基于它的非阻塞定时逻辑,可以用下面这个最经典的代码片段来概括:

unsigned long previousMillis = 0; // 记录上次执行的时间 const long interval = 1000; // 任务执行间隔,1000毫秒 void loop() { unsigned long currentMillis = millis(); // 获取当前时间 // 检查是否到达执行间隔 if (currentMillis - previousMillis >= interval) { // 保存本次执行的时间点 previousMillis = currentMillis; // 在这里执行你的周期性任务 digitalWrite(LED_BUILTIN, !digitalRead(LED_BUILTIN)); // LED状态翻转 } // 这里可以放心地添加其他需要立即响应的代码,比如读取按钮 // 它们不会因为上面的if判断而阻塞 }

这段代码的精髓在于currentMillis - previousMillis >= interval这个条件判断。这里有几个必须理解的细节:

为什么用减法比较,而不是直接比较currentMillis因为millis()返回的unsigned long类型大约在50天后会溢出归零(2^32毫秒 ≈ 49.7天)。如果直接写if (currentMillis >= previousMillis + interval),当previousMillis是一个很大的值(接近溢出点),而interval加上去导致数值溢出时,这个条件判断就会出错。而使用减法currentMillis - previousMillis,在C/C++的无符号整数运算规则下,即使currentMillis因为溢出而小于previousMillis,减法的结果也会自动处理为从最大值绕回后的正确时间差,只要这个时间差小于interval,条件就不会触发。这是一种巧妙且安全的“溢出免疫”比较方法。

interval应该设多大?这取决于你的任务对周期精度的要求。millis()本身有微小的误差(主要来自晶振精度),但对于大多数应用(秒级、分钟级)来说完全足够。需要注意的是,loop()循环一次的时间必须远小于interval,否则你可能错过时间点。例如,如果loop()一次要跑200ms,而interval设为100ms,那么任务永远无法准时执行。通常,确保loop()中除了delay()之外的代码执行时间很短(几毫秒内),是良好设计的前提。

单一任务到多任务的状态机演进上面只是一个LED任务。当任务多起来时,为每个任务都写一套previousMillisinterval会显得冗长。更优雅的做法是引入“状态机”的思想,将每个任务封装成一个结构体(struct):

struct Task { unsigned long previousMillis; // 该任务上次执行时间 const long interval; // 执行间隔 void (*function)(); // 指向任务函数的指针 }; Task taskList[] = { {0, 1000, blinkLED}, // 任务1:1秒闪LED {0, 2000, readSensor}, // 任务2:2秒读传感器 {0, 5000, logData} // 任务3:5秒记录数据 }; const int taskCount = sizeof(taskList) / sizeof(taskList[0]); void loop() { unsigned long currentMillis = millis(); for (int i = 0; i < taskCount; i++) { Task &t = taskList[i]; if (currentMillis - t.previousMillis >= t.interval) { t.previousMillis = currentMillis; t.function(); // 执行具体的任务函数 } } // 其他非周期性代码 }

这就构成了一个最简单的协作式任务调度器。每个任务都是一个独立的状态(记录了自己的上次执行时间),调度器(loop中的for循环)负责检查每个状态是否满足触发条件并执行。这种方法的扩展性极好,新增任务只需在taskList数组中添加一行即可。

注意:这里所有任务的检查和执行都是在同一个loop()循环中顺序进行的。这意味着如果某个任务函数t.function()执行时间过长,会延迟后续任务的检查与执行。因此,确保每个任务函数本身也是高效、非阻塞的,是这种架构能稳定工作的黄金法则。

3. 实战构建:一个多任务环境监测站

让我们把这些理论投入实战。假设我们要做一个简易的室内环境监测站,它需要完成以下周期性任务:

  1. 任务A:每2秒读取一次DHT11温湿度传感器,并刷新到串口监视器。
  2. 任务B:每5秒读取一次BH1750光照强度传感器。
  3. 任务C:每60秒检查一次,如果温度超过28°C,则点亮一个报警LED。
  4. 即时任务:随时检测一个按钮,按下时在串口打印一条消息。

同时,我们要求系统响应灵敏,按钮检测不能有延迟。

3.1 硬件连接与库准备

首先,需要以下硬件:

  • Arduino Uno 一块
  • DHT11温湿度传感器模块 一个
  • BH1750光照传感器模块 一个
  • LED 一个(用于超温报警)
  • 10kΩ电阻 一个
  • 按钮 一个
  • 面包板和若干杜邦线

接线示意图如下:

  • DHT11:VCC -> 5V, GND -> GND, DATA -> 数字引脚 2
  • BH1750:VCC -> 3.3V或5V, GND -> GND, SCL -> A5, SDA -> A4
  • 报警LED:正极通过220Ω电阻接数字引脚 13,负极接GND。
  • 按钮:一端接数字引脚 7,另一端通过10kΩ下拉电阻接GND,同时按钮该端也直接接5V。

在Arduino IDE中,需要安装库:DHT sensor libraryBH1750。可以通过“工具”->“管理库”进行搜索安装。

3.2 代码实现与逐行解析

以下是完整的实现代码,包含了基于millis()的多任务调度:

#include <DHT.h> #include <Wire.h> #include <BH1750.h> // 引脚定义 #define DHTPIN 2 #define DHTTYPE DHT11 #define BUTTON_PIN 7 #define ALARM_LED_PIN 13 // 传感器对象初始化 DHT dht(DHTPIN, DHTTYPE); BH1750 lightMeter; // 任务结构体定义 struct Task { unsigned long prevMillis; const long interval; void (*function)(); }; // 全局变量,用于在任务间传递数据 float currentTemp = 0.0; float currentHumidity = 0.0; float currentLux = 0.0; // 任务函数声明 void taskReadDHT(); void taskReadBH1750(); void taskCheckTempAlarm(); // 任务列表配置 Task taskList[] = { {0, 2000, taskReadDHT}, // 每2秒执行 {0, 5000, taskReadBH1750}, // 每5秒执行 {0, 60000, taskCheckTempAlarm} // 每60秒执行 }; const int taskCount = sizeof(taskList) / sizeof(taskList[0]); void setup() { Serial.begin(9600); Serial.println("环境监测站启动..."); dht.begin(); Wire.begin(); lightMeter.begin(BH1750::CONTINUOUS_HIGH_RES_MODE, 0x23); // 默认I2C地址 pinMode(BUTTON_PIN, INPUT); pinMode(ALARM_LED_PIN, OUTPUT); digitalWrite(ALARM_LED_PIN, LOW); // 初始关闭报警灯 } void loop() { unsigned long currentMillis = millis(); // 1. 调度执行所有周期性任务 for (int i = 0; i < taskCount; i++) { Task &t = taskList[i]; if (currentMillis - t.prevMillis >= t.interval) { t.prevMillis = currentMillis; t.function(); } } // 2. 处理即时任务:按钮检测(非阻塞) if (digitalRead(BUTTON_PIN) == HIGH) { // 简单延时消抖,注意这里用的是非阻塞方式检查时间,实际项目建议用更高级的消抖 static unsigned long lastDebounceTime = 0; if (currentMillis - lastDebounceTime > 50) { // 50毫秒消抖 lastDebounceTime = currentMillis; Serial.println("[按钮] 被按下!"); // 这里可以触发其他一次性的动作 } } // 3. 其他非阻塞后台工作可以放在这里 // 例如,可以在这里处理简单的串口命令解析(如果命令很短) } // --- 具体任务函数实现 --- void taskReadDHT() { float h = dht.readHumidity(); float t = dht.readTemperature(); if (isnan(h) || isnan(t)) { Serial.println("读取DHT11失败!"); return; } currentHumidity = h; currentTemp = t; Serial.print("DHT => 温度: "); Serial.print(t); Serial.print("°C, 湿度: "); Serial.print(h); Serial.println("%"); } void taskReadBH1750() { if (lightMeter.measurementReady()) { currentLux = lightMeter.readLightLevel(); Serial.print("BH1750 => 光照强度: "); Serial.print(currentLux); Serial.println(" lx"); } } void taskCheckTempAlarm() { Serial.println("--- 执行温度报警检查 ---"); if (currentTemp > 28.0) { digitalWrite(ALARM_LED_PIN, HIGH); Serial.println("警告:温度超过28°C,报警灯亮起!"); } else { digitalWrite(ALARM_LED_PIN, LOW); Serial.println("温度正常,报警灯关闭。"); } }

代码关键点解析:

  1. 全局状态变量currentTemp,currentHumidity,currentLux用于在任务间共享数据。taskCheckTempAlarm任务需要读取currentTemp来判断,而这个值是由taskReadDHT任务更新的。这种共享变量是简单任务间通信的有效方式,但要注意如果未来在中断服务程序中也会修改这些变量,则需要考虑原子操作或禁止中断来保护。
  2. 任务函数设计:每个任务函数都尽可能短小精悍,只做一件事(读传感器、判断逻辑、控制输出)。它们内部绝对不能使用delay()taskReadBH1750中使用了measurementReady()来检查数据是否就绪,这是一种非阻塞的查询方式。
  3. 按钮消抖的非阻塞实现:在loop()的按钮检测部分,我们使用了另一个基于millis()的时间判断来实现消抖。它记录了上一次有效按键的时间 (lastDebounceTime),只有当前时间与上次时间差大于50毫秒,才认为是一次新的有效按键。这避免了使用delay(50)导致整个系统暂停。
  4. 调度顺序:任务按照它们在taskList数组中的顺序被检查。理论上,执行间隔短的任务(如2秒的DHT)应该放在前面,以减少其执行延迟。但在这个例子中,由于每个任务函数执行都很快(几毫秒内),顺序影响微乎其微。

将代码上传后,打开串口监视器,你会看到类似下面的输出,并且可以随时按下按钮看到即时响应,报警LED也会根据温度条件亮灭。整个系统运行流畅,没有任何一个功能会阻塞另一个。

环境监测站启动... DHT => 温度: 25.30°C, 湿度: 45.00% BH1750 => 光照强度: 320.50 lx DHT => 温度: 25.30°C, 湿度: 45.00% [按钮] 被按下! DHT => 温度: 25.40°C, 湿度: 45.00% BH1750 => 光照强度: 315.20 lx DHT => 温度: 25.40°C, 湿度: 45.00% --- 执行温度报警检查 --- 温度正常,报警灯关闭。 ...

4. 进阶:应对更复杂场景与提升可靠性

基础的多任务调度已经能解决80%的问题。但当项目变得更加复杂,比如任务执行时间差异巨大、需要处理外部中断、或者任务需要更灵活的触发条件(如单次延迟执行、取消执行)时,我们就需要更强大的工具和更严谨的设计。

4.1 当任务执行时间过长:拆分与状态机深化

假设我们新增一个“任务D”:每10分钟通过Wi-Fi向服务器发送一次所有传感器数据的汇总报告。这个sendDataToServer()函数可能因为网络原因需要几百毫秒甚至几秒才能完成。如果直接把它放在任务函数里,会严重阻塞其他任务的调度。

解决方案是任务内部分拆成非阻塞状态机。我们不再把“发送数据”看作一个瞬间动作,而是一个有多个步骤的过程:

enum ReportState { IDLE, CONNECTING, SENDING, WAITING_RESPONSE, DISCONNECTING }; ReportState reportState = IDLE; unsigned long reportStateMillis = 0; const long reportInterval = 600000; // 10分钟 void taskSendReport() { unsigned long now = millis(); switch (reportState) { case IDLE: if (now - reportStateMillis >= reportInterval) { Serial.println("开始连接服务器..."); // 非阻塞的WiFi连接开始 WiFi.begin(ssid, password); reportState = CONNECTING; reportStateMillis = now; } break; case CONNECTING: if (WiFi.status() == WL_CONNECTED) { Serial.println("连接成功,准备发送数据..."); // 开始发送HTTP请求 startHttpRequest(); reportState = SENDING; reportStateMillis = now; } else if (now - reportStateMillis > 10000) { // 连接超时10秒 Serial.println("连接超时,返回空闲状态。"); reportState = IDLE; reportStateMillis = now; } break; case SENDING: // 检查HTTP请求是否发送完成 if (httpRequestFinished()) { Serial.println("数据发送完成,等待响应..."); reportState = WAITING_RESPONSE; reportStateMillis = now; } break; case WAITING_RESPONSE: if (now - reportStateMillis > 5000) { // 等待响应超时 Serial.println("响应超时。"); // 清理资源 cleanupHttpClient(); reportState = IDLE; reportStateMillis = now; } else if (httpResponseReady()) { // 处理响应... cleanupHttpClient(); Serial.println("报告任务完成。"); reportState = IDLE; reportStateMillis = now; // 重置计时,开始下一个10分钟周期 } break; } }

然后,在loop()的主任务调度循环中,以较短的间隔(比如100ms)频繁调用taskSendReport()。这个函数每次只执行一小步,然后立即返回,不会长时间占用CPU。这样,发送报告这个长任务就被“化整为零”,与其他短任务和谐共处了。

4.2 使用现成的调度库:TaskScheduler

对于不想重复造轮子,或者项目非常复杂的朋友,我强烈推荐使用TaskScheduler库。它是一个非常轻量级但功能强大的协作式调度库。

安装后,上面的环境监测站可以这样重构:

#include <TaskScheduler.h> // 声明任务函数 void tReadDHT(); void tReadBH1750(); void tCheckAlarm(); void tMonitorButton(); // 创建任务:参数依次为(执行间隔ms, 执行次数, 任务函数指针, 调度器指针, 是否启用) Task tDHT(2000, TASK_FOREVER, &tReadDHT); Task tBH1750(5000, TASK_FOREVER, &tReadBH1750); Task tAlarm(60000, TASK_FOREVER, &tCheckAlarm); Task tButton(50, TASK_FOREVER, &tMonitorButton); // 每50ms检查一次按钮,用于消抖 Scheduler runner; // 创建调度器实例 void setup() { Serial.begin(9600); // ... 初始化传感器和引脚 ... runner.init(); // 初始化调度器 runner.addTask(tDHT); runner.addTask(tBH1750); runner.addTask(tAlarm); runner.addTask(tButton); tDHT.enable(); tBH1750.enable(); tAlarm.enable(); tButton.enable(); } void loop() { runner.execute(); // 调度器接管loop,它会自动在合适的时间调用各个任务函数 } void tReadDHT() { /* 同前 */ } void tReadBH1750() { /* 同前 */ } void tCheckAlarm() { /* 同前 */ } void tMonitorButton() { // 这里可以放置更稳健的按钮状态机代码 static int lastButtonState = LOW; int currentState = digitalRead(BUTTON_PIN); if (currentState != lastButtonState) { delay(50); // 注意:这里用了delay,但因为此任务被调度器以50ms间隔调用,且delay时间小于间隔,所以影响可控。更优解仍是状态机。 if (currentState == digitalRead(BUTTON_PIN)) { // 状态稳定 if (currentState == HIGH) { Serial.println("[按钮] 被按下!"); } lastButtonState = currentState; } } }

TaskScheduler 的优点在于:

  • 代码更清晰:任务的定义、间隔、启用/禁用集中管理。
  • 功能丰富:支持单次执行、按次数执行、任务链(一个任务完成后启动另一个)、任务回调等。
  • 资源占用极低:库本身非常精简。

4.3 中断服务程序(ISR)与共享数据安全

当你的系统需要响应非常紧急的事件,比如旋转编码器计数、紧急停止按钮,就需要用到硬件中断。中断服务程序(ISR)会打断主循环loop()的执行。

这里有一个至关重要的原则:在ISR中,绝对不能调用millis()delay(),以及任何可能依赖中断或进行复杂I/O操作的函数(如Serial.print)。因为millis()本身依赖定时器中断,在ISR中调用它可能导致不可预知的行为。

ISR应该只做最简单、最快的事情:通常就是设置一个标志位(volatile修饰的变量)或者增加一个计数器。

volatile bool buttonPressed = false; // 必须用volatile,确保编译器不对其优化 void setup() { attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), buttonISR, RISING); } void buttonISR() { // 只做这一件事! buttonPressed = true; } void loop() { // 在主循环中检查标志位并处理 if (buttonPressed) { buttonPressed = false; // 这里可以安全地使用millis()进行消抖和逻辑处理 unsigned long now = millis(); static unsigned long lastPressTime = 0; if (now - lastPressTime > 200) { // 200ms内只响应一次 lastPressTime = now; Serial.println("中断触发按钮事件!"); // 执行相关的任务... } } // ... 其他任务调度 ... }

共享数据安全:如果ISR和主循环都会修改同一个变量(比如一个计数器),而该变量大于一个字节(如int,long),在8位AVR架构的Arduino上,读写操作可能不是原子的(可能被中断打断,导致读到一半被修改的脏数据)。对于这种情况,最简单的保护方法是在主循环中读写该变量时,临时关闭中断:

volatile long pulseCount = 0; void loop() { long currentCount; noInterrupts(); // 关闭中断 currentCount = pulseCount; // 安全地复制值 interrupts(); // 重新开启中断 // 使用currentCount... }

对于更复杂的项目,深入理解并妥善处理中断与主循环的协作,是保证系统长期稳定运行的关键。周期性任务框架与中断机制结合,能让你的Arduino项目既具备按时处理常规事务的能力,又能对紧急事件做出闪电般的响应。