3. 设备树基础:从语法到实战

设备树(Device Tree)这东西,说白了就是一块「硬件描述板」。它告诉内核:你有哪些外设、它们挂在哪条总线上、地址是多少、中断怎么配。没有它,ARM 平台的内核根本不知道该怎么跟硬件打交道。

我记得刚接触嵌入式 Linux 那会儿,最头疼的就是看那些 .dts 文件。满篇的 reg、interrupts、compatible,感觉像在看天书。后来自己动手改了几次,踩了几个坑,才慢慢摸出门道。今天我就把设备树的核心语法、文件结构、编译流程给你捋一遍。

3.1 设备树语法:节点、属性、标签

设备树的语法其实很简单。它用树形结构描述硬件,每个硬件单元就是一个「节点」,节点的特征就是「属性」。标签则是给节点起个别名,方便其他地方引用。

3.1.1 节点(Node)

节点是设备树的基本单元。每个节点代表一个硬件设备或总线。节点可以嵌套,形成父子关系。

/ {
    soc {
        uart0: serial@10000000 {
            compatible = "ns16550";
            reg = <0x10000000 0x1000>;
            interrupts = <0 33 4>;
        };
    };
};

这里 / 是根节点,soc 是子节点,serial@10000000 是具体设备节点。@ 后面的地址是为了区分同名设备,不是必须的,但我建议你加上,方便调试。

我的习惯:节点命名尽量用「设备类型@地址」的格式。比如 i2c@20000000、spi@30000000。这样一看就知道是什么设备、挂在哪里。

3.1.2 属性(Property)

属性描述节点的特征。常见的属性有:

属性名 含义 示例
compatible 设备兼容性字符串,驱动匹配的关键 "ti,am335x-uart", "ns16550"
reg 寄存器地址和长度 <0x10000000 0x1000>
interrupts 中断号、触发类型 <0 33 4>
status 设备状态:okay 或 disabled "okay"
clocks 时钟源引用 <&clk_uart0>

属性值可以是字符串、32位整数数组、64位整数数组、二进制数据,或者引用其他节点。

3.1.3 标签(Label)

标签就是给节点起个名字,方便在其他地方引用。比如上面的 uart0: 就是一个标签。后面可以用 &uart0 来引用这个节点。

避坑指南:我曾经在一个项目里,给两个不同的节点起了相同的标签,结果编译时死活报错。找了半天才发现是标签重名了。标签在整个设备树里必须是唯一的,这点跟 C 语言的变量名一样。

3.2 dts/dtsi/dtb 文件结构

设备树源文件有三种后缀:.dts、.dtsi、.dtb。它们的关系有点像 C 语言的 .c、.h、.o 文件。

  • .dts:设备树源文件,描述具体板级的硬件。一个板子一个 .dts。
  • .dtsi:设备树头文件,描述 SoC 公共部分。多个板子可以共用同一个 .dtsi。
  • .dtb:编译后的二进制文件,内核启动时直接加载。

举个例子,TI 的 AM335x 系列芯片,公共部分放在 am33xx.dtsi 里,具体板子比如 BeagleBone Black 的 am335x-boneblack.dts 通过 #include "am33xx.dtsi" 引入公共定义,然后覆盖或添加自己的节点。

// am33xx.dtsi
/ {
    ocp {
        uart0: serial@44e09000 {
            compatible = "ti,am3352-uart", "ns16550";
            reg = <0x44e09000 0x2000>;
            interrupts = <72>;
            status = "disabled";
        };
    };
};

// am335x-boneblack.dts
#include "am33xx.dtsi"

&uart0 {
    status = "okay";
    pinctrl-0 = <&uart0_pins>;
    pinctrl-names = "default";
};

你看,.dtsi 里把 uart0 的 status 设为 disabled,.dts 里通过 &uart0 引用并改为 okay。这种「公共定义 + 板级覆盖」的方式,大大减少了重复代码。

