4. 设备树基础:设备树语法、节点与属性、绑定文档、编译与反编译
设备树(Device Tree)是什么?说白了,它就是一套描述硬件信息的“说明书”。
我刚开始接触嵌入式Linux时,最头疼的就是板级文件里那些乱七八糟的硬件注册代码。每次换一个外设,都要改一堆C文件,编译整个内核。后来内核引入了设备树,情况就完全不一样了。硬件描述和驱动代码分离,改硬件配置再也不用动内核源码了。
这一章,我们就来啃下设备树这块硬骨头。我会把语法、节点、属性、绑定文档、编译反编译这些知识点,结合我在RK3568项目中的实际经验,给你讲透。
4.1 设备树到底是什么?
设备树是一个树形数据结构。它的根节点是“/”,下面挂载各种硬件节点。每个节点描述一个硬件设备,节点里用属性来描述设备的细节。
内核启动时,Bootloader会把设备树二进制文件(.dtb)传给内核。内核解析这个文件,就知道板子上有哪些硬件、怎么初始化它们了。
核心概念:
- DTS:设备树源文件(.dts),文本格式,我们直接编写
- DTC:设备树编译器,把.dts编译成.dtb
- DTB:设备树二进制文件(.dtb),内核实际使用的格式
- DTSI:设备树头文件(.dtsi),公共部分,可以被.dts包含
我习惯把.dtsi类比成C语言的头文件。公共的SoC配置、时钟、中断控制器这些,都放在.dtsi里。具体的板级差异,比如GPIO复用、外设地址,才放在.dts里。这样复用性高,维护起来也清爽。
4.2 设备树语法基础
设备树的语法其实不复杂。你把它想象成一个JSON对象,只不过写法更紧凑。
4.2.1 节点定义
每个节点以 节点名@地址 开头,用花括号包裹内容。节点名不能有特殊字符,地址一般是外设的基地址。
/dts-v1/;
/ {
model = "Rockchip RK3568 EVB1 DDR4 V10 Board";
compatible = "rockchip,rk3568-evb1-ddr4-v10", "rockchip,rk3568";
chosen {
stdout-path = &uart2;
};
memory@a00000 {
device_type = "memory";
reg = <0x00 0x00a00000 0x00 0x80000000>;
};
uart2: serial@fe660000 {
compatible = "rockchip,rk3568-uart", "snps,dw-apb-uart";
reg = <0x0 0xfe660000 0x0 0x100>;
interrupts = <0 116 4>;
clocks = <&cru SCLK_UART2>, <&cru PCLK_UART2>;
clock-names = "baudclk", "apb_pclk";
status = "disabled";
};
};
你看,根节点 / 下面有 chosen、memory、uart2 这些子节点。每个节点都有自己的属性。
我的习惯:节点名尽量用外设的通用名称,比如 uart2、i2c1、spi0。地址部分写外设的基地址,这样一眼就能看出这个节点对应哪个硬件。
4.2.2 标签与引用
标签(label)是节点的别名。上面例子里的 uart2: 就是标签。其他地方可以用 &uart2 来引用这个节点。
为什么要用标签?因为设备树里经常需要在多个地方引用同一个节点。比如在pinctrl里引用uart2的引脚配置,在chosen里引用uart2作为控制台。用标签比写完整路径方便多了。
4.2.3 包含文件与宏
设备树支持 #include 预处理指令。RK3568的公共配置都放在 rk3568.dtsi 里,板级文件直接包含它。
// rk3568-evb1-ddr4-v10.dts
#include "rk3568.dtsi"
#include "rk3568-evb.dtsi"
/ {
model = "Rockchip RK3568 EVB1 DDR4 V10 Board";
compatible = "rockchip,rk3568-evb1-ddr4-v10", "rockchip,rk3568";
// 板级特有的配置
&uart2 {
status = "okay";
};
};
这里有个细节:&uart2 { status = "okay"; }; 这种写法叫“追加节点”。它不会重新定义uart2,而是在原有节点基础上添加或覆盖属性。我在项目中经常用这种方式,在板级文件里使能某个外设。
4.3 节点与属性详解
节点是设备树的骨架,属性是血肉。每个节点可以有多个属性,属性值可以是字符串、整数、数组、二进制数据等。
4.3.1 常用属性
| 属性名 | 含义 | 示例 |
|---|---|---|
compatible |
设备兼容性字符串,驱动匹配的关键 | "rockchip,rk3568-uart", "snps,dw-apb-uart" |
reg |
寄存器地址和长度 | <0x0 0xfe660000 0x0 0x100> |
interrupts |
中断号、触发类型 | <0 116 4> |
clocks |
时钟句柄列表 | <&cru SCLK_UART2> |
status |
设备状态:okay/disabled/reserved | "okay" |
pinctrl-0 |
引脚复用配置 | <&pinctrl_uart2m0_xfer> |
注意:compatible 属性的顺序很重要。驱动匹配时,内核会按顺序尝试。第一个匹配成功的驱动会被使用。所以通常把最具体的兼容字符串放在前面,通用的放在后面。
4.3.2 地址编码与 ranges
设备树里地址用 #address-cells 和 #size-cells 来定义地址和长度的字长。RK3568是64位SoC,所以 address-cells = <2>,size-cells = <2>。
ranges 属性用于地址转换。父节点地址空间和子节点地址空间不一致时,就需要 ranges 来映射。
pcie3x2: pcie@fe280000 {
compatible = "rockchip,rk3568-pcie";
reg = <0x0 0xfe280000 0x0 0x10000>;
ranges = <0x01000000 0x0 0xf0100000 0x0 0xf0100000 0x0 0x00100000
0x02000000 0x0 0xf0200000 0x0 0xf0200000 0x0 0x00e00000>;
#address-cells = <3>;
#size-cells = <2>;
};
嗯,这里要注意:ranges 的格式是 <子地址 父地址 长度>。PCIe的地址域和CPU地址域不同,所以需要这个映射。我第一次写PCIe节点时,被这个 ranges 折腾了半天,后来发现其实就是把PCIe的地址空间映射到CPU能访问的地址空间上。
4.4 绑定文档
绑定文档(Binding Document)是设备树的“说明书”。它告诉你某个外设的节点应该怎么写,哪些属性是必需的,哪些是可选的。
绑定文档放在内核源码的 Documentation/devicetree/bindings/ 目录下。每个外设一个YAML文件或文本文件。
举个例子,RK3568的UART绑定文档里会写明:
compatible必须包含"rockchip,rk3568-uart"reg必须提供地址和长度interrupts必须指定中断号clocks和clock-names必须提供pinctrl-0和pinctrl-names可选但建议提供
我的建议:写设备树节点前,先去看绑定文档。不要凭感觉写。我曾经在RK3399项目上,因为没看绑定文档,把GPIO的 gpios 属性写成了 gpio,结果驱动死活不认。查了半天才发现是属性名写错了。
4.5 编译与反编译
设备树源文件(.dts)不能直接被内核使用,需要编译成二进制文件(.dtb)。这个工作由 dtc 工具完成。
4.5.1 编译设备树
在内核源码根目录下,编译RK3568的设备树:
make ARCH=arm64 rk3568-evb1-ddr4-v10.dtb
或者手动用dtc编译:
dtc -I dts -O dtb -o rk3568-evb1-ddr4-v10.dtb rk3568-evb1-ddr4-v10.dts
参数说明:
-I dts:输入格式是dts-O dtb:输出格式是dtb-o:指定输出文件名
4.5.2 反编译设备树
有时候你拿到一个.dtb文件,想看看里面写了什么。反编译就能把二进制文件还原成可读的dts:
dtc -I dtb -O dts -o rk3568-evb1-ddr4-v10.dts rk3568-evb1-ddr4-v10.dtb
我在调试时经常用这个命令。比如内核启动时打印了设备树错误信息,我就把dtb反编译出来,对照错误信息排查。
4.5.3 编译检查
dtc还支持语法检查。加上 -W 参数可以开启警告:
dtc -I dts -O dtb -o test.dtb test.dts -W all
常见的警告有:
- 节点缺少
compatible属性 - 地址和长度不匹配
- 重复的节点名
我曾经踩过的坑:有一次我修改了设备树,编译没报错,但内核启动时外设就是不工作。反编译一看,发现我写错了属性名,dtc把它当成了自定义属性,没有报错。所以编译通过不代表设备树正确,一定要对照绑定文档检查。
4.6 实战:编写一个简单的设备树节点
假设我们要在RK3568上添加一个I2C设备——温度传感器LM75。先看绑定文档,然后写节点。
// 在板级dts文件中追加
&i2c1 {
status = "okay";
clock-frequency = <100000>;
lm75@48 {
compatible = "national,lm75";
reg = <0x48>;
status = "okay";
};
};
这里:
&i2c1引用I2C1节点,追加配置status = "okay"使能I2C1clock-frequency设置I2C时钟频率为100KHzlm75@48是子节点,地址0x48是LM75的I2C地址compatible匹配LM75驱动reg = <0x48>告诉驱动设备地址
编译后,内核启动时就会自动探测并初始化LM75。驱动通过设备树拿到的地址和配置,直接操作硬件。
核心要点回顾:
- 设备树是硬件描述文件,驱动通过它获取硬件信息
- 节点用
节点名@地址定义,属性描述设备细节 compatible是驱动匹配的关键,顺序很重要- 绑定文档是写设备树的“圣经”,写之前一定要看
- 用
dtc编译和反编译设备树,调试时很有用
设备树其实不难,关键是理解它的设计思想:把硬件描述和驱动代码解耦。你想想看,如果没有设备树,每换一个板子就要改内核源码,那得多痛苦?