☰
STM32F103移植FreeRTOS全流程:从裸机到RTOS的实战指南
2026/10/12 17:09:22 网站建设 项目流程

1. 为什么要在 STM32F103 上跑 FreeRTOS

STM32F103 这颗芯片在嵌入式圈子里算是"国民级"的存在——Cortex-M3 内核、72MHz 主频、64KB~128KB Flash、20KB SRAM,价格便宜、资料铺天盖地,很多人入门嵌入式就是从它开始的。但真正做过稍微复杂一点的项目就会发现,裸机写while(1)大循环,一旦功能多起来,代码会迅速变成一团乱麻:按键扫描要定时、串口收包不能丢、LED 要闪烁、还要处理传感器数据,几个任务的时序互相打架,delay一多整个系统就卡死。

这时候 FreeRTOS 的价值就体现出来了。它把 CPU 时间切成一个个时间片,让多个任务"看起来"在同时运行,每个任务各管一摊事,通过信号量、队列、事件组来通信。STM32F103 的 SRAM 虽然只有 20KB,但跑一个精简配置的 FreeRTOS 内核绰绰有余——内核本身占用大概 6KB 左右的 Flash 和 1KB 左右的 RAM,剩下的资源足够你开四五个任务。

这篇内容适合两类人:一类是裸机已经写熟了、想往 RTOS 方向进阶的开发者;另一类是移植过程中被各种报错卡住、想搞清楚每一步到底在干什么的人。我会把移植的完整链路拆开讲,包括文件从哪来、每个文件干什么、配置怎么改、启动流程是什么样,以及我自己踩过的几个坑。移植这件事,网上教程一大堆,但很多只给步骤不讲原理,一旦报错就抓瞎,所以我会重点补上"为什么这么做"。

需要提前说明的是,FreeRTOS 的移植本质上就是"让内核知道怎么切换任务、怎么产生系统节拍",而这两件事都跟具体的 CPU 内核强相关。STM32F103 是 Cortex-M3,所以我们要用的是 FreeRTOS 针对 Cortex-M3 的移植层,而不是 M0、M4 或者别的架构。搞清楚这一点,后面选文件的时候就不会拿错。

2. 移植前必须理清的三个核心概念

2.1 任务切换靠的是什么硬件机制

很多人移植完能跑,但被问到"任务是怎么切换的"就答不上来。Cortex-M3 内核有两个跟 RTOS 强相关的硬件特性:一个是SysTick 定时器,另一个是PendSV 异常。

SysTick 是一个 24 位的递减计数器,内核自带,所有 Cortex-M 芯片都有。FreeRTOS 用它来产生周期性的系统节拍(tick),比如每 1ms 触发一次中断,在中断里判断有没有任务需要切换。这个节拍就是 RTOS 的"心跳",所有延时、超时、时间片轮转都基于它。

PendSV 则是一个"可挂起的系统异常",优先级可以设成最低。为什么任务切换要用它而不是直接在 SysTick 中断里切?因为如果在 SysTick 里直接切换,万一此时正在处理另一个高优先级中断,就会打乱中断嵌套的现场。把切换动作"挂起"到 PendSV,等所有高优先级中断都处理完了再执行,这样最安全。这是 Cortex-M 内核专门为 RTOS 设计的机制,理解这一点,你就明白为什么移植文件里要手动配置 PendSV 和 SysTick 的优先级了。

2.2 移植层到底要提供哪些东西

FreeRTOS 的源码结构是分层的:核心代码(tasks.c、queue.c、list.c 等)是平台无关的,所有芯片共用;而跟硬件打交道的部分被抽出来放在portable目录下,按编译器和架构分类。我们要做的移植,就是选对portable目录下的文件,并实现几个关键函数。

对于 STM32F103(Cortex-M3)+ Keil MDK(ARMCC 编译器),需要的是portable/RVDS/ARM_CM3/port.c和portmacro.h。如果你用的是 GCC 工具链(比如 STM32CubeIDE 或者 Makefile 工程),那就要选portable/GCC/ARM_CM3/目录。选错编译器对应的目录,编译时会报一堆汇编语法错误,这是新手最容易犯的错。

