ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

嵌入式按键状态机架构:消抖、单击双击长按检测与C代码实现

嵌入式按键状态机架构:消抖、单击双击长按检测与C代码实现 做嵌入式这些年我见过太多人栽在按键处理上新手写的第一版代码十个里有八个会在双击和长按这两个场景翻车。原因不复杂——大多数教程把按键检测教成了电平读取读到低电平就认为按下读到高电平就认为松开至于中间那十几毫秒的机械抖动、用户手速的快慢、按压持续的长短全都没进过代码的脑子。结果就是单击偶尔变双击长按一分神就串成好几下产品拿给用户试两分钟需求就被打回重写了。这篇文章把我自己在多个量产项目里反复使用的按键状态机架构完整拆开讲。从机械抖动的物理原理到硬件消抖和软件消抖的取舍再到单击、双击、长按怎么在一个状态机里共存时间参数怎么标定最后附上可以直接复制使用的C代码和一套调试手段。代码以C语言为主STM32、ESP32或者裸机循环都能直接套底层读IO的接口换成你自己的就行。文章末尾我还会给一个FPGA场景下用Verilog做按键检测的思路这对做逻辑开发的同行应该也有参考价值。1. 先把对手摸清机械抖动如何毁掉你的按键逻辑1.1 按下一次读到五次很多人第一次用示波器看按键波形时都会愣一下明明只按了一下IO口上却是一连串高低电平的毛刺持续好几毫秒甚至二十毫秒。这就是机械开关的抖动bounce。按键内部是两片金属触点按下去的那一瞬间触点不是直接贴合而是像乒乓球一样来回弹跳几次每一次弹跳都会让回路通断一次。等你手指彻底压实了电平才稳定下来。如果不做任何处理直接用if(KEY_READ()0) counter;这种代码去数按键次数按一次计数器可能涨三五次。更麻烦的是松开按键时同样会抖动。很多人的消抖只做了按下方向松开方向的抖动会在你不知不觉中把一个单击拆成按下、松开、又按下、又松开的多个事件最后引发逻辑错乱。1.2 抖动的时间尺度与硬件底细不同开关的抖动时长差异很大这是选参数的根本依据开关类型典型抖动时长说明微动开关鼠标那种1~10ms触发行程短弹跳少贴片轻触按键5~20ms消费电子最常见带弹簧的机械按键10~30ms行程长弹跳明显老化的触点开关20ms以上磨损后弹跳加剧除了弹跳还有一类干扰是环境电磁噪声尤其是按键引线较长、靠近电机或者电源时线上会耦合尖峰毛刺。这类毛刺可能出现在按键的任意状态包括按住不放的中间过程。所以一套完整的按键处理不仅要管按下和松开瞬间还要在确认按下后持续按住的过程里也能扛住偶发的错误电平。1.3 从电平读数到语义事件思维的第一次升级第一批做按键驱动的工程师很快发现靠连续读电平去猜用户想干什么是不靠谱的。用户的手不会按照国家标准的时序去按单击、双击、长按之间唯一的区别就是时间分布。要想把一串高低电平翻译成用户单击了用户双击了用户长按了这样的语义事件就必须引入时间维度而时间维度最好用状态机来管理。状态机的本质是我只关心我现在处于什么阶段以及每个阶段允许发生什么跳转。这样消抖、计时、事件判定全都有了明确的上下文不会出现用户已经完成双击的第一个单击正在等第二次按下结果系统已经把第一次算成单击发出去这种乌龙。下面我会把这个模型完整建出来。2. 消抖方案横向对比硬件滤波与软件采样的正确组合2.1 硬件消抖RC低通滤波的一次手算最简单的硬件消抖是在按键输出端加一个RC低通滤波器。典型接法VCC ── 10kΩ ──┬── 按键 ── GND │ 100nF │ GNDMCU的IO检测点取在10kΩ电阻和按键的连接处。按键按下时触点相当于一个小电阻几欧姆到几十欧姆电容通过触点和按键快速放电到地电平为低松开时电容通过10kΩ电阻缓慢充电回高电平。时间常数 τ R×C 10kΩ × 100nF 1ms经过大约3~5个τ电平才能充到高电平阈值以上。触点抖动产生的窄脉冲会被这个低通网络大幅衰减如果MCU引脚本身带施密特触发器比如STM32的很多引脚还能进一步抑制缓变斜坡在阈值附近的反复翻转。这里有个容易被忽略的坑RC消抖对松开方向的抖动效果比按下方向好。因为按下时抖动来源于触点弹跳电容会被触点快速放电几乎起不到滤波作用。所以纯硬件消抖并不完美通常还是要在软件侧再做一道确认。2.2 软件消抖阻塞延时为什么是双击检测的头号杀手软件消抖最原始的做法是延时法检测到电平变化后delay(10)或者delay(20)再读一次如果电平还是新的就确认状态变了。这段代码在教学里看着没问题但放到真实产品里有两个致命伤。第一个是阻塞延时期间CPU什么都干不了如果主循环里还要刷屏、跑通信、做协议按一次键就卡20ms整体体验会非常糟糕。第二个更隐蔽直接跟你做双击检测的需求冲突——双击判定需要记录第一次按下并松开的时间点然后等待第二次按下窗口通常是200~300ms。如果你在第一次按下后先阻塞20ms消抖接着又阻塞20ms消抖松开再去启动双击窗口计时手速快一点的用户可能已经完成第二次按下了你的计时器还没开始跑。所以在需要识别复杂按键事件的项目里我强烈不建议用阻塞延时做消抖。这是一条能省则省的死路。2.3 周期采样法非阻塞消抖的标准做法正确的打开方式是周期采样。用一个固定周期的定时器一般是1ms~10ms驱动扫描函数每次采样一次IO电平只有当连续N次采样都得到相同电平时才认为电平真正改变了。比如扫描周期5ms消抖时间20ms那就需要连续4次采样都是低电平才能确认按下成立。这4次采样分布在20ms的时间窗内只要中间任意一次读到高电平就认为之前的低电平是抖动重新计数。这样既不阻塞主循环又能把常见按键的抖动全部滤掉。周期性采样的另一个好处是给整个状态机提供了一个稳定的时间基准。后面所有的双击窗口、长按阈值全部用扫描次数 × 周期来累加整个系统的节拍就统一了。2.4 怎么组合选择硬件方案和软件方案不是二选一而是根据场景叠加。我的习惯是环境方案组合理由消费电子、面板按键软件周期采样即可BOM成本敏感普通环境噪声可控工业设备、引线较长RC滤波 软件周期采样外部电磁干扰大硬件先挡一部分对响应时间极敏感的场景纯硬件RC 施密特触发器比如急停、安全联锁软件无法容忍额外延迟教学Demo、逻辑验证阻塞延时法代码最直观但不适合量产如果你是做量产产品直接按RC 周期采样 状态机这个组合走省事也稳。RC挡住了高频毛刺周期采样确认了稳定电平状态机负责把稳定电平翻译成用户语义。3. 状态机建模把单击、双击、长按放进一张迁移图里3.1 为什么非要状态机if-else 扛不住在哪有人会想不就是判断一下按了几次、按了多久吗用几个全局变量加 if-else 不就行了早期我也这么干过但接下来会遇到无穷无尽的组合问题如果用户在双击窗口内第三次按下怎么办如果用户长按到一半松开了这个半成品长按算不算一次单击如果用户在双击窗口内按住了第二下并持续了1秒是算双击还是算长按这些问题的本质是单个布尔变量的当前是否按下信息量太低了它回答不了这件事进行到哪一步了。状态机用一组离散状态描述整个交互流程的进度每个状态下只处理该状态相关的输入逻辑复杂度就从全局组合爆炸降成了局部分支判断。3.2 六个状态的定义与迁移条件我常用的一套状态定义如下覆盖消抖、单击、双击、长按的完整生命周期状态含义进入条件离开条件ST_IDLE空闲等待按键复位、或某事件流程结束读到低电平ST_PRESS_DEBOUNCE检测到低电平正在确认按下从IDLE/WAIT_DOUBLE检测到0连续采样稳定为低 → ST_PRESSED出现高 → 回IDLEST_PRESSED已确认按下正在计时判断长按消抖确认按住超阈值 → ST_LONG_PRESSED松开 → ST_RELEASE_DEBOUNCEST_RELEASE_DEBOUNCE检测到高电平正在确认松开从PRESSED/LONG_PRESSED读到1稳定为高 → 根据上下文跳转又读回0 → 回ST_PRESSEDST_WAIT_DOUBLE第一次单击已完成正在等待第二次按下第一次松开消抖确认窗口内按下 → ST_PRESS_DEBOUNCE超时 → 发单击事件回IDLEST_LONG_PRESSED长按已触发等待用户松开长按计时到达读到高电平 → ST_RELEASE_DEBOUNCE关键点在于单击和双击的判定不是按下时做的而是松开后拖到 ST_WAIT_DOUBLE 窗口里做的。第一次成功松开后不急着发单击事件等一个 250ms 的双击窗口。窗口内有第二次按下就准备发双击窗口超时才补发单击。这个延迟确认策略是整套架构的灵魂理解了它双击和长按共存的难题就解了一半。3.3 三类典型操作的完整时间线用时间线来推演三种操作最直观单击IDLE → 按下抖动 → PRESS_DEBOUNCE(20ms) → 确认按下 PRESSED → 快速松开 → RELEASE_DEBOUNCE(20ms) → 确认松开 → WAIT_DOUBLE(等250ms) → 没有第二次按下 → 发 SINGLE_CLICK → IDLE双击IDLE → 第一次按下/松开同上到 WAIT_DOUBLE → 窗口内250ms检测到第二次按下 → PRESS_DEBOUNCE → 第二次松开确认 → 发 DOUBLE_CLICK → IDLE长按IDLE → 按下消抖 → PRESSED → 持续按住超过800ms → 发 LONG_PRESS → LONG_PRESSED期间不再重复发 → 松开确认 → 直接回IDLE注意长按松开后不会补发单击。有些人做的架构里长按结束后还会多出一次单击事件那是没有把 long_issued 标志位清干净导致的后面我会在代码里专门处理。3.4 单计时器与多计时器的取舍状态机需要计时的场景有三个消抖计时、长按计时、双击窗口计时。但实际上这些计时分布在不同的状态里一个状态同一时刻只需要一个计时器。所以实现时我用的是一个uint32_t timer_ms字段在每个状态下累加扫描周期即可不需要为每种计时单独开变量。只有当一段逻辑需要同时保留已用时长和剩余窗口时才考虑多计时器我的这套状态机用不到。4. 可直接复用的C语言实现数据结构、核心函数与回调4.1 数据结构与事件定义先定义对外的事件类型和内部状态枚举/* key_event.h */ typedef enum { KEY_EVENT_NONE 0, KEY_EVENT_SINGLE_CLICK, KEY_EVENT_DOUBLE_CLICK, KEY_EVENT_LONG_PRESS } key_event_t; typedef void (*key_callback_t)(uint8_t key_id, key_event_t event);内部每个按键实例需要保存的状态字段/* key_state.h */ typedef enum { ST_IDLE 0, ST_PRESS_DEBOUNCE, ST_PRESSED, ST_RELEASE_DEBOUNCE, ST_WAIT_DOUBLE, ST_LONG_PRESSED } key_state_t; typedef struct { key_state_t state; uint32_t timer_ms; /* 当前状态下的累计时长 */ uint8_t click_count; /* 已完成的有效单击次数 */ uint8_t long_issued; /* 本次按下是否已发过长按事件 */ } key_obj_t;时间参数单独抽成宏方便按项目调整#define KEY_SCAN_PERIOD_MS 5 /* 扫描周期 */ #define KEY_DEBOUNCE_TIME_MS 20 /* 消抖确认时间 */ #define KEY_LONG_PRESS_MS 800 /* 长按阈值 */ #define KEY_DOUBLE_WINDOW_MS 250 /* 双击判定窗口 */4.2 核心状态机函数实现核心函数接收按键ID和当前采样值每次扫描调用一次。这里假设低电平为按下如果你的电路是反的把sample 0和sample 1对调即可。/* key_machine.c */ static key_obj_t s_keys[KEY_NUM]; static key_callback_t s_callback; static void key_emit(uint8_t key_id, key_event_t event) { if (s_callback) { s_callback(key_id, event); } } void key_scan(uint8_t key_id, uint8_t sample) { key_obj_t *k s_keys[key_id]; switch (k-state) { case ST_IDLE: if (sample 0) { k-state ST_PRESS_DEBOUNCE; k-timer_ms 0; } break; case ST_PRESS_DEBOUNCE: if (sample 0) { k-timer_ms KEY_SCAN_PERIOD_MS; if (k-timer_ms KEY_DEBOUNCE_TIME_MS) { /* 消抖确认进入按下状态 */ k-state ST_PRESSED; k-timer_ms 0; } } else { /* 抖动毛刺放弃本次按下 */ k-state ST_IDLE; k-timer_ms 0; } break; case ST_PRESSED: if (sample 0) { k-timer_ms KEY_SCAN_PERIOD_MS; if (k-timer_ms KEY_LONG_PRESS_MS) { /* 达到长按阈值只发一次 */ k-long_issued 1; k-state ST_LONG_PRESSED; k-timer_ms 0; key_emit(key_id, KEY_EVENT_LONG_PRESS); } } else { /* 未到长按阈值就松开进入释放消抖 */ k-state ST_RELEASE_DEBOUNCE; k-timer_ms 0; } break; case ST_RELEASE_DEBOUNCE: if (sample 1) { k-timer_ms KEY_SCAN_PERIOD_MS; if (k-timer_ms KEY_DEBOUNCE_TIME_MS) { /* 确认松开 */ if (k-long_issued) { /* 长按结束清标志补发单击 */ k-long_issued 0; k-click_count 0; k-state ST_IDLE; } else { k-click_count; if (k-click_count 1) { /* 第一次单击完成进入等双击窗口 */ k-state ST_WAIT_DOUBLE; k-timer_ms 0; } else { /* 第二次单击完成双击成立 */ k-click_count 0; k-state ST_IDLE; key_emit(key_id, KEY_EVENT_DOUBLE_CLICK); } } } } else { /* 松开瞬间又读回低电平还没真松开回到按着 */ k-state ST_PRESSED; k-timer_ms 0; } break; case ST_WAIT_DOUBLE: k-timer_ms KEY_SCAN_PERIOD_MS; if (sample 0) { /* 双击窗口内检测到第二次按下 */ k-state ST_PRESS_DEBOUNCE; k-timer_ms 0; } else if (k-timer_ms KEY_DOUBLE_WINDOW_MS) { /* 窗口超时补发单击 */ k-click_count 0; k-state ST_IDLE; key_emit(key_id, KEY_EVENT_SINGLE_CLICK); } break; case ST_LONG_PRESSED: if (sample 1) { k-state ST_RELEASE_DEBOUNCE; k-timer_ms 0; } break; default: k-state ST_IDLE; k-click_count 0; k-long_issued 0; break; } }这段代码里值得注意的一个细节是 ST_RELEASE_DEBOUNCE 中又读回低电平的跳转。按键松开的过程中同样有抖动如果不加这个回退抖动会被当成一次完整的松开按下循环click_count 就会异常增加双击判定就会失灵。很多人做了按下消抖却忘记做释放消抖的回退最终在真实硬件上跑出诡异的双击误触发就是这个原因。4.3 主循环与时间基准的组织方式上面的key_scan不含任何阻塞延时它只是对一次采样做状态推进。调用方式取决于你的系统结构裸机定时器方式volatile uint8_t g_scan_flag 0; void SysTick_Handler(void) /* 1ms中断 */ { static uint16_t tick 0; if (tick 5) { tick 0; g_scan_flag 1; } } void main_loop(void) { key_init(app_on_key_event); while (1) { if (g_scan_flag) { g_scan_flag 0; key_scan(0, KEY_READ(0)); /* 其他按键 */ } /* 其他业务 */ } }RTOS线程方式void key_task(void *arg) { key_init(app_on_key_event); while (1) { key_scan(0, KEY_READ(0)); vTaskDelay(pdMS_TO_TICKS(5)); } }扫描周期定为5ms消抖20ms意味着需要连续4次采样确认。这个分辨率对按键场景完全够用人手指的典型按压时间都在100ms以上5ms的量化误差无感。4.4 回调机制让业务代码彻底忘掉按键波形对外只暴露注册回调和初始化函数void key_init(key_callback_t cb); void key_scan(uint8_t key_id, uint8_t sample);业务层写起来就是干净的分支void app_on_key_event(uint8_t key_id, key_event_t event) { switch (event) { case KEY_EVENT_SINGLE_CLICK: /* 单击切换页面 */ break; case KEY_EVENT_DOUBLE_CLICK: /* 双击回到主界面 */ break; case KEY_EVENT_LONG_PRESS: /* 长按进入配置模式 */ break; default: break; } }这样按键驱动和业务逻辑彻底解耦。换芯片、改IO口、调整时间参数都不会污染上层代码。这也是我推荐这套架构的核心原因它不是把逻辑写死在一份代码里而是把物理信号和用户意图之间的翻译层独立出来。5. 时间参数标定与边界场景推演5.1 消抖时间、双击窗口、长按阈值的取值逻辑参数不是拍脑袋定的每个值都有它的物理依据和约束关系。消抖时间KEY_DEBOUNCE_TIME_MS必须大于你实际使用的按键的最大抖动时长。便宜的轻触开关在20ms左右我一般取20ms如果用的是质量差的老化开关建议取30ms。太小会漏掉抖动尾巴太大则会吃掉用户的按压时间影响后续窗口判定。双击窗口KEY_DOUBLE_WINDOW_MS这个值取决于目标用户。我做过一堆遥控器和面板经验值是200~300ms。取250ms是比较稳的中间值手速正常的用户双击间隔在100~200ms留给250ms的窗口足够同时250ms又明显短于长按阈值两者不会混淆。如果产品面向老人可以把窗口放宽到300ms代价是单击事件的响应会延迟到300ms后才发出用户会感觉单击略微迟钝。长按阈值KEY_LONG_PRESS_MS常规取600~1000ms我用800ms。长按和双击窗口之间必须有明显的隔离带。如果长按阈值定在300ms用户双击第二个落点稍慢就会被判定成长按体验直接崩掉。我的建议是长按阈值最少要是双击窗口的2.5倍以上。5.2 参数之间的硬约束关系把三个参数放在一张表里看它们的逻辑链参数典型值必须满足的约束扫描周期5ms决定了所有计时分辨率消抖时间20ms大于按键物理抖动时长双击窗口250ms远大于消抖时间且明显小于长按阈值长按阈值800ms大于双击窗口2~3倍还有一个隐含约束双击窗口必须大于完成一次完整按下并松开的物理时间。一次按下到松开本身就需要消抖20ms 用户按压人体工学上最短的按压时长大约80~120ms 释放消抖20ms。双击窗口如果只给150ms严格来说两次连续按下的总时间可能就接近甚至超过窗口了用户会经常失败。5.3 几个容易翻车的边界场景场景一双击窗口内第三次按下。我的实现里第二次松开确认后已经发出了 DOUBLE_CLICK 并回到 IDLE第三次按下会被当成新的一次交互。所以三次快速点击的结果是双击 单击这是大多数消费电子产品的标准行为可接受。如果你需要专门识别三击那要再加状态和计数架构上并不难但要不要做取决于产品定义。场景二双击的第二次按下变成了长按。比如用户进了双击窗口第二次按下后没松开一直按住超过800ms。我的代码里会先发 LONG_PRESS松开时由于 long_issued 标志已置位直接清空并回IDLE第一次的那个单击就被吞掉了。从用户感知来说这更接近我想长按只是动作里带了一次多余的敲击吞掉单击是合理的。场景三长按结束后马上再单击。长按松开回IDLE后下一次按下就是全新交互会正常走单击流程。这里的关键是 ST_RELEASE_DEBOUNCE 里长按松开分支必须把 click_count 清零否则上一轮残留的计数会污染下一次判定。场景四极窄的噪声脉冲。比如一个5ms的毛刺出现在空闲态代码会进入PRESS_DEBOUNCE然后下一个周期又读到高电平退回IDLE不会产生任何事件。这正是周期采样消抖的意义。场景五用户在双击窗口超时的边缘按下了第二下。由于扫描周期是5ms窗口边界存在最多5ms的量化误差。250ms窗口实际生效区间是245~250ms之间这种误差人完全感知不到不用纠结。6. 量产级调试我踩过的坑和定位手段6.1 三个典型故障案例复盘故障一单击偶尔变双击。现象是用户按一下灯偶尔闪两下。用示波器抓引脚电平发现按下抖动已经消干净了但释放抖动没消干净——我的第一版代码在ST_RELEASE_DEBOUNCE里缺少读回低电平就退回ST_PRESSED的回退分支导致释放过程中的弹跳被解析成一次额外的按下松开click_count从1变成2双击事件就被送出去了。修复就是补上那个回退判断。这个坑很典型也是我前面强调释放消抖必须做回退的原因。故障二双击反应明显比单击慢半拍。有人把双击窗口调成了500ms去迁就手慢的用户结果单击事件也跟着延迟了500ms才发。这不是代码bug是参数约束的必然结果只要采用窗口超时才发单击的策略单击的响应延迟就等于双击窗口。想要单击响应快只能缩小窗口或者引入更激进的策略——比如在窗口内如果检测到按下时间超过200ms就直接判定这不是双击节奏提前结束窗口发单击。但工程上很少这么做我宁愿保持代码简单把窗口控制在合理范围。故障三长按松开后多出一声蜂鸣。现象是长按功能正常但松开时业务层又收到一次单击事件。排查后是长按松开分支里忘了清 long_issued 和 click_count导致释放确认后走了普通单击的计数路径。我在量产代码里把长按松开分支单独断开、直接回IDLE就是在用结构杜绝这类问题。6.2 用逻辑分析仪和状态打印定位按键问题按键问题最容易犯的错是靠猜测。我调试按键的一套固定流程是逻辑分析仪抓引脚波形。先确认物理层到底发生了什么。波形干净、无弹跳说明硬件没问题问题在软件波形毛刺一堆说明消抖参数或者硬件电路有问题。这一步能把问题范围缩小一半。串口实时打印状态。调试模式下每次key_scan都把按键ID、当前状态、累计时长、采样电平打出来。比如打印key0 ST_PRESSED t123ms lv0你就知道它什么时候进入按下态、按了多久。把状态机六个状态完整打一遍迁移路径一目了然。用信号发生器模拟按键时序。手动按按键最大的问题是不可重复。我习惯用信号发生器输出方波精确模拟按20ms松开20ms再按20ms这种脚本化时序一遍遍跑直到双击判定稳定。方波不带真实抖动的毛刺但配合前面的波形分析足够覆盖软件逻辑的正确性验证。写一个PC端脚本跑模拟测试。如果你把KEY_READ抽象成函数指针可以在PC上喂一组预先录好的电平序列断言最终产生的事件序列是否符合预期。这本质上是给按键驱动做单元测试投入不大但对发布前回归非常有价值。6.3 多按键矩阵与组合按键的扩展单个按键的状态机跑通后扩展多按键很简单。按键矩阵扫描时逐行逐列调用key_scan即可每个按键对应一个key_obj_t实例互不干扰。需要注意矩阵扫描的行列翻转信号别漏正常的矩阵消抖流程是先选中一行读列再切换到下一行。组合按键比如音量减 电源强制重启的实现思路是在按键驱动之上再加一层快照每次状态机产生按下确认或松开确认时更新一个 pressed_mask 位图组合键检测模块在每次更新时检查位图是否匹配预定义组合。这个模块只关心当前按住哪些键不关心单个键的单击双击逻辑两层各司其职代码不会乱。7. 从MCU到FPGAVerilog版本的按键状态机思路7.1 硬件化处理按键的好处FPGA场景下按键检测同样存在抖动问题但目标不同MCU里软件状态机追求低CPU占用和逻辑清晰FPGA里硬件化追求的是确定性和零软件开销。尤其是带有状态锁存或高速采样的逻辑按键事件如果依赖软件轮询会引入不确定的响应延迟这在某些控制场合不可接受。硬件实现的核心思想和C语言完全一致消抖、状态迁移、事件输出。区别是扫描节拍来自一个分频后的时钟通常是1kHz对应1ms的采样周期。7.2 可综合的消抖模块下面是一个经典的双级同步加计数消抖模块。先对输入做两级同步防止亚稳态再用计数器确认稳定电平。module key_debounce #( parameter STABLE_CYCLES 20 // 20ms 1kHz )( input wire clk_1k, input wire key_in, // 0 pressed output reg key_out ); reg [4:0] cnt 0; reg sync1 1b1; reg sync2 1b1; wire key_stable sync2; always (posedge clk_1k) begin sync1 key_in; sync2 sync1; end always (posedge clk_1k) begin if (key_stable key_out) begin cnt 0; end else if (cnt STABLE_CYCLES - 1) begin key_out key_stable; cnt 0; end else begin cnt cnt 1b1; end end endmodule这个模块输出key_out已经是消抖后的稳定电平状态机模块拿它当输入即可。7.3 状态机模块骨架事件检测的FSM结构如下用 localparam 定义状态用1kHz时钟做节拍。下面只列出骨架和关键分支完整代码在项目里实现时按产品需求补全事件输出即可。module key_fsm ( input wire clk_1k, input wire key_in, // 已消抖电平 output reg ev_single, output reg ev_double, output reg ev_long ); localparam S_IDLE 3d0; localparam S_PRESS 3d1; localparam S_RELEASE 3d2; localparam S_WAIT 3d3; localparam S_LONG 3d4; reg [2:0] state S_IDLE; reg [9:0] timer 0; reg long_sent 0; reg click_cnt 0; always (posedge clk_1k) begin case (state) S_IDLE: begin if (key_in 1b0) state S_PRESS; end S_PRESS: begin if (key_in 1b0) begin if (timer 800) begin ev_long 1b1; long_sent 1b1; state S_LONG; timer 0; end else begin timer timer 1b1; end end else begin state S_RELEASE; timer 0; end end // S_RELEASE / S_WAIT / S_LONG 分支与C版本一一对应 endcase end endmodule硬件化实现的优势是1ms节拍固定不存在软件轮询被高优先级中断打断的问题每个输出事件都是寄存器输出时序干净多个按键可以各自例化一份FSM互不竞争。代价是代码量比C版本多一些而且改参数要重新综合不如软件改宏方便。所以如果系统里已经有MCU按键处理优先给MCU做只有MCU太忙、或者响应要求高到必须硬件实现的时候才值得在FPGA里专门划一块资源去做。我自己在实际项目里把上面的架构在三种平台都跑过一遍裸机STM32、带RTOS的ESP32、以及一片Artix-7上给面板按键做硬件消抖。每次移植需要改的只有底层读IO的函数和扫描时钟源状态机主体几乎原样保留。这也印证了核心思路的价值——把物理信号到用户语义的翻译逻辑独立出来无论跑在什么载体上本质都是一样的。如果你正准备把手里的按键代码重写我建议先照着这套状态机把状态图画出来再动手写代码思路顺了后面踩坑的机会会少很多。
返回列表