1. 设备树基础概念:什么是设备树、为什么需要设备树、设备树文件结构

各位好,我是老陈。做嵌入式 Linux 开发十几年了,今天咱们聊聊设备树。说实话,我刚入行那会儿还没有设备树这东西,板级文件满天飞,每次换一个外设都得改内核源码,编译、烧录、重启……那叫一个痛苦。后来设备树出来了,我一开始还觉得「又多了一个要学的东西」,但真正用上之后,只能说——真香。

1.1 什么是设备树

设备树,英文叫 Device Tree,是一套用来描述硬件资源的数据结构。说白了,它就是一个硬件清单,告诉内核:你的 CPU 是什么、内存多大、I2C 控制器挂在哪、GPIO 怎么用、中断路由怎么走……

我习惯把它理解成「硬件的身份证」。内核不再需要硬编码这些信息,而是启动时从设备树里读。你想想看,这多灵活?同一份内核镜像,换一个设备树文件,就能跑在不同的板子上。

核心定义:设备树是一种树形结构的、键值对形式的硬件描述语言,用于将硬件配置从内核源码中剥离出来,交给 bootloader 在启动时传递给内核。

1.2 为什么需要设备树

在设备树出现之前,Linux 内核是怎么处理硬件差异的?答案是——板级文件(Board File)。

每个板子都要在 arch/arm/mach-xxx 下写一个 board-xxx.c,里面用 C 代码注册各种平台设备、资源、中断。我当年维护过一个项目,同时支持 5 块板子,每次内核版本升级,光合并板级文件就要花掉我整整两天。而且,这些代码都是编译进内核的,换板子就得重新编译。

设备树解决了三个核心痛点:

  • 硬件描述与内核代码解耦 —— 改硬件配置不用改内核源码,改 dts 文件就行
  • 多平台支持 —— 同一份内核镜像,配不同的 dtb 文件就能跑在不同板子上
  • 社区标准化 —— ARM 生态碎片化严重,设备树统一了硬件描述方式

我记得有一次,客户临时换了一颗不同型号的 Flash 芯片。搁以前,我得改内核源码、重新编译、发补丁。有了设备树,我只改了 dts 文件里的 reg 属性,重新编译 dtb,烧进去就完事了。前后不到 10 分钟。

我的经验:设备树不是万能的。它只描述硬件「有什么」,不描述硬件「怎么用」。驱动的逻辑代码还是得写,设备树只是把配置参数从代码里抽出来了。

1.3 设备树文件结构:dts / dtsi / dtb

设备树涉及三种文件类型,很多新手一开始容易搞混。我画了一张图,帮你理清它们的关系:

设备树文件结构关系图 设备树文件结构关系图 .dtsi 文件 SoC 级通用描述 .dts 文件 板级硬件描述 .dtb 文件 二进制设备树 #include 编译 说明: .dtsi 被 .dts 通过 #include 包含,.dts 通过 dtc 编译器生成 .dtb,最终由 bootloader 加载并传递给内核。 .dtsi • 后缀 i 表示 include • 描述 SoC 内部资源 • 可被多个 .dts 复用 • 类似 C 语言的头文件 .dts • 板级硬件描述文件 • 包含具体外设配置 • 可 #include .dtsi • 人类可读的文本格式 .dtb • 二进制设备树 • 由 dtc 编译生成 • bootloader 直接加载 • 内核解析的唯一格式

1.4 三种文件的详细对比

文件类型 全称 格式 作用 谁编写
.dtsi Device Tree Source Include 文本(人类可读) 描述 SoC 内部通用资源(CPU、中断控制器、时钟等) SoC 厂商提供
.dts Device Tree Source 文本(人类可读) 描述具体板级硬件(外设型号、引脚复用、内存布局等) 板卡开发者
.dtb Device Tree Blob 二进制(机器可读) 内核启动时解析的最终格式 由 dtc 编译器生成

1.5 一个简单的 dts 示例

光说不练假把式。咱们看一个最简单的设备树文件长什么样:

/dts-v1/;

#include "imx6ull.dtsi"

/ {
    model = "MyCustomBoard";
    compatible = "mycompany,myboard", "fsl,imx6ull";

    memory {
        device_type = "memory";
        reg = <0x80000000 0x20000000>;  /* 512MB DDR */
    };

    &uart1 {
        status = "okay";
        pinctrl-0 = <&pinctrl_uart1>;
    };

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

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

这段代码我拆开讲一下:

  • /dts-v1/; —— 版本声明,告诉编译器这是 v1 格式
  • #include "imx6ull.dtsi" —— 引入 SoC 级描述,类似 C 语言的 #include
  • / { ... }; —— 根节点,所有设备都挂在这个树下
  • model 和 compatible —— 板子标识,内核靠它匹配驱动
  • memory 节点 —— 描述内存起始地址和大小
  • &uart1 —— 引用 .dtsi 中定义的节点,覆盖或追加属性
  • eeprom@50 —— 挂在 I2C 总线上的子设备,地址 0x50

注意:我曾经遇到过一个坑——compatible 属性的字符串顺序很重要。内核匹配驱动时,会按顺序尝试,第一个匹配到的驱动会被加载。如果你把通用的写在前面,专用的写在后面,可能会加载错驱动。建议把最具体的型号写在最前面。

1.6 编译与反编译

设备树的编译工具叫 dtc(Device Tree Compiler)。我常用的几个命令:

# 编译 dts 为 dtb
dtc -I dts -O dtb -o myboard.dtb myboard.dts

# 反编译 dtb 为 dts(调试时很有用)
dtc -I dtb -O dts -o myboard.dts myboard.dtb

# 查看 dtb 中的内容(不生成文件)
dtc -I dtb -O dts /boot/myboard.dtb | less

反编译这个功能,我调试时经常用。有时候拿到一个别人给的 dtb,不知道里面配了什么,反编译一看就全明白了。

小技巧:编译时加 -@ 选项可以生成符号节点,方便后续动态加载设备树插件(DT Overlay)。这个在 FPGA 和热插拔场景下特别有用。

1.7 设备树的启动流程

最后,咱们捋一下设备树从文件到内核的完整路径:

  1. 编写阶段:开发者编写 .dts 文件,通过 #include 引用 .dtsi
  2. 编译阶段:dtc 编译器将 .dts 编译为 .dtb 二进制文件
  3. 存储阶段:.dtb 文件存放在 boot 分区或与内核镜像打包在一起
  4. 加载阶段:bootloader(如 U-Boot)从存储介质读取 .dtb 到内存
  5. 传递阶段:bootloader 将 .dtb 的内存地址通过寄存器传递给内核
  6. 解析阶段:内核启动早期,解析 .dtb 并构建 device_node 树
  7. 驱动匹配:各驱动通过 of_match_table 匹配设备节点,完成初始化