本站为个人非经营性网站,仅用于技术学习与分享,不提供任何商品、服务、报价或收费咨询。 合规信息

FreeRTOS 使用笔记:
把项目里真正用到的部分讲清楚

FreeRTOS 的手册很厚,但一个真实的固件工程往往只用到其中一小部分。 这篇笔记不讲内核实现,只讲我在几个自研平台上实际用到的那些: 任务怎么建、优先级和栈怎么定、五种通信机制怎么选、临界区的边界在哪、 以及几种“RTOS 特有”的故障各有什么可观察到的现象。

FreeRTOS RTOS 任务调度 CMSIS-RTOS2 内存管理 已发布

一、我用 FreeRTOS 解决的三类问题,以及它没解决的那些

我并不是“学会了 RTOS 就到处用”。回头看,真正让我决定上一个实时内核的, 只有三类问题,而且它们经常一起出现。

  1. 若干件周期不同、耗时也不同的活要同时推进。 这几件事的周期彼此不成倍数关系——通信轮询是几十毫秒一次、显示刷新是几百毫秒一次、 按键扫描要更快一些、参数落盘则是“有变化才做”。 裸机的时间片轮询能撑住,但只要其中任何一件会阻塞 (比如等一次存储写完),整条时间轴就跟着歪掉,而且歪的幅度取决于数据量—— 这是最难受的一类偶发问题。
  2. 需要“阻塞等待”这个语义。等一个队列里有数据、等一组事件标志凑齐、 等一段确定的时间——这些等待如果用 while 空转来实现,CPU 就白烧在那里; 交给内核之后,等待期间 CPU 可以让给别人。
  3. 需要明确的抢占关系。有几件事是硬实时的:串口帧间隔的判定、 1 ms 级的节拍推进、故障时的立即切断输出。它们必须能打断那些“慢但长”的活 (日志打印、Flash 写入、界面刷新)。

反过来,下面这几件事我没有指望 FreeRTOS 来解决:

  • 它不替代状态机。任务划分和状态划分是两件事。 把“每个系统状态做成一个任务”是一种可选的写法,它有它的收益也有它的代价 (栈内存、堆碎片、切换延迟),我在 《分层状态机与故障优先级》 里专门讨论过,这里不重复。
  • 它不解决时序精度。tick 是毫秒级的,任务唤醒还会受优先级与临界区影响。 微秒级的时序只能交给硬件定时器和中断,内核帮不上忙。
  • 它不减少共享资源的麻烦。任务一多,共享缓冲区、共享外设的竞争反而更多。 内核提供的是工具(互斥锁、临界区),用不用、怎么用还是自己的事。
  • 它不是“性能优化”。上下文切换本身要花时间;把一堆小函数拆成任务, 总体开销只会更大。

所以我的判断标准是三条:业务复杂度是否已经超过“一个主循环能想清楚”的程度; 是否存在必须抢占的环节;RAM 是否还有余量。 三条都不满足的时候,裸机的时间片轮询是更划算的选择—— 我在 《不用 RTOS 的固件怎么写:时间片轮询 + 消息队列》 里写过那种写法,几 KB RAM 的采集单元到现在还在用。

二、任务:创建方式、优先级与栈

这一节的三件事,我认为是“用 RTOS 会不会出事”的分水岭。 它们都不难,但都需要在动手之前想清楚,事后补代价很大。

创建方式:动态还是静态

动态创建时,任务的控制块和栈都从内核堆里分配;静态创建时,这两样东西由调用者提供 (通常就是文件作用域的数组),内核只是把它们登记一下。差别不只是“从哪拿内存”:

对比项动态创建静态创建
内存从哪来 内核堆(configTOTAL_HEAP_SIZE 那块) 调用者提供的控制块与栈数组,编译期确定
会不会失败 会。堆不够时返回失败,必须检查返回值 基本不会(只要参数对),因为内存早就在链接期算好了
内存账什么时候结清 运行期才能知道(要靠水位统计) 链接期就结清了,看 map 文件能算出来
能不能删除后回收 可以,但会在堆里留下空洞(取决于用哪个堆实现) 任务本身可以删除,但那两块内存不解锁、也不该复用
适合什么场景 任务数量在运行期会变的场合(按状态创建/删除任务) 启动后任务集合不再变化的常驻任务

