4、Verilog实现状态机消抖:三段式状态机代码编写,参数化消抖时间
好,咱们接着聊按键消抖。上一节我把状态机的原理讲清楚了,这一节咱们直接上代码。
我个人习惯用三段式状态机来写消抖。为什么?因为三段式结构清晰,组合逻辑和时序逻辑分得开,后期维护起来特别省心。你想想看,要是把所有逻辑揉在一个always块里,过两个月你自己回来看代码,估计都得挠头。
4.1 三段式状态机的基本结构
三段式,说白了就是三个always块:
- 第一段:时序逻辑,负责状态跳转
- 第二段:组合逻辑,负责下一状态判断
- 第三段:时序逻辑,负责输出赋值
嗯,这里要注意:第三段也可以输出组合逻辑,但我建议用时序输出。为什么呢?因为时序输出天然带寄存器,能有效避免毛刺。我在项目中遇到过因为输出组合逻辑导致后级电路误触发的惨案,从那以后我第三段一律用时序输出。
4.2 参数化消抖时间
消抖时间不能写死。不同按键的机械特性不一样,有的20ms就稳了,有的需要50ms。所以我用parameter来定义消抖时间,这样顶层例化时直接改参数就行。
核心思路:用一个计数器来计时,当按键状态稳定后,计数器开始累加。累加到设定的消抖时间后,才认为按键真的按下或释放了。
4.3 完整代码示例
module debounce_fsm #(
parameter CLK_FREQ = 50_000_000, // 时钟频率,单位Hz
parameter DEBOUNCE_TIME = 20 // 消抖时间,单位ms
)(
input wire clk,
input wire rst_n,
input wire key_in, // 按键输入,低电平有效
output reg key_out, // 消抖后输出
output reg key_posedge, // 上升沿检测
output reg key_negedge // 下降沿检测
);
// 计算需要的计数器位宽
localparam CNT_MAX = (CLK_FREQ / 1000) * DEBOUNCE_TIME;
localparam CNT_WIDTH = $clog2(CNT_MAX + 1);
// 状态定义
localparam IDLE = 2'b00; // 空闲态
localparam PRESS = 2'b01; // 按下消抖
localparam PRESSED = 2'b10; // 按下稳定
localparam RELEASE = 2'b11; // 释放消抖
reg [1:0] state, next_state;
reg [CNT_WIDTH-1:0] cnt;
reg cnt_en;
reg cnt_rst;
// 第一段:状态跳转
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
state <= IDLE;
else
state <= next_state;
end
// 第二段:下一状态判断(组合逻辑)
always @(*) begin
next_state = state;
cnt_en = 1'b0;
cnt_rst = 1'b0;
case (state)
IDLE: begin
cnt_rst = 1'b1;
if (!key_in) begin
next_state = PRESS;
end
end
PRESS: begin
cnt_en = 1'b1;
if (cnt == CNT_MAX - 1) begin
next_state = PRESSED;
end else if (key_in) begin
next_state = IDLE;
end
end
PRESSED: begin
cnt_rst = 1'b1;
if (key_in) begin
next_state = RELEASE;
end
end
RELEASE: begin
cnt_en = 1'b1;
if (cnt == CNT_MAX - 1) begin
next_state = IDLE;
end else if (!key_in) begin
next_state = PRESSED;
end
end
default: begin
next_state = IDLE;
end
endcase
end
// 计数器逻辑
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
cnt <= 'd0;
else if (cnt_rst)
cnt <= 'd0;
else if (cnt_en)
cnt <= cnt + 1'b1;
end
// 第三段:输出赋值(时序逻辑)
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
key_out <= 1'b1;
key_posedge <= 1'b0;
key_negedge <= 1'b0;
end else begin
case (next_state)
IDLE: begin
key_out <= 1'b1;
key_posedge <= 1'b0;
key_negedge <= 1'b0;
end
PRESS: begin
key_out <= 1'b1;
key_posedge <= 1'b0;
key_negedge <= 1'b0;
end
PRESSED: begin
key_out <= 1'b0;
key_posedge <= 1'b1; // 按下瞬间产生上升沿
key_negedge <= 1'b0;
end
RELEASE: begin
key_out <= 1'b0;
key_posedge <= 1'b0;
key_negedge <= 1'b0;
end
default: begin
key_out <= 1'b1;
key_posedge <= 1'b0;
key_negedge <= 1'b0;
end
endcase
end
end
endmodule
4.4 代码关键点解析
这段代码里,有几个地方我想特别强调一下:
| 模块 | 关键点 | 说明 |
|---|---|---|
| 参数定义 | CLK_FREQ 和 DEBOUNCE_TIME | 通过这两个参数,可以适配不同时钟频率和消抖需求 |
| 计数器位宽 | $clog2 函数 | 自动计算所需位宽,避免手动计算出错 |
| 状态机第二段 | 组合逻辑输出 cnt_en 和 cnt_rst | 控制计数器的启停和复位,这是三段式的精髓 |
| 第三段输出 | 基于 next_state 判断 | 比基于 state 判断提前一个时钟周期,避免输出滞后 |
小技巧:第三段输出时,我习惯用 next_state 而不是 state。这样输出能提前一拍,对于边沿检测来说更准确。当然,如果你需要输出与状态严格同步,那就用 state。
4.5 状态转移图
下面这张图是我用SVG画的,把四个状态之间的跳转关系画得清清楚楚。你对照着代码看,会更容易理解。
4.6 避坑指南
我曾经踩过的坑,你千万别再踩:
- 计数器溢出:如果消抖时间很长,计数器位宽不够会溢出。用 $clog2 自动计算,别手算。
- 组合逻辑环路:第二段里如果 cnt 和 state 互相依赖,可能会形成组合环路。我的做法是把计数器控制信号单独拿出来,不和状态直接耦合。
- 边沿检测丢失:如果你在第三段用 state 而不是 next_state,边沿检测会晚一个周期。对于高速系统来说,这一拍可能就丢了。
4.7 仿真验证要点
代码写完了,怎么验证?我一般会做这几步:
- 功能仿真:给按键输入加上毛刺,看看状态机能不能正确过滤掉。
- 边界测试:把消抖时间设成1ms和100ms,分别跑一下,确保参数化没问题。
- 连续按键:模拟快速连按,看看状态机能不能正确响应每一次按键。
- 复位测试:在任意状态下复位,确保能回到IDLE态。
嗯,基本上做到这四点,这个消抖模块就可以放心用了。下一节咱们会把这个模块挂到AXI总线上,做成一个完整的IP核。
总结一下:三段式状态机写消抖,核心就是四个状态、一个计数器、三个always块。参数化设计让代码可复用,组合逻辑和时序逻辑分离让调试更轻松。记住这些,你也能写出稳定可靠的消抖模块。