☰
STM32F103C8T6从零移植FreeRTOS:标准库工程搭建与多任务实战
2026/9/28 17:32:30 网站建设 项目流程

1. 为什么这块十几块钱的小板子值得折腾RTOS

STM32F103C8T6最小系统板大概是国内嵌入式圈子里保有量最大的开发板之一,没有之一。淘宝上十几块钱就能买到一块,板载8MHz晶振、32.768kHz RTC晶振、复位电路、BOOT跳线、SWD调试口,核心参数是72MHz主频、64KB Flash、20KB SRAM、Cortex-M3内核。很多人拿它点个灯、跑个串口就吃灰了,但说实话,这块板子的性能跑一个轻量级RTOS绰绰有余。

问题在于,大部分教程要么直接甩一个现成工程让你编译下载,要么一上来就讲FreeRTOS内核源码,中间"怎么从零搭出一个能跑RTOS的工程"这一步是断层的。我自己带过几个新人,发现他们卡住的地方往往不是RTOS本身,而是标准库工程怎么建、FreeRTOS源码怎么加进Keil、中断向量表怎么处理、堆内存怎么分配这些"脏活"。

这篇内容就是把这个断层补上。从Keil MDK新建工程开始,到标准库配置、FreeRTOS源码移植、第一个多任务程序跑通,每一步的来龙去脉都讲清楚。适合有C语言基础、用过STM32但没接触过RTOS的朋友,也适合之前移植失败想搞清楚哪里出问题的人。整个过程不需要任何额外的下载器,一块ST-Link或者DAPLink就够了。

注意:本文基于STM32标准外设库(Standard Peripheral Library)和FreeRTOS V10.x版本,不涉及HAL库和CubeMX的自动生成流程。原因后面会讲。

2. 动手之前先把这几个概念理清楚

2.1 标准库和HAL库到底选哪个

现在ST主推的是HAL库加CubeMX的组合,图形化配置确实方便。但如果你要理解RTOS的移植过程,标准库反而是更好的选择。原因很简单:标准库的代码层次浅,启动文件、中断向量表、时钟配置这些东西都是明摆着的,你能看清楚每一层在干什么。HAL库封装太厚,CubeMX一键生成之后,很多人根本不知道FreeRTOS的SysTick是怎么被接管的。

另外从实际项目角度说,F103C8T6这种资源紧张的芯片,标准库编译出来的代码体积通常比HAL库小10%到20%,对于64KB Flash来说这个差距不算小。当然如果你已经习惯了CubeMX的工作流,用HAL库移植FreeRTOS也完全可行,只是本文的步骤需要相应调整。

2.2 FreeRTOS的源码结构长什么样

下载下来的FreeRTOS源码包(从官网或GitHub获取)里面东西很多,但真正需要放进工程的只有两部分:

  • FreeRTOS/Source/ 目录下的核心文件:tasks.c、queue.c、list.c、timers.c、event_groups.c、stream_buffer.c、croutine.c。其中croutine.c是协程功能,一般不用,可以不添加。
  • FreeRTOS/Source/portable/ 目录下的移植层:这里要分两个子目录选。编译器相关选RVDS/ARM_CM3(Keil用的是ARMCC编译器,RVDS目录就是给ARMCC用的);内存管理选MemMang目录下的heap_1.c到heap_5.c之一。

还有FreeRTOS/Source/include/目录下的所有头文件,以及FreeRTOSConfig.h这个配置文件——这个文件不在源码包里,需要自己从Demo目录里找一个改,或者手写。

2.3 移植的本质是什么

说穿了,FreeRTOS移植到Cortex-M3上,核心就干三件事:

第一,接管SysTick。FreeRTOS需要一个周期性的时钟节拍来驱动任务调度,它默认用SysTick,所以你要把标准库里的SysTick_Handler换成FreeRTOS的xPortSysTickHandler。

第二,接管SVC和PendSV两个异常。SVC用于启动第一个任务,PendSV用于任务上下文切换。这两个异常的优先级必须配置为最低,否则会影响其他中断的实时性。

第三,提供内存堆。FreeRTOS创建任务、队列、信号量都需要动态分配内存,它自带了一套简单的内存管理方案(heap_1到heap_5),你需要选一个并把对应的.c文件加进工程。

理解这三点,移植过程中遇到的大部分报错你都能自己定位。

3. 从空白工程到能点灯的裸机程序

