ARTICLE DETAIL

资讯详情

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

Verilog按键消抖实战:计数器、滑动窗口与边沿检测

Verilog按键消抖实战:计数器、滑动窗口与边沿检测 1. 按键抖动到底抖的是什么机械触点给数字电路出的第一道题我刚开始写Verilog的时候第一块板子上的按键控制LED怎么调都不对劲按下按键LED不是稳稳地亮起来而是忽明忽暗像是接触不良又像是代码写坏。后来用逻辑分析仪抓了一下信号才发现问题根本不在我写的代码上而在按键这个东西本身就是个“不可靠”的机械器件。1.1 按下那一下引脚上到底发生了什么按键开关内部就是一对金属触点动触点带一个弹簧片按下去之后动触点会朝着静触点撞过去。你以为“撞上”之后就稳定接触了但弹片是有惯性的接触瞬间会产生反弹反弹之后又被压下去再反弹、再接触这个反复过程就是机械抖动。在示波器上看按下过程中引脚电平会像一串毛刺一样在高电平和低电平之间来回跳不是一次干净的跳变。这段不干净的时间通常在5~20毫秒左右。以低电平有效为例按下过程的真实波形可能是这样1b0 - 1b1 - 1b0 - 1b1 - 1b0 - 1b0 - 1b1 - 1b0干净理想波形只有一次下降沿抖动波形里却藏着一堆下降沿。对数字逻辑而言每条下降沿都可能被当成一次独立事件。50MHz时钟周期是20纳秒一个10毫秒的抖动串里包含几十万次电平翻转哪怕把毛刺粗略打散成几十个“似边沿”你的计数器也早就失控了。很多人第一次仿真按键代码时会故意写一个理想按键模型按下就是干净的一次拉低。仿真全绿下板就翻车。原因就是Testbench里没有建模抖动。所以除了写RTL你还得学会怎么在仿真里“制造”抖动信号后面我会专门演示这一点。1.2 不消抖的后果按一下变成按三次最常见的现象就是“按键计数不准”。一个按键花10毫秒抖动计数器可能一口气加了3次甚至5次。你按一下数码管跳好几下。还有一种隐蔽情况是释放抖动按下时刚好躲过去了松开手指时弹片又来一遍回弹照样算一次事件。所以按键消抖不是只处理按下沿释放沿也要管。你可能会想既然抖动时间有5~20毫秒我在代码里把按键信号延时20毫秒再采样不就能避开大部分抖动吗思路完全正确但具体怎么延时、怎么判断“电平已经稳定”、怎么兼顾响应速度这就是消抖模块要做的事了。真实项目里按键用得最频繁的场景就是“按一次触发一次动作”像菜单切换、数字输入、启动停止。如果这个基本动作都做不干净后面整个状态机都会跟着乱。这也是为什么按键消抖几乎是所有Verilog入门教程绕不开的内容它是从“仿真里跑跑波形”到“板级工程可落地”之间的一道分水岭。2. 先搭一个能看波形的环境Icarus Verilog 与 Testbench 的配合方式工欲善其事必先利其器。我用过各种仿真环境教学和快速验证场景下最顺手的还是 Icarus Verilog。它免费、开源、轻量配合 GTKWave 看波形对一个初学Verilog的人来说完全够用。你甚至不需要一块开发板光靠仿真就能把按键消抖的代码逻辑看明白。2.1 安装和第一段最小Testbench骨架安装命令很简单sudo apt install iverilog gtkwaveWindows环境也可以用iverilog-win64或WSL2里的Linux环境。打开一个文本编辑器写一个测试平台上位文件注意这个文件不是要被综合成硬件的它只是用来“模拟外部环境”、产生激励信号并且抓取波形的。module tb_key_demo; reg clk 0; reg rst_n 0; reg key_btn 1b1; // 按键消抖模块在这里例化, 先留一个空壳 always #10 clk ~clk; // 50MHz, 周期20ns initial begin $dumpfile(key_demo.vcd); $dumpvars(0, tb_key_demo); rst_n 0; #100; rst_n 1; #100; // 在后面我会插入按键抖动激励 #1000; $finish; end endmoduleIcarus Verilog的命令用法是iverilog -o key_demo tb_key_demo.v key_debounce.v vvp key_demo gtkwave key_demo.vcd第一次跑通这个流程后你就有了一张随时可以观察内部波形的“实验台”。我建议从一开始就养成写$dumpfile和$dumpvars的习惯否则模块例化多了以后你根本不知道哪个信号在哪个层级里面。2.2 用Task构造一个“会抖”的按键信号这个部分很关键。很多初学者写的Testbench永远只有干净信号仿真结果当然漂亮但上板就翻车。既然要学按键消抖就得让激励源先抖起来。Verilog里的task适合封装一段重复使用的激励逻辑。下面这段代码模拟了一次“按下→抖动→稳定→释放→抖动→稳定”的完整过程task key_press; begin // 按下瞬间产生一串抖动 key_btn 1b1; #20; key_btn 1b0; #5; key_btn 1b1; #3; key_btn 1b0; #8; key_btn 1b1; #4; key_btn 1b0; #2000; // 稳定按住2us // 释放瞬间再产生一串抖动 key_btn 1b1; #6; key_btn 1b0; #4; key_btn 1b1; #2000; end endtask注意这里的抖动次数刻意做得比较少目的是让你在波形里能明确看出“输入很乱输出却很干净”的对比。要是完全按真实物理抖动仿真几万个毛刺塞进VCD文件GTKWave打开后反而不好观察。在initial块里调用key_press若干次就能逼真地看到哪个模块能扛住抖动哪个模块不能。这个Testbench思路比直接给一个干净的下降沿再断言“输出正确”要可信得多。2.3 RTL和Testbench的职责划分别混在一起写很多新手会问“为什么仿真代码不能直接烧到板子里”因为Testbench里充满了#20这样的延迟控制、$finish这样的仿真控制语句这些根本不是可综合逻辑。它们只是在告诉仿真器“信号在某个时间点应该变成什么电平。”设计代码RTL和验证代码Testbench分开写是我特别想强调的习惯。RTL只描述电路行为比如按键消抖计数器怎么计数、何时更新输出Testbench则负责模拟外部世界包括时钟、复位、按键抖动、断言输出是否正确。混在一起很难看清哪些逻辑会被综合成真实电路哪些只是仿真辅助。如果你以后打算做稍微复杂一点的数字系统这个习惯越早形成越好。仿真不是走形式它是在上板前唯一能快速、低代价发现逻辑错误的手段。按键消抖这种时序逻辑尤其依赖仿真来验证。3. 新手最稳的选择计数器延时消抖抖动是个物理现象算法上没有银弹。但工程上最常用、最适合入门的方案就是“保持一定时间内电平不变化才认为状态有效”也就是计数器延时消抖。它的核心思想用一个生活化类比来讲你在门口等一个人他连续20秒没变动作你才敢确认他已经稳定站在那儿但凡他动一下你重新计时。3.1 为什么要先做两拍同步把按键信号直接接到计数器上之前必须明白一个概念按键信号来自FPGA引脚的外部世界它和FPGA内部时钟完全没有相位关系。如果这个信号恰好在一个触发沿附近变化寄存器采样时可能会进入亚稳态——输出不确定既可能是0也可能是1甚至可能振荡一段时间。虽然按键消抖本身对亚稳态的敏感度不如高速跨时钟域那么高但我还是建议每个按键输入都先经过两级寄存器同步。代码如下always (posedge clk or negedge rst_n) begin if (!rst_n) begin key_meta 1b1; key_sync 1b1; end else begin key_meta key_btn; key_sync key_meta; end end第一级key_meta采到的是异步信号大概率会有些残余第二级key_sync再采一次把“不确定”的时间往后推了一拍这样后面所有逻辑拿到的都是已经同步到本时钟域的干净信号。这不是消抖但这是一种纪律。有了同步再谈消抖逻辑上才稳固。3.2 计数器消抖的完整Verilog代码下面的代码是我在教学里一直使用的版本。它默认按键低电平有效且按下后的稳定逻辑电平为低释放为高。module key_debounce_counter #( parameter CLK_FREQ 50_000_000, // 系统时钟频率, 单位Hz parameter DB_TIME_MS 20 // 需要的消抖时间, 单位ms )( input wire clk, input wire rst_n, input wire key_btn, output reg key_clean ); localparam CNT_WIDTH 32; localparam CNT_MAX (CLK_FREQ / 1000) * DB_TIME_MS - 1; reg key_meta; reg key_sync; reg [CNT_WIDTH-1:0] cnt; // 两级触发器同步, 消除亚稳态风险 always (posedge clk or negedge rst_n) begin if (!rst_n) begin key_meta 1b1; key_sync 1b1; end else begin key_meta key_btn; key_sync key_meta; end end // 计数消抖主逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 0; key_clean 1b1; end else begin // 一旦按键电平与当前输出状态不一致, 就说明出现了新的动作 // 但此时不确定是真实按下还是抖动, 先把计数器清零重新等待 if (key_sync ! key_clean) begin cnt 0; end // 如果持续 CNT_MAX 个周期电平都没有变化, 认为状态稳定 else if (cnt CNT_MAX) begin key_clean key_sync; cnt 0; end else begin cnt cnt 1b1; end end end endmodule逐行拆解这段逻辑。key_clean里保存的是当前“被确认”的状态。初始默认1也就是未按下。当检测到key_sync ! key_clean时说明按键电平变化了。但注意我现在不清除不代表确认变化而是把计数器清零。这一步非常巧妙抖动时电平一直在变化计数器每次都被清零永远达不成满计数。只有当电平持续稳定CNT_MAX个周期计数器才会走完并更新key_clean。释放按键时逻辑同样生效。这样一次完整的“按下一释放”过程只会在输出端产生一次可信的下降沿和一次可信的上升沿。按50MHz时钟计算(50_000_000 / 1000) * 20 - 1等于999999。也就是说计数器需要从0数到999999完全没有抖动或中途出现一次反跳就重新清零。刚好满足20毫秒稳定触发时间。3.3 如何调参数别把消抖时间调成煎熬参数化写法的最大好处是你在不同开发板上可以直接改变CLK_FREQ和DB_TIME_MS。比如你的FPGA开发板用的是25MHz晶振那CLK_FREQ改成25_000_000保持DB_TIME_MS20消抖时间仍然是20ms。不需要手改一大片代码。但有一个常见问题有人为了追求“响应更快”把DB_TIME_MS改成2甚至1。太短会怎样5~20ms的抖动窗口摆在那里如果只等2ms后半段抖动还没走完你就已经确认按下。结果依然不稳定。实际项目里我通常取15~20ms机械按键和轻触按键都能覆盖。响应慢20ms人眼和手感完全感知不到差异。还有一点如果系统时钟频率很高比如100MHz计数器需要数到接近200万。计数器位数直接定成32位没问题但这会造成不必要的触发器消耗。理论上你可以精确计算宽度像上面代码直接统一用32位教学场景无可厚非但工程上我建议配合$clog2局部参数来压缩位宽。这里不展开知道“计数器宽度取决于最大值”就够了。4. 想要滑动窗口滑动窗口滤波消抖的Verilog写法如果说计数器消抖是“等待者”滑动窗口滤波更像“陪审团”。它不急着在第一时间做决定而是把最近N次采样结果都记录下来只有N次结果全部一致时才宣布达成共识。这种做法在处理连续按键、长按键以及噪声毛刺时有它独特的优势。4.1 滑动窗口的思想连续N次采样一致才算数滑动窗口滤波在很多领域也叫滑窗判决。对应到Verilog实现就是把每次时钟沿采样的按键值不断移动进一个移位寄存器。寄存器里有N位每一位代表一次历史采样结果。如果寄存器里全部是0说明最近N次采样都是按下状态全部是1说明最近N次都是释放状态。只要出现任何一位不同就说明电平还没稳定输出保持原来的状态不变。举个例子如果你用1kHz的采样频率也就是每隔1ms采一次窗口长度取20那么得到一个“全部一致”的判决至少需要20ms这跟计数器消抖的20ms延时是等价的。但因为窗口保留了中间过程它对单次毛刺的容忍度更强一个宽度只有1ms的异常毛刺只会污染窗口里的1位另外19位还是正常状态输出绝不会立刻翻转。4.2 滑动窗口滤波的Verilog代码与采样时钟生成50MHz时钟下滑动寄存器宽度不建议直接做到100万那样会占掉大量触发器。更合理的做法是先用一个分频计数器产生低频率的采样使能信号再在使能到来时把key_sync移进窗口。module key_debounce_sliding #( parameter CLK_FREQ 50_000_000, parameter SAMPLE_HZ 1_000, // 每次采样间隔1ms parameter WINDOW_LEN 20 // 20ms窗口 )( input wire clk, input wire rst_n, input wire key_btn, output reg key_clean ); localparam CNT_MAX (CLK_FREQ / SAMPLE_HZ) - 1; localparam DIV_W $clog2(CNT_MAX 1); reg [DIV_W-1:0] div_cnt; reg sample_en; reg key_meta; reg key_sync; reg [WINDOW_LEN-1:0] history; // 产生1kHz采样使能 always (posedge clk or negedge rst_n) begin if (!rst_n) begin div_cnt 0; sample_en 1b0; end else if (div_cnt CNT_MAX) begin div_cnt 0; sample_en 1b1; end else begin div_cnt div_cnt 1b1; sample_en 1b0; end end // 输入同步 always (posedge clk or negedge rst_n) begin if (!rst_n) begin key_meta 1b1; key_sync 1b1; end else begin key_meta key_btn; key_sync key_meta; end end // 滑动窗口判决 always (posedge clk or negedge rst_n) begin if (!rst_n) begin history {WINDOW_LEN{1b1}}; key_clean 1b1; end else if (sample_en) begin history {history[WINDOW_LEN-2:0], key_sync}; if (history {WINDOW_LEN{1b0}}) begin key_clean 1b0; end else if (history {WINDOW_LEN{1b1}}) begin key_clean 1b1; end // 其他情况保持 key_clean 不变 end end endmodule注意看历史窗口的状态更新逻辑每次sample_en到来时把key_sync从最低位移入最高位被丢弃。如果窗口里全部是0输出确认按下全部是1输出确认释放。窗口里出现高低混合时不更新输出。为什么这个方案更适合“人民陪审团”因为它的输出只在极端“铁证”下才变化。即使中间夹杂一两个异常采样只要不是连续20个都异常输出就不会被带偏。这在按键旁边有电机、继电器等干扰源的真实硬件上很实用。4.3 两种方案对比什么时候用计数器什么时候用滑动窗口我在不同课上讲这两个方案时很多学员会问既然滑动窗口看起来更抗噪声为什么不全校用滑动窗口我的回答是看资源和需求。下表帮大家做个直观对比方案抗抖能力响应延时寄存器资源适用场景计数器延时消抖中等偏强固定20ms计数器同步寄存器极少常规按键、MCU与FPGA通用滑动窗口滤波强至少N次采样周期WINDOW_LEN位寄存器强干扰环境、连续快速按键资源上滑动窗口明显更费寄存器因为要保存N位历史记录。不过如果窗口长度只有20位这点开销几乎可以忽略。真正要注意的是滑动窗口的响应延时是固定的“窗口长度乘以采样周期”而且必须保证采样频率与抖动窗口匹配。如果你按50MHz直接做20位窗口那窗口实际覆盖的时间只有20个时钟周期也就是400ns根本覆盖不了机械抖动。所以前面代码里特意加了采样分频先让采样使能变成1kHz再对1ms一次的历史采样做20次判决。这里容易算错建议自己在代码旁边留一行注释// 1kHz采样 * 20窗口 20ms判决时间防止下次改频率时踩坑。5. 把按键消抖模块接进工程模块例化与边沿提取代码能仿真通过还只是开始。真正要把按键消抖模块用到工程里还得处理两件事一是模块怎么接进顶层二是消抖输出之后如何进一步提取“按下沿”和“释放沿”。5.1 模块例化端口和参数一个都不能漏Verilog里的模块例化相当于把一块写好逻辑的芯片“贴”到主板上。下面是一个典型的顶层示例把key_debounce_counter例化到顶层模块里module top_led_control #( parameter CLK_FREQ 50_000_000 )( input wire clk, input wire rst_n, input wire key_btn, output reg led_out ); wire key_clean; key_debounce_counter #( .CLK_FREQ (CLK_FREQ), .DB_TIME_MS(20) ) u_key_debounce ( .clk (clk), .rst_n (rst_n), .key_btn (key_btn), .key_clean (key_clean) ); // 这里可以继续把 key_clean 接入后续逻辑 endmodule注意例化时端口使用.clk(clk)这种显式连接方式别用位置连接。显式连接更清晰也避免参数顺序调整时接错线。参数传递用#(...)这样才能灵活适配不同时钟频率的工程。很多项目里按键不止一个。多按键时最简单可靠的做法就是“一个按键例化一个消抖模块”。比如8个按键就例化8次。每个键的抖动相位互不相同独立处理最自然。不要脑补“一个模块通过多路选择去轮流服务所有按键”那会让你在按键响应实时性和资源复用之间陷入不必要的复杂度。5.2 从消抖输出提取按下沿消抖模块把key_clean变成了干净稳定的电平信号但很多业务逻辑需要的是“按下沿”而非电平本身。比如每按一次就让计数器加1如果直接拿key_clean的下降沿做触发正常因为key_clean是一整个按下周期都保持有效电平。我们得构建一个边沿检测模块把key_clean打一拍得到key_clean_d然后用组合逻辑组合出边沿。module edge_detect ( input wire clk, input wire rst_n, input wire key_clean, output wire neg_edge, // 下降沿, 也就是按下瞬间, 针对低电平有效 output wire pos_edge // 上升沿, 释放瞬间 ); reg key_clean_d; always (posedge clk or negedge rst_n) begin if (!rst_n) begin key_clean_d 1b1; end else begin key_clean_d key_clean; end end assign neg_edge key_clean_d ~key_clean; assign pos_edge ~key_clean_d key_clean; endmodule为什么用“当前比上一拍”的方式而不是直接看key_clean因为电平看的是状态沿看的是变化。key_clean从1变成0时neg_edge只会拉高一个周期。如果你在业务逻辑里用if (key_clean 1b0)判断那整个按下期间条件都成立计数器的加一操作会被反复执行。用neg_edge才能做到“按一下只触发一次”。5.3 带边沿检测的完整计数器工程把上面模块串起来就能做成一个真正的“按下一次加一”的模块module key_counter #( parameter CLK_FREQ 50_000_000, parameter WIDTH 8 )( input wire clk, input wire rst_n, input wire key_btn, output reg [WIDTH-1:0] value ); wire key_clean; wire neg_edge; key_debounce_counter #( .CLK_FREQ (CLK_FREQ), .DB_TIME_MS(20) ) u_key_debounce ( .clk (clk), .rst_n (rst_n), .key_btn (key_btn), .key_clean (key_clean) ); edge_detect u_edge_detect ( .clk (clk), .rst_n (rst_n), .key_clean (key_clean), .neg_edge (neg_edge), .pos_edge () ); always (posedge clk or negedge rst_n) begin if (!rst_n) begin value 0; end else if (neg_edge) begin value value 1b1; end end endmodule这个例子基本上覆盖了键盘输入类小项目的标准流程同步、消抖、边沿提取、业务逻辑。后面的业务逻辑不管是驱动数码管、控制LED还是修改状态机都建立在neg_edge或者pos_edge这个干净的单周期脉冲上。6. 实测中容易翻车的几个细节很多同学抄完代码仿真也看到漂亮的波形一到上板就冒出各种怪问题。这节我专门把那些“代码看不出问题硬件上却翻车”的细节拎出来讲。6.1 复位信号处理不当波形全乱按键消抖模块里的复位都是negedge rst_n意思是低电平有效复位。第一次跑仿真时如果你是rst_n 0; #100; rst_n 1;没问题。但有些板子的按键复位带有RC延时上升沿不陡可能在数毫秒内爬升到高电平。这段时间里寄存器的复位释放点不确定某些寄存器已经离开复位某些还在复位输出就可能出现不可预测的中间状态。考虑到这个问题我的习惯是在复位释放后再加几拍的“伪复位”比如在测试平台上先复位100ns然后保持高电平再等100ns才开始真正操作。实际工程里如果复位质量不好最好用PLL锁定信号或专用复位管理电路来处理不要指望寄存器靠自己的容错撑过去。6.2 让按键信号直接当时钟是最隐蔽的坑我见过不止一个初学者写出这样的代码always (posedge key_clean or negedge rst_n) begin // ... end这种写法把按键消抖后的key_clean当成时钟来用。仿真没问题一进综合就会出现一堆警告而且时序约束根本没法做。为什么因为key_clean是一个数据信号它上面有延迟、有毛刺风险跨时钟域后你不知道什么时候触发FPGA布局布线工具也无法对它做时钟约束。正确做法永远是所有逻辑统一用系统clk按键信号只是普通输入用同步时序逻辑判断它的边沿或电平。这个原则在整个数字电路设计里都成立。还有一个更隐蔽的版本为了“消抖”把按键信号先接组合逻辑再接时钟使能。组合逻辑的输出在几个纳秒内会出现多次翻转照样不干净。所以不仅要避免把按键信号直接接到时钟端口也要避免在重要逻辑里让组合逻辑的毛刺充当触发器使能。6.3 采样频率和消抖窗口的匹配经验最后分享几个我实测下来的经验值。如果你用计数器方案直接设20ms大多数轻触按键和机械按键都能覆盖。有些高品质按键抖动窗口能到20ms以上如果你发现计数偶尔还是不准把时间调到30ms大概率能解决。但不要无线加长因为从按下到响应输出的延时也会跟着增加。如果你用滑动窗口方案窗口长度覆盖20ms的前提下窗口长度本身代表“容错点数”。比如1kHz采样、窗口20就意味着允许出现最多19ms的单个毛刺异常。如果你测试环境里按键旁边有继电器干扰毛刺比较密集可以窗口不动、提高采样频率到2kHz这样窗口覆盖时间变成10ms可能偏短要么同步把窗口加到40保持20ms覆盖。注意这两个参数是乘法关系先明确“总判决时间”再回头定“采样频率”和“窗口长度”。还有一点如果要设计“长按重复触发”或者“双击识别”在消抖之后还要额外增加对应的定时判断逻辑而不是靠缩短消抖时间硬扛。我曾经为了做出“双击”效果把消抖窗口压到2ms结果双击没做成反而误触发了大量抖动信号。后来老老实实把消抖恢复到20ms再加一段专用于双击检测的窗口逻辑问题才解决。消抖模块的任务是“提供干净状态”后续手势检测是另一个独立功能两者职责最好分开。按键消抖这个模块代码量不大但它几乎是所有输入类数字系统的基础设施。把同步、消抖、边沿检测、参数化配置这一连串动作理清了后面写状态机、写计数器、写简易出租车计费器都会顺手很多。希望这篇详解能帮你少走点弯路。
返回列表