简介为嵌入式STM32开发者提供一份按键状态机完整工程实现面向单片机初学者及需要处理多击操作的中级开发者解决单击、双击与长按事件难以精确区分的问题。方案基于定时器中断与状态机思想代码结构简洁、规范具备良好的可读性与可移植性。资源共79个文件以C源码与头文件为主32个.c与33个.h同时包含MDK工程文件、启动文件、编译脚本及HEX固件压缩包整体约182KB目录按HARDWARE、SYSTEM、USER等模块划分便于定位与二次开发。该资源已有6789人学习下载适合对照实验板如STM32F103C8T6进行定时器、按键及串口调试练习可快速理解并复用单击、双击、长按的判定逻辑。1. 按键状态机为什么STM32的按键必须用状态机而不是延时消抖STM32 的按键交互里单击、双击、长按三种操作看似简单真要在一颗 MCU 上同时实现绕不开按键状态机。传统延时的按键逻辑一旦加上双击和长按CPU 被 HAL_Delay 卡死、消抖和判定互相打架是常态。状态机的思路是把按键过程拆成消抖、按下、长按、等待双击、释放等离散状态每个扫描周期只做一步判定不 sleep不轮询等待。机械抖动被消抖状态吸收单击和双击的先后关系被计时窗口约束主循环的 CPU 占用几乎可以忽略。这篇适合裸机开发想直接抄一个非阻塞按键模块的人也适合带 RTOS、想把按键从业务里抽出来做成事件源的开发者。下文给完整代码骨架和参数参考。2. 按键状态机核心设计状态划分、事件上报与代码骨架2.1 为什么状态机比两态可靠机械按键的物理过程比很多人想的复杂。手指按下时触点会产生 5ms 到 20ms 不等的抖动释放时同样有抖动按下停留的时间又随用户习惯波动短的可能只有几十毫秒长的能好几秒。如果用简单的「按下 / 抬起」两态去描述只能回答「当前是不是按下」回答不了「这次按下持续了多久」「这次抬起之后多久又按下了」。单击、双击、长按恰恰就是这些时序问题。延时方案的痛点很直接消抖用 HAL_Delay按键扫描期间整个主循环被卡住。卡住的窗口内如果用户又按了一下程序根本感知不到双击就没法做长按要在延时里累加时间代码嵌套越来越深最后变成一团只有自己能看懂的逻辑。状态机的收益是把「一次物理按键」翻译成「一串离散事件」。每次扫描只做一次状态判定时间是累计的不是阻塞等待的。更重要的是状态机天然适合组合同一个按键按下最终上报的是单击、双击还是长按取决于后续几毫秒内发生的事这在状态机里就是几次状态迁移。边界要说清楚状态机解决的是逻辑层面的时序判定不解决物理层面的抖动干扰。如果硬件上完全没有滤波电容按键线又长该加 RC 滤波还是要加软件状态机能吸收一部分抖动但吸收不了严重的毛刺。2.2 代码骨架状态枚举、结构体、扫描函数先把类型定义摆出来。下面这套枚举和结构体是我在多个 STM32 工程里反复用过的版本按自己的项目改引脚和端口就行。typedef enum { KEY_STATE_IDLE 0, /* 空闲等待按下 */ KEY_STATE_DEBOUNCE, /* 消抖按下后采样确认 */ KEY_STATE_PRESSED, /* 按下确认等待释放或长按 */ KEY_STATE_LONG_PRESS, /* 长按触发中 */ KEY_STATE_WAIT_DOUBLE, /* 释放后等待第二次按下 */ KEY_STATE_WAIT_RELEASE /* 双击确认后的释放等待 */ } key_state_t; typedef enum { KEY_EVENT_NONE 0, KEY_EVENT_PRESS_DOWN, /* 按下消抖后 */ KEY_EVENT_CLICK, /* 单击 */ KEY_EVENT_DOUBLE_CLICK, /* 双击 */ KEY_EVENT_LONG_PRESS, /* 长按触发 */ KEY_EVENT_LONG_RELEASE /* 长按释放 */ } key_event_t; typedef struct { GPIO_TypeDef *port; /* GPIO 端口 */ uint16_t pin; /* GPIO 引脚 */ key_state_t state; /* 当前状态 */ uint8_t level_active; /* 按下时的电平1 表示按下为高 */ uint8_t press_cnt; /* 按下计数判断双击用 */ uint32_t tick_start; /* 状态进入时间戳 */ } key_t;level_active 是很容易忽略的字段。有的板子按键按下为低电平内部上拉有的为高电平下拉把按下电平做成配置项换板子只改结构体初始化不用动状态机逻辑。扫描函数是核心这里先给 IDLE 和 DEBOUNCE 两个状态的处理void key_scan(key_t *key) { uint8_t level HAL_GPIO_ReadPin(key-port, key-pin); uint8_t active (level key-level_active) ? 1 : 0; uint32_t now HAL_GetTick(); switch (key-state) { case KEY_STATE_IDLE: if (active) { /* 检测到按下进入消抖状态 */ key-state KEY_STATE_DEBOUNCE; key-tick_start now; } break; case KEY_STATE_DEBOUNCE: if (active) { /* 持续按下超过消抖时间确认按下 */ if ((uint32_t)(now - key-tick_start) KEY_DEBOUNCE_MS) { key-state KEY_STATE_PRESSED; key-press_cnt 1; key-tick_start now; key_emit(KEY_EVENT_PRESS_DOWN); } } else { /* 消抖期内释放说明是干扰信号 */ key-state KEY_STATE_IDLE; } break; /* 其他状态后续章节补充 */ } }这里有一个关键细节状态机每次扫描只做一件事不是在一个循环里把所有条件都判断完。DEBOUNCE 状态每 1ms 到 5ms 才被调用一次前后两次采样的间隔本身就是一个软件滤波窗口比「检测到第几次变化」再延时更自然。key_emit 是事件上报函数实际工程里通常是回调函数。裸机可以做成函数指针RTOS 可以往消息队列塞事件状态机本身不关心事件被谁消费。扫描周期建议放在主循环里轮询或者放在一个 1ms 的定时器回调里。我用 5ms 居多消抖时间设 20ms 的话采样 4 次确认按下抗干扰已经足够。中断里不建议直接跑整个状态机时间基准不统一容易出问题。2.3 时间基准用 HAL_GetTick 而不是自己数数有人习惯在定时器中断里定义一个 volatile 变量自增扫描函数里读这个变量当时间。这个思路没问题但有个隐含坑如果中断和主循环共享变量读的时候要防撕裂大多数场景下直接复用 HAL_GetTick 省事得多。HAL_GetTick 返回的是系统启动以来的毫秒数做差值时要转成 uint32_t 计算(uint32_t)(now - key-tick_start) KEY_DEBOUNCE_MS即使 HAL_GetTick 发生回绕无符号减法依然得到正确差值这是嵌入式时间比较的标准写法。不要直接写成now key-tick_start KEY_DEBOUNCE_MS溢出时会判断出错。时间基准只负责提供毫秒刻度真正的「计时」是状态机通过状态进入时刻和系统当前时刻的差值算出来的。每个状态进入时记录 tick_start退出状态时读取差值天然避免了一个状态内重复累加的误差。调用频率上5ms 扫描一次消抖窗口、长按窗口、双击窗口的精度都能控制在 ±5ms 内对按键交互完全够用。扫描函数本身执行时间只有几微秒不会对主循环造成可见负担。3. 单击、双击、长按判定计时窗口、状态转移与参数设置3.1 长按判定两种触发模式阈值怎么选长按最常见的需求是「按住超过 800ms 触发一次事件」也有「按住超过 800ms 后每 300ms 连续触发」的调亮度场景。这两种要在状态机里分开处理。先看基本的到点触发一次case KEY_STATE_PRESSED: if (!active) { /* 未超过长按阈值就释放走单击/双击判定 */ key-state KEY_STATE_WAIT_DOUBLE; key-tick_start now; } else if ((uint32_t)(now - key-tick_start) KEY_LONG_PRESS_MS) { /* 按住超过阈值进入长按态 */ key-state KEY_STATE_LONG_PRESS; key-tick_start now; key_emit(KEY_EVENT_LONG_PRESS); } break; case KEY_STATE_LONG_PRESS: if (!active) { /* 长按释放 */ key-state KEY_STATE_IDLE; key_emit(KEY_EVENT_LONG_RELEASE); } else if (KEY_LONG_REPEAT_MS 0 (uint32_t)(now - key-tick_start) KEY_LONG_REPEAT_MS) { /* 连续触发模式每个周期发一次事件 */ key-tick_start now; key_emit(KEY_EVENT_LONG_REPEAT); } break;LONG_REPEAT_MS 设为 0 就关闭连续触发只保留到点触发一次。这样同一个状态机覆盖两种产品需求不用改逻辑。阈值参考表参数推荐值说明KEY_DEBOUNCE_MS10~20消抖窗口建议不小于 10msKEY_LONG_PRESS_MS600~1000长按触发阈值500ms 容易误触KEY_DOUBLE_WINDOW_MS300~450双击判定窗口太短用户按不出来KEY_LONG_REPEAT_MS0 或 200~300长按连续触发间隔0 表示关闭长按阈值不要拍脑袋定。手速正常的人两次单击间隔大约 200ms 到 400ms低于这个区间的连续点击应该判为双击。长按阈值如果设在 300ms就会和双击的第二下冲突。我一般把长按阈值设在 800ms留足余量。3.2 双击判定窗口约束与第一击延迟双击判定的最大矛盾在于单击在释放时就能上报但双击必须等用户是否按下第二下。两件事在时间上重叠必须有一个事件延迟上报。我的做法是按下释放后不立刻报单击而是进入 WAIT_DOUBLE 状态开一个窗口等第二次按下。窗口内按下了撤销单击、改报双击窗口超时补报单击。case KEY_STATE_WAIT_DOUBLE: if (!active) { /* 没有新按下窗口超时补报单击 */ if ((uint32_t)(now - key-tick_start) KEY_DOUBLE_WINDOW_MS) { key-state KEY_STATE_IDLE; key_emit(KEY_EVENT_CLICK); } } else { /* 窗口内又检测到按下进入消抖确认 */ key-state KEY_STATE_DEBOUNCE; key-press_cnt 2; key-tick_start now; } break;DEBOUNCE 状态里需要加一段对 press_cnt 为 2 的处理case KEY_STATE_DEBOUNCE: if (active) { if ((uint32_t)(now - key-tick_start) KEY_DEBOUNCE_MS) { if (key-press_cnt 2) { /* 第二次按下确认上报双击 */ key-state KEY_STATE_WAIT_RELEASE; key-press_cnt 0; key_emit(KEY_EVENT_DOUBLE_CLICK); } else { key-state KEY_STATE_PRESSED; key-press_cnt 1; key-tick_start now; key_emit(KEY_EVENT_PRESS_DOWN); } } } else { key-state KEY_STATE_IDLE; } break;第一击的单击事件会延迟 KEY_DOUBLE_WINDOW_MS 才发出这是双击方案的固有代价。用户体验上单击的响应慢了几百毫秒但对大多数控制类应用完全可接受。如果某些功能必须单击即时响应可以拆成两路一路即时上报按下事件一路延迟上报单击事件由上层逻辑自己决定听谁的。双击的第二下按住不放超过长按阈值怎么办需要在 WAIT_RELEASE 状态里继续计时超过阈值就上报长按而不是死等释放。这个坑在第 4 章专门讲。3.3 事件回调与多按键扩展状态机内部不处理业务逻辑所有判定结果通过回调函数丢出去。单按键工程可以这样写void key_emit(key_event_t evt) { switch (evt) { case KEY_EVENT_CLICK: /* 单击切换开关 */ break; case KEY_EVENT_DOUBLE_CLICK: /* 双击切换模式 */ break; case KEY_EVENT_LONG_PRESS: /* 长按进入设置 */ break; default: break; } }多个按键时用结构体数组统一管理#define KEY_NUM_MAX 4 key_t key_group[KEY_NUM_MAX] { {GPIOA, GPIO_PIN_0, KEY_STATE_IDLE, 0, 0, 0}, {GPIOA, GPIO_PIN_1, KEY_STATE_IDLE, 0, 0, 0}, /* ... */ }; void key_scan_all(void) { for (int i 0; i KEY_NUM_MAX; i) { key_scan(key_group[i]); } }回调里通过 key 指针判断是哪个按键触发的把 key_t 指针作为参数传给回调函数void key_emit(key_t *key, key_event_t evt);这样每个按键的事件是独立的互不干扰。扫描函数对所有按键逐一遍历代码量线性增长不会因为按键数变多而逻辑爆炸。4. 按键状态机实战避坑抖动、电平、休眠唤醒的五个教训4.1 消抖放哪层不要在读取层先做延时消抖现象状态机判定时而正常时而错乱双击经常消失单击偶尔变双击。原因很多人习惯在 GPIO 读取函数里先做一个 10ms 延时消抖再返回「稳定电平」。这个延时消抖把状态机的输入打造成了「已经消抖过的事件流」状态机内部的 DEBOUNCE 状态反而在重复判定。尤其当消抖延时和状态机的扫描周期接近时两次判定的时间基准互相叠加导致窗口错位。解决把消抖职责完全交给状态机。GPIO 读取层只返回当前电平不做任何延时。DEBOUNCE 状态本身就是一个软件消抖窗口前后两次采样都稳定才算按下确认。这样整个系统只有一个时间基准、一套判定逻辑。4.2 按下电平写反上拉接法和下拉接法混用现象按键按下去状态机没反应松手反而触发。原因按键接线方式有两种一种是 GPIO 内部上拉、按键按下接地按下为低电平另一种是 GPIO 内部下拉、按键按下接电源按下为高电平。结构体里 level_active 设反的话状态机把空闲电平当成按下电平整个判定翻转。解决初始化结构体之前先读一次电平确认。最简单的方法是下载程序后按住按键打印 GPIO 电平把实际按下时的电平写进 level_active。我在不同开发板上调过这个几乎每个板子都要重新确认不要凭上次经验。4.3 双击第二下按住不松手被误判成长按现象用户双击时第二下按得稍久一点就触发了长按事件。原因WAIT_RELEASE 状态只保留了「等待释放」的逻辑没有在这个状态下继续计时长按。用户第二下按住超过长按阈值状态机依然认为自己在双击流程里长按判定被跳过。解决WAIT_RELEASE 状态里同样要累积按住时间case KEY_STATE_WAIT_RELEASE: if (!active) { key-state KEY_STATE_IDLE; } else if ((uint32_t)(now - key-tick_start) KEY_LONG_PRESS_MS) { /* 双击第二下按住超时按长按处理 */ key-state KEY_STATE_LONG_PRESS; key-tick_start now; key_emit(KEY_EVENT_LONG_PRESS); } break;这样双击第二下按住超过 800ms 会变成「先双击再长按」符合大多数用户的心智模型。4.4 休眠唤醒后状态机失步唤醒瞬间误触发现象MCU 休眠后按键唤醒状态机直接上报了一个单击事件但用户明明只是按了一下唤醒键。原因休眠前按键状态可能是 WAIT_DOUBLE 或 PRESSED唤醒时 GPIO 电平已经稳定状态机却还停留在旧状态把「唤醒时读到的稳定电平」当成了一次新按下的开始。解决唤醒流程里强制重置按键状态。在唤醒函数中把所有按键结构体的 state 复位为 KEY_STATE_IDLEtick_start 清零然后让状态机重新从 IDLE 开始扫描。如果 GPIO 唤醒引脚在唤醒后还保持按下状态从 IDLE 重新消抖一次即可不会丢失事件也不会误报。4.5 RTOS 任务优先级扫描任务放太低会丢按键现象系统跑 RTOS 后按键偶尔失灵用逻辑分析仪看 GPIO 有脉冲状态机却没有任何事件输出。原因按键扫描任务优先级太低被通信任务或显示任务抢占。如果扫描间隔被拉到 50ms 以上消抖窗口的采样点落在抖动脉冲上状态机会把这次按下当成干扰丢掉。或者扫描任务被抢占太久WAIT_DOUBLE 窗口早超时了才回到任务双击永远判定不出来。解决把按键扫描放一个中等优先级任务里周期 5ms任务内只做状态机扫描和事件入队不做任何业务处理。业务逻辑放更低优先级任务通过消息队列接收按键事件。这样扫描任务即使偶尔被抢占 20ms也不会破坏判定窗口的连续性。5. 按键状态机调试与验证时间戳回放与边界测试清单5.1 用日志回放还原每一次按键状态机写完后最难的是验证双击窗口内的状态迁移是否符合预期。裸机没地方看断点串口打印是最快的验证手段。在每个状态迁移和事件上报处打一条时间戳日志printf([%lu] state: %d - %d, event: %d, level: %d\r\n, (unsigned long)now, old_state, key-state, evt, level);操作一次按键后把串口日志按时间戳逐行读出来就能还原完整时序[1000] state: 0 - 1, level: 1 /* 检测到按下进入消抖 */ [1020] state: 1 - 2, level: 1 /* 消抖确认按下生效 */ [1420] state: 2 - 4, level: 0 /* 释放进入等待双击 */ [1450] state: 4 - 1, level: 1 /* 第二次按下进入消抖 */ [1470] state: 1 - 6, event: 2 /* 双击确认上报 */这套日志也是调参数的重要依据。如果用户反馈双击不灵敏把日志里的两次按下间隔量出来就知道 KEY_DOUBLE_WINDOW_MS 该往哪个方向调。5.2 边界测试清单状态机这种时序逻辑靠「按几下感觉没问题」是远远不够的。我建议每次改完参数都强制跑一遍下面的清单特别是双击窗口和长按阈值调整之后。测试项操作期望事件单击快速按一下立即释放单击双击在双击窗口内按两下双击长按按住超过长按阈值再释放长按 长按释放双击第二下按住第二下按住超过长按阈值双击 长按单击释放稍慢按住约 100ms 释放单击快速连按三下每次间隔 150ms双击 单击抖动干扰在消抖窗口内快速碰触无事件最后一行的抖动干扰测试我一般用一根杜邦线在按键引脚上快速碰几下模拟看状态机是否稳定回到 IDLE。这个测试能暴露 DEBOUNCE 状态里释放分支缺失的问题。以前我做双击和单击上报冲突的时候花了两天反复调阈值后来翻日志才意识到问题出在「第一击延迟」的结构上跟阈值无关。从那以后每次写按键模块我都会强制走一遍边界测试清单并且保留时间戳日志的开关参数改完立刻回放验证。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?