03. 设备树语法详解——读懂这张"藏宝图"
前言:语法是基础,但不是全部
上一章我们把编译流程跑通了,现在手里有了一个 .dts 文件,不管它是自己写的还是厂商给的,摆在面前的第一件事就是:怎么读懂它?
说实话,虽然我们很少会从零开始写一整份 .dts 文件——通常都是对着芯片厂给的参考文件修修改改——但语法这一关必须过。如果你看不懂它的结构,改设备树就变成了"瞎猫碰死耗子",改对了不知道为什么,改错了更是两眼一抹黑。
好消息是,DTS 的语法非常友好。它不像 C 语言那样有复杂的指针和预处理宏,也不像 Verilog 那样需要深厚的硬件功底。它本质上就是一种带格式的 ASCII 文本,甚至可以说,它比 C 语言还要简单。
这一章,我们会像解剖一只青蛙一样,把设备树的语法拆解开来。从节点到属性,从基本类型到标准属性,从语法糖到实战示例。我们的目标很明确:看到任何一个 .dts 文件,都能一眼看穿它的结构和含义。
节点语法:树的枝干
设备树的核心结构就是树。每个设备都是一个节点,每个节点里是一堆键值对,我们称之为属性。这听起来很抽象,我们来看一个实际的例子。
节点命名规则
节点的命名格式通常如下:
node-name@unit-addressnode-name:节点名字,ASCII 字符串,最好一眼就能看出是干嘛的,比如uart1、i2c0。unit-address:设备的地址,通常是寄存器首地址。如果没有地址或者不需要,这部分可以省略。
但我们在实际的 .dts 文件里,经常看到一种更复杂的写法:
cpu0: cpu@0
intc: interrupt-controller@00a01000这里的格式是:
label: node-name@unit-address冒号前面的 cpu0 和 intc 是节点标签。为什么要加这个标签?为了方便引用。试想一下,如果我想在 .dts 文件里修改这个 interrupt-controller@00a01000 的某个属性,难道我要把这长长的一串名字敲一遍吗?太容易出错了。有了标签,我只需要写 &intc { ... };,就能精准地定位到这个节点。
我们来看一个完整的例子,这是从 I.MX6ULL 的设备树里截取的一段:
/ {
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>;
};
}这里有几个关键点需要解释:
- 根节点
/:这是整个树的起点,所有其他节点都是它的子孙。 - 节点嵌套:
cpus是根节点的子节点,cpu@0又是cpus的子节点。这种嵌套关系反映了硬件的层级结构。 - 标签的使用:
cpu0和intc都是标签,可以在文件的其他地方通过&cpu0或&intc来引用这些节点。
节点路径:绝对路径和相对路径
在设备树里,每个节点都有一个唯一的路径。就像文件系统一样,你可以用绝对路径或相对路径来引用一个节点。
绝对路径从根节点开始,用 / 分隔:
/cpus/cpu@0
/interrupt-controller@00a01000相对路径则是从当前节点开始的路径。这个概念在设备树里用得不多,但了解一下还是有好处的。
节点引用:&label 机制
这是设备树语法中最"甜"的语法糖之一。有了标签,我们就可以在文件的其他地方引用和修改节点。
假设我们在 imx6ull.dtsi 里定义了这样一个节点:
i2c1: i2c@021a0000 {
compatible = "fsl,imx6ul-i2c", "fsl,imx21-i2c";
reg = <0x021a0000 0x4000>;
status = "disabled";
};注意这里的 status = "disabled"。芯片厂商不知道你会在板子上用哪些外设,所以默认把它们都禁用了。现在你想启用 I2C1,并在它下面挂一个设备,你不需要修改 .dtsi 文件(记住,永远不要直接修改厂商的头文件),只需要在你的 .dts 文件里这样写:
&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 支持的数据类型非常直观,我们来逐一了解。
字符串属性
最简单的属性类型就是字符串:
model = "Freescale i.MX6 ULL 14x14 EVK Board";
status = "okay";
device_type = "cpu";字符串用双引号包裹,可以包含任何 ASCII 字符。
字符串列表
有时候一个属性需要多个字符串,这时候就用逗号分隔:
compatible = "fsl,imx6ull-gpmi-nand", "fsl,imx6ul-gpmi-nand";这里非常关键,它表示"先试着找前一个驱动,找不到就找后一个",是驱动匹配机制的基础。内核会按顺序查找,直到找到匹配的驱动为止。
数值属性
数值用尖括号 <> 包裹,默认是 32 位无符号整数:
reg = <0>;
clock-frequency = <100000>;
#address-cells = <1>;数值可以是十进制,也可以是十六进制(以 0x 开头)。你可以在同一个属性里放多个数值:
reg = <0x02020000 0x4000>;这表示地址是 0x02020000,长度是 0x4000(16KB)。
引用属性(phandle)
这是设备树中一个比较特殊的类型。有时候一个节点需要引用另一个节点,比如一个中断控制器需要引用它所服务的设备。这时候就会用到 phandle。
gpio1: gpio@0209c000 {
#gpio-cells = <2>;
gpio-controller;
};
some-device {
gpios = <&gpio1 12 0>;
};这里的 &gpio1 就是一个引用,它指向前面定义的 gpio1 节点。在编译后的 DTB 里,每个节点都有一个唯一的数字 ID,这个 ID 就是 phandle。
数组属性
虽然我们在前面已经见过数值数组了,但这里要强调的是,数组可以混合不同类型的数据吗?答案是:不能。一个属性里的所有数据必须是同类型的。
但你可以有多个"同类"的数据段:
reg = <0x020C406C 0x04 /* 第一组:地址 长度 */
0x020E0068 0x04 /* 第二组:地址 长度 */
0x020E02F4 0x04 /* 第三组:地址 长度 */
0x0209C000 0x04 /* 第四组:地址 长度 */
0x0209C004 0x04>; /* 第五组:地址 长度 */这在描述需要多个寄存器区域的设备时非常有用,比如我们的 LED 驱动就需要访问五个不同的寄存器区域。
空属性
有些属性不需要值,它们的存在本身就表示某种含义:
gpio-controller;
interrupt-controller;
#gpio-cells = <2>;注意这些属性后面没有分号,因为它们本身就是完整的属性定义。
标准属性:那些你一定会用到的属性
虽然你可以自定义任意属性并在驱动里读取,但 Linux 内核定义了一些标准属性。驱动程序通常会对这些属性有特定的预期处理。如果你不按规范写,驱动可能就识别不出你的设备。
compatible:驱动匹配的灵魂
这是最重要的属性,没有之一。
它的值是一个字符串列表,格式为 "manufacturer,model"。它是驱动和硬件绑定的"红娘"。
compatible = "fsl,imx6ul-evk-wm8960", "fsl,imx-audio-wm8960";这里 "fsl" 是厂商,后面是具体型号。内核在加载驱动时,会拿着这个字符串去驱动的 of_match_table 里找。只要有一个能对上,这桩"婚事"就成了。
我们可以在驱动代码里看到对应的匹配表:
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 为例:
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 ...>。
眼见为实的关键:
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>。
再看另一个例子:
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 内部总线中非常常见:
soc {
#address-cells = <1>;
#size-cells = <1>;
compatible = "simple-bus";
ranges;
};这里的空 ranges 意味着:你在 soc 节点下面写的 reg = <0x02280000 ...>,就是实际的物理地址 0x02280000,没有任何偏移。
如果 ranges 不为空,情况就复杂多了:
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。
#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/ 指令:
/delete-node/ &sim2;这会把标签为 sim2 的节点从设备树中删除。这在复用厂商的 .dtsi 文件时非常有用,你可以删除不需要的节点,而不是让它们以 disabled 状态存在。
节点追加与覆盖
我们已经讲过通过 &label 来引用节点,但这里要强调的是,引用不仅可以追加内容,还可以覆盖已有的属性。
&i2c1 {
status = "okay"; // 覆盖原来的 "disabled"
clock-frequency = <100000>; // 新增属性
};如果原来的节点里已经有 clock-frequency 属性,这里的值会覆盖原来的值。如果没有,就会新增这个属性。
这种机制非常强大,它让你可以在不修改原始文件的情况下,完全定制设备树的行为。
实战示例:一个完整的设备树文件
讲了这么多语法,现在我们来看一个完整的设备树文件。这是一个用于 Alpha 开发板的 LED 驱动设备树文件:
/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 行:根节点的 model 和 compatible 属性。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 引用来修改。
小结
这一章我们详细拆解了设备树的语法,从节点命名到属性类型,从标准属性到语法糖,最后通过一个完整的实战示例把所有知识点串联起来。
我们了解到:
- 节点有三种表示方式:普通节点、带地址的节点、带标签的节点
- 属性有多种类型:字符串、数值、引用、数组
- 标准属性如
compatible、reg、status是驱动匹配的关键 #address-cells和#size-cells决定了reg属性的解析方式ranges属性控制地址翻译,大部分 SoC 内部总线使用空ranges&label引用机制让我们可以在不修改原文件的情况下定制设备树
掌握了这些语法,你就能看懂任何 .dts 文件,也能写出规范的设备树代码。下一章,我们将深入实战,编写一个使用设备树的 LED 驱动,把理论知识应用到实际开发中。
下一步
- 04. 设备树历史演进:讲解设备树的历史背景和演进过程。
- 返回目录:查看所有章节。