STM32 零基础可移植教程 05:按键消抖,为什么按一次会触发好几次

STM32 零基础可移植教程 05:按键消抖,为什么按一次会触发好几次

上一篇我们已经能读到按键电平了:按住按键,LED 亮;松开按键,LED 灭。

这说明 GPIO 输入、上拉/下拉、按键有效电平都已经跑通。

但你后面很快会遇到一个新问题:

bash 复制代码
明明只按了一次,程序却像按了好几次

比如你想实现"按一下 LED 翻转一次",结果 LED 可能亮了又灭,或者连续翻转几次。代码看起来没问题,按键也确实只按了一下,但程序就是多触发。

这不是 STM32 特有的问题,也不是 HAL 库的锅。机械按键本来就会抖。

这一篇只解决一个目标:

bash 复制代码
用软件消抖,让按键按下一次只触发一次 LED 翻转

外部中断、长按、双击、组合键先不讲。先把最基础的"按下一次,识别一次"做稳。

本篇目标

最终现象:

bash 复制代码
每按下一次按键,LED 翻转一次

按住不放,LED 不会一直翻转

松开后再按,才会再次翻转

本篇用到的外设:

bash 复制代码
GPIO Input

GPIO Output

SysTick / HAL_GetTick

本篇跑通标准:

  • Keil 编译通过;

  • 程序能下载到开发板;

  • 按键按下一次,LED 只翻转一次;

  • 按住按键不松手,LED 不会连续翻转;

  • 能说清楚"按键抖动"和"消抖时间"是什么意思。

准备工作

你需要准备:

|

项目

|

说明

|

| --- | --- |

|

STM32 开发板

|

任意 STM32 开发板

|

|

下载器

|

ST-LINK/V2 或板载 ST-LINK

|

|

LED

|

沿用第 02 篇 LED 工程

|

|

按键

|

沿用第 04 篇按键输入工程

|

|

原理图

|

确认按键引脚、上拉/下拉、有效电平

|

建议直接从上一篇 04_key_input 复制一份,改名为:

bash 复制代码
05_key_debounce

这样 LED 和按键的 CubeMX 配置都能保留,我们只升级按键应用层代码。

为什么按键会抖

机械按键不是理想开关。

你手指按下去时,里面的金属触点不是"一瞬间稳定接通",而是会在很短时间内反复接触、弹开、再接触。

在示波器或逻辑分析仪上看,按键电平可能不是这样:

bash 复制代码
松开 ---------------- 按下

高电平 -------------- 低电平

而是这样:

bash 复制代码
高 -> 低 -> 高 -> 低 -> 高 -> 低 -> 稳定低

这段乱跳通常只持续几毫秒到几十毫秒。

对人来说,这还是一次按下;对 MCU 来说,它可能已经看到好几个边沿了。

所以我们需要做消抖。

本篇采用哪种消抖方式

按键消抖有很多种写法:

  • 简单延时消抖;

  • 计数消抖;

  • 状态机消抖;

  • 定时器周期扫描;

  • 外部中断 + 延时确认。

新手最容易想到的是:

bash 复制代码
if
 (按键按下)

{

    HAL_Delay(
20
);

    
if
 (按键仍然按下)

    {

        确认按下;

    }

}

这个思路能用,但它有一个问题:HAL_Delay() 会堵住主循环。后面你的工程里如果还有串口、蜂鸣器、传感器、通信任务,主循环被堵住就不舒服了。

所以这一篇我们用一个更适合长期扩展的写法:

bash 复制代码
每次主循环调用 App_Key_Scan()

函数内部用 HAL_GetTick() 判断电平稳定了多久

稳定超过 20 ms 后,才产生一次按下/松开事件

这种写法不需要在按键函数里长时间阻塞。

这里有一个地方很容易想岔。

很多人第一次理解消抖,会觉得:

bash 复制代码
检测到按键按下

等它超过 20 ms

然后翻转 LED

这个理解只对了一半。

因为如果代码只是判断"按键已经按下超过 20 ms",那手一直按着时,这个条件会一直成立。主循环每扫描一次,都可能再次翻转 LED。

也就是说,下面这种思路仍然不够:

bash 复制代码
if
 (按键按下超过 
20
 ms)

{

    App_LED_Toggle();

}

它跳过了抖动瞬间,但没有解决"长按时重复触发"的问题。

