4、GPIO底层抽象(三):中断与回调机制封装

前面两章我们把GPIO的输入输出、上下拉这些基础操作封装好了。但说实话,真正让GPIO“活”起来的,是中断。

你想想看,一个嵌入式系统里,CPU不可能一直轮询某个引脚的电平变化。那样太浪费了。中断才是王道——引脚电平一变,CPU立刻响应,这才是实时系统的玩法。

这一章,我们就来搞定GPIO中断的底层抽象。核心目标就两个:统一中断配置接口,以及用函数指针实现回调机制。说白了,就是让上层应用代码完全不用关心你是哪家MCU、中断怎么配的,只管注册一个函数,等中断来了自动调用。

4.1 中断配置的痛点

我在项目里见过太多这样的代码了:

// 某项目里的中断初始化
NVIC_InitTypeDef NVIC_InitStructure;
EXTI_InitTypeDef EXTI_InitStructure;

EXTI_InitStructure.EXTI_Line = EXTI_Line0;
EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt;
EXTI_InitStructure.EXTI_Trigger = EXTI_Trigger_Rising;
EXTI_InitStructure.EXTI_LineCmd = ENABLE;
EXTI_Init(&EXTI_InitStructure);

NVIC_InitStructure.NVIC_IRQChannel = EXTI0_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0x01;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0x01;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);

这段代码有什么问题?太耦合了。换一颗芯片,全部重写。而且中断处理函数里往往直接写业务逻辑,想复用?门都没有。

我们需要一个统一的抽象层。不管底层是STM32、GD32还是NXP,上层看到的接口应该是一样的。

4.2 统一中断配置接口

我个人习惯的做法是,先定义一个枚举,把中断触发方式抽象出来:

typedef enum {
    GPIO_INT_TRIGGER_NONE      = 0,
    GPIO_INT_TRIGGER_RISING    = 1,   // 上升沿触发
    GPIO_INT_TRIGGER_FALLING   = 2,   // 下降沿触发
    GPIO_INT_TRIGGER_BOTH      = 3,   // 双边沿触发
    GPIO_INT_TRIGGER_LOW_LEVEL = 4,   // 低电平触发
    GPIO_INT_TRIGGER_HIGH_LEVEL= 5    // 高电平触发
} gpio_int_trigger_t;

然后定义一个统一的中断配置结构体:

typedef struct {
    uint8_t             port;       // GPIO端口号
    uint8_t             pin;        // GPIO引脚号
    gpio_int_trigger_t  trigger;    // 触发方式
    uint8_t             priority;   // 中断优先级(0-15, 0最高)
    void               *callback;   // 回调函数指针
} gpio_int_cfg_t;

注意这里的callback字段。我故意用了void *,后面再解释为什么。

有了这个结构体,配置接口就很简单了:

/**
 * @brief  配置GPIO中断
 * @param  cfg: 中断配置结构体指针
 * @return 0: 成功, -1: 参数错误
 */
int gpio_int_config(const gpio_int_cfg_t *cfg);

底层实现里,这个函数会根据cfg->port和cfg->pin,自动映射到具体的MCU中断线、NVIC通道。上层完全不用管这些细节。

核心思想:抽象层只暴露“配置什么引脚、什么触发方式、优先级多少”,不暴露“EXTI线几、NVIC通道几”。

4.3 函数指针与回调注册机制

好,配置搞定了。但中断来了之后,怎么执行用户代码?

最原始的做法是在中断服务函数里写switch-case:

void EXTI0_IRQHandler(void) {
    if(EXTI_GetITStatus(EXTI_Line0) != RESET) {
        switch(g_current_mode) {
            case MODE_BUTTON_SCAN:
                button_scan_handler();
                break;
            case MODE_KEY_DETECT:
                key_detect_handler();
                break;
            // ... 越来越长
        }
        EXTI_ClearITPendingBit(EXTI_Line0);
    }
}

这种代码我见过太多了。每次加一个新功能,就要改中断处理函数。耦合度极高,而且容易改出bug。

正确的做法是用函数指针注册回调。

我们定义一个回调表:

// 回调函数类型定义
typedef void (*gpio_irq_handler_t)(void *arg);

// 回调注册表
typedef struct {
    gpio_irq_handler_t  handler;   // 回调函数指针
    void               *arg;       // 回调参数
} gpio_callback_t;

// 每个引脚一个回调槽位
static gpio_callback_t s_callback_table[GPIO_PIN_MAX];

然后提供注册接口:

/**
 * @brief  注册GPIO中断回调函数
 * @param  pin:     引脚编号
 * @param  handler: 回调函数指针
 * @param  arg:     回调参数(可为NULL)
 * @return 0: 成功, -1: 参数错误
 */
