4. GPIO输入检测:按键消抖原理、HAL_GPIO_ReadPin函数、按键控制LED实战

按键,是嵌入式系统里最基础、也最容易被忽视的输入设备。很多新手觉得按键检测不就是读个电平嘛,有什么难的?嗯,我当年也是这么想的。直到有一次做产品,按键按下去偶尔会触发两次,或者按一次没反应……排查了半天,才发现是「抖动」在捣鬼。

这一章,我们就来彻底搞定按键输入。从硬件原理到软件消抖,再到实战控制LED,一步到位。

4.1 按键的硬件连接与抖动现象

先看一个最简单的按键电路。按键一端接GND,另一端通过一个上拉电阻接到GPIO引脚。按键按下时,引脚被拉到低电平;松开时,上拉电阻把引脚拉回高电平。

听起来很简单对吧?但实际波形是这样的:

高电平 低电平 理想波形 实际波形(抖动) 按下抖动区 松开抖动区

看到了吗?按键按下和松开的瞬间,电平会反复跳变几次,持续5~20ms。这就是「抖动」。如果不处理,MCU可能会把一次按键误判成多次。

我的经验:机械按键的抖动时间一般在10ms左右。我习惯用20ms作为消抖延时,既可靠又不会让按键感觉「迟钝」。

4.2 软件消抖的两种主流方法

消抖的本质很简单:等电平稳定了再读取。具体有两种做法:

方法一:延时消抖(适合简单场景)

检测到电平变化后,延时20ms,再读一次。如果电平没变,就确认是有效按键。

// 延时消抖示例
if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET) {
    HAL_Delay(20);  // 延时20ms
    if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET) {
        // 确认按键按下
    }
}

这个方法简单粗暴,但有个问题:HAL_Delay会阻塞CPU。如果系统里还有别的任务(比如LED闪烁、串口通信),延时期间CPU就干等着了。

方法二:状态机消抖(推荐,适合复杂系统)

用定时器每隔10ms扫描一次按键,记录连续几次的采样值。如果连续3次(30ms)都读到低电平,才判定为按下。

// 状态机消抖示例(在定时器中断中调用)
uint8_t key_scan(void) {
    static uint8_t key_state = 0;  // 0:未按下, 1:抖动中, 2:已确认
    static uint8_t cnt = 0;
    uint8_t key_val = HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin);
    
    switch(key_state) {
        case 0:  // 空闲状态
            if (key_val == GPIO_PIN_RESET) {
                key_state = 1;  // 检测到按下,进入抖动检测
                cnt = 0;
            }
            break;
        case 1:  // 抖动检测状态
            if (key_val == GPIO_PIN_RESET) {
                cnt++;
                if (cnt >= 3) {  // 连续3次(30ms)都是低电平
                    key_state = 2;  // 确认按下
                    return 1;  // 返回按键有效
                }
            } else {
                key_state = 0;  // 抖动过程中变高了,复位
            }
            break;
        case 2:  // 等待松开
            if (key_val == GPIO_PIN_SET) {
                key_state = 0;  // 松开后回到空闲
            }
            break;
    }
    return 0;
}

我个人习惯用状态机消抖。虽然代码量多一点,但CPU不会被阻塞,而且可以轻松处理长按、双击等复杂逻辑。我在做智能家居面板时,就是用这个方案同时处理了单击、双击和长按三种操作。

4.3 HAL_GPIO_ReadPin函数详解

这个函数是HAL库读取GPIO电平的标准接口。原型如下:

GPIO_PinState HAL_GPIO_ReadPin(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);

参数说明:

参数 说明 示例
GPIOx GPIO端口基地址 GPIOA, GPIOB, GPIOC...
GPIO_Pin 引脚编号(支持多引脚组合) GPIO_PIN_0, GPIO_PIN_1...
返回值 GPIO_PIN_SET(高电平) 或 GPIO_PIN_RESET(低电平) 用于if判断

注意:读取输入引脚时,GPIO必须配置为输入模式(Mode = GPIO_MODE_INPUT)。如果配置成输出模式,读到的值可能是输出寄存器的值,而不是引脚的实际电平。我曾经见过有人把按键引脚配成推挽输出,结果读到的永远是高电平……

4.4 实战:按键控制LED

好了,理论讲完了,我们直接上实战。需求很简单:按一下按键,LED状态翻转一次

