4. 设备树基础语法:dts/dtsi/dtb 文件格式、节点与属性

设备树,说白了就是一块「硬件描述板」。它告诉内核:你有哪些外设、它们挂在哪根总线上、地址是多少、中断怎么接。没有它,Linux 内核就是个瞎子,连串口都不知道去哪找。

我记得刚接触设备树那会儿,看着一堆 .dts 文件,心里直犯嘀咕:这不就是个 XML 的变种吗?后来调 NPU 驱动时被设备树坑过几次,才老老实实把语法啃了一遍。今天咱们就把这块硬骨头啃下来。

4.1 三种文件格式:dts、dtsi、dtb

先搞清楚这三个东西是干嘛的:

后缀 全称 作用 谁写的
.dts Device Tree Source 设备树源文件,描述单板硬件 板级工程师
.dtsi Device Tree Source Include 公共头文件,被 .dts 包含 芯片厂商
.dtb Device Tree Blob 编译后的二进制,内核直接解析 编译工具生成

.dtsi 就像 C 语言的 .h 头文件。芯片厂商会把 SoC 的公共部分——比如 CPU 核、GIC 中断控制器、UART 外设——都放在 .dtsi 里。板级工程师只需要写 .dts,然后 #include 进来,再添加板子特有的东西,比如 GPIO 按键、LED、外接的摄像头传感器。

我的习惯:拿到一块新开发板,第一件事就是看它的 .dts 和 .dtsi 文件。芯片厂商的 .dtsi 一般不会错,但板级 .dts 经常有引脚复用冲突。我曾经在 RK3588 的板子上排查了三天 NPU 不工作,最后发现是 .dts 里把 NPU 的中断引脚配成了普通 GPIO。

4.2 节点与属性:设备树的基本单元

设备树的语法其实很简单。一个节点代表一个设备,属性描述这个设备的特征。来看个例子:

/dts-v1/;

#include "rk3588.dtsi"

/ {
    model = "Edge AI Board v1.0";
    compatible = "mycompany,edge-ai-board", "rockchip,rk3588";

    chosen {
        stdout-path = &uart2;
    };

    npu: npu@fdab0000 {
        compatible = "rockchip,rk3588-npu";
        reg = <0x0 0xfdab0000 0x0 0x10000>;
        interrupts = <GIC_SPI 82 IRQ_TYPE_LEVEL_HIGH>;
        clocks = <&cru CLK_NPU>;
        clock-names = "npu";
        status = "okay";
    };
};

这里有几个关键点:

  • 根节点:/ 是设备树的根,所有设备都挂在这个根下面。
  • 节点名:npu@fdab0000,@ 后面是寄存器基地址,用来区分同名节点。
  • 标签:npu: 是标签,方便其他地方引用。比如 &npu 就代表这个节点。
  • 属性:compatible、reg、interrupts 这些都是属性,每个属性都有固定的格式。
注意:节点名里的 @ 地址不是必须的,但如果同一个父节点下有多个同类型设备,必须用地址区分。否则编译会报重复节点错误。

4.3 标准属性详解

设备树里有一堆标准属性,内核的驱动框架就靠它们来匹配设备和驱动。我挑三个最常用的讲:

4.3.1 compatible:驱动匹配的钥匙

compatible 是设备树里最重要的属性,没有之一。它的格式是 "厂商,设备型号",内核通过它找到对应的驱动。

compatible = "mycompany,edge-ai-board", "rockchip,rk3588";

内核匹配时,会从左到右依次尝试。第一个匹配不上,就用第二个。这给兼容性留了空间——你可以先写自己的型号,再写通用的型号。

我在项目中遇到过一个问题:NPU 驱动死活不 probe,打印说 of_match_table 没匹配上。查了半天,发现 .dts 里写的是 "rockchip,rk3588-npu",但驱动里写的是 "rockchip,rk3588-npu-v2"。就差一个 -v2,驱动就不认了。

