嵌入式Linux设备树详解:DTS、DTSI、DTB与DTC的关系与实战
1. 从“三兄弟”的日常说起:DTS、DTSI与DTB
如果你在嵌入式Linux或者驱动开发领域摸爬滚打过一阵子,肯定对“设备树”这个词不陌生。它就像一份硬件“说明书”,告诉内核:“嘿,这块板子上有个I2C控制器在地址0x12345678,它连着两个设备,一个在0x50,一个在0x68。” 没有它,内核就得靠一堆硬编码的板级文件来识别硬件,换个板子就得重新编译内核,麻烦得很。
但刚接触设备树时,面对一堆以 .dts、.dtsi、.dtb 结尾的文件,还有一个叫 dtc 的工具,很多人会有点懵。它们长得像,名字也像,到底谁是谁,什么关系?今天我们就来彻底理清这“三兄弟”和它们背后的“翻译官”。
简单来说,你可以这样理解:
.dts和.dtsi:这是人类可读的源代码。.dts是设备树源文件,描述一块具体板子的硬件;.dtsi是“头文件”或“公共模板”,描述SoC(系统级芯片)的通用硬件资源,可以被多个.dts文件包含。.dtb:这是机器(内核)可读的二进制文件。它由.dts编译而来,内核在启动时直接读取它来获取硬件信息。dtc:这是编译器。它的工作就是把人类写的.dts/.dtsi源代码,“翻译”成机器能懂的.dtb二进制文件。
这个过程,和咱们写C程序非常像:你写 .c 和 .h 源文件,用 gcc 编译器编译成 .o 或可执行文件。在这里,.dts/.dtsi 对应 .c/.h,dtc 对应 gcc,.dtb 对应 .o 或最终的可执行二进制。理解了这个类比,就抓住了核心脉络。接下来,我们逐个深入,看看它们在实际项目中是如何运作、如何配合,以及有哪些你可能会踩到的坑。
2. 源头活水:DTS与DTSI的编写与组织逻辑
设备树的源头,是那些以文本形式存在的 .dts 和 .dtsi 文件。它们的语法虽然看起来有点怪异,但结构清晰,目的明确。
2.1 DTS:板级定义的蓝图
.dts (Device Tree Source) 文件是针对一块具体开发板或产品的完整硬件描述。它定义了这块板子上有什么,以及它们如何连接。一个典型的 .dts 文件开头会包含一个或多个 .dtsi 文件,然后在此基础上进行修改、添加或覆盖。
我们以常见的瑞芯微 RK3568 平台为例。假设我们有一个基于 RK3568 的自定义板,我们可能会创建一个 myboard-rk3568.dts 文件:
从这个例子可以看出,.dts 文件的核心工作是:
- 包含基础模板:通过
#include引入 SoC (rk3568.dtsi) 和可能的核心板通用定义。 - 定义板级独有信息:如
model、compatible字符串、内存大小 (memory@0)。 - 选择性启用/配置外设:使用
&引用在.dtsi中已定义的节点(如&gpio0,&i2c1),并设置其状态 (status = “okay”) 或属性。 - 添加板级外设:定义那些只在当前板子上存在的设备节点,如自定义的LED、按键、扩展芯片等。
注意:
compatible属性是设备树中最重要的属性之一。它是一组字符串,内核驱动程序通过匹配这个字符串来决定由哪个驱动来管理这个设备。格式通常是 “制造商,型号”,并且可以有多个,用于向后兼容。例如“mycompany,myboard-rk3568”, “rockchip,rk3568”表示优先匹配我们板子的专用驱动,如果没有,则使用 RK3568 的通用驱动。
2.2 DTSI:SoC的硬件“基因库”
.dtsi (Device Tree Source Include) 文件是可被包含的源文件。它通常描述一个SoC(系统级芯片) 的内部架构,即芯片内部集成了哪些核心外设(如CPU集群、内部内存控制器、GPIO控制器、I2C/SPI/UART等外设IP核),以及它们的默认配置和地址映射。
继续看 RK3568 的例子,rk3568.dtsi 里面会定义芯片的“骨架”:
.dtsi 文件的特点:
- 描述芯片能力:它声明了SoC有什么,比如有几个CPU,有哪些外设控制器,它们的寄存器基地址 (
reg)、中断号 (interrupts) 是什么。 - 默认不启用:几乎所有外设节点的
status属性默认都是“disabled”。这是因为一块板子可能只用了芯片部分外设,具体启用哪个,由板级.dts文件决定。 - 提供标签(Label):如
gpio0:、i2c1:。这个标签是编译时的引用点,允许板级.dts通过&gpio0这样的语法来引用并覆盖这个节点的属性。标签本身不会出现在最终的.dtb中。 - 层级化组织:复杂的SoC可能会有多级
.dtsi。例如,rk3568.dtsi可能包含一个更基础的rockchip.dtsi(定义一些Rockchip芯片的通用属性),而它自己又被更具体的rk3568-evb.dtsi(评估板通用配置)包含。
2.3 DTS与DTSI的包含与覆盖机制
理解包含和覆盖是掌握设备树编写的关键。#include 是简单的文本替换,发生在编译的第一步。dtc 编译器会将被包含的 .dtsi 文件内容原样插入到 #include 的位置。
覆盖则通过节点路径引用来实现。在板级 .dts 中,使用 &label 来引用在 .dtsi 中定义了标签的节点,然后在其下添加或修改属性。
一个常见的坑:节点合并规则
设备树编译器 dtc 会将所有相同全路径的节点进行合并。例如,在 .dtsi 中定义了 /soc/i2c@fe5a0000,在 .dts 中又通过 &i2c1 引用了它。编译时,这两个描述同一节点的部分会被合并。
- 属性合并:如果同一个属性在两个地方都定义了,后出现的会覆盖先出现的。因此,板级
.dts中的status = “okay”会覆盖.dtsi中的status = “disabled”。 - 子节点追加:如果板级
.dts在&i2c1节点下添加了新的子节点(如上面的eeprom@50),那么这个子节点会被追加到合并后的节点中。
另一个坑:okay 和 disabled 同时存在
有时在调试时,你可能会在一个节点里同时看到 status = “okay”; 和 status = “disabled”;。这听起来矛盾,但根据上述覆盖规则,这取决于它们的顺序。如果 .dtsi 里是 disabled,而你的 .dts 里先写了 okay,然后又因为某些原因(比如条件编译)在后面又写了一次 disabled,那么最终生效的是最后出现的那个。务必检查你的文件顺序和条件编译宏,确保最终状态符合预期。
3. 编译核心:DTC工具详解与实战
有了人类可读的源文件,如何变成内核能用的二进制?这就是设备树编译器 dtc (Device Tree Compiler) 的职责。它不是一个“黑盒子”,理解它的工作方式和常用命令,对于调试和逆向分析至关重要。
3.1 DTC是什么?从源码到工具
dtc 最初是内核源码树的一部分(位于 scripts/dtc/),现在它也是一个独立维护的项目。它的核心功能包括:
- 编译(
-O dtb):将.dts源文件(及其包含的所有.dtsi)编译成.dtb二进制文件。这是最常用的功能。 - 反编译(
-O dts):将.dtb二进制文件反编译成.dts源文件。这对于分析现成板子的设备树配置、学习或者调试极其有用。 - 语法检查与信息输出:检查
.dts文件的语法错误,输出设备树的结构信息等。
在典型的Linux开发环境中,你可以通过包管理器安装 dtc 工具:
编译内核时,make dtbs 命令会自动调用 dtc,根据内核 arch/arm64/boot/dts/(以ARM64为例)目录下的 .dts 文件,生成对应的 .dtb 文件,并输出到 arch/arm64/boot/dts/ 或指定的输出目录。
3.2 核心编译命令与参数解析
让我们深入几个最常用的 dtc 命令场景。
场景一:手动编译一个DTS文件
这是最基本的操作。假设你修改了 myboard.dts,需要生成新的 myboard.dtb 给内核使用。
-I dts:指定输入格式为 Device Tree Source。-O dtb:指定输出格式为 Device Tree Blob。-o myboard.dtb:指定输出的二进制文件名。myboard.dts:输入的源文件。
场景二:反编译DTB,一探究竟
当你拿到一个现成的板子,只有它的 .dtb 文件(可能在内核镜像里,也可能由Bootloader加载),想看看它到底配置了什么,反编译是唯一途径。
执行后,你会得到一个 decompiled.dts 文件。打开它,你会看到所有节点、属性都清晰呈现,但注意:
- 所有注释和宏定义都会丢失,因为它们在编译时已经被处理掉了。
- 标签(Labels)会消失,取而代之的是节点的完整路径。原来在源码中的
&i2c1引用,在反编译后的文件里会直接展开成/soc/i2c@fe5a0000。 - 属性值会以规范形式显示。比如
status = “okay”;可能会被显示为status = “ok”;(因为“ok”是标准值,“okay”是其别名,内核都接受)。
场景三:获取详细信息,辅助调试
dtc 还提供了一些有用的调试选项:
-q (quiet) 和 -s (sort) 选项可以让输出更整洁。但更常用的是结合 fdtdump 工具(通常随 dtc 安装)来获得更易读的十六进制和结构视图。
场景四:预处理与头文件包含
.dts 文件中的 #include 和 C 语言中的 #include 类似,但 dtc 本身不处理它们。实际上,编译过程通常分两步:
- 预处理:使用C预处理器
cpp来处理#include和宏定义(如#define)。 - 编译:将预处理后的中间文件交给
dtc编译。 内核的构建系统(如Makefile)会自动完成这一步。手动操作时,可以这样模拟:
预处理后的文件(.preprocessed.dts)会展开所有 #include,替换所有宏,是理解最终设备树源码的绝佳材料。当你的设备树编译出错,但错误信息指向的代码行数在原始 .dts 中对不上时,查看预处理后的文件能帮你精准定位问题。
3.3 常见编译错误与排查心得
在使用 dtc 过程中,你肯定会遇到编译错误。这里分享几个典型错误和排查思路:
-
语法错误:
ERROR: Unexpected character这通常是因为在属性值后面漏了分号;,或者字符串引号不匹配。dtc的错误提示会给出文件名和行号,但注意这个行号是预处理后文件的行号。如果你直接编译.dts,行号可能不准。这时就需要用上面提到的预处理方法,先得到.preprocessed.dts,再到对应的行号去找问题。 -
未定义的标签引用:
ERROR: Undefined label例如,在板级.dts中写了&non_existent_label,但.dtsi中并没有定义这个标签。检查拼写错误,或者确认你包含的.dtsi文件是否正确、版本是否匹配。 -
重复的节点定义:
ERROR: Duplicate node name设备树不允许在同一层级有两个相同名字的节点。比如,在.dtsi中定义了i2c@fe5a0000,你在板级.dts中想修改它,应该使用&i2c1引用覆盖,而不是重新定义一个/soc/i2c@fe5a0000节点。 -
属性类型或值错误 比如
reg属性的格式是<地址 长度>,你写错了单元格数量;或者compatible的值不是字符串。仔细对照芯片手册和已有的成功例子。
我的排查经验是:从简到繁,逐层验证。 当你写了一个复杂的设备树文件编译不过时,可以尝试:
- 先注释掉所有自定义的节点和覆盖,只保留最基本的
#include和根节点,确保基础文件能编译。 - 然后,一个一个地启用或添加外设节点,每加一个就编译一次,这样能快速定位是哪个节点引入的问题。
- 善用反编译工具。当你对某个官方
.dtb的效果不确定时,反编译它,看看最终生效的配置到底是什么,这比读分散的.dts/.dtsi源码更直接。
4. 内核的“口粮”:DTB文件的加载与解析
经过 dtc 的编译,我们得到了 .dtb (Device Tree Blob) 文件。这个二进制文件就是内核最终消费的“硬件配置表”。它不再是文本,而是一种紧凑的、带有层次结构的数据格式,便于内核在启动早期快速解析。
4.1 DTB的二进制结构与内核读取
.dtb 文件有固定的结构,主要包括:
- 头部(Header):包含魔数、版本、总大小、结构块偏移等信息。
- 结构块(Structure Block):以线性化的形式存储了整个设备树的节点和属性结构。这是文件的主体。
- 字符串块(Strings Block):存储了所有属性名(如
“compatible”、“reg”、“status”)的字符串,结构块中通过偏移量引用它们,以节省空间。 - 内存保留映射块(Memory Reservation Block):可选,用于告知内核保留某些物理内存区域(如用于 DMA、固件等),不被系统挪用。
Bootloader(如U-Boot)在加载Linux内核镜像(Image 或 zImage)的同时,也会在内存中准备好 .dtb 文件。根据体系结构约定(如ARM),Bootloader会通过特定的寄存器(如 r2)将 .dtb 在内存中的起始地址传递给内核。
内核启动的非常早期,在 setup_arch() 等函数中,就会解析这个 .dtb。解析过程就是将二进制的层次结构还原成内核内部的一个树形数据结构(struct device_node)。此后,内核的各个子系统(如平台总线、I2C、SPI总线)以及驱动程序,就可以通过标准的OF(Open Firmware)接口来查询这棵树,获取硬件信息。
4.2 如何确认内核使用了正确的DTB?
在系统运行时,有几种方法可以验证设备树的内容:
-
查看内核启动日志(dmesg) 这是最直接的方式。内核在解析设备树时,会打印很多信息。
BASHdmesg | grep -i “device tree”dmesg | grep -i “dts”你可能会看到类似
“OF: fdt: Machine model: MyAwesome Company RK3568 Development Board”的信息,这直接来自设备树根节点的model属性。 -
探索
/proc/device-tree这是一个神奇的虚拟文件系统,它将内核内存中的设备树结构以目录和文件的形式呈现出来。BASH# 查看根节点的属性ls -la /proc/device-tree/cat /proc/device-tree/modelcat /proc/device-tree/compatible# 查看某个具体节点,比如I2C1控制器ls -la /proc/device-tree/soc/i2c@fe5a0000/cat /proc/device-tree/soc/i2c@fe5a0000/name # 可能显示 “i2c@fe5a0000”cat /proc/device-tree/soc/i2c@fe5a0000/compatiblecat /proc/device-tree/soc/i2c@fe5a0000/status属性文件的内容通常是二进制的,对于字符串属性,可以用
cat查看;对于数值属性(如reg),需要用hexdump等工具。 -
使用
dtc反编译运行时设备树 更彻底的方法是,将/proc/device-tree导出为一个.dtb文件,然后反编译它。这能让你看到内核实际看到的设备树全貌,包括所有运行时解析后的值。BASH# 方法一:使用 dtc 工具(需要 CONFIG_PROC_DEVICETREE 内核配置)dtc -I fs -O dts -o /tmp/current.dts /proc/device-tree# `-I fs` 表示从文件系统(即/proc/device-tree)读取# 方法二:使用更通用的方法,通过 /sys/firmware/devicetree/base# 这个目录是 /proc/device-tree 的符号链接,内容相同find /sys/firmware/devicetree/base -type f -name “*” | while read file; doecho “=== $file ===”cat “$file” | hexdump -Cdone导出的
current.dts文件是你进行驱动调试、验证配置是否生效的终极参考。
4.3 调试实战:当驱动不匹配时
假设你为板子上的一个I2C设备(地址0x50的EEPROM)添加了设备树节点,并编写了对应的驱动,但驱动加载后没有探测到这个设备。你可以按以下步骤排查:
-
第一步:检查内核日志
BASHdmesg | grep -E “i2c|eeprom|0x50”看是否有I2C总线注册成功、是否有地址扫描到0x50、你的驱动 probe 函数是否被调用。
-
第二步:确认设备树节点已生效
BASH# 检查I2C控制器节点是否启用cat /proc/device-tree/soc/i2c@fe5a0000/status# 应该输出 “okay” 或 “ok”# 检查你的EEPROM子节点是否存在ls -la /proc/device-tree/soc/i2c@fe5a0000/# 看是否有 eeprom@50 或类似名字的目录cat /proc/device-tree/soc/i2c@fe5a0000/eeprom@50/compatible# 看 compatible 属性是否正确 -
第三步:使用反编译确认最终配置 如果
/proc/device-tree里没有你的节点,说明设备树根本没编译进去或者被覆盖了。用上面介绍的方法,反编译实际运行的.dtb,搜索你的节点,看看它是否存在,属性是否正确。 -
第四步:检查驱动匹配 如果节点存在且属性正确,但驱动没加载,可能是
compatible字符串不匹配。确保驱动代码里的.of_match_table中的字符串,和设备树里的compatible属性值完全一致,包括大小写和标点。
这个过程清晰地展示了 .dts -> .dtb -> 内核解析 -> 驱动匹配的完整链条。任何一个环节出错,硬件都无法正常工作。
5. 逆向、调试与高级技巧
掌握了基本流程后,我们来看一些更深入的应用场景和技巧,这些往往是实际项目中提升效率的关键。
5.1 从DTB逆向DTS:学习与分析的利器
正如前面提到的,dtc -I dtb -O dts 是强大的逆向工具。它的应用场景包括:
- 学习参考:当你拿到一款新的开发板,厂商只提供了内核镜像和
.dtb文件,没有完整的源码。反编译它的.dtb,是了解其硬件配置和设备树编写风格的最快途径。 - 调试验证:你修改了
.dts并重新编译,但内核行为不符合预期。将新生成的.dtb反编译,与旧的.dtb反编译结果进行diff对比,可以精确知道你修改的代码最终产生了哪些二进制层面的变化。 - 排查配置冲突:当多个
.dtsi文件包含和覆盖关系复杂时,直接看源码可能理不清最终效果。反编译最终的.dtb,得到的是所有合并、覆盖完成后的“最终版本”,一目了然。
一个实用命令:生成带注释的反编译结果
虽然标准的反编译会丢失所有注释,但 dtc 有一个 -@ 选项,可以在输出中保留某些符号信息,使其更具可读性(尽管不如原始源码)。
5.2 设备树与驱动开发的协同
设备树不是孤立的,它必须和内核驱动紧密配合。
驱动如何读取设备树?
在Linux驱动中,我们使用OF(Open Firmware)API来读取设备树。例如,在驱动程序的 probe 函数中:
设备树为驱动提供了一种声明式的硬件配置方法,驱动代码变得通用,同一份驱动代码可以通过匹配不同的设备树节点来支持不同的硬件板卡。
设备树覆盖(Device Tree Overlay)
这是一个高级但非常实用的特性,尤其适用于支持动态插拔模块的系统(如树莓派的HAT)。它允许在系统运行时,动态地加载一个“补丁” .dtbo 文件,来修改当前运行的设备树。这常用于加载一个描述新插入硬件模块(如一个外接的ADC芯片)的设备树片段,而无需重新编译和启动整个内核。
其原理是,内核或Bootloader支持将多个 .dtb/.dtbo 文件在内存中拼接、合并。这对于产品化后的现场调试和功能扩展非常有价值。
5.3 避坑指南:那些年我踩过的设备树坑
-
地址单元(
#address-cells,#size-cells)混淆:这是最易错的概念之一。在父节点中,#address-cells定义子节点reg属性中“地址”字段占用多少个32位单元格;#size-cells定义“长度”字段占用的单元格数。根节点通常是<2>用于64位系统,SoC总线节点可能是<1 1>,而没有内存映射的节点(如I2C设备下的子设备)则是<0>。配错了会导致内核完全无法解析地址,驱动必然失败。 -
引脚控制(Pinctrl)配置遗漏或错误:很多现代SoC的引脚功能是复用的。在设备树中启用一个外设(如UART)时,除了设置
status = “okay”,还必须通过pinctrl-0等属性指定正确的引脚复用配置。忘记配置pinctrl,外设可能根本无法在正确的物理引脚上工作,症状诡异。 -
时钟、电源、复位依赖:外设可能需要特定的时钟或电源域。设备树中需要引用正确的时钟控制器节点(
clocks = <&cru CLK_UART2>)并指定时钟名(clock-names = “baudclk”)。如果引用错误或时钟未启用,外设可能无法初始化或工作不正常。 -
兼容性字符串(compatible)的精确匹配:驱动通过
compatible字符串匹配设备。一个常见的错误是,设备树里的字符串和驱动里定义的字符串有细微差别,比如多了一个空格、大小写不一致、制造商名拼写不同。务必保持完全一致。 -
滥用
status = “okay”:不要为了省事,在.dtsi里把所有外设都设为okay。这可能导致资源冲突(如中断号、内存区域被多个未使用的外设占用),甚至增加功耗。正确的做法是,在.dtsi中全部disabled,在板级.dts中按需启用。
设备树是连接硬件和Linux内核的桥梁,理解DTS、DTSI、DTB和DTC的关系,是掌握嵌入式Linux系统定制和驱动开发的基石。从编写清晰、模块化的 .dtsi 和 .dts,到熟练使用 dtc 工具进行编译、反编译和调试,再到理解内核如何解析 .dtb 并与驱动交互,每一步都需要耐心和实践。希望这篇近万字的梳理,能帮你建立起关于设备树“三兄弟”和“翻译官”的完整知识图谱,在下次面对设备树相关问题时,能够游刃有余。