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 图,展示了从源码到最终内核使用的完整流程。我画的时候特意把关键路径标红了,你一看就明白。

设备树文件关系与编译流程 .dtsi 文件 SoC 公共描述 .dts 文件 板级硬件描述 .dtso 文件 叠加层源码 dtc 编译 dtc 编译 dtc -@ 编译 .dtb 文件 基础设备树二进制 .dtbo 文件 叠加层二进制 Bootloader 加载 内核动态加载 Linux 内核 解析设备树 → 构建 device_node 树 → 驱动匹配 of_* API 读取硬件信息,完成设备初始化 叠加层合并 源码文件 二进制文件 内核运行时

从这张图你能看到:.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