简介:这份资源面向Linux内核驱动初学者与嵌入式开发人员,提供按键驱动从编写到验证的完整参考,帮助理解字符设备、中断处理与输入子系统之间的协作方式。压缩包共2个文件,均为C语言源码,体积约2KB,其中一份实现按键驱动核心逻辑,涵盖设备注册、中断请求线关联、中断服务例程以及通过input_event与input_report_key上报按键事件;另一份为配套测试程序,演示打开设备节点、读取并解析按键事件、关闭设备的完整流程。目前已有362人学习下载,适合作为驱动入门练手素材。读者可借此掌握驱动模块的编译加载、内核日志查看与设备文件访问等关键环节,并对照源码理解按键按下与释放事件如何从硬件传递到用户空间,为后续开发更复杂的输入设备驱动打下基础。
1. 从一次按键无响应说起:linux按键驱动源码及测试程序到底在解决什么
做过嵌入式 Linux 的人大概率都遇到过这种场景:板子跑起来了,串口能登录,LED 能闪,但接在 GPIO 上的几个按键按下去毫无反应,dmesg里干干净净,/dev/input/下也找不到对应节点。这时候你需要的不是玄学,而是一份能跑通的 linux按键驱动源码及测试程序,把「硬件电平变化」到「应用层读到键值」这条链路完整打通。
按键驱动看着简单,实际是 Linux 输入子系统里最典型的入门切口:它同时涉及 GPIO 子系统、中断处理、input 子系统、设备树匹配、以及用户态evtest或read()验证。新手能靠它理解字符设备与输入设备的区别,熟手则关心去抖策略、中断上下半部拆分、电源管理下的唤醒配置。本文按「先讲清输入子系统模型 → 写一份可编译的按键驱动 → 配设备树 → 写测试程序 → 排坑 → 进阶」的顺序展开,所有代码都按常见内核接口给出,你照着改 GPIO 编号和 compatible 就能复现。
2. 按键驱动为什么绕不开 input 子系统:从 GPIO 中断到 event 设备的完整链路
2.1 字符设备方案和 input 子系统方案的取舍
很多人第一次写按键驱动,直觉是注册一个字符设备,在read()里返回键值。这个方案能跑,但问题很快暴露:多个按键要自己管理次设备号,阻塞/非阻塞语义要自己实现,去抖要自己写定时器,应用层还得自定义协议。一旦系统里同时有触摸屏、键盘、遥控器,你的按键就成了孤岛。
Linux 的 input 子系统正是为解决这类问题存在的。它把「输入事件」抽象成统一的struct input_dev,驱动只负责上报EV_KEY事件和键值,内核自动完成事件队列、阻塞唤醒、/dev/input/eventX节点创建、以及和evdev的对接。应用层用标准struct input_event读取,不需要知道你底层是 GPIO 还是矩阵键盘。
| 对比项 | 字符设备自实现 | input 子系统 |
|---|---|---|
| 设备节点 | 自己创建/dev/xxx | 自动生成/dev/input/eventX |
| 事件格式 | 自定义 | 标准input_event |
| 多按键管理 | 手动 | 一个 input_dev 注册多个键 |
| 去抖 | 自己写 | 自己写(内核不代劳) |
| 应用层兼容 | 需配套库 | evtest、Qt、Android 直接可用 |
结论很直接:只要是人机交互类输入,优先走 input 子系统。按键驱动源码的价值也在这里——它是理解整个输入子系统的成本最低的样本。
2.2 一次按键从硬件到应用层经历了什么
把链路拆开看,一次按键要经过这些环节:
- 硬件按下,GPIO 电平从高变低(或反之),触发边沿中断。
- CPU 进入中断处理函数,内核记录中断。
- 驱动在中断里(或下半部)读取 GPIO 值,做去抖判断。
- 确认有效后调用
input_report_key()上报键值和按下/抬起状态。 - 调用
input_sync()告诉内核「这一组事件报完了」。 - 内核 input core 把事件放入对应
input_dev的队列,唤醒等待的进程。 - 应用层从
/dev/input/eventX读到struct input_event。
这里有两个容易翻车的点:一是input_sync()漏掉,应用层会一直等不到完整事件;二是中断里做太长延时去抖,会拖垮系统响应。常见做法是中断里只记录时间戳,用定时器或工作队列做去抖确认。
2.3 设备树里按键节点该怎么写
现代内核基本都走设备树描述硬件。一个典型的按键节点长这样:
/* 按键节点:挂在根节点下,通过 gpios 指定引脚 */ keys { compatible = "gpio-keys"; /* 匹配内核自带的 gpio-keys 驱动 */ pinctrl-names = "default"; pinctrl-0 = <&key_pins>; /* 引脚复用配置,引用 pinctrl 节点 */ key_power { label = "power"; gpios = <&gpio1 12 GPIO_ACTIVE_LOW>; /* 低电平有效 */ linux,code = <KEY_POWER>; /* 上报的键值,见 input-event-codes.h */ debounce-interval = <20>; /* 去抖时间,单位 ms */ }; key_volup { label = "volume_up"; gpios = <&gpio1 13 GPIO_ACTIVE_LOW>; linux,code = <KEY_VOLUMEUP>; debounce-interval = <20>; }; };逻辑说明:compatible = "gpio-keys"会让内核用drivers/input/keyboard/gpio_keys.c这个现成驱动来解析,你甚至不用自己写驱动就能用。GPIO_ACTIVE_LOW表示按下时引脚为低电平,这个标志决定了驱动内部如何判断「按下」。linux,code必须是input-event-codes.h里定义的标准键值,随便填会导致应用层拿到无意义的 code。
参数说明:debounce-interval是 gpio-keys 驱动自带的软件去抖,单位毫秒,机械按键一般 10~30ms 够用,触点差的可以到 50ms。pinctrl-0引用的节点要在 pinctrl 控制器里把引脚设成 GPIO 功能并配上上下拉,否则可能出现悬空误触发。
提示:如果你用的是内核自带的 gpio-keys,那「驱动源码」其实不用自己写,重点在设备树和测试。但面试和排查问题时,你必须能说清 gpio_keys.c 内部是怎么注册 input_dev、怎么申请中断的,所以下面还是给一份自实现的驱动。
3. 手写一份可编译的按键驱动:input_dev 注册、中断申请与去抖上报
3.1 驱动骨架:probe 里要做哪几件事
自实现按键驱动的 probe 函数,核心动作固定为:申请 GPIO、申请中断、分配并注册 input_dev、设置支持的键值。下面是一份精简但完整的骨架:
#include <linux/module.h> #include <linux/init.h> #include <linux/input.h> #include <linux/interrupt.h> #include <linux/gpio/consumer.h> #include <linux/of.h> #include <linux/platform_device.h> #include <linux/timer.h> struct key_priv { struct gpio_desc *gpiod; /* GPIO 描述符 */ struct input_dev *input; /* input 设备 */ int irq; /* 中断号 */ int code; /* 键值 */ struct timer_list timer; /* 去抖定时器 */ bool last_state; /* 上次稳定状态 */ }; /* 定时器回调:延时后再次读取电平,确认稳定才上报 */ static void key_timer_cb(struct timer_list *t) { struct key_priv *priv = from_timer(priv, t, timer); bool state = gpiod_get_value(priv->gpiod); /* 1=按下(低有效已转换) */ if (state != priv->last_state) { priv->last_state = state; input_report_key(priv->input, priv->code, state); input_sync(priv->input); /* 必须调用,否则事件不完整 */ } } /* 中断处理:只重设定时器,不做耗时操作 */ static irqreturn_t key_isr(int irq, void *dev_id) { struct key_priv *priv = dev_id; mod_timer(&priv->timer, jiffies + msecs_to_jiffies(20)); /* 20ms 去抖 */ return IRQ_HANDLED; } static int key_probe(struct platform_device *pdev) { struct key_priv *priv; int ret; priv = devm_kzalloc(&pdev->dev, sizeof(*priv), GFP_KERNEL); if (!priv) return -ENOMEM; /* 从设备树拿 GPIO,第二个参数 0 表示第一个 gpios 属性 */ priv->gpiod = devm_gpiod_get(&pdev->dev, NULL, GPIOD_IN); if (IS_ERR(priv->gpiod)) return PTR_ERR(priv->gpiod); /* 从设备树读 linux,code */ ret = device_property_read_u32(&pdev->dev, "linux,code", &priv->code); if (ret) priv->code = KEY_ENTER; /* 没配就给个默认值 */ /* 分配 input_dev */ priv->input = devm_input_allocate_device(&pdev->dev); if (!priv->input) return -ENOMEM; priv->input->name = "my-gpio-key"; priv->input->phys = "my-key/input0"; priv->input->id.bustype = BUS_HOST; /* 声明本驱动支持哪些键值 */ input_set_capability(priv->input, EV_KEY, priv->code); ret = input_register_device(priv->input); if (ret) return ret; /* 申请中断,双边沿触发 */ priv->irq = gpiod_to_irq(priv->gpiod); ret = devm_request_irq(&pdev->dev, priv->irq, key_isr, IRQF_TRIGGER_FALLING | IRQF_TRIGGER_RISING, "my-key-irq", priv); if (ret) return ret; timer_setup(&priv->timer, key_timer_cb, 0); priv->last_state = gpiod_get_value(priv->gpiod); platform_set_drvdata(pdev, priv); return 0; } static const struct of_device_id key_of_match[] = { { .compatible = "myvendor,my-gpio-key" }, { } }; MODULE_DEVICE_TABLE(of, key_of_match); static struct platform_driver key_driver = { .probe = key_probe, .driver = { .name = "my-gpio-key", .of_match_table = key_of_match, }, }; module_platform_driver(key_driver); MODULE_LICENSE("GPL"); MODULE_AUTHOR("example"); MODULE_DESCRIPTION("Simple GPIO key driver");逻辑说明:probe 里先拿 GPIO 和键值,再分配注册 input_dev,最后申请中断。中断处理函数只做一件事——把去抖定时器往后推 20ms,真正的电平判断和上报放在定时器回调里。这样中断上下文极短,不会因为按键抖动频繁触发耗时逻辑。
参数说明:IRQF_TRIGGER_FALLING | IRQF_TRIGGER_RISING表示双边沿都触发,这样按下和抬起都能捕获。如果你只关心按下,可以只留FALLING(低有效时)。msecs_to_jiffies(20)里的 20 就是去抖窗口,机械按键建议 10~30,触摸类可以更短。input_set_capability只声明了单个键值,多按键时对每个 code 都调一次。
3.2 设备树匹配与 Makefile 编译
驱动要能被 probe,设备树里的 compatible 必须和key_of_match对上。把第 2 章的gpio-keys换成你自己的:
my_key { compatible = "myvendor,my-gpio-key"; gpios = <&gpio1 12 GPIO_ACTIVE_LOW>; linux,code = <KEY_ENTER>; pinctrl-names = "default"; pinctrl-0 = <&key_pins>; };编译用内核外部模块方式,Makefile 两行:
obj-m += my_key.o KDIR := /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) clean逻辑说明:obj-m表示编译成可加载模块,KDIR指向当前内核的构建目录。交叉编译时把KDIR换成你的内核源码路径,并加上ARCH和CROSS_COMPILE。编译产物是my_key.ko,insmod后dmesg应能看到 input 设备注册成功的日志。
参数说明:如果编译报找不到gpiod_get,检查内核版本,老内核用的是of_get_named_gpio+gpio_request那套旧接口,新内核才推荐gpiod_*。devm_前缀表示资源由设备管理自动释放,卸载模块时不用手动 free,这是推荐写法。
3.3 加载后如何确认驱动真的工作了
insmod my_key.ko之后,按顺序检查:
# 1. 看内核日志有没有 probe 成功 dmesg | tail -20 # 2. 看 input 设备是否出现 ls /dev/input/ cat /proc/bus/input/devices # 找到 name 为 my-gpio-key 的那段 # 3. 用 evtest 实时看事件(需要 root) evtest /dev/input/eventX # X 换成上一步查到的编号/proc/bus/input/devices里会列出设备的N: name、H: Handlers和B: EV/B: KEY位图。如果B: KEY里没有你设置的键值位,说明input_set_capability没生效或 code 填错。evtest按一下按键应该打印Event: time ... type 1 (EV_KEY), code 28 (KEY_ENTER), value 1,松开是value 0。看不到事件就回到中断和去抖逻辑排查。
4. 测试程序怎么写:从 evtest 到自写 read 循环与自动化脚本
4.1 用 evtest 做第一轮验证
evtest是最省事的验证工具,但它不一定在目标板上有。交叉编译环境里可以自己编一个,或者先用它确认硬件和驱动没问题,再写自己的程序。运行后选择对应 event 节点,按键时终端会实时打印事件类型、code 和 value。这一步能排除掉「驱动没上报」还是「应用层读法不对」的歧义。
4.2 自写 C 测试程序:读取 input_event 并解析
生产环境往往不允许装 evtest,需要自己写。核心就是打开 event 节点,循环read()出struct input_event:
#include <stdio.h> #include <fcntl.h> #include <unistd.h> #include <linux/input.h> #include <string.h> #include <errno.h> int main(int argc, char *argv[]) { int fd; struct input_event ev; if (argc < 2) { fprintf(stderr, "usage: %s /dev/input/eventX\n", argv[0]); return 1; } fd = open(argv[1], O_RDONLY); /* 阻塞读,没事件就睡眠 */ if (fd < 0) { perror("open"); return 1; } while (1) { ssize_t n = read(fd, &ev, sizeof(ev)); if (n != sizeof(ev)) { if (errno == EINTR) continue; /* 被信号打断,重试 */ perror("read"); break; } /* 只关心按键事件,EV_SYN 是同步分隔符 */ if (ev.type == EV_KEY) { printf("code=%d value=%d (%s)\n", ev.code, ev.value, ev.value ? "pressed" : "released"); fflush(stdout); } } close(fd); return 0; }逻辑说明:open用阻塞模式,没有事件时进程睡眠,不占 CPU。read一次读一个input_event,返回字节数不等于结构体大小说明出错或被信号打断。ev.type == EV_KEY过滤掉同步事件和其他类型,ev.value为 1 是按下,0 是抬起,2 是长按重复(如果驱动支持)。
参数说明:编译用gcc test_key.c -o test_key,交叉编译加--host或直接用交叉工具链。如果希望非阻塞,open时加O_NONBLOCK,然后用poll()等待,适合单线程处理多路输入。ev.code就是设备树里linux,code的值,ev.value的语义由 input 子系统定义,不要自己改。
4.3 用 shell 脚本做批量回归
板子量产前往往要跑按键回归,用 shell 包一层最省事:
#!/bin/bash # 找到 my-gpio-key 对应的 event 节点 EVENT=$(grep -B2 "my-gpio-key" /proc/bus/input/devices | \ grep -o "event[0-9]*" | head -1) if [ -z "$EVENT" ]; then echo "key device not found" exit 1 fi echo "listening on /dev/input/$EVENT" ./test_key /dev/input/$EVENT | while read line; do echo "[$(date +%T)] $line" done逻辑说明:先从/proc/bus/input/devices里定位设备对应的 event 编号,避免硬编码。然后调用测试程序,给每行输出打时间戳,方便统计按键响应延迟和漏报。参数说明:grep -B2取设备名前面两行,因为 Handlers 行在 name 行附近,不同内核排版略有差异,必要时用awk精确匹配。
注意:测试程序读的是
/dev/input/eventX,需要 root 或把用户加入input组。量产固件里如果不想给权限,可以用 udev 规则改节点属主,而不是直接 chmod 777。
5. 按键驱动避坑清单:五个让新手卡半天的真实问题
5.1 按键没反应,dmesg 里连 probe 都没有
现象:insmod成功但/proc/bus/input/devices里找不到设备,dmesg没有 probe 日志。原因通常是设备树 compatible 和驱动of_match_table不一致,或者设备树节点没被内核解析到。解决:用cat /proc/device-tree/逐级确认节点存在,compatible字符串逐字符比对,注意大小写和连字符。另外确认驱动确实编进了内核或已 insmod。
5.2 按一次键上报好几次
现象:evtest里按一下出现多组 pressed/released。原因是机械抖动,中断被多次触发。解决:确认去抖定时器生效,mod_timer在已有定时器未到期时会重置,天然起到「最后一次触发后延时」的效果。如果还抖,把去抖时间从 20ms 加到 30~50ms,或在硬件上并电容。注意别在中断里用mdelay硬等,那是血泪经验,会卡死系统。
5.3 应用层读到事件但 value 一直是 0
现象:evtest能看到事件,但 value 始终是 0,或者按下抬起反了。原因是GPIO_ACTIVE_LOW配置和实际硬件不符。解决:确认按键是按下接地还是接电源,设备树里对应改GPIO_ACTIVE_LOW或GPIO_ACTIVE_HIGH。gpiod_get_value返回的是逻辑值,已经按 active 标志转换过,不要自己再取反。
5.4 卸载模块时报 oops 或资源泄漏
现象:rmmod时内核报错,或再次insmod失败。原因是中断、定时器、input_dev 没正确释放。解决:用devm_系列接口申请资源,定时器在 remove 里del_timer_sync。如果用了request_irq而非devm_request_irq,必须在 remove 里free_irq。定时器回调里如果访问了已释放的内存,就是典型的 use-after-free,del_timer_sync能保证回调不再运行。
5.5 系统休眠后按键失效
现象:系统进入 suspend 后按键无法唤醒,或唤醒后事件丢失。原因是中断没配成唤醒源。解决:在设备树节点加wakeup-source;,驱动里调用device_init_wakeup(&pdev->dev, true)和enable_irq_wake(priv->irq)。注意不是所有 GPIO 控制器都支持唤醒,要查 SoC 手册确认该引脚在低功耗域。这个坑在电池设备上尤其常见,调试时先确认 suspend 是否真的进了。
6. 进阶:把按键做成可配置的多键矩阵与长按识别
单键跑通后,实际产品往往是多个按键甚至矩阵。gpio-keys 支持一个节点下挂多个子节点,每个子节点一个 GPIO 一个 code,这是最省事的扩展方式。但如果引脚紧张,就要上矩阵扫描:行线输出、列线输入,轮流拉低行线读列线,靠扫描周期和去抖窗口识别按键。矩阵方案要自己写驱动,核心是定时器周期性扫描加状态机去抖,复杂度比单键高一个量级。
另一个常见需求是长按和双击。input 子系统本身不识别长按,它只上报按下和抬起。长按要在应用层或驱动里自己做:记录按下时间戳,在定时器里判断超过阈值就上报一个不同的 code(比如 KEY_POWER 长按报 KEY_RESTART)。双击则要维护「上次抬起时间」,两次抬起间隔小于阈值算双击。这些逻辑放应用层更灵活,放驱动里则响应更快但不好改。
验证长按逻辑时,我一般会写一个带时间戳的测试脚本,把每次事件的毫秒级时间打出来,人工核对间隔是否符合预期。下面这个片段用来测长按阈值:
./test_key /dev/input/eventX | while read line; do echo "$(date +%s.%N) $line" done | awk '{print $1, $0}' | tee key_log.txt逻辑说明:给每行加高精度时间戳,事后用awk算相邻 pressed 和 released 的差值,就能看出长按判定是否准确。参数说明:date +%s.%N给到纳秒,实际精度受 shell 调度影响,做毫秒级判断够用。如果要做严格时序验证,还是得在 C 程序里用clock_gettime(CLOCK_MONOTONIC)。
最后说个我自己的习惯:每次改完驱动,先不急着上应用,而是用evtest裸看一遍事件流,确认 code、value、时序都对,再写上层逻辑。这样能把「驱动问题」和「应用问题」彻底分开,省下大量来回猜的时间。按键驱动源码及测试程序这套东西,写一遍只要半天,但把去抖、唤醒、长按这些边界都踩过,才算真正掌握。希望帮到你。
本文还有配套的精品资源,点击获取