6.1 任务调度与优先级 裸机主循环到了"采样不能停、网络要收发、屏幕要刷新"的规模,状态标志会越积越多,延时互相牵扯,改一处崩三处。这一节引入 FreeRTOS 的解法:把每个功能装进独立任务,调度器按优先级安排谁上 CPU。任务三要素、抢占规则、阻塞的意义——这些概念直接决定了你写的多任务程序是清晰的可控系统,还是新的泥潭。 任务:带独立栈的无限循环 FreeRTOS 的任务是一个永不返回的函数,配上三个属性:优先级、栈大小、名字。任务函数的标准骨架长这样: 每个任务有自己独立的栈——局部变量、调用链都在自己的栈上,任务之间天然隔离。栈大小是最容易算错的一项:给小了栈溢出(症状与裸机时代一模一样:变量被神秘改写、随机崩溃),给大了浪费 RAM。
裸机主循环到了"采样不能停、网络要收发、屏幕要刷新"的规模,状态标志会越积越多,延时互相牵扯,改一处崩三处。这一节引入 FreeRTOS 的解法:把每个功能装进独立任务,调度器按优先级安排谁上 CPU。任务三要素、抢占规则、阻塞的意义——这些概念直接决定了你写的多任务程序是清晰的可控系统,还是新的泥潭。
FreeRTOS 的任务是一个永不返回的函数,配上三个属性:优先级、栈大小、名字。任务函数的标准骨架长这样:
/* ESP-IDF 风格(ESP32);STM32 上同样成立,把 xTaskCreate 换成 xTaskCreateStatic 等价 */ #include "freertos/FreeRTOS.h" #include "freertos/task.h" void sensor_task(void *arg) /* 任务函数签名固定:参数进、永不返回 */ { for (;;) { /* 无限循环:任务没有"退出后去哪"的概念 */ int32_t v = read_sensor(); printf("sensor=%ld\n", (long)v); vTaskDelay(pdMS_TO_TICKS(100)); /* 阻塞 100 ms:把 CPU 让给别的任务 */ } } void app_main(void) /* ESP32 的入口,本身也在一个任务里 */ { xTaskCreate(sensor_task, /* 任务函数 */ "sensor", /* 名字:调试时人看的 */ 4096, /* 栈大小,单位字节(STM32 移植层常用字)*/ NULL, /* 传给任务函数的参数 */ 5, /* 优先级:数字越大越高 */ NULL); /* 任务句柄,不要可传 NULL */ }
每个任务有自己独立的栈——局部变量、调用链都在自己的栈上,任务之间天然隔离。栈大小是最容易算错的一项:给小了栈溢出(症状与裸机时代一模一样:变量被神秘改写、随机崩溃),给大了浪费 RAM。判断方法很朴实:先给个宽裕值(ESP32 上 4 KB 起步),跑起来后用 uxTaskGetStackHighWaterMark() 查水位,再收紧到水位的一倍半左右。
FreeRTOS 调度器只认三条规则,组合起来覆盖所有场景:
第 3 条是多任务系统优雅运转的枢轴,值得用代码敲打一下——对比两种"定时"的天壤之别:
void bad_task(void *arg) /* 反面教材:忙等占着 CPU */ { for (;;) { do_work(); uint32_t t = xTaskGetTickCount(); while ((xTaskGetTickCount() - t) < pdMS_TO_TICKS(100)) { } /* 这 100 ms 里 CPU 被本任务占死:低优先级任务饿着,空闲任务永远进不去 */ } } void good_task(void *arg) /* 正确写法:vTaskDelay 阻塞让位 */ { for (;;) { do_work(); vTaskDelay(pdMS_TO_TICKS(100)); /* 阻塞期间任务不占 CPU:调度器放低优先级任务和空闲任务进来跑 */ } }
两段代码功能相同,但对系统的影响判若云泥。RTOS 编程的铁律:能用阻塞 API 等的事,绝不忙等。 这条铁律还有一个硬件同盟军——空闲任务。它是调度器造出来的最低优先级兜底任务,空闲钩子函数(vApplicationIdleHook)里通常安排喂看门狗、统计 CPU 占用率、进低功耗;而很多 SDK(包括 ESP-IDF)默认在空闲任务里喂任务级看门狗——你的任务若忙等霸占 CPU,空闲任务吃不上饭,看门狗直接把系统复位。也就是说,"任务里忙等"在 ESP32 上不是风格问题,是会当场暴雷的错误。
优先级分配的原则一句话:谁的事件最急、最不能等,谁优先级最高。实践里有一套经过验证的排序:
| 优先级 | 典型任务 | 理由 |
|---|---|---|
| 最高 | 硬实时控制、安全关断 | 错过截止时间出事故 |
| 高 | 协议栈、网络收发 | 丢包重传代价大 |
| 中 | 采集、业务逻辑 | 周期性,晚几毫秒无妨 |
| 低 | 日志、显示、指示灯 | 人眼察觉不到迟滞 |
| 最低 | 空闲兜底 | 调度器保留 |
常见反模式是把所有任务都设成同一高优先级:抢占失去意义,系统退化为不可预测的轮转,某个任务稍有耗时,所有人跟着抖动。正确姿势是拉开层次,让"急事插队"成为机制而不是运气。
栈大小靠猜终归不放心,FreeRTOS 提供两层自动保险。第一层是栈水位检查:创建任务时把栈区填充为已知图案(0xA5),uxTaskGetStackHighWaterMark() 返回历史上被用到过的最深处,据此收紧或放宽栈配置——上线前跑一遍压力场景,水位数据就是最好的定容依据。第二层是栈溢出钩子:配置 configCHECK_FOR_STACK_OVERFLOW 后,内核在任务切换时比对栈尾哨兵值,一旦踩线立刻回调 vApplicationStackOverflowHook(),你可以在里面记录任务名并安全停机。两层合用,"变量莫名被改"这类悬案在开发期就会被拦下,而不是流到用户手里变成随机故障。
ESP32 有两个核,FreeRTOS 的调度规则在每核上独立生效。ESP-IDF 的默认分工值得借鉴:核 0 跑协议栈(Wi-Fi、蓝牙),核 1 留给用户应用,两边用队列与事件组通信。创建任务时可用 xTaskCreatePinnedToCore() 指定核:
xTaskCreatePinnedToCore(network_task, "net", 8192, NULL, 8, NULL, 0); /* 绑核 0 */ xTaskCreatePinnedToCore(sensor_task, "sns", 4096, NULL, 6, NULL, 1); /* 绑核 1 */
绑核不是必需,但有两个立竿见影的好处:把抖动敏感的任务与 Wi-Fi 协议栈隔离(协议栈的中断负载不可控),以及让两个真正并行的重活提速。反过来说,不绑核时调度器自动在两核间平衡负载,多数项目放任不管也没问题——先跑起来,出现时序抖动再考虑绑核,别一开始就过度设计。
最后回答方法论问题:功能怎么拆成任务?经验规则是按"等待的东西"拆,不按"代码模块"拆。等传感器就绪的、等网络数据的、等人按键的、等屏刷完的——各自是一个自然的阻塞点,各自成任务后,每个任务的核心就是"等事件、干活、再等事件"的干净循环。反例是按模块拆成"传感器任务"里既读 I2C 又刷屏又发网络——那等于把主循环换了件马甲。一个来自真实项目的参考布局:采集任务(等定时)、解析任务(等队列)、上报任务(等网络)、看门狗喂食与统计(等节拍),四个任务四种等待,互不缠绕。
至此发动机装好了。但任务一旦要共享数据(比如中断收到的字节要交给解析任务),新的问题立刻浮出水面:交接时怎么保证不丢、不错、不卡?下一节的队列、信号量与互斥量,就是 FreeRTOS 给出的成套答案。