3. GPIO驱动封装(进阶篇):添加中断回调注册机制,实现按键消抖与外部中断统一管理

上一章我们把GPIO的基本读写封装好了。但说实话,做嵌入式产品,光会点灯读按键远远不够。真正让项目“活”起来的,是中断。

我记得刚入行那会儿,在一个工业控制面板项目里,用轮询方式检测按键。结果呢?CPU大部分时间都在“傻等”按键按下,其他任务全被拖慢。后来改成中断驱动,整个系统才顺畅起来。今天我们就来聊聊,怎么把GPIO中断也封装得漂亮、可复用。

3.1 为什么需要中断回调注册机制?

先问一个问题:你手头有10个按键,每个按键按下都要执行不同的动作。你会怎么写?

很多新手会在中断服务函数里写一长串switch-case:

void EXTI0_IRQHandler(void)
{
    if(按键A按下) 执行动作A;
    else if(按键B按下) 执行动作B;
    // ... 越来越长
}

这种写法,说白了就是“硬编码”。换一个项目,所有代码都得重写。我在项目中吃过这个亏——客户中途改了3次按键功能,每次都要改中断函数,改完还得全面回归测试,痛苦不堪。

更好的做法是什么?注册回调函数。把“中断来了做什么”这件事,从中断处理函数里剥离出来,变成一个可配置的函数指针。这样,中断处理函数只负责“通知”,具体“干活”的逻辑交给上层应用去注册。

核心思想:中断处理函数 = 事件分发器,回调函数 = 具体业务逻辑。两者解耦,代码才能复用。

3.2 回调注册机制的设计

我们先设计一个简单的回调注册结构。我个人习惯用结构体把中断相关的配置打包在一起:

typedef struct {
    uint16_t            pin;            // 引脚编号
    GPIO_PinState       trigger_edge;   // 触发边沿
    uint32_t            debounce_ms;    // 消抖时间(ms)
    void (*callback)(void* arg);        // 回调函数指针
    void*               arg;            // 回调参数
} GPIO_InterruptConfig_t;

这里有个细节:callback 参数用了 void*。为什么?因为不同按键按下后,需要传递的数据类型可能完全不同——有的只需要一个状态值,有的需要传递一个结构体指针。用 void* 可以兼容所有情况。

然后我们维护一个全局的配置表:

#define MAX_INTERRUPT_CALLBACKS   16

static GPIO_InterruptConfig_t s_interruptTable[MAX_INTERRUPT_CALLBACKS];
static uint8_t s_callbackCount = 0;

注册接口很简单:

int GPIO_RegisterInterruptCallback(GPIO_InterruptConfig_t* config)
{
    if (s_callbackCount >= MAX_INTERRUPT_CALLBACKS) {
        return -1;  // 表满了
    }
    s_interruptTable[s_callbackCount++] = *config;
    return 0;
}
小提示:实际项目中,我建议把 s_interruptTable 放在一个独立的“中断管理模块”里,不要和GPIO基本驱动混在一起。职责单一,后期维护才轻松。

3.3 按键消抖:硬件还是软件?

说到按键,就绕不开消抖。机械按键按下和释放的瞬间,会产生电平抖动,持续时间通常5~20ms。如果不处理,一次按下可能触发多次中断。

硬件消抖(RC滤波+施密特触发器)当然好,但会增加BOM成本。我大部分项目都选择软件消抖,效果完全够用。

软件消抖的核心思路就一句话:检测到边沿后,不立即响应,等一段时间再确认电平状态。

在中断回调框架下,消抖逻辑可以放在回调函数里,也可以放在中断处理函数里。我建议放在回调函数里——这样中断处理函数保持轻量,消抖策略可以灵活替换。

一个典型的消抖回调实现:

void KeyDebounceCallback(void* arg)
{
    uint16_t pin = (uint16_t)(uint32_t)arg;
    
    // 延时消抖(注意:不能在中断里用HAL_Delay!)
    // 这里假设我们在任务上下文中调用
    DelayMs(20);
    
    // 再次读取电平
    if (GPIO_ReadPin(GPIOA, pin) == GPIO_PIN_RESET) {
        // 确认按键按下,执行实际逻辑
        ExecuteKeyAction(pin);
    }
}
警告:千万不要在中断服务函数里直接调用 DelayMs() 或任何阻塞延时函数!中断服务要“快进快出”。正确的做法是:中断里只设置一个标志位或发送一个事件,消抖和实际处理放在主循环或任务中执行。

3.4 外部中断统一管理

STM32的外部中断线(EXTI)是有限的。以STM32F103为例,只有16条EXTI线(EXTI0~EXTI15),但GPIO引脚可能有几十个。这意味着多个引脚可能共享同一条中断线。

怎么管理?我画了一张图来说明:

GPIO外部中断统一管理架构 硬件层:STM32 EXTI 控制器 EXTI0 ~ EXTI15 共16条中断线,每条线可配置触发边沿 驱动封装层:中断回调注册表 s_interruptTable[] 数组,存储 pin、trigger_edge、callback 等信息 中断处理函数遍历注册表,匹配到对应pin后调用回调 应用层:用户注册的回调函数 按键A:KeyA_Callback 带20ms消抖 按键B:KeyB_Callback 带30ms消抖 传感器:Sensor_Callback 上升沿触发

你看,整个架构分三层:

  • 硬件层:STM32的EXTI控制器,负责检测电平变化并产生中断。
  • 驱动封装层:我们的回调注册表,负责把硬件中断映射到用户回调函数。
  • 应用层:用户写的具体业务逻辑,每个按键或传感器都有自己的回调。

这样做的好处很明显:硬件变了?只改驱动封装层。业务逻辑变了?只改应用层。互不干扰。

3.5 中断处理函数的分发实现

有了注册表,中断处理函数就变成了一个“分发器”。以EXTI0为例:

void EXTI0_IRQHandler(void)
{
    // 检查中断标志位
    if (EXTI_GetITStatus(EXTI_Line0) != RESET) {
        // 遍历注册表,找到对应pin的回调
        for (uint8_t i = 0; i < s_callbackCount; i++) {
            if (s_interruptTable[i].pin == GPIO_PIN_0) {
                // 这里不直接执行回调,而是设置标志位
                s_interruptTable[i].pending = 1;
                break;
            }
        }
        // 清除中断标志
        EXTI_ClearITPendingBit(EXTI_Line0);
    }
}

等等,为什么这里不直接调用回调函数?

原因有两个:

  1. 中断上下文限制:回调函数里可能包含延时、打印、甚至I2C操作,这些在中断里做很危险。
  2. 消抖需要时间:消抖延时不能在中断里做。

所以正确的做法是:中断里只设置一个 pending 标志,然后在主循环或RTOS任务中轮询这个标志,发现置位后再执行回调(包含消抖逻辑)。

我的经验:在裸机系统中,我通常会在主循环的“空闲任务”里处理这些pending回调。如果系统有RTOS,那就创建一个专门的事件处理任务,优先级设为中低,避免影响实时性要求高的任务。

3.6 完整的使用示例

最后,我们看看应用层怎么用这套机制。假设板子上有两个按键:KEY1(PA0)和KEY2(PA1)。

// 按键1的回调函数
void Key1_Handler(void* arg)
{
    // 消抖处理
    DelayMs(20);
    if (GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) {
        printf("KEY1 pressed!\r\n");
        // 执行具体动作...
    }
}

// 按键2的回调函数
void Key2_Handler(void* arg)
{
    DelayMs(20);
    if (GPIO_ReadPin(GPIOA, GPIO_PIN_1) == GPIO_PIN_RESET) {
        printf("KEY2 pressed!\r\n");
        // 执行具体动作...
    }
}

// 初始化
void App_Init(void)
{
    GPIO_InterruptConfig_t cfg;
    
    // 注册KEY1
    cfg.pin = GPIO_PIN_0;
    cfg.trigger_edge = GPIO_MODE_IT_FALLING;
    cfg.debounce_ms = 20;
    cfg.callback = Key1_Handler;
    cfg.arg = NULL;
    GPIO_RegisterInterruptCallback(&cfg);
    
    // 注册KEY2
    cfg.pin = GPIO_PIN_1;
    cfg.trigger_edge = GPIO_MODE_IT_FALLING;
    cfg.debounce_ms = 20;
    cfg.callback = Key2_Handler;
    cfg.arg = NULL;
    GPIO_RegisterInterruptCallback(&cfg);
    
    // 配置NVIC,使能中断
    HAL_NVIC_SetPriority(EXTI0_IRQn, 2, 0);
    HAL_NVIC_EnableIRQ(EXTI0_IRQn);
    HAL_NVIC_SetPriority(EXTI1_IRQn, 2, 0);
    HAL_NVIC_EnableIRQ(EXTI1_IRQn);
}

// 主循环
void main(void)
{
    App_Init();
    
    while (1) {
        // 轮询pending标志,执行回调
        GPIO_ProcessPendingCallbacks();
        
        // 其他任务...
    }
}

你看,应用层代码非常干净。想增加一个新按键?写一个回调函数,注册一下就行。中断处理函数、消抖逻辑、硬件配置全都被封装好了。

总结一下:中断回调注册机制 + 软件消抖 + 统一管理表,这三者组合起来,就能让GPIO中断驱动变得灵活、可移植、易维护。我在三个不同平台(STM32F1、F4、G0)上复用同一套代码,只改了底层寄存器操作部分,上层逻辑一行没动。

嗯,这一章的内容就到这里。记住:好的驱动封装,不是功能有多强,而是别人用起来有多顺手。