4、设备树包含机制:#include、/include/与dtsi文件的设计哲学

设备树的包含机制,说白了就是解决「代码复用」和「模块化管理」的问题。

我刚开始接触设备树时,看到一堆 .dts.dtsi 文件,第一反应是:这不就是 C 语言的 #include 吗?后来踩了几个坑才发现,事情没那么简单。

4.1 为什么需要包含机制?

你想想看,一个 SoC 可能有几十个外设,每个外设又有几十个寄存器。如果所有内容都写在一个文件里,那这个文件会膨胀到几千行。维护起来?噩梦。

我在项目中遇到过一块 i.MX6 的板子,BSP 包里有 200 多个 .dtsi 文件。刚开始觉得夸张,后来发现这是合理的——每个外设一个文件,每个 SoC 系列一个文件,每个板级一个文件。清晰得很。

设备树的包含机制主要解决三个问题:

  • 复用性:同一个 SoC 的不同板子,共用 SoC 描述
  • 模块化:外设驱动与板级配置分离
  • 可维护性:修改一个外设,不用动整个文件

4.2 #include 与 /include/ 的区别

这里有个容易混淆的点。设备树支持两种包含语法:

语法一:C 风格的 #include

#include "imx6ul.dtsi"
#include "my-board.dtsi"

语法二:设备树原生的 /include/

/include/ "imx6ul.dtsi"
/include/ "my-board.dtsi"

两者功能上等价,但我个人习惯用 #include。为什么?

特性 #include /include/
预处理支持 支持宏定义、条件编译 不支持
语法检查 由 C 预处理器处理 由 DTC 直接处理
兼容性 需要开启 CONFIG_USE_DTC 原生支持
我推荐 ✅ 现代项目首选 ❌ 仅用于老旧内核

我的建议:如果你用的是 4.x 以后的内核,直接用 #include。它让你能用 #define 定义常量,用 #ifdef 做条件编译。这些在设备树里非常实用。

4.3 dtsi 文件的设计哲学

.dtsi 文件,说白了就是「设备树头文件」。但它和 C 语言的 .h 文件有个本质区别:.dtsi 里可以包含完整的节点定义,而不仅仅是声明。

我一般把 .dtsi 文件分成三个层级:

层级一:SoC 级 dtsi

描述芯片内部的所有外设。比如:

// imx6ul.dtsi
/ {
  soc {
    uart1: serial@02020000 {
      compatible = "fsl,imx6ul-uart";
      reg = <0x02020000 0x4000>;
      interrupts = <0 26 IRQ_TYPE_LEVEL_HIGH>;
      status = "disabled";  // 默认关闭
    };
    
    uart2: serial@021e8000 {
      compatible = "fsl,imx6ul-uart";
      reg = <0x021e8000 0x4000>;
      interrupts = <0 27 IRQ_TYPE_LEVEL_HIGH>;
      status = "disabled";
    };
  };
};

注意这里 status = "disabled"。这是设计哲学之一:SoC 级文件只描述硬件存在,不决定是否启用。启用与否,交给板级文件去决定。

层级二:板级公共 dtsi

描述某个板子系列的公共配置。比如:

// my-board-common.dtsi
#include "imx6ul.dtsi"

/ {
  chosen {
    stdout-path = &uart1;
  };
  
  memory@80000000 {
    device_type = "memory";
    reg = <0x80000000 0x20000000>;  // 512MB
  };
};

&uart1 {
  status = "okay";
  pinctrl-0 = <&pinctrl_uart1>;
};

&uart2 {
  status = "disabled";  // 明确禁用
};

我曾经踩过的坑:在板级 dtsi 里忘记设置 status,结果某个外设莫名其妙不工作。排查了半天才发现,SoC 级文件里默认是 disabled,板级文件没覆盖它。所以我的习惯是:每个外设都在板级文件里显式设置 status,哪怕和默认值一样。

层级三:具体板型 dts

这是最终的 .dts 文件,只包含该板子特有的配置:

// my-board-v1.dts
#include "my-board-common.dtsi"

/ {
  model = "MyBoard V1.0";
  compatible = "mycompany,myboard-v1", "fsl,imx6ul";
};

&uart2 {
  // 这个板子用了 uart2,覆盖公共配置
  status = "okay";
  pinctrl-0 = <&pinctrl_uart2>;
};

// 板子特有的 GPIO 按键
gpio-keys {
  compatible = "gpio-keys";
  power-key {
    label = "Power";
    gpios = <&gpio1 0 GPIO_ACTIVE_LOW>;
    linux,code = <KEY_POWER>;
  };
};

4.4 包含机制的注意事项

嗯,这里有几个容易翻车的地方,我一个个说:

  1. 路径冲突:两个 .dtsi 文件如果定义了同名的节点,后包含的会覆盖先包含的。所以包含顺序很重要。
  2. 循环包含:设备树预处理器不会检测循环包含。我曾经见过一个项目,A 包含 B,B 包含 A,结果编译直接爆栈。解决办法:用 #ifndef 做防护,就像 C 语言那样。
  3. 标签重复:不同 .dtsi 文件里不要定义同名的标签(比如 uart1)。否则编译会报错。我习惯在标签前加前缀,比如 soc_uart1board_uart1

一个小技巧:用 &标签 语法来引用和修改节点,而不是重新定义整个节点。这样既清晰又安全。比如:

// 正确做法
&uart1 {
  status = "okay";
};

// 错误做法(会报重复定义)
uart1: serial@02020000 {
  status = "okay";
};

4.5 实战中的组织方式

我参与过的一个项目,文件组织是这样的:

arch/arm/boot/dts/
├── imx6ul.dtsi              # SoC 级
├── imx6ul-14x14-evk.dtsi    # 官方 EVK 板公共配置
├── mycompany/
│   ├── imx6ul-myboard.dtsi  # 公司板级公共配置
│   ├── imx6ul-myboard-v1.dts  # V1 版本
│   ├── imx6ul-myboard-v2.dts  # V2 版本
│   └── imx6ul-myboard-v3.dts  # V3 版本
└── Makefile

每个 .dts 文件只有 50-100 行,只包含该版本特有的差异。这样维护起来,爽得很。

最后说一句:设备树的包含机制,本质上是一种「增量覆盖」的设计模式。你从 SoC 级开始,一层层叠加板级配置,最终得到完整的硬件描述。理解了这个哲学,你写出来的设备树就会很干净、很专业。