【Linux驱动实战】:以按键驱动入门中断子系统(附源码)

0 阅读16分钟

0. 前言

作为一名 Linux 驱动开发初学者,中断 是继续深入学习的必经之路。

在之前的学习中,我们可能都是比较习惯用 轮询 的方式检查硬件状态,或者在学习单片机时接触过中断。但是在追求高性能和低功耗的 Linux 系统中,中断绝对是硬件与内核沟通的主流方式。

本文将会记录我实现一个基于 设备树平台总线 的按键驱动全过程,此外,还引入了 内核定时器 来解决机械案件的 抖动 问题。

本文涵盖以下内容:

  • 中断基本概念与上半部,下半部机制。
  • 设备树中 GPIO 与中断属性的配置。
  • Linux 内核中断与定时器的核心 API。
  • 平台总线的驱动框架分析。

1. 中断基础知识

在接触代码之前,我们必须要搞懂几个问题:为什么需要中断?中断分为哪几类?中断的处理有哪些需要注意的点?

1.1 轮询和中断

1.轮询:

在讲中断之前,我们先搞懂轮询是什么流程。

举一个生活中常见的例子:手机发出提示,你有一个快递正在被派送,然后,你每隔一分钟都要去门口看快递员有没有来。当然,如果快递员没有来,你去查看的这段时间就白白浪费了。

对于单核 CPU 来说也是一样的,通常意义上的 并行 是在宏观层面角度而言的,这其实是由操作系统的 调度器 实现的,真实的情况是 CPU 在某一时刻同样只能做一件事,它消耗时间去查询是否触发,如果没有触发,那用来查询的时间同样是白白浪费,消耗 CPU 的资源。

2.中断:

而中断相比轮询而言效率就比较高了。

同样是上面那个场景,你的快递正在被派送,但是外卖员到你家门口时会敲门,这种情况下你就可以放心做自己的事,看书或者做饭,他来了之后敲门,你拿走快递,没有造成时间的浪费。

而站在 CPU 的角度来看,中断更有利于提升 CPU 的工作效率。

在驱动开发中,中断 允许硬件在发生特定事件时,比如我们本文要讲的按键中断,主动通知 CPU 停止当前任务,转而执行一段 特殊的处理程序

1.2 中断的上下半部

Linux 内核对中断的处理比较特殊,当中断发生时,CPU 会进入 中断上下文,此时 CPU 往往是 关中断 的,并且 不能进入休眠。如果我们在中断处理程序中进行太多的耗时的工作,整个系统会因为某些重要的中断没有被及时地处理而发生卡顿,甚至崩溃。

但是总有一些场景,我们需要在中断中处理一下耗时的工作。为了解决这个矛盾,Linux 产生了 中断上半部中断下半部 的概念:

  • 上半部: 直接响应硬件中断,运行在 中断上下文,并且通常会 关闭全局中断。在这里绝对不能休眠,必须要尽快执行完,否则系统可能会丢失其他中断。
  • 下半部: 处理比较耗时的工作,允许其他中断。

根据上下文的不同,我们还可以把下半部分为两大类,即 原子类进程类,下面我列表对比一下几种下半部的机制:

1. 下半部对比.png

如上表,是下半部的几种机制运行的上下文,以及是否运行休眠的信息。

  • 软中断 的性能最高,可以多核并行,但开发极其复杂,通常只有内核核心层使用。
  • Tasklet 是基于软中断实现的,特点是同一时刻一个 Tasklet 只能在一个 CPU 上运行,省去了并发控制的麻烦,简单好用。
  • 工作队列 运行在内核线程中。因为它可以休眠,所以如果你要读写文件、等待某个锁,必须用它。
  • 中断线程化 通过 request_threaded_irq 申请。内核会自动为该中断创建一个内核线程,逻辑最清晰,并且支持优先级调度。

1.3 为什么选择内核定时器

既然中断下半部有那么多好用的机制,为什么我们不用?而是用了 struct timer_list 呢?

根本原因在于 业务需求不同

