一、为什么需要设备树?------ 从平台总线的痛点说起
在前文的平台总线驱动开发中,我们通过手动编写dev_platform.c注册平台设备,实现了「硬件资源描述」和「驱动逻辑」的初步分离,但这种方式仍存在两个核心痛点:
- 硬件修改仍需改动代码 :更换按键 GPIO 引脚时,必须修改
dev_platform.c,重新编译内核模块,无法做到「一次编译,适配多硬件」; - 内核板级代码冗余:在设备树出现之前,ARM 架构 Linux 内核中存在大量针对不同开发板的板级描述代码,每一款开发板都要在内核中添加对应的硬件描述文件,导致内核源码冗余臃肿。
设备树(Device Tree) 就是为了解决这些问题诞生的:它是一种专门描述硬件的数据结构,将硬件资源描述完全独立于内核代码之外,通过单独的.dts文件编写,编译成二进制.dtb文件后,由 bootloader 传递给内核。内核启动时解析 dtb 文件,自动生成对应的平台设备、I2C 设备、SPI 设备等,无需我们再手动编写任何设备端代码。
二、设备树核心基础概念
先理清设备树生态中最核心的 6 个名词,对应文件的关系和作用如下表:
| 缩写 | 全称 | 中文名称 | 核心作用 |
|---|---|---|---|
| DT | Device Tree | 设备树 | 描述硬件数据结构的总称,树形结构组织硬件信息 |
| DTS | Device Tree Source | 设备树源文件 | 用 ASCII 码编写的硬件描述文件,对应一款开发板的硬件配置,存放于内核arch/xxx/boot/dts目录 |
| DTSI | Device Tree Source Include | 设备树头文件 | 类似 C 语言的.h头文件,可被多个 DTS 文件引用,通常存放同一款芯片的通用硬件配置 |
| DTC | Device Tree Compiler | 设备树编译器 | 内核自带的编译工具,将 DTS/DTSI 文件编译成二进制 DTB 文件 |
| DTB | Device Tree Blob | 设备树二进制文件 | DTS 编译后的产物,bootloader 启动内核时,会将 DTB 加载到内存并把地址传递给内核,内核解析 DTB 生成硬件设备 |
| OF | Open Firmware | 开放固件 | 设备树的起源标准,内核中所有设备树相关的 API 都以of_前缀命名 |
核心工作流程
- 针对开发板编写 DTS 文件,引用芯片通用的 DTSI 文件,添加自定义硬件节点;
- 用 DTC 将 DTS 编译为 DTB 二进制文件;
- bootloader 启动内核时,将 DTB 加载到内存,把 DTB 的内存地址传递给内核;
- 内核启动时解析 DTB 文件,根据节点内容自动生成对应的硬件设备,注册到对应总线(平台总线、I2C 总线等)。
三、设备树语法规范与节点编写
设备树是一个树形结构 ,由根节点、子节点、属性三部分组成,和电脑的文件夹结构完全一致:根节点是/,子节点是文件夹,属性是文件夹里的文件。
3.1 设备树的基本结构
一个最简的设备树文件结构如下:
dts
cpp
/dts-v1/; // 设备树版本,必须指定为v1,否则编译器视为过时的v0版本
/ { // 根节点,所有设备树文件有且只有一个根节点,DTSI的根节点编译时会和主DTS合并
// 根节点属性:描述开发板和芯片信息
model = "NanoPC-T4 RK3399 Development Board";
compatible = "rockchip,nanopi4", "rockchip,rk3399";
// 子节点1:自定义按键节点
my_key: key_test {
// 节点属性
compatible = "my,key_test";
status = "okay";
gpios = <&gpio0 5 GPIO_ACTIVE_LOW>;
debounce-interval = <20>;
};
// 子节点2:其他硬件节点(如GPIO、I2C、SPI控制器等)
uart0: serial@ff180000 {
compatible = "rockchip,rk3399-uart";
reg = <0x0 0xff180000 0x0 0x100>;
status = "okay";
};
};
3.2 节点的编写规范
节点的通用编写格式如下:
dts
cpp
[label:] node-name[@unit-address] {
[properties definitions] // 属性定义
[child nodes] // 子节点
};
各部分说明:
label::标签,可选,用于在其他位置通过&label引用该节点,修改节点内容;node-name:节点名,必须以字母开头,只能包含数字、字母、下划线、破折号等字符,建议使用反映设备功能的通用名称(如key、led、gpio);@unit-address:设备地址,可选,用于描述外设的寄存器起始地址,无地址的外设(如按键)可省略。
3.3 常用属性类型
属性是设备树描述硬件信息的核心,由「属性名 = 属性值」组成,内核中最常用的属性类型如下
| 属性类型 | 格式说明 | 示例 | 适用场景 |
|---|---|---|---|
| 字符串类型 | 用双引号""包裹 |
compatible = "my,key_test"; |
设备匹配标识、型号描述 |
| 字符串列表 | 多个字符串用逗号分隔 | compatible = "rockchip,nanopi4", "rockchip,rk3399"; |
多兼容匹配标识 |
| 32 位无符号整数 | 用尖括号<>包裹 |
debounce-interval = <20>; |
引脚编号、延时时间、寄存器地址 |
| 32 位整数数组 | 尖括号内多个数字用空格分隔 | reg = <0x0 0xff180000 0x0 0x100>; |
寄存器地址范围、多组数值 |
| 二进制类型 | 用方括号[]包裹 |
mac-address = [00 11 22 33 44 55]; |
MAC 地址、二进制数据 |
| 空属性 | 只有属性名,无属性值 | gpio-key,wakeup; |
布尔型功能开关,存在即表示开启 |
3.4 驱动开发核心属性
在平台总线驱动开发中,有两个属性是核心中的核心:
compatible属性 :设备与驱动匹配的唯一标识,格式为厂商,设备名,必须和驱动中of_match_table里的字符串完全一致,否则无法匹配;status属性 :设备的使能状态,status = "okay"表示使能该设备,status = "disabled"表示禁用该设备。
四、平台总线的设备树匹配规则(核心)
在前文中我们讲过,平台总线的匹配逻辑在内核platform_match函数中实现,优先级从高到低共 4 种,设备树匹配的优先级高于手动 name 匹配,完整匹配顺序如下:
- 驱动强制匹配:通过设备的
driver_override字段强制匹配; - 设备树匹配(本文使用) :通过驱动的
of_match_table中的compatible属性,和设备树节点的compatible属性匹配,一致则匹配成功; - ID 表匹配:通过驱动的
id_table数组匹配设备名称; - 名称匹配:对比设备的
name和驱动的driver.name字符串。
设备树匹配的完整工作流程
- 内核启动时解析设备树 DTB 文件,根据节点内容生成
platform_device平台设备,注册到平台总线的设备链表; - 我们加载驱动模块时,驱动注册到平台总线的驱动链表;
- 平台总线自动遍历设备链表,对比驱动
of_match_table中的compatible和设备树节点的compatible; - 字符串完全一致则匹配成功,总线自动触发驱动的
probe函数,执行硬件初始化; - 驱动卸载时,总线自动触发
remove函数释放资源。
核心优势:硬件信息完全写在设备树中,更换硬件只需要修改 DTS 文件、重新编译 DTB,无需修改驱动代码、无需重新编译驱动模块,真正实现了「驱动代码与硬件描述完全解耦」。
五、实战:将按键驱动改造为设备树匹配方式
我们基于前文的平台总线按键驱动,完成设备树适配改造,改造后无需再编写和加载dev_platform.c设备端模块,所有硬件信息由设备树提供。
5.1 步骤 1:编写按键设备树节点
我们使用的是 NanoPC-T4 开发板(RK3399 芯片),按键使用 GPIO0_B5(对应 GPIO 编号 5),按下为低电平。
操作步骤
-
进入内核源码的设备树目录:
bashcd linux-sdk/kernel/arch/arm64/boot/dts/rockchip/ -
编辑通用 dtsi 文件(NanoPC-T4 的通用配置文件):
bashvim rk3399-nanopi4-common.dtsi -
在根节点
/下添加如下按键节点:dts
bash/* 自定义按键设备树节点 */ my_key: key_test { compatible = "my,key_test"; // 【匹配核心】必须和驱动中的compatible完全一致 status = "okay"; // 使能该设备 gpios = <&gpio0 5 GPIO_ACTIVE_LOW>; // 按键GPIO:GPIO0_5,低电平有效 debounce-interval = <20>; // 消抖时间20ms }; -
保存退出,编译设备树,生成新的 DTB 文件:
make ARCH=arm64 nanopi4-images -j8 -
将新的 DTB 文件烧录到开发板,重启开发板。
节点验证
开发板重启后,执行以下命令,验证设备树节点是否正常生成:
bash
# 查看平台总线设备,能看到key_test节点,说明设备树解析成功
ls /sys/bus/platform/devices/ | grep key_test
# 查看设备树节点属性
cat /sys/firmware/devicetree/base/key_test/compatible
5.2 步骤 2:驱动代码核心改造点
对比前文的手动匹配驱动,设备树适配的驱动有 3 个核心修改点:
- 添加设备树匹配表
of_match_table:定义compatible匹配标识,和设备树节点一致; - 移除硬编码 GPIO 获取 :不再从
platform_data获取 GPIO 号,改为通过of_系列 API 从设备树节点解析 GPIO; - 完善设备树节点合法性校验 :在
probe函数中校验设备树节点是否存在,属性是否合法。
同时需要新增两个头文件:
cpp
#include <linux/of.h> // 设备树核心API头文件
#include <linux/of_gpio.h> // 设备树GPIO解析专用API头文件
5.3 步骤 3:设备树适配版完整驱动代码
cpp
#include <linux/module.h> // 模块编程核心头文件
#include <linux/init.h> // 模块初始化/卸载头文件
#include <linux/platform_device.h> // 平台总线驱动头文件
#include <linux/fs.h> // 文件操作头文件
#include <linux/miscdevice.h> // 杂项设备头文件
#include <linux/gpio.h> // GPIO操作库头文件
#include <linux/uaccess.h> // 内核/应用层数据交换头文件
#include <linux/interrupt.h> // 中断处理头文件
#include <linux/workqueue.h> // 工作队列(中断下半部)头文件
#include <linux/delay.h> // 内核延时头文件
#include <linux/atomic.h> // 原子操作头文件(并发安全)
#include <linux/of.h> // 【设备树新增】设备树核心API头文件
#include <linux/of_gpio.h> // 【设备树新增】设备树GPIO解析API头文件
#define KEY_NAME "key_test" // 设备名称,用于GPIO申请和中断注册
/* 全局变量优化:用原子变量替代普通int,解决中断下半部的并发安全问题 */
atomic_t flag = ATOMIC_INIT(0); // 按键状态标记:0=松开,1=按下(原子初始化)
/* 硬件相关全局变量(仅在驱动匹配成功后赋值,避免未初始化访问) */
int key_gpio = 0; // 按键GPIO号(从设备树节点解析获取)
int irq = 0; // 按键对应的中断号(通过GPIO号映射获取)
int debounce_interval = 20; // 消抖时间(从设备树节点解析获取)
/* 工作队列结构体:用于中断下半部处理消抖、电平判断等耗时操作 */
struct work_struct key_work;
/*
* 中断服务函数(中断上半部:快进快出,禁止耗时操作)
* 触发条件:按键按下/松开时,GPIO电平变化触发硬件中断
*/
irqreturn_t key_irq_handler(int irq, void *arg)
{
/* 上半部只做最紧急的事:调度工作队列到下半部 */
schedule_work(&key_work);
return IRQ_HANDLED; // 告诉内核:中断已成功处理
}
/*
* 工作队列处理函数(中断下半部:可安全执行延时、打印等耗时操作)
* 触发条件:上半部调度后,内核在进程上下文中自动调用
*/
void key_work_func(struct work_struct *workp)
{
u32 curr_level;
/* 1. 临时关闭中断,防止按键抖动重复触发 */
disable_irq(irq);
/* 2. 读取当前GPIO电平,延时消抖,再次读取确认状态稳定 */
curr_level = gpio_get_value(key_gpio);
msleep(debounce_interval); // 使用从设备树获取的消抖时间
if (curr_level == gpio_get_value(key_gpio)) {
/* 3. 用原子操作读写flag,防止并发竞态 */
if (curr_level && atomic_read(&flag)) {
atomic_set(&flag, 0); // 原子设置:标记为松开
pr_info("【按键事件】按键松开!!!\n");
} else if (curr_level == 0 && !atomic_read(&flag)) {
atomic_set(&flag, 1); // 原子设置:标记为按下
pr_info("【按键事件】按键按下!!!\n");
}
}
/* 4. 重新打开中断,等待下一次按键触发 */
enable_irq(irq);
}
/*
* 平台驱动的probe函数(匹配成功后自动执行)
* 触发条件:平台总线设备树compatible匹配成功
* 核心改动:从设备树节点解析硬件资源,替代之前的platform_data
*/
int xxx_probe(struct platform_device *pdev)
{
struct device_node *np = pdev->dev.of_node; // 获取设备树节点
int err;
pr_info("【平台驱动】设备树匹配成功,开始执行probe初始化\n");
/* 【设备树校验】检查设备树节点是否存在 */
if (!np) {
pr_err("【平台驱动】错误:无对应的设备树节点\n");
return -EINVAL;
}
/* 【设备树核心】从设备树节点解析GPIO号 */
key_gpio = of_get_named_gpio(np, "gpios", 0);
if (!gpio_is_valid(key_gpio)) { // 校验GPIO号是否合法
pr_err("【平台驱动】错误:从设备树获取GPIO失败\n");
return -EINVAL;
}
pr_info("【平台驱动】从设备树获取到按键GPIO:%d\n", key_gpio);
/* 【可选】从设备树解析消抖时间,无该属性则使用默认值20ms */
of_property_read_u32(np, "debounce-interval", &debounce_interval);
pr_info("【平台驱动】按键消抖时间:%dms\n", debounce_interval);
/* 【硬件初始化1】申请GPIO使用权 */
err = gpio_request(key_gpio, KEY_NAME);
if (err) {
pr_err("【平台驱动】GPIO申请失败,错误码:%d\n", err);
return err;
}
/* 【硬件初始化2】设置GPIO为输入模式 */
err = gpio_direction_input(key_gpio);
if (err) {
pr_err("【平台驱动】GPIO输入模式设置失败,错误码:%d\n", err);
goto err_free_gpio;
}
/* 【硬件初始化3】将GPIO号映射为中断号 */
irq = gpio_to_irq(key_gpio);
if (irq < 0) {
err = irq;
pr_err("【平台驱动】GPIO转中断号失败,错误码:%d\n", err);
goto err_free_gpio;
}
pr_info("【平台驱动】GPIO映射到中断号:%d\n", irq);
/* 【硬件初始化4】注册中断服务函数(上升沿+下降沿双触发) */
err = request_irq(irq, key_irq_handler,
IRQF_TRIGGER_RISING | IRQF_TRIGGER_FALLING,
KEY_NAME, NULL);
if (err) {
pr_err("【平台驱动】中断注册失败,错误码:%d\n", err);
goto err_free_gpio;
}
pr_info("【平台驱动】probe初始化全部完成,设备已就绪\n");
return 0; // 成功返回0
/* 错误处理:资源逆序释放 */
err_free_gpio:
gpio_free(key_gpio);
return err;
}
/*
* 平台驱动的remove函数(驱动注销时自动执行)
* 作用:释放probe中申请的所有硬件资源
*/
int xxx_remove(struct platform_device *pdev)
{
pr_info("【平台驱动】开始执行remove,释放硬件资源\n");
/* 1. 释放中断服务函数 */
free_irq(irq, NULL);
/* 2. 释放GPIO使用权 */
gpio_free(key_gpio);
pr_info("【平台驱动】remove执行完成,资源已释放\n");
return 0;
}
/*
* 【设备树核心】设备树匹配表
* 作用:定义驱动支持的设备树compatible标识,和设备树节点一致则匹配成功
*/
const struct of_device_id key_of_match_table[] = {
{ .compatible = "my,key_test", }, // 必须和设备树节点的compatible完全一致
{ /* 末尾必须加空元素,作为结束标记 */ },
};
// 声明设备树匹配表,让内核可以识别
MODULE_DEVICE_TABLE(of, key_of_match_table);
/*
* 平台驱动核心结构体
* 核心改动:添加of_match_table,绑定设备树匹配表
*/
struct platform_driver xxx_dri = {
.probe = xxx_probe, // 绑定匹配成功后的初始化回调
.remove = xxx_remove, // 绑定驱动注销后的资源释放回调
.driver = {
.name = "key_test", // 备用名称匹配,设备树匹配失败时使用
.of_match_table = key_of_match_table, // 【设备树核心】绑定匹配表
},
};
/*
* 模块安装入口函数(insmod dri_platform.ko时自动执行)
*/
static int __init xxx_init(void)
{
int err;
pr_info("【平台驱动】开始安装驱动模块\n");
/* 1. 初始化工作队列 */
INIT_WORK(&key_work, key_work_func);
/* 2. 注册平台驱动 */
err = platform_driver_register(&xxx_dri);
if (err) {
pr_err("【平台驱动】驱动注册失败,错误码:%d\n", err);
return err;
}
pr_info("【平台驱动】驱动模块安装成功\n");
return 0;
}
/*
* 模块卸载入口函数(rmmod dri_platform.ko时自动执行)
*/
static void __exit xxx_exit(void)
{
pr_info("【平台驱动】开始卸载驱动模块\n");
/* 1. 注销平台驱动 */
platform_driver_unregister(&xxx_dri);
/* 2. 等待工作队列中的任务完全执行完毕 */
flush_work(&key_work);
pr_info("【平台驱动】驱动模块卸载成功\n");
}
/* 绑定模块的安装/卸载入口函数 */
module_init(xxx_init);
module_exit(xxx_exit);
/* 模块声明(内核强制要求) */
MODULE_LICENSE("GPL");
MODULE_AUTHOR("嵌入式驱动开发");
MODULE_DESCRIPTION("设备树匹配版-平台总线按键驱动");
MODULE_VERSION("v2.0-设备树适配版");
六、编译、烧录与测试验证
6.1 驱动模块编译
使用和前文一致的 Makefile,编译驱动模块,生成dri_platform.ko文件,传到开发板。
6.2 测试步骤
-
确认设备树节点已生成:
ls /sys/bus/platform/devices/ | grep key_test能看到
key_test设备,说明内核已成功解析设备树,自动生成了平台设备。 -
加载驱动模块:
insmod dri_platform.ko -
按键功能测试:按下 / 松开按键,查看内核日志,能看到按键按下 / 松开的打印,说明驱动工作正常。
dmesg | grep 按键事件 -
驱动卸载测试:
rmmod dri_platform.ko查看日志,确认资源正常释放,无报错。
6.3 核心对比
| 对比项 | 手动注册平台设备 | 设备树匹配方式 |
|---|---|---|
| 设备端代码 | 需要手动编写dev_platform.c |
无需编写任何设备端代码 |
| 硬件修改 | 需修改代码、重新编译模块 | 只需修改设备树、重新编译 DTB |
| 匹配方式 | 设备与驱动 name 字符串匹配 | 设备树 compatible 属性匹配(优先级更高) |
| 可移植性 | 差,仅适配当前硬件 | 强,同一驱动可适配多块开发板 |
七、设备树开发常见避坑指南
-
compatible 属性不匹配
- 坑点:设备树节点的
compatible和驱动of_match_table中的字符串不一致,包括大小写、空格、逗号分隔符错误,导致匹配失败,probe函数不执行; - 修复:必须保证两个字符串完全一致,建议使用
厂商,设备名的格式,避免拼写错误。
- 坑点:设备树节点的
-
GPIO 控制器引用错误
- 坑点:设备树中
gpios属性引用的 GPIO 控制器错误(如 RK3399 写成&gpio1而不是&gpio0),导致 GPIO 解析失败; - 修复:对照芯片手册,确认 GPIO 所属的控制器和引脚编号,使用
of_get_named_gpio后必须用gpio_is_valid校验合法性。
- 坑点:设备树中
-
设备树节点未使能
- 坑点:设备树节点中忘记写
status = "okay",或写成status = "disabled",导致内核不生成对应的设备; - 修复:自定义节点必须添加
status = "okay"使能设备。
- 坑点:设备树节点中忘记写
-
设备树编译后未烧录生效
- 坑点:修改 DTS 文件后,只编译了内核,没有更新 DTB 文件,或烧录后未重启开发板,新节点不生效;
- 修复:修改 DTS 后必须重新编译 DTB,烧录到开发板并重启,通过
/sys/firmware/devicetree/base/验证节点是否生效。
-
设备树 API 使用错误
- 坑点:使用
of_property_read_u32直接读取 GPIO 号,没有使用专用的of_get_named_gpio,导致 GPIO 编号映射错误; - 修复:GPIO 解析必须使用
of_get_named_gpio函数,该函数会自动处理 GPIO 控制器的映射和极性。
- 坑点:使用
八、总结与拓展
核心总结
- 设备树的核心价值是彻底将硬件描述与驱动代码分离,解决了传统板级代码冗余、可移植性差的问题,是当前 Linux 驱动开发的主流标准;
- 平台总线的设备树匹配,核心是
compatible属性的匹配,优先级高于传统的 name 匹配,匹配成功后内核自动触发probe函数; - 驱动开发中,通过
of_系列 API 可以从设备树节点中解析所有硬件信息,无需在驱动中硬编码任何硬件参数。