3. GPIO 进阶与按键消抖:外部中断 EXTI、LL 库回调与消抖方案

按键,是嵌入式开发里最基础、也最容易出问题的外设。你想想看,一个机械按键按下去,信号在几毫秒内会弹跳几十次。如果不处理,一次按下可能被识别成十次。这一章,我们就来彻底搞定它。

我会从 EXTI 外部中断的配置讲起,然后带你看看 LL 库的中断回调机制,最后给出软件和硬件两种消抖方案。这些都是我实际项目中反复用过的套路,希望能帮你少走弯路。

本章核心脉络: 理解 EXTI 中断触发机制 → 掌握 LL 库中断回调写法 → 对比软件/硬件消抖方案 → 实战一个可靠按键驱动
GPIO 进阶与按键消抖知识体系 按键输入处理 EXTI 中断配置 LL 库中断回调 消抖方案 上升沿/下降沿 中断优先级 LL_EXTI_IRQHandler 软件消抖 硬件消抖 核心目标:一次按下,只触发一次有效动作

3.1 EXTI 外部中断:从轮询到事件驱动

轮询方式虽然简单,但 CPU 得一直盯着引脚看,浪费资源。外部中断(EXTI)则不同——引脚电平变化时,硬件自动触发中断,CPU 只需在中断服务函数里处理即可。

配置 EXTI 其实就三步:

  1. 使能 GPIO 时钟和 SYSCFG 时钟(EXTI 需要 SYSCFG 做引脚映射)
  2. 配置 GPIO 为输入模式,并选择中断触发边沿
  3. 配置 NVIC 中断优先级,使能中断通道

下面是一个 LL 库的配置示例,我习惯把按键初始化封装成一个函数:

void Button_Init(void)
{
    // 1. 使能时钟
    LL_APB2_GRP1_EnableClock(LL_APB2_GRP1_PERIPH_GPIOA);
    LL_APB2_GRP1_EnableClock(LL_APB2_GRP1_PERIPH_SYSCFG);

    // 2. 配置 PA0 为输入,上拉
    LL_GPIO_SetPinMode(GPIOA, LL_GPIO_PIN_0, LL_GPIO_MODE_INPUT);
    LL_GPIO_SetPinPull(GPIOA, LL_GPIO_PIN_0, LL_GPIO_PULL_UP);

    // 3. 将 PA0 连接到 EXTI 线 0
    LL_SYSCFG_SetEXTISource(LL_SYSCFG_EXTI_PORTA, LL_SYSCFG_EXTI_LINE0);

    // 4. 配置 EXTI:下降沿触发(按键按下时引脚从高变低)
    LL_EXTI_EnableIT_0_31(LL_EXTI_LINE_0);
    LL_EXTI_EnableFallingTrig_0_31(LL_EXTI_LINE_0);
    LL_EXTI_EnableRisingTrig_0_31(LL_EXTI_LINE_0);  // 如果需要双边沿触发

    // 5. 配置 NVIC
    NVIC_SetPriority(EXTI0_IRQn, 1);
    NVIC_EnableIRQ(EXTI0_IRQn);
}
小提示: 如果按键接的是 GND,按下时引脚变低,用下降沿触发。如果按键接 VCC,按下时引脚变高,用上升沿触发。我个人习惯用内部上拉 + 下降沿,这样外部只需一个按键到地,省元件。

3.2 LL 库中断回调:别在中断里做复杂事

配置好 EXTI 后,中断来了会进哪个函数?在 LL 库里,中断服务函数需要我们自己写,但 LL 库提供了一个弱定义的回调函数 LL_EXTI_Line0_Callback,我们只需重写它。

中断服务函数的写法:

void EXTI0_IRQHandler(void)
{
    // 检查中断标志位
    if (LL_EXTI_IsActiveFlag_0_31(LL_EXTI_LINE_0))
    {
        // 清除中断标志位
        LL_EXTI_ClearFlag_0_31(LL_EXTI_LINE_0);

        // 调用 LL 库回调
        LL_EXTI_Line0_Callback();
    }
}

// 弱定义回调,用户重写
void LL_EXTI_Line0_Callback(void)
{
    // 这里只做标记,不要做耗时操作
    // 比如:设置一个全局标志位,主循环去处理
    g_button_pressed = 1;
}
避坑指南: 我曾经在一个项目里直接在回调函数中做了延时消抖和串口打印,结果系统频繁卡死。中断服务函数里绝对不能做延时、打印、复杂计算。正确的做法是:中断里只置标志位,主循环里处理具体逻辑。

3.3 软件消抖:简单可靠的延时方案

机械按键按下和释放时,触点会弹跳 5~20ms。软件消抖的核心思路就是:检测到电平变化后,等一段时间再确认电平状态。

我常用的软件消抖有两种:

方案 原理 优点 缺点
延时消抖 检测到边沿后,延时 10~20ms,再次读取引脚电平 实现简单,代码量少 阻塞 CPU,不适合多任务
状态机消抖 定时采样引脚电平,连续 N 次相同才确认状态变化 非阻塞,适合 RTOS 或主循环 实现稍复杂,需要定时器配合

延时消抖的代码很直观:

void LL_EXTI_Line0_Callback(void)
{
    // 延时 20ms 避开弹跳期
    delay_ms(20);

    // 再次读取引脚电平,确认是否真的按下
    if (LL_GPIO_IsInputPinSet(GPIOA, LL_GPIO_PIN_0) == 0)
    {
        g_button_pressed = 1;
    }
}

但说实话,这种阻塞延时在复杂系统里不太友好。我更推荐状态机方式,配合一个 1ms 的定时器中断:

// 按键状态机
#define DEBOUNCE_TIME  5   // 连续采样 5 次(每次 1ms)

uint8_t Button_Scan(void)
{
    static uint8_t count = 0;
    static uint8_t last_state = 1;  // 默认高电平(未按下)
    uint8_t current_state = LL_GPIO_IsInputPinSet(GPIOA, LL_GPIO_PIN_0);

    if (current_state == last_state)
    {
        count++;
        if (count >= DEBOUNCE_TIME)
        {
            count = DEBOUNCE_TIME;
            // 如果连续 5ms 都是低电平,确认按下
            if (current_state == 0)
                return 1;
        }
    }
    else
    {
        count = 0;
        last_state = current_state;
    }
    return 0;
}

这个函数放在 1ms 定时器中断里调用,或者主循环里轮询。它不会阻塞,而且抗干扰能力强。

经验之谈: 我做过一个工业控制面板,现场电磁干扰很强。单纯用延时消抖偶尔会误触发。后来改成状态机 + 连续 10ms 采样,再也没有误报过。环境越恶劣,采样次数要越多。

3.4 硬件消抖:RC 滤波与施密特触发器

如果你不想在软件上花太多心思,硬件消抖是更彻底的方案。原理很简单:用 RC 低通滤波器把弹跳的高频分量滤掉,再用施密特触发器整形。

典型电路:

          VCC
           |
           R (10kΩ)
           +-------> 到 MCU 引脚
           |
按键 ----> + 
           |
           C (0.1μF)
           |
          GND

RC 时间常数 τ = R × C。一般取 τ = 1~10ms。比如 10kΩ 和 0.1μF,τ = 1ms,能有效滤除 5~20ms 的弹跳。

元件 推荐值 说明
R(上拉电阻) 4.7kΩ ~ 10kΩ 阻值太小功耗大,太大抗干扰差
C(滤波电容) 0.1μF ~ 1μF 电容越大,滤波效果越好,但响应变慢
我的建议: 如果产品对成本敏感,用软件消抖就够了,只多几行代码。如果产品要通过 EMC 认证,或者工作环境恶劣,建议硬件消抖 + 软件消抖双重保险。我做过的一个车载项目,就是 RC 滤波 + 状态机,一次都没出过问题。

3.5 实战:一个可靠的按键驱动

最后,我把前面讲的内容整合成一个完整的按键驱动。它支持短按和长按检测,使用状态机消抖,非阻塞运行。

// key_driver.h
typedef enum {
    KEY_IDLE,
    KEY_PRESSED,
    KEY_LONG_PRESS
} KeyState_t;

KeyState_t Key_GetState(void);
void Key_Task(void);  // 1ms 调用一次

// key_driver.c
static uint8_t debounce_cnt = 0;
static uint8_t press_cnt = 0;
static uint8_t last_pin = 1;

KeyState_t Key_GetState(void)
{
    static KeyState_t state = KEY_IDLE;
    uint8_t pin = LL_GPIO_IsInputPinSet(GPIOA, LL_GPIO_PIN_0);

    // 状态机消抖
    if (pin == last_pin) {
        debounce_cnt++;
        if (debounce_cnt >= 5) {
            debounce_cnt = 5;
            if (pin == 0) {
                press_cnt++;
                if (press_cnt == 1) {
                    state = KEY_PRESSED;  // 短按触发
                } else if (press_cnt >= 100) {  // 100ms 长按
                    state = KEY_LONG_PRESS;
                }
            } else {
                press_cnt = 0;
                state = KEY_IDLE;
            }
        }
    } else {
        debounce_cnt = 0;
        last_pin = pin;
    }

    return state;
}

void Key_Task(void)
{
    KeyState_t s = Key_GetState();
    switch (s) {
        case KEY_PRESSED:
            // 处理短按
            break;
        case KEY_LONG_PRESS:
            // 处理长按
            break;
        default:
            break;
    }
}

这个驱动我在三个项目里复用过了,稳定可靠。你只需要在 1ms 定时器中断里调用 Key_Task(),主循环里查询状态即可。

注意: 长按检测的时间阈值要根据实际需求调整。100ms 是短按,500ms 以上才算长按。我见过有人把长按设成 3 秒,用户按到手酸才触发,体验很差。