在按键驱动中,我们要实现一个特殊的业务需求,也就是延时

  • Tasklet 和 Workqueue 的目标是 尽快执行,一旦 CPU 有空就会跑。
  • 但按键驱动的核心需求是 等一会儿再执行,我们需要在中断触发后,避开 20ms 的波形抖动期,然后再去读取引脚电平。

我们消抖的逻辑如下:

内核定时器提供了一个关键功能:mod_timer。如果在 20ms 内又来了中断(也就是抖动),我们可以不断刷新定时时间。这种 重置 的特性,是 Tasklet 等机制不具备的。其核心行为可以参照下图理解:

2. 内核定时器.png

对不同场景的选择,我的总结如下:

  • 如果要 处理大数据量,比如网卡,那就选 Tasklet 或者 软中断
  • 如果要 操作复杂逻辑且涉及锁,如读写 I2C 寄存器,那就选 Workqueue中断线程化
  • 如果要 处理硬件抖动或完成周期性任务,那就选选 Timer

1.4 中断上下文的禁忌

在编写中断相关的代码时,有一条规矩:中断上下文中严禁调用任何 可能引起进程切换 的函数。

下面简单列举几条:

  • 不能用 msleep:中断不是进程,它没有自己的调度实体,一旦休眠,系统就再也回不来了。
  • 不能在获取 mutex 时等待:因为互斥锁在被占用时会导致调用者进入睡眠。
  • 打印函数 printk 是安全的:内核对它做了特殊处理,可以 在中断中使用。

2. 设备树配置

在 Linux 驱动开发中,我们标准的方式不再是往代码里面硬编码硬件地址,而是通过 设备树 文件告诉内核硬件的信息。

对于我们的按键中断驱动,设备树里面主要干两件事:配置引脚功能描述设备节点

2.1 配置 GPIO 引脚

在配置之前先得看看我们用的哪个引脚:

3. 40pin引脚图.png

如上图红色剪头所指,就是我们用的引脚。我用的板子是 鲁班猫 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_upGPIO 配置为 内部上拉。这非常重要,按键一端接引脚,一端接地,当按键没按下时,内部上拉能确保引脚电平稳定在高电平,防止杂信号 误触发中断

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 中,&gpio3RK_PA7 是引用了我们上一节配置好的引脚。GPIO_ACTIVE_LOW 告诉内核这个引脚是 低电平有效。Linux 的 GPIOD 子系统会根据这个标识,自动帮我们处理逻辑取反。
  • interrupt-parent 指定由谁来处理这个中断,这里是 &gpio3,即 GPIO 控制器本身也是一个 中断控制器
  • interruptsIRQ_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 语言又是面向过程的语言,但驱动开发充满了面向对象的思想。

  1. 封装私有结构体 struct my_key_dev:它把 GPIO 描述符、中断号、定时器全部封装在一起,通过这个结构体,我们在不同函数间传递数据时,只需要传递一个指针,安全又高效。
  2. 驱动通过设备树里的 compatible 字符串找到硬件,并根据自己的 .of_match_table 完成匹配。一旦匹配成功,内核就会调用 probe,整个驱动程序的初始化都会在这里完成。
  3. 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 开始验证

下面是我连接好的实物图:

4. 实物图.jpg

我们给板子通电,然后连上 SSH ,加载驱动,如下:

5. 加载驱动.png

可以看到,probe 函数中的初始化操作已经成功了。

然后看看内核的中断管理表里,是否真的多了我们申请的中断号:

cat /proc/interrupts

6. 中断号.png

可以看到,确实出现了。

然后我们按几次按键,内核日志和中断管理表的变化如下:

7. 按下按键.png

日志并没有因为 机械抖动 而出现密集的按下,抬起重复刷屏,这证明我们的 20ms 内核定时器消抖方法 生效了。

最后卸载模块:

8. 卸载.png

结语

写了好久,也是终于搞完了,大家可以用自己的板子尝试一下,如果成功了,还可以进一步,实现用按键控制 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");