深度剖析 Linux Input 子系统(3):从零写一个 Input 驱动,最详细手把手(附完整代码)

0 阅读16分钟

0. 前言

前面两篇文章我们已经拆解了 Input 子系统的架构。本文我们将手写一个完整的 GPIO 按键驱动,完整走一遍 Input 设备从宣告功能到上报事件的流程,为了大家能清晰的看到按键按下和松开的状态,我添加了红蓝两个 LED,但我们还是要把核心注意力放在 Input 事件的流转 上。

温馨提示:本文较长,可以先收藏起来,慢慢看。


1. 开发环境与预期功能介绍

我用的板子是野火的 lubancat 2,搭载 RK3568 芯片,如下图:

1. 板子.jpg

我外接了一个按键和两个 LED,LED 分别为红色和蓝色。

按键所接引脚通过上拉电阻拉至 VCC,另一端接地。

红色 LED 正极所接引脚配置为 高电平有效,初始为 低电平,负极与按键共地。

蓝色 LED 正极所接引脚配置为 低电平有效,初始为 低电平,负极与按键共地。

采用 设备树 描述硬件引脚,利用 Platform 平台总线 匹配驱动,实现软硬件深度解耦。

预期交互效果为:

  • 当模块加载后,此时未按下按键,蓝灯亮,红灯灭,标志着驱动成功识别硬件。
  • 按下按键,红灯亮,蓝灯灭。
  • 松开按键,红灯灭,蓝灯亮。
  • 通过 evtest 工具,应用层能实时捕获到标准 Input 事件,按下时输出 value 1,松开时输出 value 0

两个 LED 初始状态的差异,是为了方便我们一眼看出驱动是否加载成功,蓝灯亮起即代表驱动 probe 执行成功。

为什么我们要费力气去写一个 Input 驱动,而不是简单的字符设备驱动来读取 GPIO?答案就在 Input 子系统的 事件同步机制 中。接下来,我们先从设备树开始改起。


2. 设备树修改并编译

2.1 设备树寻找

为了整篇文章的完整性,我们从怎样寻找你的板子所使用的设备树讲起。

我用的是野火官方提供的 SDK,是野火基于 Linux 4.19.232 内核版本进行板级适配之后得到的。设备树文件一般存放在内核源码目录 /arch/arm64/boot/dts 目录下,如下图:

2. 内核目录.png

前面两级是我自建的目录,大家也可以去这个目录下寻找你自己板子使用的设备树文件。我的芯片是 RK3568,是瑞芯微的,所以我进入 rockchip 目录下:

3. 设备树文件展示.png

这个目录下设备树文件相当多,其中不带 lubancat 关键词的,都是瑞芯微提供的设备树文件;带有 lubancat 关键词的是野火官方根据板子的实际情况所进行的适配,我们要在其中寻找我们板子所用的设备树文件。

目前,大多数板子都在 /boot 目录下有一个 软链接,这个软链接指向你的板子真实使用的设备树 dtb 文件,通常将这个软链接硬编码到 U-Boot 中,这样,U-Boot 在引导时会将软链接所指向的真实文件,也就是你真正使用的设备树文件通过寄存器传递给内核,内核在启动时进行解析,最后构建出完整的设备树。

因此,我们只需要找到这个软链接,就可以知道我们真实使用的设备树文件。如下,我们进入到板子的 /boot 目录下进行查看:

4. 板子设备树.png

倒数第二行,可以看到 rk-kernel.dtb 是一个软链接,它指向的是 /boot/dtb/rk3568-lubancat-2-v3.dtb

这里需要知道,后缀为 dts 的文件是人类可读的文本文件,也就是我们要修改的,后缀为 dtb 的文件是 dts 文件编译后的产物。

截止目前,我们已经知道了要修改的设备树文件是 rk3568-lubancat-2-v3.dts ,现在,请大家回过头去看 本章的第二张图片,第三列有个颜色和其他不同的文件,正是我们要找的。

接下来的任务就明确了,我们要将这个文件进行修改,然后编译,最后拷贝到板子的 /boot/dtb 目录下,覆盖掉原本的旧文件。

2.2 设备树修改

2.2.1 设备树基本结构

