先抛一个我自己的真实经历:某次做一套四路传感器采集加 OLED 显示加串口命令解析的固件,程序规模其实不大,但它让我在调试台前耗了整整一个晚上——按键一按下,OLED 刷新就卡;串口来一帧数据,采集时序就乱;给采集循环加个防抖延时,显示刷新肉眼可见地掉帧。最后我把整个程序拆成三个 FreeRTOS 任务重新组织,问题基本消失。从那以后,我的大多数 STM32 项目都默认先考虑上 RTOS,而不是一上来就写一个大 while(1)。这篇文章要讲的,就是这个基础——STM32 上怎么跑 FreeRTOS、为什么要这么跑、第一次从裸机切到任务化编程时要跨过哪些坎。适合刚会点 STM32 基础外设、正准备接触操作系统的开发者,也适合已经在跑 demo、但对调度原理和任务通信一知半解的同行。
1. 裸机开发的痛点:先想清楚为什么要上 RTOS
1.1 轮询、中断与前后台系统的"死法"
大部分裸机程序的结构是"主循环 + 中断",也就是所谓的前后台系统。main 里的 while(1) 是后台,各种外设中断是前台。这种模式本身没有错,但一旦外设变多、逻辑变复杂,就会出现几个特别典型的症状。
第一个症状是轮询太慢。传感器采集要等转换完成,按键要去抖还要判断边沿,显示模块要一帧一帧刷,网络或者串口协议要解析字节流。你把它们全塞进一个循环,每个模块的执行时间就是所有模块执行时间之和。OLED 刷一屏要毫秒级,按键去抖又要十几毫秒,传感器读出结果还得等,几件事叠加在一起,任何一个环节慢一点,其他模块都会被拖住。
第二个症状是中断相互打架。你想用定时器中断做精确的采集节拍,又想用串口中断收数据,还想用外部中断处理按键。每个中断都觉得自己很急,但 CPU 只有一个。高优先级中断一进来,低优先级的处理就被打断;如果两个中断共享某个变量,你还得小心翼翼做原子保护。再往后,某个中断函数里一不小心写了超过几十行的处理代码,整个系统的实时性就崩了。
第三个症状是时序完全不可控。裸机不是不能做多任务,而是多任务调度这件事需要你自己去写。状态机、标志位、计时变量、switch-case 大杂烩,改一个逻辑要牵动一堆地方。程序到两三千行的时候,你已经不敢随便动 main 里的顺序了,因为改动一个轮询顺序可能让一个完全不相干的模块出问题。
1.2 一个典型例子:传感器、指令解析与显示互相干扰
我把那个经典的问题场景再说细一点。假设你有一个 STM32 工程,要同时做三件事:每 10ms 采集一次温度传感器,随时响应上位机发来的串口命令,把最新数据刷新到屏幕上。
裸机写法大概是:主循环里先读传感器,再把数据格式化好刷屏,最后检查串口接收缓冲。看上去很合理,但问题在于:刷屏本身会占用几百微秒到几毫秒,如果串口命令恰好在刷屏期间到达,而你的接收处理又被放在主循环的最后,那这条命令的响应就慢了。更麻烦的是,传感器读数的那个"10ms 周期"在主循环里是靠累计时间戳实现的,主循环执行时间稍微波动,采集间隔就跟着波动。
用 FreeRTOS 之后,这三件事可以拆成独立任务:采集任务只用 vTaskDelay(pdMS_TO_TICKS(10)) 固定周期;串口解析任务在队列里等数据,有数据才跑;显示任务用自己的节奏刷新。任务之间通过队列和信号量通信,临界资源用互斥量保护。改造以后每个任务的代码都变得非常短,可读性也高很多——这就是 RTOS 带来的第一层价值:把"时间片分配"这件事从业务代码里彻底剥离出去。
1.3 FreeRTOS 适合什么,又解决不了什么
FreeRTOS 不是万能的。如果你的项目只有一个按键加一个 LED,或者所有逻辑都能靠中断和主循环轻松搞定,那完全没必要上 RTOS。上系统是有成本的:内存开销、调度切换开销、学习成本,还有任务间通信带来的调试复杂度。
但当你出现以下任何一个信号,就可以认真考虑 FreeRTOS 了:多个周期性任务需要精确并行;某个高优先级事件必须能抢占耗时流程;模块之前靠全局变量传参传到已经理不清楚;或者你希望以后把代码从裸机迁移到更复杂的平台。FreeRTOS 在 MCU 上尤其是 STM32 上的生态非常成熟,不夸张地说,它已经是安全的默认选择。
2. FreeRTOS 的骨架:任务、调度器与 tick 是怎么协同的
2.1 任务到底是什么
在裸机眼里,"程序"是一条从 main 开始跑到底的执行流。在 FreeRTOS 眼里,程序是被切成一个个任务的集合,每个任务看起来像一个独立的"死循环函数"。
void vSensorTask(void *pvParameters) { for (;;) { read_sensor(); vTaskDelay(pdMS_TO_TICKS(10)); } }这段代码里,vSensorTask 就是一个任务函数。它不返回,返回值类型是 void,参数是一个 void 指针。任务函数一旦开始运行,就一直存在于系统中,直到你主动删除它。
但这里有件非常反直觉的事:每个任务都像"独占 CPU"地在跑。你以为传感器任务在跑,显示任务也在同时跑,实际上单核 CPU 同一时刻只能执行一条指令。FreeRTOS 通过快速切换任务让每个任务都感觉自己独占 CPU,这个切换速度有多快呢?在 72MHz 的 STM32F103 上,一次任务切换一般是几个微秒量级,人完全感知不到,于是形成了"并发"的错觉。
2.2 调度器怎么实现"凭空切换"
真正支撑这个"错觉"的是上下文切换机制,也就是 context switch。每个任务创建时,系统会为它分配独立的栈空间,并把这个任务的函数入口、参数和初始状态压进栈里。当调度器要从任务 A 切换到任务 B 时,它会先把 A 当前的寄存器现场全部保存到 A 自己的栈中,然后从 B 的栈里恢复 B 之前保存的寄存器现场,再跳回去继续执行。这一步在 STM32 上是由硬件和操作系统配合完成的,硬件自动压栈一部分寄存器,剩下的一部分由汇编代码手动保存。
你不需要会写这套汇编,但一定要理解"每个任务都有自己的一套栈和现场"这个前提。很多新手犯的错就是任务函数里开了太大的局部变量,或者调用了深度递归,一不小心把系统给的这个栈空间撑爆了。栈从哪来?由系统的内存堆分配,这也是后面要专门讨论内存配置的原因。
2.3 tick:系统的心跳
调度器不会凭空知道该在什么时候切换任务,它需要一个时间基准,这个基准就是 tick。FreeRTOS 通常使用 STM32 的 SysTick 定时器产生周期性中断,每次中断就是一个 tick。如果你把 configTICK_RATE_HZ 设为 1000,那么每 1ms 就会有一个 tick 中断,用来更新系统时间、检查延时任务是否到期、决定时间片轮转是否发生。
这个 tick 决定了系统的时间粒度。vTaskDelay(pdMS_TO_TICKS(10)) 中的 10ms 并不是精确定时器,它允许一定滞后,含义是"至少等 10ms"。如果你要的是严格的周期性采样,光用 vTaskDelay 是不够的,因为 vTaskDelay 计算的是"从现在开始等待一段时间",加上任务本身运行时间,实际周期会漂移。后面会讲 vTaskDelayUntil 对这种固定周期场景的处理。
2.4 任务的四态转换:运行、就绪、阻塞、挂起
任务在任意时刻都处于四个状态之一:运行态、就绪态、阻塞态、挂起态。单核上运行态只有当前一个任务;就绪态的意思是"我随时可以运行,但 CPU 还没轮到我";阻塞态主要发生在等待延时、等待队列数据、等待信号量时;挂起态则是由 vTaskSuspend 主动挂起,只有调用 vTaskResume 才能恢复。
有了状态机,任务间的优先级关系就清楚了:高优先级任务一旦就绪,会立刻抢占当前运行的低优先级任务;同优先级的任务则按时间片轮流执行。理解这四态,调试调度问题时你就知道该去看任务卡在哪个状态——比如任务"没反应",到底是它没被创建,还是阻塞在等待某个永远不来的信号量。
3. 在 STM32 上把 FreeRTOS 跑起来:CubeMX 配置与手动移植的对比
3.1 CubeMX 图形化配置的最小步骤
对 STM32 用户来说,最快让 FreeRTOS 跑起来的方式是用 CubeMX 生成工程。在 CubeMX 里选择芯片后,把时钟树配置好,然后在 Middleware and Software Packs 里勾选 FreeRTOS,选择一个接口版本,日常用 CMSIS_V1 或 CMSIS_V2 都可以。接着设置参数:TICK_RATE_HZ 我一般设 1000,如果对实时性要求不高也可以用 100;MAX_PRIORITIES 设 32 以内够用;TOTAL_HEAP_SIZE 根据项目估算,先给 8KB 到 16KB 起步。
生成代码后,你会发现 main.c 里已经自动创建了一个默认任务。STM32Cube 生成的代码会经过一层 CMSIS-RTOS 封装,任务函数写在一个名为 StartDefaultTask 的函数里,主函数中通过 osThreadCreate 或 osThreadNew 创建任务,最后调用 osKernelStart 启动调度器。很多人在这一步会犯一个低级错误:在 osKernelStart 之后又去写大量 "初始化逻辑"。调度器启动后,代码会从 main 进入任务上下文,main 里的普通代码不会再被顺序执行,所以所有初始化都要放在调度器启动之前,或者干脆放进任务本身。
3.2 手动移植时最容易漏的三件事
如果不想依赖 CubeMX,或者你需要一个极度精简的工程,也可以手动把 FreeRTOS 源码丢进工程编译。那时有三件事绝对不能漏。
第一件是中断处理函数的接管。FreeRTOS 需要 SysTick 产生 tick,需要 PendSV 做上下文切换,还需要 SVC 启动第一个任务。CubeMX 自动生成的工程里,这些向量已经路由好了;手动移植时,你必须确认启动文件里这三个中断向量指向 FreeRTOS 的实现,而不是默认的空处理。第二件是 FreeRTOSConfig.h 里正确设置 configCPU_CLOCK_HZ 和 configTICK_RATE_HZ,前者必须等于你实际配置的系统主频,否则延时和系统时间全部失真。第三件是裸机 HAL 工程里 SysTick 的冲突。如果你之前已经用 SysTick 做延时,移植后要么腾出来给 FreeRTOS,要么改用其他定时器提供 tick。
3.3 从裸机工程改造到 FreeRTOS 的通用套路
把一个已经写了不少代码的裸机工程迁到 FreeRTOS,不建议一次把所有代码重写。我习惯的做法是:先把工程用 CubeMX 重新生成 FreeRTOS 基础;把原来的主循环内容按功能拆成若干任务函数,每个函数里先只做一件事的分区;把共享的全局变量改成队列或者加互斥量;最后再补齐任务同步逻辑。
迁移期间最容易翻车的是外设中断回调函数。HAL 库的中断回调里往往有用户代码,如果直接在回调里写复杂逻辑,等于把时间敏感的操作又塞回了中断,就失去了上系统的意义。正确的迁移方向是让回调函数只做最轻量的事件标记或者调用 FromISR 版本 API 把数据发出去,真正处理放任务里。
4. 任务管理的实操细节:创建、优先级、延时与栈
4.1 创建任务时每个参数都要心里有数
xTaskCreate 是最常用的创建函数,原型我不完整贴了,重点说参数。任务函数名不要和函数指针混淆;任务名字符串主要用于调试,会被拷进任务控制块;栈深度单位是"字"而不是字节,在 CM3/CM4 内核上是 4 字节一个字,所以 128 字等于 512 字节。很多人在栈上吃亏,就是没搞懂这个单位。
TaskHandle_t xUsartHandle; BaseType_t ret = xTaskCreate( vUartParseTask, // 任务函数 "uart_parse", // 任务名称,调试用 256, // 栈大小,单位是字,256x4 = 1024 字节 NULL, // 传给任务的参数 2, // 优先级,数值越大优先级越高 &xUsartHandle // 返回的任务句柄,可用来挂起、删除任务 );优先级数值的范围由 configMAX_PRIORITIES 决定。这里要注意:FreeRTOS 约定数值越大优先级越高,和你在中断里习惯的"数值越小优先级越高"正好相反。这个反直觉的设定害过不少人,值得记一下。
任务创建完之后不要立刻认为它已经在跑了。调度器还没启动时,任务只是存在于就绪列表里,真正开始执行要等 vTaskStartScheduler()。所以你可以在 main 里先初始化硬件、再创建若干任务、最后启动调度器,这个顺序不能乱。
4.2 延时、周期任务与时间片
任务里最常见的延时调用是 vTaskDelay,参数用 pdMS_TO_TICKS 宏转换毫秒。它会让任务进入阻塞态,等若干个 tick 后再回到就绪态。因为就绪后的恢复时机取决于下一个 tick 中断,所以 vTaskDelay 的精度只是"至少延时 xx 毫秒",不是精密控制。
如果你需要一个严格的周期循环,比如每 10ms 采样一次,就要用 vTaskDelayUntil。它的原理是以某个任务的开始时间为基准计算下一次唤醒时间,而不是每次从现在时间再加延时,因此即使任务运行时间有波动,周期也能保持稳定。
还需要理解同优先级的时间片轮转。当两个任务优先级相同且都处于就绪态,FreeRTOS 会让它们按 tick 时间片轮流跑,每个任务跑一个时间片(由 configTIME_SLICE 控制,默认是 1 个 tick),时间到了就切到下一个就绪任务。这个机制平时很省心,但也可能导致两个同优先级任务"互相抢",如果确实有严格的先后顺序,还是用信号量把节奏控制起来更可靠。
4.3 栈空间到底该给多大
栈大小是任务设计中最难拍板的事。给大了浪费 RAM,给了小了一跑就崩。经验是:先用一个偏大的值,比如 512 字,把功能调通,再用 uxTaskGetStackHighWaterMark 查看任务运行过程中最小剩余栈空间,然后根据余量逐步调小。这个函数返回的是"历史上最少剩余栈空间",单位是字,数值越接近 0 越危险。
局部变量特别大的任务要想办法规避,比如局部数组尽量用 static 或者 malloc,把空间放堆里。函数嵌套调用链很深的模块(比如带 printf 的日志解析)是栈消耗大户,格式化函数内部会开不小的缓冲,这类任务我一般会给到 512 字到 1024 字。
4.4 任务崩溃时从哪里查起
任务跑着跑着系统就进 HardFault,是 FreeRTOS 新手最常见的问题。排查时第一件事不是看逻辑,而是确认是不是栈溢出。把 FreeRTOSConfig.h 里的 configCHECK_FOR_STACK_OVERFLOW 设为 1 或 2,再实现 vApplicationStackOverflowHook 钩子函数,在钩子里设一个断点或者翻转一个 GPIO,一旦溢出立刻能抓到现场。
第二个高发问题是中断里调用了非 FromISR 版本的 API,导致调度数据被破坏。中断里发队列、给信号量,一律查清楚是不是用了带 FromISR 后缀的函数,否则表面上程序不报错,但行为会随机变诡异。第三高发是任务里访问了未初始化或者已经被删除的资源,这类问题要配合任务句柄和状态列表去排查,下面一节讲通信时会提到。
5. 任务之间怎么说话:队列、信号量、互斥量与事件组
5.1 队列:任务间最基础的数据管道
队列在 FreeRTOS 里的本质是一个带拷贝语义的环形缓冲区。发送方调用 xQueueSend 把数据拷进队列,接收方调用 xQueueReceive 把数据拷出来。这个"拷贝"很重要,意味着队列里的数据是发送方的副本,发送方发送后可以立刻修改原变量,不会影响接收方已经拿到的数据。
QueueHandle_t xDataQueue; xDataQueue = xQueueCreate(8, sizeof(uint16_t)); // 发送方 uint16_t val = read_adc(); xQueueSend(xDataQueue, &val, 0); // 接收方 uint16_t rx_val; if (xQueueReceive(xDataQueue, &rx_val, pdMS_TO_TICKS(100)) == pdPASS) { process(rx_val); }创建队列时第一个参数是深度,第二个是单个元素大小,这两个参数决定了队列占用的内存。发送和接收都有一个超时参数,超时时间内如果队列满或空,调用方会进入阻塞态等待;设为 0 表示不等待,立即返回。
队列用得很广,但要注意别把它当全局变量用。如果我需要把同一份传感器数据发给显示任务和存储任务,可以直接在接收方里把数据再转发,或者用多个队列各自接收同一个源头任务发出的数据。频繁的大结构体互相拷贝是队列的性能杀手,比较大的数据建议用队列传指针,但传指针时要确保指针指向的内存生命周期是跨任务安全的。
5.2 二值信号量与计数信号量
信号量是更轻量的同步工具。二值信号量适合做"事件通知":比如中断里采集到一帧数据,释放一个信号量,任务里等待这个信号量然后去处理。它像一个只有 0 和 1 的旗标,释放后如果任务还没取走,多次释放也只会累积成一个。
计数信号量适合做资源计数,典型场景是有 N 个相同资源,比如 4 个 DMA 通道,或者 5 个消息缓冲。每次占用资源就 xSemaphoreTake,释放就 xSemaphoreGive,信号量的计数值表示还剩多少资源可分配。计数信号量的计数值上限在创建时指定,如果超过上限,Give 会失败。
5.3 互斥量与优先级反转
互斥量和二值信号量都叫 mutex/semaphore,容易混,但用途完全不同。互斥量用于保护临界资源,说人话就是保证同一时刻只有一个任务能访问某个共享外设或共享变量。互斥量还有一个重要特性:优先级继承。这个特性的意义在于解决经典的优先级反转问题——高优先级任务等待一个被低优先级任务持有的锁,而低优先级任务又被中等优先级任务抢占,导致高优先级任务被"夹在中间"无限等待。互斥量会在高优先级任务等待时,暂时把持有锁的低优先级任务提升到高优先级,让它可以尽快运行并释放锁。
所以规则很简单:保护共享资源用互斥量,事件通知用二值信号量。如果我用二值信号量去保护一个总线外设,虽然看起来能工作,但系统实时性在极端情况下会变得很难看。别问我怎么知道的,我在 I2C 总线上吃过这个亏。
5.4 实际项目里怎么设计任务间的消息拓扑
任务间通信设计没有银弹,但有个比较稳的思路:每个任务尽量只从指定入口获取消息,不要到处去读取全局变量。比如系统里有一个采集任务,一个命令任务,一个显示任务,我叫它"生产者-消费者-展示者"模型。采集任务往队列里发数据;命令任务收到串口指令后修改运行参数;显示任务订阅队列,更新画面。任务之间的依赖关系如果画出来是简单的箭头而不是环,调试起来会轻松很多。
如果任务间只是"通知对方去干活"而没有大量数据传输,优先考虑任务通知。FreeRTOS 的任务通知比二值信号量更快、占用内存更少,每个任务默认就有一个通知状态,可以用 xTaskNotifyGive / ulTaskNotifyTake 实现轻量级同步。这个特性在中断通知任务时特别常用。
6. 中断与服务任务之间的桥梁:ISR 版 API 与优先级边界
6.1 中断里只能做什么
任务里能用 xQueueSend、xSemaphoreGive,但在中断里不安全的,因为中断可能像恶作剧一样在任何时刻打断任务,操作系统内部的数据结构还没准备好。FreeRTOS 提供了一整套 FromISR 后缀的 API,比如 xQueueSendFromISR、xSemaphoreGiveFromISR、xTaskNotifyGiveFromISR。这套 API 在实际调用时还有一个特殊参数 pxHigherPriorityTaskWoken。
这个参数很有意思:如果中断里发出的资源唤醒了一个比当前运行任务优先级更高的任务,系统不会立刻切换,而是把需要切换这件事记录在这个变量里。等你中断处理快结束时,手动调用 portYIELD_FROM_ISR(pxHigherPriorityTaskWoken),才会真正触发任务切换。这样做的好处是中断处理不会被打断,切换时机由我们自己控制。
6.2 那组让人迷惑的中断优先级数字
STM32 的 NVIC 优先级用 4 个比特表达 0 到 15,数值越小优先级越高。FreeRTOS 在移植层会配置 configMAX_SYSCALL_INTERRUPT_PRIORITY,常见设置为 5。这意味着:优先级数值大于等于 5(也就是"足够低")的中断,可以安全调用 FromISR 版本 API;优先级数值小于 5 的高优先级中断,不能调用任何 FreeRTOS API,只能在中断里设置标志位,或者做非常简单的现场保存后,等待 CPU 空闲后再处理。
这个边界经常让人困惑,我给出一个记忆方法:越紧急的中断越不能调 API,因为它会破坏调度器数据完整性。正确做法是紧急中断只负责把硬件状态标记好,让低优先级的中断或者任务去调用系统 API。很多项目把串口接收中断配置成优先级 5 或更低,然后在里面调用 xQueueSendFromISR,把数据发给任务处理,这是一个非常典型且安全的用法。
6.3 完整案例:串口接收中断怎么接任务
串口接收中断里最常见的做法是逐字节接收或者 DMA 批量接收。如果逐字节中断,直接在中断里 xQueueSendFromISR,把收到的字节放进队列,任务里再组帧解析。这样中断只做一件事:把字节丢队列,秒回。解析协议、超时判断、帧格式校验全放到任务里。
// 中断回调中:假设在 HAL_UART_RxCpltCallback 里 BaseType_t xHigherPriorityTaskWoken = pdFALSE; uint8_t byte = rx_byte; xQueueSendFromISR(xRxQueue, &byte, &xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken);任务侧则是一个 while 循环等待队列,凑满一帧后做解析。这套结构下,无论串口数据多快,中断都不会长时间占用 CPU,任务也不会因为外部输入节奏不定而跑飞。这里还要注意:如果用的是低功耗模式,串口唤醒后第一个字节的处理优先级要重新评估,因为部分停止模式会改变中断响应时机,这属于进阶问题,先把基础跑通再考虑。
6.4 中断里调 HAL 函数要不要加锁
HAL 库自己也有一些回调函数,比如 HAL_UART_RxCpltCallback,它们本身是在中断上下文里被调用的。如果你在任务里还调用同一个外设的 HAL 函数,任务和中断之间存在竞态。FreeRTOS 的互斥量在中断里无法使用,所以更稳妥的做法是:中断通信只走队列和 FromISR API,任务的 HAL 调用放在任务上下文执行,中断回调里只读硬件状态寄存器,不主动操作外设。外设本身的访问冲突,可以通过关中断临界区保护,或者干脆把某个外设的服务任务优先级设计成唯一入口。
7. 从 demo 走向产品:内存管理、裁剪和在线调试经验
7.1 五种 heap 实现怎么选
FreeRTOS 源码里自带 heap_1.c 到 heap_5.c 五种内存分配实现,新手经常纠结该用哪个。其实原则很简单:项目里需要删除任务、创建动态队列,就用 heap_4。heap_4 支持分配和释放,能够合并相邻空闲块,碎片问题在 MCU 上表现比较可以接受;heap_1 只能分配不能释放,适合那些完全静态分配、不删任务的极简单片;heap_2 能释放但不能合并碎片,已经不太推荐;heap_5 是 heap_4 的多块版本,当 RAM 分布在多个不连续区域时用。
这些 heap 占用的内存都来自一个大的静态数组,大小由 configTOTAL_HEAP_SIZE 决定。把任务栈、队列都算成内存,一般建议先给一个偏大的值,运行稳定后再通过高水位函数逐步回收余量。
7.2 FreeRTOSConfig.h 裁剪与静态分配
FreeRTOS 的裁剪集中在 FreeRTOSConfig.h。把用不到的功能关掉,可以省掉不少 ROM 和 RAM。比如不用互斥量就把 configUSE_MUTEXES 置 0;不用软件定时器就把 configUSE_TIMERS 置 0;不希望任务被删除就把 configSUPPORT_DYNAMIC_ALLOCATION 保留但移除相关删除调用。需要注意这些配置不是随便改的,改完必须重新编译看有没有报错,因为很多功能之间存在隐性依赖。
如果你的项目需要极致的确定性,可以考虑静态分配——任务栈和任务控制块由你自己定义,不经过堆管理器。这时需要实现 vApplicationGetIdleTaskMemory 和 vApplicationGetTimerTaskMemory 等钩子,把空闲任务和定时器任务的内存交给系统。静态分配的优点是内存布局完全可控,缺点是代码繁琐一点。对大多数 STM32 项目,heap_4 动态分配已经够用。
7.3 任务栈和 CPU 占用怎么看
任务跑得稳不稳定,不能靠蒙。FreeRTOS 提供了一些运行时统计手段。configGENERATE_RUN_TIME_STATS 置 1 后,配合一个高精度定时器计数,可以在任务里调用 vTaskGetRunTimeStats 拿到每个任务占用 CPU 的百分比。这个信息对定位"哪个任务抢占了太多时间"非常有价值。
如果芯片支持 DWT,可以直接用 DWT->CYCCNT 作为时间基准,这个寄存器在 Cortex-M3/M4 上通过 CoreDebug 使能后,可以一个周期加一次数,精度很高。在任务里周期性打印 run time stats 和栈高水位,我在调试复杂项目时几乎是必开项,花在这上面的配置时间,远比瞎猜省时得多。
7.4 最后给一份任务划分和内存估算的清单
任务划分按"事件源"而不是"功能函数"来分:每个外部事件源(传感器、串口、按键、网络)对应一个任务;任务间尽量用队列传递事件,不共享裸变量;高优先级任务栈可以给小一点但运行要短,低优先级任务栈给大一点没关系但要注意不抢高优先级任务的 CPU。
内存估算的粗略方法是:把每个任务栈大小相加,再加上队列里所有元素占用内存和堆的管理开销,留出 30% 到 50% 余量。以 64KB RAM 的芯片为例,跑一个 8 任务的小系统通常需要 8KB 到 16KB 的堆空间,任务栈每个 256 到 512 字,具体取决于任务函数的局部变量。先按这个量给,再用高水位函数调整,就可以避免一上来就爆内存的尴尬。
我在实际项目里踩过这么一次坑:任务的优先级从 1 到 4 都设过,唯独忘了检查中断优先级配置,导致串口中断优先级设成了 0,结果中断里调 FromISR API 时系统时好时坏。调了一整天,最后把优先级改成 6,一切恢复正常。所以在这里再强调一遍:任务优先级和中断优先级是两套体系,中断调 API 必须先过 configMAX_SYSCALL_INTERRUPT_PRIORITY 这道门槛。这个经验如果不花时间自己撞一次,很难真正记住,我希望看到这篇内容的你,能省下这一天。