13、按键消抖与LED控制:机械按键抖动原理、消抖算法(延时/状态机)、呼吸灯PWM实现

各位同学,今天我们来聊聊FPGA开发中一个非常基础但又特别容易踩坑的话题——按键消抖与LED控制。说实话,我刚开始做FPGA项目时,觉得按键不就是个开关嘛,能有多复杂?结果第一次调试时,按一下按键,LED闪了三次,当时我就懵了。后来才明白,这就是机械按键的抖动在作怪。

13.1 机械按键抖动原理

先说说抖动是怎么回事。你想想看,机械按键内部其实就是两个金属触点。按下时,金属片会弹跳几次才能稳定接触。这个弹跳过程,就是抖动。

抖动的特点很明显:

  • 时间短:通常持续5-20ms
  • 频率高:弹跳频率可达几百Hz
  • 不可预测:每次按下的抖动波形都不一样

我在项目中遇到过最夸张的情况,有个按键按下去抖动了将近50ms。当时查了半天,最后发现是按键本身质量太差。所以啊,选型也很重要。

核心要点:抖动会导致一次按键被误判为多次触发。如果不做消抖处理,你的系统就会像抽风一样乱跳。

13.2 消抖算法:延时消抖

最简单的消抖方法就是延时。说白了,就是检测到按键电平变化后,等个10-20ms再读一次。如果电平稳定了,就认为是有效按键。

我个人的习惯是采用20ms延时。为什么是20ms?因为绝大多数按键的抖动都在20ms以内。当然,如果你用的按键特别差,可以适当延长到30ms。

来看看代码实现:

// 延时消抖模块
module debounce_delay (
    input  wire       clk,      // 系统时钟 50MHz
    input  wire       rst_n,    // 复位信号
    input  wire       key_in,   // 按键输入
    output reg        key_out   // 消抖后输出
);

    // 20ms延时计数器
    // 50MHz时钟,20ms = 1_000_000个时钟周期
    parameter DELAY_CNT = 1_000_000;
    
    reg [19:0] cnt;
    reg        key_sync1, key_sync2;
    
    // 两级同步,消除亚稳态
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            key_sync1 <= 1'b1;
            key_sync2 <= 1'b1;
        end else begin
            key_sync1 <= key_in;
            key_sync2 <= key_sync1;
        end
    end
    
    // 延时消抖逻辑
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            cnt     <= 20'd0;
            key_out <= 1'b1;
        end else begin
            if (key_sync2 != key_out) begin
                // 电平变化,开始计数
                if (cnt == DELAY_CNT - 1) begin
                    cnt     <= 20'd0;
                    key_out <= key_sync2;
                end else begin
                    cnt <= cnt + 1'b1;
                end
            end else begin
                cnt <= 20'd0;
            end
        end
    end

endmodule

小提示:这里我用了两级同步寄存器。为什么?因为按键输入是异步信号,直接采样可能会产生亚稳态。两级同步虽然不能完全消除,但能把概率降到极低。这是我做项目时血的教训换来的经验。

13.3 消抖算法:状态机消抖

延时消抖虽然简单,但有个问题——它占用了CPU(或者说逻辑资源)一直在等。如果你有多个按键要处理,延时消抖就不太合适了。

这时候,状态机消抖就派上用场了。它本质上还是延时,但通过状态机来管理,效率更高。

状态机消抖的思路是这样的:

  • IDLE状态:等待按键按下
  • PRESS_DEBOUNCE状态:按下消抖,等待20ms
  • PRESS_CONFIRM状态:确认按下
  • RELEASE_DEBOUNCE状态:释放消抖,等待20ms
  • RELEASE_CONFIRM状态:确认释放

你看,这样设计的好处是:每个状态只做一件事,逻辑清晰,也方便扩展。

