设备树语法入门:节点、属性、标签、引用、包含文件
设备树(Device Tree)的语法,说白了就是一套描述硬件拓扑的「领域特定语言」。你不需要把它想得多复杂——它本质上就是个树形结构的文本文件,每个硬件设备对应一个节点,节点下面挂属性和子节点。
我刚开始接触设备树时,总觉得这玩意儿跟 XML 差不多。后来调了几个驱动才发现,它的引用机制和编译方式有不少坑。今天咱们就把这些基础语法掰开揉碎,一个一个说清楚。
核心要点:设备树语法包含 5 个基本元素——节点(node)、属性(property)、标签(label)、引用(reference)、包含文件(#include)。掌握这 5 个,你就能读懂 90% 的设备树源文件。
2.1 节点(Node):硬件拓扑的基本单元
节点是设备树最基本的组成单位。每个节点代表一个硬件设备或总线,格式很简单:节点名@地址。
/dts-v1/;
/ {
#address-cells = <1>;
#size-cells = <1>;
cpu@0 {
compatible = "arm,cortex-a7";
reg = <0x0 0x100>;
};
memory@80000000 {
device_type = "memory";
reg = <0x80000000 0x40000000>;
};
};
看到这个结构了吗?根节点 / 下面挂了两个子节点:cpu@0 和 memory@80000000。节点名后面的 @ 跟的是地址,用来区分同名的节点。比如两个串口,可以写成 uart@1000 和 uart@2000。
我在项目中遇到过一个问题:有人把节点名写成了 CPU@0(大写),结果驱动匹配不上。设备树的节点名是大小写敏感的,这一点千万注意。
个人习惯:节点名统一用小写字母,地址用十六进制小写。这样既规范,又不容易出错。
2.2 属性(Property):描述节点的特征
属性就是挂在节点上的键值对,用来描述硬件的各种参数。常见的属性类型有 4 种:
| 属性类型 | 格式 | 示例 | 说明 |
|---|---|---|---|
| 字符串 | "string" |
compatible = "ti,am335x-uart"; |
用于匹配驱动 |
| 32 位整数 | <value> |
reg = <0x1000 0x200>; |
地址、中断号等 |
| 64 位整数 | <0x00000000 0x80000000> |
reg = <0x00000000 0x80000000 0x00000000 0x40000000>; |
高 32 位 + 低 32 位 |
| 字节数组 | [value] |
local-mac-address = [00 11 22 33 44 55]; |
MAC 地址等 |
最常用的属性是 compatible 和 reg。compatible 用来告诉内核:这个设备用哪个驱动。格式是 "厂商,设备型号",比如 "ti,am335x-uart"。
reg 描述设备的地址空间。它的长度由父节点的 #address-cells 和 #size-cells 决定。举个例子:
bus {
#address-cells = <1>;
#size-cells = <1>;
device@1000 {
reg = <0x1000 0x100>; // 起始地址 0x1000,长度 0x100
};
};
这里 #address-cells = 1 表示地址用 1 个 32 位整数表示,#size-cells = 1 表示长度也用 1 个 32 位整数。所以 reg 里每两个数一组:地址 + 长度。
我曾经踩过的坑:写 SPI 设备节点时,忘了设置 #address-cells 和 #size-cells,结果内核解析 reg 属性时直接报错。记住:这两个属性必须在父节点中定义,子节点才能正确使用 reg。
2.3 标签(Label):给节点起个名字
标签就是节点的别名。用 标签名: 放在节点前面,其他地方就能通过 &标签名 来引用这个节点。
uart0: serial@1000 {
compatible = "ns16550";
reg = <0x1000 0x100>;
interrupts = <33>;
};
这里 uart0 就是标签。后面想引用这个串口节点,直接写 &uart0 就行。
我建议每个重要的外设都加上标签。为什么?因为设备树经常需要覆盖或追加属性,没有标签你根本没法引用。比如你要在板级文件中修改串口的波特率:
&uart0 {
clock-frequency = <115200>;
};
没有标签的话,你得把整个节点重新写一遍,那多麻烦。
2.4 引用(Reference):连接节点的桥梁
引用有两种形式:一种是上面说的 &标签名,另一种是 &{/路径}。前者更常用,后者适合临时引用没有标签的节点。
// 方式一:通过标签引用(推荐)
&uart0 {
status = "okay";
};
// 方式二:通过路径引用(不常用)
&{/soc/serial@1000} {
status = "okay";
};
引用最常见的用途是覆盖属性。比如 SoC 的 .dtsi 文件里把某个外设的 status 设为 "disabled",板级 .dts 文件里通过引用把它改成 "okay"。这就是设备树的分层设计思想。
小技巧:编译设备树时,如果引用了一个不存在的标签,编译器会报错。所以写引用之前,先确认标签确实存在。我习惯在 .dtsi 文件里把所有外设都加上标签,这样板级文件引用起来很方便。
2.5 包含文件(#include):复用代码的利器
#include 和 C 语言的预处理指令一样,用来包含其他文件。设备树里主要包含两种文件:.dtsi(设备树包含文件)和 .h(头文件)。
// 包含 .dtsi 文件
#include "imx6ull.dtsi"
// 包含 .h 头文件(里面定义宏)
#include "imx6ull-pinfunc.h"
/ {
model = "My i.MX6ULL Board";
compatible = "my,imx6ull-board", "fsl,imx6ull";
};
.dtsi 文件通常放 SoC 的公共定义,比如 CPU、中断控制器、时钟等。板级 .dts 文件通过 #include 引入 .dtsi,然后覆盖或追加板级特有的配置。
头文件里一般放宏定义,比如 GPIO 号、引脚复用配置。举个例子:
// imx6ull-pinfunc.h
#define MX6UL_PAD_UART1_TX__UART1_DCE_TX 0x0084 0x0310 5 0 0
#define MX6UL_PAD_UART1_RX__UART1_DCE_RX 0x0088 0x0314 5 0 0
然后在设备树里直接用宏:
&uart1 {
pinctrl-0 = <&pinctrl_uart1>;
status = "okay";
};
&iomuxc {
pinctrl_uart1: uart1grp {
fsl,pins = <
MX6UL_PAD_UART1_TX__UART1_DCE_TX 0x1b0b1
MX6UL_PAD_UART1_RX__UART1_DCE_RX 0x1b0b1
>;
};
};
你看,用宏代替那些冗长的十六进制数,代码可读性一下子提升了不少。
注意:#include 和 /include/ 是两回事。#include 是 C 预处理器的指令,支持宏展开;/include/ 是设备树编译器(DTC)自己的包含指令,不支持宏。我建议统一用 #include,因为更灵活。
2.6 知识体系总览
说了这么多,咱们用一张图把设备树语法的核心逻辑串起来:
2.7 实战建议
学完这些语法,你可能会问:实际开发中怎么用?我分享几个经验:
- 先读后写:拿到一个新板子,先看它的
.dts和.dtsi文件,理解节点和属性的组织方式。我每次接手新项目,第一件事就是通读设备树。 - 善用标签:所有外设节点都加上标签,哪怕当前不需要覆盖。因为你永远不知道后面会不会改。
- 分层设计:SoC 公共部分放
.dtsi,板级特有部分放.dts,通过#include组合。这样代码复用率高,维护也方便。 - 编译验证:写完设备树,用
dtc -I dts -O dtb -o test.dtb test.dts编译一下。如果有语法错误,编译器会告诉你具体位置。
我的习惯:在 .dtsi 文件开头加一段注释,写明这个文件定义了哪些标签、哪些节点。这样别人接手时,一眼就能知道文件的结构。
设备树语法其实不复杂,难的是把硬件手册里的信息准确翻译成节点和属性。多写几次,你就能找到感觉了。