0. 前言
作为一名 Linux 驱动开发初学者,中断 是继续深入学习的必经之路。
在之前的学习中,我们可能都是比较习惯用 轮询 的方式检查硬件状态,或者在学习单片机时接触过中断。但是在追求高性能和低功耗的 Linux 系统中,中断绝对是硬件与内核沟通的主流方式。
本文将会记录我实现一个基于 设备树 和 平台总线 的按键驱动全过程,此外,还引入了 内核定时器 来解决机械案件的 抖动 问题。
本文涵盖以下内容:
- 中断基本概念与上半部,下半部机制。
- 设备树中 GPIO 与中断属性的配置。
- Linux 内核中断与定时器的核心 API。
- 平台总线的驱动框架分析。
1. 中断基础知识
在接触代码之前,我们必须要搞懂几个问题:为什么需要中断?中断分为哪几类?中断的处理有哪些需要注意的点?
1.1 轮询和中断
1.轮询:
在讲中断之前,我们先搞懂轮询是什么流程。
举一个生活中常见的例子:手机发出提示,你有一个快递正在被派送,然后,你每隔一分钟都要去门口看快递员有没有来。当然,如果快递员没有来,你去查看的这段时间就白白浪费了。
对于单核 CPU 来说也是一样的,通常意义上的 并行 是在宏观层面角度而言的,这其实是由操作系统的 调度器 实现的,真实的情况是 CPU 在某一时刻同样只能做一件事,它消耗时间去查询是否触发,如果没有触发,那用来查询的时间同样是白白浪费,消耗 CPU 的资源。
2.中断:
而中断相比轮询而言效率就比较高了。
同样是上面那个场景,你的快递正在被派送,但是外卖员到你家门口时会敲门,这种情况下你就可以放心做自己的事,看书或者做饭,他来了之后敲门,你拿走快递,没有造成时间的浪费。
而站在 CPU 的角度来看,中断更有利于提升 CPU 的工作效率。
在驱动开发中,中断 允许硬件在发生特定事件时,比如我们本文要讲的按键中断,主动通知 CPU 停止当前任务,转而执行一段 特殊的处理程序。
1.2 中断的上下半部
Linux 内核对中断的处理比较特殊,当中断发生时,CPU 会进入 中断上下文,此时 CPU 往往是 关中断 的,并且 不能进入休眠。如果我们在中断处理程序中进行太多的耗时的工作,整个系统会因为某些重要的中断没有被及时地处理而发生卡顿,甚至崩溃。
但是总有一些场景,我们需要在中断中处理一下耗时的工作。为了解决这个矛盾,Linux 产生了 中断上半部 和 中断下半部 的概念:
- 上半部: 直接响应硬件中断,运行在 中断上下文,并且通常会 关闭全局中断。在这里绝对不能休眠,必须要尽快执行完,否则系统可能会丢失其他中断。
- 下半部: 处理比较耗时的工作,允许其他中断。
根据上下文的不同,我们还可以把下半部分为两大类,即 原子类 和 进程类,下面我列表对比一下几种下半部的机制:
如上表,是下半部的几种机制运行的上下文,以及是否运行休眠的信息。
- 软中断 的性能最高,可以多核并行,但开发极其复杂,通常只有内核核心层使用。
- Tasklet 是基于软中断实现的,特点是同一时刻一个 Tasklet 只能在一个 CPU 上运行,省去了并发控制的麻烦,简单好用。
- 工作队列 运行在内核线程中。因为它可以休眠,所以如果你要读写文件、等待某个锁,必须用它。
- 中断线程化 通过
request_threaded_irq申请。内核会自动为该中断创建一个内核线程,逻辑最清晰,并且支持优先级调度。
1.3 为什么选择内核定时器
既然中断下半部有那么多好用的机制,为什么我们不用?而是用了 struct timer_list 呢?
根本原因在于 业务需求不同。
在按键驱动中,我们要实现一个特殊的业务需求,也就是延时。
- Tasklet 和 Workqueue 的目标是 尽快执行,一旦 CPU 有空就会跑。
- 但按键驱动的核心需求是 等一会儿再执行,我们需要在中断触发后,避开 20ms 的波形抖动期,然后再去读取引脚电平。
我们消抖的逻辑如下:
内核定时器提供了一个关键功能:mod_timer。如果在 20ms 内又来了中断(也就是抖动),我们可以不断刷新定时时间。这种 重置 的特性,是 Tasklet 等机制不具备的。其核心行为可以参照下图理解:
对不同场景的选择,我的总结如下:
- 如果要 处理大数据量,比如网卡,那就选 Tasklet 或者 软中断。
- 如果要 操作复杂逻辑且涉及锁,如读写 I2C 寄存器,那就选 Workqueue 或 中断线程化。
- 如果要 处理硬件抖动或完成周期性任务,那就选选 Timer。
1.4 中断上下文的禁忌
在编写中断相关的代码时,有一条规矩:中断上下文中严禁调用任何 可能引起进程切换 的函数。
下面简单列举几条:
- 不能用
msleep:中断不是进程,它没有自己的调度实体,一旦休眠,系统就再也回不来了。 - 不能在获取
mutex时等待:因为互斥锁在被占用时会导致调用者进入睡眠。 - 打印函数
printk是安全的:内核对它做了特殊处理,可以 在中断中使用。
2. 设备树配置
在 Linux 驱动开发中,我们标准的方式不再是往代码里面硬编码硬件地址,而是通过 设备树 文件告诉内核硬件的信息。
对于我们的按键中断驱动,设备树里面主要干两件事:配置引脚功能 和 描述设备节点。
2.1 配置 GPIO 引脚
在配置之前先得看看我们用的哪个引脚:
如上图红色剪头所指,就是我们用的引脚。我用的板子是 鲁班猫 2,芯片是瑞芯微的 rk3568 ,引脚通常是复用的,一个引脚既可以是串口的 TX,也可以是普通的 GPIO,但这里我选择的是没复用功能的引脚,选择有复用功能的引脚同样可以,后面会有配置的方法。
如下代码,即为设备树中对该 GPIO 进行配置的内容:
&pctrl {
my_button_setup {
my_button_pin: my-button-pin {
rockchip,pins = <3 RK_PA7 RK_FUNC_GPIO &pcfg_pull_up>;//内部上拉
};
};
};
- 我们使用的引脚为
GPIO3_A7,于是在配置GPIO时指定GPIO组号为3,编号为RK_PA7。 RK_FUNC_GPIO是核心配置,它告诉硬件把这个引脚的功能切换为 GPIO 模式,而不是其他外设功能,这个参数就可以把具有复用功能的引脚配置为GPIO模式。- &
pcfg_pull_up将GPIO配置为 内部上拉。这非常重要,按键一端接引脚,一端接地,当按键没按下时,内部上拉能确保引脚电平稳定在高电平,防止杂信号 误触发中断。
2.2 定义设备节点
在设备树的 根节点下,我们还需要添加一个自定义的节点:
my_button {
compatible = "lubancat,mybutton";
status = "okay";
pinctrl-names = "default";
pinctrl-0 = <&my_button_pin>;
button-gpios = <&gpio3 RK_PA7 GPIO_ACTIVE_LOW>;
//中断属性关联,指定中断源
interrupt-parent = <&gpio3>;
interrupts = <RK_PA7 IRQ_TYPE_EDGE_FALLING>;
};
compatible是用来匹配驱动和硬件的。驱动代码里的of_match_table必须和这个字符串完全一致,驱动才能跑起来。button-gpios中,&gpio3和RK_PA7是引用了我们上一节配置好的引脚。GPIO_ACTIVE_LOW告诉内核这个引脚是 低电平有效。Linux 的GPIOD子系统会根据这个标识,自动帮我们处理逻辑取反。interrupt-parent指定由谁来处理这个中断,这里是&gpio3,即GPIO控制器本身也是一个 中断控制器。interrupts中IRQ_TYPE_EDGE_FALLING为 触发方式,这里设为下降沿触发。
2.3 一个小细节
实际上,在 C 代码里我们申请中断用的是 IRQF_TRIGGER_RISING | IRQF_TRIGGER_FALLING,也就是 双边沿触发,而 DTS 里写的却是 下降沿。
在 Linux 内核中,通常以驱动代码 request_irq 中的参数为 最高准则,而 DTS 里的设置更多是提供一个默认值。因为我们要实现 按下和抬起都能打印信息,所以代码里设置双边沿是最稳妥的。
3. 核心 API 讲解
在 Linux 内核驱动中,我们不直接操作寄存器,而是调用内核提供的标准接口。本章将重点介绍代码中出现的关键函数,看看它们是如何进行配合运作的。
3.1 GPIO与中断
第一个:
devm_gpiod_get(dev, "button", GPIOD_IN):
- 这个函数会根据设备树中的
button-gpios属性获取GPIO描述符。 devm_前缀代表设备资源管理,它会自动在驱动卸载时释放GPIO资源,防止忘记调用gpiod_put导致的内存泄漏。"button"对应 DTS 中"button-gpios"的前缀。GPIOD_IN将该GPIO引脚配置为输入模式。
第二个:
gpiod_to_irq(key_gpio):
- 连接 GPIO 子系统 与 中断子系统 的核心函数。
- 每个 GPIO 引脚在硬件层面上都对应一个 特定的中断源。这个函数会查询内核的中断映射表,返回一个 虚拟中断号。后续所有的中断操作都基于这个虚拟中断号。
3.2 中断管理API
现在已经有了中断号,接下来要关注的就是如何监听中断,如下函数:
devm_request_irq(dev, irq_num, handler, flags, name, dev_id)
这是驱动程序中最重要的函数之一。
handler:中断发生时调用的回调函数,处于上半部。flags:触发方式。前面讲过,代码中使用了IRQF_TRIGGER_RISING | IRQF_TRIGGER_FALLING,表示无论是按键按下还是抬起,都会触发中断。dev_id:这是一个void *类型的指针。我们传入了自定义结构体key,当多个设备共享一个中断处理函数时,它是区分到底是谁触发了中断的关键。
3.3 内核定时器API
定时器是实现下半部延时逻辑的核心工具。
第一个: timer_setup(&key->timer, callback, 0)
- 它会初始化
struct timer_list结构体。 - 然后将定时器与回调函数
key_timer_callback绑定,但是要注意,此时定时器并没有开始计时,只是准备好了。
第二个: mod_timer(&key->timer, jiffies + msecs_to_jiffies(20))
- 启动定时器。
jiffies:Linux 内核的时间基准,记录了系统启动以来的 节拍数。- 这是第二章图中 清零重计 的关键,如果进入中断时定时器已经在跑,也就是说本次中断是 抖动引起 的,
mod_timer会强行将其到期时间推迟到当前时刻起的 20ms 后。
第三个: del_timer_sync(&key->timer)
- 在驱动卸载时删除定时器。
sync表示同步,如果定时器处理函数正在另一个 CPU 上运行,该函数会等待它执行完再退出。这能有效防止驱动卸载后,定时器回调还在运行导致的崩溃。
3.4 数据存取
platform_set_drvdata(pdev, key):
- 它会将我们的私有结构体
key藏在platform_device里面。 - 在
remove函数中,我们可以通过platform_get_drvdata把这个结构体找回来,从而获取其中的定时器和GPIO信息。
掌握了上面这些 API 的用法,我们就可以在下一章开始构建整个程序执行流程的 逻辑框架,看看这些 API 是如何被串联起来完成一项复杂的中断和消抖任务的。
4. 平台总线与程序执行逻辑
在 Linux 驱动开发中,如何组织代码 比 如何写功能 更重要。本驱动采用了 平台总线模型,实现了硬件描述与驱动逻辑的分离。
4.1 面向对象的思想
虽然 Linux 内核是用 C 语言写的,而 C 语言又是面向过程的语言,但驱动开发充满了面向对象的思想。
- 封装私有结构体
struct my_key_dev:它把 GPIO 描述符、中断号、定时器全部封装在一起,通过这个结构体,我们在不同函数间传递数据时,只需要传递一个指针,安全又高效。 - 驱动通过设备树里的
compatible字符串找到硬件,并根据自己的.of_match_table完成匹配。一旦匹配成功,内核就会调用probe,整个驱动程序的初始化都会在这里完成。 remove函数负责在驱动退出时,停掉定时器,即del_timer_sync,确保系统不会因为访问不存在的资源而崩溃。
4.2 程序执行逻辑
我们要理解的不仅是代码怎么写,更要理解信号在内核里是怎么传输的。
为了彻底理解这个驱动,我们假设一个场景:用户按下按键,由于机械特性,按键产生了 15ms 的电平抖动。 让我们看看内核是如何处理的:
- 当手指按下按键,引脚电平从高电平变为低电平。
- 这时,
GPIO控制器捕捉到下降沿,立刻向 CPU 发出中断信号。 - CPU 暂停当前任务,进入
key_isr中断处理函数,执行mod_timer(&key->timer, jiffies + 20ms),也就是说,内核定了一个 20ms 后的闹钟。 - 因为按键在抖动,电平在 15ms 内会反复跳变,每一次跳变都会再次进入
key_isr,每次进入key_isr都会重新执行mod_timer。闹钟的开始时间被不断往后推1ms、5ms、10ms、15ms,闹钟始终没机会响。 - 在 15ms 时,按键彻底接触良好,电平 稳定在低电平,不再有跳变。
- 15ms 时的最后一次中断,将闹钟定在了 15ms + 20ms = 35ms 这个时间点,和我们第二章的图是同一个逻辑。
- 接下来的 20ms 内,我们的驱动程序没有任何中断产生,CPU 可以去忙别的事情,而定时器在后台计时。
- 当时间到达 35ms,内核定时器系统触发回调函数
key_timer_callback。 - 此时运行在 下半部环境,执行
val = gpiod_get_value(key->key_gpio),虽然物理引脚是 低电平,但因为我们在设备树里设置了GPIO_ACTIVE_LOW,所以gpiod接口会返回 1。 - 代码执行
if(val == 1),条件成立,调用printk(KERN_INFO "[中断下半部]:按键按下有效!\n"),内核将这行字符串放入 ring buffer,最后你在终端输入dmesg,看到打印的信息。
5. 验证与总结
5.1 准备工作
我们在验证驱动程序运行结果之前还需要一些准备工作:
- 编写 Makefile,交叉编译生成
.ko文件。 - 将修改后的
.dts编译为.dtb并更新至开发板。 - 然后就可以加载驱动了。
5.2 开始验证
下面是我连接好的实物图:
我们给板子通电,然后连上 SSH ,加载驱动,如下:
可以看到,probe 函数中的初始化操作已经成功了。
然后看看内核的中断管理表里,是否真的多了我们申请的中断号:
cat /proc/interrupts
可以看到,确实出现了。
然后我们按几次按键,内核日志和中断管理表的变化如下:
日志并没有因为 机械抖动 而出现密集的按下,抬起重复刷屏,这证明我们的 20ms 内核定时器消抖方法 生效了。
最后卸载模块:
结语
写了好久,也是终于搞完了,大家可以用自己的板子尝试一下,如果成功了,还可以进一步,实现用按键控制 led 或者其他的一些外设。
完整的驱动代码我放在下面,建议收藏起来,以备遗忘时复习。
如果这篇文章帮助到你了,还希望点个关注支持一下,谢谢了。
完整驱动代码
#include <linux/module.h>
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/of.h> //设备树API
#include <linux/gpio/consumer.h>//gpiod API
#include <linux/interrupt.h> //中断API
#include <linux/timer.h>//内核定时器API
#include <linux/platform_device.h>
struct my_key_dev{
struct gpio_desc* key_gpio; //gpio描述符
int irq_num; //中断号
struct timer_list timer; //定时器
};
//定时器回调函数
static void key_timer_callback(struct timer_list *t)
{
struct my_key_dev* key = from_timer(key, t, timer);
int val;
val = gpiod_get_value(key->key_gpio);
if(val == 1)
{
printk(KERN_INFO "[中断下半部]:按键按下有效!\n");
}
else
{
printk(KERN_INFO "【中断下半部】:按键抬起!\n");
}
}
//中断服务函数
static irqreturn_t key_isr(int irq, void* dev_id)
{
struct my_key_dev* key = (struct my_key_dev*)dev_id;
//jiffies是内核当前节拍数
mod_timer(&key->timer, jiffies + msecs_to_jiffies(20));//定个20ms闹钟
return IRQ_HANDLED;
}
static int my_key_probe(struct platform_device* pdev)
{
struct device* dev = &pdev->dev;
struct my_key_dev *key;
int ret;
key = devm_kzalloc(dev, sizeof(*key), GFP_KERNEL);
if(!key) return -ENOMEM;
//解析设备树
key->key_gpio = devm_gpiod_get(dev, "button", GPIOD_IN);
if(IS_ERR(key->key_gpio))
{
ret = PTR_ERR(key->key_gpio);
printk(KERN_INFO "获取GPIO失败!\n");
return ret;
}
//把GPIO转换成虚拟中断号
key->irq_num = gpiod_to_irq(key->key_gpio);
if(key->irq_num < 0)
{
printk(KERN_INFO "GPIO转中断号失败!\n");
return key->irq_num;
}
//初始化内核定时器
timer_setup(&key->timer, key_timer_callback, 0);
//申请并注册中断
ret = devm_request_irq(dev, key->irq_num, key_isr, IRQF_TRIGGER_RISING | IRQF_TRIGGER_FALLING, "my_key_irq", key);
if(ret)
{
printk(KERN_INFO "申请中断失败!\n");
return ret;
}
platform_set_drvdata(pdev, key);
printk(KERN_INFO "按键驱动加载成功!\n");
return 0;
}
static int my_key_remove(struct platform_device* pdev)
{
struct my_key_dev *key = platform_get_drvdata(pdev);
del_timer_sync(&key->timer);
printk(KERN_INFO "按键驱动成功卸载!\n");
return 0;
}
static const struct of_device_id my_key_match[] = {
{.compatible = "lubancat,mybutton",},
{}
};
MODULE_DEVICE_TABLE(of, my_key_match);
static struct platform_driver my_key_driver = {
.probe = my_key_probe,
.remove = my_key_remove,
.driver = {
.name = "my_key_driver_name",
.of_match_table = my_key_match,
},
};
module_platform_driver(my_key_driver);
MODULE_LICENSE("GPL");