3、GPIO驱动封装(下):中断配置、回调函数注册、多实例支持的实现技巧

上一讲我们把GPIO的基本读写封装好了。但说真的,做嵌入式产品,光会读写IO口,那只是入门水平。真正让GPIO“活”起来的,是中断。我见过不少工程师,中断回调写得一团糟,最后整个系统跑飞了都不知道怎么查。

这一讲,咱们就聊聊GPIO中断的封装、回调函数的注册机制,还有怎么让驱动支持多个实例。嗯,这些都是实战中绕不开的硬骨头。

3.1 中断配置的封装思路

先看一个典型场景:你有一个按键,按下时触发中断。不同芯片的GPIO中断寄存器长得完全不一样。STM32有EXTI,NXP有PORT中断,ESP32有GPIO中断矩阵。但本质上,它们都做同一件事——检测引脚电平变化,然后跳到一个函数去执行。

我的做法是:把中断配置抽象成三个步骤。

  1. 选择触发方式:上升沿、下降沿、双边沿、高电平、低电平。
  2. 使能中断通道:把GPIO的中断信号连接到NVIC(或等效的中断控制器)。
  3. 注册中断服务函数:告诉芯片,中断来了该跑哪段代码。

下面是我常用的枚举定义:

typedef enum {
    GPIO_INT_TRIGGER_RISING,    // 上升沿触发
    GPIO_INT_TRIGGER_FALLING,   // 下降沿触发
    GPIO_INT_TRIGGER_BOTH,      // 双边沿触发
    GPIO_INT_TRIGGER_LOW,       // 低电平触发
    GPIO_INT_TRIGGER_HIGH       // 高电平触发
} gpio_int_trigger_t;

你可能会问:“电平触发和边沿触发到底怎么选?”
我个人的经验是:按键、编码器这类信号,用边沿触发;外部传感器输出持续电平的,用电平触发。电平触发有个坑——如果中断服务函数执行时间太长,会反复触发,导致系统卡死。我曾经在一个温控项目里吃过这个亏,后来老老实实改成了边沿触发加软件消抖。

3.2 回调函数注册机制

中断服务函数(ISR)不能写死。为什么?因为你的驱动库可能被用在不同的项目里,每个项目对中断的处理逻辑都不一样。比如按键中断,有的项目要计数,有的要唤醒系统,有的要切换LED状态。

所以,我们需要一个回调函数注册机制。

说白了,就是留一个函数指针的“插槽”,让上层应用把它的处理函数挂进来。

typedef void (*gpio_irq_handler_t)(void *arg);

typedef struct {
    gpio_port_t         port;
    gpio_pin_t          pin;
    gpio_int_trigger_t  trigger;
    gpio_irq_handler_t  handler;
    void               *arg;
} gpio_irq_cfg_t;

注册函数长这样:

gpio_error_t gpio_irq_register(gpio_irq_cfg_t *cfg) {
    if (cfg == NULL || cfg->handler == NULL) {
        return GPIO_ERR_INVALID_PARAM;
    }
    // 把配置写入内部表格
    irq_table[cfg->port][cfg->pin] = *cfg;
    // 配置硬件中断寄存器
    hal_gpio_int_config(cfg->port, cfg->pin, cfg->trigger);
    // 使能中断
    hal_gpio_int_enable(cfg->port, cfg->pin);
    return GPIO_OK;
}
小技巧:回调函数的参数 void *arg 非常有用。你可以把结构体指针、设备句柄甚至一个计数值传进去。这样同一个回调函数可以处理多个不同的中断源,只要通过arg区分就行。

那中断来了以后,ISR里怎么调用回调?

void GPIOA_IRQHandler(void) {
    // 读取中断状态寄存器,判断是哪个引脚
    uint32_t pending = READ_REG(GPIOA->ISR);
    for (int i = 0; i < 16; i++) {
        if (pending & (1 << i)) {
            // 查表,找到对应的回调
            gpio_irq_cfg_t *cfg = &irq_table[GPIO_PORTA][i];
            if (cfg->handler) {
                cfg->handler(cfg->arg);
            }
            // 清除中断标志
            WRITE_REG(GPIOA->ICR, (1 << i));
        }
    }
}

