4. 按键消抖模块设计(状态机实现)

按键消抖,说白了就是解决机械开关的「抖动」问题。我刚开始做FPGA时,第一次用按键控制LED,结果按一下灯闪了好几下——当时我还以为板子坏了。后来才知道,按键按下和释放的瞬间,金属触点会弹跳几次,产生多个脉冲。

这个抖动时间一般在5-20ms之间。你想想看,如果不用消抖,一次按键可能被识别成多次。所以,我们需要一个可靠的消抖方案。

4.1 状态转移图

我个人习惯用状态机来做消抖。为什么?因为状态机逻辑清晰,容易控制时序。我们先看状态转移图:

IDLE 等待按键 PRESS_DETECT 按下检测 PRESS_CONFIRM 按下确认 RELEASE_DETECT 释放检测 RELEASE_CONFIRM 释放确认 key_in=0 cnt满 key_in=1 key_out=1 cnt满 key_in=0 key_out=0

这个状态机有5个状态。核心思路是:检测到按键变化后,启动计数器等待20ms。如果20ms后电平稳定,才确认状态变化。嗯,这里要注意——如果计数过程中电平又跳回去了,说明是抖动,要回到初始状态重新等待。

4.2 三段式状态机编码

我写状态机一直用三段式。为什么?因为组合逻辑和时序逻辑分开,代码可读性好,也方便调试。三段式就是:

  • 第一段:时序逻辑,描述状态跳转
  • 第二段:组合逻辑,描述下一状态
  • 第三段:时序逻辑,描述输出

来看看代码怎么写:

// 第一段:状态跳转
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        state <= IDLE;
    else
        state <= next_state;
end

// 第二段:下一状态逻辑
always @(*) begin
    case (state)
        IDLE: begin
            if (key_in == 1'b0)
                next_state = PRESS_DETECT;
            else
                next_state = IDLE;
        end
        
        PRESS_DETECT: begin
            if (cnt_full)
                next_state = PRESS_CONFIRM;
            else if (key_in == 1'b1)
                next_state = IDLE;  // 抖动,回到初始
            else
                next_state = PRESS_DETECT;
        end
        
        PRESS_CONFIRM: begin
            if (key_in == 1'b1)
                next_state = RELEASE_DETECT;
            else
                next_state = PRESS_CONFIRM;
        end
        
        RELEASE_DETECT: begin
            if (cnt_full)
                next_state = RELEASE_CONFIRM;
            else if (key_in == 1'b0)
                next_state = PRESS_CONFIRM;  // 抖动,回到按下
            else
                next_state = RELEASE_DETECT;
        end
        
        RELEASE_CONFIRM: begin
            if (key_in == 1'b0)
                next_state = PRESS_DETECT;
            else
                next_state = RELEASE_CONFIRM;
        end
        
        default: next_state = IDLE;
    endcase
end

// 第三段:输出逻辑
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        key_out <= 1'b1;
    else if (state == PRESS_CONFIRM && next_state == RELEASE_DETECT)
        key_out <= 1'b0;  // 按下确认,输出低电平
    else if (state == RELEASE_CONFIRM && next_state == IDLE)
        key_out <= 1'b1;  // 释放确认,输出高电平
end
我的小技巧:第三段输出用「边沿检测」的方式写,只在状态切换的瞬间更新输出。这样能避免输出毛刺,我在好几个项目里都这么用,效果不错。

4.3 消抖计数器设计

计数器是消抖的核心。20ms怎么算?假设时钟是50MHz,周期20ns。20ms / 20ns = 1,000,000。所以计数器需要从0计到999,999。

我个人习惯用参数化设计,方便后期调整:

parameter CLK_FREQ = 50_000_000;  // 50MHz
parameter DEBOUNCE_TIME = 20;     // 20ms

localparam CNT_MAX = CLK_FREQ / 1000 * DEBOUNCE_TIME - 1;
localparam CNT_WIDTH = $clog2(CNT_MAX + 1);

reg [CNT_WIDTH-1:0] cnt;
reg cnt_full;

// 计数器逻辑
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        cnt <= 'd0;
    else if (state == PRESS_DETECT || state == RELEASE_DETECT)
        cnt <= cnt + 1'b1;
    else
        cnt <= 'd0;
end

// 计数满标志
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        cnt_full <= 1'b0;
    else if (cnt == CNT_MAX)
        cnt_full <= 1'b1;
    else
        cnt_full <= 1'b0;
end
避坑指南:我曾经犯过一个错误——计数器用组合逻辑产生cnt_full信号。结果仿真没问题,上板子就出bug。后来发现是组合逻辑产生了毛刺,导致状态机误跳转。记住,cnt_full一定要用时序逻辑打一拍。

4.4 完整模块整合

把上面三部分拼起来,就是一个完整的按键消抖模块。我一般还会加一个边沿检测,输出单脉冲:

module key_debounce (
    input  wire       clk,
    input  wire       rst_n,
    input  wire       key_in,
    output reg        key_out,
    output wire       key_pulse
);

    // ... 状态机、计数器代码放这里 ...

    // 边沿检测,输出单周期脉冲
    reg key_out_dly;
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            key_out_dly <= 1'b1;
        else
            key_out_dly <= key_out;
    end
    
    assign key_pulse = ~key_out & key_out_dly;  // 下降沿脉冲

endmodule

核心要点回顾:

  • 状态机5个状态:IDLE → PRESS_DETECT → PRESS_CONFIRM → RELEASE_DETECT → RELEASE_CONFIRM
  • 三段式编码:状态跳转、下一状态、输出逻辑分开写
  • 计数器参数化:方便适配不同时钟频率和消抖时间
  • 输出用边沿检测,避免毛刺

这个模块我用了好多年,从简单的按键控制到复杂的键盘矩阵,核心逻辑都没变过。你想想看,一个设计如果能经得起时间考验,说明它的思路是对的。

可可精品资料库