资讯动态

Linux驱动开发进阶:从GPIO到设备树与input子系统完整实战

发布时间:2026/10/21 21:54:44 来源:尧图企业网站定制
很多学 Linux 驱动的人都不是被 C 语言卡住的而是被驱动框架绕晕的。GPIO 子系统、平台驱动、设备树、MISC、input 子系统这几个词单独看好像还能理解一旦连在一起就像几座山同时挡在面前。我第 10 次带学员做这套进阶练习时特意选了一个特别朴素的需求一个按键、一个 LED按下按键点亮 LED同时把按键事件作为一个标准输入事件上报给应用层。就这么一个小目标走下来发现刚好能把上面那一串概念全部过一遍。这篇文章就把这条进阶路径原原本本拆开先解决引脚怎么控制再解决设备怎么描述然后解决驱动怎么被找到最后解决事件怎么送给用户态中间还穿插了 MISC 这种“偷懒但高效”的设备管理方式。无论你是刚开始写驱动还是写了一阵子却始终觉得概念零散这条路都值得跟着走一遍。1. 一条线串起来从点灯到标准驱动的完整链路1.1 为什么这个进阶路线能打通驱动开发的任督二脉很多人学驱动有个误区总觉得要把某个子系统学得“特别深”才能写下一个驱动。实际上驱动开发更需要的是一种拆解能力把外设的每个功能需求分配到内核对应的框架里。GPIO 负责引脚读写设备树负责描述硬件平台驱动负责设备和驱动的绑定MISC 负责生成轻量级设备节点input 负责上报按键事件。每个模块解决一个层面的问题彼此之间又通过标准接口衔接。这个实验的关键在于顺序。如果直接给你一个platform_driver的模板让你照着填大概率是填不进去的。因为你不明白为什么要有of_match_table也不明白为什么probe函数里能拿到设备树里的 GPIO 属性。但只要先从“裸操作 GPIO”的写法开始再一步步引入设备树、平台驱动、MISC 和 input整个链路就顺了。你能清楚看到每个框架到底是在为你解决什么麻烦。这也是我带人做这个“第 10 课”时最大的感触框架不是用来背的是用来解决具体问题的。1.2 四个模块到底在处理什么级别的问题先用一张表把这条链路里的主要概念和它们的作用对齐后面的代码全部围绕这张表展开模块/概念解决的核心问题在实验中的角色GPIO 子系统统一引脚读写接口隐藏硬件寄存器细节控制 LED 亮灭、读取按键电平设备树用文本描述硬件资源让驱动不写死地址和引脚描述 GPIO 引脚号、极性、中断信息平台驱动完成“设备”和“驱动”的匹配与绑定probe函数自动触发初始化外设MISC 设备以最小成本提供字符设备访问接口生成/dev/mykey_ctrl做统计和控制input 子系统统一输入事件格式复用内核生态按键按下/释放上报给应用层这里有一个很容易忽略的递进关系GPIO 子系统是“操作层”设备树是“描述层”平台驱动是“匹配层”MISC 和 input 是“暴露层”。分层之后硬件变了就改设备树驱动代码不用大动应用层变了就加新的暴露方式驱动核心也不受影响。这就是这一课真正想让你体会的Linux 驱动并不是一堆 API 的堆砌而是一套分工明确的软件工程体系。2. GPIO 子系统先把引脚这件事彻底搞清楚2.1 老 API 与新 API从数字编号到描述符GPIO 子系统的接口演进本身就是一个学习案例。老的内核 API 是gpio_request(31, led)、gpio_set_value(31, 1)这样直接用数字编号操作引脚。你写的时候要自己去查这个数字代表哪个引脚而且平台变了编号可能完全变样。我记得早期在一个项目里驱动里写死了一个 GPIO 编号换了一版内核编号直接失效查问题查了大半天最后发现是 GPIO 基地址重排了。新 API 改成了基于描述符的方式devm_gpiod_get(dev, led, GPIOD_OUT_LOW)得到的是一个struct gpio_desc *指针后续所有操作都围绕这个指针进行。这个改动最大意义在于驱动不再关心这到底是第几号引脚只关心“这个 GPIO 对应的 desc”。引脚究竟挂在哪个控制器上、是第几个、中间经过了多少映射都由内核底层去解决。写驱动的人焦虑少了一大半。老 API 和新 API 的常见对应关系大概是这样的老 API新 API说明gpio_request/gpio_freedevm_gpiod_get/devm_gpiod_put请求 GPIO 所有权gpio_direction_outputGPIOD_OUT_LOW/gpiod_direction_output设置为输出gpio_set_valuegpiod_set_value设置逻辑值gpio_get_valuegpiod_get_value读取逻辑值gpio_to_irqgpiod_to_irq获得中断号实际新项目建议无脑用新 API配合devm_前缀还能顺便解决自动释放的问题。2.2 逻辑值与物理电平以及 devm 生命周期的坑这里有个特别容易让人懵的概念gpiod_set_value(desc, 1)设置的其实是“逻辑值”不是“物理电平”。如果设备树里描述这个 GPIO 是GPIO_ACTIVE_LOW那么逻辑值 1 会让引脚输出物理低电平逻辑值 0 反而输出高电平。听起来绕但恰恰是这个设计解放了驱动作者。比如我实验里的 LED 是低电平点亮的硬件上就是引脚输出低电平才亮。如果直接操作寄存器你得在驱动里写一个注释“0 亮 1 灭”还要保证整个团队都记得这条规则。用新 API 加设备树极性描述后驱动里只需要写gpiod_set_value(led_gpio, 1)表示“点亮 LED”。底层会自动根据GPIO_ACTIVE_LOW翻转电平。按键也是一个道理按下时是有效状态驱动读到gpiod_get_value(key_gpio)返回 1不用关心按下时引脚到底是高还是低。devm_前缀也很关键。devm_gpiod_get申请的资源会跟随设备生命周期自动释放。什么意思呢在probe函数里如果后续某一步失败只需要直接return前面申请过的 GPIO、中断、内存都会被自动回收。如果用手动 API你很可能在一个错误路径上漏掉gpio_free导致模块反复加载后资源泄漏。我第一次用老 API 手动管理时就在一个分支里漏了释放结果第二次 insmod 直接报“GPIO already requested”。2.3 从 GPIO 到中断号准备接收按键事件按键驱动光轮询是不够的效率太低而且占 CPU。标准做法是把 GPIO 配置成中断源按键动作触发中断在中断处理函数里上报事件。获取中断号的方式很简单int irq gpiod_to_irq(key_gpio);。拿到这个虚拟中断号后就能用request_irq注册中断处理函数了。这一步是 GPIO 子系统和后续 input 子系统之间的桥梁。没有它input 事件就只能是轮询出来的完全没有“事件驱动”的感觉。使用gpiod_to_irq有个前提这个 GPIO 已经成功 request 到了。另外中断触发方式要根据按键电路来决定。按键按下时电平从高变低释放时从低变高所以我在实验里注册中断时用了IRQF_TRIGGER_FALLING | IRQF_TRIGGER_RISING两个沿都触发然后在中断处理函数里通过读取当前 GPIO 状态来判断是按下还是释放。注意中断处理函数属于原子上下文不能在里面调用可能睡眠的函数。像gpiod_get_value这种只做寄存器读取的调用是安全的但如果你接的 GPIO 是 I2C 扩展芯片提供的那就必须考虑到访问 I2C 会睡眠不能用普通方式直调。3. 实操闭环设备树 平台驱动 MISC input 的完整落地3.1 硬件连接与设备树描述让板子“认识”按键和 LED进入核心环节。我这里手头这块板子上的实验环境极其简单SoC 引出一个 GPIO 接按键一个 GPIO 接 LED。由于版权原因不点名具体型号你用任意一款带 Linux 内核的开发板都能复现只要把 GPIO 编号改成你自己的。我把按键连接到 GPIO0_15默认上拉为高电平按下接地为低电平LED 连接到 GPIO0_14低电平点亮。设备树里我为这个实验专门定义了一个节点把两个 GPIO 资源都放在同一个节点里方便驱动在一个probe里全部拿到#include dt-bindings/gpio/gpio.h / { my_test_device { compatible myvendor,gpiokey-led; led-gpios gpio0 14 GPIO_ACTIVE_LOW; key-gpios gpio0 15 GPIO_ACTIVE_LOW; status okay; }; };这里有两个细节值得停下来解释一下。第一属性名后面带-gpios是 GPIO 子系统的硬性约定。led-gpios对应驱动里的devm_gpiod_get(dev, led, ...)key-gpios对应devm_gpiod_get(dev, key, ...)。内核会把-gpios前面的部分作为 con id不需要你在设备树和驱动之间再约定一遍。第二GPIO_ACTIVE_LOW这个宏需要包含头文件dt-bindings/gpio/gpio.h。如果不包含你只能写裸数字一旦平台换了这个数字的语义可能就变了后患无穷。写设备树不是写给自己看的是写给大家维护的该用的宏一定要用。写完设备树后还需要确认引脚没有被其他功能占用。比如这个引脚曾经被配置成 UART 功能那 GPIO 读到的东西就完全不可控。这个动作靠 pinctrl 子系统完成通常板级 dts 里已经配好了如果没配好就需要在节点里加pinctrl-0 key_pinmux;这类描述。这部分我放到第 5 章展开先不打断主线。3.2 platform_driver 如何“找到”你的设备设备树写好了节点在内核里会变成一个platform_device但光有设备还不行还要有对应的驱动。这个驱动就是platform_driver。核心逻辑是驱动通过of_match_table声明自己支持哪些compatible字符串内核会把设备树节点的compatible属性和这张表逐一比对匹配成功就调用驱动的probe函数。我写的匹配表和驱动入口如下static const struct of_device_id my_test_of_match[] { { .compatible myvendor,gpiokey-led, }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, my_test_of_match); static struct platform_driver my_test_driver { .probe my_test_probe, .remove my_test_remove, .driver { .name my_test_drv, .of_match_table my_test_of_match, }, }; module_platform_driver(my_test_driver);MODULE_DEVICE_TABLE这行很多人容易漏但非常关键。它会把匹配信息写进模块的别名里这样当设备树里有对应compatible时modprobe才能自动把模块拉起来。如果不写就算你手动 insmod 能工作一重启或者依赖自动加载驱动就可能“找不到”。当设备树节点和驱动匹配成功后内核会调用probe函数。我在probe里做了四件事获取两个 GPIO、获取按键中断号、注册 MISC 设备、注册 input 设备。这个函数就是整个驱动的“客厅”所有初始化都在这里汇合。3.3 MISC 设备申请不了主设备号时的最优解很多初学者喜欢用register_chrdev自己申请主设备号。可真去申请一次就明白了主设备号资源有限而且申请了还得自己管理分配表对一个小外设来说太笨重了。MISC 设备的思路是所有 misc 设备共享主设备号 10靠次设备号区分。你只需要分配一个次设备号或者干脆用MISC_DYNAMIC_MINOR让内核自动分配。我在这个实验里用 MISC 设备做的是“旁路控制通道”按键事件通过 input 子系统上报同时 MISC 节点提供一个自定义接口让应用层能读取当前按键总次数或清空计数。这样你就能清楚看到两种“暴露方式”的差别。static struct miscdevice my_test_misc { .minor MISC_DYNAMIC_MINOR, .name mykey_ctrl, .fops my_test_fops, };注册后系统里就会多出一个/dev/mykey_ctrl节点。配合file_operations里的实现应用层可以打开这个节点执行read和ioctl。我在实现里维护了一个atomic_t press_count在按键中断里递增计数然后通过 MISC 设备暴露。这样应用层有两个途径感知按键一个是事件流另一个是统计读数。前者适合实时响应后者适合做监控。3.4 input 子系统把按键事件交给应用层如果只做 MISC 设备驱动也能用但每次都要自己设计事件格式应用层还得跟着你的格式走。input 子系统的价值就是把这部分标准化了驱动只需要调用input_report_key和input_sync事件就会以标准struct input_event格式分发到/dev/input/eventX。所有基于 input 的应用层工具比如evtest、libinput都能直接识别。我在probe里注册 input 设备的流程是struct input_dev *idev input_allocate_device(); idev-name my gpio key; input_set_capability(idev, EV_KEY, KEY_A); input_register_device(idev);input_set_capability就是告诉内核这个设备能上报哪些事件类型、哪些按键编码。这里我用的是KEY_A代表按键事件编码。如果后面想支持更多按键就继续调用input_set_capability添加。中断处理函数里的事件上报是最核心的动作代码非常短static irqreturn_t my_test_irq_handler(int irq, void *data) { struct my_test_dev *mdev data; int pressed; pressed gpiod_get_value(mdev-key_gpio); input_report_key(mdev-input_dev, KEY_A, pressed); input_sync(mdev-input_dev); if (pressed) atomic_inc(mdev-press_count); return IRQ_HANDLED; }这段代码里input_report_key和input_sync必须成对出现。input_report_key只是把事件写入缓冲区input_sync才真正告诉输入核心“这批事件完整了可以分发出去”。我第一次做实验时就是忘写input_sync结果按键事件一直没反应排查了半天才发现是事件一直被压在缓冲区里没提交。上报状态用的是pressed也就是当前 GPIO 的逻辑值。按下时是 1释放时是 0所以不需要额外维护一个状态变量直接读取就行。这也是 GPIO 子系统新 API 处理ACTIVE_LOW后的好处驱动里只用关心“现在是有效还是无效”不用去算电平。3.5 全流程验证从 insmod 到 evtest 看到按键事件模块编译好之后整个验证流程分为三步。第一步把设备树编译进内核或通过设备树 overlay 方式加载然后确认平台设备被创建ls /sys/bus/platform/devices/my_test_device cat /sys/bus/platform/devices/my_test_device/uevent如果能在这里看到节点说明设备树解析成功。第二步加载驱动模块insmod my_test.ko dmesg | tailprobe成功会打印出我加的初始化日志同时能看到注册的字符设备 input 节点。第三步用 evtest 查看事件。如果你板子上装了 evtest直接运行evtest /dev/input/eventX按下按键屏幕上会出现类似这样的内容Event: type 1 (EV_KEY), code 30 (KEY_A), value 1 Event: type 0 (EV_SYNC), value 0再释放按键又会冒出一条value 0的事件。这条输出意味着你已经成功走完了从 GPIO 到平台驱动、设备树、MISC 与 input 的完整链路。所有框架协同工作的结果最终在这里体现为一个标准输入事件。4. 实测踩坑记录驱动开发中那些最容易被卡住的点4.1 compatible 匹配不上导致 probe 不执行这是新手遇到最多的现象代码编译没问题insmod 也没报错但dmesg里就是看不到probe里的打印。这个时候第一反应不应该是翻源码而是去确认设备树的compatible和驱动里的of_device_id字符串是不是完全一致。我曾经在一次实验里把设备树写成myvendor,gpiokey-led驱动里写成myvendor,gpiokey_led中间一个是短横线一个是下划线结果匹配失败谁都没发现问题。排查命令也很简单cat /sys/bus/platform/devices/my_test_device/uevent这里会输出MODALIASof:Nmy_test_deviceTcompatible可以看到内核实际识别到的 compatible 字符串。再对比驱动的modinfo my_test.ko里输出的是不是同一个字符串就能定位问题了。这类问题纯属“细节决定成败”。4.2 极性配置错误让 LED 逻辑完全反了另一个高发问题是 LED 实际亮灭和代码逻辑相反。我之前带 A 同学做实验时设备树里写的是GPIO_ACTIVE_HIGH但板子上 LED 其实是低电平点亮结果gpiod_set_value(led_gpio, 1)反而把灯灭了。更隐蔽的是用老 APIgpio_set_value时根本没有极性概念所以这类问题在老 API 时代完全要靠注释约定。新 API 的做法是让驱动只写“逻辑语义”把极性问题交给设备树。一旦出现 LED 行为反了优先回来检查设备树里的GPIO_ACTIVE_LOW/HIGH是否和硬件一致而不是去驱动里强行翻转。记住驱动代表的是产品逻辑层硬件差异尽量沉淀在设备树描述层。4.3 上报了事件但应用层就是收不到按键确实在触发中断dmesg也没有异常但 evtest 里就是没有事件。这个问题的头号原因就是我前面说的漏了input_sync。输入子系统所有的“单条事件”都是待定状态只有收到 sync 事件才会真正提交给上层。可以这样理解input_report_key是装车input_sync是发车只装不发货永远到不了。另外还有一个小坑如果在中断里高频上报又没有做去重应用层可能会看到连续重复的按下事件。比如按键消抖没做按下一次硬件电平在几毫秒内抖动了多次中断触发了好几次。这种问题在实验里还能糊弄过去产品里必须处理。简单做法是引入防抖延时或状态判断复杂一点就是使用内核提供的input_fuzz或者gpio-keys驱动里现成的 debounce 机制。4.4 按键抖动与中断上下文限制说到抖动这是硬件开发里躲不开的话题。机械按键按下瞬间触点会来回弹跳导致电平在一个很短时间内反复翻转。如果直接按上升沿和下降沿触发中断一次按压可能产生多个中断上报多个事件。解决思路有几个层次。第一层硬件上做 RC 滤波或者在按键引脚并联电容第二层软件上在中断处理函数里启动一个延迟定时器比如 10ms 后再去读取 GPIO 状态避开抖动窗口第三层使用内核现成的gpio-keys驱动它把软件消抖封装好了。这个实验里为了演示框架之间的协作我故意没有把消抖做进去但你在真实项目里一定要重视它。另外一个约定俗成的规则中断处理函数里不要做耗时的、可能睡眠的操作。想打印日志就打但别高频打印中断里频繁printk会拖慢系统而且可能引发锁问题。如果你真的需要在中断触发后做繁琐处理应该用中断下半部机制比如tasklet、工作队列或threaded irq。5. 进阶建议从“会做这个实验”到“会用内核的成熟框架”5.1 不要重复造轮子gpio-keys、gpio-leds 的底层逻辑如果你已经完成了这个实验下一步很有价值的动作是去看内核里现成的gpio-keys驱动。你会发现它的整体架构和我上面写的这段代码几乎一模一样设备树节点里定义按键 GPIO、平台驱动匹配、probe 里获取 GPIO 和中断、input 子系统注册和上报事件额外多了一块软件消抖逻辑。唯一不同的是它把这一切抽象成了通用配置你在设备树里写上gpio-keys的 compatible 就能直接得到一堆按键事件。你能从它身上学到的最重要一课是框架的更高境界是“把共性做成配置”。你可以按自己的需求去扩展自己的通用驱动框架每次接入一个新按键就只改设备树不用再碰 C 代码。这才是设备树 平台驱动的组合真正想带来的效率提升。5.2 从 GPIO 到 pinctrl引脚多功能背后的管理思想很多芯片的引脚是可复用的。同一个物理引脚可能是 GPIO可能是 UART 的 TX也可能是 I2C 的 SCL。决定它当前是什么功能的就是 pinctrl 子系统。它的设计思路是把“引脚复用配置”也变成设备树的一部分驱动在probe时通过devm_pinctrl_get获取引脚控制器句柄再根据状态切换引脚功能。比如按键占用的引脚以前被设置为 UART 功能那么即使你在设备树里用key-gpios描述了它GPIO 读写也不会如期工作因为芯片内部这个引脚根本没接回 GPIO 控制器。这个坑排查起来挺隐蔽因为完全看不出是设备树错误还是驱动错误。正确做法是在设备树里为节点配置pinctrl-0 key_pinmux;并把key_pinmux在板级 pinctrl 节点里定义为“此引脚作为 GPIO 功能”。学习 pinctrl 需要额外花一些时间但它是驱动开发绕不过去的基础模块尤其是当你想在真实板子上把 UART 口改造成 GPIO 口的时候会感激自己提前学过它。5.3 从输入设备到更广的外设接入这个实验里 input 子系统只上报了一个按键。但如果你接的是触摸屏、鼠标、传感器这类输入设备底层逻辑是一样的申请 input 设备、设置能力位、上报数据、同步。触摸屏会用到EV_ABS和ABS_MT_POSITION_X/Y鼠标会用到EV_REL和REL_X/Y格式虽然有差异但机制完全一样。所以我强烈建议你在完成这个实验之后把gpio-keys的源码完整读一遍。它短小精悍也就几百行却把设备树解析、平台驱动、中断、input 子系统这些知识点全部串了起来。等你读懂了它的实现再看触摸屏驱动会发现核心流程完全一致剩下的不过是具体硬件协议不同罢了。我在实际做这套实验时最深的体会是驱动学习的顺序远比你学了多少 API 重要。只要有一个真实的小目标摆在面前GPIO、设备树、platform、MISC、input 这些概念就会自己找到各自的位置。你的第一个驱动框架不是背下来的是在解决一个一个具体问题之后“长”出来的。现在回到你自己的板子上先点亮一个 LED再注册一个按键剩下的路自然会越走越宽。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