3.1 Keil工程创建与文件组织

打开Keil MDK,Project -> New uVision Project,选一个空目录,器件选择STMicroelectronics -> STM32F103 -> STM32F103C8。弹出"Copy CMSIS Core Files"的对话框时选"是",让Keil自动把CMSIS内核文件复制过来。

工程建好之后,我习惯按下面的结构组织目录,后面加FreeRTOS的时候不会乱:

Project/ ├── CMSIS/ # 内核相关,Keil自动生成 ├── Startup/ # 启动文件 startup_stm32f10x_md.s ├── Library/ # 标准外设库 src 和 inc ├── User/ # main.c、stm32f10x_it.c、系统时钟配置 ├── FreeRTOS/ # 后面加 │ ├── src/ │ ├── inc/ │ └── portable/ └── Output/ # 编译输出

在Keil里右键Target -> Manage Project Items,建立对应的Group,把文件添加进去。标准外设库需要添加的文件至少包括:stm32f10x_rcc.c、stm32f10x_gpio.c、stm32f10x_usart.c(如果用串口调试)、misc.c。启动文件选startup_stm32f10x_md.s,因为C8T6属于中等容量产品(Medium Density),Flash 64KB。

提示:标准外设库可以从ST官网下载STM32F10x_StdPeriph_Lib_V3.5.0,这是最后一个版本,之后ST就全力推HAL了。网上有些"最新标准外设库"的说法其实指的就是这个V3.5.0。

3.2 时钟配置与宏定义

在Options for Target -> C/C++ -> Define里,需要添加两个宏:USE_STDPERIPH_DRIVER和STM32F10X_MD。前者告诉标准库启用外设驱动,后者告诉库当前芯片是中容量产品,决定了启动文件和寄存器映射。

时钟配置我一般直接写一个SystemInit的替代函数放在main.c里,用HSE 8MHz倍频到72MHz:

