ARTICLE DETAIL

资讯详情

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

Verilog按键消抖模块设计与仿真:从原理到工程实践

Verilog按键消抖模块设计与仿真:从原理到工程实践 如果把 Verilog 常用模块排一个学习顺序按键消抖大概率排在第二位仅次于流水灯。但越是看起来基础的模块反而更容易翻车。我早先用两层状态机写按键计数的时候就栽过大跟头逻辑明明“应该没问题”上板后计数器一次跳好几格七段数码管像疯了一样乱闪。查到最后才发现我根本没有认真处理机械按键的物理抖动也没有做同步直接把按键信号喂给了状态机。这篇文章就把按键消抖模块从原理到代码、从仿真到工程落地的完整链路拆开讲清楚顺便把我踩过的一些坑也一起交代了希望能让新手少走两个晚上弯路。1. 按键抖动的物理根源从一次“按下”变成一长串脉冲说起1.1 抖动波形与时间尺度机械按键的内部结构说白了就是两片金属触点。按下时弹片发生形变触点接触后并不会一次到位而是会产生几次甚至几十次反弹这个现象叫接触抖动。你想象一下用手指快速敲桌子手指骨节会连续弹好几下按键触点的弹跳就是这个性质只是频率更高、幅度更大。在示波器上看一次按下动作的完整波形大概长这样按下瞬间电平在高低之间快速切换持续几毫秒后稳定稳定一段时间后松开时又来一轮抖动最后回到初始电平。这就是“按下抖动—稳定—释放抖动”三段式特征。机械开关的抖动时间通常集中在 5ms 到 20ms便宜的轻触开关可能到 30ms 以上。如果你的系统时钟是 50MHz周期只有 20ns那么在这段抖动窗口内数字逻辑至少能感受到几十万个采样周期稍微处理不当就会把一次按键理解成几十次触发。1.2 硬件消抖的常用套路与局限既然物理世界有抖动最容易想到的方式就是在电路上做文章。经典做法是 RC 低通滤波在按键触点并联一个电阻和电容让电容给信号“抹平”再用施密特触发器整形。RC 时间常数选在 10ms 左右按下时电容缓慢充放电抖动部分的高频毛刺被滤掉输出就干净了。这个方案能用但有几个现实短板。第一RC 的参数需要根据按键类型和驱动能力调整太大会让响应变慢太小又滤不干净。第二在板子面积紧张、器件成本受限的时候每个按键都加电阻电容显然不划算。第三如果按键通过长线从外部面板引入线上耦合进来的噪声也会干扰消抖效果。所以硬件消抖更多是“锦上添花”而不是可编程逻辑里的推荐方案。1.3 软件/逻辑消抖为什么是主流选择FPGA 本身是硬逻辑按键数量动辄几个、十几个硬件消抖的成本会线性增长。而逻辑消抖只需要在每个按键信号后面接一段寄存器加计数器资源占用几乎可以忽略。更重要的是逻辑消抖的时序是可控的、可仿真的甚至可以在同一个工程里适配不同按键的抖动特性。因此把按键消抖做成一个通用 Verilog 模块在现代硬件设计中几乎是标配。2. 常见消抖算法对比延时、采样、状态机各有什么坑2.1 延时法让渡判断时机却不能应对不规则抖动“延时法”是很多软件出身的人最先想到的思路检测到按键边沿后延时 10ms再读一次电平如果和边沿方向一致就认为按键生效。这个方案看起来直白但有个致命弱点如果最后一次抖动恰好发生在延时结束之后你就会读到错误电平导致一次按下被误判。有人会优化成多次采样、少数服从多数但这会儿变成另一个复杂度问题。而且延时法在实现上往往靠阻塞式延时或 for 循环空转这在时序逻辑里很不友好会让主状态机卡住。我用过一阵子后就放弃了。2.2 滑动窗口/连续采样统计意义最好代价是额外存储还有一种思路是做滑动窗口滤波每次时钟沿把按键电平打进一个 N 位移位寄存器只有当寄存器里所有位都是 0 或都是 1 时才认为按键稳定。这个方案本质上是一个数字低通滤波器统计学意义最好对随机毛刺的抑制能力强。但它的缺点是资源消耗偏大。要滤掉 10ms 抖动、50MHz 采样N 得是 500000 位用移位寄存器实现明显不现实。实际中滑动窗口滤波多用于 N 很小、抖动很短的场景或者用在 UART 接收管脚的毛刺过滤上。按键消抖这种长窗口场景有更省资源的等价做法。2.3 边沿触发重新计时我推荐的主方案与理由我最终采用的方案可以叫“边沿触发重新计时”核心逻辑只有一句话当同步后的按键电平和当前稳定状态不一致时计数器开始累加如果中途又发生了任何一次反跳计数器立即清空重新计时只有连续稳定满一个消抖窗口才更新稳定状态。这个方案不需要冗长的移位寄存器也不需要状态机只需要一个计数器和两个输出寄存器。它和状态机消抖在本质上是一回事行为也等效但代码量更少更容易让学生理解“连续稳定”这个核心概念。另外这个方案的另一个好处是天然不具备“重复触发”问题消抖完成后只要按键电平没有再次变化计数器始终为 0不会再产生额外的脉冲输出。正好符合“按一次只生效一次”的预期。3. 可综合的 key_debounce 模块代码拆解3.1 端口与参数定义先想清楚输出是脉冲还是电平在写代码之前得先明确模块的输出语义。我习惯把模块设计成两个输出key_flag消抖完成时产生一个时钟周期的高电平脉冲用来告诉外部逻辑“状态已经稳定且发生了切换”。key_value消抖后的稳定电平默认高电平表示未按下低电平表示按下。外部逻辑要判断“按下事件”就用if (key_flag !key_value)要判断“释放事件”就用if (key_flag key_value)。参数方面我保留了DEBOUNCE_CNT作为消抖所需的连续采样周期数。默认值 500_000 对应 50MHz 时钟下的 10ms这是工程上比较稳妥的起点。下面是完整的模块代码后面会逐段解释。module key_debounce #( parameter integer DEBOUNCE_CNT 500_000 )( input wire clk, input wire rst_n, input wire key_in, output reg key_flag, output reg key_value ); localparam integer CNT_WIDTH $clog2(DEBOUNCE_CNT 1); reg [1:0] key_in_r; reg [CNT_WIDTH-1:0] cnt; wire key_in_sync; assign key_in_sync key_in_r[1]; // 两级同步器 always (posedge clk or negedge rst_n) begin if (!rst_n) key_in_r 2b11; else key_in_r {key_in_r[0], key_in}; end // 消抖计数与输出 always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt d0; key_flag 1b0; key_value 1b1; end else begin key_flag 1b0; if (key_in_sync ! key_value) begin if (cnt DEBOUNCE_CNT - 1) begin cnt d0; key_flag 1b1; key_value key_in_sync; end else begin cnt cnt 1b1; end end else begin cnt d0; end end end endmodule3.2 两级同步器为什么消抖前必须先做同步按键信号来自 FPGA 外部和系统主时钟完全异步。一个异步信号直接进 always 块采样随时可能违反触发器的建立时间和保持时间导致输出进入亚稳态。亚稳态的后果是寄存器可能在一段时间内输出既不是 0 也不是 1甚至在不同寄存器间传播成不一致的值对设计来说就是一颗定时炸弹。解决方法是先用两级寄存器打两拍也就是所谓的“两级同步器”。第一级把异步信号采样到时钟域里第二级给第一级一个时钟周期的稳定时间。虽然第二级的输出仍然可能有很小的概率出现亚稳态但两级同步器已经把发生概率压低到可以忽略的程度。对按键这种慢信号来说两级绰绰有余。复位时我把同步器赋成2b11原因是默认未按下按键输入由上拉电阻拉高。3.3 计数主体用“状态不一致则计数一致则清空”完成消抖消抖的核心代码在第二个 always 块里。每次时钟沿key_flag先默认清零避免它一直保持高电平。然后判断key_in_sync与key_value是否一致不一致说明按键状态和当前稳定状态出现了差异进入“观察期”。计数器加 1当计数到DEBOUNCE_CNT - 1时说明这个差异状态已经连续稳定了整整DEBOUNCE_CNT个时钟周期于是把key_value更新为key_in_sync同时拉高key_flag。一致说明还是稳定状态计数器清空不产生任何事件。这里有一点容易绕我比较的是“不同电平”而不是“上升沿”或“下降沿”。这样写的好处是可以把按下和释放两种抖动用同一套逻辑处理不需要单独检测两个边沿。只要中间出现过一次反跳电平就会短暂回到和key_value一致的状态计数器立刻清空前面积累的时间归零下一次抖动再来又从零开始。这就实现了“任何抖动都重新计时”的效果。3.4 关于计数器位宽与 $clog2 的使用经验localparam integer CNT_WIDTH $clog2(DEBOUNCE_CNT 1);这句是自动计算计数器位宽的关键。$clog2是求以 2 为底的对数并向上取整。默认 500_000 个周期时$clog2(500001)等于 19所以计数器位宽是 19 位。如果你用的综合工具版本较老不支持$clog2可以手动改成parameter integer CNT_WIDTH 19;但这时记住只要修改了DEBOUNCE_CNT就要同步修改位宽否则会出现计数溢出的隐患。用$clog2是为了减少这种人工维护成本Vivado 和 Icarus Verilog 都支持放心用。4. 参数推导与边界条件消抖时间、时钟频率、重复按键速度4.1 消抖时间的选择依据消抖时间不是越大越好。太小了滤不掉一些慢性子按键的抖动太大了会导致按键响应迟钝操作手感很差。机械按键的抖动大多在 5ms 到 20ms所以我一般取 10ms 作为默认值。如果用的按键本身很便宜或者供应商生产一致性差我会直接拉到 20ms 保平安。还要考虑一个场景人快速连按两个不同按键时两次动作间隔通常不小于 50ms。所以 10ms 的消抖窗口并不会让人感觉“吞键”也无须担心快速操作被拦掉。如果你做的是音乐节奏游戏一类对时间敏感的应用可以把消抖时间压到 5ms但前提是你对按键的物理特性做过实测。4.2 计数周期数和位宽的推算在码参数之前建议先算清楚。消抖周期数DEBOUNCE_CNT的计算公式是DEBOUNCE_CNT 时钟频率 × 消抖时间。比如 50MHz 时钟、10ms 消抖DEBOUNCE_CNT 50_000_000 × 0.01 500_000。再算位宽。需要能表示 500000 这个数2 的 19 次方是 524288刚好够所以位宽取 19 位。不同时钟频率下的参数可以参考下表时钟频率消抖时间计数周期数计数器位宽12MHz10ms120_00017 位25MHz10ms250_00018 位50MHz10ms500_00019 位100MHz10ms1_000_00020 位如果改消抖时间是 20ms把周期数翻倍即可位宽大概率要再加一位建议重新用$clog2算一遍别凭感觉写。4.3 小心长按与重复触发场景有些需求是“长按 1 秒后连续生效”这和单纯的按键消抖是两个问题。我的模块在每次电平稳定切换时只产生一个key_flag脉冲长按过程中不会再产生新脉冲。要做长按重复触发应该在消抖后的key_value上再接一个长按定时器而不是在消抖模块里加连续触发逻辑。强行把两个功能揉在一起会让模块的通用性变差后面再想复用到别的项目就难受了。5. 仿真验证用 Icarus Verilog 构建可复现的按键模型5.1 Testbench 的按键抖动模型设计写仿真激励时最忌讳的就是把key_in当成理想的阶跃信号一个 0 直接拉下去完事那样只能让消抖逻辑“空转”根本验证不了关键场景。我习惯在 Testbench 里给按键动作加上毛刺模拟真实的抖动按下后弹起一下再弹起一下最后才稳定。为了不让仿真跑太久仿真时可以把DEBOUNCE_CNT从默认的 500_000 改小比如设成 10_000在 50MHz 下就是 200us。这样做只是缩短时间尺度并不会改变逻辑的验证效果。下面是一份可直接运行的 Testbenchtimescale 1ns/1ps module tb_key_debounce; reg clk; reg rst_n; reg key_in; wire key_flag; wire key_value; key_debounce #( .DEBOUNCE_CNT(10_000) // 50MHz下对应200us仿真加速用 ) dut ( .clk (clk), .rst_n (rst_n), .key_in (key_in), .key_flag (key_flag), .key_value(key_value) ); always #10 clk ~clk; // 20ns周期 initial begin clk 0; rst_n 0; key_in 1; #100; rst_n 1; #100; // 按下先压下一段再弹起再压回 key_in 0; #50_000; key_in 1; #30_000; key_in 0; #500_000; // 释放同样带毛刺 key_in 1; #40_000; key_in 0; #20_000; key_in 1; #500_000; $finish; end initial begin $dumpfile(key_debounce.vcd); $dumpvars(0, tb_key_debounce); end always (posedge clk) begin if (key_flag) $display(%t: key_flag pulse, key_value%b, $time, key_value); end endmodule5.2 编译仿真与波形观察在命令行里执行iverilog -o sim.vvp tb_key_debounce.v key_debounce.v vvp sim.vvp仿真结束后会生成key_debounce.vcd用 GTKWave 打开gtkwave key_debounce.vcd观察key_in_sync、cnt、key_flag、key_value这四个信号重点确认两点第一按键输入虽然抖动多次cnt并没有一路加到底而是在反跳位置被清空过这说明“重新计时”逻辑生效了。第二最终key_value在最后一次抖动结束约 200us 后跳变同时key_flag只拉高一个时钟周期。如果key_flag持续很久或者多次出现就要回头查输出逻辑了。5.3 仿真覆盖不到的现实问题仿真能验证逻辑行为但验证不了真实按键的“脾气”。同一批按键抖动时间都可能差出几毫秒。所以上板实测时我建议把DEBOUNCE_CNT从大往小调比如先设 20ms 的量确认功能稳定后再慢慢压小找到不会误触发并且手感也不肉的最小值。整个过程建议用逻辑分析仪或者 ILA 抓真实按键波形这才是最靠谱的数据。6. 工程落地时的注意点低电平有效、多按键、与状态机配合6.1 低电平有效按键的端口极性处理绝大多数开发板的按键电路是低电平有效按键一端接地一端接 FPGA 引脚并通过上拉电阻拉高。没有按下时读到的电平是 1按下时读到 0。我的模块默认就是按这个逻辑设计的所以key_value为 0 代表按下外部判断时写成if (key_flag !key_value)就是“按下事件”。如果你手里的按键是高电平有效直接取反输入也行但更推荐在模块内部增加一个ACTIVE_LOW参数来控制极性保持每次例化时的可读性。工程里我最烦看到“这个信号为什么反着”的疑问所以宁可多写一个参数。6.2 多按键独立例化与矩阵键盘场景几个独立按键各自例化一个key_debounce即可。每个模块的资源都很少例化十个也不心疼。但要注意如果多个按键需要“同时按下”的判定最好把消抖后的key_value都送到同一个主时钟域逻辑里做组合判断不要分别产生脉冲再拼接避免各个按键的key_flag相位不一致。矩阵键盘是另一回事。行列扫描需要周期性选中某一行再读列线电平所以不能简单地把每个列线都接一个消抖模块。更合理的做法是对扫描后的列信号做消抖或者把扫描频率放慢在每行稳定选中后再做判断。这时候消抖模块的“通用性”就要让位给更上层的扫描状态机需要单独设计。6.3 消抖后的信号不能直接当时钟用我在多个项目里见过类似的坑有人图省事把消抖后的key_value直接接到另一个模块的always (posedge key_value)上想让按键上升沿当触发时钟。这种设计会引入新的跨时钟域问题后患无穷。正确的做法是保持全局单时钟把消抖后的key_value作为普通数据信号在主时钟域里再打一拍检测边沿。这套思路和 UART 接收里的过采样滤波也是同一个道理认真理解一次按键消抖后面写 SPART 收发、写 FIFO 控制逻辑时都会觉得这些模块的底层套路其实非常相似。最后再分享一个我自己的习惯无论开发板的按键硬件上是不是已经有 RC 滤波我接到 FPGA 里的按键信号都会先过一级同步器再消抖。同步器只占两三个触发器却能挡住大量棘手的亚稳态问题。按键消抖这个模块虽然小但它算是可编程逻辑里“慢信号进快时钟域”的典型样本真正吃透了后面很多模块的大门也就打开了。
返回列表