注意:.dtsi 文件里不要写板级特有的东西,比如 GPIO 引脚复用、具体外设的使能。这些应该放在 .dts 里。否则换个板子,你就要改公共文件,容易出问题。

3.3 设备树编译与反编译

设备树源文件不能直接给内核用,得先编译成二进制 .dtb 文件。反过来,有时候拿到一个 .dtb,想看看里面是什么内容,就需要反编译。

3.3.1 编译:dts → dtb

编译工具是 dtc(Device Tree Compiler)。基本用法:

dtc -I dts -O dtb -o am335x-boneblack.dtb am335x-boneblack.dts

参数说明:

  • -I dts:输入格式是 .dts
  • -O dtb:输出格式是 .dtb
  • -o:指定输出文件名

如果 .dts 里 #include 了头文件,编译前需要先预处理。内核的构建系统会自动处理,但如果你手动编译,记得加 -E 参数:

cpp -P -I include am335x-boneblack.dts | dtc -I dts -O dtb -o output.dtb

这里 cpp 是 C 预处理器,-P 表示不输出行号标记,-I 指定头文件搜索路径。

3.3.2 反编译:dtb → dts

反编译同样用 dtc:

dtc -I dtb -O dts -o output.dts am335x-boneblack.dtb

反编译出来的 .dts 文件,可读性比源文件差一些,因为标签、注释都丢了。但用来排查问题足够了。

我的经验:调试设备树时,我经常在板子上执行 dtc -I fs -O dts /proc/device-tree,直接把内核当前使用的设备树反编译出来。这样能确认内核到底加载了哪个 .dtb,节点状态对不对。

3.4 设备树核心结构图

下面这张图,把设备树的文件结构、编译流程、内核加载关系串起来了。你一看就明白。

设备树文件结构与编译流程 .dts 板级文件 .dtsi SoC 公共文件 .h 头文件(可选) cpp 预处理 dtc 编译 .dtb 二进制文件 内核解析 / 驱动匹配 dtc 反编译 → .dts 调试技巧 cat /proc/device-tree/

3.5 实战:手动编译一个设备树

光说不练假把式。我们动手编译一个最简单的设备树。

创建一个 test.dts 文件:

/dts-v1/;

/ {
    model = "Test Board";
    compatible = "test,board";

    chosen {
        stdout-path = &uart0;
    };

    uart0: serial@10000000 {
        compatible = "ns16550";
        reg = <0x10000000 0x1000>;
        interrupts = <0 33 4>;
        status = "okay";
    };
};

然后编译:

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

如果一切正常,你会得到一个 test.dtb 文件。再用 hexdump 看看它的二进制内容:

hexdump -C test.dtb | head -20

你会看到文件头以 D0 0D FE ED 开头——这是设备树的魔数(Magic Number)。内核就是靠这个魔数来识别 .dtb 文件的。

避坑指南:我曾经在编译时忘了加 /dts-v1/; 版本声明,结果 dtc 报了一堆警告。虽然也能生成 .dtb,但内核加载时会解析失败。所以,每个 .dts 文件的第一行,一定要写上 /dts-v1/;。

反编译验证一下:

dtc -I dtb -O dts -o test_decompiled.dts test.dtb
cat test_decompiled.dts

对比反编译出来的文件和源文件,你会发现结构完全一致,只是标签和注释没了。嗯,这就是反编译的局限性——信息会丢失。

3.6 小结

设备树的基础知识,说白了就是三件事:

  • 语法:节点、属性、标签,怎么描述硬件
  • 文件结构:.dts 管板级,.dtsi 管公共,.dtb 是最终产物
  • 编译流程:预处理 → dtc 编译 → 内核加载,反过来也能反编译

这些是基本功。后面讲驱动匹配、低功耗调优的时候,你会反复跟设备树打交道。现在把基础打牢,后面就顺了。