按键按下去了,示波器上电平干净利落地从高电平掉到地,/sys/class/gpio里value也能读到 0,可驱动里的中断处理函数就是不进——这个场景我见过太多次,问的人一半卡在设备树,一半卡在触发类型和消抖。GPIO 读取和中断注册这两件事,看着像两个独立的小任务,实际上它们是同一条链路上的前后两段:你得先让引脚能读、读得对,才谈得上让它在电平变化时通知你。很多人第一步就抄了网上那段request_irq(gpio_to_irq(gpio), ...),跑不通就怀疑驱动写错了,结果查了半天发现引脚被 pinctrl 复用了,压根没切到 GPIO 功能。
这篇内容围绕 Linux 下 GPIO 的读取路径和中断注册流程展开。前半段讲清楚应用层和内核层读一个引脚电平的三条路——sysfs、字符设备/dev/gpiochipN配合 libgpiod、以及内核空间的gpiod_get_value,各自适用什么场景、有哪些坑;后半段拆解中断注册的完整链路:中断号从哪来、request_irq每个参数为什么这么填、边沿触发和电平触发的本质差异、上半部和下半部怎么分、消抖该放在哪一层。适合已经能写简单字符设备驱动、想把手上的 GPIO 中断真正跑通的人;也适合应用层只想监听一个边沿事件、不想碰内核的读者,gpiomon那套用法同样会讲到。
1. 读一个 GPIO 电平,Linux 给了三条互不相同的路
同一个引脚,用户态可以从/sys/class/gpio读,可以从/dev/gpiochipN读,内核态可以调gpiod_get_value读。这三条路不是新旧替代关系那么简单,它们的调用开销、并发模型、能拿到的信息都不一样。选错路,后面的中断注册会更难受。
1.1 sysfs 那条老路:调试能用,产品别用
/sys/class/gpio是最早普及的一套接口,操作方式极其直白:先export出编号,再设direction,然后cat value。写个脚本验证硬件通不通,没有比这更快的方式。
echo 47 > /sys/class/gpio/export echo in > /sys/class/gpio/gpio47/direction cat /sys/class/gpio/gpio47/value echo 47 > /sys/class/gpio/unexport问题也很明显。第一,这里的编号是全局 GPIO 编号,等于控制器的 base 加上线内偏移,而 base 在不同内核版本、不同设备树配置下会变,写死在脚本里的 47 换块板子就失效了。第二,每次读value都是一次完整的系统调用加字符串解析,轮询起来 CPU 占用很难看。第三,这套接口在内核里已经被标记为 deprecated,虽然CONFIG_GPIO_SYSFS打开后还能用,但新代码不该再依赖它。
它唯一还算有用的场景是edge加poll:把edge设成rising或falling,然后对value文件做poll(),能拿到一个粗粒度的事件通知。但延迟完全不可控,事件也没有时间戳,做产品级按键检测基本没戏。
提示:如果你只是想知道某个引脚当前是高还是低,
/sys/kernel/debug/gpio里的表格反而更省事,一眼能看到所有线的方向、当前值和占用者,不用 export 任何东西。
1.2 字符设备加 libgpiod:应用层的正路
内核 4.8 之后引入的 GPIO 字符设备接口,把每个 GPIO 控制器暴露成一个/dev/gpiochipN节点。这里的线编号是控制器内部的偏移量,不是一个全局数字,所以它不会因为别的控制器加载顺序变化而漂移,这一点比 sysfs 强太多。
先用工具看一眼芯片上有哪些线:
gpiodetect gpioinfo gpiochip0gpioinfo的输出里,"used" 一列能看到每条线当前是否被占用、被谁占用,"consumer" 是申请时填的名字。调试"我的线是不是被别人抢了",这一条命令就能定位。
读一次电平:
gpioget gpiochip0 15监听边沿事件,这是最接近"用户态中断"的用法:
gpiomon --falling-edge --num-events=5 gpiochip0 15gpiomon会阻塞在那里,每来一个下降沿打印一行,带高精度时间戳。这个时间戳对排查抖动非常有用——连续几行间隔只有几百微秒,那基本可以确定是机械抖动而不是真实按键。
写代码的话,libgpiod 的 v1 接口大致长这样:
#include <gpiod.h> #include <stdio.h> int main(void) { struct gpiod_chip *chip; struct gpiod_line *line; struct gpiod_line_event ev; chip = gpiod_chip_open_by_name("gpiochip0"); if (!chip) return -1; line = gpiod_chip_get_line(chip, 15); if (!line) return -1; if (gpiod_line_request_falling_edge_events(line, "btn-mon") < 0) return -1; while (gpiod_line_event_wait(line, NULL) == 1) { if (gpiod_line_event_read(line, &ev) == 0) printf("event type=%d ts=%ld.%09ld\n", ev.event_type, (long)ev.ts.tv_sec, (long)ev.ts.tv_nsec); } gpiod_line_release(line); gpiod_chip_close(chip); return 0; }编译时链接-lgpiod。注意 v1 和 v2 的 API 差别很大,v2 改成了gpiod_chip_request_lines()配合gpiod_line_settings、gpiod_line_config这套对象模型,如果你在较新的发行版上编译报"函数未定义",先确认装的是哪个大版本。我的建议是新项目直接上 v2,v1 只用来维护老代码。
1.3 内核里的 gpiod_get_value:先搞清楚能不能睡
驱动里读电平,标准做法是devm_gpiod_get()拿到描述符,再用gpiod_get_value()或gpiod_get_value_cansleep()读。
struct gpio_desc *desc; int val; desc = devm_gpiod_get(&pdev->dev, "button", GPIOD_IN); if (IS_ERR(desc)) return PTR_ERR(desc); val = gpiod_get_value(desc); if (val < 0) return val;两个函数的分界线是这条线背后的控制器访问会不会睡眠。片上 GPIO 控制器通常就是几个寄存器读写,原子上下文里调用毫无问题,用gpiod_get_value()。但如果这条线挂在一颗 I2C 或 SPI 的 GPIO 扩展芯片上,读一次要走总线,总线传输可能睡眠,那就必须用gpiod_get_value_cansleep()。在原子上下文(包括中断处理函数)里调用了 cansleep 版本,内核会直接抛出警告;反过来,在可以睡眠的上下文里用了非 cansleep 版本去访问扩展芯片,行为是未定义的,轻则不生效重则挂死。
还有一个极容易踩的点:gpiod_get_value()返回的是逻辑电平,不是引脚上的物理电压。设备树里写了GPIO_ACTIVE_LOW,那么引脚被拉低时函数返回 1,被拉高时返回 0。很多人在驱动里又手动!val一次,结果逻辑翻了两遍等于没翻,按键按下反而上报"松开"。我的习惯是:低有效只在设备树里声明一次,驱动代码里一律按逻辑值处理,不再做任何取反。
| 方式 | 调用位置 | 开销 | 事件能力 | 适用场景 |
|---|---|---|---|---|
| sysfs | 用户态 | 高(syscall + 字符串解析) | 仅 poll,无时间戳 | 临时验证硬件 |
| 字符设备 / libgpiod | 用户态 | 低 | 完整边沿事件 + 时间戳 | 应用层监听按键、外部信号 |
| gpiod_get_value | 内核态 | 最低 | 需自行注册中断 | 驱动内部逻辑、中断处理 |
2. 电平读不准,问题多半不在代码里
代码写完读出来恒为 0 或者恒为 1,第一反应往往是"驱动写错了"。我排查过的案例里,真正错在代码的不到三成,剩下七成集中在引脚复用、上下拉配置和电气特性上。这一层不解决,中断注册写得再漂亮也是白搭。
2.1 pinctrl 抢走了引脚:申请成功但电平纹丝不动
现代 SoC 的引脚几乎都是复用的,同一个球既可以做 UART 的 TX,也可以做 I2C 的 SCL,还可以做普通 GPIO。gpiod_get()成功只说明"这个 GPIO 编号在控制器上存在且没被别人占用",不代表引脚已经被切换到 GPIO 功能。如果 pinmux 还停在某个外设功能上,你读到的寄存器值跟外部电平毫无关系。
判断方法很直接,看 pinctrl 的状态:
ls /sys/kernel/debug/pinctrl/ cat /sys/kernel/debug/pinctrl/*/pinmux-pins | grep -A2 -B2 "pin 15"输出里如果显示这个引脚被某个 device 以非 GPIO 的功能占用,那就是它了。解决办法是在设备树的 pinctrl 节点里把该引脚配置成 GPIO 功能,并在驱动节点里用pinctrl-0引用:
&pinctrl { btn_pin: btn-pin { pins = "GPIO1_15"; function = "gpio"; bias-pull-up; }; }; button { pinctrl-names = "default"; pinctrl-0 = <&btn_pin>; };这里有个顺序问题值得说清楚:pinctrl-0的生效时机是在驱动probe之前,由设备核心自动完成的。所以如果你在probe里手动去改 pinmux,很可能被后面的自动配置覆盖掉。正确做法就是老老实实写在设备树里,别在代码里跟它抢。
2.2 内部弱上拉救不了长线和按键
引脚配成输入之后,如果没有明确电平,读出来就是随机的。很多人第一反应是开内部上拉,结果发现按键没按的时候读数还是在飘。原因是内部上拉电阻通常几十千欧量级,配上长排线带来的几十皮法电容,上升沿会被拉得很缓;再加上按键机械触点本身的接触电阻不稳定,读数自然不稳。
比较可靠的做法是外部加一颗 10k 量级的上拉或下拉,内部上下拉只在短距离、干净环境下作为补充。按键的典型接法是:一端接 GPIO,一端接地,GPIO 侧外部上拉。这样按下时读到低电平,松开时读到高电平,也就是"低有效"。设备树里对应写:
button { gpios = <&gpio1 15 GPIO_ACTIVE_LOW>; };低有效声明在这里的意义,不只是让驱动少写一个取反,更重要的是让中断触发类型的语义也对齐。声明成低有效之后,逻辑上的"按下"对应物理上的下降沿,后面配置触发类型时思路就是一致的,不容易搞混方向。
2.3 读到的时间序列在抖:消抖该放在哪一层
机械按键按下和松开的瞬间,触点会在几毫秒内反复弹跳,示波器上能看到一串密集的窄脉冲。这段抖动如果直接送进中断,一次按键可能触发十几次中断,用户看到的就是"按一下加了 5"。
消抖的位置有三个选择,各有取舍:
- 硬件消抖:并联 0.1uF 电容加 RC 滤波,或者用专用的消抖芯片。最彻底,不占 CPU,但增加物料成本,且电容会让边沿变缓,反过来影响高频信号的响应。
- 内核驱动消抖:老接口是
gpiod_set_debounce(),现在更推荐在设备树里给 GPIO 控制器或者gpio-keys节点写debounce-interval,由控制器硬件或者驱动框架去实现。硬件支持消抖的控制器(不少 SoC 的 GPIO 模块自带消抖计数器)走这条路几乎零成本。 - 软件时间戳消抖:在中断处理函数里记录上一次有效事件的时间,跟当前时间差小于阈值就直接丢弃。实现最简单,通用性最好,代价是每次抖动都会进一次中断,只是很快返回。
我一般优先看控制器是否支持硬件消抖,支持就用设备树属性;不支持就在中断里加时间戳过滤。20ms 是个比较万金油的阈值,太短滤不干净,太长会让快速连击失效。
3. 中断号是怎么来的:从设备树属性到 request_irq
真正开始注册中断时,第一个卡住的问题永远是"irq 号填什么"。request_irq()的第一个参数是一个整数,但你在代码里找不到这个整数从哪来。它有两套来源,取决于你的设备树是怎么写的。
3.1 gpiod_to_irq 与设备树 interrupts 两条路径
第一条路径是从 GPIO 描述符反查中断号:
irq = gpiod_to_irq(desc); if (irq < 0) return irq;这个函数内部会去问 GPIO 控制器"这条线对应哪个中断"。它能不能成功,取决于控制器驱动是否实现了.to_irq回调。片上 GPIO 控制器一般都实现,但不是每条线都支持中断——不少 SoC 的 GPIO 分成若干 bank,只有部分 bank 的中断能路由到中断控制器。如果这条线不支持,gpiod_to_irq()会返回-ENXIO。
第二条路径是设备树里直接声明中断:
button { gpios = <&gpio1 15 GPIO_ACTIVE_LOW>; interrupt-parent = <&gpio1>; interrupts = <15 IRQ_TYPE_EDGE_FALLING>; };或者更紧凑的写法:
button { gpios = <&gpio1 15 GPIO_ACTIVE_LOW>; interrupts-extended = <&gpio1 15 IRQ_TYPE_EDGE_FALLING>; };驱动里用platform_get_irq(pdev, 0)取出来。这种写法把触发类型也一起声明在设备树里了,驱动代码不需要也不应该再传IRQF_TRIGGER_*标志——两边都写会冲突,内核会以最后一次设置为准,行为容易变得不可预期。
注意:
IRQ_TYPE_EDGE_FALLING这类宏定义在dt-bindings/interrupt-controller/irq.h里,写设备树时记得#include进来,直接写数字虽然能过编译,但可读性极差,后来接手的人要查半天。
3.2 request_irq 五个参数,每个都有讲究
int request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags, const char *name, void *dev);irq是上一步拿到的中断号。handler是处理函数,返回irqreturn_t。flags是重点,常见组合如下:
| 标志 | 含义 | 使用场景 |
|---|---|---|
IRQF_TRIGGER_RISING | 上升沿触发 | 高有效信号的出现 |
IRQF_TRIGGER_FALLING | 下降沿触发 | 低有效按键按下 |
IRQF_TRIGGER_HIGH | 高电平触发 | 需要保持电平的信号 |
IRQF_TRIGGER_LOW | 低电平触发 | 需要保持电平的信号 |
IRQF_SHARED | 允许共享 | 多个设备接同一中断线 |
IRQF_ONESHOT | 处理期间屏蔽该中断源 | 配合线程化中断使用 |
触发类型的选择不是随便挑一个。边沿触发只在电平跳变的瞬间产生一次中断,适合按键、脉冲计数这类事件型信号,CPU 开销小,但如果中断处理不及时,两个靠得很近的边沿可能被合并成一个,也就是"丢边沿"。电平触发在电平保持期间会持续产生中断,只要源没清就一直来,适合"必须处理完才能继续"的场景,代价是处理函数里必须真正清掉中断源,否则会陷入中断风暴。
name会出现在/proc/interrupts里,起个有意义的名字,调试时省一半力气。dev是传给处理函数的私有数据,同时也是共享中断的识别标识,free_irq()时要用同一个指针,不能传 NULL。
返回值方面,-EBUSY通常是中断号被别人占了但你没加IRQF_SHARED;-EINVAL最常见的两种情况是 handler 为 NULL,或者触发类型与已有配置冲突;-ENOMEM是内核内存不足,一般意味着哪里泄漏了。
3.3 中断上下文里绝对不能做的事
这一点必须单独强调,因为它是最容易写出"偶发死机"的地方。中断处理函数运行在中断上下文,这个上下文没有进程、不能睡眠。具体到代码上,以下操作全部禁止:
- 调用任何可能睡眠的函数,包括
mutex_lock()、kmalloc(GFP_KERNEL)、msleep(); - 通过 I2C、SPI、USB 总线读写设备,这些总线的传输接口都可能睡眠;
- 调用
copy_to_user()/copy_from_user(); - 做耗时的循环或者大量打印。
需要睡眠的操作只能挪到下半部:软中断、tasklet、工作队列,或者最省事的线程化中断。我在一个项目里见过有人在按键中断里直接调 I2C 读寄存器判断按键状态,单次测试没问题,跑压力测试十几分钟就报 "scheduling while atomic",定位花了整整两天。
4. 上半部只做最少的事:线程化中断与消抖落点
内核把中断处理拆成上半部和下半部,不是为了让代码好看,是因为上半部运行期间本 CPU 的对应中断是被屏蔽的,做得越久,系统响应越差,丢中断的概率越高。GPIO 中断尤其如此,按键这种慢速事件根本不值得占用中断上下文。
4.1 request_threaded_irq 的分工方式
int request_threaded_irq(unsigned int irq, irq_handler_t handler, irq_handler_t thread_fn, unsigned long flags, const char *name, void *dev);比request_irq()多了一个thread_fn。运行逻辑是:中断到来先执行handler,如果它返回IRQ_WAKE_THREAD,内核就会唤醒一个内核线程去执行thread_fn,thread_fn运行在进程上下文,可以睡眠、可以走总线、可以拿 mutex。
handler为 NULL 是一种特殊写法,内核会自动用一个默认的 primary handler 直接返回IRQ_WAKE_THREAD,等于所有工作都放到线程里。这种情况下IRQF_ONESHOT会被自动加上——因为线程还没跑完的时候不能让中断源继续产生中断,否则线程会被反复唤醒。
我的用法习惯是:handler里只做时间戳过滤和必要的快速判断,返回IRQ_WAKE_THREAD;真正的读电平、状态机迁移、上报事件全部放thread_fn。这样即使后面要加 I2C 扩展芯片的支持,代码结构也不用改。
static irqreturn_t btn_isr(int irq, void *arg) { struct btn_data *d = arg; ktime_t now = ktime_get(); s64 delta = ktime_ms_delta(now, d->last_ts); if (delta < 20) return IRQ_HANDLED; /* 抖动,直接吞掉,不唤醒线程 */ d->last_ts = now; return IRQ_WAKE_THREAD; } static irqreturn_t btn_thread(int irq, void *arg) { struct btn_data *d = arg; int val = gpiod_get_value_cansleep(d->gpio); if (val < 0) return IRQ_HANDLED; /* 这里可以睡眠、可以上报 input 事件、可以走 I2C */ dev_info(d->dev, "button state %d\n", val); return IRQ_HANDLED; }注意return IRQ_HANDLED和return IRQ_WAKE_THREAD的区别:前者表示"中断是我的,处理完了",后者表示"中断是我的,但我需要线程接着干"。如果返回IRQ_NONE,内核会认为这不是你的中断,共享中断场景下会继续问下一个 handler;如果所有 handler 都返回IRQ_NONE,内核会打印 "nobody cared" 并把这条中断线关掉。
4.2 IRQF_SHARED 和 IRQF_ONESHOT 的两个误用
共享中断的误用:多条设备共用一根中断线时,每个 handler 都必须能判断"这次中断是不是我的"。判断依据通常是读自己设备的状态寄存器。如果无脑返回IRQ_HANDLED,别的设备的中断就会被你"认领",别人的 handler 永远收不到事件,而你的 handler 会收到一堆跟自己无关的中断。
ONESHOT 的误用:IRQF_ONESHOT的本意是"在这个中断的处理线程跑完之前,不要放开中断源"。它必须和线程化中断配合使用,单独加在request_irq()上会被内核拒绝,返回-EINVAL。这个报错信息不太直观,第一次遇到容易绕远路。
4.3 中断侧消抖的两种写法对比
时间戳差分前面已经给过代码,优点是实现简单、不需要额外定时器,缺点是无法区分"真实快速连击"和"抖动"。如果你需要更严谨的消抖,可以用定时器兜底:中断里只记录时间并启动一个延时工作,延时到期后再读一次电平确认。
static irqreturn_t btn_isr(int irq, void *arg) { struct btn_data *d = arg; mod_timer(&d->debounce_timer, jiffies + msecs_to_jiffies(20)); return IRQ_HANDLED; } static void btn_debounce_fn(struct timer_list *t) { struct btn_data *d = from_timer(d, t, debounce_timer); int val = gpiod_get_value(d->gpio); /* 此时电平已经稳定,按 val 处理 */ }两种方式的差别在于:时间戳差分是"第一次边沿就认,后续的在窗口内丢弃",响应快但可能把一个真实的长按抖动误判;定时器方式是"等到电平稳定再判断",更准确但会有固定延迟。按键场景我更倾向定时器方式,脉冲计数场景必须用时间戳方式,因为定时器会漏掉高频脉冲。
5. 中断不来、来了不停、来得太少:三条排错链路
中断跑不通的现象无非三类:一次都不进、进得太频繁、该进的没进。三类问题的排查路径完全不同,混在一起查会很痛苦。我通常按下面的顺序走。
5.1 中断完全不触发
第一步看计数:
cat /proc/interrupts | grep btn如果这一行存在但计数一直是 0,说明中断注册成功了,但硬件侧从来没产生过中断。接下来依次确认:
- 引脚是否真被切到了 GPIO 功能?回到第 2.1 节查 pinctrl;
- 触发类型和实际电平跳变方向是否匹配?按键是"按下变低",那应该是下降沿或者低电平触发,配成上升沿当然永远不进;
- 这条线在这颗控制器上是否支持中断?
gpiod_to_irq()如果返回负数,在 probe 里应该直接报错退出,别硬着头皮往下走; - 中断是否被别的驱动抢了?
cat /proc/interrupts里同名或同号的行都看一遍。
如果/proc/interrupts里根本没有你申请的那一行,那是request_irq就没成功,回去看返回值。
5.2 中断风暴:计数飞涨、系统卡顿
/proc/interrupts里的数字几秒钟涨几万,同时系统明显变卡,这就是中断风暴。三个典型成因:
电平触发没有清源。电平触发的语义是"只要电平保持,中断就一直有效"。处理函数返回后如果源还在,内核会立刻重新进入,形成死循环。解决办法要么在 handler 里真正清掉设备侧的中断状态位,要么改用边沿触发。
边沿触发但硬件在抖。机械触点抖动会在几毫秒内产生几十个边沿,每一个都触发一次中断。加快消抖,或者加硬件 RC。
共享中断配错。某条线被多个设备共用,其中一个设备的中断源没清干净,一直拉着有效电平,所有人的 handler 都被反复调用。
5.3 丢中断:该来的没来
丢中断的表现是"按十次只响应七八次",且没有规律。常见原因:
| 现象 | 可能原因 | 处理方向 |
|---|---|---|
| 快速连击时丢事件 | 边沿被合并,处理太慢 | 改线程化,缩短上半部 |
| 特定方向丢事件 | 控制器只支持单边沿 | 换触发类型或换引脚 |
| 高负载时丢事件 | 中断被长时间屏蔽 | 检查是否有长临界区 |
| 随机丢事件 | 电气干扰,边沿不干净 | 加滤波电容,缩短走线 |
最后一条尤其容易被忽略。板子上电机、继电器动作时,GPIO 线上会感应出毛刺,示波器不一定抓得到,但中断计数会异常。这种情况下软件层面无论如何优化都没用,必须从布线和滤波上解决。
6. 一份可复用的驱动骨架:按键输入加中断上报
把前面所有点串起来,给一份能直接改成自己项目的骨架。这份代码用平台设备驱动模型,设备树匹配,资源全部走devm_系列,卸载路径干净。
6.1 设备树节点与匹配表
/ { button { compatible = "demo,gpio-button"; pinctrl-names = "default"; pinctrl-0 = <&btn_pin>; gpios = <&gpio1 15 GPIO_ACTIVE_LOW>; interrupts-extended = <&gpio1 15 IRQ_TYPE_EDGE_FALLING>; debounce-interval = <20>; status = "okay"; }; };驱动里的匹配表:
static const struct of_device_id btn_of_match[] = { { .compatible = "demo,gpio-button" }, { } }; MODULE_DEVICE_TABLE(of, btn_of_match);compatible字符串里的厂商前缀随便起,但要和驱动里完全一致,大小写也不能差。我见过因为多了一个空格导致probe压根不进的案例。
6.2 probe 里资源申请的先后顺序
顺序很重要,错了会出现"中断注册成功但读不到电平"这类怪现象。正确顺序是:先拿 GPIO 描述符并设置方向,再取中断号,最后注册中断。
struct btn_data { struct gpio_desc *gpio; struct device *dev; ktime_t last_ts; int irq; }; static int btn_probe(struct platform_device *pdev) { struct btn_data *d; int ret; d = devm_kzalloc(&pdev->dev, sizeof(*d), GFP_KERNEL); if (!d) return -ENOMEM; d->dev = &pdev->dev; platform_set_drvdata(pdev, d); d->gpio = devm_gpiod_get(&pdev->dev, "button", GPIOD_IN); if (IS_ERR(d->gpio)) return dev_err_probe(&pdev->dev, PTR_ERR(d->gpio), "failed to get gpio\n"); d->irq = gpiod_to_irq(d->gpio); if (d->irq < 0) return dev_err_probe(&pdev->dev, d->irq, "gpio has no irq\n"); ret = devm_request_threaded_irq(&pdev->dev, d->irq, btn_isr, btn_thread, IRQF_ONESHOT, "demo-btn", d); if (ret) return dev_err_probe(&pdev->dev, ret, "failed to request irq\n"); dev_info(&pdev->dev, "button ready, irq=%d\n", d->irq); return 0; }注意devm_request_threaded_irq()的 flags 里没有写触发类型,因为设备树里已经通过IRQ_TYPE_EDGE_FALLING声明了。如果两处都写,内核可能报冲突,或者以其中一个为准,行为不稳定。这一点我在早期项目里吃过亏,明明改了设备树却完全不生效,最后发现是驱动里把边沿类型写死了。
dev_err_probe()是个好东西,它在返回-EPROBE_DEFER的时候不会打印错误日志。GPIO 控制器有时比你的驱动加载得晚,devm_gpiod_get()会返回-EPROBE_DEFER,用普通dev_err()会在每次开机时刷一堆吓人的错误,实际什么都不影响。
6.3 卸载路径与几个容易漏的资源问题
用devm_系列之后,remove回调基本可以不做任何事情,内核会在设备解绑时自动按申请的反序释放中断和 GPIO。但有几个坑跟devm_无关,需要自己注意:
中断线程还在跑的时候设备已经释放。devm_request_threaded_irq()释放时会等待正在执行的线程完成,这一点是安全的。但如果你在remove里先手动释放了某些thread_fn会用到的资源(比如先注销了 input 设备),那线程里再去用就会出问题。稳妥的做法是在remove里先把中断关掉,再释放其他资源。
共享数据没做并发保护。中断线程和用户态的系统调用可能同时访问同一份数据。thread_fn运行在进程上下文,可以被抢占,该加锁的地方还是要加锁。上半部handler运行在中断上下文,跟它共享的数据要用spin_lock_irqsave()。
上报事件用 input 子系统更省事。如果这个按键最终要变成用户态能收到的事件,用gpio-keys驱动配合 input 子系统,设备树里配几个属性就完事,完全不用自己写驱动。只有在需要特殊处理(比如长按、双击、组合键)的时候,自己写驱动才有价值。
提示:调试阶段可以在
thread_fn里加一行dev_dbg(),然后用echo 'module demo_btn +p' > /sys/kernel/debug/dynamic_debug/control动态打开,比重新编译内核模块快得多。
7. 最后几句实测体会
我在这类调试上花的时间,八成不在写代码,在确认"硬件到底给了什么"。所以现在我的习惯是:拿到一块新板子,先不写驱动,先用gpioinfo看线、用gpiomon看事件、用示波器看波形,三样都对上了再动内核代码。gpiomon的时间戳尤其好用,它能直接告诉你抖动的真实宽度,比凭经验猜 20ms 靠谱得多。
另一个反复验证过的结论是:触发类型和电平有效性,一定要在一个地方声明。要么全在设备树,要么全在驱动,别两边都写。我见过太多"改了没生效"的问题,根源都是同一个配置被写了两遍,改的那一遍不是生效的那一遍。
至于消抖阈值,别迷信固定值。便宜的轻触开关抖动时间可能到 10ms 以上,好一点的贴片按键只有 1~2ms。拿gpiomon抓几十次真实按键,看看相邻事件的最小间隔,取那个值稍微放大一点,比抄别人的 20ms 准确得多。