4. 设备树复用策略:dtsi文件的拆分与组织、公共节点抽取、板级差异覆盖
设备树复用,说白了就是「一套核心,多板适配」。
我早年做第一个多板卡项目时,犯过一个低级错误——给每块板子都写了一份完整的 .dts。结果呢?改一个 UART 时钟,得翻遍 6 个文件。后来我学乖了,开始认真研究 .dtsi 的拆分艺术。今天就把这些经验掰开揉碎讲给你听。
4.1 为什么要拆?——从「面条式」到「积木式」
先看一个反面教材。很多人刚开始写设备树,喜欢这样:
// board_A.dts
/dts-v1/;
/ {
model = "Board A";
compatible = "vendor,board-a";
soc {
uart0: serial@10000000 {
compatible = "vendor,uart";
reg = <0x10000000 0x1000>;
clocks = <&clk 0>;
status = "okay";
};
gpio0: gpio@20000000 {
compatible = "vendor,gpio";
reg = <0x20000000 0x1000>;
gpio-controller;
#gpio-cells = <2>;
};
// ... 几百行重复内容
};
};
然后 board_B.dts 几乎一模一样,只是改了几个 status 和引脚。这就是典型的「面条式」写法——牵一发而动全身。
我建议的做法是:把设备树拆成「公共底座」和「板级差异」两层。就像搭积木,核心模块固定,换板子只换顶层几块。
核心原则:
- 一次定义,多处引用——公共节点只写一次
- 增量覆盖,不改底座——板级差异用
&node语法追加或修改 - 按功能域拆分——时钟、GPIO、外设各归各的文件
4.2 拆分策略:dtsi 文件的组织艺术
我个人习惯按三层结构来组织:
| 层级 | 文件名示例 | 包含内容 | 复用范围 |
|---|---|---|---|
| SoC 级 | soc-xxx.dtsi |
CPU、中断控制器、总线、时钟树、固定外设 | 同系列 SoC 所有板卡 |
| 平台级 | platform-xxx.dtsi |
DDR 配置、PMIC、基础 GPIO 复用 | 同一参考设计的多款变体 |
| 板级 | board-xxx.dts |
具体外设使能、引脚覆盖、差异化参数 | 单块板卡 |
举个例子,我做过一个基于 SoC X1 的项目,有三块板子:开发板、工业控制板、消费电子板。文件结构是这样的:
dts/
├── soc-x1.dtsi # SoC 级:CPU、GIC、UART、I2C 控制器
├── platform-x1-common.dtsi # 平台级:DDR 时序、PMIC、基础引脚
├── platform-x1-industrial.dtsi # 工业级平台差异:宽温、冗余时钟
├── board-x1-eval.dts # 开发板:使能所有外设,调试用
├── board-x1-industrial.dts # 工业板:使能 CAN、RS485,禁用 HDMI
└── board-x1-consumer.dts # 消费板:使能 HDMI、音频,禁用 CAN
你看,soc-x1.dtsi 三块板子共用,platform-x1-common.dtsi 也共用。只有最顶层的 .dts 和极少数平台差异文件需要单独维护。
我的小技巧:文件名里带上 SoC 代号和层级关键词,比如 soc-imx8mp.dtsi、board-imx8mp-evk.dts。这样 grep 的时候一目了然,不会拿错文件。
4.3 公共节点抽取——把「不变」锁进底座
公共节点抽取,核心是识别出哪些东西是「所有板子都一样的」。
我一般从这几个维度入手:
- SoC 内部外设:UART 控制器、I2C 控制器、SPI 控制器、DMA、时钟、复位——这些硬件模块的寄存器地址和中断号是固定的,必须放
.dtsi。 - 固定总线拓扑:AXI、AHB、APB 总线结构,以及它们下面的桥接设备。
- 基础引脚功能:比如调试串口的引脚,所有板子都一样,可以预定义在平台级
.dtsi里。
举个例子,一个典型的 SoC 级 .dtsi 长这样:
// soc-x1.dtsi
/dts-v1/;
/ {
#address-cells = <2>;
#size-cells = <2>;
cpus {
#address-cells = <1>;
#size-cells = <0>;
cpu0: cpu@0 {
device_type = "cpu";
compatible = "vendor,cortex-a55";
reg = <0x0>;
clocks = <&clk 0>;
};
};
soc {
compatible = "simple-bus";
#address-cells = <2>;
#size-cells = <2>;
ranges;
clk: clock-controller@10000000 {
compatible = "vendor,x1-clk";
reg = <0x0 0x10000000 0x0 0x1000>;
#clock-cells = <1>;
};
uart0: serial@10100000 {
compatible = "vendor,uart";
reg = <0x0 0x10100000 0x0 0x1000>;
interrupts = <GIC_SPI 32 IRQ_TYPE_LEVEL_HIGH>;
clocks = <&clk 10>;
status = "disabled"; // 默认关闭,板级按需使能
};
gpio0: gpio@10200000 {
compatible = "vendor,gpio";
reg = <0x0 0x10200000 0x0 0x1000>;
gpio-controller;
#gpio-cells = <2>;
interrupts = <GIC_SPI 64 IRQ_TYPE_LEVEL_HIGH>;
};
};
};
注意看,uart0 的 status 我写的是 "disabled"。为什么?因为不是所有板子都用这个 UART。板级文件里再通过 &uart0 { status = "okay"; }; 来使能。这叫「默认关闭,按需打开」——我踩过的坑之一就是默认全开,结果某块板子引脚冲突,查了半天。
4.4 板级差异覆盖——用「引用+覆盖」代替「复制+修改」
板级差异覆盖,是复用策略的精髓。语法很简单:
// board-x1-industrial.dts
/dts-v1/;
#include "soc-x1.dtsi"
#include "platform-x1-common.dtsi"
/ {
model = "Industrial Board X1";
compatible = "vendor,x1-industrial", "vendor,x1";
};
// 使能 UART0,并修改波特率
&uart0 {
status = "okay";
clock-frequency = <115200>;
};
// 覆盖 GPIO0 的引脚配置
&gpio0 {
pinctrl-0 = <&pinctrl_uart0_default>;
pinctrl-names = "default";
};
// 添加板级特有的节点
&i2c0 {
status = "okay";
clock-frequency = <400000>;
temperature_sensor: sensor@48 {
compatible = "vendor,tmp117";
reg = <0x48>;
};
};
这里有几个关键点:
- 用
&node引用已有节点,而不是重新定义整个节点。这样底座文件里的寄存器地址、中断号等核心属性不会被意外修改。 - 覆盖只写差异部分。比如
uart0我只改了status和clock-frequency,其他属性继承自soc-x1.dtsi。 - 新增节点直接写在板级文件里。比如 I2C 总线下的温度传感器,这是板级特有的,不需要污染公共文件。
我曾经踩过的坑:在板级文件里重新定义了整个 uart0 节点,结果因为 reg 写错了一位地址,调试串口死活不工作。后来发现是复制粘贴时手抖了。记住:能用引用就别重写,重写等于给自己埋雷。
4.5 实战案例:三块板卡的统一管理
下面我用一个完整的例子,展示从拆分到覆盖的全流程。假设我们有 SoC X1,三块板卡:
| 板卡 | UART0 | UART1 | I2C0 | GPIO 扩展 |
|---|---|---|---|---|
| 开发板 | 调试串口 | 蓝牙模块 | 温度传感器 | 无 |
| 工业板 | 调试串口 | RS485 | ADC 芯片 | PCA9535 |
| 消费板 | 禁用 | GPS 模块 | 触摸屏 | 无 |
文件组织如下:
dts/
├── soc-x1.dtsi # SoC 级:定义所有外设控制器,status 默认 disabled
├── platform-x1-common.dtsi # 平台级:DDR、PMIC、调试串口引脚
├── board-x1-eval.dts # 开发板:使能 UART0/UART1/I2C0,加温度传感器
├── board-x1-industrial.dts # 工业板:使能 UART0/UART1/I2C0,加 RS485 和 GPIO 扩展
└── board-x1-consumer.dts # 消费板:禁用 UART0,使能 UART1/I2C0,加触摸屏
开发板的 .dts 文件:
// board-x1-eval.dts
/dts-v1/;
#include "soc-x1.dtsi"
#include "platform-x1-common.dtsi"
/ {
model = "X1 Evaluation Board";
compatible = "vendor,x1-eval", "vendor,x1";
};
&uart0 {
status = "okay";
};
&uart1 {
status = "okay";
bluetooth {
compatible = "vendor,bcm43438";
max-speed = <3000000>;
};
};
&i2c0 {
status = "okay";
temperature_sensor: tmp117@48 {
compatible = "vendor,tmp117";
reg = <0x48>;
};
};
工业板的 .dts 文件:
// board-x1-industrial.dts
/dts-v1/;
#include "soc-x1.dtsi"
#include "platform-x1-common.dtsi"
/ {
model = "X1 Industrial Board";
compatible = "vendor,x1-industrial", "vendor,x1";
};
&uart0 {
status = "okay";
};
&uart1 {
status = "okay";
rs485 {
compatible = "vendor,rs485";
rs485-mode;
};
};
&i2c0 {
status = "okay";
adc: ads1115@48 {
compatible = "ti,ads1115";
reg = <0x48>;
};
};
&i2c1 {
status = "okay";
gpio_expander: pca9535@20 {
compatible = "nxp,pca9535";
reg = <0x20>;
gpio-controller;
#gpio-cells = <2>;
};
};
消费板的 .dts 文件:
// board-x1-consumer.dts
/dts-v1/;
#include "soc-x1.dtsi"
#include "platform-x1-common.dtsi"
/ {
model = "X1 Consumer Board";
compatible = "vendor,x1-consumer", "vendor,x1";
};
&uart0 {
status = "disabled"; // 消费板不用调试串口,省电
};
&uart1 {
status = "okay";
gps {
compatible = "vendor,neo-6m";
gnss-mode;
};
};
&i2c0 {
status = "okay";
touchscreen: ft5x06@38 {
compatible = "edt,ft5x06";
reg = <0x38>;
interrupt-parent = <&gpio0>;
interrupts = <5 IRQ_TYPE_EDGE_FALLING>;
};
};
你看,三块板子的公共部分(SoC 级、平台级)完全复用,差异部分通过引用覆盖和新增节点来实现。维护成本从「改三份」降到了「改一份公共 + 三份小差异」。
复用策略的核心收益:
- SoC 级 bug 修复,只改
soc-x1.dtsi,三块板子自动受益 - 新板卡只需写几十行差异,不用从头抄几百行
- 代码审查时,一眼就能看出板级差异在哪里
4.6 避坑指南与最佳实践
最后,分享几个我这些年积累的经验:
- 别把板级特有节点塞进公共
.dtsi。我见过有人把温度传感器定义在 SoC 级文件里,结果工业板用 ADC、消费板用触摸屏,公共文件被改得面目全非。记住:公共文件只放「所有板子都有的东西」。 - 覆盖时注意属性合并规则。设备树的属性合并是「追加」而非「替换」。比如你在公共文件里定义了
pinctrl-0,板级文件又定义了一个pinctrl-0,编译器会报重复定义的警告。正确的做法是用&node { pinctrl-0 = <&new_pinmux>; };整体替换。 - 善用
#include的路径管理。我习惯把.dtsi放在dts/include/目录下,.dts放在dts/board/目录下。编译时通过-I参数指定搜索路径,这样文件结构清晰,不会出现「找不到头文件」的尴尬。 - 版本控制里标注依赖关系。在
board-xxx.dts的文件头注释里写明它依赖哪些.dtsi文件。比如:
// board-x1-industrial.dts
// 依赖: soc-x1.dtsi, platform-x1-common.dtsi
// 覆盖: uart0, uart1, i2c0, i2c1
// 新增: gpio_expander (PCA9535)
这样后来者接手时,不用翻遍所有文件就能知道依赖关系。
我的个人习惯:每次新建板卡,先复制一份最接近的 .dts,然后删掉不需要的覆盖,再添加新的差异。这样比从零开始写快得多,而且不容易漏掉公共依赖。
嗯,设备树复用策略的核心就这些。说白了就是「把不变的锁进底座,把变的留在顶层」。你想想看,如果每块板子都独立维护一份完整的设备树,那跟十年前写裸机代码有什么区别?
这张图把三层结构的关系画得很清楚。从上往下看,板级文件通过 #include 引用平台级和 SoC 级文件,然后通过 &node 覆盖差异。底层底座纹丝不动,顶层差异灵活调整——这就是设备树复用的精髓。