3、设备树文件结构:.dts、.dtsi、.dts.pre、.dtb文件的区别与作用,include机制

说到设备树的文件结构,很多初学者第一眼看到那一堆后缀名就懵了。.dts.dtsi.dts.pre.dtb……这到底都是些什么鬼?

别急,我当年刚接触Zephyr时也被绕晕过。后来在项目里踩了几次坑,才算真正搞明白。今天咱们就把这几个文件类型彻底说清楚。

3.1 四种文件类型,各司其职

先给个总览表格,方便你快速对照:

后缀名 全称 作用 是否可读
.dts Device Tree Source 设备树源文件,描述硬件 是(文本)
.dtsi Device Tree Source Include 可被包含的公共定义文件 是(文本)
.dts.pre Device Tree Preprocessed 预处理后的中间文件 是(文本)
.dtb Device Tree Blob 编译后的二进制文件 否(二进制)

3.2 .dts 文件:真正的“主菜”

.dts 文件是设备树的源文件。说白了,它就是描述一块具体板子上所有硬件信息的文本文件。

每个板子通常都有一个对应的 .dts 文件。比如 nrf52840dk_nrf52840.dts,一看名字就知道是给 nRF52840 DK 开发板用的。

我个人习惯把 .dts 文件想象成“板子的身份证”。它里面写了:

  • CPU 是什么型号
  • 内存有多大
  • 哪些外设被使能了
  • GPIO 引脚怎么分配的
  • 时钟频率是多少

举个例子,一个简单的 .dts 文件片段长这样:

/dts-v1/;

#include "nrf52840.dtsi"

/ {
    model = "Nordic nRF52840 DK";
    compatible = "nordic,nrf52840-dk";

    chosen {
        zephyr,console = &uart0;
    };

    &uart0 {
        status = "okay";
        current-speed = <115200>;
    };
};

看到没?它用 #include 引用了另一个文件。这就是咱们接下来要说的 .dtsi

3.3 .dtsi 文件:公共定义的“仓库”

.dtsi 文件,全称是 Device Tree Source Include。它存在的意义就一个:复用

你想想看,同一颗芯片可能用在几十种不同的板子上。如果每个板子的 .dts 文件都从头写一遍芯片的引脚定义、时钟配置……那不得累死?

所以 .dtsi 文件就充当了“公共仓库”的角色。芯片厂商会把芯片的通用定义放在 .dtsi 里,板子厂商只需要 #include 一下,再补充板子特有的配置就行了。

我在项目中遇到过这样的情况:有个同事把芯片的引脚定义直接写在了 .dts 文件里,结果换了块板子,所有东西都得重写。嗯,从那以后我就养成了习惯——能用 .dtsi 复用的,绝不写在 .dts

小技巧:Zephyr 的 .dtsi 文件通常放在 dts/arm/dts/riscv/ 等目录下。你可以把它理解成 C 语言里的头文件(.h),但语法上用的是设备树语言。

3.4 .dts.pre 文件:预处理后的“半成品”

这个文件你可能平时不太注意,但它其实挺重要的。

.dts.pre.dts 文件经过 C 预处理器(CPP)处理后的结果。什么意思呢?就是所有 #include#define#ifdef 这些预处理指令都被展开和解析了。

为什么要生成这个中间文件?

  • 方便调试:如果编译出错,你可以打开 .dts.pre 看看最终展开成什么样了
  • 方便检查:确认 #include 的路径对不对,宏定义有没有生效

我曾经有一次死活编译不过,查了半天发现是 .dtsi 文件路径写错了。后来养成习惯,先看一眼 .dts.pre,确认所有 #include 都正确展开了,再往下排查。

注意:.dts.pre 是构建过程中自动生成的临时文件。你不需要手动创建它,但学会看它,能帮你省下不少调试时间。

3.5 .dtb 文件:机器能读懂的“二进制”

最后这个 .dtb,是设备树的最终形态——二进制文件。

Zephyr 的构建系统会把 .dts 文件(经过预处理后)编译成 .dtb 文件。这个二进制文件可以直接被 bootloader 或内核解析,用来初始化硬件。

说白了,.dts 是给人看的,.dtb 是给机器吃的。

你可以用 dtc 工具把 .dtb 反编译回 .dts

dtc -I dtb -O dts -o output.dts input.dtb

这个命令我偶尔会用,比如拿到一块别人编译好的固件,想看看它的设备树配置。

3.6 include 机制:设备树的“模块化”之道

Zephyr 的设备树 #include 机制,其实和 C 语言的 #include 是同一套东西。因为设备树源文件在编译前会先经过 C 预处理器处理。

这意味着你可以:

  • #include "nrf52840.dtsi" 引入芯片定义
  • #include "nrf52840dk_common.dtsi" 引入板级公共配置
  • #define 定义宏,然后在 .dts 里使用
  • #ifdef 做条件编译

举个例子,Zephyr 的 nrf52840dk_nrf52840.dts 文件里,你经常能看到这样的结构:

/dts-v1/;
#include <nordic/nrf52840_qiaa.dtsi>
#include "nrf52840dk_nrf52840_common.dtsi"

/ {
    model = "Nordic nRF52840 DK";
    compatible = "nordic,nrf52840-dk";

    chosen {
        zephyr,console = &uart0;
        zephyr,shell-uart = &uart0;
        zephyr,uart-mcumgr = &uart0;
    };
};

这里 #include 了两层:

  1. 芯片级:nrf52840_qiaa.dtsi 定义了芯片内部所有外设
  2. 板级:nrf52840dk_nrf52840_common.dtsi 定义了这块板子的引脚分配和默认配置

最后在 .dts 文件里,只需要写板子特有的配置(比如 chosen 节点)就行了。

核心思想:设备树的 include 机制,本质上就是“分层抽象”。芯片层、板层、应用层各司其职,互不干扰。这也是 Zephyr 能支持几百种开发板的原因之一。

3.7 避坑指南:我踩过的几个坑

最后分享几个我实际项目中遇到的坑,希望能帮你少走弯路:

  • 路径问题:#include 的路径是相对于 dts/ 目录的,不是相对于当前文件。我第一次用的时候就在这上面栽了跟头。
  • 重复定义:如果你在 .dts.dtsi 里定义了同一个节点,.dts 里的会覆盖 .dtsi 里的。这个特性可以用来做板级定制。
  • 预处理顺序:设备树的预处理是在编译之前进行的。如果你在 .dts 里用了 #define,记得它只在当前文件有效,不会影响到被 #include 进来的 .dtsi 文件。

嗯,关于设备树的文件结构,今天就聊到这儿。下一节咱们会深入设备树的语法和节点结构,到时候你会看到这些文件是怎么组织成一颗完整的“树”的。