4、状态机消抖法:有限状态机(FSM)设计、三态消抖状态机、状态转移图绘制

按键消抖这件事,前面我们聊了延时法和计数法。说实话,这两种方法对付一般的抖动都够用。但如果你追求更优雅、更可控、更省资源的方案,那状态机消抖法值得你花时间搞明白。

我个人习惯把状态机消抖法叫做“软硬兼施”的方案。它不像延时法那样死板地等时间,也不像计数法那样傻傻地数脉冲。它用有限状态机来“感知”按键的状态变化,说白了就是让FPGA学会“思考”——这个按键现在是稳定按下、稳定释放,还是正在抖动?

核心思想:将按键的输入信号抽象为几个离散状态,通过状态之间的跳转逻辑来滤除抖动。只有连续采样到稳定电平,才认为按键状态真正改变了。

4.1 三态消抖状态机:从“二值”到“三态”的进化

你可能会问:按键不就是按下和释放两个状态吗?为什么要搞三个?

嗯,这里要注意。传统的二值思维(0或1)在消抖场景下会出问题。因为抖动期间,电平在0和1之间来回跳变,你根本没法判断按键到底想干嘛。所以我们需要引入第三个状态——中间态,用来表示“正在抖动,尚未稳定”。

我设计的这个三态消抖状态机,包含以下三个状态:

  • IDLE(空闲态):按键处于稳定释放状态,输出为高电平(假设按键按下为低)。
  • PRESS_DEBOUNCE(按下消抖态):检测到按键可能被按下,正在等待抖动结束。如果连续采样到N次低电平,则确认按下。
  • RELEASE_DEBOUNCE(释放消抖态):检测到按键可能被释放,正在等待抖动结束。如果连续采样到N次高电平,则确认释放。

你看,这个设计把“按下”和“释放”的消抖过程分开了。为什么这么做?因为按键按下和释放时的抖动特性可能不一样。我在项目中遇到过一种按键,按下时抖得厉害,释放时却很干脆。如果用一个统一的消抖逻辑,要么按下消抖不干净,要么释放响应太慢。分开处理,就能分别设置不同的采样次数。

我的经验:按下消抖的采样次数可以设大一点(比如20ms),释放消抖可以设小一点(比如5ms)。这样既能保证按下不误触,又能让释放响应更灵敏。当然,具体数值要根据你的按键硬件来调。

4.2 状态转移图:一图胜千言

搞状态机设计,我最怕的就是逻辑混乱。所以每次动手写代码前,我都会先画状态转移图。下面这张图是我用SVG画的,它清晰地展示了三态消抖状态机的完整工作流程。

三态消抖状态机 - 状态转移图 IDLE 空闲态(输出高) PRESS_DEBOUNCE 按下消抖态 RELEASE_DEBOUNCE 释放消抖态 key_in == 0(检测到按下) 采样到高电平(抖动返回) 连续N次采样到低电平 连续N次采样到高电平 采样到低电平(抖动返回) key_in == 1 保持 采样未满N次 采样未满N次

这张图你看懂了吗?我简单解释一下:

  • 系统上电后,默认在IDLE状态。此时按键输出为高电平(释放状态)。
  • 一旦检测到key_in为低电平(按键按下),立即跳转到PRESS_DEBOUNCE状态。
  • 在PRESS_DEBOUNCE中,如果采样到高电平,说明是抖动,跳回IDLE。如果连续N次都采样到低电平,说明按键真的按下了,跳转到RELEASE_DEBOUNCE状态,同时输出一个脉冲。
  • 在RELEASE_DEBOUNCE中,逻辑类似。如果采样到低电平,说明是抖动,跳回PRESS_DEBOUNCE。如果连续N次都采样到高电平,说明按键真的释放了,跳回IDLE。

注意:这里有一个容易踩的坑。我曾经在PRESS_DEBOUNCE状态里,检测到一次高电平就直接跳回IDLE。结果发现按键按下后,稍微抖一下就回到IDLE了,根本进不了释放消抖。后来我改成“连续采样到高电平才跳回”,问题就解决了。记住:消抖的核心是“连续采样”,不是“单次采样”。

4.3 Verilog代码实现:把状态机写进FPGA

光说不练假把式。下面是我在实际项目中用过的三态消抖状态机代码。我把它简化了一下,方便你理解核心逻辑。

