第 2 章 机器的说明书:设备树
在以前,硬件配置是写死在代码里的。你也许见过那种令人窒息的 board.c 文件:几千行寄存器配置,混杂着 #ifdef 和魔术数字,维护这种代码简直是噩梦。如果你换了一块板子,或者把外设换个位置,你就得去内核源码里动刀——这违背了软件工程的基本原则。
我们需要一种方式,把「硬件描述」从「驱动逻辑」里剥离出来。这正是设备树存在的意义。你可以把它想象成一张给内核看的硬件说明书(Hardware Manifest)。内核启动时并不知道自己跑在哪块板子上,它手里只有一个二进制 blob(DTB),里面记录了这棵板子上有什么设备、它们接在哪条总线上、地址是多少。内核的任务就是读懂这份说明书,然后去匹配驱动。
但「说明书」这个比喻有一个地方是错的:真正的说明书是给人看的,结构可以松散。设备树是给机器看的,它的结构是严格树状的,每个节点对应一个物理设备或总线,子节点挂在父节点下面——这不是人话,是机器话。任何一点格式错误,内核解析时就会直接 panic。这一章我们要拆解这份说明书到底是怎么写的,这不仅仅是背诵语法,更是理解 Linux 内核是如何认知硬件世界的。
2.1 概览:树状的世界观
DTSpec 定义了一个叫作设备树的数据结构。引导程序会把这棵树加载到内存里,然后把指针扔给客户端程序(通常是内核)。设备树的核心逻辑非常简单:它是一棵树。节点就是树上的枝干,每个节点代表一个设备,节点里包含了一系列「属性」,属性是键值对用来描述这个设备的特性。除了最顶层的根节点,每个节点都有且仅有一个父节点。
这里的「设备」是一个很宽泛的概念。它可能是一个真实的硬件设备,比如一个 UART 控制器;也可能是一个硬件设备的一部分,比如 TPM 芯片里的随机数生成器;甚至可能是一个通过虚拟化提供的设备,比如远程 I2C 设备的协议代理。节点不需要一定对应物理实体,但通常情况下,它们和物理硬件有着某种对应关系。
节点的设计必须具备通用性——你不能为了某个特定的操作系统或者某个特定的项目去定制节点。节点描述的应该是「这个设备是什么」,任何 OS 或项目拿到这个描述都应该能理解它。设备树通常用来描述那些无法被动态探测的设备。比如 PCI 架构允许操作系统主动去扫描总线探测有哪些设备插在上面,这种情况下你其实不需要在设备树里描述 PCI 设备本身。但是,PCI 主桥本身通常是需要探测的,如果它不能被软件探测到,那就必须写在设备树里。当然,引导程序也可以负责去扫描 PCI 总线,然后把扫描结果生成一棵设备树交给操作系统——这也是完全合法的。
为了让你有个直观感受,下面这个例子展示了一个几乎足以启动简单操作系统的设备树结构,里面包含了平台类型、CPU、内存和一个串口:
/ {
cpus {
#address-cells = <1>;
#size-cells = <0>;
cpu@0 {
device_type = "cpu";
reg = <0>;
timebase-frequency = <825000000>;
clock-frequency = <825000000>;
};
cpu@1 {
device_type = "cpu";
reg = <1>;
timebase-frequency = <825000000>;
clock-frequency = <825000000>;
};
};
memory@0 {
device_type = "memory";
reg = <0 0x20000000>;
};
chosen {
bootargs = "root=/dev/sda2";
};
aliases {
serial0 = "/uart@fe001000";
};
uart@fe001000 {
compatible = "ns16550";
reg = <0xfe001000 0x100>;
};
};你看,这就是一棵树的全部家当。根节点 / 下面挂着 cpus、memory、chosen 和 uart。它们通过属性描述了自己是谁、在哪里。
2.2 结构与命名规则
现在让我们把镜头拉近,看看这棵树的骨架是怎么搭起来的。节点名称的命名规则看似随意,实则严苛——每个节点的名字必须遵循 node-name@unit-address 的格式。
node-name(节点名) 长度必须是 1 到 31 个字符,字符集只能包含数字、字母、逗号、句号、下划线、加号、减号。它必须以字母(大小写均可)开头,最重要的是应该描述设备的通用类别而不是具体的型号——比如叫 ethernet 就比叫 intel-i82574l 要好。
unit-address(单元地址) 是 @ 后面的内容,它和节点所在的总线类型强相关。最关键的规则是:单元地址必须匹配该节点 reg 属性里的第一个地址。如果节点没有 reg 属性,那么 @unit-address 必须省略,这种情况下 node-name 本身必须足以在同一层级中区分该节点。根节点是个特例,它既没有 node-name 也没有 unit-address,就用一个正斜杠 / 表示。
cpu@0 { ... };
cpu@1 { ... };
ethernet@fe002000 { ... };
ethernet@fe003000 { ... };这里有一个非常容易踩的坑:如果你的节点名是 serial@fe001000,但你的 reg 属性第一个地址是 0xfe001000,这就对上了。但如果 reg 里写的是 0xfe002000,编译器通常会报错,或者内核解析时会一脸懵逼。
虽然你可以给节点起各种名字,但为了维护性,建议使用一套通用的词汇表。名字应该反映功能而不是编程模型——比如尽量用 gpio 而不是 gpio-controller(虽然后者也被接受),用 i2c 而不是某个厂商的代号。推荐的名字包括 adc、audio-codec、backlight、bluetooth、cache-controller、camera、ethernet、gpio、i2c、interrupt-controller、keyboard、mmc、pci、rtc、serial、spi、timer、usb 等等。给节点起名时先去这份列表里找找,能对上号的就用标准名。
那么怎么在树里找到唯一的那个节点呢?用路径。就像文件系统一样,路径是从根节点开始经过所有后代节点直到目标节点的全路径。比如要找到 cpu #1,路径就是 /cpus/cpu@1。根节点的路径就是 /。如果路径在上下文中是明确的,有时候可以省略单元地址,但如果遇到歧义,行为就是未定义的——这通常意味着内核可能会选错节点,或者直接挂掉。
节点只是个壳,属性才是灵魂。每个节点包含一系列属性,属性由名称和值组成。属性名称是长度 1 到 31 个字符的字符串,允许的字符集和节点名类似但多了 ? 和 #。如果你要定义非标准的属性,必须加上一个唯一的前缀(比如公司股票代码)防止冲突,例如 fsl,channel-fifo-len 或 linux,network-index。
属性值是一个字节数组,长度可以是零。DTSpec 定义了几种基本的数据类型:<empty> 是空值,属性的存在本身即表示 true;<u32> 是大端序的 32 位整数;<u64> 是大端序的 64 位整数由两个 <u32> 拼接而成;<string> 是可打印字符串以 null 结尾;<prop-encoded-array> 是编码数组格式由具体属性定义,这是最复杂也最灵活的类型;<phandle> 是一个唯一的 <u32> 数值用于引用树里的其他节点;<stringlist> 是多个字符串拼接在一起。
2.3 标准属性详解
有了节点和属性作为骨架,现在我们来填肉。DTSpec 规定了一系列标准属性,它们是内核理解硬件的通用词汇表。本节所有示例均使用 DTS 格式。
compatible 是最重要的属性,它包含一个或多个字符串定义了设备的编程模型。操作系统通过这个属性来查找并加载对应的驱动程序,这个列表是从最具体到最一般排序的,允许一个设备声称自己是"某型号"同时也兼容"某通用系列"。推荐格式是 "manufacturer,model",字符串里只能有小写字母、数字和连字符,必须以字母开头。内核会先找支持 fsl,mpc8641 的驱动,找不到就退而求其次找支持 ns16550 的通用驱动,这种回退机制是兼容性的基石。
model 属性指定设备的精确制造商型号,格式和 compatible 类似。compatible 是给驱动匹配用的(可能很通用),model 是给人看的(告诉你板子具体是啥)。
phandle 就好比 C 语言里的指针,它给节点分配了一个全局唯一的数字 ID,其他节点可以通过这个 ID 来引用它。你可能会遇到一种古老的写法叫 linux,phandle,那是历史遗留的垃圾——如果找不到 phandle,作为兼容性补救,可以去读 linux,phandle。但实际上你在写 .dts 源文件时通常不需要手写 phandle = <1>,编译器(DTC)会自动帮你处理引用,在源码里我们通常用 &label 来引用。
status 属性表示设备的操作状态,如果这个属性不存在默认等同于 "okay"。有效值包括 "okay"(设备是可操作的)、"disabled"(设备当前不可操作但将来可能可以)、"reserved"(设备是可操作的但不要使用它,通常表示这块硬件被其他软件组件占用了)、"fail"(设备挂了检测到严重错误)。调试时如果驱动加载了但设备没反应,第一件事就是检查这个属性是不是被设成了 "disabled"。
#address-cells 和 #size-cells 用在拥有子节点的父节点上,它们规定了子节点的 reg 属性里地址和长度各占多少个 <u32> 单元。这两个属性不会从祖先节点继承,你必须在每个有子节点的节点里显式定义。如果不定义,客户端程序(内核)通常会假设默认值:地址 2 个,长度 1 个。
reg 描述了设备在其父总线地址空间内的资源地址。通常这是内存映射 I/O(MMIO)寄存器的偏移量和长度,但在某些总线上(比如 I2C)含义可能不同。格式就是一串 <address length> 对,具体的 cell 数量由父节点的 #address-cells 和 #size-cells 决定。
virtual-reg 属性指定了一个有效虚拟地址,这个地址映射到了 reg 属性里的第一个物理地址。它的作用是让引导程序告诉客户端程序:"嘿,我已经帮你把这个物理地址映射到这个虚拟地址了,你直接用就行,省得你自己去 ioremap 了。" 这在某些早期启动阶段非常有用。
ranges 是一个定义地址映射的属性,它负责把子总线的地址空间翻译成父总线的地址空间。格式是一堆三元组:child-bus-address(子总线里的物理地址)、parent-bus-address(父总线里的物理地址)、length(映射范围的大小)。如果值是 <empty>,表示父子地址空间完全一样不需要翻译;如果属性不存在,表示子节点和父节点地址空间之间没有映射关系。
dma-ranges 描述了子总线物理地址空间到父总线物理地址空间的 DMA 映射关系,格式和 ranges 完全一样。这在某些 IOMMU 或者复杂总线拓扑结构中至关重要——搞错了这个,DMA 就会写到错误的内存地址上去,然后数据就崩了。
dma-coherent 和 dma-noncoherent 是一对布尔型属性用来指示 DMA 缓存一致性。如果你的架构默认行为已经符合预期,就不需要加这些属性,这主要是为了修正某些架构的默认行为或者处理特殊硬件。
name 和 device_type 是两个历史遗留产物。name 以前用来指定节点名,现在不推荐用了,内核直接从节点名本身解析。device_type 是 IEEE 1275 标准里用来描述 FCode 编程模型的,DTSpec 不用 FCode 所以也不怎么用它了——除了 cpu 和 memory 节点为了兼容性还保留着,别的地方别再用了。
2.4 中断与中断映射
这可能是设备树里最让人头秃的部分。DTSpec 采用了一种叫中断树的模型,虽然叫树但实际上它是一个有向无环图(DAG)。在这个逻辑树里,硬件的中断层级和路由关系被完整地表达了出来。
每个中断信号都在一个特定的上下文里被解释,这个上下文就是"中断域"。一个中断描述符的格式由中断域的根节点定义,根节点用 #interrupt-cells 属性来定义。比如 Open PIC 控制器可能用 2 个 cell(一个中断号一个极性/触发方式),而另一个控制器可能只用 1 个。中断域的根节点只有两种可能:中断控制器(真实的物理硬件需要驱动来处理中断)或者中断连接点(不产生也不处理中断,只负责做翻译和转发)。
如果你是一个产生中断的设备,你需要这些属性:interrupts(定义了这个设备产生的中断,格式由你所在的 interrupt domain 的根节点定义)、interrupt-parent(指向你的中断父节点,如果这个属性不存在默认你的设备树父节点就是你的中断父节点)、interrupts-extended(如果你有两个中断一个发给 PIC 一个发给 GIC,那就用这个,它把父句柄和中断描述符绑在一起了)。
如果你是一个中断控制器,你需要 #interrupt-cells(告诉别人引用我的中断需要几个 cell)和 interrupt-controller(只要我有了这个属性我就宣布自己是个中断控制器)。
连接点是做翻译工作的,它有一张表叫 interrupt-map。这张表定义了子域的中断描述符怎么映射到父域,每一行包含五个部分:child unit address(子节点的单元地址)、child interrupt specifier(子节点的中断描述符)、interrupt parent(一个 <phandle> 指向父中断控制器)、parent unit address(父域里的单元地址,如果父节点是纯控制器没地址概念这里就是空的)、parent interrupt specifier(父域里的中断描述符)。interrupt-map-mask 是一个掩码,查表前先用这个掩码对输入的(子地址 + 子描述符)做一次 AND 操作——这通常是为了屏蔽掉那些不相关的位(比如 Function Number)。
PCI 的中断映射是经典场景。PCI 的 INTA/B/C/D 是物理引脚,它们怎么接到中断控制器的第几号线上是完全随板的。假设 SoC 里有个 PCI 总线控制器下面插了两个插槽(Slot 1 和 Slot 2),它们的 INTA~INTD 都接到了 Open PIC 上:
soc {
#address-cells = <1>;
#size-cells = <1>;
open-pic {
clock-frequency = <0>;
interrupt-controller;
#address-cells = <0>;
#interrupt-cells = <2>;
};
pci {
#address-cells = <3>;
#size-cells = <2>;
#interrupt-cells = <1>;
interrupt-map-mask = <0xf800 0 0 7>;
interrupt-map = <
0x8800 0 0 1 &open-pic 2 1 /* Slot 1 INTA */
0x8800 0 0 2 &open-pic 3 1 /* Slot 1 INTB */
0x8800 0 0 3 &open-pic 4 1 /* Slot 1 INTC */
0x8800 0 0 4 &open-pic 1 1 /* Slot 1 INTD */
0x9000 0 0 1 &open-pic 3 1 /* Slot 2 INTA */
>;
};
};我们来拆解第一行 0x8800 0 0 1 &open-pic 2 1:child unit address 是 PCI 地址(3 个 cell)包含了高位的设备号 (IDSEL 0x11);child interrupt specifier 是 <1> 表示 INTA;interrupt parent 指向 open-pic;parent unit address 是空的(因为 open-pic 的 #address-cells 是 0);parent interrupt specifier 是 <2 1> 表示 open-pic 的第 2 号中断高电平触发。
假设 Slot 2 (IDSEL 0x12) 上的 Function 3 发出了一个 INTB 信号。构造输入值:地址是 0x9300... (包含 0x12 和 0x3),中断是 2 (INTB)。应用 Mask <0xf800 0 0 7>:地址部分 AND 掩码 -> 0x9000 (Function 3 被屏蔽了),中断部分 AND 7 -> 2。拿着 <0x9000 0 0 2> 去查表,命中第二行 Slot 2 的 INTB 条目,得到结果 &open-pic 4 1。这就是内核解析 PCI 中断的全过程。
2.5 连接节点与说明符映射
中断是"一种"资源,像 GPIO、时钟、复位信号也是类似的资源。它们都有"域"的概念,都需要映射。我们把这种统称为 Nexus Node(连接节点),它负责把子域的 <specifier> 映射到父域。
属性规律是通用的,把 interrupt 换成 gpio、clock 就行:#<specifier>-cells 描述符占几个 cell,<specifier>-map** 是映射表格式为 (child specifier, specifier parent, parent specifier),<specifier>-map-mask** 查表前屏蔽干扰位,****<specifier>-map-pass-thru** 定义了哪些位需要"穿透"——也就是子域的某些标志位(比如高低电平有效)需要原封不动地传给父域不能被映射表覆盖。
假设有个板子,SoC 上有两个 GPIO 控制器它们通过一个连接器引出。外部设备看的是连接器的引脚号,SoC 看的是控制器的引脚号:
soc {
soc_gpio1: gpio-controller1 {
#gpio-cells = <2>;
};
soc_gpio2: gpio-controller2 {
#gpio-cells = <2>;
};
};
connector: connector {
#gpio-cells = <2>;
gpio-map = <
0 0 &soc_gpio1 1 0
1 0 &soc_gpio2 4 0
2 0 &soc_gpio1 3 0
3 0 &soc_gpio2 2 0
>;
gpio-map-mask = <0xf 0x0>;
gpio-map-pass-thru = <0x0 0x1>;
};
expansion_device {
reset-gpios = <&connector 2 GPIO_ACTIVE_LOW>;
};查找 Pin 2 的过程:查询值是 <2 GPIO_ACTIVE_LOW>,注意 GPIO_ACTIVE_LOW 是标志位(比如 0x1)。应用 gpio-map-mask (<0xf 0x0>):Pin 号保留标志位被抹掉 -> <0x2 0x0>。查表 gpio-map 匹配到 2 0 &soc_gpio1 3 0,得到父节点 soc_gpio1 和描述符 <3 0>。应用 gpio-map-pass-thru (<0x0 0x1>):这个掩码表示"第 2 个 cell 的第 0 位要穿透",先把查表得到的父描述符 <3 0> 的对应位清空(根据 pass-thru 的反码),再把子描述符里的位拷贝过去,最终结果 <3 GPIO_ACTIVE_LOW>。这个机制确保了即使做了地址映射,标志位(如高低电平有效)这种电气特性也能从外部设备一直传递到最终控制器。
本章建立的核心认知是:设备树不仅仅是静态数据,它是一套描述硬件拓扑和资源映射的图灵完备语言。 理解了 reg、ranges 和各种 map 属性,你就理解了内核是如何将混乱的物理世界抽象成有序的软件对象的。
还记得开头那个问题吗——为什么要剥离硬件描述?因为只有剥离出来,我们才能用一套统一的逻辑去处理千奇百怪的硬件板卡。这一章所有的属性定义,都是在构建这套通用逻辑的词汇表。下一章我们将深入到具体的设备节点定义,看看那些 CPU、内存等必须存在的节点是怎么规定出来的。