简介:这份资源面向STM32嵌入式初学者与需要实现按键多态识别的开发者,围绕STM32F103ZET6给出长按、短按、双击三种按键行为的完整工程实现,解决单一按键难以区分多种操作意图的问题。方案采用外部中断捕获按键按下标志,配合20ms定时器轮询状态机判定动作类型:长按控制LED7亮灭并累加data值、同时切换LED8,短按切换LED6,双击切换LED8,长按过程中通过串口输出data值,代码中保留了while阻塞写法便于理解时序。压缩包共142个文件,约3.49MB,以36个c源文件与37个h头文件构成核心驱动与工程逻辑,另含Keil工程文件、编译中间产物及hex、map等输出,可直接打开编译验证。已有5844人学习下载,适合对照源码理解中断加定时器的按键消抖与状态判定思路,并在此基础上扩展自己的交互逻辑。
1. 按键状态机:从“按下去就响”到长按短按双击的完整识别
很多刚接触 STM32F103ZET6 的开发者,按键处理都停留在if(HAL_GPIO_ReadPin(...) == RESET) { 点亮LED; }这一步。真放到产品里,问题立刻暴露:手一抖就触发两次、长按松手才响应、双击根本识别不出来。这个项目要解决的就是这件事——在 STM32F103ZET6 上把一颗普通机械按键做成能区分短按、长按、双击的输入源,用状态机加时间窗口的方式把抖动、误触、连击全部收进可控逻辑里。适合正在做嵌入式按键交互、被“玄学抖动”折磨过的开发者,也适合想把按键模块抽成可复用组件的同学。下面从工程结构、状态机实现、定时器节拍到避坑排查,一路拆到能上手改。
2. 工程结构与按键模块设计:把按键从业务代码里剥出来
2.1 目录分层与模块职责
STM32F103ZET6 属于大容量型号,Flash 512KB、SRAM 64KB,跑裸机按键状态机绰绰有余,没必要上 RTOS。我一般按下面这种分层组织工程,按键相关代码集中在BSP/KEY下,业务层只拿事件,不碰电平:
Project/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ └── stm32f1xx_hal_conf.h │ └── Src/ │ ├── main.c # 初始化 + 主循环调度 │ ├── stm32f1xx_it.c # SysTick / TIM 中断 │ └── stm32f1xx_hal_msp.c ├── BSP/ │ ├── KEY/ │ │ ├── key.h # 事件枚举、对外接口 │ │ └── key.c # 状态机 + 消抖 + 时间窗口 │ └── LED/ │ ├── led.h │ └── led.c └── Drivers/ # HAL 库这样拆的好处很直接:key.c只依赖 HAL 的 GPIO 读接口和一个毫秒时基,换引脚只改key.h里的宏,换芯片只改 GPIO 初始化,业务层main.c里永远只有KEY_EVENT_SHORT、KEY_EVENT_LONG、KEY_EVENT_DOUBLE这几个枚举值。常见做法是把按键扫描放进 1ms 或 5ms 的定时器中断,主循环只消费事件队列,避免在主循环里HAL_Delay阻塞导致漏检。
2.2 引脚配置与上拉输入
ZET6 的按键一般接在 GPIOA 或 GPIOE 上,机械按键一端接引脚、一端接 GND,引脚配成上拉输入,按下读到低电平。用 CubeMX 生成后核心配置如下,手写也一致:
/* key.c 中的 GPIO 初始化,按键接 PA0,低电平有效 */ void KEY_GPIO_Init(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); /* 使能 GPIOA 时钟,漏了读出来永远是 0 */ gpio.Pin = GPIO_PIN_0; gpio.Mode = GPIO_MODE_INPUT; gpio.Pull = GPIO_PULLUP; /* 内部上拉,省一个外部电阻 */ HAL_GPIO_Init(GPIOA, &gpio); }GPIO_MODE_INPUT配GPIO_PULLUP是机械按键最省事的方式,松开时内部上拉拉高,按下被 GND 拉低。参数上唯一要注意的是时钟使能必须在HAL_GPIO_Init之前,这是新手翻车率最高的一行。如果板子上已经有外部上拉,Pull改成GPIO_NOPULL也行,但别两边都不上拉,否则引脚浮空,读出来的值随机跳,后面状态机再严谨也救不回来。
2.3 事件枚举与对外接口
模块对外只暴露三个东西:初始化、周期扫描、取事件。事件用枚举定义,业务层switch一下就能用:
/* key.h */ #ifndef __KEY_H #define __KEY_H #include "main.h" typedef enum { KEY_EVENT_NONE = 0, KEY_EVENT_SHORT, /* 短按:按下后 50ms~800ms 内松开 */ KEY_EVENT_LONG, /* 长按:按住超过 800ms,松手时上报一次 */ KEY_EVENT_DOUBLE /* 双击:两次短按间隔小于 300ms */ } KEY_Event_t; void KEY_Init(void); void KEY_Scan(void); /* 建议 5ms 调一次 */ KEY_Event_t KEY_GetEvent(void); /* 取走事件并清空,非阻塞 */ #endif接口设计上我坚持“扫描”和“取事件”分离:KEY_Scan放在定时器中断或主循环固定节拍里跑,只更新内部状态;KEY_GetEvent在主循环里取,取完自动清KEY_EVENT_NONE。这样按键响应和业务处理解耦,业务里做耗时操作也不会把按键状态机卡死。事件枚举里没有“按下瞬间”这个状态,是因为对短按/长按/双击这套语义来说,只有“动作完成”才有意义,中间态留在状态机内部即可。
3. 状态机核心实现:短按、长按、双击怎么在一套逻辑里共存
3.1 四状态迁移模型
按键识别的本质是“电平 + 时间”的二维判断,用状态机写最不容易乱。我用的四个状态:空闲、消抖确认、按下保持、等待第二击。迁移条件全部基于 5ms 节拍计数,不依赖HAL_Delay:
| 当前状态 | 条件 | 下一状态 | 动作 |
|---|---|---|---|
| IDLE | 读到低电平 | DEBOUNCE | 计数清零 |
| DEBOUNCE | 连续 4 次(20ms)低电平 | PRESSED | 记录按下时刻 |
| DEBOUNCE | 中途读到高电平 | IDLE | 判定为抖动,丢弃 |
| PRESSED | 松开且按住 < 800ms | WAIT_DOUBLE | 暂存一次短按 |
| PRESSED | 按住 ≥ 800ms | PRESSED | 标记长按已触发 |
| WAIT_DOUBLE | 300ms 内再次短按 | IDLE | 上报 DOUBLE |
| WAIT_DOUBLE | 300ms 超时 | IDLE | 上报 SHORT |
这张表就是整个模块的“黑匣子”,出问题先对着它查。20ms 消抖、800ms 长按阈值、300ms 双击窗口这三个数不是拍脑袋,是机械按键手感和大批量一致性的折中,后面避坑章节会讲怎么调。
3.2 扫描函数逐段拆解
/* key.c 核心状态机,5ms 调用一次 */ #define DEBOUNCE_CNT 4 /* 4 * 5ms = 20ms 消抖 */ #define LONG_PRESS_CNT 160 /* 160 * 5ms = 800ms 长按 */ #define DOUBLE_GAP_CNT 60 /* 60 * 5ms = 300ms 双击间隔 */ static uint8_t s_state = 0; /* 0:IDLE 1:DEBOUNCE 2:PRESSED 3:WAIT_DOUBLE */ static uint16_t s_cnt = 0; /* 通用计时计数 */ static uint8_t s_long_fired = 0; /* 长按是否已上报,防止重复 */ static KEY_Event_t s_event = KEY_EVENT_NONE; void KEY_Scan(void) { uint8_t pressed = (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET); switch (s_state) { case 0: /* IDLE */ if (pressed) { s_state = 1; s_cnt = 0; } break; case 1: /* DEBOUNCE */ if (pressed) { if (++s_cnt >= DEBOUNCE_CNT) { s_state = 2; s_cnt = 0; s_long_fired = 0; } } else { s_state = 0; /* 抖动,回空闲 */ } break; case 2: /* PRESSED */ if (pressed) { if (!s_long_fired && ++s_cnt >= LONG_PRESS_CNT) { s_long_fired = 1; /* 长按到点先标记,松手再上报 */ } } else { if (s_long_fired) { s_event = KEY_EVENT_LONG; s_state = 0; } else { s_state = 3; s_cnt = 0; /* 短按,进双击等待窗口 */ } } break; case 3: /* WAIT_DOUBLE */ if (pressed) { s_event = KEY_EVENT_DOUBLE; /* 窗口内再次按下即双击 */ s_state = 0; } else if (++s_cnt >= DOUBLE_GAP_CNT) { s_event = KEY_EVENT_SHORT; /* 窗口超时,确认单次短按 */ s_state = 0; } break; } }逐段看逻辑:case 1是消抖,连续 4 个节拍都读到低才认按下,中途弹起直接回 IDLE,这一层把机械触点 5~15ms 的抖动挡在外面。case 2是按住阶段,s_long_fired这个标志很关键——长按到 800ms 时只置位不上报,等松手才发KEY_EVENT_LONG,这样业务层拿到的长按事件一定伴随“已松开”,不会出现按住不放连续触发的问题。case 3是双击窗口,300ms 内再按就是双击,超时就补发短按。注意s_cnt在case 2里是按住计时、在case 3里是间隔计时,复用一个变量但进入状态时都清零,不会串。
3.3 事件读取与主循环调度
KEY_Event_t KEY_GetEvent(void) { KEY_Event_t e = s_event; s_event = KEY_EVENT_NONE; /* 读走即清,避免重复消费 */ return e; } /* main.c 主循环 */ while (1) { KEY_Scan(); /* 裸机下靠主循环节拍,建议配 5ms 定时器 */ switch (KEY_GetEvent()) { case KEY_EVENT_SHORT: LED_Toggle(); break; case KEY_EVENT_LONG: LED_AllOff(); break; case KEY_EVENT_DOUBLE: LED_AllOn(); break; default: break; } HAL_Delay(5); /* 仅演示;正式项目用 TIM 中断驱动 KEY_Scan */ }KEY_GetEvent读走即清是防重复消费的关键,业务层哪怕一个事件处理里调两次也不会拿到同一个。主循环里HAL_Delay(5)只是让示例能跑,正式项目我一般开 TIM3 做 5ms 中断,在中断里调KEY_Scan,主循环只取事件,这样业务里做 Flash 写入、串口发送这类耗时操作也不会让按键节拍漂移。参数上HAL_Delay(5)和DEBOUNCE_CNT=4是绑定的,如果改成 10ms 节拍,消抖计数要减半,否则消抖时间翻倍,手感变“肉”。
4. 定时器节拍与消抖参数:让识别结果稳定可复现
4.1 用 TIM 中断替代主循环延时
裸机项目里最稳的做法是让硬件定时器产生固定节拍。以 TIM3 为例,72MHz 主频、预分频 7199、重装载 49,得到 5ms 中断:
/* TIM3 初始化:72MHz / (7199+1) / (49+1) = 200Hz,即 5ms */ void KEY_Timer_Init(void) { __HAL_RCC_TIM3_CLK_ENABLE(); TIM3->PSC = 7199; TIM3->ARR = 49; TIM3->DIER |= TIM_DIER_UIE; TIM3->CR1 |= TIM_CR1_CEN; HAL_NVIC_SetPriority(TIM3_IRQn, 2, 0); HAL_NVIC_EnableIRQ(TIM3_IRQn); } void TIM3_IRQHandler(void) { if (TIM3->SR & TIM_SR_UIF) { TIM3->SR &= ~TIM_SR_UIF; KEY_Scan(); /* 状态机在中断里推进 */ } }PSC=7199把 72MHz 分频到 10kHz,ARR=49再数 50 个得到 200Hz,正好 5ms。中断优先级设 2,别设成 0 去抢 SysTick,否则 HAL 的毫秒计时会受影响。状态机放中断里跑,执行时间只有几十条指令,对 5ms 周期毫无压力。这样改完,主循环里那行HAL_Delay(5)就可以删掉,按键节拍由硬件保证,业务再忙也不丢事件。
4.2 三个时间参数的调法
消抖 20ms、长按 800ms、双击 300ms 是通用值,但不同按键手感差异很大。轻触按键弹片软,消抖可以降到 10~15ms;工业面板上的大按键回弹慢,20~30ms 更稳。长按阈值看场景:菜单进入用 800ms 合适,紧急停止类操作建议 1.5s 以上防误触。双击窗口 300ms 对大多数人顺手,手速慢的用户可以放到 400ms,但别超过 500ms,否则单击响应会明显变迟钝。调参时把这三个宏单独拎出来,改完用串口打印事件时间戳验证,比盯着 LED 猜靠谱得多。
4.3 多按键扩展的写法
一个按键一套状态机变量,两个按键就复制一份s_state/s_cnt会很难看。常见做法是抽成结构体数组:
typedef struct { GPIO_TypeDef *port; uint16_t pin; uint8_t state; uint16_t cnt; uint8_t long_fired; KEY_Event_t event; } KEY_Obj_t; static KEY_Obj_t s_keys[] = { { GPIOA, GPIO_PIN_0, 0, 0, 0, KEY_EVENT_NONE }, { GPIOA, GPIO_PIN_1, 0, 0, 0, KEY_EVENT_NONE }, };KEY_Scan里for循环遍历数组,每个对象独立跑同一套迁移逻辑,事件按索引取。这样加按键只改数组,状态机代码一行不动,后面做组合键(两个键同时按下)也有地方挂。
5. 避坑与排查:按键识别翻车的五个真实场景
5.1 现象:不按也触发,事件乱跳
原因通常是引脚浮空或上拉没配。GPIO_MODE_INPUT配了GPIO_NOPULL又没外部上拉,引脚电平随环境漂,状态机把噪声当按键。解决:确认Pull为GPIO_PULLUP,或万用表量松开时引脚是否为 3.3V。另一个隐蔽原因是 GPIO 时钟没使能,读出来恒为 0,表现成“一直按着”,长按事件刷屏。
5.2 现象:按一次出两次短按
消抖时间不够,或者KEY_Scan节拍不稳。机械按键弹跳能持续 10ms 以上,DEBOUNCE_CNT=2(10ms)在劣质按键上就会漏。解决:消抖提到 20ms 以上,并确认扫描由定时器驱动而不是主循环里带HAL_Delay的软节拍。还有一种情况是KEY_GetEvent没做读走即清,业务层轮询两次拿到同一个事件。
5.3 现象:双击永远识别不出来
先查双击窗口是不是太短。DOUBLE_GAP_CNT=60对应 300ms,如果扫描节拍实际是 10ms 而不是 5ms,窗口就缩成 600ms 的一半都不到,手速稍慢就超时。解决:用示波器或串口打时间戳确认KEY_Scan真实周期,再按实际节拍换算计数。另一个原因是第一次短按后状态机没进WAIT_DOUBLE,检查case 2里s_long_fired是否被误置位。
5.4 现象:长按松手后短按也触发
case 2里长按上报后没把状态清干净,或者s_long_fired在下次按下时没复位。解决:进入PRESSED状态时(case 1迁移那行)必须s_long_fired = 0,长按上报后直接回IDLE,不要落进WAIT_DOUBLE。这个顺序错了,长按会被当成一次短按再进双击窗口,松手 300ms 后补发一个短按,业务层就看到“长按 + 短按”两个事件。
5.5 现象:加了串口打印后按键变迟钝
在KEY_Scan或中断里调printf,串口阻塞把 5ms 节拍拖成几十毫秒,消抖和长按计时全乱。解决:中断里只置标志、不打印,调试信息在主循环里输出;或者用 DMA 串口。血泪经验是按键状态机里任何阻塞调用都是定时炸弹,包括HAL_Delay、带等待的 Flash 操作。
6. 二次开发:把按键模块改成配置驱动与组合键支持
这套代码拿去改,最值得动的是把硬编码宏变成运行时可配的结构体,顺手把组合键支持加上。我一般这么改:给KEY_Obj_t加debounce_ms、long_ms、double_ms三个字段,初始化时按毫秒填,KEY_Scan里用ms / 5换算成计数,这样同一份代码能同时服务轻触键和工业大按键,不用改宏重编译。组合键则在扫描完所有按键后加一层判定:两个键的state同时处于PRESSED且都未触发长按,就合成一个KEY_EVENT_COMBO,并给两个键都置long_fired屏蔽后续单键事件。
验证方法别靠肉眼盯 LED。我会在KEY_GetEvent里挂一个环形缓冲,把事件类型和HAL_GetTick()时间戳存进去,主循环通过串口按固定格式吐出,再用脚本统计 100 次按压的短按/长按/双击分布和误触发率。改完参数跑一轮,数据对不上就回去查状态迁移表,比反复烧录猜原因快得多。
| 改造点 | 改动位置 | 验证方式 |
|---|---|---|
| 参数运行时可配 | KEY_Obj_t加毫秒字段 | 串口打印实际计数 |
| 组合键 | KEY_Scan末尾加判定 | 双键同按看事件类型 |
| 事件缓冲 | KEY_GetEvent前加环形队列 | 统计误触发率 |
从那以后我每次动按键状态机,都强制先把三个时间参数用串口打出来跑一遍 100 次按压统计,确认分布正常再进业务联调,吃过一次“消抖少 5ms 导致产线批量双击误判”的亏之后,这个习惯再没省过。希望帮到你。
本文还有配套的精品资源,点击获取