ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32F103R8T6 FreeRTOS移植实战:从裸机到多任务避坑指南

STM32F103R8T6 FreeRTOS移植实战:从裸机到多任务避坑指南

简介:这份资源面向嵌入式开发初学者与进阶工程师,提供将FreeRTOS实时操作系统移植到STM32F103R8T6(ARM Cortex-M3内核)的完整工程资料,帮助读者理解轻量级RTOS在资源受限微控制器上的落地方法。压缩包共226个文件,约5.87MB,以c与h源码为主,辅以lst、o、d、crf等编译中间文件,以及uvprojx、uvoptx工程配置、sct分散加载、hex与axf可执行产物和readme说明,覆盖从源码到烧录的完整链路。目录按HARDWARE、CORE、SYSTEM、FreeRTOS、USER、STM32F10x_FWLib等模块组织,分别承载硬件驱动、内核启动与中断、系统时钟与内存管理、任务调度与信号量队列、用户业务逻辑及标准外设库,结构清晰便于按模块研读。目前已有925人学习下载,适合希望掌握Cortex-M3架构、FreeRTOS内核原理与嵌入式移植流程的开发者参考借鉴。

1. 从裸机到多任务:为什么这个 STM32F103R8T6 移植包值得拆一遍

很多刚接触 STM32 的开发者都有过这样的经历:裸机跑得好好的工程,一旦要同时处理按键、串口、屏幕刷新和传感器采集,主循环里的delay就开始互相打架,改到最后自己都理不清哪个任务该先跑。这份 STM32F103R8T6_freertos移植.zip 解决的正是这个痛点——它把 FreeRTOS 实时内核完整地搬到了 STM32F103R8T6 这颗经典芯片上,让你从「一个大循环硬扛」切换到「多个任务各管一摊」的开发模式。STM32F103R8T6 属于 F1 系列中等容量型号,64KB Flash、20KB SRAM,资源不算宽裕,恰恰是练手 RTOS 的好靶子。这份资源适合两类人:一是想从裸机过渡到 RTOS 的初学者,二是需要在 F103 平台上快速搭多任务框架的工程师。拿到手就能编译、下载、看任务跑起来,不用自己从零配时钟树和中断优先级。

2. 工程结构拆解:从启动文件到任务创建的完整链路

2.1 目录布局与关键文件定位

拿到这个移植包,第一件事不是急着打开 main.c,而是先把目录结构摸清楚。一个标准的 FreeRTOS 移植工程通常长这样:

STM32F103R8T6_FreeRTOS/ ├── CMSIS/ # 内核外设访问层 │ ├── core_cm3.h │ └── stm32f10x.h ├── FreeRTOS/ │ ├── Source/ │ │ ├── tasks.c # 任务调度核心 │ │ ├── queue.c # 队列与信号量 │ │ ├── list.c # 内核链表 │ │ ├── timers.c # 软件定时器 │ │ └── portable/ │ │ └── RVDS/ARM_CM3/ # Cortex-M3 移植层 │ │ ├── port.c │ │ └── portmacro.h │ └── Include/ │ └── FreeRTOSConfig.h # 裁剪配置文件 ├── User/ │ ├── main.c │ ├── stm32f10x_it.c # 中断服务函数 │ └── system_stm32f10x.c ├── Drivers/ │ ├── stm32f10x_gpio.c │ ├── stm32f10x_rcc.c │ └── stm32f10x_usart.c └── Start/ ├── startup_stm32f10x_md.s └── stm32f10x_flash.ld

这里有几个文件是移植成败的关键。port.c负责上下文切换的底层汇编,FreeRTOSConfig.h决定内核裁剪到什么程度,stm32f10x_it.c里的SysTick_Handler和PendSV_Handler必须交给 FreeRTOS 接管。很多人移植失败就栽在这三个地方——要么中断向量没重定向,要么堆栈大小给少了,要么 config 里把不该关的功能关了。

2.2 FreeRTOSConfig.h 的核心参数怎么配

这个头文件是整个移植的「控制面板」,配错了轻则任务跑不起来,重则一进调度就 HardFault。以下是一份针对 STM32F103R8T6(20KB SRAM)的典型配置:

