做嵌入式这几年,FreeRTOS 我前前后后移植过不下七八次,从最早跟着教程在 STM32F103C8T6 上一步一步配,到后来在 GD32、STM32H7 上重新梳理移植流程,踩过的坑比写过的业务代码都多。尤其是第一次移植那会儿,凌晨两三点对着 Keil 的编译输出框发呆,明明照抄了别人的步骤,就是跑不起来。后来想明白了:移植 FreeRTOS 不是“把文件复制进工程”就完事,真正要搞清楚的是它和 Cortex-M3 内核、启动文件、中断优先级、内存布局之间那一堆隐性约定。这篇文章就把我这些年移植 FreeRTOS 到 STM32F103C8T6 的心得整理出来,从原理讲到实操,再到一堆踩坑实录,给正准备入坑 RTOS 的同学一条能直接照抄的路径。
## 1. 移植前的准备工作与整体思路
1.1 目标板与期望效果
我最初用的是一块很常见的 STM32F103C8T6 最小系统板,蓝 pill 那种,板上 64KB Flash、20KB RAM,主频 72MHz,外接一个 8MHz 晶振经过 PLL 倍频到 72MHz。这个配置放今天看确实不算高,但拿来学 FreeRTOS 恰恰合适——资源有限反而逼着你认真对待每一个任务栈大小、每一块堆内存,而不是像在 H7 上那样肆无忌惮地开大数组。
移植的最终目标很简单:上电后创建两个任务,一个每 500ms 翻转一次 LED,另一个每 1000ms 往串口打印一次运行计数。任务能正常调度、能互相切换、不死机、不进入 HardFault,这就说明移植基本成功了。之后再谈队列、信号量、软件定时器这些组件,才有意义。
1.2 工具链选择:Keil 与 IAR 的取舍
这个项目我分别在 Keil MDK 和 IAR EWARM 下各移植过一次,得出的结论是:FreeRTOS 的官方源码对这两家编译器支持都很成熟,移植步骤本质上没有任何区别,差异主要藏在两个地方。
第一个是启动文件。Keil 用startup_stm32f103xb.s,IAR 用startup_stm32f103xb.s的同名文件,但汇编语法不同,IAR 的启动文件里中断向量名后面没有[WEAK]那种修饰方式,用的是weak伪指令。如果你用 CubeMX 生成工程,它会自动帮你选对对应的启动文件,不需要操心。
第二个是 FreeRTOS 源码里portable目录下的移植层选择。Keil 用的是RVDS/ARM_CM3目录下的port.c和portmacro.h,IAR 对应的是IAR/ARM_CM3。虽然它们最终调用的内核寄存器操作逻辑完全一致,但内联汇编的写法、编译器的 intrinsics 函数不一样,混着用会直接编译报错。
所以我给你的第一个建议就是:别贪省事从网上随便下一个“完整工程”,而是先去 FreeRTOS 官网下载官方源码包,自己动手把需要的文件挑出来、放进自己的工程目录。这个过程虽然多花半小时,但你能亲眼看到一个 RTOS 到底由哪些文件构成,后面排查问题会有底气得多。
1.3 资源盘点:20KB RAM 能把系统玩出什么花样
STM32F103C8T6 的 20KB RAM 听起来小,实际上给 FreeRTOS 用绰绰有余。我当时对内存做了一个比较保守的分配:给configTOTAL_HEAP_SIZE留了 8KB,用来给任务栈、队列、信号量等内核对象分配内存;剩下的 12KB 留给全局变量、中断栈和硬件外设的缓冲区。
按照 FreeRTOS 官方推荐的最小任务栈configMINIMAL_STACK_SIZE是 128 字(注意,不是 128 字节)来算,一个空任务的栈消耗大约 512 字节。8KB 堆里放四五个任务、两三个队列,完全没压力。真正吃 RAM 的往往是业务逻辑里的局部数组、协议栈缓冲区,那部分和 RTOS 本身无关,做裸机开发时该精打细算的,移植 RTOS 后也一样要精打细算。
## 2. 核心机制决定移植方向:FreeRTOS 在 Cortex-M3 上依赖什么
2.1 四个必须接上的底层入口
很多人移植 FreeRTOS 失败,是因为只把tasks.c、queue.c这些内核源码加进工程,却忽略了 FreeRTOS 和 CPU 之间的“翻译层”。这一层就是portable目录下对应架构的移植文件,它在 Keil/ARMCC 环境下叫port.c,里面实现了四个关键的底层入口:
xPortStartScheduler():启动调度器,创建空闲任务,然后触发 SVC 异常,把控制权交给第一个任务。vPortYield():任务主动让出 CPU 时调用的函数,实现方式通常是触发 PendSV。xPortSysTickHandler():tick 中断的服务函数,每次 tick 到来都会调它,更新任务时间片、检查延时任务是否到期。vPortSVCHandler()/xPortPendSVHandler():SVC 和 PendSV 异常的处理函数,真正的任务上下文切换就发生在这里。
如果用一句话概括移植的本质:让 CPU 的异常机制能够和 FreeRTOS 的调度逻辑对接上。Cortex-M3 内核把 SVC、PendSV、SysTick 这三个异常从硬件层面定义好了,FreeRTOS 做的就是利用这三个异常完成“启动第一个任务”和“切换当前任务”这两个核心动作。
2.2 SysTick、PendSV、SVC 到底怎么分工
这三个异常的分工是理解 FreeRTOS 移植的一把钥匙,我分开讲。
SVC(系统服务调用)只在启动调度器时用一次。vTaskStartScheduler()会调用xPortStartScheduler(),后者通过svc 0指令触发 SVC 异常,然后在 SVC 异常处理函数里完成第一个任务的现场初始化,加载第一个任务的栈指针、寄存器值,从异常返回后直接跳进任务代码。为什么非要用 SVC?因为 SVC 是同步异常,调用者主动触发、处理器立即响应,适合用来做这种“初始化关键跳转”。
SysTick 是操作系统的“心跳”。FreeRTOS 默认用 SysTick 产生周期性中断,configTICK_RATE_HZ设为 1000 就是每 1ms 进一次中断,调度器依靠这个节拍来统计时间、判断任务延时是否到期、做时间片轮转调度。
PendSV 是上下文切换的执行者。它被设计成“可挂起的系统服务”,什么意思呢?就是当高优先级中断正在运行时,如果有任务切换请求,可以先“挂起” PendSV,等当前中断处理完、系统回到线程模式后再真正切换任务。这样一来,任务切换就不会打断关键中断的执行,也不会在中断处理的中途制造竞态条件。这个设计是 FreeRTOS 在 Cortex-M3 上能够稳定运行的重要保障。
2.3 为什么中断优先级越低越好用
Cortex-M3 的 NVIC 采用“数值越小优先级越高”的规则,FreeRTOS 对 SysTick 和 PendSV 的设置是系统里最低的优先级。为什么?想象一个场景,串口接收中断刚进来到一半,数据还没读完,如果此时 SysTick 抢进来做任务切换,就会把串口中断的处理撕成两半,等切回来继续执行时,时间片已经过去了好几个微秒。对于高速通信场景,这种打断可能直接导致丢数据。
更核心的原因是 FreeRTOS 要求“临界区”期间不能被调度器打断。临界区通过关中断portENTER_CRITICAL实现,但如果 SysTick 的优先级比某些外设中断还高,就算你在普通代码里关了低优先级中断,高优先级的 SysTick 依然能抢进来做任务切换,临界区保护就形同虚设了。所以 FreeRTOS 把调度相关的两个异常(PendSV、SysTick)放在最低优先级,同时强制要求调用 FreeRTOS API 的中断优先级不能位于系统的最高优先级区域,这是通过configMAX_SYSCALL_INTERRUPT_PRIORITY宏来控制管理的。
## 3. 实操:在 Keil MDK 上完整移植 FreeRTOS 到 STM32F103C8T6
3.1 目录结构搭建与源码拷贝
先说文件从哪里来。我建议直接到 FreeRTOS 官网下载较新的长期支持版本,解压后只用关注FreeRTOS/Source目录。
真正需要拷贝进自己工程核心目录的内容有这些:
tasks.c:任务管理和调度核心queue.c:队列、信号量、互斥锁的实现基础list.c:内核链表操作timers.c:软件定时器,如果不用可去掉portable/MemMang/heap_4.c:内存堆管理算法(heap_4 支持碎片合并,适合大多数场景)portable/RVDS/ARM_CM3/port.c和portmacro.h:Cortex-M3 移植层FreeRTOSConfig.h:系统配置文件,每个工程都要单独维护
文件拷贝好之后,在 Keil 里新建一个FreeRTOS分组,把这些.c文件加进去,然后在 C/C++ 编译选项的 Include Paths 里把对应的头文件路径全部加进来。这里有一个新手常犯的错误:只加了FreeRTOS/Source/include,而FreeRTOSConfig.h放在另一个目录,没加它的路径,编译直接报找不到头文件。建议FreeRTOSConfig.h和include目录一层一层对照检查,路径这个东西错一个字母就够你查半小时的。
3.2 启动文件与中断处理函数的衔接
拷贝完源码后,有一个关键动作:检查启动文件里的SVC_Handler、PendSV_Handler、SysTick_Handler这三个中断向量。
在 STM32 的标准启动文件里,这三个向量默认指向一个B .的弱定义死循环,也就是说如果有其他源文件也定义了同名函数,链接器会优先使用强定义,把弱定义替换掉。FreeRTOS 的port.c里恰好就实现了vPortSVCHandler、xPortPendSVHandler、xPortSysTickHandler(具体符号名和 FreeRTOSConfig.h 里的宏定义有关),所以正常情况下不需要手动改启动文件。
但这里有一个很容易踩坑的点:如果你的裸机工程原本就在用SysTick_Handler,比如 HAL 库的HAL_Init()会把 SysTick 用作 HAL 的时基,那移植 FreeRTOS 后就会产生两个“主人”。我在第 5 章会详细说这个冲突,这里先记住结论:裸机工程里凡是用到 SysTick 的代码,移植前必须想清楚是否会被 FreeRTOS 接管。
我用的是标准外设库(SPL),启动文件里三个 handler 都是[WEAK],所以直接编译链接就能通过。如果你用 HAL 库加 CubeMX,另一种做法是把 FreeRTOS 的xPortSysTickHandler直接放到自己的 SysTick 中断函数里调用,这属于 CubeMX 自动生成的方案,后面单独讲。
3.3 FreeRTOSConfig.h 一键配置指南
FreeRTOSConfig.h是整个移植过程中信息密度最高的文件,里面的每个宏都直接决定内核行为。我把我常用的针对 STM32F103C8T6 的配置贴出来,并解释几个关键项。
#ifndef FREERTOS_CONFIG_H #define FREERTOS_CONFIG_H #define configUSE_PREEMPTION 1 #define configUSE_IDLE_HOOK 0 #define configUSE_TICK_HOOK 0 #define configCPU_CLOCK_HZ ( ( unsigned long ) 72000000 ) #define configTICK_RATE_HZ ( ( TickType_t ) 1000 ) #define configMAX_PRIORITIES ( 5 ) #define configMINIMAL_STACK_SIZE ( ( unsigned short ) 128 ) #define configTOTAL_HEAP_SIZE ( ( size_t ) ( 8 * 1024 ) ) #define configMAX_TASK_NAME_LEN ( 16 ) #define configUSE_TRACE_FACILITY 1 #define configUSE_16_BIT_TICKS 0 #define configIDLE_SHOULD_YIELD 1 #define configUSE_MUTEXES 1 #define configUSE_RECURSIVE_MUTEXES 1 #define configUSE_COUNTING_SEMAPHORES 1 #define configQUEUE_REGISTRY_SIZE 8 #define configUSE_TIMERS 1 #define configTIMER_TASK_PRIORITY ( 2 ) #define configTIMER_QUEUE_LENGTH 10 #define configTIMER_TASK_STACK_DEPTH 256 #define configCHECK_FOR_STACK_OVERFLOW 2 #define configUSE_MALLOC_FAILED_HOOK 1 #define configKERNEL_INTERRUPT_PRIORITY ( 15 ) #define configMAX_SYSCALL_INTERRUPT_PRIORITY ( 5 ) #define configASSERT( x ) if( ( x ) == 0 ) { taskDISABLE_INTERRUPTS(); for( ;; ); } #endif几个要点逐个说。
configCPU_CLOCK_HZ是 CPU 时钟频率,对 STM32F103C8T6 来说就是 72MHz。这个值会影响vTaskDelay等时间相关函数的精度,如果配错,延时时间会成倍偏差。我见过有人把外部晶振的 8MHz 填进去,结果vTaskDelay(1000)实际延时不正确,查了半天才发现是这里的问题。
configTOTAL_HEAP_SIZE是内核可用的堆内存总大小,单位为字节。C8T6 只有 20KB RAM,我这里给了 8KB,如果你是只做实验、不跑大型协议栈,8KB 完全够用。如果后面要移植 LVGL 或 FreeModbus,就需要重新评估,甚至考虑用外部 SRAM。
configMINIMAL_STACK_SIZE的单位是“字”(Word),在 32 位处理器上就是 4 字节。128 字等于 512 字节,这是系统空闲任务和软件定时器任务的参考栈大小,业务任务建议在此基础上按需求翻倍。
configKERNEL_INTERRUPT_PRIORITY和configMAX_SYSCALL_INTERRUPT_PRIORITY的取值,我在第 2 章说过优先级数值越小越高。这里内核中断优先级设为 15(最低),意味着 SysTick 和 PendSV 的抢占优先级是系统最低的;configMAX_SYSCALL_INTERRUPT_PRIORITY设为 5,表示只有优先级数值大于等于 5(即优先级不高于 5)的中断里才允许调用xQueueSendFromISR这类中断安全的 API。这个 5 和 STM32 HAL 库默认的外设中断抢占优先级 5 是配套的,很多人初始化外设中断时图省事用默认优先级 0,然后在中断回调里调用 FreeRTOS API,直接触发断言死机。
3.4 创建第一个任务与验证
配置好之后,写一个最简单的入口函数来验证移植结果。我在main.c里写了两个任务,逻辑很简单,但重点是要验证任务切换是否真的在跑。
#include "FreeRTOS.h" #include "task.h" static void vTaskLED( void *pvParameters ) { for( ;; ) { GPIOA->ODR ^= GPIO_Pin_0; vTaskDelay( pdMS_TO_TICKS( 500 ) ); } } static void vTaskPrint( void *pvParameters ) { uint32_t ulCount = 0; for( ;; ) { printf( "task print: %lu\r\n", ulCount++ ); vTaskDelay( pdMS_TO_TICKS( 1000 ) ); } } int main( void ) { // 中断、时钟、串口等硬件初始化略 xTaskCreate( vTaskLED, "LED", 128, NULL, 1, NULL ); xTaskCreate( vTaskPrint, "Print", 128, NULL, 1, NULL ); vTaskStartScheduler(); while( 1 ); }重点观察两件事:LED 能不能精确按 500ms 闪烁,串口能不能每隔 1s 稳定打印。如果 LED 闪烁明显不稳定、时快时慢,或者串口打印乱码,多半是时钟配置或 SysTick 频率不对,优先检查SystemCoreClock和configCPU_CLOCK_HZ是否匹配。如果任务压根不执行,则要考虑是否中断向量表没有正确链接到 FreeRTOS 的 handler,或者FreeRTOSConfig.h里的宏配置导致断言死循环。
## 4. 堆栈、内存与溢出检测——排查后顾之忧
4.1 heap_1 到 heap_5 该怎么选
FreeRTOS 的portable/MemMang目录下提供了 5 种内存堆管理实现,移植时很多人随手就选 heap_4,但最好还是理解一下它们各自的使用场景。
- heap_1:只支持分配不支持释放,简单可靠,适合系统启动后任务和队列一次性创建完的场景。
- heap_2:支持释放,但不会合并相邻内存块,容易产生碎片,不太推荐用在长期运行的系统里。
- heap_3:直接包装 C 库的
malloc/free,需要编译器支持,效率一般,但代码体积小。 - heap_4:在 heap_2 基础上增加了相邻空闲块合并,能有效缓解碎片问题,是绝大多数应用的默认选择。
- heap_5:支持在多个不连续内存区域里分配,适合 MCU 既有内部 RAM 又有外部 SRAM 的复杂场景。
我在这颗 C8T6 上用的就是 heap_4,它比较均衡,既能释放内存,又能合并碎片。除非你有明确理由,否则新手一律建议 heap_4。
4.2 任务栈大小与 configTOTAL_HEAP_SIZE 的平衡
任务栈大小是 RTOS 开发里最常见的玄学问题。栈开小了,任务一跑复杂逻辑就溢出,系统莫名其妙的死机;栈开大了,8KB 堆几下就被吃光了。
我的经验是从两个方向推算:第一,任务里最大局部数组 + 函数调用链中所有局部变量 + 中断嵌套预留,这几项加起来算出一个理论下界;第二,用uxTaskGetStackHighWaterMark()在运行一段时间后查询任务栈剩余的最小水位,根据实测值再调整。
UBaseType_t uxHighWaterMark; uxHighWaterMark = uxTaskGetStackHighWaterMark( xTaskHandle );这个函数返回的是任务运行以来栈空间最少剩余的字数,留的余量越接近 0 说明栈越危险。我一般会让每个任务至少保留 20% 以上的剩余量,如果低于这个数,就把这个任务的栈大小往上调。
configTOTAL_HEAP_SIZE则是一个总体预算。任务栈、队列、信号量、事件组全部从这个堆里分配,所以如果你开了很多任务,8KB 可能不够,pvPortMalloc会返回 NULL,进入vApplicationMallocFailedHook。这时候要么调大堆,要么精简任务,没有第三条路。
4.3 堆栈溢出检测怎么打开
FreeRTOS 提供了两种堆栈溢出检测机制,通过configCHECK_FOR_STACK_OVERFLOW来配置。设为 1 时,只在任务切换时检查任务栈是否有溢出痕迹,开销小,但可能漏检;设为 2 时,除了切换检查之外,还会在每次中断进入时检查栈指针是否越界,可靠性更高,但会稍微增加中断延迟。
我建议开发阶段直接设为 2,并实现vApplicationStackOverflowHook:
void vApplicationStackOverflowHook( TaskHandle_t xTask, char *pcTaskName ) { /* 停在这里看变量值,或者用串口打印任务名 */ taskDISABLE_INTERRUPTS(); for( ;; ); }当溢出检测触发时,钩子函数里会拿到出问题的任务句柄和名字,这对定位问题非常有价值。发布正式固件时,如果你不希望承担这段检测代码的开销,可以把它改为 0 或 1,但开发调试期千万不要省这个功能,它救过我很多次。
4.4 如何在运行期查看系统内存使用
FreeRTOS 提供的xPortGetFreeHeapSize()可以随时查看堆剩余字节数,配合heap_4.c里的xPortGetMinimumEverFreeHeapSize()还可以知道系统运行以来堆内存最少到达过多少。这两个接口是排查内存不够用、内存泄漏的利器。
我习惯在调试串口里做一个meminfo命令,每 5 秒把当前剩余堆、任务栈高水位、系统运行时间一起打印出来,观察一段时间,如果剩余堆在不断减少,就要怀疑哪个任务在持续申请内存没有释放。这套方法没什么高深技巧,但确实最有效。
## 5. 踩过的坑与排查心得
5.1 中断优先级没设置导致的诡异现象
这是我第一次移植时卡得最久的问题:任务创建正常、调度器启动了、LED 闪烁也正常,但只要一在串口中断里调用xQueueSendFromISR往队列发数据,系统就死机。
后来定位到原因,串口中断的抢占优先级被我初始化为 0(最高优先级),而configMAX_SYSCALL_INTERRUPT_PRIORITY配置的是 5。FreeRTOS 规定,只有优先级数值不小于这个宏所设值得中断,才允许调用带FromISR后缀的 API。优先级 0 的中断打断了许多内核临界区操作,导致链表结构被破坏,系统直接 HardFault。
解决方式是把串口中断优先级从 0 改成 5 或更高数值,再配合configASSERT在开发期尽早暴露问题。这件事给我的教训是:移植 RTOS 后,所有外设中断的优先级都要重新审视一遍,不能沿用裸机时“越高越好”的习惯。
5.2 Keil 编译错误 Q0147E:failed to create directory
移植过程中还碰到过一个纯工程配置问题,Keil 报错大概是这样的:
.\obj\freertos.hex: error: Q0147E: failed to create directory .\obj\freertos这个报错看着像是文件系统没法创建目录,实际上通常是 Keil 的 Output 目录配置出了岔子。可能的原因有三个:输出目录路径里的某个子目录不存在,而 Keil 不会自动创建多级目录;杀毒软件在编译时锁住了目标目录,导致创建失败;还有工程路径里有中文或特殊字符。
排查时先看 Target Options 里的 Output 选项卡,确认 Select Folder for Objects 指向的目录是否真实存在,然后手动把缺失目录创建出来,或者干脆把输出目录指定为工程目录下的.\obj。我那次就是用第二种方式解决的,把 “Executable folder name” 里的freertos去掉,让 Keil 直接输出到obj根目录,错误就消失了。如果你是在公司电脑上遇到这个问题,优先检查杀毒软件/终端安全软件的拦截记录,有时候不是代码问题,是环境问题。
5.3 任务不调度或死机问题的定位思路
移植完成后最常见的问题就是“任务不切换”或者“一运行就死机”,这时候不要漫无目的地改代码,按下面这个顺序排查基本能覆盖绝大部分场景:
- 看
configASSERT是否命中断言,进调试器暂停,看 PC 指针停在哪个函数、哪个 assert 条件不满足,这是最直接的线索。 - 检查
vTaskStartScheduler()之前的硬件初始化是否正确,特别是 SysTick 时钟和SystemCoreClock是否一致。 - 检查启动文件里是否还有
SysTick_Handler这类同名强函数,如果有,FreeRTOS 的中断入口可能没被链接进去。 - 检查外设中断优先级是否在
configMAX_SYSCALL_INTERRUPT_PRIORITY的限制范围内。 - 如果任务用到了浮点运算或用到了较深的函数调用,把任务栈加大再试,排除栈溢出。
这套排查顺序我后来几乎形成了肌肉记忆,遇到 RTOS 相关死机,先把这几个点过一遍,大部分问题都能浮出水面。
## 6. 从 CubeMX 配置生成的视角再聊几句
6.1 CubeMX 快速生成 FreeRTOS 工程
除了手动移植,STM32CubeMX 现在也直接支持在中间件里配置 FreeRTOS,你勾选后它会自动帮你把内核源码、移植层、配置文件全部生成好,SysTick_Handler里也会自动调用osSystickHandler()。这套方案省事,尤其适合用 HAL 库的项目。CubeMX 默认把 FreeRTOS 的 tick 改为使用TIM7等硬件定时器来驱动 HAL 时基,避免和 SysTick 抢资源,这比手动移植时那种“裸机 SysTick 和 RTOS SysTick 打架”的方案要规整得多。
不过用手动移植的方式走一遍仍然有价值,因为你能看到哪些文件参与了系统启动、中断向量是怎么被覆盖的、配置宏到底影响哪些行为。很多用 CubeMX 生成工程的人遇到问题完全没法下手,就是因为对这些底层衔接不清楚。我个人建议,学习阶段先手动移植一次,工作阶段随便你用什么方式生成。
6.2 移植完成后还能继续玩什么
FreeRTOS 移植成功只是第一步,后续可以在这个基础上继续加组件。比较常见的方向有:移植 LVGL 做图形界面,C8T6 的 64KB Flash 和 20KB RAM 可以做简单的仪表盘或者菜单界面;移植 FreeModbus 做主从站通信,和工业上位机对接;再往上还可以接网络协议栈,不过那就得换更大资源的芯片了。
把一个 RTOS 从零移植到一款新 MCU 上的过程,本质上是在跟“硬件中断、任务切换、内存管理”这三个核心概念打交道。把这些搞懂了,以后再接触其他 RTOS,比如 RT-Thread、Zephyr,你会觉得它们的骨架都惊人地相似。我在实际移植中还有一个小心得:每个新项目开始前,先把 FreeRTOSConfig.h 从头到尾读一遍,对照官方文档理解每个宏的默认值和修改后果,这比复制粘贴别人的配置然后出问题再猜,要高效得多。希望这篇心得能帮你少走一点弯路。