3. GPIO驱动分层设计(下):按键输入与消抖处理、中断服务函数分层(ISR与Callback分离)、实战:按键控制LED亮灭

上一讲我们把GPIO输出层的架子搭好了,LED能听话地亮灭。但嵌入式系统不能只会输出,还得会“听”——按键输入就是最典型的输入场景。

这一讲我们重点解决三个问题:按键怎么读才准?中断服务函数怎么写才不乱?怎么把输入和输出串成一个完整的功能?

我个人习惯把按键驱动也拆成两层:硬件抽象层(HAL) 负责读引脚电平、配置中断;应用层 负责消抖、判断长按短按、触发回调。这样换一个平台,改的只是HAL层那几行代码。

3.1 按键输入的基础:电平读取与硬件连接

先看硬件。大多数开发板上的按键电路长这样:

  • 一端接GND,另一端通过10kΩ上拉电阻接VCC,再接到GPIO引脚
  • 按下时引脚读到低电平(0),松开时读到高电平(1)
  • 也有反接的——按下读高电平,取决于电路设计

嗯,这里要注意:按键引脚必须配置为输入模式,并且使能内部上拉或下拉。如果外部已经有上拉电阻,内部上拉可以关掉,但别悬空——悬空引脚在电磁干扰下会乱跳,我在项目中遇到过因为省一个上拉电阻导致按键误触发的情况,排查了两天才找到原因。

配置要点: GPIO输入模式不需要配置输出速度,但别忘了使能时钟。我见过新手只配了GPIO寄存器没开RCC时钟,读回来的数据永远是0x00。

3.2 消抖处理:为什么不能直接读按键?

直接读按键?你试试看。按下一次,程序可能读到几十次电平跳变。这就是机械抖动——按键内部的金属弹片在接触瞬间会反弹几次,持续5~20ms。

消抖方法主要有两种:

方法 原理 适用场景 缺点
硬件消抖 RC低通滤波 + 施密特触发器 对响应速度要求高的场合 增加BOM成本,不灵活
软件延时消抖 检测到电平变化后延时10~20ms再读一次 大多数通用场景 阻塞CPU,不适合中断中直接使用
状态机消抖 连续采样N次,N次结果一致才确认 低功耗、多按键场景 代码稍复杂,但最可靠

我个人最常用的是状态机消抖。为什么?因为它不阻塞、不依赖定时器延时,而且能同时处理多个按键。下面给出一个典型实现:

/* 按键状态机 */
typedef enum {
    KEY_STATE_IDLE,      // 空闲
    KEY_STATE_DEBOUNCE,  // 消抖中
    KEY_STATE_PRESSED,   // 确认按下
    KEY_STATE_RELEASE    // 等待释放
} KeyState_t;

typedef struct {
    GPIO_TypeDef *port;
    uint16_t      pin;
    KeyState_t    state;
    uint8_t       debounce_cnt;
    uint8_t       debounce_threshold;  // 通常设为3~5
} Key_t;

uint8_t Key_Scan(Key_t *key) {
    uint8_t level = HAL_GPIO_ReadPin(key->port, key->pin);
    
    switch (key->state) {
        case KEY_STATE_IDLE:
            if (level == 0) {  // 检测到按下
                key->state = KEY_STATE_DEBOUNCE;
                key->debounce_cnt = 0;
            }
            break;
            
        case KEY_STATE_DEBOUNCE:
            if (level == 0) {
                key->debounce_cnt++;
                if (key->debounce_cnt >= key->debounce_threshold) {
                    key->state = KEY_STATE_PRESSED;
                    return 1;  // 返回按键有效
                }
            } else {
                key->state = KEY_STATE_IDLE;  // 抖动回弹,复位
            }
            break;
            
        case KEY_STATE_PRESSED:
            if (level == 1) {
                key->state = KEY_STATE_RELEASE;
            }
            break;
            
        case KEY_STATE_RELEASE:
            key->state = KEY_STATE_IDLE;
            break;
    }
    return 0;
}
关键点: 这个状态机需要在主循环或定时器中断中周期性调用(比如每5ms一次)。debounce_threshold设为3~5,对应15~25ms的消抖时间,足够覆盖大多数机械按键的抖动周期。

3.3 中断服务函数分层:ISR与Callback分离

轮询读按键在简单场景下够用,但如果你想让系统在按键按下时立刻响应,或者想在睡眠中唤醒MCU,那就得上外部中断。

