第四章 多核启动与初始化:UEFI/BIOS配置、ACPI表解析、多核唤醒流程(SMP boot)、APIC与中断控制器初始化

各位同学,今天我们来聊聊多核系统启动这个硬骨头。说实话,很多做嵌入式Linux的朋友,对启动流程的理解往往停留在“bootloader把内核加载到内存,然后内核自己就跑起来了”。但在AMD多核平台上,事情远没那么简单。

我个人习惯把多核启动分成四个阶段:固件准备 → 硬件拓扑发现 → 核心唤醒 → 中断系统就绪。咱们一个一个来拆解。

4.1 UEFI/BIOS配置:多核启动的第一道关卡

你想想看,CPU上电后,第一个执行的代码是什么?是UEFI固件。AMD平台现在基本都走UEFI了,Legacy BIOS已经很少见。UEFI干的第一件事,就是读取处理器上的MSR(Model Specific Register),搞清楚这颗芯片到底有多少个核心。

我在项目中遇到过一个问题:某款AMD EPYC处理器,BIOS里默认把SMT(同步多线程)关掉了。结果系统启动后只看到一半的逻辑核心。排查了半天,最后发现是BIOS设置里有个“SMT Control”选项被设成了Disabled。嗯,这里要注意:做实时系统,SMT的开关直接影响你的确定性

关键配置项:
  • MTRR(Memory Type Range Registers):设置内存区域的缓存属性。实时系统里,我建议把关键数据区设为Write-Combining或Uncacheable,避免缓存一致性问题。
  • MTRRcap & MTRRphysBase/Mask:每个核心都有自己的MTRR,但UEFI会在启动时统一配置。如果你要做核间隔离,记得检查这些寄存器是否一致。
  • Local APIC Base Address:默认在0xFEE00000,但有些BIOS会重映射。我踩过坑——APIC地址被BIOS改到了0xFEC00000,结果驱动里写死了地址,中断全丢了。

UEFI还会做一件事:构建ACPI表。说白了,ACPI就是操作系统和固件之间的“翻译官”。操作系统通过ACPI知道:我有几个核心?它们怎么连接的?中断控制器长什么样?

4.2 ACPI表解析:读懂硬件的“身份证”

ACPI表里最重要的几个表,我列一下:

表名 作用 我的经验
RSDP ACPI表的根指针,系统启动时第一个要找的 UEFI下通过EFI_CONFIG_TABLE获取,Legacy下在EBDA区域
MADT(Multiple APIC Description Table) 描述所有APIC、I/O APIC、核心的拓扑关系 实时系统里,我经常用MADT来确认核心的物理ID和逻辑ID映射
SRAT(System Resource Affinity Table) 描述内存和核心的亲和性(NUMA信息) AMD多Die架构下,这个表特别重要。跨Die访问内存延迟能差2-3倍
DSDT(Differentiated System Description Table) 包含AML字节码,描述硬件设备 调试时可以用iasl反编译,看看BIOS到底干了什么

解析ACPI表时,我建议你重点关注MADT表。它里面有一个个的“记录”,每条记录代表一个核心、一个I/O APIC或者一个中断覆盖。举个例子:

// MADT表结构(简化版)
struct madt_entry {
    uint8_t type;       // 0=Local APIC, 1=I/O APIC, 2=Interrupt Source Override...
    uint8_t length;
    uint8_t acpi_processor_id;  // ACPI处理器ID
    uint8_t apic_id;            // 物理APIC ID
    uint32_t flags;             // 位0: 是否启用
};

// 遍历所有核心
for each entry in MADT:
    if entry.type == 0:  // Local APIC
        if entry.flags & 0x1:
            // 这个核心是启用的
            core_list[entry.acpi_processor_id] = entry.apic_id;
小技巧:在AMD平台上,APIC ID并不总是连续的。比如一个8核处理器,APIC ID可能是0, 1, 2, 3, 8, 9, 10, 11。这是因为每个CCX(Core Complex)内部有4个核心,跨CCX的ID会跳变。写代码时千万别用“从0到N-1”的假设。

4.3 多核唤醒流程(SMP boot):从BSP到AP

好了,UEFI跑完了,ACPI表也解析了。现在操作系统内核开始接管。这时候只有一个核心在跑——我们叫它BSP(Bootstrap Processor)。其他核心都处于休眠状态,等着被唤醒。

唤醒AP(Application Processor)的流程,说白了就是BSP给AP发一个“起床”信号。具体怎么做?

  1. BSP准备启动代码:在内存低地址(通常是0x1000以下)放一段16位实模式代码,叫“trampoline code”。这段代码负责把AP从实模式切换到保护模式,再切换到长模式。
  2. 发送INIT IPI:BSP通过Local APIC发送一个INIT中断给目标AP。这相当于“清空你的状态,准备重新开始”。
  3. 发送SIPI(Startup IPI):BSP再发一个SIPI,告诉AP“去0x1000处执行代码”。AP收到后,跳转到trampoline code开始执行。
  4. AP自举:AP执行trampoline code,设置页表、GDT、IDT,然后跳转到内核的启动函数。最后,AP设置自己的Local APIC,进入空闲循环等待任务。

我曾经在调试一个AMD Ryzen平台时,发现某个AP总是唤醒失败。折腾了两天,最后发现是trampoline code放在了0x1000,但那个地址被BIOS的SMM(System Management Mode)占用了。嗯,这里要记住:trampoline code的地址必须避开SMM区域,一般建议放在0x8000以上。