我的几个工程在这件事上是分成两派的,而且理由都很实在。

一派是“全部静态创建”。这类工程的任务数量是固定的: 几个职责明确的常驻任务,启动之后不再增删。既然数量固定, 把它们全部用静态方式建起来就是最省心的——内存占用在链接期就看得见, 运行期不会再申请,也就不存在“堆不够导致创建失败”这个故障模式。 这类工程里连空闲任务和定时器任务的栈都是由代码提供的, 整个 RAM 的账在编译完成那一刻就结清了。

另一派是“动态 + 静态混用”。这类工程里, 常驻的、频次低的任务(比如读时钟、翻运行指示灯)用静态方式创建; 而业务任务跟着系统状态走——进入一个状态就创建对应任务,退出就删除。 这种写法下任务数量是变动的,只能动态创建,于是也就必须接受两件事: 创建可能失败,以及频繁创建删除会在堆里留下碎片。

我把“创建可能失败”当成一条真实经历来记:那个工程的状态切换很频繁, 连续切换若干次之后,有一次 xTaskCreate 没能返回成功。 当时的处理是——打印一行错误,把这次创建放进重试队列,隔一段时间再试一次。 加了这个重试之后,现场再遇到类似情况设备会自己恢复,只是状态切换慢了一拍; 而没有重试的话,设备会卡在“状态切不过去”这个谁也想不通的现象上。 结论很简单:只要用了动态创建,返回值就一定要看,并且要有一条重试路径。

优先级怎么定

我的第一条原则是:按“错过截止时间的后果”排序,而不是按“我觉得它重要”排序。 照这个原则排下来,几个平台的结构意外地一致:

  • 最上面是内核自己(空闲任务、定时器服务任务),以及那些“晚一点就出错”的实时环节;
  • 中间是通信收发与协议处理;
  • 再往下是业务逻辑、状态机推进、参数管理;
  • 最下面是显示刷新、按键扫描、日志输出这类“晚几十毫秒没人看得出来”的活。

第二条原则是:档位不要多。三到五档就够用了。 档位一多,就没人说得清“这个任务该放第 7 档还是第 8 档”, 最后变成每个人按自己写代码那天的感觉去填。我习惯做法是用一组有名字的枚举 (低 / 常规 / 高 / 实时)来代替裸数字,代码里任何地方都不出现裸优先级。 这样做还有一个好处:调整整体结构的时候只改一处。

第三条原则可能有点反直觉:“把某个任务的优先级调高”经常是错误的第一反应。 如果现象是“偶发丢帧”,先要问的是“它为什么没及时跑到”—— 是被低优先级任务持锁挡住了、是被临界区屏蔽了中断、 还是它自己在某个阻塞调用上等了太久。这些问题调优先级都治不了, 只会让别的任务开始出问题。

最后是中断这块,我认为是 FreeRTOS 里最容易搞错、症状又最隐蔽的地方。 在 Cortex-M 上,中断优先级的数值越小代表优先级越高, 而 FreeRTOS 的任务优先级正好相反(数值越大越高)。这两套规则同时出现在一个工程里, 再加上“只有中断优先级不高于某个边界的中断,才允许调用内核的 FromISR 系列接口”这条约束, 一旦配错,症状通常是“偶发卡死”或者某条断言在深夜触发。 我的做法是把“这个中断允不允许调用内核接口”写在中断初始化的注释里, 并且把优先级分组与边界值放在同一个配置文件里,避免两处各写一遍。

栈要留多少:怎么估、怎么验