移植层要提供的核心内容其实就三块:一是任务上下文切换的汇编代码(PendSV 处理函数xPortPendSVHandler),二是启动第一个任务的汇编代码(prvPortStartFirstTask),三是 SysTick 中断处理函数xPortSysTickHandler。这些在port.c里都已经写好了,我们不需要自己写汇编,只要正确配置就能用。

2.3 FreeRTOSConfig.h 是移植的"总开关"

整个移植过程中,唯一需要你大量动手改的文件就是FreeRTOSConfig.h。它决定了内核裁剪成什么样、开哪些功能、用多少资源。这个文件不是 FreeRTOS 源码自带的,而是由用户工程提供的——因为不同项目需求不同,配置自然不一样。

配置项里最关键的几个:configCPU_CLOCK_HZ要填你芯片的实际主频(STM32F103 通常是 72000000);configTICK_RATE_HZ是系统节拍频率,一般填 1000,也就是 1ms 一个 tick;configTOTAL_HEAP_SIZE是内核堆大小,STM32F103 只有 20KB SRAM,这个值不能给太大,一般 8KB~10KB 比较稳妥;configMAX_PRIORITIES是最大任务优先级数,填 5~8 就够用了,填太大浪费 RAM。

还有一个容易忽略的:configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY,它决定了哪些中断可以调用 FreeRTOS 的 API。这个值跟 Cortex-M3 的中断优先级分组有关,配错了会导致configASSERT断言失败,程序卡死。后面我会专门讲这个坑。

3. 手把手搭建工程:从零到能编译

3.1 源码获取与目录规划

FreeRTOS 的源码可以从官网下载,也可以从一些成熟的工程模板里提取。下载下来是一个压缩包,解压后核心目录是FreeRTOS/Source,里面有tasks.c、queue.c、list.c、timers.c、event_groups.c、croutine.c这些核心文件,以及portable和include两个子目录。

我的习惯是在自己的工程目录下建一个FreeRTOS文件夹,然后把源码按下面的结构整理:

Project/ ├── FreeRTOS/ │ ├── Source/ │ │ ├── include/ (内核头文件) │ │ ├── portable/ │ │ │ ├── RVDS/ARM_CM3/ (移植层,Keil用) │ │ │ └── MemMang/ (内存管理) │ │ ├── tasks.c │ │ ├── queue.c │ │ ├── list.c │ │ ├── timers.c │ │ └── event_groups.c │ └── FreeRTOSConfig.h (配置文件,放这里方便管理) └── User/ ├── main.c └── stm32f10x_it.c

MemMang目录下有五个heap_x.c文件,从heap_1.c到heap_5.c,这是 FreeRTOS 提供的五种内存管理方案。新手最常纠结选哪个,我的建议是:一般项目直接用heap_4.c。原因后面单独讲。

3.2 往 Keil 工程里添加文件分组

打开 Keil 工程,在 Project 窗口右键新建两个 Group,一个叫FreeRTOS_Core,一个叫FreeRTOS_Port。

FreeRTOS_Core里添加:tasks.c、queue.c、list.c、timers.c、event_groups.c。如果你不用软件定时器和事件组,timers.c和event_groups.c可以不添加,能省一点 Flash。croutine.c是协程功能,现在基本没人用了,不用加。

FreeRTOS_Port里添加:port.c(来自RVDS/ARM_CM3)和heap_4.c(来自MemMang)。

然后在 Options for Target 的 C/C++ 选项卡里,把两个头文件路径加进去:FreeRTOS/Source/include和FreeRTOS/Source/portable/RVDS/ARM_CM3。注意FreeRTOSConfig.h所在的目录也要加进去,否则编译器找不到它。

提示:FreeRTOSConfig.h的搜索路径一定要加,而且这个文件必须能被全局找到。很多"undefined identifier configXXX"的报错,都是因为路径没配对。

3.3 中断处理函数的归属问题

这一步是移植里最容易出问题的地方。STM32 的标准库工程里,stm32f10x_it.c文件里默认定义了SysTick_Handler、PendSV_Handler、SVC_Handler这三个函数。而 FreeRTOS 的port.c里也定义了对应的处理函数,名字分别是xPortSysTickHandler、xPortPendSVHandler、vPortSVCHandler。

