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画的,把四个状态之间的跳转关系画得清清楚楚。你对照着代码看,会更容易理解。

按键消抖状态机转移图 IDLE 空闲态 PRESS 按下消抖 PRESSED 按下稳定 RELEASE 释放消抖 key_in=0 cnt==MAX key_in=1 key_in=1 cnt==MAX key_in=0 key_in=1 key_in=0

4.6 避坑指南

我曾经踩过的坑,你千万别再踩:

  • 计数器溢出:如果消抖时间很长,计数器位宽不够会溢出。用 $clog2 自动计算,别手算。
  • 组合逻辑环路:第二段里如果 cnt 和 state 互相依赖,可能会形成组合环路。我的做法是把计数器控制信号单独拿出来,不和状态直接耦合。
  • 边沿检测丢失:如果你在第三段用 state 而不是 next_state,边沿检测会晚一个周期。对于高速系统来说,这一拍可能就丢了。

4.7 仿真验证要点

代码写完了,怎么验证?我一般会做这几步:

  1. 功能仿真:给按键输入加上毛刺,看看状态机能不能正确过滤掉。
  2. 边界测试:把消抖时间设成1ms和100ms,分别跑一下,确保参数化没问题。
  3. 连续按键:模拟快速连按,看看状态机能不能正确响应每一次按键。
  4. 复位测试:在任意状态下复位,确保能回到IDLE态。

嗯,基本上做到这四点,这个消抖模块就可以放心用了。下一节咱们会把这个模块挂到AXI总线上,做成一个完整的IP核。

总结一下:三段式状态机写消抖,核心就是四个状态、一个计数器、三个always块。参数化设计让代码可复用,组合逻辑和时序逻辑分离让调试更轻松。记住这些,你也能写出稳定可靠的消抖模块。

可可精品资料库