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

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

本博客是一篇保姆级教程,手把手带你理解GPIO子系统、中断机制,并深入解析一个真实的按键驱动代码(含去抖动定时器和环形缓冲区)。适合嵌入式Linux驱动初学者,学完即可动手编写自己的驱动。
你的领导会来看这篇博客,所以我写得特别详细,放心食用。


目录

  1. GPIO子系统基础
    • 1.1 引脚编号:从硬件到软件
    • 1.2 基于sysfs直接操作GPIO
    • 1.3 GPIO子系统核心函数(新旧两套)
  2. Linux中断驱动开发
    • 2.1 中断使用流程
    • 2.2 request_irq 函数精讲
    • 2.3 中断处理函数的限制与底半部
  3. 按键驱动中的去抖动技术(定时器)
    • 3.1 为什么需要去抖动?
    • 3.2 Linux内核定时器用法
    • 3.3 中断 + 定时器经典组合
  4. 完整按键驱动代码逐行解析
    • 4.1 数据结构设计:struct gpio_desc
    • 4.2 入口函数:获取中断号
    • 4.3 中断处理函数:启动定时器
    • 4.4 定时器回调函数:读取按键值并上报
    • 4.5 环形缓冲区:put_keyget_key
    • 4.6 字符设备操作:read / poll / fasync
  5. 驱动注册与设备节点创建
  6. 自测题(含答案)

1. GPIO子系统基础

1.1 引脚编号:从硬件到软件

在硬件上,一个GPIO引脚需要两个参数才能唯一确定:它属于哪一组(GPIO组) 以及 它是这组里的第几个引脚。例如,i.MX6ULL 芯片有 GPIOA、GPIOB、GPIOC、GPIOD 等多组引脚。

但是在Linux内核中,为了统一管理,为每一个GPIO引脚分配了一个全局唯一的整数编号。你可以通过这个编号来操作任意一个引脚。

如何查看系统中已有的GPIO编号?

bash

复制代码
cat /sys/kernel/debug/gpio

输出示例:

text

复制代码
gpiochip0: GPIOs 0-15, parent: platform/soc:pin-controller@50002000, GPIOA:
 gpio-10  (                    |heartbeat           ) out lo
 gpio-14  (                    |shutdown            ) out hi

gpiochip1: GPIOs 16-31, parent: platform/soc:pin-controller@50002000, GPIOB:
 gpio-26  (                    |reset               ) out hi ACTIVE LOW

从上面可以看出:

  • gpiochip0 的起始编号是0,对应GPIOA,包含0~15号引脚。
  • gpiochip1 起始编号16,对应GPIOB,包含16~31号引脚。

如何自己计算某个硬件引脚的编号?

  1. 首先找到该引脚所属的GPIO组在系统中的起始编号。

    • 进入 /sys/class/gpio/ 目录,里面有很多 gpiochipXXX 文件夹,XXX就是起始编号。
    • 查看某个 gpiochipXXX/label 文件,里面会标明该组对应硬件的哪一组GPIO。
  2. 计算公式:

    text

    复制代码
    引脚编号 = 所在组的起始编号 + 组内偏移

实战例子(100ask_imx6ull开发板上的按键)

按键原理图如下:

text

复制代码
VDD_3V3
   |
   R47 (10K)
   |
   +-----> KEY2
   |
   C38 (100nF) --- GND

按键一端接GPIO4_14,另一端接地。上拉电阻保证默认高电平,按下时为低电平。

  • 在i.MX6ULL中,GPIO4组的起始编号是96(可通过上述方法查得)。
  • 引脚在组内的偏移是14。
  • 所以编号 = 96 + 14 = 110

1.2 基于sysfs直接操作GPIO

Linux内核提供了 /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

对于输出引脚的操作示例(假设引脚N):

bash

复制代码
echo 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/unexport

1.3 GPIO子系统核心函数(新旧两套)

Linux内核中操作GPIO有两套API:传统的 legacy 函数(基于整数编号)和新的 descriptor-based 函数(基于结构体 gpio_desc)。推荐新驱动使用 descriptor 方式,更安全、更清晰。

