07. 驱动代码对比——从硬编码到设备树
前言:为什么要做这次对比
如果你跟着我们的教程一路走过来,你应该已经接触过两种写驱动的方式了。一种是最早期的硬编码方式,把所有寄存器地址直接写在 C 代码里;另一种是我们刚学会的设备树方式,从设备树里读取硬件信息。
说实话,当初我第一次看到这两种方式的对比时,内心是很震撼的。就像是你一直用手动挡开车,突然有一天有人给你换成了自动挡——你会意识到:"原来这事儿还能这么干?"这种认知上的转变,比单纯学习新 API 要有意义得多。
所以这一章我们不学新的 API 函数,而是停下来好好对比一下这两种方式。我们会逐行分析代码的差异,看看设备树到底解决了什么问题,以及它有没有引入新的复杂度。这种对比式的学习,能帮你建立更完整的技术认知,而不仅仅是记住几个函数调用。
硬编码时代的驱动:直接把地址写死
我们先来看看硬编码方式的驱动是怎么写的。下面这段代码来自我们早期的 LED 驱动,它代表了设备树普及之前的主流做法。
地址定义:一堆宏定义的堆砌
在硬编码方式下,我们首先需要把所有寄存器地址定义成宏或者常量。打开 led_reg.h 文件,你会看到类似这样的内容:
/* led_reg.h - 硬编码方式的寄存器地址定义 */
static const u32 kCCM_CCGR1_BASE = 0X020C406C;
static const u32 kSW_MUX_GPIO1_IO03_BASE = 0X020E0068;
static const u32 kSW_PAD_GPIO1_IO03_BASE = 0X020E02F4;
static const u32 kGPIO1_DR_BASE = 0X0209C000;
static const u32 kGPIO1_GDIR_BASE = 0X0209C004;这些地址是从芯片手册里一个个抄出来的。你得翻几百页的参考手册,找到每个寄存器的物理地址,然后手动敲进代码里。而且这些地址是物理地址,内核没法直接访问,还需要通过 ioremap 映射成虚拟地址。
说实话,这种做法有两个很明显的痛点。第一是容易出错,地址这种东西,错一个数字就是灾难;第二是这些地址完全绑定到特定的芯片型号上,如果你换了块板子,或者只是引脚改了一下,你就得修改代码,重新编译。
内存映射:手动调用 ioremap
定义完地址之后,下一步就是做内存映射。我们来看看 led_hw.c 里的实现:
static void ioremapping_registers(void) {
#define IOREMAP(BASE_ADDR) ioremap(BASE_ADDR, kRegSize)
IMX6U_CCM_CCGR1 = IOREMAP(kCCM_CCGR1_BASE);
SW_MUX_GPIO1_IO03 = IOREMAP(kSW_MUX_GPIO1_IO03_BASE);
SW_PAD_GPIO1_IO03 = IOREMAP(kSW_PAD_GPIO1_IO03_BASE);
GPIO1_DR = IOREMAP(kGPIO1_DR_BASE);
GPIO1_GDIR = IOREMAP(kGPIO1_GDIR_BASE);
#undef IOREMAP
pr_info("IMX6U_CCM_CCGR1 = 0x%p (phys: 0x%x)\n",
IMX6U_CCM_CCGR1, kCCM_CCGR1_BASE);
/* ... 其他打印 ... */
}这里的逻辑很简单:对每个物理地址调用一次 ioremap,把返回的虚拟地址保存到全局变量里。为了少写点重复代码,作者还用了一个宏来简化调用。
但这个流程有个问题:所有的映射操作都是硬编码的。如果你想换一个 GPIO 引脚,或者寄存器地址变了,你得修改这里所有的代码。而且这段代码完全没有错误处理——如果 ioremap 失败了怎么办?直接访问空指针会触发内核 panic。当然你可以加上错误处理,但那会让代码变得更冗长。
初始化流程:直接操作寄存器
完成地址映射之后,就是初始化硬件了。我们来看看使能 GPIO 时钟的代码:
static void enable_gpio_clock(void) {
u32 clock_settings = readl(IMX6U_CCM_CCGR1);
pr_info("CCGR1 raw value: 0x%08x\n Bits: ", clock_settings);
pr_info("\n");
pr_bin_u32(clock_settings);
clock_settings &= ~(0b11 << 26);
clock_settings |= 0b11 << 26;
pr_info("CCGR1 new raw value: 0x%08x \nBits: ", clock_settings);
pr_bin_u32(clock_settings);
pr_info("\n");
writel(clock_settings, IMX6U_CCM_CCGR1);
}这里的逻辑是:先读取当前值,然后修改其中的某些位,最后写回去。这种"读-改-写"的模式在寄存器操作中非常常见。
但请注意这里的细节:26 这个数字是硬编码的,它代表 GPIO1 的时钟控制位在 CCGR1 寄存器中的位置。这个数字是从芯片手册里查出来的,你得自己记住它。如果换了个 GPIO 组,这个位偏移量就得改。
设备树时代的驱动:从数据结构里读取
看完硬编码方式,我们再来看看设备树方式是怎么做的。你会发现代码的结构发生了明显的变化——不再是"我知道地址,我来映射",而是"告诉我设备在哪里,我去找"。
设备树节点:硬件信息的结构化描述
首先我们需要在设备树里定义硬件信息。下面是我们为 LED 设备编写的设备树节点:
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 */
};这个节点描述了 LED 设备所需的五个寄存器地址。请注意这里的几个关键点:
第一,所有的寄存器地址都集中在一个 reg 属性里,而不是散落在 C 代码的各个角落。第二,每个地址后面都有一个长度字段(这里是 0X04,表示 4 个字节),这个信息对于 ioremap 来说是必要的——它需要知道要映射多大的内存区域。第三,这个节点有一个 compatible 属性,驱动可以通过这个属性来匹配设备(虽然我们现在的代码还没用到这个机制,但这是平台设备驱动的基础)。
设备查找:通过路径找到节点
有了设备树节点之后,驱动代码的第一步就是找到这个节点。我们来看看设备树版本的 led_hw_init 函数:
int led_hw_init(void) {
/* ... 变量定义 ... */
/* 1. 获取设备树节点 */
led.device_tree_node = of_find_node_by_path(kIMX_AES_LED);
if (led.device_tree_node == NULL) {
pr_err("dtsled node can not found!\n");
return -EINVAL;
}
pr_info("dtsled node has been found!\n");这里的 of_find_node_by_path 函数通过路径字符串查找设备树节点。kIMX_AES_LED 是一个常量字符串,值是 "/imx_aes_led",正是我们在设备树里定义的节点路径。
请注意这里的错误处理:如果找不到节点,函数会返回错误码。这是一个好习惯,但在硬编码版本里,我们没有这种检查——因为地址是写死的,编译时就确定了,不存在"找不到"的情况。这种差异反映了一个深层的设计理念:设备树方式承认硬件配置是动态的,需要在运行时检查。
属性读取:验证设备的存在性
找到节点之后,我们的代码还做了一些额外的检查——读取设备的属性:
/* 2. 获取 compatible 属性 */
proper = of_find_property(led.device_tree_node, "compatible", NULL);
if (proper == NULL) {
pr_err("compatible property find failed\n");
} else {
pr_info("compatible = %s\n", (char*)proper->value);
}
/* 3. 获取 status 属性 */
ret = of_property_read_string(led.device_tree_node, "status", &str);
if (ret < 0) {
pr_err("status read failed!\n");
} else {
pr_info("status = %s\n", str);
}这些检查在实际的产品驱动中是很有价值的。比如 status 属性可以用来禁用某个设备——当你在设备树里把 status 改成 "disabled" 时,驱动就会发现这个设备不可用,从而跳过初始化。这种机制在系统调试时非常有用。
地址映射:使用 of_iomap
最关键的差异在于地址映射。设备树版本使用了 of_iomap 函数:
/* 4. 获取 reg 属性内容 */
ret = of_property_read_u32_array(led.device_tree_node, "reg", regdata, 10);
if (ret < 0) {
pr_err("reg property read failed!\n");
of_node_put(led.device_tree_node);
return -EINVAL;
}
/* 5. 使用 of_iomap 进行寄存器地址映射 */
led.ccm_ccgr1 = of_iomap(led.device_tree_node, 0);
led.sw_mux_gpio = of_iomap(led.device_tree_node, 1);
led.sw_pad_gpio = of_iomap(led.device_tree_node, 2);
led.gpio_dr = of_iomap(led.device_tree_node, 3);
led.gpio_gdir = of_iomap(led.device_tree_node, 4);请注意 of_iomap 的调用方式。第一个参数是设备树节点,第二个参数是索引。这个索引对应 reg 属性中的第几组地址。索引 0 对应第一组 0X020C406C 0X04,索引 1 对应第二组,以此类推。
这个设计非常巧妙。驱动代码不需要知道具体的地址值,它只需要知道"我需要第几个寄存器"。具体的地址是什么,那是设备树的事情。这种抽象让驱动代码和硬件配置解耦了。
错误处理:更完善的资源管理
设备树版本的代码在错误处理上也更加完善。我们来看看资源释放的部分:
void led_hw_deinit(void) {
pr_info("Deinit LED Hardware\n");
if (led.ccm_ccgr1) {
iounmap(led.ccm_ccgr1);
led.ccm_ccgr1 = NULL;
}
/* ... 其他 iounmap 调用 ... */
if (led.device_tree_node) {
of_node_put(led.device_tree_node);
led.device_tree_node = NULL;
}
}请注意最后两行:我们调用了 of_node_put 来释放设备树节点的引用计数。这是设备树 API 的要求——每次调用 of_find_node_by_path 都会增加节点的引用计数,用完后必须调用 of_node_put 来减少计数,否则会导致内存泄漏。
在硬编码版本里,我们没有这个概念,因为根本没有设备树节点。但设备树版本引入了这个新的资源管理维度,你需要记住在适当的时候释放资源。
核心差异对比:四个维度的分析
现在我们来系统总结一下这两种方式的核心差异。我们从四个维度来对比:地址获取方式、设备发现机制、可维护性和可移植性。
地址获取方式:硬编码 vs 动态读取
硬编码方式最直接的问题就是地址写死在代码里。你在 led_reg.h 里定义了一堆常量,然后在 led_hw.c 里使用。这种方式的问题是:地址是编译时常量,修改地址需要重新编译驱动。
设备树方式把地址信息放到了设备树文件里,驱动代码通过 API 动态读取。修改地址只需要重新编译设备树(甚至可以只编译设备树的 dtb 文件),不需要动驱动代码。
从系统设计的角度看,这是"配置与代码分离"原则的体现。硬件配置是易变的,驱动逻辑是相对稳定的。把易变的部分剥离出来,可以显著降低维护成本。
设备发现:无机制 vs 路径查找
硬编码方式根本没有设备发现的概念。驱动代码假设硬件一定存在,直接操作寄存器。如果硬件不存在,你会收到一些奇怪的错误——可能是访问了无效地址,可能是读到了全 0 或全 1 的数据。
设备树方式有明确的设备发现流程。驱动先通过 of_find_node_by_path 查找设备树节点,如果找不到就知道设备不存在,可以提前返回。这比硬编码方式的各种诡异错误要友好得多。
更进一步的设备树驱动(比如平台设备驱动)还会使用 compatible 属性进行自动匹配。内核会自动把设备树节点和对应的驱动绑定起来,你甚至不需要手动调用 of_find_node_by_path。但这属于更高级的主题了。
可维护性:重新编译 vs 修改配置
假设你现在需要把 LED 从 GPIO1_IO03 改成 GPIO1_IO04。我们来看看两种方式的修改成本。
在硬编码方式下,你需要:
- 查找芯片手册,找到新引脚对应的寄存器地址
- 修改
led_reg.h里的地址定义 - 检查
led_hw.c里是否有硬编码的位偏移量(比如那个26) - 重新编译驱动模块
- 部署新的
.ko文件到目标系统
在设备树方式下,你需要:
- 修改设备树文件里的
reg属性(如果寄存器地址变了) - 重新编译设备树为
.dtb文件 - 部署新的
.dtb文件到目标系统的 boot 分区
设备树方式的优势在于:驱动代码完全不用动。而且设备树文件的修改通常比驱动代码的修改要简单——你不需要理解驱动的内部逻辑,只需要知道硬件配置。
可移植性:板子绑定 vs 通用驱动
硬编码方式的驱动是绑定到特定板子的。因为寄存器地址、引脚配置这些信息都是为某块板子量身定制的,换一块板子就得修改代码。这在产品开发中是个大问题——假设你用同一款芯片做了三款不同的产品,你就得维护三份几乎相同的驱动代码。
设备树方式的驱动可以做到硬件无关。驱动代码只关心"怎么操作 LED",不关心"LED 在哪个引脚上"。硬件差异由设备树描述,驱动代码保持通用。这种设计让驱动的复用性大大提高。
优缺点分析:没有银弹
到这里,你可能会觉得设备树方式完胜硬编码。但说实话,技术世界里没有银弹,每种方案都有它的适用场景。我们来客观分析一下两种方式的优缺点。
硬编码方式的优点
硬编码方式最大的优点就是简单直接。你不需要学习设备树的语法,不需要理解 OF API,不需要编译设备树。对于刚入门驱动开发的人来说,硬编码方式的学习曲线更低。
另一个优点是调试方便。所有的逻辑都在 C 代码里,你可以用 GDB 单步调试,可以在代码里加断点,可以直接打印变量值。设备树方式在调试设备树配置时,需要额外的工具(比如 dtc 来反编译 dtb,或者 /sys/firmware/devicetree 来查看运行时设备树)。
对于一些一次性项目、原型验证,或者你确定永远不会换硬件的场景,硬编码方式也完全够用。没有必要为了"正确性"而引入不必要的复杂性。
硬编码方式的缺点
硬编码方式的缺点我们在前面已经说了很多:代码不可移植、修改需要重新编译、硬件信息散落在代码的各个角落。这里就不重复了。
设备树方式的优点
设备树方式的核心优势是"配置与代码分离"。这让驱动更加通用,硬件配置更加集中。对于需要支持多硬件平台的项目来说,这是巨大的优势。
另一个优点是标准化。设备树是 Linux 内核社区的标准做法,所有的芯片厂商、外设驱动都遵循这个标准。这意味着你写的驱动更容易被上游社区接受,也更容易复用别人的代码。
设备树还支持一些高级特性,比如设备 overlays(运行时动态添加设备)、属性绑定(通过 compatible 属性自动匹配驱动)等。这些特性在复杂系统中非常有用。
设备树方式的缺点
设备树方式的学习曲线确实比较陡峭。你需要学习 DTS 语法、OF API、设备树编译流程,还得理解内核的驱动模型。对于初学者来说,这些概念可能会让人有点晕。
设备树的调试也比纯 C 代码麻烦。设备树编译成 dtb 后是二进制格式,你不能直接用文本编辑器查看。虽然可以用 dtc -I dtb -O dts 反编译,但这个额外的步骤还是会增加调试成本。
还有一个容易被忽视的问题:设备树文件的错误在编译时可能不会被检测出来。比如你把寄存器地址写错了,设备树编译器不会报错,因为从语法上看这是合法的。只有当你运行驱动时,才会发现访问了错误的地址。这种运行时错误比编译时错误更难定位。
迁移建议:什么时候该升级
那么问题来了:你什么时候应该从硬编码迁移到设备树?
我的建议是:
如果你只是在学习驱动开发的基础概念,硬编码方式完全够用。你现在的重点是理解字符设备框架、文件操作接口、并发控制这些核心概念,硬件配置的方式不是重点。等这些基础打牢了,再来学习设备树也不迟。
如果你在做一个原型项目或者一次性项目,而且硬件配置基本确定不会变,硬编码方式也没问题。不要为了"工程正确性"而引入不必要的复杂性。
但如果你在做一个正式的产品,或者需要支持多个硬件平台,那就应该考虑设备树方式了。这时候可维护性和可移植性带来的收益,会超过学习成本。
如果你准备把驱动提交到主线内核,那设备树是必须的。内核社区不会接受硬编码硬件信息的驱动,这是社区的硬性要求。
迁移的时候,建议分步骤进行。先保持驱动逻辑不变,只把硬件配置部分改成从设备树读取。等这部分稳定了,再考虑重构驱动代码本身。不要试图一次性改太多,否则出问题时你很难定位是哪一步出了问题。
下一步
到这里,我们已经完成了从硬编码到设备树的完整对比。你应该能清楚地看到两种方式的差异,以及它们各自的适用场景。
但我们的设备树之旅还没有结束。到目前为止,我们写的驱动还是"裸驱动"——它手动查找设备树节点,手动进行资源管理。这种写法虽然能工作,但不是 Linux 内核推荐的做法。
接下来的章节,我们会学习更现代的驱动框架:平台设备驱动。你会发现,设备树和平台设备驱动的结合,能让硬件抽象变得更加优雅。内核会自动帮你完成设备匹配、资源分配这些繁琐的工作,你只需要专注于设备的操作逻辑。
继续阅读: 08. 设备树驱动改造 了解如何用更现代的方式编写设备树驱动。