3. GPIO 与按键控制:GPIO 模式配置、按键消抖处理(硬件+软件)、外部中断实现唤醒

好,咱们进入第三章。这一章聊的是嵌入式开发里最基础、但也最容易翻车的内容——GPIO 与按键控制。

你可能会想,按键嘛,不就是读个电平高低?其实没那么简单。我在做共享单车锁控项目时,就遇到过因为按键抖动导致误触发开锁的严重 bug。嗯,今天咱们就把这块彻底讲透。

3.1 GPIO 模式配置:别小看这个初始化

GPIO 是芯片和外界打交道的「手脚」。但手脚怎么摆,是有讲究的。

我个人习惯,在写任何外设驱动前,先把 GPIO 的寄存器手册翻一遍。别嫌麻烦,很多坑就藏在模式配置里。

常见的 GPIO 模式有这些:

模式 说明 典型用途
推挽输出 能输出高电平和低电平,驱动能力强 LED 控制、蜂鸣器
开漏输出 只能输出低电平,高电平靠外部上拉 I2C 总线、多设备共用线
浮空输入 输入电平完全由外部决定,内部无上下拉 外部中断检测
上拉输入 内部接上拉电阻,默认高电平 按键检测(常开型)
下拉输入 内部接下拉电阻,默认低电平 按键检测(常闭型)
模拟模式 关闭数字功能,信号直接进 ADC 等外设 ADC 采样

对于共享单车锁控板上的按键,我一般用上拉输入模式。为什么?因为按键按下时接地,不按时靠内部上拉保持高电平,这样最省电,也最稳定。

核心要点:按键检测的 GPIO 一定要配置为输入模式,并且使能内部上拉或下拉。千万别忘了配置速度——输入模式不需要高速,设为低速即可,能减少 EMI 干扰。

来看一段初始化代码,以 STM32 为例:

// 按键 GPIO 初始化
void Key_GPIO_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    
    // 使能 GPIO 时钟
    __HAL_RCC_GPIOA_CLK_ENABLE();
    
    // 配置 PA0 为上拉输入
    GPIO_InitStruct.Pin = GPIO_PIN_0;
    GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
    GPIO_InitStruct.Pull = GPIO_PULLUP;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
}

这段代码看着简单,但我曾经犯过一个错:忘了使能时钟。结果 GPIO 死活读不到电平,排查了半天。你想想看,时钟都没开,外设怎么工作?

3.2 按键消抖处理:硬件和软件两手抓

按键按下和松开时,机械触点会弹跳。这个弹跳时间大概 5~20ms。如果不处理,一次按键可能被误判成多次。

我在项目中遇到过最离谱的情况:用户按一次开锁键,锁控板检测到了 7 次下降沿,结果锁开了又关、关了又开。嗯,这就是没做消抖的后果。

3.2.1 硬件消抖

硬件消抖,说白了就是加个 RC 低通滤波器。电容把毛刺滤掉,电阻限制充放电电流。

典型电路是这样的:

VCC
  |
  R (10kΩ)
  |
  +-----> 到 GPIO
  |
  C (0.1μF)
  |
 GND

时间常数 τ = R × C = 10k × 0.1μ = 1ms。这个值能滤掉大部分抖动,但不会让按键响应太迟钝。

我的经验:硬件消抖适合对成本不敏感、对实时性要求高的场景。共享单车锁控板因为要省成本,我一般只用软件消抖,硬件上只保留一个 100Ω 的限流电阻。

3.2.2 软件消抖

软件消抖有两种主流方法:

  • 延时消抖:检测到电平变化后,延时 10~20ms 再读一次。如果电平一致,就确认按键动作。
  • 状态机消抖:用状态机记录按键状态,每隔几毫秒扫描一次,连续多次采样一致才确认。

我个人更推荐状态机方式。为什么?延时消抖会阻塞 CPU,在低功耗场景下简直是灾难。

来看一个简单的状态机消抖代码:

// 按键状态机
typedef enum {
    KEY_STATE_IDLE,      // 空闲
    KEY_STATE_PRESSED,   // 按下确认
    KEY_STATE_RELEASED   // 松开确认
} KeyState;