真正稳定的写法应该是:

bash 复制代码
检测到电平变化

等待 20 ms

确认这个电平仍然稳定

如果稳定状态从"松开"变成"按下"

才产生一次"按下事件"

重点是最后这句:

bash 复制代码
状态变了,才产生事件

按键一直按着时,它的稳定状态一直是"按下",但它不是每一轮扫描都在"刚刚按下"。所以 LED 只应该在"松开 -> 按下"这个变化发生后翻转一次。

换句话说,消抖里面其实有两件事:

|

动作

|

解决的问题

|

| --- | --- |

|

等 20 ms 再确认

|

跳过机械触点刚接触时的抖动

|

|

判断状态变化

|

避免按住不放时一直触发

|

所以这篇代码里才会同时出现两个概念:

bash 复制代码
稳定状态:现在到底是按下还是松开

按键事件:这一次有没有刚刚按下或刚刚松开

如果只看电平,就容易写成长按一直触发。

如果把稳定电平转换成事件,就能做到:

bash 复制代码
按下一次 -> 产生一次按下事件 -> LED 翻转一次

一直按住 -> 不再产生新事件

松开后再按 -> 再产生下一次按下事件

这就是本篇消抖代码真正想解决的问题。

硬件连接

硬件连接沿用上一篇。

按键常见有两种接法。

第一种:按键一端接 GPIO,一端接 GND。

bash 复制代码
GPIO ---- 按键 ---- GND

这种情况下通常使用上拉:

bash 复制代码
松开:读到 1

按下:读到 0

第二种:按键一端接 GPIO,一端接 3.3V。

bash 复制代码
GPIO ---- 按键 ---- 3.3V

这种情况下通常使用下拉:

bash 复制代码
松开:读到 0

按下:读到 1

本篇代码默认按键是低电平有效:

bash 复制代码
#define APP_KEY_PRESSED_LEVEL  GPIO_PIN_RESET

如果你的按键是高电平有效,后面改成 GPIO_PIN_SET 即可。

CubeMX 配置步骤

1. 保留 LED 输出配置

LED 的配置沿用第 02 篇:

|

配置项

|

推荐值

|

| --- | --- |

|

GPIO mode

|

GPIO_Output

|

|

User Label

|

LED

|

|

初始电平

|

按 LED 有效电平设置为默认灭

|

这一篇用 LED 来显示按键事件。

按键每确认一次"按下事件",LED 翻转一次。

2. 保留按键输入配置

按键配置沿用第 04 篇:

|

配置项

|

推荐值

|

| --- | --- |

|

GPIO mode

|

GPIO_Input

|

|

User Label

|

KEY

|

|

Pull-up/Pull-down

|

按原理图选择

|

如果你的按键一端接 GND,通常选择:

bash 复制代码
Pull-up

如果你的按键一端接 3.3V,通常选择:

bash 复制代码
Pull-down

3. 生成 Keil 工程

配置确认后点击:

bash 复制代码
GENERATE CODE

然后打开 Keil 工程,先编译一次。

Keil 工程生成和编译

打开 Keil 后,先编译:

bash 复制代码
Build / F7

确认输出里没有错误:

bash 复制代码
0 Error(s)

这一篇主要修改 app_key.h/.cmain.c,CubeMX 配置变化不大。

完整代码

这一篇我们升级上一篇的按键模块。

你需要保留:

bash 复制代码
Core/Inc/app_led.h

Core/Src/app_led.c

然后把上一篇的 app_key.h/.c 替换成下面这个消抖版本:

bash 复制代码
Core/Inc/app_key.h

Core/Src/app_key.c

1. 更新 Core/Inc/app_key.h

打开:

bash 复制代码
Core/Inc/app_key.h

替换为下面代码:

bash 复制代码
#ifndef APP_KEY_H

#define APP_KEY_H


#include "main.h"


typedef
enum

{

    APP_KEY_LEVEL_RELEASED = 
0
,

    APP_KEY_LEVEL_PRESSED = 
1

} App_KeyLevel;


typedef
enum

{

    APP_KEY_EVENT_NONE = 
0
,

    APP_KEY_EVENT_PRESSED,

    APP_KEY_EVENT_RELEASED

} App_KeyEvent;


void App_Key_Init(void)
;

