首页 / 资讯中心 / 文章详情

FPGA按键消抖模块状态机实现:从抖动原理到Verilog代码

FPGA按键消抖模块状态机实现:从抖动原理到Verilog代码 ★ FEATURED ARTICLE
做FPGA和嵌入式开发按键几乎是绕不开的输入方式。它表面上只需要一根IO口读电平但恰恰是这份简单让不少人忽略了背后的时序问题。我见过不止一个新人写的按键模块在低速测试时一切正常一挂到系统里就开始随机误触发最后排查半天问题就出在消抖逻辑上。按键消抖模块的状态机实现就是把这个问题从“靠运气”变成“靠逻辑”的关键。这篇文章不打算只丢一段代码而是把抖动原理、状态机设计思路、Verilog实现、仿真验证整个链路完整过一遍讲清楚每个参数怎么来的、每个状态为什么这么跳、出问题时怎么排查。1. 按键消抖的本质抖动到底是怎么回事1.1 机械按键抖动产生的物理机制机械按键内部是一对金属弹片按下时弹片受力闭合松开时弹片靠弹性恢复。问题就出在这个闭合和恢复的过程上。弹片不是一接触就稳稳贴住而是会来回弹跳几次才稳定下来。用示波器抓按键引脚的电平波形按下的瞬间高电平不是干净利落地跳到底而是在高电平和低电平之间来回跳变持续几毫秒甚至十几毫秒然后才稳定。我拿不同价位的按键实测过几毛钱的轻触开关抖动时间普遍在5ms到15ms之间某些工业级的按键会好一些但也不能完全避免。这还只是常温下的表现温度变化、弹片老化、按压力度不均匀都会让抖动时间变长。如果电路上再叠加一点容性负载波形边缘变缓判断的边界就更模糊了。所以消抖的本质就是把这段“非稳定期”过滤掉只认稳定之后的电平状态。如果直接用原始信号做边沿检测或者计数一次按键就可能被识别成好几次这在计数、翻页、菜单切换这类场景里会直接表现为“按一下跳两下”非常恼人。1.2 三种消抖方案的横向对比处理抖动业内常见的做法有三类硬件消抖、软件延时消抖、状态机消抖。硬件消抖最常见的方案是RC低通滤波加施密特触发器或者用RS触发器搭一个锁存结构。RC滤波利用电容的充放电把毛刺平滑掉施密特触发器提供滞回比较防止输出在阈值附近来回翻转。这个方案的好处是响应快不占任何CPU或逻辑资源。代价是增加电阻电容元件PCB面积和BOM成本都上去了而且消抖时间在硬件定型后基本就锁死了想调整只能改板子。软件延时消抖的思路更直接检测到按键电平变化后延时20ms再读一次如果状态没变就认为有效。这个方案胜在简单很多单片机的入门教程就是这么教的。但它有个很明显的问题——延时期间CPU被占着做不了其他事情。放在一个实时性要求高的系统里这种盲等非常浪费。后来有人改成定时器轮询每1ms扫一次按键连续20次状态一致才确认这已经有点状态机的意思了。状态机消抖是FPGA上最主流、也最适合硬件逻辑实现的方案。它的核心是用有限状态机记住“当前处于按下确认阶段还是释放确认阶段”再配合一个计数器判断“电平是否稳定了足够时间”。整个过程完全由硬件逻辑并行运行不占用CPU资源消耗也就几十个触发器加一个计数器而且消抖时间可以通过参数随时调整不用动电路。1.3 为什么FPGA场景下状态机方案是首选FPGA是并行执行的按键消抖模块作为一个独立逻辑块可以跟其他业务逻辑同时跑不需要像MCU那样轮询。而且FPGA内部时钟稳定计数器精度非常高能让每次消抖判断的时间一致性做得很好。相比CPU上的软件消抖可能被中断、调度的时序抖动影响FPGA里的状态机消抖只要输入同步做好行为就是确定性的。另外还有一个容易被忽略的点状态机消抖天然适合处理长按、连击、释放这些不同阶段。如果只用单纯计数器加边沿检测能检测到一次下降沿但很难区分“长按未松手”和“重复点击”。状态机通过状态转移把这些输入历史明确记录下来后续想扩展双击、长按功能也只需要在现有状态上增加分支。2. 状态机消抖的核心设计思路2.1 状态机与计数器的配合逻辑状态机消抖的经典结构是三个状态IDLE空闲态、PRESS_CNT按压确认态、RELEASE_CNT释放确认态。它们的关系是这样系统复位后停在IDLE按键没有按下时一直在这里等待。当检测到key_in变低说明有人按下按键但此时还不能确定是真按还是抖动于是进入PRESS_CNT。在PRESS_CNT里计数器开始计时。这里有一个关键设计如果低电平能持续稳定超过消抖时间计数器先计满此时进入RELEASE_CNT确认这次按下有效。如果计时过程中key_in突然变高说明刚才那一下是弹片抖动或者误触状态机立刻跳回IDLE计数器清零不做任何输出。同理在RELEASE_CNT里key_in已经恢复为高电平计数器重新计时。高电平持续稳定超过消抖时间后确认释放完成回到IDLE。如果释放过程中key_in又变低也就是用户没松手或者弹片又弹回去了状态机回到PRESS_CNT重新确认按压。这个逻辑比简单延时方案严谨的地方在于它持续监控电平状态任何一次超出预期的跳变都会让状态机回到正确的位置而不是傻等20ms之后才去检查。你再想想如果用户按下后马上松手又快速按下传统延时方案很容易漏掉第二次按下但状态机方案在释放确认阶段发现电平变低后马上回到PRESS_CNT重新计时新一次按键从一开始就被纳入了判断流程。2.2 消抖时长怎么定计数器位宽怎么算消抖时间的选择本质是在“过滤真实抖动”和“保留快速操作”之间取平衡。根据机械按键的物理特性20ms是一个很稳妥的默认值。它能覆盖绝大多数开关的抖动窗口同时对一般人的手速来说20ms远小于一次快速点击的时间不会误吞有效操作。接下来是计数器位宽的计算。以最常见的50MHz系统时钟为例时钟周期T等于1除以50MHz也就是20ns。要让计数器在20ms后达到满值需要的周期数是20ms除以20ns等于1,000,000。这个数用二进制表示需要多少位2的19次方是524,288不够2的20次方是1,048,576刚好够。所以计数器位宽取20位计数最大值设为1,000,000。如果是其他时钟频率计算方式一模一样。我整理了一张常见频率的对照表方便直接查系统时钟时钟周期20ms对应计数值计数器位宽100MHz10ns2,000,00021位50MHz20ns1,000,00020位25MHz40ns500,00019位12MHz约83.3ns约240,00018位实际写代码时我习惯用一个宏定义把计数上限放出来后面想调整消抖时间只改这一个参数就够了。源码里的注释一定写上“在XX MHz时钟下对应XX ms”不然过几个月自己回来看真的想不起来这个数字怎么来的。2.3 三段式状态机的框架为什么是主流状态机在FPGA里一般有三种写法一段式、两段式、三段式。一段式把所有逻辑揉在一个always块里状态寄存、状态跳转、输出全放一起简单但输出容易出毛刺而且代码可读性差。两段式把状态寄存和状态跳转分开输出还是用组合逻辑做。三段式则把状态寄存、次态跳转、输出逻辑分成三段输出用时序逻辑可以天然消除组合逻辑的毛刺。对按键消抖这种模块来说输出信号是要交给其他业务逻辑用的如果有毛刺后面模块很容易误触发。所以我建议直接用三段式代码结构清晰输出干净而且跟主流风格一致别人接手也好读。三段式的结构是第一段时序逻辑每个时钟沿把next_state锁存到state第二段组合逻辑根据当前state和输入信号算出next_state第三段时序逻辑根据当前state和输入信号产生输出寄存器打拍3. 完整Verilog实现与关键细节3.1 一个可以直接上手的三段式FSM代码下面这份代码我写成教学级风格每个段落都加注释方便直接抄作业。module key_debounce_fsm ( input wire clk, // 系统时钟50MHz input wire rst_n, // 异步复位低有效 input wire key_in, // 按键原始输入按下为低 output reg key_press, // 有效按下脉冲单周期高 output reg key_release // 有效释放脉冲单周期高 ); // 状态定义 localparam IDLE 2b00; localparam PRESS_CNT 2b01; localparam RELEASE_CNT 2b10; // 消抖时间20ms 50MHz 1,000,000 个时钟周期 localparam CNT_MAX 20d1_000_000; reg [1:0] state; reg [1:0] next_state; reg [19:0] cnt; // 计数完成信号等于CNT_MAX - 1表示倒数最后一拍 wire cnt_done (cnt CNT_MAX - 1b1); // —— 第一段状态寄存器 —— always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end // —— 第二段次态跳转逻辑 —— always (*) begin next_state state; case (state) IDLE: begin if (!key_in) // 检测到按下请求 next_state PRESS_CNT; end PRESS_CNT: begin if (!key_in cnt_done) // 低电平稳定20ms确认按下 next_state RELEASE_CNT; else if (!key_in) // 低电平但还没计时完 next_state PRESS_CNT; else // 中途变高判定为抖动 next_state IDLE; end RELEASE_CNT: begin if (key_in cnt_done) // 高电平稳定20ms确认释放 next_state IDLE; else if (key_in) // 高电平但还没计时完 next_state RELEASE_CNT; else // 又变低回到按压确认 next_state PRESS_CNT; end default: next_state IDLE; endcase end // —— 计数器控制 —— always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 20d0; else begin case (state) IDLE: begin cnt 20d0; end PRESS_CNT, RELEASE_CNT: begin if (cnt_done) cnt 20d0; else cnt cnt 1b1; end default: cnt 20d0; endcase end end // —— 第三段输出逻辑时序逻辑输出无毛刺 —— always (posedge clk or negedge rst_n) begin if (!rst_n) begin key_press 1b0; key_release 1b0; end else begin key_press (state PRESS_CNT) cnt_done !key_in; key_release (state RELEASE_CNT) cnt_done key_in; end end endmodule代码里有几个细节值得多说两句。第一cnt_done的判断时机。cnt值是寄存器输出cnt_done用组合逻辑比较产生当cnt走到999,999时cnt_done为高。状态机在这个节拍根据key_in的状态做跳转输出逻辑也在这一拍产生脉冲。下一拍计数器因为cnt_done拉高被清零状态也完成跳转一切衔接得很自然。第二第二段里我习惯先写next_state state;再做case分支。这样即使某些分支条件没写全next_state也保持原状态不会意外产生锁存器。这是综合优化里很重要的小习惯。第三输出用了时序逻辑。如果用assign直接拉组合逻辑cnt_done几个信号经过组合链路产生输出的瞬间任何竞争都可能变成毛刺。寄存器打一拍之后输出全部对时钟沿对齐干净且稳定。3.2 输入同步与外设复用的关键细节按键在真实电路里是异步信号直接进FPGA的时钟域会有亚稳态风险。稳妥的做法是先在模块入口加两级同步触发器把key_in打两拍之后再把同步后的信号送给状态机。同步逻辑可以写在当前模块内部也可以单独拆成一个通用模块。我之前习惯写在模块内部省事reg key_in_d1, 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然后把所有状态机判断里的key_in替换成key_in_d2。同步器加两级就够了更多级别会额外引入延迟反而影响消抖时间的一致性。key_press和key_release这两个输出都是单周期脉冲。为什么强调这一点因为后续业务模块如果拿key_press去驱动计数器脉冲宽度不一致会导致计数不准。按下有效时key_press只拉高一个时钟周期后续模块直接用使能信号来锁存逻辑清晰不会重复触发。3.3 多按键场景下的资源与编码优化如果系统里只有一两个按键每个按键独立实例化一个消抖模块完全没问题。但按键一多比如矩阵键盘有16个甚至更多每个按键都放一个20位计数器逻辑资源就比较可观了。常用的优化方案是计数器分时复用。比如把所有按键的当前电平做成一个位向量系统每1ms用同一个计数器产生一次采样脉冲对所有按键同时采样每个按键再配一个小的“稳定周期计数器”。因为1ms采样一次连续20次采样结果一致等价于20ms消抖。这样真正的大计数器只有一个每个按键只需要一个5位的稳定计数器和状态寄存器资源开销大幅下降。代价是代码复杂度上去了调度逻辑写起来没有独立状态机那么直观。所以我的建议是按键少于8个直接实例化独立模块按键很多再考虑分时复用方案。状态编码上3个状态的小状态机用二进制编码就行资源用量差别不大。如果状态数多于4个在FPGA里One-hot编码更利于时序收敛因为每个状态只翻转一个触发器组合逻辑更简单。代码的可读性也要考虑我见过有人把状态定义写成localparam IDLE 3b001; PRESS_CNT 3b010; RELEASE_CNT 3b100;后续查找状态跳转关系时会直观很多。4. 仿真验证与实测排坑4.1 仿真激励怎么构造才能测出问题很多人写testbench只测一个场景按下等20ms松开等20ms看有没有输出。这种测法等于只验证了“理想情况”。真实按键的抖动是随机的仿真必须覆盖抖动、快速连按、半按等场景才有意义。我的做法是在testbench里用task封装抖动序列让按下和释放都带上可控的抖动然后观察输出是否稳定。一个稍微完整一点的testbench结构是这样timescale 1ns / 1ps module tb_key_debounce_fsm; reg clk; reg rst_n; reg key_in; wire key_press; wire key_release; key_debounce_fsm uut ( .clk(clk), .rst_n(rst_n), .key_in(key_in), .key_press(key_press), .key_release(key_release) ); initial clk 0; always #10 clk ~clk; // 50MHz时钟 initial begin rst_n 0; key_in 1b1; #100; rst_n 1; #100; // 场景1正常按下无抖动长按后释放 key_in 1b0; #30_000_000; key_in 1b1; #30_000_000; // 场景2按下时带抖动释放时带抖动 press_with_jitter; #30_000_000; release_with_jitter; #30_000_000; // 场景3快速点按按下时长小于消抖时间应该被过滤 key_in 1b0; #5_000_000; key_in 1b1; #30_000_000; $finish; end task press_with_jitter; begin // 模拟按下抖动先来回翻转几次然后稳定拉低 repeat (6) begin #2 key_in ~key_in; end key_in 1b0; end endtask task release_with_jitter; begin repeat (6) begin #2 key_in ~key_in; end key_in 1b1; end endtask endmodule这个testbench里的抖动每次翻转只持续2ns跟真实场景差距很大。真实抖动是毫秒级别的但仿真时间宝贵直接把消抖时间改成2ms或者更小测的是逻辑正确性不是真实时序。我通常在模块里把计数上限定义成parameter仿真时传一个小值进去比如#(.CNT_MAX(20d2_000))这样一小段仿真就能跑完多种场景功能验证完再把参数改回20ms。波形出来之后主要检查三点。第一key_press和key_release是不是单周期脉冲有没有多余毛刺。第二快速点按场景下输出应该没有脉冲产生。第三如果抖动发生在计数接近完成时比如已经计数到999,998个周期这时候key_in突然跳变一下状态机必须立刻回到IDLE不能在后一拍误判成有效按键。4.2 我踩过的几个坑与解决办法坑一计数满之后没有及时清零。现象是连续按键时第二次按键的确认时间明显变短。原因是计数器在PRESS_CNT计数满后没有通过cnt_done信号立刻复位等状态切到RELEASE_CNT时计数器带着非零值继续跑。解决办法就是代码里那段if (cnt_done) cnt 20d0;让计数满的那一刻同时清零跟状态跳转对齐。坑二次态逻辑缺少default分支。综合工具会报latch warning甚至推断出意想不到的锁存器。这种现象大多出现在后来改动代码觉得某个分支不会走到就没写。硬件代码最忌讳“我觉得不会走到”不管什么情况default分支必须写上并且指向安全状态IDLE。坑三key_in没有做同步直接给状态机用。现象是系统工作一段时间后偶发误触发仿真却什么都复现不出来。外部按键信号必须过两级同步触发器否则进入时钟域时处于亚稳态后续所有状态判断都可能出错。这个坑非常隐蔽因为大多数时候同步器能正常工作只有温度、电压波动的极端情况才暴露。坑四计数边界的比较出错。有人会把if (cnt CNT_MAX - 1b1)写成if (cnt CNT_MAX)两个写法差了一个周期。用计数上限减1作为完成标志会让计数器在第1,000,000个周期就清零而用等于上限计数器会多走一个周期消抖时间变成20.02ms。这个差异看着不大但会破坏后续模块对接的时序。坑五刚开始写的时候我一度把key_press做成组合逻辑输出结果仿真波形里出现亚微秒级别的毛刺。后来改成第三段时序逻辑输出脉冲稳定地对齐时钟沿后面模块再也不误动作了。4.3 常见问题速查表症状可能原因解决办法按一下输出多个脉冲输出组合逻辑毛刺第三段改用时序逻辑输出按键按下后没有响应消抖参数太大或计数器未复位检查CNT_MAX和计数器清零逻辑长按久了松开release不输出状态机卡在PRESS_CNT检查key_in同步信号是否接对快速连续点击丢按键释放确认耗时过长状态机加回退分支或做连击检测仿真波形里没有输出仿真时间太短CNT_MAX过大仿真时通过parameter传入小计数值上电后按键状态异常复位释放时key_in悬空变低按键输入加外部上拉电阻5. 状态机消抖的经验与扩展建议按键消抖模块用状态机的思路我在好几个项目里验证过从最简单的单按键到矩阵键盘扫描这套逻辑都站得住脚。实际体验下来最大的感受是消抖不是一个独立的功能点而是一个时序边界问题。状态跳转、计数器边界、输出打拍任何一环松懈都会在系统联调时找上门来。所以经验上我会建议先花时间把状态转移图画清楚再动手写代码大部分问题在动手之前就能解决。如果后续需要在快速双击和长按场景里做功能扩展这套状态的扩展性也很顺。比如在RELEASE_CNT里多加一个计时分支用于区分“短按释放”和“长按释放”对应不同脉冲输出再比如增加一个DOUBLE_WAIT状态用来覆盖双击检测的窗口。状态机的好处就在这儿添加新状态和转移分支只是在现有框架上做增量修改核心逻辑不会推倒重来。还有一个实测心得分享给做FPGA开发的朋友按键消抖模块虽然小但它是典型的“看着简单、做扎实要下功夫”的模块。写完之后不要急着接到业务模块里先单独建testbench把抖动、快速点按、半按这些边界场景都过一遍。等仿真波形完全符合预期了再往顶层挂。这一步能省掉后面大量联调的时间。
阅读完成 · 觉得有帮助?
咨询建站