栈给多少,是所有 RTOS 新手(包括当年的我)最容易随手填一个数的地方。 我给自己的估法是一个三步走:

  1. 走最坏路径。不是走“平时那条路”,而是走“分支最深、局部变量最多”的那条路: 出错处理、参数解析、格式化输出往往比正常路径深得多。 把这条路径上所有函数的局部变量加起来,得到的是“数据”部分。
  2. 加三笔固定开销。函数调用的返回地址(嵌套越深越多)、 被调用到的库函数自己的栈用量、以及中断嵌套时压在任务栈上的那份。 第三笔最容易被忘掉——中断是压在被打断的那个任务的栈上的。
  3. 再留一段余量给“以后会加的东西”。这一条听起来不严谨, 但它符合真实情况:栈用量的增长几乎总是来自后面加的功能, 而不是当初估错的那几个字节。

三个具体的“重灾户”要单独处理:

  • 格式化输出。把整数格式化成字符串、尤其是带浮点的格式化, 栈用量会比想象中大得多。我吃过一次亏:一个任务编译能过、跑起来偶发跑飞, 表现是随机重启和乱码打印,最后定位到就是格式化函数把栈吃穿了。 后来的做法是要么给这个任务明显更大的栈,要么干脆不用浮点格式化。
  • 递归。任务里不该出现递归,因为最坏深度不可控。
  • 大数组。缓冲区一律放成文件作用域的静态数组, 不要放在任务函数里的栈上。栈上放一个大数组,等于把“内存不够”这件事 伪装成“随机跑飞”。

验的办法在第八节:栈溢出检查负责“抓住越界”,高水位负责“告诉我还剩多少”。 两者都要用,缺一个都不够。

若干任务方块按优先级纵向排列,旁边画出运行、就绪、阻塞三种状态的转换,中心是调度器
图 1 · 任务与调度结构图

三、任务间通信:五种机制怎么选

内核提供的通信与同步对象就那么几种,但选错的时候症状差别很大。 我把它们放在一张表里,按“解决什么问题”来分:

机制解决什么开销什么时候用哪个
队列 在任务之间传数据,且数据是值拷贝、有长度上限 中:一次拷贝 + 两个控制块 生产-消费结构:串口收到的帧、要落盘的记录、要显示的文本
二值信号量 只表达“发生了一件事”,不带数据 小 中断通知任务;或者用“有/没有”表达一个资源的占用状态(但要小心它没有优先级继承)
计数信号量 记录“发生了几次”,可以被消费若干次 小 事件计数(比如脉冲个数)、多个同类资源的占用计数
互斥锁 保护共享资源,带优先级继承,只能由持有者释放 中:比信号量多一些逻辑 多个任务要访问同一个外设、同一块缓冲区时。这是它的正业
事件组 一次等待一组条件(可“全满足”或“任一满足”) 中 一个动作要等好几个前置条件都齐了才做,而且不想为每个条件建一个对象
任务通知 直接给某个任务发一个 32 位值,不需要额外的控制块 最小:不占额外内存、不经过队列 一对一的通知,尤其是“中断通知某个任务”。限制是不能多个接收者、不能广播

我在项目里最常用的就是最后一种:中断通知任务。 原因很直接——它不需要额外分配内存,也没有队列的拷贝开销, 而我的使用场景几乎全是一对一的:一个外设中断唤醒一个任务。 代价是它不能被两个任务共用,也不能“广播”;一旦出现一对多, 就得换回队列或事件组。所以用它的前提是先把“谁通知谁”想清楚。

几条我踩过之后总结的取舍经验:

  • 能用“值语义 + 队列”解决的,不要用“全局变量 + 互斥锁”。 前者把“同步”和“数据传递”一次性解决了,后者要求每个访问点都记得加锁, 漏一个就是一个偶发 bug。数据量小的时候,队列的一次拷贝完全值得。
  • 不要往队列里塞大结构体。队列是值拷贝,塞进去一次、取出来一次, 内存里还长期占着“长度 × 元素大小”这块空间。 数据大就传指针——但传指针必须自己保证缓冲区的生命周期, 典型的做法是配一个内存池或者用双缓冲轮换。
  • 二值信号量不要用来保护资源。它没有优先级继承, 用它当锁会重现优先级反转(第九节)。

