文章目录
- [Linux 内核底层机制:设备树(Device Tree)的数据结构与内存组织](#Linux 内核底层机制:设备树(Device Tree)的数据结构与内存组织)
- 一、核心结论
- [二、属性层级:单向链表(struct property)](#二、属性层级:单向链表(struct property))
-
- [2.1 内核结构体定义](#2.1 内核结构体定义)
- [2.2 内存组织示例](#2.2 内存组织示例)
- [2.3 为什么采用单向链表?](#2.3 为什么采用单向链表?)
- [三、节点层级:First Child / Next Sibling 多叉树(struct device_node)](#三、节点层级:First Child / Next Sibling 多叉树(struct device_node))
-
- [3.1 内核结构体定义](#3.1 内核结构体定义)
- [3.2 为什么不是子节点数组?](#3.2 为什么不是子节点数组?)
- [3.3 内存组织示例](#3.3 内存组织示例)
- [3.4 节点遍历方式](#3.4 节点遍历方式)
- [3.5 Overlay 与 RCU](#3.5 Overlay 与 RCU)
- 四、核心对比
- 五、总结
- [六、设备树的生命周期:从 DTB 到驱动绑定](#六、设备树的生命周期:从 DTB 到驱动绑定)
-
-
- [6.1 阶段一:获取基础启动信息 ------ `early_init_dt_scan()`](#6.1 阶段一:获取基础启动信息 ——
early_init_dt_scan()) - [6.2 阶段二:构建内存设备树 ------ `unflatten_device_tree()`](#6.2 阶段二:构建内存设备树 ——
unflatten_device_tree()) - [6.3 阶段三:生成总线设备触发驱动匹配 ------ `of_platform_populate()`](#6.3 阶段三:生成总线设备触发驱动匹配 ——
of_platform_populate()) - [6.4 核心流程总结图](#6.4 核心流程总结图)
- [6.1 阶段一:获取基础启动信息 ------ `early_init_dt_scan()`](#6.1 阶段一:获取基础启动信息 ——
-
Linux 内核底层机制:设备树(Device Tree)的数据结构与内存组织
Linux 内核中的设备树(Device Tree,简称 DT)用于描述硬件平台信息。内核启动时,会将设备树二进制文件(DTB)解析为一系列 struct device_node 和 struct property 对象,并组织成一棵完整的设备树,供驱动程序查询和匹配。
设备树在内存中的组织主要分为两个层级:
- 节点(Device Node):表示一个硬件设备或总线。
- 属性(Property) :表示节点内部的配置项,例如
compatible、reg、status等。
由于两者承担的职责不同,因此 Linux 内核采用了不同的数据结构进行组织。
一、核心结论
Linux 内核针对设备树的两个层级采用了不同的数据结构:
- 设备树属性(Property) :采用单向链表组织。
- 设备树节点(Device Node) :采用 First Child / Next Sibling(左孩子-右兄弟)表示法构建一棵多叉树。
对于现代 Linux(5.x/6.x)内核,节点遍历主要依赖树形结构和遍历接口(Iterator);而早期 Linux 内核(4.x 及以前)曾额外维护一条 allnext 全局单向链表用于快速遍历所有节点。
二、属性层级:单向链表(struct property)
设备树中的属性(Property)表示节点的具体配置项,例如:
compatibleregstatusinterruptsmax-brightness-levels
每个节点都拥有自己的属性链表。
2.1 内核结构体定义
源码位置:
text
include/linux/of.h
c
struct property {
char *name; /* 属性名称 */
int length; /* 属性值长度(字节) */
void *value; /* 属性值 */
struct property *next; /* 下一个属性 */
unsigned long _flags;
unsigned int unique_id;
};
其中真正用于组织属性链表的是:
c
struct property *next;
因此,一个节点内部所有属性都是通过单向链表连接起来的。
2.2 内存组织示例
例如下面的设备树:
dts
&i2c1 {
status = "okay";
gp7101@58 {
compatible = "gp7101-backlight";
reg = <0x58>;
max-brightness-levels = <255>;
default-brightness-level = <100>;
};
};
解析后的属性组织如下:
properties
│
▼
+------------------------------+
| compatible |
| value = "gp7101-backlight" |
| next ----------------------┐ |
+----------------------------│-+
▼
+------------------------------+
| reg |
| value = <0x58> |
| next ----------------------┐ |
+----------------------------│-+
▼
+------------------------------+
| max-brightness-levels |
| value = <255> |
| next ----------------------┐ |
+----------------------------│-+
▼
+------------------------------+
| default-brightness-level |
| value = <100> |
| next = NULL |
+------------------------------+
整个节点内部形成一条单向链表。
2.3 为什么采用单向链表?
设备树属性具有以下特点:
- 每个节点通常只有几个到十几个属性;
- 驱动程序主要根据属性名顺序查找对应属性;
- 属性数量较少,不需要复杂的数据结构;
- 大多数情况下属性在设备树展开后保持稳定,驱动主要以只读方式访问。
因此,Linux 采用最简单的单向链表即可满足需求,实现简单且内存开销较低。
例如:
c
of_property_read_u32(np, "reg", &value);
其内部最终就是遍历属性链表,根据 name 找到对应的 Property。
需要说明的是,对于支持 Device Tree Overlay 的系统,属性也可能在运行时动态增加或删除,因此内核保留了 deadprops 等机制管理失效属性,并通过 RCU 保证并发访问的安全性。
三、节点层级:First Child / Next Sibling 多叉树(struct device_node)
设备树中的节点(Device Node)表示一个硬件设备。
例如:
dts
i2c@40000000
serial@50000000
gp7101@58
gpio@10000000
每个节点对应一个:
c
struct device_node
对象。
3.1 内核结构体定义
源码位置:
text
include/linux/of.h
c
struct device_node {
const char *name;
phandle phandle;
const char *full_name;
struct fwnode_handle fwnode;
struct property *properties;
struct property *deadprops;
struct device_node *parent;
struct device_node *child;
struct device_node *sibling;
#if defined(CONFIG_OF_KOBJ)
struct kobject kobj;
#endif
unsigned long _flags;
void *data;
};
描述树结构的关键成员只有三个:
c
parent
child
sibling
3.2 为什么不是子节点数组?
Linux 并没有为每个节点维护:
c
children[100]
这样的子节点数组。
原因是:
不同节点拥有的子设备数量完全不同。
例如:
root
├── cpu
├── memory
├── gpio
├── uart
├── spi
├── i2c
├── usb
└── ...
有的节点只有一个子节点,
有的可能几十个。
如果使用数组:
- 需要预留大量空间;
- 插入删除效率低;
- 内存浪费严重。
因此 Linux 采用了经典的数据结构:
First Child / Next Sibling(左孩子-右兄弟)表示法
每个节点只需要三个指针:
parent
child
sibling
即可表示任意规模的多叉树。
3.3 内存组织示例
例如:
dts
/ {
i2c@40000000 {
gp7101@58 {
};
sensor@60 {
};
};
uart@50000000 {
};
};
解析后形成如下关系:
[ root ]
│
│ child (大儿子)
▼
[ i2c ] ───────── sibling (二弟) ────────▶ [ uart ] ── sibling ──▶ NULL
│ │
│ child (大儿子) │ child (无子节点 无子节点!!!)
▼ ▼
[ gp7101 ] ─────── sibling (二弟) ────▶ [ sensor ] ── sibling ──▶ NULL
│ │
│ child │ child
▼ ▼
NULL NULL
对应关系:
root
├── i2c
│ ├── gp7101
│ └── sensor
│
└── uart
整个树只依赖:
parent
child
sibling
三个指针即可表示。
3.4 节点遍历方式
现代 Linux 内核(5.x/6.x)主要基于树形结构进行遍历,例如:
- 遍历子节点;
- 查找父节点;
- 深度优先遍历(DFS);
- 使用设备树遍历接口(Iterator)。
例如:
c
for_each_child_of_node(parent, child)
就是沿着:
child → sibling → sibling
依次访问所有子节点。
需要注意的是,**早期 Linux 内核(4.x 及以前)**曾在 struct device_node 中维护一个 allnext 指针,将所有节点串成一条全局单向链表,方便快速遍历整个设备树。
而在现代 Linux 内核中,allnext 成员已经移除,节点遍历主要依赖树形结构和专门的遍历接口,不再维护独立的全局节点链表。
3.5 Overlay 与 RCU
现代 Linux 支持 Device Tree Overlay,可以在系统运行过程中动态增加或删除节点及属性。
为了保证遍历期间的数据一致性,设备树相关数据结构采用 RCU(Read-Copy-Update) 机制进行保护:
- 读端通常无需加锁即可访问设备树;
- 写端修改节点或属性时,会在 RCU 机制下更新指针;
- 待所有读者退出临界区后,再释放旧数据。
因此,无论节点还是属性,在支持 Overlay 的场景下,都能够实现安全的并发访问。
四、核心对比
| 对比项 | Property | Device Node |
|---|---|---|
| 对应结构体 | struct property |
struct device_node |
| 数据结构 | 单向链表 | First Child / Next Sibling(左孩子-右兄弟)表示的多叉树 |
| 关键指针 | next |
parent、child、sibling |
| 典型访问方式 | 顺序遍历属性链表 | 遍历父子关系、兄弟关系或使用 Iterator |
| 生命周期 | 基本稳定,可被 Overlay 修改 | 基本稳定,可被 Overlay 动态增删 |
| 并发保护 | Overlay 修改时由 RCU 保护 | RCU |
| 设计目标 | 保存节点配置项 | 表达硬件拓扑关系 |
五、总结
Linux 内核根据设备树不同层级的特点,采用了不同的数据结构:
- **属性(Property)**采用单向链表组织。由于每个节点的属性数量较少,驱动主要按照属性名顺序查找,因此单向链表实现简单、内存占用低,能够满足绝大多数访问需求。
- **节点(Device Node)**采用 First Child / Next Sibling(左孩子-右兄弟)表示法 构建多叉树,仅依靠
parent、child和sibling三个指针即可描述任意复杂的硬件拓扑关系。
此外,现代 Linux 内核支持 Device Tree Overlay,允许设备树在运行时动态增删节点和属性。为了保证遍历期间的并发安全,内核使用 RCU(Read-Copy-Update) 对相关数据结构进行保护,使读操作无需加锁,同时保证写操作的安全性。
理解设备树节点与属性在内存中的组织方式,有助于深入理解 Linux 内核中设备树的解析过程、驱动匹配机制以及设备枚举流程,也能够帮助开发者更准确地使用设备树相关 API 进行驱动开发。
六、设备树的生命周期:从 DTB 到驱动绑定
前面我们了解了设备树在内存中的数据结构(单向链表与多叉树),那么内核是如何一步步将编译好的二进制文件(DTB)转化为这些数据结构,并最终和驱动程序绑定起来的呢?
整个过程主要经历三个核心阶段:早期扫描、解构设备树、以及平台设备转换。这三步对应了三个至关重要的内核函数:
6.1 阶段一:获取基础启动信息 ------ early_init_dt_scan()
在内核启动极早期(setup_arch() 阶段),内存管理子系统(如 buddy system)还未建立。此时内核无法大规模分配内存来构建树形结构,只能对 DTB 进行原地扫描。
- 执行动作: 内核直接读取物理内存中扁平的 DTB 数据。
- 核心任务:
- 校验 DTB 的 Magic Number,确认设备树合法。
- 扫描
/chosen节点,获取内核启动参数(bootargs)和 initrd 地址。 - 扫描根节点,获取
#address-cells和#size-cells。 - 扫描
/memory节点,获取系统物理内存的基地址和大小,从而初始化早期内存分配器(memblock)。
- 状态总结: 此时设备树依然是扁平的二进制数据(FDT),并没有生成
struct device_node。
6.2 阶段二:构建内存设备树 ------ unflatten_device_tree()
当 memblock 早期内存分配器就绪后,内核终于有了分配内存的能力。此时,内核会将扁平的 DTB "解压"成我们在第三节提到的 First Child / Next Sibling 多叉树。
-
执行动作: 解析 DTB,动态分配内存,实例化节点与属性。
-
核心任务:
-
通常包含两轮遍历(Pass 1 & Pass 2):
-
第一轮: 快速遍历一遍 DTB,计算出所有的
device_node和property需要占用多少总内存空间,并一次性分配。 -
第二轮: 真正开始解析数据,填充各个
struct device_node和struct property的指针成员(即前面提到的parent、child、sibling和next),建立完整的树形和链表关系。 -
状态总结: 执行完毕后,全局指针
of_root指向设备树的根节点,此时 逻辑设备树 已经在内存中完全建立。
6.3 阶段三:生成总线设备触发驱动匹配 ------ of_platform_populate()
设备树只是提供硬件信息的"数据库",要让驱动程序跑起来,内核需要将这些信息转化为 Linux 设备模型(Device Model)认识的 struct platform_device 对象。
-
执行动作: 遍历
of_root设备树,将符合条件的节点转换为平台设备(Platform Device)并注册到内核的 platform 总线上。 -
核心任务:
-
遍历根节点下的子节点,以及带有
simple-bus等兼容属性的节点。 -
为这些节点分配并初始化
struct platform_device。 -
将设备树节点指针(
np)绑定到platform_device.dev.of_node上。 -
调用
device_register()将设备挂载到 platform 总线上。 -
状态总结: 一旦设备挂载到总线上,就会触发总线的
match机制。总线会对比设备的compatible属性与驱动程序的of_match_table。一旦匹配成功,就会调用驱动程序的probe()函数,完成最终的驱动绑定。
6.4 核心流程总结图
为了更直观地理解,你可以用下面这张图来记忆整个生命周期:
text
+-------------------+
| DTB 文件 | (Bootloader 传递到内存)
+--------+----------+
│
▼
[ early_init_dt_scan() ]
提取 memory、chosen 参数,初始化早期内存
│
▼
[ unflatten_device_tree() ]
构建 struct device_node (左孩子-右兄弟)
构建 struct property (单向链表)
│
▼
[ of_platform_populate() ]
遍历树,实例化 struct platform_device,注册到总线
│
▼
+-------------------+
| Platform Bus匹配 | (根据 compatible 属性)
+--------+----------+
│
▼
[ 驱动 probe() 运行 ]
通过 dev->of_node 再次读取特定 Property,初始化硬件
面试连招提醒:
大厂面试官如果在考察这部分时,通常会追问:"是不是所有的设备树节点都会被转换成 platform_device? "
你的回答应该是:不是 。比如 I2C 和 SPI 总线下的子设备节点,是由对应的 I2C/SPI 总线控制器驱动在自身的 probe() 函数中解析并注册为 i2c_client 或 spi_device 的,它们挂在对应的 I2C/SPI 总线上,而不是直接挂在顶层的 Platform 总线上。