设备树基础语法:DTS、DTC、DTB、DTSI 文件格式与编译流程

设备树这东西,刚接触时容易懵。一堆缩写来回倒腾,DTS、DTB、DTC、DTSI……到底谁是谁?

我当年第一次移植内核时,就栽在设备树编译上。改了个 dts 文件,make 完直接黑屏。后来才发现,是忘了把 include 的 dtsi 路径写对。

说白了,设备树就是一套描述硬件的「配置文件」。它告诉内核:你有哪些外设、挂在哪条总线上、寄存器地址是多少、中断号是几。没有它,内核就是个瞎子。

四种文件格式,各司其职

先理清这四种文件的关系。我习惯把它们分成「源码」和「产物」两类:

文件后缀 全称 本质 是否可读
.dts Device Tree Source 设备树源码(文本) 是
.dtsi Device Tree Source Include 设备树头文件(文本) 是
.dtb Device Tree Blob 编译后的二进制 否
.dtbo Device Tree Blob Overlay 叠加层二进制 否

.dts 是主文件,一个板子通常对应一个。比如 imx6ull-myboard.dts。

.dtsi 是公共片段,类似 C 语言的 .h 头文件。SoC 厂商会把 CPU、内存控制器、时钟这些通用部分写在 .dtsi 里,板级差异才放 .dts。

.dtb 是最终产物,Bootloader 直接加载它传给内核。

.dtbo 是后来加的,用于运行时动态修改设备树。我用的不多,但树莓派和 FPGA 场景很常见。

我的习惯: 拿到一个新板子,先看 .dtsi 里的 soc 节点。那里藏着 SoC 所有外设的骨架,比看原理图快多了。

DTS 语法速览:节点、属性、值

设备树的语法其实很简单。一个节点就是一个设备,属性就是设备的参数。

/dts-v1/;

#include "imx6ull.dtsi"

/ {
    model = "MyBoard i.MX6ULL";
    compatible = "mycompany,imx6ull-myboard";

    chosen {
        stdout-path = &uart1;
    };

    memory@80000000 {
        device_type = "memory";
        reg = <0x80000000 0x20000000>;
    };

    leds {
        compatible = "gpio-leds";
        led0 {
            label = "heartbeat";
            gpios = <&gpio1 3 GPIO_ACTIVE_LOW>;
        };
    };
};

几个关键点:

  • 根节点用 / 表示,一个文件只有一个。
  • 节点名可以带 @地址,比如 memory@80000000,表示这个设备在 0x80000000 处。
  • 属性分几种:字符串用双引号,32 位整数用尖括号 <>,字节数组用方括号 []。
  • &uart1 这种写法叫「引用」,用来引用其他节点。我经常用它来给已有节点追加属性。
注意: 节点名不要用大写字母和特殊符号。内核解析时对大小写敏感,但社区规范统一用小写加连字符。我曾经见过有人用 LED_0,结果驱动匹配不上,查了一下午。

编译流程:从 DTS 到 DTB

整个过程就三步:预处理、编译、打包。嗯,跟 C 语言编译几乎一样。

# 第一步:预处理(展开 #include 和宏)
cpp -nostdinc -I include -undef -x assembler-with-cpp \
    imx6ull-myboard.dts imx6ull-myboard.dts.pre

# 第二步:编译为 DTB
dtc -I dts -O dtb -o imx6ull-myboard.dtb imx6ull-myboard.dts.pre

# 第三步(可选):反编译验证
dtc -I dtb -O dts -o imx6ull-myboard.dts.dump imx6ull-myboard.dtb

实际工程中,你不需要手动敲这些命令。内核的 Makefile 已经封装好了:

make ARCH=arm imx6ull-myboard.dtb

它会自动处理依赖关系。你改了 .dtsi,它也会重新编译所有引用它的 .dts。

核心流程: DTS + DTSI → 预处理 → DTC 编译 → DTB → Bootloader 加载 → 内核解析

DTC 编译器:不只是翻译

DTC(Device Tree Compiler)是设备树的灵魂工具。它除了把文本转二进制,还会做两件重要的事:

  • 语法检查: 节点名是否合法、属性值类型是否匹配、地址长度是否对齐。我遇到过最坑的是 reg 属性少写了一个 <>,DTC 直接报段错误。
  • 自动合并: 同一个节点如果在多个地方出现,DTC 会把属性合并到一起。这就是为什么 .dtsi 里定义节点,.dts 里可以追加属性。

DTC 还有一些实用选项:

选项 作用 我什么时候用
-@ 生成叠加层支持信息 做动态设备树时必加
-H 指定 phandle 格式 兼容旧版 U-Boot 时用
-Wno-unit_address_vs_reg 忽略地址不匹配警告 某些老 SoC 的 dtsi 会触发,不影响功能

SVG:设备树编译与加载全流程

下面这张图,把从源码到内核解析的完整路径画出来了。我每次给新人讲设备树,都会先画一遍这个流程。

设备树编译与加载流程 设备树编译与加载流程 .dts 板级文件 .dtsi SoC 头文件 .h 宏定义头文件 CPP 预处理(展开 #include / #define) DTC 编译(语法检查 + 合并) .dtb 二进制设备树 Bootloader(U-Boot)加载 DTB 内核解析 DTB → 创建设备模型 最终:/sys/firmware/devicetree 可查看解析后的设备树

避坑指南:我踩过的三个坑

这些年跟设备树打交道,没少被折腾。挑三个典型的说说:

  1. phandle 冲突
    我曾经在 .dtsi 里定义了一个 gpio0 节点,又在 .dts 里重新定义了一个同名节点。DTC 编译不报错,但运行时内核拿到的 phandle 是错的,GPIO 控制完全乱套。后来我养成了习惯:所有节点引用都用 & 语法,绝不重复定义完整节点。
  2. reg 属性长度不匹配
    有一次移植 LCD 驱动,屏幕死活不亮。反编译 DTB 一看,reg 属性里地址是 32 位,长度却是 64 位的格式。DTC 的 #address-cells 和 #size-cells 没配对。这个坑特别隐蔽,因为 DTC 只检查格式,不检查语义。
  3. U-Boot 传错 DTB
    有块板子有两个 DTB 文件,一个对应 512MB 内存,一个对应 1GB。U-Boot 的 bootcmd 里写死了文件名,换了内存颗粒后忘了改。内核启动时 panic,提示 Unable to handle kernel NULL pointer dereference。查了两天才发现是 DTB 里的内存大小跟实际不匹配。
调试技巧: 内核启动后,去 /sys/firmware/devicetree/base 看看。那里是内核解析后的设备树,跟你的 .dts 对比一下,能快速定位问题。我几乎每次调设备树都会用这招。

小结

设备树的基础语法不复杂,但细节决定成败。DTS 是源码,DTSI 是公共片段,DTC 是编译器,DTB 是最终产物。记住这个链条,你就掌握了设备树的骨架。