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 等,子节点通常在根节点内部。
- 属性 在节点内部,描述引脚号,电平等信息。上面截图中的
model和compatible都是属性。
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 文件。
使用下面命令,实现只编译设备树,不编译内核,从而节省大量时间:
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 的标准做法,避免资源泄露。
#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 层 现在缓冲区里这一堆数据是一个完整的物理动作,从而把这个整体打包发给用户。
示意图如下:
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 进行编译:
这样就好了。
上面提到过,我搭建了 NFS 网络文件系统,现在编译生成的内核模块已经在板子的共享文件夹中了,直接过去加载即可。
4. 实验验证与现象观察
驱动写好了,设备树也改好了,现在是见证奇迹的时刻,我们下面验证这个 Input 驱动是否工作正常。
4.1 观察LED现象
加载驱动之后,可以看到内核日志正常打印信息,蓝灯也亮了,符合我们的预期。
然后我们按下按键,看看 LED 状态的变化:
LED 状态确实切换了。
4.2 查看input设备
用下面命令查看我们的 input 设备是否存在:
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 子系统 的分层设计,更深入探讨了 内核定时器消抖 这种必备技能。
本篇文章到这里就结束了,如果这篇文章帮助到你了,请留个关注,订阅我的专栏,我后续还会更新硬核内容。
最后,如果遇到问题欢迎私信或者评论区交流~