篇幅限制,我不会把设备树从头到尾讲一遍,这里只讲我们本文涉及到的。

设备树由一个个 节点 组成,结构就像一棵树,这也是它被命名为设备树的原因。

  • 根节点/ { } 表示,所有的设备节点都挂在它下面。由于我的文件根节点里面写了太多东西,截图截不全,我重新找一个设备树文件为大家展示根节点的全貌:

5. 根节点全貌.png

  • 子节点 用于描述具体的设备,比如按键和 LED 等,子节点通常在根节点内部。
  • 属性 在节点内部,描述引脚号,电平等信息。上面截图中的 modelcompatible 都是属性。

2.2.2 节点解析

我们先来看看本文所用的设备节点长什么样子:

6. 子节点.png

该节点是位于 根节点内部 的,节点名称为 my_red_blue_button ,请对这个名字留一点印象,后面我们要通过这个名字来判断新的设备树是否解析成功。下面来看节点内部的属性:

  • compatible 是最重要的属性,它是一个字符串。内核会拿着这个字符串去驱动程序里找与它相同的进行匹配,从而完成 设备与驱动的匹配。我们设为 "lubancat,myredblue",稍后在驱动代码里也要写一模一样的。
  • status 用来说明这个节点是否启用,我们填 "okay" 就行。
  • pinctrl-names 是用来定义引脚控制状态的名称列表,"default" 是最常见的状态名称,表示采用默认的引脚配置。
  • pinctrl-0 对应 pinctrl-names 中第一个状态的引脚配置,my_button_pin 是在 pinctrl 节点中预先定义好的引脚配置,这里使用& 引用这个节点。

再看 button-gpios = <&gpio3 RK_PA7 GPIO_ACTIVE_LOW>,首先要知道设备树中 GPIO 的配置,属性名称一定要以 -gpios 为后缀,在驱动程序中根据这个属性名称的 前缀字符串 获取该 GPIO。&gpio3 代表按键接在第三组 GPIO 控制器上,RK_PA7 是具体的引脚编号,是瑞芯微官方的宏定义,GPIO_ACTIVE_LOW 代表低电平有效。

其余两个 LED 的 GPIO 与上面的按键同理。在往下看:

interrupt-parent 属性指定当前设备使用哪个中断控制器,&gpio3 表示使用 GPIO3 控制器作为中断父设备。

interrupts 指定中断的具体参数,第一个参数是引脚,第二个参数代表 双边沿 触发。

2.2.3 pinctrl相关

在 RK3568 这种复杂的芯片上,一个引脚可能有多种功能,同一个引脚既可以是 GPIO,也可以是串口。Pinctrl 在这里的作用就是强行把这个引脚固定在 GPIO 模式。

根节点外面,我们通过 &pinctrl { ... } 对已有的 Pinctrl 节点进行了追加,使用 & 符号代表引用并修改:

7. 追加pinctrl.png

我新建了一个 my_button_setup 引脚组,在工程比较庞大时,这样能更有效的管理引脚配置,这个习惯大家可以培养一下。

my_button_pin 是标签,用于在其他地方引用这个节点。my-button-pin 是真实的节点名称,设备树解析后会出现在 /pinctrl/my_button_setup/my-button-pin 路径中。

然后就是确认引脚配置了:3 为按键所在的 GPIO 引脚组,RK_PA7 为具体引脚编号,RK_FUNC_GPIO 表示切换为GPIO功能,&pcfg_pull_up 表示将引脚配置为内部上拉,这保证当按键没按下的时候引脚电平是稳定的高电平,不会因为外界干扰产生浮空状态。

2.3 设备树编译

到这里,设备树已经修改完成。我们要进行编译,得到 dtb 文件。

使用下面命令,实现只编译设备树,不编译内核,从而节省大量时间:

make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- dtbs -j8

8. 设备树编译.png

可以看到,终端提示已经编译生成了新的 rk3568-lubancat-2-v3.dtb 文件。

2.4 拷贝到板子并验证

下面将编译生成的 dtb 文件拷贝到板子。

拷贝的方法有很多种,我不一一讲,只简要介绍一下我的方法。

