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
2
3
4
5
6
mkdir -p ~/work
cd ~/work
git clone --recurse-submodules https://github.com/FreeRTOS/FreeRTOS-LTS.git
cd FreeRTOS-LTS
git checkout 202604.01-LTS
git submodule update --init --recursive

  截至 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
2
3
4
5
6
7
export PROJECT_DIR=~/work/stm32f103-freertos
export FREERTOS_LTS=~/work/FreeRTOS-LTS

cd "$PROJECT_DIR"
mkdir -p mw cfg
cp -r "$FREERTOS_LTS/FreeRTOS/FreeRTOS-Kernel" mw/
cp mw/FreeRTOS-Kernel/examples/template_configuration/FreeRTOSConfig.h cfg/

  $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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
your-project/
├── app/
│ ├── main.c
│ └── freertos_hooks.c
├── cfg/
│ └── FreeRTOSConfig.h
├── mw/
│ └── FreeRTOS-Kernel/
│ ├── tasks.c
│ ├── queue.c
│ ├── list.c
│ ├── timers.c
│ ├── event_groups.c
│ ├── stream_buffer.c
│ ├── include/
│ └── portable/
│ ├── GCC/ARM_CM3/
│ │ ├── port.c
│ │ └── portmacro.h
│ └── MemMang/heap_4.c
├── Drivers/
├── CMakeLists.txt
└── ...

2 选择移植层和内存方案

2.1 选择 port

  STM32F103ZET6 是 Cortex-M3 内核,使用 arm-none-eabi-gcc 编译,因此选择 GCC 的 Cortex-M3 端口:

1
2
mw/FreeRTOS-Kernel/portable/GCC/ARM_CM3/port.c
mw/FreeRTOS-Kernel/portable/GCC/ARM_CM3/portmacro.h

  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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
set(FREERTOS_KERNEL_DIR ${CMAKE_CURRENT_SOURCE_DIR}/mw/FreeRTOS-Kernel)

target_sources(${CMAKE_PROJECT_NAME} PRIVATE
# FreeRTOS 内核核心。
${FREERTOS_KERNEL_DIR}/tasks.c
${FREERTOS_KERNEL_DIR}/queue.c
${FREERTOS_KERNEL_DIR}/list.c
${FREERTOS_KERNEL_DIR}/timers.c
${FREERTOS_KERNEL_DIR}/event_groups.c
${FREERTOS_KERNEL_DIR}/stream_buffer.c

# 内存管理和 Cortex-M3 移植层。
${FREERTOS_KERNEL_DIR}/portable/MemMang/heap_4.c
${FREERTOS_KERNEL_DIR}/portable/GCC/ARM_CM3/port.c
)

target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE
${FREERTOS_KERNEL_DIR}/include
${FREERTOS_KERNEL_DIR}/portable/GCC/ARM_CM3
${CMAKE_CURRENT_SOURCE_DIR}/cfg
)

  这里有三个容易遗漏的点:

  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
#ifndef FREERTOS_CONFIG_H
#define FREERTOS_CONFIG_H

/* 调度器配置。 */
#define configUSE_PREEMPTION 1
#define configUSE_TIME_SLICING 1
#define configUSE_PORT_OPTIMISED_TASK_SELECTION 1
#define configUSE_TICKLESS_IDLE 0
#define configIDLE_SHOULD_YIELD 1

/* 时钟和节拍。STM32F103 主频 72 MHz,节拍 1 kHz。 */
#define configCPU_CLOCK_HZ 72000000UL
#define configTICK_RATE_HZ 1000
#define configTICK_TYPE_WIDTH_IN_BITS TICK_TYPE_WIDTH_32_BITS

/* 任务和内存。 */
#define configMAX_PRIORITIES 5
#define configMINIMAL_STACK_SIZE 128
#define configMAX_TASK_NAME_LEN 16
#define configTOTAL_HEAP_SIZE (16 * 1024)
#define configSUPPORT_STATIC_ALLOCATION 0
#define configSUPPORT_DYNAMIC_ALLOCATION 1

/* 通信、同步和可选组件。 */
#define configUSE_MUTEXES 1
#define configUSE_RECURSIVE_MUTEXES 1
#define configUSE_COUNTING_SEMAPHORES 1
#define configUSE_QUEUE_SETS 0
#define configUSE_TASK_NOTIFICATIONS 1
#define configUSE_TIMERS 1
#define configTIMER_TASK_PRIORITY (configMAX_PRIORITIES - 1)
#define configTIMER_QUEUE_LENGTH 5
#define configTIMER_TASK_STACK_DEPTH 128
#define configUSE_EVENT_GROUPS 1
#define configUSE_STREAM_BUFFERS 1

/* 钩子和调试。 */
#define configUSE_IDLE_HOOK 0
#define configUSE_TICK_HOOK 0
#define configUSE_MALLOC_FAILED_HOOK 1
#define configCHECK_FOR_STACK_OVERFLOW 2
#define configUSE_TRACE_FACILITY 0
#define configGENERATE_RUN_TIME_STATS 0
#define configUSE_STATS_FORMATTING_FUNCTIONS 0

/* Cortex-M3 NVIC 优先级。STM32F103 使用 4 位优先级。 */
#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))

/* 断言:调试时在 for(;;) 处打断点。 */
#define configASSERT( x ) \
do { if( ( x ) == 0 ) { for( ;; ) { } } } while( 0 )