如果你两个都留着,链接时会报"符号重复定义"。解决办法有两种:一是把stm32f10x_it.c里的这三个函数注释掉或者删掉;二是在FreeRTOSConfig.h里用宏把 FreeRTOS 的处理函数名映射成标准库的名字:

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

我更推荐第二种,因为这样不用动标准库文件,工程结构更干净。但要注意,这三个宏必须写在FreeRTOSConfig.h里,而且要在包含port.c之前生效。

4. FreeRTOSConfig.h 逐项拆解与配置实战

4.1 时钟与节拍相关配置

先看最基础的几个:

#define configUSE_PREEMPTION 1 #define configUSE_IDLE_HOOK 0 #define configUSE_TICK_HOOK 0 #define configCPU_CLOCK_HZ (72000000) #define configTICK_RATE_HZ ((TickType_t)1000) #define configMAX_PRIORITIES (8) #define configMINIMAL_STACK_SIZE ((unsigned short)128) #define configTOTAL_HEAP_SIZE ((size_t)(10 * 1024)) #define configMAX_TASK_NAME_LEN (16) #define configUSE_16_BIT_TICKS 0 #define configIDLE_SHOULD_YIELD 1

configCPU_CLOCK_HZ填 72000000,这是 STM32F103 的标准主频。这个值必须跟实际系统时钟一致,否则 SysTick 的节拍周期就不准,所有延时都会偏。如果你用的是 8MHz 外部晶振倍频到 72MHz,那这里就是 72M;如果你改了时钟树,这里也要跟着改。

configTICK_RATE_HZ填 1000,意味着 1ms 一个 tick。这个值越大,系统时间精度越高,但中断开销也越大。一般项目 1000 足够,对时间精度要求不高的可以降到 100,能减少中断次数。

configMAX_PRIORITIES填 8,表示支持 0~7 共 8 个优先级,数字越大优先级越高。0 是空闲任务的优先级,用户任务从 1 开始用。填太大浪费 RAM(每个优先级都要维护一个就绪链表),8 个对大多数项目够用了。

configMINIMAL_STACK_SIZE是空闲任务的栈大小,单位是"字"(word),不是字节。填 128 就是 128 个字 = 512 字节。这个值不能太小,否则空闲任务可能栈溢出。

configTOTAL_HEAP_SIZE是内核堆的总大小,单位字节。STM32F103 只有 20KB SRAM,还要留给全局变量、栈、堆,所以这里给 10KB 已经比较激进了。如果你的任务多、队列多,可以适当加大,但要盯着 SRAM 占用别爆。

4.2 内存管理方案的选择逻辑

前面提到heap_4.c,这里展开讲讲为什么。FreeRTOS 的五种内存方案各有适用场景:

方案特点适用场景
heap_1只分配不释放任务创建后不再删除的简单项目
heap_2可释放,但不合并碎片已废弃,不推荐
heap_3封装标准 malloc/free需要标准库内存管理时
heap_4可释放,带碎片合并通用场景,最推荐
heap_5支持多块不连续内存内存分散在多个区域时

heap_4.c用首次适应算法分配内存,释放时会把相邻的空闲块合并,能有效减少碎片。对于大多数 STM32F103 项目,任务和队列的创建删除都不频繁,heap_4是最稳妥的选择。heap_1虽然简单,但一旦你想动态删除任务就不行了。heap_3依赖标准库的malloc,会引入额外的代码体积,而且标准库的malloc本身不一定线程安全。

注意:configTOTAL_HEAP_SIZE定义的是 FreeRTOS 自己管理的堆,跟标准库的堆是两回事。如果你同时用了标准库的malloc和 FreeRTOS 的pvPortMalloc,要确保两者加起来不超过芯片的 SRAM。

4.3 中断优先级配置的坑

这是移植里最隐蔽的坑,没有之一。Cortex-M3 的中断优先级寄存器是 8 位的,但 STM32F103 只实现了高 4 位,所以实际可用的优先级是 0~15。FreeRTOS 要求:凡是调用 FreeRTOS API 的中断,其优先级必须低于configMAX_SYSCALL_INTERRUPT_PRIORITY设定的阈值。

配置项长这样:

#define configPRIO_BITS 4 #define configLIBRARY_LOWEST_INTERRUPT_PRIORITY 15 #define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 5 #define configKERNEL_INTERRUPT_PRIORITY \ (configLIBRARY_LOWEST_INTERRUPT_PRIORITY << (8 - configPRIO_BITS)) #define configMAX_SYSCALL_INTERRUPT_PRIORITY \ (configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY << (8 - configPRIO_BITS))

这里的逻辑是:configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY设为 5,意味着优先级数值大于等于 5 的中断(数值越大优先级越低)才能调用 FreeRTOS 的FromISR系列 API。优先级 0~4 的中断是"高优先级中断",它们不受 FreeRTOS 临界区保护,绝对不能调用任何 FreeRTOS API。

为什么这么设计?因为 FreeRTOS 进入临界区时会通过basepri寄存器屏蔽掉低于某个阈值的中断,如果高优先级中断里调用了 FreeRTOS API,就可能在内核数据结构被修改到一半时打断它,导致链表错乱、系统崩溃。

实际配置时,你的外设中断(比如串口、定时器)如果要用xQueueSendFromISR这类函数,优先级就要设成 5~15。用标准库的NVIC_Init配置时,NVIC_IRQChannelPreemptionPriority要填对应的值。这里还涉及优先级分组的问题,建议用NVIC_PriorityGroup_4,也就是全部 4 位都用作抢占优先级,这样逻辑最简单。

5. 启动流程与第一个任务的创建

5.1 从 main 到调度器启动发生了什么

裸机程序的入口是main,初始化完外设就进while(1)。用了 FreeRTOS 之后,main里要做的是:初始化硬件、创建任务、启动调度器。启动调度器之后,main函数就"回不来"了,CPU 交给 FreeRTOS 管理。

int main(void) { /* 硬件初始化 */ LED_Init(); USART1_Init(115200); /* 创建任务 */ xTaskCreate(LED_Task, "LED", 128, NULL, 2, NULL); xTaskCreate(USART_Task, "USART", 256, NULL, 3, NULL); /* 启动调度器 */ vTaskStartScheduler(); /* 正常情况下不会执行到这里 */ while (1); }

vTaskStartScheduler内部做了几件事:创建空闲任务(优先级 0)、创建定时器任务(如果开了软件定时器)、初始化 SysTick、然后调用xPortStartScheduler启动第一个任务。第一个任务是通过 SVC 异常启动的,vPortSVCHandler会从就绪链表里取出最高优先级的任务,恢复它的上下文,然后跳转执行。

5.2 任务函数的标准写法

任务函数必须是死循环,不能返回。如果返回了,FreeRTOS 会触发断言或者直接跑飞。

void LED_Task(void *pvParameters) { while (1) { GPIO_SetBits(GPIOC, GPIO_Pin_13); vTaskDelay(500); /* 延时500个tick,即500ms */ GPIO_ResetBits(GPIOC, GPIO_Pin_13); vTaskDelay(500); } }

这里用vTaskDelay而不是裸机的delay_ms。区别在于:vTaskDelay会让当前任务进入阻塞态,把 CPU 让给其他任务;而裸机的忙等延时会把 CPU 占死。这就是 RTOS 的核心价值——延时期间 CPU 不闲着。

vTaskDelay的参数是 tick 数,不是毫秒。如果configTICK_RATE_HZ是 1000,那 500 个 tick 就是 500ms。如果改成 100,那 500 个 tick 就是 5 秒。所以写代码时最好用pdMS_TO_TICKS(500)这个宏来转换,避免节拍频率改了之后延时全乱。

5.3 栈大小的估算方法

xTaskCreate的第三个参数是栈深度,单位是字(word),不是字节。一个 128 的栈就是 512 字节。栈给太小会溢出,给太大浪费 RAM。怎么估?

我的经验是:先给一个偏大的值(比如 256),跑起来之后用uxTaskGetStackHighWaterMark查每个任务的历史最小剩余栈空间。这个函数返回的是任务运行过程中栈剩余的最小值,如果返回值接近 0,说明栈快溢出了,要加大;如果返回值很大,说明给多了,可以适当减小。

UBaseType_t watermark = uxTaskGetStackHighWaterMark(NULL); printf("Task stack watermark: %u\r\n", watermark);

这个函数在调试阶段非常有用,建议每个任务都查一遍。等产品稳定了,再把栈大小调到合适的值。

