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 设备树核心结构图
下面这张图,把设备树的文件结构、编译流程、内核加载关系串起来了。你一看就明白。
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 编译 → 内核加载,反过来也能反编译
这些是基本功。后面讲驱动匹配、低功耗调优的时候,你会反复跟设备树打交道。现在把基础打牢,后面就顺了。