0. 前言前面两篇文章我们已经拆解了 Input 子系统的架构。本文我们将手写一个完整的 GPIO 按键驱动完整走一遍 Input 设备从宣告功能到上报事件的流程为了大家能清晰的看到按键按下和松开的状态我添加了红蓝两个 LED但我们还是要把核心注意力放在Input 事件的流转上。温馨提示本文较长可以先收藏起来慢慢看。1. 开发环境与预期功能介绍我用的板子是野火的lubancat 2搭载RK3568芯片如下图我外接了一个按键和两个 LEDLED 分别为红色和蓝色。按键所接引脚通过上拉电阻拉至 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文件。使用下面命令实现只编译设备树不编译内核从而节省大量时间makeARCHarm64CROSS_COMPILEaarch64-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 的标准做法避免资源泄露。#includelinux/module.h#includelinux/init.h#includelinux/gpio/consumer.h#includelinux/interrupt.h#includelinux/platform_device.h#includelinux/timer.h#includelinux/input.h#includelinux/jiffies.hstructmy_key_data{structinput_dev*input;//输入设备structgpio_desc*gpiod_key;//按键structgpio_desc*gpiod_red;//红灯structgpio_desc*gpiod_blue;//绿灯structtimer_listtimer;//内核定时器消抖intirq_num;//中断号unsignedintkey_code;//表示的键码};//定时器回调函数staticvoidmy_key_timer_handler(structtimer_list*t){structmy_key_data*datafrom_timer(data,t,timer);//找到宿主结构体intstate;stategpiod_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);//同步}//中断处理函数staticirqreturn_tmy_key_isr(intirq,void*dev_id){structmy_key_data*data(structmy_key_data*)dev_id;mod_timer(data-timer,jiffiesmsecs_to_jiffies(20));//开始或刷新定时器returnIRQ_HANDLED;}staticintmy_key_probe(structplatform_device*pdev){structdevice*devpdev-dev;structmy_key_data*data;intret;datadevm_kzalloc(dev,sizeof(*data),GFP_KERNEL);if(!data){printk(KERN_ERRkzalloc failed!\n);return-ENOMEM;}printk(KERN_INFOkzalloc success!\n);data-key_codeKEY_POWER;//模拟电源键//获取三个gpiodata-gpiod_keydevm_gpiod_get(dev,button,GPIOD_IN);//默认输入if(IS_ERR(data-gpiod_key)){printk(KERN_ERRget key gpiod failed!\n);returnPTR_ERR(data-gpiod_key);}data-gpiod_reddevm_gpiod_get(dev,red,GPIOD_OUT_LOW);//默认低电平无效if(IS_ERR(data-gpiod_red)){printk(KERN_ERRget red gpiod failed!\n);returnPTR_ERR(data-gpiod_red);}data-gpiod_bluedevm_gpiod_get(dev,blue,GPIOD_OUT_LOW);//默认低电平有效if(IS_ERR(data-gpiod_blue)){printk(KERN_ERRget blue gpiod failed!\n);returnPTR_ERR(data-gpiod_blue);}printk(KERN_INFOThree gpios get success!\n);//初始化Input设备data-inputdevm_input_allocate_device(dev);data-input-nameGPIO Key;//input设备名称input_set_capability(data-input,EV_KEY,data-key_code);retinput_register_device(data-input);if(ret){printk(KERN_ERRInit Input device failed!\n);returnret;}//初始化内核定时器timer_setup(data-timer,my_key_timer_handler,0);//获取并申请中断data-irq_numgpiod_to_irq(data-gpiod_key);retdevm_request_irq(dev,data-irq_num,my_key_isr,IRQF_TRIGGER_RISING|IRQF_TRIGGER_FALLING,my_key_irq,data);//双边沿触发if(ret){printk(KERN_ERRget irq failed!\n);returnret;}platform_set_drvdata(pdev,data);return0;}staticintmy_key_remove(structplatform_device*pdev){structmy_key_data*dataplatform_get_drvdata(pdev);del_timer_sync(data-timer);//驱动卸载前关灯gpiod_set_value(data-gpiod_red,0);gpiod_set_value(data-gpiod_blue,1);return0;}staticconststructof_device_idmy_key_id[]{{.compatiblelubancat,myredblue,},{}};MODULE_DEVICE_TABLE(of,my_key_id);staticstructplatform_drivermy_key_driver{.probemy_key_probe,.removemy_key_remove,.driver{.namemy_key,.of_match_tablemy_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,>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);//按下(state1)则红灯亮gpiod_set_value(data-gpiod_blue,state);//按下(state1)则蓝灯灭这里有个细节要注意我们在设备树中定义蓝灯为ACTIVE_LOW低电平有效。当我们调用gpiod_set_value(..., 1)时这里的 1 不代表高电平而是代表逻辑 1也就是有效内核会根据设备树的定义自动输出低电平。这种逻辑封装让我们在代码里只需要关心有效或无效而不需要纠结物理上电平的高低。3.2.5 同步的必要性可能已经有人注意到了我在定时器回调函数中上报事件之后调用了input_sync为什么需要input_sync呢举个例子假设你的设备是一个两轴摇杆硬件上报数据是分先后顺序的第一步上报 X100第二步上报 Y200如果不使用input_sync用户空间可能会在上报 X100 之后读到数据而使用的依然是旧的 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) ARCHarm64 CROSS_COMPILEaarch64-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是系统分配的事件编号。EV3代表支持EV_SYN (1)和EV_KEY (2)二进制 11 正好是 3。KEY100000 00000000这一串十六进制代表了它支持KEY_POWER每个键码都有特定的码值。4.3 evtest事件抓去使用流程如下这时我们就可以操控按键了当按下一次按键再松开可以看到数据已经成功上报了这里显示当按键按下时代表电源键按下对应value 1松开相反。4.4 卸载驱动最后我们卸载驱动remove函数中我添加了灭灯的逻辑保证驱动卸载之前将两个灯都处于熄灭状态。5. 全文总结通过input系列的三篇文章我们实现了一个从底层硬件到应用层检测的完整闭环。不仅介绍了如何通过设备树描述硬件理解了Input 子系统的分层设计更深入探讨了内核定时器消抖这种必备技能。本篇文章到这里就结束了如果这篇文章帮助到你了请留个关注订阅我的专栏我后续还会更新硬核内容。最后如果遇到问题欢迎私信或者评论区交流~