BA4RDK

欢迎来到我的博客

博客园 首页 新随笔 联系 订阅 管理

STM32 按键扫描的正确姿势:状态机 + 位标志,一次搞定消抖、长按、短按

副标题:一个单按键,短按翻页、长按触发长按事件,互不干扰,代码只有 40 行。


一、需求

项目里只有一个物理按键,但要实现两种操作:

  • 短按:翻页(切到下个界面)
  • 长按(≥1 秒):触发长按事件

看起来简单,但做起来有几个坑:

  1. 机械抖动:按下和松开的瞬间,电平会抖几十毫秒,直接读会误判成多次按下。
  2. 长按过程中不能误触发短按:长按 1 秒,如果不处理,松开时会被当成短按再翻一次页。
  3. 中断里写、主循环读:标志位被编译器优化掉读操作,导致主循环永远读不到新值。

这篇文章用 状态机 + 位标志 的方案,把这三个坑都填上。


二、整体设计

1. 用状态机消抖

按键只有两个稳定状态:释放按下。消抖的思路是:连续采样 N 次电平一致,才认为状态真的变了。

释放态 ──连续2次低电平──> 按下态
按下态 ──连续2次高电平──> 释放态

2. 用位标志报告「事件」而不是「状态」

状态是「按键现在按着还是松开」,事件是「按键刚刚发生了什么」。主循环关心的是事件(有没有一次新的按下/松开/长按),而不是持续的状态。

所以用 key_flags 一个字节,三个位分别表示三个事件:

#define KEY_PRESS   0x01   // 按下事件
#define KEY_RELEASE 0x02   // 短按松开事件
#define KEY_LONG    0x04   // 长按事件

产生事件用 |= 置位,消费事件用 &= ~ 清零。

3. 短按/长按的区分放在按键模块内部

关键点:长按触发过之后,松开时就不再产生 KEY_RELEASE。这样 KEY_RELEASE 的语义就是纯粹的「短按松开」,主循环根本不用判断是不是长按,直接翻页即可。


三、完整代码

Key.h

#ifndef __KEY_H
#define __KEY_H

#include "main.h"

//按键
#define KEY_GPIO_Port GPIOC
#define KEY_Pin GPIO_PIN_9
//电平
#define KEY_DOWN 0
#define KEY_UP 1
//事件
#define KEY_PRESS   0x01
#define KEY_RELEASE 0x02
#define KEY_LONG    0x04

extern volatile uint8_t key_flags;
void key_scan(void);
#endif

Key.c

#include "Key.h"

//在中断里被写、主循环里读,不加会被编译器优化掉读操作,导致主循环读不到新值
volatile uint8_t key_flags = 0;

void key_scan(void)
{
    static uint8_t key_state = 0;     // 0=释放 1=按下
    static uint8_t key_cnt   = 0;     // 消抖计数
    static uint16_t hold_cnt = 0;     // 长按计时
    static uint8_t long_fired = 0;    // 长按是否已触发

    uint8_t cur = HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin);

    switch (key_state) {

    case 0:                             // 释放态,检测是否按下
        if (cur == KEY_DOWN) {
            if (++key_cnt >= 2) {       // 连续 2 次低电平才算按下
                key_state = 1;
                key_cnt   = 0;
                hold_cnt  = 0;          // 复位长按计时
                long_fired = 0;         // 复位长按标志,保证每次按下都能触发
                key_flags |= KEY_PRESS; // 触发按下事件
            }
        } else key_cnt = 0;
        break;

    case 1:                             // 按下态,检测释放 / 长按
        if (cur == KEY_UP) {
            if (++key_cnt >= 2) {
                key_state = 0;
                key_cnt   = 0;
                if (!long_fired) key_flags |= KEY_RELEASE; // 只有没触发过长按,才发“短按释放”
            }
        } else {
            key_cnt = 0;
            if (!long_fired && ++hold_cnt >= 100)   // 100 × 10ms = 1秒
            {
                long_fired = 1;         // 置位,保证只触发一次
                key_flags |= KEY_LONG;
            }
        }
        break;
    }
}

四、逐段解析

1. 为什么 key_flags 要加 volatile

key_scan()SysTick 中断里执行(后面会讲),key_flags 在中断里被写、主循环里读。