6. 移植过程中最容易翻车的几个点

6.1 编译报错 "undefined symbol" 的排查顺序

移植时最常见的报错就是各种 undefined symbol。排查顺序我总结成一条链路:

第一步,看报错的是哪个符号。如果是xTaskCreate、vTaskDelay这类内核函数,说明tasks.c没加进工程,或者头文件路径没配对。如果是xPortPendSVHandler这类移植层函数,说明port.c没加,或者编译器目录选错了。

第二步,看是不是FreeRTOSConfig.h找不到。报错通常是cannot open source input file "FreeRTOSConfig.h",这时候检查头文件搜索路径。

第三步,看是不是中断处理函数重复定义。报错是multiply defined,说明stm32f10x_it.c和port.c里都定义了PendSV_Handler之类。用前面说的宏映射方法解决。

第四步,看是不是编译器不匹配。如果你用的是 GCC 工具链却加了RVDS目录下的port.c,会报一堆汇编语法错误,比如bad instruction。这时候换成GCC/ARM_CM3目录下的文件。

6.2 程序卡在 configASSERT 的定位思路

configASSERT是 FreeRTOS 的断言宏,默认在FreeRTOSConfig.h里定义成空。调试阶段强烈建议打开它:

#define configASSERT(x) if ((x) == 0) { taskDISABLE_INTERRUPTS(); for(;;); }

一旦断言失败,程序会关中断然后死循环。这时候用调试器看调用栈,就能定位到是哪一行触发的断言。最常见的断言失败原因是中断优先级配错了——比如在优先级 0 的中断里调用了xQueueSendFromISR,FreeRTOS 检测到就会断言失败。

还有一种情况是栈溢出。FreeRTOS 有个configCHECK_FOR_STACK_OVERFLOW配置项,设成 1 或 2 可以检测栈溢出。设成 2 会在任务切换时检查栈末尾的标记字节有没有被覆盖,更可靠但稍慢。检测到溢出会调用vApplicationStackOverflowHook,你可以在这个钩子函数里打印是哪个任务溢出了。

6.3 中断里调用 API 必须用 FromISR 版本

这个坑我见过太多人踩。在中断服务函数里,不能调用xQueueSend,必须调用xQueueSendFromISR;不能调用xSemaphoreGive,必须调用xSemaphoreGiveFromISR。带FromISR后缀的版本不会阻塞,而且会在退出时判断是否需要触发任务切换。

void USART1_IRQHandler(void) { BaseType_t xHigherPriorityTaskWoken = pdFALSE; uint8_t data; if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { data = USART_ReceiveData(USART1); xQueueSendFromISR(xQueue, &data, &xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); } }

xHigherPriorityTaskWoken这个变量很关键。如果中断唤醒了比当前任务优先级更高的任务,它会被设成pdTRUE,然后portYIELD_FROM_ISR会触发一次任务切换,让高优先级任务立即执行。如果忘了这一步,高优先级任务要等到下一个 tick 才能运行,实时性就打折扣了。

6.4 SRAM 不够用的取舍策略

STM32F103C8T6 只有 20KB SRAM,跑 FreeRTOS 之后经常捉襟见肘。如果编译报 "region RAM overflowed",说明 SRAM 不够了。这时候有几个方向可以优化:

一是减小configTOTAL_HEAP_SIZE,从 10KB 降到 6KB 试试。二是减小各任务的栈大小,用uxTaskGetStackHighWaterMark找到实际需求。三是关掉不用的功能,比如configUSE_TIMERS设成 0 可以省掉定时器任务。四是把一些大的全局数组改成const放到 Flash 里,或者用__attribute__((section(".ccmram")))放到 CCM RAM(不过 F103 没有 CCM,这条对 F4 系列适用)。

还有一个技巧:把configMINIMAL_STACK_SIZE从 128 降到 64。空闲任务的栈需求其实不大,64 个字(256 字节)通常够用。但要注意,如果你在空闲钩子函数里做了复杂操作,栈就要留够。

7. 移植完成后的验证与性能观察

7.1 用 LED 和串口做最小验证

移植完别急着上复杂功能,先用最简单的例子验证。我的做法是创建两个任务:一个闪 LED,一个通过串口每隔一秒打印一次系统运行时间。