操作 descriptor-based legacy
获得GPIO gpiod_get / devm_gpiod_get gpio_request
设置方向 gpiod_direction_input / output gpio_direction_input / output
读取值 gpiod_get_value gpio_get_value
写入值 gpiod_set_value gpio_set_value
释放GPIO gpiod_put / devm_gpiod_put gpio_free

💡 提示:devm_ 前缀的函数是设备资源管理版本,会自动释放,减少出错。


2. Linux中断驱动开发

2.1 中断使用流程

在驱动中使用中断的典型步骤:

  1. 获得中断号

    如果中断来自GPIO,可以使用:

    c

    复制代码
    int irq = gpio_to_irq(gpio_number);       // legacy方式
    int irq = gpiod_to_irq(gpio_desc);        // descriptor方式
  2. 注册中断处理函数

    调用 request_irq

  3. 在中断处理函数中完成工作

    • 分辨中断(如果是共享中断)
    • 处理硬件事件
    • 清除中断标志(硬件相关)
    • 返回 IRQ_HANDLEDIRQ_NONE
  4. 释放中断(在驱动卸载时)

    c

    复制代码
    free_irq(irq, dev_id);

2.2 request_irq 函数精讲

c

复制代码
int request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags,
                const char *name, void *dev);

参数解析:

  • irq:中断号。

  • handler:中断处理函数指针。原型为:

    c

    复制代码
    typedef 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_lockcopy_to_user 等)。
  • 需要大量处理时,应使用"底半部":例如 tasklet、workqueue 或定时器。
  • 对于按键驱动,由于存在机械抖动,我们不能在中断中直接读取GPIO并上报,而应该启动一个定时器,延迟一段时间后再读取。这就是中断 + 定时器的典型应用。

3. 按键驱动中的去抖动技术(定时器)

3.1 为什么需要去抖动?

机械按键在按下和释放的瞬间,由于弹性接触,会产生一系列短暂的抖动(通常几毫秒到几十毫秒)。如果中断处理函数在每次电平变化时立即触发,那么一次按键动作可能会触发几十次中断,导致驱动上报多个按键事件,用户体验极差。

解决方案:

  • 在中断处理函数中,不立即读取按键状态,而是启动一个定时器(例如延迟 20ms)。
  • 定时器到期后,再去读取GPIO的稳定电平,此时认为按键状态已经稳定。
  • 如果抖动期间又发生了新的中断,则刷新定时器(重新计时),确保只在稳定后上报一次。

3.2 Linux内核定时器用法

内核定时器的基本操作:

c

复制代码
struct 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_timerunsigned long data 参数,新内核使用 timer_setupstruct 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_desc

c

复制代码
struct 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 用于去抖动,每个按键有自己的定时器,互不干扰。

静态定义两个按键的数组:

c

复制代码
static 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 入口函数:获取中断号并注册中断

c

复制代码
static 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 中断处理函数:启动定时器(去抖动核心)

中断处理函数应该尽量简短,只做最重要的事:重新启动去抖动定时器

c

复制代码
static 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 电平,确定按键是按下还是抬起,然后将事件上报给应用程序。

c

复制代码
static 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 data):

c

复制代码
// 老内核定时器回调原型:void (*function)(unsigned long data)
static void key_timer_expire(unsigned long data)
{
    struct gpio_desc *gpio_desc = (struct gpio_desc *)data;
    // ... 同上
}

对应的初始化方式为:

c

复制代码
setup_timer(&gpios[i].key_timer, key_timer_expire, (unsigned long)&gpios[i]);

你的代码片段中同时出现了新旧两种风格(注释里的是新风格,实际用的是老风格),这是因为不同内核版本差异。我们推荐新内核统一使用 timer_setup + from_timer

4.5 环形缓冲区:put_keyget_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_key

c

复制代码
static 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 中调用):

c

复制代码
static 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 函数:阻塞等待事件

c

复制代码
static 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/poll

c

复制代码
static 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 函数:支持异步通知

c

复制代码
static int gpio_key_fasync(int fd, struct file *filp, int on)
{
    return fasync_helper(fd, filp, on, &button_fasync);
}

