1. 为什么我要开这个专栏
搞嵌入式这行的朋友,尤其是玩STM32、GD32这些MCU的,迟早会碰到一个分水岭:裸机跑不动了。不是芯片跑不动,是你的代码结构跑不动了。我最早做项目的时候,一个主循环里塞了按键扫描、串口解析、LCD刷新、Flash读写,刚开始还能凑合,功能一多,时序全乱套。串口数据收一半被LCD刷屏打断,Flash写入的时候按键直接没响应,客户现场一个劲儿打电话说“死机了”。那时候我就知道,必须上RTOS了。
FreeRTOS是我接触的第一个实时操作系统,也是到目前为止用得最多的一个。原因很简单:免费、开源、代码量小、移植方便、资料多。你在网上搜“STM32 多任务”,十篇有八篇讲的是FreeRTOS。但资料多不代表学得顺,我见过太多人卡在几个固定的坎上:任务栈到底给多大?优先级怎么分配?中断里能不能调API?堆栈溢出怎么查?移植的时候该改哪几个文件?这些问题,官方文档不会掰开揉碎跟你讲,视频教程又往往跳过了“为什么”。
这个专栏就是来解决这些问题的。我不打算写成一本教科书,而是按照一个实际项目的推进节奏,从移植开始,到任务创建、通信机制、内存管理、问题排查,一步步把FreeRTOS用起来。适合谁看?如果你已经会写STM32的裸机程序,能看懂C语言指针和结构体,那这个专栏就是为你准备的。如果你连GPIO都没点过灯,建议先补一下基础,不然会有点吃力。
专栏会覆盖这些内容:FreeRTOS在STM32上的移植(包括STM32CubeMX的配置方式)、任务管理与优先级设计、队列和信号量的实战用法、内存管理方案的选择、堆栈溢出检测、以及和LVGL这类GUI库的配合。中间会穿插一些我实际踩过的坑,比如GD32F303移植时的一个时钟配置问题,还有STM32F407上跑FatFS加W25Q64时任务栈被撑爆的经历。这些细节,你在别的地方不一定看得到。
提示:这个专栏的代码示例以STM32F4系列为主,但移植思路和配置方法对GD32、STM32F1、F7等Cortex-M内核芯片同样适用,差异主要在启动文件和时钟配置上。
2. FreeRTOS到底解决了什么问题
2.1 裸机开发的三个死穴
先说说为什么非得上RTOS。裸机开发不是不能用,小项目用裸机反而更简单直接。但一旦项目规模上去,裸机就会暴露三个很难绕过去的问题。
第一个是实时性问题。裸机的主循环是顺序执行的,你永远不知道当前这个函数要跑多久。如果串口中断来了,但主循环正在做一个耗时200ms的Flash擦除,那串口数据就可能丢。你可以在Flash擦除前后关中断,但关中断时间太长又会影响其他中断响应。这是一个两难。
第二个是代码耦合问题。裸机项目里,模块之间往往通过全局变量通信。按键模块设置一个标志位,主循环轮询这个标志位再调用处理函数。模块一多,全局变量满天飞,改一个地方牵动全身。我接手过一个前同事的项目,一个g_state变量被七个模块读写,查一个bug花了两天。
第三个是功能扩展问题。裸机加功能,基本就是往主循环里再塞一个函数。主循环越来越长,执行一轮的时间越来越久,实时性进一步恶化。到最后,你不敢再加功能了,因为加进去整个系统的时序就崩了。
2.2 RTOS的核心思路:用栈换结构
FreeRTOS解决这些问题的思路,说白了就是给每个功能模块分配一个独立的“执行流”,也就是任务。每个任务有自己的栈空间和程序计数器,看起来就像每个模块在独立运行一样。调度器负责在任务之间切换,谁优先级高谁先跑,优先级相同就轮流跑。
这样做的好处很直接。串口接收可以是一个高优先级任务,Flash擦除可以是一个低优先级任务。串口数据来了,任务立刻被唤醒处理,不用等Flash擦完。模块之间通过队列、信号量这些机制通信,不再依赖全局变量,耦合度大大降低。加新功能?再创建一个任务就行,不影响已有任务的时序。
但代价是什么?每个任务都要占一份栈空间。STM32F407的RAM有192KB,听起来不少,但如果你创建十个任务,每个任务栈给1KB,那就是10KB没了。再加上FreeRTOS内核本身的开销,内存一下子就紧张了。所以任务栈大小的分配,是FreeRTOS实战中一个非常关键的问题,后面会专门讲。
2.3 FreeRTOS在同类中的位置
市面上RTOS不少,为什么选FreeRTOS?我列个表对比一下常见的几个。
| RTOS | 授权方式 | 代码体积 | 移植难度 | 资料丰富度 | 适用场景 |
|---|---|---|---|---|---|
| FreeRTOS | MIT免费 | 小(6-10KB) | 低 | 非常丰富 | 通用MCU项目 |
| RT-Thread | Apache免费 | 中 | 中 | 丰富(国内) | 物联网、带GUI |
| uC/OS-III | 商业收费 | 中 | 中 | 一般 | 工业控制 |
| Zephyr | Apache免费 | 大 | 高 | 一般 | 复杂物联网 |
FreeRTOS最大的优势就是“刚刚好”。它不像RT-Thread那样自带一堆组件,也不像uC/OS那样收费。它就是一个纯粹的内核,你需要什么自己加。这种轻量级的定位,让它在资源受限的MCU上特别受欢迎。STM32CubeMX直接内置了FreeRTOS的配置选项,点几下鼠标就能生成工程,这对新手来说非常友好。
但CubeMX生成的代码有个问题:它把很多配置细节隐藏了,你如果不了解背后的原理,出了问题根本不知道怎么查。比如它默认用的是Heap_4内存管理方案,你知道为什么吗?它默认把configTICK_RATE_HZ设成1000,你知道这意味着什么吗?这些后面都会展开讲。
3. 移植FreeRTOS的两种路子
3.1 手动移植:搞清楚每个文件的作用
手动移植FreeRTOS,听起来吓人,其实核心就是搞明白哪些文件必须加,哪些文件需要改。我以STM32F407为例,把步骤拆开说。
首先你得从FreeRTOS官网或者GitHub下载源码包。解压之后,你会看到几个关键目录。FreeRTOS/Source下面是一堆.c文件,这些是内核的核心代码,全部都要加到工程里。FreeRTOS/Source/include下面是头文件,也要全部包含。FreeRTOS/Source/portable这个目录最重要,它里面按编译器和芯片架构分了子目录。对于STM32F407(Cortex-M4内核)配合Keil MDK,你需要的是RVDS/ARM_CM4F这个目录下的port.c。注意,是ARM_CM4F,带F的,因为STM32F4有硬件浮点单元。如果你用的是STM32F1(Cortex-M3),那就选ARM_CM3。
还有一个文件容易被忽略:FreeRTOSConfig.h。这个文件不在源码包里,需要你自己创建或者从Demo里复制。它定义了FreeRTOS的所有配置选项,比如系统时钟频率、最大优先级数、是否启用堆栈溢出检测等等。这个文件是移植的核心,后面会详细讲每个配置项的含义。
手动移植的步骤大致是这样的:
- 把
FreeRTOS/Source下的所有.c文件加入工程分组 - 把
portable/RVDS/ARM_CM4F/port.c加入工程 - 把
portable/MemMang/heap_4.c加入工程(内存管理方案,后面讲为什么选4) - 在工程的头文件搜索路径里添加
FreeRTOS/Source/include和portable/RVDS/ARM_CM4F的路径 - 创建
FreeRTOSConfig.h并放到能被包含到的位置 - 修改
stm32f4xx_it.c,把SysTick_Handler、PendSV_Handler、SVC_Handler这三个中断处理函数替换成FreeRTOS的版本
最后一步特别关键。FreeRTOS需要这三个中断来实现任务调度和上下文切换。如果你不替换,编译能过,但一运行就HardFault。我见过不止一个人卡在这里,查了半天以为是栈溢出,其实是中断向量没接对。
注意:替换中断处理函数时,不要直接删掉原来的函数体,而是把函数名改成FreeRTOS对应的名字,比如
xPortSysTickHandler。具体名字看port.c里的定义。
3.2 CubeMX移植:方便但有坑
STM32CubeMX从某个版本开始内置了FreeRTOS中间件,配置起来确实方便。在Middleware里勾选FREERTOS,然后选择接口版本。这里有个选择:CMSIS_V1还是CMSIS_V2。V1是旧版,V2是新版,API略有不同。新手建议选V1,资料多,兼容性好。V2的某些API名字变了,照着老教程做会编译报错。
CubeMX会自动帮你生成FreeRTOSConfig.h和初始化代码,省去了手动添加文件的麻烦。但它有几个坑要注意。
第一个坑是时钟源。CubeMX默认把FreeRTOS的时基源设成SysTick,但SysTick同时也是HAL库的时基。这会导致一个问题:HAL_Delay在FreeRTOS启动后就不准了。因为SysTick被FreeRTOS接管了,HAL库的延时计数不再更新。解决办法是把HAL的时基源改成别的定时器,比如TIM1。在CubeMX的SYS配置里,把Timebase Source从SysTick改成TIM1就行。
第二个坑是中断优先级。FreeRTOS对中断优先级有要求,特别是调用FromISR结尾的API时,中断优先级不能高于configMAX_SYSCALL_INTERRUPT_PRIORITY。CubeMX默认把SysTick和PendSV的优先级设成最低(15),这是对的。但你自己配置的其他中断,比如串口中断,如果优先级设得比这个高,在中断里调FreeRTOS的API就会出问题。这个后面讲中断管理时会详细说。
第三个坑是默认任务栈大小。CubeMX生成的默认任务栈是128字(注意单位是字,不是字节,在32位系统上等于512字节)。这个大小跑个点灯程序够用,但稍微复杂一点的功能就会溢出。我建议至少给256字,也就是1KB。如果任务里用了printf或者浮点运算,还得再加。
3.3 移植后的验证:点灯不够,要跑调度
移植完怎么验证成功了?很多人点个灯就完事了,这不够。点灯只能说明任务创建成功了,不能说明调度器工作正常。我建议做一个简单的验证:创建两个任务,一个让LED以500ms闪烁,另一个通过串口每隔1秒打印一次系统运行时间。如果两个任务都能正常运行,而且串口打印的时间间隔稳定,那说明调度器工作正常。
更进一步,可以创建一个任务专门检测CPU使用率。FreeRTOS有个vTaskGetRunTimeStats函数,可以统计每个任务占用的CPU时间。这个功能需要配置一个高精度的定时器作为运行时间统计的时基。配置好之后,你能清楚地看到每个任务吃了多少CPU,对优化很有帮助。
4. 任务栈和优先级:最容易翻车的地方
4.1 任务栈到底给多大
任务栈大小是FreeRTOS新手最容易搞错的地方。给小了,栈溢出,程序跑飞;给大了,浪费RAM。怎么确定一个任务的栈需要多大?
先说一个经验值。一个简单的任务,只做GPIO翻转或者简单的逻辑判断,128字(512字节)够了。如果任务里调用了printf,至少要256字,因为printf内部会用到不少栈空间。如果任务里有浮点运算,再加128字,因为浮点运算的上下文保存需要额外空间。如果任务里用了递归或者大的局部数组,那就要根据实际情况算了。
但经验值只是起点,最终还是要靠实测。FreeRTOS提供了两个函数来检查栈使用情况:uxTaskGetStackHighWaterMark和vTaskList。前者返回任务栈的历史最小剩余量,单位是字。如果这个值接近0,说明栈快满了,需要加大。后者会打印所有任务的状态,包括栈的剩余量。
我一般的做法是:先给一个偏大的值,比如512字,让系统跑一段时间,覆盖所有功能路径,然后用uxTaskGetStackHighWaterMark看看实际用了多少。如果剩余量一直在200字以上,就可以适当减小。但不要减到刚好够用,留至少30%的余量,因为有些路径可能测试时没走到。
提示:栈溢出是FreeRTOS项目中最常见的崩溃原因之一。建议在
FreeRTOSConfig.h里把configCHECK_FOR_STACK_OVERFLOW设为2,这样每次任务切换时都会检查栈指针是否越界。虽然会稍微增加一点开销,但能帮你及早发现问题。
4.2 优先级怎么分配
FreeRTOS的优先级是数字越大优先级越高,这点和某些RTOS相反,要注意。configMAX_PRIORITIES定义了最大优先级数,CubeMX默认是7,也就是优先级0到6。0是最低优先级,空闲任务用的就是0。
优先级分配的原则是:越紧急的任务优先级越高。什么叫紧急?就是“必须在多长时间内响应”的那个时间越短,优先级越高。比如串口接收,数据来了必须马上取走,否则下一字节来了就覆盖了,这种任务优先级要高。而LCD刷新,晚个几十毫秒用户也看不出来,优先级可以低。
但优先级不是越高越好。高优先级任务如果一直占用CPU,低优先级任务就永远得不到执行,这叫“优先级反转”或者“饥饿”。FreeRTOS的调度策略是:高优先级任务就绪时,立刻抢占低优先级任务。所以高优先级任务里不能有死循环,必须适时阻塞,让出CPU。
我一般会把任务分成几个层级。最高层是硬件相关的紧急处理,比如串口接收、ADC采样。中间层是业务逻辑,比如数据处理、状态机。最低层是显示刷新、日志写入这些对时间不敏感的任务。空闲任务优先级0,用来做一些清理工作或者进入低功耗模式。
4.3 栈溢出检测的两种模式
FreeRTOS的栈溢出检测有两个模式,通过configCHECK_FOR_STACK_OVERFLOW配置。
模式1:在任务切换时检查栈指针是否超出了任务栈的范围。这个检查很快,但只能在切换时发现,如果任务在运行中栈溢出,可能已经破坏了其他内存才被发现。
模式2:在任务创建时,把栈空间填充一个已知的标记值(比如0xA5),任务切换时检查栈末尾的标记是否被覆盖。这个检查更可靠,能发现栈曾经溢出过,但开销稍大。
我建议用模式2。虽然多花一点CPU时间,但能更早发现问题。而且模式2还能配合uxTaskGetStackHighWaterMark一起用,那个函数就是基于这个标记值来计算的。
如果检测到栈溢出,FreeRTOS会调用vApplicationStackOverflowHook这个回调函数。你需要自己实现这个函数,在里面打印出是哪个任务溢出了,然后做相应处理。最简单的处理就是让LED快闪,提示开发者出了问题。
5. 队列和信号量:任务间通信的正确姿势
5.1 队列:最常用的通信方式
队列是FreeRTOS里最基础也最常用的通信机制。一个任务往队列里发数据,另一个任务从队列里取数据。队列是FIFO(先进先出)的,但也可以配置成LIFO(后进先出)。队列里存的是数据的拷贝,不是指针,所以发送方和接收方的数据缓冲区是独立的,不用担心生命周期问题。
创建队列用xQueueCreate,参数是队列长度和每个元素的大小。比如你要传一个uint8_t类型的串口数据,队列长度32,那就xQueueCreate(32, sizeof(uint8_t))。发送用xQueueSend,接收用xQueueReceive。这两个函数都有阻塞版本和非阻塞版本,带FromISR后缀的是在中断里用的。
队列的一个常见用法是串口数据接收。串口中断里收到一个字节,用xQueueSendFromISR把字节发到队列里。一个专门的任务从队列里取数据,组包、解析。这样中断处理函数非常短,不会影响其他中断的响应。
但队列有个坑:如果队列满了,xQueueSend会阻塞。在中断里不能用阻塞版本,只能用xQueueSendFromISR,而且如果队列满了,它会直接返回失败,数据就丢了。所以队列长度要设计得合理,或者加一个溢出计数,丢了数据能知道。
5.2 信号量:同步和互斥
信号量分两种:二值信号量和计数信号量。二值信号量就是0和1两个状态,常用来做任务同步。比如中断里释放一个信号量,任务里等待这个信号量,实现了中断通知任务的效果。计数信号量可以大于1,常用来管理资源池,比如你有3个串口,就用一个初始值为3的计数信号量,用之前获取,用完释放。
互斥量(Mutex)是一种特殊的二值信号量,它支持优先级继承。什么叫优先级继承?假设低优先级任务A持有互斥量,高优先级任务B也在等这个互斥量。正常情况下B会一直等A释放,但如果此时有一个中优先级任务C就绪了,C会抢占A,导致A迟迟不能释放互斥量,B也跟着一直等。这就是优先级反转。互斥量的优先级继承机制会临时把A的优先级提升到和B一样,让A尽快执行完释放互斥量,然后优先级恢复。
互斥量用来保护共享资源,比如I2C总线、SPI总线、串口打印。多个任务都要用I2C,那就用一个互斥量保护,谁用谁加锁,用完解锁。这样就不会出现两个任务同时操作I2C导致数据错乱的情况。
注意:互斥量不能在中断里使用。中断里只能用二值信号量,而且要用
FromISR版本。
5.3 队列集和事件组:进阶用法
队列集(Queue Set)允许一个任务同时等待多个队列。比如一个任务既要处理串口数据,又要处理按键事件,这两个事件来自不同的队列。不用队列集的话,你得轮询两个队列,或者创建两个任务分别处理。用队列集,一个任务就能同时等两个队列,哪个有数据就处理哪个。
事件组(Event Group)是另一种同步机制,它允许任务等待多个事件的组合。比如一个任务要等“网络连接成功”和“数据准备好”两个事件都发生才继续执行。事件组用位来表示事件,每个位代表一个事件,可以等待任意位组合。这个在复杂项目中很有用,但新手可以先跳过,等用到的时候再学。
6. 内存管理:Heap_4为什么是默认选择
6.1 五种堆管理方案对比
FreeRTOS提供了五种内存管理方案,从heap_1到heap_5。CubeMX默认用的是heap_4,这不是随便选的。
heap_1是最简单的,只支持分配,不支持释放。适合那些只在启动时创建任务和队列,运行中不再动态分配内存的项目。优点是代码极小,没有碎片问题。缺点是不能释放,灵活性差。
heap_2支持释放,但不会合并相邻的空闲块。这意味着如果你反复分配和释放不同大小的内存,会产生碎片。碎片多了,即使总空闲内存够,也可能分配失败。heap_2现在已经不推荐用了。
heap_3是对标准库的malloc和free的封装,加了线程保护。它的大小取决于标准库的实现,而且效率不一定好。一般不推荐在嵌入式项目里用。
heap_4支持释放,而且会合并相邻的空闲块。这大大减少了碎片问题。它用一个链表管理空闲块,分配时找第一个足够大的块,释放时检查前后块是否空闲,是就合并。heap_4的代码量适中,效率不错,所以成了最常用的方案。
heap_5和heap_4类似,但支持多个不连续的内存区域。比如你的STM32有内部RAM和外部SRAM,想把它们都纳入堆管理,就用heap_5。一般项目用不到。
6.2 内存分配失败怎么办
即使有了heap_4,内存分配仍然可能失败。原因可能是堆空间不够,也可能是碎片太多。FreeRTOS的pvPortMalloc在分配失败时返回NULL。如果你用xTaskCreate创建任务,它内部会调用pvPortMalloc,失败时返回errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY。
怎么处理分配失败?首先,创建任务和队列尽量放在系统启动阶段,这时候内存最充裕。运行中尽量避免动态创建和删除任务。如果确实需要动态创建,一定要检查返回值,失败了要有降级处理,比如点亮错误LED或者重启系统。
其次,合理设置configTOTAL_HEAP_SIZE。这个值决定了FreeRTOS可用的堆大小。CubeMX默认给的是3072字节,对于稍微复杂点的项目就不够了。我一般会设成10KB到20KB,具体看项目需求。但也不能太大,因为堆是从RAM里划出来的,给多了其他变量就没地方放了。
6.3 栈和堆的区别
这里要澄清一个容易混淆的概念:任务栈和FreeRTOS堆是两回事。
任务栈是每个任务独立拥有的,在创建任务时指定大小。它用来存放局部变量、函数调用返回地址、中断时的上下文。任务栈是从哪里分配的?如果你用xTaskCreate,任务栈是从FreeRTOS堆里分配的。如果你用xTaskCreateStatic,任务栈是你自己提供的静态数组。
FreeRTOS堆是内核管理的一块内存区域,用来分配任务栈、队列、信号量等内核对象。它的大小由configTOTAL_HEAP_SIZE决定。
所以当你创建一个任务时,它同时消耗了任务栈(指定的大小)和堆(任务控制块TCB加上任务栈本身)。如果堆不够,任务创建就会失败。
7. 中断管理和临界区
7.1 中断优先级配置的坑
FreeRTOS对中断优先级有明确要求。Cortex-M内核的中断优先级是数字越小优先级越高,这和FreeRTOS的任务优先级正好相反,很容易搞混。
configMAX_SYSCALL_INTERRUPT_PRIORITY这个配置项定义了一个阈值。优先级高于这个阈值(数字更小)的中断,不受FreeRTOS管理,不能调用任何FreeRTOS的API。优先级低于或等于这个阈值的中断,可以调用以FromISR结尾的API。
为什么有这个限制?因为FreeRTOS在进入临界区时会屏蔽中断,但只屏蔽优先级低于阈值的那些。如果允许高优先级中断调用FreeRTOS API,而FreeRTOS又屏蔽不了它,就可能破坏内核数据结构。
CubeMX默认把configMAX_SYSCALL_INTERRUPT_PRIORITY设成5,也就是优先级0到4的中断不能调FreeRTOS API,优先级5到15的可以。你配置外设中断时,如果要在中断里调FreeRTOS API,优先级必须设成5或更低(数字更大)。
我见过一个典型的错误:串口中断优先级设成1,然后在中断里调xQueueSendFromISR。编译没问题,运行就HardFault。查半天以为是队列问题,其实是优先级配置错了。
7.2 临界区的正确用法
临界区是一段不能被中断打断的代码。FreeRTOS提供了taskENTER_CRITICAL和taskEXIT_CRITICAL来进入和退出临界区。在临界区里,FreeRTOS会屏蔽中断,保证代码原子执行。
但临界区不能太长。你屏蔽中断的时间越长,系统的实时性就越差。如果临界区里有耗时操作,其他中断都会被延迟响应。所以临界区里只放最核心的几行代码,比如修改变量、检查标志位。
在中断里进入临界区要用taskENTER_CRITICAL_FROM_ISR和taskEXIT_CRITICAL_FROM_ISR。注意这两个函数返回一个值,退出时要传回去,用来恢复之前的中断屏蔽状态。
提示:能用信号量或互斥量解决的问题,尽量不要用临界区。临界区是最后的手段,因为它会影响整个系统的中断响应。
7.3 中断服务程序的最佳实践
写FreeRTOS项目的中断服务程序,有几个原则。
第一,中断服务程序要尽可能短。只做最紧急的事情,比如读取硬件寄存器、清除中断标志、把数据放入队列。复杂的处理放到任务里做。
第二,中断里只能调用FromISR结尾的API。这些API不会阻塞,而且会在退出时触发任务切换(如果有更高优先级任务就绪)。
第三,中断里不要用printf。printf是阻塞的,而且不可重入,在中断里用会导致各种奇怪的问题。如果非要在中断里输出调试信息,可以用一个环形缓冲区,把数据存进去,任务里再打印。
第四,注意中断的嵌套。Cortex-M支持中断嵌套,高优先级中断可以打断低优先级中断。如果两个中断都要调FreeRTOS API,要确保它们的优先级配置正确,否则可能出问题。
8. 常见问题排查实录
8.1 程序一启动就HardFault
这是移植FreeRTOS后最常见的问题。原因通常有三个。
第一个是中断向量没接对。检查stm32f4xx_it.c里的SVC_Handler、PendSV_Handler、SysTick_Handler是否已经替换成FreeRTOS的版本。如果这三个函数还是HAL库的默认实现,FreeRTOS启动调度器时就会HardFault。
第二个是FreeRTOSConfig.h里的时钟配置不对。configCPU_CLOCK_HZ必须和实际的系统时钟一致。如果实际是168MHz,你写成72MHz,SysTick的配置就会错,调度器启动后可能跑飞。
第三个是堆空间不够。如果configTOTAL_HEAP_SIZE太小,创建任务时pvPortMalloc返回NULL,但你没有检查返回值,后续操作空指针就会HardFault。
8.2 任务运行一段时间后死机
这种问题通常是栈溢出或者内存泄漏。
栈溢出可以用前面说的configCHECK_FOR_STACK_OVERFLOW来检测。如果检测到溢出,加大对应任务的栈。
内存泄漏的排查稍微麻烦一点。如果你在运行中动态创建和删除任务或队列,每次创建都会消耗堆,删除会释放堆。但如果释放不彻底,或者有地方分配了没释放,堆就会越来越少,最终分配失败。可以用xPortGetFreeHeapSize函数定期打印剩余堆大小,观察是否在持续下降。
8.3 串口数据丢失
串口数据丢失的原因很多,但在FreeRTOS项目里,最常见的是队列满了。
串口中断收到数据,发到队列。如果处理任务来不及取,队列满了,新数据就丢了。解决办法有两个:一是加大队列长度,二是提高处理任务的优先级,让它更快地取数据。
还有一个可能的原因是中断优先级配置不对。如果串口中断优先级低于configMAX_SYSCALL_INTERRUPT_PRIORITY,在中断里调xQueueSendFromISR会失败。检查一下串口中断的优先级设置。
8.4 系统运行变慢
系统变慢通常是CPU被某个任务占满了。用vTaskGetRunTimeStats看看哪个任务占用CPU最多。如果是某个任务里有死循环或者忙等待,改成阻塞方式。如果是任务优先级分配不合理,低优先级任务一直被打断,调整优先级。
还有一种可能是中断太频繁。比如一个定时器中断设成了1微秒一次,CPU大部分时间都在处理中断,任务根本跑不起来。检查一下中断频率是否合理。
8.5 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决措施 |
|---|---|---|---|
| 启动即HardFault | 中断向量未替换 | 检查stm32f4xx_it.c | 替换为FreeRTOS版本 |
| 启动即HardFault | 时钟配置错误 | 核对configCPU_CLOCK_HZ | 改为实际时钟频率 |
| 运行中死机 | 栈溢出 | 开启栈溢出检测 | 加大任务栈 |
| 运行中死机 | 堆耗尽 | 打印剩余堆大小 | 加大堆或修复泄漏 |
| 串口丢数据 | 队列满 | 打印队列剩余空间 | 加大队列或提高任务优先级 |
| 串口丢数据 | 中断优先级错误 | 检查中断优先级 | 设为5或更低 |
| 系统变慢 | 任务占满CPU | 运行时间统计 | 优化任务或调整优先级 |
| 系统变慢 | 中断太频繁 | 检查中断频率 | 降低中断频率 |
9. 和LVGL配合的注意事项
9.1 LVGL的任务化改造
LVGL默认是在主循环里调lv_task_handler的,但在FreeRTOS项目里,最好把LVGL放到独立任务里。创建一个优先级较低的任务,里面循环调用lv_task_handler,每次调用后延时5ms。这样LVGL的刷新不会阻塞其他任务。
但要注意,LVGL的API不是线程安全的。如果你在多个任务里操作LVGL对象,必须加互斥量保护。我一般只在一个任务里操作LVGL,其他任务通过队列发送消息给这个任务,由它统一更新界面。
9.2 栈和内存的额外开销
LVGL本身需要不少内存。显示缓冲区、对象结构体、样式表,加起来可能几十KB。如果你的STM32RAM不够,可以把显示缓冲区设小一点,或者用外部SRAM。
LVGL任务的栈也要给够。因为LVGL内部有递归调用,栈需求比普通任务大。我一般给LVGL任务至少1KB的栈,复杂界面给2KB。
9.3 刷新率和CPU占用的平衡
LVGL的刷新率越高,CPU占用越大。在STM32F407上,如果SPI屏幕的刷新率设成30fps,CPU可能被吃掉一半。这时候要么降低刷新率,要么优化LVGL的配置,比如减少同时显示的对象数量,关闭一些动画效果。
可以用vTaskGetRunTimeStats看看LVGL任务占了多少CPU。如果超过50%,就要考虑优化了。
10. 从项目实战中积累的经验
10.1 GD32F303移植的一个坑
GD32F303虽然和STM32F103引脚兼容,但FreeRTOS的移植文件不能直接照搬。我遇到过一个问题:用STM32F103的port.c移植到GD32F303上,任务切换偶尔会失败。查了很久发现是GD32的SysTick在某些配置下行为略有不同。解决办法是改用GD32官方的FreeRTOS移植包,或者手动调整SysTick的配置。
10.2 STM32F407加FatFS和W25Q64的栈问题
在一个项目里,我用STM32F407通过SPI连接W25Q64,上面跑FatFS,同时用FreeRTOS管理任务。FatFS的文件操作任务栈设了256字,结果一写文件就HardFault。用栈溢出检测发现是FatFS内部用了不少栈空间,256字不够。加大到512字后问题解决。
这个经历告诉我,涉及文件系统、网络协议栈这些复杂组件的任务,栈要给得比普通任务大得多。因为这些组件内部往往有多层函数调用和较大的局部变量。
10.3 优先级反转的实战案例
有一次,一个低优先级的日志任务持有互斥量往串口打印,一个高优先级的控制任务也要用串口,被阻塞了。此时一个中优先级的计算任务就绪,抢占了日志任务。结果控制任务等了很久才拿到串口。这就是典型的优先级反转。
解决办法是把日志任务的互斥量改成支持优先级继承的互斥量。这样日志任务在持有互斥量时,优先级会被临时提升到和控制任务一样,中优先级的计算任务就无法抢占它了。
10.4 空闲任务钩子函数的妙用
FreeRTOS的空闲任务优先级是0,当所有其他任务都阻塞时,空闲任务就会运行。你可以通过vApplicationIdleHook这个钩子函数,在空闲任务里做一些低优先级的后台工作,比如内存整理、状态监测、进入低功耗模式。
但要注意,空闲任务钩子里不能调用任何会阻塞的API,因为空闲任务不能被阻塞,否则系统就没有任务可调度了。也不能在里面死循环,否则其他任务永远得不到执行。
我一般会在空闲任务钩子里做一个简单的CPU使用率统计,或者检查一些标志位,做一些轻量级的清理工作。
10.5 调试技巧:用SEGGER RTT代替串口打印
串口打印调试信息很方便,但会占用串口资源,而且打印本身耗时。如果打印太频繁,会影响系统实时性。我后来改用SEGGER RTT(Real Time Transfer),它通过JTAG/SWD接口输出调试信息,不占用串口,速度也快得多。
在FreeRTOS项目里用RTT,可以在任何任务里调用SEGGER_RTT_printf,输出会直接显示在调试终端上。而且RTT是线程安全的,多个任务同时打印也不会乱。唯一的要求是调试器要连着,但开发阶段这本来也是常态。
11. 后续专栏内容规划
这个开篇主要把FreeRTOS的整体框架和核心概念过了一遍。后续我会按主题深入,每个主题都会配一个可运行的工程示例。
接下来会写任务管理的细节,包括任务创建、删除、挂起、恢复,以及任务通知这种轻量级的同步方式。然后会写队列的深入用法,包括队列集和邮箱。信号量和互斥量会单独一篇,重点讲优先级继承和死锁避免。内存管理会详细分析heap_4的源码,让你彻底搞懂它是怎么分配和释放的。中断管理会结合STM32的具体中断来讲,包括串口、定时器、DMA的中断处理。
再往后会写FreeRTOS在具体项目中的应用,比如用FreeRTOS做一个多任务的串口服务器,或者用FreeRTOS加LVGL做一个带界面的数据采集器。这些实战项目会把前面讲的知识点串起来,让你看到它们在实际中是怎么配合的。
最后会写一些高级话题,比如低功耗设计、安全认证、以及FreeRTOS在SMP(对称多处理)上的支持。这些可能不是每个项目都用得到,但了解一下没坏处。
我在实际项目里用FreeRTOS差不多有六七年了,从STM32F1到F7,从GD32到国产的其它Cortex-M芯片,踩过的坑不少。这个专栏会尽量把这些经验都写进去,让你少走弯路。FreeRTOS不难,但细节很多,一个配置不对就可能卡好几天。希望这个专栏能帮你把这些细节都搞清楚,让FreeRTOS真正成为你项目里的得力工具,而不是一个添乱的黑盒。