void Blink_Task(void *pvParameters) { while (1) { GPIO_SetBits(GPIOC, GPIO_Pin_13); vTaskDelay(pdMS_TO_TICKS(200)); GPIO_ResetBits(GPIOC, GPIO_Pin_13); vTaskDelay(pdMS_TO_TICKS(800)); } } void Print_Task(void *pvParameters) { TickType_t lastWake = xTaskGetTickCount(); while (1) { printf("Tick: %lu\r\n", (unsigned long)xTaskGetTickCount()); vTaskDelayUntil(&lastWake, pdMS_TO_TICKS(1000)); } }

如果 LED 按 1 秒周期闪烁(亮 200ms 灭 800ms),串口每秒打印一次,说明调度器工作正常。如果 LED 闪烁频率明显不对,可能是configCPU_CLOCK_HZ填错了,导致 tick 周期不准。

vTaskDelayUntil和vTaskDelay的区别值得说一下。vTaskDelay是"相对延时",从调用时刻起延时指定 tick 数;vTaskDelayUntil是"绝对延时",保证任务以固定周期执行,不受任务执行时间波动影响。做周期性任务(比如采样)时应该用vTaskDelayUntil,这样周期才稳定。

7.2 查看任务运行状态

FreeRTOS 提供了一个vTaskList函数,可以打印所有任务的名称、状态、优先级、剩余栈空间。要使用它,需要在FreeRTOSConfig.h里打开两个配置:

#define configUSE_TRACE_FACILITY 1 #define configUSE_STATS_FORMATTING_FUNCTIONS 1

然后定义一个足够大的字符缓冲区:

char taskListBuffer[512]; vTaskList(taskListBuffer); printf("%s\r\n", taskListBuffer);

输出大概长这样:

Name State Prio Stack Num LED R 2 100 1 USART B 3 200 2 IDLE R 0 60 3 Tmr Svc B 2 180 4

State 列里 R 是就绪/运行,B 是阻塞,S 是挂起。Stack 列是剩余栈空间(单位字),如果某个任务这个值很小,就要警惕栈溢出。这个工具在调试阶段非常实用,能一眼看出哪个任务在占 CPU、哪个任务栈快满了。

7.3 系统节拍精度的实测方法

SysTick 的精度直接影响所有延时和超时的准确性。验证方法很简单:用xTaskGetTickCount读 tick 计数,跟一个外部精确时钟对比。比如让一个任务每 1000 个 tick 翻转一次 IO,用示波器或者逻辑分析仪测 IO 的周期,如果是标准的 1 秒,说明节拍准确。

如果测出来偏差较大,检查两个地方:一是configCPU_CLOCK_HZ是否跟实际主频一致;二是SystemCoreClock变量是否被正确更新。标准库的SystemInit函数会设置SystemCoreClock,但如果你用的是寄存器版本或者自己写的时钟初始化,要确保这个变量跟实际频率同步。

还有一个细节:SysTick 的重装载值是根据configCPU_CLOCK_HZ / configTICK_RATE_HZ算出来的。72MHz 除以 1000 等于 72000,这个值小于 24 位计数器的最大值 16777215,所以没问题。但如果你把configTICK_RATE_HZ设得特别高,比如 100000,那 72000 就变成 720,虽然还能用,但中断太频繁会拖慢系统。

8. 从能跑到跑得好的进阶思路

8.1 优先级分配的经验法则

任务优先级怎么分,是 RTOS 应用设计的核心问题。我的原则是:按实时性要求分配,而不是按重要性分配。实时性要求高的任务(比如电机控制、通信协议解析)给高优先级;实时性要求低的(比如 LED 显示、日志打印)给低优先级。

具体到 STM32F103 这种资源有限的芯片,我一般这样分:

优先级用途
7紧急事件处理(如故障保护)
5-6硬实时任务(通信、控制)
3-4普通业务任务(数据处理)
1-2低速任务(显示、按键)
0空闲任务(系统占用)

注意 FreeRTOS 的优先级数值越大优先级越高,这跟某些 RTOS 是反的。另外,如果两个任务优先级相同,它们会按时间片轮转,每个 tick 切换一次。时间片轮转适合两个同等重要的任务,但会增加切换开销,能不用就不用。

