ARM64平台设备树引导Linux内核:从硬件描述到系统启动的完整链路

你有没有遇到过这样的场景:同一份Linux内核镜像,烧录到两块看似相同的开发板上,一块能正常启动,另一块却卡在“Uncompressing Linux… done, booting the kernel.”之后再无响应?排查半天发现,只是因为某根UART引脚接错了位置——而这个差异,本该由代码之外的方式表达清楚。

这正是 设备树 (Device Tree)诞生的初衷。在ARM64世界里,它早已不是“可选项”,而是连接硬件与操作系统的 唯一桥梁 。今天我们就来拆解这条从U-Boot跳转到内核初始化之间的关键路径,看看那份 .dtb 文件到底经历了什么,又是如何决定整个系统命运的。


为什么ARM64必须用设备树?

早年的嵌入式Linux内核中,板级信息是硬编码的。比如某个串口控制器的地址写死在 arch/arm/mach-s3c24xx/ 目录下,每增加一块新板子就要改一次代码。这种方式在SoC种类繁多、定制化需求激增的今天已经完全不可行。

ARM64架构自设计之初就摒弃了这种模式,转而强制使用 Flattened Device Tree (FDT),即扁平化设备树。这意味着:

没有正确的DTB,内核根本不会启动。

这不是夸张。如果你传给内核一个空指针或无效地址作为设备树参数,你会发现 start_kernel() 甚至都没机会执行——它会在早期检测阶段直接崩溃。

所以问题来了:这个神秘的DTB究竟是什么?它是怎么被创造出来,又被谁交到内核手里的?


设备树的本质:一份结构化的硬件说明书

我们可以把设备树理解为一张“硬件地图”。它不包含任何可执行逻辑,只负责回答以下几个核心问题:

  • 这块板上有几个CPU?它们的ID和能力是什么?
  • 内存从哪里开始?有多大?
  • 外设挂在哪条总线上?寄存器映射在哪里?
  • 中断线怎么连接的?时钟源来自哪里?
  • 哪个串口应该作为控制台输出?

这些问题的答案不再藏在C代码里,而是以一种标准化的数据格式呈现出来。

.dts 到 .dtb :从文本到二进制的编译过程

开发者编写的是 .dts (Device Tree Source)文件,例如:

/dts-v1/;
#include "skeleton64.dtsi"

/ {
    model = "MyARM64 Board";
    compatible = "mycorp,myarm64";

    cpus { ... };
    memory@80000000 { ... };

    soc {
        uart0: serial@9000000 {
            compatible = "snps,dw-apb-uart";
            reg = <0x0 0x9000000 0x0 0x1000>;
            interrupts = <0 97 4>;
        };
    };

    chosen {
        bootargs = "console=ttyS0,115200 root=/dev/mmcblk0p2 rw";
    };
};

这段文本经过 dtc 编译器处理后变成 .dtb 文件:

dtc -I dts -O dtb -o board.dtb board.dts

生成的DTB是一个带有魔数( 0xd00dfeed )的二进制块,包含三大部分:
- 结构块 (structure block):节点父子关系
- 字符串块 (strings block):属性名去重存储
- 内存保留块 (memory reservation block):标注不能被使用的物理内存区域

最终,这份二进制说明书会被加载进内存,并通过寄存器传递给内核。


U-Boot的角色:不只是加载内核,更是“硬件中介”

很多人以为U-Boot的任务就是“把kernel和dtb读进内存然后跳过去”。其实远不止如此。在ARM64平台上,U-Boot承担着至关重要的中间协调角色。

启动命令背后的真相

典型的U-Boot启动脚本如下:

setenv bootargs 'console=ttyS0,115200 root=/dev/mmcblk0p2 rw'
load mmc 0:1 ${kernel_addr_r} Image
load mmc 0:1 ${fdt_addr_r} board.dtb
fdt addr ${fdt_addr_r}
fdt resize
booti ${kernel_addr_r} - ${fdt_addr_r}

