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 设备树与硬件描述的关系
设备树本质上是一份 硬件拓扑图。它告诉内核三件事:
- 有什么硬件:CPU、内存、外设控制器、总线……
- 在哪里:寄存器地址、中断号、DMA 通道……
- 怎么用:时钟频率、电压、引脚复用配置……
但设备树不是万能的。它只描述硬件,不描述行为。举个例子:
- 设备树可以告诉你 UART 的寄存器基地址是 0x10000000,中断号是 5。
- 但它不会告诉你这个 UART 的 FIFO 深度是多少,或者怎么配置流控。
这些行为细节,还是得靠驱动代码去实现。设备树只是把「硬件配置参数」从代码里剥离出来。
我曾经踩过的坑: 有一次我把 GPIO 的中断触发类型写在了 DTS 里,但驱动里又硬编码了另一种触发方式。结果中断要么不触发,要么疯狂触发。排查了两天才发现是两边不一致。所以我的建议是:DTS 里只放硬件固有的属性,不要放软件策略。中断触发类型这种既跟硬件相关又跟驱动策略相关的东西,最好在驱动里统一处理。
下面这张图展示了设备树在内核启动流程中的位置:
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 配置,以及怎么裁剪出一个最小镜像。但今天,先把基础打牢——理解设备树是什么,为什么需要它,以及它怎么跟硬件对话。