8.2 队列和信号量的选择

任务间通信有两种基本方式:队列和信号量。队列用来传数据,信号量用来同步。但实际用的时候经常混。

队列的本质是一块环形缓冲区,xQueueSend往尾部写,xQueueReceive从头部读。如果队列满了,发送方会阻塞(或者超时返回);如果队列空了,接收方会阻塞。这个阻塞机制就是任务同步的基础。

信号量分二值信号量和计数信号量。二值信号量只有 0 和 1 两个状态,常用来做"事件通知"——中断里 give,任务里 take。计数信号量可以累加,常用来管理资源池,比如有 5 个缓冲区,就初始化一个计数为 5 的信号量,用之前 take,用完 give。

还有一个互斥信号量(Mutex),专门用来保护共享资源。它跟二值信号量的区别是支持优先级继承——如果低优先级任务持有互斥量,高优先级任务来抢,低优先级任务会临时提升到高优先级,避免优先级反转。用 I2C、SPI 这类共享总线时,互斥量是标配。

8.3 中断和任务的职责划分

一个常见的架构问题是:中断里该做多少事?我的原则是中断里只做最紧急、最简短的操作,剩下的交给任务。比如串口接收,中断里只把数据丢进队列,解析协议、处理命令这些耗时操作放到任务里做。

这样做的好处是中断响应快,不会因为处理复杂逻辑而阻塞其他中断。代价是需要一个队列来缓冲数据,以及一个任务来消费数据。对于 STM32F103 这种 SRAM 紧张的芯片,队列长度要控制好,太长浪费内存,太短容易丢数据。

如果数据量很大,可以用 DMA + 空闲中断的方式:DMA 负责搬运数据,空闲中断负责通知任务"一帧数据收完了"。这样 CPU 几乎不参与数据搬运,效率最高。不过 DMA 的配置相对复杂,新手可以先从简单的中断 + 队列方案入手,跑通了再优化。

8.4 低功耗场景下的 tickless 模式

如果项目是电池供电的,FreeRTOS 的 tickless 模式能显著降低功耗。默认情况下,SysTick 每 1ms 中断一次,即使所有任务都在阻塞,CPU 也会被周期性唤醒,白白耗电。tickless 模式会在空闲时关掉 SysTick,让 CPU 进入低功耗模式,等到下一个任务该唤醒时再通过其他定时器唤醒。

开启方法是在FreeRTOSConfig.h里设configUSE_TICKLESS_IDLE为 1,并实现vPortSuppressTicksAndSleep函数(或者用移植层提供的默认实现)。STM32F103 支持睡眠、停止、待机三种低功耗模式,tickless 通常配合停止模式使用。

不过 tickless 模式会增加系统复杂度,调试也更麻烦。如果项目对功耗要求不高,不建议开。我个人的经验是:先用默认的 tick 模式把功能跑通,等产品定型了再考虑低功耗优化。

9. 我踩过的几个真实坑和解决过程

9.1 串口打印乱码,查了半天是时钟配置

有一次移植完,LED 闪得正常,但串口打印全是乱码。第一反应是波特率不对,检查了串口初始化代码,115200 没问题。又怀疑是晶振,换了板子还是一样。最后用示波器测串口波形,发现实际波特率大概是设定值的两倍。

顺着这个线索查下去,发现是SystemInit里把外部晶振当成了 8MHz,但实际板子上焊的是 12MHz 晶振。时钟树算出来的系统频率是 72MHz,但实际跑到了 108MHz(超频了),所以串口分频系数不对。改掉晶振频率宏定义之后,一切正常。

这个坑的教训是:移植 FreeRTOS 之前,先确保裸机程序本身是正常的。如果裸机的串口、定时器都不准,移植 RTOS 之后问题只会更复杂。我现在的习惯是,拿到新板子先用裸机点灯、串口打印、定时器中断各验证一遍,确认硬件和时钟没问题,再上 RTOS。

9.2 任务创建失败,堆不够了

另一个坑是xTaskCreate返回errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY,任务创建失败。查了一下,是configTOTAL_HEAP_SIZE设得太小,创建了三个任务之后堆就耗尽了。

