本节摘要:FreeRTOS 在微控制器世界近乎默认存在——ESP32 的 Arduino 核心自带它,Pico SDK 与众多厂商核都有移植。本节不铺陈 API 清单,只讲四个最容易被误解的机制:任务的堆栈账本、任务间通信四件套的选型逻辑、优先级翻转的现场还原,以及堆栈溢出这个头号杀手的完整解剖。
第 3 章结束时,硬件之间的对话已经打通;从这一节起,对话的对象换成"软件内部"。RTOS 放在总线之后,因为它回答的问题接在通信之后自然浮现:设备多了、代码大了,谁先谁后、谁等谁、数据怎么在部件间传递——这些正是调度器的工作。2.1 节的临界区概念在这里会以系统化的面目重逢。
每个 FreeRTOS 任务有一块私有堆栈:函数调用、局部变量、中断压栈都花在这里。堆栈给小了溢出(踩坏相邻任务的内存,症状是随机的离奇崩溃),给大了浪费(本就紧张的 RAM 被虚占)。估算堆栈是 RTOS 开发的第一门算术:把任务调用链里最深的一层局部变量与调用开销加起来,再乘二留余量,是多数场景够用的起点。
更靠谱的做法是量出来而不是猜:FreeRTOS 提供水位查询,返回"历史最小剩余量"。观察运行足够长时间后,把水位余量收敛到总量的两成上下——既不浪费也不冒进。下面是创建任务与查水位的骨架:
#include <Arduino.h> TaskHandle_t collectTask; void collectLoop(void *param) { // 任务函数:永不返回 for (;;) { // 任务主体必须是无限循环 readAllSensors(); // 干活 vTaskDelay(pdMS_TO_TICKS(20)); // 阻塞 20ms,让出 CPU } } void setup() { xTaskCreatePinnedToCore(collectLoop, "collect", 2048, NULL, 3, &collectTask, 0); // 名称 堆栈字 参数 优先级 句柄 核号 } void loop() { Serial.printf("collect 水位:%u 字\n", uxTaskGetStackHighWaterMark(collectTask)); vTaskDelay(pdMS_TO_TICKS(5000)); }
注意堆栈参数的单位是字而非字节(Cortex-M 上一个字 4 字节,2048 字即 8KB)。优先级数字越大越紧要;vTaskDelay 是任务版延时——它阻塞的是"这个任务",调度器立即调度别人,与 delay 占着 CPU 死等的裸机延时是两种生物。
任务之间不共享变量(那要加锁、易错),而是传消息。FreeRTOS 给了四件工具,选型的唯一逻辑是按需取重:
| 机制 | 传什么 | 开销 | 典型场景 |
|---|---|---|---|
| 队列 | 数据副本,先进先出 | 中 | 采集任务把样本递给处理任务 |
| 信号量 | 一个"发生了"的信号 | 低 | 中断通知任务:数据到了 |
| 事件组 | 多个标志位的与或组合 | 中 | 等齐三个条件再开工 |
| 任务通知 | 直接塞给目标任务的 32 位值 | 最低 | 轻量点对点,替代二值信号量 |
经验法则是能用任务通知就不用信号量,能用信号量就不用队列——但别为省那点开销硬塞:需要传结构化数据时队列的正确性远比省下的几百字节值钱。

用互斥量保护共享资源时会出现一种反直觉的局面,如图所示:低优先级任务持锁干活,高优先级任务来了排队等锁;但真正运行的是与这把锁毫无关系的中优先级任务——它把持锁者挤到一边,持锁者干不完活放不了锁,高优先级就一直等。三种优先级完成了一次"翻转"。
解法是优先级继承:互斥量检测到高优先级在等锁时,把持锁者临时抬到同等优先级,让中优先级插不了队,锁尽快释放。工程纪律因此有两条:保护共享资源一律用互斥量而不是二值信号量(后者没有继承机制);持锁区间尽量短,持锁期间绝不等待任何慢操作。
背景:一台设备在特定操作序列下(连续进菜单第三层再退回)随机重启,复位原因显示程序崩溃。日志无异常,桌面复现概率低。
操作:逐任务查水位,发现菜单任务的水位随进入层级加深而下降——每深入一层少几百字,最深时余量不足 40 字。定位到根因:菜单渲染函数递归构建设置项列表,每层递归在栈上铺一个大数组。两步修复:递归改为迭代(用静态队列模拟层级),该任务堆栈从 1024 字上调到 2048 字。
结果:压力测试连续执行该操作序列一万次,水位最低 600 字,再无重启。
解读:堆栈溢出是嵌入式头号隐形杀手,因为它的症状是"随机、与他处无关、复位后消失"。水位查询让这个杀手现形——它把"猜"变成"测"。凡是用了递归、大局部数组、sprintf 的任务,都该把水位检查纳入调试例行项。
变式:项目复杂到任务多、内存紧时,可在创建阶段改用静态创建接口(xTaskCreateStatic),堆栈与任务控制块全部由你显式声明,内存账本从"运行时分配"变成"编译期确定"——量产固件普遍偏好这种可预算性,第 7 章性能优化会再回收这个话题。
⚠️ 常见坑:在中断里调用带阻塞语义的 API(等队列、拿锁)是 RTOS 禁忌,会直接断言崩溃。中断与任务的接口只有一种正确形态:中断侧用不带阻塞的 FromISR 变体发通知或入队。
💡 关键直觉:RTOS 的价值不在"多任务"而在"等待有主"。写每个任务时自问一句:它在等什么?答案必须是队列、通知或延时,而绝不能是"等别的任务快点跑完"。