嵌入式Linux嵌入式Linux驱动开发设备树驱动改造——从硬编码到设备树的实战之旅仓库已经开源所有教程主线内核移植跑新版本imx-linux/uboot都在这里或者一起来尝试跑7.0的Linux欢迎各位大佬观摩喜欢的话点个⭐仓库地址https://github.com/Awesome-Embedded-Learning-Studio/imx-forge静态网页https://awesome-embedded-learning-studio.github.io/imx-forge/前言我们为什么要做这件事如果你跟着我们的教程一路走过来现在应该已经写过两种 LED 驱动了一种是完全硬编码的版本所有寄存器地址都写死在代码里另一种是我们在上一章对比过的设备树版本。但说实话光看对比是不够的——你得亲自把手弄脏把一个硬编码驱动改造成设备树驱动才能真正理解这个过程。这就是我们这一章要做的事情。我们会从一个实实在在的硬编码驱动出发一步步把它改造成使用设备树的版本。这个过程不是一蹴而就的我们需要分步骤进行添加头文件、定义节点路径、查找设备节点、读取设备属性、改造地址映射、完善错误处理。每一步都有它的道理每一步都可能踩坑。你会发现这次改造的核心思想不是重写而是渐进式重构。我们会保持硬件抽象层不变只修改硬件信息的获取方式。这样做的好处是你可以逐步验证每一步的正确性如果出错了也能快速定位是哪一步出了问题。这比一次性改完所有代码要安全得多。说实话这种渐进式改造的方法论在实际工程项目中非常重要。很多时候我们不敢改老代码就是怕改出问题来。但如果你学会了这种小步迭代、逐步验证的方式你会发现即使是重构核心代码也不那么可怕了。改造策略为什么渐进式改造很重要在我们开始动手之前先来聊聊改造策略。你可能会问为什么不直接写出最终的设备树驱动版本而要分这么多步骤答案很简单因为我们要保证每一步都是可验证的。假设你一次性改完所有代码然后上板测试发现驱动起不来这时候你该从哪里开始排查是设备树节点写错了是 OF API 调用方式不对还是地址映射出了问题当所有改动混在一起时调试会变得异常困难。但如果你分步骤进行每一步改完都能验证它的正确性那情况就完全不同了。比如你先添加设备树头文件编译一下确保没问题然后添加节点查找代码上板测试确认能找到节点再然后添加属性读取代码确认能读取到正确的值。这样一步步推进即使出错了也能快速定位到具体是哪一步出了问题。另一个重要的原因是我们要保持硬件抽象层的接口不变。你仔细看我们的代码结构会发现led_hw.h里定义的接口——led_hw_init、led_hw_deinit、led_set_status、led_get_status——在整个改造过程中都没有变化。这意味着上层的字符设备驱动代码完全不需要修改我们只需要改动硬件抽象层的实现。这种接口稳定的设计让大规模重构变得可控。步骤1添加设备树头文件改造的第一步非常简单添加设备树相关的头文件。打开led_hw.c在原有的头文件之后添加两行#includelinux/of.h#includelinux/of_address.h这两个头文件的作用是什么呢linux/of.h提供了设备树的基础数据结构和 API比如struct device_node、of_find_node_by_path、of_property_read_string等。而linux/of_address.h提供了地址映射相关的 API最重要的是of_iomap函数。当初我第一次改代码时就是从这里开始的。因为不知道需要哪些头文件我把驱动里用到的每个 OF API 都去查了一下它们的定义位置然后一个一个添加头文件。后来才发现其实只需要这两个核心头文件就够了其他 OF API 的声明都会通过这两个头文件间接包含进来。添加完头文件之后先编译一下确保没有语法错误。虽然我们还没有写任何设备树相关代码但这一步能确认头文件路径正确、没有拼写错误。这种小步验证的习惯能帮你避免很多低级错误。步骤2定义设备树节点路径接下来我们需要定义设备树节点的路径。在硬编码版本里我们用宏定义来存储寄存器地址而在设备树版本里我们需要一个字符串来存储设备树节点的路径。staticconstchar*kIMX_AES_LED/imx_aes_led;这里有个细节需要注意路径字符串前面有一个斜杠/表示这是从根节点开始的绝对路径。如果你写成imx_aes_led没有前面的斜杠那of_find_node_by_path会把它当作相对路径处理结果就找不到节点了。这一点我当初踩过坑找了半天才发现是少了一个斜杠。但你可能会问这个路径字符串是怎么确定的答案很简单它是我们在设备树文件里定义的节点路径。我们来看看设备树文件/imx_aes_led { #address-cells 1; #size-cells 1; compatible atkalpha-led; status okay; reg ...; };请注意节点名前面的斜杠——这表示这个节点直接挂在根节点下面。所以它的完整路径就是/imx_aes_led。如果你把节点定义在其他节点下面比如iomuxc里面那路径就会变成/iomuxc/imx_aes_led你需要在代码里相应修改路径字符串。还有一个细节路径字符串是用static const修饰的。const表示这个字符串不会被修改static表示这个符号只在当前文件可见。这样做的好处是编译器可以把字符串放在只读段而且不会和其他文件的同名符号冲突。步骤3查找设备树节点定义好路径之后下一步就是查找设备树节点。这一步是设备树驱动的入口——如果找不到节点后续的所有操作都没法进行。我们先来看看代码是怎么写的intled_hw_init(void){/* ... 变量定义 ... *//* 1. 获取设备树节点 */led.device_tree_nodeof_find_node_by_path(kIMX_AES_LED);if(led.device_tree_nodeNULL){pr_err(dtsled node can not found!\n);return-EINVAL;}pr_info(dtsled node has been found!\n);这里的of_find_node_by_path函数是设备树 API 的核心函数之一。它的作用是根据路径字符串查找设备树节点如果找到了就返回一个struct device_node*指针如果找不到就返回NULL。请注意这里的错误处理我们检查了返回值是否为NULL如果是就打印错误信息并返回-EINVAL。这个错误码表示无效参数虽然在这里可能不太准确——更合适的可能是-ENODEV设备不存在——但对于我们自己写的驱动来说只要能区分成功和失败就行具体的错误码可以后续优化。但这里有个重要的细节of_find_node_by_path成功返回时会增加设备树节点的引用计数。这意味着你用完这个节点之后必须调用of_node_put来释放引用否则会导致内存泄漏。我们会在资源清理部分详细讨论这个问题。现在你可能会问如果设备树节点不存在是什么原因导致的这里有几个常见的可能性。第一设备树文件根本没有定义这个节点第二节点定义了但是路径字符串写错了第三设备树编译后的 dtb 文件没有正确部署到目标系统第四内核启动时没有加载正确的 dtb 文件。这些都需要你逐一排查。一个有用的调试技巧是你可以通过/sys/firmware/devicetree/base目录来查看运行时的设备树。如果目标系统上存在/sys/firmware/devicetree/base/imx_aes_led目录那说明节点确实存在如果不存在那就需要检查设备树文件和 dtb 部署。步骤4读取设备属性找到设备树节点之后下一步就是读取设备的属性。在实际的驱动开发中这一步非常重要——你需要验证设备的compatible属性是否匹配、status属性是否为okay以及reg属性里包含的地址信息是否正确。我们先来看看读取compatible属性的代码/* 2. 获取 compatible 属性 */properof_find_property(led.device_tree_node,compatible,NULL);if(properNULL){pr_err(compatible property find failed\n);}else{pr_info(compatible %s\n,(char*)proper-value);}这里的of_find_property函数用来查找设备树节点中的属性。请注意它的返回值类型是struct property*这个结构体的value字段指向属性值的原始数据。对于字符串类型的属性value字段就是一个以 null 结尾的字符串所以我们可以直接把它转换成char*来打印。但请注意这里的错误处理如果of_find_property返回NULL我们只是打印了一个错误信息并没有直接返回失败。这是因为compatible属性对于我们的驱动来说并不是严格必需的——我们通过路径查找节点而不是通过compatible属性匹配。但在平台设备驱动中compatible属性是驱动匹配的核心那时候你就需要严格检查它了。接下来是读取status属性/* 3. 获取 status 属性 */retof_property_read_string(led.device_tree_node,status,str);if(ret0){pr_err(status read failed!\n);}else{pr_info(status %s\n,str);}这里使用了of_property_read_string函数它专门用来读取字符串类型的属性。请注意这个函数的参数设计第三个参数是一个const char**类型的指针函数会把读取到的字符串地址存储到这个指针指向的位置。这种设计避免了函数内部分配内存调用者不需要手动释放。对于status属性标准的值是okay表示设备可用disabled表示设备被禁用。如果status属性不存在大多数驱动会默认认为设备是可用的。但在我们的代码里如果读取失败我们只是打印错误信息继续执行。这是因为我们的驱动比较简单不依赖status属性来做决定。最后是读取reg属性/* 4. 获取 reg 属性内容 */retof_property_read_u32_array(led.device_tree_node,reg,regdata,10);if(ret0){pr_err(reg property read failed!\n);of_node_put(led.device_tree_node);return-EINVAL;}pr_info(reg data:\n);for(inti0;i10;i){pr_cont(%#X ,regdata[i]);}pr_cont(\n);这里的of_property_read_u32_array函数用来读取整数数组类型的属性。reg属性本质上是一个地址和长度的数组我们的 LED 节点有 5 个寄存器每个寄存器有一个地址和一个长度所以总共有 10 个 u32 值。请注意这里的一个细节我们直接指定了读取 10 个值。这种方式在我们的特定场景下没问题因为我们知道设备树里确实有 5 组地址。但在更通用的驱动中你应该先查询属性里有多少个值然后动态分配内存来存储。这可以通过of_property_count_u32_elems函数来实现。还有一个重要的细节在reg属性读取失败时我们调用了of_node_put(led.device_tree_node)来释放节点引用。这是因为在读取失败时我们直接返回了错误码不再执行后续的清理逻辑所以需要在这里手动释放引用。这是一个很容易被遗忘的细节但忘记释放会导致内存泄漏。步骤5使用 of_iomap 替代 ioremap现在我们来到了改造的核心部分地址映射。在硬编码版本里我们直接调用ioremap把物理地址映射成虚拟地址而在设备树版本里我们使用of_iomap函数它可以从设备树节点的reg属性中自动提取地址信息并进行映射。我们先来看看代码/* 5. 使用 of_iomap 进行寄存器地址映射 */led.ccm_ccgr1of_iomap(led.device_tree_node,0);led.sw_mux_gpioof_iomap(led.device_tree_node,1);led.sw_pad_gpioof_iomap(led.device_tree_node,2);led.gpio_drof_iomap(led.device_tree_node,3);led.gpio_gdirof_iomap(led.device_tree_node,4);if(!led.ccm_ccgr1||!led.sw_mux_gpio||!led.sw_pad_gpio||!led.gpio_dr||!led.gpio_gdir){pr_err(ioremap failed!\n);of_node_put(led.device_tree_node);return-ENOMEM;}请注意of_iomap的调用方式第一个参数是设备树节点第二个参数是索引。这个索引对应reg属性中的第几组地址。索引 0 对应第一组0X020C406C 0X04索引 1 对应第二组0X020E0068 0X04以此类推。这个设计非常巧妙。驱动代码不需要知道具体的地址值它只需要知道我需要第几个寄存器。具体的地址是什么那是设备树的事情。这种抽象让驱动代码和硬件配置解耦了——如果你换了一块板子只需要修改设备树文件里的地址驱动代码完全不用动。但这里有个容易被忽略的细节of_iomap内部会调用ioremap所以失败时返回的也是NULL。我们需要检查所有映射是否成功只要有一个失败了就应该报错并清理资源。请注意这里的错误处理如果映射失败我们不仅打印错误信息还调用了of_node_put来释放节点引用然后返回-ENOMEM表示内存不足。你可能会问为什么of_iomap比直接调用ioremap更好除了我们刚才说的抽象性优势之外of_iomap还有一个重要的特性它会自动处理reg属性中的地址转换。在某些架构上设备树里的地址可能不是物理地址而是需要经过某种转换的总线地址。of_iomap会自动处理这种转换而直接使用ioremap就需要你手动计算转换后的地址。步骤6完善错误处理和资源清理完成了地址映射之后硬件初始化的后续步骤——使能时钟、配置 GPIO 复用和电气属性、设置 GPIO 方向——这些操作在硬编码版本和设备树版本中基本是一样的。区别只是地址的来源不同硬编码版本用的是全局变量设备树版本用的是结构体成员。所以我们直接跳到资源清理的部分。这是设备树驱动改造中很容易被忽略但实际上非常重要的一环。voidled_hw_deinit(void){pr_info(Deinit LED Hardware\n);if(led.ccm_ccgr1){iounmap(led.ccm_ccgr1);led.ccm_ccgr1NULL;}/* ... 其他 iounmap 调用 ... */if(led.device_tree_node){of_node_put(led.device_tree_node);led.device_tree_nodeNULL;}}请注意这里几个重要的细节。第一在调用iounmap之前我们检查了指针是否为NULL。这是为了防止双重释放——如果led_hw_deinit被调用两次第一次会把指针设为NULL第二次检查到NULL就不会再次调用iounmap。这种防御性编程在实际项目中非常重要。第二在释放完所有资源之后我们把指针都设为NULL。这是一种叫做悬空指针预防的实践。如果不把指针设为NULL那么在释放之后指针仍然指向已经释放的内存如果后续代码错误地使用了这个指针就会导致未定义行为。把指针设为NULL之后如果有人错误使用至少会立即触发空指针异常而不是产生难以调试的内存破坏。第三最后我们调用了of_node_put(led.device_tree_node)。这是设备树 API 的要求——每次调用of_find_node_by_path都会增加节点的引用计数用完后必须调用of_node_put来减少计数。如果忘记调用设备树节点的内存永远不会被释放会导致内存泄漏。但你可能会问在模块卸载的时候内核不是会自动清理所有资源吗对于设备树节点来说答案是不会。设备树节点的生命周期管理是通过引用计数来实现的只有当引用计数降为零时节点才会被释放。如果你忘记调用of_node_put引用计数永远不会降为零节点就永远不会被释放。设备树文件编写驱动代码改造完成之后我们还需要编写对应的设备树文件。设备树文件是驱动和硬件之间的桥梁它告诉内核硬件在哪里以及硬件是什么样子的。我们来看看完整的设备树文件/dts-v1/; #include imx6ull.dtsi #include imx6ull-aes.dtsi / { model Awesome Embedded Studio IMX6ULL Example Driver; compatible fsl,imx6ull-14x14-evk, fsl,imx6ull; imx_aes_led { #address-cells 1; #size-cells 1; compatible atkalpha-led; status okay; reg 0X020C406C 0X04 /* CCM_CCGR1_BASE */ 0X020E0068 0X04 /* SW_MUX_GPIO1_IO03_BASE */ 0X020E02F4 0X04 /* SW_PAD_GPIO1_IO03_BASE */ 0X0209C000 0X04 /* GPIO1_DR_BASE */ 0X0209C004 0X04 ; /* GPIO1_GDIR_BASE */ }; };这个文件的开头是/dts-v1/;表示这是设备树源文件的版本 1 格式。然后我们包含了两个.dtsi文件imx6ull.dtsi描述了 IMX6ULL 芯片本身的硬件信息imx6ull-aes.dtsi描述了我们板子的共性配置。根节点/下面有两个属性model和compatible。model是一个自由文本字段用来描述这块板子compatible是一个字符串列表用来匹配平台代码。这些是设备树的标准属性大多数设备树文件都会包含。然后是我们的 LED 节点imx_aes_led。请注意这个节点直接挂在根节点下面所以它的完整路径是/imx_aes_led。如果你把节点定义在其他地方比如iomuxc里面那路径就会不同。节点里有四个属性。#address-cells和#size-cells定义了子节点中地址和长度的表示方式——因为我们的节点没有子节点这两个属性其实不是必需的但加上也无妨。compatible属性定义了设备的兼容性字符串在平台设备驱动中用来匹配驱动。status属性表示设备的状态okay表示设备可用disabled表示设备被禁用。最重要的是reg属性它定义了设备所需的寄存器地址。reg属性的格式是地址和长度交替出现。我们的 LED 设备需要 5 个寄存器每个寄存器 4 字节所以reg属性里有 10 个值。请注意reg属性中的注释。我们在每个地址后面都添加了注释说明这个地址对应的寄存器名称。这是一个好习惯设备树文件不像 C 代码那样有良好的结构化表示加上注释能让代码更容易维护。常见问题与调试到这里我们的设备树驱动改造就完成了。但在实际操作中你可能会遇到各种各样的问题。我们来总结一些常见的坑点和对应的调试方法。节点路径不对最常见的问题是节点路径写错了。比如你把设备树节点定义在/soc/iomuxc下面但代码里写的路径是/imx_aes_led那肯定找不到节点。调试方法是在代码里打印完整的错误信息包括你查找的路径字符串。还可以通过/sys/firmware/devicetree/base目录来查看运行时的设备树结构确认节点的实际路径。reg 属性格式错误reg属性的格式是地址和长度交替出现如果格式不对会导致读取失败。常见错误包括只写了地址没写长度、长度写成了0、地址和长度的数量不匹配等。调试方法是使用of_property_read_u32_array读取reg属性后把读取到的所有值都打印出来。你可以数一下值的数量是否正确每个值的大小是否合理。地址映射失败of_iomap调用失败通常是因为reg属性里的地址不正确或者索引超出了范围。比如reg属性只定义了 3 组地址但你尝试用索引 3 去映射第四组那就会返回NULL。调试方法是先检查reg属性的值是否正确然后确认of_iomap的索引没有超出范围。你还可以在调用of_iomap之后立即检查返回值是否为NULL如果是就打印错误信息。忘记释放资源忘记调用of_node_put或iounmap会导致内存泄漏。这种问题在短时间运行时不会暴露但长时间运行后会耗尽系统内存。调试方法是使用内核的内存调试工具比如kmemleak可以检测到内存泄漏。更好的做法是在代码里养成好习惯每次获取资源后都确保在所有退出路径上都释放资源。下一步到这里我们已经完成了从硬编码驱动到设备树驱动的完整改造。你应该能看到设备树方式的核心优势在于配置与代码分离——硬件信息不再硬编码在驱动里而是通过设备树文件来描述。这让驱动更加通用也更容易维护。但说实话我们现在写的设备树驱动还不是标准的 Linux 驱动。我们手动查找设备树节点手动进行资源管理这种方式虽然能工作但不是内核推荐的最佳实践。更现代的做法是使用平台设备驱动框架让内核自动完成设备匹配和资源分配。继续阅读09. 板级DTS实操与完整实战演练 从修改设备树到点亮LED的完整闭环——编写DTS、编译DTB、编写驱动、部署测试一气呵成。相关阅读04. OF API 基础与验证——从 DTS 到代码的桥梁 - 相似度 82%嵌入式Linux嵌入式Linux驱动开发板级DTS实操与完整实战演练——从修改设备树到点亮LED的完整闭环 - 相似度 82%现代Qt开发教程新手篇1.15——正则与文本处理 - 相似度 71%