设备树语法详解:根节点、别名节点、chosen节点、memory节点、cpus节点、reg属性、interrupts属性、compatible属性

设备树(Device Tree)说白了就是一块描述硬件的数据结构。我刚开始接触Linux内核时,总觉得这玩意儿有点多余——硬件信息不都在代码里写死了吗?后来做了一次板级移植,才发现没有设备树,改一个GPIO都得重新编译整个内核。嗯,从那以后我就老老实实学设备树语法了。

这一章我们聚焦设备树的核心语法。你不需要一次性记住所有细节,但根节点、几个关键子节点和常用属性,必须烂熟于心。

核心观点:设备树是硬件描述与内核代码的解耦层。你改硬件,只改.dts文件,不用动C代码。

设备树核心结构图 / (根节点) aliases { } chosen { } memory { } cpus { } soc { } leds { } 常用属性 • compatible = "vendor,device"; — 驱动匹配 • reg = <0x10000000 0x1000>; — 地址/长度 • interrupts = <0 29 4>; — 中断号/触发 • status = "okay" / "disabled"; — 设备使能 • #address-cells = <1>; — 地址宽度 • #size-cells = <1>; — 长度宽度

根节点(/)

每个设备树文件有且只有一个根节点,用斜杠 / 表示。它就像文件系统的根目录,所有其他节点都是它的孩子或孙子。

/ {
    model = "TI AM335x BeagleBone Black";
    compatible = "ti,am335x-bone-black", "ti,am335x-bone", "ti,am33xx";
};

根节点里一般放什么?model 描述板子型号,compatible 给内核做板级匹配。我在项目里见过有人把根节点写得乱七八糟,外设节点直接挂在根下面——这其实不违规,但可读性很差。我建议根节点只放全局属性,外设都放到 soc 或 amba 总线节点下。

别名节点(aliases)

别名节点是个很实用的东西。它给其他节点起个短名字,方便引用。

aliases {
    serial0 = &uart0;
    serial1 = &uart1;
    mmc0 = &mmc1;
    i2c0 = &i2c0;
};

为什么要用别名?举个例子,U-Boot 启动时需要通过 serial0 找到控制台串口。如果没有别名,你得写一长串路径 /soc/serial@44e09000。有了别名,直接写 serial0 就行。

我的习惯:每个板级.dts里我都会把串口、MMC、I2C、SPI这些常用外设都加上别名。后期调试时少敲很多字。

chosen节点

chosen 节点不描述硬件,它传递的是运行时参数。最典型的用法是指定内核启动参数和控制台。

chosen {
    stdout-path = &serial0;
    bootargs = "console=ttyO0,115200 root=/dev/mmcblk0p2 rw";
};

stdout-path 告诉内核把日志输出到哪个串口。bootargs 就是内核启动参数。我记得有一次调试,板子死活不打印启动信息,查了半天发现 stdout-path 写成了 serial0 而不是 &serial0——少了个 & 符号,内核根本找不到设备。

注意:chosen节点里的内容由bootloader填充。U-Boot会覆盖.dts里写的bootargs,所以你在.dts里写bootargs可能不生效。调试时记得先检查U-Boot的环境变量。

memory节点

内存节点告诉内核物理内存的起始地址和大小。格式很简单:

memory@80000000 {
    device_type = "memory";
    reg = <0x80000000 0x20000000>;  // 起始0x80000000,大小512MB
};

这里 reg 的第一个值是基地址,第二个值是长度。为什么是 0x20000000?512MB 嘛。我见过有人把长度写成 0x80000000(2GB),但板子实际只有512MB——内核启动后直接访问不存在的地址,死机。

另外,device_type = "memory" 是历史遗留,新设备树里其实可以省略。但为了兼容性,我建议还是写上。

cpus节点

CPU 节点描述处理器核心。单核、双核、大小核架构,都在这里定义。

cpus {
    #address-cells = <1>;
    #size-cells = <0>;

    cpu@0 {
        compatible = "arm,cortex-a9";
        device_type = "cpu";
        reg = <0>;
        clock-frequency = <800000000>;
    };

    cpu@1 {
        compatible = "arm,cortex-a9";
        device_type = "cpu";
        reg = <1>;
        clock-frequency = <800000000>;
    };
};

每个 CPU 核心是一个子节点,reg 的值就是核心编号。#address-cells = <1> 表示地址用 1 个 32 位整数表示,#size-cells = <0> 表示没有长度字段——因为 CPU 编号不需要长度。

为什么 #size-cells 是 0?你想想看,CPU 编号就是一个数字,不像内存地址那样有范围。所以长度字段是多余的。

reg属性——地址描述的核心

reg 属性是设备树里最常用的属性之一。它描述设备的地址空间:基地址和范围。

uart0: serial@44e09000 {
    compatible = "ti,omap3-uart";
    reg = <0x44e09000 0x2000>;
    interrupts = <72>;
};

这里 reg = <0x44e09000 0x2000> 表示 UART 寄存器基地址是 0x44e09000,占用 0x2000(8KB)空间。内核驱动通过这个信息做 ioremap。

reg 的格式由父节点的 #address-cells 和 #size-cells 决定。比如:

#address-cells #size-cells reg 含义
1 1 基地址(32位) + 长度(32位)
2 1 基地址(64位) + 长度(32位)
1 0 只有基地址,没有长度

我曾经在一个 64 位 ARM 板子上踩过坑:DDR 地址在 0x800000000(超过 32 位),但 #address-cells 写成了 1,结果高 32 位被截断,内核只认到 0x80000000。嗯,这种 bug 很难查。

interrupts属性

中断属性描述设备连接到哪个中断控制器、使用哪个中断号、触发方式是什么。

gpio0: gpio@44e07000 {
    compatible = "ti,omap4-gpio";
    reg = <0x44e07000 0x1000>;
    interrupts = <96>;
    #gpio-cells = <2>;
};

如果系统有多个中断控制器,需要用 interrupt-parent 指定父控制器:

device {
    interrupt-parent = &gic;
    interrupts = <0 29 4>;
};

这里 <0 29 4> 的含义取决于中断控制器的 #interrupt-cells。对于 ARM GIC:

  • 第一个值:中断类型(0=SPI,1=PPI)
  • 第二个值:中断号
  • 第三个值:触发方式(1=上升沿,4=高电平)

避坑指南:我曾经把 SPI 中断的触发方式写成了 0,结果中断只触发一次就不再响应。查了两天,才发现 GIC 的触发方式不能为 0。电平触发用 4,边沿触发用 1,记住这两个就够了。

compatible属性——驱动匹配的关键

compatible 是设备树里最重要的属性。它把硬件设备和内核驱动联系起来。

compatible = "ti,omap3-uart", "ns16550";

内核驱动通过 of_match_table 匹配这个字符串。匹配顺序是从左到右:先试 ti,omap3-uart,如果没找到驱动,再试 ns16550。

命名规范是 厂商,设备型号。为什么要有厂商前缀?避免命名冲突。比如两家公司都叫 uart,加上厂商名就区分开了。

我建议 compatible 至少写两个值:第一个是精确的芯片型号,第二个是兼容的通用型号。这样即使内核没有你的专用驱动,也能用通用驱动跑起来。

总结一下:设备树语法不复杂,但细节很多。根节点是骨架,aliases 是快捷方式,chosen 传参数,memory 和 cpus 描述核心硬件,reg、interrupts、compatible 是驱动匹配的三板斧。把这些搞明白,大部分设备树你都能看懂、能改。