FPGA电子琴设计:Verilog实现按键消抖与音阶分频
简介这份资源面向数字电路与FPGA入门学习者、电子类课程设计学生及Verilog自学者提供一套基于ALTERA FPGA平台、用Verilog实现的电子琴完整工程可用于理解音符生成、频率合成、振荡器与音效处理等模块的硬件逻辑组织方式并借助Quartus II完成综合、布局布线与下载验证。压缩包共69个文件约211KB以hdb、cdb等综合与编译中间数据库文件为主辅以rpt、summary报告、qmsg消息日志、qsf与pin约束文件以及sof、pof下载配置文件和顶层Verilog源文件完整保留了从工程配置到硬件烧录的流程痕迹。已有421人学习下载。读者可从中获得可综合的电子琴设计参考、Quartus工程配置范例与编译报告解读素材便于对照复现按键转音符、波形生成与音效处理的实现思路并借鉴资源分配与实时响应的优化经验。1. 一块开发板弹出《小星星》这个 FPGA 电子琴项目到底能干什么去年帮一个学弟看毕设他抱着一块 FPGA 开发板过来说想做个电子琴结果按键按下去只有电流声。我让他把工程打开发现按键消抖没做、音阶分频系数算错、蜂鸣器驱动极性反了三个问题叠在一起难怪只能听个响。后来把这三处改完板子通电按下第一个键一段《小星星》从无源蜂鸣器里出来他当场就笑了。这个基于 FPGA 的电子琴设计本质上就是干这件事用 Verilog 把按键输入、音阶分频、音频输出串成一条完整链路让一块 FPGA 开发板变成一台能弹的电子琴。它适合两类人。一类是正在做数字电路课程设计或毕设的学生需要一份结构清晰、能直接综合下载的 Verilog 工程另一类是想入门 FPGA 实际项目的开发者电子琴这个题目不大但按键消抖、分频器、状态机、顶层例化这些基本功一个不少拿来练手比点灯有意思得多。整个项目围绕 Verilog 展开核心是把 50MHz 系统时钟分频成不同音阶频率再驱动蜂鸣器发声技术栈不复杂但每一环都有讲究。2. 系统架构拆解从按键到发声的完整信号链路2.1 顶层模块划分与数据流这个电子琴的架构不复杂但模块边界要划清楚否则后面改音阶、加功能会互相牵扯。我一般把它拆成四个子模块加一个顶层按键消抖模块、音阶编码模块、分频发声模块、音频输出模块顶层负责把它们例化并连线。数据流是这样的机械按键按下后产生带抖动的电平信号先送进消抖模块输出一个干净的单周期脉冲音阶编码模块根据当前按下的键值查表得到对应的分频系数分频发声模块用系统时钟和分频系数产生对应频率的方波最后音频输出模块把方波送到蜂鸣器引脚。整条链路是纯组合逻辑加时序逻辑没有软核、没有 CPU全部用 Verilog 描述。// 顶层模块只做例化和连线不放具体逻辑 module piano_top( input wire clk, // 50MHz 系统时钟 input wire rst_n, // 低电平复位 input wire [7:0] key_in, // 8 个琴键低有效 output wire buzzer // 蜂鸣器输出 ); wire [7:0] key_pulse; // 消抖后的单周期脉冲 wire [17:0] div_coef; // 当前音阶分频系数 wire sound_en; // 发声使能 // 按键消抖 key_debounce u_debounce( .clk (clk), .rst_n (rst_n), .key_in (key_in), .key_pulse(key_pulse) ); // 音阶编码按键 - 分频系数 tone_encoder u_encoder( .clk (clk), .rst_n (rst_n), .key_pulse(key_pulse), .div_coef (div_coef), .sound_en (sound_en) ); // 分频发声 tone_gen u_tone( .clk (clk), .rst_n (rst_n), .div_coef (div_coef), .sound_en (sound_en), .buzzer (buzzer) ); endmodule这段顶层代码的关键在于端口命名和连线方向。key_in是 8 位输入对应 8 个琴键低电平有效是机械按键的常见接法按下接地。key_pulse是消抖后的脉冲宽度为一个时钟周期这样编码模块只需要检测脉冲沿就能知道哪个键被按下。div_coef用 18 位宽是因为 50MHz 分频到最低音阶时计数值会超过 16 位留两位余量避免溢出。sound_en控制是否发声没有按键按下时关掉输出避免蜂鸣器一直响。顶层不写逻辑是血泪经验。早期我把消抖逻辑直接写在顶层后来想加一个录音回放功能改得整个文件面目全非。拆成子模块后每个模块职责单一改音阶只动编码模块改消抖时间只动消抖模块互不影响。2.2 按键消抖模块的时序设计机械按键的抖动是绕不开的问题。按下瞬间触点会在几毫秒内反复通断如果直接拿这个信号去触发音阶按一次键可能切换好几个音。常见做法是做一个 20ms 的计时窗口窗口内电平稳定才认为是有效按键。module key_debounce( input wire clk, input wire rst_n, input wire [7:0] key_in, output reg [7:0] key_pulse ); parameter CNT_20MS 20d1_000_000; // 50MHz 下 20ms 计数值 reg [19:0] cnt; reg [7:0] key_r0, key_r1; // 两级同步寄存器 reg [7:0] key_stable; // 稳定后的键值 // 两级寄存器同步消除亚稳态 always (posedge clk or negedge rst_n) begin if (!rst_n) begin key_r0 8hFF; key_r1 8hFF; end else begin key_r0 key_in; key_r1 key_r0; end end // 20ms 计时与稳定值采样 always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 20d0; key_stable 8hFF; end else if (cnt CNT_20MS - 1) begin cnt 20d0; key_stable key_r1; // 每 20ms 采样一次 end else begin cnt cnt 1b1; end end // 检测下降沿输出单周期脉冲 always (posedge clk or negedge rst_n) begin if (!rst_n) key_pulse 8d0; else key_pulse key_stable (~key_r1); // 上一拍高、当前拍低 end endmodule这里有几个参数值得说清楚。CNT_20MS取 1_000_000是因为 50MHz 时钟下 20ms 对应 1,000,000 个周期这个值直接决定消抖窗口长度改大更稳但响应变慢改小响应快但可能漏掉抖动。两级寄存器key_r0、key_r1做同步是因为按键信号相对系统时钟是异步的不同步直接采样会出亚稳态这是 FPGA 设计的常识新手容易忽略。key_pulse的生成逻辑是key_stable (~key_r1)意思是稳定值当前为低、上一拍为高说明刚按下输出一个脉冲。注意这里用的是key_r1而不是key_stable的上一拍因为key_stable每 20ms 才更新一次用它做边沿检测会漏掉快速按键。这个细节我调了半天才想明白仿真波形一看就清楚了。2.3 音阶编码与分频系数计算音阶编码的核心是一张查找表每个按键对应一个分频系数分频系数决定输出方波的频率。中央 C 是 261.6Hz50MHz 除以 261.6 约等于 191,000但方波需要高低电平各翻转一次所以实际计数值是频率对应周期的一半。module tone_encoder( input wire clk, input wire rst_n, input wire [7:0] key_pulse, output reg [17:0] div_coef, output reg sound_en ); // 音阶分频系数表50MHz / (2 * 目标频率) // Do Re Mi Fa Sol La Si Do localparam [17:0] TONE_DO 18d95556; localparam [17:0] TONE_RE 18d85131; localparam [17:0] TONE_MI 18d75843; localparam [17:0] TONE_FA 18d71586; localparam [17:0] TONE_SOL 18d63776; localparam [17:0] TONE_LA 18d56818; localparam [17:0] TONE_SI 18d50619; localparam [17:0] TONE_DO2 18d47778; always (posedge clk or negedge rst_n) begin if (!rst_n) begin div_coef 18d0; sound_en 1b0; end else if (key_pulse ! 8d0) begin sound_en 1b1; case (key_pulse) 8b0000_0001: div_coef TONE_DO; 8b0000_0010: div_coef TONE_RE; 8b0000_0100: div_coef TONE_MI; 8b0000_1000: div_coef TONE_FA; 8b0001_0000: div_coef TONE_SOL; 8b0010_0000: div_coef TONE_LA; 8b0100_0000: div_coef TONE_SI; 8b1000_0000: div_coef TONE_DO2; default: begin div_coef 18d0; sound_en 1b0; end endcase end end endmodule分频系数的算法是50_000_000 / (2 * f)。以 Do 为例261.6Hz 对应 50,000,000 / 523.2 ≈ 95,565取 95,556 是四舍五入后的值误差在千分之一以内人耳听不出来。Re 是 293.7Hz算出来约 85,131依此类推。这些数值不是拍脑袋写的每个都要按公式算算错了音准就偏弹出来像跑调。key_pulse是 8 位独热码一次只有一个位为高所以case语句直接匹配。sound_en在检测到按键时置高没有按键时保持上一次的值这样松手后声音会持续到下一次按键实际弹奏时更连贯。如果想要松手即停可以把sound_en改成跟随按键电平但那样会有尾音截断的问题看具体需求取舍。2.4 分频发声与蜂鸣器驱动分频发声模块拿到div_coef后用一个计数器循环计数计到系数值就翻转输出电平产生方波。无源蜂鸣器需要方波驱动有源蜂鸣器给高电平就响这个项目用的是无源蜂鸣器所以必须输出方波。module tone_gen( input wire clk, input wire rst_n, input wire [17:0] div_coef, input wire sound_en, output reg buzzer ); reg [17:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 18d0; buzzer 1b0; end else if (!sound_en || div_coef 18d0) begin cnt 18d0; buzzer 1b0; // 不发声时拉低避免直流偏置 end else if (cnt div_coef - 1b1) begin cnt 18d0; buzzer ~buzzer; // 计满翻转产生方波 end else begin cnt cnt 1b1; end end endmodulecnt计到div_coef - 1时翻转buzzer这样每个半周期正好是div_coef个时钟输出频率就是50MHz / (2 * div_coef)和编码模块的公式对上了。sound_en为低时把buzzer拉低这一步很重要如果悬空或者保持高电平蜂鸣器可能会有微弱直流声听起来像底噪。蜂鸣器驱动能力有限FPGA 的 IO 口直接驱动小功率无源蜂鸣器一般没问题但如果蜂鸣器功率稍大建议加一个三极管或者 MOS 管做电流放大否则 IO 口可能带不动声音小甚至不响。这是硬件上的坑软件仿真看不出来上板才发现。3. 从零跑通工程搭建、综合与上板验证3.1 工程目录与文件组织拿到源码后别急着打开综合工具先把目录理清楚。我习惯按功能分目录而不是把所有.v文件堆在一个文件夹里后期改起来找文件会快很多。fpga_piano/ ├── rtl/ │ ├── piano_top.v # 顶层 │ ├── key_debounce.v # 按键消抖 │ ├── tone_encoder.v # 音阶编码 │ └── tone_gen.v # 分频发声 ├── sim/ │ └── tb_piano_top.v # 仿真测试平台 ├── constr/ │ └── piano.xdc # 引脚约束 └── doc/ └── pin_map.md # 引脚分配说明rtl放综合用的设计文件sim放测试平台constr放约束文件doc放引脚说明。这个结构不强制但比全平铺强。综合工具对目录层级没有要求只要在工程里把文件加进去就行关键是别把测试平台也加进综合列表否则会报模块重复定义。3.2 引脚约束与时钟配置约束文件是上板能不能跑的关键。不同开发板的引脚编号不一样必须按自己板子的原理图改。下面是一个示例约束用的是常见的 50MHz 晶振和 8 个独立按键。# 系统时钟 50MHz set_property PACKAGE_PIN E3 [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports clk] create_clock -period 20.000 -name sys_clk [get_ports clk] # 复位按键 set_property PACKAGE_PIN J15 [get_ports rst_n] set_property IOSTANDARD LVCMOS33 [get_ports rst_n] # 8 个琴键 set_property PACKAGE_PIN L1 [get_ports {key_in[0]}] set_property PACKAGE_PIN M1 [get_ports {key_in[1]}] set_property PACKAGE_PIN N1 [get_ports {key_in[2]}] set_property PACKAGE_PIN P1 [get_ports {key_in[3]}] set_property PACKAGE_PIN R1 [get_ports {key_in[4]}] set_property PACKAGE_PIN T1 [get_ports {key_in[5]}] set_property PACKAGE_PIN U1 [get_ports {key_in[6]}] set_property PACKAGE_PIN V1 [get_ports {key_in[7]}] set_property IOSTANDARD LVCMOS33 [get_ports {key_in[*]}] # 蜂鸣器 set_property PACKAGE_PIN H1 [get_ports buzzer] set_property IOSTANDARD LVCMOS33 [get_ports buzzer]create_clock那行告诉工具系统时钟是 50MHz周期 20ns时序分析靠它。如果板子晶振不是 50MHz比如 100MHz那所有分频系数都要重算CNT_20MS也要改成 2_000_000这一步漏了音准全错。引脚编号必须对照原理图写错了轻则不响重则 IO 冲突。3.3 仿真验证与上板调试上板之前先跑仿真能省很多来回烧录的时间。测试平台给一个按键脉冲看buzzer有没有按预期频率翻转。timescale 1ns / 1ps module tb_piano_top; reg clk; reg rst_n; reg [7:0] key_in; wire buzzer; piano_top u_dut( .clk (clk), .rst_n (rst_n), .key_in (key_in), .buzzer (buzzer) ); // 50MHz 时钟 initial clk 1b0; always #10 clk ~clk; initial begin rst_n 1b0; key_in 8hFF; #200; rst_n 1b1; // 模拟按下第一个键Do #1000; key_in 8hFE; // bit0 拉低 #50_000_000; // 等 50ms观察稳定发声 key_in 8hFF; // 松手 #1_000_000; $stop; end endmodule仿真时重点看两个地方一是key_pulse是不是在按下后约 20ms 出现一个单周期脉冲二是buzzer的翻转周期是不是约 3.8ms对应 261.6Hz 的半周期。如果key_pulse一直不出现检查消抖计数器和边沿检测逻辑如果buzzer频率不对回头核对分频系数。上板后如果没声音按这个顺序排查先量蜂鸣器两端有没有方波没有就查约束和引脚有方波但声音很小查蜂鸣器是有源还是无源有源蜂鸣器给方波反而声音异常声音断续查按键接触和消抖窗口是否太短。4. 避坑与排查上板没声音、音准跑偏的常见问题4.1 按键按下去没反应仿真却正常现象是仿真波形里key_pulse正常出现上板按键却毫无反应。原因多半是引脚约束和实际按键接法不匹配。机械按键常见两种接法按下接地低有效和按下接电源高有效。代码里默认低有效如果板子是高有效key_stable永远是高边沿检测永远不触发。解决办法是看原理图确认按键接法高有效的话在消抖模块输入前加一级取反或者把边沿检测改成上升沿。4.2 有声音但音准明显跑偏现象是能响但弹出来不像 Do Re Mi整体偏高或偏低。原因通常是分频系数按错误的系统时钟算的。比如板子实际是 100MHz 晶振代码里还按 50MHz 算系数输出频率直接翻倍。解决办法是先确认板子晶振频率再按f_clk / (2 * f_tone)重算所有系数同时把消抖计数器的CNT_20MS按新时钟改掉。这个错误很隐蔽因为声音是有的只是不对新手容易以为是蜂鸣器问题。4.3 蜂鸣器一直响或者有底噪现象是没按键时蜂鸣器也有微弱声音或者按键松开后一直响。原因是sound_en逻辑或者输出极性处理不当。如果buzzer在不发声时保持高电平无源蜂鸣器会有直流通过产生底噪如果sound_en松手后不清零声音会一直持续。解决办法是在tone_gen里加!sound_en时强制拉低buzzer并在编码模块里根据需求决定松手是否清零sound_en。想要松手即停就把sound_en跟随按键电平想要延音就保持。4.4 综合报时序违例或者资源不够现象是综合能过但时序报告有违例或者布局布线失败。这个设计逻辑很简单正常不会资源不够违例多半是约束文件里时钟没定义或者定义错了。检查create_clock的端口名和周期是否和实际一致周期写错会导致工具按错误频率分析。另外如果用了多个时钟域比如按键用了另一个时钟要做跨时钟域处理这个项目单时钟一般不会遇到。4.5 多个按键同时按下时声音异常现象是同时按两个键声音变成噪音或者某个音占主导。原因是key_pulse是独热码假设同时按下时多个位为高case语句匹配不到任何分支走default把div_coef清零。解决办法是在编码模块里加优先级比如用casez或者按位或的方式取最低有效位保证同时按下时只响应一个键。电子琴实际弹奏很少同时按但测试时容易踩到。5. 二次开发加录音回放和音色调节的改造思路这个工程跑通之后最自然的改造方向是加录音回放。思路不复杂在tone_encoder后面挂一个 RAM按键时把div_coef按时间顺序写进去回放时按同样的节奏读出来送给tone_gen。关键是要有一个节拍计数器记录每个音持续了多少个时钟周期回放时按计数值切换。RAM 深度取决于要录多长8 个音、每个音 0.5 秒、50MHz 时钟下计数值很大实际存储的是压缩后的音阶编号而不是原始计数值回放时再查表还原这样 1KB 的 RAM 就能录几十个音。// 录音回放核心思路存音阶编号回放时查表 reg [2:0] rec_mem [0:1023]; // 存音阶编号 0~7 reg [9:0] wr_addr, rd_addr; reg [19:0] beat_cnt; // 节拍计数 reg rec_en, play_en; // 录音按键时写入编号 always (posedge clk) begin if (rec_en key_pulse ! 8d0) begin rec_mem[wr_addr] key_to_index(key_pulse); wr_addr wr_addr 1b1; end end // 回放按节拍读出编号查表得系数 always (posedge clk) begin if (play_en beat_cnt BEAT_MAX) begin beat_cnt 20d0; div_coef index_to_coef(rec_mem[rd_addr]); rd_addr rd_addr 1b1; end else begin beat_cnt beat_cnt 1b1; end endkey_to_index和index_to_coef是两个查表函数把 8 位独热码转成 3 位编号再转回分频系数这样存储省了 15 位。BEAT_MAX控制每个音的长度改它就能改节奏。这个改造的验证方法是先录一段音回放时用仿真看div_coef序列是否和录音时一致一致再上板听。另一个方向是音色调节。现在输出的是方波音色比较单薄可以在tone_gen里加一个占空比寄存器把固定 50% 占空比改成可调不同占空比听感略有差异。再进一步可以加简单的包络按键按下时音量渐强、松手渐弱用一个计数器控制输出使能的占空比模拟。这些改动都不大但能让电子琴听起来不那么“电子”。我自己的习惯是每次改完先跑仿真对比波形再上板听绝不跳过仿真直接烧。有一次偷懒跳了仿真结果 RAM 读写地址位宽不匹配上板回放全是噪音查了两个小时才发现是地址截断。从那以后我每次加存储类模块都强制先跑一遍读写时序仿真确认地址和数据对齐再上板。希望这些思路帮到你拿到工程先跑通单音再动录音回放一步一步来不容易翻车。本文还有配套的精品资源点击获取