第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"; } 来激活。

我的习惯:dtsi 里只放「硬件一定有」的东西。像 GPIO 按键、LED 这种板级定制的东西,别放 dtsi,否则不同板子之间会打架。

3.3 dts:板级定制的主文件

dts 才是你真正要写的文件。它通过 #include 引入 dtsi,然后做三件事:

  1. 覆盖:把 dtsi 里 disabled 的外设改成 okay
  2. 追加:添加板级特有的设备(比如 LCD 屏参数、WiFi 模块)
  3. 修改:调整引脚复用、时钟频率等

看个实际例子:

#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 里定义的那个节点,然后修改它」。这种语法叫「标签引用」,我个人觉得是设备树最优雅的设计之一。

曾经踩过的坑:dts 里如果忘记 #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 就是「运行时补丁」。它只描述「新增或修改的部分」,然后通过 configfslibfdt 动态加载到已有的 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

那个 -@ 参数是生成符号表,让补丁能正确找到要修改的节点。

一句话总结:dtsi 是公共头文件,dts 是板级主文件,dtb 是编译产物,dtbo 是运行时补丁。四者配合,构成了设备树的完整生态。

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 可能和原始 dts 不完全一样(比如注释会丢失),但结构和值都是对的。我经常用它来验证自己写的 dts 编译后是不是预期的样子。

好了,这四种文件的关系你搞明白了吗?下一章咱们就动手写第一个 dts 文件,从零开始搭一个最小系统。