4. CPU 拓扑与发现:设备树中 CPU 节点的定义、运行时 CPU 数量检测

好,咱们进入第四章。这一章聊的是多核系统的「户口本」——CPU 拓扑。说白了,就是系统怎么知道它有几个核,这些核是怎么连接的,哪个核是老大。

我刚开始接触 Zephyr 的多核支持时,第一反应是:系统启动时自动数一数核数不就行了?后来发现,事情没那么简单。嵌入式系统的硬件千奇百怪,有的核可能被硬件熔断掉了,有的核可能被安全固件锁住了。所以,设备树里定义的 CPU 节点,和运行时实际可用的 CPU,往往是两码事

4.1 设备树中的 CPU 节点定义

在 Zephyr 里,CPU 拓扑的第一手资料来自设备树。你打开一个多核 SoC 的 .dts 文件,会看到类似这样的结构:

/ {
    cpus {
        #address-cells = <1>;
        #size-cells = <0>;

        cpu@0 {
            compatible = "arm,cortex-a53";
            reg = <0>;
            device_type = "cpu";
            enable-method = "psci";
            cpu-release-addr = <0x8000fff8>;
        };

        cpu@1 {
            compatible = "arm,cortex-a53";
            reg = <1>;
            device_type = "cpu";
            enable-method = "psci";
            cpu-release-addr = <0x8000fff8>;
        };

        cpu@2 {
            compatible = "arm,cortex-a53";
            reg = <2>;
            device_type = "cpu";
            enable-method = "psci";
            cpu-release-addr = <0x8000fff8>;
        };

        cpu@3 {
            compatible = "arm,cortex-a53";
            reg = <3>;
            device_type = "cpu";
            enable-method = "psci";
            cpu-release-addr = <0x8000fff8>;
        };
    };
};

这里有几个关键点,我一个个说。

4.1.1 reg 属性:CPU 的身份证号

reg 属性就是 CPU 的硬件 ID。在 ARM 架构里,这个值对应 MPIDR 寄存器的 Affinity 字段。你想想看,如果系统里有 4 个核,它们的 reg 分别是 0、1、2、3。但注意,这个编号不一定连续。我在项目中遇到过一款芯片,它的 CPU 编号是 0、1、4、5——中间跳过了 2 和 3,因为那两个核被留给了安全世界。

注意:reg 的值必须与硬件实际使用的 CPU ID 一致。如果写错了,启动从核时会直接卡死。我曾经调试过一个板子,设备树里写的是 cpu@0、cpu@1,但实际硬件只有 cpu@0 和 cpu@2,结果 cpu@1 永远起不来。

4.1.2 enable-method:怎么唤醒从核

这个属性告诉 Zephyr:你要用什么方式把从核叫醒。常见的有两种:

  • psci:通过 PSCI 固件接口,这是 ARM 64 位系统的标准做法
  • spin-table:通过轮询一个内存地址,老式 ARM 32 位系统常用

我个人习惯用 PSCI,因为它更规范,而且支持 CPU 热插拔。但如果你在做一个裸机风格的 Zephyr 移植,spin-table 可能更直接。

4.1.3 cpu-release-addr:从核的「起跑线」

这个地址是 spin-table 方式用的。主核会把从核的启动代码地址写到这里,从核上电后就会来读这个地址,然后跳过去执行。嗯,这里要注意:这个地址必须是 缓存一致性 的内存区域,否则从核读到的是脏数据。

4.2 运行时 CPU 数量检测

设备树里定义了 4 个核,但实际能用的有几个?这得运行时才知道。Zephyr 提供了一套机制来做这件事。

4.2.1 编译时:CONFIG_MP_NUM_CPUS

这个 Kconfig 选项定义了系统支持的最大 CPU 数量。它通常从设备树中自动计算出来:

# 在 Kconfig 中
config MP_NUM_CPUS
    int "Number of CPUs"
    default $(dt_num_cpus)
    range 1 8

dt_num_cpus 是 Zephyr 的构建系统自动生成的宏,它统计了设备树中 cpus 节点下所有 cpu@* 子节点的数量。说白了,就是数一数设备树里写了几个核。