App_KeyLevel App_Key_ReadLevel(void)
;

App_KeyEvent App_Key_Scan(void)
;


#endif

这里分成两个概念:

|

名称

|

含义

|

| --- | --- |

| App_KeyLevel |

当前稳定状态:按下还是松开

|

| App_KeyEvent |

这一次扫描有没有新事件:刚按下、刚松开、无事件

|

为什么要分开?

因为"当前按着"不等于"刚刚按下"。

按住不放时,状态一直是按下,但事件只应该触发一次。

2. 更新 Core/Src/app_key.c

打开:

bash 复制代码
Core/Src/app_key.c

替换为下面代码:

bash 复制代码
#include "app_key.h"


/* * KEY_GPIO_Port and KEY_Pin are generated by CubeMX in main.h. * Set the key pin User Label to KEY in CubeMX. */

#ifndef KEY_GPIO_Port

#error "KEY_GPIO_Port is not defined. Set the key pin User Label to KEY in CubeMX."

#endif


#ifndef KEY_Pin

#error "KEY_Pin is not defined. Set the key pin User Label to KEY in CubeMX."

#endif


/* * Many key circuits are active-low: * released -> GPIO_PIN_SET * pressed  -> GPIO_PIN_RESET * * If your key is active-high, change this macro to GPIO_PIN_SET. */

#ifndef APP_KEY_PRESSED_LEVEL

#define APP_KEY_PRESSED_LEVEL  GPIO_PIN_RESET

#endif


#ifndef APP_KEY_DEBOUNCE_MS

#define APP_KEY_DEBOUNCE_MS    20u

#endif


static
 App_KeyLevel s_stable_level = APP_KEY_LEVEL_RELEASED;

static
 App_KeyLevel s_last_sample_level = APP_KEY_LEVEL_RELEASED;

static
uint32_t
 s_last_change_tick = 
0u
;


static App_KeyLevel App_Key_ReadRawLevel(void)
{

    GPIO_PinState level = HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin);


    
if
 (level == APP_KEY_PRESSED_LEVEL)

    {

        
return
 APP_KEY_LEVEL_PRESSED;

    }


    
return
 APP_KEY_LEVEL_RELEASED;

}


void App_Key_Init(void)
{

    App_KeyLevel level = App_Key_ReadRawLevel();


    s_stable_level = level;

    s_last_sample_level = level;

    s_last_change_tick = HAL_GetTick();

}


App_KeyLevel App_Key_ReadLevel(void)
{

    
return
 s_stable_level;

}


App_KeyEvent App_Key_Scan(void)
{

    App_KeyLevel current_sample = App_Key_ReadRawLevel();

    
uint32_t
 now = HAL_GetTick();


    
if
 (current_sample != s_last_sample_level)

    {

        s_last_sample_level = current_sample;

        s_last_change_tick = now;

    }


    
if
 ((now - s_last_change_tick) >= APP_KEY_DEBOUNCE_MS)

    {

        
if
 (s_stable_level != s_last_sample_level)

        {

            s_stable_level = s_last_sample_level;


            
if
 (s_stable_level == APP_KEY_LEVEL_PRESSED)

            {

                
return
 APP_KEY_EVENT_PRESSED;

            }


            
return
 APP_KEY_EVENT_RELEASED;

        }

    }


    
return
 APP_KEY_EVENT_NONE;

}

这段代码不长,但比上一篇多了状态。

核心逻辑是:

  1. 每次读取一次按键原始电平;

  2. 如果发现电平变化,就记录变化时间;

  3. 如果这个电平稳定超过 APP_KEY_DEBOUNCE_MS

  4. 并且稳定状态真的发生变化;

  5. 才返回一次按下或松开事件。

默认消抖时间是:

bash 复制代码
#define APP_KEY_DEBOUNCE_MS    20u

大多数普通按键用 10 ms 到 30 ms 都可以。你可以先用 20 ms。

3. 确认 app_key.c 加入 Keil 工程

如果你是从上一篇工程复制过来的,app_key.c 大概率已经在 Keil 工程里。

但还是建议检查一下:

bash 复制代码
Application/User/Core

里面应该能看到:

bash 复制代码
app_key.c

app_led.c

如果没有,就右键 Application/User/Core,选择:

bash 复制代码
Add Existing Files to Group 'Application/User/Core'

