设备树语法精讲:节点与属性、reg与ranges、中断控制器、时钟与GPIO绑定

设备树(Device Tree)这东西,说白了就是一块描述硬件的数据结构。我刚开始接触U-Boot移植时,总觉得它就是个配置文件,后来踩了几个坑才明白——它其实是操作系统和硬件之间的翻译官。你想想看,没有它,内核怎么知道你的CPU连了哪些外设?地址怎么映射?中断怎么路由?

这一章,咱们就把设备树的核心语法掰开揉碎了讲。我会结合自己实际调试过的案例,把节点、属性、reg、ranges、中断、时钟、GPIO这些关键点一一说清楚。

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

设备树里最基本的单位就是节点(node)。每个节点代表一个硬件设备或总线。节点里可以嵌套子节点,形成树形结构。每个节点又有一堆属性(property),用来描述设备的特性。

来看个最简单的例子:

/dts-v1/;

/ {
    compatible = "myboard,imx6ull";
    #address-cells = <1>;
    #size-cells = <1>;

    memory@80000000 {
        device_type = "memory";
        reg = <0x80000000 0x20000000>;
    };

    uart1: serial@02020000 {
        compatible = "fsl,imx6ull-uart", "fsl,imx-uart";
        reg = <0x02020000 0x4000>;
        interrupts = <0 26 4>;
        clocks = <&clks IMX6ULL_CLK_UART1>;
        status = "okay";
    };
};

这里有几个关键点:

  • 根节点:用 / 表示,是整个设备树的起点
  • 节点名称:格式是 名称@地址,比如 memory@80000000
  • compatible:最重要的属性之一,告诉内核这个设备兼容哪个驱动。我习惯把具体型号写在前面,通用型号写在后面,这样驱动匹配时优先级更明确
  • status:控制设备是否启用,"okay" 表示启用,"disabled" 表示禁用
我的习惯:调试新板子时,我会先把所有外设节点的 status 设为 "disabled",然后逐个打开测试。这样能快速定位是哪个设备导致系统挂死。

2.2 reg 与 ranges:地址映射的核心

说到地址映射,reg 和 ranges 是绕不开的两个属性。我见过不少新手在这上面栽跟头,包括我自己。

2.2.1 reg 属性

reg 描述设备的地址空间。它的格式由父节点的 #address-cells 和 #size-cells 决定:

  • #address-cells:地址占几个32位单元
  • #size-cells:长度占几个32位单元

举个例子:

spi@02018000 {
    #address-cells = <1>;
    #size-cells = <0>;
    reg = <0x02018000 0x4000>;
    
    flash@0 {
        reg = <0>;  // 片选0
    };
};

这里 reg = <0x02018000 0x4000> 表示:基地址 0x02018000,长度 0x4000 字节。子节点的 reg = <0> 表示片选0,因为 #size-cells = <0>,所以不需要长度。

我曾经踩过的坑:有一次移植网卡驱动,死活读不到寄存器。查了两天才发现,父节点的 #address-cells 是 2,但我只给了 1 个地址值。内核解析 reg 时直接读错了位置。记住:reg 的单元数必须严格等于 #address-cells + #size-cells。

2.2.2 ranges 属性

ranges 用于地址转换,把子节点的地址映射到父节点的地址空间。它的格式是:

ranges = <子地址 父地址 长度>;

看个实际例子:

soc {
    #address-cells = <1>;
    #size-cells = <1>;
    ranges = <0x0 0x20000000 0x10000000>;
    
    aips1: aips-bus@02000000 {
        compatible = "fsl,aips-bus", "simple-bus";
        #address-cells = <1>;
        #size-cells = <1>;
        reg = <0x02000000 0x100000>;
        ranges;
        
        uart1: serial@02020000 {
            reg = <0x02020000 0x4000>;
        };
    };
};

这里 ranges 是空的,表示子地址和父地址一一对应。而 soc 节点的 ranges = <0x0 0x20000000 0x10000000> 表示:子地址 0x0 映射到父地址 0x20000000,长度 0x10000000。

关键理解:ranges 不是必须的。如果节点地址和父节点地址一致,可以写 ranges;(空值)或者干脆不写。但如果不写,子节点的地址就无法被父节点解析,驱动里用 of_iomap 会失败。

2.3 中断控制器绑定

中断是嵌入式系统里最头疼的部分之一。设备树里中断的描述涉及三个关键属性:

属性 作用 说明
interrupt-parent 指定中断控制器 指向中断控制器的 phandle
interrupts 描述中断号 格式由中断控制器决定
interrupt-controller 声明自己是中断控制器 空属性,有就行
#interrupt-cells 中断描述符的单元数 通常为 1、2 或 3

来看一个典型的中断控制器和中断消费者的例子:

intc: interrupt-controller@00a01000 {
    compatible = "arm,cortex-a7-gic";
    #interrupt-cells = <3>;
    interrupt-controller;
    reg = <0x00a01000 0x1000>,
          <0x00a02000 0x100>;
};

uart1: serial@02020000 {
    compatible = "fsl,imx6ull-uart", "fsl,imx-uart";
    reg = <0x02020000 0x4000>;
    interrupt-parent = <&intc>;
    interrupts = <0 26 4>;
};

这里 interrupts = <0 26 4> 的三个值分别表示:

  • 0:中断类型,0 表示 SPI(共享外设中断),1 表示 PPI(私有外设中断)
  • 26:中断号,对于 SPI 来说就是 32 + 26 = 58
  • 4:触发方式,4 表示高电平触发
