3. GPIO 输入检测:按键输入原理与实战

按键检测,可以说是嵌入式开发里最基础、也最容易被忽视的环节。我见过不少新手,写个按键程序觉得简单,结果一上机就各种误触发、抖动没处理好。今天咱们就把这个知识点彻底讲透。

3.1 按键输入原理

按键的本质是什么?说白了就是一个机械开关。按下时导通,松开时断开。我们通过 GPIO 读取电平变化,就能判断按键状态。

常见的按键电路有两种接法:

  • 上拉输入:按键一端接 GND,另一端接 GPIO,内部或外部上拉电阻到 VCC。按下时读到低电平,松开时读到高电平。
  • 下拉输入:按键一端接 VCC,另一端接 GPIO,内部或外部下拉电阻到 GND。按下时读到高电平,松开时读到低电平。

我个人习惯用上拉输入,因为 STM32 的 GPIO 内部自带上拉电阻,省一个外部元件。你想想看,能省则省嘛。

关键点:按键按下时,GPIO 引脚的电平是确定的(高或低),但松开时引脚处于悬空状态,必须靠上拉或下拉电阻来固定电平。否则,你读到的就是不确定值,程序会乱跳。

3.2 机械抖动问题

这里有个坑,我必须重点讲——机械抖动。

按键是金属弹片结构,按下和松开的瞬间,触点会反复接触、断开几次,持续 5~20ms。如果你在抖动期间读取电平,可能会读到多次跳变。我曾经在一个项目中,没做消抖处理,结果按键按一次,程序却响应了三四次,用户直接投诉。

为什么会这样?看这张图就明白了:

按键按下时的电平抖动示意图 理想波形: 按下 松开 实际波形: 抖动区 抖动区 消抖原理: 1. 检测到电平变化后,不立即响应 2. 等待 10~20ms(抖动期结束) 3. 再次读取电平,确认状态稳定 4. 若电平与之前一致,则判定为有效按键

3.3 标准库按键扫描实现

先看标准库的写法。我记得早期做项目时,标准库用得最多,代码风格比较直接。

// 标准库:按键初始化
void KEY_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStructure;
    
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
    
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;  // PA0 接按键
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;  // 上拉输入
    GPIO_Init(GPIOA, &GPIO_InitStructure);
}

// 标准库:带消抖的按键扫描
uint8_t KEY_Scan(void)
{
    static uint8_t key_up = 1;  // 按键松开标志
    
    if(key_up && (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == 0))
    {
        Delay_ms(10);  // 消抖延时
        if(GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == 0)
        {
            key_up = 0;
            return 1;  // 按键按下
        }
    }
    else if(GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == 1)
    {
        key_up = 1;
    }
    return 0;
}

这段代码的核心思路:检测到低电平后,先等 10ms,再读一次。如果还是低电平,说明真的按下了,不是抖动。这里用了 static 变量记录按键状态,防止一次按下被多次触发。

小技巧:Delay_ms(10) 在标准库里通常用 SysTick 实现。但要注意,如果在中断里调用这个延时函数,可能会阻塞系统。我一般只在主循环里用这种阻塞式消抖。

3.4 HAL 库按键扫描实现

迁移到 HAL 库后,代码风格变了,但逻辑是一样的。HAL 库把 GPIO 操作封装成了更统一的接口。

// HAL 库:按键初始化
void KEY_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    
    __HAL_RCC_GPIOA_CLK_ENABLE();
    
    GPIO_InitStruct.Pin = GPIO_PIN_0;
    GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
    GPIO_InitStruct.Pull = GPIO_PULLUP;  // 上拉
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
}

// HAL 库:带消抖的按键扫描
uint8_t KEY_Scan(void)
{
    static uint8_t key_up = 1;
    
    if(key_up && (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET))
    {
        HAL_Delay(10);  // HAL 库自带的延时函数
        if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET)
        {
            key_up = 0;
            return 1;
        }
    }
    else if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_SET)
    {
        key_up = 1;
    }
    return 0;
}

你看,核心逻辑完全没变。只是函数名从 GPIO_ReadInputDataBit 变成了 HAL_GPIO_ReadPin,延时从自定义的 Delay_ms 换成了 HAL_Delay。这就是迁移的直观感受——接口变了,思想不变。

3.5 轮询方式 vs 中断方式

按键检测有两种主流方式:轮询和中断。我做个表格对比一下:

对比项 轮询方式 中断方式
原理 主循环不断读取 GPIO 电平 按键触发外部中断,CPU 暂停当前任务去处理
CPU 占用 持续占用,即使没按键也在查 只有按键时才占用,平时 CPU 干别的
实时性 取决于循环周期,可能有延迟 响应快,微秒级响应
代码复杂度 简单,容易理解 稍复杂,需配置 NVIC、中断优先级
适用场景 按键少、任务简单、对实时性要求不高 按键多、系统复杂、需要快速响应

我个人习惯:简单项目用轮询,复杂项目用中断。但要注意,中断里不能做耗时操作,比如 HAL_Delay。消抖最好在中断里只设一个标志位,在主循环里处理。

避坑指南:我曾经在一个项目中,把消抖延时直接写在中断服务函数里,结果按键一按,整个系统卡死。因为中断里调用了 HAL_Delay,而 HAL_Delay 依赖 SysTick 中断,优先级比外部中断低,导致死锁。切记:中断服务函数要短小精悍,只做标志位设置。

3.6 中断方式代码示例

这里给出 HAL 库的中断方式实现,供你参考:

// 按键中断初始化
void KEY_EXTI_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    
    __HAL_RCC_GPIOA_CLK_ENABLE();
    __HAL_RCC_SYSCFG_CLK_ENABLE();
    
    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(GPIO_PIN_0) != RESET)
    {
        // 这里只设标志位,不做延时消抖
        key_press_flag = 1;
        __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0);
    }
}

// 主循环中处理
void main_loop(void)
{
    if(key_press_flag)
    {
        key_press_flag = 0;
        HAL_Delay(10);  // 在主循环中消抖
        if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET)
        {
            // 确认按键按下,执行动作
            handle_key_press();
        }
    }
}

这个模式我一直在用:中断只负责通知,主循环负责处理。既保证了实时性,又避免了中断里的坑。

3.7 本章小结

按键检测看似简单,但做好并不容易。总结几个要点:

  • 硬件上:必须加上拉或下拉电阻,避免浮空
  • 软件上:必须做消抖处理,10~20ms 延时是经验值
  • 方式选择:简单项目用轮询,复杂项目用中断
  • 中断原则:只设标志,不做耗时操作