第2章:Linux内核SMP基础:SMP架构原理、CPU拓扑与调度域、内核启动流程中的多核初始化

好,咱们进入正题。这一章讲的是SMP基础,说白了就是多核CPU怎么在Linux底下协同工作。很多做单片机的兄弟刚转过来,总觉得多核就是多个CPU各干各的,其实没那么简单。你想想看,如果两个核同时访问同一块内存,谁先谁后?数据会不会乱?这些问题不搞清楚,后面做核间通信就是空中楼阁。

2.1 SMP架构原理

SMP,对称多处理。什么叫对称?每个CPU核心地位平等,共享同一块物理内存,访问外设的路径也一样。我刚开始接触车载Linux时,有个项目用的i.MX8QM,4个Cortex-A53加2个Cortex-A72,异构的。但A53那四个核就是典型的SMP,它们共用L2缓存,通过CCI互联总线访问DDR。

这里有个关键点:缓存一致性。每个核都有自己的L1 cache,如果核0改了某个变量,核1读到的却是旧值,那就出大事了。硬件上靠MESI协议(或者它的变种)来保证。嗯,这里要注意,MESI不是Linux管的,是硬件自动完成的。但Linux必须知道这个机制,才能正确设置内存属性。

核心要点:SMP下,所有核共享内存地址空间。但每个核有独立的寄存器、L1 cache和本地计时器。中断控制器(GIC)负责把中断分发给各个核。

我在项目中遇到过一个问题:两个核同时往一个链表里插入节点,结果链表指针乱掉了。表面看是并发问题,根子其实是没理解SMP的内存模型。你以为的原子操作,在别的核看来可能不是原子的。

2.2 CPU拓扑与调度域

Linux怎么知道CPU长什么样?靠CPU拓扑。拓扑信息描述了哪些核共享L1、L2、L3缓存,哪些核在同一个物理芯片上。这些信息从设备树或者ACPI表中读取,然后内核构建出调度域(sched domain)的层级结构。

调度域分好几层:

  • SMT层级:超线程,一个物理核的两个逻辑核
  • MC层级:多核,共享L2缓存的核
  • DIE层级:整个CPU芯片
  • NUMA层级:非一致内存访问节点

车载平台上,最常见的是MC层级。比如TI的TDA4VM,有4个A72核,它们共享L2缓存,调度域就建在MC层。内核调度器会根据这个拓扑做负载均衡——尽量把任务放在共享缓存的核上,减少cache miss。

个人经验:我建议你在做性能调优时,先看看 /sys/devices/system/cpu/cpu*/topology/ 下面的文件。里面能看到 core_siblings、thread_siblings 这些信息。有一次我发现两个核明明在同一个cluster里,但调度器老把任务往另一个cluster上扔,查了半天是设备树里拓扑信息写错了。

调度域还有个重要概念:调度组。每个调度域里分成多个组,组内是共享资源的核。调度器做负载均衡时,只在组之间迁移任务,不会跨域乱移。这能避免不必要的cache抖动。

2.3 内核启动流程中的多核初始化

这部分我当年啃了很久。内核启动时,主核(boot CPU)先跑,从核(secondary CPUs)在休眠状态等着。主核做完初始化后,一个个把从核唤醒。流程大概是这样的:

  1. 主核执行start_kernel(),完成基础初始化
  2. 调用smp_init(),开始唤醒从核
  3. 对每个从核,调用cpu_up(),发送IPI中断
  4. 从核收到中断,跳转到secondary_start_kernel()
  5. 从核初始化自己的per-CPU数据、调度域、中断等
  6. 从核进入idle循环,等待调度

代码层面,关键函数在 arch/arm64/kernel/smp.c 里。我截一段核心逻辑:

// 伪代码,简化版
void __init smp_init(void)
{
    for_each_possible_cpu(cpu) {
        if (cpu == smp_processor_id())
            continue;  // 跳过主核
        cpu_up(cpu);   // 唤醒从核
    }
}

int __cpu_up(unsigned int cpu, struct task_struct *idle)
{
    // 设置从核的启动地址
    // 发送IPI中断
    // 等待从核确认
    sev();  // 发送事件信号
    return 0;
}

避坑指南:我曾经在调试一个启动失败的问题时,发现从核一直卡在等待确认状态。最后定位到是GIC中断配置有问题,从核收不到IPI。嗯,这里要注意,不同平台的从核唤醒机制不一样——ARM用PSCI,x86用INIT-SIPI-SIPI序列。车载平台基本都是ARM,所以PSCI是重点。

从核启动后,还有个重要步骤:per-CPU变量初始化。每个核都有自己的per-CPU数据区,比如当前进程指针、本地定时器、统计信息等。这些数据通过 DEFINE_PER_CPU() 宏定义,访问时用 this_cpu_ptr()per_cpu_ptr()。我建议你写驱动时,尽量用per-CPU变量来避免锁竞争,性能会好很多。

最后说一句,多核初始化过程中,主核和从核的同步靠 cpu_running 标志位和 synchronize_rcu() 来保证。从核没完全就绪前,主核不会把任务调度过去。这个机制很成熟,但如果你自己写核间通信代码,一定要参考这个同步模式。

好了,这一章就到这里。下一章我们讲实际的核间通信机制,包括IPI、共享内存、rpmsg这些。到时候我会拿一个实际的车载项目案例来拆解,保证干货满满。