第2章 设备树语法入门:节点、属性、标签、包含文件

好,咱们今天聊聊设备树最基础的东西。很多刚入行的朋友觉得设备树就是一堆看不懂的文本,其实没那么玄乎。说白了,它就是描述硬件的一张「地图」——告诉内核,你的板子上有哪些外设、它们怎么连接的、地址是多少。

我个人习惯把设备树比作「硬件的简历」。每个外设就是一份简历,上面写着它的名字、地址、中断号、时钟频率等等。内核拿到这份简历,就知道怎么跟它打交道了。

2.1 节点(Node)—— 硬件的「身份证」

节点是设备树最基本的单元。每个节点代表一个硬件设备,或者一个总线、一个控制器。节点用一对花括号 { } 包裹,里面可以放属性和子节点。

来看个最简单的例子:

/ {
    uart0: serial@10000000 {
        compatible = "ns16550";
        reg = <0x10000000 0x1000>;
        interrupts = <0 33 4>;
    };
};

这里 serial@10000000 就是一个节点。节点名由两部分组成:serial 是设备类型,10000000 是它的基地址。嗯,这里要注意——节点名里的地址只是给人看的,真正起作用的还是 reg 属性里的值。

我的经验:节点名尽量用标准名称,比如 serial、i2c、spi、ethernet。别自己瞎起名字,否则内核的驱动匹配会出问题。我见过有人把串口节点叫成「my_uart」,结果驱动死活加载不上,排查了半天才发现是名字不规范。

2.2 属性(Property)—— 硬件的「详细信息」

属性就是节点的具体参数。常见的属性类型有:

属性类型 格式 示例 说明
字符串 "string" compatible = "ns16550"; 用于驱动匹配
32位整数 <value> reg = <0x10000000 0x1000>; 地址、大小等
64位整数 <0x00000000 0x10000000> reg = <0x00000000 0x10000000 0x0 0x1000>; 大地址空间
布尔值 无值 status = "disabled"; 存在即为真
字节数组 [value ...] local-mac-address = [00 11 22 33 44 55]; MAC地址等

最关键的属性是 compatible。它就像设备的「身份证号」,内核通过它找到对应的驱动。格式一般是「厂商,设备型号」,比如 "ti,am335x-uart""fsl,imx6q-uart"

避坑指南:我曾经在项目中遇到过一个问题——compatible 字符串写错了大小写。内核的驱动匹配是严格区分大小写的,你写 "Ns16550" 和 "ns16550" 完全是两回事。那次排查花了我整整一个下午,最后发现就是首字母大写的问题。所以,写 compatible 的时候一定要跟驱动源码里的一模一样。

2.3 标签(Label)—— 给节点起个「外号」

标签就是节点的别名。你看上面例子里的 uart0:,它就是标签。有了标签,其他地方引用这个节点就方便多了。

为什么要用标签?举个例子:

&uart0 {
    status = "okay";
    clock-frequency = <115200>;
};

这里 &uart0 就是引用了上面那个串口节点。如果没有标签,你就得写完整的路径 &{/serial@10000000},又长又容易写错。

我个人习惯给所有重要的节点都加上标签。比如:

  • uart0uart1 给串口
  • i2c0i2c1 给 I2C 总线
  • mmc0mmc1 给 SD/MMC 控制器
  • eth0eth1 给以太网

标签命名也有讲究。我建议用「设备类型+序号」的格式,这样一看就知道是什么设备。别用 my_device_1 这种名字,三个月后你自己都看不懂。

关键点:标签在编译时会被替换成节点的完整路径。所以 &uart0 最终会变成 /serial@10000000。这个替换是在编译阶段完成的,不影响运行效率。

2.4 包含文件(Include)—— 代码复用的利器

设备树也支持 #include 预处理指令。这个跟 C 语言的 #include 是一个道理——把别的文件的内容包含进来。

最常见的用法是包含 SoC 的通用设备树文件:

#include "imx6q.dtsi"

/ {
    model = "My Custom Board";
    compatible = "my,imx6q-board", "fsl,imx6q";
};