KeyState key_scan(void)
{
    static KeyState state = KEY_STATE_IDLE;
    static uint8_t cnt = 0;
    uint8_t level = HAL_GPIO_ReadPin(KEY_GPIO_PORT, KEY_PIN);
    
    switch(state) {
        case KEY_STATE_IDLE:
            if(level == 0) {  // 检测到低电平(按下)
                cnt++;
                if(cnt >= 3) {  // 连续 3 次采样为低
                    cnt = 0;
                    state = KEY_STATE_PRESSED;
                    return KEY_STATE_PRESSED;
                }
            } else {
                cnt = 0;
            }
            break;
            
        case KEY_STATE_PRESSED:
            if(level == 1) {  // 检测到高电平(松开)
                cnt++;
                if(cnt >= 3) {
                    cnt = 0;
                    state = KEY_STATE_IDLE;
                    return KEY_STATE_RELEASED;
                }
            } else {
                cnt = 0;
            }
            break;
    }
    return state;  // 无有效事件
}

这段代码每 5ms 调用一次。连续 3 次采样一致(15ms),才认为按键状态变化。这样既消抖,又不阻塞。

注意:采样间隔和连续次数要根据实际按键的抖动特性调整。我遇到过一种劣质按键,抖动长达 30ms,最后不得不把连续次数改成 6 次。所以,量产前一定要实测按键波形。

3.3 外部中断实现唤醒:低功耗的关键

共享单车锁控板是电池供电的。如果 CPU 一直轮询按键,功耗根本扛不住。所以必须用外部中断——平时 CPU 睡大觉,按键按下时唤醒。

外部中断的配置要点:

  • 触发方式:一般用下降沿触发(按键按下时电平从高变低)。
  • 中断优先级:按键中断优先级不要设太高,避免打断更重要的任务(比如通信)。
  • 中断服务函数:要短小精悍,只做标志位设置,不要在里面做复杂处理。

来看配置代码:

// 外部中断初始化
void Key_EXTI_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    
    __HAL_RCC_GPIOA_CLK_ENABLE();
    __HAL_RCC_SYSCFG_CLK_ENABLE();
    
    // PA0 作为外部中断线
    GPIO_InitStruct.Pin = GPIO_PIN_0;
    GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING;  // 下降沿触发
    GPIO_InitStruct.Pull = GPIO_PULLUP;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
    
    // 配置中断优先级
    HAL_NVIC_SetPriority(EXTI0_IRQn, 2, 0);
    HAL_NVIC_EnableIRQ(EXTI0_IRQn);
}

// 中断服务函数
void EXTI0_IRQHandler(void)
{
    if(__HAL_GPIO_EXTI_GET_IT(KEY_PIN) != RESET) {
        // 清除中断标志
        __HAL_GPIO_EXTI_CLEAR_IT(KEY_PIN);
        
        // 设置按键事件标志(不要在这里做消抖!)
        g_key_event_flag = 1;
    }
}

中断里只设一个标志位。然后在主循环里检测这个标志,再做消抖和处理。这样中断服务函数执行时间极短,不会影响其他中断。

低功耗流程:

  1. 主循环检测到无事可做,调用 HAL_PWR_EnterSTOPMode() 进入停止模式。
  2. 用户按下按键,外部中断唤醒 CPU。
  3. CPU 从停止模式恢复,执行中断服务函数,设置标志位。
  4. 主循环检测到标志位,执行消抖和按键处理。
  5. 处理完毕,再次进入停止模式。

这样,待机电流可以做到 10μA 以下。我实测过,一节 18650 电池能撑半年以上。

3.4 避坑指南

最后,分享几个我踩过的坑:

  • GPIO 复用功能冲突:有些引脚默认是 JTAG/SWD 调试口。我曾在 PA15 上接了按键,结果调试器死活连不上。后来才发现 PA15 是 JTDI 复用引脚,需要先关闭 JTAG 功能。
  • 中断标志位没清干净:外部中断触发后,一定要在中断服务函数里清除标志位。否则会一直进中断,CPU 根本没法休眠。
  • 按键唤醒后要重新配置时钟:从停止模式唤醒后,HSE/PLL 等时钟源可能已经关闭。我遇到过唤醒后系统时钟变成 HSI,导致外设工作异常。所以唤醒后第一件事就是重新配置时钟。

嗯,这一章的内容就到这里。GPIO 和按键看似简单,但做好了,整个系统的稳定性和功耗就成功了一半。下一章咱们聊定时器,那又是另一片天地了。