注意看,这里有个关键点:中断标志必须在回调执行完之后再清除。如果先清除再调用回调,万一回调执行期间同一个中断又来了,就会丢失。我踩过这个坑,排查了整整两天。

3.3 多实例支持的实现技巧

什么叫多实例?就是同一个驱动,同时控制多个GPIO引脚,每个引脚有自己的配置、自己的回调、自己的状态。你不能把所有引脚的中断处理都写在一个全局函数里,那样代码根本没法维护。

我的做法是:用表格管理所有实例。

字段 说明 示例值
port GPIO端口号 GPIO_PORTA
pin 引脚号 5
trigger 触发方式 GPIO_INT_TRIGGER_FALLING
handler 回调函数指针 button_press_handler
arg 回调参数 &button1_state
enabled 是否使能 true

这个表格的大小取决于芯片的GPIO总数。比如STM32F103有112个引脚,那就定义一个112项的数组。每个引脚一个槽位,没用的引脚留空。

#define GPIO_MAX_PINS   112
static gpio_irq_cfg_t irq_table[GPIO_MAX_PINS];

注册时,通过端口和引脚号计算出索引:

static int irq_index(gpio_port_t port, gpio_pin_t pin) {
    return port * 16 + pin;  // 假设每个端口16个引脚
}

这样,不管你有多少个GPIO中断,驱动层都能统一管理。上层应用只需要关心它自己的那一个引脚,完全不用管其他引脚的中断是怎么配置的。

注意:多实例表格必须用静态数组,不能动态分配。嵌入式系统里malloc是禁忌,你不知道堆什么时候会碎片化。我见过有人用链表管理中断回调,结果中断里调了malloc,系统直接死机。

3.4 整体架构图

下面这张图展示了GPIO中断驱动的整体调用关系。你可以看到,上层应用通过回调函数与驱动层解耦,驱动层通过表格管理所有实例,硬件抽象层负责真正的寄存器操作。

GPIO中断驱动架构 上层应用 按键处理 / 传感器响应 / 唤醒逻辑 注册回调 GPIO驱动层(中断管理) irq_table[ ] 多实例表格 gpio_irq_register / gpio_irq_unregister 调用HAL接口 硬件抽象层(HAL) hal_gpio_int_config / hal_gpio_int_enable 操作寄存器 硬件寄存器 EXTI / PORT ISR / GPIO 中断矩阵 关键设计点 • 表格索引 = port*16+pin • 回调带void*arg • 先执行回调 • 再清中断标志 • 静态数组管理 • 不支持动态分配

3.5 避坑指南与实战建议

讲到这里,我把这些年踩过的坑总结一下:

  • 中断里别做复杂运算。回调函数里尽量只做标志位设置或数据拷贝,耗时操作放到主循环里处理。我曾经在中断里做浮点运算,结果系统响应延迟了200微秒,整个控制环路都崩了。
  • 注意中断优先级。GPIO中断的优先级不要设得太高,否则会抢占系统滴答定时器,导致延时函数不准。我一般把GPIO中断设为中等优先级。
  • 多实例表格要加保护。如果在中断里修改了表格(比如动态注册/注销),主循环也在读表格,就会出现竞态。最简单的办法:注册操作关中断,注销操作也关中断。
  • 回调函数不能阻塞。如果回调里调用了延时函数或者等待信号量,整个中断系统就卡住了。记住,中断服务函数是“借来的时间”,越快还回去越好。
核心总结:GPIO中断封装的核心就三件事——配置触发方式、注册回调函数、用表格管理多实例。回调函数用函数指针加void*参数实现通用性,多实例用静态数组加索引映射实现确定性。做到这三点,你的GPIO驱动就能在各种项目里复用,而且不出幺蛾子。