初学Linux之设备树的使用| RK3399上实操

一、为什么需要设备树?------ 从平台总线的痛点说起

在前文的平台总线驱动开发中,我们通过手动编写dev_platform.c注册平台设备,实现了「硬件资源描述」和「驱动逻辑」的初步分离,但这种方式仍存在两个核心痛点:

  1. 硬件修改仍需改动代码 :更换按键 GPIO 引脚时,必须修改dev_platform.c,重新编译内核模块,无法做到「一次编译,适配多硬件」;
  2. 内核板级代码冗余:在设备树出现之前,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_前缀命名

核心工作流程

  1. 针对开发板编写 DTS 文件,引用芯片通用的 DTSI 文件,添加自定义硬件节点;
  2. 用 DTC 将 DTS 编译为 DTB 二进制文件;
  3. bootloader 启动内核时,将 DTB 加载到内存,把 DTB 的内存地址传递给内核;
  4. 内核启动时解析 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:节点名,必须以字母开头,只能包含数字、字母、下划线、破折号等字符,建议使用反映设备功能的通用名称(如keyledgpio);
  • @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 驱动开发核心属性

在平台总线驱动开发中,有两个属性是核心中的核心:

  1. compatible属性 :设备与驱动匹配的唯一标识,格式为厂商,设备名,必须和驱动中of_match_table里的字符串完全一致,否则无法匹配;
  2. status属性 :设备的使能状态,status = "okay"表示使能该设备,status = "disabled"表示禁用该设备。

四、平台总线的设备树匹配规则(核心)

在前文中我们讲过,平台总线的匹配逻辑在内核platform_match函数中实现,优先级从高到低共 4 种,设备树匹配的优先级高于手动 name 匹配,完整匹配顺序如下:

  1. 驱动强制匹配:通过设备的driver_override字段强制匹配;
  2. 设备树匹配(本文使用) :通过驱动的of_match_table中的compatible属性,和设备树节点的compatible属性匹配,一致则匹配成功;
  3. ID 表匹配:通过驱动的id_table数组匹配设备名称;
  4. 名称匹配:对比设备的name和驱动的driver.name字符串。

设备树匹配的完整工作流程

  1. 内核启动时解析设备树 DTB 文件,根据节点内容生成platform_device平台设备,注册到平台总线的设备链表;
  2. 我们加载驱动模块时,驱动注册到平台总线的驱动链表;
  3. 平台总线自动遍历设备链表,对比驱动of_match_table中的compatible和设备树节点的compatible
  4. 字符串完全一致则匹配成功,总线自动触发驱动的probe函数,执行硬件初始化;
  5. 驱动卸载时,总线自动触发remove函数释放资源。

核心优势:硬件信息完全写在设备树中,更换硬件只需要修改 DTS 文件、重新编译 DTB,无需修改驱动代码、无需重新编译驱动模块,真正实现了「驱动代码与硬件描述完全解耦」。


五、实战:将按键驱动改造为设备树匹配方式

我们基于前文的平台总线按键驱动,完成设备树适配改造,改造后无需再编写和加载dev_platform.c设备端模块,所有硬件信息由设备树提供。

5.1 步骤 1:编写按键设备树节点

我们使用的是 NanoPC-T4 开发板(RK3399 芯片),按键使用 GPIO0_B5(对应 GPIO 编号 5),按下为低电平。

操作步骤
  1. 进入内核源码的设备树目录:

    bash 复制代码
    cd linux-sdk/kernel/arch/arm64/boot/dts/rockchip/
  2. 编辑通用 dtsi 文件(NanoPC-T4 的通用配置文件):

    bash 复制代码
    vim rk3399-nanopi4-common.dtsi
  3. 在根节点/下添加如下按键节点:

    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
    };
  4. 保存退出,编译设备树,生成新的 DTB 文件:

    复制代码
    make ARCH=arm64 nanopi4-images -j8
  5. 将新的 DTB 文件烧录到开发板,重启开发板。

节点验证

开发板重启后,执行以下命令,验证设备树节点是否正常生成:

bash 复制代码
# 查看平台总线设备,能看到key_test节点,说明设备树解析成功
ls /sys/bus/platform/devices/ | grep key_test
# 查看设备树节点属性
cat /sys/firmware/devicetree/base/key_test/compatible

5.2 步骤 2:驱动代码核心改造点