很多初学者把整个业务逻辑直接写在中断服务函数里——这是个大坑。中断服务函数应该短、快、不阻塞。我在项目中见过有人在ISR里调printf、做延时、甚至跑文件系统,结果系统动不动就死机。

我的做法是:ISR只做一件事——设置标志位或唤醒信号量;真正的处理逻辑放在Callback回调函数中,由主循环或任务调度器去执行。

看代码:

/* 中断服务函数 —— 只做最轻量的事 */
void EXTI0_IRQHandler(void) {
    if (__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_0) != RESET) {
        /* 清除中断标志位 —— 必须做 */
        __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0);
        
        /* 设置按键按下标志,不在这里做任何业务处理 */
        g_key_pending = 1;
    }
}

/* 应用层回调 —— 在主循环中调用 */
void Key_ProcessCallback(void) {
    if (g_key_pending) {
        g_key_pending = 0;  // 清标志
        
        /* 这里才做消抖和按键判断 */
        if (Key_Scan(&user_key)) {
            /* 按键有效,触发上层动作 */
            LED_Toggle(LED_BUILTIN);
        }
    }
}

为什么要这样分?说白了就两个原因:

  • 实时性:ISR执行时间过长会阻塞其他中断,尤其是低优先级中断可能永远得不到响应
  • 可移植性:换MCU时只需要重写ISR(硬件相关),Callback逻辑完全不用动
曾经踩过的坑: 我曾经在ISR里直接调用了一个带互斥锁的函数,结果那个锁被主循环占着,ISR死等,系统直接卡死。记住:ISR里不要拿锁、不要调阻塞函数、不要做复杂计算。

3.4 实战:按键控制LED亮灭

理论说完了,咱们动手。目标很简单:按一下按键,LED状态翻转(亮→灭→亮)。

整体架构图如下:

按键控制LED — 分层架构流程图 硬件层 GPIO寄存器 / EXTI控制器 HAL层 HAL_GPIO_ReadPin / EXTI_IRQHandler 驱动层 Key_Scan / LED_Write / 状态机 应用层 Key_ProcessCallback / 业务逻辑 读引脚 封装接口 回调触发 数据流向: 按键按下 → 硬件电平变化 → EXTI中断触发 → ISR设置标志 → 主循环调用Callback → 状态机消抖 → LED翻转

完整代码实现:

/* main.c —— 主循环 */
int main(void) {
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();   // 初始化LED引脚(输出)和按键引脚(输入+中断)
    
    while (1) {
        Key_ProcessCallback();  // 处理按键事件
        /* 其他任务... */
    }
}

/* gpio.c —— 硬件初始化 */
void MX_GPIO_Init(void) {
    __HAL_RCC_GPIOA_CLK_ENABLE();
    __HAL_RCC_GPIOC_CLK_ENABLE();
    
    /* LED引脚:PA5,推挽输出 */
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    GPIO_InitStruct.Pin = GPIO_PIN_5;
    GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
    
    /* 按键引脚:PC13,上拉输入 + 下降沿中断 */
    GPIO_InitStruct.Pin = GPIO_PIN_13;
    GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING;
    GPIO_InitStruct.Pull = GPIO_PULLUP;
    HAL_GPIO_Init(GPIOC, &GPIO_InitStruct);
    
    /* 使能中断线 */
    HAL_NVIC_SetPriority(EXTI15_10_IRQn, 2, 0);
    HAL_NVIC_EnableIRQ(EXTI15_10_IRQn);
}

/* stm32f1xx_it.c —— 中断服务函数 */
void EXTI15_10_IRQHandler(void) {
    if (__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_13) != RESET) {
        __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_13);
        g_key_pending = 1;  // 只设标志,不做其他
    }
}
调试小技巧: 第一次调试时,可以在Callback里加一个计数变量,用串口打印出来,看看按键按下一次到底触发了多少次中断。如果计数远大于1,说明消抖没做好或者中断配置有问题。

3.5 避坑总结

最后,把我在项目中踩过的坑列出来,你遇到了可以直接对照:

  • 中断标志位没清干净:有些MCU需要先读再写才能清除,只写一次可能清不掉,导致反复进中断
  • 消抖阈值设得太小:我曾经设成2(对应10ms),结果在强电磁环境下按键还是误触发,改成5之后稳了
  • Callback里做了阻塞操作:比如在Callback里调了HAL_Delay(100),导致下一次中断响应延迟,按键感觉“卡顿”
  • 忘了配置NVIC优先级分组:默认分组可能不是你想要的,中断嵌套时优先级乱套