3. 设备树源文件:.dts/.dtsi 的结构与组织

设备树的源文件,说白了就是两种后缀:.dts.dtsi。很多刚入行的朋友会问,这俩到底啥区别?我简单解释一下。

.dts 是 Device Tree Source,也就是一个板子的完整描述文件。而 .dtsi 是 Device Tree Source Include,相当于头文件,用来放一些通用的、可复用的内容。我个人习惯把 SoC 的公共部分、外设控制器定义、时钟配置这些扔到 .dtsi 里,然后每个具体的板子只写自己特有的东西。

3.1 文件结构:从根节点开始

一个典型的 .dts 文件,结构其实很清晰。我直接上代码,你一看就明白:

/dts-v1/;

#include "soc.dtsi"
#include "board-common.dtsi"

/ {
    model = "MyBoard v2.0";
    compatible = "vendor,myboard-v2", "vendor,myboard";

    chosen {
        bootargs = "console=ttyS0,115200 root=/dev/mmcblk0p2";
    };

    memory@80000000 {
        device_type = "memory";
        reg = <0x0 0x80000000 0x0 0x20000000>;
    };

    &uart0 {
        status = "okay";
        clock-frequency = <115200>;
    };
};

注意第一行 /dts-v1/;,这是告诉编译器用 v1 版本的语法。我见过有人漏掉这行,结果编译报错,折腾了半天才发现。

根节点 / 下面,通常放的是板级信息:model 是板子名字,compatible 是兼容性字符串。这两个字段很重要,内核启动时会拿它们来匹配对应的驱动。

关键点:compatible 的写法有讲究。一般格式是 "厂商,型号",比如 "ti,am335x-evm"。而且最好写多个值,从最具体到最通用,这样内核可以按优先级匹配。

3.2 Include 机制:别重复造轮子

设备树的 #include 和 C 语言的 #include 很像,但有个小区别——它支持 .dtsi 文件的递归包含。我在项目中遇到过,有人把 SoC 的配置在每个板子文件里都复制一份,结果 SoC 升级时改了 20 个文件,改到崩溃。

正确的做法是:

  • SoC 级 .dtsi:放 CPU 核心、中断控制器、定时器、GPIO 控制器等所有 SoC 共有的外设。
  • 板级公共 .dtsi:放同一系列板子共用的部分,比如 DDR 配置、PMIC 设置。
  • 具体板子 .dts:只放这个板子独有的东西,比如 LED 引脚、按键映射。

举个例子,我做过一个项目,有 5 款板子都用同一颗 SoC。我把 SoC 的配置放在 soc.dtsi,然后每个板子文件就 50 行左右,清爽得很。

小技巧:你可以用 #include "soc.dtsi" 来包含,也可以用 /include/ "soc.dtsi"。前者是 C 预处理器语法,后者是设备树原生语法。我个人推荐用 #include,因为它支持宏定义,后面会讲到。

3.3 宏定义的使用:让代码更优雅

设备树支持 C 预处理器,这意味着你可以用 #define 来定义常量。嗯,这里要注意,不是所有设备树编译器都默认开启宏支持,你需要确保编译时加了 -@ 选项或者用了 cpp 预处理。

我建议把常用的宏定义放在一个单独的 .h 文件里,比如:

// gpio-defs.h
#define GPIO_ACTIVE_HIGH 0
#define GPIO_ACTIVE_LOW  1

#define PIN_OUTPUT       0
#define PIN_INPUT        1

然后在 .dts 文件里这样用:

#include "gpio-defs.h"

&gpio1 {
    led-red {
        gpios = <&gpio0 12 GPIO_ACTIVE_HIGH>;
        label = "red-led";
    };
};

你看,这样是不是比直接写数字 01 清晰多了?我曾经接手过一个项目,里面全是裸数字,什么 gpios = <&gpio0 12 0>,我根本不知道那个 0 是什么意思。后来花了半天时间对照 datasheet 才搞清楚。

避坑指南:我曾经在宏定义里用了 #define UART_BAUD 115200,然后在多个节点里引用。结果后来发现,不同 UART 的时钟源不一样,波特率计算方式也不同。所以宏定义虽好,但别滥用——只放那些真正通用的常量。

3.4 节点引用与覆盖

设备树里有个很实用的机制——节点引用。你可以用 &label 的方式引用一个已经定义好的节点,然后修改它的属性。这有点像面向对象里的继承和重写。

举个例子:

// soc.dtsi
uart0: serial@10000000 {
    compatible = "vendor,uart";
    reg = <0x10000000 0x1000>;
    interrupts = <33>;
    status = "disabled";
};

// board.dts
&uart0 {
    status = "okay";
    clock-frequency = <115200>;
};

这样,SoC 的 .dtsi 里把 UART 默认关掉,板子文件里再打开并配置频率。结构清晰,互不干扰。

我个人习惯把所有的 &引用 集中放在文件末尾,用注释分组。比如:

/* 使能外设 */
&uart0 { status = "okay"; };
&i2c1 { status = "okay"; };

/* 配置引脚 */
&pinmux_uart0 { pinctrl-0 = <&uart0_pins>; };

这样找起来方便,改起来也快。

3.5 文件组织的实战建议

说了这么多,我总结一下在实际项目中怎么组织这些文件。你想想看,一个中等规模的嵌入式项目,可能有 10 到 20 个板子变种。如果文件组织不好,维护起来就是噩梦。

文件 内容 复用范围
soc.dtsi CPU、中断控制器、总线、所有外设控制器 同一 SoC 的所有板子
soc-pinmux.dtsi 引脚复用配置 同一 SoC 的所有板子
board-common.dtsi DDR 参数、PMIC、公共外设 同一系列板子
board-v1.dts V1 板子特有的 LED、按键、传感器 仅 V1 板子
board-v2.dts V2 板子特有的改动 仅 V2 板子

这个结构我用了好几年,效果不错。每次新加一个板子,只需要新建一个 .dts 文件,然后 #include 对应的 .dtsi,再写几十行特有的配置就行了。

核心原则:把共性的东西往上提,把个性的东西往下放。这样当 SoC 升级时,你只需要改一个 soc.dtsi,所有板子自动受益。当某个板子出问题时,你也只需要看它自己的 .dts 文件。

好了,这一章的内容就这些。下一章我们会深入设备树的绑定文档,看看怎么给自定义外设写规范的 binding。到时候我会分享一个我踩过的坑——写错了 binding 导致驱动加载失败,查了两天才找到原因。