设备树基础:DTS 的起源、语法与启动流程

各位同学,咱们今天聊聊设备树。说实话,我刚入行那会儿,Linux 内核里还没有设备树这东西。那时候板级文件满天飞,每个开发板都要在 arch/arm/mach-xxx 下面写一堆 board-xxx.c,里面全是硬编码的寄存器地址、中断号、GPIO 映射。你想想看,换个引脚就要改 C 代码重新编译内核,多折腾。

后来 ARM 社区实在受不了了,从 PowerPC 那边借来了设备树(Device Tree)这套机制。说白了,就是把硬件描述从内核源码里剥离出来,变成一份独立的数据文件。我当年第一次接触 DTS 时,心里还嘀咕:这不就是换了个格式的配置文件吗?用久了才发现,这玩意儿带来的灵活性,真不是盖的。

设备树的起源与作用

设备树的英文全称是 Device Tree,它的核心思想很简单:用一棵树形结构来描述硬件。每个硬件设备都是一个节点,节点里挂属性,属性描述设备的寄存器地址、中断、时钟、DMA 通道等信息。

为什么需要它?我举个例子。以前 Linux 内核要支持一块新板子,得在 arch/arm/mach-xxx 下新增一个 board-xxx.c,里面要注册平台设备、定义资源、绑定驱动。如果芯片厂商换了引脚复用,或者改了内存映射,你就得改 C 代码。更麻烦的是,同一颗 SoC 可能用在几十种板子上,每个板子的外设连接都不一样——那内核源码得膨胀成什么样?

设备树解决了这个问题。它把硬件描述和内核代码彻底解耦。内核只负责解析 DTB(编译后的设备树二进制文件),然后根据 DTB 里的信息去匹配驱动、注册设备。换板子?换一份 DTB 就行,内核不用重新编译。

核心要点:

  • 设备树是硬件描述文件,不是驱动代码
  • 它让同一份内核镜像支持多种硬件平台
  • DTB 由 bootloader 加载并传递给内核

基本语法结构

设备树的源文件后缀是 .dts,编译后的二进制文件是 .dtb。语法上它有点像 JSON 和 C 的混合体,但更简洁。咱们直接看一个例子:

/dts-v1/;

/ {
    model = "MyBoard RevB";
    compatible = "vendor,myboard-revb", "vendor,myboard";

    chosen {
        stdout-path = &uart0;
    };

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

    soc {
        compatible = "simple-bus";
        #address-cells = <1>;
        #size-cells = <1>;
        ranges;

        uart0: serial@10000000 {
            compatible = "vendor,uart-ip-v1";
            reg = <0x10000000 0x1000>;
            interrupts = <0 33 4>;
            clock-frequency = <24000000>;
            status = "okay";
        };
    };
};

这个例子包含了设备树最核心的语法要素。我来拆开讲:

  • /dts-v1/; —— 版本声明,告诉编译器这是 DTS v1 格式。没有这行,编译器会报错。
  • / { ... }; —— 根节点。整棵树的起点,所有设备都挂在这个节点下面。
  • model 和 compatible —— 根节点的两个关键属性。model 描述板子名称,compatible 用于内核匹配板级驱动。
  • chosen —— 特殊节点,不描述硬件,而是传递内核启动参数。比如 stdout-path 指定控制台串口。
  • memory@80000000 —— 内存节点。@ 后面跟的是起始地址。reg 属性描述地址范围,这里表示从 0x80000000 开始,大小 1GB。
  • uart0: serial@10000000 —— 串口节点。uart0: 是标签(label),可以在其他地方引用。compatible 用于驱动匹配,reg 描述寄存器基址和范围,interrupts 描述中断号。

个人习惯:我写 DTS 时,喜欢在 compatible 属性里把厂商名放在前面,比如 "vendor,device"。这样内核匹配驱动时,先精确匹配厂商+设备名,匹配不上再降级到通用设备名。这个习惯帮我避免过好几次驱动加载失败的问题。

节点与属性详解

