嵌入式Linux设备树:从源码结构到二进制格式的完整解析
1. 项目概述:从源码到硬件的“地图”解析
搞嵌入式Linux开发,特别是驱动或者BSP移植,设备树(Device Tree)绝对是一个绕不开的核心概念。很多朋友刚接触时,会觉得它很神秘,一堆.dts、.dtsi、.dtb文件,编译来编译去,最后还要加载到内核里。今天,我们不谈那些高深的理论,就从最实在的目录结构和最终那个二进制文件.dtb的格式入手,把它彻底拆开揉碎了讲清楚。你可以把设备树想象成一张描述硬件平台的“地图”,而这张地图的绘制(源码)、出版规范(语法)、以及最终印刷成册的二进制格式(dtb),就是我们今天要深挖的内容。
理解设备树的目录结构,能让你在庞大的内核源码或BSP包中快速找到你需要的设备节点定义,进行修改和调试。而理解.dtb的二进制格式,则能让你在系统无法启动、调试信息不足时,多一种强有力的分析手段,比如手动解析dtb内容,验证配置是否正确,甚至进行一些底层的修补。无论你是正在为一块新板卡适配Linux,还是试图优化现有设备的驱动匹配,掌握这些“地图”的绘制与编码规则,都将让你事半功倍。
2. 设备树源码的目录结构解析
Linux内核中的设备树源码,并不是随意堆放在一起的。它有一套清晰、可维护的目录组织方式,这套结构体现了硬件描述的层次化和复用思想。
2.1 核心目录布局
通常,在内核源码的arch/<arch>/boot/dts/目录下,存放着针对特定处理器体系结构(如ARM、PowerPC)的设备树文件。以常见的ARM架构为例,路径就是arch/arm/boot/dts/。在这个目录下,你会看到如下的典型结构:
arch/arm/boot/dts/ ├── vendor/ # 芯片原厂或方案商目录 │ ├── common/ # 该厂商通用的基础文件 │ │ ├── vendor-common.dtsi │ │ └── vendor-clock.dtsi │ ├── vendor-soc-core.dtsi # SoC核心定义 │ ├── vendor-board-common.dtsi # 该厂商某系列板卡的通用配置 │ └── ... ├── vendor-board-v1.dts # 具体的板级设备树文件 ├── vendor-board-v2.dts ├── other-vendor-board.dts # 其他厂商的板级文件 └── Makefile # 指定哪些.dts需要编译为.dtbvendor目录:这是最关键的分类维度,通常以芯片厂商或主要的方案设计商命名,例如ti(德州仪器)、nvidia、rockchip、xilinx等。把同一家的所有相关文件放在一起,便于管理和查找。
.dts文件:这是设备树源文件,对应一个具体的硬件板卡(Board)。它是编译的入口,可以理解为最终成品的“总装图”。一个.dts文件会通过#include指令包含多个.dtsi文件,并在此基础上进行板卡特有的配置和覆盖。
.dtsi文件:这是设备树包含文件,类似于C语言的头文件。它用于存放可复用的代码片段,例如:
- SoC级定义:描述一个SoC芯片内部的所有通用外设,如CPU集群、内存控制器、中断控制器、GPIO、I2C、SPI控制器等。文件名常类似
vendor-soc.dtsi。 - 板级通用定义:描述使用同一款SoC的不同板卡之间共享的硬件部分,比如核心电源方案、某些始终存在的传感器等。文件名可能类似
vendor-board-common.dtsi。 - 模块定义:描述一个独立的硬件模块,比如一个特定的音频编解码器、Wi-Fi/蓝牙芯片、摄像头模组等。这些文件可能被多个不同的板级
.dts引用。
注意:
.dtsi的后缀i代表include,这是一个约定俗成的命名,内核的编译工具dtc并不会区别对待.dts和.dtsi,它们语法完全一样。区分它们主要是为了人类开发者理解文件的用途。
2.2 包含与覆盖机制
设备树的核心魅力在于其“叠加”与“覆盖”的能力。一个典型的板级.dts文件开头可能是这样的:
/dts-v1/; #include "vendor-soc.dtsi" #include "vendor-lcd-panel.dtsi" #include "vendor-audio-codec.dtsi" / { model = "My Company Awesome Board"; compatible = "mycompany,awesome-board", "vendor,soc"; memory@80000000 { device_type = "memory"; reg = <0x80000000 0x40000000>; // 板卡实际内存为1GB }; // 覆盖SoC中I2C1节点的时钟频率 &i2c1 { clock-frequency = <400000>; // 设置为400kHz }; // 启用SoC中未启用的SPI2控制器,并添加子节点 &spi2 { status = "okay"; cs-gpios = <&gpio 10 GPIO_ACTIVE_LOW>; flash@0 { compatible = "jedec,spi-nor"; reg = <0>; spi-max-frequency = <50000000>; }; }; };解析:
#include:这行指令在编译前由预处理器处理,直接将.dtsi文件的内容插入当前位置。它实现了代码的复用。- 节点覆盖:
&i2c1和&spi2是引用语法。它们不是定义新节点,而是引用已经在vendor-soc.dtsi中定义过的节点路径。在其后的花括号{}内,你可以:- 添加新属性:例如为
spi2添加cs-gpios。 - 修改已有属性:例如将
i2c1的clock-frequency从默认值修改为<400000>。 - 添加子节点:例如在
spi2下添加flash@0子节点。 - 修改节点的
status属性从disabled为okay,从而启用该控制器。
- 添加新属性:例如为
这种机制使得SoC定义保持稳定和通用,而板卡差异化的配置可以清晰、集中地管理,极大提升了维护性。
2.3 实操心得:如何高效浏览和修改
面对一个陌生的BSP包,如何快速定位你要改的设备节点?
- 先找板级
.dts:通常BSP的编译脚本或文档会指明使用的是哪个.dts文件。例如,make ARCH=arm dtbs命令会编译arch/arm/boot/dts/Makefile中列出的所有目标。找到你的板卡对应的.dts文件,这是你的主战场。 - 顺藤摸瓜:打开这个
.dts文件,查看它#include了哪些.dtsi文件。通常第一个包含的就是SoC核心文件。你需要修改的控制器基础定义(如寄存器地址、中断号)很可能在那里。 - 善用搜索:在
dts目录下,使用grep -r "compatible =.*your-device"来搜索特定兼容性字符串的设备节点定义。这是找到驱动匹配的设备节点最快的方法。 - 理解节点路径:设备树是一个树形结构。在源码中,节点的路径通过嵌套关系体现。在修改或覆盖时,你必须确保引用的路径(如
&i2c1)在之前已经被定义过。通常,SoC的.dtsi会定义这些总线控制器节点。
3. DTB二进制格式深度拆解
.dts文件经过设备树编译器dtc编译后,生成.dtb文件。这个文件不再是人类可读的文本,而是一种紧凑的、平台无关的二进制格式,用于被Bootloader或内核在早期启动阶段快速解析。理解它的格式,是进行高级调试和问题排查的基础。
3.1 DTB整体结构
一个DTB文件由四个主要部分组成,按顺序排列在文件中:
+-------------------+ | struct fdt_header | <- 文件头 (Header) +-------------------+ | Memory Reserve Map | <- 内存保留映射区 +-------------------+ | Structure Block | <- 结构块 (设备树主体) +-------------------+ | Strings Block | <- 字符串块 +-------------------+1. 文件头这是一个固定大小的结构体(在32位系统上为40字节),包含了整个DTB的元信息。最重要的字段包括:
magic:固定值0xd00dfeed,用于标识这是一个DTB文件。totalsize:整个DTB文件的总大小。off_dt_struct:结构块在文件中的偏移量。off_dt_strings:字符串块在文件中的偏移量。off_mem_rsvmap:内存保留映射区在文件中的偏移量。version:DTB格式版本。last_comp_version:向后兼容的最低版本。
通过解析头,解析器就能知道其他三个部分的具体位置和大小。
2. 内存保留映射区这部分定义了一系列物理内存区域,这些区域在启动初期不能被操作系统使用(例如,可能用于存放Bootloader、ATF固件、或者特定的DMA缓冲区)。每个条目由一对64位的(address, size)组成,列表以一个全零的条目(0, 0)结束。内核在初始化内存管理时会避开这些区域。
3. 结构块这是DTB的核心,它以线性化的方式描述了整个设备树的节点层次和属性。它由一系列“令牌”组成:
FDT_BEGIN_NODE (0x00000001):标记一个节点的开始,后面紧跟该节点的名字(以\0结尾)。FDT_END_NODE (0x00000002):标记当前节点的结束。FDT_PROP (0x00000003):标记一个属性的开始。后面紧跟以下数据:- 属性值的长度(32位)。
- 属性名字符串在字符串块中的偏移量(32位)。
- 属性值的数据(按4字节对齐填充)。
FDT_NOP (0x00000004):一个空操作标记,可用于“删除”某个节点或属性(在动态修改DTB时,将其替换为FDT_NOP)。FDT_END (0x00000009):标记整个结构块的结束。
解析器通过顺序读取这些令牌,就能在内存中重建出树形结构。
4. 字符串块这是一个简单的、包含了所有属性名称字符串的池,每个字符串以\0分隔。在结构块中,属性名不是直接存储字符串,而是存储一个指向该字符串在字符串块中位置的偏移量。这种设计极大地节省了空间,因为像reg、interrupts、compatible这样的属性名会被大量重复使用。
3.2 关键属性在二进制中的存储
了解常见属性如何编码,有助于我们手动分析DTB:
compatible:属性值是一个字符串列表。在二进制中,就是多个以\0结尾的字符串连续存放。例如"simple-bus", "vendor,soc",存储为s i m p l e - b u s \0 v e n d o r , s o c \0。reg:属性值通常是<地址1 长度1 [地址2 长度2 ...]>。在二进制中,它被存储为原始字节序列。每个地址和长度所占的字节数(如32位或64位)由父节点的#address-cells和#size-cells属性决定。解析时必须知道这些单元格大小,否则无法正确解读。interrupts:存储格式依赖于该节点所连接的中断控制器(通过interrupt-parent指定)。对于简单的ARM GIC,可能存储为<中断号 触发类型>。这也是原始字节。status:就是一个简单的字符串,如"okay"或"disabled",存储为o k a y \0。
3.3 实操工具:如何查看和反编译DTB
我们不需要手写解析器,Linux内核源码的scripts/dtc/目录下就提供了强大的工具dtc。
反编译DTB为DTS:这是最常用的调试命令。
dtc -I dtb -O dts -o output.dts input.dtb这个命令会将二进制的
input.dtb反编译成可读的output.dts文件。当你的板子启动失败,怀疑是设备树问题时,可以从Bootloader传递的地址或者内核镜像中提取出DTB,反编译后检查内容是否正确。查看DTB信息:
fdtdump input.dtbfdtdump工具(通常由dtc包提供)会以更接近二进制原始结构的方式打印DTB内容,包括头部信息和结构块的令牌流,适合深入学习。在系统中查看已加载的设备树: Linux内核启动后,会将解析后的设备树以文件系统形式挂载在
/proc/device-tree/(旧版本)或/sys/firmware/devicetree/base/(新版本)。这是一个目录结构,直接映射了设备树的节点和属性。你可以用cat命令查看属性,用ls命令查看子节点。# 查看根节点的compatible属性 cat /sys/firmware/devicetree/base/compatible # 查看I2C1控制器的状态 cat /sys/firmware/devicetree/base/soc/i2c@12340000/status这是运行时调试驱动匹配问题的利器。
4. 从源码到二进制:编译流程与关键参数
理解了结构和格式,我们来看看它们是如何被组织起来,最终生成可用的.dtb文件的。
4.1 内核构建系统中的设备树编译
在内核源码顶层执行make dtbs,会触发以下过程:
解析Makefile:构建系统会读取
arch/<arch>/boot/dts/Makefile。这个文件里定义了哪些.dts文件需要被编译,以及它们之间的依赖关系。例如:dtb-$(CONFIG_ARCH_VENDOR) += vendor-board-v1.dtb vendor-board-v1.dtb: vendor-soc.dtsi vendor-board-common.dtsi这表示当配置了
CONFIG_ARCH_VENDOR时,需要构建vendor-board-v1.dtb,并且它依赖于列出的.dtsi文件。调用dtc编译:对于每个目标
.dts,构建系统会调用dtc编译器,命令类似于:dtc -O dtb -o vendor-board-v1.dtb -b 0 -@ arch/arm/boot/dts/vendor-board-v1.dts-O dtb:指定输出格式为二进制DTB。-o:指定输出文件名。-b 0:指定物理地址的起始偏移量,通常为0。在某些需要重定位DTB的场景下会用到。-@:生成符号插件支持。这个选项会在DTB中生成一个__symbols__节点,包含了所有带有label的节点的路径。这对于动态设备树覆盖至关重要,它允许在系统运行时,通过加载一个.dtbo文件来修改已有的设备树。-@选项生成的符号表,让覆盖层能够准确地引用到要修改的目标节点。
4.2 预处理器的作用
在dtc编译之前,.dts文件会先经过C预处理器(cpp)处理。这意味着你可以在设备树源文件中使用C预处理指令,这带来了极大的灵活性:
#include:包含其他文件,实现代码复用。#define:定义宏,用于参数化配置。#define BOARD_REVISION_A #ifdef BOARD_REVISION_A &uart0 { status = "okay"; }; #else &uart1 { status = "okay"; }; #endif#ifdef / #ifndef / #endif:条件编译,可以根据不同的配置(如板卡版本、功能裁剪)生成不同的设备树内容。
预处理器处理完成后,才会将纯净的设备树语法交给dtc进行编译。这也是为什么设备树源文件能如此模块化和可配置的原因。
4.3 常见编译问题与排查
- 语法错误:
dtc会报告具体的行号和错误信息,如缺少分号、括号不匹配、节点名格式错误等。这类错误通常比较容易定位和修复。 - 未定义的引用:如果你在覆盖一个节点(如
&i2c1)时,该节点在之前的所有包含文件中都未被定义,dtc会报错。你需要检查包含路径,或者确认节点名是否拼写正确。 - 地址单元格大小未定义:当解析
reg属性时,如果其父节点或祖先节点没有定义#address-cells和#size-cells,解析器将不知道如何划分数据。通常需要在根节点或总线节点明确定义这些属性。 - 生成的DTB过大:有时预处理器展开后,文件会非常大。可以使用
dtc的-H选项指定生成的头文件模式,或者检查是否包含了大量未使用的条件编译分支。在最终产品中,通常会通过裁剪只包含必要的设备节点来减小DTB体积。
5. 高级应用:动态设备树覆盖与调试技巧
设备树不仅仅是静态的启动配置,在现代Linux系统中,它还可以动态修改,这为模块化硬件支持和热插拔带来了便利。
5.1 设备树覆盖原理
设备树覆盖允许在系统运行时,向已有的设备树添加或修改节点。这常用于支持可插拔的硬件模块,比如通过SPI或I2C接口连接的帽子板、扩展板等。
工作原理:
- 基础DTB:系统启动时加载的基础设备树,在编译时使用了
-@选项,包含了__symbols__节点。 - 覆盖DTBO:为一个硬件模块编写一个
.dts覆盖层源文件。在这个文件中,你可以使用&label来引用基础DTB中的节点(这些label在基础DTB的源文件中用节点名: label:的语法定义),然后添加新的子节点或修改属性。 - 编译与加载:将覆盖层
.dts编译成.dtbo。在系统启动后,通过特定的内核接口(如ConfigFS)或Bootloader(如U-Boot的fdt apply命令)将这个.dtbo应用到运行时的设备树上。内核会动态合并这些更改,并触发相应的驱动探测或卸载。
5.2 手动分析与调试DTB
当系统启动失败,且日志指向设备树问题时,除了反编译,还有一些更底层的调试方法:
使用
hexdump或xxd初步查看:hexdump -C my.dtb | head -50你可以看到文件开头的魔数
d0 0d fe ed,以及后续的一些结构。虽然不直观,但可以快速验证文件是否是一个有效的DTB。对比DTB差异:如果你修改了设备树源码,但不确定编译后的DTB是否如预期变化,可以使用
fdtdiff工具(需要单独安装或从dtc工具集找)来比较两个DTB文件的差异。fdtdiff old.dtb new.dtb这比直接比较文本化的DTS更准确,因为它比较的是最终二进制结构。
在U-Boot中调试:U-Boot提供了强大的fdt命令集。
=> fdt addr $fdt_addr_r # 设置当前操作的DTB地址 => fdt print /soc/i2c@12340000 # 打印某个节点的详细信息 => fdt list /soc # 列出某个节点的子节点 => fdt get value myvar /clock-frequency # 获取属性值到环境变量在U-Boot阶段检查设备树状态,可以排除内核驱动本身的问题,锁定是DTB传递错误还是内容错误。
5.3 性能与空间考量
DTB文件最终会被加载到系统内存中,并由内核解析。对于资源受限的嵌入式系统,需要考虑其大小和解析开销。
- 精简DTB:移除所有不需要的设备节点(将
status设为"disabled"只是不启用,节点仍在DTB中)。最彻底的方法是直接修改.dts源文件,删除未使用的#include和节点定义。可以使用dtc的-R选项进行二进制裁剪,但更推荐在源码层面管理。 - 解析优化:内核在解析DTB时,会将其转换为更高效的内部表示。这个过程在启动早期完成,对于现代处理器,解析一个几十KB的DTB耗时可以忽略不计。主要关注点在于DTB本身的大小不要占用过多的内存或存储空间。
- 压缩DTB:Bootloader或内核本身支持压缩的DTB。例如,U-Boot可以加载
*.dtb.gz文件并在内存中解压。这可以节省存储空间(如SPI NOR Flash),但会略微增加启动时间。
理解设备树从源码目录结构到二进制格式的完整生命周期,能让你从一个被动的配置修改者,变成一个主动的系统调试者和设计者。下次再遇到设备树相关的问题,不妨尝试用fdtdump看看二进制内容,或者用U-Boot的命令手动检查一下,你会发现很多问题都变得清晰起来。
