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 包含机制的注意事项
嗯,这里有几个容易翻车的地方,我一个个说:
- 路径冲突:两个
.dtsi文件如果定义了同名的节点,后包含的会覆盖先包含的。所以包含顺序很重要。 - 循环包含:设备树预处理器不会检测循环包含。我曾经见过一个项目,A 包含 B,B 包含 A,结果编译直接爆栈。解决办法:用
#ifndef做防护,就像 C 语言那样。 - 标签重复:不同
.dtsi文件里不要定义同名的标签(比如uart1)。否则编译会报错。我习惯在标签前加前缀,比如soc_uart1、board_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 级开始,一层层叠加板级配置,最终得到完整的硬件描述。理解了这个哲学,你写出来的设备树就会很干净、很专业。