第二十章:按键控制蜂鸣器实验

本章对应原书第 416~433 页,属于「第五篇 实战篇」。

本章在讲什么?(先看这个)

上一章直接用按键控制 LED,你可能在按按键时偶尔发现"按一次却触发了好几次"——这不是错觉,而是按键抖动在捣乱。本章的核心任务有两个:一是认识蜂鸣器(有源/无源的区别),二是学会按键消抖——这是嵌入式和 FPGA 领域的经典必修课。本章的工程第一次采用了"顶层 + 子模块"的分层设计:一个消抖模块、一个控制模块、一个顶层例化模块,这种模块化架构以后会一直沿用。

核心概念拆解

20.1 蜂鸣器简介

蜂鸣器是把电信号变成机械振动从而发声的器件。它有两个维度的分类:

按结构分:压电式和电磁式。

压电式蜂鸣器 电磁式蜂鸣器
原理 压电效应,交变电压使压电片振动 线圈产生磁场,驱动振动膜片
外观 圆形金属片结构,较薄 塑料外壳,内部有线圈,较厚
工作电压 较高(通常9V以上) 较低(1.5~12V)
工作电流 很小(<10mA) 较大(>20mA)
音质 尖锐刺耳 较柔和
适用 报警器 语音、音乐设备

本章使用电磁式蜂鸣器

按驱动方式分:有源和无源。这里的"源"指的是内部震荡源

  • 有源蜂鸣器:内部自带震荡电路,通电就响,给它直流电平就能发声,控制简单;
  • 无源蜂鸣器:内部没有震荡源,必须外部提供震荡信号(比如方波)才会响,好处是频率可调,能演奏音调。

区分小技巧:引脚朝上放置时,无源蜂鸣器能看到绿色电路板,有源蜂鸣器是黑胶封闭。本章用的是有源蜂鸣器,给高电平就响,给低电平就停。

图20.1.1 有源蜂鸣器(左)和无源蜂鸣器(右)

20.2 硬件设计

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

图20.2.2 蜂鸣器电路原理图

注意原理图里的三极管 Q1:ZYNQ 的 IO 引脚电流驱动能力有限(一般只有几毫安),而电磁式蜂鸣器工作电流要 20mA 以上,直接驱动带不动。所以电路里加了一个三极管做电流放大——IO 输出小电流控制三极管导通,由电源经三极管提供大电流驱动蜂鸣器。这就像用水龙头(IO)去控制水闸(三极管),水闸后面才是大水流(蜂鸣器)。

20.3 实验任务

用 PL_KEY0 按键控制蜂鸣器:上电后蜂鸣器鸣叫;按一次按键停止鸣叫;再按一次恢复鸣叫——即每按一次翻转一次状态。

20.4 程序设计

系统分三个模块:顶层模块 top_key_beep、按键消抖模块 key_debounce、按键控制蜂鸣器模块 key_beep

图20.4.1 按键控制蜂鸣器系统框图

按键消抖:为什么要消抖?

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

图20.4.4 按键输入波形图

如果不处理,FPGA 会在几毫秒内"看到"按键被按下了十几次,本来想按一次响一声,结果响了十几声。

消抖的思路很朴素:按键值必须稳定保持 20ms 以上才算有效。具体做法是"20ms 倒计时法":

  1. 检测到按键值发生变化(按下或松开)→ 计数器装入 100_0000(20ms),开始倒计时;
  2. 倒计时期间按键值又变了(抖动)→ 计数器重新装入 100_0000,从头再倒计时;
  3. 计数器顺利倒数到 1(说明电平稳稳保持了 20ms)→ 这次按键状态有效,把按键值输出。

先给按键信号打两拍(key_d0、key_d1),一是消除外部输入的亚稳态,二是通过比较 key_d0 和 key_d1 是否相等来判断"按键值是否变化"。

图20.4.5 按键消抖波形图

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

图20.4.7 按键控制蜂鸣器波形图

顶层模块

顶层把两个子模块例化连接,注意 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;   // 稳定,有效按下被识别

图20.4.10 按键消抖模块仿真图2

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

图20.4.12 按键控制蜂鸣器仿真波形图

蜂鸣器部分:每检测到一次 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。