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再读一次,确认电平没变,才算有效。
流程如下:
- 读取GPIO电平
- 如果与上次状态不同,启动延时(10~20ms)
- 延时结束后,再次读取GPIO电平
- 如果电平与第一次一致,确认按键状态变化
- 如果电平不一致,视为抖动,忽略
代码实现(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识别,每一步都有对应的处理手段。
3.9 避坑指南
做按键检测,有几个坑我踩过,写出来给你提个醒:
- 延时太长:有人消抖延时设到50ms,按键按下去要等半天才有反应。10~20ms足够,别贪多。
- 忘了释放检测:只检测按下,不检测释放。结果按一次触发好几次。一定要等到按键释放,才算一次完整的按键事件。
- 中断里做延时:在中断服务函数里调HAL_Delay(),系统直接卡死。中断里只做标记,消抖逻辑放主循环或定时器任务里。
- IO模式配错:按键接地,GPIO却配成下拉输入。结果按下时读不到低电平,因为内部下拉把电平拉死了。
我的经验:量产产品里,我一般用状态机消抖 + 外部4.7kΩ上拉电阻。软件上再加一个20ms的去抖窗口。这套组合,在几十万套产品上验证过,没出过问题。
按键检测,看起来简单,但做扎实了,能省很多调试时间。你想想看,如果连按键都读不准,后面的逻辑再漂亮也没用。打好这个基础,后面做矩阵键盘、长按短按组合键,就水到渠成了。