我在板子和虚拟机之间搭建了一个 NFS 网络文件系统,虚拟机作为服务器端,板子作为客户端。我只需要在虚拟机上将该 dtb 文件拷贝到共享文件夹中,然后在板子的终端进入共享文件夹,再将 dtb 文件拷贝到 /boot/dtb 下即可。

整个流程如下:

虚拟机终端:

9. 拷贝dtb(1).png

板子终端:

10. 拷贝dtb(2).png

到这就拷贝好了。

然后使用 reboot 重启板子进行验证。

我们在 proc/device-tree 目录下找到了 以设备树中节点命名 的目录,并且该目录下的文件,与我们在节点中定义的属性同名,我们还可以用 cat 读取这些文件的内容,发现和我们定义的值也是相同的。不过有的文件由于值为十六进制,用cat读会显示乱码,这时可以使用 hexdump 进行读取,如下图:

11. 查看属性文件.png

12. 读取十六进制.png

至此,我们的 设备树就被成功解析了,也就代表着我们硬件部分已经完成,下面我们开始搞驱动程序。


3. 驱动程序编写与深度解析

3.1 完整驱动代码实现

我们将代码命名为 my_input_key.c,注意一下代码中对 devm_ 系列函数的使用,这是现代 Linux 的标准做法,避免资源泄露。

#include <linux/module.h>
#include <linux/init.h>
#include <linux/gpio/consumer.h>
#include <linux/interrupt.h>
#include <linux/platform_device.h>
#include <linux/timer.h>
#include <linux/input.h>
#include <linux/jiffies.h>struct my_key_data{
    struct input_dev* input;//输入设备
    struct gpio_desc* gpiod_key;//按键
    struct gpio_desc* gpiod_red;//红灯
    struct gpio_desc* gpiod_blue;//绿灯
    struct timer_list timer;//内核定时器,消抖int irq_num;//中断号
    unsigned int key_code;//表示的键码
};
​
//定时器回调函数
static void my_key_timer_handler(struct timer_list* t)
{
    struct my_key_data* data = from_timer(data, t, timer);//找到宿主结构体int state;
​
    state = gpiod_get_value(data->gpiod_key);//读取稳定后的电平
​
    gpiod_set_value(data->gpiod_red, state);
    gpiod_set_value(data->gpiod_blue, state);
​
    input_report_key(data->input, data->key_code, state);//上报
    input_sync(data->input);//同步
}
​
//中断处理函数
static irqreturn_t my_key_isr(int irq, void* dev_id)
{
    struct my_key_data* data = (struct my_key_data*)dev_id;
​
    mod_timer(&data->timer, jiffies + msecs_to_jiffies(20));//开始或刷新定时器return IRQ_HANDLED;
}
​
static int my_key_probe(struct platform_device* pdev)
{
    struct device* dev = &pdev->dev;
    struct my_key_data* data;
    int ret;
​
    data = devm_kzalloc(dev, sizeof(*data), GFP_KERNEL);
    if(!data)
    {
        printk(KERN_ERR "kzalloc failed!\n");
        return -ENOMEM;
    }
    printk(KERN_INFO "kzalloc success!\n");
​
    data->key_code = KEY_POWER;//模拟电源键
​
​
    //获取三个gpio
    data->gpiod_key = devm_gpiod_get(dev, "button", GPIOD_IN);//默认输入
    if(IS_ERR(data->gpiod_key))
    {
        printk(KERN_ERR "get key gpiod failed!\n");
        return PTR_ERR(data->gpiod_key);
    }
​
    data->gpiod_red = devm_gpiod_get(dev, "red", GPIOD_OUT_LOW);//默认低电平,无效
    if(IS_ERR(data->gpiod_red))
    {
        printk(KERN_ERR "get red gpiod failed!\n");
        return PTR_ERR(data->gpiod_red);
    }
​
    data->gpiod_blue = devm_gpiod_get(dev, "blue", GPIOD_OUT_LOW);//默认低电平,有效
    if(IS_ERR(data->gpiod_blue))
    {
        printk(KERN_ERR "get blue gpiod failed!\n");
        return PTR_ERR(data->gpiod_blue);
    }
    printk(KERN_INFO "Three gpios get success!\n");
​
​
    //初始化Input设备
    data->input = devm_input_allocate_device(dev);
    data->input->name = "GPIO Key";//input设备名称
    input_set_capability(data->input, EV_KEY, data->key_code);
    ret = input_register_device(data->input);
    if(ret)
    {
        printk(KERN_ERR "Init Input device failed!\n");
        return ret;
    }
​
    //初始化内核定时器
    timer_setup(&data->timer, my_key_timer_handler, 0);
​
    //获取并申请中断
    data->irq_num = gpiod_to_irq(data->gpiod_key);
    ret = devm_request_irq(dev, data->irq_num, my_key_isr, IRQF_TRIGGER_RISING|IRQF_TRIGGER_FALLING, "my_key_irq", data);//双边沿触发if(ret)
    {
        printk(KERN_ERR "get irq failed!\n");
        return ret;
    }
​
    platform_set_drvdata(pdev,data);
    return 0;
}
​
static int my_key_remove(struct platform_device* pdev)
{
    struct my_key_data* data = platform_get_drvdata(pdev);
​
    del_timer_sync(&data->timer);
​
    //驱动卸载前关灯
    gpiod_set_value(data->gpiod_red, 0);
    gpiod_set_value(data->gpiod_blue, 1);
​
    return 0;
}
​
static const struct of_device_id my_key_id[] = {
    {.compatible = "lubancat,myredblue",},
    {}
};
​
MODULE_DEVICE_TABLE(of, my_key_id);
​
static struct platform_driver my_key_driver = {
    .probe = my_key_probe,
    .remove = my_key_remove,
    .driver = {
        .name = "my_key",
        .of_match_table = my_key_id,
    },
};
module_platform_driver(my_key_driver);
​
MODULE_LICENSE("GPL");
​

