3、基于状态机的按键消抖设计:三段式状态机实现20ms延时消抖,仿真验证
按键消抖,说白了就是跟物理世界的「抖动」做斗争。你按下一个按键,金属触点不会乖乖地一下接通,它会弹跳几次,产生一串毛刺信号。如果不处理,FPGA 可能会误判成你按了好几次。
我个人习惯用状态机来做消抖。为什么?因为状态机逻辑清晰,可读性强,而且很容易移植到不同的项目里。今天我们就用三段式状态机,实现一个经典的 20ms 延时消抖方案。
3.1 按键抖动的本质
先看看抖动长什么样。按键按下时,信号会在高低电平之间快速跳变,持续大约 5-20ms。这段「不稳定期」就是抖动。
我曾经在一个工业控制项目里吃过亏。按键直接连到 FPGA 的输入,没做消抖。结果操作员按一次启动键,设备连续启动了三次。嗯,从那以后,我所有的按键输入都老老实实加了消抖模块。
核心思路:检测到按键电平变化后,启动一个 20ms 的计数器。计数器溢出时,再采样一次电平。如果电平稳定,就输出有效信号。说白了就是「等一等,再确认」。
3.2 三段式状态机结构
三段式状态机,就是把状态机的三个部分分开写:
- 第一段:状态跳转(时序逻辑)
- 第二段:次态逻辑(组合逻辑)
- 第三段:输出逻辑(时序逻辑或组合逻辑)
你想想看,这样写有什么好处?第一,代码结构清晰,维护起来方便。第二,综合工具容易优化。第三,仿真的时候调试也简单。
我设计的按键消抖状态机,包含四个状态:
| 状态 | 含义 | 说明 |
|---|---|---|
| IDLE | 空闲态 | 等待按键按下,输出低电平 |
| PRESS_DETECT | 按下检测 | 检测到低电平,启动 20ms 计数器 |
| PRESS_CONFIRM | 按下确认 | 计数器溢出,确认按键按下,输出高电平 |
| RELEASE_DETECT | 释放检测 | 检测到高电平,等待释放完成 |
小技巧:我习惯把状态编码用 localparam 定义在模块头部,这样修改状态编码时,只需要改一处。
3.3 核心代码实现
下面给出完整的三段式状态机代码。注意看,每一段的分工非常明确。
module key_debounce (
input clk, // 系统时钟,假设 50MHz
input rst_n, // 异步复位,低有效
input key_in, // 按键输入,按下为低电平
output reg key_out // 消抖后输出,按下为高电平,持续一个时钟周期
);
// 参数定义
localparam IDLE = 2'b00;
localparam PRESS_DETECT = 2'b01;
localparam PRESS_CONFIRM = 2'b10;
localparam RELEASE_DETECT = 2'b11;
// 20ms 计数器最大值:50MHz * 20ms = 1_000_000
localparam CNT_20MS = 20'd1_000_000;
// 信号定义
reg [1:0] state, next_state;
reg [19:0] cnt;
reg cnt_en;
reg cnt_full;
// 第一段:状态跳转(时序逻辑)
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
state <= IDLE;
else
state <= next_state;
end
// 第二段:次态逻辑(组合逻辑)
always @(*) begin
next_state = state; // 默认保持当前状态
case (state)
IDLE: begin
if (key_in == 1'b0)
next_state = PRESS_DETECT;
end
PRESS_DETECT: begin
if (cnt_full)
next_state = PRESS_CONFIRM;
else if (key_in == 1'b1)
next_state = IDLE; // 抖动期间弹回,重新等待
end
PRESS_CONFIRM: begin
if (key_in == 1'b1)
next_state = RELEASE_DETECT;
end
RELEASE_DETECT: begin
if (cnt_full)
next_state = IDLE;
else if (key_in == 1'b0)
next_state = PRESS_CONFIRM; // 释放过程中又按下
end
default: next_state = IDLE;
endcase
end
// 第三段:输出逻辑(时序逻辑)
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
key_out <= 1'b0;
else if (state == PRESS_CONFIRM && next_state != PRESS_CONFIRM)
key_out <= 1'b1; // 进入 PRESS_CONFIRM 状态时,输出一个脉冲
else
key_out <= 1'b0;
end
// 计数器控制
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
cnt <= 20'd0;
else if (cnt_en) begin
if (cnt == CNT_20MS - 1)
cnt <= 20'd0;
else
cnt <= cnt + 1'b1;
end
else
cnt <= 20'd0;
end
assign cnt_en = (state == PRESS_DETECT) || (state == RELEASE_DETECT);
assign cnt_full = (cnt == CNT_20MS - 1);
endmodule
注意:计数器最大值 CNT_20MS 要根据你的实际时钟频率计算。如果时钟是 50MHz,20ms 就是 1,000,000 个时钟周期。如果时钟是 100MHz,就要改成 2,000,000。我曾经见过有人直接复制代码,没改参数,结果消抖时间完全不对。
3.4 仿真验证
写完了代码,一定要仿真。我个人的习惯是:先写一个简单的 testbench,验证基本功能,然后再加一些随机抖动,看看状态机能不能扛住。
下面是一个仿真测试的要点:
- 正常按下:按键拉低,等待 20ms,确认输出脉冲
- 抖动干扰:在 20ms 内模拟几次弹跳,状态机应该回到 IDLE 重新计时
- 快速连按:连续两次按下,间隔小于 20ms,看状态机如何处理
仿真波形中,重点关注 state 信号的变化。你会看到状态机在 IDLE 和 PRESS_DETECT 之间来回跳转,直到抖动结束,计数器跑满,才进入 PRESS_CONFIRM。
避坑指南:我曾经在仿真时发现 key_out 输出了一串毛刺。查了半天,发现是第三段输出逻辑写成了组合逻辑。记住,输出用时序逻辑,可以有效滤除毛刺。
3.5 状态机流程图
下面我用 SVG 画了一张状态转移图,方便你理解整个流程。
这张图里,你从 IDLE 出发,看到 key_in=0 就进入 PRESS_DETECT。如果在计数过程中 key_in 弹回高电平,就回到 IDLE 重新等。只有计数器跑满,才进入 PRESS_CONFIRM,输出有效信号。释放按键时同理。
3.6 实际项目中的注意事项
最后,分享几个我在实际项目中踩过的坑:
- 时钟频率变化:如果系统时钟不是 50MHz,一定要重新计算 CNT_20MS。我习惯在代码里加一个注释,写明时钟频率和对应的计数值。
- 多个按键:如果有多个按键,每个按键都需要独立的消抖模块。不要试图用一个状态机处理多个按键,那样逻辑会变得非常复杂。
- 异步输入同步化:key_in 来自外部,建议先经过两级触发器同步,再送入状态机。这样可以有效降低亚稳态风险。
我的习惯:在模块内部,所有输入信号都先打两拍。虽然多花几个触发器,但换来的是整个系统的稳定性。这个习惯帮我避免了好几次现场故障。
好了,三段式状态机实现按键消抖的内容就到这里。代码和仿真都给了,你可以在自己的环境里跑一跑。有什么问题,欢迎随时交流。
可可精品资料库