简介:面向意法半导体STM32嵌入式开发者,一套FreeRTOS V9.00在STM32F103C8T6上的完整移植成功案例可供下载。作者专门分析了为何ZET6型号的工程直接迁移到C8T6后无法运行,并针对FreeRTOSConfig.h配置头文件做了逐项中文注释,把任务调度、系统时钟节拍、堆栈空间分配、中断优先级分组等核心参数讲清楚;同时将LED示例改为PC13引脚,烧录到C8T6核心板即可验证系统正常运行。压缩包共2000个文件,大小约72.16MB,其中C源文件与H头文件合计超过1800个,覆盖FreeRTOS内核源码、STM32标准外设库和用户应用代码,另有txt、PDF、HTML等格式的说明文档,便于查阅和二次开发。目前已有1154人学习下载,这套经实际验证、带注释、可直接运行的工程,对于刚接触FreeRTOS的开发者或需要将工程从ZET6移植到C8T6的工程师,能够显著降低环境配置和移植排错难度,是学习与项目起步的高性价比资料。无论是毕设原型、竞赛准备还是产品预研,都可以通过这份资料快速搭建一个可运行的FreeRTOS基础平台,再根据需求裁剪功能。 做嵌入式开发的朋友应该都听说过FreeRTOS,就算没实际用过,大概率也在项目需求或招聘信息里见过这个词。这些年物联网设备爆发之后,FreeRTOS几乎成了单片机RTOS的事实标准,而STM32F103C8T6这块芯片,又恰好是无数人入门STM32时摸过的第一颗料。把这两个东西放一起——在F103C8T6上移植FreeRTOS——就成了很多人的第一个RTOS实战项目。
这篇东西不是官方文档搬过来的说明,是我自己从裸机工程一路折腾到多任务稳定运行的全过程记录。包含源码怎么加、配置文件每一项怎么设、为什么设、跑起来之后怎么验证、以及那些让人头皮发麻的HardFault和任务卡死问题到底怎么查。适合正在学RTOS、刚拿到最小系统板想搞点东西、或者准备在项目里上FreeRTOS但对工程结构还不太熟的朋友。看完你至少能独立搭一个能用的FreeRTOS工程,并且知道出问题时从哪儿下手。
1. 为什么要在F103C8T6上移植FreeRTOS
1.1 这颗小芯片跑RTOS到底行不行
先说硬件底子。STM32F103C8T6,Cortex-M3内核,主频72MHz,Flash 64KB,RAM 20KB。很多人一听RTOS就觉得得H7、F4才跑得动,其实这是个误解。FreeRTOS内核做的非常精简,完整编译下来ROM占用通常只有6~12KB,RAM主要消耗是任务栈和内核对象,一个最小任务的栈给128字节能干不少事。在F103C8T6上跑三五个任务、做点串口处理、传感器采集、状态控制,资源完全够用。
我实测过,一个包含空闲任务、两个业务任务的工程,Flash占用大概14KB左右,RAM占用(含全局变量和任务栈)4~6KB,剩下的资源还能塞点逻辑代码。换句话说,这颗几块钱的芯片跑小型RTOS应用,一点都不勉强。而且F103C8T6是64脚封装、引脚兼容性极好,市面上最小系统板遍地都是,学习成本极低。用它做FreeRTOS移植练习,摔坏了也不心疼。
1.2 移植前要想清楚的事
动手之前建议先搞清楚一个问题:你到底是在“学RTOS”,还是在“用RTOS”。这两个目标对应的路径不一样。
如果纯粹为了学习,我强烈建议别用CubeMX一键生成,而是手动建立工程、手动添加源码、手写配置文件。这个过程虽然繁琐,但你能把FreeRTOS的源码结构、任务调度入口、中断处理机制、堆栈管理逻辑全部过一遍。等这一遍走通之后,再用CubeMX生成工程,你会发现在配置界面里选的每一个选项都能对应到具体代码上,那种感觉完全不一样。
如果是为了项目交付,那就别折腾,直接CubeMX勾选FreeRTOS生成工程,把注意力放在业务逻辑上。CubeMX生成的FreeRTOS工程经过了大量验证,默认配置比较稳妥,比自己手动拼的工程出问题概率低得多。我下面讲的内容两种方式都覆盖,但重点偏向手动移植,因为只有理解了原理,碰到问题才知道怎么排。
2. 移植前的准备:工具链与源码
2.1 需要准备的东西
我这次用的环境如下,供参考:
- 硬件:STM32F103C8T6最小系统板(蓝色那种),板载LED在PC13
- 调试器:ST-Link V2
- 编译环境:Keil MDK 5.37,AC5编译器
- 固件库:标准外设库V3.5(也可以选HAL库,后面细说)
- FreeRTOS版本:V10.4.6
版本选择上,FreeRTOS V10.x是目前的主流,V9和V10的API基本兼容。V10之后内核加入了流缓冲区和消息缓冲区功能,对实际开发很有用。别去用老掉牙的V8或更早版本,那些东西在Cortex-M3上的实现也成熟,但资料少、API旧,没必要给自己找麻烦。
2.2 FreeRTOS源码目录怎么看
下载下来的FreeRTOS源码包,很多人第一眼就懵,目录很多。其实真正需要的只有两个地方:
FreeRTOS/Source/:内核全部源码,包括tasks.c、queue.c、list.c、timers.c、event_groups.c、stream_buffer.c,以及一个include头文件目录FreeRTOS/Source/portable/:移植层代码,我们用的是RVDS/ARM_CM3这个目录下的port.c和portmacro.h
那portable/MemMang/呢?这个很关键,里面有heap_1.c、heap_2.c、heap_3.c、heap_4.c、heap_5.c五个内存管理方案,移植时必须选一个加进工程。我用的heap_4,支持内存合并和碎片整理,是实际项目里最常用的选择。heap_1最简单但只支持分配不支持释放,只适合极简场景,heap_2有历史遗留问题的坑,heap_4是最稳妥的默认选项。
还需要说明一点:ARM_CM3这个目录还有一个port.c和portmacro.h的IAR版本、GCC版本。Keil下编译必须选择RVDS/ARM_CM3,目录选错了会出现各种稀奇古怪的编译错误。这个坑我见过太多人踩了。
2.3 用标准库还是HAL库
这是个避不开的问题。我这次用的是标准库V3.5,因为移植FreeRTOS本质上和硬件库无关,FreeRTOS只依赖Cortex-M3内核的底层机制(SVC、PendSV、SysTick),也就是那些__asm和寄存器操作。标准库代码精简、直接操作寄存器,适合我这类喜欢完全掌控的老派做法。
如果你用HAL库,注意一个关键点:HAL库自带的HAL_Delay()依赖SysTick,而FreeRTOS接管SysTick之后,HAL_Delay()会跑飞或者时间完全不准。解决办法要么用vTaskDelay()替代,要么把HAL的时基改为TIM2之类的硬件定时器。CubeMX生成工程时选FreeRTOS组件,它会自动把HAL时基切到另一个定时器上,这就是为什么CubeMX生成的工程里会无缘无故多出一个TIM.
总的来说,学习阶段推荐标准库,因为更能看清FreeRTOS做了什么、没做什么。项目阶段用CubeMX生成HAL库工程,效率高、不容易漏配置。
3. 核心移植步骤:从工程模板到任务跑起来
3.1 搭建基础工程
第一步不是加FreeRTOS,而是先保证一个裸机工程能正常编译、下载、点灯。这一步的目的是把编译器和调试链路打通。我通常用标准库新建一个最小工程,包含启动文件、系统时钟初始化、GPIO配置。跑一下LED闪烁,确认最小系统板工作正常。
这一步别省。很多人一上来就把FreeRTOS代码丢进工程,出问题了分不清是硬件问题、工程配置问题还是RTOS本身的问题。先把地基打好,后面定位问题会容易十倍。
工程能跑之后,需要把Flash和RAM的分配检查一遍。F103C8T6虽然有64KB Flash和20KB RAM,但芯片型号后缀如果是C6,Flash实际可能只有32KB,不过我们讨论的C8是满64KB。在Keil的Options for Target里面,Flash起始0x08000000、大小0x10000,RAM起始0x20000000、大小0x5000,确认这两个值没填错。
3.2 往工程里添加FreeRTOS源码
这一步是把FreeRTOS的文件分组加进Keil工程里。建议在工程下建一个FreeRTOS分组,然后按如下清单添加:
- 内核源码:
tasks.c、queue.c、list.c、timers.c,用不到哪个可以不加,比如不用软件定时器就不加timers.c,但建议先全加上 - 移植层:
portable/RVDS/ARM_CM3/port.c - 内存管理:
portable/MemMang/heap_4.c - 头文件目录:
Source/include、Source/portable/RVDS/ARM_CM3
还有一个极其容易漏的地方——FreeRTOS.h这个总头文件会去Include FreeRTOSConfig.h,因此在C/C++编译选项里的Include Path必须把存放FreeRTOSConfig.h的目录加上。如果你把配置文件放在用户目录,这里就填用户目录路径。
检查一下FreeRTOS.h源码,里面有这么一段条件编译的逻辑,它需要能看到FreeRTOSConfig.h才行得到配置。任何#include "FreeRTOS.h"报错找不到头文件,基本都是Include Path没加全。
3.3 配置FreeRTOSConfig.h
FreeRTOSConfig.h是整个移植里最核心的文件。它不被包含在源码包里,需要自己写或者从官方示例工程里拷贝。F103C8T6的RAM只有20KB,所以内存相关的配置必须精打细算。
下面是我这次用的关键配置项:
configUSE_PREEMPTION设为1,使用抢占式调度configCPU_CLOCK_HZ设为72MHz,要和实际系统时钟一致,否则时间计算全错configTICK_RATE_HZ设为1000,也就是1ms一个系统节拍configTOTAL_HEAP_SIZE设为(10 * 1024),给内核堆分配10KB,留10KB给全局变量和任务栈configMINIMAL_STACK_SIZE设为128,单位是字(Word),不是字节configMAX_PRIORITIES设为5,够用了,别设太高浪费RAMconfigUSE_TIMERS设为1,使能软件定时器configUSE_IDLE_HOOK和configUSE_TICK_HOOK先设为0,调试阶段不需要
一个大坑是configTOTAL_HEAP_SIZE。很多人按网上帖子把堆大小设成15KB甚至20KB,结果一编译链接就直接报错,RAM溢出。F103C8T6的RAM就20KB,加上任务栈、全局变量、堆,必须统筹分配。我的建议是:先把堆设为8~10KB,跑起来之后再根据实际使用量调整,够用就好。
还有一个和调试强相关的配置:configASSERT。这玩意儿默认是不定义的,但我建议开发阶段务必定义。它会在参数错误、API误用、中断优先级配置不对时触发断言,帮你迅速定位问题。定义方法是在FreeRTOSConfig.h里写:
#define configASSERT(x) if((x) == 0) { taskDISABLE_INTERRUPTS(); for(;;); }这个宏的代价是每个API调用都会多一点检查开销,但开发阶段的调试价值远远超过这点性能损失。等产品稳定了,可以再把断言关掉。
3.4 关键:SVC、PendSV、SysTick中断优先级
这一步是Cortex-M3上移植FreeRTOS最容易出错的地方,也是很多人程序跑飞的根本原因。FreeRTOS在Cortex-M3上使用三个异常:SVC用于启动第一个任务,PendSV用于任务切换,SysTick用于系统节拍时钟。
这三个中断的优先级有硬性要求:必须设置为最低优先级,并且在NVIC里配置为可屏蔽。原因是FreeRTOS在临界区保护时用portSET_INTERRUPT_MASK()屏蔽中断,如果SysTick或PendSV的优先级不是最低,在临界区内触发这些中断时会被挂起,等退出临界区再执行,这会导致调度时序错乱。
标准做法是在启动代码里设置如下优先级分组,然后设置这三个异常优先级。如果使用CMSIS函数,可以这样:
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4);优先级分组设为Group_4表示全部4位都用于抢占优先级,子优先级为0。在这个分组下,SysTick和PendSV的优先级值要设为最高数值(数值越大优先级越低),通常是15。SVC的优先级可以设为最高,因为它只在启动时调用一次。这部分的代码放在main函数最开始,且必须在创建任务之前执行。
如果你用了CubeMX,这就是为什么CubeMX生成的代码里有两行优先级设置的影子,它分别对应HAL_NVIC_SetPriority(PendSV_IRQn, 15, 0)和HAL_NVIC_SetPriority(SysTick_IRQn, 15, 0)。手动移植时别忘了写。
3.5 创建第一个任务
配置文件搞定,源码添加完毕,接下来的逻辑就是创建任务、启动调度器。这里也有一个新手容易搞混的地方:xTaskCreate只是创建任务,并不会立即运行;要让任务跑起来,必须调用vTaskStartScheduler()启动调度器。调度器启动之后,main函数里vTaskStartScheduler()后面的代码永远不会执行到。
第一个任务建议写一个简单的LED闪烁任务。F103C8T6最小系统板上的LED接在PC13,引脚输出低电平时点亮。任务代码如下:
void led_task(void *arg) { for (;;) { GPIO_ResetBits(GPIOC, GPIO_Pin_13); vTaskDelay(pdMS_TO_TICKS(500)); GPIO_SetBits(GPIOC, GPIO_Pin_13); vTaskDelay(pdMS_TO_TICKS(500)); } }注意里面的pdMS_TO_TICKS(500),这个宏把毫秒转换成系统节拍数。因为configTICK_RATE_HZ是1000,1ms等于1个tick,所以500ms就是500个tick。如果你的tick频率改成了100,那500ms就是50个tick。别再把delay直接写死成500,除非你知道tick频率是多少。
在main里创建任务并启动调度器:
int main(void) { NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4); SystemInit(); led_init(); xTaskCreate(led_task, "led", 128, NULL, 1, NULL); vTaskStartScheduler(); for (;;); }注意任务栈大小128表示的是128个Word,也就是512字节。栈空间分配在FreeRTOS内部,不占普通全局变量空间(实际是从堆里割出来的),这也是为什么configTOTAL_HEAP_SIZE决定了你能创建多少个任务、每个任务能用多大栈。
4. 实操代码与运行验证
4.1 点灯不是目的,验证调度才是
第一个任务能闪灯之后,很多人就觉得移植成功了。说实话,这只是“Hello World”,还不能证明移植完全正确。我建议接着加两个任务,用不同频率闪烁两个不同的LED,或者用一串调试信息验证调度行为。
有次我在调试一个工程时,手头只有一块带两个LED的板子。LED1以500ms周期闪烁,LED2以1000ms周期闪烁。用示波器同时抓两个引脚,可以看到两个方波各自稳定输出。如果其中一个波形频率不对、或者两个波形出现互相干扰,说明调度器配置有问题,比如tick频率不对、任务优先级设置失误。
更严谨的验证方式是使用一个GPIO翻转信号观察空闲任务的运行情况。在vApplicationIdleHook里翻转一个引脚,然后观察这个引脚的波形。空闲任务只在其他任务阻塞或挂起时运行,所以这个引脚上的波形状态能直观反映任务调度是否健康。
4.2 串口打印辅助观察任务切换
我这次的测试工程里加了串口输出(USART1,波特率115200),在每个任务里打印进入和退出的信息。这样通过电脑上的串口调试工具就能看到任务切换的序列。需要注意串口打印函数要加互斥保护,否则两个任务同时打印时会出现字符交错。最简单的做法是用xSemaphoreCreateMutex()创建互斥锁,打印前后加锁解锁。这也是实际项目中必须有的意识:多任务访问共享资源必须加锁。
实测打印序列大致如下:
led task, count = 1 print task, count = 10 led task, count = 2 print task, count = 11如果打印序列乱了、任务卡住不动、或者输出乱码,就要用下面第5节的方法排查。
4.3 内存使用怎么观察
FreeRTOS提供了查看堆内存占用情况的API:xPortGetFreeHeapSize(),返回当前剩余的堆大小。在调试阶段,我会用串口定期打印这个值。如果剩余堆大小持续下降,说明有内存泄漏,通常是任务被反复创建、队列或信号量被反复申请后没有释放。产品发布前,这个值应该在网上线以后的数小时内保持稳定。
另外uxTaskGetStackHighWaterMark()可以获取某个任务栈的历史最低剩余空间。这个函数对栈大小的设置非常有指导意义。比如任务创建时给了128个Word的栈,运行一段时间后调用这个函数得到剩余空间只有20个Word,说明该任务栈紧张,应该调大。反过来如果剩余100个Word,说明栈给多了,可以精简。我实际开发中习惯每个任务栈都先给一个保守偏大的值,跑一段时间后用这个函数统计,再统一调优。
5. 常见问题与排错实录
5.1 程序跑飞进入HardFault
这是FreeRTOS移植最常见的现象,没有之一。程序一启动就进入HardFault或者跑一段时间进入HardFault,先别慌,有几种最可能的原因:
第一,中断优先级配置不对。如上文所说,PendSV和SysTick必须是可屏蔽的最低优先级。如果你把SysTick配成了高优先级,比如0或1,那么一旦在临界区内触发了SysTick,硬件会把异常挂起,等关闭中断的临界区代码执行完才响应,这个延迟会导致任务切换错乱,严重时直接HardFault。
第二,栈溢出。中断服务程序和任务函数之间的栈切换很复杂,最简单的排查办法是定义configCHECK_FOR_STACK_OVERFLOW为1或2,然后实现钩子函数:
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) { for(;;); }程序跑飞前如果能进入这个钩子函数,说明就是栈溢出了。
第三,中断服务函数里调用了FreaRTOS的API,但所调用API的FromISR版本不对。在中断里绝对不能直接调用xQueueSend之类不带FromISR后缀的函数,必须使用xQueueSendFromISR。这个错误编译时不会报错,但运行时会随机崩溃,极其难查。
5.2 任务不切换、只有高优先级任务在跑
如果任务1优先级为1,任务2优先级为2,且任务2的代码是一个死循环、没有延时和阻塞,那么任务1永远不会有运行机会。这不是移植Bug,而是抢占式调度的正常行为:高优先级任务准备好时,低优先级任务必须让路。解决办法是在每个任务的循环里都必须有阻塞操作,比如vTaskDelay、xQueueReceive、xSemaphoreTake,让出CPU给其他任务。
还有种情况是任务运行一会儿后全卡死,但硬件看门狗没有复位。这种往往是某个任务里不小心调用了一个死循环或者阻塞时间过长的函数。可以使用vTaskList()配合调试串口打印当前所有任务的状态,状态显示为B是阻塞,R是就绪,D是删除。如果发现所有任务都停在阻塞状态,那就是互相等待了,典型的死锁问题。
5.3 用了HAL_Delay导致系统卡死
这个问题在HAL库工程里非常经典。HAL库的HAL_Delay()依赖SysTick中断,而FreeRTOS启动调度器之后完全接管了SysTick并调整了它的中断回调。这时候任务里再调用HAL_Delay(),轻则时间不准,重则直接卡死,因为SysTick的中断优先级被改了,定时行为完全变了。
我踩过这个坑后的解决办法是:全工程搜索HAL_Delay,全部替换为osDelay()或vTaskDelay()。如果某个底层驱动库内部用了HAL_Delay(比如一些传感器驱动),那就得把底层库的延时也换成vTaskDelay,或者使用HAL_GetTick自己的时基。CubeMX生成的工程会额外把HAL时基绑定到TIM6等定时器上,目的就是让HAL库的延时不依赖SysTick。手动移植时没有这个环节,所以要自己注意。
5.4 FreeRTOSConfig.h里的断言突然生效
如果定义了configASSERT,程序突然停在一个死循环里,而且正好是configASSERT函数的for(;;),绝大多数是调用了非法参数。最常见的几个触发点:信号量、队列句柄传了一个NULL(比如队列还没创建好就被任务使用了),或者API在中断里用了非FromISR版本。这时候就可以利用调试器的调用栈,确认具体是哪个文件哪一行触发的断言,一般一眼就能定位。
还有一种可能:configASSERT真的很好用,以至于我在所有开发阶段都一直开着它,只在最后Release版本才关掉。关掉前全工程测试一遍,确认断言没有触发,才去掉。这是我对接产品时坚持的做法。
6. 移植经验总结与扩展建议
6.1 我个人实际操作中的体会
先说结论:FreeRTOS在F103C8T6上跑小型多任务应用是完全可以依赖的,关键是把优先级、堆大小和中断处理这三件事想清楚。这三件事不解决,换更大的芯片也一样会出问题。
移植过程中我最大的感悟是,不要一上来就追求“移植成功”这个结果,而要把每一部分都拆开弄明白。为什么中断优先级要那样设?为什么任务里必须要有阻塞点?为什么栈大小不是越大越好?这几个问题弄明白了,以后再上手任何带RTOS的开发板,甚至在上Linux驱动里做类似的概念对接,都会觉得顺畅很多。
6.2 后面还能怎么玩
这颗F103C8T6跑通FreeRTOS之后,扩展空间很大。环境监测项目里,可以挂DHT11温度湿度传感器和OLED显示屏——采集任务、显示任务、通信任务各干各的,典型的多任务结构。用FreeRTOS的软件定时器做周期性采集,配合队列把传感器数据从ISR传到任务,这是非常经典、也非常值得练手的学习路径。
我自己后来在这个基础上接了4G模块做MQTT上云,采集任务负责读传感器,通信任务负责联网发包,消息通过队列传递。跑起来之后最明显的感觉是:单线程裸机那种“一个模块卡住整机遭殃”的噩梦彻底结束了。所有任务都是独立的时间片逻辑,模块之间用队列解耦,出了问题只需要盯住出问题的那个任务就行。
6.3 最后再分享一个小技巧
调试RTOS任务调度时,强烈建议给每个任务设置不同的优先级和延时时间,并在每个任务入口打印一条带时间戳的日志。这样从一开始就能直观看到任务调度的全貌。我曾经花过整整一个晚上排查一个“任务不定期卡死”的问题,最后发现不过是某个任务的栈给小了,而当时的代码里没开栈溢出检测,问题只能在系统跑近一个小时后才偶现。把configCHECK_FOR_STACK_OVERFLOW打开、把每个任务的栈余量打印出来的那一刻,问题立刻现出原形。
所以,树棯永远不要觉得“能用就行”。把检测手段一层层加上去,系统才真正算是可靠的。
本文还有配套的精品资源,点击获取