ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FPGA电子琴设计:分频计数与按键消抖的完整工程实践

FPGA电子琴设计:分频计数与按键消抖的完整工程实践

简介:基于FPGA的电子琴设计完整工程包,面向数字逻辑与EDA课程设计人群,主要解决电子琴发音、按键选曲和音调控制等典型教学问题。压缩包内共133个文件,约7.11MB,以Verilog源码(.v)、Quartus工程配置(.qpf/.qsf)、下载配置文件(.sof/.pof)为核心,另含docx格式的设计报告、mif波形初始化数据、多张原理示意图和仿真结果图,以及综合布局布线中间文件,属于可直接运行的完整工程。目录结构清晰,便于打开后对照原理图、代码和报告逐步学习。目前已有1341人学习下载。通过该资源可深入理解音调分频、按键消抖与扫描、蜂鸣器驱动等FPGA实现细节,多个Verilog子模块分别负责发声、选择等逻辑,层次分明。同时借助配套论文报告可梳理完整设计流程与调试思路,适合作为课程设计参考或入门练手素材。

1. 基于 FPGA 的电子琴设计:从分频计数到真实发声的完整链路

做 FPGA 开发的人大多有个共同经历:Verilog 语法背得滚瓜烂熟,LED 流水灯仿真也跑通了,可一旦想把按键输入变成真实的声音,就不知道该从哪里下手。这套基于 FPGA 的电子琴设计.zip 解决的正是这个衔接问题——它把音阶频率生成、按键扫描、音频驱动串成一条完整链路,拿到手不是看一遍就丢的代码,而是能直接烧到开发板上弹出调子的工程。适合刚入门 FPGA、手头有一块带无源蜂鸣器或音频接口的开发板、想系统接触外设驱动和时序设计的开发者。我就按自己拆这类工程的顺序,从原理讲到上板,把参数和坑一起交代清楚。

2. 音调生成原理:为什么电子琴的核心是一张频率表

2.1 从音符到频率:十二平均律与计数器分频的换算

电子琴发声的本质是让扬声器按特定频率振动。标准音 A4(钢琴第 49 键)频率为 440Hz,其他音符的频率由十二平均律公式给出:

f(n) = 440 × 2^((n−69)/12)

其中 n 是 MIDI 音符编号,C4 对应 n=60,D4 对应 n=62。做 FPGA 实现时,最常见的做法不是去算浮点对数,而是预先算好每个音符对应的频率,再换算成时钟分频的计数值,存成一张查找表。

以 25MHz 系统时钟、输出 50% 占空比方波为例,计数器在每个时钟上升沿累加,当计满 N 时翻转输出电平。这样输出频率为:

f_out = f_clk / (2 × (N+1))

反推计数值:

N = f_clk / (2 × f_out) − 1

以 C4(261.63Hz)计算:

N = 25000000 / (2 × 261.63) − 1 ≈ 47777 − 1 = 47776

用 47776 这个整数算出的实际频率约为 261.60Hz,偏差不到 0.02%,人耳完全听不出来。下面这张表是 C 大调一个八度内常用音符的计算结果,其中计数值按 25MHz 时钟取整:

音名MIDI 编号标准频率 (Hz)分频计数值 N(25MHz)计数器位宽建议
C460261.634777616 bit
D462293.664256616 bit
E464329.633792116 bit
F465349.233578916 bit
G467392.003188816 bit
A469440.002840915 bit
B471493.882530915 bit
C572523.252388915 bit

注意最后一列:计数值最大 47776,小于 2 的 15 次方(32768)吗?不,47776 大于 32768,所以至少需要 16 位计数器;而低音区音符频率更低、计数值更大,往下扩展音域时建议统一用 17 位计数器,留出余量。我一般会直接定义reg [17:0] cnt,避免后面加低音时位数不够导致上板后“高音正常、低音不出声”的诡异现象。

2.2 方案选型:计数器分频还是 DDS 相位累加

我知道有人会问:都用 DDS(直接数字频率合成)时代了,计数器分频是不是太土了?两种方案在电子琴场景下各有适用边界。

计数器分频的优点在于逻辑简单、输出频率精确可控,每个音符的计数值是常量,用 case 语句一查即得,非常适合按键数量固定、音域窄的入门设计。缺点是一次只能输出一个频率,想同时发两个音(和弦)就必须并行复制多套计数器,逻辑资源呈线性增长。