我们逐行分析这些命令的实际意义:

  • load mmc ... :将内核镜像和DTB从存储设备加载至预设地址(如 0x80080000 和 0x87f00000 )
  • fdt addr :告诉U-Boot当前要操作的DTB位于哪个物理地址
  • fdt resize :为后续可能的修改预留空间(DTB默认无空闲区)
  • booti :设置AArch64异常级别(EL2→EL1),并将DTB物理地址放入 x0 寄存器

注意最后一点: booti 会把DTB地址放在 x0 ,内核入口函数靠它找到设备树 。

如果这里出错,比如地址写错或者DTB损坏,后果很严重——内核连最基本的内存信息都拿不到,自然无法继续运行。


内核眼中的设备树:从二进制到运行时模型

当CPU跳入 _start 后,第一件事不是打印“Hello World”,而是检查设备树是否合法。

第一步:验证DTB有效性

内核调用 early_init_dt_verify(phys_addr_t dt_phys) 检查以下内容:

  • 魔数是否为 0xd00dfeed
  • 是否超出物理内存边界
  • 总长度是否合理

一旦失败,就会触发 panic("Invalid device tree blob header") ——没错,还没初始化完就崩了。

第二步:展开设备树节点

通过 unflatten_device_tree() 函数,内核将DTB反序列化成一系列 struct device_node 结构体,形成一棵完整的树:

struct device_node {
    const char *name;
    const char *type;
    phandle phandle;
    const char *full_name;
    struct fwnode_handle fwnode;
    struct property *properties;    // 属性链表(reg, compatible等)
    struct device_node *parent;
    struct device_node *child;
    struct device_node *sibling;
};

每一个节点对应一个硬件实体,比如CPU、内存、UART等。每个属性则提供具体配置信息。

例如, reg = <0x0 0x9000000 0x0 0x1000>; 表示该设备占用从 0x90000000 开始的4KB空间。

第三步:提取关键系统信息

在这棵构建好的树中,内核优先解析几个特殊节点:

1. /memory 节点 → 确定可用RAM
memory@80000000 {
    device_type = "memory";
    reg = <0x0 0x80000000 0x0 0x80000000>; /* 2GB */
};

内核从中获取物理内存起始地址和大小,调用 memblock_add() 将其加入内存管理框架。没有这个信息, kmalloc 都没法工作。

2. /cpus 节点 → 初始化CPU拓扑

双核还是四核?支持哪些扩展指令集?是否启用PSCI电源管理?这些都由 /cpus/cpu@X 节点定义。

特别是 enable-method = "psci"; 这一行,决定了CPU上线时调用哪个固件接口。

3. /chosen/bootargs → 获取启动参数
chosen {
    bootargs = "console=ttyS0,115200 earlycon root=/dev/mmcblk0p2 rw";
}

这里的字符串会被复制到 saved_command_line ,成为后续解析 console= 、 root= 等参数的基础。

4. stdout-path → 定位控制台设备
stdout-path = &uart0;

结合 earlycon 参数,内核可以在 printk 机制初始化前就输出调试信息。这对调试早期故障至关重要。


驱动是怎么被“匹配”上的?OF API 的魔法时刻

设备树解析完成后,真正的“自动装配”才刚刚开始。

Linux内核使用 Open Firmware API (简称OF API)实现驱动与设备的动态绑定。其核心思想是: 根据 compatible 属性进行匹配 。

举个例子,对于上面定义的UART设备:

uart0: serial@9000000 {
    compatible = "snps,dw-apb-uart";
    ...
};

对应的驱动需要声明匹配表:

static const struct of_device_id dw_uart_of_match[] = {
    { .compatible = "snps,dw-apb-uart" },
    { }
};
MODULE_DEVICE_TABLE(of, dw_uart_of_match);

static struct platform_driver dw_uart_platdrv = {
    .driver = {
        .name = "dw-apb-uart",
        .of_match_table = dw_uart_of_match,
    },
    .probe = dw_uart_probe,
};