/* 可选 API。 */
#define INCLUDE_vTaskPrioritySet 1
#define INCLUDE_uxTaskPriorityGet 1
#define INCLUDE_vTaskDelete 1
#define INCLUDE_vTaskSuspend 1
#define INCLUDE_xTaskDelayUntil 1
#define INCLUDE_vTaskDelay 1
#define INCLUDE_xTaskGetSchedulerState 1
#define INCLUDE_xTaskGetCurrentTaskHandle 1
#define INCLUDE_uxTaskGetStackHighWaterMark 1
#define INCLUDE_xTaskGetIdleTaskHandle 1
#define INCLUDE_eTaskGetState 1
#define INCLUDE_xTimerPendFunctionCall 1
#define INCLUDE_xTaskAbortDelay 1
#define INCLUDE_xTaskGetHandle 1
#define INCLUDE_xTaskResumeFromISR 1

/* 中断处理函数:使用 FreeRTOS 的 Cortex-M3 端口。 */
#define vPortSVCHandler SVC_Handler
#define xPortPendSVHandler PendSV_Handler
#define xPortSysTickHandler SysTick_Handler
#define configCHECK_HANDLER_INSTALLATION 1

#endif /* FREERTOS_CONFIG_H */

  这份配置按功能分成几组,下面逐项说明。第一次移植不要急着打开所有可选项,先保证调度器、节拍、堆和中断优先级这几项正确。

调度器行为

配置 示例值 说明
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
2
3
#define vPortSVCHandler SVC_Handler
#define xPortPendSVHandler PendSV_Handler
#define xPortSysTickHandler SysTick_Handler

  它们的作用是把 port.c 里的 vPortSVCHandler 等函数重命名为 STM32 启动文件里的向量名。然后打开 stm32f1xx_it.c,把这三个空函数注释掉或删除:

1
2
3
4
5
6
7
8
9
10
11
// void SVC_Handler(void)
// {
// }

// void PendSV_Handler(void)
// {
// }

// void SysTick_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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static void MX_GPIO_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};

__HAL_RCC_GPIOB_CLK_ENABLE();
__HAL_RCC_GPIOE_CLK_ENABLE();

/* 先输出高电平,让两个 LED 熄灭。 */
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_5, GPIO_PIN_SET);
HAL_GPIO_WritePin(GPIOE, GPIO_PIN_5, GPIO_PIN_SET);

GPIO_InitStruct.Pin = GPIO_PIN_5;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;

HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);
HAL_GPIO_Init(GPIOE, &GPIO_InitStruct);
}

6.2 main.c 和测试任务

  在 app/main.c 中创建两个任务,分别以 500 ms 和 1000 ms 的周期翻转 LED。如果你使用 CubeMX 工程,把任务代码放在 Core/Src/main.c 的 USER CODE 区域即可,目录名不影响移植:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include "main.h"
#include "FreeRTOS.h"
#include "task.h"

static TaskHandle_t s_led0_task = NULL;
static TaskHandle_t s_led1_task = NULL;

static void vLed0Task(void *argument)
{
(void)argument;

for (;;)
{
HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_5);
vTaskDelay(pdMS_TO_TICKS(500));
}
}

static void vLed1Task(void *argument)
{
(void)argument;

for (;;)
{
HAL_GPIO_TogglePin(GPIOE, GPIO_PIN_5);
vTaskDelay(pdMS_TO_TICKS(1000));
}
}

int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();

if (xTaskCreate(vLed0Task, "LED0", 128, NULL, 2, &s_led0_task) != pdPASS)
{
Error_Handler();
}

if (xTaskCreate(vLed1Task, "LED1", 128, NULL, 1, &s_led1_task) != pdPASS)
{
Error_Handler();
}

vTaskStartScheduler();

/* 正常情况下不会执行到这里;返回说明调度器启动失败。 */
for (;;)
{
}
}

  注意 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include "FreeRTOS.h"
#include "task.h"

void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName)
{
(void)xTask;
(void)pcTaskName;

taskDISABLE_INTERRUPTS();
for (;;)
{
}
}

void vApplicationMallocFailedHook(void)
{
taskDISABLE_INTERRUPTS();
for (;;)
{
}
}

  调试时可以在 for(;;) 处打断点,观察是哪个任务溢出了栈,或者确认是不是堆太小。正式产品中可以在这里记录错误、复位或点亮故障指示灯。

6.4 编译和下载

  按工程原来的方式配置和编译,例如:

1
2
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build -j

  然后用 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 内核的手动移植。核心步骤可以概括为:

  1. 准备一个能运行的裸机工程,确认时钟、启动文件、器件宏和下载流程正常。
  2. 获取 LTS 源码,把 FreeRTOS-Kernel 拷贝到 mw,配置文件放到 cfg。
  3. 选择 GCC/ARM_CM3 移植层和 heap_4 内存方案。
  4. 把内核源文件和头文件路径加入 CMake。
  5. 配置 FreeRTOSConfig.h,重点检查时钟、节拍、优先级、堆栈和中断优先级。
  6. 处理 SVC_Handler、PendSV_Handler、SysTick_Handler 的冲突,推荐把 HAL 时基改为 TIM6。
  7. 创建测试任务,编译下载,用板载 LED 验证调度器运行。
  8. 通过堆、栈和断言检查系统是否健康。

  移植本身并不复杂,真正容易出问题的是工程细节:启动文件选错、中断向量重复、优先级分组不对、堆栈太小。把这几项逐一确认,FreeRTOS 就能稳定跑起来。