4. 设备树基础(DTS):从语法到Rockchip实战
设备树,说白了就是描述硬件的一张“地图”。Linux内核通过它才知道你板子上接了哪些外设、内存多大、中断怎么连。我刚开始接触嵌入式Linux时,总觉得DTS就是个配置文件,后来踩了几次坑才明白——它其实是驱动和硬件之间的“翻译官”。
4.1 DTS语法入门:节点、属性、reg、interrupts
先看一个最简单的设备树节点长什么样:
/dts-v1/;
/ {
model = "Rockchip RK3588 EVB Board";
compatible = "rockchip,rk3588-evb", "rockchip,rk3588";
chosen {
stdout-path = &uart2;
};
memory@a00000 {
device_type = "memory";
reg = <0x0 0xa00000 0x0 0x80000000>;
};
uart2: serial@fe660000 {
compatible = "rockchip,rk3588-uart", "snps,dw-apb-uart";
reg = <0x0 0xfe660000 0x0 0x100>;
interrupts = <0 252 4>;
clocks = <&cru SCLK_UART2>, <&cru PCLK_UART2>;
clock-names = "baudclk", "apb_pclk";
status = "disabled";
};
};
这里有几个关键概念,我一个个说。
4.1.1 节点(Node)
每个硬件设备或总线都是一个节点。节点用{ }包裹,可以嵌套。节点名格式是name@address,比如serial@fe660000。@后面的地址只是为了区分同名节点,不是必须的。
4.1.2 属性(Property)
属性就是键值对。值可以是字符串、32位整数数组、64位整数数组、字节数组等。常见的属性有:
- compatible:驱动匹配的关键字。内核通过它找到对应的驱动。
- status:设备状态,
okay表示启用,disabled表示禁用。 - model:板子型号描述。
4.1.3 reg属性——地址资源
reg描述设备的地址空间。格式是<地址 长度>,可以有多组。比如:
reg = <0x0 0xfe660000 0x0 0x100>;
这表示UART2的寄存器基地址是0xfe660000,映射范围0x100字节。为什么是两组<地址 长度>?因为RK3588是64位SoC,地址用两个32位单元表示高32位和低32位。
4.1.4 interrupts属性——中断资源
interrupts描述设备使用的中断号。格式取决于中断控制器。在RK3588上,GIC(通用中断控制器)使用三个单元:
interrupts = <0 252 4>;
- 第一个值:中断类型,0=SPI(共享外设中断),1=PPI(私有外设中断)
- 第二个值:中断号,252是UART2的中断号
- 第三个值:触发方式,4=高电平触发,1=上升沿触发
4.2 Rockchip平台DTS文件结构
Rockchip的DTS文件组织方式很有代表性。我拿RK3588举例,它的设备树文件分三层:
| 文件 | 作用 | 包含关系 |
|---|---|---|
| rk3588.dtsi | SoC级描述,定义所有内部外设、时钟、中断控制器、总线等 | 被所有RK3588板级dts包含 |
| rk3588-evb.dts | 板级描述,定义具体板子上的外设(如HDMI、USB、以太网PHY等) | #include "rk3588.dtsi" |
| rk3588-evb.dtsi | 板级公共部分,如果多个板型共用部分外设,可以抽出来 | 被rk3588-evb.dts包含 |
为什么要分层?说白了就是复用。SoC厂商(Rockchip)提供rk3588.dtsi,板卡厂商只需要写自己的板级dts,然后#include进去,再修改或添加板级特有的节点就行。
4.2.1 rk3588.dtsi 里有什么?
打开这个文件,你会看到几百个节点。我挑几个典型的:
- cpus:CPU核心描述,4个Cortex-A76 + 4个Cortex-A55
- gic:中断控制器,GIC-600
- cru:时钟复位单元,管理所有时钟和复位信号
- i2c0 ~ i2c8:9个I2C控制器
- uart0 ~ uart9:10个UART控制器
- pinctrl:引脚复用控制器
每个外设节点都定义了reg、interrupts、clocks等基础属性。板级dts只需要设置status = "okay",然后配置pinctrl引脚复用即可。
4.2.2 rk3588-evb.dts 怎么用?
看一个实际例子:
// rk3588-evb.dts
/dts-v1/;
#include "rk3588.dtsi"
&uart2 {
status = "okay";
pinctrl-names = "default";
pinctrl-0 = <&uart2m0_xfer>;
};
&i2c0 {
status = "okay";
clock-frequency = <400000>;
hym8563: rtc@51 {
compatible = "haoyu,hym8563";
reg = <0x51>;
status = "okay";
};
};
注意这里的&uart2语法——这是设备树的“引用”机制。它表示修改rk3588.dtsi中定义的uart2节点,添加或覆盖属性。这样就不用把整个节点复制一遍。
&标签的修改合并到原节点上。如果板级dts和SoC dtsi定义了相同的属性,板级dts的优先级更高。
4.3 编译与反编译DTB
DTS是文本文件,内核不能直接解析。需要编译成二进制DTB文件。工具链是dtc(Device Tree Compiler)。
4.3.1 编译DTS到DTB
# 基本编译
dtc -I dts -O dtb -o rk3588-evb.dtb rk3588-evb.dts
# 带调试信息(方便定位错误)
dtc -I dts -O dtb -o rk3588-evb.dtb rk3588-evb.dts -@
# 检查语法错误
dtc -I dts -O dtb rk3588-evb.dts 2>&1 | head -20
如果编译报错,最常见的原因是:
- 缺少
#include的头文件(如.dtsi文件路径不对) - 引用了未定义的标签(比如
&uart2但uart2没定义) - 属性值格式错误(比如
reg少了一个<>)
4.3.2 反编译DTB到DTS
调试时经常需要看实际运行的设备树是什么样。反编译命令:
# 反编译DTB
dtc -I dtb -O dts -o rk3588-evb-decompiled.dts rk3588-evb.dtb
# 查看设备树结构(只显示节点名)
dtc -I dtb -O dts rk3588-evb.dtb | grep "^[a-zA-Z]" | head -30
#include路径写错,编译没报错但实际没包含进去。反编译一看就知道。
4.3.3 在Linux内核中编译DTS
如果你在内核源码树里工作,更推荐用内核的编译系统:
# 只编译设备树
make ARCH=arm64 dtbs
# 编译指定板子的设备树
make ARCH=arm64 rk3588-evb.dtb
# 编译后输出在 arch/arm64/boot/dts/rockchip/ 下
内核编译系统会自动处理#include依赖和头文件路径,比手动调用dtc省心很多。
4.4 设备树知识体系总览
下面这张图总结了本章的核心内容,从DTS语法到编译部署的完整链路:
4.5 避坑指南
最后分享几个我实际踩过的坑:
- 中断号写错:我曾经把SPI中断号写成了PPI,结果驱动注册中断失败,内核报
irq: no irq handler。查了半天才发现是中断类型字段写错了。 - reg地址不对齐:RK3588的某些外设要求寄存器地址按4KB对齐。如果reg的起始地址没对齐,驱动访问时可能触发对齐异常。
- 忘记设置status:SoC dtsi里默认
status = "disabled",板级dts必须显式设置status = "okay"。我刚开始经常忘记,结果设备树里明明有节点,内核就是不枚举。 - pinctrl配置冲突:两个外设用了同一个引脚,编译不报错,但运行时只有一个能工作。这种问题最难查,建议用
pinctrl的调试接口检查引脚复用状态。
#include路径和标签引用是否正确。我一般先用dtc -I dts -O dts做一次“格式化”,看看展开后的文件是不是预期的样子。