news 2026/10/26 5:12:32

STM32F103ZET6按键状态机:短按长按双击识别与消抖实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32F103ZET6按键状态机:短按长按双击识别与消抖实战

简介:这份资源面向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松开且按住 < 800msWAIT_DOUBLE暂存一次短按
PRESSED按住 ≥ 800msPRESSED标记长按已触发
WAIT_DOUBLE300ms 内再次短按IDLE上报 DOUBLE
WAIT_DOUBLE300ms 超时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 导致产线批量双击误判”的亏之后,这个习惯再没省过。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 5:12:22

工业少样本异常检测:缺陷线索保持与结构特征细化

1. 这不是又一篇“调参式”异常检测论文——它直击工业质检最痛的三个现实断层你有没有在产线现场见过这样的场景&#xff1a;光学检测设备突然报出一堆“疑似缺陷”&#xff0c;工程师紧急停机&#xff0c;结果拆开一查&#xff0c;全是误报&#xff1b;再过两天&#xff0c;真…

作者头像 李华
网站建设 2026/10/26 5:10:52

NLP pipeline实战:核心任务、批处理性能与生产部署避坑指南

1. 先把 pipeline 这件事说透&#xff1a;它到底封装了什么很多人第一次接触 NLP 的时候&#xff0c;卡住的地方往往不是在模型原理&#xff0c;而是在前一公里&#xff1a;文本怎么变成模型吃得下的东西&#xff0c;模型吐出来的张量又怎么变回人能读懂的结果。pipeline这个入…

作者头像 李华
网站建设 2026/10/26 5:10:17

最大似然估计原理与实践:从数学推导到机器学习应用

1. 最大似然估计的本质理解最大似然估计&#xff08;Maximum Likelihood Estimation, MLE&#xff09;是统计学中参数估计的经典方法&#xff0c;它的核心思想可以用一个生活场景来类比&#xff1a;假设你在森林里听到灌木丛中有沙沙声&#xff0c;根据经验判断可能是兔子或松鼠…

作者头像 李华
网站建设 2026/10/26 5:09:36

K8S 1.29 部署 Nexus 3:StatefulSet、存储与探针避坑

Nexus 这套制品仓库&#xff0c;我前后在 K8s 上搭过好几回&#xff0c;每次都会在同样几个地方卡住&#xff1a;Pod 起来了但 Ready 一直是 0/1、日志里刷 Permission denied、上传一个几百兆的 Docker 镜像被网关 413 拦掉。这些问题单看都不复杂&#xff0c;可凑在一起就很容…

作者头像 李华
网站建设 2026/10/26 5:09:21

DevDay 2026深度拆解:Personal Agent常驻代理与Sol 6.1工具调用实战

1. 这场发布会到底讲了什么&#xff1a;从标题拆解核心信息先把标题里的三个关键词拆开看&#xff1a;Personal Agent、Sol 6.1、缺席的Astra。这三个词基本就是整场DevDay 2026的信息骨架——一个正式发布、一个版本迭代、一个被反复追问却没上台的东西。Personal Agent是这次…

作者头像 李华
网站建设 2026/10/26 5:09:07

区块链与iPaaS融合:企业系统集成的信任革新

1. 当区块链遇上iPaaS&#xff1a;系统集成的信任革命企业级系统集成领域正在经历一场由区块链技术引发的底层架构变革。作为某跨国企业数字化转型项目的技术负责人&#xff0c;我在过去三年主导了七个基于区块链的iPaaS&#xff08;Integration Platform as a Service&#xf…

作者头像 李华