23、SMP在ARM64上的实现:ARM64异常级别、MMU配置、SMP启动

好,我们进入第23章。这一章我打算聊聊ARM64架构下SMP的具体实现。说实话,这部分内容在嵌入式领域算是比较硬核的。很多开发者写裸机程序或者单核RT-Thread时,基本不用关心异常级别和MMU。但一旦上了多核,这些就成了绕不开的坎。

我个人习惯把ARM64的SMP启动分成三个层次来看:异常级别模型MMU页表配置核间启动流程。咱们一个一个说。

23.1 ARM64异常级别:EL0到EL3

ARM64处理器定义了四个异常级别,从EL0到EL3。数字越大,权限越高。我刚开始接触ARM64时,总觉得这跟ARM32的七种模式比起来太简单了。但用久了你会发现,这种分层设计其实更清晰。

异常级别 典型用途 运行环境
EL0 用户态应用程序 非特权
EL1 操作系统内核 特权
EL2 Hypervisor(虚拟化) 更高特权
EL3 Secure Monitor(安全监控) 最高特权

在RT-Thread SMP场景下,我们主要工作在EL1。为什么?因为RT-Thread是跑在OS内核层的,不需要虚拟化,也不需要安全监控。但启动时,CPU上电默认在EL3,然后逐级下降到EL1。这个过程需要我们自己处理。

关键点:RT-Thread SMP的启动代码,必须包含从EL3到EL1的异常级别切换。否则CPU会卡在错误级别,无法正常调度。

我记得有一次调试,板子上电后所有核都跑起来了,但主核就是无法唤醒从核。查了两天才发现,从核在EL2级别卡住了,根本没进入EL1。嗯,这种坑踩过一次就记住了。

23.2 MMU配置:页表与内存映射

MMU(内存管理单元)在SMP中扮演的角色比单核重要得多。单核时代,你可以关掉MMU直接跑物理地址。但多核环境下,每个核需要独立的虚拟地址空间,同时又要共享部分内存区域。这时候MMU就成了必需品。

ARM64的MMU使用四级页表结构:

  • Level 0:覆盖整个地址空间,粒度大
  • Level 1:中间层,用于大块映射
  • Level 2:细粒度映射
  • Level 3:最细粒度,4KB页

在RT-Thread中,我们通常使用两级或三级页表就够了。为什么?因为嵌入式系统的地址空间没那么大,没必要搞四级。我建议的做法是:

/* 页表配置示例 */
#define TCR_T0SZ         25          /* 虚拟地址空间大小:2^(64-25) = 512GB */
#define TCR_TG0          TCR_TG0_4KB /* 粒度:4KB */
#define TCR_IRGN0        TCR_IRGN0_WBWA /* 内部缓存属性 */
#define TCR_ORGN0        TCR_ORGN0_WBWA /* 外部缓存属性 */
#define TCR_SH0          TCR_SH0_ISH  /* 内部共享域 */

这里有个细节要注意:共享域(Shareability Domain)。在SMP中,所有核必须配置为内部共享域(Inner Shareable),否则核间缓存一致性会出问题。我曾经在一个项目里,因为把共享域配成了Non-Shareable,结果两个核访问同一块内存时数据不一致,调试了整整一周。

避坑指南:MMU的页表必须放在所有核都能访问到的内存区域。如果你把页表放在某个核的私有L2缓存里,其他核就看不到了。我曾经犯过这个错,从核启动时直接Page Fault。

23.3 SMP启动流程:从主核到从核

ARM64的SMP启动,说白了就是主核先跑起来,然后一个个唤醒从核。具体流程是这样的:

  1. 主核初始化:主核(通常是CPU0)从EL3启动,逐级下降到EL1,初始化MMU、中断控制器、时钟等。
  2. 准备从核启动代码:主核把从核的启动代码复制到某个固定地址(比如0x0000_0000或某个SRAM区域)。
  3. 发送SEV指令:主核通过SEV(Send Event)指令或PSCI(Power State Coordination Interface)协议唤醒从核。
  4. 从核自举:从核收到唤醒信号后,执行启动代码,完成自身初始化,然后进入空闲循环等待调度。

在RT-Thread中,从核的启动入口通常定义在rt_hw_secondary_cpu_boot()函数里。代码大致长这样:

void rt_hw_secondary_cpu_boot(void)
{
    /* 初始化从核的异常向量表 */
    rt_hw_vector_init();

    /* 配置从核的MMU,使用与主核相同的页表 */
    rt_hw_mmu_init();

    /* 使能从核的中断 */
    rt_hw_interrupt_enable();

    /* 进入调度器,等待任务分配 */
    rt_scheduler_start();
}

你想想看,这里有个隐含问题:从核的MMU配置必须和主核一致。如果主核用了三级页表,从核却配成了两级,那内存映射就全乱了。我建议的做法是,主核把页表基地址放在一个全局变量里,从核启动时直接读取。

个人经验:从核启动时,最好加一个自旋锁(spinlock)等待主核完成所有初始化。否则从核跑得太快,主核还没准备好,就会出问题。我曾经遇到过从核比主核先进入调度器,结果任务队列还是空的,直接空指针异常。

23.4 核间通信与同步

SMP启动完成后,核间通信就成了日常操作。ARM64提供了几种核间通信机制:

  • SEV/WFE指令:轻量级的事件通知,适合简单的唤醒场景。
  • GIC中断控制器:通过SGI(Software Generated Interrupt)实现核间中断。
  • 共享内存:通过原子操作和内存屏障保证数据一致性。

在RT-Thread中,我们主要用GIC的SGI来实现核间中断。每个核可以向其他核发送中断,触发调度或任务迁移。代码示例:

/* 向CPU1发送SGI中断 */
void send_sgi_to_cpu1(void)
{
    uint32_t sgi_id = 0;  /* SGI中断号 */
    uint32_t target_list = 1 << 1; /* 目标核:CPU1 */
    
    /* 写入GIC的SGI寄存器 */
    *(volatile uint32_t *)(GICD_BASE + GICD_SGIR) = 
        (sgi_id & 0xF) | (target_list & 0xFF) << 16;
}

这里要注意,GIC的寄存器访问必须使用volatile关键字,否则编译器优化后可能不会真正写入硬件。我见过有人因为这个关键字漏了,SGI死活发不出去。

23.5 总结与建议

ARM64上的SMP实现,核心就是三件事:异常级别切换MMU统一配置从核有序启动。这三件事环环相扣,哪一步出问题,整个系统都跑不起来。

我个人建议,在移植RT-Thread SMP到新平台时,先不要急着跑复杂应用。先写一个最小的启动代码,让主核和从核都能打印一条信息。确认核间通信正常后,再逐步添加调度、中断、任务迁移等功能。这样调试起来会轻松很多。

好,这一章就到这里。下一章我们会深入GIC中断控制器,看看多核环境下中断是怎么分发和处理的。