简介:一份面向嵌入式初学者的FreeRTOS移植通用模板,基于STM32F103正点原子精英板,适用于需要快速搭建RTOS工程、理解任务调度与中断同步机制的开发场景。包内共196个文件,以c/h源码、uvprojx工程配置、axf与hex可执行文件、txt说明文档为主,核心包含FreeRTOS任务创建、vTaskStartScheduler调度启动、中断服务例程同步机制等完整实现,配套标准外设库文件与编译脚本,解压后约3.7MB。模板已完成时钟配置、内存分区、FreeRTOS裁剪等移植工作,使用者只需修改业务任务函数即可投入新项目。同时包含调试辅助文件(如map、lst、crf)可帮助分析任务堆栈与资源占用,便于学习RTOS运行机制与排错。目前已有2847人学习下载,适合正在学习RTOS移植或需要快速原型验证的STM32开发者。
1. FreeRTOS移植实验:先把“通用模板”四个字说透
一块 STM32F103 最小系统板、一个 Keil 工程、一枚点不亮的 LED,这是很多人在裸机上搭 FreeRTOS 时的真实画面。FreeRTOS 移植实验看着热闹,真正决定成败的并不是往工程里塞了多少源码,而是三件事:把内核需要的时钟和中断处理好,把 FreeRTOSConfig.h 配成和芯片匹配的参数,再让启动文件里的中断向量和内核的处理函数对上号。STM32F103 通用模板想解决的就是这套重复劳动——内核源码不用读懂每一行,但要清楚哪些文件必须进工程、哪几个宏必须亲手改。这篇文章给做课程设计和产品预研的工程师用,先讲机制再给配置,最后把常见的坑一条条排掉,让你抄配置的时候心里有底,出了错也知道去哪个文件里找原因。
2. 移植前先看皮:Cortex-M3 上 FreeRTOS 的运行机制与工程文件结构
很多人一上来就复制源码、添加文件、编译,报错了再回头查,这种顺序其实是反的。先搞清楚 FreeRTOS 在 STM32F103 上到底依赖哪些硬件资源,再动手加文件,效率会高很多,也更容易避开“编译过但跑不起来”的尴尬。
2.1 三个中断和一个节拍:真正要动的东西就四件
FreeRTOS 跑在 Cortex-M3 这种带硬件中断嵌套的核上,理论上只需要四个运行条件:一个能产生周期性中断的时基,用来驱动任务节拍;一个在任务切换时保存和恢复现场的中断;一个在系统启动时切入第一个任务的中断;再加上一段用于任务栈和内核对象分配的内存。
对应到 STM32F103 上,就是 SysTick、PendSV、SVC 和一块 RAM。SysTick 是内核自带的 24 位递减计数器,用它做 FreeRTOS 的 tick 既省事又准,不需要额外占用 TIM。PendSV 是可挂起的系统服务调用,它最大的特点是:如果当前有更高优先级的中断正在处理,PendSV 会被推迟到所有中断处理完之后再执行。这个特性天然适合做上下文切换——切换动作不会打断正在执行的中断服务函数,也就不会破坏中断现场的完整性。SVC 则用于系统启动时触发第一个任务。
所以严格说,FreeRTOS 移植并不需要你理解调度器的每一行代码,但必须保证这三条中断路径是通的:SysTick 能按时触发并调用内核的 tick 处理函数,SVC 能进入内核的启动逻辑,PendSV 能执行任务切换。GPIO、串口、I2C 这些外设驱动和操作系统没有任何关系,它们只是被任务调用的普通函数。
2.2 源码包里哪些文件夹搬过去,哪些留在原地
从官方渠道拿到 FreeRTOS 源码包之后,不要整个目录都塞进工程。一个最小可用的 F103 移植只需要内核源码和两个端口文件。内核源码通常在 source 目录下,包括 tasks.c、queue.c、list.c、timers.c、event_groups.c 和 croutine.c 这些顶层文件,其中 croutine.c 是协程,F103 上基本用不到,但编译进去也不碍事。
端口文件在 portable 目录下。Cortex-M3 的端口文件通常位于 ARM_CM3 相关的子目录,工具链不同,路径也不同:Keil MDK 用 RVDS 系列,GCC 用 GCC 目录。需要特别注意的是 portable 目录下的 MemMang 子目录,里面有 heap_1.c 到 heap_5.c 五个内存管理实现,移植时只选一个加入工程。我一般选 heap_4.c,它支持释放和合并空闲块,不会像 heap_2.c 那样不能释放,也不会像 heap_5.c 那样需要手动指定内存块位置,对绝大多数 F103 应用足够用。
一个典型的工程文件清单大概是这样的:
| 文件或目录 | 作用 | 必须添加 |
|---|---|---|
| source/include 头文件目录 | FreeRTOS 公共头文件 | 是 |
| source/tasks.c | 任务调度核心 | 是 |
| source/queue.c | 队列与信号量实现 | 是 |
| source/list.c | 内核链表 | 是 |
| source/timers.c | 软件定时器 | 按需 |
| portable 下的 ARM_CM3 端口文件 | 上下文切换、tick 处理 | 是 |
| portable/MemMang/heap_4.c | 内存堆分配 | 是 |
这里有个常见的误区:有的人只加 source 下的 .c 文件,忘了 portable 下的端口文件,编译时发现一堆 Undefined symbol,然后开始怀疑是不是编译器坏了。实际上 FreeRTOS 的调度器实现和芯片相关逻辑是分开的,tasks.c 负责通用调度算法,端口文件负责具体芯片的寄存器操作和中断入口,两个缺一不可。
2.3 最小工程验证:还没引入 FreeRTOS 前的点灯测试
我在正式移植之前,一定会先在同一个工程里跑一个裸机点灯程序。这一步看着傻,其实是性价比最高的排错手段——它能一次性排除芯片时钟配置、下载器连接、启动文件选择、LED 引脚初始化这些基础问题。如果裸机点灯都起不来,加了 FreeRTOS 之后只会更乱。
#include "stm32f1xx_hal.h" /* 时钟树初始化通常由 CubeMX 生成,这里默认 SystemCoreClock 已经等于实际主频 */ int main(void) { HAL_Init(); SystemClock_Config(); __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitTypeDef gpio = {0}; gpio.Pin = GPIO_PIN_5; gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOA, &gpio); while (1) { HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5); HAL_Delay(500); } }这段代码的逻辑不用多说,重点在验证两件事:第一,SystemCoreClock 这个全局变量的值是不是你预期的 72MHz 或 8MHz,这个值在后面配置 FreeRTOS 的 configCPU_CLOCK_HZ 时要用;第二,确认下载器能正常烧录并复位运行。很多同学在移植 FreeRTOS 之后才发现自己板子的晶振不是 8M 而是 12M,或者外部晶振压根没起振,系统跑在 HSI 上——这些问题如果在裸机阶段暴露,排查成本低得多。
裸机点灯通过之后,再向外设初始化代码里加 FreeRTOS 就有了一个干净的基础。记住一个原则:在引入操作系统之前,先让芯片裸奔正常;连裸奔都不正常的板子,没有一个操作系统能救得回来。
3. 改 FreeRTOSConfig.h:把内核配置成适配 STM32F103 的样子
FreeRTOSConfig.h 是整个移植过程中唯一需要逐行阅读的配置文件,它是内核行为和硬件资源之间的契约。很多人的移植失败不是因为源码缺文件,而是因为这个文件里的宏配置和 F103 的实际情况对不上,导致系统表面能跑,实际处处不对。
3.1 必调的六个宏:每个都对应一个翻车点
configCPU_CLOCK_HZ 一定要写实际的主频。这个宏告诉内核 tick 中断的时钟频率,如果写错,所有和时间有关的 API 都会按错误比例跑,比如 vTaskDelay(1000) 实际只等了 500ms。更稳妥的写法是直接用 SystemCoreClock,不要自己写死 72000000,因为这颗芯片可能跑在 8MHz 内部时钟、36MHz、72MHz 等不同主频下,写死等于埋雷。
configTICK_RATE_HZ 决定系统节拍频率,F103 上我一般给 1000,也就是 1ms 一个 tick。有人追求实时性把它设成 10000,后果是 SysTick 中断每秒触发一万次,CPU 大量时间耗在中断进出和任务切换上,系统整体吞吐反而下降。1000Hz 对绝大多数传感采集、通信协议、人机交互场景足够。
configTOTAL_HEAP_SIZE 是给 heap_4.c 用的总堆大小。STM32F103 不同型号 SRAM 差别很大,C8T6 只有 20KB,ZET6 有 64KB。模板里先给个保守值,比如 12KB,等真的跑起来再用 vTaskList 看剩余堆,慢慢调。堆给太大,任务栈和中断栈就不够用,直接 HardFault。
configMINIMAL_STACK_SIZE 是内核里 IDLE 任务和默认任务的最小栈深度,单位是字不是字节。F103 是纯整数核,没有 FPU,128 字也就是 512 字节够用。如果你的任务里用了 printf 浮点格式化或者大数组,单独给这个任务开更大的栈,不要调这个全局最小值。
configUSE_PREEMPTION 和 configUSE_TIME_SLICING 要一起看。前者决定是否用抢占式调度,后者决定同优先级任务之间是否按时间片轮转。F103 上做实时应用,两个都开,即 preemption 为 1、time slicing 为 1,不然同优先级任务里有一个写了个死循环,其他任务全部饿死。
3.2 中断优先级与临界区:为什么不能把所有优先级拉满
Cortex-M3 的 NVIC 优先级寄存器只用高 4 位,也就是说硬件上只有 16 级抢占优先级。但 FreeRTOS 内部用 0 到 255 表示优先级,所以它需要一个转换规则。configKERNEL_INTERRUPT_PRIORITY 默认给 255,对应到硬件上是最低优先级,内核在关闭中断时用 BASEPRI 寄存器实现临界区保护。configMAX_SYSCALL_INTERRUPT_PRIORITY 给 191,这是允许调用 FreeRTOS API 的中断优先级上限。
很多人第一次移植图省事,把这两个宏都改成 0,心想优先级越高越好。结果任务切换全都乱套,临界区形同虚设。原因很简单:BASEPRI 机制是靠比较中断优先级数值来屏蔽中断的,如果内核在临界区里只屏蔽数值大于等于某个阈值的中断,而你所有中断都是最高优先级,内核根本屏蔽不住它们,队列、信号量这些数据结构就可能被中断打乱。
STM32F103 用 NVIC_PriorityGroup_4,也就是 16 级全抢占优先级,没有子优先级。实际使用中,我会把所有可能调用 FreeRTOS API 的中断优先级设在 11 到 15 之间,数值越大优先级越低;优先级 0 到 10 留给不调用 API、需要极快响应的中断。这个习惯能避免大部分和中断嵌套相关的诡异问题。
3.3 一份可直接复制改写的核心配置
下面的配置片段是我在 F103 上反复用的一套核心配置,可以作为模板直接套用,但注释里标注的几项必须根据你的芯片型号和应用场景改。
/* FreeRTOSConfig.h 核心片段 */ #define configUSE_PREEMPTION 1 #define configUSE_TIME_SLICING 1 #define configUSE_PORT_OPTIMISED_TASK_SELECTION 1 #define configCPU_CLOCK_HZ (SystemCoreClock) /* 用变量,不写死 */ #define configTICK_RATE_HZ (1000) /* 1ms 节拍 */ #define configMINIMAL_STACK_SIZE ((uint16_t)128) /* 单位是字 */ #define configTOTAL_HEAP_SIZE ((size_t)(12 * 1024)) /* 12KB,按 RAM 调整 */ #define configMAX_TASK_NAME_LEN (16) #define configUSE_IDLE_HOOK 0 #define configUSE_TICK_HOOK 0 /* 中断优先级契约,配合 NVIC_PriorityGroup_4 使用 */ #define configKERNEL_INTERRUPT_PRIORITY 255 #define configMAX_SYSCALL_INTERRUPT_PRIORITY 191 /* 调试辅助功能,验证完可以关掉 */ #define configCHECK_FOR_STACK_OVERFLOW 2 #define configUSE_TRACE_FACILITY 1 #define configUSE_STATS_FORMATTING_FUNCTIONS 1configUSE_PORT_OPTIMISED_TASK_SELECTION 这个宏值得说明一下。它开启后,调度器用 Cortex-M3 的硬件前导零指令从就绪列表中直接选出最高优先级任务,而不是用通用 C 代码遍历链表。F103 上建议开启,能明显减少任务切换时间,但它依赖端口文件支持,如果你的端口文件版本不支持,编译会报错,此时把它关掉即可。
configCHECK_FOR_STACK_OVERFLOW 设成 2 表示在每次上下文切换时都检查任务栈溢出,这是调试期非常有用的功能。但它需要配合一个钩子函数 vApplicationStackOverflowHook 才能生效,别只开宏不写函数,否则编译都过不了。
4. 启动文件与中断处理:把 SVC、PendSV、SysTick 正确交还给内核
配置好 FreeRTOSConfig.h 之后,接下来的工作量集中在启动文件和中断处理函数的命名上。这也是整个移植过程中最像“玄学”的部分:明明所有源码都加了,编译也通过了,任务就是起不来,最后发现只是向量表里三个函数名没对上。
4.1 三个中断各自干的事:任务切换的黑匣子拆开看
SVC 在系统启动时由调度器触发,它负责初始化任务上下文并切入第一个任务。在裸机工程里,SVC_Handler 通常是用户自己写的中断服务函数,但在 FreeRTOS 里它必须指向内核的 vPortSVCHandler,否则第一个任务根本启动不了。
PendSV 是任务切换的真正执行者。它做的事情是:保存当前任务的寄存器现场到任务栈,从下一个要运行的任务栈里恢复寄存器现场,然后通过修改栈指针和控制寄存器完成任务跳转。这个中断的优先级应该设置为最低,这样它不会抢在任何外设中断之前执行,保证中断服务的完整性。
SysTick 则负责周期性调用内核的 xTaskIncrementTick,每触发一次,系统 tick 计数加一,同时检查是否有任务延时到期、是否应该触发时间片轮转。如果 SysTick 中断没有正确连接到内核函数,整个系统就像没有心跳的人,vTaskDelay 永远等不到超时。
4.2 修改启动文件向量名的两种做法
不同版本的 FreeRTOS 端口文件,中断处理函数的命名不一样。有的叫 vPortSVCHandler、xPortPendSVHandler、xPortSysTickHandler,有的通过宏把这三个名字映射成裸机工程里的 SVC_Handler、PendSV_Handler、SysTick_Handler。你需要打开工程里的启动文件,比如 startup_stm32f103xe.s,找到向量表里这三行,确认名字和端口文件里的导出符号一致。
常用做法是直接改启动文件里的向量名:
__Vectors DCD __initial_sp DCD Reset_Handler DCD NMI_Handler DCD HardFault_Handler ; ... 其他向量 ... DCD vPortSVCHandler ; 原来是 SVC_Handler,改成 vPortSVCHandler ; ... 其他向量 ... DCD xPortPendSVHandler ; 原来是 PendSV_Handler ; ... 其他向量 ... DCD xPortSysTickHandler ; 原来是 SysTick_Handler如果不想动启动文件,另一种做法是在 FreeRTOSConfig.h 里加入宏映射:
#define vPortSVCHandler SVC_Handler #define xPortPendSVHandler PendSV_Handler #define xPortSysTickHandler SysTick_Handler这样编译器看到端口文件里定义的函数名时,会把它替换成裸机工程原有的中断向量名。两种方式选一种即可,不要同时做,否则可能出现重复定义或者链接混乱。判断到底该用哪种方式,直接看端口文件里函数声明处有没有被#ifdef包裹的宏定义,有就用宏映射,没有就改启动文件。
4.3 HAL 与时基冲突:SysTick 到底归谁管
如果你用的是 STM32F1xx 的 HAL 库,这里有一个绕不开的冲突:HAL 库默认用 SysTick 做自己的时基,SysTick_Handler 里会调用 HAL_IncTick,uwTick 变量负责给 HAL_Delay 提供计数。现在 FreeRTOS 也要 SysTick,两个系统抢一个中断。
在 CubeMX 生成的工程里,最干净的做法是进入 SYS 配置页面,把 Timebase Source 从 SysTick 改成 TIM6 或 TIM7。这样 HAL 库的时基由定时器提供,SysTick 中断完全让给 FreeRTOS。手动建立工程时,做法一样:初始化一个基本定时器并启动其中断,在它的中断里调用 HAL_IncTick,同时保持 SysTick 向量指向 FreeRTOS 的 tick 处理函数。
也有一种临时手段:把 SysTick_Handler 里同时调用 HAL_IncTick 和内核的 tick 处理函数。
void SysTick_Handler(void) { HAL_IncTick(); if (xTaskGetSchedulerState() != taskSCHEDULER_NOT_STARTED) { xPortSysTickHandler(); } }这段代码能工作,但我不建议长期保留。SysTick 中断服务变长,会影响 tick 的确定性;而且这种写法把 HAL 时基和内核节拍耦合在一起,一旦调度器在 SysTick 里触发 PendSV,中断时序会受到干扰。标准外设库没有这个问题,它本身不占用 SysTick,所以很多老工程师用标准外设库做 FreeRTOS 移植会顺手很多。
5. 移植避坑手记:F103 上反复出现的五个典型问题
这一章是踩坑记录,每个问题我都按现象、原因、解决的顺序写。这些坑不是某一块特定板子的问题,而是 F103 移植 FreeRTOS 时反复出现的共性问题。
5.1 现象:烧录后 LED 不闪,调试器停在 HardFault_Handler
原因:多数情况是中断向量名没对上,或者是端口文件没有真正编译进工程。向量名对不上,SVC 一触发就跳进默认的死循环处理函数,系统根本进不了第一个任务。端口文件没加进去,则链接器找不到内核需要的硬件操作函数,编译报错还算好的,最怕的是某些编译优化下函数被错误链接。
解决:先检查工程树里 portable 目录下的端口文件和 heap_4.c 是否确实被编译。然后在 HardFault_Handler 里打断点,查看当前的 LR 寄存器值,再对照 map 文件看是从哪个函数跳进来的。这种排查方式比满屏 printf 输出更直接。记住:FreeRTOS 移植的本质是让三个中断和内核正确连接,任何和这个目标无关的错误,先从向量名查起。
5.2 现象:任务创建成功,调度器也启动了,但任务卡在 vTaskDelay 里永远不恢复
原因:SysTick 中断没有路由到内核。常见于 HAL 库工程,SysTick_Handler 还是 HAL 默认的实现,只调用了 HAL_IncTick,内核的 tick 处理函数根本没被调用。此时 tick 计数不增长,所有延时任务永远沉睡。
解决:在调试器里周期性查看 xTickCount 的值,如果不增长,说明 SysTick 路径有问题。回到第 4.3 节,把 SysTick 让给 FreeRTOS,或者将 SysTick_Handler 里的处理函数替换为内核的处理函数。修复之后,vTaskDelay 恢复,任务就开始按节奏跑了。
5.3 现象:系统跑几分钟后死机,或者某个任务突然消失,vTaskList 显示这个任务栈剩余为 0
原因:任务栈溢出。常见于任务里定义了较大的局部数组,或者使用了带浮点格式化的 printf。还有一个隐蔽点是中断嵌套时,中断服务函数也会消耗任务栈空间,如果中断嵌套层数深,栈溢出更容易发生。
解决:把 configCHECK_FOR_STACK_OVERFLOW 设为 2,并实现钩子函数:
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) { /* 在这里断点或翻转一个调试 GPIO */ for (;;) {} }钩子函数一旦被调用,说明栈真的溢出了。你可以在 vTaskList 输出里看到每个任务剩余的栈大小,然后单独给任务增加栈深度。另外排查任务里是否用了大局部数组或者递归调用,这类代码应该改为静态数组或者改用队列传递数据。
5.4 现象:外部中断里调用 xQueueSendFromISR,系统偶尔死在断言或 HardFault
原因:这个中断的优先级太高了,高到内核临界区无法屏蔽它。Cortex-M3 的 BASEPRI 只能屏蔽优先级数值大于等于阈值的中断,如果某个中断的优先级数值很小,它在任何临界区内都能触发,此时调用 FreeRTOS 的 FromISR 系列 API,就可能在临界区里操作链表或队列,数据结构被破坏。
解决:把该外设中断的抢占优先级设置到 11 到 15 之间,数值越大优先级越低。同时确认 NVIC 优先级分组是 Group4。中断服务函数里只做数据接收和标记,具体的处理逻辑放到任务里执行,这个习惯能避开很多中断相关的疑难杂症。
5.5 现象:两个任务都能跑,LED 也闪,但串口打印的周期不对,或者 HAL_Delay 直接卡死
原因:HAL_Delay 依赖 HAL 库的 uwTick,而 uwTick 靠 SysTick_Handler 里的 HAL_IncTick 维护。当你把 SysTick 完全让给 FreeRTOS 之后,uwTick 停止增长,HAL_Delay 就变成了一个永远不会结束的循环。
解决:要么把 HAL 时基源改到 TIM6/TIM7,要么彻底不用 HAL_Delay,改用 vTaskDelay。我的习惯是:在 FreeRTOS 工程里,所有任务内部的阻塞延时一律用 vTaskDelay 或 vTaskDelayUntil,裸机时代的 HAL_Delay 只在系统启动阶段、调度器尚未开始时使用。这样时基只有一个源头,不会出现两个延时系统互相干扰的问题。
6. 验证移植的唯一标准:两个任务以不同节奏跑起来
移植是否成功,标准其实很简单:两个任务以不同的延时各自跑起来,并且在长周期内不崩溃。我用这个冒烟测试验证每一块新的 F103 板子。
static void vTaskLED1(void *pvParameters) { for (;;) { HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5); vTaskDelay(500); } } static void vTaskLED2(void *pvParameters) { for (;;) { HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_0); vTaskDelay(200); } } int main(void) { HAL_Init(); SystemClock_Config(); /* GPIO 初始化略 */ xTaskCreate(vTaskLED1, "led1", 128, NULL, 1, NULL); xTaskCreate(vTaskLED2, "led2", 128, NULL, 2, NULL); vTaskStartScheduler(); for (;;) { } }如果两个 LED 分别以 500ms 和 200ms 周期闪烁,说明任务创建、调度器启动、SysTick、PendSV 切换这条主链路全部正常。下一步可以用 vTaskList 验证每个任务的运行状态和栈余量:
char ucInfo[512]; vTaskList((char *)ucInfo); HAL_UART_Transmit(&huart1, (uint8_t *)ucInfo, strlen(ucInfo), 1000);vTaskList 会输出每个任务的状态、优先级、栈剩余和任务名,State 列为 R 表示运行态,B 表示阻塞态,栈余量是排查溢出的第一手资料。这个函数很消耗资源,只应该在调试阶段用,生产代码里要全部关掉。
我现在的习惯是:移植完的板子先跑 24 小时这个双任务冒烟程序,期间用逻辑分析仪观察两个引脚的翻转波形是否稳定,偶尔人为触发几次外部中断看会不会卡死。能把这一步坚持下来的工程,后面加串口、加传感器、加通信协议,基本不会再回头改内核相关的代码。希望帮到你。
本文还有配套的精品资源,点击获取