/* FreeRTOSConfig.h - 针对 STM32F103R8T6 裁剪 */ #define configUSE_PREEMPTION 1 // 抢占式调度,高优先级任务可打断低优先级 #define configUSE_IDLE_HOOK 0 // 不用空闲钩子,省空间 #define configUSE_TICK_HOOK 0 #define configCPU_CLOCK_HZ (72000000) // 系统主频 72MHz #define configTICK_RATE_HZ (1000) // 1ms 一个 tick #define configMAX_PRIORITIES (5) // 只用 5 级优先级,够用且省 RAM #define configMINIMAL_STACK_SIZE (64) // 空闲任务栈,单位是字不是字节 #define configTOTAL_HEAP_SIZE (6 * 1024) // 堆区 6KB,留给任务和队列 #define configMAX_TASK_NAME_LEN (8) // 任务名短一点省空间 #define configUSE_16_BIT_TICKS 0 // 32 位 tick 计数 #define configUSE_MUTEXES 1 // 用到互斥量就开 #define configUSE_COUNTING_SEMAPHORES 1 #define configCHECK_FOR_STACK_OVERFLOW 2 // 栈溢出检测,调试期必开 #define configUSE_MALLOC_FAILED_HOOK 1 // 内存分配失败钩子

几个参数值得展开说。configTICK_RATE_HZ设成 1000 意味着每个 tick 是 1ms,这是最常用的值,再高中断开销大,再低任务响应变迟钝。configMINIMAL_STACK_SIZE的单位是「字」,在 32 位机上一个字 4 字节,所以 64 字等于 256 字节,这是空闲任务的最小栈,实际任务要按需加大。configTOTAL_HEAP_SIZE是 FreeRTOS 自己管理的堆,不是芯片的全部 RAM,20KB 的 SRAM 里拿出 6KB 给内核堆是比较稳妥的,剩下的留给全局变量和主栈。configCHECK_FOR_STACK_OVERFLOW设成 2 是最严格的检测方式,会在任务切换时检查栈末尾的标记字有没有被踩,调试阶段强烈建议开着,产品发布再关掉省几个周期。

2.3 中断优先级与 PendSV/SysTick 的接管

Cortex-M3 的中断优先级配置是 FreeRTOS 移植里最容易翻车的地方。FreeRTOS 需要一个最低优先级的中断来做上下文切换(PendSV),还需要一个 tick 中断来驱动调度(SysTick)。在port.c里,这两个中断的优先级被设成最低:

/* port.c 中定义的中断优先级 */ #define configKERNEL_INTERRUPT_PRIORITY (255) /* 最低优先级 */ #define configMAX_SYSCALL_INTERRUPT_PRIORITY (191) /* 允许调用 API 的最高优先级 */

STM32 的 NVIC 优先级寄存器只用了高 4 位,所以 255 对应实际寄存器的 0xF0,191 对应 0xB0。这意味着优先级数值大于 191 的中断里不能调用 FreeRTOS 的 API(比如xQueueSendFromISR),否则会触发断言。很多人在串口中断里直接调xQueueSend而不是xQueueSendFromISR,结果就是随机死机——这是血泪教训级别的坑。

在stm32f10x_it.c里,你需要确保这三个中断向量指向 FreeRTOS 的处理函数:

