2. 设备树语法:节点、属性、标签、包含文件(#include)详解

好,咱们进入设备树语法的核心部分。说实话,很多工程师刚开始看设备树文件(.dts),会觉得它长得像C语言,但又不太一样。我当年第一次接触时也懵过——这玩意儿到底怎么组织?节点和属性又是什么关系?别急,咱们一个一个拆开讲。

2.1 节点(Node)——设备树的基本单元

设备树里,每个硬件设备或总线都用一个节点来表示。你可以把节点想象成一个“硬件模块的身份证”。

节点的基本格式长这样:

/ {
    cpu {
        compatible = "arm,cortex-a9";
        reg = <0x10000000 0x1000>;
    };
};

这里 / 是根节点,cpu 是它的子节点。每个节点用花括号 {} 包裹,最后用分号结束。嗯,这里要注意:分号不能丢,丢了编译直接报错。我刚开始写设备树时,就经常漏分号,排查半天才发现。

节点可以嵌套,比如:

/ {
    soc {
        uart0: serial@10000000 {
            compatible = "ns16550";
            reg = <0x10000000 0x1000>;
        };
    };
};

这里 serial@10000000soc 的子节点。节点名后面的 @10000000地址后缀,用来区分同一类型的不同实例。比如两个串口,一个叫 serial@10000000,另一个叫 serial@10001000

核心要点:节点名 + 地址后缀 = 唯一标识。地址后缀不是必须的,但强烈建议加上,尤其是内存映射设备。

2.2 属性(Property)——描述节点的“形容词”

属性就是节点的特征描述。每个属性都有一个名字和一个值。常见的属性类型有:

  • 字符串compatible = "arm,cortex-a9";
  • 32位整数reg = <0x10000000 0x1000>;
  • 64位整数reg = <0x0 0x10000000 0x0 0x1000>;
  • 字节数组local-mac-address = [00 11 22 33 44 55];
  • 布尔值status = "okay";status = "disabled";

我个人习惯把 compatible 属性看作“设备驱动匹配的钥匙”。内核就是靠它来找到对应的驱动。比如:

compatible = "ti,am335x-gpio", "ti,omap-gpio";

这里有两个字符串,内核会按顺序尝试匹配。第一个匹配上了就用第一个驱动,否则看第二个。我在项目中遇到过,有些开发板厂商喜欢把自家芯片的兼容字符串写在前面,通用兼容字符串写在后面——这样既能用上定制驱动,又能回退到通用驱动。

小技巧:compatible 时,建议遵循“厂商,型号”的命名规范。比如 "fsl,imx6q-uart",这样一看就知道是飞思卡尔 i.MX6Q 的串口。

2.3 标签(Label)——给节点起个“外号”

标签就是节点的别名。你想想看,如果节点名很长,比如 serial@10000000,每次引用都要写全名,多麻烦。标签就是用来解决这个问题的。

定义标签的语法很简单:

uart0: serial@10000000 {
    compatible = "ns16550";
    reg = <0x10000000 0x1000>;
};

这里的 uart0: 就是标签。之后在其他地方引用时,直接用 &uart0 就行。比如:

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

这相当于在 uart0 节点里追加了 statusclock-frequency 属性。注意,标签引用必须在节点定义之后,否则编译器会报“未定义标签”。

避坑指南:我曾经在一个项目里,因为标签名重复导致编译失败。两个不同的 .dtsi 文件里都定义了 uart0:,结果链接时报错。所以,建议给标签加上模块前缀,比如 soc_uart0board_uart0,避免冲突。

2.4 包含文件(#include)——模块化你的设备树

设备树支持 #include 预处理指令,用法和C语言一模一样。这玩意儿太有用了——你可以把通用的硬件定义放在 .dtsi 文件里,然后不同的板子只需要包含它,再覆盖或追加自己的配置。

举个例子:

// imx6q.dtsi(SoC通用定义)
/ {
    soc {
        uart1: serial@02020000 {
            compatible = "fsl,imx6q-uart", "fsl,imx21-uart";
            reg = <0x02020000 0x4000>;
            interrupts = <0 26 0x04>;
            status = "disabled";
        };
    };
};

// myboard.dts(板级定义)
#include "imx6q.dtsi"

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

你看,SoC 厂商提供 imx6q.dtsi,定义了所有外设的默认配置(默认 disabled)。板级文件只需要包含它,然后通过标签引用把需要的设备 status 改成 okay,再配上引脚复用。这样既减少了重复代码,又保持了清晰的结构。

我建议把设备树文件分成三层:

层级 文件名 内容
SoC 层 soc.dtsi CPU、中断控制器、时钟、总线等
板级层 board.dtsi 板载外设、GPIO、引脚复用
产品层 product.dts 最终配置、覆盖和使能

这样做的好处是,同一个 SoC 的不同板子,只需要改板级层和产品层,SoC 层完全不用动。我在一个项目里,用这种方式管理了 5 款不同板子,每次改板只需要改几十行代码,爽得很。

注意:#include 是 C 预处理器的指令,所以设备树文件必须经过预处理器处理。编译时用 cpp -P 或者直接用 dtc -I dts -O dtb,它会自动调用预处理器。

2.5 节点引用与覆盖——灵活修改已有节点

除了用标签追加属性,设备树还支持节点引用覆盖。比如:

&uart1 {
    status = "okay";
    clock-frequency = <24000000>;
};

这会在编译时,把 statusclock-frequency 合并到 uart1 节点里。如果属性已经存在,新值会覆盖旧值。如果属性不存在,就新增。

这里有个坑:覆盖时不能改变节点的结构。比如你不能在覆盖里添加子节点,只能修改属性。想添加子节点?得在原始节点定义里预留好位置,或者用 /delete-node/ 先删除再重新定义。

个人经验:我习惯在 .dtsi 文件里把节点定义得尽量完整,包括所有可能的属性,但把 status 默认设为 disabled。然后在 .dts 文件里,只通过标签引用修改 status 和少量板级相关的属性。这样既清晰又安全,不容易改出问题。

2.6 实战:一个完整的设备树片段

最后,咱们看一个完整的例子,把上面讲的知识点串起来:

// soc.dtsi
/ {
    compatible = "myvendor,my-soc";
    #address-cells = <1>;
    #size-cells = <1>;

    cpu@0 {
        compatible = "arm,cortex-a9";
        reg = <0>;
        device_type = "cpu";
    };

    uart0: serial@10000000 {
        compatible = "ns16550";
        reg = <0x10000000 0x1000>;
        interrupts = <0 5 4>;
        clock-frequency = <24000000>;
        status = "disabled";
    };
};

// board.dts
#include "soc.dtsi"

&uart0 {
    status = "okay";
    pinctrl-0 = <&pinctrl_uart0>;
};

这个例子展示了根节点、子节点、属性、标签、包含文件、节点引用覆盖的全部用法。你想想看,如果没有这些语法,每个板子都得把 SoC 的定义抄一遍,那得多痛苦?

好了,设备树语法就讲到这里。下一节咱们会深入 reg 属性和地址映射,那是设备树里最绕的部分,也是我当年踩坑最多的地方。做好准备!