还有一个观察值得记下来:我见过几个工程把 configUSE_MUTEXES、configUSE_COUNTING_SEMAPHORES、 configUSE_TIMERS 之类的选项全都打开, 但应用层代码里几乎不用这些对象——模块之间靠的是一个自研的环形队列, 中断里往里写、任务里往外读,冲突用一个很短的临界区解决。 这种“配置项开着但没用”本身不致命(每种对象只是多一点点代码), 但它说明一件事:配置项应该反映实际用法,而不是“万一以后要用”。 自研环形队列在这里是有道理的——它的语义更简单 (写不进去是丢还是等,由调用者当场决定),也不占额外的内核对象内存。 那种写法我在 《不用 RTOS 的固件怎么写》 里详细写过,它在裸机工程和 RTOS 工程里同样能用。

五种方式的图标并列:队列、信号量、互斥锁、事件组、任务通知,每种标注一句话用途
图 2 · 任务间通信方式对照图

四、临界区与中断安全 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);
}

围绕这条还有三个配套的约束,漏掉任何一个都会出问题:

  1. 输出参数一定要初始化。不初始化的时候,它可能恰好是非零值, 于是一次不需要切换的中断也触发了切换——不影响功能,但白花时间,而且很难查。
  2. FromISR 系列不能在临界区里调用。内核会断言失败。 如果必须保护,用“中断安全的临界区”那一套,而不是普通的关中断。
  3. 调用 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。

还有两条要注意的:

  1. vTaskDelayUntil 不会“补跑”。 如果某一轮的实际耗时超过了周期,它会立刻返回, 也就是说这一轮迟到了,节奏往后挪,但不会连续跑两次把进度追回来。 如果业务上不能容忍“迟到”,就得在上层记一个迟到标志并做降级处理, 而不是指望内核帮你追。
  2. tick 是毫秒级的。如果某个动作要求周期比一个 tick 还小, 就不该用延时来定时,应该交给硬件定时器中断。

七、堆:怎么定大小、五种实现怎么选、不够时最先表现成什么

先说一个经常被混淆的前提:MCU 的 SRAM、内核的堆、应用自己的静态数组, 是三块不同的内存。内核的堆只是链接脚本里划出来的一段 (configTOTAL_HEAP_SIZE 指定大小), 它不是“所有可用 RAM”。我见过把内核堆开得很大、结果 HAL 的缓冲区没地方放的情况, 现象是“网络正常但别的地方出错”。

谁会吃内核的堆

  • 动态创建的任务:栈 + 任务控制块;
  • 队列、信号量、事件组:控制块 + 队列的存储区;
  • 软件定时器:每个定时器的控制块,以及定时器服务任务自己的栈和命令队列;
  • 应用自己调内存分配接口的地方,以及某些协议栈适配层内部的分配。

怎么定这个大小

  1. 先算“确定的”部分。动态任务的数量 × (栈 + 控制块)、 队列数量 × (控制块 + 长度 × 元素大小)、定时器数量 × 控制块, 这些都可以按最坏情况一项一项加出来。
  2. 再给“不确定的”留余量。运行期会创建/删除的任务、 协议栈适配层、日志缓冲、以及将来要加的功能。
  3. 最后用实测校正,而不是靠猜。内核提供了两个统计接口: 一个给出当前剩余堆,一个给出历史最低水位。 决定该给多少的是后者——只看“当前还剩多少”会得出完全错误的结论, 因为最坏的那一刻往往已经过去了。
/* 按个人理解重写的通用片段:用最低水位校正堆大小 */
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。

内存不够时,最先表现成什么

