3. 设备树文件结构:dts、dtsi、dtb、dtbo 文件格式与关系
做嵌入式 Linux 开发,天天跟设备树打交道。但你有没有想过,那些 .dts、.dtsi、.dtb 文件到底有什么区别?它们之间是怎么串起来的?
我记得刚入行那会儿,看着一堆 .dts 和 .dtsi 文件,心里直犯嘀咕:这玩意儿怎么跟 C 语言的 .h 文件似的?后来踩了几个坑,才真正搞明白它们的设计哲学。今天我就把这几年的理解,掰开了揉碎了讲给你听。
3.1 四种文件格式,各司其职
设备树家族有四个成员:dts、dtsi、dtb、dtbo。说白了,它们就是同一份数据在不同阶段的不同形态。
| 文件格式 | 全称 | 本质 | 使用场景 |
|---|---|---|---|
| .dts | Device Tree Source | 文本源码,描述硬件 | 板级描述文件 |
| .dtsi | Device Tree Source Include | 公共头文件,可复用 | SoC 通用描述 |
| .dtb | Device Tree Blob | 二进制,内核直接使用 | Bootloader 传给内核 |
| .dtbo | Device Tree Blob Overlay | 二进制补丁,动态加载 | 设备树叠加层 |
嗯,这里要注意:.dts 和 .dtsi 是人看的,.dtb 和 .dtbo 是机器吃的。你写代码时用 .dts,编译后生成 .dtb,内核启动时解析 .dtb。就这么简单。
3.2 dtsi:设备树的“头文件”
为什么要有 .dtsi?你想想看,一个 SoC 厂商要支持几十款开发板,每款板子都写一份完整的 .dts,那得重复多少代码?
所以就有了 .dtsi。它专门放公共的、可复用的描述。比如 CPU 核数、中断控制器、时钟频率这些,都是 SoC 级别的,跟具体板子无关。
我在项目中遇到过这样的情况:某芯片原厂更新了 SoC 的 .dtsi,我们只需要重新编译一下,所有板子的 .dts 自动受益。这就是 .dtsi 的价值——一处修改,处处生效。
// 一个典型的 .dtsi 文件(soc.dtsi)
/ {
#address-cells = <2>;
#size-cells = <2>;
cpu@0 {
compatible = "arm,cortex-a72";
reg = <0x0 0x0>;
};
gic: interrupt-controller@f0000000 {
compatible = "arm,gic-v3";
reg = <0x0 0xf0000000 0x0 0x10000>;
interrupt-controller;
#interrupt-cells = <3>;
};
};
你看,这里只描述了 SoC 内部的东西,没有板级外设。板子上的 UART、GPIO、I2C 这些,留给 .dts 去加。
3.3 dts:板级硬件的“最终描述”
.dts 才是真正描述你手上那块板子的文件。它通过 #include 引入 .dtsi,然后补充板级特有的内容。
我建议你养成一个习惯:把 .dts 写得尽量精简。能用 &节点 引用的,就别重新写一遍。这样代码清晰,也方便后续维护。
// 一个典型的 .dts 文件(myboard.dts)
#include "soc.dtsi"
#include "myboard-pinctrl.dtsi"
/ {
model = "MyBoard v2.0";
compatible = "mycompany,myboard", "mycompany,soc";
chosen {
stdout-path = &uart0;
};
memory@80000000 {
device_type = "memory";
reg = <0x0 0x80000000 0x0 0x40000000>;
};
};
&uart0 {
status = "okay";
clock-frequency = <115200>;
};
&i2c0 {
status = "okay";
eeprom@50 {
compatible = "atmel,24c02";
reg = <0x50>;
};
};
这里有个细节:#include 可以引入 .dtsi,也可以引入 .h 头文件。为什么?因为设备树编译器(dtc)在预处理阶段会调用 C 预处理器,所以 #define 宏定义也能用。我曾经用这个特性,把 GPIO 管脚号定义在 .h 里,多个 .dts 共享,省了不少事。
3.4 dtb:内核真正吃进去的东西
.dtb 是编译后的二进制文件。你执行 make dtbs 或者手动运行 dtc -I dts -O dtb -o myboard.dtb myboard.dts,就能得到它。
内核启动时,Bootloader 会把 .dtb 加载到内存,然后把地址传给内核。内核解析这个二进制,构建出设备树结构体,驱动再从中读取硬件信息。
核心要点:.dtb 是扁平化的二进制格式。它没有指针,没有动态分配,所有数据都是连续存放的。内核解析时,通过偏移量来访问各个节点和属性。
我调试过一个问题:某次 .dtb 文件损坏了,内核启动到一半就挂掉。后来发现是 Bootloader 加载时地址没对齐。嗯,从那以后我每次都会用 dtc -I dtb -O dts 反编译一下,确认 .dtb 内容正确。
3.5 dtbo:动态叠加的“补丁”
dtbo 是设备树叠加层。它的出现,解决了运行时修改设备树的需求。
举个例子:你的板子有一个 M.2 接口,可以插不同的扩展卡。每张卡有不同的设备树描述。你不可能为每种组合都编译一个完整的 .dtb。这时候,dtbo 就派上用场了。
你只需要编译一个基础的 .dtb,再为每张卡编译一个 .dtbo。系统启动后,根据实际插入的卡,动态加载对应的 .dtbo,叠加到基础设备树上。
// 一个简单的 dtbo 文件(m2-nvme.dtso)
/dts-v1/;
/plugin/;
/ {
fragment@0 {
target = &pcie0;
__overlay__ {
status = "okay";
nvme@0,0 {
reg = <0x0 0x0 0x0 0x0 0x0>;
};
};
};
};
编译命令:dtc -I dts -O dtb -@ -o m2-nvme.dtbo m2-nvme.dtso。注意那个 -@ 选项,它告诉编译器保留符号信息,否则叠加层找不到目标节点。
避坑指南:我曾经在叠加层里写错了 target 路径,结果加载后整个 PCIe 控制器都挂了。调试了两天才发现,是 fragment@0 的 target 指向了错误的 phandle。所以,写 dtbo 时一定要仔细核对节点引用。
3.6 它们之间的关系:一张图说清楚
下面这张 SVG 图,展示了从源码到最终内核使用的完整流程。我画的时候特意把关键路径标红了,你一看就明白。
从这张图你能看到:.dts 和 .dtsi 是源头,.dtb 是最终产物,.dtbo 是可选补丁。内核启动时,先加载 .dtb,如果有叠加层需求,再动态合并 .dtbo。
3.7 实战建议:如何组织你的设备树文件
说了这么多理论,最后给点实际建议。我这些年总结了几条原则:
- 把 SoC 级描述全扔进 .dtsi:CPU、中断控制器、时钟、DMA 这些,跟板子无关的,统统放 .dtsi。一个 SoC 只维护一份。
- 板级差异用 .dts 解决:内存大小、外设使能、管脚复用,这些跟具体板子绑定的,放 .dts。
- 模块化用 .dtsi 拆分:如果板子有多个功能模块(比如 WiFi、音频、显示),每个模块单独一个 .dtsi,主 .dts 用
#include引入。这样代码清晰,也方便复用。 - 动态场景用 .dtbo:可插拔的外设、FPGA 动态加载、不同 SKU 的差异化配置,用叠加层实现。
小技巧:我习惯在 .dts 文件头部加一段注释,写明这个文件依赖哪些 .dtsi,以及依赖的版本号。这样别人接手时,一眼就知道文件之间的关系,不会出现版本不匹配的问题。
好了,关于设备树的四种文件格式,就讲到这里。你只要记住一句话:.dtsi 是公共库,.dts 是板级描述,.dtb 是编译产物,.dtbo 是运行时补丁。搞清楚了它们的关系,设备树这块你就入门了。
公众号:蓝海资料掘金营,微信deep3321