一、我用 FreeRTOS 解决的三类问题,以及它没解决的那些
我并不是“学会了 RTOS 就到处用”。回头看,真正让我决定上一个实时内核的, 只有三类问题,而且它们经常一起出现。
- 若干件周期不同、耗时也不同的活要同时推进。 这几件事的周期彼此不成倍数关系——通信轮询是几十毫秒一次、显示刷新是几百毫秒一次、 按键扫描要更快一些、参数落盘则是“有变化才做”。 裸机的时间片轮询能撑住,但只要其中任何一件会阻塞 (比如等一次存储写完),整条时间轴就跟着歪掉,而且歪的幅度取决于数据量—— 这是最难受的一类偶发问题。
-
需要“阻塞等待”这个语义。等一个队列里有数据、等一组事件标志凑齐、
等一段确定的时间——这些等待如果用
while空转来实现,CPU 就白烧在那里; 交给内核之后,等待期间 CPU 可以让给别人。 - 需要明确的抢占关系。有几件事是硬实时的:串口帧间隔的判定、 1 ms 级的节拍推进、故障时的立即切断输出。它们必须能打断那些“慢但长”的活 (日志打印、Flash 写入、界面刷新)。
反过来,下面这几件事我没有指望 FreeRTOS 来解决:
- 它不替代状态机。任务划分和状态划分是两件事。 把“每个系统状态做成一个任务”是一种可选的写法,它有它的收益也有它的代价 (栈内存、堆碎片、切换延迟),我在 《分层状态机与故障优先级》 里专门讨论过,这里不重复。
- 它不解决时序精度。tick 是毫秒级的,任务唤醒还会受优先级与临界区影响。 微秒级的时序只能交给硬件定时器和中断,内核帮不上忙。
- 它不减少共享资源的麻烦。任务一多,共享缓冲区、共享外设的竞争反而更多。 内核提供的是工具(互斥锁、临界区),用不用、怎么用还是自己的事。
- 它不是“性能优化”。上下文切换本身要花时间;把一堆小函数拆成任务, 总体开销只会更大。
所以我的判断标准是三条:业务复杂度是否已经超过“一个主循环能想清楚”的程度; 是否存在必须抢占的环节;RAM 是否还有余量。 三条都不满足的时候,裸机的时间片轮询是更划算的选择—— 我在 《不用 RTOS 的固件怎么写:时间片轮询 + 消息队列》 里写过那种写法,几 KB RAM 的采集单元到现在还在用。
二、任务:创建方式、优先级与栈
这一节的三件事,我认为是“用 RTOS 会不会出事”的分水岭。 它们都不难,但都需要在动手之前想清楚,事后补代价很大。
创建方式:动态还是静态
动态创建时,任务的控制块和栈都从内核堆里分配;静态创建时,这两样东西由调用者提供 (通常就是文件作用域的数组),内核只是把它们登记一下。差别不只是“从哪拿内存”:
| 对比项 | 动态创建 | 静态创建 |
|---|---|---|
| 内存从哪来 | 内核堆(configTOTAL_HEAP_SIZE 那块) |
调用者提供的控制块与栈数组,编译期确定 |
| 会不会失败 | 会。堆不够时返回失败,必须检查返回值 | 基本不会(只要参数对),因为内存早就在链接期算好了 |
| 内存账什么时候结清 | 运行期才能知道(要靠水位统计) | 链接期就结清了,看 map 文件能算出来 |
| 能不能删除后回收 | 可以,但会在堆里留下空洞(取决于用哪个堆实现) | 任务本身可以删除,但那两块内存不解锁、也不该复用 |
| 适合什么场景 | 任务数量在运行期会变的场合(按状态创建/删除任务) | 启动后任务集合不再变化的常驻任务 |
我的几个工程在这件事上是分成两派的,而且理由都很实在。
一派是“全部静态创建”。这类工程的任务数量是固定的: 几个职责明确的常驻任务,启动之后不再增删。既然数量固定, 把它们全部用静态方式建起来就是最省心的——内存占用在链接期就看得见, 运行期不会再申请,也就不存在“堆不够导致创建失败”这个故障模式。 这类工程里连空闲任务和定时器任务的栈都是由代码提供的, 整个 RAM 的账在编译完成那一刻就结清了。
另一派是“动态 + 静态混用”。这类工程里, 常驻的、频次低的任务(比如读时钟、翻运行指示灯)用静态方式创建; 而业务任务跟着系统状态走——进入一个状态就创建对应任务,退出就删除。 这种写法下任务数量是变动的,只能动态创建,于是也就必须接受两件事: 创建可能失败,以及频繁创建删除会在堆里留下碎片。
我把“创建可能失败”当成一条真实经历来记:那个工程的状态切换很频繁,
连续切换若干次之后,有一次 xTaskCreate 没能返回成功。
当时的处理是——打印一行错误,把这次创建放进重试队列,隔一段时间再试一次。
加了这个重试之后,现场再遇到类似情况设备会自己恢复,只是状态切换慢了一拍;
而没有重试的话,设备会卡在“状态切不过去”这个谁也想不通的现象上。
结论很简单:只要用了动态创建,返回值就一定要看,并且要有一条重试路径。
优先级怎么定
我的第一条原则是:按“错过截止时间的后果”排序,而不是按“我觉得它重要”排序。 照这个原则排下来,几个平台的结构意外地一致:
- 最上面是内核自己(空闲任务、定时器服务任务),以及那些“晚一点就出错”的实时环节;
- 中间是通信收发与协议处理;
- 再往下是业务逻辑、状态机推进、参数管理;
- 最下面是显示刷新、按键扫描、日志输出这类“晚几十毫秒没人看得出来”的活。
第二条原则是:档位不要多。三到五档就够用了。 档位一多,就没人说得清“这个任务该放第 7 档还是第 8 档”, 最后变成每个人按自己写代码那天的感觉去填。我习惯做法是用一组有名字的枚举 (低 / 常规 / 高 / 实时)来代替裸数字,代码里任何地方都不出现裸优先级。 这样做还有一个好处:调整整体结构的时候只改一处。
第三条原则可能有点反直觉:“把某个任务的优先级调高”经常是错误的第一反应。 如果现象是“偶发丢帧”,先要问的是“它为什么没及时跑到”—— 是被低优先级任务持锁挡住了、是被临界区屏蔽了中断、 还是它自己在某个阻塞调用上等了太久。这些问题调优先级都治不了, 只会让别的任务开始出问题。
最后是中断这块,我认为是 FreeRTOS 里最容易搞错、症状又最隐蔽的地方。
在 Cortex-M 上,中断优先级的数值越小代表优先级越高,
而 FreeRTOS 的任务优先级正好相反(数值越大越高)。这两套规则同时出现在一个工程里,
再加上“只有中断优先级不高于某个边界的中断,才允许调用内核的
FromISR 系列接口”这条约束,
一旦配错,症状通常是“偶发卡死”或者某条断言在深夜触发。
我的做法是把“这个中断允不允许调用内核接口”写在中断初始化的注释里,
并且把优先级分组与边界值放在同一个配置文件里,避免两处各写一遍。
栈要留多少:怎么估、怎么验
栈给多少,是所有 RTOS 新手(包括当年的我)最容易随手填一个数的地方。 我给自己的估法是一个三步走:
- 走最坏路径。不是走“平时那条路”,而是走“分支最深、局部变量最多”的那条路: 出错处理、参数解析、格式化输出往往比正常路径深得多。 把这条路径上所有函数的局部变量加起来,得到的是“数据”部分。
- 加三笔固定开销。函数调用的返回地址(嵌套越深越多)、 被调用到的库函数自己的栈用量、以及中断嵌套时压在任务栈上的那份。 第三笔最容易被忘掉——中断是压在被打断的那个任务的栈上的。
- 再留一段余量给“以后会加的东西”。这一条听起来不严谨, 但它符合真实情况:栈用量的增长几乎总是来自后面加的功能, 而不是当初估错的那几个字节。
三个具体的“重灾户”要单独处理:
- 格式化输出。把整数格式化成字符串、尤其是带浮点的格式化, 栈用量会比想象中大得多。我吃过一次亏:一个任务编译能过、跑起来偶发跑飞, 表现是随机重启和乱码打印,最后定位到就是格式化函数把栈吃穿了。 后来的做法是要么给这个任务明显更大的栈,要么干脆不用浮点格式化。
- 递归。任务里不该出现递归,因为最坏深度不可控。
- 大数组。缓冲区一律放成文件作用域的静态数组, 不要放在任务函数里的栈上。栈上放一个大数组,等于把“内存不够”这件事 伪装成“随机跑飞”。
验的办法在第八节:栈溢出检查负责“抓住越界”,高水位负责“告诉我还剩多少”。 两者都要用,缺一个都不够。
三、任务间通信:五种机制怎么选
内核提供的通信与同步对象就那么几种,但选错的时候症状差别很大。 我把它们放在一张表里,按“解决什么问题”来分:
| 机制 | 解决什么 | 开销 | 什么时候用哪个 |
|---|---|---|---|
| 队列 | 在任务之间传数据,且数据是值拷贝、有长度上限 | 中:一次拷贝 + 两个控制块 | 生产-消费结构:串口收到的帧、要落盘的记录、要显示的文本 |
| 二值信号量 | 只表达“发生了一件事”,不带数据 | 小 | 中断通知任务;或者用“有/没有”表达一个资源的占用状态(但要小心它没有优先级继承) |
| 计数信号量 | 记录“发生了几次”,可以被消费若干次 | 小 | 事件计数(比如脉冲个数)、多个同类资源的占用计数 |
| 互斥锁 | 保护共享资源,带优先级继承,只能由持有者释放 | 中:比信号量多一些逻辑 | 多个任务要访问同一个外设、同一块缓冲区时。这是它的正业 |
| 事件组 | 一次等待一组条件(可“全满足”或“任一满足”) | 中 | 一个动作要等好几个前置条件都齐了才做,而且不想为每个条件建一个对象 |
| 任务通知 | 直接给某个任务发一个 32 位值,不需要额外的控制块 | 最小:不占额外内存、不经过队列 | 一对一的通知,尤其是“中断通知某个任务”。限制是不能多个接收者、不能广播 |
我在项目里最常用的就是最后一种:中断通知任务。 原因很直接——它不需要额外分配内存,也没有队列的拷贝开销, 而我的使用场景几乎全是一对一的:一个外设中断唤醒一个任务。 代价是它不能被两个任务共用,也不能“广播”;一旦出现一对多, 就得换回队列或事件组。所以用它的前提是先把“谁通知谁”想清楚。
几条我踩过之后总结的取舍经验:
- 能用“值语义 + 队列”解决的,不要用“全局变量 + 互斥锁”。 前者把“同步”和“数据传递”一次性解决了,后者要求每个访问点都记得加锁, 漏一个就是一个偶发 bug。数据量小的时候,队列的一次拷贝完全值得。
- 不要往队列里塞大结构体。队列是值拷贝,塞进去一次、取出来一次, 内存里还长期占着“长度 × 元素大小”这块空间。 数据大就传指针——但传指针必须自己保证缓冲区的生命周期, 典型的做法是配一个内存池或者用双缓冲轮换。
- 二值信号量不要用来保护资源。它没有优先级继承, 用它当锁会重现优先级反转(第九节)。
还有一个观察值得记下来:我见过几个工程把
configUSE_MUTEXES、configUSE_COUNTING_SEMAPHORES、
configUSE_TIMERS 之类的选项全都打开,
但应用层代码里几乎不用这些对象——模块之间靠的是一个自研的环形队列,
中断里往里写、任务里往外读,冲突用一个很短的临界区解决。
这种“配置项开着但没用”本身不致命(每种对象只是多一点点代码),
但它说明一件事:配置项应该反映实际用法,而不是“万一以后要用”。
自研环形队列在这里是有道理的——它的语义更简单
(写不进去是丢还是等,由调用者当场决定),也不占额外的内核对象内存。
那种写法我在
《不用 RTOS 的固件怎么写》
里详细写过,它在裸机工程和 RTOS 工程里同样能用。
四、临界区与中断安全 API
三种保护手段,边界在哪
内核给了三种粒度不同的保护手段,我按“影响范围从大到小”排一下, 顺便说清楚各自不能做什么:
-
关中断(
taskENTER_CRITICAL/taskEXIT_CRITICAL): 会屏蔽到某个优先级以下的所有中断。它是唯一能保护“中断里也会访问的数据”的手段, 代价也最大——这期间来的中断会被推迟。 -
关调度器(
vTaskSuspendAll/xTaskResumeAll): 只禁止任务切换,中断照常进。适合保护“比较长、但不希望被别的任务插进来”的操作, 比如一整笔跨页的存储写入、一次不希望被打断的多行打印。 - 互斥锁:会睡眠,所以不能在中断里用, 也不能在调度器被挂起时用。它是最“文明”的方式,也是唯一带优先级继承的方式。
选择的顺序可以概括成一句话:能用互斥锁就别关调度器,能关调度器就别关中断; 只有当中断也会碰同一份数据时,才不得不关中断。
我违反过这条顺序。为了让一段存储写入“绝对不被打断”,我用了关中断, 而那段写入要几十毫秒。结果是这段时间里串口接收中断全被屏蔽,丢了帧, 现象是“只要在写参数,通信就偶尔失败”。 后来改成只关调度器——写入本身不被别的任务插进来就够了, 中断该进还是让它进。顺带说一句,我在别人的源码里见过一条自我提醒的注释, 大意是“这里禁止中断应改成禁止调度”,说明踩过这个坑的人不止我一个。
FromISR 系列为什么必须配套 portYIELD_FROM_ISR
这是我认为最值得单独讲清楚的一条,因为它的症状很隐蔽。
现象是这样的:中断里明明已经发了通知/写了队列,但对应的任务却要等到下一个 tick 才被唤醒。单次看只是“晚了一毫秒”,但在密集通信的场景下, 这种延迟会一帧一帧地累积,最后表现为节拍抖动、缓冲区慢慢变满、甚至丢帧。
原因在于内核的分工:FromISR 系列函数不能在中断里做上下文切换。
它们能做的只是判断“这次操作是否让一个更高优先级的任务变成就绪了”,
然后通过一个输出参数把结论告诉你。真正的切换必须在中断退出之前,
由你自己调用 portYIELD_FROM_ISR 来触发。
换句话说:这个输出参数不是可选的装饰,它是这条链路的一半。
/* 按个人理解重写的通用片段:中断里通知任务,并在退出前按需触发切换 */
void EXTI_IRQHandler(void)
{
BaseType_t higher_woken = pdFALSE; /* 必须先初始化成"不需要切换" */
if (exti_flag_is_set()) {
exti_flag_clear();
vTaskNotifyGiveFromISR(task_handle, &higher_woken);
}
/* 只有真正唤醒了更高优先级的任务,才在这里触发一次切换 */
portYIELD_FROM_ISR(higher_woken);
}围绕这条还有三个配套的约束,漏掉任何一个都会出问题:
- 输出参数一定要初始化。不初始化的时候,它可能恰好是非零值, 于是一次不需要切换的中断也触发了切换——不影响功能,但白花时间,而且很难查。
- FromISR 系列不能在临界区里调用。内核会断言失败。 如果必须保护,用“中断安全的临界区”那一套,而不是普通的关中断。
- 调用 FromISR 的中断,其中断优先级必须在“内核可管理”的范围内。 这个范围由配置项和 MCU 的中断优先级分组共同决定,需要和硬件初始化一起核对, 不能只在配置文件里改一个数。
五、软件定时器:什么时候该用,什么时候不如自己数节拍
软件定时器经常被当成“更方便的延时”,我觉得这是误解。 它实际上是一个共享的调度器:所有定时器都由一个专门的 “定时器服务任务”统一驱动,回调函数就跑在那个任务的上下文里。 理解这一点,它的大部分约束就自然明白了。
三个必须知道的约束:
- 周期不能是 0。创建的时候必须给一个合法周期。 我原来想的是“先创建,稍后再设真实周期”,于是填了 0,结果创建直接失败。 正确做法是先给一个占位周期,启动之前再用修改周期的接口改成真实值。 这个坑我在两个工程里各踩过一次,后来在代码注释里写死了这句话。
- 回调里不能调用会阻塞的接口。 回调跑在定时器服务任务里,它一阻塞,所有定时器的触发时间都会被推后。 回调里应该只做“投递”——发个通知、写个队列,处理放到任务里做。
- 定时器服务任务自己的栈是单独配置的。 回调里做的事情越重,这个栈就要越大;默认值通常只够做很轻的事。 格式化输出一类的操作放进回调,很容易把它撑爆。
什么时候我倾向于用它:
- 需要多个不同周期的软定时,而且数量在运行期会变(要动态创建/删除);
- 需要“延时一段时间后做一件事”这种一次性动作;
- 需要一个“可以重置、可以查询剩余时间”的倒计时。
什么时候我觉得不如自己数节拍:
- 只有一两个固定周期的动作。在已有的周期任务里用一个计数器做模运算, 或者用“上次时间戳 + 比较”的写法,代码更短、开销更小,而且时序一眼就能看懂—— 它就在那个任务里,不在另一个任务的队列里。
- 需要和硬件节拍严格对齐。这种需求应该挂在硬件定时器中断 或者已有的节拍任务上,交给软件定时器只会多一层不确定性。
我的几个工程在这件事上正好是两种做法,可以对照着看。 一个带以太网的工程里没有用内核的软件定时器, 而是自己写了一个基于毫秒节拍的软定时器模块(若干个定时器编号, 提供复位、清标志、停止、启动这几个操作)。 理由很直接:这些定时器全都要被主循环以毫秒粒度检查, 交给定时器服务任务反而多一层队列和一次上下文切换。 另一个加注类设备的工程反过来了,它用了内核定时器, 并且把它包成一个“可以查询剩余时间”的倒计时结构, 用来实现“有脉冲就复位、一段时间没有脉冲就超时停机”的保护逻辑—— 这种“随时可能被复位”的语义,用内核定时器确实更省事。
所以用之前先问自己一句:这些定时器是不是同一种触发方式、同一个时间粒度? 如果是,而且数量固定,自己数节拍往往更清楚;如果不是, 交给内核统一管更合理。
六、时间管理:vTaskDelay 与 vTaskDelayUntil
这两个接口的区别,我在文档里看过很多遍都没往心里去,直到有一次做周期任务, 发现实际周期总是比设定值大一点点,而且随负载变化。原因就在这里。
-
vTaskDelay:从调用那一刻起算,延时指定的 tick 数。 所以“这一轮干了多久”会被加到周期里去。 -
vTaskDelayUntil:从上一次的唤醒时刻起算, 保证的是“相邻两次唤醒之间的间隔固定”。
用一句话概括:vTaskDelay 是“干完活再歇一会儿”,
vTaskDelayUntil 是“固定节拍,活干完就等下一个节拍”。
前者的周期 = 干活时间 + 延时;后者的周期 = 延时(只要干活时间小于延时)。
这就是“用错了会漂移”的全部原因——漂移量等于任务体的耗时,
而这个耗时在正常情况下是稳定的,一旦数据量变大就会变,
于是周期跟着变,表现出来就是“平时挺准,忙起来就不准”。
/* 按个人理解重写的通用片段:固定周期任务应该用 vTaskDelayUntil */
static void periodic_task(void *argument)
{
TickType_t last_wake = xTaskGetTickCount();
const TickType_t period = pdMS_TO_TICKS(TASK_PERIOD_MS);
(void)argument;
for (;;) {
do_periodic_work(); /* 这一轮要干的事 */
vTaskDelayUntil(&last_wake, period); /* 以上一次唤醒为基准,周期不漂 */
}
}选哪个,我的规则很死板:
- 凡是“周期性”的任务,一律用
vTaskDelayUntil; - 凡是“重试之前等一下”“让出 CPU 一会儿”“等外设稳定”这类非周期场景,用
vTaskDelay。
还有两条要注意的:
-
vTaskDelayUntil不会“补跑”。 如果某一轮的实际耗时超过了周期,它会立刻返回, 也就是说这一轮迟到了,节奏往后挪,但不会连续跑两次把进度追回来。 如果业务上不能容忍“迟到”,就得在上层记一个迟到标志并做降级处理, 而不是指望内核帮你追。 - tick 是毫秒级的。如果某个动作要求周期比一个 tick 还小, 就不该用延时来定时,应该交给硬件定时器中断。
七、堆:怎么定大小、五种实现怎么选、不够时最先表现成什么
先说一个经常被混淆的前提:MCU 的 SRAM、内核的堆、应用自己的静态数组,
是三块不同的内存。内核的堆只是链接脚本里划出来的一段
(configTOTAL_HEAP_SIZE 指定大小),
它不是“所有可用 RAM”。我见过把内核堆开得很大、结果 HAL 的缓冲区没地方放的情况,
现象是“网络正常但别的地方出错”。
谁会吃内核的堆
- 动态创建的任务:栈 + 任务控制块;
- 队列、信号量、事件组:控制块 + 队列的存储区;
- 软件定时器:每个定时器的控制块,以及定时器服务任务自己的栈和命令队列;
- 应用自己调内存分配接口的地方,以及某些协议栈适配层内部的分配。
怎么定这个大小
- 先算“确定的”部分。动态任务的数量 × (栈 + 控制块)、 队列数量 × (控制块 + 长度 × 元素大小)、定时器数量 × 控制块, 这些都可以按最坏情况一项一项加出来。
- 再给“不确定的”留余量。运行期会创建/删除的任务、 协议栈适配层、日志缓冲、以及将来要加的功能。
- 最后用实测校正,而不是靠猜。内核提供了两个统计接口: 一个给出当前剩余堆,一个给出历史最低水位。 决定该给多少的是后者——只看“当前还剩多少”会得出完全错误的结论, 因为最坏的那一刻往往已经过去了。
/* 按个人理解重写的通用片段:用最低水位校正堆大小 */
unsigned int free_now = xPortGetFreeHeapSize(); /* 当前剩余 */
unsigned int free_min = xPortGetMinimumEverFreeHeapSize(); /* 历史最低水位 */
/* 决定"堆够不够"的是 free_min,不是 free_now:
free_now 只说明此刻,free_min 说明跑过的最坏情况 */五种堆实现怎么选
内核自带五种堆实现,通过配置包含哪一个源文件来选择, 同一时刻只能有一个:
| 实现 | 能不能释放 | 碎片情况 | 适用场合 |
|---|---|---|---|
heap_1 |
只能分配,不能释放 | 不涉及 | 最简单也最确定。启动时把对象建完就再也不删的工程,用它最省事 |
heap_2 |
可以释放 | 不合并相邻空闲块,碎片会累积 | 已被 heap_4 取代,新工程不建议再用 |
heap_3 |
可以释放 | 取决于标准库实现 | 包一层标准库的分配函数,线程安全靠挂起调度器实现;适合已经有成熟堆管理的工程 |
heap_4 |
可以释放 | 会合并相邻空闲块,碎片可控 | 最常用。有运行期创建/删除需求的工程基本都选它 |
heap_5 |
可以释放 | 同 heap_4 |
在 heap_4 的基础上支持不连续的多块内存区域;RAM 分成几段时才需要 |
我的选择很朴素:任务集合固定的工程,用 heap_1 就够了——
反正从来不释放,连合并逻辑都不需要,行为最确定;
有运行期创建/删除任务的工程,用 heap_4,
因为“删了再建”在这种工程里是常态,不合并会很快出现“总空闲够、就是没有连续的一块”。
只有 RAM 被分成两段(比如内部 SRAM 和另一块地址不连续的区域)时,
才会考虑 heap_5。
内存不够时,最先表现成什么
这一段我认为比上面两张表都重要,因为“内存不够”的表现非常不像内存问题。 按我遇到过的顺序排一下:
- 任务创建返回失败。这是最温和的一种——前提是你检查了返回值。 如果没检查,这个任务就静默地不存在,之后的现象是“某个功能完全没反应”。
- 队列或信号量创建失败,随后在别处跑飞。 创建接口失败时通常返回空句柄,如果代码里没有判空就继续用这个句柄, 会在某个跟内存毫无关系的地方触发硬件异常。
- 创建的时候成功,跑了一段时间之后才失败。 典型的碎片型:总空闲量还够,但没有一块足够大的连续空间了。 这种最容易被误判成“偶发”。
- 整个系统卡死。如果配置了“内存分配失败钩子”但没实现它, 默认行为就是在原地打转——从外面看就是“程序不动了”, 而且没有任何输出告诉你为什么。
- 看起来像通信坏了。队列没建成功、任务收不到数据, 现象是“发送方一切正常,接收方永远不动作”。 这一条的排查成本最高,因为它会把注意力引向通信本身。
所以我的做法是三条:动态创建一律判返回值;打开内存分配失败钩子并且实现它 (至少置一个能被外部读到的标志);把历史最低水位做成一个可以随时查看的数。 这三条合起来,能让“内存不够”这类问题从一开始就暴露成它本来的样子。
八、把调试手段用起来
内核自带了不少诊断能力,但需要主动配置。我后悔没有早点把它们打开—— 它们能把好几天的手工排查变成一次查看。
栈溢出检查的两级
配置项提供两个级别,我都用过:
- 第一级:在任务切换时检查栈指针是否已经越界。 开销小,缺点是只有“切换的那一刻恰好越界”才能被抓到。
- 第二级:任务创建时把整段栈填上一个已知图案, 切换时检查栈末尾若干字节的图案有没有被改写。 它能抓到“曾经溢出过”,即使溢出发生在两次切换之间。开销稍大一些。
不管用哪一级,都必须自己实现栈溢出钩子函数。 不实现的话,检测到溢出之后没有任何输出,等于白检测。 而且写这个钩子本身也有讲究——我犯过一个错:在钩子里直接做格式化打印, 结果钩子自己又用了不少栈,把情况搅得更乱。 后来改成“只置一个全局标志就停下来(或者等看门狗复位), 由安全的地方去读这个标志并打印”。
/* 按个人理解重写的通用片段:栈溢出钩子只置标志,不在钩子里做重活 */
static volatile unsigned int g_stack_overflow_flag = 0u;
void vApplicationStackOverflowHook(TaskHandle_t task, char *task_name)
{
(void)task;
(void)task_name;
g_stack_overflow_flag = 1u; /* 由安全的地方(例如周期任务)去读取与上报 */
taskDISABLE_INTERRUPTS();
for (;;) {
/* 停在这里,等看门狗复位;此时不要调用任何会阻塞的内核接口 */
}
}这里有一条重要的限制要写清楚:栈溢出检查能发现“越过了自己栈的末端”, 但并不能保证“没有踩到别人”。 检查通过不等于安全。真正用来定栈大小的是下面这个数。
高水位:调栈的唯一依据
每个任务都有一个“历史最低剩余栈”的统计。这个数说明的是 “从任务创建到现在,栈最多被用掉了多少”。 用它来调栈,比按平均用量的倍数去猜靠谱得多。
我的用法是:把每个任务的高水位集中打印成一行, 在“最忙的工况”下打一次——所谓最忙,是指把所有分支都跑过一遍、 包括出错处理和参数写入这些平时不走的路径。 只看平时的数据会得到一个偏乐观的结论。
用任务状态快照解决“我怀疑它没在跑”
内核有一个接口能一次性拿到所有任务的状态快照: 任务名、当前状态(就绪 / 阻塞 / 挂起 / 已删除)、当前优先级、 高水位、任务编号。它需要调用者提供一个结构体数组,数组长度要够, 所以常见写法是先用一个固定上限的静态数组,或者先问一次任务数量再按需申请。
这个手段解决的是最让人抓狂的一类问题:“我怀疑某个任务根本没在跑”。 有快照之后,“它到底是在阻塞、在挂起,还是压根没被创建”一眼就能看出来。 我习惯把它封装成一个可以用调试命令触发一次的打印函数, 用参数区的特殊寄存器或者调试串口命令来调用,量产版本用编译开关关掉。
运行时间统计
它的目的是回答“CPU 时间被谁吃掉了”。前置配置有三项,缺一不可:
- 打开“生成运行时间统计”的配置项;
- 如果要使用格式化输出的统计接口,还要打开对应的格式化功能开关;
- 提供一个比 tick 频率高得多的计数源, 也就是实现“配置统计时基”和“读取统计计数”这两个接口。
第三项最容易漏。计数源的选择上,一个自由运行的硬件定时器计数器是最省事的: 读统计计数时直接返回那个定时器的计数寄存器就行。 分辨率要比 tick 高一个数量级以上,否则算出来的百分比全都是量化误差, 看着有数其实没意义。
还有一个细节:这个计数接口应该是只读的。 如果为了“重新开始统计”而在里面把计数器清零, 别的也在用这个定时器的功能就会受影响。
我看到过两种做法:一种是打开运行时间统计、用一个通用定时器作时基, 数据准确但有每次切换读计数器的开销; 另一种是在 tick 钩子里数空闲任务被调度了多少次来估算 CPU 占用, 更省事但要自己处理计数溢出,而且那个实现后来被整段注释掉了。 我的建议是调试期用前者,定型之后再决定要不要关掉。
九、几种 RTOS 特有的故障:现象比原因好认
这一类故障的共同特点是:从现象看不出跟 RTOS 有关系。 我把遇到过的、以及看到别人踩过的整理成一张表。 排查的时候我一般是“从现象反查”,而不是从代码往下读。
| 故障 | 可观察到的现象 | 怎么查 / 怎么改 |
|---|---|---|
| 任务栈溢出 | 随机跑飞、硬件异常、打印乱码;现象和负载相关,数据一多就出现 | 打开第二级栈溢出检查并实现钩子;看高水位;把大数组和格式化输出从栈上挪走 |
| 优先级反转 | 高优先级任务偶尔被“卡住”,卡住的时长恰好等于某个低优先级任务持锁的时长 | 共享资源改用带优先级继承的互斥锁;把持锁区间压到最短;不要在持锁时做耗时操作 |
| 忘记让出(忙等) | 低优先级任务饿死;同优先级任务轮不到;系统“看起来在跑但什么都没干”;功耗偏高 | 检查每个死循环里有没有阻塞点;把“轮询标志位”改成“等通知或等信号量” |
| 在中断里调了非中断安全的接口 | 断言触发或直接死机;如果断言被关掉,表现为偶发数据错乱、队列计数不对 | 把所有 FromISR 后缀理一遍;核对该中断优先级是否在内核可管理范围内 |
| 在临界区里调了会阻塞的接口 | 断言触发,或者干脆死锁在原地 | 临界区里只做赋值和队列/通知操作;需要等待的事情放到临界区外面 |
| 定时器回调里做重活 | 所有软件定时器的触发时间一起被推后,周期越长偏得越多 | 回调里只投递,处理交给任务;必要时加大定时器服务任务的栈 |
| 钩子函数没实现 | 明明检测到了问题,却没有任何输出;或者栈溢出时程序卡在一个莫名的死循环里 | 把栈溢出钩子、内存分配失败钩子都实现掉,至少在钩子里置一个能被外部读到的标志 |
| 优先级方向搞反 | 任务的调度顺序和预期完全相反;“提高了优先级反而更糟” | 记住两套规则:任务优先级数值越大越高,Cortex-M 中断优先级数值越小越高;统一用命名枚举表达 |
| 共享资源没保护 | 多任务下的偶发数据错乱,单任务测试完全正常 | 用“值语义 + 队列”代替共享全局变量;必须共享时用互斥锁,并且让所有访问点都走同一把锁 |
如果只让我留一条经验,那就是最后一行:“单任务测试正常、多任务偶发出错” 几乎一定是共享资源问题,而不是内核的问题。 这类问题最早的信号往往不是数据错,而是“某个计数偶尔对不上”—— 看到这种信号就该去查共享变量,而不是继续加打印。
十、CMSIS-RTOS2 封装层:好处与代价
有几个工程并没有直接用原生接口,而是在上面套了一层 CMSIS-RTOS2 的封装。 用下来我的结论是:它的价值完全取决于“你有没有换平台的需求”。
好处
- 可移植。线程、消息队列、信号量、定时器这些概念被抽象成一套统一的接口, 底层换成别的内核时,应用层代码基本不用动。 对“同一条产品线要出好几个硬件版本、将来可能换芯片”的团队来说,这个收益很实在。
- 写法规整。所有内核对象都用同一种“属性结构体”描述, 静态创建时要用到的控制块和栈也是通过这个结构体传进去的。 比起原生接口里每个对象一套参数,读起来更一致。
- 命名统一。看过任意一种 RTOS 的人都能读懂代码,团队协作的沟通成本低。
代价
- 多一层,排查时要多穿一层。 错误码被归一化成几个笼统的值,具体是参数错还是内存不够, 往往还要回到原生接口那一层去看。
- 部分参数被隐藏或固定。内核特有的一些选项在封装层里没有对应字段, 只能改配置宏;某些默认行为(比如对象属性的默认值、栈的对齐方式)由封装层决定, 想改要翻它的实现。
- 资料少。网上的例子几乎都是原生接口写的, 用封装层的时候每遇到一个新需求,都要自己做一次“这个功能对应哪个接口”的映射。
- 优先级要过一次转换。封装层用一组命名优先级 (Normal、BelowNormal、AboveNormal 之类)来代替裸数字, 而它到底映射到内核的哪个数值,由移植层决定。 这件事不能想当然,需要去查一下移植层的映射表—— 我就见过“以为 Normal 是中间档,结果它比预期低”的情况。
我的取舍是:只在确实希望保留换平台可能性的那个工程里用它, 其余工程直接用原生接口——出问题时少一层,资料也多。 如果只是因为“封装层的函数名看起来更整洁”就套一层,我认为不划算: 整洁是一次性的收益,排查成本是长期的。
十一、一份 FreeRTOS 配置自查清单
下面这张表是我新建工程时会过一遍的配置项。它不追求完整, 只覆盖“漏掉之后会真的出事”的那些。
| 分类 | 配置项 | 怎么定 | 漏掉 / 配错的后果 |
|---|---|---|---|
| 节拍 | 节拍频率 | 先问“最短需要多细的延时粒度”,再决定;不是越高越好 | 配得过高:切换开销变大、CPU 白烧;配得过低:延时粒度不够用 |
| 优先级 | 优先级档数 | 够用就好,通常几档;档数越多,每个任务的内存开销也会跟着涨 | 档数太少不够分;太多则没人说得清该用哪一档 |
| 最小栈 | 空闲任务栈大小 | 只是空闲任务的用量,不能当成“每个任务都照这个给” | 把最小栈当成通用值,任务一复杂就溢出 |
| 中断边界 | 内核可管理的中断优先级上限 | 要和 MCU 的中断优先级分组一起核对,两处必须一致 | 偶发卡死、断言在深夜触发——最难查的一类 |
| 堆 | 堆大小与堆实现 | 先算确定项,再用历史最低水位校正;实现按“会不会释放”选 | 创建失败、碎片、系统卡死,而且表现都不像内存问题 |
| 栈保护 | 栈溢出检查级别 | 至少开到第一级,有条件就开第二级,并实现钩子 | 溢出时静默跑飞,只能靠现象猜 |
| 失败处理 | 内存分配失败钩子 | 打开并实现;至少在钩子里置一个可被外部读到的标志 | 分配失败时在原地打转,外部看就是“程序不动了” |
| 空闲钩子 | 空闲任务钩子 | 只有在需要进低功耗、或者要统计空闲占比时才打开 | 钩子里写了阻塞调用,会把空闲任务卡住 |
| 软件定时器 | 定时器开关、服务任务栈、命令队列长度 | 不用就关掉,省下一个任务和一段栈;要用就按回调的工作量给栈 | 回调里做的事超过栈容量;或者命令队列太短,创建定时器失败 |
| 静态分配 | 静态分配开关 | 要用静态创建就必须打开;打开之后空闲任务与定时器任务的内存也要自己提供 | 编译能过但链接缺符号,或者干脆建不起来 |
| 运行统计 | 运行时间统计开关 | 要统计才开,并且必须提供高分辨率的计数源 | 计数源没提供,统计出来的百分比没有意义 |
| 断言 | 内核断言 | 调试期一定打开 | 参数错误变成“偶发数据错乱”,把半天能查清的问题拖成一周 |
| 同步对象开关 | 互斥锁 / 计数信号量 / 递归互斥锁 | 用到才开 | 全开着不用只是浪费一点代码;真正的问题是没人知道该用哪个 |
| 低功耗 | 无节拍空闲模式 | 要做低功耗才用,并且要重新验证所有延时行为 | 延时的时间基准变了,原本“差不多准”的时序全都要重测 |
十二、几条我反复用到的经验
- 先问“要不要 RTOS”,再问“怎么用 RTOS”。 业务能用一个主循环想清楚、也没有必须抢占的环节时,裸机更划算。
- 动态创建一律判返回值,并且给一条重试路径。 创建失败基本都是堆不够;有重试的设备会自己恢复,没有重试的会卡在一个想不通的现象上。
- 任务集合固定就用静态创建。把内存账在链接期结清, 比在运行期靠统计去猜要安心得多。
- 周期性任务一律用“以上次唤醒为基准”的延时。 用“干完再歇”的写法,周期会随负载漂移,而且平时看不出来。
- 能用“值 + 队列”解决的,不要用“共享变量 + 锁”。 前者把同步和数据一起解决了,后者每漏一个访问点就是一个偶发 bug。
- 中断里只投递,切换交给中断退出前的那一次调用。 输出参数不是装饰,它是这条链路的一半。
- 保护手段按需升级:能上锁就别关调度器,能关调度器就别关中断。 关中断的时长要按“会不会丢帧”来核算,不能按“反正只有几十毫秒”。
- 把诊断功能当功能来做。栈溢出钩子、内存失败钩子、 高水位打印、任务状态快照——它们加起来占不了多少资源, 但能把几天的排查变成一次查看。
- 看到“单任务正常、多任务偶发出错”,先怀疑共享资源。 这类问题最早的信号往往是“某个计数偶尔对不上”,而不是数据明显出错。
参考资料与说明
- FreeRTOS 官方文档与源码(采用 MIT 许可),包括任务与调度、 队列与信号量、软件定时器、内存管理、运行时间统计与栈溢出检查各章。 配置项的确切语义以官方文档与源码注释为准,本文只记录我的取舍经验。
- CMSIS-RTOS2 的公开接口说明(Arm 提供,采用 Apache-2.0 许可), 本文只讨论它作为 FreeRTOS 封装层时的用法与代价。
- Arm Cortex-M 系列关于中断优先级与优先级分组的公开文档, 用于说明“任务优先级与中断优先级方向相反”这条容易混淆的约定。
- 与本文相关的两篇笔记: 《LwIP 协议栈使用笔记:三种 API、配置项取舍与常见坑》 和 《不用 RTOS 的固件怎么写:时间片轮询 + 消息队列》, 前者是本文提到的以太网工程里协议栈那一侧的记录,后者是不用 RTOS 时的对照写法。
- 文中出现的芯片型号仅用于说明技术方案,与相关厂商无隶属或授权关系; 示例代码为按个人理解重写的最小片段,不代表任何产品或交付代码。
- 出于对个人项目实现细节的保护,本文只公开方法与思路; 涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。