在 platform_bus 扫描过程中,内核会遍历所有未绑定的设备树节点,尝试与注册的驱动进行匹配。一旦发现 compatible 字符串一致,立即调用 .probe() 函数完成初始化。

这就是所谓的“ 设备驱动分离 ”:同一个驱动可以支持不同厂商但兼容的硬件,只需更新DTB即可。


实战技巧:常见坑点与调试秘籍

即便原理清晰,在实际开发中仍容易踩坑。以下是几个高频问题及其解决方案。

❌ 问题1:内核启动卡住,串口无输出

可能原因 :
- DTB未正确传递( x0 寄存器为空)
- stdout-path 指向错误设备
- bootargs 缺少 earlycon

排查方法 :
1. 检查U-Boot的 booti 命令是否带上了DTB地址
2. 使用 fdtdump board.dtb | grep stdout 确认路径正确
3. 添加 earlycon=pl011,0x9000000,115200n8 显式指定early console

❌ 问题2:设备未识别,驱动不加载

典型现象 :

platform dw-apb-uart.0: No matching node in device tree

根源分析 :
- compatible 字符串拼写错误(大小写敏感!)
- 驱动未启用 CONFIG_OF 或未注册OF匹配表
- 设备节点缺少必要的属性(如 reg )

解决步骤 :
1. 用 of_dump_flat_device_tree() 在内核中打印完整DTB结构
2. 对比驱动期望的 compatible 和实际值
3. 使用 make ARCH=arm64 dtbs 确保DTB已重新编译

✅ 秘籍:利用U-Boot动态修补设备树

有时候你不想为每个小改动都重新编译DTB。U-Boot提供了强大的运行时修改能力:

# 禁用某个不需要的设备
fdt rm /soc/i2c1

# 修改串口波特率
fdt set /soc/uart0 clock-frequency <50000000>

# 添加新的启动参数
fdt set /chosen bootargs "console=ttyS1,115200 root=/dev/nfs"

这在调试阶段非常有用,尤其适用于量产环境中通过脚本差异化配置。


最佳实践清单:写出健壮的设备树

项目 推荐做法
.dtsi 分层 SoC共性放 .dtsi ,板级差异放 .dts
compatible 命名 使用“厂商,型号”格式,优先采用已有标准( 查阅ePAPR )
地址单元设置 正确设置 #address-cells 和 #size-cells ,尤其是在PCIe或多地址空间系统中
中断映射 明确指定 interrupt-parent 和触发类型(高电平/边沿)
内存保留 使用 /memreserve/ 标注安全监控、TEE等占用的内存
版本管理 DTB应与内核版本配套发布,避免API不兼容
调试支持 启用 CONFIG_OF_EARLY_FLATTREE + CONFIG_DEBUG_FS ,便于故障诊断

此外,建议在CI流程中加入设备树语法检查:

dtc -I dts -O dtb -o /dev/null board.dts && echo "Syntax OK"

防患于未然。


写在最后:设备树不只是技术,更是一种思维方式

掌握设备树,本质上是在学习一种 硬件抽象思维 。它教会我们:

  • 不要把硬件细节耦合进软件逻辑;
  • 让数据驱动行为,而非代码硬编码;
  • 把配置留给外部,提升系统的灵活性和复用性。

随着RISC-V等新兴架构也全面采用设备树作为标准硬件描述方式,这项技能的重要性只会越来越高。未来甚至可能出现“Signed DTB”用于安全启动验证,或是通过AI自动生成初步设备树草案。

无论你是做物联网终端、边缘计算盒子,还是参与云服务器固件开发,深入理解设备树的工作机制,都将让你在面对“为什么开不了机”这类问题时,少一分慌乱,多一分底气。

如果你正在调试一块新的ARM64板子,不妨先问自己三个问题:

  1. 我的DTB真的被加载了吗?
  2. x0 寄存器里有它的物理地址吗?
  3. compatible 写对了吗?

答案往往就藏在这最基础的几步之中。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