4. Verilog实现基础消抖:模块端口定义、参数化设计、计数器与状态机

好,咱们直接进入正题。按键消抖的Verilog实现,说白了就是三件事:定义好端口、配好参数、写好逻辑。逻辑部分又分两块——计数器负责数时间,状态机负责管流程。我一个个拆开讲。

4.1 模块端口定义——先搭好骨架

写模块的第一步,先把端口想清楚。我个人习惯是:输入、输出、时钟、复位,一个都不能少。

module debounce #(
    parameter TIME_20MS = 1_000_000  // 默认20ms,假设时钟50MHz
)(
    input  wire       clk,           // 系统时钟
    input  wire       rst_n,         // 异步复位,低有效
    input  wire       key_in,        // 按键输入(未消抖)
    output reg        key_out        // 消抖后输出
);

嗯,这里要注意:key_in是原始按键信号,可能带着毛刺。key_out是我们处理后的干净信号。端口定义就这么简单,但参数化设计才是关键。

4.2 参数化设计——TIME_20MS的学问

为什么要把20ms做成参数?你想想看,不同板子的时钟频率不一样。50MHz下20ms需要数1_000_000个时钟,但如果是25MHz呢?那就得改成500_000。写死在代码里,换块板子就得改源码,太蠢了。

核心思路:用参数代替魔数,让代码可配置、可复用。

我在项目中遇到过一件事:有个同事把20ms直接写成了20'd1000000,结果换到低速时钟后,消抖时间变成了40ms,按键反应慢半拍。从那以后,我所有消抖模块都强制用参数。

参数的计算公式很简单:

// 假设时钟频率为 clk_freq (Hz)
// 消抖时间要求为 debounce_time (秒)
// 则计数器最大值 = clk_freq * debounce_time

// 例如:50MHz时钟,20ms消抖
// 50_000_000 * 0.02 = 1_000_000

小技巧:参数值最好写成易于理解的格式。比如TIME_20MS = 1_000_000,一看就知道是20ms。别写成TIME_20MS = 20'd1000000,虽然结果一样,但可读性差很多。

4.3 计数器实现——数够20ms才算数

计数器的作用很简单:当按键状态发生变化时,开始计数。如果计数过程中按键又抖了,就清零重数。只有连续稳定地数够20ms,才认为按键真的按下了。

我画了个流程图,帮你理清逻辑:

按键消抖计数器状态流程图 等待按键变化 key_in变化 是否稳定? 不稳定 计数器清零 稳定 计数器递增 计满20ms? 未满 已满 输出稳定状态 回到等待

对应的Verilog代码长这样:

reg [19:0] cnt;  // 20位计数器,够数1_000_000
reg       cnt_en; // 计数使能

// 计数器逻辑
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        cnt <= 20'd0;
    end else if (cnt_en) begin
        if (cnt == TIME_20MS - 1)
            cnt <= cnt;  // 计满保持
        else
            cnt <= cnt + 1'b1;
    end else begin
        cnt <= 20'd0;  // 不使能就清零
    end
end

我曾经踩过的坑:计数器位宽一定要算够!20ms在50MHz下需要1_000_000,用20位计数器(最大1_048_575)刚好。如果时钟是100MHz,需要2_000_000,那就得用21位。位宽不够,计数值会溢出,消抖时间直接对半砍。

4.4 状态机实现——让逻辑有条理

计数器只管数数,但什么时候开始数、什么时候停止、什么时候输出,这些决策得靠状态机。我习惯用三段式状态机,清晰又好维护。

消抖状态机一般分三个状态:

状态 含义 行为
IDLE 空闲等待 检测按键变化,一旦变化进入COUNT
COUNT 计数中 使能计数器,若中途变化回IDLE,计满进入OUTPUT
OUTPUT 输出稳定值 输出消抖后的电平,然后回IDLE

代码实现如下:

localparam IDLE   = 2'b00;
localparam COUNT  = 2'b01;
localparam OUTPUT = 2'b10;

reg [1:0] state, next_state;

// 第一段:状态转移
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 != key_out)  // 按键变化
                next_state = COUNT;
        end
        COUNT: begin
            if (key_in != key_out)  // 中途抖动
                next_state = IDLE;
            else if (cnt == TIME_20MS - 1)  // 计满
                next_state = OUTPUT;
        end
        OUTPUT: begin
            next_state = IDLE;  // 输出一拍后返回
        end
    endcase
end

// 第三段:输出逻辑
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        key_out <= 1'b1;  // 假设按键按下为低
        cnt_en  <= 1'b0;
    end else begin
        case (state)
            IDLE: begin
                cnt_en <= 1'b0;
            end
            COUNT: begin
                cnt_en <= 1'b1;
            end
            OUTPUT: begin
                key_out <= key_in;  // 输出稳定值
                cnt_en  <= 1'b0;
            end
        endcase
    end
end

关键点:状态机里判断按键变化时,比较的是key_in和key_out。为什么不是和上一个时钟的key_in比?因为key_out是消抖后的稳定值,用它做参考更可靠。这是我调了无数次仿真才悟出来的。

4.5 完整模块整合

把上面几块拼起来,就是一个完整的消抖模块。我个人习惯把计数器、状态机、输出逻辑分开写,这样调试时能一眼定位问题。

module debounce #(
    parameter TIME_20MS = 1_000_000
)(
    input  wire       clk,
    input  wire       rst_n,
    input  wire       key_in,
    output reg        key_out
);

    // 状态定义
    localparam IDLE   = 2'b00;
    localparam COUNT  = 2'b01;
    localparam OUTPUT = 2'b10;

    // 信号声明
    reg [1:0] state, next_state;
    reg [19:0] cnt;
    reg        cnt_en;

    // ... 上面三段式状态机代码 ...
    // ... 计数器代码 ...

endmodule

个人建议:仿真时把TIME_20MS改小一点,比如改成100,这样不用等20ms就能看到结果。等仿真通过后,再改回真实值。这招我用了十年,从来没失手过。

好了,基础消抖的实现就这些。说白了就是:状态机管决策,计数器管计时,两者配合就能滤掉毛刺。下一节我们会聊到更高级的消抖方法,但基础版一定要吃透,因为它是所有变体的根基。


可可精品资料库