FreeRTOS 多核移植:从 SMP 配置到调度器跑起来
好,咱们直接进入正题。这一章要聊的是 FreeRTOS 多核移植,说白了就是让 FreeRTOS 能在多个 CPU 核心上同时跑任务。我最早接触多核移植是在一个工业控制项目上,双核 ARM Cortex-A5,当时想着「不就是把单核的配置改改嘛」——结果折腾了两周才把两个核都唤醒。嗯,这里面的坑,咱们一个一个填。
FreeRTOS SMP 配置:打开多核的钥匙
FreeRTOS 默认是单核版本,要让它支持多核,得先打开 SMP 配置。这个开关在 FreeRTOSConfig.h 里:
/* 启用 SMP 支持 */
#define configUSE_SMP 1
/* 指定 CPU 核心数量 */
#define configNUMBER_OF_CORES 2
/* 每个核心的优先级位数 */
#define configMAX_PRIORITIES 32
我个人习惯在项目初期就把 configUSE_SMP 设为 1,哪怕你暂时只用一个核。为什么?因为一旦后续要加核,改配置比改代码安全得多。我在一个项目中吃过这个亏——前期没开 SMP,后期加核时发现所有任务创建函数都得加参数,改得头皮发麻。
| 宏定义 | 作用 | 典型值 |
|---|---|---|
| configUSE_SMP | 启用多核调度 | 1 |
| configNUMBER_OF_CORES | 声明可用核心数 | 2 / 4 / 8 |
| configRUN_MULTIPLE_PRIORITIES | 允许不同优先级任务并行 | 1 |
| configUSE_CORE_AFFINITY | 启用 CPU 亲和性 | 1 |
这里有个容易忽略的点:configNUMBER_OF_CORES 只是告诉 FreeRTOS「你有几个核」,但实际唤醒核心的工作,还得靠底层启动代码。别指望配了这个宏,第二个核就自己跑起来了——没那么智能。
多核启动流程:从 Reset 到所有核就绪
多核启动,说白了就是「先让一个核干活,再让它把其他核叫醒」。以常见的 ARM Cortex-A 系列为例,启动流程大概是这样的:
- 主核(Core 0)从 Reset 向量启动,执行汇编初始化,设置栈指针、清零 BSS 段。
- 主核初始化系统时钟、MMU、GIC 中断控制器,这些外设是所有核共享的。
- 主核调用
portNXP_SMP_Startup(),这个函数会向从核发送 SGI(软件生成中断),触发从核启动。 - 从核(Core 1..N)收到 SGI 后,跳转到
vPortStartFirstTask(),开始执行 FreeRTOS 调度器。 - 所有核都进入调度循环,开始抢任务执行。
我曾经在一个项目里遇到从核死活不响应 SGI 的问题。查了两天,最后发现是 GIC 的 distributor 没有使能从核的中断接口。你想想看,中断信号发出去了,但接收端没开「门」,当然没反应。
我曾经在 Zynq-7000 上移植时,发现从核启动后立即崩溃。后来定位到是每个核的栈指针没有独立设置。记住:每个核心必须有自己独立的栈空间,不能共用主核的栈。
下面这张图展示了多核启动的完整流程:
CPU 亲和性设置:把任务绑在指定核上
多核系统里,不是所有任务都适合「哪个核空闲就上哪个核」。比如中断处理任务,最好绑在指定核上,避免缓存抖动。FreeRTOS SMP 提供了亲和性接口:
/* 创建任务时指定亲和性 */
TaskHandle_t xTaskHandle;
xTaskCreate(vTaskFunction, "Task1", 1024, NULL, 1, &xTaskHandle);
/* 设置任务只能在 Core 0 上运行 */
vTaskCoreAffinitySet(xTaskHandle, 0x01);
/* 设置任务可以在 Core 0 和 Core 1 上运行 */
vTaskCoreAffinitySet(xTaskHandle, 0x03);
亲和性掩码是个位图:bit 0 对应 Core 0,bit 1 对应 Core 1,以此类推。我个人习惯把时间关键型任务绑在 Core 0,把后台处理任务放开给所有核。这样既保证了关键任务的响应速度,又充分利用了多核性能。
亲和性设置不是「强制绑定」,而是「允许运行在哪些核上」。如果你把所有任务都绑在 Core 0 上,那多核就白费了。我曾经见过一个项目,工程师把 8 个任务全绑在 Core 0 上,Core 1 到 Core 3 全程空闲——那还不如用单核呢。
任务调度器初始化:让所有核都跑起来
调度器初始化是多核移植的最后一步。单核下调用 vTaskStartScheduler() 就完事了,多核下需要额外处理:
/* 主核调用 */
void main(void)
{
/* 硬件初始化 */
prvSetupHardware();
/* 创建任务 */
xTaskCreate(vTaskA, "TaskA", 256, NULL, 1, NULL);
xTaskCreate(vTaskB, "TaskB", 256, NULL, 2, NULL);
/* 启动调度器 —— 这会唤醒所有核 */
vTaskStartScheduler();
/* 永远不会执行到这里 */
for(;;);
}
在 FreeRTOS SMP 中,vTaskStartScheduler() 内部会做三件事:
- 创建空闲任务和定时器服务任务(每个核一份)。
- 初始化每个核的调度数据结构(就绪列表、延时列表等)。
- 向从核发送 SGI,让它们跳转到
vPortStartFirstTask()。
这里有个细节:每个核都有自己的 pxCurrentTCB(当前运行任务控制块),但共享同一个就绪列表。调度器在分配任务时,会检查任务的亲和性掩码,只把它放到允许的核的就绪队列里。
我在 Renode 上调试多核启动时,喜欢在
vPortStartFirstTask() 入口处加一个 GPIO 翻转。这样从示波器上就能看到每个核是否真的跑起来了。Renode 里可以用 gpio 命令模拟这个行为,比看日志直观得多。
最后提一句:多核调度器的初始化顺序很重要。必须先让主核完成所有初始化,再唤醒从核。如果从核在主核还没准备好就抢着跑,轻则死锁,重则内存踩踏。嗯,这个坑我踩过,不想让你们再踩一次。