STM32F103移植FreeRTOS实战:从裸机到多任务系统搭建 1. 从裸机到RTOS为什么我们需要FreeRTOS如果你已经玩了一段时间的STM32用标准库或者HAL库写过一些点灯、串口收发、ADC采样的程序那你大概率已经习惯了“超级循环Super Loop”的编程模式。主函数里一个while(1)大循环里面按顺序调用各个功能函数再配合中断处理一些紧急事件。这种模式在功能简单、实时性要求不高的场景下确实简单直接。但当你开始尝试做一个稍微复杂点的项目比如一边通过串口接收数据并解析协议一边要控制电机PWM同时还得刷新一个小屏幕并且保证某个关键任务比如读取传感器必须每10毫秒执行一次时你就会发现裸机编程的力不从心。问题会接踵而至在while(1)里如果串口解析函数因为等待一帧完整数据而阻塞了后面的电机控制和屏幕刷新就会被延迟整个系统的响应变得不可预测。中断虽然能打断主循环但中断服务函数里不适合做复杂、耗时的操作。更头疼的是任务间的协调和通信比如电机控制需要等待串口解析出的指令你可能会用一堆全局变量和标志位代码很快会变得像一团乱麻难以维护和调试。这时实时操作系统RTOS就该登场了。它就像一个项目管家帮你把一个大项目整个嵌入式应用拆分成多个独立的小任务Task每个任务专注于一件事。RTOS内核负责在多个任务之间进行调度根据优先级决定哪个任务此刻可以运行。对于开发者而言你只需要关心每个任务内部的逻辑实现而任务间的切换、同步、通信都可以交给RTOS提供的成熟机制如队列、信号量、事件组等来处理。FreeRTOS正是RTOS领域的一个明星。它开源、免费、体量小巧、可裁剪并且拥有一个极其活跃的社区和丰富的资料。对于资源受限的微控制器MCU如我们常用的STM32F103系列Cortex-M3内核FreeRTOS是入门RTOS世界最理想的选择之一。它让你能以相对较低的学习成本掌握多任务编程的核心思想从而设计出更健壮、更易维护的嵌入式系统。本次“FreeRTOS之旅”我们就以经典的STM32F103C8T6俗称“蓝桥杯”或“最小系统板”为硬件平台从零开始完成FreeRTOS的移植与第一个多任务程序的创建。2. 环境准备打造你的FreeRTOS开发基地在开始写代码之前一个干净、可靠的开发环境是成功的第一步。对于STM32开发Keil MDK现在常叫Keil5依然是国内很多工程师和学生的首选其集成度高、调试方便。网络热词中频繁出现的“如何新建一个stm32f103的keil工程”也说明了这是很多人的第一个门槛。为了避免后续移植过程中出现各种诡异的路径或配置问题我强烈建议你从头手动搭建一个最基础的工程框架而不是直接使用某些现成的、包含大量不明所以文件的“模板工程”。2.1 创建纯净的STM32F103基础工程首先我们需要一个不包含任何RTOS代码的、能正常编译运行的STM32F103裸机工程。这个工程将作为我们移植FreeRTOS的“底板”。新建工程与选择器件打开Keil5点击Project - New uVision Project...。选择一个空文件夹作为工程目录并给工程起名例如FreeRTOS_Demo。在弹出的器件选择窗口中找到STMicroelectronics-STM32F1 Series-STM32F103-STM32F103C8根据你的具体芯片选择C8T6就选C8。点击OK。管理运行时环境RTEKeil会弹出“Manage Run-Time Environment”窗口。这里我们暂时什么都不选直接点击Cancel。这样做的目的是获得一个最干净的工程所有文件我们后续手动添加避免RTE自动添加的文件带来干扰。这也是解决很多“找不到头文件”错误的根本方法。添加启动文件与基础驱动文件在工程目录下手动创建几个文件夹例如Drivers/CMSIS用于内核相关文件、Drivers/STM32F1xx_HAL_Driver如果你用HAL库或Drivers/StdPeriph_Driver如果你用标准库、User用于用户代码。启动文件从STM32CubeF1软件包或标准库包中找到对应你芯片型号的启动文件例如startup_stm32f103xb.s复制到Drivers/CMSIS文件夹并添加到Keil工程的Drivers/CMSIS分组下。系统文件将system_stm32f1xx.c和对应的头文件也放入Drivers/CMSIS。外设库根据你的选择将HAL库或标准库的核心.c文件至少需要gpio.c、rcc.c用于配置时钟和GPIO复制到对应的驱动文件夹并添加到工程。用户代码在User文件夹下创建main.c、stm32f1xx_it.c中断服务函数文件和stm32f1xx_conf.h库配置文件。配置工程选项Target选项卡确认芯片型号正确。在Code Generation中勾选Use MicroLIB这是一个为嵌入式系统优化的精简C库可以减小代码体积。C/C选项卡在Define框中根据你使用的库添加全局宏定义。例如使用HAL库则添加USE_HAL_DRIVER, STM32F103xB。在Include Paths中添加所有头文件所在的文件夹路径Drivers/CMSIS, Drivers/STM32F1xx_HAL_Driver/Inc, User。Debug选项卡根据你的调试器如ST-Link进行设置。Utilities选项卡设置调试器的下载算法通常选STM32F1xx/2xx/3xx...的Flash算法。编写一个简单的测试程序在main.c中写一个最简单的LED闪烁程序假设LED接在PC13。配置好系统时钟HAL库用SystemClock_Config()标准库用RCC相关函数初始化GPIO然后在while(1)中翻转LED并延时。编译、下载到板子确保LED能正常闪烁。至此一个纯净的、可运行的裸机工程就准备好了。这个工程是我们后续所有操作的基础请务必确保它本身没有问题。注意很多新手会卡在“找不到core_cm3.h”或“HAL_Init未定义”这类错误上90%的原因都是头文件路径没有添加正确或者宏定义没有添加。请严格按照上述步骤检查C/C选项卡中的配置。2.2 获取FreeRTOS内核源码FreeRTOS的源码可以从其官网或Github仓库获取。但对于初学者我推荐一个更直接的方式使用STM32CubeMX。CubeMX是ST官方推出的图形化配置工具它集成了FreeRTOS的中间件可以一键生成包含FreeRTOS的工程框架并且版本匹配度很好。使用STM32CubeMX生成带FreeRTOS的工程打开CubeMX新建一个STM32F103C8Tx工程。在Pinout Configuration页面的Middleware分类下找到FREERTOS将其模式从Disabled改为CMSIS_V2这是FreeRTOS的一个兼容层接口更易用。此时CubeMX的左侧项目树中会出现FreeRTOS的配置项。生成代码配置好时钟树通常选择外部晶振系统时钟设为72MHz、GPIO继续用PC13控制LED等基础设置后点击Project Manager选项卡设置好工程名称、路径、工具链MDK-ARM V5。在Code Generator中建议选择“为每个外设生成单独的.c/.h文件”这样结构更清晰。最后点击GENERATE CODE。提取FreeRTOS源码代码生成后不要直接在这个工程上开发。我们的目的是获取纯净的FreeRTOS源码。打开生成的工程目录你会找到一个Middlewares/Third_Party/FreeRTOS文件夹。这里面就是我们需要移植的FreeRTOS内核源码。重点关注以下子文件夹Source/核心源码包含tasks.c,queue.c,list.c,timers.c等。Source/portable/这是移植的关键。里面包含了针对不同编译器和处理器架构的移植层代码。我们需要的是RVDS对应Keil ARM编译器下的ARM_CM3对应Cortex-M3内核端口文件。Source/include/所有头文件。我们将从CubeMX生成的工程中把Middlewares/Third_Party/FreeRTOS这个文件夹完整地复制到我们之前创建的纯净裸机工程目录下。这样我们就拥有了版本匹配、且针对Cortex-M3优化好的FreeRTOS源码。3. 向工程中移植FreeRTOS源码现在我们手上有两样东西一个能正常运行的STM32F103裸机工程和一份FreeRTOS源码。接下来的目标是把它们结合起来。3.1 将FreeRTOS文件添加到Keil工程在Keil工程中新建几个分组Group来管理FreeRTOS的文件这样结构清晰便于管理。创建分组在Keil的Project窗口右键点击Target 1选择Add Group...。建议创建如下分组FreeRTOS_Core用于存放核心源文件。FreeRTOS_Port用于存放与处理器架构相关的移植层文件。FreeRTOS_Config用于存放FreeRTOS的配置文件稍后创建。FreeRTOS_Hooks用于存放用户钩子函数文件可选。添加文件到分组将FreeRTOS/Source目录下的tasks.c,queue.c,list.c,timers.c,event_groups.c,stream_buffer.c后两个可根据需要添加添加到FreeRTOS_Core分组。将FreeRTOS/Source/portable/RVDS/ARM_CM3目录下的port.c添加到FreeRTOS_Port分组。这个文件实现了任务切换、时钟节拍中断等与硬件紧密相关的函数是移植的核心。将FreeRTOS/Source/portable/MemMang目录下的内存管理文件添加到FreeRTOS_Port分组。FreeRTOS提供了5种内存堆管理方案heap_1.c到heap_5.c。对于初学者heap_4.c是最推荐的选择它支持内存碎片合并功能比较全面。我们将heap_4.c添加进来。添加头文件路径这是至关重要的一步。打开工程的Options for Target - C/C - Include Paths。添加以下路径.\FreeRTOS\Source\include核心头文件.\FreeRTOS\Source\portable\RVDS\ARM_CM3移植层头文件.\FreeRTOS稍后我们放配置文件的目录3.2 创建与定制FreeRTOS配置文件FreeRTOSConfig.hFreeRTOS是高度可裁剪的几乎所有功能都可以通过一个名为FreeRTOSConfig.h的配置文件来开启或关闭并调整参数。这个文件需要用户根据自己项目的需求来创建和修改。我们可以从FreeRTOS提供的Demo项目中找一个FreeRTOSConfig.h作为起点但更简单的方法是从我们之前用CubeMX生成的工程里拷贝一份。获取基础配置文件从CubeMX生成的工程的Core/Inc或Middlewares/Third_Party/FreeRTOS目录下找到FreeRTOSConfig.h将其复制到我们裸机工程的User目录或新建的FreeRTOS目录下并添加到Keil的FreeRTOS_Config分组。关键配置项详解与修改打开FreeRTOSConfig.h我们需要关注并可能修改以下几个核心配置configUSE_PREEMPTION: 设置为1启用抢占式调度。这是FreeRTOS最常用的调度方式高优先级任务可以抢占低优先级任务。configUSE_TICKLESS_IDLE: 对于STM32F103这种没有低功耗定时器特殊支持且初学阶段建议设置为0禁用Tickless低功耗模式避免复杂化。configCPU_CLOCK_HZ: 设置为你的系统时钟频率这里是7200000072MHz。configTICK_RATE_HZ: FreeRTOS的系统时钟节拍频率即每秒产生多少次tick中断。通常设置为1000即1ms一个节拍。这决定了时间片长度和时间精度。configMAX_PRIORITIES: 最大任务优先级数。STM32F103资源有限不宜设置过大5-10之间通常足够。这里设为7。configMINIMAL_STACK_SIZE: 定义空闲任务Idle Task的堆栈大小以字Word32位机是4字节为单位。对于Cortex-M3建议至少128。我们可以设为128。configTOTAL_HEAP_SIZE:这是最重要的参数之一它定义了FreeRTOS内核动态管理的内存堆的总大小。所有任务栈、队列、信号量等内核对象都从这个堆中分配。对于STM32F103C8T664KB Flash20KB RAM我们需要谨慎评估。RAM总共20KB系统本身、全局变量、栈等要占用一部分。建议初始设置为(size_t) (10 * 1024)即10KB。后续根据任务创建情况调整。堆栈溢出是FreeRTOS调试中最常见的问题后面会专门讲检测方法。configUSE_16_BIT_TICKS: 设置为0。对于32位机使用32位的tick计数器可以获得更长的溢出周期。configCHECK_FOR_STACK_OVERFLOW: 强烈建议设置为2启用堆栈溢出检测方法2在任务切换和栈填充时检查魔数。这是调试的利器。configUSE_MUTEXES,configUSE_RECURSIVE_MUTEXES,configUSE_COUNTING_SEMAPHORES,configUSE_QUEUES: 根据你需要使用的功能将它们设置为1。建议先全部开启。configUSE_IDLE_HOOK,configUSE_TICK_HOOK: 钩子函数用于在空闲任务和时钟节拍中断中插入用户代码可用于低功耗管理或性能统计初学可设为0。3.3 修改启动文件与系统初始化FreeRTOS需要接管系统的SysTick定时器用于产生时钟节拍xTaskGetTickCount()和PendSV中断用于上下文切换。修改启动文件找到我们工程中的启动文件如startup_stm32f103xb.s。我们需要将SVC_Handler、PendSV_Handler和SysTick_Handler这三个中断服务程序ISR的“弱定义”WEAK注释掉或者确保它们被FreeRTOS的移植层实现所覆盖。更常见的做法是在FreeRTOSConfig.h中通过宏定义重定向这些中断向量。确保FreeRTOSConfig.h中有如下定义通常CubeMX生成的配置里已经有了#define xPortPendSVHandler PendSV_Handler #define xPortSysTickHandler SysTick_Handler #define vPortSVCHandler SVC_Handler这样FreeRTOS移植层文件port.c中定义的xPortPendSVHandler、xPortSysTickHandler和vPortSVCHandler函数就会在链接时覆盖启动文件中的弱符号定义成为真正的中断服务函数。修改main.c初始化流程在main函数一开始仍然进行必要的硬件初始化HAL_Init()、SystemClock_Config()、GPIO初始化等。关键步骤在硬件初始化之后创建所有应用任务之前调用xTaskCreate()等函数创建你的任务。在所有任务创建完毕后调用vTaskStartScheduler()。这个函数永远不会返回它会启动FreeRTOS调度器开始多任务调度。如果调度器启动失败例如内存不足这个函数会调用configASSERT()如果使能了断言或进入一个错误处理循环。一个典型的main.c框架如下#include “main.h” #include “FreeRTOS.h” #include “task.h” // 任务函数原型 void vTask1(void *pvParameters); void vTask2(void *pvParameters); int main(void) { // 1. 硬件初始化 HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); // ... 其他外设初始化 // 2. 创建FreeRTOS任务 xTaskCreate(vTask1, “Task1”, 128, NULL, 2, NULL); // 任务名栈大小参数优先级任务句柄 xTaskCreate(vTask2, “Task2”, 128, NULL, 1, NULL); // 3. 启动调度器 vTaskStartScheduler(); // 如果调度器启动失败才会执行到这里 while (1) { // 错误处理 } } // 任务1闪烁LED1 void vTask1(void *pvParameters) { while (1) { HAL_GPIO_TogglePin(LED1_GPIO_Port, LED1_Pin); vTaskDelay(pdMS_TO_TICKS(500)); // 延迟500毫秒注意使用FreeRTOS的延时 } } // 任务2打印信息假设已初始化串口 void vTask2(void *pvParameters) { while (1) { printf(“Task2 is running.\r\n”); vTaskDelay(pdMS_TO_TICKS(1000)); } }4. 编译、下载与第一个多任务调试完成上述所有步骤后点击编译。你可能会遇到一些错误常见的有..\freertos\port\portmacro.h(73): error: #35: #error directive: configtick_t这个错误直接指向了网络热词中提到的一个经典问题。错误信息通常不完整完整信息可能是“#error configUSE_16_BIT_TICKS must be set to 0 for this port.”。这是因为在portmacro.h中检测到configUSE_16_BIT_TICKS被定义为1但Cortex-M3端口要求它必须为0。请回到FreeRTOSConfig.h确保configUSE_16_BIT_TICKS被定义为0。未定义符号错误检查头文件路径是否全部正确添加特别是FreeRTOS/Source/include和移植层路径。检查是否遗漏添加了某个必需的.c源文件到工程如list.c。内存不足错误如果编译通过但链接时提示内存不足可能是configTOTAL_HEAP_SIZE设置过大超过了芯片的RAM总量。请合理减小该值并为全局变量、栈等预留空间。编译通过后将程序下载到STM32F103开发板。如果一切顺利你应该能看到两个任务在同时运行LED以0.5秒的间隔闪烁同时串口如果已连接每隔1秒打印一次信息。这证明了FreeRTOS调度器正在工作两个任务在并发执行。4.1 使用调试器观察任务运行Keil的调试器可以很好地与FreeRTOS配合。在调试模式下CtrlF5你可以打开View - Watch Windows - Call Stack Locals查看当前调用栈。更强大的是Keil提供了FreeRTOS的调试组件。点击View - System Viewer - FreeRTOS可能需要手动安装或启用相关软件包。在这里你可以实时看到所有任务的状态Running, Ready, Blocked, Suspended、优先级、堆栈使用情况高水位线和运行时间。这是分析多任务系统运行时行为的利器。通过设置断点你可以观察任务切换的过程。例如在vTaskDelay()函数内部或任务函数里设置断点当任务阻塞延时时调度器会自动切换到下一个就绪的最高优先级任务。4.2 堆栈溢出检测实战之前我们在FreeRTOSConfig.h中设置了configCHECK_FOR_STACK_OVERFLOW为2。当任务堆栈溢出发生时FreeRTOS会调用一个钩子函数vApplicationStackOverflowHook()。我们需要实现这个函数以便在溢出时能立刻知道。在main.c或单独的文件中添加如下函数void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) { (void)xTask; // 消除未使用参数警告 printf(“[ERROR] Stack overflow in task: %s\r\n”, pcTaskName); // 这里可以点亮错误灯或者进入死循环 while (1) { HAL_GPIO_TogglePin(ERROR_LED_GPIO_Port, ERROR_LED_Pin); HAL_Delay(100); } }然后在FreeRTOSConfig.h中确保configCHECK_FOR_STACK_OVERFLOW为2并且编译器优化等级不要太高-O0或-O1否则魔数检测可能会被优化掉。如何模拟堆栈溢出创建一个任务时故意给它分配极小的堆栈比如64字256字节然后在这个任务函数里声明一个大的局部数组例如uint8_t bigArray[300];很快堆栈就会溢出触发上面的钩子函数。通过这个实验你可以深刻理解堆栈分配和溢出的概念并学会如何使用这个调试工具。5. 进阶第一步任务通信与同步初探让两个任务独立运行只是开始真正的威力在于任务间的协作。FreeRTOS提供了队列Queue、信号量Semaphore、互斥量Mutex、事件组Event Group等多种机制。这里以最常用的队列为例展示如何让任务Task1控制任务Task2。假设Task1是按键扫描任务Task2是LED控制任务。Task1检测到按键按下后通过队列发送一个命令给Task2Task2根据命令改变LED的闪烁模式。创建队列在全局区域或在main函数内任务创建前定义一个队列句柄并创建队列。QueueHandle_t xLedCmdQueue NULL; // 队列句柄 int main(void) { // ... 硬件初始化 // 创建队列可以传递一个uint8_t类型的命令队列深度为5 xLedCmdQueue xQueueCreate(5, sizeof(uint8_t)); if (xLedCmdQueue NULL) { // 队列创建失败处理 } // ... 创建任务 vTaskStartScheduler(); }任务1发送命令。void vTaskKeyScan(void *pvParameters) { uint8_t cmd 0; while (1) { if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) GPIO_PIN_RESET) { HAL_Delay(50); // 简单消抖 if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) GPIO_PIN_RESET) { cmd 1; // 假设命令1代表切换模式 // 发送命令到队列等待10个ticks10ms if (xQueueSend(xLedCmdQueue, cmd, pdMS_TO_TICKS(10)) ! pdPASS) { // 发送失败队列满或超时 } } while (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) GPIO_PIN_RESET); // 等待按键释放 } vTaskDelay(pdMS_TO_TICKS(10)); // 每10ms扫描一次按键 } }任务2接收命令并执行。void vTaskLedCtrl(void *pvParameters) { uint8_t receivedCmd; TickType_t xLastWakeTime xTaskGetTickCount(); const TickType_t xFrequency pdMS_TO_TICKS(500); // 默认500ms间隔 TickType_t xInterval xFrequency; while (1) { // 尝试从队列接收命令不阻塞立即返回 if (xQueueReceive(xLedCmdQueue, receivedCmd, 0) pdPASS) { if (receivedCmd 1) { // 切换闪烁间隔500ms - 200ms xInterval (xInterval pdMS_TO_TICKS(500)) ? pdMS_TO_TICKS(200) : pdMS_TO_TICKS(500); } } // 控制LED闪烁 HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); // 使用固定频率延迟保证闪烁周期稳定 vTaskDelayUntil(xLastWakeTime, xInterval); } }通过这个简单的例子你就能体会到队列如何解耦两个任务按键扫描任务不需要知道LED如何控制LED控制任务也不需要轮询按键状态。它们通过一个数据通道队列进行通信各自独立运行系统的模块化和可维护性大大提升。这也是从裸机思维转向RTOS多任务思维的关键一步。