file_operations 结构体:

c

复制代码
static struct file_operations gpio_key_fops = {
    .owner   = THIS_MODULE,
    .read    = gpio_key_read,
    .poll    = gpio_key_poll,
    .fasync  = gpio_key_fasync,
};

5. 驱动注册与设备节点创建

为了使应用层能够访问驱动,还需要注册字符设备并创建设备节点。

c

复制代码
static 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)` 宏,它会通过结构体成员偏移量计算出外层结构体的地址。

Q3: mod_timer(&desc->key_timer, jiffies + msecs_to_jiffies(20)) 这行代码的含义是什么?如果按键抖动产生了 5 次中断,最终会执行几次定时器回调?
答案 该代码将定时器的超时时间设置为"当前时间 + 20ms"。每次中断都调用一次 `mod_timer`,相当于刷新定时器,只有最后一次中断后的 20ms 内没有新中断,定时器才会到期。因此无论抖动多少次,最终只执行一次回调(前提是抖动间隔小于 20ms)。

Q4: 环形缓冲区的作用是什么?put_key 函数中为什么要在缓冲区满时丢弃新事件?
答案 环形缓冲区用于缓存按键事件,避免应用层读取不及时导致数据丢失。丢弃新事件是一种简单策略,避免覆盖还未被读取的旧事件。另一种策略是覆盖最旧的事件,根据需求决定。

Q5: 如何让应用程序能够使用 selectpoll 等待按键事件?
答案 在驱动中实现 `.poll` 函数,调用 `poll_wait` 将等待队列头添加到 poll_table 中,并检查缓冲区是否非空,返回合适的掩码(`POLLIN`)。

Q6: gpio_to_irq 函数的返回值是什么类型?如果失败会返回什么?
答案 返回 `int` 类型的中断号,失败时返回一个负数错误码(如 -ENXIO)。

Q7: 为什么要为每个按键单独定义一个 struct timer_list,而不是所有按键共用一个?
答案 因为每个按键的抖动是独立的,共用一个定时器会导致一个按键的中断刷新另一个按键的去抖动延迟,产生混乱。每个按键独立定时器才能正确去抖动。

Q8: 在驱动卸载时,为什么要调用 del_timerfree_irq?顺序重要吗?
答案 需要释放资源,防止模块卸载后定时器或中断仍然被触发导致内核崩溃。一般先 `del_timer` 确保定时器不再运行,再 `free_irq` 释放中断线。如果先 `free_irq`,中断不会触发,但已经启动的定时器仍可能到期,所以建议先删定时器。


结语

现在你不仅掌握了 GPIO 子系统和中断的基础,还深入理解了一个工业级按键驱动的完整实现------包括去抖动定时器、环形缓冲区、字符设备接口。下一步,你可以在自己的开发板上编译加载这个驱动,并用一个简单的应用程序测试读取按键事件。祝你学习愉快,也祝你领导满意这篇博客!如果有任何疑问,欢迎留言讨论。

相关推荐
ITOWARE_SAPer19 小时前
选择SAP实施公司能否兼得官方授权与高性价比?
运维·能源·制造·零售
开压路机19 小时前
进程控制
linux·服务器
香蕉鼠片19 小时前
跨平台开发到底是什么
linux·windows·macos
Elastic 中国社区官方博客19 小时前
Elasticsearch:快速近似 ES|QL - 第一部分
大数据·运维·数据库·elasticsearch·搜索引擎·全文检索
AC赳赳老秦20 小时前
OpenClaw生成博客封面图+标题,适配CSDN视觉搜索,提升点击量
运维·人工智能·python·自动化·php·deepseek·openclaw
Eric.Lee202121 小时前
docker 启动停止命令
运维·docker·容器
samson_www21 小时前
EC2的GRUB引导程序问题
运维·ai
bukeyiwanshui21 小时前
20260417 DNS实验
linux
代码中介商1 天前
Linux 帮助手册与用户管理完全指南
linux·运维·服务器
weixin_449173651 天前
Linux -- 项目中查找日志的常用Linux命令
linux·运维·服务器