/* stm32f10x_it.c - 中断向量重定向 */ void SysTick_Handler(void) { if (xTaskGetSchedulerState() != taskSCHEDULER_NOT_STARTED) { xPortSysTickHandler(); // 调度器启动后才交给 FreeRTOS } } void PendSV_Handler(void) { xPortPendSVHandler(); // 上下文切换,必须无条件交给 FreeRTOS } void SVC_Handler(void) { vPortSVCHandler(); // 启动第一个任务时用 }

注意SysTick_Handler里加了一个调度器状态判断。如果调度器还没启动就调用xPortSysTickHandler,内核链表还没初始化,直接跑飞。这个判断是很多移植教程里漏掉的细节。

3. 跑起来:从零编译到看见 LED 按任务节奏闪烁

3.1 工具链准备与工程导入

这份工程可以用 Keil MDK 或者 STM32CubeIDE 打开。如果用 Keil,需要确认安装了 STM32F1 的 Device Family Pack;如果用 CubeIDE,直接把源码目录拖进新建的 STM32 工程即可。编译器优化等级建议先设成-O0或-Og,调试阶段不要开-O2,否则断点跳来跳去会让你怀疑人生。

导入后先别急着编译,检查三个地方:一是启动文件是不是startup_stm32f10x_md.s(md 对应中等容量),二是链接脚本里的 RAM 起始地址和大小是不是0x20000000和0x5000(20KB),三是预处理器宏里有没有定义STM32F10X_MD和USE_STDPERIPH_DRIVER。这三项错一个,编译能过但跑不起来。

3.2 创建第一个任务并启动调度器

main.c是整个工程的入口,典型的 FreeRTOS 启动流程如下:

/* main.c - 创建任务并启动调度器 */ #include "FreeRTOS.h" #include "task.h" #include "stm32f10x.h" /* 任务句柄 */ static TaskHandle_t xLedTaskHandle = NULL; static TaskHandle_t xPrintTaskHandle = NULL; /* LED 闪烁任务:500ms 翻转一次 */ static void vLedTask(void *pvParameters) { GPIO_InitTypeDef gpio; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE); gpio.GPIO_Pin = GPIO_Pin_13; gpio.GPIO_Mode = GPIO_Mode_Out_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOC, &gpio); for (;;) { GPIO_WriteBit(GPIOC, GPIO_Pin_13, (BitAction)(1 - GPIO_ReadOutputDataBit(GPIOC, GPIO_Pin_13))); vTaskDelay(pdMS_TO_TICKS(500)); // 阻塞 500ms,让出 CPU } } /* 串口打印任务:每秒输出一次 tick 计数 */ static void vPrintTask(void *pvParameters) { for (;;) { printf("Tick: %lu\r\n", (unsigned long)xTaskGetTickCount()); vTaskDelay(pdMS_TO_TICKS(1000)); } } int main(void) { /* 系统时钟初始化,72MHz */ SystemInit(); /* 创建任务:栈深度 128 字,优先级 2 和 1 */ xTaskCreate(vLedTask, "LED", 128, NULL, 2, &xLedTaskHandle); xTaskCreate(vPrintTask, "PRINT", 256, NULL, 1, &xPrintTaskHandle); /* 启动调度器,从此不再返回 */ vTaskStartScheduler(); /* 如果走到这里说明堆不够,调度器启动失败 */ for (;;); }

这段代码里有几个关键点。xTaskCreate的第三个参数是栈深度,单位是字,LED 任务用了 128 字(512 字节),打印任务因为要调printf用了 256 字(1KB)。printf在嵌入式里是个吃栈大户,栈给小了会直接 HardFault。优先级数值越大优先级越高,LED 任务设成 2,打印任务设成 1,所以 LED 会抢占打印。vTaskDelay的参数是 tick 数,用pdMS_TO_TICKS宏把毫秒转成 tick,这样即使以后改了configTICK_RATE_HZ也不用改业务代码。

3.3 验证任务调度是否正常

烧录后如果 LED 以 1Hz 频率闪烁,串口每秒输出一次 tick 计数,说明调度器跑起来了。这时候可以做个压力测试:把 LED 任务的vTaskDelay改成vTaskDelay(1),让它几乎不阻塞,观察打印任务还能不能正常输出。如果打印任务被饿死,说明抢占式调度生效了;如果两个任务都正常,检查configUSE_PREEMPTION是不是被误设成 0。

另一个验证手段是用 GPIO 翻转配合示波器看任务切换频率。在任务切换钩子vApplicationTaskSwitchHook里翻转一个 IO,能直观看到调度器每秒切换多少次。这个钩子需要在FreeRTOSConfig.h里把configUSE_TASK_SWITCH_HOOK打开。

4. 避坑指南:移植 FreeRTOS 时最容易踩的五个雷

4.1 HardFault 一进调度就触发

现象:编译下载都正常,但vTaskStartScheduler()一调用就进 HardFault_Handler。

原因:九成是堆栈配置问题。要么configTOTAL_HEAP_SIZE设得比实际可用 RAM 还大,要么configMINIMAL_STACK_SIZE太小导致空闲任务栈溢出,要么主栈(MSP)和任务栈(PSP)切换时栈指针没对齐。

解决:先把configTOTAL_HEAP_SIZE降到 4KB 试试,把configCHECK_FOR_STACK_OVERFLOW设成 2,在vApplicationStackOverflowHook里打个断点。如果还不行,检查启动文件里的堆栈大小定义,Stack_Size至少给 0x400。

4.2 中断里调用 API 导致随机死机

现象:主循环跑得好好的,一进串口中断调xQueueSendFromISR就偶尔死机,重启后又正常。

原因:中断优先级设错了。STM32 的 NVIC 优先级数值越小优先级越高,而 FreeRTOS 要求调用FromISR系列 API 的中断优先级数值必须大于等于configMAX_SYSCALL_INTERRUPT_PRIORITY(通常是 5 左右)。如果串口中断优先级设成了 0 或 1,就会在中断里操作内核数据结构时被更高优先级中断打断,导致链表损坏。

解决:把所有需要调用 FreeRTOS API 的中断优先级都设成 5 以上。用NVIC_SetPriority(USART1_IRQn, 6)这样的方式显式设置,不要依赖默认值。

4.3 printf 重定向后任务栈不够用

现象:单独跑 LED 任务正常,加上 printf 任务后系统随机崩溃。

原因:printf内部用了大量局部变量,尤其是浮点格式化时栈开销可能超过 500 字节。如果任务栈只给了 128 字(512 字节),很容易踩到栈边界。

解决:给调用printf的任务至少 256 字栈空间,如果格式化浮点数建议给到 512 字。更稳妥的做法是用snprintf到固定缓冲区再通过串口发送,避免printf的不可预测栈开销。

4.4 vTaskDelay 在中断里调用导致断言失败

现象:在定时器中断里调用vTaskDelay后系统卡死,调试器显示停在configASSERT里。

原因:vTaskDelay是任务级 API,只能在任务上下文里调用,不能在中断里用。中断里应该用vTaskDelayUntil的 ISR 版本或者直接操作硬件定时器。

解决:中断里需要延时就用硬件定时器或者xTaskResumeFromISR唤醒一个任务来处理。记住一条铁律:所有带FromISR后缀的 API 才能在中断里调,不带后缀的一律只能在任务里用。

4.5 任务删除了但内存没回收

现象:反复创建和删除任务后,系统可用堆越来越小,最终xTaskCreate返回errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY。

原因:FreeRTOS 的vTaskDelete只回收任务控制块和栈,如果任务里动态申请了内存没释放,或者用了pvPortMalloc没配对vPortFree,就会泄漏。

解决:尽量用静态创建方式xTaskCreateStatic,内存由编译期分配,不依赖堆。如果必须动态创建,确保删除任务前释放所有该任务申请的资源。调试阶段可以在vApplicationMallocFailedHook里打个断点,看看是谁把堆吃光了。

5. 二次开发改造:把这份移植包变成你自己的多任务框架

拿到这份移植包,跑通只是第一步,真正有价值的是把它改造成适合自己项目的框架。我一般会做三件事:加一个任务间通信的队列层、把硬件初始化抽象成独立模块、再补一套轻量级的日志系统。

先说队列层。裸机时代全局变量满天飞,上了 RTOS 之后应该用队列来传递数据。比如串口接收任务收到一帧数据后,通过队列发给解析任务:

/* 定义队列句柄和消息结构 */ typedef struct { uint8_t data[64]; uint16_t len; } UartMsg_t; static QueueHandle_t xUartQueue = NULL; /* 在 main 里创建队列,长度 4 个消息 */ xUartQueue = xQueueCreate(4, sizeof(UartMsg_t)); /* 串口中断里发送到队列 */ void USART1_IRQHandler(void) { BaseType_t xHigherPriorityTaskWoken = pdFALSE; UartMsg_t msg; /* ... 填充 msg ... */ xQueueSendFromISR(xUartQueue, &msg, &xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); } /* 解析任务里阻塞等待 */ static void vParseTask(void *pvParameters) { UartMsg_t msg; for (;;) { if (xQueueReceive(xUartQueue, &msg, portMAX_DELAY) == pdTRUE) { /* 处理 msg */ } } }

这里的关键是portYIELD_FROM_ISR,它确保如果中断唤醒了更高优先级任务,退出中断后立刻切换过去,而不是等下一个 tick。少了这一句,实时性会打折扣。

再说硬件抽象。把 GPIO、USART、SPI 的初始化各自封成一个bsp_xxx_init()函数,放在独立的 bsp 目录下。任务函数里只调 bsp 接口,不直接碰寄存器。这样以后换芯片型号,只需要改 bsp 层,任务代码一行不动。

最后是日志系统。用printf打日志在 RTOS 里有个隐患:多个任务同时调printf会互相穿插,输出乱成一团。解决办法是加一个互斥量:

static SemaphoreHandle_t xLogMutex = NULL; void log_init(void) { xLogMutex = xSemaphoreCreateMutex(); } void log_printf(const char *fmt, ...) { va_list args; if (xSemaphoreTake(xLogMutex, pdMS_TO_TICKS(100)) == pdTRUE) { va_start(args, fmt); vprintf(fmt, args); va_end(args); xSemaphoreGive(xLogMutex); } }

这样每个任务的日志都是完整的一行,不会出现半行被截断的情况。互斥量的超时设成 100ms,避免某个任务持有锁不放导致其他任务永久阻塞。

改造完之后,验证方法很简单:创建三个任务分别以不同频率翻转三个 LED,同时通过队列传递计数,用逻辑分析仪抓三个 IO 的波形。如果三个 LED 各自独立闪烁且互不干扰,队列数据也能正确传递,说明框架搭稳了。从那以后我每次移植 RTOS,都会先把队列和互斥量这两样东西配好再写业务逻辑,不然等到任务多起来再补,改动量会大到让你想重写。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进