STM32 按键扫描的正确姿势:状态机 + 位标志,一次搞定消抖、长按、短按
副标题:一个单按键,短按翻页、长按触发长按事件,互不干扰,代码只有 40 行。
一、需求
项目里只有一个物理按键,但要实现两种操作:
- 短按:翻页(切到下个界面)
- 长按(≥1 秒):触发长按事件
看起来简单,但做起来有几个坑:
- 机械抖动:按下和松开的瞬间,电平会抖几十毫秒,直接读会误判成多次按下。
- 长按过程中不能误触发短按:长按 1 秒,如果不处理,松开时会被当成短按再翻一次页。
- 中断里写、主循环读:标志位被编译器优化掉读操作,导致主循环永远读不到新值。
这篇文章用 状态机 + 位标志 的方案,把这三个坑都填上。
二、整体设计
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_cnt 和 long_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_fired 和 hold_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_cnt 和 long_fired |
代码短、无结构体、无动态内存,跑在 10ms 定时中断里开销极小,适合资源紧张的 MCU 和只有一个按键的小设备。
浙公网安备 33010602011771号