.dtsi 文件是 SoC 厂商提供的通用描述文件,里面定义了芯片内部的所有外设。你只需要在自己的板级文件(.dts)里引用它,然后修改或启用你需要的外设就行了。

这样做的好处很明显:

  • 不用重复写 SoC 的通用配置
  • SoC 升级时只需要更新 .dtsi 文件
  • 不同板子可以共享同一个 .dtsi

除了 .dtsi,还可以包含头文件(.h)。头文件里通常定义了一些宏,比如中断号、时钟索引等:

#include "imx6q-pinfunc.h"
#include "irq.h"

&uart0 {
    pinctrl-0 = <&pinctrl_uart0>;
    interrupts = <GIC_SPI 33 IRQ_TYPE_LEVEL_HIGH>;
};

这里 GIC_SPIIRQ_TYPE_LEVEL_HIGH 就是头文件里定义的宏。用宏的好处是代码可读性更强,而且不容易写错数字。

我的习惯:我会把板子特有的配置放在 .dts 文件里,把 SoC 通用的配置放在 .dtsi 文件里。如果某个外设的配置特别复杂,我还会单独建一个 .dtsi 文件来包含它。比如显示控制器(Display Controller)的配置通常很复杂,我就单独放一个 display.dtsi,然后在主文件里 #include 它。

2.5 实战:一个完整的设备树片段

咱们把上面学的东西串起来,写一个完整的例子:

#include "imx6q.dtsi"
#include "imx6q-pinfunc.h"

/ {
    model = "My i.MX6Q Board";
    compatible = "my,imx6q-board", "fsl,imx6q";

    memory@10000000 {
        device_type = "memory";
        reg = <0x10000000 0x40000000>;
    };

    leds {
        compatible = "gpio-leds";
        led0: led@0 {
            label = "heartbeat";
            gpios = <&gpio1 0 GPIO_ACTIVE_HIGH>;
            linux,default-trigger = "heartbeat";
        };
    };
};

&uart0 {
    status = "okay";
    pinctrl-0 = <&pinctrl_uart0>;
};

&i2c0 {
    status = "okay";
    clock-frequency = <100000>;

    eeprom@50 {
        compatible = "atmel,24c02";
        reg = <0x50>;
    };
};

这个例子包含了:

  • 根节点 /,定义了板子型号和兼容性
  • 内存节点,告诉内核内存的起始地址和大小
  • LED 节点,用 GPIO 控制心跳灯
  • 通过标签引用 uart0i2c0,启用它们并配置引脚
  • 在 I2C 总线下挂载了一个 EEPROM 子节点
注意:子节点的 reg 属性含义跟父节点有关。比如 I2C 设备子节点的 reg 表示设备地址(7位地址),而内存节点的 reg 表示地址范围。这个区别很容易搞混,我刚开始学的时候就犯过这个错——把 I2C 设备的地址写成了 32 位,结果内核报地址越界。

2.6 避坑总结

最后,我把自己这些年踩过的坑总结一下:

  1. 节点名不要用特殊字符——只能用字母、数字、连字符和下划线。我见过有人用空格,编译直接报错。
  2. compatible 字符串要跟驱动完全一致——大小写、下划线都不能错。最好直接从驱动源码里复制。
  3. 标签不要重复——整个设备树里标签必须唯一。如果两个节点用了同一个标签,编译会报错。
  4. 包含文件路径要正确——#include 的搜索路径跟编译器有关。建议用相对路径,或者把 .dtsi 文件放在内核源码的 arch/arm/boot/dts/ 目录下。
  5. 别忘了 status = "okay";——很多 SoC 的 .dtsi 文件里默认把外设 status = "disabled";,你需要在自己板级文件里显式启用它。

嗯,设备树的基础语法就这些。说白了就是四个东西:节点描述硬件、属性描述参数、标签方便引用、包含文件实现复用。掌握了这些,你就能看懂大部分设备树文件了。下一章咱们聊聊更进阶的内容——如何调试设备树,当内核启动时设备树加载失败该怎么办。