4. GPIO驱动封装(下):中断回调机制设计,外部中断统一管理
上一节我们把GPIO的基本读写封装好了。但真正让嵌入式系统“活”起来的,是中断。你想想看,CPU总不能一直轮询按键吧?那太浪费了。中断机制让外设主动“喊”CPU来处理,这才是高效的做法。
这一节,我们来聊聊GPIO中断的封装。重点有两个:一是怎么设计一套干净的回调机制,二是如何统一管理多个外部中断源。我在项目里见过太多“中断回调满天飞”的代码,维护起来简直噩梦。今天我们就把这个坑填上。
4.1 中断回调的核心思想
回调函数,说白了就是一个函数指针。你把一个函数“注册”到驱动层,当中断触发时,驱动层自动调用它。这样业务代码和硬件驱动就解耦了。
我个人习惯把回调设计成“注册-触发”模型:
- 注册阶段:用户调用
GPIO_RegisterCallback,传入引脚号和回调函数指针。 - 触发阶段:ISR(中断服务函数)里查表,找到对应的回调,直接执行。
核心原则:ISR里只做“标记”或“快速处理”,绝对不要做耗时操作。回调函数本身也要短小精悍。
4.2 数据结构设计:回调表
我们需要一张表来管理所有引脚的回调。这张表就是驱动层和业务层的“契约”。
/* 回调函数类型定义 */
typedef void (*GPIO_Callback_t)(void* param);
/* 回调表项 */
typedef struct {
uint8_t pin; /* 引脚号 */
GPIO_Callback_t callback; /* 回调函数指针 */
void* param; /* 用户参数 */
uint8_t enabled; /* 使能标志 */
} GPIO_CallbackItem_t;
/* 最大回调数量,根据芯片引脚数调整 */
#define GPIO_CALLBACK_MAX 16
/* 全局回调表 */
static GPIO_CallbackItem_t g_callbackTable[GPIO_CALLBACK_MAX];
static uint8_t g_callbackCount = 0;
这里我用了静态数组,而不是链表。为什么?因为嵌入式环境里,动态分配内存是禁忌。静态数组简单可靠,查找速度快。我在一个RTOS项目里曾经试过链表,结果中断上下文里malloc直接死机……从那以后我再也不敢在ISR里动态分配了。
4.3 注册与注销接口
接口设计要直观。用户只需要关心“哪个引脚”、“哪个函数”、“什么参数”。
/**
* @brief 注册GPIO中断回调
* @param pin 引脚号
* @param callback 回调函数指针
* @param param 用户参数(可为NULL)
* @return 0成功,-1失败(表满或参数无效)
*/
int GPIO_RegisterCallback(uint8_t pin, GPIO_Callback_t callback, void* param)
{
if (callback == NULL) return -1;
if (g_callbackCount >= GPIO_CALLBACK_MAX) return -1;
/* 检查是否已注册 */
for (int i = 0; i < g_callbackCount; i++) {
if (g_callbackTable[i].pin == pin) {
/* 已存在则更新 */
g_callbackTable[i].callback = callback;
g_callbackTable[i].param = param;
g_callbackTable[i].enabled = 1;
return 0;
}
}
/* 新增表项 */
g_callbackTable[g_callbackCount].pin = pin;
g_callbackTable[g_callbackCount].callback = callback;
g_callbackTable[g_callbackCount].param = param;
g_callbackTable[g_callbackCount].enabled = 1;
g_callbackCount++;
return 0;
}
/**
* @brief 注销GPIO中断回调
* @param pin 引脚号
* @return 0成功,-1失败(未找到)
*/
int GPIO_UnregisterCallback(uint8_t pin)
{
for (int i = 0; i < g_callbackCount; i++) {
if (g_callbackTable[i].pin == pin) {
/* 用最后一个元素覆盖,避免频繁移动 */
g_callbackTable[i] = g_callbackTable[g_callbackCount - 1];
g_callbackCount--;
return 0;
}
}
return -1;
}
小技巧:注销时我用“覆盖法”而不是“移位法”。因为回调表是无序的,覆盖最后一个元素效率最高。如果你需要保持顺序,那就得移位,但大多数场景不需要。
4.4 中断统一分发器
这是整个机制的核心。所有GPIO中断都进这一个函数,由它查表分发。
/**
* @brief GPIO中断统一分发器
* @param pin 触发中断的引脚号
*/
void GPIO_IRQ_Dispatcher(uint8_t pin)
{
for (int i = 0; i < g_callbackCount; i++) {
if (g_callbackTable[i].pin == pin && g_callbackTable[i].enabled) {
/* 执行回调 */
if (g_callbackTable[i].callback) {
g_callbackTable[i].callback(g_callbackTable[i].param);
}
break;
}
}
}
这个分发器放在芯片的HAL层中断函数里调用。比如STM32的 HAL_GPIO_EXTI_Callback 里,直接调 GPIO_IRQ_Dispatcher(pin) 就行。
注意:分发器里不要加延时、打印、或者任何阻塞操作。中断上下文里时间就是生命。我曾经见过有人在回调里加了个printf,结果波特率低的时候直接导致中断嵌套溢出……血的教训。
4.5 外部中断统一管理流程
把整个流程串起来,就是下面这张图。我建议你仔细看看,理解每个环节的职责。
4.6 中断优先级与嵌套处理
统一管理不只是查表分发,还要考虑优先级。不同芯片的NVIC(嵌套向量中断控制器)配置不同,但原则是一样的:
| 场景 | 推荐优先级 | 说明 |
|---|---|---|
| 按键/触摸 | 低(如15) | 允许被高优先级中断打断,不影响实时性 |
| 传感器触发 | 中(如7) | 需要及时响应,但可容忍少量延迟 |
| 唤醒/紧急事件 | 高(如0) | 必须立即处理,不可被其他中断阻塞 |
我个人习惯在驱动层提供一个 GPIO_SetIRQPriority 接口,让用户配置。但注意,优先级分组(如STM32的4位抢占优先级)必须在系统初始化时统一设置,不要在驱动层里改。
4.7 实战避坑指南
嗯,这里我要多说几句。中断回调看起来简单,但坑不少。我列几个常见的:
- 回调里调用阻塞函数:比如
delay_ms、printf、malloc。这些在中断里都是定时炸弹。 - 回调表被中断嵌套修改:如果在ISR里注册/注销回调,而主循环也在操作同一张表,就会出问题。解决方案是加临界区保护,或者用双缓冲。
- 忘记清除中断标志:有些芯片需要手动清除挂起位。否则会一直触发,系统直接卡死。
- 回调函数指针为NULL:注册时一定要检查,分发时也要判空。我曾经漏了一次,结果某个引脚没注册回调,一触发就跑飞了……
避坑:如果你在中断里调用了回调,而回调里又操作了同一个外设(比如GPIO),就可能产生重入问题。建议回调里只做“标记+传参”,具体处理放到主循环或任务里。
4.8 完整使用示例
最后,我们看一个完整的例子。假设按键PA0触发中断,按下时点亮LED。
/* 按键中断回调 */
void Key_Handler(void* param)
{
/* 注意:这里只做标记,不直接操作LED */
uint8_t* flag = (uint8_t*)param;
*flag = 1;
}
int main(void)
{
uint8_t keyPressed = 0;
/* 初始化硬件 */
HAL_Init();
GPIO_Init();
/* 注册回调 */
GPIO_RegisterCallback(0, Key_Handler, &keyPressed);
/* 使能中断(具体配置依赖芯片) */
HAL_NVIC_EnableIRQ(EXTI0_IRQn);
while (1)
{
if (keyPressed)
{
keyPressed = 0;
GPIO_WritePin(LED_PIN, 1); /* 点亮LED */
/* 注意:这里可以加消抖延时,但不要在中断里做 */
}
/* 其他任务... */
}
}
这个模式叫“中断标记+主循环处理”。它把实时性要求高的部分(响应中断)和耗时操作(点亮LED)分开了。我在多个产品里都用这个模式,稳定可靠。