设备树语法入门:节点、属性、标签、别名、包含文件

设备树(Device Tree)说白了就是一块板子的「硬件简历」。CPU 是什么?内存多大?外设挂在哪?全写在一个文本文件里。内核启动时读它,就知道该怎么初始化硬件了。

我刚开始接触设备树时,觉得它跟 XML 差不多——都是树形结构。但用久了你会发现,它比 XML 简洁得多。今天咱们就把最核心的五个语法要素讲透:节点、属性、标签、别名、包含文件。

一句话总结:设备树 = 一棵树,节点是树枝,属性是树叶,标签和别名是给树枝贴的标签,包含文件是复用其他树的枝叶。

2.1 节点(Node)—— 树的骨架

节点是设备树的基本单元。每个节点代表一个硬件设备或总线。写法很简单:

/dts-v1/;

/ {
    cpu {
        compatible = "arm,cortex-a72";
        reg = <0x00>;
    };

    memory {
        device_type = "memory";
        reg = <0x80000000 0x40000000>;
    };
};

看到没?/ 是根节点,里面挂了一个 cpu 和一个 memory。节点可以嵌套,比如:

/ {
    soc {
        i2c {
            eeprom {
                compatible = "atmel,24c02";
            };
        };
    };
};

我在项目中遇到过有人把节点名写成 I2C(大写),结果内核死活认不出来。记住:节点名统一小写,用下划线或连字符分隔单词。

命名规范:节点名格式为 name@address,比如 i2c@3f804000。address 部分写该设备在总线上的地址或寄存器基址。

2.2 属性(Property)—— 节点的血肉

属性描述节点的具体特征。常用的属性类型有四种:

属性类型 写法示例 说明
字符串 compatible = "ti,am335x-uart"; 驱动匹配的关键,格式为 "厂商,设备名"
32位整数 reg = <0x48020000 0x1000>; 地址和长度,尖括号包裹
64位整数 reg = <0x00000000 0x80000000>; 高32位在前,低32位在后
布尔值 status = "disabled"; 存在即 true,不存在即 false

举个例子,一个完整的 UART 节点长这样:

uart0: serial@48020000 {
    compatible = "ti,am335x-uart", "ti,omap3-uart";
    reg = <0x48020000 0x2000>;
    interrupts = <72>;
    status = "disabled";
};

这里 compatible 写了两个字符串,内核会按顺序匹配驱动。第一个匹配不上就试第二个。我曾经因为写反了顺序,导致驱动加载了错误的配置,折腾了大半天。

注意:reg 属性的地址和长度单位是 #address-cells 和 #size-cells 决定的。默认都是 1,即 32 位。如果父节点指定了 2,那就是 64 位。

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

标签就是节点的别名,方便在其他地方引用。写法是在节点名前加 标签名::

uart0: serial@48020000 {
    // ...
};

i2c0: i2c@44e0b000 {
    // ...
};

有了标签,你就可以在别处用 &uart0 来引用它。比如在板级文件中覆盖配置:

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

我个人习惯给所有重要的外设都加上标签。这样后期做多板卡适配时,改起来特别方便。你想想看,如果没有标签,你要修改一个深层嵌套的节点,得把整条路径写一遍,多累啊。

2.4 别名(Alias)—— 系统级的快捷方式

别名和标签有点像,但作用域不同。别名定义在根节点的 aliases 子节点里:

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

别名主要用于 Bootloader(如 U-Boot)和内核早期启动阶段。比如 U-Boot 通过 serial0 就能找到控制台串口,不用知道它具体叫 uart0 还是 serial@48020000。

对比项 标签(Label) 别名(Alias)
定义位置 节点名前 根节点 aliases 下
引用方式 &标签名 serial0 字符串
主要用途 DTS 文件内引用 Bootloader / 内核早期使用
作用域 整个 DTS 编译单元 全局(通过 /aliases 节点)

我的建议:标签和别名都写上。标签方便 DTS 内部引用,别名方便 Bootloader 和用户空间工具查找设备。两者不冲突,反而互补。

2.5 包含文件(Include)—— 别重复造轮子

设备树支持 #include 预处理指令,跟 C 语言一样。通常用来引入 SoC 公用的 .dtsi 文件:

#include "am335x.dtsi"
#include "am335x-bone-common.dtsi"

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

.dtsi 文件里定义了 SoC 的所有外设节点,板级 .dts 文件只需要包含它,然后覆盖或启用需要的部分。这样一套 SoC 可以衍生出几十种板卡,每个板卡只需写几十行差异配置。

我曾经维护过一个项目,有 8 块不同板卡。一开始我把所有配置写在一个文件里,用 #ifdef 区分。结果文件膨胀到 3000 多行,改一处要查半天。后来改成 .dtsi + 板级 .dts 的结构,每个文件不到 200 行,清爽多了。

最佳实践:

  • SoC 级通用配置放 .dtsi
  • 板级差异配置放 .dts
  • 多板卡共享的部分再抽一个 common.dtsi
  • 避免在 .dts 中重复定义已在 .dtsi 中定义的节点

2.6 知识体系总览

下面这张图把今天讲的五个要素串起来了。你可以看到它们如何协同工作:

设备树语法核心要素 根节点 / cpu 节点 memory 节点 uart0 节点 compatible reg interrupts status 标签: uart0: serial@... 别名: serial0 = &uart0 包含: #include "xxx.dtsi" 节点是骨架,属性是血肉,标签和别名是快捷方式,包含文件是复用利器

2.7 实战小练习

光看不练假把式。给你一个简单的需求:为一块虚拟板卡编写设备树,包含一个 CPU、256MB 内存、一个 UART(地址 0x10000000,中断号 33)。

试试看,能不能写出下面这个效果:

/dts-v1/;

#include "virtual-soc.dtsi"

/ {
    model = "Virtual Board v1.0";
    compatible = "virtual,board-v1";

    aliases {
        serial0 = &uart0;
    };
};

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

嗯,这里要注意:virtual-soc.dtsi 里已经定义了 uart0 节点,板级文件只需要用 &uart0 引用并启用它。别重复定义整个节点,否则编译器会报错。

常见错误:新手最容易犯的错是在板级 .dts 里重新写一遍 uart0: serial@10000000 { ... };,而不是用 &uart0 { ... }; 来覆盖。前者会定义一个新节点,后者才是引用已有节点。