FreeRTOS 提供了xPortGetFreeHeapSize和xPortGetMinimumEverFreeHeapSize两个函数,可以查当前剩余堆和历史最小剩余堆。我在main里创建完所有任务后打印这两个值,发现剩余堆只有几百字节,历史最小值几乎为 0,说明堆给少了。

解决办法是把configTOTAL_HEAP_SIZE从 6KB 加到 10KB,同时把不用的任务栈减小。改完之后剩余堆有 3KB 左右,历史最小值也有 1KB 以上,比较安全。

这里有个经验:堆的大小要留有余量。因为除了任务栈,队列、信号量、事件组这些内核对象也要从堆里分配。如果你算出来刚好够用,实际跑起来可能因为碎片或者临时分配而失败。留 20%~30% 的余量比较稳妥。

9.3 中断优先级配错导致系统随机死机

最诡异的一次是系统跑着跑着随机死机,有时候几分钟,有时候几小时。用调试器抓,发现死在configASSERT里,但看调用栈又看不出明显问题。

后来把configASSERT的断言信息打印出来,发现是vPortValidateInterruptPriority触发的。这个函数会检查当前中断的优先级是否允许调用 FreeRTOS API。查了一下,是定时器 3 的中断优先级配成了 2,而configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY是 5,优先级 2 高于 5,不允许调用 FreeRTOS API。但我在定时器中断里调用了xQueueSendFromISR,所以偶尔触发断言。

把定时器中断优先级改成 6 之后,问题消失。这个坑的隐蔽性在于:它不一定每次都触发,取决于中断发生的时机。如果中断发生时内核正好在临界区,就会断言失败;如果不在临界区,可能就蒙混过关了。所以这种问题表现为"随机死机",很难复现。

教训是:所有调用 FreeRTOS API 的中断,优先级数值必须大于等于configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY。配置中断的时候养成习惯,先想清楚这个中断要不要调 FreeRTOS API,要调的话优先级就不能太高。

9.4 栈溢出导致的玄学问题

还有一次是某个任务偶尔跑飞,现象是串口输出乱码、变量值莫名其妙改变。查了很久没找到原因,后来打开configCHECK_FOR_STACK_OVERFLOW设为 2,触发了栈溢出钩子函数,才发现是那个任务的栈给小了。

那个任务里定义了一个比较大的局部数组,加上函数调用深度,实际需要的栈超过了分配的值。栈溢出之后覆盖了相邻内存区域的数据,导致各种玄学现象。把栈从 128 加到 256 之后,问题解决。

这个坑的教训是:局部变量大的任务,栈要给够。特别是那些在栈上定义数组、结构体的任务,一定要估算好。用uxTaskGetStackHighWaterMark定期检查,比事后排查省事得多。

10. 移植完成之后还能做什么

移植跑通只是第一步,后面还有很多可以深挖的方向。比如可以研究一下 FreeRTOS 的软件定时器,它能在不占用额外硬件定时器的情况下实现多个定时任务;可以研究事件组,它能实现多个任务等待多个事件组合的复杂同步逻辑;还可以研究流缓冲区(Stream Buffer)和消息缓冲区(Message Buffer),它们比队列更适合传输字节流。

如果项目对实时性要求极高,可以研究一下 FreeRTOS 的抢占式调度和时间片轮转的细节,调整configUSE_TIME_SLICING和configUSE_PREEMPTION来优化响应时间。如果对内存敏感,可以研究heap_5的多区域内存管理,把不同速度的 RAM 分开管理。

我个人在实际项目中的体会是:FreeRTOS 在 STM32F103 上跑,最关键的三个配置是configCPU_CLOCK_HZ、configTOTAL_HEAP_SIZE和中断优先级。这三个配对了,系统基本就稳了;配错了,就会出现各种莫名其妙的问题。所以每次新建工程,我都会先把这三个配置反复确认一遍,再开始写业务代码。

最后分享一个小技巧:在main函数启动调度器之前,把所有任务的栈大小、优先级、堆使用情况打印出来,存成一份"基线"。以后每次改代码,对比一下这份基线,如果堆使用量突然增加很多,或者某个任务栈剩余量骤降,就能提前发现问题,不用等到系统跑飞了再查。这个习惯帮我省了很多调试时间。

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

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

立即咨询