设备树基础: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 加载。咱们看看整个流程:
- BootROM 启动,加载 bootloader(如 U-Boot)。
- U-Boot 从存储介质(eMMC、NAND、网络)读取 DTB 文件到内存。
- U-Boot 可能根据硬件版本修改 DTB(比如填充 MAC 地址、调整内存大小)。
- U-Boot 启动内核时,把 DTB 的物理地址通过寄存器
r2(ARM 架构)传递给内核。 - 内核启动早期,在
setup_arch()中解析 DTB,建立设备树结构。 - 内核 遍历设备树,为每个
compatible匹配的节点创建平台设备。 - 驱动 在 probe 时读取设备树属性,获取硬件资源。
我画了一张流程图,把关键步骤串起来:
从这张图你能看到,设备树在整个启动流程中扮演的是 硬件描述传递者 的角色。它不参与计算,不执行逻辑,只是把硬件信息从 bootloader 传递给内核。但正是这个简单的角色,让 Linux 的硬件支持变得极其灵活。
我记得有一次,客户要求在一款已经量产的板子上换用更大容量的 NAND Flash。按照以前的做法,我得改内核源码、重新编译、重新测试。但有了设备树,我只需要在 DTS 里改一下 reg 属性里的分区大小,重新编译 DTB,替换掉 boot 分区里的文件就行。整个过程不到十分钟。
调试小技巧:如果你怀疑设备树解析有问题,可以在内核启动参数里加 earlycon 和 dtb_debug。内核会在早期串口输出 DTB 解析过程中的关键信息,比如哪个节点匹配了哪个驱动、哪个属性没找到。这比瞎猜快多了。
好了,设备树的基础概念就讲到这里。你只要记住三件事:设备树是硬件描述文件、语法是节点加属性、它在启动流程中由 bootloader 加载给内核解析。后面的章节我们会深入讲设备树的绑定规则、中断映射、时钟管理,还有电源管理相关的设备树用法。那些才是真正实战中天天要打交道的东西。