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中断抽象层的架构:
从这张图可以看得很清楚:用户只需要跟绿色部分打交道——注册回调、配置中断。紫色和红色部分由底层封装好,用户完全不用碰。
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的调试接口也封装进来,让开发阶段能看到每个引脚的状态变化。嗯,那个也很有用。