Linux GPIO子系统与中断驱动开发:从入门到实战(完整版)

张开发
2026/4/16 20:49:52 15 分钟阅读

分享文章

Linux GPIO子系统与中断驱动开发:从入门到实战(完整版)
Linux GPIO子系统与中断驱动开发从入门到实战完整版本博客是一篇保姆级教程手把手带你理解GPIO子系统、中断机制并深入解析一个真实的按键驱动代码含去抖动定时器和环形缓冲区。适合嵌入式Linux驱动初学者学完即可动手编写自己的驱动。你的领导会来看这篇博客所以我写得特别详细放心食用。目录GPIO子系统基础1.1 引脚编号从硬件到软件1.2 基于sysfs直接操作GPIO1.3 GPIO子系统核心函数新旧两套Linux中断驱动开发2.1 中断使用流程2.2request_irq函数精讲2.3 中断处理函数的限制与底半部按键驱动中的去抖动技术定时器3.1 为什么需要去抖动3.2 Linux内核定时器用法3.3 中断 定时器经典组合完整按键驱动代码逐行解析4.1 数据结构设计struct gpio_desc4.2 入口函数获取中断号4.3 中断处理函数启动定时器4.4 定时器回调函数读取按键值并上报4.5 环形缓冲区put_key与get_key4.6 字符设备操作read/poll/fasync驱动注册与设备节点创建自测题含答案1. GPIO子系统基础1.1 引脚编号从硬件到软件在硬件上一个GPIO引脚需要两个参数才能唯一确定它属于哪一组GPIO组以及它是这组里的第几个引脚。例如i.MX6ULL 芯片有 GPIOA、GPIOB、GPIOC、GPIOD 等多组引脚。但是在Linux内核中为了统一管理为每一个GPIO引脚分配了一个全局唯一的整数编号。你可以通过这个编号来操作任意一个引脚。如何查看系统中已有的GPIO编号bashcat /sys/kernel/debug/gpio输出示例textgpiochip0: GPIOs 0-15, parent: platform/soc:pin-controller50002000, GPIOA: gpio-10 ( |heartbeat ) out lo gpio-14 ( |shutdown ) out hi gpiochip1: GPIOs 16-31, parent: platform/soc:pin-controller50002000, GPIOB: gpio-26 ( |reset ) out hi ACTIVE LOW从上面可以看出gpiochip0的起始编号是0对应GPIOA包含0~15号引脚。gpiochip1起始编号16对应GPIOB包含16~31号引脚。如何自己计算某个硬件引脚的编号首先找到该引脚所属的GPIO组在系统中的起始编号。进入/sys/class/gpio/目录里面有很多gpiochipXXX文件夹XXX就是起始编号。查看某个gpiochipXXX/label文件里面会标明该组对应硬件的哪一组GPIO。计算公式text引脚编号 所在组的起始编号 组内偏移实战例子100ask_imx6ull开发板上的按键按键原理图如下textVDD_3V3 | R47 (10K) | ----- KEY2 | C38 (100nF) --- GND按键一端接GPIO4_14另一端接地。上拉电阻保证默认高电平按下时为低电平。在i.MX6ULL中GPIO4组的起始编号是96可通过上述方法查得。引脚在组内的偏移是14。所以编号 96 14 110。1.2 基于sysfs直接操作GPIOLinux内核提供了/sys/class/gpio接口允许用户在命令行直接操作GPIO前提是该引脚没有被其他驱动占用。这是一种非常方便的调试方式。操作步骤以编号110为例bash# 1. 导出引脚相当于内核中调用 gpio_request echo 110 /sys/class/gpio/export # 2. 设置方向为输入 echo in /sys/class/gpio/gpio110/direction # 3. 读取引脚值0表示低电平1表示高电平 cat /sys/class/gpio/gpio110/value # 4. 使用完毕后释放引脚 echo 110 /sys/class/gpio/unexport⚠️ 注意如果该引脚已经被内核驱动占用export操作会失败并提示Device or resource busy。对于输出引脚的操作示例假设引脚Nbashecho N /sys/class/gpio/export echo out /sys/class/gpio/gpioN/direction echo 1 /sys/class/gpio/gpioN/value # 输出高电平 echo 0 /sys/class/gpio/gpioN/value # 输出低电平 echo N /sys/class/gpio/unexport1.3 GPIO子系统核心函数新旧两套Linux内核中操作GPIO有两套API传统的legacy函数基于整数编号和新的descriptor-based函数基于结构体gpio_desc。推荐新驱动使用 descriptor 方式更安全、更清晰。操作descriptor-basedlegacy获得GPIOgpiod_get/devm_gpiod_getgpio_request设置方向gpiod_direction_input/outputgpio_direction_input/output读取值gpiod_get_valuegpio_get_value写入值gpiod_set_valuegpio_set_value释放GPIOgpiod_put/devm_gpiod_putgpio_free 提示devm_前缀的函数是设备资源管理版本会自动释放减少出错。2. Linux中断驱动开发2.1 中断使用流程在驱动中使用中断的典型步骤获得中断号如果中断来自GPIO可以使用cint irq gpio_to_irq(gpio_number); // legacy方式 int irq gpiod_to_irq(gpio_desc); // descriptor方式注册中断处理函数调用request_irq。在中断处理函数中完成工作分辨中断如果是共享中断处理硬件事件清除中断标志硬件相关返回IRQ_HANDLED或IRQ_NONE释放中断在驱动卸载时cfree_irq(irq, dev_id);2.2request_irq函数精讲cint request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags, const char *name, void *dev);参数解析irq中断号。handler中断处理函数指针。原型为ctypedef irqreturn_t (*irq_handler_t)(int irq, void *dev);返回值irqreturn_t可以是IRQ_NONE该中断不是本驱动产生的用于共享中断IRQ_HANDLED本驱动已正确处理IRQ_WAKE_THREAD需要唤醒内核线程处理flags中断触发方式及属性。常用值IRQF_TRIGGER_RISING: 上升沿触发IRQF_TRIGGER_FALLING: 下降沿触发IRQF_TRIGGER_HIGH: 高电平触发IRQF_TRIGGER_LOW: 低电平触发IRQF_SHARED: 多个设备共享一根中断线name中断名称会在/proc/interrupts中显示。dev传递给中断处理函数的参数通常指向设备结构体用于区分共享中断中的不同设备。释放中断时必须传入相同的指针。2.3 中断处理函数的限制与底半部执行时间要短中断上下文不能睡眠不能调用可能引起调度的函数如mutex_lock、copy_to_user等。需要大量处理时应使用“底半部”例如 tasklet、workqueue 或定时器。对于按键驱动由于存在机械抖动我们不能在中断中直接读取GPIO并上报而应该启动一个定时器延迟一段时间后再读取。这就是中断 定时器的典型应用。3. 按键驱动中的去抖动技术定时器3.1 为什么需要去抖动机械按键在按下和释放的瞬间由于弹性接触会产生一系列短暂的抖动通常几毫秒到几十毫秒。如果中断处理函数在每次电平变化时立即触发那么一次按键动作可能会触发几十次中断导致驱动上报多个按键事件用户体验极差。解决方案在中断处理函数中不立即读取按键状态而是启动一个定时器例如延迟 20ms。定时器到期后再去读取GPIO的稳定电平此时认为按键状态已经稳定。如果抖动期间又发生了新的中断则刷新定时器重新计时确保只在稳定后上报一次。3.2 Linux内核定时器用法内核定时器的基本操作cstruct timer_list { unsigned long expires; // 超时时刻jiffies void (*function)(struct timer_list *); // 回调函数新内核 // ... 其他字段 }; // 初始化定时器 timer_setup(timer, callback, flags); // 启动/修改定时器在当前时间基础上延迟 jiffies mod_timer(timer, jiffies msecs_to_jiffies(20)); // 删除定时器 del_timer(timer);注意旧内核Linux 4.15使用setup_timer和unsigned long data参数新内核使用timer_setup和struct timer_list *参数。我们在代码中会看到两种风格稍后解释。3.3 中断 定时器经典组合伪代码c// 中断处理函数 irqreturn_t key_isr(int irq, void *dev_id) { struct gpio_desc *desc dev_id; // 重新启动定时器延迟20ms mod_timer(desc-key_timer, jiffies msecs_to_jiffies(20)); return IRQ_HANDLED; } // 定时器回调函数 void key_timer_expire(struct timer_list *t) { struct gpio_desc *desc from_timer(desc, t, key_timer); int val gpio_get_value(desc-gpio); // 上报按键事件放入环形缓冲区唤醒等待队列等 }4. 完整按键驱动代码逐行解析下面我们结合你提供的代码片段完整解析一个基于GPIO中断 内核定时器去抖动的按键驱动。该驱动支持多个按键使用环形缓冲区存储事件并实现了阻塞读、poll 和异步通知。4.1 数据结构设计struct gpio_desccstruct gpio_desc { int gpio; // GPIO编号 int irq; // 对应的中断号 char *name; // 按键名称 int key; // 按键值可以用于区分不同按键 struct timer_list key_timer; // 去抖动定时器 };每个按键对应一个gpio_desc结构体。key字段可以用来存储按键的键值例如 KEY_UP、KEY_DOWN也可以结合val编码成一个整数表示“哪个按键 按下/抬起”。timer_list用于去抖动每个按键有自己的定时器互不干扰。静态定义两个按键的数组cstatic struct gpio_desc gpios[] { {131, 0, gpio_100ask_1, 0, }, {132, 0, gpio_100ask_2, 0, }, };这里假设 GPIO 131 和 132 已经通过其他方式确定例如从设备树或硬编码。irq初始为0在入口函数中会通过gpio_to_irq填充。注key字段需要初始化为某个值例如 KEY_1、KEY_2或者后续动态分配。4.2 入口函数获取中断号并注册中断cstatic int __init gpio_drv_init(void) { int err; int i; int count sizeof(gpios) / sizeof(gpios[0]); printk(%s %s line %d\n, __FILE__, __FUNCTION__, __LINE__); for (i 0; i count; i) { // 1. 将GPIO编号转换为中断号 gpios[i].irq gpio_to_irq(gpios[i].gpio); if (gpios[i].irq 0) { printk(Failed to get irq for gpio %d\n, gpios[i].gpio); return -EINVAL; } // 2. 注册中断处理函数假设中断处理函数名为 gpio_key_isr err request_irq(gpios[i].irq, gpio_key_isr, IRQF_TRIGGER_FALLING, // 下降沿触发按键按下 gpios[i].name, gpios[i]); if (err) { printk(Failed to request irq %d for gpio %d\n, gpios[i].irq, gpios[i].gpio); return err; } // 3. 初始化定时器新内核风格 timer_setup(gpios[i].key_timer, key_timer_expire, 0); // 也可以将 gpios[i] 的指针作为定时器的数据在回调中通过 from_timer 获取 // 新内核不需要显式设置 data因为 from_timer 通过结构体成员偏移量获取 } // 后续还要注册字符设备、创建类等... return 0; }关键点解析gpio_to_irq将一个 GPIO 编号转换为系统中断号。内核内部会查找该 GPIO 对应的硬件中断线。request_irq注册中断。触发方式为下降沿因为按键按下时电平从高变低。注意这里dev_id参数传入了对应gpio_desc结构体的地址中断处理函数可以通过该指针区分是哪个按键触发了中断。timer_setup初始化定时器指定到期时要调用的函数。新内核中定时器回调函数的原型是void (*)(struct timer_list *)我们需要通过from_timer宏获得包含该定时器的结构体指针。4.3 中断处理函数启动定时器去抖动核心中断处理函数应该尽量简短只做最重要的事重新启动去抖动定时器。cstatic irqreturn_t gpio_key_isr(int irq, void *dev_id) { struct gpio_desc *desc (struct gpio_desc *)dev_id; // 每来一次中断就将定时器推迟 20ms 后再触发 mod_timer(desc-key_timer, jiffies msecs_to_jiffies(20)); return IRQ_HANDLED; }如果按键抖动产生了多次中断每次中断都会调用mod_timer重新设置定时器的超时时间为“当前时间20ms”。这样只有在最后一次中断后的 20ms 内没有新中断时定时器才会真正到期执行。这就实现了去抖动。4.4 定时器回调函数读取按键值并上报当定时器到期时说明按键已经稳定此时可以读取 GPIO 电平确定按键是按下还是抬起然后将事件上报给应用程序。cstatic void key_timer_expire(struct timer_list *t) { // 新内核推荐的方式通过 container_of 获取包含该定时器的结构体指针 struct gpio_desc *gpio_desc from_timer(gpio_desc, t, key_timer); int val; int key; // 读取稳定的GPIO电平 val gpio_get_value(gpio_desc-gpio); // 将按键编号和电平值编码成一个整数 // 例如低8位表示按键ID第8位表示按下(1)或抬起(0) key (gpio_desc-key) | (val 8); // 将事件放入环形缓冲区 put_key(key); // 唤醒等待队列中的读进程如果有进程在read上阻塞 wake_up_interruptible(gpio_wait); // 发送异步通知信号给应用程序如果应用注册了SIGIO kill_fasync(button_fasync, SIGIO, POLL_IN); }老内核兼容写法如果你看到别人代码里的unsigned long datac// 老内核定时器回调原型void (*function)(unsigned long data) static void key_timer_expire(unsigned long data) { struct gpio_desc *gpio_desc (struct gpio_desc *)data; // ... 同上 }对应的初始化方式为csetup_timer(gpios[i].key_timer, key_timer_expire, (unsigned long)gpios[i]);你的代码片段中同时出现了新旧两种风格注释里的是新风格实际用的是老风格这是因为不同内核版本差异。我们推荐新内核统一使用timer_setupfrom_timer。4.5 环形缓冲区put_key与get_key按键事件需要被缓存以便应用程序通过read系统调用读取。环形缓冲区是一种高效的数据结构避免了对大块数据的频繁拷贝。通常定义如下全局变量c#define KEY_BUF_SIZE 16 static int g_keys[KEY_BUF_SIZE]; // 环形缓冲区 static int r 0, w 0; // 读指针、写指针 static int is_key_buf_full(void) // 判断缓冲区是否满 { return (w 1) % KEY_BUF_SIZE r; } static int is_key_buf_empty(void) { return r w; }写入函数put_keycstatic void put_key(int key) { if (!is_key_buf_full()) { g_keys[w] key; w NEXT_POS(w); // w (w 1) % KEY_BUF_SIZE } // 如果缓冲区满了丢弃新事件也可以选择覆盖最旧的根据设计决定 }读取函数get_key通常在read中调用cstatic int get_key(void) { int key g_keys[r]; r NEXT_POS(r); return key; }这样中断上下文定时器回调中调用put_key放入数据进程上下文read系统调用中调用get_key取出数据二者通过等待队列同步。4.6 字符设备操作read/poll/fasync驱动需要实现file_operations中的关键函数让应用程序能够打开设备、读取按键事件、使用select/poll等待事件、接收异步信号。read函数阻塞等待事件cstatic ssize_t gpio_key_read(struct file *filp, char __user *buf, size_t count, loff_t *fpos) { int ret; int key; // 等待缓冲区非空即等待按键事件 ret wait_event_interruptible(gpio_wait, !is_key_buf_empty()); if (ret) return -ERESTARTSYS; // 从缓冲区取出一个事件 key get_key(); // 复制到用户空间 if (copy_to_user(buf, key, sizeof(key))) return -EFAULT; return sizeof(key); }poll函数支持select/pollcstatic unsigned int gpio_key_poll(struct file *filp, poll_table *wait) { poll_wait(filp, gpio_wait, wait); if (!is_key_buf_empty()) return POLLIN | POLLRDNORM; return 0; }fasync函数支持异步通知cstatic int gpio_key_fasync(int fd, struct file *filp, int on) { return fasync_helper(fd, filp, on, button_fasync); }file_operations结构体cstatic struct file_operations gpio_key_fops { .owner THIS_MODULE, .read gpio_key_read, .poll gpio_key_poll, .fasync gpio_key_fasync, };5. 驱动注册与设备节点创建为了使应用层能够访问驱动还需要注册字符设备并创建设备节点。cstatic int major; static struct class *gpio_class; static int __init gpio_drv_init(void) { // ... 前面的GPIO和中断初始化代码 ... // 注册字符设备动态分配主设备号 major register_chrdev(0, gpio_key, gpio_key_fops); if (major 0) { printk(Failed to register chrdev\n); return major; } // 创建类 gpio_class class_create(THIS_MODULE, gpio_key_class); if (IS_ERR(gpio_class)) { unregister_chrdev(major, gpio_key); return PTR_ERR(gpio_class); } // 创建设备节点 /dev/gpio_key device_create(gpio_class, NULL, MKDEV(major, 0), NULL, gpio_key); return 0; } static void __exit gpio_drv_exit(void) { int i; int count sizeof(gpios) / sizeof(gpios[0]); // 释放中断、删除定时器 for (i 0; i count; i) { free_irq(gpios[i].irq, gpios[i]); del_timer(gpios[i].key_timer); } device_destroy(gpio_class, MKDEV(major, 0)); class_destroy(gpio_class); unregister_chrdev(major, gpio_key); } module_init(gpio_drv_init); module_exit(gpio_drv_exit); MODULE_LICENSE(GPL);6. 自测题含答案Q1为什么按键驱动中不能直接在中断处理函数里读取 GPIO 状态并上报给应用程序答案 因为机械按键存在抖动中断会多次触发。直接上报会导致一次按键动作产生多个事件。正确做法是在中断中启动定时器待抖动过后再读取稳定状态。Q2在定时器回调函数key_timer_expire中如果使用新内核的struct timer_list *t参数如何获得包含该定时器的gpio_desc结构体指针答案 使用 from_timer(gpio_desc, t, key_timer) 宏它会通过结构体成员偏移量计算出外层结构体的地址。Q3mod_timer(desc-key_timer, jiffies msecs_to_jiffies(20))这行代码的含义是什么如果按键抖动产生了 5 次中断最终会执行几次定时器回调答案 该代码将定时器的超时时间设置为“当前时间 20ms”。每次中断都调用一次 mod_timer相当于刷新定时器只有最后一次中断后的 20ms 内没有新中断定时器才会到期。因此无论抖动多少次最终只执行一次回调前提是抖动间隔小于 20ms。Q4环形缓冲区的作用是什么put_key函数中为什么要在缓冲区满时丢弃新事件答案 环形缓冲区用于缓存按键事件避免应用层读取不及时导致数据丢失。丢弃新事件是一种简单策略避免覆盖还未被读取的旧事件。另一种策略是覆盖最旧的事件根据需求决定。Q5如何让应用程序能够使用select或poll等待按键事件答案 在驱动中实现 .poll 函数调用 poll_wait 将等待队列头添加到 poll_table 中并检查缓冲区是否非空返回合适的掩码POLLIN。Q6gpio_to_irq函数的返回值是什么类型如果失败会返回什么答案 返回 int 类型的中断号失败时返回一个负数错误码如 -ENXIO。Q7为什么要为每个按键单独定义一个struct timer_list而不是所有按键共用一个答案 因为每个按键的抖动是独立的共用一个定时器会导致一个按键的中断刷新另一个按键的去抖动延迟产生混乱。每个按键独立定时器才能正确去抖动。Q8在驱动卸载时为什么要调用del_timer和free_irq顺序重要吗答案 需要释放资源防止模块卸载后定时器或中断仍然被触发导致内核崩溃。一般先 del_timer 确保定时器不再运行再 free_irq 释放中断线。如果先 free_irq中断不会触发但已经启动的定时器仍可能到期所以建议先删定时器。结语现在你不仅掌握了 GPIO 子系统和中断的基础还深入理解了一个工业级按键驱动的完整实现——包括去抖动定时器、环形缓冲区、字符设备接口。下一步你可以在自己的开发板上编译加载这个驱动并用一个简单的应用程序测试读取按键事件。祝你学习愉快也祝你领导满意这篇博客如果有任何疑问欢迎留言讨论。

更多文章