void RCC_Configuration(void) { ErrorStatus HSEStartUpStatus; RCC_DeInit(); RCC_HSEConfig(RCC_HSE_ON); HSEStartUpStatus = RCC_WaitForHSEStartUp(); if(HSEStartUpStatus == SUCCESS) { FLASH_PrefetchBufferCmd(FLASH_PrefetchBuffer_Enable); FLASH_SetLatency(FLASH_Latency_2); RCC_HCLKConfig(RCC_SYSCLK_Div1); // AHB = 72MHz RCC_PCLK2Config(RCC_HCLK_Div1); // APB2 = 72MHz RCC_PCLK1Config(RCC_HCLK_Div2); // APB1 = 36MHz RCC_PLLConfig(RCC_PLLSource_HSE_Div1, RCC_PLLMul_9); // 8*9=72 RCC_PLLCmd(ENABLE); while(RCC_GetFlagStatus(RCC_FLAG_PLLRDY) == RESET); RCC_SYSCLKConfig(RCC_SYSCLKSource_PLLCLK); while(RCC_GetSYSCLKSource() != 0x08); } }

这段代码里FLASH_Latency_2是必须的,72MHz下Flash需要2个等待周期,不设置的话程序跑起来会莫名其妙死机。这是很多人容易忽略的点。

3.3 验证裸机程序

在main.c里写一个最简单的GPIO翻转,PC13接LED(最小系统板上的LED通常是PC13,低电平点亮):

int main(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_Configuration(); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_13; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOC, &GPIO_InitStructure); while(1) { GPIO_SetBits(GPIOC, GPIO_Pin_13); for(volatile int i=0;i<500000;i++); GPIO_ResetBits(GPIOC, GPIO_Pin_13); for(volatile int i=0;i<500000;i++); } }

编译下载,LED闪烁就说明裸机环境没问题。这一步必须先跑通,不然后面RTOS出问题你分不清是裸机配置的锅还是移植的锅。

4. FreeRTOS源码加入工程的完整操作

4.1 文件复制与分组

把FreeRTOS源码包里的文件按下面的方式复制到工程的FreeRTOS目录:

源路径目标位置说明
Source/*.cFreeRTOS/src/核心源文件
Source/include/*.hFreeRTOS/inc/核心头文件
Source/portable/RVDS/ARM_CM3/port.cFreeRTOS/portable/移植层
Source/portable/RVDS/ARM_CM3/portmacro.hFreeRTOS/portable/移植层头文件
Source/portable/MemMang/heap_4.cFreeRTOS/portable/内存管理

heap_4.c是我推荐的选择,它支持内存释放和碎片合并,比heap_1(只能分配不能释放)和heap_2(不支持碎片合并)更实用。heap_5支持多块不连续内存区域,F103C8T6用不上。

在Keil里新建两个Group:FreeRTOS_Core和FreeRTOS_Port,分别把src下的.c文件和port.c、heap_4.c加进去。头文件路径在Options for Target -> C/C++ -> Include Paths里添加FreeRTOS/inc和FreeRTOS/portable。

4.2 FreeRTOSConfig.h的关键配置项

这个文件是整个移植过程中最需要动脑子的地方。它不在源码包里,需要自己创建。我一般从Demo目录(比如Demo/CORTEX_STM32F103_Keil)里拷一份过来改,比从零写省事。

几个必须关注的配置:

#define configUSE_PREEMPTION 1 // 抢占式调度 #define configUSE_IDLE_HOOK 0 // 空闲钩子,不用就关 #define configUSE_TICK_HOOK 0 // 节拍钩子 #define configCPU_CLOCK_HZ (72000000) // CPU频率 #define configTICK_RATE_HZ (1000) // 节拍频率1kHz #define configMAX_PRIORITIES (5) // 最大优先级数 #define configMINIMAL_STACK_SIZE (128) // 最小任务栈 #define configTOTAL_HEAP_SIZE ((size_t)(10*1024)) // 堆大小10KB #define configMAX_TASK_NAME_LEN (16) #define configUSE_16_BIT_TICKS 0 // 32位节拍计数 #define configUSE_MUTEXES 1 #define configUSE_COUNTING_SEMAPHORES 1 #define configUSE_TRACE_FACILITY 0 #define configCHECK_FOR_STACK_OVERFLOW 2 // 栈溢出检测 #define configUSE_MALLOC_FAILED_HOOK 1 // 内存分配失败钩子

configTOTAL_HEAP_SIZE设成10KB,因为F103C8T6总共只有20KB SRAM,还要留给主栈、全局变量。这个值不能太大,否则编译能过但运行起来会HardFault。configCHECK_FOR_STACK_OVERFLOW设成2是方法二检测,比方法一更可靠但稍慢,调试阶段建议开着。

还需要在FreeRTOSConfig.h里声明几个中断处理函数的映射:

#define vPortSVCHandler SVC_Handler #define xPortPendSVHandler PendSV_Handler #define xPortSysTickHandler SysTick_Handler

这三行是移植的核心。它的作用是告诉FreeRTOS,当SVC、PendSV、SysTick异常发生时,跳转到FreeRTOS提供的处理函数,而不是标准库stm32f10x_it.c里的默认处理函数。

4.3 中断向量表的冲突处理

标准库的stm32f10x_it.c里默认定义了SVC_Handler、PendSV_Handler、SysTick_Handler这三个函数,虽然里面是空的。如果你不做处理,编译时会报重复定义的错误。

解决办法有两个:一是把stm32f10x_it.c里这三个函数注释掉;二是用宏定义的方式重命名。我推荐第一种,直接注释掉最干净。注释之后,FreeRTOSConfig.h里的宏定义就会生效,启动文件里的向量表会指向FreeRTOS的实现。

注意:如果你用的是startup_stm32f10x_md.s,里面的向量表用的是弱定义(WEAK),所以只要你在C文件里定义了同名函数,就会覆盖弱定义。这就是为什么注释掉stm32f10x_it.c里的函数之后,FreeRTOS的port.c里的函数能生效。

5. 第一个多任务程序与验证方法

5.1 创建两个闪烁任务

在main.c里包含FreeRTOS.h和task.h,然后写两个任务函数:

#include "FreeRTOS.h" #include "task.h" void vTaskLED1(void *pvParameters) { while(1) { GPIO_ResetBits(GPIOC, GPIO_Pin_13); vTaskDelay(500 / portTICK_RATE_MS); GPIO_SetBits(GPIOC, GPIO_Pin_13); vTaskDelay(500 / portTICK_RATE_MS); } } void vTaskLED2(void *pvParameters) { while(1) { GPIO_ResetBits(GPIOA, GPIO_Pin_0); vTaskDelay(200 / portTICK_RATE_MS); GPIO_SetBits(GPIOA, GPIO_Pin_0); vTaskDelay(200 / portTICK_RATE_MS); } } int main(void) { RCC_Configuration(); // GPIO初始化省略 xTaskCreate(vTaskLED1, "LED1", 128, NULL, 2, NULL); xTaskCreate(vTaskLED2, "LED2", 128, NULL, 1, NULL); vTaskStartScheduler(); while(1); }

两个任务优先级不同,LED1优先级2,LED2优先级1。如果移植成功,你会看到两个LED以不同频率闪烁,而且互不干扰。这比单任务延时闪烁更能说明RTOS在正常工作。

5.2 用串口打印任务状态

光看LED闪烁不够直观,加一个串口输出任务状态更有说服力。配置USART1,波特率115200,然后在空闲任务钩子或者一个低优先级任务里调用vTaskList:

void vTaskMonitor(void *pvParameters) { char pcWriteBuffer[256]; while(1) { vTaskList(pcWriteBuffer); printf("Task Name\tState\tPrio\tStack\tNum\r\n"); printf("%s\r\n", pcWriteBuffer); vTaskDelay(2000 / portTICK_RATE_MS); } }

要使用vTaskList,需要在FreeRTOSConfig.h里把configUSE_TRACE_FACILITY和configUSE_STATS_FORMATTING_FUNCTIONS都设为1。串口打印出来你会看到类似这样的内容:

Task Name State Prio Stack Num LED1 R 2 100 3 LED2 B 1 100 4 Monitor R 1 120 2 IDLE R 0 80 1

State列里R是Ready,B是Blocked。看到这个输出,说明调度器、任务栈、延时机制全部正常。

5.3 常见编译错误与排查

移植过程中最容易遇到的几个报错,我列个表方便对照:

报错信息原因解决
Symbol SVC_Handler multiply definedstm32f10x_it.c和port.c都定义了注释掉it.c里的
undefined symbol xPortSysTickHandlerFreeRTOSConfig.h里没做宏映射添加三行宏定义
cannot open source input file "FreeRTOS.h"头文件路径没加Include Paths里加inc目录
region RAM overflowed堆设太大减小configTOTAL_HEAP_SIZE
编译通过但运行HardFault中断优先级配置错误检查PendSV和SysTick优先级

最后一条特别说一下。Cortex-M3的PendSV和SysTick优先级必须设为最低(数值最大),否则在中断里调用FreeRTOS的API会导致优先级反转问题。在port.c里FreeRTOS已经自动配置了,但如果你在别的地方改了NVIC优先级分组,可能会覆盖掉。建议在main函数开头调用NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4),全部用抢占优先级,不用子优先级。

6. 移植过程中那些文档不会告诉你的事

6.1 堆内存分配的坑

configTOTAL_HEAP_SIZE设成10KB,但实际能用的没这么多。FreeRTOS的heap_4会在堆的开头放一个链表头,每个分配的内存块也有头部信息,实际可用内存大概要打八折。我实测过,10KB的堆,创建两个128字(512字节)栈的任务加一个队列,剩余可用堆大概在7KB左右。

如果你在创建任务时返回errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY,不要急着加大堆,先算一下:每个任务的栈深度乘以4字节,加上TCB结构体(大概100字节),再加上队列和信号量的开销。F103C8T6的20KB SRAM,堆设10KB到12KB是比较安全的范围,再大就要小心主栈溢出。

6.2 栈溢出检测的实际效果

configCHECK_FOR_STACK_OVERFLOW设成2的时候,FreeRTOS会在任务切换时检查栈指针是否越界,以及栈末尾的标记字节是否被改写。一旦检测到溢出,会调用vApplicationStackOverflowHook。你需要自己实现这个钩子函数,否则检测到了也没反应:

void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) { printf("Stack overflow in task: %s\r\n", pcTaskName); while(1); }

我建议调试阶段把这个钩子写成死循环加打印,这样一旦溢出立刻能定位到是哪个任务。产品阶段可以改成软复位或者记录日志。

6.3 中断里调用API的注意事项

FreeRTOS的中断安全API都是以FromISR结尾的,比如xQueueSendFromISR、xSemaphoreGiveFromISR。在中断里调用普通版本的API,轻则数据错乱,重则直接HardFault。

还有一个细节:中断优先级。Cortex-M3里,优先级数值小于configMAX_SYSCALL_INTERRUPT_PRIORITY的中断,不能调用任何FreeRTOS的API,包括FromISR版本。这个宏在FreeRTOSConfig.h里定义,默认是5(对于4位优先级的情况)。也就是说,优先级0到4的中断是"不受FreeRTOS管理"的,响应速度最快,但不能调用API。优先级5到15的中断可以调用FromISR API。

这个机制叫"中断延迟处理",目的是保证高优先级中断的实时性不被RTOS的临界区影响。实际项目中,像电机控制、高速ADC采样这类对实时性要求极高的中断,就设成高优先级,里面只做最紧急的处理,然后把事件通过队列发给任务去慢慢处理。

6.4 标准库和FreeRTOS的SysTick冲突

标准库的SysTick_Handler默认是空的,但SysTick_Config函数会配置SysTick。如果你在别的地方调用了SysTick_Config,它会覆盖FreeRTOS的配置。所以移植FreeRTOS之后,不要再手动调用SysTick_Config,也不要调用delay_ms这类基于SysTick的延时函数。要延时就用vTaskDelay。

我见过有人移植完之后发现任务调度不正常,查了半天发现是某个驱动文件里调用了SysTick_Config。这种问题很隐蔽,因为编译不报错,运行时才出问题。

7. 从能跑到好用还差哪些配置

7.1 任务优先级的分配策略

FreeRTOS的优先级数值越大优先级越高,configMAX_PRIORITIES设成5意味着优先级0到4可用。优先级0是空闲任务,不要用。实际项目中我一般这样分:

  • 优先级4:紧急事件处理,比如故障保护
  • 优先级3:实时控制任务,比如PID计算
  • 优先级2:数据处理任务,比如协议解析
  • 优先级1:人机交互、日志输出
  • 优先级0:空闲任务

原则是:越靠近硬件的、实时性要求越高的,优先级越高。但不要把所有任务都设成高优先级,那样等于没有优先级。

7.2 队列和信号量的基本用法

任务之间通信最常用的就是队列。比如串口接收中断收到数据后,通过队列发给解析任务:

QueueHandle_t xUartQueue; // 中断里 void USART1_IRQHandler(void) { BaseType_t xHigherPriorityTaskWoken = pdFALSE; uint8_t data = USART_ReceiveData(USART1); xQueueSendFromISR(xUartQueue, &data, &xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); } // 任务里 void vUartTask(void *pvParameters) { uint8_t data; while(1) { if(xQueueReceive(xUartQueue, &data, portMAX_DELAY) == pdTRUE) { // 处理数据 } } }

portYIELD_FROM_ISR这行很关键。如果中断发送数据唤醒了更高优先级的任务,这个宏会触发一次上下文切换,让高优先级任务立即运行。不写的话,要等到下一个SysTick才会切换,响应会慢一个节拍。

7.3 调试技巧:用GPIO测量任务执行时间

想知道某个任务实际跑了多久,最土但最有效的办法是用GPIO。在任务开头拉高一个引脚,结尾拉低,用示波器看波形宽度:

void vMeasureTask(void *pvParameters) { while(1) { GPIO_SetBits(GPIOB, GPIO_Pin_0); // 被测代码 GPIO_ResetBits(GPIOB, GPIO_Pin_0); vTaskDelay(10 / portTICK_RATE_MS); } }

没有示波器的话,用逻辑分析仪也行,几十块钱的就能用。这个方法比用串口打印时间戳准确得多,因为串口本身有延迟。

7.4 后续可以扩展的方向

工程跑通之后,可以往上加的东西很多。比如把LVGL图形库移植上去,用SPI接一块ST7789屏幕,做一个带界面的小设备。LVGL需要配置心跳和时基,正好可以用FreeRTOS的xTaskGetTickCount来提供。再比如加入FreeModbus协议栈,把F103C8T6做成一个Modbus从站,通过RS485和上位机通信。

内存紧张是F103C8T6永远的痛。如果任务多了堆不够用,可以考虑把一些不常用的功能放到外部Flash,或者换用STM32F103CBT6(128KB Flash,20KB SRAM),引脚完全兼容,代码不用改。

我个人在实际操作中的体会是,移植FreeRTOS最难的不是技术本身,而是耐心。每一步都要验证,裸机点灯验证、SysTick验证、第一个任务验证、任务间通信验证,一步一步来。跳过任何一步,后面出问题都要花更多时间回头查。我见过太多人直接拿别人的工程改,结果连SysTick被谁接管了都不知道,出了问题完全无从下手。自己从零搭一遍,虽然慢,但后面用起来心里有底。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询