我个人的调试技巧:遇到中断不触发时,先用 cat /proc/interrupts 看看中断有没有注册上。如果注册了但不触发,八成是中断号或者触发方式写错了。我会对照芯片手册,把 interrupts 的三个值一个一个核对。

2.4 时钟绑定

时钟系统在设备树里也是个容易出幺蛾子的地方。时钟提供者(clock provider)和时钟消费者(clock consumer)通过 clocks 和 clock-names 关联。

先看时钟提供者:

clks: clock-controller@020c4000 {
    compatible = "fsl,imx6ull-ccm";
    reg = <0x020c4000 0x4000>;
    #clock-cells = <1>;
    clocks = <&osc 0>, <&rtc 0>;
    clock-names = "osc", "rtc";
};

再看时钟消费者:

uart1: serial@02020000 {
    compatible = "fsl,imx6ull-uart", "fsl,imx-uart";
    reg = <0x02020000 0x4000>;
    interrupts = <0 26 4>;
    clocks = <&clks IMX6ULL_CLK_UART1>;
    clock-names = "uart";
};

这里 #clock-cells = <1> 表示时钟提供者需要 1 个参数来指定具体的时钟输出。IMX6ULL_CLK_UART1 就是在头文件里定义的宏,对应 UART1 的时钟索引。

我曾经遇到的坑:有一次移植 SD 卡驱动,时钟频率总是不对。查了半天发现是 clock-names 写错了,驱动里用 clk_get(dev, "sdhci") 去拿时钟,但设备树里写的是 "sdhc"。名字对不上,拿到的时钟是空的,驱动就用默认频率跑,结果死活初始化不了。记住:clock-names 必须和驱动里要求的名字完全一致。

2.5 GPIO 绑定

GPIO 在设备树里的描述方式,跟时钟和中断类似,也是提供者-消费者模式。GPIO 控制器用 gpio-controller 声明自己,用 #gpio-cells 指定描述一个 GPIO 需要几个参数。

来看 GPIO 控制器的定义:

gpio1: gpio@0209c000 {
    compatible = "fsl,imx6ull-gpio", "fsl,imx35-gpio";
    reg = <0x0209c000 0x4000>;
    interrupts = <0 66 4>;
    gpio-controller;
    #gpio-cells = <2>;
    interrupt-controller;
    #interrupt-cells = <2>;
};

GPIO 消费者的写法:

led {
    compatible = "gpio-leds";
    pinctrl-names = "default";
    pinctrl-0 = <&pinctrl_led>;
    
    led0 {
        label = "heartbeat";
        gpios = <&gpio1 3 GPIO_ACTIVE_LOW>;
        linux,default-trigger = "heartbeat";
    };
};

这里 gpios = <&gpio1 3 GPIO_ACTIVE_LOW> 的三个参数:

  • &gpio1:指向 GPIO 控制器的 phandle
  • 3:GPIO 引脚号,这里是 GPIO1_IO03
  • GPIO_ACTIVE_LOW:低电平有效,这个宏定义在 dt-bindings/gpio/gpio.h 里
关键点:GPIO 的引脚号是控制器内部的偏移,不是芯片的物理引脚号。比如 i.MX6ULL 的 GPIO1 有 32 个引脚,编号就是 0-31。千万别把芯片手册上的引脚号直接写进去,那是物理引脚,不是 GPIO 控制器的偏移。

2.6 知识结构总览

说了这么多,咱们用一张图把设备树的核心语法串起来:

设备树核心语法结构图 设备树核心语法结构 根节点 / 节点与属性 地址映射 外设绑定 compatible / status / 节点名称 reg / ranges / #address-cells interrupts / clocks / gpios 节点与属性 • 节点:硬件设备的抽象 • 属性:描述设备特性 • 嵌套结构形成树形 地址映射 • reg:设备地址空间 • ranges:父子地址转换 • cells:地址/长度单元数 外设绑定 • 中断:interrupt-parent/cells • 时钟:clocks / clock-names • GPIO:gpios / gpio-controller 设备树 = 节点结构 + 地址描述 + 外设绑定

2.7 实战经验总结

说了这么多语法,最后分享几个我在实际项目中总结的经验:

  1. 先看芯片手册,再写设备树:我见过太多人直接抄其他板子的设备树,结果地址不对、中断号不对,调试起来更费时间。芯片手册里的内存映射表、中断号表才是第一手资料。
  2. 善用 dtc 工具:写完设备树后,用 dtc -I dts -O dtb -o test.dtb test.dts 编译,再用 dtc -I dtb -O dts test.dtb 反编译,看看有没有语法错误。这个习惯帮我省了不少时间。
  3. 从简单开始:新板子第一次跑,我通常只保留 UART 和内存节点,其他全部 disabled。等串口能打印了,再逐个打开外设。这样出了问题,范围很小,好定位。
  4. 注意 phandle 的引用:&intc、&clks 这种引用,一定要确保被引用的节点存在,而且 phandle 标签没有冲突。我曾经在两个头文件里定义了相同的标签,编译不报错,但运行时引用指向了错误的节点。
我的调试小工具:在 U-Boot 里,可以用 fdt list 查看设备树,用 fdt get value 读取某个属性的值。我经常在 U-Boot 命令行里先验证设备树是否正确,再启动内核。这样能避免内核启动后才发现设备树有问题。

嗯,设备树的语法其实不难,难的是把每个属性跟芯片手册里的硬件特性对应起来。多写几次,多踩几个坑,自然就熟练了。