DDS 相位累加方案则是维护一个 N 位相位累加器,每个时钟沿加上频率控制字 FWORD = f_out × 2^N / f_clk,取累加器最高位作为方波输出。它的频率分辨率极高,更改输出频率只是改一个常数,不需要复位计数器,也不存在“计数器溢出时输出毛刺”的问题。但代价是相位累加器至少做 24 位以上才听得出顺滑的音高变化,综合资源占用比分频方案高不少。

我的建议是:如果压缩包里的工程目标是低端入门板、资源紧张,选计数器分频;如果你打算后续加入滑音、颤音或自动伴奏,趁早换成 DDS 结构。这套设计.zip 里默认是计数器分频实现,它在教学和基础弹奏场景下足够用,而且调试时可以直接数波形周期验证音高,对新手更友好。

2.3 按键扫描与消抖:边沿检测比电平检测更可靠

按键处理不能只做“按下时输出对应频率”这种电平判断,机械按键按下和释放的瞬间会产生 5~10ms 的抖动,直接判断电平会导致音头出现爆音、音高飘忽。常见做法是先用一个定时器做消抖:每 1ms 采样一次按键电平,连续采样到 20 次稳定状态才确认按键变化,然后把消抖后的信号送进边沿检测逻辑。

边沿检测的思路是打两拍:

reg key_sync1, key_sync2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin key_sync1 <= 1'b1; key_sync2 <= 1'b1; end else begin key_sync1 <= key_in[0]; // 第一拍同步 key_sync2 <= key_sync1; // 第二拍消除亚稳态 end end wire key_falling = key_sync2 & !key_sync1; // 下降沿脉冲

第二拍打出来的key_sync2是彻底同步到系统时钟域的信号,key_falling在高电平有效时为单周期脉冲,代表“这个键刚刚按下”。把派生出来的key_pressed送到音调模块作为 enable 信号,而不是直接接线——这是整个工程不出怪声的关键习惯。

3. 模块实现:分频器、按键扫描与顶层连线

3.1 顶层模块:先规划端口,再谈内部逻辑

拿到工程后别急着看代码,先把顶层端口理清楚。一个最小可用的电子琴系统需要三类端口:时钟与复位、按键输入、音频输出。下面是一个 4 键版本(C4 / D4 / E4 / F4)的顶层框架:

module top_organ ( input wire clk_25m, // 25MHz 系统时钟 input wire rst_n, // 低电平复位 input wire [3:0] key_in, // 4 个按键,低电平有效 output wire audio_out // 方波音频输出 );

按键低电平有效是因为开发板上的按键通常一端接地、一端接 FPGA 引脚,按下时引脚被拉低。audio_out建议直接接到无源蜂鸣器的驱动三极管基极,或者经过一个 RC 低通滤波后接音频功放,不要裸接耳机。

3.2 音符分频器:查找表驱动计数器翻转

下面这段代码是整套工程的核心。它根据按键输入从查找表中取分频计数值,计数器累加到目标值后翻转输出,同时计数器清零:

module note_divider ( input wire clk, input wire rst_n, input wire [3:0] key_in, // 4 位按键编码 output reg audio_out ); reg [17:0] cnt; reg [17:0] div_val; // 当前音符对应的分频计数值 // 音符查表:按键编号 -> 分频计数值 always @(*) begin case (key_in) 4'b1110: div_val = 47776; // C4 4'b1101: div_val = 42566; // D4 4'b1011: div_val = 37921; // E4 4'b0111: div_val = 35789; // F4 default: div_val = 18'd0; // 无按键,静音 endcase end always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 18'd0; audio_out <= 1'b0; end else if (div_val == 18'd0) begin cnt <= 18'd0; audio_out <= 1'b0; // 无按键时输出低电平 end else if (cnt >= div_val - 1) begin cnt <= 18'd0; audio_out <= ~audio_out; // 分频翻转 end else begin cnt <= cnt + 1; end end endmodule

逻辑说明:div_val == 0的分支强制静音,这是很多人容易漏的细节。如果把无按键时的计数值也当成合法数据,计数器会持续翻转,开发板上电就会听到一个固定频率的底噪。查找表用了case的默认项兜底,比单独写if (key_in != 4'b1111)更稳妥。

参数注意点:计数器位宽选了 18 位,覆盖到 65536 以上。如果只做 C4 到 C5 这一个八度,用 16 位就能跑,但一旦后续加低音 A2(110Hz),计数值要到 113636,必须扩到 18 位以上。养成习惯,位宽宁大勿小。

3.3 按键消抖模块:定时采样替代延时阻塞

上一章说消抖要用定时采样,这里给出实际模块。它每 1ms 采样一次按键电平,连续 20 次相同才送出结果:

module key_debounce #( parameter SAMPLE_CNT = 25_000, // 1ms 采样间隔(25MHz 时钟) parameter STABLE_NUM = 20 // 连续稳定次数 )( input wire clk, input wire rst_n, input wire [3:0] key_in_raw, output reg [3:0] key_out ); reg [24:0] sample_cnt; reg sample_pulse; reg [4:0] stable_cnt; reg [3:0] key_prev; // 1ms 采样脉冲生成 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sample_cnt <= 0; sample_pulse <= 0; end else if (sample_cnt >= SAMPLE_CNT - 1) begin sample_cnt <= 0; sample_pulse <= 1; end else begin sample_cnt <= sample_cnt + 1; sample_pulse <= 0; end end // 连续 20 次一致才更新输出 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin stable_cnt <= 0; key_prev <= 4'b1111; key_out <= 4'b1111; end else if (sample_pulse) begin if (key_in_raw == key_prev) begin if (stable_cnt >= STABLE_NUM) key_out <= key_in_raw; else stable_cnt <= stable_cnt + 1; end else begin stable_cnt <= 0; key_prev <= key_in_raw; end end end endmodule

参数说明:SAMPLE_CNT决定了消抖的时间粒度,25MHz 时钟下数到 24999 恰好 1ms。STABLE_NUM是连续稳定次数,20ms 的消抖窗口基本能滤掉市面上大多数轻触按键的抖动毛刺。如果你用的按键品质差或者老化,可以把STABLE_NUM提到 30,代价是按下到发声的响应延迟会多 10ms——弹快速音阶时手感肉,不建议调太大。

3.4 多按键优先级:给“同时按下”一个明确规则

简洁的电子琴设计总会遇到一个问题:两个键同时按下,到底发哪个音?常见做法有两种:一是直接禁止,按键扫描结果只取第一个检测到的按键;二是做优先级,比如低音优先或高音优先。我在前面分频器代码里用的是case顺序查找,4 个 key 编码互斥,本质上是靠按键编码的自然优先级。如果按键编码不是互斥的(比如要做和弦),就得引入优先级编码器:

reg [3:0] key_prio_out; always @(*) begin if (!key_in[3]) key_prio_out = 4'b0111; // 最高位按键优先 else if (!key_in[2]) key_prio_out = 4'b1011; else if (!key_in[1]) key_prio_out = 4'b1101; else if (!key_in[0]) key_prio_out = 4'b1110; else key_prio_out = 4'b1111; // 静音 end

这段代码把“F4 键优先于 C4”的规则写得很直白,优先级从高到低对应按键编号从高位到低位。工程里如果发现某个键按下时高频优先抢音,就是这里优先级顺序的问题,调整if分支顺序即可,不需要动分频器。

4. 综合与验证:仿真确认频率,约束决定成败

4.1 Testbench 设计:如何不靠耳朵验证音高

上板之前一定要先做仿真。理由很现实:人耳对 261Hz 和 280Hz 的差别不一定听得出来,但仿真器能用波形精确告诉你分频结果对不对。下面这个 Testbench 模拟按下 C4 键 2ms,然后测量audio_out的周期:

module tb_top_organ; reg clk_25m; reg rst_n; reg [3:0] key_in; wire audio_out; top_organ uut ( .clk_25m (clk_25m), .rst_n (rst_n), .key_in (key_in), .audio_out (audio_out) ); initial begin clk_25m = 0; rst_n = 0; key_in = 4'b1111; #100 rst_n = 1; #1000 key_in = 4'b1110; // 按下 C4 #2_000_000 $finish; // 仿真 2ms,足够看到多个周期 end always #20 clk_25m = ~clk_25m; // 25MHz 时钟,周期 40ns // 统计 audio_out 周期数,打印每个边沿间隔 always @(posedge audio_out) begin $display("Time = %0t, audio_out rising edge", $time); end endmodule

逻辑说明:#20翻转一次时钟,周期正好 40ns,对应 25MHz。$display在每个输出上升沿打印当前仿真时间,相邻两次打印的时间差就是输出方波周期。如果 2ms 内打印的时间戳间隔约 3823ns,说明频率约 261.6Hz,C4 音高无误。

仿真平台选择上,Vivado 自带行为仿真即可,开源的 Icarus Verilog + GTKWave 也能跑这套代码。我一般会把audio_out添加为模拟信号观察,能直观看到占空比是不是 50%。如果看到输出波形只有 1 个时钟周期宽的高电平,基本可以断定是查找表分支写错,div_val取到了 1 或者 0。

4.2 时序约束与引脚分配:大多数人翻车的重灾区

仿真过了不代表板子会响。一套工程落地的关键一步是物理约束。首先要在约束文件里分配按键和音频输出引脚,下面是一段示例格式的引脚约束(具体引脚号以你的开发板手册为准):

set_property PACKAGE_PIN K17 [get_ports clk_25m] set_property PACKAGE_PIN L14 [get_ports rst_n] set_property PACKAGE_PIN M12 [get_ports key_in[0]] set_property PACKAGE_PIN M11 [get_ports key_in[1]] set_property PACKAGE_PIN N12 [get_ports key_in[2]] set_property PACKAGE_PIN N11 [get_ports key_in[3]] set_property PACKAGE_PIN P15 [get_ports audio_out] set_property IOSTANDARD LVCMOS33 [get_ports {key_in[*] audio_out}]

参数说明:IOSTANDARD LVCMOS33是绝大多数入门开发板上 IO 口的电平标准,如果你的板子供电是 2.5V,要改成对应标准,否则波形无法达到逻辑高电平阈值。对系统时钟不建议加false_path或max_delay约束,电子琴里的分频逻辑本质都是同步时序,保持默认的create_clock即可:

create_clock -period 40.000 -name sys_clk [get_ports clk_25m]

如果你用的开发板时钟频率不是 25MHz(常见还有 50MHz),必须回头改第 2 章那张表里的每个计数值。有人直接把 47776 当作“魔法数字”沿用,结果 50MHz 板子上出来的音全高了一个八度,那不是电路问题,是参数没迁移。

4.3 上板烧录:从 bit 流到第一声响的检查顺序

烧录后如果没声音,不要急着改代码,按这个顺序排查效率最高:

  1. 确认音频输出引脚接到了蜂鸣器正极,且蜂鸣器另一端有驱动电路(三极管或 MOS 管),FPGA IO 直驱无源蜂鸣器的电流往往不够。
  2. 用万用表量蜂鸣器两端电压,按下按键时应该能看到方波信号,如果恒为高或恒为低,说明分频器没跑起来。
  3. 确认复位按键状态。很多开发板的复位是高有效,但你代码里写的是低有效复位,焊盘默认上拉导致一直处于复位状态。

最后一点看似低级,实际是这类工程最常见的“上电无声”原因。我会在顶层模块里加一个initial快照打印,把复位释放后的audio_out初始值打出来,配合串口调试能看到模块内部状态,比瞎猜高效得多。

5. 避坑指南:五个真实踩坑记录

5.1 现象:上电后蜂鸣器一直响,按键没反应

原因:按键输入高电平有效还是低电平有效搞反了。多数开发板按键按下接地,而我在第 3 章顶层里写的是低电平有效,两者恰好一致;但如果你的板子按键按下接 3.3V,代码里的key_in == 4'b1110永远不成立,div_val落到 default 分支的 0,本应静音,可如果 default 分支写成了某个音符值,就会变成一直响。

解决:先查原理图确认按键有效电平,再统一修改顶层里按键编号的注释和查找表条件。不要试图在消抖模块里反转电平,那样会把“按下”和“释放”的语义搞乱。

5.2 现象:按下按键,发出的声音频率偏了将近一个八度

原因:系统时钟频率和计数值表不匹配。25MHz 时钟配 50MHz 的计数值,或者反过来,都会让音高整体偏移 2 倍左右。这类问题在仿真里通常看不出来,因为仿真只验证了逻辑行为没有验证真实时间。

解决:回看开发板原理图确认晶振频率。如果板子是 50MHz,最省事的办法是加一个 PLL 把时钟降到 25MHz,或者在代码里把always @(posedge clk)的触发沿保留,但把所有分频计数值全部乘 2。后者改动量小但要注意计数器位宽是否够,30 万以上的计数需要 19 位。

5.3 现象:单按一个键正常,两个键快速切换时出现爆音

原因:波形切换发生在音频输出电平的任意相位点,没有对齐到输出波形边沿。计数器分频切换音符的瞬间,如果此刻输出电平恰好是低电平,波形会产生一个非整数倍的截断,听起来就是“啪”的一声。

解决:在音符切换时把计数器清零:

always @(posedge clk) begin if (key_in != key_in_prev) begin cnt <= 0; audio_out <= 0; end end

但这样又引入一个新的爆音:输出强制拉低。更好的办法是只在音符从无到有时做静音过渡,从有到有时不用清计数器。工程里这套逻辑如果比较混乱,我的建议是加一个 1ms 的软启动使能,音符切换时先静音 1ms 再输出新频率,爆音基本听不见。

5.4 现象:综合报告显示逻辑单元占用率超过了 80%

原因:计数器位宽定义成了 32 位,或者每颗音符都单独建了一个分频器实例。32 位加法器在 FPGA 里并不贵,但 4 个 32 位计数器加上多路选择,资源就上去了。

解决:把音符查表从每个分频器内部复制改成顶层统一查表,只保留一个分频器实例。位宽统一用 18 位,支持到 100kHz 以下的音频范围足够了。如果要做多音,优先考虑共享查找表 + 仲裁器,而不是复制模块。

5.5 现象:仿真波形完美,上板后按第一下无声,按第二下才响

原因:复位释放后按键模块的key_prev初始值是 4'b1111,可实际按键初始状态也是高电平(未按下),看起来没问题;但如果你代码里写的是 4'b0000,第一次按键按下时会先经历一次“从 0000 到 1110”的状态跳变,被消抖模块当作释放处理。

解决:检查消抖模块里key_prev的初始值,必须和按键的默认电平一致。这个问题很难通过仿真发现,因为仿真里rst_n释放后按键状态已经稳定,实板上电瞬间的电平建立过程却不受控制。我的习惯是在顶层里把按键输入经过一个 10ms 的延时寄存器组再送进消抖模块,确保上电后读到的第一个电平稳定而真实。

6. 进阶技巧:从方波到正弦波,再支持多音和弦

6.1 用 PWM 改善音色:方波太硬,占空比渐变更有味道

计数器分频输出的 50% 方波在蜂鸣器上听感偏“硬”,适合做提示音,弹旋律久了耳朵累。一个低成本的改善是把占空比调成 25% 甚至 10%,听感会柔和不少。实现方式是在分频计数器基础上再增加一个比较阈值:

// 占空比可调的方波输出,DUTY = 256 对应 100% reg [7:0] pwm_cnt; assign audio_out = (pwm_cnt < DUTY_VALUE) ? 1'b1 : 1'b0;

这里DUTY_VALUE设为 64,占空比即为 25%。注意这只是改变了波形占空比,没有改变频率,音高不受影响。更饱满的音色需要查正弦表,用 ROM 存一个周期的正弦采样点,按音符频率作为采样步进读出,配上后面一个简单 RC 低通滤波就能做出接近正弦的音色。工程压缩包里如果提供了 sine_lut.mif 文件,那说明作者已经帮你把这一步做好了,上板时别忘了把 .mif 文件路径加入工程,否则 ROM 初始化会失败。

6.2 多音和弦:复制分频器,加法混音

想要同时按两个键发出和弦,只需要把分频器模块实例化两次,各自的audio_out用一个加法器合并:

wire audio_c4, audio_e4; note_divider u_c4 (.clk(clk_25m), .rst_n(rst_n), .key_in(use_key_c4), .audio_out(audio_c4)); note_divider u_e4 (.clk(clk_25m), .rst_n(rst_n), .key_in(use_key_e4), .audio_out(audio_e4)); wire audio_mix; assign audio_mix = audio_c4 + audio_e4; // 混音,幅度限幅后输出

混音后的信号幅度会翻倍,直接送蜂鸣器会过载失真,输出前要做一个简单的限幅:

assign audio_out = (audio_mix >= 2'b10) ? 1'b1 : audio_mix[0];

实际听感上,这种两个数字方波的纯加法混音会有明显的互调失真,属于入门方案的正常水平。想要更好的和弦效果,建议把每个通道换成 6.1 的 PWM 正弦生成方式,混音后过一阶低通。这套基于 FPGA 的电子琴设计.zip 里如果已经带了双通道示例,你可以直接沿用它的混音模块,重点调校限幅阈值和低通截止频率即可。

最后说个个人习惯:从那以后,我每跑一套 FPGA 音频工程,都强制走一遍“先查时钟频率,再验分频系数,最后看引脚约束”的三步清单——这三步能把上面 90% 的坑挡在仿真阶段。这套工程对我最大的价值不是代码本身,而是那五个真实踩坑点让我少走了两个月的弯路。希望这篇拆解能帮你把这个周末的时间花在弹奏上,而不是烧写完盯着无声的板子发呆。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进