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 外部中断统一管理流程

把整个流程串起来,就是下面这张图。我建议你仔细看看,理解每个环节的职责。

GPIO中断统一管理流程图 硬件外设 芯片HAL中断函数 GPIO_IRQ_Dispatcher 回调表 Pin 0 → Callback_A Pin 1 → Callback_B Pin 3 → Callback_C ... 按键处理函数 传感器触发函数 唤醒处理函数 说明: 硬件中断 → HAL层统一入口 → 分发器查表 → 执行用户回调 整个链路清晰,业务层只需注册回调,无需关心中断细节。

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)分开了。我在多个产品里都用这个模式,稳定可靠。