这一段我认为比上面两张表都重要,因为“内存不够”的表现非常不像内存问题。 按我遇到过的顺序排一下:

  1. 任务创建返回失败。这是最温和的一种——前提是你检查了返回值。 如果没检查,这个任务就静默地不存在,之后的现象是“某个功能完全没反应”。
  2. 队列或信号量创建失败,随后在别处跑飞。 创建接口失败时通常返回空句柄,如果代码里没有判空就继续用这个句柄, 会在某个跟内存毫无关系的地方触发硬件异常。
  3. 创建的时候成功,跑了一段时间之后才失败。 典型的碎片型:总空闲量还够,但没有一块足够大的连续空间了。 这种最容易被误判成“偶发”。
  4. 整个系统卡死。如果配置了“内存分配失败钩子”但没实现它, 默认行为就是在原地打转——从外面看就是“程序不动了”, 而且没有任何输出告诉你为什么。
  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 时间被谁吃掉了”。前置配置有三项,缺一不可:

  1. 打开“生成运行时间统计”的配置项;
  2. 如果要使用格式化输出的统计接口,还要打开对应的格式化功能开关;
  3. 提供一个比 tick 频率高得多的计数源, 也就是实现“配置统计时基”和“读取统计计数”这两个接口。

第三项最容易漏。计数源的选择上,一个自由运行的硬件定时器计数器是最省事的: 读统计计数时直接返回那个定时器的计数寄存器就行。 分辨率要比 tick 高一个数量级以上,否则算出来的百分比全都是量化误差, 看着有数其实没意义。

还有一个细节:这个计数接口应该是只读的。 如果为了“重新开始统计”而在里面把计数器清零, 别的也在用这个定时器的功能就会受影响。

我看到过两种做法:一种是打开运行时间统计、用一个通用定时器作时基, 数据准确但有每次切换读计数器的开销; 另一种是在 tick 钩子里数空闲任务被调度了多少次来估算 CPU 占用, 更省事但要自己处理计数溢出,而且那个实现后来被整段注释掉了。 我的建议是调试期用前者,定型之后再决定要不要关掉。

九、几种 RTOS 特有的故障:现象比原因好认

这一类故障的共同特点是:从现象看不出跟 RTOS 有关系。 我把遇到过的、以及看到别人踩过的整理成一张表。 排查的时候我一般是“从现象反查”,而不是从代码往下读。

故障可观察到的现象怎么查 / 怎么改
任务栈溢出 随机跑飞、硬件异常、打印乱码;现象和负载相关,数据一多就出现 打开第二级栈溢出检查并实现钩子;看高水位;把大数组和格式化输出从栈上挪走
优先级反转 高优先级任务偶尔被“卡住”,卡住的时长恰好等于某个低优先级任务持锁的时长 共享资源改用带优先级继承的互斥锁;把持锁区间压到最短;不要在持锁时做耗时操作
忘记让出(忙等) 低优先级任务饿死;同优先级任务轮不到;系统“看起来在跑但什么都没干”;功耗偏高 检查每个死循环里有没有阻塞点;把“轮询标志位”改成“等通知或等信号量”
在中断里调了非中断安全的接口 断言触发或直接死机;如果断言被关掉,表现为偶发数据错乱、队列计数不对 把所有 FromISR 后缀理一遍;核对该中断优先级是否在内核可管理范围内
在临界区里调了会阻塞的接口 断言触发,或者干脆死锁在原地 临界区里只做赋值和队列/通知操作;需要等待的事情放到临界区外面
定时器回调里做重活 所有软件定时器的触发时间一起被推后,周期越长偏得越多 回调里只投递,处理交给任务;必要时加大定时器服务任务的栈
钩子函数没实现 明明检测到了问题,却没有任何输出;或者栈溢出时程序卡在一个莫名的死循环里 把栈溢出钩子、内存分配失败钩子都实现掉,至少在钩子里置一个能被外部读到的标志
优先级方向搞反 任务的调度顺序和预期完全相反;“提高了优先级反而更糟” 记住两套规则:任务优先级数值越大越高,Cortex-M 中断优先级数值越小越高;统一用命名枚举表达
共享资源没保护 多任务下的偶发数据错乱,单任务测试完全正常 用“值语义 + 队列”代替共享全局变量;必须共享时用互斥锁,并且让所有访问点都走同一把锁

