Skip to content

03. 设备树语法详解——读懂这张"藏宝图"

前言:语法是基础,但不是全部

上一章我们把编译流程跑通了,现在手里有了一个 .dts 文件,不管它是自己写的还是厂商给的,摆在面前的第一件事就是:怎么读懂它?

说实话,虽然我们很少会从零开始写一整份 .dts 文件——通常都是对着芯片厂给的参考文件修修改改——但语法这一关必须过。如果你看不懂它的结构,改设备树就变成了"瞎猫碰死耗子",改对了不知道为什么,改错了更是两眼一抹黑。

好消息是,DTS 的语法非常友好。它不像 C 语言那样有复杂的指针和预处理宏,也不像 Verilog 那样需要深厚的硬件功底。它本质上就是一种带格式的 ASCII 文本,甚至可以说,它比 C 语言还要简单。

这一章,我们会像解剖一只青蛙一样,把设备树的语法拆解开来。从节点到属性,从基本类型到标准属性,从语法糖到实战示例。我们的目标很明确:看到任何一个 .dts 文件,都能一眼看穿它的结构和含义


节点语法:树的枝干

设备树的核心结构就是。每个设备都是一个节点,每个节点里是一堆键值对,我们称之为属性。这听起来很抽象,我们来看一个实际的例子。

节点命名规则

节点的命名格式通常如下:

node-name@unit-address
  • node-name:节点名字,ASCII 字符串,最好一眼就能看出是干嘛的,比如 uart1i2c0
  • unit-address:设备的地址,通常是寄存器首地址。如果没有地址或者不需要,这部分可以省略。

但我们在实际的 .dts 文件里,经常看到一种更复杂的写法:

c
cpu0: cpu@0
intc: interrupt-controller@00a01000

这里的格式是:

label: node-name@unit-address

冒号前面的 cpu0intc节点标签。为什么要加这个标签?为了方便引用。试想一下,如果我想在 .dts 文件里修改这个 interrupt-controller@00a01000 的某个属性,难道我要把这长长的一串名字敲一遍吗?太容易出错了。有了标签,我只需要写 &intc { ... };,就能精准地定位到这个节点。

我们来看一个完整的例子,这是从 I.MX6ULL 的设备树里截取的一段:

c
/ {
    aliases {
        can0 = &flexcan1;
    };

    cpus {
        #address-cells = <1>;
        #size-cells = <0>;

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

    intc: interrupt-controller@00a01000 {
        compatible = "arm,cortex-a7-gic";
        reg = <0x00a01000 0x1000>;
        interrupt-controller;
        #interrupt-cells = <2>;
    };
}

这里有几个关键点需要解释:

  1. 根节点 /:这是整个树的起点,所有其他节点都是它的子孙。
  2. 节点嵌套cpus 是根节点的子节点,cpu@0 又是 cpus 的子节点。这种嵌套关系反映了硬件的层级结构。
  3. 标签的使用cpu0intc 都是标签,可以在文件的其他地方通过 &cpu0&intc 来引用这些节点。

节点路径:绝对路径和相对路径

在设备树里,每个节点都有一个唯一的路径。就像文件系统一样,你可以用绝对路径或相对路径来引用一个节点。

绝对路径从根节点开始,用 / 分隔:

/cpus/cpu@0
/interrupt-controller@00a01000

相对路径则是从当前节点开始的路径。这个概念在设备树里用得不多,但了解一下还是有好处的。

节点引用:&label 机制

这是设备树语法中最"甜"的语法糖之一。有了标签,我们就可以在文件的其他地方引用和修改节点。

假设我们在 imx6ull.dtsi 里定义了这样一个节点:

c
i2c1: i2c@021a0000 {
    compatible = "fsl,imx6ul-i2c", "fsl,imx21-i2c";
    reg = <0x021a0000 0x4000>;
    status = "disabled";
};

注意这里的 status = "disabled"。芯片厂商不知道你会在板子上用哪些外设,所以默认把它们都禁用了。现在你想启用 I2C1,并在它下面挂一个设备,你不需要修改 .dtsi 文件(记住,永远不要直接修改厂商的头文件),只需要在你的 .dts 文件里这样写:

c
&i2c1 {
    clock-frequency = <100000>;
    status = "okay";

    mag3110@0e {
        compatible = "fsl,mag3110";
        reg = <0x0e>;
    };

    fxls8471@1e {
        compatible = "fsl,fxls8471";
        reg = <0x1e>;
    };
};

通过这种方式,我们既启用了 I2C1 控制器,又给它挂上了两个具体的从设备。而且,这些修改只局限在当前的 .dts 文件里,不会污染 imx6ull.dtsi。这种"通过引用标签来追加内容"的机制,是设备树移植中最常用的操作。


属性类型:数据的各种面孔

节点里面全是属性,属性就是键值对。DTS 支持的数据类型非常直观,我们来逐一了解。

字符串属性

最简单的属性类型就是字符串:

c
model = "Freescale i.MX6 ULL 14x14 EVK Board";
status = "okay";
device_type = "cpu";

字符串用双引号包裹,可以包含任何 ASCII 字符。

字符串列表

有时候一个属性需要多个字符串,这时候就用逗号分隔:

c
compatible = "fsl,imx6ull-gpmi-nand", "fsl,imx6ul-gpmi-nand";

这里非常关键,它表示"先试着找前一个驱动,找不到就找后一个",是驱动匹配机制的基础。内核会按顺序查找,直到找到匹配的驱动为止。

数值属性

数值用尖括号 <> 包裹,默认是 32 位无符号整数:

c
reg = <0>;
clock-frequency = <100000>;
#address-cells = <1>;

数值可以是十进制,也可以是十六进制(以 0x 开头)。你可以在同一个属性里放多个数值:

c
reg = <0x02020000 0x4000>;

这表示地址是 0x02020000,长度是 0x4000(16KB)。

引用属性(phandle)

这是设备树中一个比较特殊的类型。有时候一个节点需要引用另一个节点,比如一个中断控制器需要引用它所服务的设备。这时候就会用到 phandle。

c
gpio1: gpio@0209c000 {
    #gpio-cells = <2>;
    gpio-controller;
};

some-device {
    gpios = <&gpio1 12 0>;
};

这里的 &gpio1 就是一个引用,它指向前面定义的 gpio1 节点。在编译后的 DTB 里,每个节点都有一个唯一的数字 ID,这个 ID 就是 phandle。

数组属性

虽然我们在前面已经见过数值数组了,但这里要强调的是,数组可以混合不同类型的数据吗?答案是:不能。一个属性里的所有数据必须是同类型的。

但你可以有多个"同类"的数据段:

c
reg = <0x020C406C 0x04    /* 第一组:地址 长度 */
       0x020E0068 0x04    /* 第二组:地址 长度 */
       0x020E02F4 0x04    /* 第三组:地址 长度 */
       0x0209C000 0x04    /* 第四组:地址 长度 */
       0x0209C004 0x04>;  /* 第五组:地址 长度 */

这在描述需要多个寄存器区域的设备时非常有用,比如我们的 LED 驱动就需要访问五个不同的寄存器区域。

空属性

有些属性不需要值,它们的存在本身就表示某种含义:

c
gpio-controller;
interrupt-controller;
#gpio-cells = <2>;

注意这些属性后面没有分号,因为它们本身就是完整的属性定义。


标准属性:那些你一定会用到的属性

虽然你可以自定义任意属性并在驱动里读取,但 Linux 内核定义了一些标准属性。驱动程序通常会对这些属性有特定的预期处理。如果你不按规范写,驱动可能就识别不出你的设备。

compatible:驱动匹配的灵魂

这是最重要的属性,没有之一。

它的值是一个字符串列表,格式为 "manufacturer,model"。它是驱动和硬件绑定的"红娘"。

c
compatible = "fsl,imx6ul-evk-wm8960", "fsl,imx-audio-wm8960";

这里 "fsl" 是厂商,后面是具体型号。内核在加载驱动时,会拿着这个字符串去驱动的 of_match_table 里找。只要有一个能对上,这桩"婚事"就成了。

我们可以在驱动代码里看到对应的匹配表:

c
static const struct of_device_id imx_wm8960_dt_ids[] = {
    { .compatible = "fsl,imx-audio-wm8960", },
    { /* sentinel */ }
};

只要设备树里的 compatible 值包含 "fsl,imx-audio-wm8960",这个驱动就会被触发。

status:设备的生死开关

这个属性决定了设备是"活着"还是"死了"。

  • "okay":设备可操作,内核会加载对应的驱动。
  • "disabled":设备禁用。虽然节点在树里,但内核会忽略它。
  • "fail":设备检测到了严重错误,基本没救了。
  • "fail-sss":同上,后面跟的是具体的错误码。

这是我们在移植时最喜欢改的属性之一。很多外设在 .dtsi 里默认是 disabled,我们需要在 .dts 里把它改成 okay 来"激活"它。

reg:地址与长度

reg 属性用来描述设备寄存器地址范围。它的格式完全由父节点的 #address-cells#size-cells 决定。

以 UART1 为例:

c
uart1: serial@02020000 {
    compatible = "fsl,imx6ul-uart", "fsl,imx6q-uart", "fsl,imx21-uart";
    reg = <0x02020000 0x4000>;
};

查阅芯片手册,UART1 的寄存器首地址正是 0x02020000。虽然 0x4000 (16KB) 的范围可能比实际寄存器占用的空间大,但这只是为了包含整个地址块,内核拿到这个首地址就能进行映射操作了。

#address-cells 和 #size-cells:地址的度量衡

这两个属性专门用来指导子节点如何写 reg 属性。它们出现在父节点里:

  • #address-cells:决定了子节点的 reg 属性中,地址字段占用多少个 32 位整数(<u32>)。
  • #size-cells:决定了子节点的 reg 属性中,长度字段占用多少个 32 位整数。

reg 属性的格式通常是:reg = <address1 length1 address2 length2 ...>

眼见为实的关键:

c
spi4 {
    compatible = "spi-gpio";
    #address-cells = <1>;  // 地址占 1 个 cell
    #size-cells = <0>;     // 长度占 0 个 cell(即没有长度)

    gpio_spi: gpio_spi@0 {
        compatible = "fairchild,74hc595";
        reg = <0>;          // 所以这里只有一个地址 0,没有长度
    };
};

在这个例子中,父节点规定:我的孩子写地址时只写一个数,写长度时省略。所以子节点的 reg 只有一个 <0>

再看另一个例子:

c
aips3: aips-bus@02200000 {
    compatible = "fsl,aips-bus", "simple-bus";
    #address-cells = <1>;
    #size-cells = <1>;     // 地址占 1 个,长度也占 1 个

    dcp: dcp@02280000 {
        compatible = "fsl,imx6sl-dcp";
        reg = <0x02280000 0x4000>; // 地址是 0x02280000,长度是 0x4000
    };
};

这里 <0x02280000 0x4000>,前一个是地址,后一个是长度。

ranges:地址翻译指南

ranges 是一个属性,但它更像是一个翻译函数

格式为:ranges = <child-bus-address parent-bus-address length>

它的作用是告诉内核:子总线上的这段地址,对应父总线上的哪段地址

如果 ranges 属性值为空,那意味着子地址空间和父地址空间是一一对应的,不需要翻译。这在 SoC 内部总线中非常常见:

c
soc {
    #address-cells = <1>;
    #size-cells = <1>;
    compatible = "simple-bus";
    ranges;
};

这里的空 ranges 意味着:你在 soc 节点下面写的 reg = <0x02280000 ...>,就是实际的物理地址 0x02280000,没有任何偏移。

如果 ranges 不为空,情况就复杂多了:

c
soc {
    ranges = <0x0 0xe0000000 0x00100000>;

    serial {
        reg = <0x4600 0x100>;
    };
};

这里 <0x0 0xe0000000 0x00100000> 意味着: 子总线地址 0x0 对应 父总线地址 0xe0000000,范围是 1MB。 那么 serial 节点里的 0x4600 经过翻译,最终的物理地址就是 0xe0000000 + 0x4600 = 0xe0004600

这就是为什么我们普通开发板移植很少关心 ranges 的原因——大部分 SoC 内部总线都是直接映射的。


语法糖:让代码更简洁的技巧

设备树提供了一些语法糖,让我们的代码更加简洁和可维护。

include 机制

和 C 语言一样,设备树也支持"头文件"的概念,只不过它的后缀是 .dtsi

c
#include <dt-bindings/input/input.h>
#include "imx6ull.dtsi"

第 1 行引用的是 C 语言风格的头文件 input.h。你可能会疑惑:不是说设备树吗?为什么能 .h 文件?

这就是设备树编译器(DTC)的灵活性之一。它不仅认 .dtsi,连 .h 甚至另一个 .dts 文件都能 #include

但在工程上我们有个不成文的约定:

  • .h 文件通常用来放宏定义,特别是 dt-bindings 目录下的那些,用来把"魔术数字"变成人类可读的常量。
  • .dtsi 文件才是真正用来存放设备树节点定义的头文件。

/delete-node/:删除节点

有时候你不需要某个节点,想把它删除掉。这时候可以用 /delete-node/ 指令:

c
/delete-node/ &sim2;

这会把标签为 sim2 的节点从设备树中删除。这在复用厂商的 .dtsi 文件时非常有用,你可以删除不需要的节点,而不是让它们以 disabled 状态存在。

节点追加与覆盖

我们已经讲过通过 &label 来引用节点,但这里要强调的是,引用不仅可以追加内容,还可以覆盖已有的属性。

c
&i2c1 {
    status = "okay";  // 覆盖原来的 "disabled"
    clock-frequency = <100000>;  // 新增属性
};

如果原来的节点里已经有 clock-frequency 属性,这里的值会覆盖原来的值。如果没有,就会新增这个属性。

这种机制非常强大,它让你可以在不修改原始文件的情况下,完全定制设备树的行为。


实战示例:一个完整的设备树文件

讲了这么多语法,现在我们来看一个完整的设备树文件。这是一个用于 Alpha 开发板的 LED 驱动设备树文件:

c
/dts-v1/;
#include "imx6ull.dtsi"
#include "imx6ull-aes.dtsi"

/ {
    model = "Awesome Embedded Studio IMX6ULL Example Driver";
    compatible = "fsl,imx6ull-14x14-evk", "fsl,imx6ull";

    /*
     * PS 下,可以看到我们在/下追加了一个新的LED节点
     * 这个节点描述了 LED 驱动需要的所有寄存器地址
     */
    imx_aes_led {
        #address-cells = <1>;
        #size-cells = <1>;
        compatible = "atkalpha-led";
        status = "okay";
        reg = < 0X020C406C 0X04    /* CCM_CCGR1_BASE */
                0X020E0068 0X04    /* SW_MUX_GPIO1_IO03_BASE */
                0X020E02F4 0X04    /* SW_PAD_GPIO1_IO03_BASE */
                0X0209C000 0X04    /* GPIO1_DR_BASE */
                0X0209C004 0X04 >; /* GPIO1_GDIR_BASE */
    };
};

我们来逐行解析这个文件:

第 1 行/dts-v1/; 声明了设备树的版本。这是必须的,告诉 DTC 编译器我们用的是哪个版本的语法。

第 2-3 行#include 指令引入了两个头文件。imx6ull.dtsi 描述了 I.MX6ULL 芯片的所有硬件外设,imx6ull-aes.dtsi 则是 Alpha 开发板特有的配置。

第 5-6 行:根节点的 modelcompatible 属性。model 是给人看的,描述这是什么板子;compatible 是给内核看的,用于匹配初始化代码。这里的 "fsl,imx6ull" 必须和内核里定义的某个 DT_MACHINE_START.dt_compat 表匹配,否则内核启动失败。

第 11 行imx_aes_led 是我们自定义的节点名称。注意这里没有 @address 后缀,因为这个节点不对应任何实际的硬件寄存器区域——它只是一个"容器",用来描述驱动需要的各种寄存器。

第 12-13 行#address-cells#size-cells 都设置为 1,表示子节点(虽然这里没有)的 reg 属性中地址和长度各占一个 cell。对于这个节点本身,它也影响 reg 属性的解析方式。

第 14 行compatible = "atkalpha-led" 是驱动匹配的关键。驱动代码里会有一个匹配表,只要里面有 "atkalpha-led",这个驱动就会被绑定到这个设备上。

第 15 行status = "okay" 表示这个设备是启用的。如果设为 "disabled",内核会忽略它。

第 16-20 行reg 属性列出了驱动需要的所有寄存器地址。每个地址由两部分组成:物理地址和长度。这里的注释清楚地标明了每个寄存器的用途,从时钟配置到 GPIO 控制,一应俱全。


踩坑预警:这些错误你可能也会犯

在设备树开发的路上,有些坑是几乎每个人都会踩的。这里总结几个最常见的错误,希望能帮你节省点调试时间。

根节点 compatible 拼写错误

如果你手滑,把 .dts 根节点的 compatible 改成了 "fsl,imx6ullll"(多打个 l),内核遍历一圈发现没人认这孩子,结果就是——内核启动失败

你在串口上只能看到 Starting kernel ...,然后就没有然后了,没有任何报错信息。这对新手来说简直是噩梦。所以如果内核启动不了,第一件事就是检查根节点的 compatible 拼写!

忘记写 ranges

在定义总线节点时,如果忘记写 ranges 属性,内核在尝试映射这些外设寄存器时,可能会拿到错误的地址,直接导致驱动无法访问硬件。记住:如果是 SoC 内部总线,通常需要空 ranges 属性表示直接映射。

#address-cells 和 #size-cells 不匹配

这个错误非常隐蔽。如果父节点定义了 #address-cells = <1>#size-cells = <1>,但子节点的 reg 属性只写了一个数字,DTC 编译器可能不会报错,但内核解析时会出问题。这种情况下,你只能通过反复检查来发现错误。

直接修改 .dtsi 文件

这是新手最容易犯的错误。当你发现需要修改某个设备的配置时,直接去修改 .dtsi 文件。这样做的问题在于:.dtsi 是公用的,你这一改,所有其他引用这个文件的项目都会被影响。正确的做法是在你的 .dts 文件里通过 &label 引用来修改。


小结

这一章我们详细拆解了设备树的语法,从节点命名到属性类型,从标准属性到语法糖,最后通过一个完整的实战示例把所有知识点串联起来。

我们了解到:

  • 节点有三种表示方式:普通节点、带地址的节点、带标签的节点
  • 属性有多种类型:字符串、数值、引用、数组
  • 标准属性如 compatibleregstatus 是驱动匹配的关键
  • #address-cells#size-cells 决定了 reg 属性的解析方式
  • ranges 属性控制地址翻译,大部分 SoC 内部总线使用空 ranges
  • &label 引用机制让我们可以在不修改原文件的情况下定制设备树

掌握了这些语法,你就能看懂任何 .dts 文件,也能写出规范的设备树代码。下一章,我们将深入实战,编写一个使用设备树的 LED 驱动,把理论知识应用到实际开发中。


下一步

Built with VitePress