ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

FPGA入门必学:Verilog按键消抖原理、代码与仿真详解

2026/9/28 1:54:35 拓冰建站 浏览量
FPGA入门必学:Verilog按键消抖原理、代码与仿真详解 写FPGA和Verilog的人几乎都有一个绕不过去的入门关卡就是按键消抖。你兴冲冲烧录了第一个点灯程序按下板子上的按键结果灯要么不亮要么闪烁好几下要么计数器一下子跳了好几个数。这时候十有八九不是板子坏了而是少了“按键消抖”这一步。这篇内容就是专门给刚接触Verilog的小白准备的我会从按键为什么会有抖动讲起把消抖的常见思路拆开然后手写一段可以直接用的Verilog消抖代码再带你用Icarus Verilog写testbench做仿真最后把新手最容易踩的坑一次性列清楚。我能理解你现在可能是刚学数字逻辑代码能跑通但心里发虚也理解你可能已经被各种模块例化、状态机、时序约束搞得头晕。放心按键消抖是典型的“麻雀虽小五脏俱全”的入门案例只要你认真跟完这篇你对时钟计数、信号同步、边沿检测、仿真验证都会有一个非常直观的认识。看完之后你可以直接照着抄到自己的工程里也能理解为什么网上搜出来的按键消抖代码长什么样、为什么那样写。另外多说一句按键消抖虽然看着简单但里面藏着的“时间窗口”、“稳定电平确认”、“亚稳态处理”这些概念后面学IIC、SPI、串口、DDR3都会反复遇到。所以别觉得这是小题大做把这个例子吃透比刷十遍语法书都管用。1. 按键抖动到底是怎么一回事机械世界的毛刺怎么进了数字系统1.1 机械开关不是理想开关按下瞬间会“弹跳”你可以在纸上画一个理想的按键按下通松开断。但在真实世界里按键内部是金属弹片或者导电橡胶机械结构从接触到稳定需要几毫秒甚至几十毫秒。这段时间里触点会靠自身的弹性反复弹开、接触、再弹开、再接触就像你用力关一扇有弹簧的门门不会一次合上而是在门框上弹几下才安静下来。用示波器看按键引脚的电平你会发现按下时并不是干净地从高电平跳到低电平而是来回抖动一串脉冲高、低、高、低交替持续几毫秒到几十毫秒不等。这个现象就叫“抖动”bounce。对模拟电路工程师来说这是物理接触的必然现象对数字逻辑工程师来说这串抖动脉冲会造成严重问题因为数字电路只认0和1不知道什么是“弹跳”。按键抖动通常发生在按下和释放两个阶段而且持续时间不是固定的。同一个按键按得快一点和按得慢一点抖动波形都不一样不同批次、不同型号的按键抖动时间也千差万别。这也是为什么按键消抖不能靠固定写死一种完全精确的模型而是要用“等待稳定”的思路来处理。1.2 不消抖直接采样后果是什么如果你直接把按键引脚接到FPGA的输入端口然后在always块里读取寄存器的值最直观的后果就是一次物理按键会被当成多次数字按键。举个例子你写了一个计数器每检测到一次按键按下就加一。本来你想按一下加到1结果因为抖动key_in在几毫秒内变化了十几次计数器直接从0跳到了12。你用数码管显示就会发现数码管数字乱跳或者一瞬间闪过好几个数字。如果是用在抢答器、闹钟设置、步进电机控制这种对输入次数敏感的场景误触发会让人崩溃。更隐蔽的问题是按键信号是异步输入的和FPGA时钟没有任何固定关系。如果直接拿这个信号做边沿检测会产生亚稳态。所谓亚稳态就是触发器的建立时间和保持时间没有被满足输出端在0和1之间震荡甚至会把这个不确定状态传给后面的电路导致整个逻辑错乱。所以按键信号不能直接进时序逻辑需要先同步。我曾经在一个小项目里图省事没有消抖就检测下降沿结果每次按下去串口发送的都是乱七八糟的字符。一开始以为是波特率错了折腾半天才发现是抖动导致同一个字节被发送了好多次。从那以后我再也不敢不消抖直接采按键了。2. 消抖思路拆解硬件上能消逻辑上也能消2.1 硬件消抖RC低通滤波和施密特触发器最传统的消抖方式是在电路板上解决。按键引脚对地接一个电容再串联一个电阻组成RC低通滤波器。抖动脉冲频率高电容会把高频成分滤掉让电平信号变得圆滑。后面再接一个施密特触发器整形就能输出干净的高低电平。这种方案在模拟电路时代很成熟成本低、可靠性高。但FPGA开发板为了节约面积和灵活性不会给每个按键都配一组RC滤波和施密特触发器。大多数开发板的按键只有简单的上拉电阻直接把电平送到FPGA引脚。所以你想用现成板子做实验最靠谱的办法就是写Verilog逻辑做消抖。硬件消抖还有一个问题RC时间常数是固定的一旦按键老化、抖动时间变长滤波效果就会变差。逻辑消抖可以通过参数随时调整等待时间甚至在同一个设计里给不同按键配置不同消抖时长这在硬件方案里很难做到。2.2 逻辑消抖的核心延时采样和连续电平确认逻辑消抖的原理说穿了很简单抖动期间电平是乱的我不能每看到一个变化就信一次我要等一段时间如果这段时间内电平稳定在一个值我再认为按键真的按下了。最常见的实现方法是“延时采样”。检测到按键电平变化后启动一个计数器计数到设定的消抖时间比如20ms。如果在这20ms内电平一直保持新状态就判定电平切换有效如果中途又变回去了说明之前的“新状态”只是抖动计数器清零重新等待。这种方法相当于给信号加了一个“时间窗口”窗口内稳定才算数。另一种思路是“连续多次采样”。一个信号连续N个采样周期都读到同一个值才认为它是真的。比如连续采样20次每次间隔1ms如果20次全是低电平就判定按键按下。这种方法本质上是滑动窗口滤波的雏形。从工程角度说延时采样法对随机抖动更有效因为它允许抖动期内电平反复变化只要在最后一段时间稳定即可。连续采样法则取决于N次采样的覆盖范围和抖动概率覆盖不好的话也可能误判。实际综合设计里两种思路都有应用按键消抖通常用延时采样高速总线上的毛刺滤除则偏向滑动窗口。2.3 滑动窗口滤波和按键消抖是什么关系你可能最近在看Verilog相关经验帖时总看到“滑动窗口滤波”这个词它和按键消抖其实是一家人。滑动窗口的核心思想是维护一个长度为N的移位寄存器每个时钟周期把当前输入移进去然后看整个窗口里的数据规律。如果窗口里所有位都是0说明输入长时间稳定在0如果所有位都是1说明输入稳定在1如果窗口里有0也有1说明信号还在变化输出保持上一次的结果。这样做可以实时滤除窄毛刺因为在窗口长度内只要有一个采样点“反弹”输出就不会改变。按键消抖可以看成滑动窗口的简化版不需要完整保存N个点只需要一个计数器记录“新状态维持了多久”。只要这个持续时间超过阈值就判定稳定。所以你在很多综合代码里会看到计数器方案而不是真正的移位寄存器方案因为计数器占用的寄存器少很多逻辑也更简单。理解这层关系很重要。等以后你去处理IIC总线上的SDA毛刺、SPI时钟边沿附近的数据抖动或者DDR3读写时对有效窗口的采样你会发现方法一模一样窗口内稳定数据才可信。3. Verilog按键消抖模块从零开始写参数、代码、逐行解读3.1 先把参数算清楚时钟频率、消抖时间、计数器位宽写代码之前先把数学搞好。假设你板子上的时钟频率是50MHz也就是每秒50,000,000个周期一个周期20ns。想要消抖20ms那么需要等待的时间周期数就是50,000,000 × 20 / 1000 1,000,000也就是需要数到100万个周期。这个数字用二进制表示需要多少位因为2^20 1,048,576刚好大于100万所以计数器位宽20位就够了。如果板子时钟是100MHz消抖时间还是20ms那就要数2,000,000个周期仍然要用20位以上2^21 2,097,152。如果不重新算直接把之前50MHz工程里的计数器位宽照抄很可能因为位宽不够导致计数器溢出消抖时间远小于预期。有一个Verilog内置函数可以省去人工计算就是$clog2返回参数向上取整的以2为底的对数。比如$clog2(1_000_000)结果是20。你可以用localparam CNT_WIDTH $clog2(CNT_LIMIT 1);来让代码适应不同参数不会出现位宽不够的问题。注意$clog2是SystemVerilog语法但Icarus Verilog也支持很多综合工具也都兼容可以放心用。3.2 核心第一步为什么要给按键输入打两拍按键信号是真实世界的物理信号和FPGA内部时钟完全无关。直接把这种异步信号送入always块触发器很容易进入亚稳态。解决办法是“两级同步”也就是把一个寄存器串联到下一个寄存器。第一级寄存器采样到按键信号时可能处于亚稳态但它有整个时钟周期来稳定。到下一个时钟上升沿第二级寄存器再从第一级取值此时第一级输出大概率已经稳定了。这个“打两拍”的结构并不能百分之百消除亚稳态但能把亚稳态传递到后续电路的概率降到几乎可以忽略。工业界标准做法就是至少两个触发器做同步。同步之后的信号抖动的毛刺会被拆分到相邻时钟周期但逻辑电平和原始输入基本一致不影响消抖判断。你可以把同步后的信号名字写在代码注释里这样后面的人一看就知道这个信号是安全可用的。另外要注意同步器的复位初值应该和按键松开电平一致。一般开发板按键按下为低电平松开为高电平所以两个同步寄存器复位时都应该赋值为1。如果初始化成0上电瞬间会误判成“已按下”后面整个消抖逻辑都会出错。3.3 完整可综合代码与逐行解析下面这段是完整的Verilog按键消抖模块参数化设计支持不同时钟频率和消抖时间。代码风格尽量简洁适合小白阅读。module key_debounce #( parameter CLK_FREQ 50_000_000, // 时钟频率单位Hz parameter DEBOUNCE_MS 20 // 消抖时间单位ms )( input wire clk, input wire rst_n, input wire key_in, // 原始按键输入按下为0松开为1 output reg key_out // 消抖后的电平输出 ); // 消抖时间对应的时钟周期数 localparam CNT_LIMIT CLK_FREQ * DEBOUNCE_MS / 1000; // 计数器位宽自动计算 localparam CNT_WIDTH $clog2(CNT_LIMIT 1); localparam CNT_MAX CNT_LIMIT - 1; reg [CNT_WIDTH-1:0] cnt; reg key_in_d1; reg key_in_d2; reg key_stable; wire key_sync; assign key_sync 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 // 二级核心消抖逻辑 always (posedge clk or negedge rst_n) begin if(!rst_n) begin cnt 0; key_stable 1b1; // 复位默认松开 end else if(key_sync ! key_stable) begin if(cnt CNT_MAX) begin key_stable key_sync; cnt 0; end else begin cnt cnt 1b1; end end else begin cnt 0; end end assign key_out key_stable; endmodule逐行来看。key_sync是同步后的按键信号。第二个always块里key_sync ! key_stable表示“当前输入状态和之前确认的状态不一样”也就是检测到了切换可能。这时候计数器开始累加。只要输入一直没有变回去计数器就会一直加加到CNT_MAX时说明新状态已经稳定了足够长时间于是把key_stable更新成新的电平。如果输入只是在抖动比如说从1变成0还没过几个周期又变回1那么key_sync ! key_stable就不成立进入else分支计数器立刻清零。这样抖动就不会被认成一次有效按键。整个过程只用一个16到20位的计数器和一个状态寄存器资源占用非常少。需要注意的是key_out直接连的是key_stable也就是消抖后的电平。按下为低、松开为高。如果你的按键是按下为高把复位初值和同步寄存器赋值改成0即可。3.4 扩展功能从电平输出变成单脉冲输出很多实际场景里你不需要持续的电平信号而是希望“按一下产生一个脉冲”。比如给状态机触发一次跳转或者给计数器加一。有了消抖后的key_stable你可以很容易产生一个下降沿脉冲因为按下瞬间电平从1变成0。加两个寄存器就行reg key_stable_d1; always (posedge clk or negedge rst_n) begin if(!rst_n) begin key_stable_d1 1b1; end else begin key_stable_d1 key_stable; end end assign key_press_pulse key_stable_d1 ~key_stable;key_press_pulse只在高电平变成低电平的那个时钟周期为1其他时间为0。这就得到了一个干净的单脉冲。注意这个检测一定要放在消抖模块的输出端不要在原始key_in上做否则抖动期间会产生一堆毛刺脉冲。如果你希望检测“松开”时刻把逻辑反过来写成~key_stable_d1 key_stable即可。按下和松开边缘都可以用来做触发看你的应用场景需要哪种行为。4. 用Icarus Verilog仿真验证看不到波形一切等于零4.1 搭建本地仿真环境iverilog和GTKWave写完了代码下一步不是直接上板而是先仿真。仿真能看到内部所有信号波形是调试Verilog最强大的工具。Icarus Verilog是一个开源的Verilog仿真器跨平台Windows和Linux都能装。它的命令行名字通常叫iverilog配套的波形查看工具是GTKWave。如果你用的是Windows可以去Icarus Verilog官网下载安装包装完在CMD里就能直接运行iverilog。Linux下更简单Ubuntu直接sudo apt install iverilog gtkwave。仿真的基本流程分三步编写设计文件编写testbench测试文件然后编译仿真。命令行如下iverilog -o sim.vvp key_debounce.v tb.v vvp sim.vvp gtkwave tb.vcdiverilog负责把设计文件和测试文件一起编译成可执行的仿真文件vvp执行仿真生成VCD波形文件最后用GTKWave打开。VCD文件记录了所有信号随时间变化的历史是波形查看的基础。4.2 写一个带真实抖动波形的testbench测试平台testbench里我们需要模拟真实按键的抖动。以50MHz时钟为例周期20ns。从初始的高电平开始按下时先变低然后来回弹跳几个周期最后稳定为低。释放时也做同样处理。完整testbench代码如下timescale 1ns/1ps module tb; reg clk 0; reg rst_n 0; reg key_in 1; wire key_out; always #10 clk ~clk; // 50MHz时钟 key_debounce #( .CLK_FREQ(50_000_000), .DEBOUNCE_MS(1) // 仿真时减小消抖时间节省等待 ) dut ( .clk(clk), .rst_n(rst_n), .key_in(key_in), .key_out(key_out) ); initial begin rst_n 0; repeat (5) (posedge clk); rst_n 1; repeat (5) (posedge clk); // 模拟按下抖动低-高-低-高-低之后稳定低 #10 key_in 0; #200 key_in 1; #300 key_in 0; #400 key_in 1; #150 key_in 0; #1_300_000; // 等待1.3ms大于1ms消抖时间 // 模拟释放抖动高-低-高-低-高之后稳定高 #10 key_in 1; #200 key_in 0; #300 key_in 1; #400 key_in 0; #150 key_in 1; #1_300_000; // 再等待1.3ms $finish; end initial begin $dumpfile(tb.vcd); $dumpvars(0, tb); end endmodule这段代码里DEBOUNCE_MS设置成1是为了让仿真不要等太久。如果设成20ms仿真器要运行20万个时钟周期也就是4ms的仿真时间虽然也能跑完但在交互调试时太磨叽。实际工程你还是要在参数里恢复成20ms。抖动时间安排在200ns到400ns之间虽然真实抖动通常几毫秒但仿真中我们只要保证抖动远小于消抖时间就够了。关键是让key_in在消抖窗口开始之前多次翻转验证计数器不会在抖动期间输出错误结果。Testbench里另一个细节是repeat (5) (posedge clk);用来让复位信号持续5个时钟周期确保所有寄存器都完成异步复位。之后释放复位再做后续操作。4.3 从波形里判断消抖是否成功仿真完成后用GTKWave打开VCD文件把key_in、key_sync、cnt、key_stable、key_out都拖进波形窗口。你希望看到的画面是这样的在第一次按下抖动期间key_in上下跳动好几次cnt可能先变成1、2但很快又被清0。当key_in稳定为低约1ms后cnt持续累加到CNT_MAXkey_stable从1变成0key_out也跟着变成0而且之后再无变化。在释放抖动期间同样是key_in混乱cnt反复清零之后稳定高电平约1mskey_stable回到1。从第一次按下去到松开key_out只发生了两次电平切换对应一次按下和一次释放。如果看到key_out跟着key_in一起抖动那说明消抖时间太短或者计数器逻辑有问题。这时候第一件事就是放大波形数清楚cnt到底有没有跑满再看看key_sync是否为稳定干净电平。仿真最大的好处就是你可以看到每个寄存器内部细节比上板后对着示波器猜要高效得多。5. 新手最容易踩的坑和排查技巧边踩边填5.1 计数器位宽不够消抖时间大打折扣新手经常直接抄一段代码不重新算计数器位宽。比如原来100MHz时钟消抖20ms需要2,000,000计数但如果代码里计数器是reg [15:0]最大只能数到65535每1.3ms左右就溢出回绕一次。最终现象是按键输入变化后计数器不断归零重来key_stable要么迟迟不更新要么在错误时机更新。排查方法很简单仿真的波形里看cnt是否在某一个数值被截断回0。解决办法是用$clog2自动计算位宽或者自己先按“时钟频率 × 消抖秒数”算出最大值再选择合适的二进制位宽。记住一个经验法则50MHz、1ms消抖需要16位10ms需要19位20ms需要20位。5.2 复位初值写错上电就乱触发按键板子通常是按下为低、松开为高。同步寄存器和key_stable的复位初值都应该是1。如果你图省事写成0上电复位结束后key_sync默认是1key_stable默认是0两者不相等计数器马上开始计时不到20ms就会把key_stable误更新成1。这个时候你还没按键输出就已经经历了一次虚假的电平变化。如果你做成脉冲输出上电后就会多出一个误触发脉冲后面整个状态机都可能被带偏。所以每次新建模块第一件事就是确认按键有效电平是低还是高并把所有相关初值一次性改对。5.3 边沿检测放错了位置消抖就白做了有人想当然地在原始key_in上做下降沿检测检测到脉冲后再去延时一段时间。这样做的问题是抖动期间key_in会产生好几个下降沿每个都被当成一次有效边沿后面的延时计数器会被反复打断输出当然不稳定。正确的顺序是先同步再消抖最后在key_stable上做边沿检测。也就是说消抖模块输出的电平已经是稳定信号边沿检测只是给它加一个过渡脉冲。这个顺序千万别搞反。我在自己项目里吃过这个亏浪费了整整一个晚上最后才发现是把检测放到了消抖前。5.4 多按键扩展是并行复制还是共用一个模块板子上按键往往不止一个。你可以直接把模块例化多份每个按键一个实例资源和逻辑完全独立这是最稳妥的做法。但要注意如果按键数量很多比如矩阵键盘就不能简单例化了而要用扫描消抖结合的方式本质上仍然是每个按键独立计时。还有一种偷懒做法是直接把所有按键信号通过组合逻辑拼接消抖后统一处理。比如写一个key_debounce模块端口从单个按键位扩展到多bit向量。这样做的好处是节省一部分计数器坏处是代码可读性下降而且一旦某个按键抖动特性不一样无法单独调整时间。我的建议是新手期老老实实逐个例化等到你真正理解了模块复用再考虑向量化。5.5 常见问题速查表现象可能原因排查方法输出跟着输入一起抖消抖时间太短调大DEBOUNCE_MS仿真看cnt是否跑满上电就触发一次复位初值写错检查同步器和key_stable初值是否为松开电平按键几次才触发一次计数器位宽不够溢出用$clog2算位宽仿真看cnt是否被截断按下一次输出多个脉冲边沿检测放在key_in上把边沿检测改到key_stable之后切换时钟后消抖失效参数没有重新计算用参数化设计不写死计数器目标值6. 从按键消抖延伸出去这步学到的东西能用到哪6.1 “时间窗口”思维在协议时序中的价值你能从按键消抖里抽出的最重要一句话任何信号只要想确认它的有效状态就必须在足够长的时间窗口内保持稳定。这句话放到通信协议里同样成立。比如IIC协议解析时SCL和SDA上的信号同样有建立时间和保持时间如果乱码毛刺进来读到的数据就是错的。你可以在采样点附近加一段持续N个周期稳定判断的窗口只有窗口内数据一致才把它当作有效位。QSPI读写Flash时候也是同理外部Flash输出来的数据在时钟边沿附近有短暂的毛刺区间采样点应该避开它。DDR3就更典型了数据有效窗口很短控制器要在正确的时间点对DQ信号采样本质上也是在做“窗口内稳定才采样”的判断。所以说按键消抖是你第一次系统接触“时间驯化信号”这个概念后面所有时序逻辑都是它的高级版。6.2 滑动窗口滤波的Verilog实现思路按键消抖已经用了计数器但如果你真想直接做滑动窗口滤波也很简单。维护一个N位移位寄存器每个时钟周期把同步后的按键信号移位进去。然后检查 (shift_reg)是否全为1或者~| (shift_reg)是否全为0。全为1表示连续N个周期采样都是高输出可以更新为高全为0表示连续N个周期采样都是低输出更新为低其他情况保持原输出。下面是示意代码片段parameter N 8; reg [N-1:0] shift_reg; wire all_high shift_reg; wire all_low ~|shift_reg; always (posedge clk or negedge rst_n) begin if(!rst_n) begin shift_reg 0; end else begin shift_reg {shift_reg[N-2:0], key_sync}; end end always (posedge clk or negedge rst_n) begin if(!rst_n) begin filter_out 1b1; end else if(all_high) begin filter_out 1b1; end else if(all_low) begin filter_out 1b0; end end你会发现这种滑动窗口的方案比计数器版更直观但占用的寄存器数量随窗口长度线性增加。如果你要过滤1ms内的毛刺、时钟20ns那窗口长度可能是5万移位寄存器根本不可能这么长。所以真正工程里还是用计数器方案做长窗口用短小的滑动窗口做边沿附近的毛刺过滤。两者可以组合使用。6.3 学习路径建议消抖之后练什么如果这是你第一个正经写的Verilog小模块那完成得很好。下一步我建议按这个顺序继续先写一个按键单脉冲控制LED亮灭的小工程把今天学的消抖和脉冲结合起来然后写一个带使能的计数器用按键脉冲控制加减用数码管显示再做UART串口发送把按键值通过串口发到电脑。这几步能把时序逻辑、状态机、模块例化全部串起来。等这些基本功扎实了再去看IIC主机控制器、SPI接口、QSPI Flash读写这些复杂IP里的状态机核心逻辑和按键消抖一样离不开稳定窗口和边沿检测。很多人一上来就啃DDR3控制器啃得头晕眼花其实就是因为前面这些小模块里的基本功没有吃透。我个人的经验是每学一个新模块除了看官方手册最有效的办法就是像今天这样写一个带真实扰动情况的testbench观察波形对齐时间节点然后故意制造bug再通过波形找出来。这个过程比单纯读十篇教程都有用。按键消抖这一课你能把仿真练熟后面学所有通信协议都会顺很多。最后分享一个小技巧调试按键消抖时别只盯着key_out一定要看cnt和key_sync。如果cnt迟迟不清零说明输入还在变化如果cnt清零次数太多说明抖动还没结束。这两个信号结合能让你对代码的“时间感”特别敏感。上了板别急着一次成功先用仿真把时间对齐再烧进去你就知道这套流程有多省时间了。