简介:一份基于STM32F103与FreeRTOS的模板工程,面向需要快速搭建多任务实时系统的嵌入式开发者,适合工业控制、物联网终端等场景。工程将FreeRTOS内核与STM32固件库整合,包含初始化代码、任务定义、调度机制、中断处理等基础模块,并给出两个任务分别控制PC6与PC7引脚电平翻转的完整示例,能直观展示任务创建、优先级调度以及队列通信的运行逻辑。压缩包共642个文件,涵盖C源码、H头文件、启动文件、链接脚本以及Keil工程配置等,整体仅8.45MB,目录结构清晰,便于对照学习和二次移植。目前已有450人浏览学习,适合有一定C语言和STM32基础、希望系统掌握FreeRTOS应用开发的读者。除了基础的LED控制演示外,模板还预留了数据队列和同步机制,开发者可在此基础上扩展传感器采集、网络通信或定时任务,进而加深对实时操作系统资源管理的理解。 很多做嵌入式开发的朋友第一次接触FreeRTOS,都是在某个项目被裸机逻辑折磨到不行之后。模块一多,中断里累加标志位、主循环轮询分派,看似可行,一旦加上按键扫描、屏幕刷新、通信协议解析、传感器轮询,一个细节没照顾到,时序就乱了。我自己刚用STM32F103跑FreeRTOS时,最想要的其实不是一份能编译通过的官方Demo,而是一个干净、精简、看得懂每行配置的工程模板——拿来就能改,改完就能跑,跑起来还能稳定。这篇就围绕STM32F103和FreeRTOS,把我自己整理和维护这套模板程序时的思路、取舍、踩坑细节整个过一遍,给打算从裸机切到RTOS的朋友做一个可以直接参考的底子。
1. 先说清楚:这个模板解决的三个真实痛点
1.1 痛点一:官方Demo太复杂,反而不适合起步
如果你下载过FreeRTOS官方在GitHub上的STM32工程,或者用CubeMX自动生成过一份完整配置,大概率会有同感:代码结构完整到让人不知道从哪里下手。官方Demo要考虑协议栈、移植层、不同编译器、Trace工具、可裁剪模块,因此默认打开了一堆功能;CubeMX生成的工程又引入了HAL层和一堆中间件依赖。对只是想“先跑起两个任务、加一个队列”的人来说,这些配置全是噪音。
我自己早期最痛苦的一次经历,是想在标准外设库v3.5的老工程里塞进FreeRTOS,结果发现官方下载的Demo里用的还是老的编译器和IDE工程结构,光是把heap_4.c、port.c、tasks.c这些文件理清楚,就花了一个晚上。后来我干脆做了一个极简模板:只保留STM32F103标准外设库启动文件、FreeRTOS源码里必须的tasks.c、queue.c、list.c、port.c、heap_4.c,外加一个我改过的FreeRTOSConfig.h。整个工程干净到每个文件是干什么的,一眼就能看明白。
1.2 痛点二:时基和中断优先级藏在细节里,出了问题很难查
FreeRTOS在Cortex-M3上跑,有一个很多人第一次移植时会忽略的点:SysTick和PendSV的优先级设置。STM32F103使用标准库时,如果你在SystemInit()之后自己初始化了外设中断,又在xPortStartScheduler()之前没有正确设置PendSV和SysTick的优先级,系统轻则偶尔死机,重则直接进HardFault。更隐蔽的是,如果某个外设中断的优先级比SysTick还高,FreeRTOS的时基就会被卡住,表现出来就是任务不再切换、看门狗超时复位。
这个模板把优先级分组固定为NVIC_PriorityGroup_4(也就是4位全用于抢占优先级),并且把所有受FreeRTOS管理的中断优先级限制在5及以下,PendSV和SysTick设为最低优先级。这样做的好处是,你在自己的驱动代码里可以放心用0~4这些更高的优先级做紧急处理,不会因为中断嵌套层级太深把调度器搞出问题。
1.3 痛点三:模板必须是“半成品”,而不是“成品”
太多人拿到的模板已经帮你写好了业务逻辑,比如点灯、串口打印、按键扫描,结果你往里面加自己的代码时,反而觉得束手束脚。我的思路恰恰相反:模板只提供三样东西——稳定跑起来的内核、完善的错误处理钩子、几个常用的IPC组件实例。业务代码全部留空,但每个组件都放在独立文件里,你拿到手后直接往文件里填内容,而不是去改模板的框架。
所以这篇文章里我讲的关键路径就三条:第一步,文件结构和移植要做到什么程度;第二步,FreeRTOSConfig.h里每个关键宏该怎么选;第三步,任务、队列、信号量的标准写法加上堆栈和优先级反转的排查方法。
2. 移植不是照抄工程:时钟、时基和中断优先级这三座山
2.1 文件清单:标准库v3.5下最精简的FreeRTOS包
我做模板用的是标准外设库v3.5,因为很多存量STM32F103项目还停留在这一代代码上。FreeRTOS内核我选用10.4.x版本,这个版本稳定、资料多、网上讨论充分,对Cortex-M3支持非常成熟。
文件层面,模板的FreeRTOS目录结构是这样的:
FreeRTOS/ ├── include/ # 内核头文件 │ ├── FreeRTOS.h │ ├── task.h │ ├── queue.h │ ├── semphr.h │ ├── timers.h │ └── ... # 其他需要包含的头文件 ├── portable/ │ ├── GCC/ARM_CM3/ # 用GCC工具链时对应的port.c和portmacro.h │ └── RVDS/ARM_CM3/ # 用Keil/ARMCC时对应的port.c和portmacro.h ├── src/ │ ├── tasks.c │ ├── queue.c │ ├── list.c │ ├── timers.c │ ├── event_groups.c │ └── croutine.c └── heap/ └── heap_4.c使用Keil MDK工程时,port.c选择RVDS/ARM_CM3目录下的版本;换成GCC交叉编译链(比如VSCode+arm-none-eabi-gcc那套环境),就把port.c换成GCC目录下的,并调整编译器的启动文件。很多朋友在VSCode里移植FreeRTOS失败,最后查出来都是port.c选错了工具链版本,这类问题特别浪费生命。
2.2 启动文件与时钟配置:先保证裸机环境绝对可靠
在跑FreeRTOS之前,我会先用一个空工程把以下三件事测通:
- 系统主频配置为72MHz,外部8MHz晶振经过PLL倍频到72MHz;
- SysTick在一个无限循环里做精准延时,验证时钟源正确;
- 串口1能输出字符,方便后面打印调试信息。
这三步看似基础,但很多人移植RTOS后出现任务切换时间不对、delay失效、串口乱码,回头看全是时钟没配好。FreeRTOS在vTaskDelay()里依赖SysTick中断进行时间片计数,如果SysTick的时钟源没选择HCLK/8,或者主频不是72MHz,所有时间相关的API都会失真。
我习惯在main()里先调用SystemInit(),再调用自定义的Clock_Config_72MHz(),确保进入xPortStartScheduler()之前,SysTick已经被FreeRTOS接管而不是被用户代码占用。如果你在裸机阶段用了delay_ms()且是基于SysTick实现的,移植FreeRTOS前必须把SysTick的处理权交给内核,否则调度器启动后会跟你的delay函数打架。
2.3 中断优先级:Cortex-M3上的三条铁律
STM32F103的NVIC只实现了4位优先级,但FreeRTOS要求你在FreeRTOSConfig.h里定义configPRIO_BITS。标准库下这个值要写死为4,跟芯片硬件一致。这里有三条我踩过坑之后总结出的铁律:
- 铁律一:
configKERNEL_INTERRUPT_PRIORITY要设置为最低优先级。对STM32F103来说就是15 << 4,也就是写成configKERNEL_INTERRUPT_PRIORITY时对应( 15 << 4 )。如果写错,系统调度会异常频繁,甚至进HardFault。 - 铁律二:
configMAX_SYSCALL_INTERRUPT_PRIORITY决定了哪些中断能调用FreeRTOS的API。把它设为( 5 << 4 ),意味着优先级数值大于等于5的中断可以调xQueueSendFromISR()这类带FromISR后缀的函数,而优先级在0~4之间的高优先级中断则不建议调用任何FreeRTOS API。 - 铁律三:整个工程的NVIC优先级分组要统一,不能一部分代码用分组2,一部分用分组4。模板固定使用
NVIC_PriorityGroup_4,在全工程初始化时只设置一次。
有朋友问为什么要这么麻烦,直接全用0优先级不行吗?不行。如果中断优先级都设成0,那么任何中断都能嵌套其他中断,FreeRTOS内部临界区的portENTER_CRITICAL()会失效,整个内核的数据结构随时可能被破坏。稳定的做法一定是:用户中断按紧急程度分成0~4和5~15两层,前者只管最紧急的事情且不碰内核API,后者才允许调用带FromISR后缀的FreeRTOS功能。
3. FreeRTOSConfig.h:模板里真正需要逐行读的文件
很多模板工程拿到手后,FreeRTOSConfig.h被当成“不用改的配置文件”,其实这是整个RTOS工程里最值得逐行理解的文件。我把模板中用到的关键宏整理成一张表,并附上我这个工程里的取值和理由。
| 宏定义 | 模板取值 | 说明与理由 |
|---|---|---|
| configUSE_PREEMPTION | 1 | 使用抢占式调度,优先级高的任务就绪后立即抢占CPU,适合多数实时控制场景 |
| configUSE_TIME_SLICING | 1 | 同等优先级任务按时间片轮询,避免某个同优先级任务饿死 |
| configCPU_CLOCK_HZ | 72000000 | 必须跟实际主频一致,72MHz对应STM32F103最大主频 |
| configTICK_RATE_HZ | 1000 | 时基1ms,串口、按键扫描等常见场景够用;如果对功耗要求极高可以降到100 |
| configMAX_PRIORITIES | 7 | 优先级数量尽量小,每个任务控制块会占用RAM,够用就行 |
| configTOTAL_HEAP_SIZE | 8192 | 8KB堆,模板任务较少时绰绰有余;后期加任务再调大 |
| configMINIMAL_STACK_SIZE | 128 | 最小任务栈以字为单位,128字等于512字节,空闲任务用这个值 |
| configUSE_TIMERS | 1 | 启用软件定时器,便于实现周期任务而不用开额外硬件定时器 |
| configUSE_MUTEXES | 1 | 启用互斥量,配合优先级继承机制解决经典优先级反转问题 |
| configUSE_RECURSIVE_MUTEXES | 1 | 启用递归互斥量,某些驱动里需要在同一个任务内重复加锁 |
| configUSE_COUNTING_SEMAPHORES | 1 | 计数信号量,常用于资源计数或事件累计 |
| configCHECK_FOR_STACK_OVERFLOW | 2 | 开启栈溢出检测,检测到溢出时调用钩子函数,方便开发期定位问题 |
| configUSE_IDLE_HOOK | 1 | 空闲任务钩子,可用于低功耗处理或统计CPU使用率 |
| configUSE_TICK_HOOK | 0 | 时基钩子一般不用,避免在中断上下文做复杂操作 |
3.1 为什么heap大小是8KB而不是64KB
STM32F103的RAM大小根据型号不同,从20KB到64KB都有。如果用的是C8T6(20KB RAM),你必须在内存预算上精打细算。模板把堆设为8KB,是因为考虑最通用的C8T6也能流畅运行三到四个普通任务加一个软件定时器。一个普通任务栈通常分配128~256字,也就是512~1024字节,三个任务也就3KB左右,队列入参、信号量这些内核对象再加一点,8KB足够。
用heap_4.c有个好处,它能合并相邻空闲内存块,并且不会像heap_2那样产生严重碎片。我见过有人为了省RAM特别执着地换heap_1或heap_2,但除非你内存确实寸土寸金,否则没有必要放弃heap_4的易用性和稳定性。模板默认就用heap_4,这是FreeRTOS官方近年最推荐的通用堆实现。
3.2 栈溢出检测:开发期一定要开着,发布期再关掉
configCHECK_FOR_STACK_OVERFLOW设为2,意味着内核会在任务切换时检查每个任务栈顶的“哨兵值”是否被破坏。如果被破坏,就调用vApplicationStackOverflowHook()。
我在模板里写了这样一个钩子函数:
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) { /* 开发期:点亮一个错误LED,同时停在这里方便仿真器定位 */ GPIO_SetBits(GPIOC, GPIO_Pin_13); while (1) { /* 停在这里,查看xTask和pcTaskName即可知道哪个任务溢出 */ } }实际排查时,你只要在调试器里暂停,查看pcTaskName指向的字符串,就能直接锁定是哪个任务栈不够。发布产品时可以根据需求把configCHECK_FOR_STACK_OVERFLOW改回0,省去每切换一次任务都要做检测的开销。这是最实用的开发期手段,没有之一。
4. 模板的骨架:任务、队列、信号量与软件定时器的标准写法
4.1 主函数与任务创建:先跑起来再谈架构
模板的main()函数结构非常固定,我直接贴出来:
int main(void) { /* 板级初始化:时钟、NVIC分组、LED、串口 */ Board_Init(); /* 创建任务 */ xTaskCreate(App_Task_LED, "LED", 128, NULL, 3, NULL); xTaskCreate(App_Task_UART, "UART", 256, NULL, 2, NULL); xTaskCreate(App_Task_Key, "Key", 128, NULL, 1, NULL); /* 启动调度器,不会返回 */ vTaskStartScheduler(); /* 如果到了这里,说明堆内存不足或硬件配置错误 */ while (1) { } }任务创建时,优先级从高到低分别给LED任务3、UART任务2、按键任务1。这样做的用意是:LED任务的实时性要求最高,闪烁频率必须稳定;UART任务负责缓冲区和协议解析,不能被打乱;按键任务偏交互,实时性要求最低。
每个任务都是一个无限循环:
void App_Task_LED(void *argument) { for (;;) { GPIO_ToggleBits(GPIOC, GPIO_Pin_13); vTaskDelay(pdMS_TO_TICKS(500)); } }这里必须用vTaskDelay()而不是裸机时代的delay_ms(),因为vTaskDelay()会让出CPU给其他任务,而忙等延时直接占用整个内核。这是从裸机思维转到RTOS思维的第一课。
4.2 队列:任务间传数据的标准姿势
队列是FreeRTOS最常用的数据交互手段。我在模板里放了一个串口接收任务的实现,当串口收到一帧数据后,通过中断把字节放入队列,解析任务从队列取出并处理。这样的好处是解析逻辑不阻塞串口中断,数据也不会丢。
先定义队列句柄:
QueueHandle_t xUartQueue; void App_Task_UART_Init(void) { xUartQueue = xQueueCreate(64, sizeof(uint8_t)); }中断里发送数据:
void USART1_IRQHandler(void) { BaseType_t xHigherPriorityTaskWoken = pdFALSE; uint8_t data; if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { data = (uint8_t)(USART1->DR & 0xFF); xQueueSendFromISR(xUartQueue, &data, &xHigherPriorityTaskWoken); USART_ClearITPendingBit(USART1, USART_IT_RXNE); } portYIELD_FROM_ISR(xHigherPriorityTaskWoken); }解析任务里接收:
void App_Task_UART(void *argument) { uint8_t data; for (;;) { if (xQueueReceive(xUartQueue, &data, portMAX_DELAY) == pdTRUE) { /* 处理这一字节,拼帧、校验、解析 */ } } }这段代码值得注意的点是portYIELD_FROM_ISR()。如果中断发现某个等待队列的任务优先级比当前任务高,它会触发一次上下文切换,让高优先级任务立刻执行。这句话被很多人漏掉,结果就是串口数据明明进队列了,解析任务却要等到下一个时基才动,实时性打了折扣。
4.3 互斥量和优先级反转:模板里直接规避问题
优先级反转是个经典的RTOS问题:低优先级任务持有互斥量,高优先级任务等待该互斥量,此时中优先级任务抢占低优先级任务,让高优先级任务干等。FreeRTOS的互斥量自带优先级继承机制,能在低优先级任务持有互斥量时临时把它的优先级提升到等待者的优先级,等释放后再降回来。
模板里我直接开启configUSE_MUTEXES和configUSE_RECURSIVE_MUTEXES,并给了一个示范:
SemaphoreHandle_t xMutex; void Some_Device_Read(void) { xSemaphoreTake(xMutex, portMAX_DELAY); /* 临界资源访问 */ xSemaphoreGive(xMutex); }如果你在面试题里看到“FreeRTOS如何解决优先级反转”,答案核心就是这个优先级继承机制,不是完全取消优先级,而是临时提升。同时要注意,在中断里绝对不要使用互斥量,只能用二值信号量或队列,因为互斥量的优先级继承机制在中断上下文里没有意义且有风险。
4.4 软件定时器:用回调替代一个空转任务
很多场景其实不需要独立任务,比如LED慢闪、传感器周期采样、超时判断。用xTimerCreate()创建软件定时器,能省掉一个任务栈的空间。模板示范了如何创建一个周期1秒的定时器:
TimerHandle_t xTimer1; void Timer1_Callback(TimerHandle_t xTimer) { /* 周期执行的逻辑 */ } void App_Timer_Init(void) { xTimer1 = xTimerCreate("Timer1", pdMS_TO_TICKS(1000), pdTRUE, NULL, Timer1_Callback); if (xTimer1 != NULL) { xTimerStart(xTimer1, 0); } }记住一点:软件定时器的回调是在TimerTask上下文中执行的,不是真的硬件中断,因此回调里不能调用会长时间阻塞的API,更不能使用vTaskDelay()。它的本质还是一个任务,只是FreeRTOS帮你管理了调度。
5. 跑起来不代表稳:堆栈溢出与优先级反转的完整排查
5.1 一次HardFault排查:从症状到根因的完整链路
我自己在调试模板时遇到过这样一个问题:程序启动后前几秒正常,串口打印几次后突然死机,接上JLINK发现停在了HardFault_Handler。
排查链路是这样的。第一步,查看Call Stack,发现是在vListInsert里崩的,这是FreeRTOS内部把任务控制块插入就绪列表的操作。第二步,查看崩溃时的R13栈指针,发现已经跑到RAM的末尾附近,怀疑是某个任务的栈溢出,覆盖了系统堆或其他任务的数据。第三步,打开configCHECK_FOR_STACK_OVERFLOW为2后,程序在溢出钩子里停住,pcTaskName指向的正是UART任务的名称。第四步,把UART任务的栈从256字加到384字后,问题消失。
这个排查过程看起来很简单,但如果你没有开启栈溢出检测,可能要在HardFault里反复看寄存器看半天才能猜出原因。模板里默认打开栈溢出检测的价值,就是帮你在开发期省掉这种低级排查。
5.2 堆栈分配常见的认知偏差:128字到底能放多少局部变量
很多人对任务栈大小没有概念,以为随便128字就够用。实际上128字等于512字节,一个普普通通的函数如果嵌了几层调用,每层再放一些局部变量、结构体,很有可能就爆了。更隐蔽的是,printf族函数(包括printf、sprintf,如果重定向到串口)会消耗非常可观的栈空间,特别是在ARMCC编译器下,一个未优化且带浮点格式化的sprintf可能吃掉几百字节。
模板里我建议的起点是:
| 任务类型 | 建议栈大小(字) | 说明 |
|---|---|---|
| 只做延时和IO翻转的任务 | 128 | 栈开销极小 |
| 涉及串口打印的任务 | 256 | 给printf留余量 |
| 涉及协议解析的任务 | 256~384 | 看帧结构和临时缓冲区大小 |
| 涉及浮点运算的任务 | 384或更多 | 避免printf浮点格式化导致栈爆 |
如果你发现某个任务反复出现栈溢出但又不确定需要多大,可以把栈值先给到512字,跑一段时间稳定后,再通过uxTaskGetStackHighWaterMark()查看任务历史最低剩余栈空间,根据实际用量调整到合理值。
5.3 优先级反转的复现与破解:互斥量的优先级继承怎么验证
很多人对优先级反转的理解停留在概念层面,没有真正复现过。你可以在模板里做一个小实验:任务A优先级3,任务B优先级2,任务C优先级1。任务A和任务C共享一个互斥量,任务B是一个忙等的CPU密集任务。没有优先级继承时,任务C持有互斥量期间被任务B抢占,任务A只能眼巴巴等着;开启优先级继承后,任务C持有互斥量的瞬间临时升到优先级3,任务B就无法抢占,等任务C释放互斥量后优先级恢复,任务A拿到资源执行。
模板里开启互斥量功能的意义就在于此,你不需要手动实现任何额外代码,FreeRTOS内核已经在xSemaphoreTake()里自动处理了优先级继承。但在你自己的代码里,要尽量避免长时间持有互斥量,否则即使有继承机制,整个系统的实时性依然会被拖累。
5.4 用串口打印辅助调试时的时间陷阱
One more thing I want to point out: 串口打印本身也是会阻塞的。如果你在一个任务里用printf输出大量日志,任务会被串口波特率卡住,特别是在115200波特率下,一个字符大约耗时86.8微秒,100个字符就要8.68毫秒。这个时间看起来不长,但在RTOS里足够让其他任务错过周期。
我自己的做法是:模板里单独开一个日志任务,日志通过队列发送到该任务,由它统一执行串口输出。这样业务任务只做入队,出队和打印交给低优先级任务,不会因为日志输出拖垮实时逻辑。如果你用的串口支持DMA,更好,直接把日志任务和DMA配合起来,CPU几乎零负担。
6. 模板能用但不够用?继续生长的几个方向
6.1 从标准库到HAL/CubeMX:模板的代码怎么迁移
不少初学者问的是:“韦东山、安富莱这些教程有的用标准库,有的用HAL库,到底学哪个?”我的观点是,如果工程项目已经基于标准库跑了很久,硬迁HAL没必要;如果是新项目且工具链已经是CubeMX+GCC,那直接用CubeMX生成的FreeRTOS工程再裁剪,也是可以的。
但无论你选哪条路,前面讲的几个核心概念完全通用:时基、中断优先级分组、栈溢出检测、队列与互斥量的使用。标准库模板的价值在于,它把所有Open成分摊开放在你面前,没有CubeMX帮你自动隐藏细节。因此我建议至少用标准库模板完整跑通一遍,理解了每个文件的作用后,再决定要不要用CubeMX加速生产。
6.2 模板基础上加低功耗:空闲钩子与Tickless模式
STM32F103追求低功耗时,FreeRTOS的Tickless模式值得研究。模板里我预留了configUSE_IDLE_HOOK,空闲任务钩子可以在没有任何任务运行时进入STOP模式,然后在SysTick或外部事件唤醒。
不过要注意,如果系统多个任务只是用vTaskDelay()等待,它们会周期性地醒来,低功耗效果并不会太好。想要真正做低功耗,需要调整业务任务的周期策略,比如把周期从20ms拉长到500ms,或者用外部中断唤醒代替轮询。Tickless模式下还要处理时基补偿的问题,否则vTaskDelay的时间会漂移。
6.3 模板演进成自己的“设备框架”
最后分享一个我一直在做的整理方式:每在一个项目里用过一次模板,就把复用率高的代码抽出来回填到模板里。比如我后来在模板里加入了标准的环形缓冲区、命令行解析器、简易按键状态机,这样新项目开工时,不需要再移植这些东西。
我甚至回头给模板加了map文件分析脚本,编译后自动检查每个任务的栈是否合理。方法是解析*.map文件里的符号地址,看任务栈数组的地址是否与其他变量存在重叠风险。这种思路你在做大型项目时一定会用上,因为肉眼审查栈大小在几百个任务的工程里完全不现实。
这个模板从最早的裸机工程改造而来,前后迭代过三四个项目版本,最核心的变化其实不是代码,而是思维方式的转变:写任何一个模块之前,先想清楚它跑在哪个任务里,它的数据跟谁交互,它的栈需要多大,它在实时性上有什么要求。带着这些问题去用FreeRTOS,你会发现模板本身反而变成一个不断生长的工具,而不是一份固定的代码拷贝。
本文还有配套的精品资源,点击获取