第2章:SMP启动流程——从Boot ROM到多核齐飞

说实话,SMP启动这块,我当年刚接触NuttX时也踩过不少坑。你想想看,一个单核系统跑得好好的,突然要让它带好几个小弟一起干活,这中间的启动流程要是理不清,后面调试起来简直要命。今天我就把这块掰开了揉碎了讲给你听。

2.1 从Boot ROM到主核初始化

芯片上电后,第一件事是什么?不是跑操作系统,而是执行固化在芯片内部的Boot ROM代码。这段代码我们改不了,它是芯片厂商写死的。Boot ROM会做最基本的硬件初始化,比如设置时钟、配置内存控制器,然后跳转到用户指定的启动地址。

我个人习惯把启动过程分成三个阶段:

  • 第一阶段:Boot ROM —— 芯片出厂自带的固件,负责加载启动镜像
  • 第二阶段:Bootloader —— 比如U-Boot,负责加载NuttX内核
  • 第三阶段:NuttX内核启动 —— 这才是我们真正要关心的

在NuttX里,主核(通常叫CPU0)会先跑起来。它要完成一系列初始化工作,包括:

  1. 设置异常向量表
  2. 初始化栈指针
  3. 清零BSS段
  4. 调用芯片级的初始化函数
  5. 最终跳转到 nuttx_start()

关键点:主核的入口函数是 __start,通常在 arch/arm/src/chip/chip_boot.c 里定义。不同架构的名字可能不一样,但逻辑大同小异。

2.2 从主核到从核的唤醒机制

主核初始化完成后,就该叫醒其他核了。这里有个问题——从核上电后默认是休眠状态,你得用特定方式唤醒它们。

我在项目中遇到过一种情况:某款芯片的从核唤醒方式特别奇葩,不是发IPI中断,而是要写一个特定的寄存器序列。当时查手册查了整整两天才搞定。嗯,这里要注意,不同芯片的唤醒机制差异很大,千万别想当然。

常见的唤醒方式有这几种:

唤醒方式 说明 典型芯片
IPI中断 主核发送核间中断,从核收到后跳转到指定地址 ARM GIC架构
Spin Table 主核在共享内存中写入从核的启动地址,从核轮询读取 ARM64架构
硬件寄存器 写特定寄存器触发从核复位并跳转 某些RISC-V芯片

在NuttX里,唤醒从核的代码通常在 up_cpu_start() 函数中。它会遍历所有从核,逐个发送唤醒信号。每个从核被唤醒后,会执行自己的入口函数。

小技巧:调试从核唤醒时,我习惯先在从核入口函数里点亮一个LED。这样一眼就能看出从核有没有跑起来,比看日志快多了。

2.3 CPU启动栈与入口函数

每个核都需要有自己的栈,这个不用我多说吧?但具体怎么分配,这里头有讲究。

NuttX里,每个CPU的启动栈是在链接脚本里预分配的。比如:

/* 在链接脚本中为每个CPU分配启动栈 */
. = ALIGN(16);
_cpu0_stack = .;
. += CONFIG_IDLETHREAD_STACKSIZE;
_cpu1_stack = .;
. += CONFIG_IDLETHREAD_STACKSIZE;
_cpu2_stack = .;
. += CONFIG_IDLETHREAD_STACKSIZE;
_cpu3_stack = .;
. += CONFIG_IDLETHREAD_STACKSIZE;

从核的入口函数长什么样?我直接给你看个典型实现:

void arm64_cpu_boot(int cpu)
{
    /* 设置当前CPU的栈指针 */
    up_set_stack(cpu);

    /* 初始化当前CPU的中断控制器 */
    up_cpu_irq_initialize(cpu);

    /* 启动调度器 */
    sched_cpu_start(cpu);

    /* 进入空闲循环 */
    while (1) {
        asm("wfi");
    }
}

这里有个坑,我曾经踩过:从核的入口函数里,绝对不能调用任何依赖全局变量已经初始化的函数。为什么?因为从核启动时,主核可能还没完成全局变量的初始化。你想想看,两个核同时访问未初始化的全局变量,那后果...

警告:从核入口函数中,printf 这类函数要慎用。因为控制台驱动可能还没初始化好,一调用就死机。我建议用 putchar 或者直接写寄存器来输出调试信息。

2.4 启动流程的完整时序

把上面这些串起来,完整的SMP启动时序是这样的:

  1. Boot ROM加载Bootloader
  2. Bootloader加载NuttX镜像到内存
  3. 主核开始执行 __start
  4. 主核完成基础初始化(栈、BSS、MMU等)
  5. 主核调用 nuttx_start()
  6. nuttx_start() 初始化调度器
  7. 主核调用 up_cpu_start() 唤醒从核
  8. 每个从核执行自己的 cpu_boot() 函数
  9. 所有核就绪后,调度器开始分配任务

说实话,这个流程看起来简单,但每个步骤都可能出问题。我建议你在移植时,每完成一步就加一个调试输出,确保每一步都走通了再往下走。

我的经验:启动流程调试,最怕的就是「死得不明不白」。所以我会在关键位置放一些「心跳」——比如写一个特定的内存地址,或者翻转一个GPIO。这样用示波器一看就知道程序跑到哪一步了。

好了,SMP启动流程就讲到这里。下一章我们会深入SMP调度器的实现,看看任务是怎么在多核之间分配的。到时候你会看到,启动只是第一步,真正的挑战还在后面呢。