对比前文的手动匹配驱动,设备树适配的驱动有 3 个核心修改点:

  1. 添加设备树匹配表of_match_table :定义compatible匹配标识,和设备树节点一致;
  2. 移除硬编码 GPIO 获取 :不再从platform_data获取 GPIO 号,改为通过of_系列 API 从设备树节点解析 GPIO;
  3. 完善设备树节点合法性校验 :在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 测试步骤

  1. 确认设备树节点已生成

    复制代码
    ls /sys/bus/platform/devices/ | grep key_test

    能看到key_test设备,说明内核已成功解析设备树,自动生成了平台设备。

  2. 加载驱动模块

    复制代码
    insmod dri_platform.ko
  3. 按键功能测试:按下 / 松开按键,查看内核日志,能看到按键按下 / 松开的打印,说明驱动工作正常。

    复制代码
    dmesg | grep 按键事件
  4. 驱动卸载测试

    复制代码
    rmmod dri_platform.ko

    查看日志,确认资源正常释放,无报错。

6.3 核心对比

对比项 手动注册平台设备 设备树匹配方式
设备端代码 需要手动编写dev_platform.c 无需编写任何设备端代码
硬件修改 需修改代码、重新编译模块 只需修改设备树、重新编译 DTB
匹配方式 设备与驱动 name 字符串匹配 设备树 compatible 属性匹配(优先级更高)
可移植性 差,仅适配当前硬件 强,同一驱动可适配多块开发板

七、设备树开发常见避坑指南

  1. compatible 属性不匹配

    • 坑点:设备树节点的compatible和驱动of_match_table中的字符串不一致,包括大小写、空格、逗号分隔符错误,导致匹配失败,probe函数不执行;
    • 修复:必须保证两个字符串完全一致,建议使用厂商,设备名的格式,避免拼写错误。
  2. GPIO 控制器引用错误

    • 坑点:设备树中gpios属性引用的 GPIO 控制器错误(如 RK3399 写成&gpio1而不是&gpio0),导致 GPIO 解析失败;
    • 修复:对照芯片手册,确认 GPIO 所属的控制器和引脚编号,使用of_get_named_gpio后必须用gpio_is_valid校验合法性。
  3. 设备树节点未使能

    • 坑点:设备树节点中忘记写status = "okay",或写成status = "disabled",导致内核不生成对应的设备;
    • 修复:自定义节点必须添加status = "okay"使能设备。
  4. 设备树编译后未烧录生效

    • 坑点:修改 DTS 文件后,只编译了内核,没有更新 DTB 文件,或烧录后未重启开发板,新节点不生效;
    • 修复:修改 DTS 后必须重新编译 DTB,烧录到开发板并重启,通过/sys/firmware/devicetree/base/验证节点是否生效。
  5. 设备树 API 使用错误

    • 坑点:使用of_property_read_u32直接读取 GPIO 号,没有使用专用的of_get_named_gpio,导致 GPIO 编号映射错误;
    • 修复:GPIO 解析必须使用of_get_named_gpio函数,该函数会自动处理 GPIO 控制器的映射和极性。

八、总结与拓展

核心总结

  1. 设备树的核心价值是彻底将硬件描述与驱动代码分离,解决了传统板级代码冗余、可移植性差的问题,是当前 Linux 驱动开发的主流标准;
  2. 平台总线的设备树匹配,核心是compatible属性的匹配,优先级高于传统的 name 匹配,匹配成功后内核自动触发probe函数;
  3. 驱动开发中,通过of_系列 API 可以从设备树节点中解析所有硬件信息,无需在驱动中硬编码任何硬件参数。
相关推荐
Yupureki2 小时前
《Linux系统编程》19.线程同步与互斥
java·linux·服务器·c语言·开发语言·数据结构·c++
又来敲代码了2 小时前
Zrlog博客的系统部署
java·linux·运维·mysql·apache·tornado
feng_you_ying_li2 小时前
linux开发工具的介绍(5)
linux·运维·centos
Lugas Luo2 小时前
Kernel 5.10 SD卡专属探测、上电与注册流程分析 (Detect -> Power Up -> Add)
linux·嵌入式硬件
艾莉丝努力练剑2 小时前
【Linux信号】Linux进程信号(下):可重入函数、Volatile关键字、SIGCHLD信号
linux·运维·服务器·c++·人工智能·后端·学习
si莉亚2 小时前
2026.3.31成功安装Ubuntu22.04+ROS2记录
linux·c++·开源
RrEeSsEeTt2 小时前
【HackTheBox】- Monteverde 靶机学习
linux·网络安全·渗透测试·kali·红队·hackthebox·ad域
美式请加冰2 小时前
Linux权限的概念
linux·运维·服务器
两年半的个人练习生^_^2 小时前
List集合的使用和源码
linux·windows·list