11、SMP启动流程详解:从BootROM到RT-Thread SMP启动、汇编代码分析

各位同学,今天我们来聊聊多核启动这个“第一道坎”。

说实话,我第一次接触SMP启动时,被那个“主核先跑,从核等待”的流程搞得晕头转向。后来在调试一块四核Cortex-A7的板子时,从核死活不响应IPI中断,查了三天才发现是启动地址对齐出了问题。嗯,这些坑咱们今天一次性讲清楚。

11.1 从BootROM到RT-Thread:启动的“接力赛”

芯片上电后,谁先跑?当然是BootROM。它固化在芯片内部,负责最底层的硬件初始化。BootROM做完后,会跳转到用户指定的启动地址——通常是Flash或SRAM的起始位置。

这里有个关键点:BootROM只唤醒一个核心,也就是我们说的“主核”(Primary Core)。其他核心(从核)处于WFI或WFE状态,等待主核发信号唤醒。

启动流程概览:

  1. BootROM → 主核启动,从核休眠
  2. 主核执行Bootloader(如u-boot)
  3. Bootloader加载RT-Thread内核镜像
  4. RT-Thread内核初始化(主核完成)
  5. 主核唤醒从核,从核进入SMP启动流程

我个人习惯把这一步叫做“接力赛第一棒”。BootROM是芯片厂商写死的,我们改不了。但从Bootloader开始,就完全由我们掌控了。

11.2 主核启动:从Reset Vector到C环境

主核上电后,第一件事就是去Reset Vector取指令。这个地址通常是0x00000000或0xFFFF0000,具体看芯片设计。

我们来看看典型的启动汇编代码。以ARM Cortex-A系列为例:

; 启动代码入口
.section .text.startup
.globl _start
_start:
    ; 设置栈指针
    ldr sp, =__stack_top
    
    ; 关闭中断
    cpsid if
    
    ; 初始化BSS段
    ldr r0, =__bss_start
    ldr r1, =__bss_end
    mov r2, #0
bss_loop:
    cmp r0, r1
    strlo r2, [r0], #4
    blo bss_loop
    
    ; 跳转到C语言入口
    bl rt_hw_board_init

这段代码看着简单,但坑不少。我曾经在BSS清零时忘了检查边界条件,结果把栈空间也给清了,系统直接跑飞。你想想看,栈都没了,函数调用怎么可能正常?

避坑指南: 我建议在BSS清零前,先确认链接脚本中__bss_start和__bss_end的地址是否正确。可以用JTAG单步调试,打印这两个地址的值。

11.3 从核启动:主核的“唤醒术”

主核跑起来后,从核还在睡大觉。怎么唤醒它们?这取决于硬件架构。

对于ARM GIC(通用中断控制器)架构,通常使用SGI(软件生成中断)来唤醒从核。具体流程是:

  1. 主核为每个从核准备一个启动地址(通常是SRAM中的一段代码)
  2. 主核向从核发送SGI中断,中断号通常是0或1
  3. 从核收到中断后,从WFI/WFE状态醒来
  4. 从核读取启动地址,跳转执行

来看看RT-Thread中唤醒从核的代码片段:

void rt_hw_secondary_cpu_up(void)
{
    unsigned int cpu_id;
    
    for (cpu_id = 1; cpu_id < CONFIG_SMP_CORES; cpu_id++) {
        // 设置从核启动地址
        *((volatile unsigned int *)SMP_BOOT_ADDR) = (unsigned int)secondary_startup;
        
        // 发送SGI中断
        dsb();
        sev();
        
        // 等待从核确认
        while (!cpu_online(cpu_id)) {
            cpu_relax();
        }
    }
}

这里有个细节:dsb()sev()是内存屏障和事件发送指令。为什么需要内存屏障?因为主核写入启动地址后,从核可能因为缓存一致性看不到这个值。说白了,就是保证写入操作对其他核心可见。

注意: 不同架构的唤醒机制差异很大。比如RISC-V使用CLINT(核心本地中断器),x86使用IPI(处理器间中断)。移植时一定要查芯片手册,别想当然。

11.4 从核启动汇编:从核的“独立王国”

从核被唤醒后,执行的代码和主核类似,但有一些关键区别。我们来看secondary_startup的汇编实现:

; 从核启动入口
.section .text.secondary
.globl secondary_startup
secondary_startup:
    ; 设置从核栈指针
    ldr sp, =__secondary_stack_top
    
    ; 关闭本地中断
    cpsid if
    
    ; 初始化MMU(如果需要)
    bl mmu_init_secondary
    
    ; 使能缓存
    bl cache_enable
    
    ; 跳转到C语言入口
    bl rt_hw_secondary_cpu_init

注意看,从核的栈是独立的——__secondary_stack_top。为什么?因为每个核心都需要自己的栈空间,否则函数调用会互相覆盖。我在项目中就见过有人偷懒,让从核和主核共用栈,结果系统跑着跑着就莫名其妙崩溃了。

另外,从核的MMU初始化通常比主核简单。主核需要建立页表、映射外设地址空间,而从核只需要继承主核的页表设置,或者做少量调整。说白了,从核就是个“拿来主义”,直接用主核搭好的环境。

11.5 RT-Thread SMP启动的“最后一公里”

从核进入C语言环境后,RT-Thread会调用rt_hw_secondary_cpu_init。这个函数负责:

  • 初始化从核的中断控制器
  • 注册从核到调度器
  • 启动从核上的空闲线程
  • 等待调度器分配任务

来看看核心代码:

void rt_hw_secondary_cpu_init(void)
{
    rt_base_t level;
    
    // 初始化本地定时器
    rt_hw_timer_init();
    
    // 使能中断
    level = rt_hw_interrupt_disable();
    rt_hw_interrupt_enable();
    
    // 注册当前核心
    rt_cpu_self()->id = smp_processor_id();
    rt_cpu_self()->online = 1;
    
    // 启动调度器
    rt_scheduler_start();
    
    // 不会执行到这里
    while (1);
}

这里有个容易忽略的点:rt_hw_interrupt_disable()rt_hw_interrupt_enable()之间的代码必须非常短。为什么?因为从核刚启动时,中断控制器可能还没完全配置好,贸然开中断会导致未定义中断触发。我建议在使能中断前,先把所有中断源屏蔽掉,再逐个使能。

启动完成标志: 当所有从核都执行到rt_scheduler_start()后,SMP系统就正式启动了。此时主核和从核都在调度器的管理下,共同处理任务。

11.6 常见问题与调试技巧

最后,分享几个我在调试SMP启动时遇到的典型问题:

问题现象 可能原因 解决方法
从核不响应唤醒 启动地址未对齐 检查启动地址是否4字节对齐
从核启动后死机 栈空间不足或重叠 检查链接脚本中栈地址分配
系统跑飞 缓存一致性问题 添加内存屏障指令
中断异常 中断控制器未初始化 确保从核初始化了GIC

我曾经遇到一个最诡异的问题:从核启动后,偶尔能正常工作,偶尔直接卡死。查了整整两天,最后发现是电源管理单元(PMU)的时序问题——从核上电后需要等待一定时间才能访问外设。嗯,这种硬件相关的坑,只能靠仔细阅读芯片手册来避免。

调试建议: 我习惯在从核启动代码中加一个GPIO翻转的调试点。如果从核执行到了某个位置,GPIO就会输出一个脉冲。这样用逻辑分析仪一看,就知道从核卡在哪一步了。

好了,SMP启动流程就讲到这里。下一章我们会深入分析调度器在多核环境下的实现,看看RT-Thread是如何在多个核心之间分配任务的。