第二十章:按键控制蜂鸣器实验
本章对应原书第 416~433 页,属于「第五篇 实战篇」。
本章在讲什么?(先看这个)
上一章直接用按键控制 LED,你可能在按按键时偶尔发现"按一次却触发了好几次"——这不是错觉,而是按键抖动在捣乱。本章的核心任务有两个:一是认识蜂鸣器(有源/无源的区别),二是学会按键消抖——这是嵌入式和 FPGA 领域的经典必修课。本章的工程第一次采用了"顶层 + 子模块"的分层设计:一个消抖模块、一个控制模块、一个顶层例化模块,这种模块化架构以后会一直沿用。
核心概念拆解
20.1 蜂鸣器简介
蜂鸣器是把电信号变成机械振动从而发声的器件。它有两个维度的分类:
按结构分:压电式和电磁式。
| 压电式蜂鸣器 | 电磁式蜂鸣器 | |
|---|---|---|
| 原理 | 压电效应,交变电压使压电片振动 | 线圈产生磁场,驱动振动膜片 |
| 外观 | 圆形金属片结构,较薄 | 塑料外壳,内部有线圈,较厚 |
| 工作电压 | 较高(通常9V以上) | 较低(1.5~12V) |
| 工作电流 | 很小(<10mA) | 较大(>20mA) |
| 音质 | 尖锐刺耳 | 较柔和 |
| 适用 | 报警器 | 语音、音乐设备 |
本章使用电磁式蜂鸣器。
按驱动方式分:有源和无源。这里的"源"指的是内部震荡源:
- 有源蜂鸣器:内部自带震荡电路,通电就响,给它直流电平就能发声,控制简单;
- 无源蜂鸣器:内部没有震荡源,必须外部提供震荡信号(比如方波)才会响,好处是频率可调,能演奏音调。
区分小技巧:引脚朝上放置时,无源蜂鸣器能看到绿色电路板,有源蜂鸣器是黑胶封闭。本章用的是有源蜂鸣器,给高电平就响,给低电平就停。

20.2 硬件设计
开发板左下侧板载 1 个有源蜂鸣器,接至 ZYNQ 的 PL 引脚(M14,高电平发声)。

注意原理图里的三极管 Q1:ZYNQ 的 IO 引脚电流驱动能力有限(一般只有几毫安),而电磁式蜂鸣器工作电流要 20mA 以上,直接驱动带不动。所以电路里加了一个三极管做电流放大——IO 输出小电流控制三极管导通,由电源经三极管提供大电流驱动蜂鸣器。这就像用水龙头(IO)去控制水闸(三极管),水闸后面才是大水流(蜂鸣器)。
20.3 实验任务
用 PL_KEY0 按键控制蜂鸣器:上电后蜂鸣器鸣叫;按一次按键停止鸣叫;再按一次恢复鸣叫——即每按一次翻转一次状态。
20.4 程序设计
系统分三个模块:顶层模块 top_key_beep、按键消抖模块 key_debounce、按键控制蜂鸣器模块 key_beep。

按键消抖:为什么要消抖?
我们用的按键是机械弹性开关,按下和松开瞬间,金属弹片会物理弹跳几次才稳定——反映到电平上,就是一串快速变化的高低电平:

如果不处理,FPGA 会在几毫秒内"看到"按键被按下了十几次,本来想按一次响一声,结果响了十几声。
消抖的思路很朴素:按键值必须稳定保持 20ms 以上才算有效。具体做法是"20ms 倒计时法":
- 检测到按键值发生变化(按下或松开)→ 计数器装入 100_0000(20ms),开始倒计时;
- 倒计时期间按键值又变了(抖动)→ 计数器重新装入 100_0000,从头再倒计时;
- 计数器顺利倒数到 1(说明电平稳稳保持了 20ms)→ 这次按键状态有效,把按键值输出。
先给按键信号打两拍(key_d0、key_d1),一是消除外部输入的亚稳态,二是通过比较 key_d0 和 key_d1 是否相等来判断"按键值是否变化"。

