ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

FPGA按键消抖完全指南:从计数器原理到Verilog仿真与上板避坑

FPGA按键消抖完全指南:从计数器原理到Verilog仿真与上板避坑 很多初学者写的第一段 verilog 代码是流水灯第二段是读按键然后卡死在按键消抖上。你按下一次按键LED 却像触电一样乱闪或者明明按了一次逻辑里却检测到了十几次跳变。这不是你代码能力的问题而是所有机械按键的物理宿命按下和释放的瞬间金属触点会来回弹跳很多下。FPGA 的时钟又跑得飞快50MHz、100MHz 的采样把这些弹跳全部如实记录了下来。这篇文章是写给刚开始学 verilog、手里只有一块开发板甚至只有电脑的朋友把按键消抖从原理到代码再到仿真完整捋一遍顺便把我自己踩过的坑都摆出来。1. 抖动到底是怎么产生的先搞懂按键的“物理脾气”1.1 按键内部的小机关按键本质上是一根金属簧片靠弹力让两个触点接触或者分离。你按下的时候簧片不是干脆利落地“啪”一下贴上去而是在触点上弹来弹去好几次才稳定下来。这个弹跳过程就是抖动英文叫 bounce。你可以把它想象成你用手指弹一下桌上的圆珠笔圆珠笔不是稳稳停住而是先蹦几下。按键里的簧片也一样只不过弹跳时间很短肉眼根本看不见。不要小看这几下弹跳。在电气上一次按键按下产生的不是一个干净的高到低跳变而是一连串的高低电频翻转。这个翻转过程通常持续 5 到 20 毫秒具体时长取决于按键的机械结构和质量。普通 6x6 轻触开关的实测抖动一般在 5ms 到 20ms 之间质量差一点的甚至能到 30ms。很久以前用单片机做键盘时有人说“消抖10ms就够了”其实只对一部分开关成立保守设计最好按 20ms 算。1.2 在高速时钟下一抖就是一场灾难单片机的处理方式往往很简单检测到按键变化后软件延时 10ms再读一次。但把这种做法直接搬到 FPGA 里是行不通的因为 FPGA 的时钟太快了。50MHz 时钟的周期是 20ns100MHz 是 10ns而按键抖动持续 5ms 到 20ms。相当于在抖动期间时钟已经跑了几十万个周期每一拍采样到的电平都可能不同。后果就是你以为自己检测到了一次按下实际上你的逻辑在抖动期间判断出了几十次“按下——释放——按下——释放”的事件。写了一个“按键按一次LED状态翻转一次”的小模块上板之后 LED 却像抽风一样乱闪多半就是这个问题。另外还有一个隐患FPGA 里的组合逻辑对毛刺特别敏感。如果按键信号没有经过处理就直接参与边沿检测、状态判断一个几纳秒的毛刺都可能被当成有效事件后续状态机立刻走错路。这一点在调试时非常隐蔽因为仿真里如果没建模抖动你是完全看不到问题的。1.3 示波器下的真实抖动波形如果你手头有示波器可以做一个很直观的实验把探头接到按键的一脚地接 GND按下按键时用单次触发抓下降沿。你会看到一串高低跳变的脉冲而不是干净的下降沿。拨开波形仔细看这些脉冲宽度还不规则有的宽有的窄毫无规律。所以任何“每隔固定时间采样一次”的做法理论上都有可能在很倒霉的时刻采到某一段异常状态。理解了物理层面的抖动后面所有消抖方案的逻辑就很清楚了无非就是想办法确认“按键确实稳定处于某个状态”而不是靠一两个时钟周期的采样结果下结论。2. 消除抖动有哪些流派选一条适合你的路2.1 硬件滤波RC低通与RS触发器最原始的办法是在电路上做文章。RC 低通滤波是典型方案在按键输出和地之间接一个电容再串联一个电阻。抖动产生的高频分量会被电容吸收波形变成平滑的斜坡上升或下降相当于把毛刺滤掉了。但这样出来的波形边沿变缓后面一般还要接施密特触发器整形硬件成本和板子面积都会增加适合产品定型、逻辑不想再改的场合。RS 触发器方案则是利用按键有常开和常闭两套触点两个触点分别接到一个基本 RS 触发器的 S 和 R 端。当按键切换时总有一路信号先到把触发器锁存住从而忽略后续的弹跳。这种方法硬件逻辑非常少但前提是你的按键模块本身引出两个触点连线很多成品开发板的按键只有一根线加一根地线根本没法用RS方案。2.2 逻辑域里的几种消抖思路到了纯逻辑层面思路就多起来了。常见的有三种。第一种是延时重采样。检测到按键变化后什么都不做等个 10ms 到 20ms再去采样一次。如果采到的还是目标电平就认为按键稳定了。这种做法代码量最少但存在一个尴尬的盲区等你延时结束的那一刻如果抖动刚好还没结束你又采到错误的电平。所以它只能说“大概率有用”不能说“可靠”。第二种是计数器计时法也就是本文的核心。它的思路是检测到按键变低按下之后启动一个计数器开始数周期。如果计数满 CNT_MAX 个周期按键依然保持低电平才确认这是一次真实按下。任何一次抖动造成的“弹起”都会让计数器清零重来。这种方法逻辑简单、抗抖动能力强而且参数直观CNT_MAX 乘以时钟周期就是你要的消抖时间。第三种是滑动窗口滤波类似数字信号处理里的滑动平均。把最近 N 次采样结果存到一个移位寄存器里只有当这 N 次采样全部为低电平才认为按键稳定按下只有全部为高电平才认为稳定释放。这个方法后面我单独用一整章讲因为它思路清爽实现也不难。2.3 方案对比与选型方案核心思想资源消耗抗抖动能力适合场景RC低通整形模拟滤波电容电阻等硬件强产品定型、不占逻辑资源RS触发器电平锁存极少逻辑较强有常开常闭触点的按键延时重采样等一段时间再读极少逻辑中低频单片机不太适合FPGA计数器计时计时稳定确认计数器加状态强FPGA入门经典教学首选滑动窗口连续N次一致移位寄存器强想灵活调节时间常数、逻辑规整我给初学者的建议是先把计数器计时法吃透因为这个思路能帮你建立“状态计时”的硬件思维后面写状态机、写通信协议都会用到。等你熟练了再去看滑动窗口和显式状态机。3. 计数器消抖代码逐行拆解FPGA里的“计时确认”3.1 为什么第一步要做两级同步按键信号相对于 FPGA 的系统时钟来说是异步的。你可能在某个时钟上升沿附近按下按键这个跳变离时钟沿就差了零点几纳秒触发器可能进入亚稳态——输出既不是稳定的0也不是稳定的1而是悬在半空、无法预测的状态。这个亚稳态如果直接传到下一级逻辑会造成电路行为不可控。解决办法是加两级同步器也就是用两个触发器串起来。第一级触发器把异步信号采进来哪怕进入亚稳态经过一个时钟周期后信号大概率会在第二级触发器处稳定为合法的0或1。这就叫“用时间换确定性”。注意我说的不是“一定稳定”而是“大概率稳定”两级触发器已经能把亚稳态传播概率压低到工程上可以忽略不计的程度。3.2 计数器怎么算“稳定”这笔账两级同步之后key_d1 可以看作是与系统时钟同步的、相对干净的按键信号。现在关键问题是什么时候才能确认一次真实按下我的做法是在检测到低电平后启动计数持续数 CNT_MAX 拍。只要中途 key_d1 变高一次也就是出现了一次弹起计数器立刻清零重来。只有连续 CNT_MAX 拍都读到低电平才宣布“这次是真正的按下”。很多人会问CNT_MAX 怎么取值很简单你要的消抖时间除以时钟周期就行。比如目标消抖时间 20ms时钟 100MHz那么 CNT_MAX 20ms / 10ns 2_000_000。就这么来的没有任何玄学。3.3 完整代码与信号说明下面是完整代码我加了比较详细的注释方便你对着看。module key_debounce #( parameter CNT_MAX 21d2_000_000 // 100MHz时钟下约20ms )( input wire clk, // 系统时钟 input wire rst_n, // 异步复位低有效 input wire key_in, // 按键输入按下为低 output reg key_out // 消抖后输出按下为低 ); // 1. 两级同步器 reg key_d0 1b1; reg key_d1 1b1; always (posedge clk or negedge rst_n) begin if (!rst_n) begin key_d0 1b1; key_d1 1b1; end else begin key_d0 key_in; // 第一级采原始按键信号 key_d1 key_d0; // 第二级再打一拍降低亚稳态风险 end end // 2. 计数器 状态确认 reg [20:0] cnt; // 计数器21位 reg key_flag; // 是否已经确认按下 reg key_ff; // 输出锁存寄存器 always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 21d0; key_flag 1b0; key_ff 1b1; // 复位后输出为高按键未按下 end else begin if (!key_flag) begin // 状态A尚未确认按下等待稳定低电平 if (key_d1 1b0) begin if (cnt CNT_MAX) cnt cnt 1b1; if (cnt CNT_MAX - 1) begin key_flag 1b1; // 连续低电平时间够长确认按下 key_ff 1b0; // 输出拉低 end end else begin cnt 21d0; // 中途弹起立刻清零 end end else begin // 状态B已经确认按下等待释放 if (key_d1 1b1) begin key_flag 1b0; // 检测到释放 key_ff 1b1; // 输出恢复高 cnt 21d0; end end end end assign key_out key_ff; endmodule代码不长但有几个点我要专门展开说。如果你能完全看懂这几段按键消抖这块基本就通了。3.4 为什么这几个细节必须这样写第一判断计数满用的是cnt CNT_MAX - 1不是cnt CNT_MAX。原因很简单cnt 从 0 开始递增当计数到 CNT_MAX 个周期时最后一个周期里 cnt 的值是 CNT_MAX - 1。等到下一个时钟沿 cnt 才会变成 CNT_MAX但那已经是第 CNT_MAX 1 拍了时间就超了。新手最容易在这类边界条件上翻车。第二为什么用reg key_ff锁存输出不直接用组合逻辑赋值如果你用assign key_out (状态判断)这种写法只要中间信号有毛刺输出就可能跟着抖动。用寄存器锁存相当于给输出加了一层“稳定化”保证后续模块拿到的始终是平坦的电平。这在跨模块通信时特别重要。第三为什么一定要用非阻塞赋值而不是因为这是一个时序逻辑 always 块非阻塞赋值模拟的是触发器的并行特性右边先采样左边后更新。如果你写成阻塞赋值仿真时就会看到各种诡异竞争和中间状态综合时也可能多出来一些你没预期的锁存器。这不是风格问题是硬件代码的底线问题。计数器写法也是一样cnt cnt 1就是在时钟沿瞬间完成递增不是立刻生效。另外提一个扩展点如果某些开发板的按键按下是高电平有效你只需要把代码里所有key_d1 1b0和key_d1 1b1的判断对调其余逻辑不用动。极性这个事上板前一定看原理图确认清楚不然写反了消抖模块会变成“一按下就输出高、一释放就输出低”完全反向。4. 用 Icarus Verilog 跑仿真没有开发板也能验证4.1 环境准备装好 Icarus Verilog 和 GTKWaveIcarus Verilog 是个开源的 Verilog 仿真工具免费、命令行操作、支持 Windows 和 Linux。配合 GTKWave 看波形足以应付绝大多数入门级仿真需求。很多初学者一上来就用 Quartus 或 Vivado综合一次要等好几分钟其实逻辑验证完全可以先用 Icarus 在电脑上跑完再上板综合。Linux 或 WSL 下安装很简单sudo apt install iverilog gtkwaveWindows 下直接去 Icarus Verilog 官网下载安装包装完以后 iverilog 和 gtkwave 都会出现在程序菜单里。装好之后命令行里敲iverilog -v确认能识别工具。4.2 带抖动的 testbench 怎么写写 testbench 最重要的一点就是你必须主动制造抖动。很多新手写 testbench 时只给一个干净的下降沿仿真当然通过上板却一塌糊涂就是因为仿真环境里没有建模抖动的过程。下面这个 testbench 里我定义了一个jitter_press任务用来模拟一次带弹跳的按键按下。它会在真正按住之前交替翻转键值若干次模拟物理上的簧片弹跳。timescale 1ns / 1ps module tb_key_debounce; reg clk; reg rst_n; reg key_in; wire key_out; // 在仿真里把 CNT_MAX 改小避免仿真时间过长 key_debounce #( .CNT_MAX(21d1000) ) uut ( .clk (clk), .rst_n (rst_n), .key_in(key_in), .key_out(key_out) ); // 50MHz时钟周期20ns initial clk 0; always #10 clk ~clk; // 模拟带抖动的按键按下 task jitter_press; integer k; begin for (k 0; k 5; k k 1) begin key_in 0; #(20 k * 10); // 低电平持续时间不规则 key_in 1; #(15 k * 7); // 高电平持续时间也不规则 end key_in 0; // 抖动结束稳定按住 #(30_000); // 按住30us确保超过1000个时钟周期 key_in 1; // 释放 #(30_000); end endtask initial begin rst_n 0; key_in 1; #100; rst_n 1; #100; $display(Test 1: jitter press); jitter_press; $display(Test 2: clean press); key_in 0; #(30_000); key_in 1; #(30_000); $finish; end endmodule这里有个非常关键的点为什么仿真里要把 CNT_MAX 改成 1000而不是用真实的 2_000_000因为如果保持真实参数意味着仿真要跑完 20ms 的物理时间对仿真器来说是 200 万个时钟周期波形文件巨大等待时间也很长。把参数改小到 10001000 个周期是 20us验证的就是“计数器满后才输出的逻辑形态”而不是“20ms 到底有多长”。时间长度由参数决定逻辑形态才是你要验证的东西。上板前再把参数改回真实值就行。4.3 编译、运行、看波形三步走编译和运行命令iverilog -o tb_key_debounce.vvp tb_key_debounce.v key_debounce.v vvp tb_key_debounce.vvp gtkwave tb_key_debounce.vvp第一条命令把 testbench 和被测模块一起编译成 vvp 文件。第二条命令执行仿真。第三条命令打开 GTKWave 查看波形。在 GTKWave 左侧的模块树里找到tb_key_debounce点开里面的uut实例把key_in、key_d1、cnt、key_flag、key_out这几个信号添加到波形窗口。观察重点有三个抖动期间key_out必须保持高电平不能被误拉低。抖动结束并持续按住足够时间后key_out才变低并且一直保持到释放。cnt的信号在抖动期间会有多次归零稳定按下后才一路数到 CNT_MAX - 1。看到这三个现象说明消抖逻辑在行为上是正确的。再强调一次仿真里波形对了只说明逻辑设计没问题是否能在真实抖动场景下稳定工作还要靠参数和实际实验来验证。5. 滑动窗口滤波法从“计数确认”到“连续一致”5.1 滑窗的核心思想计数器计时法要求“从某个起点开始连续稳定计时”逻辑上有一种“状态推进”的感觉。而滑动窗口滤波的思路完全不同它不管起点在哪只看最近 W 次采样。如果最近 W 次采样全部为 0就认为当前稳定在低电平如果全部为 1就认为当前稳定在高电平只要窗口内还有混着的采样值就保持上一个稳定输出不变。你可以把滑动窗口想象成“现场投票”每个时钟周期投一次票只有当最近 W 个成员都投同一个结果才改变最终决定。任何一票“跑偏”都会被多数票压制住。这个思路在软件滤波里很常见比如鼠标键盘的消抖、传感器数据的滑动平均。放到硬件里实现其实就一个移位寄存器加两个位宽检测逻辑非常简单。5.2 一个可用的滑窗消抖代码下面的代码是滑窗法的基本形态窗口长度用参数控制。注意我同样加了二级同步器按键信号必须先同步否则窗口里的采样数据没什么意义。module key_debounce_sliding #( parameter WIN_LEN 16 )( input wire clk, input wire rst_n, input wire key_in, output reg key_out ); reg key_d0, key_d1; always (posedge clk or negedge rst_n) begin if (!rst_n) begin key_d0 1b1; key_d1 1b1; end else begin key_d0 key_in; key_d1 key_d0; end end reg [WIN_LEN-1:0] win; always (posedge clk or negedge rst_n) begin if (!rst_n) win {WIN_LEN{1b1}}; // 复位时认为全部是高电平 else win {win[WIN_LEN-2:0], key_d1}; // 左移最新采样放最低位 end // 窗口内全部为0按下确认 wire all_zero ~|win; // 窗口内全部为1释放确认 wire all_one win; always (posedge clk or negedge rst_n) begin if (!rst_n) key_out 1b1; else begin if (all_zero) key_out 1b0; else if (all_one) key_out 1b1; // 其余情况保持原样 end end endmodule~|win是或非门的缩位操作只要 win 里有一位是 1结果就是 0只有全部是 0 才输出 1。win是缩位与操作全部是 1 才输出 1。这两个信号就是“连续全0”和“连续全1”的硬件表达。5.3 滑窗容易踩的一个坑直接窗口做不了长延时纯滑窗有个很现实的问题如果时钟是 100MHz一个时钟周期 10ns你想实现 20ms 消抖窗口长度得是 2_000_000 位。FPGA 里不可能为单个按键搞一个两百万位的移位寄存器资源直接爆掉。所以在实际工程里滑窗法通常配合“采样使能”使用。不是每个时钟周期都移位而是每隔一段时间产生一个采样脉冲把此刻的 key_d1 移入窗口。比如用计数器产生 1kHz 的采样时钟每 1ms 采样一次窗口长度 16就代表 16ms 的消抖时间。这样资源占用只有 16 位而时间常数依然可以做得很大。再说说滑窗法和计数法的本质差异。计数器法对“时间连续性”要求更高一旦中途出现一次弹起计数器清零必须重新计时。滑窗法只要最近 W 次采样一致就切换输出它对单次毛刺的容忍度稍高但也有个边界问题如果抖动正好让 W 次采样里混了几次相同的错误电平窗口可能会误判。所以窗口长度的选择要结合实际的抖动时长。总体来说滑窗法的代码结构更规整配合采样使能后非常适合需要频繁调节滤波时长的场景比如按键、编码器、以及低速串行信号去毛刺。6. 上板实测后才会遇到的那些坑6.1 计数器位宽算错消抖直接“失效”我见过很多同学抄了计数器消抖代码上板后发现按键还是偶发乱跳百思不得其解。最后发现是计数器的位宽不够。比如时钟 100MHz消抖时间 20ms需要的周期数是 2_000_000这个数大于 2 的 20 次方1_048_576所以 20 位计数器根本数不到 2_000_000计数器会在最大值回绕消抖时间缩短一半甚至更短自然消不干净。正确的位宽计算方法是先算出需要的周期数 N然后保证 2 的位数次方大于 N。2_000_000 小于 2 的 21 次方2_097_152所以至少要 21 位。代码里我用的就是reg [20:0] cnt。计算过程不复杂但非常容易忽略因为综合工具通常不会因为你位宽不够报错它只会默默产生一个提前回绕的计数器。6.2 只处理按下不处理释放松键时的抖动也够你喝一壶很多最简教程只处理“按下”这个方向释放直接忽略。这在实验里好像没什么问题但如果你用来计数、翻页、切换模式就可能遇到“按一次算两次”的情况。原因就是释放时也会有抖动释放瞬间的弹跳如果被当成新的按下事件就多记了一次。我的示范代码里做了简单的释放判断但严格说释放方向也应该做同样的稳定确认也就是不仅仅检测到key_d1 1就立刻释放而是要保证释放后的高电平也稳定持续一段时间后再翻转。工业级写法通常用显式状态机把“按下检测”“按下确认”“释放检测”“释放确认”分开每个状态都有自己的计数器。这才是彻底的双向消抖。6.3 亚稳态不是玄学是真实的时序风险前面提到两级同步器这里再说得直白一点。按键信号跟时钟毫无关系它是异步的。如果一个信号刚好在时钟上升沿附近变化触发器会采样到一个无法预测的中间电平这就是亚稳态。亚稳态会在一到两个时钟周期内慢慢稳定下来但稳定成 0 还是 1 是不确定的。如果不加同步器直接把这个信号拿去判断边沿你的状态机可能随机地多走一步或少走一步而且这种随机现象不是每次都能复现。所以我建议所有的按键输入、外部中断输入第一件事永远是两级同步。这不仅适用于按键也适用于任何跨时钟域的单 bit 信号。不要觉得多打两拍浪费时间这是性价比最高的稳定性投资。6.4 参数是按时钟频率算出来的不是拍脑袋定的最后再强调一遍参数问题。很多人把CNT_MAX随便填一个像 20d999_999 这样的数不抬头看一眼自己的时钟频率。50MHz 时钟下20d999_999 对应的消抖时间是 20ms但如果你的开发板时钟是 100MHz同样的参数消抖时间是 10ms偏短如果是 12MHz 的时钟同样的参数消抖时间直接变成 83ms按键会迟钝得让人抓狂。我自己的习惯是先把时钟频率写到注释里再顺手算好周期和位宽。比如“50MHz 时钟20ms 消抖需要 1_000_000 拍20 位足够”。写注释不是为了好看是为了下次调试时能快速确认参数来源。这个习惯帮我避掉过不少低级错误。如果你是自己玩开发板我强烈建议仿真通过后再花几分钟看一眼真实按键按下时cnt的波形确认抖动期间计数器清零了几次、稳定后计时是否满值。多花这几分钟远比上板后对着一个乱闪的 LED 猜来猜去要省时间。按键消抖算是一个很典型的“小逻辑、大坑点”模块把这个模块吃透了后面写状态机、写跨时钟域处理很多思路都是相通的。
返回列表