3. 按键输入:按键消抖原理与独立按键检测

按键,是单片机与人交互最基础的方式。你按一下,它就得知道。但现实中的按键,远没有你想的那么听话。

我记得刚做第一个项目时,用STM32检测按键,明明只按了一次,程序却响应了三四次。当时我还以为是芯片坏了,折腾了半天……后来才知道,是按键抖动在捣鬼。

3.1 按键抖动的本质

按键内部是金属弹片。按下时,弹片不会一下子稳定接触,而是会反弹几次,再稳定下来。松开时也一样。

这个过程,就叫“抖动”。

抖动的时间一般在5~20ms。对单片机来说,这足够它检测到好几次电平变化了。

关键认知:按键抖动是物理现象,不是故障。任何机械按键都有抖动,只是程度不同。我们的任务,是让程序“无视”这段抖动期。

3.2 硬件消抖 vs 软件消抖

消抖有两种思路:硬件上把抖动滤掉,或者软件上等抖动过去再判断。

对比项 硬件消抖 软件消抖
原理 RC低通滤波 + 施密特触发器 延时采样 + 状态确认
成本 增加电阻、电容(约0.1元) 零成本,只占用CPU时间
可靠性 高,抗干扰能力强 依赖程序逻辑,受中断影响
灵活性 固定,改参数要换元件 可调延时,适应不同按键
适用场景 工业环境、长线传输 绝大多数嵌入式产品

我个人习惯,在开发板上做实验时,只用软件消抖。省事,改起来也快。但如果是做产品,尤其是用在电机、继电器旁边的按键,我会加上RC硬件消抖——那玩意儿产生的电磁干扰,软件真扛不住。

3.3 硬件消抖电路

最简单的硬件消抖,就是一个电阻和一个电容,组成低通滤波器。

原理很简单:按键按下时,电平跳变会被电容“吸收”,变成缓慢的充放电曲线。只要时间常数RC选得合适,抖动的高频分量就被滤掉了。

推荐参数:R=10kΩ,C=0.1μF。时间常数τ=RC=1ms,能有效滤除10ms以内的抖动。

小技巧:如果按键引线超过30cm,或者环境有强干扰,可以在电容两端并联一个100nF的瓷片电容,效果更好。

3.4 GPIO输入模式配置

STM32的GPIO做输入时,有几种模式可选。选错了,按键可能永远读不对。

模式 内部结构 适用场景
浮空输入 无上下拉,高阻态 外部已有上拉/下拉电阻
上拉输入 内部接VDD(约40kΩ) 按键接地(按下为低电平)
下拉输入 内部接GND(约40kΩ) 按键接VCC(按下为高电平)
模拟输入 关闭数字功能 ADC采样,不用于按键

最常用的按键接法:按键一端接GPIO,另一端接GND。GPIO配置为上拉输入。没按时,引脚被拉到高电平;按下时,引脚被拉到低电平。

嗯,这里要注意:STM32内部上拉电阻约40kΩ,阻值偏大。如果按键引线长,容易受干扰。我遇到过几次,按键没按,电平自己跳变——后来换成外部4.7kΩ上拉,问题就解决了。

3.5 软件消抖——延时去抖法

这是最经典、最常用的方法。说白了就是:检测到电平变化后,等10~20ms再读一次,确认电平没变,才算有效。

流程如下:

  1. 读取GPIO电平
  2. 如果与上次状态不同,启动延时(10~20ms)
  3. 延时结束后,再次读取GPIO电平
  4. 如果电平与第一次一致,确认按键状态变化
  5. 如果电平不一致,视为抖动,忽略

代码实现(HAL库,阻塞式延时):

// 独立按键检测函数
// 返回值:0-未按下,1-按下(低电平有效)
uint8_t Key_Scan(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
    // 第一次读取
    if(HAL_GPIO_ReadPin(GPIOx, GPIO_Pin) == GPIO_PIN_RESET)
    {
        // 延时消抖,10ms
        HAL_Delay(10);
        
        // 第二次读取,确认
        if(HAL_GPIO_ReadPin(GPIOx, GPIO_Pin) == GPIO_PIN_RESET)
        {
            // 等待按键释放(可选)
            while(HAL_GPIO_ReadPin(GPIOx, GPIO_Pin) == GPIO_PIN_RESET);
            return 1;
        }
    }
    return 0;
}