3.2 核心逻辑拆解

对于这份 150 行左右的驱动代码,看似复杂,但其实从逻辑上可以拆解为四个关键部分。

3.2.1 资源获取

probe 初始化函数中,驱动要从设备树里面获取资源。

  • 获取 gpio:通过 devm_gpiod_get(dev, "button", GPIOD_IN) 获取,正如前面讲过的,驱动程序会自动寻找以 -gpios 结尾的属性,并匹配前面的字符串 "button"
  • 获取中断号:通过 gpiod_to_irq(data->gpiod_key),我们获取 gpio 之后,内核已经知道 data->gpiod_key 这个引脚连在 gpio3 上面了,然后会自动算出对应的中断号。

3.2.2 input设备

驱动程序为按键申请了一个 struct input_dev ,步骤如下:

  • 使用 devm_input_allocate_device(dev) 分配内存。
  • 使用 input_set_capability(data->input, EV_KEY, data->key_code) 设置功能,EV_KEY 表示这是一个 按键事件,代码中的 KEY_POWER 宏表示该按键的功能为 电源
  • 最后用 input_register_device 正式注册,这一步之后,系统 /dev/input/ 下就有了我们创建的事件的位置。

后面加载驱动之后,我们也会对这一步进行验证。

3.2.3 内核定时器消抖

机械按键在按下瞬间会产生 5ms-20ms 的电平抖动,如果我们直接在中断里上报事件,你会发现按一下按键,系统却以为你按了几十次,LED 也会乱闪。

我们可以使用内核定时器来延迟判断的时间,大致逻辑如下:

  • 当按键按下或者松开时,触发中断,进入 中断处理函数 my_key_isr
  • 在中断处理函数中只做一件事,就是启动(严格意义上讲是重置)定时器,调用 mod_timer(&data->timer, jiffies + msecs_to_jiffies(20))。这一步到底是什么意思,可能有人疑惑,我详细解释一下:调用 mod_timer 就等于开启了内核定时器,开始计时,从现在起数 20ms 然后执行定时器回调函数,但是问题在于,按键抖动期间根本就数不到 20ms ,中断又来了,从而导致内核定时器重新计时。
  • 直到按键的最后一次抖动触发了最后一次中断,此后,内核定时器平稳的计时 20ms ,进入定时器回调函数。
  • 在定时器回调函数中,此时电平已经完全稳定下来,我们完成事件的上报和 LED 的控制。

