19、多核处理器的启动流程:主核与从核的同步

多核处理器,说白了就是一块芯片上放了多个CPU核心。

你可能会想:那它们怎么启动?是一起跑,还是一个一个来?

答案是:必须有一个主次顺序。我做过好几款多核芯片的底层启动,每次都要跟这个同步问题打交道。今天咱们就把它彻底讲透。

19.1 主核与从核的基本分工

多核启动的核心思路很简单:

  • 主核(Boot Core / Primary Core):负责整个系统的初始化,包括时钟、DDR、中断控制器、外设等。它先跑起来。
  • 从核(Secondary Core / Slave Core):等待主核发信号,然后才启动自己的执行流。

为什么不能所有核一起启动?

嗯,这里有个关键问题:如果多个核同时去配置同一个硬件寄存器,结果就是乱套。比如两个核同时写DDR控制器,谁说了算?

所以必须由主核统一指挥,从核乖乖等着。

核心原则:多核启动 = 主核先跑 + 从核等待 + 同步握手。

19.2 典型的多核启动流程

我以ARM Cortex-A系列为例,讲一下最常见的启动步骤。其他架构(RISC-V、MIPS)逻辑类似,只是寄存器名字不同。

  1. 上电复位:所有核心同时从复位向量地址开始执行。
  2. 核心身份识别:每个核读取自己的核心ID(如MPIDR寄存器),判断自己是主核还是从核。
  3. 主核执行初始化:主核继续跑,配置时钟、DDR、中断、MMU等。
  4. 从核进入低功耗等待:从核执行一个WFI(Wait For Interrupt)或自旋循环,等待主核唤醒。
  5. 主核发送唤醒信号:主核通过SEV指令或写特定寄存器,唤醒从核。
  6. 从核跳转到入口函数:从核被唤醒后,读取主核指定的启动地址,跳转执行。

你看,整个过程就像一场接力赛。主核先跑第一棒,然后把接力棒交给从核。

19.3 核心身份识别:谁当主核?

每个核心都有一个唯一的硬件ID。在ARM中,这个ID存在MPIDR寄存器里。

通常,ID为0的核心被指定为主核。其他核心都是从核。

代码实现很简单:

// 伪代码:核心身份识别
uint32_t core_id = read_mpidr();
if (core_id == 0) {
    // 我是主核
    primary_main();
} else {
    // 我是从核
    secondary_wait();
}

我在项目中遇到过一个问题:某款芯片的MPIDR寄存器布局不是标准的,导致从核误判成了主核。结果两个核同时去初始化DDR,系统直接挂掉。排查了好久才发现是硬件手册写错了。

注意:不同芯片厂商的MPIDR实现可能有差异。一定要仔细看芯片手册,不要想当然。

19.4 从核的等待机制

从核在等待期间不能闲着乱跑,否则会干扰主核的初始化。常见的等待方式有两种:

方式 原理 功耗 适用场景
自旋等待(Spin-loop) 不断轮询一个内存变量 高(CPU一直跑) 简单、实时性要求高
WFI等待 进入低功耗状态,等待中断唤醒 省电场景

我个人习惯在启动阶段用自旋等待。为什么?因为启动时不在乎那点功耗,但响应速度要快。等系统跑起来后,再切换到WFI模式省电。

自旋等待的代码长这样:

// 从核自旋等待
volatile uint32_t *boot_flag = (uint32_t *)0x80000000;
volatile uint32_t *boot_addr = (uint32_t *)0x80000004;

while (*boot_flag == 0) {
    // 空转,等待主核写1
    __asm__("wfe");  // 有些架构用WFE代替WFI,更轻量
}

// 主核已经写好了启动地址,跳过去执行
void (*entry)(void) = (void (*)(void))(*boot_addr);
entry();

这里有个坑:boot_flagboot_addr 必须放在所有核心都能访问到的共享内存区域。如果放在某个核的私有Cache里,其他核根本看不到更新。

避坑指南:我曾经把启动标志放在主核的本地SRAM里,结果从核一直读不到更新。后来改成DDR里的共享区域,问题解决。记住:共享变量一定要用volatile,并且考虑Cache一致性。

19.5 主核唤醒从核的机制

主核完成初始化后,需要通知从核开始干活。不同架构的唤醒方式不同:

  • ARM:使用SEV(Send Event)指令,或者写GIC(通用中断控制器)的SGI(软件生成中断)。
  • RISC-V:通过CLINT(核心本地中断器)的IPI(核间中断)机制。
  • DSP:通常通过写特定的控制寄存器。

以ARM为例,主核的唤醒代码:

// 主核:设置从核的启动地址
*((uint32_t *)0x80000004) = (uint32_t)secondary_entry;
__asm__("dsb");  // 确保写操作完成

// 设置启动标志
*((uint32_t *)0x80000000) = 1;
__asm__("dsb");
__asm__("sev");  // 发送事件,唤醒从核

注意这里的 dsb 指令。它保证前面的写操作在内存系统中可见。没有它,从核可能读到旧数据。

我见过一个案例:工程师忘了加 dsb,结果从核被唤醒后读到的启动地址是0,直接跳到了空指针。嗯,这种bug非常难查。

19.6 多核启动的同步点

整个启动过程中,有几个关键的同步点:

  1. 复位后身份识别:所有核同时读ID,但只有主核继续。
  2. 从核等待就绪:从核必须等到主核完成初始化。
  3. 主核唤醒从核:主核发信号,从核响应。
  4. 从核跳转执行:从核开始跑自己的任务。

每个同步点都需要硬件或软件机制来保证顺序。我习惯用一张图来理解:

多核启动同步流程 主核(Core 0) 读取MPIDR 初始化时钟/DDR 设置启动地址 发送SEV唤醒 从核(Core 1~N) 读取MPIDR 自旋等待标志 读取启动地址 跳转执行任务 同步点1:身份识别 同步点2:共享变量 同步点3:SEV唤醒 时间 →

这张图把主核和从核的流程并列展示。你可以看到,三个同步点把整个启动过程串联起来。任何一个同步点出问题,系统都起不来。

19.7 实际项目中的注意事项

做了这么多年底层,我总结了几条多核启动的实战经验:

  • 共享变量必须加volatile:编译器优化可能把轮询变量优化成死循环。
  • 注意Cache一致性:如果从核的Cache里缓存了旧值,它永远看不到主核的更新。必要时可以关闭从核的Cache,或者使用Cache清理指令。
  • 启动地址要对齐:有些架构要求启动地址4字节对齐或8字节对齐,不对齐会触发异常。
  • 从核的栈要独立:每个从核必须有自己独立的栈空间,不能共用。否则函数调用会互相覆盖。

警告:千万不要在主核初始化完成之前,让从核去访问外设。从核可能读到未初始化的寄存器值,导致不可预知的行为。

19.8 小结

多核启动的核心就是「主核指挥,从核等待」。主核负责全局初始化,从核通过自旋或WFI等待信号。同步点必须用硬件或软件机制保证。

我个人觉得,多核启动的难点不在于代码有多复杂,而在于你能否把时序和同步想清楚。只要把主从关系理清,剩下的就是填代码了。

嗯,这一章就到这里。记住:多核启动,同步第一。


公众号:蓝海资料掘金营,微信deep3321