注意:上面的代码用了HAL_Delay(),它是阻塞的。如果在中断或实时性要求高的任务里用,会卡住整个程序。这种情况下,要用状态机+定时器的方式实现非阻塞消抖。

3.6 状态机消抖——非阻塞方案

阻塞延时太粗暴。真正做产品时,我几乎不用HAL_Delay()做消抖。更好的办法是用状态机,配合一个10ms的定时器中断。

状态机有三个状态:

  • 状态0:等待按下——检测到低电平,进入状态1,记录时间
  • 状态1:消抖确认——10ms后再次读取,如果还是低电平,进入状态2;否则回到状态0
  • 状态2:等待释放——检测到高电平,回到状态0,同时返回按键按下事件

这样做的好处:CPU不用傻等,可以去做别的事。定时器每10ms调用一次这个状态机就行。

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

KeyState keyState = KEY_STATE_IDLE;

// 每10ms调用一次
uint8_t Key_Task(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
    uint8_t pinVal = HAL_GPIO_ReadPin(GPIOx, GPIO_Pin);
    static uint8_t event = 0;
    
    event = 0;
    
    switch(keyState)
    {
        case KEY_STATE_IDLE:
            if(pinVal == GPIO_PIN_RESET)
                keyState = KEY_STATE_DEBOUNCE;
            break;
            
        case KEY_STATE_DEBOUNCE:
            if(pinVal == GPIO_PIN_RESET)
            {
                keyState = KEY_STATE_PRESSED;
                event = 1;  // 产生按键事件
            }
            else
                keyState = KEY_STATE_IDLE;
            break;
            
        case KEY_STATE_PRESSED:
            if(pinVal == GPIO_PIN_SET)
                keyState = KEY_STATE_IDLE;
            break;
    }
    
    return event;
}

3.7 独立按键检测完整示例

把上面的知识点串起来,写一个完整的按键检测程序。硬件连接:PA0接按键到GND,PC13接板载LED。

// main.c 片段
int main(void)
{
    HAL_Init();
    SystemClock_Config();
    
    // 配置GPIO
    __HAL_RCC_GPIOA_CLK_ENABLE();
    __HAL_RCC_GPIOC_CLK_ENABLE();
    
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    
    // PA0 上拉输入
    GPIO_InitStruct.Pin = GPIO_PIN_0;
    GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
    GPIO_InitStruct.Pull = GPIO_PULLUP;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
    
    // PC13 推挽输出
    GPIO_InitStruct.Pin = GPIO_PIN_13;
    GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
    HAL_GPIO_Init(GPIOC, &GPIO_InitStruct);
    
    // 初始状态:LED灭
    HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET);
    
    while(1)
    {
        // 检测按键,按下时翻转LED
        if(Key_Scan(GPIOA, GPIO_PIN_0))
        {
            HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13);
        }
    }
}

3.8 知识结构总览

下面这张图,把本章的核心逻辑串起来了。从物理按键到CPU识别,每一步都有对应的处理手段。

按键输入知识结构图 物理按键 抖动现象(5~20ms) 硬件消抖(RC滤波) 软件消抖(延时/状态机) GPIO上拉输入配置 独立按键检测程序 CPU识别按键事件 核心原则:先消抖,后判断;硬件软件二选一,或两者结合

3.9 避坑指南

做按键检测,有几个坑我踩过,写出来给你提个醒:

  • 延时太长:有人消抖延时设到50ms,按键按下去要等半天才有反应。10~20ms足够,别贪多。
  • 忘了释放检测:只检测按下,不检测释放。结果按一次触发好几次。一定要等到按键释放,才算一次完整的按键事件。
  • 中断里做延时:在中断服务函数里调HAL_Delay(),系统直接卡死。中断里只做标记,消抖逻辑放主循环或定时器任务里。
  • IO模式配错:按键接地,GPIO却配成下拉输入。结果按下时读不到低电平,因为内部下拉把电平拉死了。

我的经验:量产产品里,我一般用状态机消抖 + 外部4.7kΩ上拉电阻。软件上再加一个20ms的去抖窗口。这套组合,在几十万套产品上验证过,没出过问题。

按键检测,看起来简单,但做扎实了,能省很多调试时间。你想想看,如果连按键都读不准,后面的逻辑再漂亮也没用。打好这个基础,后面做矩阵键盘、长按短按组合键,就水到渠成了。