如果只让我留一条经验,那就是最后一行:“单任务测试正常、多任务偶发出错” 几乎一定是共享资源问题,而不是内核的问题。 这类问题最早的信号往往不是数据错,而是“某个计数偶尔对不上”—— 看到这种信号就该去查共享变量,而不是继续加打印。

十、CMSIS-RTOS2 封装层:好处与代价

有几个工程并没有直接用原生接口,而是在上面套了一层 CMSIS-RTOS2 的封装。 用下来我的结论是:它的价值完全取决于“你有没有换平台的需求”。

好处

  • 可移植。线程、消息队列、信号量、定时器这些概念被抽象成一套统一的接口, 底层换成别的内核时,应用层代码基本不用动。 对“同一条产品线要出好几个硬件版本、将来可能换芯片”的团队来说,这个收益很实在。
  • 写法规整。所有内核对象都用同一种“属性结构体”描述, 静态创建时要用到的控制块和栈也是通过这个结构体传进去的。 比起原生接口里每个对象一套参数,读起来更一致。
  • 命名统一。看过任意一种 RTOS 的人都能读懂代码,团队协作的沟通成本低。

代价

  • 多一层,排查时要多穿一层。 错误码被归一化成几个笼统的值,具体是参数错还是内存不够, 往往还要回到原生接口那一层去看。
  • 部分参数被隐藏或固定。内核特有的一些选项在封装层里没有对应字段, 只能改配置宏;某些默认行为(比如对象属性的默认值、栈的对齐方式)由封装层决定, 想改要翻它的实现。
  • 资料少。网上的例子几乎都是原生接口写的, 用封装层的时候每遇到一个新需求,都要自己做一次“这个功能对应哪个接口”的映射。
  • 优先级要过一次转换。封装层用一组命名优先级 (Normal、BelowNormal、AboveNormal 之类)来代替裸数字, 而它到底映射到内核的哪个数值,由移植层决定。 这件事不能想当然,需要去查一下移植层的映射表—— 我就见过“以为 Normal 是中间档,结果它比预期低”的情况。

我的取舍是:只在确实希望保留换平台可能性的那个工程里用它, 其余工程直接用原生接口——出问题时少一层,资料也多。 如果只是因为“封装层的函数名看起来更整洁”就套一层,我认为不划算: 整洁是一次性的收益,排查成本是长期的。

十一、一份 FreeRTOS 配置自查清单

下面这张表是我新建工程时会过一遍的配置项。它不追求完整, 只覆盖“漏掉之后会真的出事”的那些。

分类配置项怎么定漏掉 / 配错的后果
节拍 节拍频率 先问“最短需要多细的延时粒度”,再决定;不是越高越好 配得过高:切换开销变大、CPU 白烧;配得过低:延时粒度不够用
优先级 优先级档数 够用就好,通常几档;档数越多,每个任务的内存开销也会跟着涨 档数太少不够分;太多则没人说得清该用哪一档
最小栈 空闲任务栈大小 只是空闲任务的用量,不能当成“每个任务都照这个给” 把最小栈当成通用值,任务一复杂就溢出
中断边界 内核可管理的中断优先级上限 要和 MCU 的中断优先级分组一起核对,两处必须一致 偶发卡死、断言在深夜触发——最难查的一类
堆 堆大小与堆实现 先算确定项,再用历史最低水位校正;实现按“会不会释放”选 创建失败、碎片、系统卡死,而且表现都不像内存问题
栈保护 栈溢出检查级别 至少开到第一级,有条件就开第二级,并实现钩子 溢出时静默跑飞,只能靠现象猜
失败处理 内存分配失败钩子 打开并实现;至少在钩子里置一个可被外部读到的标志 分配失败时在原地打转,外部看就是“程序不动了”
空闲钩子 空闲任务钩子 只有在需要进低功耗、或者要统计空闲占比时才打开 钩子里写了阻塞调用,会把空闲任务卡住
软件定时器 定时器开关、服务任务栈、命令队列长度 不用就关掉,省下一个任务和一段栈;要用就按回调的工作量给栈 回调里做的事超过栈容量;或者命令队列太短,创建定时器失败
静态分配 静态分配开关 要用静态创建就必须打开;打开之后空闲任务与定时器任务的内存也要自己提供 编译能过但链接缺符号,或者干脆建不起来
运行统计 运行时间统计开关 要统计才开,并且必须提供高分辨率的计数源 计数源没提供,统计出来的百分比没有意义
断言 内核断言 调试期一定打开 参数错误变成“偶发数据错乱”,把半天能查清的问题拖成一周
同步对象开关 互斥锁 / 计数信号量 / 递归互斥锁 用到才开 全开着不用只是浪费一点代码;真正的问题是没人知道该用哪个
低功耗 无节拍空闲模式 要做低功耗才用,并且要重新验证所有延时行为 延时的时间基准变了,原本“差不多准”的时序全都要重测