编译器做优化时,如果不知道这个变量会被中断改变,可能会把主循环里「读 key_flags」优化成只读一次、甚至直接缓存到寄存器。加上 volatile 就是告诉编译器:每次都老老实实去内存里读最新值,别优化

这是中断共享变量的铁律:一边写、一边读的变量,必须 volatile

2. key_state 状态机

只有两个状态,switch 清晰明了:

  • case 0(释放态):检测低电平(按下),连续 2 次采样都是低,才切到按下态。
  • case 1(按下态):检测高电平(松开),连续 2 次都是高,才切回释放态;期间一直低,就计时长按。

3. 消抖:key_cnt 为什么是 >= 2

key_scan() 每 10ms 被调用一次。机械按键抖动通常持续 5~20ms。要求「连续 2 次采样电平一致」,等于要求电平稳定 20ms 才算数,把抖动滤掉了。

如果只判断 == 1,任何一次瞬时抖动都会被当成一次有效动作。

4. 长按计时:hold_cntlong_fired

按下态里,每次采样到低电平,hold_cnt 自增。因为是每 10ms 扫一次,hold_cnt >= 100 就是 100 × 10ms = 1 秒,达到就触发长按。

long_fired 是关键:它保证长按只触发一次。没有它,长按 3 秒就会连续触发 3 次长按事件。

5. 短按/长按冲突的解决

这是最容易踩的坑。看 case 1 里的释放分支:

if (!long_fired) key_flags |= KEY_RELEASE;
  • 短按:按下后 1 秒内就松开,long_fired 还是 0,正常产生 KEY_RELEASE
  • 长按:按住超过 1 秒,long_fired 被置 1;之后松开时 !long_fired 为假,不产生 KEY_RELEASE

这样,主循环只要看到 KEY_RELEASE 就翻页,看到 KEY_LONG 就做长按的事,二者天然互斥,不需要额外的 key_long_pressed 标记变量

6. 为什么 long_firedhold_cnt 要在 case 0 里复位

case 0:
    if (cur == KEY_DOWN) {
        if (++key_cnt >= 2) {
            ...
            hold_cnt  = 0;   // 复位长按计时
            long_fired = 0;  // 复位长按标志
            ...

这是很多人会漏的地方。如果不复位,第二次按键时 long_fired 还残留着 1,会导致「长按只能用一次,再按就不灵了」。每次进入按下态,都要把所有相关计时/标志清零,保证这次按键是全新的开始。


五、SysTick 调度

key_scan() 需要周期性调用,放 SysTick 中断里最省事。SysTick 默认 1ms 进一次,加个计数器凑 10ms:

void SysTick_Handler(void)
{
    HAL_IncTick();
    Key_Time++;                 // 每 1ms 自增

    if (Key_Time >= 10) {       // 满 10ms 扫一次
        Key_Time = 0;
        key_scan();
    }
}

Key_Time 是用户变量,记得在 stm32f4xx_it.c 的 USER CODE PV 区定义:uint8_t Key_Time = 0;


六、主循环消费事件

主循环里轮询 key_flags,读到哪个事件就清零哪个:

while (1)
{
    // 按下事件:这里暂不响应(可做“按下即亮屏”之类)
    if (key_flags & KEY_PRESS) {
        key_flags &= ~KEY_PRESS;
    }

    // 长按事件:触发长按动作
    if (key_flags & KEY_LONG) {
        key_flags &= ~KEY_LONG;
        // 长按要做什么写这里
    }

    // 短按松开事件:翻页
    if (key_flags & KEY_RELEASE) {
        key_flags &= ~KEY_RELEASE;
        UI_NextPage();
    }
}

注意:消费(&= ~)和判断(&)要成对,读完立刻清掉,否则下一次循环还会读到同一个事件,重复执行。


七、总结

这套方案的精髓就三点:

问题 解决方案
机械抖动误触发 状态机 + 连续 2 次采样一致才切换
长按误触发短按 long_fired 标记,长按后松开不产生 KEY_RELEASE
中断共享变量读不到 volatile 关键字
长按重复触发 long_fired 置位后不再触发
长按只能用一次 case 0 里复位 hold_cntlong_fired

代码短、无结构体、无动态内存,跑在 10ms 定时中断里开销极小,适合资源紧张的 MCU 和只有一个按键的小设备。


posted on 2026-08-22 20:30  BA4RDK  阅读(52)  评论(0)    收藏  举报