拓冰建站拓冰建站
首页 / 资讯中心 / 正文

嵌入式按键状态机:从消抖到长按短按双击的完整实现

简介面向 STM32F103 嵌入式开发者的按键交互参考工程专注于长按、短按与双击检测的完整实现适合入门学习或中小型项目直接借鉴解决裸机开发中按键状态识别的常见痛点。压缩包共 134 个文件约 2.99MB包含 34 个 C 源文件、35 个头文件另附 Keil 工程文件.uvprojx、编译生成的 HEX 固件、链接映射 map 文件等既可在工程中阅读源码也可直接烧录验证。目前已有 1126 人学习该资源属于实用型参考示例。工程细化拆解了短按的轮询/中断检测、长按的定时器超时判断、双击的计数与时间窗口判定并补充防抖动处理和 LED/流水灯控制示例可帮助读者掌握 GPIO、定时器、状态机在按键任务中的综合运用也为后续人机交互设计提供可复用的代码思路。1. 长按、短按、双击按键需求翻倍状态管理复杂度翻四倍产品的交互从“单击开关”扩成“单击确认、双击唤醒、长按重启”时最容易翻车的不是 GPIO 配置而是主循环里那句if (key 0)。直接读电平区分不了“按了一下”“按住没松”“快速按两下”用 delay 消抖又会卡死主循环双击的第二次按下往往被延时段直接吞掉。把按键识别从电平判断升级为时间模式判断才算真正解决几种按压方式并存的问题。本质上是两件事固定周期非阻塞扫描以及一组记录时间与状态的状态机。这套设计在单片机、嵌入式 Linux、智能家居面板上都通用核心只有五个状态和三个时间参数。2. 固定节拍扫描与消抖窗口状态机只吃干净边沿2.1 阻塞式 delay 消抖为什么挡不住双击机械按键按下时簧片会反弹产生几次到几十次电平跳变。常见做法是检测到按下之后 delay 20ms 再读一次。能消抖但带来两个实实在在的问题。第一delay 期间 CPU 被锁死裸机主循环对屏幕刷新、通信处理的响应全被拖慢RTOS 里其他任务直接饿死。第二双击需要关注“释放后窗口内重新按下”如果第二次按下发生在当前延时或后续阻塞逻辑里边沿就漏掉了。按键扫描一旦写进延时实时性就无从谈起。所以按键扫描要独立成固定周期任务每 10ms 读一次 GPIO把原始电平原样交给状态机。消抖由状态机计数完成不产生任何阻塞。这里说的“状态机只吃干净边沿”指的就是消抖之后才能进入正式的按压判定不要让抖动电平参与长按、双击计时。2.2 消抖窗口怎么算从开关规格到采样次数不同按键类型的抖动差异很大选择消抖窗口要先看开关规格。下表是常见经验值按键类型抖动典型范围推荐消抖窗口机械微动 / 轻触开关2 ~ 10ms20ms薄膜按键 / 导电胶10 ~ 30ms30 ~ 50ms霍尔 / 电容触摸0.1 ~ 1ms5 ~ 10ms以 10ms 扫描周期为例消抖窗口 20ms 对应连续 2 次采样一致窗口 30ms 对应连续 3 次。别把消抖窗口设到单个扫描周期以内比如 5ms 窗口在 10ms 扫描下等于没有消抖。如果按键老化、氧化导致抖动加大窗口调大到 30ms 或 50ms比换滤波电容更直接。裸机主循环里用一个 10ms 定时标志控制扫描节奏代码骨架如下/* main.c裸机主循环 10ms 节拍扫描 */ volatile uint8_t tick_10ms 0; void SysTick_Handler(void) { static uint8_t cnt 0; if (cnt 10) { /* 假设 SysTick 是 1ms 中断 */ cnt 0; tick_10ms 1; } } key_sm_t key1; key_sm_t key2; int main(void) { while (1) { if (tick_10ms) { tick_10ms 0; key_sm_scan(key1, gpio_read(KEY1_IO)); key_sm_scan(key2, gpio_read(KEY2_IO)); } } }扫描入口放在主循环而不是中断里跑完整状态机避免 GPIO 操作和事件分发占用中断时间。每个按键独占一个key_sm_t实例多按键就复制结构体并逐个喂数据状态机之间互不影响。key_sm_t和key_sm_scan的接口先定义到头文件里/* key_sm.h */ #pragma once #include stdint.h typedef enum { KEY_EV_SINGLE_CLICK 1, KEY_EV_DOUBLE_CLICK, KEY_EV_LONG_PRESS, KEY_EV_LONG_REPEAT, KEY_EV_RELEASE } key_event_t; typedef struct { uint8_t state; uint16_t cnt; uint8_t single_pending; /* 是否有待提交的单击 */ } key_sm_t; void key_sm_scan(key_sm_t *ks, uint8_t level);接口里不需要传时间基准状态机内部所有计时都换算成扫描次数主循环只要保证key_sm_scan按 10ms 周期被调用即可。2.3 扫描周期和消抖的量化误差扫描周期固定后所有时间参数都必须转换成整数扫描次数。这里有一个容易被忽略的量化误差如果长按阈值设成 805ms在 10ms 扫描下实际触发时间是 800ms 或 810ms。所以参数设定时先做除法余数不能直接丢掉要就近取整否则实测行为会与设计值差一个周期。我在工程里的习惯是所有时间参数统一用宏定义单位是毫秒实际计数用除法换算。这样调整参数时只改一处宏不用翻状态机逻辑。扫描周期尽量选 10ms 的理由是它能同时覆盖 20ms 消抖和 300ms 双击窗口的整数关系计算简单也足够响应常见的短按操作。3. 长按、短按、双击状态机从电平到事件的完整实现3.1 五个状态覆盖按压动作的全周期状态机要覆盖“按下抖动稳定、按下保持、释放后等待、长按确认”四个时间点拆成五个状态状态含义ST_IDLE空闲待机ST_PRESS_DEBOUNCE按下消抖中ST_HOLDING按下已确认等待长按阈值ST_LONG_ACTIVE长按已触发等待释放ST_CLICK_WAIT已释放等待双击窗口在 ST_HOLDING 中如果计时达到长按阈值进入 ST_LONG_ACTIVE 并上报长按如果先松开则进入 ST_CLICK_WAIT 并标记“有一个待定单击”。双击窗口内再次按下就把待定单击升级成双击窗口超时就补发单击。关键点在于“单击”不能一松手就发必须等双击窗口过期否则双击会被拆成两次单击。状态转移关系整理成表方便写代码时对照当前状态条件动作 / 事件下一状态ST_IDLE按下消抖计数 1ST_PRESS_DEBOUNCEST_PRESS_DEBOUNCE连续 N 次按下有待定单击则改判双击ST_HOLDINGST_PRESS_DEBOUNCE消抖中途释放丢弃本次抖动ST_IDLE 或 ST_CLICK_WAITST_HOLDING计时 ≥ 长按阈值上报长按ST_LONG_ACTIVEST_HOLDING松开标记待定单击ST_CLICK_WAITST_CLICK_WAIT窗口内按下进入消抖等待变双击ST_PRESS_DEBOUNCEST_CLICK_WAIT窗口超时补发单击ST_IDLEST_LONG_ACTIVE松开上报释放ST_IDLE这个表里最容易被新手写反的是“ST_HOLDING 松开后不能直接发单击”而要先进入等待窗口。另一个容易漏的是“ST_LONG_ACTIVE 松开时不再发单击”否则会出现长按结束后又补一个短按的双重触发。3.2 可直接落地的 C 实现以下是完整的按键状态机实现对应key_sm.h的接口/* key_sm.c */ #include key_sm.h #define KEY_SCAN_MS 10 #define T_DEBOUNCE_MS 20 #define T_LONG_MS 800 #define T_DOUBLE_MS 300 #define DEBOUNCE_CNT (T_DEBOUNCE_MS / KEY_SCAN_MS) /* 2 次连续稳定 */ #define LONG_CNT (T_LONG_MS / KEY_SCAN_MS) /* 80 次扫描 */ #define DOUBLE_CNT (T_DOUBLE_MS / KEY_SCAN_MS) /* 30 次扫描 */ enum { ST_IDLE 0, ST_PRESS_DEBOUNCE, ST_HOLDING, ST_LONG_ACTIVE, ST_CLICK_WAIT }; static void key_event_post(uint8_t ev); void key_sm_scan(key_sm_t *ks, uint8_t level) { uint8_t pressed (level 0); /* 低电平有效 */ switch (ks-state) { case ST_IDLE: if (pressed) { ks-state ST_PRESS_DEBOUNCE; ks-cnt 1; } break; case ST_PRESS_DEBOUNCE: if (pressed) { if (ks-cnt DEBOUNCE_CNT) { if (ks-single_pending) { /* 双击窗口内第二次按下生效取消待定单击上报双击 */ ks-single_pending 0; key_event_post(KEY_EV_DOUBLE_CLICK); } ks-state ST_HOLDING; ks-cnt 0; } } else { if (ks-single_pending) { /* 第二次按下消抖失败回到双击窗口继续等 */ ks-state ST_CLICK_WAIT; } else { /* 第一次按下消抖失败视为杂波 */ ks-state ST_IDLE; } ks-cnt 0; } break; case ST_HOLDING: if (pressed) { if (ks-cnt LONG_CNT) { ks-state ST_LONG_ACTIVE; ks-cnt 0; ks-single_pending 0; key_event_post(KEY_EV_LONG_PRESS); } } else { /* 未达到长按阈值就松开标记待定单击 */ ks-state ST_CLICK_WAIT; ks-cnt 0; ks-single_pending 1; } break; case ST_CLICK_WAIT: if (pressed) { /* 窗口内再次按下重新消抖 */ ks-state ST_PRESS_DEBOUNCE; ks-cnt 1; } else if (ks-cnt DOUBLE_CNT) { /* 双击窗口超时补发单击 */ if (ks-single_pending) { ks-single_pending 0; key_event_post(KEY_EV_SINGLE_CLICK); } ks-state ST_IDLE; } break; case ST_LONG_ACTIVE: if (!pressed) { ks-state ST_IDLE; ks-cnt 0; key_event_post(KEY_EV_RELEASE); } break; } }代码的边界处理比常规按键轮询要多两层考虑。第一ST_PRESS_DEBOUNCE 消抖失败时区分是“第一次按下”还是“第二次按下”第二次按下失败要退回 ST_CLICK_WAIT否则待定单击的窗口计时会乱。第二ST_CLICK_WAIT 里再次按下后先回到消抖状态而不是立即确认为双击因为第二次按下同样有机械抖动必须经过消抖才算有效。事件上报统一走key_event_post外部回调。回调里做 UI 切换、消息发送、日志输出都可以但不要在回调里阻塞超过一个扫描周期。事件与扫描分离后键盘扫描层不用关心业务逻辑按键响应粒度也更容易测试。3.3 手算一遍完整的双击时序以 10ms 扫描周期为基础双击过程是0ms 第一次按下20ms 消抖完成进入 ST_HOLDING200ms 松开进入 ST_CLICK_WAIT 并设置待定单击350ms 第二次按下370ms 消抖完成此时发现single_pending 1置零并上报双击。整个过程两次按下间隔 150ms落在 300ms 窗口内识别正确。如果把第二次按下放在 310ms即窗口最后 10ms 内按下时序依然成立。用户手指习惯不同双击窗口的起始点是“第一次释放时刻”而不是“第一次按下时刻”。这个语义必须跟产品经理对齐否则容易出现参数实测对不上的问题。4. 时间阈值与重复上报参数怎么调、边界怎么防4.1 时间参数先定扫描周期再定阈值所有阈值在代码里都以扫描周期为单位因此先定扫描周期再定消抖、长按、双击的时间。下表是一组实用初值和调整方向参数10ms 扫描下的初值典型范围调整方向消抖窗口20ms10 ~ 50ms按键抖动大就加到 30ms 以上长按阈值800ms500 ~ 2000ms长者模式建议 1000ms 以上双击窗口300ms250 ~ 400ms双击成功率低就加到 350ms重复上报周期500ms200 ~ 1000ms音量类需求常用 200ms一个容易踩的坑长按阈值和消抖窗口是先后关系。假设需求写明“按下 800ms 触发长按”从按下时刻算起消抖已经消耗了 20ms。若代码按 800ms 整算实际触发点会延迟到 820ms。对用户体验来说800ms 和 820ms 几乎无法区分但如果产品有严格要求长按计数应该扣除消抖窗口。另一个归整技巧时间参数尽量设置成扫描周期的整数倍。例如 700ms 除以 10ms 是 70 次完全没有误差如果是 705ms量化后可能落在 700ms 或 710ms。写配置时直接写成整数次扫描避免宏展开后出现除不尽的情况。4.2 双击窗口和短按防误触是一对矛盾双击窗口设得越大“两次独立短按”被合并成双击的概率越高设得越小用户手速稍慢就识别不成。我一般先设 300ms用真实用户在不同人群、不同按压习惯下测试。抱怨双击成功率低优先加到 350ms不要动消抖抱怨单击变双击优先减到 280ms也不要一次砍太多。这里有个和消抖窗口的联动关系双击窗口要至少大于消抖窗口的 3 倍否则第二次按下还在消抖时窗口已经超时双击永远识别不了。按默认参数 20ms 消抖、300ms 窗口余量足够。4.3 长按重复上报从“只触发一次”到“连续步进”基础状态机里长按触发一次后只有释放事件适合开关类需求。音量调节、翻页这类需求需要长按期间持续上报。把第 3 章的 ST_LONG_ACTIVE 分支替换成下面的实现#define REPEAT_CNT (500 / KEY_SCAN_MS) /* 每 500ms 一次 */ case ST_LONG_ACTIVE: if (pressed) { if (ks-cnt REPEAT_CNT) { ks-cnt 0; key_event_post(KEY_EV_LONG_REPEAT); } } else { ks-cnt 0; ks-state ST_IDLE; key_event_post(KEY_EV_RELEASE); } break;这个分支里进入 ST_LONG_ACTIVE 时已经上报过一次KEY_EV_LONG_PRESS之后每 500ms 上报一次KEY_EV_LONG_REPEAT直到松开。两个事件分开业务层可以区分“刚进入长按”和“长按持续中”音量条动画不至于首帧跳动过快。若产品要求首屏立即响应把初始步进时间设短一点比如首次 200ms后续 500ms需要再加一个标志位记录是否首次重复。4.4 按键中断场景中断只负责唤醒不负责判定低功耗设备常用外部中断唤醒芯片但不要在中断服务函数里跑完整状态机。中断上下文不稳定扫描节拍会被打断长按计时失真而且中断里执行回调可能和主循环竞争变量。常见做法是外部中断只置一个唤醒标志系统醒过来后立刻启动 10ms 节拍继续用key_sm_scan扫描同一个按键。如果按键在睡眠前已经处于按住状态唤醒后的第一次扫描会看到高电平状态机会把它当作正常按下处理不会因此多出一个长按事件。这个行为正好也是产品想要的任何边缘唤醒后按键逻辑从当前真实电平重新开始。5. 可观测性与验证按键状态机的调参三板斧5.1 事件回灌法把操作序列变成文本调试按键最怕“凭手感”。在key_event_post里加一行日志把每个事件和时间戳打出来static void key_event_post(uint8_t ev) { static const char *n[] { , SINGLE, DOUBLE, LONG, LONG_RPT, RELEASE }; printf([%u] %s\n, (unsigned)tick_ms(), n[ev]); }手动按出“单击 - 缓慢双击 - 长按”三组动作把输出保存为基准日志。每次改时间参数后重新跑一遍相同的操作序列对比两次日志差异就能确认参数调整是否影响了既有行为。这套方法比盯着示波器看电平直观得多特别适合裸机调试时没有逻辑分析仪的场景。5.2 GPIO 示踪状态迁移和事件分两路引出不依赖串口的调试方式是额外占用两个 GPIO一个翻转表示状态机正在运行一个在事件上报时翻转。逻辑分析仪抓两个通道主循环卡死时能看到扫描引脚不再跳变长按阈值设错时事件引脚和状态引脚之间的偏移会直接暴露出来。调试完成后把这两个引脚释放不影响产品功能。5.3 从 GPIO 状态机扩展到 ADC 按键和矩阵键盘状态机本身不关心电平来源。ADC 按键把分压区间映射成level 1或level 0继续调用同一个扫描函数两个 IO 口做四个按键的电阻网络方案也只需要在采样层做区间判断。矩阵键盘则先把行、列扫描转换成逻辑键值再为每个键值维护一个独立的key_sm_t实例。只要扫描入口保持固定节拍状态机的时间语义就不会变。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门