Skip to content

3.3 FreeRTOS 任务划分 ​

这节要搞懂:当前工程开了哪些任务,它们各自负责什么、怎么互相配合。 需要基础:3.2、第一章 1.5 – 1.8 ⭐ 本章最重要的两节之一

一、什么是任务划分 ​

任务划分指的是:根据功能的执行周期、阻塞特性和优先级要求,把整个系统的功能分配到若干个独立任务中,并确定它们之间的通信方式。

拆任务的三条判断标准,满足任意一条就该拆开:

  1. 做事的频率不一样 —— 时间每秒刷一次,天气 10 分钟拉一次
  2. 会不会长时间卡住 —— 联网请求要好几秒,绝不能和显示混在一起
  3. 急不急 —— 播音断一下人耳立刻能听出来,天气晚一分钟没人在意

也别拆太细

每个任务至少吃 2KB 内存。 五六个任务足够做一个完整项目了。

二、当前工程有哪些任务 ​

工程里的任务分成三类:启动任务、本地任务、在线任务。

启动任务 ​

任务什么时候创建干什么
app_taskapp_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 状态让协议栈把消息交给你

四、配置从哪来,又怎么生效 ⭐ ​

这台设备没有按键,所有设置都来自外部。工程里的配置入口分四类:

#入口改什么走哪条路
1BLE 配置特征值城市、亮度、音量、闹钟、报警阈值等普通配置app_config_update()
2BLE WiFi 配网特征值写入 ssid / passwordWiFi 配网存储
3MQTT 属性下发部分云端配置项(比 BLE 少)app_config_update()
4HTTP 网页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 能改的字段不一样

MQTT 只开放了 8 项,闹钟、刷新间隔、自动亮度上下限目前只能用 BLE 改。

完整对照表见 3.11 第五节。

回调里绝对不能阻塞

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

怎么读这张表:

列含义
StateR = 就绪/运行中,B = 阻塞(在等东西),S = 挂起
Prio优先级
Stack⭐ 栈还剩多少字节(历史最低值)

注意看 State 那一列

大部分任务都是 B(阻塞) —— 它们在等队列、等延时,一点 CPU 都不占。

这就是第一章 1.5 讲的"睡过去让出 CPU",现在你能亲眼看到了。

第 5 步:定栈大小 ​

把所有功能都跑一遍(连网、拉天气、手机改配置、等闹钟响),再看一次这张表,记下每个任务的 Stack 最低值:

最终栈大小 = 当前值 - 最低剩余 + 1000(留余量)

剩余低于 500 就危险了

说明随时可能栈溢出。立刻调大,别等它半夜崩给你看。

动手试试 ​

  1. 在每个任务的循环里加一行打印任务名和栈剩余,跑 10 分钟,找出最紧张的那个。
  2. 用手机改一次亮度,在 app_config_update()、配置回调、config_apply_task 三处各加一行日志,看事件是怎么一步步传下去的。
  3. 故意把 s_data_mutex 的加锁去掉,让 sensor_task 高频写、主循环高频读,观察屏幕上会不会出现明显错乱的数值。