硬件连接

  • 按键:PA0 → 上拉电阻10K → GND
  • LED:PB0 → 限流电阻220Ω → VCC(低电平点亮)

完整代码

// main.c
#include "main.h"

// 按键引脚定义
#define KEY_PORT   GPIOA
#define KEY_PIN    GPIO_PIN_0

// LED引脚定义
#define LED_PORT   GPIOB
#define LED_PIN    GPIO_PIN_0

int main(void) {
    HAL_Init();
    SystemClock_Config();  // 配置系统时钟
    MX_GPIO_Init();       // 初始化GPIO
    
    uint8_t led_state = 0;  // 0:灭, 1:亮
    
    while (1) {
        // 检测按键按下(带消抖)
        if (HAL_GPIO_ReadPin(KEY_PORT, KEY_PIN) == GPIO_PIN_RESET) {
            HAL_Delay(20);  // 消抖延时
            
            if (HAL_GPIO_ReadPin(KEY_PORT, KEY_PIN) == GPIO_PIN_RESET) {
                // 确认按键按下,翻转LED
                led_state = !led_state;
                HAL_GPIO_WritePin(LED_PORT, LED_PIN, 
                    led_state ? GPIO_PIN_RESET : GPIO_PIN_SET);
                
                // 等待按键松开(防止一次按下触发多次)
                while (HAL_GPIO_ReadPin(KEY_PORT, KEY_PIN) == GPIO_PIN_RESET);
            }
        }
    }
}

// GPIO初始化函数
void MX_GPIO_Init(void) {
    __HAL_RCC_GPIOA_CLK_ENABLE();
    __HAL_RCC_GPIOB_CLK_ENABLE();
    
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    
    // 配置PA0为输入,上拉
    GPIO_InitStruct.Pin = KEY_PIN;
    GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
    GPIO_InitStruct.Pull = GPIO_PULLUP;
    HAL_GPIO_Init(KEY_PORT, &GPIO_InitStruct);
    
    // 配置PB0为推挽输出
    GPIO_InitStruct.Pin = LED_PIN;
    GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
    HAL_GPIO_Init(LED_PORT, &GPIO_InitStruct);
    
    // 初始状态:LED灭
    HAL_GPIO_WritePin(LED_PORT, LED_PIN, GPIO_PIN_SET);
}

代码要点:

  • 按键按下时读到的电平是低(GPIO_PIN_RESET),因为外部接了上拉电阻
  • LED低电平点亮,所以写GPIO_PIN_RESET时LED亮
  • 等待按键松开的while循环是必要的,否则一次按下会触发多次翻转

4.5 避坑指南

我曾经踩过的坑:

  • 上拉/下拉配置错误:按键一端接GND,GPIO内部要配成上拉。如果配成下拉,按键按下时电平变化不明显,甚至可能读不到低电平。
  • 忘记使能GPIO时钟:很多人初始化GPIO时忘了调用__HAL_RCC_GPIOx_CLK_ENABLE(),结果引脚死活没反应。嗯,我刚开始也犯过这个错。
  • 消抖延时太短:有些劣质按键抖动时间可能长达20ms。我建议至少用15~20ms的延时,别为了省那几毫秒导致按键误触发。
  • 没有处理按键松开:如果不等待按键松开就退出,一次按下可能会被检测到多次。上面的代码里用while循环等待松开,就是解决这个问题。

4.6 进阶思考

如果你觉得上面的延时消抖太「笨」,可以试试用定时器中断+状态机的方式。这样CPU不用傻等,可以同时处理LED呼吸灯、串口通信等其他任务。

另外,实际产品中按键往往不止一个。你可以把多个按键的扫描放在一个定时器中断里,每10ms扫描一次,用状态机统一管理。这样代码结构清晰,扩展性也好。

一个小技巧:如果按键接在支持外部中断的引脚上(比如PA0),可以配置成下降沿触发中断。在中断服务函数里启动一个20ms的定时器,定时器溢出时再读取一次电平。这样既省CPU,又能精确检测按键动作。我在做低功耗产品时经常用这个方案。


好了,这一章的内容就到这里。按键消抖看似简单,但做不好真的很影响用户体验。希望你能把上面的代码跑一遍,亲手感受一下消抖前后的区别。

如果你在实际调试中遇到问题,欢迎随时交流。记住:做硬件,耐心比天赋更重要

专注资料整理