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,然后删掉不需要的覆盖,再添加新的差异。这样比从零开始写快得多,而且不容易漏掉公共依赖。

嗯,设备树复用策略的核心就这些。说白了就是「把不变的锁进底座,把变的留在顶层」。你想想看,如果每块板子都独立维护一份完整的设备树,那跟十年前写裸机代码有什么区别?

设备树复用策略:三层文件结构 板级文件(.dts) board-x1-eval.dts board-x1-industrial.dts board-x1-consumer.dts 平台级文件(.dtsi) platform-x1-common.dtsi platform-x1-industrial.dtsi (DDR、PMIC、基础引脚) SoC 级文件(.dtsi) soc-x1.dtsi (CPU、GIC、UART、I2C、时钟树) 所有外设 status 默认 disabled 复用策略说明 板级差异覆盖 (引用 + 覆盖) 平台级复用 (多板共享) SoC 级底座 (全系列共用) 核心收益: • 一次定义 • 多处引用 • 增量覆盖 • 不改底座 • 按功能域拆分 • 按需使能

这张图把三层结构的关系画得很清楚。从上往下看,板级文件通过 #include 引用平台级和 SoC 级文件,然后通过 &node 覆盖差异。底层底座纹丝不动,顶层差异灵活调整——这就是设备树复用的精髓。