3.2.4 LED 联动

为了验证驱动运行状态,我们在定时器回调中加入了两行:

gpiod_set_value(data->gpiod_red, state);//按下(state=1)则红灯亮
gpiod_set_value(data->gpiod_blue, state);//按下(state=1)则蓝灯灭

这里有个细节要注意:我们在设备树中定义蓝灯为 ACTIVE_LOW 低电平有效。当我们调用 gpiod_set_value(..., 1) 时,这里的 1 不代表高电平,而是代表逻辑 1,也就是有效,内核会根据设备树的定义,自动输出 低电平。这种逻辑封装让我们在代码里只需要关心有效或无效,而不需要纠结物理上电平的高低。

3.2.5 同步的必要性

可能已经有人注意到了,我在定时器回调函数中,上报事件之后,调用了 input_sync,为什么需要 input_sync 呢?

举个例子:

假设你的设备是一个两轴摇杆,硬件上报数据是分先后顺序的:

  • 第一步,上报 X=100;
  • 第二步,上报 Y=200;

如果不使用 input_sync,用户空间可能会在上报 X=100 之后读到数据,而使用的依然是旧的 Y 值,这就导致在用户空间看到的 Y 值并没有更新。

这样来看就很明确了,input_sync 的本质其实就是发送一个特殊的事件:EV_SYN / SYN_REPORT。它告诉 Handler 层 现在缓冲区里这一堆数据是一个完整的物理动作,从而把这个整体打包发给用户。

示意图如下:

13. 同步.png

3.3 驱动程序编译

作为一个手把手的教程,没有 Makefile 是算不上完整的,下面附上 Makefile 并进行编译测试。

#换成你自己的内核源码路径
KERNEL_DIR := /home/xlp/workspace/kernel
​
obj-m := my_input.o
​
all:
    make -C $(KERNEL_DIR) M=$(PWD) ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- modules
​
clean:
    make -C $(KERNEL_DIR) M=$(PWD) clean

然后中断执行 make 进行编译:

14. 驱动编译.png

这样就好了。

上面提到过,我搭建了 NFS 网络文件系统,现在编译生成的内核模块已经在板子的共享文件夹中了,直接过去加载即可。


4. 实验验证与现象观察

驱动写好了,设备树也改好了,现在是见证奇迹的时刻,我们下面验证这个 Input 驱动是否工作正常。

4.1 观察LED现象

加载驱动之后,可以看到内核日志正常打印信息,蓝灯也亮了,符合我们的预期。

15. 加载驱动.png

16. 内核日志(1).png

17. 内核加载现象.jpg 然后我们按下按键,看看 LED 状态的变化:

18. 按下按键现象.jpg

LED 状态确实切换了。

4.2 查看input设备

用下面命令查看我们的 input 设备是否存在:

cat /proc/bus/input/devices

19. 查看input设备.png

可以看到,第四个设备也就是 event3 就是我们的 input 设备,名称为 "GPIO Key",正是我们在驱动程序中设定的。

event3 是系统分配的事件编号。

EV=3 代表支持 EV_SYN (1)EV_KEY (2),二进制 11 正好是 3。

KEY=100000 00000000 这一串十六进制代表了它支持 KEY_POWER,每个键码都有特定的码值。

4.3 evtest事件抓去

使用流程如下:

20. evtest事件抓取.png

这时我们就可以操控按键了,当按下一次按键再松开,可以看到数据已经成功上报了,这里显示,当按键按下时代表电源键按下,对应value 1,松开相反。

21. evtest(2).png

4.4 卸载驱动

最后,我们卸载驱动,remove 函数中,我添加了灭灯的逻辑,保证驱动卸载之前将两个灯都处于熄灭状态。


5. 全文总结

通过 input 系列的三篇文章,我们实现了一个从底层硬件到应用层检测的完整闭环。

不仅介绍了如何通过 设备树 描述硬件,理解了 Input 子系统 的分层设计,更深入探讨了 内核定时器消抖 这种必备技能。

本篇文章到这里就结束了,如果这篇文章帮助到你了,请留个关注,订阅我的专栏,我后续还会更新硬核内容。

最后,如果遇到问题欢迎私信或者评论区交流~