上一篇我们比较了传统 BIOS、UEFI 和 CSM。这次换一条常见于开发板和嵌入式设备的路线:Linux 内核刚醒来时,怎样知道眼前这块板子装了什么硬件?答案常常是一份叫作设备树的说明书。

想象奶奶家有一栋新盖的房子。维修师傅刚进门,不知道水管、电线和开关都藏在哪里。屋主可以给他一张平面图,标出配电箱、厨房插座和水阀的位置。设备树做的事有点像这张图:告诉 Linux 内核这台机器有哪些硬件、它们连接在哪里、各自占用哪些资源。

这张图不会替维修师傅修东西。内核还需要有对应的驱动程序,才能真正操作某种串口、网卡或传感器。设备树负责描述,驱动负责控制;两者合作,硬件才会被系统使用。

设备树不是 Linux 凭空发明的

设备树最早来自 Open Firmware。Open Firmware 是一种固件环境,常见于 PowerPC 和 SPARC 等平台。它需要把机器的硬件结构告诉后续运行的操作系统,于是用一棵由节点和属性组成的树来描述设备。

树这个名字来自它的形状:最上面是根节点,下面可以分出处理器、内存、总线;总线下面再列出挂在上面的网卡、串口和其他设备。像一棵家谱树一样,沿着分支能看出谁属于谁、设备怎么连接。

后来,PowerPC Linux 社区希望不同机器都能用比较统一的方式告诉内核硬件信息。2005 年前后,内核项目把这种描述整理成 Flattened Device Tree,简称 FDT。Flattened 可以理解为“压平”:原本看起来像一棵树的数据,被打包成紧凑的二进制内容,便于启动程序放进内存后交给内核。

因此,Linux 不需要整套 Open Firmware 才能读这份资料。只要启动程序能把 FDT 放到内存、按约定告诉内核它在哪里,内核就可以读取。U-Boot 等启动程序后来也学会了加载、检查和修改这份数据。

ARM 开发板越来越多,内核的板级代码也越长

ARM 处理器逐渐进入越来越多的开发板、路由器、手机和平板。它们虽然可能使用相似的处理器核心,却会搭配不同的内存、时钟、串口、网卡和外围芯片。对操作系统来说,这些差别不能靠猜。

早期 ARM Linux 常用板级代码告诉内核机器型号和设备资料。启动时,旧式系统还会由启动程序交来一些编号和 ATAG 标签,说明内存大小、命令行等基本信息。很多更细的硬件登记工作则写在每块板子专属的 C 代码里。

这种办法一开始很直接:新板子来了,就在内核里加一份代码,登记它的时钟、设备和连接方式。可是板子越来越多,内核就要装进越来越多只服务于某一块板的说明。几块板子即使都有串口,也可能把地址和中断写在不同文件里;维护一个新处理器家族时,还要修改大量架构专属代码。

麻烦不在于当年的工程师不知道硬件,而在于“哪些设备在哪里”这类资料和“怎样操作设备”这类通用程序混在一起了。每多一块板,内核代码就多几张专属说明纸;处理器厂商也难以单独维护自己的硬件描述,而不碰内核的大量通用部分。

设备树带来的变化,是把很多板级硬件资料从 C 代码里拿出来,放进一份结构化的数据文件。内核保留处理器和驱动程序;开发板用自己的设备树说明内存、外设和连接。只要设备驱动和硬件描述符合约定,同一份通用内核就有机会支持多块板子。

以前常把“板子是什么样”写进内核代码;设备树让这份硬件说明可以独立保存、选择和传递。

设备树里真的写着什么?

设备树可以告诉内核处理器和内存的大致信息,也可以描述片上总线、串口、I²C 总线、GPIO 控制器、时钟和中断等。每一种设备会有一个节点,节点里再用属性说明名字、地址、资源、状态,以及它和其他设备的关系。

一个重要属性叫 compatible,可以把它理解成设备的“型号或家族名称”。驱动会根据它匹配自己能操作的设备。reg 常用来说明设备占用的地址范围;interrupts 描述设备怎样提醒处理器;status 则可以表示一个设备目前启用还是关闭。实际字段要遵守该硬件的设备树绑定规则,不能随便起名字、随便填数字。

设备节点示意(省略具体地址规则)serial@10000000 {
    compatible = "vendor,uart-v1";
    reg = <设备地址 设备范围>;
    interrupts = <中断编号>;
    status = "okay";
};

这只是帮助认词的示意片段,不能原样拿去启动某一块真实开发板。真正的地址、时钟、中断编号和字段含义由芯片资料及设备树绑定定义。把号码填错,内核就可能去错误的位置找设备,或等不到正确的中断。

