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启动,说白了就是主核先跑起来,然后一个个唤醒从核。具体流程是这样的:
- 主核初始化:主核(通常是CPU0)从EL3启动,逐级下降到EL1,初始化MMU、中断控制器、时钟等。
- 准备从核启动代码:主核把从核的启动代码复制到某个固定地址(比如0x0000_0000或某个SRAM区域)。
- 发送SEV指令:主核通过SEV(Send Event)指令或PSCI(Power State Coordination Interface)协议唤醒从核。
- 从核自举:从核收到唤醒信号后,执行启动代码,完成自身初始化,然后进入空闲循环等待调度。
在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中断控制器,看看多核环境下中断是怎么分发和处理的。