第2章:SMP启动流程——从Boot ROM到多核齐飞
说实话,SMP启动这块,我当年刚接触NuttX时也踩过不少坑。你想想看,一个单核系统跑得好好的,突然要让它带好几个小弟一起干活,这中间的启动流程要是理不清,后面调试起来简直要命。今天我就把这块掰开了揉碎了讲给你听。
2.1 从Boot ROM到主核初始化
芯片上电后,第一件事是什么?不是跑操作系统,而是执行固化在芯片内部的Boot ROM代码。这段代码我们改不了,它是芯片厂商写死的。Boot ROM会做最基本的硬件初始化,比如设置时钟、配置内存控制器,然后跳转到用户指定的启动地址。
我个人习惯把启动过程分成三个阶段:
- 第一阶段:Boot ROM —— 芯片出厂自带的固件,负责加载启动镜像
- 第二阶段:Bootloader —— 比如U-Boot,负责加载NuttX内核
- 第三阶段:NuttX内核启动 —— 这才是我们真正要关心的
在NuttX里,主核(通常叫CPU0)会先跑起来。它要完成一系列初始化工作,包括:
- 设置异常向量表
- 初始化栈指针
- 清零BSS段
- 调用芯片级的初始化函数
- 最终跳转到
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启动时序是这样的:
- Boot ROM加载Bootloader
- Bootloader加载NuttX镜像到内存
- 主核开始执行
__start - 主核完成基础初始化(栈、BSS、MMU等)
- 主核调用
nuttx_start() nuttx_start()初始化调度器- 主核调用
up_cpu_start()唤醒从核 - 每个从核执行自己的
cpu_boot()函数 - 所有核就绪后,调度器开始分配任务
说实话,这个流程看起来简单,但每个步骤都可能出问题。我建议你在移植时,每完成一步就加一个调试输出,确保每一步都走通了再往下走。
我的经验:启动流程调试,最怕的就是「死得不明不白」。所以我会在关键位置放一些「心跳」——比如写一个特定的内存地址,或者翻转一个GPIO。这样用示波器一看就知道程序跑到哪一步了。
好了,SMP启动流程就讲到这里。下一章我们会深入SMP调度器的实现,看看任务是怎么在多核之间分配的。到时候你会看到,启动只是第一步,真正的挑战还在后面呢。