☰
STM32按键状态机:单击双击长按检测的工程实现与架构解析
2026/10/20 3:53:30 网站建设 项目流程

简介:面向嵌入式初学者的STM32按键状态机示例工程,演示利用定时器中断配合状态机,在单个按键上准确区分单击、双击与长按操作,可应对短按切换亮度、长按关机等实际交互场景中的按键识别与消抖难题。压缩包共含79个文件,以32个C源文件、33个头文件为主体,并配有Keil工程配置、启动文件、调试配置、hex烧录文件及README说明,整体体积仅182KB,目录结构清晰,便于对照源码阅读与二次开发。程序基于STM32F103C8T6平台编写,代码简洁规范、可读性强,重点展示定时器3中断服务、按键扫描消抖以及长按、单击、双击事件判定逻辑,读者可从源码中学习状态机划分与超时控制的具体实现,并通过修改定时参数与按键引脚快速移植到自己的项目中。目前已有6789人参与学习下载,适合正在学习STM32中断编程、按键检测或状态机设计的开发者作为可直接运行的参考工程。

1. 嵌入式STM32按键状态机:单击双击长按不再靠延时硬凑

按键大概是嵌入式硬件里最不起眼却又最容易翻车的外设。做过产品的人都知道,单击、双击、长按这三个动作如果用延时的思路去判断,等产品上了量,总有几个按键会在快速连点、按住再释放时状态错乱——该触发单击的时候上报成双击,该长按的时候被当成短按。这个基于STM32的按键状态机工程,把按键检测拆成一条清晰的状态迁移链路,用固定时基轮询GPIO,不阻塞、不依赖延时函数,单击、双击、长按三种操作各归各的事件,不抢占、不丢失。适合正在写裸机或RTOS应用的单片机开发者,也适合想把手上的按键模块统一成标准组件的工程师。

2. 架构与状态设计:从引脚电平到应用事件的完整链路

2.1 硬件与时基:按键接在哪,时间基准从哪来

先界定硬件基础。最典型也最省事的接法是:按键一端接PA0,另一端接GND,MCU内部上拉。按下时引脚读到低电平,松开回到高电平。我一般直接开内部上拉,而不是在外面再放两颗上拉电阻——批量生产的板子焊得多了,外部电阻漏焊的问题比想象中常见,内部上拉省事,复位之后状态也稳定。血泪经验是,第一版板子我没开内部上拉,按键悬空时状态机在IDLE和去抖两个状态之间来回抖,串口日志刷了一屏幕,后来拿万用表一量,静态电平就在1V附近晃。

引脚初始化代码:

void Board_Init(void) { __HAL_RCC_GPIOA_CLK_ENABLE(); // 打开GPIOA时钟 GPIO_InitTypeDef gpio = {0}; gpio.Pin = GPIO_PIN_0; // PA0作为按键输入 gpio.Mode = GPIO_MODE_INPUT; // 数字输入模式 gpio.Pull = GPIO_PULLUP; // 内部上拉 HAL_GPIO_Init(GPIOA, &gpio); SysTick_Config(SystemCoreClock / 1000); // 1ms时基 }

参数含义:GPIO_MODE_INPUT把引脚配成普通数字输入,GPIO_PULLUP启用内部上拉,SysTick_Config让内核定时器每1ms触发一次中断,给整个按键状态机提供统一的时间基准。主循环后续用HAL_GetTick()就能拿到毫秒级时钟。

这里有个容易踩的点:PA0这类引脚有的还带ADC和唤醒功能,如果开发环境默认把它配成了模拟输入或复用功能,初始化时一定要像上面这样显式设成GPIO输入。后面避坑章节还会单独提这个事。

2.2 状态机模型:五个状态一张迁移表

这个工程把按键的一生拆成五个主状态,外加一个长按后的等待释放状态。看迁移表比看代码直观:

状态进入条件退出条件上报事件
IDLE初始或事件处理完成读到低电平进入去抖无
PRESS_DEBOUNCE引脚刚变低持续10ms进入PRESSED;期间变高退回IDLE无
PRESSED按下已确认按住超800ms上报长按;提前释放进入双击窗口KEY_EVENT_LONG_PRESS
DOUBLE_WAIT第一次短按释放350ms内再按下进入第二次去抖;超时上报单击KEY_EVENT_SINGLE_CLICK
RELEASE_WAIT长按已上报引脚释放回到IDLE无
SECOND_PRESSED第二次按下确认释放上报双击,回到IDLEKEY_EVENT_DOUBLE_CLICK

为什么这么拆?核心原因是代码里不能出现长时间的阻塞等待。如果释放后直接HAL_Delay(350)等着判断第二次按下,那这350ms里LED闪烁、串口打印、通信协议全部卡死。状态机的做法是把“等待”变成“每次扫描时检查时间差值”,到点了自动迁移,其他任务照常运行。这是这个工程和普通if-else写法最本质的区别。

三个时间参数全部在key.h里用宏定义,方便二次调整:

#define KEY_DEBOUNCE_MS 10 // 去抖时长,滤除机械抖动 #define KEY_LONG_MS 800 // 长按判定阈值 #define KEY_DOUBLE_MS 350 // 双击判定窗口

参数解释:10ms覆盖常见轻触开关的抖动宽度;800ms是短按/长按比较通用的分界;350ms的双击窗口给大部分人的操作习惯留了余量,想更灵敏就缩到250ms,想更容易触发就放到400ms。把时间收敛成宏而不是散落在代码里的魔法数字,后面调手感只需要改这三个数。

2.3 工程结构与模块依赖

拿到代码包之后先看目录,整个工程按层分好:

KeyStateMachine/ ├── Core/ /* 启动文件、系统时钟、HAL内核 */ ├── App/ │ ├── main.c /* 主循环,调用Key_Scan */ │ ├── key.c / key.h /* 按键状态机源码 */ │ ├── app_task.c /* 事件分发:LED/串口演示 */ └── Doc/ ├── wiring.md /* 接线说明 */ └── event_sequence.md /* 事件时序说明 */

关键边界是:key.c只做两件事——读引脚、判断状态,不碰LED不碰串口。应用层在app_task.c里根据KeyEvent决定具体行为。接口一共三个:

void Key_Init(KeyHandle *handle, GPIO_TypeDef *port, uint16_t pin); KeyEvent Key_Scan(KeyHandle *handle, uint32_t now_ms); const char *Key_EventName(KeyEvent ev);

Key_Init在main启动时调用一次,传入引脚信息;Key_Scan每次扫描周期调用,传入当前时间戳,返回本次扫描命中事件;Key_EventName用于串口打印调试。这样设计之后,任何其他工程想复用,只需要复制key.c和key.h,改一下初始化参数即可。如果项目用的还是标准外设库而不是HAL,把HAL_GPIO_ReadPin换成GPIO_ReadInputDataBit,状态机逻辑一行不用动。

3. 核心代码拆解:扫描、去抖、事件判定三位一体

3.1 读引脚与去抖:滤波不是靠延时,靠状态转移

先看承载按键数据的结构体:

typedef struct { GPIO_TypeDef *port; // 端口基地址 uint16_t pin; // 引脚号 KeyState state; // 当前状态机状态 uint32_t debounce_start; // 去抖起始时间戳 uint32_t press_start; // 按下确认时间戳 uint32_t release_start; // 释放时间戳 } KeyHandle;

字段作用:port和pin决定读哪个引脚;state保存当前状态;三个时间戳分别记录去抖、按下、释放的时间点,供状态机里各种时间差判断使用。

去抖分支是状态机的第一道关卡:

KeyEvent Key_Scan(KeyHandle *key, uint32_t now_ms) { uint8_t level = HAL_GPIO_ReadPin(key->port, key->pin); switch (key->state) { case KEY_STATE_IDLE: if (level == GPIO_PIN_RESET) { key->debounce_start = now_ms; key->state = KEY_STATE_PRESS_DEBOUNCE; } break; case KEY_STATE_PRESS_DEBOUNCE: if (level == GPIO_PIN_RESET) { if (now_ms - key->debounce_start >= KEY_DEBOUNCE_MS) { key->press_start = now_ms; key->state = KEY_STATE_PRESSED; } } else { key->state = KEY_STATE_IDLE; } break; /* 其余状态在下面小节展开 */ } return KEY_EVENT_NONE; }

逻辑说明:IDLE读到低电平时先不确认按下,记录debounce_start并进入去抖状态;之后每次扫描都检查这个低电平是否持续了10ms。如果中途变高,说明只是抖动毛刺,直接退回IDLE。和传统延时去抖的区别在于:这10ms期间CPU是空闲的,主循环还能处理LED、串口等其它任务;延时的写法则是睡在代码里,什么也干不了。

3.2 单击与双击的相处之道:释放后留一段二次判断窗口

单击和双击共用同一个起点:第一次释放之后不立刻下结论,先观察一段时间。这段窗口代码是核心中的核心:

case KEY_STATE_PRESSED: if (level == GPIO_PIN_SET) { key->release_start = now_ms; key->state = KEY_STATE_DOUBLE_WAIT; } break; case KEY_STATE_DOUBLE_WAIT: if (level == GPIO_PIN_RESET) { key->debounce_start = now_ms; key->state = KEY_STATE_SECOND_DEBOUNCE; } else if (now_ms - key->release_start >= KEY_DOUBLE_MS) { key->state = KEY_STATE_IDLE; return KEY_EVENT_SINGLE_CLICK; } break;

逻辑说明:第一次释放时记录release_start,进入DOUBLE_WAIT。这个状态的每一轮扫描都会做两个判断:如果引脚又变低,说明用户想补第二下,进入第二次去抖;如果过了350ms还没动静,判定为一次干净的单击,上报KEY_EVENT_SINGLE_CLICK并回到IDLE。

参数说明:now_ms - key->release_start是释放后的等待时长,完全不占用CPU。这里有个容易忽略的细节——进入DOUBLE_WAIT后,用户如果一直不按,状态机会在350ms后自动超时并上报单击;如果用户按下去一直不松,会走到SECOND_DEBOUNCE再做一次10ms确认。每个分支都有明确的出口,不会卡死在半路。

3.3 双击判定与长按判定:两条路径互不干扰

第二次按下同样需要去抖,然后才是双击:

case KEY_STATE_SECOND_DEBOUNCE: if (level == GPIO_PIN_RESET) { if (now_ms - key->debounce_start >= KEY_DEBOUNCE_MS) { key->state = KEY_STATE_SECOND_PRESSED; } } else { key->state = KEY_STATE_DOUBLE_WAIT; // 第二下是抖动,回到等待 } break; case KEY_STATE_SECOND_PRESSED: if (level == GPIO_PIN_SET) { key->state = KEY_STATE_IDLE; return KEY_EVENT_DOUBLE_CLICK; } break;

第二下按住之后,只要检测到释放边沿就上报双击。这里有个设计取舍:如果第二下也长时间按住,很多产品希望报长按而不是双击。这个基础版把“第二次按下并释放”理解为完成双击动作,先拿到正确行为,再做复杂取舍。

长按分支嵌在PRESSED状态里:

case KEY_STATE_PRESSED: if (level == GPIO_PIN_RESET && now_ms - key->press_start >= KEY_LONG_MS) { key->state = KEY_STATE_RELEASE_WAIT; return KEY_EVENT_LONG_PRESS; } break; case KEY_STATE_RELEASE_WAIT: if (level == GPIO_PIN_SET) { key->state = KEY_STATE_IDLE; } break;

逻辑说明:PRESSED状态下,每轮扫描都会做一次“是否超过800ms”的判断。一旦超时,立刻返回长按事件,同时强制迁移到RELEASE_WAIT。为什么要这一步?如果不上报后不改状态,用户按住不放时Key_Scan每调一次就会返回一次长按,应用层会收到一堆重复事件。RELEASE_WAIT让状态机对这些重复扫描不再响应,直到用户松开,释放边沿才回到IDLE。

整个状态机一周期只会返回0或1个事件,单击事件不会在双击窗口结束前出现,三个事件天然互斥,应用层接收时不用做任何二次仲裁。

3.4 主循环集成:10ms调度思想

main函数里的集成方式直接决定这套代码在项目里的表现:

int main(void) { HAL_Init(); Board_Init(); KeyHandle key; Key_Init(&key, GPIOA, GPIO_PIN_0); uint32_t last_heartbeat = 0; while (1) { uint32_t now = HAL_GetTick(); KeyEvent ev = Key_Scan(&key, now); if (ev == KEY_EVENT_SINGLE_CLICK) { printf("single click\r\n"); Led_Toggle(1); } else if (ev == KEY_EVENT_DOUBLE_CLICK) { printf("double click\r\n"); Led_BlinkTimes(2); } else if (ev == KEY_EVENT_LONG_PRESS) { printf("long press\r\n"); Led_BlinkTimes(5); } if (now - last_heartbeat >= 1000) { last_heartbeat = now; Heartbeat_Toggle(); } } }

逻辑说明:主循环用同一个now做所有时间判断,按键扫描和心跳任务共享时基,不互相阻塞。这个写法里Key_Scan的实际调用周期等于一次while循环的执行时间,只要循环体是微秒级,扫描等效周期完全够用。

如果想严格固定扫描周期,常见做法是:把Key_Scan放进定时器中断,事件缓存到全局变量,主循环取缓存事件消费。但我个人更推荐在RTOS环境下用10ms周期任务调Key_Scan,事件就在任务上下文里消费,代码可读性最好。这个状态机不依赖任何操作系统API,在裸机和RTOS之间平移不需要改内部逻辑。

4. 调试验证与避坑:按键乱跳、单击变双击、长按卡死怎么办

按键模块的坑集中在电气、时序、状态丢失三个方面。这里把调试中真实遇到过的故障按现象、原因、解决的格式整理出来。

4.1 现象:程序烧进去,按键没按,串口却一直打印单击事件

原因:引脚悬空,内部上拉没生效。单片机引脚对外部浮空很敏感,手指靠近或者走线稍长,噪声就能把电平拉低一瞬间,状态机误以为发生了一次完整按键。

解决:确认GPIO_PULLUP是否配置正确;如果板子上外接了上拉电阻,内部上拉可以不开,但要先确认电阻确实焊上且阻值没问题。第一步用万用表测静态电平:不按应为3.3V,按下应为0V。测出来的值和预期不符,先查硬件,不要急着改代码。这个排查顺序能省掉大量无效调试时间。

4.2 现象:双击经常被识别成两次单击,或单击被识别成双击

原因:KEY_DOUBLE_MS和实际手速不匹配。有的用户体验双击间隔只有150ms,有的习惯200~300ms;反过来,为了不误判,把窗口拉到400ms以上,单击释放后稍微停顿一下,就会被吞成“假的第二次按下”。

解决:打开串口事件打印,连续双击100次统计识别成功率。再用示波器或逻辑分析仪抓一次真实按压间隔,把KEY_DOUBLE_MS放在“低于最短真实间隔”和“高于单次释放停顿时间”之间。经验值是300~350ms,能覆盖绝大多数人的操作习惯。调参时每次只改这一个宏,记录现象对比,不要几个参数一起动。

4.3 现象:长按一次之后,按键彻底失灵,必须复位才能恢复

原因:长按逻辑只上报了KEY_EVENT_LONG_PRESS,状态机没有立刻切走。下一次扫描仍然满足长按条件,又返回一次长按;或者应用层在处理长按事件时执行了阻塞延时,扫描永远停在PRESSED。

解决:长按上报和RELEASE_WAIT迁移必须放在同一个case分支里,上报一次立即转移状态。应用层收到长按事件后只做LED翻转或置标志位,不要在事件处理里HAL_Delay。检查方式:串口打印状态编号,按住按键时只会看到一次LONG_PRESS,松开后状态编号回0。如果看到连续多次LONG_PRESS,就是状态迁移没写对。

4.4 现象:把Key_Scan放进定时器中断后,长按时间忽长忽短

原因:SysTick中断优先级不够高,或者中断服务函数里还跑了串口打印这类耗时操作,导致系统tick丢拍。状态机完全依赖now_ms差值判断时间,tick一丢,长按的实际持续时间就偏了。

解决:SysTick的抢占优先级设在最高,中断里只读取一次GPIO和tick,把事件写进全局缓存就退出,耗时消费放到主循环。验证时用示波器同时抓按键引脚和一个GPIO翻转信号,测量“按下到事件输出”的延迟,稳定在预期毫秒范围内就说明时基是准的。

提示:完整验证我一般按四步走——静态电平测量、单击事件测试、双击和长按边界测试、连续按压1000次统计误报率。四步都过了,按键模块才算真的可靠。

5. 二次开发:从单按键到按键组与组合键的改造思路

5.1 多按键实例化

KeyHandle是纯C结构体,天然支持数组化。把一个按键的封装扩展成按键组:

KeyHandle key[KEY_MAX_NUM]; const Key_Config_t key_table[] = { {GPIOA, GPIO_PIN_0}, {GPIOA, GPIO_PIN_1}, {GPIOB, GPIO_PIN_5}, }; for (int i = 0; i < KEY_MAX_NUM; i++) { Key_Init(&key[i], key_table[i].port, key_table[i].pin); }

每个按键实例拥有独立的状态和时间戳,互不干扰。扫描时用一个循环包一层Key_ScanAll,事件顺序按数组索引自然分派。如果两个按键同时按下,状态机不会互相覆盖,谁先完成判定谁先上报。

5.2 事件回调重构

工程默认是返回值分发。想再解耦一层,可以给结构体加函数指针:

typedef void (*KeyEventCallback)(KeyEvent ev); void Key_SetCallback(KeyHandle *key, KeyEventCallback cb);

应用层注册回调后,Key_Scan内部命中事件直接调用回调函数,main里的switch分发就不需要了。这种改动适合按键数量多、事件种类多的场景,也方便把按键模块直接对接上层的命令解析器。

5.3 组合键扩展

组合键是另一个常见需求。思路是给每个按键分配唯一ID,扫描时维护一张“当前按住的键位表”,当某个键保持按住、另一个键触发单击或双击时,合成一个组合事件上抛。这个工程的时间窗口管理可以直接复用,需要额外补充的只是键位表的状态维护。

我从第一次在自制板卡上调这套按键状态机起,踩过悬空引脚的坑,也试过把扫描塞进定时器中断里把系统节拍弄乱。从那以后,每次新建一个按键处理模块,都强制先确认电平极性,再把KEY_DEBOUNCE_MS、KEY_LONG_MS、KEY_DOUBLE_MS三个参数定下来,最后才谈单击双击长按。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询