避坑指南:我曾经在AMD EPYC 7002系列上遇到一个坑——发送SIPI后,AP没有在规定时间内响应。后来发现是BIOS里“CC6 State”(核心深度睡眠)没关。AP在深度睡眠状态下,根本收不到IPI。解决方案:在UEFI配置里把“Global C-State Control”设为Disabled。

唤醒流程的代码,我贴一段核心逻辑:

// BSP唤醒AP的伪代码
void smp_boot_ap(uint8_t apic_id) {
    // 1. 准备trampoline code
    memcpy((void*)0x8000, trampoline_code, trampoline_size);
    
    // 2. 发送INIT IPI
    apic_write(APIC_ICR, 0x000C4500 | (apic_id << 24));
    wait_for_delivery();
    
    // 3. 等待10ms(AP需要时间复位)
    delay(10);
    
    // 4. 发送SIPI
    apic_write(APIC_ICR, 0x000C4608 | (apic_id << 24));  // 0x08 = 0x8000 >> 12
    wait_for_delivery();
    
    // 5. 等待AP启动完成
    while (!ap_ready[apic_id]) {
        // 超时处理
        if (timeout > 100ms) {
            // 唤醒失败,记录错误
            break;
        }
    }
}

4.4 APIC与中断控制器初始化:让核心能“说话”

核心都醒了,但还不能干活。为什么?因为没有中断系统。在AMD多核平台上,每个核心都有一个Local APIC,而整个系统还有一个或多个I/O APIC。它们之间通过APIC总线(或者x2APIC模式下的MSR)通信。

初始化流程大致如下:

  1. 启用Local APIC:设置IA32_APIC_BASE MSR,把Local APIC的基地址映射到虚拟地址空间。注意,AMD平台要求APIC必须工作在x2APIC模式才能支持超过255个核心。
  2. 设置Spurious Interrupt Vector:写0xFEE000F0寄存器,设置伪中断向量(通常用0xFF)。同时把位8置1,启用APIC。
  3. 配置LINT0/LINT1:这两个引脚通常连接到8259A中断控制器。在SMP模式下,我们一般把它们设为NMI或ExtINT。
  4. 初始化I/O APIC:I/O APIC负责把外部设备的中断路由到某个核心的Local APIC。每个I/O APIC有24个中断引脚(Redirection Table Entry),我们需要配置每个引脚的目标核心和触发方式。
  5. 设置中断亲和性:对于实时系统,我建议把关键中断(比如定时器、DMA)绑定到特定核心,避免中断风暴打乱所有核心的调度。
AMD平台的特殊之处:
  • AMD的Local APIC默认使用x2APIC模式(如果BIOS支持)。x2APIC模式下,APIC ID是32位的,可以支持更多核心。
  • AMD的I/O APIC通常有两个:一个处理传统ISA中断,一个处理PCIe MSI/MSI-X中断。别搞混了。
  • AMD的IOMMU(I/O Memory Management Unit)可以重映射中断。如果你要用PCIe设备做实时通信,记得配置IOMMU中断重映射。

初始化代码示例:

// 初始化Local APIC(x2APIC模式)
void local_apic_init() {
    uint64_t apic_base;
    
    // 读取IA32_APIC_BASE MSR
    asm volatile("rdmsr" : "=A" (apic_base) : "c" (0x1B));
    
    // 启用x2APIC模式(位10)
    apic_base |= (1 << 10);
    asm volatile("wrmsr" : : "A" (apic_base), "c" (0x1B));
    
    // 设置伪中断向量
    apic_write(0xF0, 0x1FF);  // 向量0xFF,启用APIC
    
    // 设置LINT0为ExtINT(接收8259A的中断)
    apic_write(0x350, 0x700);  // LINT0: 固定模式,ExtINT
    
    // 设置LINT1为NMI
    apic_write(0x360, 0x400);  // LINT1: NMI
    
    // 启用性能计数器中断(可选)
    apic_write(0x340, 0x100);  // PCINT: 固定模式,向量0x10
}
调试技巧:如果你怀疑中断配置有问题,可以读取Local APIC的“Error Status Register”(0xFEE00280)。如果这个寄存器非零,说明APIC内部发生了错误。我经常在调试时轮询这个寄存器,配合printk输出,快速定位问题。

4.5 总结:多核启动的“黄金流程”

好了,咱们把整个流程串起来:

  1. UEFI阶段:配置MTRR、APIC基地址、SMT开关。构建ACPI表。
  2. ACPI解析阶段:读取MADT表,获取核心拓扑和APIC ID映射。读取SRAT表,了解NUMA内存布局。
  3. SMP唤醒阶段:BSP通过INIT+SIPI协议唤醒所有AP。每个AP执行trampoline code,进入保护模式/长模式,然后注册到内核调度器。
  4. APIC初始化阶段:每个核心初始化自己的Local APIC。系统初始化I/O APIC,配置中断路由和亲和性。

我个人习惯在启动完成后,打印一张“核心状态表”,确认每个核心的APIC ID、状态、以及绑定的中断。这样一旦出问题,能快速定位是哪个核心没起来,或者哪个中断没配好。

下一章,咱们聊聊多核环境下的内存管理——特别是缓存一致性协议(MOESI)和NUMA感知的内存分配。这可是实时系统的核心难点之一。


公众号:蓝海资料掘金营,微信deep3321