FreeRTOS 这套内核我断断续续用了几年,从最早在 STM32F103C8T6 上照着教程抄一份能跑闪灯的最小工程,到后来拿它做多轴控制、串口协议解析、屏幕刷新的调度框架,踩过的坑基本能凑成一本小册子。这篇就是我个人笔记的整理版,不讲教科书里那些大道理,只讲我实际动手时是怎么配的、为什么这么配、哪些地方一不留神就翻车。如果你手上正好有块 F103C8T6 或者 H743,正准备从裸机超级循环切到 FreeRTOS,或者想把 LVGL 挂上去,又或者只是面试前想把调度器内部流程捋顺,这份笔记应该都能给你省点时间。整篇内容按我自己的学习顺序来:先说清楚为什么要换、换完得到什么,再一步步走移植、配参数、写任务、调队列信号量,最后把编译报错、硬件异常、面试常问的东西一并摊开。
1. 从裸机超级循环切到 FreeRTOS,我到底图什么
1.1 一个真实项目的转折点
我最早接触 FreeRTOS 其实是被逼的。当时做的是一个带按键、OLED、串口上位机通信和两路 PWM 输出的小控制器,裸机主循环里塞了按键扫描、显示刷新、协议解析、PWM 输出更新四件事。前三个月运行正常,后来需求加了一条:协议解析要在收到完整帧后 5ms 内响应。这一下问题全暴露了,OLED 刷一屏要十来毫秒,正好卡在解析前面,通信响应时间抖动得厉害,上位机时不时报超时。
改成 FreeRTOS 之后,我把协议解析提到优先级 4 的任务里,OLED 放到优先级 1,通信响应立刻稳定下来。这件事让我真正理解了 RTOS 的价值不是"能跑多任务"这么表面,而是它把时间这个维度变成了可设计、可分配的资源。裸机下你只能靠人工估算每段代码耗时来拼主循环,一旦需求变化就要重新算账;用了内核之后,你只需要回答"谁更急"这个问题,剩下的交给调度器。
1.2 FreeRTOS 给我带来的三样东西
第一样是任务级的隔离与优先级保证。每个任务有自己的栈空间,局部变量互不干扰,我不用再担心某个函数写了个大数组把整个系统的栈冲掉。优先级机制保证了高优先级任务的响应延迟只受中断和临界区影响,不受低优先级任务阻塞。
第二样是阻塞式同步原语。裸机里做"等数据"通常靠标志位轮询,CPU 空转浪费电,还在低功耗场景下很难受。FreeRTOS 的队列、信号量、事件组可以让你挂起当前任务,等条件满足再唤醒,这段时间 CPU 完全可以让给别的任务甚至进低功耗。
第三样是可裁剪、可静态分配。整个内核最小配置编译出来才几 KB,F103C8T6 只有 20KB RAM 也够用。而且 heap_1 到 heap_5 给了你不同的内存策略,甚至可以用configSUPPORT_STATIC_ALLOCATION全静态分配,这对做工业类项目非常友好——没有动态内存,就没有碎片带来的长期运行风险。
注意:很多教程一上来就讲"RTOS 比裸机好",这是不严谨的。任务的栈开销、上下文切换开销都是实打实的,一个只做按键和闪灯的项目上 RTOS 反而是负优化。我的判断标准是:任务数超过 3 个且其中有响应时间要求不一致的,才值得上内核。
1.3 版本、目录与学习资料的选择
版本上我建议直接用官方主线的最新稳定版,或者至少是 10.x 以后的大版本。早期老教程里用的 V7.x 在很多 API 上有差异,比如二值信号量创建后的初始状态、xTaskCreateStatic的引入、任务通知功能的增强等。跟着老教程在版本上纠缠,是新手最容易浪费时间的坑。
下载下来的源码目录其实很简单,Source文件夹下是内核代码,portable下按编译器和架构分了不同目录。你要做的就是从这一大堆文件里挑出真正需要的那几个,剩下的全部丢掉。这一步看着简单,实际上很多人第一次移植失败就败在文件挑错了或者重复包含了。具体挑哪些,我在第 2 章会给出完整的清单和判断依据。
至于学习资料,我个人的路径是:官方的 API 参考文档当地图用,随时查参数含义;FreeRTOSConfig.h里的每一项注释当说明书读,官方在这个文件里写得非常清楚,很多人从来不读;剩下的就是自己动手改需求,比如把任务优先级调乱看看会发生什么,把栈开小一点看看钩子函数是不是真的会触发。看十遍文档不如故意制造一次栈溢出,这个后面会讲怎么操作。
2. STM32F103C8T6 + Keil 移植实战
2.1 源码取舍:只留必要文件
先明确一点,F103C8T6 是 Cortex-M3 内核,所以在portable下要找RVDS/ARM_CM3这个目录(Keil 用 ARMCC/ARMCLANG 编译时用 RVDS 这份,虽然目录名看着有点年代感,但一直到现在的 AC6 都能用)。如果是 IAR 环境,对应的是IAR/ARM_CM3,里面多一个portasm.s汇编文件,其他大同小异。
内核核心文件只需要这几个:
tasks.c:任务调度核心,必须要list.c:链表实现,tasks.c依赖它,必须要queue.c:队列、信号量、互斥量的基础,必须要timers.c:软件定时器,只有用到xTimerCreate才需要event_groups.c:事件组,只有用到才需要stream_buffer.c:流缓冲和消息缓冲,新版本引入,用不到可以不加croutine.c:协程功能,现代项目基本不用,直接丢
portable里除了RVDS/ARM_CM3下的port.c和portmacro.h,还必须带上一份内存管理实现,在MemMang目录下从heap_1.c到heap_5.c中选且只选一份。
这里有个新手高频翻车点:顺手把
MemMang里五个文件全拖进工程,编译直接报一堆重复符号。内存管理方案是互斥的,选一个就好,我个人默认选heap_4.c,原因在 3.3 节详细说。
2.2 工程分组、头文件路径与宏定义
Keil 里我习惯建两个分组:FreeRTOS/Source放内核 c 文件,FreeRTOS/Port放port.c和选好的heap_4.c。头文件路径要加三条:Source/include、portable/RVDS/ARM_CM3,以及你放FreeRTOSConfig.h的那个目录。
宏定义这块,Keil 的 C/C++ 选项卡里需要根据芯片手册填__NVIC_PRIO_BITS。STM32F1 系列的 NVIC 只实现了 4 个优先级位,所以写:
__NVIC_PRIO_BITS=4这个宏在port.c里被用来计算优先级寄存器的移位量。如果这里填错,表现是中断优先级配置看起来正常,但configASSERT会在运行时莫名其妙触发,或者中断嵌套行为不符合预期。我在 F4 系列上就吃过这个亏,F4 同样是 4 位,但有人照着 H7 的 4 位去抄,看着没错,实际 H7 是 4 位没错但有些系列存在子优先级分组差异,填之前一定翻一下芯片参考手册的 NVIC 章节。
另外FreeRTOSConfig.h里通常需要包含芯片头文件,方便拿到SystemCoreClock:
#include "stm32f10x.h" extern uint32_t SystemCoreClock;用SystemCoreClock而不是写死 72000000,好处是你改时钟树的时候不用再改配置文件,少一个忘记同步的地方。
2.3 FreeRTOSConfig.h 关键参数逐个算给你看
这个文件是整个移植的灵魂,我挑几个必然要动的说,并且把数值怎么来的算清楚。
时钟节拍相关的两项:
#define configCPU_CLOCK_HZ ( SystemCoreClock ) #define configTICK_RATE_HZ ( ( TickType_t ) 1000 )configCPU_CLOCK_HZ是给 SysTick 计算重装载值用的。F103C8T6 主频 72MHz,如果节拍设 1000Hz,那么每次 SysTick 中断间隔是 72MHz / 1000 = 72000 个时钟周期。SysTick 是 24 位递减计数器,最大 16777215,72000 完全放得下,所以 1ms 节拍没问题。如果你想设 10000Hz,那就是 7200 周期一次,中断过于频繁,上下文切换开销会吃掉大量 CPU,一般 100Hz 到 1000Hz 是合理区间。我用 1000Hz 是因为要和 LVGL 的 1ms tick 对齐,省得再搞一个定时器。
优先级数量:
#define configMAX_PRIORITIES ( 32 )这个值决定就绪链表的数组大小,越大内存占用越多。32 个优先级对绝大多数项目都够,每个优先级一个链表头,成本很小。如果你 RAM 实在紧张,可以降到 8,但要注意别把任务优先级写到超过这个范围,超了会被 assert 拦住。
最小的空闲任务栈和总堆:
#define configMINIMAL_STACK_SIZE ( ( unsigned short ) 128 ) #define configTOTAL_HEAP_SIZE ( ( size_t ) ( 10 * 1024 ) )configMINIMAL_STACK_SIZE的单位是字(word)不是字节,128 字在 32 位机上是 512 字节。这个值只给空闲任务用,你要是往空闲钩子里塞了 printf,512 字节很可能不够。configTOTAL_HEAP_SIZE只在选了 heap_1/2/4/5 时有效,单位是字节。F103C8T6 一共 20KB RAM,全局变量和主栈吃掉一部分,我留 10KB 给 FreeRTOS 堆,实测跑四个任务加一个队列加两个信号量还剩两三百字节余量,属于比较紧但能跑的水平。如果开了 LVGL,这个值必须往上抬,具体见第 6 章。
栈溢出检测和内存分配失败钩子:
#define configCHECK_FOR_STACK_OVERFLOW 2 #define configUSE_MALLOC_FAILED_HOOK 1这两个宏打开之后,你必须自己实现对应的钩子函数,否则链接会报未定义符号。vApplicationStackOverflowHook和vApplicationMallocFailedHook两个函数建议在钩子里点亮一个 LED 然后死循环,比打印日志更可靠——因为栈都溢出了,printf 本身可能也不安全。
中断优先级相关的两个宏,写法要绕一下:
#define configLIBRARY_LOWEST_INTERRUPT_PRIORITY 15 #define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 5 #define configKERNEL_INTERRUPT_PRIORITY \ ( configLIBRARY_LOWEST_INTERRUPT_PRIORITY << (8 - configPRIO_BITS) ) #define configMAX_SYSCALL_INTERRUPT_PRIORITY \ ( configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY << (8 - configPRIO_BITS) )为什么要左移?因为 Cortex-M3 的优先级寄存器是 8 位,但 STM32 只实现了高 4 位,低 4 位读出来是 0、写进去被忽略。configPRIO_BITS是 4,所以左移 4 位。15 左移 4 位就是 0xF0,也就是最低优先级,PendSV 和 SysTick 都会被设成这个值。5 左移 4 位是 0x50,意思是所有调用 FreeRTOS 中断安全 API 的中断,优先级数值必须大于等于 5(数值越大优先级越低)。这条规则记死了,后面讲二值信号量时还会用到。
2.4 三个异常处理函数与中断优先级的坑
Cortex-M3 的启动文件里有三个弱定义的异常处理函数:SVC_Handler、PendSV_Handler、SysTick_Handler。FreeRTOS 的port.c里自己实现了这三个,但函数名是按 FreeRTOS 内部命名来的:vPortSVCHandler、xPortPendSVHandler、xPortSysTickHandler。
两种对接方式,任选一种:
方式一是删除或注释掉stm32f10x_it.c里原本空实现的这三个函数,然后在FreeRTOSConfig.h里加映射:
#define vPortSVCHandler SVC_Handler #define xPortPendSVHandler PendSV_Handler #define xPortSysTickHandler SysTick_Handler方式二是保留it.c里的函数,在里面手动调用 FreeRTOS 的实现。我个人推荐方式一,改动集中在一个文件里,好维护。
如果你不改,链接时会报重复定义,或者更隐蔽的情况——链接通过了,但跑起来直接进 HardFault,因为向量表里指向的是你那个空函数,内核从来没被启动过。
中断优先级这块,我见过最多的错误是:串口中断里想用xQueueSendFromISR,中断优先级配成了 3。程序一跑起来configASSERT就挂了,断言信息里那串ucMaxSysCallPriority看得人一头雾水。解决办法两个:要么把串口中断优先级改成 5 到 15 之间,要么在中断里别调 FreeRTOS 的 API,自己置个标志位让任务去处理。我一般选前者,因为中断里直接发队列的延迟确实更低。
实操心得:
configASSERT一定要定义。很多人为了省事注释掉,结果程序跑飞了完全没有提示。我的定义方式是:如果uxCriticalNesting == 1000就点亮 LED 死循环(这个值是未初始化时的默认值,能识别出"进临界区之前就挂了"的情况),否则直接死循环。配合调试器看调用栈,定位速度快很多。
3. 调度器内部:Cortex-M3 上下文切换与内存管理
3.1 SVC、PendSV、SysTick 各司其职
想真正用好 FreeRTOS,这三个异常的职责分工必须搞清楚,否则你没法解释为什么程序会以某种方式跑偏。
SVC(Supervisor Call)只在启动调度器vTaskStartScheduler()时触发一次。它的作用是把第一个要运行的任务的上下文从栈里"弹"出来,让 CPU 正式进入第一个任务。port.c里的vPortSVCHandler干的就是这件事:通过pxCurrentTCB找到当前任务控制块,取出栈顶指针,然后手动恢复 R4-R11,再通过bx r14触发硬件自动恢复 R0-R3、R12、LR、PC、xPSR。这个过程在《Cortex-M3 权威指南》里有详细描述,但很多人只背了结论没理解数据流。
SysTick负责产生节拍。F103 上它是内核自带的定时器,不需要占用 TIM 外设。每 1ms 中断一次,xPortSysTickHandler里会做两件事:一是把节拍计数加一,二是判断有没有任务需要被唤醒。如果唤醒的任务优先级高于当前任务,就触发一次 PendSV 挂起,实际切换推迟到 PendSV 里执行。
PendSV(Pendable Service Call)是真正干活的。它被设成最低优先级(0xFF,也就是 15 左移 4 位那个值),目的是让它永远不打断任何其他中断。为什么要这么设计?因为上下文切换如果被别的中断打断,任务栈状态的一致性就很难保证。把切换放在所有中断之后执行,就能保证切换过程是原子的。这个设计叫"延迟切换",是 Cortex-M 上 RTOS 的标准做法。
整个切换流程可以概括成:SysTick 或任务主动让出 → 触发 PendSV → 当前任务把 R4-R11 压栈并更新自己的pxTopOfStack→ 从就绪链表挑下一个任务 → 恢复新任务的 R4-R11 → 中断返回。R0-R3、R12、LR、PC、xPSR 由硬件在中断进入和退出时自动处理,软件不用管。
3.2 压栈了什么,pxTopOfStack 怎么变
这部分我觉得值得掰开讲,因为它是理解栈溢出的前提。
中断发生时,Cortex-M3 硬件自动把 8 个寄存器压入当前栈:R0、R1、R2、R3、R12、LR(也叫 R14)、PC、xPSR,一共 32 字节。此时 SP 指向这 32 字节的底部。接着软件在 PendSV 里手动把 R4 到 R11 再加 32 字节压进去。所以一次完整的上下文保存,任务栈上要多出 64 字节。
pxCurrentTCB->pxTopOfStack保存的就是最后压入的那个位置的地址。任务切回来时,从这个地址往上恢复 R4-R11,然后硬件自动恢复剩下 8 个。这就是为什么你在调试器里看一个任务的栈,会看到一堆乱七八糟的数值。
对写应用的人来说,最实用的推论是:每个任务至少要留出 64 字节给上下文,加上函数的局部变量、函数调用层次、还可能有的浮点寄存器(如果用 FPU)。我给任务分栈的经验值是:只有一个空循环加一次vTaskDelay的任务给 128 字(512 字节);有 printf 或者几十字节局部数组的给 256 字(1KB);用 LVGL 或者做协议解析、跑数学运算的给 512 字(2KB)起步。宁大勿小,因为 F103 总共 20KB RAM,确实要算着花,但栈溢出导致的 HardFault 比内存紧张难受多了。
3.3 heap_1 到 heap_5 选型对照
内存管理这五份实现各有脾气,我用一张表把它们的差异列清楚,后面再讲我的选型逻辑。
| 方案 | 能否释放 | 碎片问题 | 适用场景 | 我的使用频率 |
|---|---|---|---|---|
| heap_1 | 不能 | 无 | 任务创建后不再删除,追求绝对确定性和安全 | 偶用 |
| heap_2 | 能,但不合并相邻块 | 严重 | 已不推荐,历史遗留 | 不用 |
| heap_3 | 能 | 取决于编译器 | 需要兼容标准 malloc/free 的场合 | 很少 |
| heap_4 | 能,且合并相邻空闲块 | 可控 | 通用场景,最常用 | 默认 |
| heap_5 | 同 heap_4,支持多块不连续区域 | 可控 | 内存分散在多个段,比如外扩 SDRAM | 特定项目 |
heap_1 的特点是最简单、最快、代码最少,它只做"分配"不做"释放"。这听起来像缺功能,但在很多工业项目中反而是优点:任务在系统启动阶段全部创建完毕,之后再也不动态创建删除,那么用 heap_1 就完全没有碎片风险,行为完全可预测。我做过的几个长期运行不重启的设备,用的就是 heap_1。
heap_4 是我日常的默认选择。它用首次适应算法找空闲块,释放时会把相邻的空闲块合并回去,能有效抵抗碎片。它还有一个vPortGetHeapStats之类的接口可以看当前堆使用情况,调试时很有用。代价是代码比 heap_1 大一点,第一块空闲块管理有一些额外开销。
heap_5 和 heap_4 算法完全一样,区别是它允许你在初始化时指定多个不连续的内存区域。STM32H743 上如果用了外部 SDRAM,正好把内部 SRAM 和外部 SDRAM 都交给内核管理,这时候就得用 heap_5。用之前要调vPortDefineHeapRegions把区域数组传进去,而且必须在创建任何内核对象之前调用。
选型的判断逻辑我总结成三句话:不删任务就用 heap_1,常规项目用 heap_4,内存不连续用 heap_5。heap_2 现在没有理由再用了。
3.4 栈溢出检测两种模式与钩子函数
configCHECK_FOR_STACK_OVERFLOW设成 1 和 2 的行为完全不同,这个细节很多人不知道。
设成 1 时,内核在每次上下文切换时检查任务的栈指针有没有超出该任务的栈范围,这是"事后检查",只能在溢出已经发生、但还没破坏别的内存时捕捉到。优点是开销极小,只比较一个指针。
设成 2 时,在任务创建时会把整个栈空间用0xa5填充一遍;每次切换时检查栈末尾 16 个字节是不是还是0xa5,如果不是,说明栈被写穿了。这种方式能更早发现"快要溢出但还没出界"的情况,代价是任务创建时要多做一次填充,切换时要多比较 16 个字节。
我一般设 2,因为 F103 的 RAM 太金贵,栈开小是常态,宁可多损失一点点性能也要早点发现问题。触发之后会调用:
void vApplicationStackOverflowHook( TaskHandle_t xTask, char *pcTaskName ) { ( void ) xTask; ( void ) pcTaskName; taskDISABLE_INTERRUPTS(); for( ;; ) { /* 在这里点灯或者翻转某个 GPIO,方便示波器抓 */ } }在钩子里pcTaskName能告诉你哪个任务挂了,调试时把断点打在这一行,直接看调用栈,比盲猜快十倍。这也是为什么我强烈建议任务名字起得有辨识度,别都用"task1"、"task2"。
4. 任务、队列、信号量的实战写法
4.1 xTaskCreate 参数逐项拆解与优先级设计
xTaskCreate有六个参数,我按实际使用中的坑点顺序说。
BaseType_t xTaskCreate( TaskFunction_t pxTaskCode, const char * const pcTaskName, const configSTACK_DEPTH_TYPE usStackDepth, void * const pvParameters, UBaseType_t uxPriority, TaskHandle_t * const pxCreatedTask );usStackDepth的单位是字,这点第 3 章说过,但每次还是有人按字节填。F103 上如果你填 128 以为是 128 字节,实际会有 512 字节,看起来是赚了,但会误导你对整体 RAM 占用的估算。反过来填 512 以为是 512 字节,实际吃了 2KB,20KB 的芯片上跑三四个任务就直接爆了。我习惯在栈大小后面写个注释,标明实际字节数,比如/* 256 words = 1KB */。
pvParameters这个参数很值得用。很多人写一堆全局变量给任务共享参数,其实完全可以通过这里传。比如你有三路串口要处理,写一个通用任务函数,创建三次,每次把串口句柄通过pvParameters传进去,代码立刻少一多半,而且每个任务的数据完全隔离,不会有互相踩的问题。注意传进去的地址在任务运行期间必须一直有效,所以别传一个局部变量的地址。
优先级的分配是我最想强调的。原则只有一条:按响应时间要求排,不按重要性排。很多人潜意识里觉得"通信最重要所以给最高优先级",结果通信任务一忙就把按键扫描饿死了,用户按键没反应。正确做法是先算每个任务的最大可接受响应延迟,延迟要求越苛刻的优先级越高。典型的排布是:
- 优先级 5:中断服务或对应的高实时任务(比如协议接收、故障保护)
- 优先级 3:周期性控制任务(PWM 更新、闭环计算)
- 优先级 2:显示刷新、日志输出
- 优先级 1:状态机、非紧急的协议处理
- 优先级 0:空闲任务,内核自动创建
优先级数量不多的话可以留间隔,方便以后插进来新任务。同优先级任务之间靠时间片轮转,前提是configUSE_TIME_SLICING为 1,这个默认开着。
4.2 队列传字符串的经典翻车现场
这是我看过最多人中招的地方,包括我自己。
char buf[32]; sprintf(buf, "temp=%d", value); xQueueSend( xQueue, &buf, 0 ); /* 错误示范 */队列项大小如果是sizeof(char *),发出去的是指针,任务函数返回后buf就没了,接收方拿到的是一块已经被复用的栈内存,打印出来是乱码。这类问题最难查,因为它可能半天才复现一次。
正确的两种做法。第一种是队列项直接定义为数组,做值拷贝:
#define MSG_LEN 32 QueueHandle_t xQueue = xQueueCreate( 8, MSG_LEN ); char txBuf[MSG_LEN]; snprintf( txBuf, MSG_LEN, "temp=%d", value ); xQueueSend( xQueue, txBuf, pdMS_TO_TICKS( 10 ) );这样每次发送会把 32 个字节完整复制进队列的存储区,接收方拿到的是独立副本,安全。代价是每次收发有内存拷贝开销,32 字节还好,超过 128 字节就要掂量一下。
第二种是定义结构体,把长度和内容一起发:
typedef struct { uint8_t len; uint8_t data[64]; } Msg_t;这样接收方能知道实际有效长度,避免读越界。做串口帧转发时我用的是这种,比裸数组好用。
还有第三种,用stream_buffer或message_buffer。消息缓冲内部就是按"变长消息"设计的,发送时传长度,接收时先探长度再取内容,非常适合协议帧转发。代价是它只能有一个写入者一个读取者,多对多场景要自己加锁。
注意:
xQueueSend的超时参数单位是 tick,不是毫秒。用pdMS_TO_TICKS(10)转换,或者用portMAX_DELAY无限等待。直接写10在 1000Hz 节拍下刚好是 10ms,但在 100Hz 节拍下就变成 100ms,换个配置行为就变了,这是很隐蔽的坑。
4.3 二值信号量在中断中的正确用法
二值信号量最典型的用法就是"中断通知任务"。串口中断收到一帧数据,给一个信号量;解析任务在信号量上阻塞,收到就醒。
SemaphoreHandle_t xSem = xSemaphoreCreateBinary(); void USART1_IRQHandler( void ) { BaseType_t xHigherPriorityTaskWoken = pdFALSE; if( USART_GetITStatus( USART1, USART_IT_RXNE ) != RESET ) { /* 收数据 */ xSemaphoreGiveFromISR( xSem, &xHigherPriorityTaskWoken ); } portYIELD_FROM_ISR( xHigherPriorityTaskWoken ); }这里面有三个必须记住的点。
第一,二值信号量创建后初始是"空"的。这是新版 FreeRTOS 的行为,老版本创建出来就是"可用"状态。所以新版本里要先xSemaphoreGive一次才能Take到。如果你想用"事件已经发生"的语义,创建后立刻给一次即可;如果你想用"最多一个令牌"的计数语义,那就保持空初始状态。
第二,portYIELD_FROM_ISR那个参数是地址,不是值。它的作用是:如果刚才给信号量唤醒了比当前被中断任务优先级更高的任务,就在这里触发一次 PendSV,让高优先级任务在中断退出时立刻运行,而不是等下一个节拍。少了这一步,响应延迟可能凭空多出几毫秒。中断优先级配得不对(数值小于configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY)的话,GiveFromISR会触发断言,这一点第 2.4 节讲过。
第三,xSemaphoreTake在任务侧等的时候,可以用portMAX_DELAY无限等,但注意它的参数类型是TickType_t,如果你开了configUSE_16_BIT_TICKS,portMAX_DELAY就只有 0xFFFF,配合 1kHz 节拍只能等 65 秒就溢出了。Cortex-M3 上这个宏默认是 0,也就是 32 位 tick,不用担心。
4.4 任务通知、互斥量什么时候替代信号量
FreeRTOS 里能实现同步的东西不止信号量一种,选错了要么浪费资源要么埋下隐患。
任务通知(Task Notification)是效率最高的方案,比二值信号量快很多,也不需要额外分配信号量对象。它受限于"只能一对一",而且接收方必须知道自己要被谁通知。如果场景是"一个中断通知一个固定任务",直接用任务通知更省内存也更快。用xTaskNotifyGiveFromISR发,ulTaskNotifyTake收,代码量和信号量差不多。缺点是如果想一个中断唤醒多个任务,或者多个中断唤醒一个任务需要计数,就得换回信号量或事件组。
互斥量(Mutex)和二值信号量长得像,但语义完全不同。互斥量有优先级继承机制,目的是保护共享资源,不是做事件通知。判断标准很简单:如果是为了"等一个事件发生",用信号量或任务通知;如果是为了"同一时刻只有一个任务能访问某段资源",用互斥量。我见过有人用二值信号量去保护 SPI 总线,结果低优先级任务持有时被中优先级任务抢占,导致高优先级任务被卡住的优先级翻转问题——这就是互斥量存在的意义。
实操心得:互斥量不能在中断里 Give,因为中断没有任务上下文,优先级继承无从谈起。中断里要给的都是信号量或任务通知。这个区别在写驱动时会经常用到,I2C 总线这种"任务级共享"用互斥量,而"中断触发的数据到达"用信号量。
5. 编译、下载与运行期问题排查实录
5.1 q0147e 报错与其他编译环境问题
热词里那个.\obj\freertos.hex: error: q0147e: failed to create directory我见过,这是 IAR 环境下典型的输出路径配置问题。报错信息本身有点误导,它说"无法创建目录 .\obj\freertos.hex",看起来像把 hex 文件当成目录名了,实际原因通常是这么几种。
第一种是输出目录不存在。IAR 不会自动帮你创建多级目录,如果obj文件夹被清理掉了或者被版本控制忽略了,链接器写文件时就失败。解决办法是在 Project > Options > Linker > Output 里确认输出路径,或者干脆在磁盘上手动建好obj目录。
第二种是输出文件名和扩展名填反了位置。IAR 的输出设置里有两个框,一个是输出文件路径和名字,一个是扩展名。如果路径框里填成了.\obj\而名字框里填了freertos.hex,有些版本就会把它拼成.\obj\freertos.hex然后试图把.hex当作目录层级。检查一下这两个框,路径归路径、名字归名字。
第三种是路径太长或者含中文、空格。Windows 对路径长度有限制,工程放在深层目录里再叠上 IAR 的临时路径,很容易超。我的习惯是把工程放在盘符根目录下的英文短路径里,比如D:\proj\f103_rtos,能避掉一大类莫名其妙的报错。
第四种是文件被占用。杀毒软件或者另一个 IAR 实例占着 obj 目录里的文件,链接器写不进去。关掉其他实例、把工程重新 Rebuild All 一般能解决。
5.2 HardFault 三步定位法
FreeRTOS 项目进 HardFault,八成是栈溢出或者中断优先级配置错误。我总结了一套固定流程。
第一步,看调用栈。在 HardFault_Handler 里打断点,调试器停下后打开 Call Stack 窗口,看看最后执行的是哪个函数、哪个任务。如果停在 PendSV 或者某个 FreeRTOS 内部函数里,基本可以确定是栈被写坏了。
第二步,人为制造条件验证。把configCHECK_FOR_STACK_OVERFLOW设成 2、所有任务的栈临时翻倍,如果问题消失,那就是栈溢出。反过来,把所有任务栈恢复,把所有调用 FromISR API 的中断优先级都改成 15,如果问题消失,那就是优先级配置问题。
第三步,用uxTaskGetStackHighWaterMark查余量。这个函数返回任务是运行过程中栈曾经剩余的最小值,单位是字。如果这个值很小(比如小于 20),说明你栈开得太紧,稍微来点中断嵌套就爆。我一般在调试阶段周期性打印这个值,把所有任务的余量都摸清楚再决定最终栈大小。
顺便说一个容易被忽略的点:FreeRTOS 的栈溢出检测只在任务切换时检查,如果一个任务在自己的时间片内就溢出了,检查可能来不及。所以钩子函数触发了说明已经出事了,但钩子没触发不代表一定安全,还是得靠高水位线来兜底。
5.3 常见问题速查表
我把这几年遇到的高频问题整理成表,按现象查原因会快很多。