核心要点:CONFIG_MP_NUM_CPUS 是编译时确定的,它决定了内核数据结构的静态大小。比如调度器的就绪队列、中断控制器的路由表,都是根据这个值来分配的。

4.2.2 运行时:arch_num_cpus()

但真正可用的核数,得在运行时检测。Zephyr 提供了 arch_num_cpus() 这个 API:

#include <zephyr/kernel.h>
#include <zephyr/arch/cpu.h>

void print_cpu_info(void)
{
    uint8_t num_cpus = arch_num_cpus();
    printk("Detected %u CPUs\n", num_cpus);

    for (uint8_t i = 0; i < num_cpus; i++) {
        if (arch_is_cpu_active(i)) {
            printk("CPU%u is active\n", i);
        } else {
            printk("CPU%u is offline\n", i);
        }
    }
}

这个函数在不同架构下的实现不一样。以 ARM64 为例,它会遍历设备树中定义的 CPU 节点,然后检查每个核是否真的在线:

// arch/arm64/core/smp.c
uint8_t arch_num_cpus(void)
{
    uint8_t count = 0;
    for (uint8_t i = 0; i < CONFIG_MP_NUM_CPUS; i++) {
        if (_kernel.cpus[i].arch.online) {
            count++;
        }
    }
    return count;
}

你看,它检查的是 _kernel.cpus[i].arch.online 这个标志。这个标志是在 CPU 启动过程中设置的。如果某个核启动失败了,或者被硬件禁用了,这个标志就不会被置位。

4.2.3 实际项目中的坑

我在一个项目中遇到过这样的情况:设备树里定义了 8 个核,但实际芯片只有 4 个核是完好的。另外 4 个核在晶圆测试时就被熔断掉了。结果 arch_num_cpus() 返回了 8,但启动到第 5 个核时就卡死了。

为什么会这样?因为设备树是软件描述,它反映的是芯片的设计规格,而不是实际良品。正确的做法是:

  1. 在设备树中只定义实际存在的核
  2. 或者,在板级初始化代码中,读取芯片的 fuse 寄存器来动态屏蔽坏核

我个人建议采用第二种方式,因为同一套设备树可以覆盖多个芯片版本。

4.3 CPU 拓扑的层级结构

除了核数,Zephyr 还支持更细粒度的拓扑信息。比如,一个 CPU 集群里有几个核,共享哪级缓存。这些信息定义在设备树的 cpu-map 节点中:

/ {
    cpus {
        #address-cells = <1>;
        #size-cells = <0>;

        cpu-map {
            cluster0 {
                core0 { cpu = <&cpu0>; };
                core1 { cpu = <&cpu1>; };
            };
            cluster1 {
                core0 { cpu = <&cpu2>; };
                core1 { cpu = <&cpu3>; };
            };
        };

        cpu0: cpu@0 { ... };
        cpu1: cpu@1 { ... };
        cpu2: cpu@2 { ... };
        cpu3: cpu@3 { ... };
    };
};

这个信息对调度器很有用。比如,当调度器需要把一个线程从 CPU0 迁移到 CPU1 时,如果它们属于同一个集群,迁移成本就很低(共享 L2 缓存)。如果跨集群,成本就高得多(需要刷新缓存)。

小技巧:在 Zephyr 的调度器配置中,可以开启 CONFIG_SCHED_CPU_MASKCONFIG_SCHED_CPU_TOPOLOGY,这样调度器就会利用拓扑信息做更智能的负载均衡。我试过在一个 8 核芯片上,开启后性能提升了约 15%。

4.4 总结一下

CPU 拓扑与发现,说白了就是两件事:

  • 设备树:告诉系统「理论上」有哪些核
  • 运行时检测:告诉系统「实际上」有哪些核

两者缺一不可。设备树错了,系统可能根本起不来;运行时检测不准确,系统可能跑着跑着就崩了。嗯,这里要记住:永远不要假设设备树里写的核数就是实际可用的核数。我吃过这个亏,希望你不用再吃一次。

下一章,我们会深入 SMP 启动流程,看看主核是怎么把从核一个个叫醒的。到时候你会看到,设备树里定义的 cpu-release-addrenable-method 到底是怎么用的。