外观
3.3 FreeRTOS 任务划分
这节要搞懂:当前工程开了哪些任务,它们各自负责什么、怎么互相配合。 需要基础:3.2、第一章 1.5 – 1.8 ⭐ 本章最重要的两节之一
一、什么是任务划分
任务划分指的是:根据功能的执行周期、阻塞特性和优先级要求,把整个系统的功能分配到若干个独立任务中,并确定它们之间的通信方式。
拆任务的三条判断标准,满足任意一条就该拆开:
- 做事的频率不一样 —— 时间每秒刷一次,天气 10 分钟拉一次
- 会不会长时间卡住 —— 联网请求要好几秒,绝不能和显示混在一起
- 急不急 —— 播音断一下人耳立刻能听出来,天气晚一分钟没人在意
也别拆太细
每个任务至少吃 2KB 内存。 五六个任务足够做一个完整项目了。
二、当前工程有哪些任务
工程里的任务分成三类:启动任务、本地任务、在线任务。
启动任务
| 任务 | 什么时候创建 | 干什么 |
|---|---|---|
app_task | app_main() 里唯一创建的任务 | 跑完整个初始化流程,最后进入 update_dashboard_loop() 变成主界面刷新任务 |
app_task 身兼两职
它前半生是"初始化流程",后半生是"主界面刷新循环"。
这和教科书里"main 只负责启动"的写法不一样,但对这个规模的项目完全够用 —— 少一个任务就少 2KB 内存。
本地任务(不依赖网络,LCD 初始化后就创建)
| 任务 | 干什么 |
|---|---|
config_apply_task | 从配置事件队列取事件,把改动应用到硬件(亮度、闹钟等) |
light_task | 读光敏电阻,自动调背光 |
voice_command_task | 处理 PDM 麦克风采到的音频,识别语音命令 |
alarm_task | 每秒比对当前时间和闹钟,到点触发播放铃声 |
sensor_task | 周期读 AHT20 温湿度 |
在线任务(WiFi 连上之后才创建)
| 任务 | 干什么 |
|---|---|
weather_task | 周期请求天气 API |
time_sync_task | 周期做 SNTP 校时 |
在线任务为什么要晚点创建
没连上网就发 HTTP 请求,只会一直失败重试、白占内存和 CPU。
等 WiFi 成功之后再 xTaskCreate,逻辑更简单,也不用在任务里到处判断"现在有没有网"。
系统自己的任务(不用你创建)
| 任务 | 谁创建的 | 干什么 |
|---|---|---|
| NimBLE 主机任务 | app_ble_start() | 处理 BLE 连接,特征值被写时回调你 |
| MQTT 客户端任务 | MQTT 客户端启动时 | 收发消息,收到命令时回调你 |
| WiFi / TCP-IP 任务 | WiFi 初始化时 | 协议栈,优先级很高,业务任务别去挤 |
三、任务之间靠什么配合
工程里用了第一章讲的三样东西:
| 机制 | 工程里的名字 | 用来干嘛 |
|---|---|---|
| 互斥锁(1.7) | s_data_mutex | 保护温湿度、天气这些多个任务都要读写的数据 |
| 队列(1.8) | s_config_event_queue | 传递配置变更事件 |
| 回调函数 | 配置变化 / MQTT 快照 / BLE 状态 | 让协议栈把消息交给你 |
四、配置从哪来,又怎么生效 ⭐
这台设备没有按键,所有设置都来自外部。工程里的配置入口分四类:
| # | 入口 | 改什么 | 走哪条路 |
|---|---|---|---|
| 1 | BLE 配置特征值 | 城市、亮度、音量、闹钟、报警阈值等普通配置 | app_config_update() |
| 2 | BLE WiFi 配网特征值 | 写入 ssid / password | WiFi 配网存储 |
| 3 | MQTT 属性下发 | 部分云端配置项(比 BLE 少) | app_config_update() |
| 4 | HTTP 网页 | WiFi 配网、SD 卡文件管理 | 各自独立的路径 |
别把"普通配置"和"WiFi 凭据"混在一起
它们走的是完全不同的链路,存的也是不同的 NVS 命名空间:
| 存到哪 | 经过谁 | |
|---|---|---|
| 普通配置 | 配置命名空间 | app_config_update() → 配置回调 → config_apply_task |
| WiFi 凭据 | wifi_cfg 命名空间 | app_wifi_provision() / save_wifi() |
改 WiFi 和改亮度不是同一条链路。
普通配置的完整链路
只有第 1、3 类走下面这条路:
BLE 配置特征值 ──► app_config_update_from_json() ──┐
├──► app_config_update()
MQTT 属性下发 ───────────────────────────────────┘ │
▼
写入 NVS(配置)
│
▼
配置变化回调
│
▼
s_config_event_queue
│
▼
config_apply_task
│
▼
把新配置应用到硬件(改背光 / 更新闹钟)这样设计的好处:
- 两种协议,一套处理 —— BLE 和 MQTT 走到
app_config_update()之后就完全一样了 - 回调里不干重活 —— 协议栈的回调只负责"解析 + 更新配置",立刻返回;真正改硬件的活交给
config_apply_task - 加第三种控制方式(语音、红外)只要也调
app_config_update(),下游一行都不用改
回调里绝对不能阻塞
BLE 和 MQTT 的回调跑在协议栈自己的任务上。在里面刷屏、写 NVS、等锁,都会拖慢甚至卡死整个蓝牙 / 网络。
原则和中断服务函数一样:只做最少的事,然后立刻返回。
五、共享数据用互斥锁保护
温湿度、天气这些数据,sensor_task / weather_task 在写,update_dashboard_loop() 在读 —— 典型的竞态场景(1.7 讲过)。
工程里用 s_data_mutex 保护:
c
static SemaphoreHandle_t s_data_mutex;
// 写:sensor_task 采到新数据
xSemaphoreTake(s_data_mutex, portMAX_DELAY);
s_temperature = temp;
s_humidity = humi;
xSemaphoreGive(s_data_mutex);
// 读:主界面循环要显示
xSemaphoreTake(s_data_mutex, portMAX_DELAY);
float t = s_temperature;
float h = s_humidity;
xSemaphoreGive(s_data_mutex);拿着锁的时候别干慢活
锁里面只做内存读写,几微秒就该放开。
绝对不要在锁里刷屏、读传感器、发 HTTP —— 那样别的任务要等你很久。
六、主界面为什么写在循环里而不是状态机
update_dashboard_loop() 是一个周期刷新循环:每隔一小段时间,把时间、日期、天气、温湿度重新画一遍(变了才画,见 3.7)。
为什么不用第一章 1.11 讲的状态机?
因为这台设备没有按键,界面也不需要切换——只有一个主界面。状态机是用来管理"多个界面之间怎么跳转"的,只有一个界面时用不上。
什么时候该上状态机
如果以后要加"长按进设置页""滑动切换天气详情页"这类多界面切换,那时候再引入状态机(1.11 的写法可以直接套用)。
现在不需要,就别提前加。
七、栈大小怎么定
工程里的栈大小写在宏里,比如:
c
#define APP_TASK_STACK_SIZE ...
#define APP_TASK_PRIORITY ...怎么确认够不够用(1.10 讲过):
c
ESP_LOGI(TAG, "[%s] stack remain: %d",
pcTaskGetName(NULL),
uxTaskGetStackHighWaterMark(NULL));把所有功能跑一遍(连网、拉天气、响闹钟、手机改配置),看每个任务的最低剩余:
最终栈大小 = 当前值 - 最低剩余 + 1000(留余量)联网任务要给足栈
weather_task 走 HTTPS,加密握手很吃栈,给少了必崩。这类任务从 6144 起步。
本节要点
app_main()只创建app_task,真正的初始化在app_task里app_task身兼两职:先跑初始化,最后变成主界面刷新循环- 本地任务(配置应用、背光、语音、闹钟、传感器)LCD 初始化后就创建
- 在线任务(天气、校时)等 WiFi 成功后才创建
- BLE 和 MQTT 有各自的系统任务,通过回调把消息交给你
- ⭐ 配置入口分四类:BLE 配置、BLE 配网、MQTT 下发、HTTP 网页
- ⭐ 普通配置走
app_config_update()→ 写 NVS → 回调 → 队列 →config_apply_task→ 硬件 - ⭐ WiFi 凭据不走这条路,它有自己的保存路径和 NVS 命名空间
- 回调里只更新配置 + 投队列,绝不阻塞
- 共享数据用
s_data_mutex保护,锁里只做内存读写 - 只有一个界面,所以不需要状态机
操作过程
目标:把工程里所有任务的状态和栈余量打印出来,亲眼看到它们在并发运行。
第 1 步:打开任务统计功能
进到:
Component config → FreeRTOS → Kernel
[*] configUSE_TRACE_FACILITY
[*] configUSE_STATS_FORMATTING_FUNCTIONS第 2 步:加一段打印代码
在 main.c 里加一个诊断任务:
c
static void task_monitor(void *pvParameters)
{
static char buf[1024];
while (1) {
vTaskList(buf);
printf("\n%-16s %-6s %-6s %-6s %s\n",
"Name", "State", "Prio", "Stack", "Num");
printf("%s", buf);
printf("free heap: %lu\n", esp_get_free_heap_size());
vTaskDelay(pdMS_TO_TICKS(10000)); // 每 10 秒打一次
}
}在 app_task() 创建其他任务的地方,把它也加上:
c
xTaskCreate(task_monitor, "monitor", 3072, NULL, 1, NULL);第 3 步:编译烧录
第 4 步:应该看到什么
Name State Prio Stack Num
app_task R 5 1832 3
sensor_task B 3 1120 7
alarm_task B 4 980 8
light_task B 3 1450 6
config_apply_ta B 4 1280 5
weather_task B 3 3200 9
monitor R 1 2100 10
IDLE R 0 1100 1
free heap: 152340怎么读这张表:
| 列 | 含义 |
|---|---|
| State | R = 就绪/运行中,B = 阻塞(在等东西),S = 挂起 |
| Prio | 优先级 |
| Stack | ⭐ 栈还剩多少字节(历史最低值) |
注意看 State 那一列
大部分任务都是 B(阻塞) —— 它们在等队列、等延时,一点 CPU 都不占。
这就是第一章 1.5 讲的"睡过去让出 CPU",现在你能亲眼看到了。
第 5 步:定栈大小
把所有功能都跑一遍(连网、拉天气、手机改配置、等闹钟响),再看一次这张表,记下每个任务的 Stack 最低值:
最终栈大小 = 当前值 - 最低剩余 + 1000(留余量)剩余低于 500 就危险了
说明随时可能栈溢出。立刻调大,别等它半夜崩给你看。
动手试试
- 在每个任务的循环里加一行打印任务名和栈剩余,跑 10 分钟,找出最紧张的那个。
- 用手机改一次亮度,在
app_config_update()、配置回调、config_apply_task三处各加一行日志,看事件是怎么一步步传下去的。 - 故意把
s_data_mutex的加锁去掉,让sensor_task高频写、主循环高频读,观察屏幕上会不会出现明显错乱的数值。