简介:这份资源面向数字电路与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 = 20'd1_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 <= 8'hFF; key_r1 <= 8'hFF; 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 <= 20'd0; key_stable <= 8'hFF; end else if (cnt == CNT_20MS - 1) begin cnt <= 20'd0; key_stable <= key_r1; // 每 20ms 采样一次 end else begin cnt <= cnt + 1'b1; end end // 检测下降沿,输出单周期脉冲 always @(posedge clk or negedge rst_n) begin if (!rst_n) key_pulse <= 8'd0; 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.6Hz,50MHz 除以 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 = 18'd95556; localparam [17:0] TONE_RE = 18'd85131; localparam [17:0] TONE_MI = 18'd75843; localparam [17:0] TONE_FA = 18'd71586; localparam [17:0] TONE_SOL = 18'd63776; localparam [17:0] TONE_LA = 18'd56818; localparam [17:0] TONE_SI = 18'd50619; localparam [17:0] TONE_DO2 = 18'd47778; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin div_coef <= 18'd0; sound_en <= 1'b0; end else if (key_pulse != 8'd0) begin sound_en <= 1'b1; case (key_pulse) 8'b0000_0001: div_coef <= TONE_DO; 8'b0000_0010: div_coef <= TONE_RE; 8'b0000_0100: div_coef <= TONE_MI; 8'b0000_1000: div_coef <= TONE_FA; 8'b0001_0000: div_coef <= TONE_SOL; 8'b0010_0000: div_coef <= TONE_LA; 8'b0100_0000: div_coef <= TONE_SI; 8'b1000_0000: div_coef <= TONE_DO2; default: begin div_coef <= 18'd0; sound_en <= 1'b0; 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 <= 18'd0; buzzer <= 1'b0; end else if (!sound_en || div_coef == 18'd0) begin cnt <= 18'd0; buzzer <= 1'b0; // 不发声时拉低,避免直流偏置 end else if (cnt >= div_coef - 1'b1) begin cnt <= 18'd0; buzzer <= ~buzzer; // 计满翻转,产生方波 end else begin cnt <= cnt + 1'b1; 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 = 1'b0; always #10 clk = ~clk; initial begin rst_n = 1'b0; key_in = 8'hFF; #200; rst_n = 1'b1; // 模拟按下第一个键(Do) #1000; key_in = 8'hFE; // bit0 拉低 #50_000_000; // 等 50ms,观察稳定发声 key_in = 8'hFF; // 松手 #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 != 8'd0) begin rec_mem[wr_addr] <= key_to_index(key_pulse); wr_addr <= wr_addr + 1'b1; end end // 回放:按节拍读出编号,查表得系数 always @(posedge clk) begin if (play_en && beat_cnt == BEAT_MAX) begin beat_cnt <= 20'd0; div_coef <= index_to_coef(rec_mem[rd_addr]); rd_addr <= rd_addr + 1'b1; end else begin beat_cnt <= beat_cnt + 1'b1; end endkey_to_index和index_to_coef是两个查表函数,把 8 位独热码转成 3 位编号再转回分频系数,这样存储省了 15 位。BEAT_MAX控制每个音的长度,改它就能改节奏。这个改造的验证方法是先录一段音,回放时用仿真看div_coef序列是否和录音时一致,一致再上板听。
另一个方向是音色调节。现在输出的是方波,音色比较单薄,可以在tone_gen里加一个占空比寄存器,把固定 50% 占空比改成可调,不同占空比听感略有差异。再进一步可以加简单的包络,按键按下时音量渐强、松手渐弱,用一个计数器控制输出使能的占空比模拟。这些改动都不大,但能让电子琴听起来不那么“电子”。
我自己的习惯是每次改完先跑仿真对比波形,再上板听,绝不跳过仿真直接烧。有一次偷懒跳了仿真,结果 RAM 读写地址位宽不匹配,上板回放全是噪音,查了两个小时才发现是地址截断。从那以后我每次加存储类模块都强制先跑一遍读写时序仿真,确认地址和数据对齐再上板。希望这些思路帮到你,拿到工程先跑通单音,再动录音回放,一步一步来不容易翻车。
本文还有配套的精品资源,点击获取