然后添加:

bash 复制代码
Core/Src/app_key.c

main.c 调用方式

这一篇的 main.c 比上一篇更清楚:主循环不停扫描按键,一旦检测到"按下事件",就翻转 LED。

1. Includes 区域

找到:

bash 复制代码
/* USER CODE BEGIN Includes */

/* USER CODE END Includes */

改成:

bash 复制代码
/* USER CODE BEGIN Includes */

#include "app_led.h"

#include "app_key.h"

/* USER CODE END Includes */

2. 初始化区域

确保 MX_GPIO_Init() 已经在前面执行:

bash 复制代码
MX_GPIO_Init();

然后在 USER CODE BEGIN 2 里添加:

bash 复制代码
/* USER CODE BEGIN 2 */

App_LED_Init();

App_Key_Init();

/* USER CODE END 2 */

App_Key_Init() 会读取一次当前按键状态,把它作为初始稳定状态。

3. while 循环区域

找到:

bash 复制代码
while
 (
1
)

{

  
/* USER CODE END WHILE */


  
/* USER CODE BEGIN 3 */

  
/* USER CODE END 3 */

}

改成:

bash 复制代码
while
 (
1
)

{

/* USER CODE END WHILE */


/* USER CODE BEGIN 3 */

if
 (App_Key_Scan() == APP_KEY_EVENT_PRESSED)

  {

      App_LED_Toggle();

  }


  HAL_Delay(
5
);

/* USER CODE END 3 */

}

这里要注意一个细节:

bash 复制代码
HAL_Delay(
5
);

不是消抖本身,它只是让主循环每隔大约 5 ms 扫描一次按键。

真正的消抖判断在 App_Key_Scan() 里,由 HAL_GetTick()APP_KEY_DEBOUNCE_MS 完成。

不要在这个 while 里写很长的延时,比如:

bash 复制代码
HAL_Delay(
1000
);

如果主循环 1 秒才扫描一次按键,那短按很容易被错过。

编译、下载和验证

代码加完后,先编译:

bash 复制代码
Build / F7

如果没有错误,再下载:

bash 复制代码
Download

下载后观察现象:

bash 复制代码
按下一次按键 -> LED 翻转一次

按住不放 -> LED 不继续翻转

松开后再按 -> LED 再翻转一次

如果你用 Keil 下载后程序没有自动跑,但按复位键后能跑,先按复位键验证外设现象。这个问题不影响本篇按键消抖逻辑。

移植到其他板子的修改点

这篇的移植点主要有 6 个。

|

要改的地方

|

为什么要改

|

在哪里改

|

| --- | --- | --- |

|

按键引脚

|

不同板子的按键接到不同 GPIO

|

CubeMX Pinout 页面

|

|

User Label

|

代码依赖 KEY_PinKEY_GPIO_Port

|

CubeMX GPIO 页面,标签设为 KEY

|

|

上拉/下拉

|

按键接 GND 还是 3.3V 不同

|

CubeMX GPIO Pull-up/Pull-down

|

|

按下有效电平

|

有些按下读 0,有些按下读 1

| APP_KEY_PRESSED_LEVEL |

|

消抖时间

|

不同按键抖动时间不同

| APP_KEY_DEBOUNCE_MS |

|

主循环扫描周期

|

扫描太慢会错过短按

| while (1)

里的 HAL_Delay(5)

|

推荐移植顺序:

  1. 看原理图,确认按键接到哪个 MCU 引脚;

  2. 确认按键按下时是高电平还是低电平;

  3. CubeMX 配置 GPIO_Input

  4. User Label 填 KEY

  5. 按原理图选择 Pull-up 或 Pull-down;

  6. 修改 APP_KEY_PRESSED_LEVEL

  7. 先用 20 ms 消抖;

  8. 如果误触发,再适当加到 30 ms 或 50 ms。

常见问题排查

1. 按一下还是触发好几次

优先检查:

|

优先检查

|

具体方法

|

| --- | --- |

|

是否使用了 App_Key_Scan()

|

不要继续用上一篇的 App_Key_Read() 直接判断

|

|

是否只处理 APP_KEY_EVENT_PRESSED

|

按住状态不是事件,事件只应触发一次

|

|

消抖时间是否太短

|

APP_KEY_DEBOUNCE_MS 从 20 改到 30 或 50

