初学Linux之设备树的使用| RK3399上实操
一、为什么需要设备树—— 从平台总线的痛点说起在前文的平台总线驱动开发中我们通过手动编写dev_platform.c注册平台设备实现了「硬件资源描述」和「驱动逻辑」的初步分离但这种方式仍存在两个核心痛点硬件修改仍需改动代码更换按键 GPIO 引脚时必须修改dev_platform.c重新编译内核模块无法做到「一次编译适配多硬件」内核板级代码冗余在设备树出现之前ARM 架构 Linux 内核中存在大量针对不同开发板的板级描述代码每一款开发板都要在内核中添加对应的硬件描述文件导致内核源码冗余臃肿。设备树Device Tree就是为了解决这些问题诞生的它是一种专门描述硬件的数据结构将硬件资源描述完全独立于内核代码之外通过单独的.dts文件编写编译成二进制.dtb文件后由 bootloader 传递给内核。内核启动时解析 dtb 文件自动生成对应的平台设备、I2C 设备、SPI 设备等无需我们再手动编写任何设备端代码。二、设备树核心基础概念先理清设备树生态中最核心的 6 个名词对应文件的关系和作用如下表缩写全称中文名称核心作用DTDevice Tree设备树描述硬件数据结构的总称树形结构组织硬件信息DTSDevice Tree Source设备树源文件用 ASCII 码编写的硬件描述文件对应一款开发板的硬件配置存放于内核arch/xxx/boot/dts目录DTSIDevice Tree Source Include设备树头文件类似 C 语言的.h头文件可被多个 DTS 文件引用通常存放同一款芯片的通用硬件配置DTCDevice Tree Compiler设备树编译器内核自带的编译工具将 DTS/DTSI 文件编译成二进制 DTB 文件DTBDevice Tree Blob设备树二进制文件DTS 编译后的产物bootloader 启动内核时会将 DTB 加载到内存并把地址传递给内核内核解析 DTB 生成硬件设备OFOpen Firmware开放固件设备树的起源标准内核中所有设备树相关的 API 都以of_前缀命名核心工作流程针对开发板编写 DTS 文件引用芯片通用的 DTSI 文件添加自定义硬件节点用 DTC 将 DTS 编译为 DTB 二进制文件bootloader 启动内核时将 DTB 加载到内存把 DTB 的内存地址传递给内核内核启动时解析 DTB 文件根据节点内容自动生成对应的硬件设备注册到对应总线平台总线、I2C 总线等。三、设备树语法规范与节点编写设备树是一个树形结构由根节点、子节点、属性三部分组成和电脑的文件夹结构完全一致根节点是/子节点是文件夹属性是文件夹里的文件。3.1 设备树的基本结构一个最简的设备树文件结构如下dts/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: serialff180000 { compatible rockchip,rk3399-uart; reg 0x0 0xff180000 0x0 0x100; status okay; }; };3.2 节点的编写规范节点的通用编写格式如下dts[label:] node-name[unit-address] { [properties definitions] // 属性定义 [child nodes] // 子节点 };各部分说明label:标签可选用于在其他位置通过label引用该节点修改节点内容node-name节点名必须以字母开头只能包含数字、字母、下划线、破折号等字符建议使用反映设备功能的通用名称如key、led、gpiounit-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按下为低电平。操作步骤进入内核源码的设备树目录cd linux-sdk/kernel/arch/arm64/boot/dts/rockchip/编辑通用 dtsi 文件NanoPC-T4 的通用配置文件vim rk3399-nanopi4-common.dtsi在根节点/下添加如下按键节点dts/* 自定义按键设备树节点 */ my_key: key_test { compatible my,key_test; // 【匹配核心】必须和驱动中的compatible完全一致 status okay; // 使能该设备 gpios gpio0 5 GPIO_ACTIVE_LOW; // 按键GPIOGPIO0_5低电平有效 debounce-interval 20; // 消抖时间20ms };保存退出编译设备树生成新的 DTB 文件make ARCHarm64 nanopi4-images -j8将新的 DTB 文件烧录到开发板重启开发板。节点验证开发板重启后执行以下命令验证设备树节点是否正常生成# 查看平台总线设备能看到key_test节点说明设备树解析成功 ls /sys/bus/platform/devices/ | grep key_test # 查看设备树节点属性 cat /sys/firmware/devicetree/base/key_test/compatible5.2 步骤 2驱动代码核心改造点对比前文的手动匹配驱动设备树适配的驱动有 3 个核心修改点添加设备树匹配表of_match_table定义compatible匹配标识和设备树节点一致移除硬编码 GPIO 获取不再从platform_data获取 GPIO 号改为通过of_系列 API 从设备树节点解析 GPIO完善设备树节点合法性校验在probe函数中校验设备树节点是否存在属性是否合法。同时需要新增两个头文件#include linux/of.h // 设备树核心API头文件 #include linux/of_gpio.h // 设备树GPIO解析专用API头文件5.3 步骤 3设备树适配版完整驱动代码#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 可以从设备树节点中解析所有硬件信息无需在驱动中硬编码任何硬件参数。