int gpio_int_register(uint8_t pin, gpio_irq_handler_t handler, void *arg)
{
    if (pin >= GPIO_PIN_MAX || handler == NULL) {
        return -1;
    }
    s_callback_table[pin].handler = handler;
    s_callback_table[pin].arg     = arg;
    return 0;
}

底层中断服务函数里,统一这样写:

// 这是底层中断服务函数,用户不需要动
void gpio_irq_dispatcher(uint8_t pin)
{
    if (s_callback_table[pin].handler != NULL) {
        s_callback_table[pin].handler(s_callback_table[pin].arg);
    }
    // 清除中断标志由底层自动处理
}

用户代码只需要:

// 用户定义的回调函数
void my_button_handler(void *arg)
{
    // 处理按键逻辑
    uint8_t *pressed = (uint8_t *)arg;
    if (*pressed) {
        // 按键按下后的操作
    }
}

// 注册回调
gpio_int_register(GPIO_PIN_0, my_button_handler, &some_flag);

// 配置中断
gpio_int_cfg_t cfg = {
    .port     = GPIO_PORT_A,
    .pin      = GPIO_PIN_0,
    .trigger  = GPIO_INT_TRIGGER_RISING,
    .priority = 5,
    .callback = NULL   // 这里不用,我们用注册表
};
gpio_int_config(&cfg);

小技巧:回调函数的arg参数可以传任意类型指针。我经常用它传一个结构体,里面放多个状态变量。这样回调函数就能拿到完整的上下文信息。

4.4 松耦合设计的关键点

做到这一步,我们已经实现了基本的松耦合。但有几个细节要注意:

设计要点 说明 我曾经踩过的坑
回调表大小 每个引脚一个槽位,用引脚号直接索引 曾经用链表管理,中断里遍历链表,性能惨不忍睹
中断优先级分组 统一在初始化时设置,不让用户乱改 某次用户改了分组,导致中断嵌套死锁
临界区保护 注册回调时关中断,防止注册一半中断来了 没加保护,结果回调指针被撕成两半
回调执行时间 回调里不要做耗时操作,否则影响实时性 有人在回调里打印日志,系统直接卡死

警告:回调函数是在中断上下文执行的。不要在回调里调用printf、malloc、delay等阻塞或不可重入的函数。如果需要处理复杂逻辑,建议在回调里只设置一个标志位,然后在主循环里处理。

4.5 整体架构图

说了这么多,我们来看看整个GPIO中断抽象层的架构:

GPIO中断抽象层架构图 用户应用层 回调注册接口 gpio_int_register(pin, handler, arg) 中断配置接口 gpio_int_config(&cfg) 中断分发器 gpio_irq_dispatcher(pin) MCU硬件抽象层 EXTI / NVIC / GPIO 寄存器操作 用户只需关心 1. 注册回调函数 2. 配置触发方式 3. 设置优先级 底层自动处理 1. 中断线映射 2. NVIC配置 3. 标志位清除

从这张图可以看得很清楚:用户只需要跟绿色部分打交道——注册回调、配置中断。紫色和红色部分由底层封装好,用户完全不用碰。

4.6 完整代码示例

最后,给出一份完整的头文件接口声明,方便你直接参考使用:

#ifndef _GPIO_INT_H_
#define _GPIO_INT_H_

#include <stdint.h>

/* 中断触发方式枚举 */
typedef enum {
    GPIO_INT_TRIGGER_NONE       = 0,
    GPIO_INT_TRIGGER_RISING     = 1,
    GPIO_INT_TRIGGER_FALLING    = 2,
    GPIO_INT_TRIGGER_BOTH       = 3,
    GPIO_INT_TRIGGER_LOW_LEVEL  = 4,
    GPIO_INT_TRIGGER_HIGH_LEVEL = 5
} gpio_int_trigger_t;

/* 回调函数类型 */
typedef void (*gpio_irq_handler_t)(void *arg);

/* 中断配置结构体 */
typedef struct {
    uint8_t             port;
    uint8_t             pin;
    gpio_int_trigger_t  trigger;
    uint8_t             priority;
} gpio_int_cfg_t;

/* 初始化中断模块(设置优先级分组等) */
void gpio_int_init(void);

/* 配置GPIO中断 */
int  gpio_int_config(const gpio_int_cfg_t *cfg);

/* 注册回调函数 */
int  gpio_int_register(uint8_t pin, gpio_irq_handler_t handler, void *arg);

/* 注销回调函数 */
int  gpio_int_unregister(uint8_t pin);

/* 全局使能/禁止中断 */
void gpio_int_global_enable(void);
void gpio_int_global_disable(void);

#endif /* _GPIO_INT_H_ */

总结一下:这一章的核心就三件事——统一配置接口、函数指针回调、松耦合设计。做到这三点,你的GPIO中断代码就能轻松跨平台复用。下一层,我们会把GPIO的调试接口也封装进来,让开发阶段能看到每个引脚的状态变化。嗯,那个也很有用。