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里。这样复用性高,维护起来也清爽。

设备树知识体系结构图 设备树知识体系结构 设备树 (Device Tree) 语法与结构 节点与属性 绑定文档 节点命名 / 标签 / 路径 include / 宏 / 注释 reg / interrupts / clocks status / compatible / gpios Documentation/devicetree required / optional 属性 编译 (dtc) 与反编译 (dtc -I dtb -O 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" 使能I2C1
  • clock-frequency 设置I2C时钟频率为100KHz
  • lm75@48 是子节点,地址0x48是LM75的I2C地址
  • compatible 匹配LM75驱动
  • reg = <0x48> 告诉驱动设备地址

编译后,内核启动时就会自动探测并初始化LM75。驱动通过设备树拿到的地址和配置,直接操作硬件。

核心要点回顾:

  1. 设备树是硬件描述文件,驱动通过它获取硬件信息
  2. 节点用 节点名@地址 定义,属性描述设备细节
  3. compatible 是驱动匹配的关键,顺序很重要
  4. 绑定文档是写设备树的“圣经”,写之前一定要看
  5. 用 dtc 编译和反编译设备树,调试时很有用

设备树其实不难,关键是理解它的设计思想:把硬件描述和驱动代码解耦。你想想看,如果没有设备树,每换一个板子就要改内核源码,那得多痛苦?