// 状态机消抖模块
module debounce_fsm (
    input  wire       clk,
    input  wire       rst_n,
    input  wire       key_in,
    output reg        key_pulse  // 按键脉冲信号
);

    // 状态定义
    localparam IDLE            = 3'd0;
    localparam PRESS_DEBOUNCE  = 3'd1;
    localparam PRESS_CONFIRM   = 3'd2;
    localparam RELEASE_DEBOUNCE = 3'd3;
    localparam RELEASE_CONFIRM = 3'd4;
    
    reg [2:0] state, next_state;
    reg [19:0] cnt;
    reg        key_sync1, key_sync2;
    
    // 同步
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            key_sync1 <= 1'b1;
            key_sync2 <= 1'b1;
        end else begin
            key_sync1 <= key_in;
            key_sync2 <= key_sync1;
        end
    end
    
    // 状态转移
    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_sync2 == 1'b0)
                    next_state = PRESS_DEBOUNCE;
            end
            PRESS_DEBOUNCE: begin
                if (cnt == 1_000_000 - 1)
                    next_state = PRESS_CONFIRM;
                else if (key_sync2 == 1'b1)
                    next_state = IDLE;  // 抖动回到高电平,重新等待
            end
            PRESS_CONFIRM: begin
                if (key_sync2 == 1'b1)
                    next_state = RELEASE_DEBOUNCE;
            end
            RELEASE_DEBOUNCE: begin
                if (cnt == 1_000_000 - 1)
                    next_state = RELEASE_CONFIRM;
                else if (key_sync2 == 1'b0)
                    next_state = PRESS_CONFIRM;  // 抖动回到低电平
            end
            RELEASE_CONFIRM: begin
                next_state = IDLE;
            end
        endcase
    end
    
    // 计数器
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            cnt <= 20'd0;
        else if (state != next_state)  // 状态变化时清零
            cnt <= 20'd0;
        else
            cnt <= cnt + 1'b1;
    end
    
    // 输出脉冲
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            key_pulse <= 1'b0;
        else if (state == PRESS_CONFIRM && next_state == RELEASE_DEBOUNCE)
            key_pulse <= 1'b1;  // 确认按下后产生一个脉冲
        else
            key_pulse <= 1'b0;
    end

endmodule

注意:状态机消抖中,我特别加了一个判断——如果在消抖过程中检测到电平又变回去了,就回到初始状态重新等待。这是为了防止按键在抖动过程中被误判。我曾经有个项目没加这个判断,结果按键按下去后,因为抖动刚好在20ms边界上,导致状态机卡死了。嗯,后来就老老实实加上了。

13.4 呼吸灯PWM实现

说完了按键消抖,咱们再来看看LED控制。呼吸灯,说白了就是让LED的亮度慢慢变化,像呼吸一样。

实现呼吸灯的核心是PWM(脉宽调制)。通过改变PWM的占空比,就能控制LED的亮度。占空比越大,LED越亮。

具体怎么做呢?我一般用两个计数器:

  • 周期计数器:决定PWM的频率,比如1kHz
  • 占空比计数器:从0逐渐增加到最大值,再逐渐减小到0

来看看代码:

// 呼吸灯PWM模块
module breath_led (
    input  wire       clk,      // 50MHz
    input  wire       rst_n,
    input  wire       key_pulse, // 按键脉冲,控制开关
    output reg        led_out    // LED输出
);

    // PWM频率 1kHz,周期 = 50_000个时钟周期
    parameter PWM_PERIOD = 50_000;
    // 呼吸周期 2秒,步进 = 50_000 / 1000 = 50
    parameter STEP = 50;
    
    reg [15:0] pwm_cnt;      // PWM周期计数器
    reg [15:0] duty_cnt;     // 占空比计数器
    reg        dir;          // 方向:0=变亮,1=变暗
    reg        enable;       // 使能信号
    
    // 按键控制使能
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            enable <= 1'b0;
        else if (key_pulse)
            enable <= ~enable;  // 按键切换
    end
    
    // PWM周期计数
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            pwm_cnt <= 16'd0;
        else if (pwm_cnt == PWM_PERIOD - 1)
            pwm_cnt <= 16'd0;
        else
            pwm_cnt <= pwm_cnt + 1'b1;
    end
    
    // 占空比控制
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            duty_cnt <= 16'd0;
            dir      <= 1'b0;
        end else if (enable) begin
            if (pwm_cnt == PWM_PERIOD - 1) begin
                // 每个PWM周期更新一次占空比
                if (dir == 1'b0) begin
                    // 变亮
                    if (duty_cnt >= PWM_PERIOD - STEP)
                        dir <= 1'b1;  // 达到最亮,开始变暗
                    else
                        duty_cnt <= duty_cnt + STEP;
                end else begin
                    // 变暗
                    if (duty_cnt <= STEP)
                        dir <= 1'b0;  // 达到最暗,开始变亮
                    else
                        duty_cnt <= duty_cnt - STEP;
                end
            end
        end else begin
            duty_cnt <= 16'd0;  // 关闭时LED熄灭
        end
    end
    
    // PWM输出
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            led_out <= 1'b0;
        else if (enable)
            led_out <= (pwm_cnt < duty_cnt) ? 1'b1 : 1'b0;
        else
            led_out <= 1'b0;
    end

endmodule

关键点:呼吸灯的效果好坏,取决于两个参数——PWM频率和步进大小。PWM频率太低会看到闪烁,太高则浪费资源。我一般用1kHz,人眼基本看不出闪烁。步进大小决定了呼吸速度,50步对应2秒一个周期,效果比较自然。

13.5 综合实战:按键控制呼吸灯

好了,现在我们把按键消抖和呼吸灯结合起来。按一下按键,呼吸灯开始工作;再按一下,呼吸灯停止。

整体架构是这样的:

  1. 按键输入 → 同步器 → 状态机消抖 → 按键脉冲
  2. 按键脉冲 → 呼吸灯控制模块 → PWM输出 → LED

顶层模块代码:

// 顶层模块
module top_key_led (
    input  wire       clk,
    input  wire       rst_n,
    input  wire       key,
    output wire       led
);

    wire key_pulse;
    
    // 状态机消抖
    debounce_fsm u_debounce (
        .clk       (clk),
        .rst_n     (rst_n),
        .key_in    (key),
        .key_pulse (key_pulse)
    );
    
    // 呼吸灯
    breath_led u_breath (
        .clk       (clk),
        .rst_n     (rst_n),
        .key_pulse (key_pulse),
        .led_out   (led)
    );

endmodule

实战建议:调试时,我习惯先用示波器看按键波形。如果看到明显的抖动,就调整消抖时间。另外,呼吸灯的PWM频率可以用逻辑分析仪抓一下,确保周期正确。这些工具用熟了,调试效率会高很多。

最后总结一下今天的内容:

  • 机械按键抖动是物理特性,必须做消抖处理
  • 延时消抖简单直接,适合按键数量少的场景
  • 状态机消抖更灵活,适合多按键或复杂系统
  • 呼吸灯通过PWM控制占空比实现,核心是两个计数器
  • 按键和LED结合,就是一个完整的输入输出控制系统

我个人觉得,按键消抖和呼吸灯控制是FPGA入门的必修课。它们虽然简单,但涉及了同步、状态机、PWM这些基础概念。把这些搞懂了,后面学更复杂的东西就会轻松很多。好了,今天就到这里,大家动手试试吧。