做按键中断这个实验我印象最深的是第一次按下按键时系统直接卡死重启了。当时用的还是比较老的开发板内核日志也没有现在这么友好排查了一整天才发现是中断处理函数里调用了一个会睡眠的函数直接在中断上下文触发了内核调度器的警告。从那以后我写中断驱动都会先想清楚一个问题这个中断处理函数里到底能不能做这件事。这篇就以RK平台为背景把按键中断从设备树配置、驱动代码到实测排查的完整链路再过一遍。1. 为什么按键一定要用中断驱动轮询与中断的取舍很多刚从裸机开发转到Linux驱动开发的同学会有个疑问按键检测这么简单的事我在主循环里不断读GPIO电平不就行了为什么非要引入中断直接给结论轮询在简单场景下确实能用但放到Linux里问题会被放大。1.1 轮询方案在Linux里的三个致命问题第一个问题是CPU占用和功耗。应用层起一个线程每隔几毫秒去读一次/sys/class/gpio或者通过ioctl读GPIO看起来不复杂。但系统里可能有多个驱动、多个任务在争抢CPU你的轮询线程为了保证按键响应及时频率不能太低比如10ms读一次CPU占用率不高但是会不断触发系统调用和调度睡眠唤醒的功耗开销在低功耗设备上完全不可接受。第二个问题是响应延迟的不确定性。Linux是抢占式调度系统你的轮询线程什么时候能跑到取决于当前CPU的负载、调度器的优先级、中断的处理情况。负载高的时候10ms的轮询周期可能变成50ms甚至100ms才轮到一次按键手感完全没法保证。第三个问题是资源浪费。如果系统里只有一两个按键轮询也许能接受。但如果是矩阵键盘、旋钮编码器、多路传感器开关每个都要轮询CPU就成天在问你变了没这在工程上属于典型的错误抽象。1.2 中断的本质让硬件主动通知你中断驱动和轮询最大的区别在于轮询是CPU主动去问中断是硬件主动来通知。按键被按下GPIO电平发生变化触发中断控制器CPU保存现场进入内核的中断处理流程调用你注册的处理函数。这个过程是硬件级的异步通知不依赖调度器延迟是微秒量级的而且CPU在按键没有动作时完全不用管这个设备可以安心睡大觉。我在实际项目里做过一个粗略对比同样是一个三键输入设备用2ms轮询跑起来空闲状态下系统整体电流比中断方案高出接近5倍而且高负载时按键偶尔丢事件。换中断方案之后事件一个不丢功耗优势也很明显。1.3 什么时候轮询反而是正确选择把话说回来中断不是万能的。有些场景下轮询反而更合理GPIO没有对应的中断控制器比如某些通过I2C扩展的IO口芯片本身不支持边沿中断。中断频率极高且需要持续采集比如PWM信号测量这时候中断处理函数本身的开销反而比轮询更大。硬件抖动严重且没有硬件滤波在极端环境下中断风暴会把CPU打死轮询加软件滤波更稳定。判断标准很简单事件是稀疏的还是密集的。稀疏事件用中断密集且持续的事件用轮询。按键是最典型的稀疏事件所以中断驱动是绝对的主流。2. RK平台中断链路拆解从硬件引脚到request_irq的中断号RK平台的中断链路比想象中的要长一层。芯片内部的GPIO控制器分为若干个GPIO bank每个bank下面有32个pin中断控制器负责把这些pin上的边沿或电平信号汇总后上报给ARM GIC通用中断控制器。2.1 硬件连接与GPIO编号的映射关系先说硬件侧。假设我们用一个按键一端接GND另一端接SoC的某个GPIO pin内部上拉使能按键按下时电平被拉低。驱动要做的第一件事就是通过设备树告诉内核这个GPIO要被用作中断源。RK平台在设备树里描述GPIO中断通常长这样gpio3 { key_power { gpios gpio3 RK_PB2 GPIO_ACTIVE_LOW; linux,code KEY_POWER; label KEY-POWER; gpio-key,wakeup; }; };如果走标准的gpio-keys框架设备树写完之后驱动逻辑内核已经帮你做好了应用层通过输入子系统直接读事件。但我们这里要讲的是自己写中断驱动所以设备树要稍微偏底层一点直接把一个GPIO定义成中断源key_device: key-device { compatible example,key-driver; pinctrl-names default; pinctrl-0 key_pin_default; interrupt-parent gpio3; interrupts RK_PB2 IRQ_TYPE_EDGE_FALLING; status okay; };pinctrl { key_pin_default: key-pin-default { rockchip,pins 3 RK_PB2 RK_FUNC_GPIO pcfg_pull_up; }; };这里有几个地方值得注意。interrupt-parent指向的是gpio3这个GPIO控制器节点interrupts的第一个参数是pin的索引RK_PB2对应的是GPIO3_B2换算下来是第18号pin第二个参数是触发类型。RK平台用的是IRQ_TYPE_EDGE_FALLING表示下降沿触发对应按键按下瞬间由高到低的跳变。2.2 中断控制器级联为什么不能直接写GIC的中断号很多第一次接触RK平台的人会问为什么interrupts里不直接写GIC的中断号因为GPIO控制器本身就是一个中断控制器它挂在GIC下面属于级联关系。GPIO控制器收到引脚中断后会通过它自己的中断线向GIC发中断GIC再通知CPU。这就意味着一个pin的中断链路是GPIO引脚 → GPIO控制器内部的引脚映射 → GPIO控制器的中断输出线 → GIC → CPU。Linux中断子系统用irq_domain把每一级的中断域串起来驱动里拿到的中断号已经是内核抽象出来的Linux IRQ号不是硬件中断号也不是GIC号。驱动里获取中断号有两种常见方式/* 方式一直接用GPIO API转换 */ int irq gpio_to_irq(gpio_num); /* 方式二通过platform_get_irq从设备树解析 */ int irq platform_get_irq(pdev, 0);我倾向于在按键这种简单场景下用gpio_to_irq因为它更直观。但要记住这个函数必须在GPIO已经被申请之后调用否则拿到的中断号可能是无效的。2.3 电平触发和边沿触发的区别与选择另一件必须搞清楚的事情是触发方式。RK平台的GPIO中断支持四种高电平触发、低电平触发、上升沿触发、下降沿触发。按键场景下最容易踩坑的是电平和边沿的选择。电平触发的特点是只要按键保持按下状态只要电平没有恢复中断就会一直被触发。这意味着你在中断处理函数里哪怕只做了一次打印按住按键不放中断会以极快的速度反复触发直接把你系统打爆。边沿触发只在电平跳变的那一瞬间产生一次中断按住不放不会持续触发。但边沿触发也有它的代价如果按键抖动比较严重一个物理按键动作可能产生多次边沿跳变也就是会出现多次中断。这个问题后面专门讲怎么处理。对比项电平触发边沿触发触发条件电平保持期间持续触发仅在电平跳变的瞬间触发一次按键按住不放中断风暴必须处理只有一次中断天然免疫抖动影响相对较小但持续触发频繁抖动会产生多次边沿需软件防抖典型应用唤醒源、状态检测按键、编码器3. 按键中断驱动的完整实现从request_irq到input子系统上报设备树配置好之后真正写驱动代码的时候反而简单了核心就是probe函数里把中断注册好中断处理函数里把事件上报出去。3.1 驱动的整体框架我习惯用platform_driver框架来做。模块加载时通过compatible匹配设备树节点probe函数执行GPIO申请、中断注册、输入设备注册remove函数做反向回收。#include linux/module.h #include linux/platform_device.h #include linux/gpio.h #include linux/interrupt.h #include linux/of_gpio.h #include linux/input.h #define KEY_DEV_GPIO 16 /* 假设是GPIO3_B2对应的全局GPIO号 */ struct key_priv { int gpio; int irq; int key_code; struct input_dev *input_dev; }; static irqreturn_t key_isr(int irq, void *data) { struct key_priv *priv data; /* 按键按下上报按键事件 */ input_report_key(priv-input_dev, priv-key_code, 1); input_sync(priv-input_dev); return IRQ_HANDLED; } static void key_release(struct key_priv *priv) { if (priv-irq) free_irq(priv-irq, priv); if (priv-gpio) gpio_free(priv-gpio); if (priv-input_dev) input_unregister_device(priv-input_dev); } static int key_probe(struct platform_device *pdev) { struct device *dev pdev-dev; struct key_priv *priv; struct device_node *np dev-of_node; int ret; priv devm_kzalloc(dev, sizeof(*priv), GFP_KERNEL); if (!priv) return -ENOMEM; platform_set_drvdata(pdev, priv); /* 从设备树解析GPIO和按键码 */ priv-gpio of_get_named_gpio(np, key-gpio, 0); if (!gpio_is_valid(priv-gpio)) { dev_err(dev, invalid gpio\n); return -EINVAL; } ret of_property_read_u32(np, key-code, priv-key_code); if (ret) priv-key_code KEY_ENTER; ret devm_gpio_request_one(dev, priv-gpio, GPIOF_IN | GPIOF_EXPORT_DIR_CHANGE, key-gpio); if (ret) { dev_err(dev, gpio request failed\n); return ret; } /* 把GPIO转为中断号 */ priv-irq gpio_to_irq(priv-gpio); if (priv-irq 0) { dev_err(dev, gpio_to_irq failed\n); return priv-irq; } /* 申请中断注意最后一个参数是传给中断处理函数的data */ ret request_irq(priv-irq, key_isr, IRQF_TRIGGER_FALLING | IRQF_SHARED, key-irq, priv); if (ret) { dev_err(dev, request_irq failed: %d\n, ret); return ret; } /* 注册input设备这样应用层可以通过/dev/input/eventX读取 */ priv-input_dev input_allocate_device(); if (!priv-input_dev) { ret -ENOMEM; goto err_irq; } priv-input_dev-name rk-key-driver; priv-input_dev-evbit[0] BIT_MASK(EV_KEY); set_bit(priv-key_code, priv-input_dev-keybit); ret input_register_device(priv-input_dev); if (ret) { input_free_device(priv-input_dev); goto err_irq; } return 0; err_irq: free_irq(priv-irq, priv); return ret; } static int key_remove(struct platform_device *pdev) { struct key_priv *priv platform_get_drvdata(pdev); key_release(priv); return 0; } static const struct of_device_id key_of_match[] { { .compatible example,key-driver }, { /* 无匹配项结束标记 */ } }; MODULE_DEVICE_TABLE(of, key_of_match); static struct platform_driver key_driver { .probe key_probe, .remove key_remove, .driver { .name rk_key_driver, .of_match_table key_of_match, }, }; module_platform_driver(key_driver); MODULE_LICENSE(GPL); MODULE_AUTHOR(Embedded Engineer);这段代码里需要重点解释几个设计决策不然直接抄容易出现莫名其妙的问题。IRQF_SHARED这个标志很多人看不懂。它表示这个中断允许和其他驱动共享。为什么按键中断要加共享因为RK平台的GPIO中断在OS上面做了统一管理一个GPIO bank的所有pin共用同一条中断线如果其他驱动已经在这条线上注册了中断你的request_irq不加IRQF_SHARED就会失败。加了共享之后每次中断触发内核会把注册在该中断号上的所有处理函数都调用一遍每个函数需要自己判断是不是自己的引脚产生了中断。这里要立刻接出一个在实际项目里遇到过的问题也是后面会单独讲排障的原因之一。**我在GPIO中断里加了IRQF_SHARED但系统里并没有其他设备共享这个中断。**内核仍然正常工作。后来发现有些情况不加这个标志也能注册成功取决于中断控制器对共享的支持策略。但作为通用写法加上会让兼容性更好尤其是在RK这种GPIO中断统一管理的平台上。3.2 为什么中断处理函数里直接上报按键事件上面代码里中断处理函数直接调用了input_report_key和input_sync这在Linux input子系统的语境里是允许的。input_report_key本质上是把按键事件填到一个缓冲区里input_sync告诉接收端这批事件提交了这两个操作都是可以在中断上下文安全执行的不存在睡眠行为。但如果你做过其他平台的驱动开发可能会觉得奇怪很多书上都强调中断处理函数要尽量短最好只做个标记具体处理放到底半部。这个矛盾在于按键事件本身极其简单上报动作的开销很小不值得为此引入额外的底半部机制。如果按键逻辑里包含了I2C读取、延时等待、复杂滤波这些耗时操作那就必须考虑底半部处理了。这个我在后面专门开一章节详细讲。3.3 中断处理函数的签名和返回值static irqreturn_t key_isr(int irq, void *data)irq是触发的中断号如果是共享中断可以用它做区分。data就是request_irq的最后一个参数这里传了priv结构体指针处理函数里可以用它访问输入设备和GPIO信息。返回值用IRQ_HANDLED表示这个中断被你处理了。如果是共享中断但检测下来不是自己设备触发的中断应该返回IRQ_NONE表示不是我的事让内核继续调用同一中断号上的其他处理函数。这两个宏的定义在linux/interrupt.h里本质上是返回值的两个枚举。我见过有人不管三七二十一统一返回IRQ_HANDLED虽然大多数情况下不会出错但这是错误示范尤其是共享中断场景下会干扰内核的中断统计和调试判断。4. 实测现场中断不响应、抖动误触发、卸载崩溃的完整排查只看代码的话按键中断驱动确实不难难度全在实测阶段。我梳理三个最典型的故障现象把完整排查链路写出来。这些坑几乎每个做过RK按键驱动的人都踩过。4.1 中断注册成功但按键按下毫无反应现象request_irq返回0设备和中断都注册成功驱动日志也正常打印但按下按键中断处理函数里的打印完全不出现。排查链路第一步先确认GPIO有没有被其他模块复用。RK平台的pinctrl是非常典型的谁先申请谁先用机制。如果你的按键GPIO和某个I2C、UART、PWM的引脚冲突而那个外设的驱动先加载了pinctrl会把引脚复用成外设功能GPIO中断自然收不到信号。排查方法查看设备树里pinctrl节点有没有被其他节点引用或者直接读/sys/kernel/debug/pinctrl下的引脚复用状态。第二步确认中断有没有确实发生。中断注册成功后内核会在/proc/interrupts里面列出这个中断号和它触发的次数。按下按键后再看这个文件如果次数没变说明中断信号根本没到CPU问题在硬件链路或者GPIO配置如果次数在涨说明中断确实触发了问题在驱动代码里中断号和设备树的对应关系。查看中断计数的命令cat /proc/interrupts会看到类似这行输出CPU0 CPU1 336: 5 0 gpio3 18 Edge key-irq第一列是Linux中断号中间是CPU上的触发次数统计gpio3 18 Edge表示这是gpio3控制器的第18号pin触发方式是边沿。如果次数一直是0往下查硬件侧。第三步用读取GPIO电平的方式反向验证引脚功能是否OKcat /sys/kernel/debug/gpio这个文件会把当前所有GPIO的方向和电平状态列出来。如果按键按下时对应引脚的电平状态有变化说明GPIO本身是通的问题可能出在中断控制器映射上。如果电平没有变化那就回到原理图查硬件吧——按键没焊好、上拉电阻没贴、引脚确认错了都有可能。4.2 按一次按键事件上报了好几次现象按键按下释放一次应用层通过evtest或者自写程序读到了多次按键事件有时甚至是按下-释放-按下-释放的乱序组合。根因这是典型的接触抖动问题。机械按键内部是金属弹片按下和释放的瞬间会有微秒到毫秒级别的机械抖动反映在电平上就是短时间内多次跳变。边沿触发在这种场景下会把这些跳变全部转换成中断于是就有了按一下触发好几次的效果。解决方案软件防抖思路和单片机上的防抖完全一样——只认稳定状态。对于边沿触发的中断方式我推荐在中断处理函数里读取一次按键电平如果确认确实是有效电平比如按下对应低电平再启动一个定时器延迟几个毫秒再确认一次。只有两次确认一致才认为是真正的按键事件。我的简化版实现思路如下防抖定时器用内核的timer_liststatic void key_debounce_timer(struct timer_list *t) { struct key_priv *priv from_timer(priv, t, debounce_timer); /* 再次读取GPIO电平确认按键仍处于按下状态 */ if (gpio_get_value(priv-gpio) 0) { input_report_key(priv-input_dev, priv-key_code, 1); input_sync(priv-input_dev); } }在中断处理函数里不再直接上报而是重置定时器static irqreturn_t key_isr(int irq, void *data) { struct key_priv *priv data; /* 每次中断都重新启动防抖定时器 */ mod_timer(priv-debounce_timer, jiffies msecs_to_jiffies(20)); return IRQ_HANDLED; }这样做的本质是吸收了抖动期的多次边沿只有最后一次跳变之后的20毫秒内没有新的边沿才确认按键真正按下。我一般把按键防抖时间设在15到30毫秒之间太短会漏掉部分抖动太长会影响按键手感按下之后明显感觉顿了。另一个更彻底的办法是使用gpiod_set_debounce接口让硬件或者内核框架帮你做防抖。但RK平台上这个接口是否真正生效取决于GPIO控制器驱动是否实现了debounce配置功能建议实测验证不要想当然。4.3 卸载驱动时系统崩溃或者卡死现象执行rmmod卸载驱动有时候会直接宕机有时候会在free_irq的时候打印一堆堆栈信息。根因request_irq的最后一个参数是dev_idfree_irq需要传入完全相同的dev_id内核才能找到对应的中断处理函数并精确移除。如果remove函数里free_irq传了NULL或者传了别的指针和注册时的指针不一致内核会在中断链表里被搞糊涂。特别是共享中断内核通过dev_id区分同一个中断号上的多个处理函数这个指针一旦对不上直接崩。我自己的一个惨痛教训是probe里request_irq时用了priv指针但remove里不知道为啥写成了dev指针看起来都是某个有效的指针但在共享中断场景下内核完全无法匹配。后面的做法是在remove里从platform_get_drvdata取出priv严格复用同一个指针。还有一个系统性的卸载崩溃隐患如果中断处理函数正在执行的时候执行了free_irq内核会等待该中断上的处理函数执行完毕再释放这是free_irq的同步语义。但如果中断处理函数里有msleep之类会睡眠的调用free_irq等待时就会连带引发调度问题最终可能导致死锁。所以又回到了那个原则中断处理函数里不要调用任何可能睡眠的函数。5. 中断处理函数的轻量化tasklet、workqueue与threaded IRQ怎么选如果只是上报一个按键事件直接在上半部做完完全没有问题。但按键功能往往不会这么单纯比如你可能需要按键触发一个网络请求、点亮屏幕、启动一段录像甚至要执行一个I2C设备的初始化流程。这些操作要么耗时要么需要睡眠等待在上半部绝对不能做。Linux内核为此提供了几种把耗时工作推迟到底半部的机制实际选型要看具体情况。tasklet以及新版内核的softirq机制tasklet运行在软中断上下文执行速度快但不能睡眠不能在tasklet里访问用户态内存也不能长时间占用CPU。适合做那种耗时很短但必须从上半部挪出来的工作比如拷贝数据、解析一个简单协议头。workqueue工作队列运行在进程上下文允许睡眠可以调用msleep、wait_event、mutex_lock这些会阻塞的函数。代价是执行时机取决于系统负载和调度延迟比tasklet大。驱动里常见的做法是在中断里schedule_work然后在一个work_struct里慢慢干活。threaded IRQ线程化中断本质上是把整个中断处理函数放到一个内核线程里去执行。request_threaded_irq的用法和request_irq几乎一样区别在于处理函数运行在独立的线程上下文天然可以睡眠而且可以设置线程的优先级保证按键事件相对及时。机制所属上下文能否睡眠调度延迟适用场景上半部直接处理中断上下文不能最低快速置位、简单上报tasklet/softirq软中断上下文不能低耗时极短的收尾工作workqueue进程上下文能较高耗时操作、需要睡眠threaded IRQ内核线程上下文能中低中断本身就需要慢处理按键驱动的实际工程中我推荐一个组合方案上半部只做一件事disable_irq_nosync关掉中断同时把一个work_struct挂进系统默认工作队列。底半部在进程上下文读GPIO电平、做防抖确认、上报input事件、最后enable_irq重新打开中断。这个方案的好处是不会漏事件。如果不用屏蔽中断防抖期间按键再次抖动产生的边沿会源源不断进中断定时器反复重置事件会被无限推迟。用disable_irq_nosync把中断屏蔽掉等防抖确认完成后再打开逻辑就闭合了。static irqreturn_t key_isr(int irq, void *data) { struct key_priv *priv data; disable_irq_nosync(irq); schedule_work(priv-debounce_work); return IRQ_HANDLED; } static void key_debounce_work(struct work_struct *work) { struct key_priv *priv container_of(work, struct key_priv, debounce_work); /* 这里可以做耗时的防抖确认、电平读取、事件上报 */ msleep(20); if (gpio_get_value(priv-gpio) 0) { input_report_key(priv-input_dev, priv-key_code, 1); input_sync(priv-input_dev); } enable_irq(priv-irq); }这里有个细节很值得注意disable_irq_nosync的名字里有个nosync它只是把中断标记成待禁用状态立即返回不等处理函数结束。如果在中断处理函数里调用普通的disable_irq会直接产生死锁——因为disable_irq等待的正是当前正在执行的这个中断处理函数。在中断处理函数里只能调用disable_irq_nosync这是一个血泪教训换来的死规矩。6. 进阶调试手段中断统计、devmem验证与日志分级写完代码、避完坑我最后分享几个让调试效率明显提升的工具和方法。这些东西在书本上不算核心内容但实际干活时比看代码管用得多。第一个是/proc/interrupts前面排查中断不响应时已经用到了。它的价值不止于看次数还能看出中断分散在哪个CPU上。如果你看到某个中断全部集中在CPU0按键响应偶尔出现卡顿可以考虑通过irq_set_affinity_hint把中断绑到多个CPU上或者用echo f /proc/irq/336/smp_affinity在用户态调整亲和性。第二个是devmem命令。RK平台通过/dev/mem节点可以随手读寄存器值。驱动开发中遇到GPIO方向不对、上拉没生效这类问题时用devmem直接读GPIO控制器的寄存器是最快的验证手段。举例来说读一组GPIO数据寄存器能直接看到按键按下前后引脚电平的原始状态不需要经过内核GPIO子系统的缓存这对于判断硬件到底有没有变化很有用。使用示例# 假设GPIO3_B2对应的数据寄存器地址是0xFE7A0000相关的bank地址 devmem 0xFE7A0000 32按下前后对比读出来的数值变化是排查问题的最硬证据。第三个是动态调试开关。按键驱动在调试阶段难免要在中断里打印电平状态但如果打印放在上半部按下一次按键可能因为抖动打印几十行直接刷屏。我的做法是把打印放到防抖确认之后也就是工作队列里并且用pr_debug而不是printk这样默认静默需要时再通过dynamic_debug打开echo file key_driver.c p /sys/kernel/debug/dynamic_debug/control这样既能保留调试信息又不会影响正式版本的行为。最后一个不得不提的工具是evtest。驱动注册了input设备之后应用层能不能读到事件用evtest一测便知evtest /dev/input/eventX它会实时打印事件类型、按键码和按下释放状态。如果evtest里有事件但业务上层没反应问题就不在驱动侧而是向上游的应用层或事件分发机制了。从硬件引脚到设备树从request_irq到input子系统从抖动防抖到底半部选型按键中断驱动这条链路走一遍基本就能把RK平台中断相关的核心知识点串起来了。我在实际项目里最大的体会是按键驱动虽然小但它强迫你把中断上下文的概念、设备树的中断映射关系、内核的工作机制全部过一遍是性价比很高的驱动练习题目。如果看完这篇你也去做按键实验建议先别急着抄代码拿/proc/interrupts和devmem把中断链路先摸一遍再动手写逻辑会顺手很多。