第3章:设备树文件结构——dts、dtsi、dtb、dtbo的区别与联系
说实话,我刚接触设备树那会儿,也被这堆后缀名搞晕过。dts、dtsi、dtb、dtbo……看着像一家人,但各自干啥的?今天咱们就把它们彻底捋清楚。
3.1 先看一张总图
我习惯把这四种文件的关系想象成「源代码 → 头文件 → 编译产物 → 补丁包」的流程。你想想看:
- dts(Device Tree Source)—— 设备树源文件,你手写的那个
- dtsi(Device Tree Source Include)—— 公共头文件,被 dts 引用
- dtb(Device Tree Blob)—— 编译后的二进制,内核直接吃
- dtbo(Device Tree Blob Overlay)—— 运行时补丁,动态加载用
嗯,说白了就是 C 语言的 .c、.h、.o、.so 的关系。但细节上有些坑,我一个个说。
3.2 dtsi:公共部分抽出来
我在项目中遇到过一块 SoC 要支持七八款不同外设的板子。如果每个板子都写一份完整的 dts,那维护量太大了。所以就有了 dtsi。
dtsi 文件里放的是「通用」内容:
- SoC 内部外设的定义(比如 UART、I2C、SPI 控制器)
- 时钟、中断、GPIO 等基础资源
- 内存映射地址、寄存器偏移量
举个例子,一个典型的 imx6ull.dtsi 会包含:
/ {
#address-cells = <1>;
#size-cells = <1>;
cpus {
#address-cells = <1>;
#size-cells = <0>;
cpu@0 {
compatible = "arm,cortex-a7";
device_type = "cpu";
reg = <0>;
};
};
soc {
uart1: serial@02020000 {
compatible = "fsl,imx6ul-uart", "fsl,imx6q-uart";
reg = <0x02020000 0x4000>;
interrupts = <GIC_SPI 26 IRQ_TYPE_LEVEL_HIGH>;
status = "disabled";
};
};
};
注意看,这里 status = "disabled"。为什么?因为 dtsi 不知道你的板子上到底焊了哪个外设,所以默认全关。具体哪个外设要打开,由板级 dts 去 &uart1 { status = "okay"; } 来激活。
3.3 dts:板级定制的主文件
dts 才是你真正要写的文件。它通过 #include 引入 dtsi,然后做三件事:
- 覆盖:把 dtsi 里
disabled的外设改成okay - 追加:添加板级特有的设备(比如 LCD 屏参数、WiFi 模块)
- 修改:调整引脚复用、时钟频率等
看个实际例子:
#include "imx6ull.dtsi"
/ {
model = "MyBoard i.MX6ULL";
compatible = "myboard,imx6ull", "fsl,imx6ull";
chosen {
stdout-path = &uart1;
};
leds {
compatible = "gpio-leds";
led0 {
label = "heartbeat";
gpios = &gpio1 3 GPIO_ACTIVE_LOW;
linux,default-trigger = "heartbeat";
};
};
};
&uart1 {
status = "okay";
pinctrl-names = "default";
pinctrl-0 = <&pinctrl_uart1>;
};
这里 &uart1 的写法就是「引用 dtsi 里定义的那个节点,然后修改它」。这种语法叫「标签引用」,我个人觉得是设备树最优雅的设计之一。
#include 对应的 dtsi,编译出来的 dtb 会缺一堆节点。而且编译器不会报错,只是内核启动时某些外设不工作。排查起来特别痛苦。
3.4 dtb:内核真正吃的东西
dtb 是编译产物。用 dtc(Device Tree Compiler)把 dts 编译成二进制:
dtc -I dts -O dtb -o myboard.dtb myboard.dts
编译后的 dtb 是扁平化的二进制格式,内核在启动时由 bootloader(比如 U-Boot)加载到内存,然后解析。你想想看,内核不可能去解析文本格式的 dts,那太慢了。所以 dtb 就是给内核吃的「机器码」。
dtb 的大小通常只有几 KB 到几十 KB。我见过最夸张的一个 dtb 有 200KB,那板子外设多得吓人。
3.5 dtbo:动态补丁的妙用
dtbo 是后来才加入的机制。它的出现解决了一个实际问题:
假设你的板子有扩展接口,可以插不同的子卡。插 A 卡时要用 I2C 和 SPI,插 B 卡时要用 UART 和 GPIO。如果每个组合都编译一个完整的 dtb,那得几十个文件。而且有些场景下,系统运行时才能知道插了什么卡。
dtbo 就是「运行时补丁」。它只描述「新增或修改的部分」,然后通过 configfs 或 libfdt 动态加载到已有的 dtb 上。
一个 dtbo 文件长这样:
/dts-v1/;
/plugin/;
/ {
fragment@0 {
target = &i2c1;
__overlay__ {
#address-cells = <1>;
#size-cells = <0>;
temp_sensor: tmp102@48 {
compatible = "ti,tmp102";
reg = <0x48>;
};
};
};
};
注意开头的 /plugin/;,这是告诉编译器「这是个补丁,不是完整设备树」。编译方式也略有不同:
dtc -I dts -O dtb -@ -o myoverlay.dtbo myoverlay.dts
那个 -@ 参数是生成符号表,让补丁能正确找到要修改的节点。
3.6 它们之间的转换关系
| 源文件 | 编译命令 | 产物 | 用途 |
|---|---|---|---|
| .dts + .dtsi | dtc -I dts -O dtb | .dtb | 内核启动时加载 |
| .dts (带 /plugin/) | dtc -I dts -O dtb -@ | .dtbo | 运行时动态加载 |
| .dtb | dtc -I dtb -O dts | .dts (反编译) | 调试、逆向分析 |
最后那个反编译功能我经常用。有时候拿到一个别人的 dtb,想知道它里面配了啥,直接 dtc -I dtb -O dts -o dump.dts board.dtb 就能看到源码。嗯,这招在调试时特别管用。
好了,这四种文件的关系你搞明白了吗?下一章咱们就动手写第一个 dts 文件,从零开始搭一个最小系统。