DTS、DTB、FDT:一份说明书的三种叫法

开发者通常用容易阅读的文本写设备树,文件后缀常是 .dts;重复使用的片段可能放在 .dtsi 文件里。然后由设备树编译器 dtc 把文字转换成紧凑的二进制文件,常见后缀是 .dtb。

FDT 则经常用来称呼这种“压平后的设备树”格式或内存中的数据。简单记忆:DTS 比较像源文件,DTB 是编译后的二进制,FDT 是这套平面格式的常见名字。启动时内核读取的通常是内存里的 DTB/FDT,而不是从 SD 卡上临时打开 .dts 文本。

编译好的 DTB 可以和 Linux 内核分开存放。开机时,启动程序按板子型号挑一份合适的 DTB,再把内核和 DTB 放到内存合适的位置。分开存放的好处是更换一块硬件描述时,不一定要重新制作整个内核;但挑错文件仍会造成问题。

U-Boot 怎样把说明书交给内核?

在许多 ARM 开发板上,U-Boot 会从 SD 卡、闪存或其他存储设备读取 Linux 内核和 DTB,把两者放到内存。接着它按 ARM Linux 启动约定,把 DTB 的位置交给内核,再跳到内核入口。硬件说明书因此会在内存里陪着这次启动,而不是由内核自己猜出板子型号。

启动程序还可能在交接前调整这份说明。比如告诉内核启动命令行是什么、initramfs 放在内存哪里,或者补充实际内存范围。设备树里的 /chosen 节点常用来放这类启动时信息;板级硬件节点则描述机器上的固定设备。

要注意,U-Boot 自己也可能使用一份设备树配置自己的驱动和硬件,也可能另外准备一份要交给 Linux 的工作设备树。它们用途相近,却不一定是同一份数据。U-Boot 文档把前者称作 control FDT,后者称作 working FDT。

设备树能做什么,不能做什么?

设备树擅长说明固定硬件的拓扑和资源:哪条总线上挂了什么芯片、它的地址范围在哪里、它连到哪个中断或时钟。它让内核不必把每一块板子的所有资料都写死在专用 C 文件里。

但设备树不会让没有驱动的设备突然工作。假如说明书说这里有一颗网卡,内核却没有能操作这颗网卡的驱动,系统仍然无法正常使用它。说明书也不会自动测量每根线接在哪里;板卡厂商、开发者或启动链路中的组件必须提供正确描述。

拿错房屋平面图也会误导维修师傅。开发板使用了另一块板子的 DTB,可能会造成串口没有输出、存储设备找不到、网卡无法启动,甚至更早阶段就卡住。看到设备树问题时,首先要确认 DTB 确实对应当前硬件,再检查内核驱动和描述是否匹配。

为什么台式电脑通常更多使用 ACPI?

你可能已经在上一篇见过 ACPI。传统台式机和笔记本通常由固件通过 ACPI 表格描述电源管理、处理器和部分设备关系;许多 ARM 开发板则使用设备树。它们都是把平台信息交给操作系统的方式,只是历史、结构和使用范围不同。

设备树不是 ARM 独占,也不是每台 ARM 机器都只能用它;ACPI 也不只属于传统台式机。这里只说最常见的场景:你在嵌入式开发板的 Linux 源码里经常看到 .dts,在普通 PC 上更常遇到 ACPI。

从一份硬件说明,到 Linux 开始找设备

一次典型的开发板启动开发者写 DTS 硬件说明
        ↓ dtc 编译
生成 DTB 二进制设备树
        ↓ U-Boot 选取并加载
DTB 与 Linux 内核一起放进内存
        ↓ U-Boot 交出 DTB 的位置
Linux 内核读取硬件信息、匹配驱动
        ↓ 找到根文件系统
启动 initramfs 中的 /init 或正式系统里的 PID 1

设备树的出现,让不同开发板能用数据告诉通用内核“我是谁、设备在哪里”。Open Firmware 提供了早期经验,PowerPC 的 FDT 把它变成便于传递的二进制形式,后来 Linux 将这套机制推广到 ARM 等平台。它解决的是硬件描述怎样交接的问题,不会替代启动程序、驱动程序或内核。

下一篇我们拿一份简化的 DTS 来逐行认路:根节点、compatible、reg、interrupts 和 /chosen 分别在说什么;再跟着 U-Boot 看看这份 DTB 怎样选中、装入内存并交给 Linux。之后,我们就能顺着这条路走到内核找到根文件系统、启动 initramfs 和 PID 1。