设备树的节点和属性,说白了就是 键值对 的集合。节点可以有子节点,属性就是键值对。咱们把常用的属性类型列个表:

属性类型 语法示例 说明
字符串 compatible = "vendor,device"; 单个字符串,用于驱动匹配
字符串列表 compatible = "a", "b", "c"; 多个字符串,内核按顺序尝试匹配
32 位整数 reg = <0x10000000 0x1000>; 尖括号包裹,可包含多个值
64 位整数 reg = <0x00000000 0x80000000>; 用两个 32 位整数拼成 64 位
布尔值 status = "okay"; 用字符串表示,"okay" 或 "disabled"
二进制数据 mac-address = [00 11 22 33 44 55]; 方括号包裹十六进制字节
引用 &uart0 通过标签引用其他节点

这里有个容易踩的坑:#address-cells 和 #size-cells。这两个属性决定了 reg 里地址和长度的编码方式。比如 #address-cells = <1> 表示地址用 1 个 32 位整数表示,#size-cells = <1> 表示长度也用 1 个 32 位整数。如果 SoC 支持 64 位地址空间,这两个值就要设成 <2>。

我曾经踩过的坑:有次调试一块新板子,内存节点怎么都识别不对。查了半天,发现是 #address-cells 设成了 <2>,但 reg 里只写了两个值——内核把前两个值当成了地址,后两个值当成了长度,结果内存大小算出来完全不对。记住:reg 里的值是 地址 + 长度 交替排列的,地址和长度分别占 #address-cells 和 #size-cells 个单元。

设备树在 Linux 启动流程中的位置

设备树不是内核自己生成的,它由 bootloader 加载。咱们看看整个流程:

  1. BootROM 启动,加载 bootloader(如 U-Boot)。
  2. U-Boot 从存储介质(eMMC、NAND、网络)读取 DTB 文件到内存。
  3. U-Boot 可能根据硬件版本修改 DTB(比如填充 MAC 地址、调整内存大小)。
  4. U-Boot 启动内核时,把 DTB 的物理地址通过寄存器 r2(ARM 架构)传递给内核。
  5. 内核启动早期,在 setup_arch() 中解析 DTB,建立设备树结构。
  6. 内核 遍历设备树,为每个 compatible 匹配的节点创建平台设备。
  7. 驱动 在 probe 时读取设备树属性,获取硬件资源。

我画了一张流程图,把关键步骤串起来:

设备树在 Linux 启动流程中的位置 BootROM U-Boot 加载 DTB 到内存 U-Boot 修改 DTB(MAC/内存) 内核 setup_arch() 解析 DTB 遍历节点,创建平台设备 驱动 probe,读取属性 关键点 • DTB 由 bootloader 加载 • 内核不生成 DTB,只解析 • 同一内核可换 DTB 换板子

从这张图你能看到,设备树在整个启动流程中扮演的是 硬件描述传递者 的角色。它不参与计算,不执行逻辑,只是把硬件信息从 bootloader 传递给内核。但正是这个简单的角色,让 Linux 的硬件支持变得极其灵活。

我记得有一次,客户要求在一款已经量产的板子上换用更大容量的 NAND Flash。按照以前的做法,我得改内核源码、重新编译、重新测试。但有了设备树,我只需要在 DTS 里改一下 reg 属性里的分区大小,重新编译 DTB,替换掉 boot 分区里的文件就行。整个过程不到十分钟。

调试小技巧:如果你怀疑设备树解析有问题,可以在内核启动参数里加 earlycon 和 dtb_debug。内核会在早期串口输出 DTB 解析过程中的关键信息,比如哪个节点匹配了哪个驱动、哪个属性没找到。这比瞎猜快多了。

好了,设备树的基础概念就讲到这里。你只要记住三件事:设备树是硬件描述文件、语法是节点加属性、它在启动流程中由 bootloader 加载给内核解析。后面的章节我们会深入讲设备树的绑定规则、中断映射、时钟管理,还有电源管理相关的设备树用法。那些才是真正实战中天天要打交道的东西。