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,才认为按键真的按下了。
我画了个流程图,帮你理清逻辑:
对应的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就能看到结果。等仿真通过后,再改回真实值。这招我用了十年,从来没失手过。
好了,基础消抖的实现就这些。说白了就是:状态机管决策,计数器管计时,两者配合就能滤掉毛刺。下一节我们会聊到更高级的消抖方法,但基础版一定要吃透,因为它是所有变体的根基。
可可精品资料库