ESP32 综合教程:用 FreeRTOS 队列拆分采样、显示与联网任务
本文由 AI 协助整理,示例使用 Arduino-ESP32 自带的 FreeRTOS API。ESP32、ESP32-S2、ESP32-C3、ESP32-S3 等芯片的核心数量和调度配置并不完全相同,教程不依赖固定核心。任务栈、优先级和周期必须根据实际库与负载测量,不能照抄一个数字便认为万事大吉。

为什么一个 loop 写到底会越来越难维护
最初的 ESP32 程序通常很清楚:读传感器,刷新屏幕,连接 WiFi,发布 MQTT,然后延时两秒。功能只有一个时,这种写法没有问题。等项目增加网页、按键、SD 卡和自动重连以后,loop() 就会变成一个忙碌的前台:每个人都说自己只占一分钟,最后谁也排不上号。
最典型的现象是网络重连阻塞十秒,传感器十秒没采样,OLED 也像死机;或者 SD 卡写入变慢,网页连接跟着掉线。多任务的意义不是让两个核心显得更忙,而是把不同节奏和不同失败模式隔开。
本教程把系统拆成三个任务:
- 采样任务:固定周期读取传感器,生成一份不可变的
SensorSample。 - 显示任务:等待最新数据,刷新 OLED。
- 联网任务:等待最新数据,维护 WiFi/MQTT 并上报。
采样任务向显示与联网各发一份数据。网络断线可以慢慢重连,但不能拖住采样。
先认识一个容易忽略的问题:队列不是广播
FreeRTOS 队列中的一条消息,被某个任务 xQueueReceive() 取出后,就从队列消失。如果显示任务和联网任务同时读取同一个队列,它们不会都收到每条数据,而会轮流抢到不同样本。
因此,本项目创建两个队列:displayQueue 和 networkQueue。采样任务把同一结构体分别复制到两个队列。两个队列长度都是 1,并使用 xQueueOverwrite(),含义是“我只关心最新值”。
这很适合仪表盘和遥测:显示任务若慢了一点,不必按顺序补画十个过期温度;网络恢复后也可以先上报最新状态。若业务要求一条都不能丢,例如交易或报警事件,就不能使用覆盖队列,应扩大队列、处理背压,并设计持久化。
定义数据和系统资源
#include <Arduino.h>
#include <WiFi.h>
struct SensorSample {
uint32_t sequence;
uint32_t capturedAtMs;
float temperature;
float humidity;
bool valid;
};
QueueHandle_t displayQueue = nullptr;
QueueHandle_t networkQueue = nullptr;
SemaphoreHandle_t i2cMutex = nullptr;
volatile uint32_t displayOverwriteCount = 0;
volatile uint32_t networkOverwriteCount = 0;
队列按值复制整个结构体,不要把局部变量指针发送给另一个任务。局部变量在函数下一轮可能已经被覆盖,接收方拿到的指针看起来还在,内容却像借来的桌子被主人搬走了。
volatile 不能让复杂共享操作自动线程安全。这里只把计数作为观察示例;正式统计可使用临界区、原子操作或让单一任务拥有计数。
创建长度为 1 的最新值队列
void createSystemResources() {
displayQueue = xQueueCreate(1, sizeof(SensorSample));
networkQueue = xQueueCreate(1, sizeof(SensorSample));
i2cMutex = xSemaphoreCreateMutex();
if (!displayQueue || !networkQueue || !i2cMutex) {
Serial.println("Create RTOS resource failed");
while (true) delay(1000);
}
}
创建失败通常意味着内存不足或系统状态异常。不要继续启动任务并等待一个空句柄在未来制造更隐晦的崩溃。
采样任务:使用固定周期
示例先用模拟数据,替换成 BME280 或 SHT30 时,把真实读取放进 readSensor():
SensorSample readSensor(uint32_t sequence) {
SensorSample sample{};
sample.sequence = sequence;
sample.capturedAtMs = millis();
if (xSemaphoreTake(i2cMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
// 在这里读取 I2C 传感器。
sample.temperature = 24.0f + (sequence % 20) * 0.1f;
sample.humidity = 50.0f + (sequence % 10);
xSemaphoreGive(i2cMutex);
sample.valid = isfinite(sample.temperature) &&
isfinite(sample.humidity) &&
sample.temperature >= -40.0f &&
sample.temperature <= 125.0f &&
sample.humidity >= 0.0f &&
sample.humidity <= 100.0f;
} else {
sample.valid = false;
}
return sample;
}
采样任务使用 vTaskDelayUntil()。它按计划唤醒时间计算周期,比“执行完再延时两秒”更不容易累积漂移:
void sensorTask(void* parameter) {
TickType_t lastWake = xTaskGetTickCount();
uint32_t sequence = 0;
for (;;) {
SensorSample sample = readSensor(sequence++);
xQueueOverwrite(displayQueue, &sample);
xQueueOverwrite(networkQueue, &sample);
vTaskDelayUntil(&lastWake, pdMS_TO_TICKS(2000));
}
}
长度为 1 的队列配合 xQueueOverwrite() 时,旧值会被新值替换。函数是复制结构体,采样任务随后修改局部变量不会影响队列里的副本。
显示任务:没有新数据就阻塞等待
void renderDisplay(const SensorSample& sample) {
if (xSemaphoreTake(i2cMutex, pdMS_TO_TICKS(100)) != pdTRUE) {
Serial.println("Display skipped: I2C busy");
return;
}
// 在这里刷新 OLED。示例用串口代替。
if (sample.valid) {
Serial.printf("DISPLAY seq=%lu temp=%.1f hum=%.1f\n",
static_cast<unsigned long>(sample.sequence),
sample.temperature, sample.humidity);
} else {
Serial.println("DISPLAY sensor error");
}
xSemaphoreGive(i2cMutex);
}
void displayTask(void* parameter) {
SensorSample sample{};
for (;;) {
if (xQueueReceive(displayQueue, &sample, portMAX_DELAY) == pdTRUE) {
renderDisplay(sample);
}
}
}
portMAX_DELAY 让任务没有消息时休眠,不会写一个空循环占满 CPU。收到新样本后才刷新屏幕。
传感器和 OLED 都在同一 I2C 总线上,所以使用互斥锁。锁只保护实际总线访问,格式化字符串和其他耗时工作尽量在锁外完成。拿着锁去连接 WiFi,相当于拿着唯一一把卫生间钥匙出门买菜,其他任务只能站着等。
联网任务:失败不能拖住采样
const char* WIFI_SSID = "YOUR_WIFI";
const char* WIFI_PASS = "YOUR_PASSWORD";
bool ensureWiFi() {
if (WiFi.status() == WL_CONNECTED) return true;
WiFi.begin(WIFI_SSID, WIFI_PASS);
unsigned long started = millis();
while (WiFi.status() != WL_CONNECTED && millis() - started < 5000) {
vTaskDelay(pdMS_TO_TICKS(200));
}
return WiFi.status() == WL_CONNECTED;
}
void publishSample(const SensorSample& sample) {
// 在这里执行有超时上限的 MQTT 或 HTTP 上报。
Serial.printf("NETWORK seq=%lu valid=%d\n",
static_cast<unsigned long>(sample.sequence), sample.valid);
}
void networkTask(void* parameter) {
SensorSample sample{};
unsigned long nextReconnectAt = 0;
for (;;) {
if (WiFi.status() != WL_CONNECTED && millis() >= nextReconnectAt) {
ensureWiFi();
nextReconnectAt = millis() + 10000;
}
if (xQueueReceive(networkQueue, &sample,
pdMS_TO_TICKS(1000)) == pdTRUE) {
if (WiFi.status() == WL_CONNECTED && sample.valid) {
publishSample(sample);
} else {
Serial.println("NETWORK sample not published");
}
}
}
}
联网任务每次等待队列最多一秒,可以定期处理重连。真实 MQTT 客户端还需要频繁运行其 loop() 或事件处理函数,重连也应设置超时与间隔。
网络恢复以后,这个设计只上报最新样本,不补发离线期间的全部数据。如果业务需要补发,应把离线记录写入受控存储,恢复后按序发送,并设置容量上限与去重编号。
创建任务
void setup() {
Serial.begin(115200);
WiFi.mode(WIFI_STA);
// 在这里初始化 Wire、传感器和 OLED。
createSystemResources();
BaseType_t sensorOk = xTaskCreate(
sensorTask, "sensor", 4096, nullptr, 2, nullptr);
BaseType_t displayOk = xTaskCreate(
displayTask, "display", 4096, nullptr, 1, nullptr);
BaseType_t networkOk = xTaskCreate(
networkTask, "network", 6144, nullptr, 1, nullptr);
if (sensorOk != pdPASS || displayOk != pdPASS || networkOk != pdPASS) {
Serial.println("Create task failed");
}
}
void loop() {
vTaskDelay(pdMS_TO_TICKS(1000));
}
栈大小只是起点,不是通用答案。TLS、JSON、大型局部数组和某些显示库都会增加栈需求。任务优先级也不是越高越好;高优先级任务若不阻塞或延时,会饿死低优先级任务,并触发看门狗。
本教程使用 xTaskCreate(),让调度器安排核心,便于兼容单核和双核 ESP32 系列。只有测量证明需要固定核心时,才考虑 xTaskCreatePinnedToCore(),不要把“固定到核心 0”当成一种装饰性优化。

监控任务栈
可以周期打印任务剩余栈的低水位:
Serial.printf("stack words remaining: %u\n",
static_cast<unsigned>(uxTaskGetStackHighWaterMark(nullptr)));
这个值表示历史上最少剩余多少栈空间,单位和具体端口实现有关,应结合 Arduino-ESP32 版本理解。重点不是追求一个漂亮数字,而是观察它是否接近危险边界,并在最重负载下测试。
不要在每个任务里放巨大的局部数组。能静态分配、复用或放到堆上的缓冲区,需要根据生命周期认真选择。栈溢出经常表现成随机重启,看起来像供电问题,实际上是任务背包塞爆了。
队列该等多久
FreeRTOS 队列函数通常允许设置等待时间:
0:立即返回,适合不能阻塞的路径。- 有限时间:给资源一点机会,同时保留超时处理。
portMAX_DELAY:一直等,适合纯消费者等待新消息。
发送关键事件时,如果队列满了,应该统计失败并决定重试、落盘或报警;发送最新状态时,覆盖旧值更合理。队列策略必须由数据语义决定,而不是所有地方统一写成 portMAX_DELAY。
互斥锁、队列和通知怎样选
队列用于传递数据并明确所有权;互斥锁用于短时间保护共享资源,例如 I2C、SPI 或同一个文件;任务通知适合轻量的一对一事件或计数。
不要用互斥锁保护所有变量,导致整个程序重新变成串行。更好的设计是让一个任务拥有一个资源:例如只有存储任务写 SD,其他任务通过队列发送日志记录。这样文件句柄不用被多个任务抢,错误也集中在一个模块处理。
常见错误
两个消费者读取同一队列
结果是每个消费者只拿到一部分数据。需要广播时创建两个队列,或设计发布订阅层。
队列里发送指针
指针指向的内存可能在接收前已失效或被修改。小结构体优先按值复制;大缓冲区需要明确内存池和所有权转移规则。
高优先级任务没有阻塞点
无限循环中既不延时,也不等待队列,会长期占用 CPU。每个常驻任务都要有清楚的节奏或阻塞条件。
持有互斥锁时间太长
锁内执行网络请求、长延时或复杂格式化,会让其他任务超时。锁的范围只覆盖真正共享的资源访问。
在多个任务同时调用同一客户端
许多 Arduino 网络库不是线程安全的。建议只有联网任务拥有 MQTT 或 HTTP 客户端,其他任务通过队列提交上报请求。
分阶段验证
- 只创建采样任务,每两秒打印序号,观察周期。
- 加显示队列和显示任务,确认序号连续更新。
- 加网络队列,先不连接 WiFi,只打印收到的样本。
- 接入真实传感器和 OLED,使用 I2C 互斥锁。
- 接入 WiFi/MQTT,关闭路由器观察采样和显示是否继续。
- 增加故意的网络延迟,确认最新值队列不会无限增长。
- 打印栈低水位、重连次数和采样错误数。
- 连续运行数天,检查内存、任务节奏和错误计数是否稳定。
故障排查表
| 现象 | 优先检查 |
|---|---|
| 随机重启或看门狗报警 | 任务不让出 CPU、栈不足、长临界区 |
| OLED 偶尔乱码 | 多任务同时访问 I2C,或锁范围不正确 |
| 网络断开后采样也停止 | 采样与重连仍在同一阻塞路径 |
| 显示和网络各收到一半数据 | 两个消费者共用一个队列 |
| 队列不断满 | 消费者太慢,或事件数据不应使用最新值覆盖策略 |
| MQTT 偶发崩溃 | 多个任务同时操作非线程安全客户端 |
| 运行一段时间内存下降 | 动态分配、String 拼接或资源未释放 |
后续扩展
可以增加存储任务,把关键事件写入 SD;增加命令队列,让网络任务把控制命令交给执行任务;增加事件组表示 WiFi、MQTT、SD 和传感器状态;使用任务通知唤醒特定工作;为关键任务增加运行时间和超时监控。
项目再扩大时,可以把队列、任务和状态封装到模块中,但不要为了目录漂亮而过早创建十几个抽象层。架构的目的始终是让数据流、资源所有权和失败边界更清楚。
总结
FreeRTOS 多任务项目的核心不是“同时运行三个函数”,而是明确谁拥有资源、数据怎样移动、失败会影响谁。采样、显示和联网分开后,网络掉线不再拖住传感器,OLED 也不必等待 MQTT 重连。
用两个长度为 1 的队列分发最新状态,用互斥锁短暂保护 I2C,用阻塞等待代替空转,再通过栈、队列和错误计数观察系统。把这些原则掌握以后,ESP32 项目会从越来越长的 loop(),变成一组边界清楚、能独立排错的任务。
参考资料
打赏
如果这篇学习记录对你有帮助,欢迎扫码支持后续整理。

打赏支持:王庆岩
浙公网安备 33010602011771号