摘要
本报告旨在为嵌入式Linux开发者详细梳理设备树(Device Tree, DT)在系统启动中的完整解析流程。报告将从引导加载程序(Bootloader)如何准备和传递设备树二进制文件(DTB)开始,逐步深入到内核如何将其转化为可操作的内存数据结构,如何与设备驱动程序进行匹配与绑定,最终阐述驱动程序如何利用这些信息在sysfs
文件系统中创建可供用户空间访问的设备节点。这份报告旨在解答从静态的硬件描述到用户可见的动态设备文件这一过程中的所有核心技术问题。
引言:设备树的核心作用与演变
1.1 设备树的起源与必要性
设备树最初源于SPARC和PowerPC平台的Open Firmware项目,其设计初衷是为了在不修改操作系统内核的情况下,支持多种不同的硬件配置 。在ARM架构中,设备树的引入尤其关键,因为它彻底改变了传统的"一个板子一个内核"的困境 。
在设备树出现之前,Linux内核的硬件描述是硬编码在所谓的board-file
(如arch/arm/mach-xxx/board-yyy.c
)中的 。这意味着,每当硬件发生微小改动,例如更改了一个I2C外设的地址,就需要重新编译整个内核镜像 。这种紧耦合的设计使得内核开发难以扩展,也极大地增加了新板卡移植的复杂性。
设备树的出现标志着一种从硬编码到数据驱动的嵌入式软件开发范式转变。在旧的ARM启动机制中,引导加载程序使用一种名为ATAGS
(一个链表结构)的机制,只能传递内存大小、内核命令行等少量基本信息 。而其他所有非自发现(non-discoverable)的硬件信息,如I2C控制器地址、GPIO引脚配置等,都必须预先硬编码在内核源码的
board-file
里 。
与此形成鲜明对比的是,设备树二进制文件(DTB)包含了完整的硬件拓扑结构。引导加载程序只需将DTB的物理地址传递给内核(在ARM上,这一地址通常被加载到R2寄存器中),内核就能在运行时自主解析整个硬件配置 。这种转变将硬件描述从内核源码中移除,放入独立的文件(
.dts
)中,使得同一个内核镜像能够通过加载不同的DTB文件来支持多个不同的板卡 。这种解耦机制是现代Linux内核在嵌入式领域取得成功,特别是实现通用主线内核支持的关键。
1.2 设备树的基本构成
设备树本质上是一种描述硬件的数据结构,采用树形或非循环图的格式 。其基本结构由节点(nodes)和属性(properties)组成 。每个节点都代表一个设备或总线,并可以包含任意数量的命名属性和子节点 。
-
根节点(
/
):设备树的顶层节点,没有名称和地址 。 -
节点命名 :每个节点都遵循
node-name@unit-address
的命名惯例 。node-name
描述设备的类型,unit-address
则指定设备在其父总线地址空间中的基地址 。 -
核心属性:
-
#address-cells
和#size-cells
:这两个属性定义了子节点reg
属性中地址和大小字段的单元格(cell,即32位值)数量 。这是一种可扩展的地址表示机制。 -
compatible
(兼容性字符串):这是连接硬件描述与设备驱动程序的关键契约 。它是一个字符串列表,通常由vendor,device
组成,其中最具体的字符串排在最前面,最通用的排在最后 。驱动程序会使用这个字符串来声明自己支持哪些硬件 。 -
phandle
与aliases
:设备树允许节点之间通过phandle
进行引用 。这种引用通常通过&label
的形式来实现,其中label
是节点的标签 。此外,/aliases
节点为常用的节点路径提供了简短的别名,便于访问 。
-
第一章:启动加载阶段:DTB的准备与传递
2.1 DTB的生成与存储
设备树的生命周期始于其源文件。硬件开发者通常会编写设备树源文件(.dts
),并引用包含SoC级别通用定义的设备树包含文件(.dtsi
) 。这种
.dtsi
文件通过#include
指令实现模块化和重用,使得不同板卡可以共享SoC的硬件描述 。
这些.dts
和.dtsi
文件由设备树编译器(Device Tree Compiler, dtc
)处理,dtc
将人类可读的文本格式转换为紧凑的、面向机器的二进制格式,即设备树二进制文件(DTB,文件后缀为.dtb
)。生成的DTB文件通常与Linux内核镜像(如
zImage
或uImage
)一同存储在系统的非易失性存储介质(如SD卡、eMMC或NAND Flash)的启动分区中 。
2.2 U-Boot的职责与DTB的修改
引导加载程序(例如U-Boot)是DTB解析过程的第一阶段参与者 。它的主要职责是:
-
将内核镜像和DTB文件从存储介质加载到RAM中 。
-
在内存中对DTB进行必要的运行时修改,例如更新
chosen
节点中的bootargs
(内核命令行参数)、设置RAM的基地址和大小,或者配置网络接口的MAC地址等 。 -
加载完成后,U-Boot将最终的DTB准备好,并将其物理内存地址传递给内核 。
这些修改至关重要,它确保了内核接收到的硬件描述是最新、最准确的,能够反映引导加载程序在启动时根据用户配置或探测结果所做的任何调整。
2.3 DTB的关键传递机制
在ARM架构中,U-Boot将DTB的物理地址传递给内核的机制是一个设计精巧的关键环节 。在跳转到内核入口点之前,U-Boot会执行以下操作:
-
将CPU的
r0
寄存器设置为0。 -
将
r1
寄存器设置为机器类型ID(旧机制)。 -
将
r2
寄存器设置为DTB在系统RAM中的物理地址 。
然后,U-Boot跳转到内核的入口点,内核从此接管控制权 。下表对比了设备树机制与旧的ATAGS机制的差异,突出了前者在灵活性和可扩展性方面的优势。
表1:ATAGS与设备树传递机制对比
特性 | ATAGS (旧机制) | 设备树 (现代机制) |
---|---|---|
数据结构 | 一个链表,由一系列标签(tag)组成 | 一个扁平化、树状的二进制数据块(DTB) |
传递方式 | 引导加载程序通过r2 寄存器传递一个指向ATAGS链表头部的指针 |
引导加载程序通过r2 寄存器传递一个指向DTB二进制数据块的指针 |
描述内容 | 仅限于少量基本信息,如内存大小、命令行参数等 | 包含完整的硬件拓扑、外设地址、中断、GPIO等所有非自发现的硬件信息 |
优点 | 简单、开销小 | 硬件描述与内核代码完全解耦,支持通用内核镜像 |
缺点 | 缺乏灵活性,板级硬件信息需要硬编码在内核源码中 | 需要额外的dtc 工具编译,DTB文件需要占用一定内存空间 |
适用范围 | 已被弃用,不建议用于新平台 | 现代嵌入式Linux的首选,ARM等多种架构强制使用 |
第二章:内核早期启动:DTB的初步解析
3.1 内核入口点与DTB的接收
在U-Boot将控制权交给Linux内核后,内核的启动代码会开始执行。在start_kernel()
函数被调用之前,内核的架构特定启动代码(如ARM上的head.S
文件)会从r2
寄存器中读取DTB的物理地址 。这个地址随后会被保存到一个全局变量中,供内核后续使用 。
这个阶段非常关键,因为此时内存管理单元(MMU)尚未启用,内核只能访问物理地址 。这意味着内核无法进行复杂的动态内存分配和构建复杂的C数据结构。然而,它需要一些关键信息才能完成MMU的初始化,例如RAM的地址和大小,以及
bootargs
中的内核命令行参数 。
3.2 of_scan_flat_dt()
与libfdt
库
为了在MMU启用之前的受限环境中获取必要信息,内核使用一个轻量级的库来直接解析DTB的扁平化二进制数据 。这个库就是
libfdt
(Flattened Device Tree library)。
libfdt
被集成在内核源码中,提供了一系列低级API来检查、读取和修改DTB二进制文件 。
内核的启动代码会调用of_scan_flat_dt()
函数来遍历DTB,并使用回调函数来提取关键信息 。例如:
-
early_init_dt_scan_chosen()
:用于解析/chosen
节点下的内核命令行参数bootargs
。 -
early_init_dt_scan_memory()
:用于确定系统RAM的地址和大小 。
这种设计表明内核对DTB的解析并非一次性完成,而是分为两个阶段:早期扫描(pre-MMU)和后期解压(post-MMU)。这个设计是启动过程中内存和寻址限制所决定的。在MMU启用前,内核无法进行复杂的动态内存分配,因此需要libfdt
这种能够在物理内存中直接操作DTB二进制数据的工具。
3.3 DTB的"解压":从扁平化到树形结构
当内核完成了MMU的初始化和虚拟内存的配置后,它就可以进行更复杂的内存操作。此时,一个关键的函数unflatten_device_tree()
会被调用 。这个函数的核心任务是将静态的DTB二进制数据转换成一个动态的、链表连接的内存数据结构:
struct device_node
树 。
unflatten_device_tree()
的执行标志着DTB的静态描述阶段结束,进入了DT在内存中作为动态数据结构的活跃阶段。这种两阶段解析过程是内核为了在有限制的早期启动环境中获取关键信息,同时在成熟的运行时环境中构建高效、易于访问的数据结构之间做出的权衡。
第三章:DT的内存表示与驱动程序的接口
4.1 struct device_node
:DT在内存中的实体
DTB经过unflatten_device_tree()
的转换后,在内核中被表示为一系列相互连接的struct device_node
实例 。
struct device_node
是DT中每个节点的内存表示,它包含了指向其父节点、子节点、兄弟节点和属性列表的指针,从而构成了完整的树形结构 。
驱动程序不会直接操作DTB二进制数据,而是通过一套标准的of_*
家族API来与这个struct device_node
树进行交互 。这些API包括:
-
of_get_next_child()
:用于遍历一个节点的所有子节点 。 -
of_find_compatible_node()
:用于根据兼容性字符串在DT树中查找匹配的节点 。 -
of_get_property()
:用于读取一个节点中特定属性的值 。
这些高级API将复杂的树形遍历和数据解析细节封装起来,为驱动程序提供了一个简洁、高效的接口。
4.2 DT与内核设备模型的整合
设备树的解析为Linux设备模型提供了设备实例化的数据来源。Linux设备模型的核心思想是围绕着总线(bus
)、设备(device
)和驱动(driver
)这三元组来组织的 。
DT中的节点本身并不是Linux设备模型中的"设备",而只是对硬件的静态描述 。内核在解析DT后,会根据这些静态数据来动态创建
struct device
实例。对于SoC上那些无法被自动枚举的内存映射设备,内核为此创建了一个虚拟总线------platform_bus
。
of_platform_populate()
函数是连接DT解析和Linux设备模型的关键桥梁 。它会遍历DT树,找到那些具有
compatible
属性的节点,并为它们创建对应的platform_device
实例,然后将这些设备注册到platform_bus
上 。对于其他总线类型(如I2C、SPI),其总线驱动的
probe
函数会负责为其子节点创建相应的设备实例(如i2c_client
),并注册到各自的总线上 。
这个过程将设备描述从静态数据转换为了内核设备模型中的动态实例,从而实现了从设计蓝图到可操作实体的转变。
第四章:驱动程序绑定与设备实例
5.1 设备与驱动的匹配模型
在Linux设备模型中,一个设备只有在找到与其匹配的驱动程序时,才会被"激活"并进行初始化 。当一个新的设备实例(例如一个
platform_device
)被注册到总线上时,内核的驱动核心会自动遍历所有已注册的驱动程序,寻找与该设备匹配的项 。
platform_bus
上的匹配过程由platform_match()
函数实现 。该函数会尝试多种匹配方式,其中最重要的一种就是基于设备树的兼容性匹配 。
5.2 DT与驱动的"兼容"桥梁
设备树驱动绑定机制的核心是compatible
字符串 。这是一种在设备和驱动之间建立"契约"的机制:
-
DT端 :设备树节点通过
compatible
属性来声明其所代表的硬件类型 。该属性是一个字符串列表,由最具体的兼容性字符串到最通用的兼容性字符串排列 。 -
驱动端 :驱动程序通过定义一个名为
of_match_table
的const struct of_device_id
数组来声明自己支持的compatible
字符串 。这个数组通常通过MODULE_DEVICE_TABLE(of,...)
宏导出,供内核驱动核心识别 。
匹配过程由of_match_device()
函数执行 。当一个
platform_device
被注册时,内核会调用此函数,将设备的compatible
字符串列表与所有已注册驱动的of_match_table
进行比较,如果找到了匹配项,则认为匹配成功 。
表2:DT节点与驱动匹配示例
模块 | 关键元素 | 示例内容 | 作用 |
---|---|---|---|
设备树 | DT节点路径 | &i2c1 {... my_device@4a {... } } |
定义一个在I2C总线1上,地址为0x4a的设备 |
compatible 属性 |
compatible = "acme-inc,my-device"; |
声明该设备的硬件兼容性字符串 | |
驱动程序 | of_match_table |
static const struct of_device_id my_device_of_match = { {.compatible = "acme-inc,my-device", }, { } }; MODULE_DEVICE_TABLE(of, my_device_of_match); |
声明该驱动程序支持"acme-inc,my-device"这个兼容性字符串 |
probe() 函数 |
static int my_device_probe(struct platform_device *pdev) {... } |
匹配成功后,内核调用的初始化函数 |
上表展示了compatible
字符串如何成为连接设备树和驱动程序的"魔术字符串"。一个在设备树中声明的设备节点,只有当其compatible
字符串与某个驱动程序的of_match_table
中的一项完全匹配时,才会被内核成功绑定。
5.3 probe()
函数的调用与驱动的参与
当内核找到匹配的设备与驱动后,它会自动调用驱动程序提供的probe()
函数 。例如,对于
platform_driver
,其platform_driver
结构体中的probe()
函数会被调用,并将对应的platform_device
实例作为参数传入 。
probe()
函数是驱动程序参与设备初始化的起点 。在该函数中,驱动程序会:
-
获取DT节点 :从传入的
platform_device
实例中,通过dev->of_node
指针获取到对应的struct device_node
。 -
读取属性 :使用
of_*
家族API(例如of_get_property()
、of_get_next_child()
)从DT节点中读取硬件配置信息 。这些信息可能包括内存映射地址(reg
)、中断号(interrupts
)、GPIO引脚配置(gpios
)等 。 -
初始化硬件:利用读取到的信息,驱动程序对硬件进行具体的初始化和配置 。例如,I2C控制器驱动会根据DT中的
clock-frequency
属性设置总线速度 。 -
注册设备:完成初始化后,驱动程序会向内核注册更高层级的设备接口,例如字符设备、块设备或网络设备 。
第五章:从DT节点到/sys
目录的最终映射
6.1 kobject
基础设施与设备模型的基石
/sys
文件系统是一个虚拟文件系统,它的主要功能是将内核中的数据结构和关系以目录和文件的形式,层次化地暴露给用户空间 。
sysfs
的核心是kobject
。
kobject
是Linux内核中用于描述和管理对象的通用基础设施 。每个
kobject
都代表内核中的一个对象,并在/sys
中对应一个目录。struct device
(如platform_device
)是内嵌了kobject
的更高级抽象 。当
struct device
被注册时,其内嵌的kobject
也会被注册,从而在sysfs
中自动创建相应的目录结构 。
6.2 probe()
函数中的/sys
目录生成
当驱动程序的probe()
函数成功返回后,内核设备模型会注册这个设备实例 。这个注册过程会自动触发
kobject
的创建和注册,从而在/sys/devices/platform/...
或/sys/bus/i2c/devices/...
等目录下创建对应的设备目录 。这些目录的名称通常基于设备的
name
和id
。
6.3 驱动程序如何创建属性文件
设备目录创建后,驱动程序可以在其中添加属性文件,以暴露设备的运行时状态或提供配置接口 。这些属性文件由
struct device_attribute
结构体定义,其中包含了文件的名称、访问权限以及两个关键的回调函数:show()
和store()
。
-
show()
:当用户空间应用程序通过cat
等命令读取/sys
下的属性文件时,内核会调用这个函数 。驱动程序会执行相应的逻辑,将设备的实时状态或信息写入内核提供的缓冲区,然后返回给用户空间 。 -
store()
:当用户写入属性文件时,内核会调用这个函数 。驱动程序会接收用户写入的数据,并据此配置或控制硬件 。
值得注意的是,用户在/sys
下看到的设备节点并非DT节点的直接镜像,而是内核设备模型在运行时生成的抽象。DT节点是静态的硬件描述 ,而/sys
文件是动态的软件接口 。DTB的原始二进制数据可以在/sys/firmware/devicetree/base
或/proc/device-tree
下以只读形式访问 。而
/sys/devices/...
下的文件则是由设备驱动程序在probe()
阶段动态创建的。/sys
接口的背后是show()
和store()
回调函数实现的具体设备控制逻辑,这些逻辑根据DT中提供的静态数据(如寄存器地址、中断号)来操作硬件 。因此,
/sys
是内核提供给用户空间的标准化接口,而DT则是驱动程序实现这些接口所需的"设计蓝图"。
结论与展望
7.1 完整流程回顾
设备树在Linux内核启动过程中扮演着至关重要的角色,其解析流程可概括为以下步骤:
-
DTB的准备 :开发者使用
dtc
工具将.dts
源文件编译成DTB二进制文件,并将其与内核镜像一同存储在启动介质中 。 -
DTB的传递:引导加载程序(如U-Boot)将DTB加载到RAM中,进行必要的运行时修改,并将DTB的物理地址通过特定的CPU寄存器(如ARM上的R2)传递给内核 。
-
早期解析 :内核在早期启动阶段(pre-MMU),利用
libfdt
库调用of_scan_flat_dt()
函数,提取内存布局和bootargs
等关键信息 。 -
构建数据结构 :MMU启用后,
unflatten_device_tree()
函数将DTB的扁平化二进制数据转换成可供内核高效访问的struct device_node
树形数据结构 。 -
设备实例化 :内核设备模型遍历
device_node
树,根据节点的compatible
属性创建对应的platform_device
或其他总线设备实例 。 -
驱动绑定与
probe
:内核驱动核心将新创建的设备实例与已注册的驱动程序的of_match_table
进行匹配。匹配成功后,内核调用驱动的probe()
函数 。 -
sysfs
文件创建 :在probe()
函数中,驱动程序根据DT信息初始化硬件,并利用kobject
基础设施在/sys
目录下创建设备目录和属性文件,从而向用户空间暴露可读写的设备接口 。
7.2 DT的未来与意义
设备树机制的引入是嵌入式Linux发展的一个里程碑,它将硬件描述从内核源码中分离,使开发者能够使用同一个内核二进制文件来支持不同的硬件平台 。这种从硬编码到数据驱动的范式转变极大地提高了内核的可维护性和可移植性。展望未来,设备树仍然是现代嵌入式系统不可或缺的一部分。例如,设备树Overlay(DTO)机制允许在运行时动态地加载和应用硬件配置,使得系统能够支持热插拔设备或可配置的扩展板 。设备树的持续演进和广泛应用确保了Linux在快速变化的嵌入式硬件生态中保持其强大的适应性和灵活性。