|

|

主循环是否有其它长延时

|

避免 HAL_Delay(1000) 这类长阻塞

|

2. 按下没反应

优先检查:

  • 按键引脚是否选错;

  • Pull-up/Pull-down 是否选反;

  • APP_KEY_PRESSED_LEVEL 是否和硬件一致;

  • app_key.c 是否加入 Keil 工程;

  • App_Key_Init() 是否放在 MX_GPIO_Init() 后面;

  • App_Key_Scan() 是否在 while (1) 里反复调用。

3. 逻辑反了:松开时触发

大概率是有效电平写反。

打开 app_key.c,找到:

bash 复制代码
#define APP_KEY_PRESSED_LEVEL  GPIO_PIN_RESET

如果你的按键按下读到高电平,改成:

bash 复制代码
#define APP_KEY_PRESSED_LEVEL  GPIO_PIN_SET

4. 长按时 LED 还是一直翻转

检查你的主循环是不是写成了这样:

bash 复制代码
if
 (App_Key_ReadLevel() == APP_KEY_LEVEL_PRESSED)

{

    App_LED_Toggle();

}

这种写法判断的是"当前是否按着",不是"刚刚按下事件"。

本篇应该使用:

bash 复制代码
if
 (App_Key_Scan() == APP_KEY_EVENT_PRESSED)

{

    App_LED_Toggle();

}

5. 短按容易丢

如果你按得比较快,程序没反应,优先看主循环里有没有长时间阻塞。

比如:

bash 复制代码
HAL_Delay(
1000
);

这会让主循环 1 秒才回来一次,短按很容易错过。

先改成:

bash 复制代码
HAL_Delay(
5
);

后面工程复杂了,可以用定时器周期扫描或 RTOS 任务来处理按键。

6. undefined symbol App_Key_Scan

通常是 Keil 里编译的还是旧文件,或者 app_key.c 没有加入工程。

检查:

  • Core/Src/app_key.c 是否已经替换成本文新版;

  • Keil 工程树里是否有 app_key.c

  • 是否重新编译了整个工程。

本篇小结

这一篇我们把按键从"读电平"升级到了"识别事件"。

你现在至少应该知道:

  • 机械按键会抖,按一次可能产生多个电平跳变;

  • 消抖不是玄学,本质是等待电平稳定一小段时间;

  • App_Key_ReadLevel() 表示当前稳定状态;

  • App_Key_Scan() 表示这次扫描是否产生新事件;

  • 按下一次只翻转一次 LED,应该处理 APP_KEY_EVENT_PRESSED

  • 主循环不能被长时间 HAL_Delay() 堵住,否则会影响按键扫描;

  • 移植时重点改按键引脚、上拉/下拉、有效电平和消抖时间。

下一篇我们可以继续讲:

STM32 外部中断按键:不用一直在 while 里盯着按键。

不过在进入外部中断之前,建议你先把这一篇跑通。因为后面即使用中断,机械按键抖动这个问题也不会消失,只是触发方式变了。

相关推荐
czhaii5 小时前
跟我动手学FX系列PLC GX2环境
嵌入式硬件
拾知_H6 小时前
STM32/Delay延时函数编程思路
stm32·单片机·时钟·延时
2zcode7 小时前
基于STM32的智能扫地机器人设计与实现
stm32·嵌入式硬件·机器人
jllllyuz8 小时前
单相并网逆变器控制代码实现(STM32版)
stm32·单片机·嵌入式硬件
冉卓电子9 小时前
GD32C103RBT6 misc 内核驱动库极简解析
单片机·嵌入式硬件
yongui478349 小时前
MAX6675 K型热电偶温度采集程序(Keil环境)
单片机·嵌入式硬件
豆包公子9 小时前
AUTOSAR CP XCP 移植到裸机 MCU-实践篇
单片机·嵌入式硬件
三佛科技-134163842129 小时前
智能暖脚按摩器方案开发,智能暖脚按摩器MCU单片机主控芯片选择 (FT60F系列8位MCU)
单片机·嵌入式硬件·物联网·智能家居·pcb工艺
是温不嗜温10 小时前
芯茂微100V SR同步整流方案技术解析:效率+5%、温降-20°C,管脚兼容直接替代传统肖特基
嵌入式硬件·电源管理·电源芯片·ac-dc