十二、几条我反复用到的经验

  1. 先问“要不要 RTOS”,再问“怎么用 RTOS”。 业务能用一个主循环想清楚、也没有必须抢占的环节时,裸机更划算。
  2. 动态创建一律判返回值,并且给一条重试路径。 创建失败基本都是堆不够;有重试的设备会自己恢复,没有重试的会卡在一个想不通的现象上。
  3. 任务集合固定就用静态创建。把内存账在链接期结清, 比在运行期靠统计去猜要安心得多。
  4. 周期性任务一律用“以上次唤醒为基准”的延时。 用“干完再歇”的写法,周期会随负载漂移,而且平时看不出来。
  5. 能用“值 + 队列”解决的,不要用“共享变量 + 锁”。 前者把同步和数据一起解决了,后者每漏一个访问点就是一个偶发 bug。
  6. 中断里只投递,切换交给中断退出前的那一次调用。 输出参数不是装饰,它是这条链路的一半。
  7. 保护手段按需升级:能上锁就别关调度器,能关调度器就别关中断。 关中断的时长要按“会不会丢帧”来核算,不能按“反正只有几十毫秒”。
  8. 把诊断功能当功能来做。栈溢出钩子、内存失败钩子、 高水位打印、任务状态快照——它们加起来占不了多少资源, 但能把几天的排查变成一次查看。
  9. 看到“单任务正常、多任务偶发出错”,先怀疑共享资源。 这类问题最早的信号往往是“某个计数偶尔对不上”,而不是数据明显出错。

参考资料与说明

  • FreeRTOS 官方文档与源码(采用 MIT 许可),包括任务与调度、 队列与信号量、软件定时器、内存管理、运行时间统计与栈溢出检查各章。 配置项的确切语义以官方文档与源码注释为准,本文只记录我的取舍经验。
  • CMSIS-RTOS2 的公开接口说明(Arm 提供,采用 Apache-2.0 许可), 本文只讨论它作为 FreeRTOS 封装层时的用法与代价。
  • Arm Cortex-M 系列关于中断优先级与优先级分组的公开文档, 用于说明“任务优先级与中断优先级方向相反”这条容易混淆的约定。
  • 与本文相关的两篇笔记: 《LwIP 协议栈使用笔记:三种 API、配置项取舍与常见坑》 和 《不用 RTOS 的固件怎么写:时间片轮询 + 消息队列》, 前者是本文提到的以太网工程里协议栈那一侧的记录,后者是不用 RTOS 时的对照写法。
  • 文中出现的芯片型号仅用于说明技术方案,与相关厂商无隶属或授权关系; 示例代码为按个人理解重写的最小片段,不代表任何产品或交付代码。
  • 出于对个人项目实现细节的保护,本文只公开方法与思路; 涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。