FPGA按键消抖怎么做?状态机加计数器彻底解决机械抖动

发布时间:2026/10/15 16:16:39
FPGA按键消抖怎么做?状态机加计数器彻底解决机械抖动 按键消抖基本上是每个做过FPGA或者嵌入式开发的人都会遇上的第一道坎。用示波器去抓一下机械按键的引脚波形你会发现按下和松开的瞬间电平并不是干净地从高跳到低、从低跳到高而是在目标电平附近来回弹跳几十次持续几毫秒到二十毫秒不等。这样原始的信号要是直接送给逻辑电路一次按键就可能被识别成三四次触发轻则逻辑错乱重则可能在电机控制这类场景里造成事故。项目标题里的“状态机实现”就是我这些年一直在用的消抖方案。它不是简单地delay一下再读电平而是把按键看作一个连续变化的输入事件用有限状态机去跟踪它的“稳定程度”配合一个计数器来确认电平是否真的稳定。这套做法的好处是不阻塞主逻辑、响应快、事件输出干净而且模块化之后可以直接在多个项目里复用换平台改改参数就能跑。这篇文章我就把完整的思路、代码、仿真和踩坑记录都摊开讲适合正在学FPGA的同学也适合想把按键处理做得更稳妥的嵌入式工程师参考。1. 先搞清楚机械按键的抖动到底是什么1.1 抖动产生的物理机制机械按键的内部结构说白了就是两片金属触点和一块弹片。按下去的时候弹片发生形变触点以一定的速度碰撞闭合松开的时候弹片回弹触点分离。问题出在“碰撞”和“回弹”这个过程并不是一次性的因为弹片有弹性、触点有硬度接触瞬间会像乒乓球落地一样连续弹跳好几次每次弹跳的间隔非常短通常只有几百微秒但整个弹跳过程加起来能持续5到20毫秒。我实测过几款常见的轻触开关和自锁开关抖动时长差异不小。便宜的轻触开关抖动反而短一些大概在3到8毫秒因为弹片行程短大尺寸的自锁开关或者微动开关行程长、力度大抖动能达到15毫秒甚至更夸张。更麻烦的是同一颗按键在按下和松开两个方向上的抖动时间也不一样松开时的回弹往往比按下时更剧烈因为释放瞬间弹片的形变恢复速度不恒定。从波形上看按键信号在抖动期间会在高低电平之间反复翻转。比如一个接上拉电阻的按键按下时电平应该是低电平但抖动期间它会变成“低-高-低-高-低”这种狗啃一样的形状直到彻底稳定在低电平。这个波形如果直接送给状态机或者计数器它看到的就是一串离散的边沿脉冲。1.2 常见消抖方案横向对比消抖这件事硬件和软件各有各的做法。硬件方案最经典的是RC低通滤波加施密特触发器电容把高频抖动平滑掉施密特触发器再整出一个干净的数字电平。这个方案实时性好硬件上做好了几乎不需要软件操心但代价也很实在每个按键都要占用两个器件PCB面积变大批量生产时电容电阻的误差会影响消抖时间的一致性一旦电路板做死了想调整消抖参数只能改板子。软件方案里最常见的是延时重读法检测到电平变化后先延时20到30毫秒再读一次电平如果和刚才的变化方向一致就认为按键有效。这个逻辑写起来很简单但有一个特别烦人的问题——它是阻塞的。单片机在执行延时的时候什么都干不了如果主循环里同时要处理显示刷新、通信协议或者其他按键延时期间的响应就会卡顿。更麻烦的是在RTOS环境下延时重读很容易和任务调度冲突。还有一种思路是轮询多次法每隔几毫秒读一次按键连续读到N次相同电平才判定状态稳定。这种方法本质上就是我要讲的软件化状态机但它如果写成一串 if-else 嵌套代码维护起来非常痛苦而且对“连续”的定义不够严格经常出现前面读了两次低电平、后面读到两次高电平最终误判的情况。1.3 状态机方案好在哪状态机方案真正解决了上面几个痛点。它的核心思想是我不去猜这个电平”最终“会变成什么我只观察”当前状态“和”持续了多少个周期“只有同一电平连续稳定N个周期我才承认状态切换成功。中间只要出现一次反方向的跳变计数器立刻清零重来。这样做的好处第一个是非阻塞。状态机每个时钟周期都只是判断当前状态和计数条件不会停在那等时间主逻辑可以照常运行。第二个好处是事件输出干净。配合边沿脉冲生成逻辑每次有效的按键按下只会产生一个单周期脉冲不会出现一次按键连续触发多次的情况。第三个好处是参数化。消抖时间做成参数换按键、换时钟频率改一个数字就行不用动逻辑结构。在FPGA里状态机加计数器本来就是最标准的时序逻辑写法综合后资源占用极小几十个LUT就能搞定而且时序约束容易满足。在MCU里这套状态机思路同样适用只是把时钟周期换成定时器中断周期而已。所以不管你在哪个平台状态机消抖都是值得掌握的基础套路。2. 状态机模型的设计思路与参数计算2.1 四态模型与状态转移我用的这套状态机一共四个状态名字很直白状态含义输出电平key_outS_IDLE空闲按键未按下高S_PRESS_CONF检测到可能按下正在确认高S_PRESS_STABLE已确认按下按键处于稳定按下状态低S_RELEASE_CONF检测到可能释放正在确认低从S_IDLE开始如果key_in变成低电平说明用户可能按下了按键但还不能确认所以跳到S_PRESS_CONF开始计数。在S_PRESS_CONF里计数器不断累加如果低电平连续保持满CNT_MAX个时钟周期就跳到S_PRESS_STABLE此时才真正认定“按下有效”如果中间任何一次key_in变回高电平说明那只是抖动立刻回到S_IDLE并把计数器清零。进入S_PRESS_STABLE之后按键已经被稳定识别为按下状态。如果key_in一直是低就原地待着如果检测到高电平说明用户可能松手了跳进S_RELEASE_CONF进入释放确认流程。在S_RELEASE_CONF里做对称的计数高电平连续保持CNT_MAX个周期就回到S_IDLE结束整个流程如果期间又变回低电平说明只是释放过程中的抖动回到S_PRESS_STABLE继续按下的稳定状态。这个四态模型的对称性非常好按下和释放的路径完全镜像。实际测试下来4状态比2状态好在逻辑上更清晰也更容易往后扩展功能比如在S_PRESS_STABLE里加长按检测或者在S_RELEASE_CONF里加双击判断都是在稳定状态上做文章结构不会被破坏。2.2 计数器的工作逻辑计数器是这个状态机里真正干“消抖”这个活的核心部件它的工作逻辑就一条原则只认连续。我举个例子你就明白了。假设CNT_MAX设成20用户按下按键后信号在抖动期间出现了“低-高-低-高-低”的序列计数器在第一次检测到低电平时从0开始加但还没来得及加到20信号就跳回高电平了此时计数器立刻清零。等到信号再次变低计数器又从0开始重新计。只有低电平持续稳定计数器才能一路加到20触发跳转。这段逻辑用组合逻辑配合时序寄存器实现写法上有讲究。计数器在每个时钟沿判断一次如果当前状态需要继续计数且电平保持正确方向就加1只要电平翻转无条件清零。我强烈建议把“清零”合并到状态转移逻辑里而不是单独写一个always块去清零否则容易出现多驱动或者漏清的情况。有一个容易被忽略的细节计数器到达CNT_MAX-1和到达CNT_MAX的区别。代码里判断的是“cnt CNT_MAX - 1”然后跳转并清零这样计数器最大值正好是CNT_MAX-1避免计数器溢出到上限再清零的边界问题。如果判断条件写反了或者漏了减1消抖时间就会差一个时钟周期虽然看起来不多但在追求精确的场合会很别扭。2.3 消抖阈值与计数器位宽计算确定CNT_MAX的数值是消抖设计里最需要拍脑袋但实际上有据可循的一件事。机械按键的抖动时间通常在5到20毫秒保险起见按最恶劣的20毫秒来设计。然后看你的系统时钟频率如果FPGA跑50MHz也就是20纳秒一个周期那么20毫秒就是50,000,000乘以0.02等于1,000,000个时钟周期。计数器位宽怎么定算出需要的最大计数值后向上取2的幂。1,000,000小于2的20次方1,048,576所以计数器至少要20位。我在代码里直接用reg [19:0] cnt正好能覆盖到1,048,575满足需求。如果你用10MHz的时钟20毫秒只需要200,000个周期18位计数器就够如果改成1kHz的采样使能时钟那么20毫秒只需要20个采样周期5位计数器就行。这里我想多说一句不要盲目把CNT_MAX设得特别大。虽然消抖更稳但按键的响应延迟也会变大。按下后要等CNT_MAX个周期才能识别如果你的消抖时间是50毫秒用户会觉得按键有“黏连感”按下去半天没反应。我自己的经验是普通轻触开关20毫秒左右的手感最合适大行程按键可以放宽到30毫秒但再往上就不推荐了。3. RTL代码实现与仿真验证3.1 完整Verilog模块代码下面这段代码是我在实际项目里反复用过的按键消抖模块去掉了和具体业务相关的部分只保留核心的状态机逻辑。代码风格偏向RTL设计常用的两段式一段时序逻辑寄存状态和计数器一段组合逻辑计算次态和次态计数器。module key_debounce_fsm #( parameter CNT_MAX 1_000_000 // 20ms 50MHz )( input wire clk, // 系统时钟 input wire rst_n, // 异步复位低有效 input wire key_in, // 原始按键输入 output reg key_out, // 消抖后的电平输出 output reg key_pulse // 按键有效按下脉冲单时钟周期 ); localparam S_IDLE 2b00; localparam S_PRESS_CONF 2b01; localparam S_PRESS_STABLE 2b10; localparam S_RELEASE_CONF 2b11; reg [1:0] state, next_state; reg [19:0] cnt, cnt_next; // 状态与计数器寄存 always (posedge clk or negedge rst_n) begin if (!rst_n) begin state S_IDLE; cnt 20d0; end else begin state next_state; cnt cnt_next; end end // 次态逻辑与计数器更新 always (*) begin next_state state; cnt_next cnt; case (state) S_IDLE: begin if (~key_in) begin next_state S_PRESS_CONF; cnt_next 20d0; end end S_PRESS_CONF: begin if (~key_in) begin if (cnt CNT_MAX - 1) begin next_state S_PRESS_STABLE; cnt_next 20d0; end else begin cnt_next cnt 1b1; end end else begin next_state S_IDLE; cnt_next 20d0; end end S_PRESS_STABLE: begin if (key_in) begin next_state S_RELEASE_CONF; cnt_next 20d0; end end S_RELEASE_CONF: begin if (key_in) begin if (cnt CNT_MAX - 1) begin next_state S_IDLE; cnt_next 20d0; end else begin cnt_next cnt 1b1; end end else begin next_state S_PRESS_STABLE; cnt_next 20d0; end end default: begin next_state S_IDLE; cnt_next 20d0; end endcase end // 输出逻辑电平与脉冲 always (posedge clk or negedge rst_n) begin if (!rst_n) begin key_out 1b1; key_pulse 1b0; end else begin key_out (state S_PRESS_STABLE) ? 1b0 : 1b1; key_pulse (next_state S_PRESS_STABLE) ? 1b1 : 1b0; end end endmodule3.2 代码逐段拆解先看状态和计数器寄存这段。这是一个标准的两段式状态机第一段时序逻辑负责在每个时钟上升沿把next_state和cnt_next锁存到寄存器里同时处理异步复位。这里的复位我专门用了negedge rst_n对应低电平复位的常用约定。复位时状态跳到S_IDLE计数器清零保证上电或者复位后模块处于一个确定的初始状态。组合逻辑那段是整个模块的核心。我把next_state和cnt_next都先赋值成当前值这叫“保持默认”然后用case语句按状态分支处理。这样做的好处是每个状态只需要写出和自己相关的转移条件不用每个分支都把无关的情况写全代码看起来清爽很多。综合工具也能正确推断出每个寄存器的使能条件。这里有个写了容易错的地方在每个要跳转的状态里cnt_next 20d0这一步必须显式写出来不然计数器不会清零。比如在S_PRESS_CONF里计满了要跳S_PRESS_STABLE如果忘了清零那S_PRESS_STABLE一进来计数器就是CNT_MAX-1释放确认的计数起点就错了。写的时候多看几眼清零路径有没有覆盖到所有跳转。输出逻辑我用了一个小技巧key_out表示消抖后的电平用当前的稳定状态直接判断在S_PRESS_STABLE时输出低电平其余是高电平。key_pulse则是用next_state来判断只要下一个状态即将进入S_PRESS_STABLE就输出一个高脉冲。因为这个条件只会在从S_PRESS_CONF跳转到S_PRESS_STABLE的那个时钟周期成立所以得到的脉冲宽度正好是一个时钟周期。3.3 Testbench怎么写才有效仿真验证按键消抖模块核心是构造足够“恶心”的输入信号去检验状态机能不能扛住抖动。我写Testbench时会安排四类场景。第一类是正常按键也就是没有抖动的理想波形按下20ms再释放20ms预期结果是key_pulse只在按下完成后出现一个脉冲key_out在按下期间保持低电平。第二类是带抖动的按键。在按下瞬间让key_in在低电平附近来回跳变十几次每次持续时间在0.1ms到3ms之间然后稳定为低电平。预期结果是状态机最终仍然进入S_PRESS_STABLE而且key_pulse只在抖动彻底结束后出现一次中间不会因为某一次跳变而误触发。这个场景是仿真的重头戏可以用repeat循环快速生成随机抖动序列。第三类是快速连按。模拟用户快速按了两下按键每次按下15ms、松开15ms预期结果是产生两个独立的key_pulse脉冲间隔清晰不会漏掉第二次按下。这个场景能检验模块在释放确认还没完成时遇到再次按下的处理能力。第四类是干扰毛刺。在S_IDLE状态下给一个只有2ms的低电平毛刺持续时间远小于CNT_MAX预期结果是不产生任何按下脉冲key_out保持高电平。这个场景专门验证消抖的“抗误触发”能力。仿真时还有一个细节把CNT_MAX参数在仿真时改小。如果按20毫秒的真实值来仿真一个场景就要仿真20毫秒的模拟时间在50MHz时钟下是1,000,000个周期虽然也能跑但来回调波形和看波形都费劲。我在Testbench里通常把CNT_MAX设成20或者50先验证逻辑正确再用真实参数做一次全量回归这样效率高又不影响可靠性。3.4 按键输出脉冲的设计细节很多人一开始会纠结到底应该给下游一个电平信号还是一个脉冲信号我的答案是两个都给因为不同场景需求不一样。比如做键盘扫描矩阵需要消抖后的电平状态来判断哪一行哪一列被按下做单次触发功能比如“按一下切换开关状态”则需要一个干净的单脉冲信号来翻转状态。key_pulse的生成逻辑有个关键点判断的是next_state而不是state。为什么因为state是当前已经寄存的状态它在跳转到S_PRESS_STABLE的时钟沿之后才变成S_PRESS_STABLE而且会一直维持这个状态不变。如果拿state去判断输出就只是一个电平会一直保持为高直到状态机离开S_PRESS_STABLE这显然不是脉冲。而next_state是组合逻辑直接输出的目标状态只有在从S_PRESS_CONF跳转到S_PRESS_STABLE的那个时钟周期里它才等于S_PRESS_STABLE下一个周期状态寄存后next_state又变成了别的值自然形成一个单周期脉冲。如果只想要一个按下释放都关心的边沿脉冲比如“按下和松开都触发一次”可以在key_out上做边沿检测用一级寄存器把key_out打一拍然后做异或。这个扩展也很常用在菜单系统里经常要用到。4. 工程落地的坑与经验4.1 快速连按和长按场景怎么处理状态机天然支持长按保持因为进入S_PRESS_STABLE后只要key_in一直是低电平状态就会一直待在那里key_out持续输出低。这个特性在做长按控制时非常有用不需要额外逻辑。我做过一个需要“按住持续加速”的项目就是直接从key_pulse扩展出来的长按事件在S_PRESS_STABLE里再放一个计数器每满N毫秒产出一个连发脉冲松开即停止。快速连按的坑在于释放确认时间。用户快速按第二下的时候如果第一次的释放确认还没走完状态机可能还在S_RELEASE_CONF里此时第二次按下的低电平会让它跳回S_PRESS_STABLE但这时候不会再产生key_pulse第二次按下就被吞掉了。解决思路有两种一种是把释放确认的阈值设得比按下阈值短一些比如释放只要5ms稳定就回S_IDLE按下需要20ms稳定这样能提高连按识别率另一种是在业务层做处理如果两次按下间隔极短本身就应该被识别为“连打”而不是两次独立按键。从手感角度说消抖时间不宜超过30ms否则快速连按一定会丢事件。我在实际项目里测试过20ms按下阈值加10ms释放阈值能稳定支持每秒8次以上的连点对绝大多数人机交互来说足够了。4.2 异步输入必须先同步按键输入相对于系统时钟是异步信号这句话的意思是这个信号的变化时刻和时钟沿没有任何固定关系。直接把这路信号送进状态机在最坏情况下会触发寄存器亚稳态——寄存器的输出在时钟沿附近采到了一个不确定的电平可能会在高低之间徘徊不定导致状态机在几个周期里看到诡异的状态跳变。解决办法是两级同步俗称打两拍。第一级寄存器把异步信号同步到本地时钟域第二级寄存器进一步降低亚稳态传播的概率。两级同步后的信号才允许送给状态机和计数器。这段逻辑要放在状态机模块外面或者作为模块内部的输入预处理千万不要省。reg key_in_d1; reg key_in_d2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin key_in_d1 1b1; key_in_d2 1b1; end else begin key_in_d1 key_in; key_in_d2 key_in_d1; end end wire key_in_sync key_in_d2;这里还有个细节上拉电阻的处理。板级如果按键没接上拉悬空输入在未按下时电平是不确定的同步器采到什么就是什么状态机可能随机进入按下状态。所以我一般要求硬件设计必须给每个按键配置上拉电阻软件侧则把同步器的复位值设成高电平保证上电阶段默认未按下。4.3 复位与初态问题状态机的初态必须是S_IDLE这一点没有任何商量余地。如果复位后状态随机落在某个中间态计数器里面的值也是不确定的模块第一次工作就可能出现误触发。异步复位低电平有效在FPGA里非常标准关键是复位释放的时刻要避开时钟上升沿否则可能出现复位释放后第一个时钟沿采不到稳定值的情况。我在项目里处理复位释放时序的做法是用锁相环或者专门的复位管理模块产生一个持续若干周期的同步复位信号而不是直接拿外部按键或者上电RC复位电路的原始信号去复位状态机。同步复位的好处是整个设计里的所有寄存器在同一个时钟沿释放复位状态一致性有保障。还有一个容易踩的坑计数器在复位时清零了但如果复位信号释放后正好遇上key_in为低电平状态机就会立刻进入S_PRESS_CONF开始计数。这本身不是错但如果你在下电瞬间检测到了这个流程可能产生一个无效的按键事件。解决方法是上位机或主控在系统启动完成前屏蔽按键事件这个和状态机本身无关但工程上一定要想到。4.4 低功耗下的消抖策略在电池供电的设备里高频计数消抖的功耗账要仔细算。一个50MHz的FPGA如果一直跑着20位计数器做按键检测动态功耗虽然不大但相比深度睡眠状态下的微安级电流来说是成百上千倍的差距。所以低功耗场景下不能简单照搬这套高频状态机。我常用的做法是两级消抖睡眠时按键引脚接到中断唤醒源上用户按下按键触发中断把设备唤醒唤醒后进入正常工作模式再跑完整的状态机消抖流程。相当于把“检测有没有人按键”和“确认这个按键是否有效”分成两个阶段低频待机时用最简单的硬件中断判断高频工作时用精确的状态机确认。这个思路在MCU和FPGA混合系统里都验证过待机电流能控制到很低。如果必须要让状态机本身低功耗运行就把时钟频率降下来用1kHz的采样使能替代50MHz的连续计数。这时CNT_MAX只要设成20计数器只要5位每个采样周期才翻一次状态翻转率大幅降低功耗自然下来。代价是系统的按键响应延迟会变成最大2ms完全可接受。5. 收尾一个复用多年的小模块这个按键消抖模块我在FPGA键盘扫描、工业控制面板、手持设备固件好几个项目里都复用过。每次移植到新平台真正要改的只有两处一是CNT_MAX参数按系统时钟和期望的消抖时间算一遍二是输入同步器的时钟频率确认打两拍的延迟是否合理。其余的逻辑结构基本没动过。印象最深的一次是在现场排查一台设备的按键频繁误触发问题。当时设备安装在振动很强的环境里机械结构本身的震动传导到按键上导致按键触点在小幅抖动原本的消抖阈值只有2毫秒根本扛不住。我直接把CNT_MAX从2毫秒改到25毫秒重新综合下载后问题立刻消失。那次踩坑之后我就养成了一个习惯把消抖阈值做成配置参数仿真阶段用小值快速验证逻辑现场阶段再按实际环境调到合适的值。最后再分享一个小技巧。如果你不确定某个按键的抖动到底有多长用一个逻辑分析仪或者示波器在按键引脚上连续抓几十次按下和释放的波形把抖动时间最长的那一次记下来乘以1.5到2倍作为消抖阈值手感通常都不会差。盲目照搬网上例程里的20毫秒不一定适合你的按键多花五分钟实测比调一晚上代码靠谱得多。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询