20ms 计数多少个时钟周期?20ms / 20ns = 100_0000 个,所以计数器定义为 20 位。
消抖模块代码:
module key_debounce(
input sys_clk ,
input sys_rst_n ,
input key , //外部输入的按键值
output reg key_filter //按键消抖后的值
);
parameter CNT_MAX = 20'd100_0000; //消抖时间20ms
reg [19:0] cnt;
reg key_d0; //按键信号延迟一个时钟周期
reg key_d1; //按键信号延迟两个时钟周期
//对按键打两拍(消除亚稳态)
always @(posedge sys_clk or negedge sys_rst_n) begin
if(!sys_rst_n) begin
key_d0 <= 1'b1;
key_d1 <= 1'b1;
end
else begin
key_d0 <= key;
key_d1 <= key_d0;
end
end
//按键值消抖:20ms倒计时
always @(posedge sys_clk or negedge sys_rst_n) begin
if(!sys_rst_n)
cnt <= 20'd0;
else begin
if(key_d1 != key_d0) //检测到按键状态变化
cnt <= CNT_MAX; //重新开始20ms倒计时
else begin //按键状态稳定
if(cnt > 20'd0)
cnt <= cnt - 20'd1;
else
cnt <= 20'd0;
end
end
end
//倒计时结束后输出消抖后的按键值
always @(posedge sys_clk or negedge sys_rst_n) begin
if(!sys_rst_n)
key_filter <= 1'b1;
else if(cnt == 20'd1) //20ms计时结束
key_filter <= key_d1;
else
key_filter <= key_filter;
end
endmodule
按键控制蜂鸣器:下降沿检测
消抖后的按键值 key_filter 是"电平"信号(按下期间一直是低电平),但我们的任务是"每按一次翻转一次",需要把它变成"事件"信号——这就用到了边沿检测。
方法:把 key_filter 延迟一拍存到 key_filter_d0,然后:
assign neg_key_filter = (~key_filter) & key_filter_d0;
当前值是低、上一拍是高,说明刚从高变低,即捕获到下降沿(对应"按下"这个动作)。这个信号一个时钟周期内为高,是一个脉冲。
module key_beep(
input sys_clk,
input sys_rst_n,
input key_filter, //消抖后按键值
output reg beep //蜂鸣器
);
reg key_filter_d0; //消抖后的按键值延迟一拍
wire neg_key_filter; //按键有效脉冲信号
//捕获按键的下降沿,得到一个时钟周期的脉冲
assign neg_key_filter = (~key_filter) & key_filter_d0;
always @(posedge sys_clk or negedge sys_rst_n) begin
if(!sys_rst_n)
key_filter_d0 <= 1'b1;
else
key_filter_d0 <= key_filter;
end
//每检测到一次有效按下,翻转蜂鸣器状态
always @(posedge sys_clk or negedge sys_rst_n) begin
if(!sys_rst_n)
beep <= 1'b1; //初始鸣叫
else if(neg_key_filter) //有效的一次按下
beep <= ~beep; //翻转
else
beep <= beep;
end
endmodule

顶层模块
顶层把两个子模块例化连接,注意 CNT_MAX 参数定义在顶层,通过例化传递给消抖模块——以后想改消抖时间只需改一处:
module top_key_beep(
input sys_clk , //系统时钟
input sys_rst_n, //系统复位,低电平有效
input key , //按键
output beep //蜂鸣器
);
parameter CNT_MAX = 20'd100_0000; //消抖时间20ms
wire key_filter; //按键消抖后的值
//例化按键消抖模块
key_debounce #(
.CNT_MAX (CNT_MAX)
) u_key_debounce(
.sys_clk (sys_clk),
.sys_rst_n (sys_rst_n),
.key (key),
.key_filter (key_filter)
);
//例化按键控制蜂鸣器模块
key_beep u_key_beep(
.sys_clk (sys_clk),
.sys_rst_n (sys_rst_n),
.key_filter (key_filter),
.beep (beep)
);
endmodule
仿真验证
Testbench 的精髓是模拟抖动:在真正的按下/松开前后插入几段快速翻转的短脉冲,来验证消抖逻辑能否滤掉它们。仿真时把 CNT_MAX 改成 10(200ns),加快仿真速度:
// 模拟按键:前抖动 → 有效按下 → 后抖动 → 有效释放
#20 key <= 1'b0; // ┐
#20 key <= 1'b1; // ┘ 前抖动(会被消抖滤除)
#50 key <= 1'b0; // ┐
#40 key <= 1'b1; // ┘ 抖动,倒计时被反复重置
#20 key <= 1'b0;
#300 key <= 1'b1; // 稳定,有效按下被识别

仿真波形验证了:倒计时期间出现抖动,cnt 会被重新赋值从头倒计时;只有电平稳定保持 200ns(对应实际的 20ms)后 key_filter 才更新。

蜂鸣器部分:每检测到一次 neg_key_filter 脉冲,beep 翻转一次。
20.5 下载验证
引脚约束:
| 信号名 | 方向 | 管脚 | 说明 |
|---|---|---|---|
| sys_clk | input | U18 | 系统时钟 50MHz |
| sys_rst_n | input | N16 | PL复位按键,低有效 |
| key | input | L14 | PL按键0 |
| beep | output | M14 | 蜂鸣器(高电平发声) |
create_clock -period 20.000 -name sys_clk [get_ports sys_clk]
set_property -dict {PACKAGE_PIN M14 IOSTANDARD LVCMOS33} [get_ports beep]
set_property -dict {PACKAGE_PIN L14 IOSTANDARD LVCMOS33} [get_ports key]
set_property -dict {PACKAGE_PIN U18 IOSTANDARD LVCMOS33} [get_ports sys_clk]
set_property -dict {PACKAGE_PIN N16 IOSTANDARD LVCMOS33} [get_ports sys_rst_n]
下载后:上电蜂鸣器鸣叫;按一次 PL_KEY0,蜂鸣器停止;再按一次恢复鸣叫,且每次按下只翻转一次(消抖生效)。
初学者容易踩的坑
- 以为按下一次就是一个干净的脉冲:实际按键前后都有几毫秒的机械抖动,不加消抖逻辑时"按一次"可能被识别成十几次,蜂鸣器状态会随机翻转。
- 消抖计数器方向搞混:本例是"检测到变化就重新倒计时",如果写成"检测到变化就开始正计时",抖动反而会被计入有效时间。
- 忘打两拍直接用外部输入:key 是异步输入,直接参与逻辑判断有亚稳态风险。必须先打两拍再做后续处理。
- 参数传错层:CNT_MAX 在顶层定义、通过例化传给子模块,若在仿真里直接改子模块内部参数而不改顶层传递值,会发现修改"不生效"。
- 蜂鸣器初始电平:本设计 beep 复位值是 1(上电就响),这是任务要求"初始鸣叫"。如果复位值写成 0,现象就变成"上电安静、按键才响",不要误判为程序错误。
- 下降沿检测表达式写反:
(~key_filter) & key_filter_d0是检测下降沿;写反成key_filter & (~key_filter_d0)就变成检测上升沿,按键"按下"会变成"松开"触发。
小结 & 下一步
本章掌握了三件事:蜂鸣器的分类与三极管驱动电路、按键消抖的"20ms 倒计时"实现、下降沿检测的经典写法(打一拍后与门拼接),并第一次采用了顶层 + 子模块的分层设计。这套"打两拍 → 消抖 → 边沿检测"的流水线在几乎所有的按键处理场景都能复用。下一步建议做拓展训练:把消抖模块扩展成支持两个按键同时消抖,任意一个按键按下都翻转蜂鸣器;也可以思考本章开头提到的触摸按键——下一章我们将不用机械按键,而是用触摸芯片来控制 LED。