第 08 讲:按键同步与消抖 —— 物理噪声的数字滤波、双级同步与单周期脉冲
课前导读:跨越“物理机械世界”与“纯净数字世界”的深渊
在前几讲中,我们操作的都是芯片内部的确定性信号(计数器、移位寄存器、PWM 比较器)。这些信号由全局 50MHz 时钟统一步调,整齐划一。
但在本讲中,我们将第一次直面真实物理世界的“粗暴入侵”——机械按键(Key Switch)。
初学者往往以为:按下一个按键,电平就是干脆利落地从高(1)跌落到低(0)。
然而现实世界极其残酷!
- 机械开关内部是充满弹性的微型金属弹片。在你的手指按压或松开的瞬间,金属片会在微观层面发生剧烈的碰撞、反弹与多次离合!
- 这种微观反弹持续 5 ~ 20 毫秒(ms)。
- 对于 50MHz 的 FPGA 来说,20ms 是整整 1,000,000(一百万)个时钟周期!
- 如果不做任何处理,你轻轻按一下按键,FPGA 就会认为你以每秒几万次的手速狂按了上百次按钮!原本“按一下开关灯”,会瞬间变成“灯在 20ms 内疯狂暴闪了几十次,最后随机停在开或关”。
本讲将带你深入物理现实,彻底攻破异步信号亚稳态消除(打两拍)、连续平稳计时滤波算法以及工业级单周期脉冲发生技术。
第一章:物理真相——机械弹性抖动与亚稳态危机
1.1 显微镜下的机械抖动(Mechanical Bounce)
我们用高速示波器连接到机械按键的引脚上,捕捉手指按下与松开瞬间的电压波形:
按键未按 (3.3V) ──────┐
│ 【前沿抖动 5~20ms】 【后沿抖动 5~20ms】
│ ┌─┐ ┌──┐ ┌─┐ ┌─┐ ┌──┐ ┌─┐
└──┘ └─┘ └─┘ └─── 稳定按下 (0V) ──────┘ └─┘ └─┘ └─── 恢复 3.3V
│
▼
FPGA 在这 20ms 内采样到上千次 01 跳变!
- 前沿抖动(按下抖动):弹片初次撞击接触点,因机械弹性发生连续回跳,产生密集的脉冲毛刺;
- 稳定导通期:手指彻底压实,电平稳定在 $0\text{V}$;
- 后沿抖动(松开抖动):手指松开,弹簧释放回位,再次产生数毫秒的抖动毛刺。
结论:按键输入不仅按下需要滤波,松开同样必须滤波!
1.2 异步输入的最大杀手:建立/保持时间违例与亚稳态(Metastability)
除了毛刺,还有更隐蔽的致命物理危机:
- 板载的 50MHz 时钟一刻不停地跳变;
- 人类按按键是完全随机的异步事件。按下的电平跳变边缘,极大概率正好撞在某个时钟上升沿的采样窗口内!
- 违规后果:触发器输入电压未能提前稳定(违背建立时间 $t_{\text{su}}$ 或保持时间 $t_h$),触发器内部的两个背对背反相器将陷入平衡木中间状态,输出电平浮动在 $1.5\text{V}$ 的不定态
x,并在电路内部引发致命震荡。
治病良方:双触发器级联(工业界俗称“打两拍”)
外部脏信号 key_n ──► [ D 触发器 1 ] ──(中间信号 key_sync_ff1)──► [ D 触发器 2 ] ──► 安全干净信号 key_sync_ff2
(完全异步随机) ▲ ▲
clk clk
- 第 1 级触发器:正面迎接异步信号冲击。即使它不幸陷入亚稳态,在经历一个完整时钟周期(20ns)后,亚稳态绝大概率会跌落稳定为 0 或 1;
- 第 2 级触发器:采样第 1 级的输出,此时信号已经恢复纯净,彻底消除了亚稳态传导风险!
第二章:key_debounce.v 消抖核心驱动源码解剖
本模块负责在后台把带有毛刺的外部按键信号,清洗为一个极其平稳的稳定电平 key_stable_n,以及一个仅持续 1 个时钟周期的脉冲信号 key_pressed。
module key_debounce #(
parameter integer DEBOUNCE_CYCLES = 1_000_000 // 50MHz主频下,100万拍 = 20ms
)(
input wire clk, // 50MHz 系统主时钟
input wire rst_n, // 系统复位信号 (低有效)
input wire key_n, // 外部机械按键引脚输入 (脏信号,低有效)
output reg key_pressed, // 报信人:确认按下时,射出 1 个周期的单脉冲
output reg key_stable_n // 官方认证:滤波后极其平稳的最终按键状态
);
reg key_sync_ff1; // 双触发器打两拍寄存器 1
reg key_sync_ff2; // 双触发器打两拍寄存器 2
reg [31:0] stable_cnt; // 20ms 连续平稳倒计时秒表
// 进程一:双级触发器“打两拍”,消除亚稳态
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
key_sync_ff1 <= 1'b1;
key_sync_ff2 <= 1'b1;
end else begin
key_sync_ff1 <= key_n;
key_sync_ff2 <= key_sync_ff1;
end
end
// 进程二:连续平稳性检验与脉冲发射
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
stable_cnt <= 32'd0;
key_stable_n <= 1'b1; // 默认没按按键 (高电平 1)
key_pressed <= 1'b0; // 默认电铃安静 (0)
end else begin
key_pressed <= 1'b0; // 【门铃弹簧设计】:默认每一拍都把脉冲按在 0
// 分支 1:外来信号和目前认可的稳定状态完全一样 (没动静或回弹了)
if (key_sync_ff2 == key_stable_n) begin
stable_cnt <= 32'd0; // 秒表无情清零!
end
// 分支 2:外来信号保持新状态,且秒表无间断数满了整整 20ms!
else if (stable_cnt == DEBOUNCE_CYCLES - 1) begin
stable_cnt <= 32'd0; // 秒表归位
key_stable_n <= key_sync_ff2; // 官方正式承认新状态!
if (!key_sync_ff2)
key_pressed <= 1'b1; // 若是按下动作,猛敲一下电铃!
end
// 分支 3:外来信号发生改变,但连续平稳时间还没满 20ms
else begin
stable_cnt <= stable_cnt + 1'b1; // 秒表继续累加验证
end
end
end
endmodule
第三章:变量角色扮演——“三人保安室”现场推演
很多同学在面对这几个变量时脑子容易打结。我们把芯片内部的消抖逻辑还原为一个活生生的**“公司保安室”故事**:
| 变量名 | 真实身份 | 他的职责与性格 |
|---|---|---|
key_stable_n |
【保安室的官方黑板】 (滤波认定的稳定值) |
上面只记录当前被官方正式认可的门禁状态:1(门关着/没按)或 0(门开着/按下了)。他极其沉稳甚至“冷血”,绝不听信任何片面的风吹草动,只有保安彻底核验无误后才改写黑板。 |
stable_cnt |
【手拿秒表的严格保安】 (连续平稳计时器) |
他负责死死盯着大门(key_sync_ff2)。一旦发现大门状态和黑板写的不一样,他就按下秒表开始计时 20 毫秒。 如果中途大门有哪怕一丁点晃动弹回,他立刻把秒表清零归位,前功尽弃重新数! |
key_pressed |
【挂在墙上的回弹电铃】 (单周期触发脉冲) |
他的职责是向后级业务电路报信。 只有在确认按键按下的那一瞬间,“叮”敲响一声(维持仅仅 1 个时钟周期的 1),下一拍立刻自动闭嘴熄灭(变回 0)。 |
3.1 核心攻坚:为什么 else 一上来必须写 key_pressed <= 1'b0;?
请看进程二的这行代码:
end else begin
key_pressed <= 1'b0; // <--- 为什么每次都要先把它写成 0?!
if (key_sync_ff2 == key_stable_n) begin
...
这是硬件设计中极其经典的**“回弹门铃设计”**!
现实逻辑思考:
如果你用手指死死按住一个按键,按了整整 5 秒钟不放。
- 你希望你的数码设备怎么反应?
- 是希望它只响应一次操作(例如手机按一下音量加,音量上升 1 格)?
- 还是希望它在这 5 秒钟里连续执行上亿次操作,音量瞬间爆满死机?
- 答案显而易见:我们只要按下去的那一瞬间产生一个动作,后面按住不放的时间应该全部静默!
这就是这行代码的绝妙之处:
- 默认态:每一个 50MHz 时钟节拍到来,代码第一行就是强制把门铃往下压(设为
0); - 触发态:只有当 20ms 消抖结束、正式确认按下的那一拍,底下的
key_pressed <= 1'b1;才会推翻默认值,把门铃敲响(变成1); - 回弹态:仅仅过了 20 纳秒(下一个时钟节拍到来),由于底下的分支条件已经不再满足,开头的默认赋值重新掌权,电铃在下一个上升沿瞬间自动落回
0! - 效果:无论你手按了 1 秒、10 秒还是一小时,
key_pressed在时间轴上严格只产生宽度仅为 20ns 的单脉冲!
第四章:微观时钟拍数演练——放慢时间看抗噪过程
为了让你彻底看清消抖算法是怎么“吞噬”弹簧毛刺的,我们假设消抖目标是连续平稳 3 拍(设 DEBOUNCE_CYCLES = 3),来看看手指按键全过程中每一个时钟周期内部变量的真值表:
| 时钟拍数 | 手指物理动作 | 外界按键输入 key_n |
经过两拍安检 key_sync_ff2 |
黑板状态 key_stable_n |
秒表 stable_cnt 怎么变?为什么? |
报信电铃 key_pressed |
状态说明 |
|---|---|---|---|---|---|---|---|
| 第 1 拍 | 手指悬空没按 | 1 | 1 | 1 | 0(外面和黑板一样是 1,没变化,秒表清零) | 0 | 静默期,无事件 |
| 第 2 拍 | 手指刚碰上弹片 | 0 | 0 | 1 | 0 \to 1(外面变成了 0,和黑板不一样!秒表开始跑:1!) | 0 | 捕捉到异动,启动计时 |
| 第 3 拍 | 弹片机械反弹! | 1 | 1 | 1 | 1 \to 0(又弹回 1 了!和黑板又一样了!秒表被无情清零!) | 0 | 杂波被成功粉碎吸收! |
| 第 4 拍 | 手指二次撞击压下 | 0 | 0 | 1 | 0 \to 1(重新变 0,秒表重新起跑:1!) | 0 | 重新启动计时 |
| 第 5 拍 | 手指持续死死按住 | 0 | 0 | 1 | 1 \to 2(持续为 0,依然不一致,秒表累加:2!) | 0 | 继续累加验证 |
| 第 6 拍 (奇迹瞬间!) |
手指持续按住 | 0 | 0 | 1 $\to$ 0 | 2 \to 0(连续稳稳数满 3 拍!官方正式认可!黑板写上 0!) | 叮!变成 1! | 成功确认按下! 射出单周期脉冲! |
| 第 7 拍 | 手指依然死按着 | 0 | 0 | 0 | 0(外面是 0,黑板也是 0,恢复一致,秒表停转) | 自动落回 0! | 门铃自动回弹熄灭! |
| 第 8 拍 | 手指依然死按着 | 0 | 0 | 0 | 0(信号与黑板一致,秒表保持 0) | 0 | 哪怕按一万年,电铃绝不再响 |
核心机制总结:
看到第 3 拍的妙处了吗?
只要外界信号在抖动中弹回原状,key_sync_ff2 == key_stable_n 就会瞬间成立,秒表立刻被踹回 0!
这意味着:外界信号必须纹丝不动、连续不打嗝地保持新状态整整 20 毫秒,秒表才有可能数到终点!任何小于 20ms 的机械毛刺,在硬件面前都会被无情抹杀!
第五章:顶层系统联动——key_demo_board_top.v 实战演练
现在,我们把写好的消抖模块作为底层驱动,装配进一个顶层开关灯工程中:
module key_demo_board_top (
input wire sys_clk, // 50MHz 时钟
input wire sys_rst_n, // 复位按键
input wire key_n, // 机械操作按键
output wire [3:0] led // 4 颗 LED 输出
);
wire key_pressed; // 导线:接消抖模块射出的电铃单脉冲
wire key_stable_n; // 导线:接消抖后的稳定电平
reg led_state; // 1 位寄存器:记住灯当前是开(1)还是关(0)
// 1. 安装消抖安全卫士
key_debounce #(
.DEBOUNCE_CYCLES(1_000_000) // 设定 20ms 倒计时门槛
) u_key_debounce (
.clk (sys_clk),
.rst_n (sys_rst_n),
.key_n (key_n), // 接收外部带有毛刺的脏信号
.key_pressed (key_pressed), // 吐出纯净的单脉冲
.key_stable_n(key_stable_n)
);
// 2. 业务核心:按一下翻转一次
always @(posedge sys_clk or negedge sys_rst_n) begin
if (!sys_rst_n)
led_state <= 1'b0; // 复位关灯
else if (key_pressed)
led_state <= ~led_state; // 听到铃声瞬间翻转:开变关,关变开
end
// 3. 输出连线:仅第 0 颗灯受控,前 3 颗强制常灭
assign led = {3'b000, led_state};
endmodule
5.1 整个电路的现场互动体验:
- 未按按键时:
key_pressed一直是 0,led_state稳固保持当前值不动; - 手指按下一瞬间:消抖模块吃掉前沿毛刺,在第 20ms 的那一瞬时射出一个 20ns 宽度的
key_pressed = 1; - 顶层响应:
always块在时钟沿捕捉到这个高电平,立即把led_state取反(灭变亮,或亮变灭); - 手指持续按着不松:
key_pressed已经落回 0,灯保持新状态稳定发光,绝无闪烁; - 手指松开按键:消抖模块吃掉后沿松开毛刺,黑板改写回 1,由于
!key_sync_ff2为假,松开时不会触发key_pressed,灯依然保持现状! - 再次按下:灯再次翻转熄灭。按一下、变一次,手感如同机械开关般清脆可靠!
第六章:工程实操常见翻车排错指南
1. 故障:按键按下去,LED 依然会有概率狂跳两次
- 排查诊断:
检查你的消抖时间参数DEBOUNCE_CYCLES是否设得太小了!有的劣质老旧按键弹性老化严重,抖动时间长达 $15\sim 18\text{ms}$。如果代码只设了 5ms(250,000 拍),毛刺还没震荡结束,计时器就已经数满认定了,导致二次误判。工业界标准安全消抖时间一律建议设为 $20\text{ms}$。
2. 故障:按键无论怎么按,板子完全毫无反应
- 排查诊断:
- 检查 ADC 管脚约束文件中,该按键引脚是否开启了内部弱上拉:
PULLTYPE = PULLUP;。如果没开上拉且外部没有硬件上拉电阻,引脚未按时悬空,无法形成确定的 $1 \to 0$ 跳变; - 确认开发板原理图按键类型:有的板子按键按下是给高电平(Active-High)。如果是高有效,代码中打两拍的初始值、比较条件以及
if (!key_sync_ff2)全部需要反转!
- 检查 ADC 管脚约束文件中,该按键引脚是否开启了内部弱上拉:
本讲核心总结
- 机械抖动是不可逾越的物理现实:任何机械触点必有 5~20ms 毛刺,直接接入电路必死无疑。
- 双级触发器(打两拍)灭绝亚稳态:跨越异步世界的必备安检门。
- “连续平稳计时法”的精髓:只要有毛刺反弹,计时器立刻清零重来;唯有一动不动坚持满 20ms,才承认状态改变。
- “门铃弹簧”单周期脉冲范式:利用默认赋值
key_pressed <= 1'b0,打造仅维持一拍的自回弹使能信号,是驱动后级状态机翻转的绝对标准。