4.3.2 reg:告诉驱动去哪找寄存器

reg 描述设备的地址空间。格式是 <地址 长度>,可以有多个地址段。

reg = <0x0 0xfdab0000 0x0 0x10000>;

这个例子表示:NPU 的寄存器基地址是 0xfdab0000,地址范围是 0x10000(64KB)。为什么是两对?因为这是 64 位地址,高 32 位是 0x0,低 32 位是 0xfdab0000。

避坑指南:我曾经在调试 NPU 时,发现驱动读出来的寄存器值全是 0。用 devmem2 手动读,地址是对的。最后发现 reg 里的长度写小了,驱动只映射了前 4KB,而 NPU 的关键寄存器在偏移 0x5000 的位置。驱动访问越界,读回来的全是 0。

4.3.3 interrupts:中断怎么接

interrupts 描述设备的中断信息。格式取决于中断控制器,ARM 平台一般用 GIC(Generic Interrupt Controller)。

interrupts = <GIC_SPI 82 IRQ_TYPE_LEVEL_HIGH>;

三个字段的含义:

  • 中断类型:GIC_SPI 是共享外设中断,GIC_PPI 是私有外设中断。
  • 中断号:82,这是硬件中断号,不是 Linux 的 IRQ 号。
  • 触发方式:IRQ_TYPE_LEVEL_HIGH 是高电平触发,还有上升沿、下降沿等。

你想想看,如果中断号写错了,驱动注册的中断永远不会触发。或者触发方式写错了,中断可能被触发两次。这些坑我都踩过。

4.4 phandle 与标签:设备树里的指针

设备树里经常需要引用其他节点,比如时钟控制器、中断控制器、GPIO 控制器。这时候就需要 phandle(指针句柄)和标签。

标签就是节点名前面的那个标识符:

npu: npu@fdab0000 {
    ...
};

这里的 npu: 就是标签。其他地方可以用 &npu 来引用这个节点。比如:

clocks = <&cru CLK_NPU>;

这表示 NPU 的时钟来自 cru 节点(Clock and Reset Unit)的 CLK_NPU 时钟输出。

编译时,设备树编译器会把 &npu 转换成 phandle——一个 32 位的整数指针。内核解析时,通过这个 phandle 找到对应的节点。

小技巧:用 dtc -I dtb -O dts 反编译 .dtb 文件,你会看到 phandle 被展开成数字。比如 phandle = <0x0000007d>。这有助于调试时确认引用关系是否正确。

4.5 知识结构图

下面这张图把设备树的核心概念串起来了:

设备树核心概念结构图 设备树核心概念结构图 根节点 / chosen 节点 npu@fdab0000 uart2 节点 标准属性 compatible 驱动匹配 reg 地址空间 interrupts 中断配置 status 启用/禁用 引用机制:标签 + phandle 标签:npu: 定义节点别名 引用:&npu 编译成 phandle 数字 dts → dtc 编译器 → dtb → 内核解析 → 驱动匹配

4.6 编译与反编译

设备树源文件需要编译成 .dtb 才能被内核使用。工具是 dtc(Device Tree Compiler)。

# 编译:dts → dtb
dtc -I dts -O dtb -o myboard.dtb myboard.dts

# 反编译:dtb → dts
dtc -I dtb -O dts -o myboard.dts myboard.dtb

反编译是调试利器。内核启动时如果报设备树错误,你可以把 .dtb 反编译成 .dts,看看实际解析出来的内容对不对。我曾经用这个办法发现了一个 reg 属性被编译后地址偏移了 4 字节的问题——因为 .dts 里少写了一个 0x0 占位。

核心要点回顾:
  • .dts 是源文件,.dtsi 是公共头文件,.dtb 是二进制产物。
  • 节点代表设备,属性描述设备特征。
  • compatible 匹配驱动,reg 定义地址,interrupts 配置中断。
  • 标签 npu: 和引用 &npu 实现节点间关联。
  • dtc 工具负责编译和反编译。