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

0. 前言

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

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


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

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

我外接了一个按键和两个 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 目录下,如下图:

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

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

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

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

倒数第二行,可以看到 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 设备树基本结构

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

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

  • 根节点/ { } 表示,所有的设备节点都挂在它下面。由于我的文件根节点里面写了太多东西,截图截不全,我重新找一个设备树文件为大家展示根节点的全貌:
  • 子节点 用于描述具体的设备,比如按键和 LED 等,子节点通常在根节点内部。
  • 属性 在节点内部,描述引脚号,电平等信息。上面截图中的 modelcompatible 都是属性。

2.2.2 节点解析

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

该节点是位于 根节点内部 的,节点名称为 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 节点进行了追加,使用 & 符号代表引用并修改:

我新建了一个 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 文件。

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

bash 复制代码
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- dtbs -j8

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

2.4 拷贝到板子并验证

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

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

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

整个流程如下:

虚拟机终端:

板子终端:

到这就拷贝好了。

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

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

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


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

3.1 完整驱动代码实现

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

c 复制代码
#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 联动

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

c 复制代码
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 层 现在缓冲区里这一堆数据是一个完整的物理动作,从而把这个整体打包发给用户。

示意图如下:

3.3 驱动程序编译

作为一个手把手的教程,没有 Makefile 是算不上完整的,下面附上 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 进行编译:

这样就好了。

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


4. 实验验证与现象观察

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

4.1 观察LED现象

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

然后我们按下按键,看看 LED 状态的变化:

LED 状态确实切换了。

4.2 查看input设备

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

bash 复制代码
cat /proc/bus/input/devices

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

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

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

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

4.3 evtest事件抓去

使用流程如下:

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

4.4 卸载驱动

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


5. 全文总结

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

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

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

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

相关推荐
光电的一只菜鸡6 小时前
ubuntu之坑(二十)——VMware虚拟机系统无法正常进入如何处理
linux·运维·ubuntu
你住过的屋檐8 小时前
【Nginx】linux上安装nginx
linux·运维·nginx
宵时待雨8 小时前
linux笔记归纳17:传输层协议UDP
linux·笔记·udp
吴声子夜歌9 小时前
Java面试——HBase原理及应用
java·面试·hbase
吴声子夜歌9 小时前
Java面试——ElasticSearch原理及应用
java·面试·es
吴声子夜歌10 小时前
Java面试——Spring原理及应用(一)
java·spring·面试
SCandL15210 小时前
k8s service
linux·容器·kubernetes
liangshanbo121510 小时前
Nginx 进程管理命令深度面试题解析
java·nginx·面试
会飞的土拨鼠呀11 小时前
Linux的存储使用率应该如何计算
linux·运维·服务器
众人皆醒我独醉12 小时前
InferenceGraph:把多个推理服务编排成 DAG
面试·kubernetes·gpu