☰
FPGA按键消抖怎么做?状态机加计数器彻底解决机械抖动
2026/10/15 16:16:34 网站建设 项目流程

按键消抖,基本上是每个做过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_out)
S_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 = 2'b00; localparam S_PRESS_CONF = 2'b01; localparam S_PRESS_STABLE = 2'b10; localparam S_RELEASE_CONF = 2'b11; 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 <= 20'd0; 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 = 20'd0; end end S_PRESS_CONF: begin if (~key_in) begin if (cnt >= CNT_MAX - 1) begin next_state = S_PRESS_STABLE; cnt_next = 20'd0; end else begin cnt_next = cnt + 1'b1; end end else begin next_state = S_IDLE; cnt_next = 20'd0; end end S_PRESS_STABLE: begin if (key_in) begin next_state = S_RELEASE_CONF; cnt_next = 20'd0; end end S_RELEASE_CONF: begin if (key_in) begin if (cnt >= CNT_MAX - 1) begin next_state = S_IDLE; cnt_next = 20'd0; end else begin cnt_next = cnt + 1'b1; end end else begin next_state = S_PRESS_STABLE; cnt_next = 20'd0; end end default: begin next_state = S_IDLE; cnt_next = 20'd0; end endcase end // 输出逻辑:电平与脉冲 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin key_out <= 1'b1; key_pulse <= 1'b0; end else begin key_out <= (state == S_PRESS_STABLE) ? 1'b0 : 1'b1; key_pulse <= (next_state == S_PRESS_STABLE) ? 1'b1 : 1'b0; end end endmodule

3.2 代码逐段拆解

先看状态和计数器寄存这段。这是一个标准的两段式状态机,第一段时序逻辑负责在每个时钟上升沿把next_state和cnt_next锁存到寄存器里,同时处理异步复位。这里的复位我专门用了negedge rst_n,对应低电平复位的常用约定。复位时状态跳到S_IDLE,计数器清零,保证上电或者复位后模块处于一个确定的初始状态。

组合逻辑那段是整个模块的核心。我把next_state和cnt_next都先赋值成当前值,这叫“保持默认”,然后用case语句按状态分支处理。这样做的好处是每个状态只需要写出和自己相关的转移条件,不用每个分支都把无关的情况写全,代码看起来清爽很多。综合工具也能正确推断出每个寄存器的使能条件。

这里有个写了容易错的地方:在每个要跳转的状态里,cnt_next = 20'd0这一步必须显式写出来,不然计数器不会清零。比如在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 <= 1'b1; key_in_d2 <= 1'b1; 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毫秒不一定适合你的按键,多花五分钟实测,比调一晚上代码靠谱得多。

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

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

立即咨询