1. 设备树基础:从一块砖头说起

做嵌入式 Linux 开发的朋友,一定都跟设备树打过交道。我第一次接触它是在 2012 年,当时接手一个 PowerPC 平台的项目。那会儿我还在用老办法——硬编码板级信息。结果换了个内存颗粒,整个 BSP 都得重编。后来我才明白,设备树这东西,说白了就是让内核和硬件解耦的「中间人」。

1.1 设备树的起源:为什么要有它?

在设备树出现之前,Linux 内核是怎么知道板子上有哪些硬件的?

答案是:硬编码。每个板子都在 arch/arm/mach-xxx 下有一堆 C 文件,里面写满了寄存器地址、中断号、GPIO 映射。我见过一个项目,光是板级文件就有 3000 多行。换一个外设,改代码;换一颗 SoC,重写。这种模式在 ARM 架构上尤其痛苦——因为 ARM 不像 x86 那样有 ACPI 或者 BIOS 来枚举硬件。

2005 年左右,PowerPC 社区先受不了了。他们搞出了 Open Firmware 和 FDT(Flattened Device Tree) 的概念。后来 ARM 社区也跟进,到了 2011 年 Linux 3.0 左右,设备树正式成为 ARM 平台的标准做法。

核心思想: 把硬件描述从内核源码里抽出来,放到一个独立的文本文件中。内核启动时解析这个文件,就知道自己跑在什么板子上、有哪些外设、怎么访问它们。

你想想看,这就像搬家。以前你得把家具尺寸、房间布局都刻在脑子里(硬编码)。现在你有一张图纸(设备树),到了新房子看一眼图纸就知道怎么摆。内核就是那个搬家工,图纸换了,搬家工不用换。

1.2 设备树语法基础:看懂 DTS 文件

设备树的源文件后缀是 .dts,编译后的二进制是 .dtb。语法其实很简单,就是一棵树——根节点是 /,下面挂各种子节点。

来看一个最简单的例子:

/dts-v1/;

/ {
    model = "MyBoard V1.0";
    compatible = "vendor,myboard-v1", "vendor,myboard";

    #address-cells = <1>;
    #size-cells = <1>;

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

    chosen {
        bootargs = "console=ttyS0,115200 root=/dev/mmcblk0p2";
    };

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

        uart0: serial@10000000 {
            compatible = "ns16550";
            reg = <0x10000000 0x1000>;
            interrupts = <0 5 4>;
            clock-frequency = <1843200>;
        };

        gpio0: gpio@10001000 {
            compatible = "vendor,gpio-controller";
            reg = <0x10001000 0x1000>;
            gpio-controller;
            #gpio-cells = <2>;
        };
    };
};

嗯,这里要注意几个关键点:

  • 根节点:/ 是整棵树的起点,每个 DTS 文件只有一个根。
  • 属性:compatible、reg、interrupts 这些都是属性。它们描述了硬件的特征。
  • 节点名:uart0、gpio0 是标签(label),方便其他节点引用。真正的节点名是 serial@10000000,@ 后面跟的是地址。
  • reg 属性:格式是 <起始地址 长度>。上面 memory 节点的 reg = <0x80000000 0x10000000> 表示内存从 0x80000000 开始,大小 256MB。

我的习惯: 写 DTS 文件时,我总会在每个节点上加一个 status = "okay" 或 status = "disabled"。这样调试时可以直接在 DTS 里禁用一个外设,不用改驱动代码。曾经有个项目,I2C 总线上的一个传感器老是挂死,我就是靠 status = "disabled" 先把它屏蔽掉,等驱动修好了再打开。

1.3 设备树与硬件描述的关系

设备树本质上是一份 硬件拓扑图。它告诉内核三件事:

  1. 有什么硬件:CPU、内存、外设控制器、总线……
  2. 在哪里:寄存器地址、中断号、DMA 通道……
  3. 怎么用:时钟频率、电压、引脚复用配置……

但设备树不是万能的。它只描述硬件,不描述行为。举个例子:

  • 设备树可以告诉你 UART 的寄存器基地址是 0x10000000,中断号是 5。
  • 但它不会告诉你这个 UART 的 FIFO 深度是多少,或者怎么配置流控。

这些行为细节,还是得靠驱动代码去实现。设备树只是把「硬件配置参数」从代码里剥离出来。

我曾经踩过的坑: 有一次我把 GPIO 的中断触发类型写在了 DTS 里,但驱动里又硬编码了另一种触发方式。结果中断要么不触发,要么疯狂触发。排查了两天才发现是两边不一致。所以我的建议是:DTS 里只放硬件固有的属性,不要放软件策略。中断触发类型这种既跟硬件相关又跟驱动策略相关的东西,最好在驱动里统一处理。

下面这张图展示了设备树在内核启动流程中的位置:

设备树在内核启动中的角色 设备树在内核启动中的角色 Bootloader 设备树 (.dtb) Linux 内核 加载 解析 OF API 解析 驱动匹配 硬件初始化 关键流程 Bootloader 加载 DTB → 内核 OF API 解析 → 驱动通过 compatible 匹配 → 读取 reg/interrupts 等属性完成初始化

1.4 设备树的三大文件类型

实际项目中,你会遇到三种跟设备树相关的文件:

文件类型 后缀 说明 我的用法
源文件 .dts 人类可读的文本,描述一个具体板子 每个板子一个,比如 imx6ull-myboard.dts
头文件 .dtsi 公共部分,被多个 .dts 包含 SoC 级描述放这里,比如 imx6ull.dtsi
二进制文件 .dtb 编译后的设备树,内核直接使用 用 dtc 工具编译生成

我习惯把 SoC 级别的描述(比如 CPU 核、内存控制器、中断控制器)放在 .dtsi 里,板级差异(比如外设型号、引脚复用)放在 .dts 里。这样换板子时只需要改 .dts,SoC 部分不用动。

一个小技巧: 编译 DTS 时可以用 dtc -I dts -O dtb -o output.dtb input.dts。如果想反编译看看内核实际用了什么配置,用 dtc -I dtb -O dts -o output.dts input.dtb。我经常拿反编译出来的 DTS 跟源文件对比,检查有没有被覆盖或修改。

1.5 设备树的核心价值

说了这么多,设备树到底解决了什么问题?我总结了三句话:

  • 解耦:硬件描述不再跟内核源码绑定。换板子不用改内核,换 DTB 就行。
  • 标准化:所有硬件都用同一套语法描述。驱动开发者只需要知道怎么解析设备树,不用关心具体板子。
  • 可维护性:一个 DTS 文件就能看清整个板子的硬件拓扑。比起翻 3000 行 C 代码,舒服太多了。

我记得有一次,客户临时要求换一颗 Flash 芯片。以前的做法是改驱动、改板级文件、重新编译内核。有了设备树,我只需要在 DTS 里改一下 reg 属性里的地址和大小,重新编译 DTB,烧进去就完事了。前后不到十分钟。

这就是设备树的魅力。它让嵌入式 Linux 开发从「写死」变成了「配活」。后面几章,我们会深入设备树的每个细节,包括中断映射、时钟绑定、pinctrl 配置,以及怎么裁剪出一个最小镜像。但今天,先把基础打牢——理解设备树是什么,为什么需要它,以及它怎么跟硬件对话。