FreeRTOS进阶篇(二)——内核移植
FreeRTOS进阶篇(二)——内核移植
前言:
基础篇(二)介绍了 FreeRTOS 的工程结构,这一篇开始动手:以 M144Z-M3 最小系统板(STM32F103ZET6)为例,使用 FreeRTOS-LTS 202604.01-LTS 的内核,从一个能点灯的裸机工程出发,手动完成 FreeRTOS 内核的移植。移植完成后,两个 LED 会以不同周期闪烁。
1 准备工作
1.1 硬件与软件环境
本文使用的硬件和软件环境如下:
| 项目 | 说明 |
|---|---|
| 开发板 | M144Z-M3 最小系统板 STM32F103 版 |
| MCU | STM32F103ZET6,Cortex-M3,512 KB Flash,64 KB SRAM,最高 72 MHz |
| LED | LED0:PB5(红色),LED1:PE5(绿色),低电平点亮 |
| 工具链 | arm-none-eabi-gcc |
| 构建系统 | CMake + Make/Ninja |
| 基础库 | STM32CubeF1 HAL;使用 StdPeriph 也可以,移植步骤相同 |
| FreeRTOS | FreeRTOS-LTS 202604.01-LTS,内核 V11.3.1 |
| 下载调试 | ST-Link/DAP/J-Link + OpenOCD,或你现有的下载方式 |
如果你用的是其他 STM32F103 板子,只需要把 LED 引脚、Flash/SRAM 大小和启动文件换成自己的,移植步骤不变。
1.2 先准备一个能运行的裸机工程
移植 FreeRTOS 之前,先确认裸机工程本身是正常的:能编译、能下载、能运行。建议用 STM32CubeMX 生成一个只包含 HAL 的裸机工程,配置如下:
- 芯片选择
STM32F103ZETx。 - 时钟树配置为 72 MHz(HSE 8 MHz 经 PLL 倍频)。
- 打开 SWD 调试接口。
- 配置 PB5、PE5 为推挽输出,用于板载 LED0/LED1。
- 把
System Core -> SYS -> Timebase Source从SysTick改为TIM6(或任意一个未使用的定时器)。 - 不要勾选 FreeRTOS。本文要手动移植,如果 CubeMX 已经生成了 FreeRTOS 中间件,建议新建一个纯裸机工程,避免两套内核混在一起。
为什么要把 HAL 时基改成 TIM6?因为 FreeRTOS 的 Cortex-M 端口要独占 SysTick 作为系统节拍。如果 HAL 也把 SysTick 当作 1 ms 时基,两边就会争用同一个中断。把 HAL 时基换到 TIM6 后,HAL_Delay() 仍然可用,SysTick 则完整交给 FreeRTOS。CubeMX 会因此生成 stm32f1xx_hal_timebase_tim.c,中断服务函数通过 HAL_TIM_IRQHandler() 最终调用 HAL_IncTick(),不会调用 FreeRTOS API。要确认这个文件也在编译源文件列表中,否则 HAL 仍会回退到 SysTick 时基。
对于 STM32F103ZET6 这类大容量产品,还要注意工程里的器件宏和启动文件:
| 库 | 器件宏 | 启动文件 |
|---|---|---|
| HAL / CubeMX | STM32F103xE |
startup_stm32f103xe.s |
| StdPeriph | STM32F10X_HD |
startup_stm32f10x_hd.s |
链接脚本按 512 KB Flash、64 KB SRAM 配置。如果这里选错成 STM32F10X_MD 或中容量启动文件,程序可能能编译,但中断向量表条目不完整,Flash/RAM 容量也会对不上。
1.3 获取 LTS 源码并拷贝内核
使用长期支持版,按基础篇(二)中的方式获取源码,并锁定到具体补丁标签:
1 | mkdir -p ~/work |
截至 2026 年 10 月,202604.01-LTS 是最新的 LTS 补丁,它把内核锁定在 V11.3.1。主线的 FreeRTOS-Kernel 仓库已经发布了更新的 V11.3.2(2026-10-16),但 V11.3.2 不属于 202604-LTS 这组经过验证的固定版本组合。本文以 LTS 为例,是为了和基础篇保持一致,也便于产品项目按 LTS 的维护周期升级;如果你更想用主线最新内核,把 mw/FreeRTOS-Kernel 换成 V11.3.2 后重新编译、测试即可,移植步骤和配置方法不变。
然后只把内核仓库拷贝到自己的工程中。下面用 $PROJECT_DIR 表示你的 STM32 工程根目录,用 $FREERTOS_LTS 表示刚克隆的 FreeRTOS-LTS 目录;把这两个变量换成你自己的实际路径:
1 | export PROJECT_DIR=~/work/stm32f103-freertos |
$PROJECT_DIR 应该是已经能编译、下载的 STM32 裸机工程根目录;如果还没有,先按 1.2 生成一个,再继续后面的步骤。本文约定 FreeRTOS 作为中间件放在 mw 目录,配置放在 cfg 目录;后面的 CMake 命令都以这个工程根目录为基准。
FreeRTOS-Kernel 在 LTS 仓库里是子模块,拷贝过来的 .git 是一个文件而不是目录;如果要把内核纳入自己的 Git 管理,需要去掉它和 .github 目录:
1 | rm -rf mw/FreeRTOS-Kernel/.git mw/FreeRTOS-Kernel/.github |
建议保留 FreeRTOS-Kernel 原有的目录结构,只把需要的 .c 文件加入编译,不要急于物理删除文件。这样以后升级内核时可以直接对比官方目录,减少合并成本。移植相关的内容只有三类:内核源码、portable 移植层、FreeRTOSConfig.h 配置,尽量不要修改内核源码。
拷贝完成后的相关目录如下(省略了应用层和芯片驱动):
1 | your-project/ |
2 选择移植层和内存方案
2.1 选择 port
STM32F103ZET6 是 Cortex-M3 内核,使用 arm-none-eabi-gcc 编译,因此选择 GCC 的 Cortex-M3 端口:
1 | mw/FreeRTOS-Kernel/portable/GCC/ARM_CM3/port.c |
port.c 需要加入编译,portmacro.h 只需要加入头文件搜索路径。不同工具链的对应关系如下:
| 工具链 | Cortex-M3 端口目录 |
|---|---|
arm-none-eabi-gcc |
portable/GCC/ARM_CM3 |
| Keil MDK(ARM Compiler 5) | portable/RVDS/ARM_CM3 |
| Keil MDK(ARM Compiler 6 / armclang) | portable/GCC/ARM_CM3(ARMClang 复用 GCC 端口) |
| IAR EWARM | portable/IAR/ARM_CM3 |
选择 port 时,编译器、内核架构和 FPU/MPU/TrustZone 特性必须匹配。STM32F103 没有 FPU 和 TrustZone,直接用 ARM_CM3 即可。
2.2 选择内存管理方案
FreeRTOS 的 portable/MemMang 提供了 heap_1 到 heap_5 五种动态内存方案。本文选择最常用的 heap_4:它支持分配和释放,会合并相邻空闲块,碎片比 heap_2 少,适合任务、队列在运行中动态创建和删除的场景。
1 | mw/FreeRTOS-Kernel/portable/MemMang/heap_4.c |
heap_4 使用 configTOTAL_HEAP_SIZE 指定堆大小。STM32F103ZET6 有 64 KB SRAM,示例先给 16 KB,后续根据任务数量和栈大小调整。如果只用静态分配,可以完全不选 heap_x.c,但本文使用动态分配。
2.3 需要加入编译的文件
第一次移植先把下面这些文件全部加入编译,等系统稳定后再考虑裁剪:
| 文件 | 是否必需 | 说明 |
|---|---|---|
tasks.c |
必需 | 任务管理和调度核心 |
queue.c |
通常必需 | 队列、信号量、互斥量的基础,实际工程几乎都会用到 |
list.c |
必需 | 内核链表 |
timers.c |
按需 | 软件定时器,configUSE_TIMERS=1 时需要 |
event_groups.c |
按需 | 事件组,configUSE_EVENT_GROUPS=1 时需要 |
stream_buffer.c |
按需 | 流/消息缓冲区,configUSE_STREAM_BUFFERS=1 时需要 |
portable/GCC/ARM_CM3/port.c |
必需 | Cortex-M3 移植层 |
portable/MemMang/heap_4.c |
动态分配时必需 | 内存管理 |
croutine.c 是协程支持,属于历史特性,新项目不需要加入。
3 把内核加入工程
3.1 CMake 配置
在工程根目录的 CMakeLists.txt 中,把内核源文件加入可执行目标,并添加头文件路径。下面的 ${CMAKE_PROJECT_NAME} 要换成你工程里实际的可执行目标名:
1 | set(FREERTOS_KERNEL_DIR ${CMAKE_CURRENT_SOURCE_DIR}/mw/FreeRTOS-Kernel) |
这里有三个容易遗漏的点:
cfg必须加入头文件路径,否则编译时找不到FreeRTOSConfig.h。portable/GCC/ARM_CM3也必须加入头文件路径,否则port.c找不到portmacro.h。- 工程原有的
-mcpu=cortex-m3 -mthumb、器件宏和链接脚本不要动;FreeRTOS 只要求编译器按 Cortex-M3 生成代码。
如果你的工程使用 CubeMX 生成的 CMake 结构,把上面的源文件和路径加到实际的可执行目标里即可。若不想手动列文件,也可以直接 add_subdirectory(mw/FreeRTOS-Kernel) 使用内核自带的 CMake 目标,但需要额外设置 FREERTOS_PORT=GCC_ARM_CM3、FREERTOS_HEAP=4 和 freertos_config 目标。本文为了看清移植过程,采用手动列文件的方式。
应用层文件也不要忘记加入目标。CubeMX 工程通常已经把 Core/Src/main.c 加进去了;如果按本文新建了 app/freertos_hooks.c,需要把它也加入 target_sources,否则会报钩子函数未定义。
3.2 Keil / IAR 用户
如果使用 Keil 或 IAR,不需要 CMake,按同样的文件清单把 .c 文件加入工程,并把 include、对应的 port 目录和 cfg 加入头文件搜索路径即可。注意编译器要和 port 目录匹配:Keil ARM Compiler 5 用 RVDS/ARM_CM3,ARM Compiler 6 用 GCC/ARM_CM3,IAR 用 IAR/ARM_CM3。
4 配置 FreeRTOSConfig.h
4.1 最小可用配置
官方模板在 mw/FreeRTOS-Kernel/examples/template_configuration/FreeRTOSConfig.h,内容很长。第一次移植可以先用下面这份精简配置,把系统跑起来,后续再按需打开其他功能:
1 |
|
这份配置按功能分成几组,下面逐项说明。第一次移植不要急着打开所有可选项,先保证调度器、节拍、堆和中断优先级这几项正确。
调度器行为
| 配置 | 示例值 | 说明 |
|---|---|---|
configUSE_PREEMPTION |
1 | 抢占式调度。1:高优先级任务就绪时立即抢占当前任务;0:合作式调度,任务必须主动让出 CPU。移植后一般保持 1。 |
configUSE_TIME_SLICING |
1 | 同优先级任务按 tick 轮流执行。1 表示每个 tick 切换一次;仅在 configUSE_PREEMPTION=1 时有效,设为 0 后同优先级任务不会自动轮转。 |
configUSE_PORT_OPTIMISED_TASK_SELECTION |
1 | 使用 Cortex-M3 的 CLZ 指令配合就绪位图查找最高优先级任务,速度快;要求 configMAX_PRIORITIES <= 32。设为 0 时使用通用 C 实现,优先级数量不受 32 限制,但选择稍慢。 |
configUSE_TICKLESS_IDLE |
0 | 低功耗无节拍模式。1 时空闲时关闭 tick 中断以省电,需要移植层支持并在睡眠前后补偿时间;第一次移植保持 0。 |
configIDLE_SHOULD_YIELD |
1 | 空闲任务是否让出 CPU。1:有空闲优先级(0)的应用任务就绪时,空闲任务立即让出,避免争抢同一时间片;0:空闲任务运行完整个时间片。 |
时钟和节拍
| 配置 | 示例值 | 说明 |
|---|---|---|
configCPU_CLOCK_HZ |
72000000UL |
SysTick 的计数时钟,必须等于实际 HCLK(通常是 SystemCoreClock)。示例为 72 MHz;修改时钟树后要同步修改,否则节拍周期会成比例偏差。 |
configTICK_RATE_HZ |
1000 | 每秒 tick 数,1000 表示 1 个 tick = 1 ms。频率越高延时粒度越细,但 tick 中断开销越大;常用 100~1000 Hz。 |
configTICK_TYPE_WIDTH_IN_BITS |
TICK_TYPE_WIDTH_32_BITS |
TickType_t 的位宽。Cortex-M3 用 32 位,1000 Hz 下约 49.7 天回绕;16 位已过时,64 位会增加 RAM 和运算开销。 |
任务和内存
| 配置 | 示例值 | 说明 |
|---|---|---|
configMAX_PRIORITIES |
5 | 优先级数量,有效范围是 0~configMAX_PRIORITIES-1,数值越大优先级越高。每个优先级对应一条就绪链表,数量越多占用的 RAM 越大。 |
configMINIMAL_STACK_SIZE |
128 | 空闲任务的栈大小,单位是字(word),不是字节。Cortex-M3 上 1 字 = 4 字节,128 字 = 512 字节;空闲钩子或调试输出占用较多栈时要调大。 |
configMAX_TASK_NAME_LEN |
16 | 任务名的最大长度,包含结尾的 \0,超长会被截断。名字保存在每个任务的 TCB 中,值越大每个任务占用的 RAM 越多。 |
configTOTAL_HEAP_SIZE |
(16 * 1024) |
heap_4 管理的堆总大小,单位是字节。这块内存在 .bss 中静态预留,实际分配多少都占用同样的 SRAM;空闲任务、定时器任务、任务栈和队列也都从这个堆里分配,需要为全局变量和主栈留出余量。 |
configSUPPORT_STATIC_ALLOCATION |
0 | 是否启用静态创建 API(xTaskCreateStatic、xQueueCreateStatic 等)。置 1 后还要为内核对象提供静态内存;本文全部动态创建,保持 0。 |
configSUPPORT_DYNAMIC_ALLOCATION |
1 | 是否启用动态创建 API(xTaskCreate、xQueueCreate 等),依赖 heap_x.c。它和静态分配至少要有一个为 1;本文使用 heap_4,所以为 1。 |
通信、同步和可选组件
| 配置 | 示例值 | 说明 |
|---|---|---|
configUSE_MUTEXES |
1 | 启用互斥量和优先级继承(xSemaphoreCreateMutex 等)。任务之间需要保护共享资源时必须打开。 |
configUSE_RECURSIVE_MUTEXES |
1 | 启用递归互斥量(xSemaphoreCreateRecursiveMutex),同一任务可以多次获取。依赖 configUSE_MUTEXES=1。 |
configUSE_COUNTING_SEMAPHORES |
1 | 启用计数信号量(xSemaphoreCreateCounting),可用于资源计数或事件计数。 |
configUSE_QUEUE_SETS |
0 | 启用队列集(xQueueCreateSet 等),可以同时等待多个队列或信号量。普通工程很少用到,保持 0 可省代码和 RAM。 |
configUSE_TASK_NOTIFICATIONS |
1 | 启用任务通知(xTaskNotifyGive、ulTaskNotifyTake 等),比队列和信号量更轻量。每个任务会为此多占一小块 RAM;不用时可以关掉。 |
configUSE_TIMERS |
1 | 启用软件定时器(xTimerCreate 等)。调度器启动时会创建定时器服务任务和命令队列,timers.c 也必须加入编译。 |
configTIMER_TASK_PRIORITY |
(configMAX_PRIORITIES - 1) |
定时器服务任务的优先级,必须小于 configMAX_PRIORITIES,示例取最高优先级,保证回调及时执行。回调运行在定时器任务上下文,不能阻塞。 |
configTIMER_QUEUE_LENGTH |
5 | 定时器命令队列长度,即最多缓存多少条启动、停止、复位命令。队列满时定时器 API 返回 pdFAIL;频繁操作定时器时应加大。 |
configTIMER_TASK_STACK_DEPTH |
128 | 定时器服务任务的栈大小,单位是字,128 字 = 512 字节。回调里使用 printf 或较大的局部变量时要调大。 |
configUSE_EVENT_GROUPS |
1 | 启用事件组(xEventGroupCreate 等),需要把 event_groups.c 加入编译。不用时设为 0,可以省掉这部分代码。 |
configUSE_STREAM_BUFFERS |
1 | 启用流缓冲区和消息缓冲区(xStreamBufferCreate、xMessageBufferCreate),需要把 stream_buffer.c 加入编译。 |
钩子和调试
| 配置 | 示例值 | 说明 |
|---|---|---|
configUSE_IDLE_HOOK |
0 | 1 时每个空闲循环调用 vApplicationIdleHook(),常用于进入低功耗或做后台清理;钩子里不能阻塞。 |
configUSE_TICK_HOOK |
0 | 1 时每个 tick 中断调用 vApplicationTickHook()。它在中断上下文执行,必须短小,只能调用 FromISR 类 API。 |
configUSE_MALLOC_FAILED_HOOK |
1 | 1 时 pvPortMalloc() 失败会调用 vApplicationMallocFailedHook(),需要在应用层实现。移植阶段打开便于发现堆不够。 |
configCHECK_FOR_STACK_OVERFLOW |
2 | 栈溢出检查级别。0 不检查;1 在任务切换时检查栈指针是否越界;2 在 1 的基础上检查栈尾的填充图案,能更早发现溢出,但增加少量切换开销。需要实现 vApplicationStackOverflowHook()。 |
configUSE_TRACE_FACILITY |
0 | 启用任务状态追踪,以及 uxTaskGetSystemState()、vTaskGetInfo() 等 API。会为每个任务增加 TCB 字段,第一次移植保持 0。 |
configGENERATE_RUN_TIME_STATS |
0 | 统计每个任务的运行时间,需要额外的高频定时器,并实现 portGET_RUN_TIME_COUNTER_VALUE() 等宏。会增加调度开销,按需打开。 |
configUSE_STATS_FORMATTING_FUNCTIONS |
0 | 启用 vTaskList()、vTaskGetRunTimeStats() 这类格式化输出函数。依赖 trace facility 或运行时间统计,还会引入 sprintf 的代码和栈开销。 |
中断优先级
| 配置 | 示例值 | 说明 |
|---|---|---|
configPRIO_BITS |
4 | NVIC 实际实现的优先级位数。STM32F103 为 4 位(16 级),必须和芯片一致,否则移位计算和断言都会出错。 |
configLIBRARY_LOWEST_INTERRUPT_PRIORITY |
15 | 最低优先级(数值最大)。结合 configPRIO_BITS 换算成 configKERNEL_INTERRUPT_PRIORITY,用作 SysTick、PendSV 等内核异常的优先级。 |
configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY |
5 | 允许调用 FreeRTOS FromISR API 的最高优先级(数值最小)。数值比它更小的中断优先级更高,不会被 FreeRTOS 临界区屏蔽,因此不能调用任何 FreeRTOS API;它结合 configPRIO_BITS 换算成 configMAX_SYSCALL_INTERRUPT_PRIORITY。 |
configKERNEL_INTERRUPT_PRIORITY |
15 << 4 = 0xF0 |
内核异常实际写入 NVIC 的优先级,由 configLIBRARY_LOWEST_INTERRUPT_PRIORITY 左移 8 - configPRIO_BITS 位得到。 |
configMAX_SYSCALL_INTERRUPT_PRIORITY |
5 << 4 = 0x50 |
写入 BASEPRI 的门限值,由 configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 左移 8 - configPRIO_BITS 位得到。FreeRTOS 临界区会屏蔽优先级数值不低于该值的中断。 |
优先级分组和 FromISR 的具体使用限制见 4.2 节。
断言
configASSERT(x) 在 x 为假时执行断言体。示例中直接进入 for(;;),方便在调试器里打断点查看调用栈。移植阶段建议保留;正式发布可以改成记录错误、复位或定义为空。调度器启动时也会用它检查配置和中断优先级是否合法。
可选 API
INCLUDE_* 宏控制对应的可选 API 是否参与编译。设为 0 可以减小代码体积,但调用未启用的 API 会链接失败。本文把常用的 API 都打开,方便后续实验:
| 配置 | 说明 |
|---|---|
INCLUDE_vTaskPrioritySet |
启用 vTaskPrioritySet(),运行时修改任务优先级。 |
INCLUDE_uxTaskPriorityGet |
启用 uxTaskPriorityGet(),查询任务优先级。 |
INCLUDE_vTaskDelete |
启用 vTaskDelete(),删除任务。 |
INCLUDE_vTaskSuspend |
启用 vTaskSuspend()、vTaskResume();xTaskResumeFromISR() 还要求打开 INCLUDE_xTaskResumeFromISR。 |
INCLUDE_xTaskDelayUntil |
启用 xTaskDelayUntil()(旧名 vTaskDelayUntil()),按固定周期延时。 |
INCLUDE_vTaskDelay |
启用 vTaskDelay(),相对延时。 |
INCLUDE_xTaskGetSchedulerState |
启用 xTaskGetSchedulerState(),查询调度器状态。 |
INCLUDE_xTaskGetCurrentTaskHandle |
启用 xTaskGetCurrentTaskHandle(),获取当前任务句柄。 |
INCLUDE_uxTaskGetStackHighWaterMark |
启用 uxTaskGetStackHighWaterMark(),查询任务栈高水位。 |
INCLUDE_xTaskGetIdleTaskHandle |
启用 xTaskGetIdleTaskHandle(),获取空闲任务句柄。 |
INCLUDE_eTaskGetState |
启用 eTaskGetState(),查询任务状态。 |
INCLUDE_xTimerPendFunctionCall |
启用 xTimerPendFunctionCall()、xTimerPendFunctionCallFromISR(),把函数调用交给定时器任务执行;依赖 configUSE_TIMERS=1。 |
INCLUDE_xTaskAbortDelay |
启用 xTaskAbortDelay(),提前结束正在延时的任务。 |
INCLUDE_xTaskGetHandle |
启用 xTaskGetHandle(),按任务名查找任务句柄。 |
INCLUDE_xTaskResumeFromISR |
启用 xTaskResumeFromISR(),在中断中恢复任务;需要同时打开 INCLUDE_vTaskSuspend。 |
中断处理函数
最后四个宏负责把内核端口的中断处理函数接到 STM32 的向量表上:
| 配置 | 说明 |
|---|---|
vPortSVCHandler → SVC_Handler |
把 port.c 中的 SVC 处理函数重命名为启动文件的 SVC_Handler,用于启动第一个任务。 |
xPortPendSVHandler → PendSV_Handler |
重命名为 PendSV_Handler,用于任务上下文切换。 |
xPortSysTickHandler → SysTick_Handler |
重命名为 SysTick_Handler,用于产生系统节拍。 |
configCHECK_HANDLER_INSTALLATION |
置 1 时,启动调度器会检查向量表中的 SVC 和 PendSV 是否指向端口实现,不检查 SysTick(因为节拍也可以由其它定时器产生)。该检查依赖 configASSERT,使用 Bootloader 或重映射向量表时还要确保 SCB->VTOR 指向当前应用。 |
这三个改名宏的具体处理方式见 5.2 节。上面没有列出的宏并不代表不能配置:一部分宏在 FreeRTOS.h 中有默认值,另一部分只在打开对应功能时才会被引用。第一次移植先保持这份最小配置,等系统稳定后再按需逐项打开。
新版内核使用 configTICK_TYPE_WIDTH_IN_BITS,不再使用 configUSE_16_BIT_TICKS。如果从旧工程拷贝配置,这两个宏不能同时定义,否则 FreeRTOS.h 会直接报错。模板里如果还有 configUSE_16_BIT_TICKS,把它删掉或注释掉。
如果暂时使用 CubeMX 默认的 HSI 8 MHz、没有配置 PLL,就把 configCPU_CLOCK_HZ 改成 8000000UL。这个值必须和 SystemCoreClock 一致,否则 LED 的闪烁周期会成比例偏差,甚至影响 SysTick 的配置。
4.2 STM32F103 的中断优先级
STM32F103 的 NVIC 实现了 4 位优先级,优先级数值范围是 0~15,数值越小优先级越高。FreeRTOS 要求把所有优先级位都配置成抢占优先级,也就是使用 NVIC_PRIORITYGROUP_4。HAL_Init() 默认会设置这个分组;如果你自己的工程没有调用 HAL_Init(),需要在启动调度器前执行:
1 | HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_4); |
如果使用 StdPeriph 库,对应的函数是 NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4);。
配置中的两个中断优先级含义如下:
| 宏 | 示例值 | 含义 |
|---|---|---|
configKERNEL_INTERRUPT_PRIORITY |
15 << 4 = 0xF0 |
SysTick 和 PendSV 使用的最低优先级 |
configMAX_SYSCALL_INTERRUPT_PRIORITY |
5 << 4 = 0x50 |
允许调用 FreeRTOS FromISR API 的优先级上限 |
对于 STM32F103,可以这样理解:NVIC 优先级数值为 5~15 的中断,可以调用 xxxFromISR();数值为 0~4 的中断优先级更高,不受 FreeRTOS 临界区屏蔽,因此不能调用任何 FreeRTOS API。比如 HAL_NVIC_SetPriority(USART1_IRQn, 5, 0) 是安全的,而设置为 0 后再调用 xQueueSendFromISR() 就可能导致系统异常。
configASSERT 在调度器启动时会检查这些优先级配置。如果程序停在 configASSERT 的 for(;;) 里,优先检查优先级分组和 configMAX_SYSCALL_INTERRUPT_PRIORITY 的值。
4.3 配置检查清单
在编译前,先对照下面几项检查一遍:
configCPU_CLOCK_HZ和SystemCoreClock一致。- 只定义
configTICK_TYPE_WIDTH_IN_BITS,没有同时定义configUSE_16_BIT_TICKS。 configTOTAL_HEAP_SIZE是字节数,任务栈和configMINIMAL_STACK_SIZE是字数。configUSE_TIMERS=1时,timers.c已加入编译。configUSE_EVENT_GROUPS=1时,event_groups.c已加入编译。configUSE_STREAM_BUFFERS=1时,stream_buffer.c已加入编译。configCHECK_FOR_STACK_OVERFLOW=2时,实现了vApplicationStackOverflowHook()。configUSE_MALLOC_FAILED_HOOK=1时,实现了vApplicationMallocFailedHook()。configCHECK_HANDLER_INSTALLATION=1时,已经定义了configASSERT。- NVIC 优先级分组为
NVIC_PRIORITYGROUP_4。
5 处理中断向量
5.1 冲突从哪里来
FreeRTOS 的 Cortex-M3 端口在 port.c 中实现了三个异常处理函数:
vPortSVCHandler():启动第一个任务时使用。xPortPendSVHandler():任务上下文切换。xPortSysTickHandler():系统节拍中断。
而 STM32 工程的 stm32f1xx_it.c 里通常也会有 SVC_Handler()、PendSV_Handler()、SysTick_Handler() 三个空函数。两边同时存在,要么链接时报重复定义,要么链接器保留了错误的实现,导致调度器启动后直接 HardFault。
5.2 推荐做法:SysTick 交给 FreeRTOS,HAL 时基用 TIM6
按 1.2 节的配置,HAL 时基已经改成 TIM6,SysTick 可以完整交给 FreeRTOS。此时在 FreeRTOSConfig.h 中保留这三个宏:
1 |
它们的作用是把 port.c 里的 vPortSVCHandler 等函数重命名为 STM32 启动文件里的向量名。然后打开 stm32f1xx_it.c,把这三个空函数注释掉或删除:
1 | // void SVC_Handler(void) |
启动文件 startup_stm32f103xe.s 里对这三个函数有弱定义(weak),port.c 通过宏重命名后提供强定义,链接器会用强定义覆盖弱定义,所以不需要修改启动文件。TIM6_IRQHandler() 继续负责 HAL 的 1 ms 时基,HAL_Delay() 仍然可用。
注意:CubeMX 重新生成代码时可能会把这三个空函数重新加回来,每次重新生成后都要检查一遍。
5.3 如果不改 HAL 时基
如果暂时不想把 HAL 时基改成 TIM6,也有两种补救方式,但都不如上面的方案干净:
- 方式一:把
configUSE_TICK_HOOK设为 1,实现vApplicationTickHook(),在里面调用HAL_IncTick()。这种方式依赖configTICK_RATE_HZ=1000才能保持HAL_Delay()的毫秒语义,而且调度器启动前的初始化阶段可能还没有正确的 HAL 时基。 - 方式二:保留
stm32f1xx_it.c里的SysTick_Handler(),在里面先调用HAL_IncTick(),再调用xPortSysTickHandler()。这属于间接路由,需要把configCHECK_HANDLER_INSTALLATION设为 0,并自行声明xPortSysTickHandler(),比直接路由更容易出错。
如果使用 StdPeriph 库,没有 HAL 时基的问题:直接把 stm32f10x_it.c 里的 SVC_Handler()、PendSV_Handler()、SysTick_Handler() 注释掉即可。原来基于 SysTick 的 delay_ms() 一类函数在调度器启动后不要再使用,统一换成 vTaskDelay()。
5.4 向量表和优先级分组
使用直接路由时,不需要修改启动文件的向量表,但要注意两点:
configCHECK_HANDLER_INSTALLATION设为 1 时,xPortStartScheduler()会检查向量表里的 SVC 和 PendSV 是否指向端口函数。如果使用了 Bootloader 或重映射了向量表,要确保SCB->VTOR指向当前应用的向量表。- NVIC 优先级分组必须是
NVIC_PRIORITYGROUP_4,否则 FreeRTOS 计算出的中断优先级会不正确,表现为调度器启动时触发configASSERT或运行中随机 HardFault。
6 编写测试任务
6.1 配置 LED 引脚
M144Z-M3 的 LED0 接 PB5,LED1 接 PE5,低电平点亮。如果 CubeMX 生成的 MX_GPIO_Init() 里还没有这两个引脚,可以这样配置:
1 | static void MX_GPIO_Init(void) |
6.2 main.c 和测试任务
在 app/main.c 中创建两个任务,分别以 500 ms 和 1000 ms 的周期翻转 LED。如果你使用 CubeMX 工程,把任务代码放在 Core/Src/main.c 的 USER CODE 区域即可,目录名不影响移植:
1 |
|
注意 FreeRTOS.h 必须放在 task.h 前面。任务栈 128 的单位是字,即 512 字节;简单的 LED 任务够用,如果后面要用 printf() 或较大的局部变量,需要调大到 256 甚至 512。Error_Handler() 是 CubeMX 生成的错误处理函数;如果你的工程没有,换成一个 for(;;) 或自己的故障处理即可。
如果使用 StdPeriph 库,把 HAL_GPIO_TogglePin() 换成 GPIO_WriteBit() 或 GPIO_SetBits()/GPIO_ResetBits() 即可,任务创建、延时和调度部分完全一样。
6.3 钩子函数
配置里打开了栈溢出检查和堆分配失败钩子,所以必须实现这两个函数。可以放在 app/freertos_hooks.c:
1 |
|
调试时可以在 for(;;) 处打断点,观察是哪个任务溢出了栈,或者确认是不是堆太小。正式产品中可以在这里记录错误、复位或点亮故障指示灯。
6.4 编译和下载
按工程原来的方式配置和编译,例如:
1 | cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug |
然后用 ST-Link、DAP 或 OpenOCD 把生成的 .elf 下载到板子上。如果一切正常,可以看到:
- LED0(PB5,红色)每 500 ms 翻转一次。
- LED1(PE5,绿色)每 1000 ms 翻转一次。
- 两个 LED 以不同节奏闪烁,说明两个任务都在被调度器正常调度。
也可以在两个任务函数里打断点,确认程序能分别进入 vLed0Task() 和 vLed1Task()。如果调度器启动后程序停在 configASSERT、HardFault 或 vApplicationMallocFailedHook(),先看下一节的排查表。
7 调试与常见问题
7.1 验证点
移植完成后,建议按下面的顺序确认:
- 工程能编译通过,没有重复定义或未定义符号。
- 程序能正常下载并运行,没有一上电就 HardFault。
-
vTaskStartScheduler()没有返回。 - LED0 和 LED1 按不同周期闪烁。
- 两个任务都能在调试器中命中断点。
-
xPortGetFreeHeapSize()还有余量。 - 任务栈的高水位标记没有接近 0。
7.2 常见问题
| 现象 | 常见原因 | 处理 |
|---|---|---|
编译找不到 FreeRTOSConfig.h |
cfg 目录没有加入头文件路径 |
在 CMake 的 target_include_directories 中加入 cfg |
链接报 vApplicationStackOverflowHook 未定义 |
开了栈溢出检查但没实现钩子 | 实现钩子,或把 configCHECK_FOR_STACK_OVERFLOW 设为 0 |
链接报 vApplicationMallocFailedHook 未定义 |
开了堆失败钩子但没实现 | 实现钩子,或把 configUSE_MALLOC_FAILED_HOOK 设为 0 |
报错 Missing definition: configTICK_TYPE_WIDTH_IN_BITS |
旧配置只定义了 configUSE_16_BIT_TICKS |
改用 configTICK_TYPE_WIDTH_IN_BITS |
报错 Only one of configUSE_16_BIT_TICKS and configTICK_TYPE_WIDTH_IN_BITS... |
两个宏同时定义 | 删除 configUSE_16_BIT_TICKS |
链接报 SVC_Handler / PendSV_Handler / SysTick_Handler 重复定义 |
port.c 和 stm32f1xx_it.c 都实现了 |
注释掉 stm32f1xx_it.c 中的三个空函数 |
链接报 vPortSVCHandler 未定义 |
port.c 没有加入编译,或 port 选择错误 |
加入 portable/GCC/ARM_CM3/port.c |
| 调度器启动即 HardFault,但没有重复定义 | 没有定义 vPortSVCHandler 等宏,向量表仍指向空函数 |
在 FreeRTOSConfig.h 中定义三个重命名宏 |
| 调度器启动即 HardFault | 中断向量冲突、NVIC 分组错误、优先级配置非法 | 检查三个异常函数、NVIC_PRIORITYGROUP_4 和优先级宏 |
configASSERT 停在优先级检查处 |
configMAX_SYSCALL_INTERRUPT_PRIORITY 为 0 或包含未实现位 |
使用 5 << 4,并确认 NVIC 分组为 4 |
vTaskStartScheduler() 返回 |
堆太小,空闲任务或定时器任务创建失败 | 增大 configTOTAL_HEAP_SIZE,检查堆失败钩子 |
HAL_Delay() 卡死 |
HAL 时基仍占用 SysTick,而 SysTick 已被 FreeRTOS 接管 | 把 HAL 时基改为 TIM6,或改用 vTaskDelay() |
| LED 不闪 | GPIO 没配置、任务创建失败、调度器没启动 | 检查 PB5/PE5、xTaskCreate() 返回值和 vTaskStartScheduler() |
中断里调用 FromISR 后异常 |
中断优先级数值小于 configMAX_SYSCALL_INTERRUPT_PRIORITY |
把 NVIC 优先级设为 5 或更大 |
xTaskCreate() 返回 errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY |
堆不够 | 增大 configTOTAL_HEAP_SIZE |
7.3 调试技巧
移植阶段最值得关注的三个指标是堆、栈和断言:
- 在创建完任务后调用
xPortGetFreeHeapSize(),看堆还剩多少。如果接近 0,说明configTOTAL_HEAP_SIZE需要调大。 - 在任务里调用
uxTaskGetStackHighWaterMark(NULL),返回值是任务栈从创建以来剩余的最小字数。如果接近 0,说明任务栈太小。 - 在
configASSERT的for(;;)处打断点,能快速定位优先级、配置和内核状态相关的错误。 - 如果打开了
configUSE_TRACE_FACILITY和configUSE_STATS_FORMATTING_FUNCTIONS,还可以用vTaskList()打印任务状态;第一次移植先不用开,等系统跑起来再说。 - 遇到 HardFault 时,先在调试器里看调用栈和
SCB->CFSR、SCB->HFSR,再回头检查异常向量和栈溢出。很多 HardFault 不是内核 bug,而是中断函数装错或任务栈溢出。
8 移植后的维护
移植成功后,建议保持下面的工程习惯:
- 不要修改内核源码。需要改行为时优先改
FreeRTOSConfig.h或应用层;确有必要修改内核时,记录下来,升级时逐条对比。 - 配置和内核分开。
FreeRTOSConfig.h放在cfg或应用配置目录,不放在FreeRTOS-Kernel里面,升级时只需对比配置文件。 - 保留官方目录结构。只通过编译哪些
.c文件来裁剪,不轻易物理删除portable和include下的文件。 - 锁定 LTS 版本。产品开发使用
YYYYMM.XX-LTS标签,升级前看CHANGELOG.md和History.txt,升级后重新跑任务、栈和堆的检查。 - 记录 port 和 heap 选择。例如“GCC + Cortex-M3 + heap_4 + TIM6 作为 HAL 时基”,后续换芯片或换工具链时能快速定位差异。
本文只移植了 FreeRTOS 内核,TCP/IP、文件系统、MQTT 等组件按需再添加。内核跑起来之后,就可以继续学习任务管理、队列、信号量、事件组和内存管理了。
9 小结
本文以 STM32F103ZET6 为例,从能点灯的裸机工程出发,完成了 FreeRTOS-LTS 内核的手动移植。核心步骤可以概括为:
- 准备一个能运行的裸机工程,确认时钟、启动文件、器件宏和下载流程正常。
- 获取 LTS 源码,把
FreeRTOS-Kernel拷贝到mw,配置文件放到cfg。 - 选择
GCC/ARM_CM3移植层和heap_4内存方案。 - 把内核源文件和头文件路径加入 CMake。
- 配置
FreeRTOSConfig.h,重点检查时钟、节拍、优先级、堆栈和中断优先级。 - 处理
SVC_Handler、PendSV_Handler、SysTick_Handler的冲突,推荐把 HAL 时基改为 TIM6。 - 创建测试任务,编译下载,用板载 LED 验证调度器运行。
- 通过堆、栈和断言检查系统是否健康。
移植本身并不复杂,真正容易出问题的是工程细节:启动文件选错、中断向量重复、优先级分组不对、堆栈太小。把这几项逐一确认,FreeRTOS 就能稳定跑起来。