module debounce_fsm (
    input  wire       clk,        // 系统时钟,假设50MHz
    input  wire       rst_n,      // 异步复位,低有效
    input  wire       key_in,     // 按键输入(按下为0)
    output reg        key_out,    // 消抖后输出(按下为0)
    output reg        key_pulse   // 按下脉冲(按下瞬间输出一个时钟周期的高电平)
);

    // 状态定义
    localparam IDLE             = 2'b00;
    localparam PRESS_DEBOUNCE   = 2'b01;
    localparam RELEASE_DEBOUNCE = 2'b10;

    // 消抖参数:假设需要20ms的消抖时间,50MHz时钟对应1_000_000个周期
    // 这里为了仿真方便,设小一点
    localparam DEBOUNCE_CNT_MAX = 100_000;

    reg [1:0] state, next_state;
    reg [16:0] cnt;  // 计数器,用于连续采样
    reg [1:0] sample_cnt;  // 连续采样次数计数

    // 状态寄存器
    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_DEBOUNCE;
            end
            PRESS_DEBOUNCE: begin
                if (cnt == DEBOUNCE_CNT_MAX && key_in == 1'b0)
                    next_state = RELEASE_DEBOUNCE;
                else if (key_in == 1'b1)
                    next_state = IDLE;
            end
            RELEASE_DEBOUNCE: begin
                if (cnt == DEBOUNCE_CNT_MAX && key_in == 1'b1)
                    next_state = IDLE;
                else if (key_in == 1'b0)
                    next_state = PRESS_DEBOUNCE;
            end
            default: next_state = IDLE;
        endcase
    end

    // 计数器逻辑
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            cnt <= 0;
        else begin
            case (state)
                IDLE: cnt <= 0;
                PRESS_DEBOUNCE, RELEASE_DEBOUNCE: begin
                    if (cnt == DEBOUNCE_CNT_MAX)
                        cnt <= cnt;
                    else
                        cnt <= cnt + 1;
                end
                default: cnt <= 0;
            endcase
        end
    end

    // 输出逻辑
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            key_out   <= 1'b1;
            key_pulse <= 1'b0;
        end else begin
            case (state)
                IDLE: begin
                    key_out   <= 1'b1;
                    key_pulse <= 1'b0;
                end
                PRESS_DEBOUNCE: begin
                    key_out   <= 1'b1;  // 未确认按下前,输出保持高
                    key_pulse <= 1'b0;
                end
                RELEASE_DEBOUNCE: begin
                    key_out   <= 1'b0;  // 确认按下后,输出低
                    key_pulse <= 1'b1;  // 产生一个脉冲
                end
                default: begin
                    key_out   <= 1'b1;
                    key_pulse <= 1'b0;
                end
            endcase
        end
    end

endmodule

这段代码里,我用了三段式状态机写法。为什么用三段式?因为组合逻辑和时序逻辑分开,代码更清晰,综合出来的电路也更稳定。你想想看,如果所有逻辑都揉在一个always块里,调试起来多痛苦。

避坑指南:我曾经在次态逻辑里直接用了非阻塞赋值,结果仿真时状态跳来跳去,完全不对。后来才想起来,组合逻辑必须用阻塞赋值。这个坑我踩过一次,之后就再也没犯过。你也记住:组合逻辑用=,时序逻辑用<=。

4.4 状态机消抖法的优缺点

说了这么多,我们来总结一下状态机消抖法的特点。我列个表格,一目了然。

优点 缺点
逻辑清晰,状态转移一目了然 代码量相对较大,初学者可能觉得复杂
按下和释放可以分别设置消抖时间 需要额外的计数器资源
抗干扰能力强,能有效滤除毛刺 状态机设计不当容易产生毛刺或死锁
易于扩展,比如增加长按、双击检测 对时序要求较高,需要仔细设计

我个人觉得,状态机消抖法是“一劳永逸”的方案。虽然第一次写代码时花的时间多一点,但一旦写好,后面基本不用改。而且它的可扩展性特别好。我在一个项目中,基于这个三态状态机,只加了几个状态和计数器,就实现了长按、短按、双击的检测。如果你有兴趣,后面可以自己试试。

好了,状态机消抖法就讲到这里。记住:状态机设计的核心是“状态清晰、转移明确、输出可控”。只要把握住这三点,你也能写出优雅的消抖代码。

一句话总结:三态消抖状态机,用三个状态(空闲、按下消抖、释放消抖)和连续采样机制,实现了按键抖动的可靠滤除。它比延时法和计数法更灵活、更可控,是嵌入式系统中按键处理的“高级玩法”。

可可精品资料库