这一篇在干嘛?
不用 CPU、不用软核,让一块 FPGA 靠纯数字电路把《梁祝》完整演奏出来,还能让数码管同步显示简谱。这个实验把锁相环、ROM、计数器、查表译码、数控分频五种技术串成一条流水线,是理解「纯硬件状态机 + 数据查表」思路的绝佳案例。
实验目标与原理
实验任务原文:
应用硬件描述语言,设计一个乐曲硬件演奏电路,它能将一首预先设置存储好的乐曲自动播放出来,除此之外,也能够通过按键的方式输入音符,使其具备简易电子琴的功能。通过此项研究,能够深切的体会利用 EDA 工具开发的优越性,在此基础上,对乐曲硬件演奏电路功能进行丰富,具有一定的社会实用性。

图 5-1 按键控制蜂鸣器系统框图
该工程由如图 5-1 所示的 5 个模块构成:
- 时钟源与锁相环:输入的时钟 50MHz 通过锁相环后输出 2KHz 和 1MHz 的时钟,分别输送给分频器 FDIV 和计数器 CNT138T;FDIV 将时钟分频成 4Hz 的时钟。
- 计数器 CNT138T:完成计数功能,138 进制,每来一个时钟沿加一。
- 乐谱 ROM(MUSIC):记录了一首歌的乐谱。根据上一模块计数器所计的数读取相应地址里的数据传递给下一模块。
- 译码器 FCODE:将 ROM 里读出的数据翻译成可预置计数器 SPKER 和简谱码显示 DECL7S 所需的数据。
- 分频驱动器 SPKER:一个数控分频器,完成分频的功能,并驱动蜂鸣器。
整条数据链的工作方式原文概括得非常清楚:
当一个 4Hz 的时钟脉冲来到时,乐谱发生器模块输出一个音符数据给分频系数模块,分频系数模块输出此音符相应的分频系数所需的初始值,将初始值送给数控分频器模块,当 1MHz 的时钟脉冲来到时,数控分频器就根据分频系数输出相应的频率(即此音符所对应的发音频率)给扬声器,扬声器就可发出对应音符的声音来。连续的 4Hz 的时钟脉冲就将乐谱发生器里所存储的音符数据一个接一个的送给了分频系数模块,再经过数控分频模块,最后扬声器一个接一个的发出音符数据所对应的声音来。曲子也就流畅的播放出来了。同时 led 数码管会随着音乐显示相应的乐谱。当乐曲一遍演奏完成后,乐曲发生器能自动从头开始循环演奏。

图 5-2 乐曲演奏电路顶层设计
与利用微处理器(CPU 或 MCU)来实现乐曲演奏相比,以纯硬件完成乐曲演奏电路的逻辑要复杂一些。本实验设计项目作为《梁祝》乐曲演奏电路的实现。组成乐曲的每个音符的发音频率值及其持续的时间是乐曲能连续演奏所需的两个基本要素——「发什么音」和「发多久」,整个电路就是围绕这两个要素搭建的。
要素一:音符频率从哪来(数控分频 SPKER)

图 5-3 电子琴音阶基频对照图(单位:Hz)
音符的频率可以由图 5-3 中的 SPKER 获得。这是一个用作分频器的可预置计数器。由 CLK 端输入一具有较高频率(1MHz)的时钟,通过 SPKER 分频后,经由 D 触发器构成的分频电路,由 SPK_KX 口输出。由于直接从分频器中出来的输出信号是脉宽极窄的信号,为了有利于驱动扬声器,需另加一个 D 触发器分频以均衡其占空比,但这时的频率将是原来的 1/2。SPKER 对 CLK 输入信号的分频比由输入的 11 位预置数 TN[10..0] 决定。SPK_KX 的输出频率将决定每一音符的音调;这样,分频计数器的预置值 TN[10..0] 与输出频率就有了对应关系,而输出的频率又与音乐音符的发声有对应关系,例如,在 F_CODE 模块中若取 TN[10..0]=11’H40C,将由 SPK_KX 发出音符为「3」音的信号频率。
这里的思路值得咀嚼:蜂鸣器要响,就要给它一个音符频率的方波,比如中音 6(la)约 440Hz。FPGA 手里有 1MHz 的高频时钟,用一个 11 位计数器从预置值 TN 一路加到 11’h7FF(2047),加满就翻转输出并重新装载 TN——装载值越大,计数周期越长,输出频率越低。所以「选音符」就被翻译成了「给 SPKER 塞哪个 11 位预置数」。
为什么还要再过一级 D 触发器?
计数器翻转出来的信号高电平时间极短(脉宽窄),直接驱动蜂鸣器发声会很弱。过一级 D 触发器二分频后,占空比变成规整的 50%,驱动能力均衡,只是频率减半——所以查表时预置数要按二分频后的目标频率反推。这是工程上「用频率换驱动质量」的典型取舍。
要素二:节拍时长从哪来(4Hz 节拍钟 + 138 进制计数)
音符的持续时间需根据乐曲的速度及每个音符的节拍数来确定。模块 F_CODE 的功能首先是为模块 SPKER(11 位分频器)提供决定所发音符的分频预置数,而此数在 SPKER 输入口停留的时间即为此音符的节拍周期。模块 F_CODE 是乐曲简谱码对应的分频预置数查表电路,数据是根据图 5-3 得到的,程序中设置了《梁祝》乐曲全部音符所对应的分频预置数,共 14 个。每一音符的停留时间则由音乐节拍和音调发生查表模块 MUSIC 中简谱码和工作时钟 inclock 的频率决定,在此为 4Hz。这 4Hz 频率来自分频模块 FDIV,模块 MUSIC 是一个 LPM_ROM。它的输入频率来自锁相环的 2kHz 输出频率。
模块 CNT138T 是一个 8 位二进制计数器,内部设置计数最大值为 139,作为音符数据 ROM 的地址发生器。这个计数器的计数频率即为 4Hz。即每一计数值的停留时间为 0.25 秒,即当全音符设为 1 秒时,4/4 拍的 4 分音符持续时间。例如,《梁祝》乐曲的第一个音符为「3」,此音在逻辑中停留了 4 个时钟节拍,即 1 秒时间,相应地,所对应的「3」音符分频预置值为 11’H40C,在 SPKER 的输入端停留了 1 秒。随着计数器 CNT138T 按 4Hz 的时钟速率作加法计数时,即随地址值递增时,音符数据 ROM 模块 MUSIC 中的音符数据将从 ROM 中通过 q[3..0] 端口输向 F_CODE 模块,《梁祝》乐曲就开始连续自然地演奏起来了。CNT138T 的节拍是 139,正好等于 ROM 中的简谱码数,所以可以确保循环演奏。对于其他乐曲,此计数最大值要根据情况更改。
把两个要素合起来看,一秒的《梁祝》在硬件里是这样流动的:
4Hz 节拍 → CNT138T 地址+1 → ROM 取出简谱码 → F_CODE 查表得 TN[10:0]
→ SPKER 以 1MHz 计数分频 → D 触发器二分频 → 蜂鸣器发出音符
最后是定制音符数据 ROM MUSIC。该 ROM 中对应《梁祝》乐曲的音符数据见后文初始化文件。注意数据表中的数据位宽、深度和数据的表达类型。为了节省篇幅,例中的数据都横排了,实际上必须以每一分号为一行来展开。最后对该 ROM 进行仿真,确认音符数据已经进入 ROM 中。
Verilog 实现
【例 5-1】与【例 5-2】起,原文代码一字不改,全部保留如下。
module CNT138T (CLK, CNT8);
input CLK; output[7:0] CNT8 ; reg[7:0] CNT; wire LD;
always @(posedge CLK or posedge LD ) begin
if (LD) CNT <= 8'b00000000 ; else CNT<=CNT+1; end
assign CNT8=CNT; assign LD=(CNT==138) ;
endmodule
2]
module F_CODE (INX, CODE, H, TO);
input[3:0] INX; output[3:0] CODE; output H; output[10:0] TO;
reg[10:0] TO; reg[3:0] CODE; reg H;
always @(INX) begin
case (INX) //译码电路,查表方式,控制音调的预置
0 : begin TO <= 11'H7FF; CODE<=0; H<=0; end
1 : begin TO <= 11'H305; CODE<=1; H<=0; end
2 : begin TO <= 11'H390; CODE<=2; H<=0; end3 : begin TO <= 11'H40C; CODE<=3; H<=0; end
4 : begin TO <= 11'H45C; CODE<=4; H<=0; end
5 : begin TO <= 11'H4AD; CODE<=5; H<=0; end
6 : begin TO <= 11'H50A; CODE<=6; H<=0; end
7 : begin TO <= 11'H55C; CODE<=7; H<=0; end
8 : begin TO <= 11'H582; CODE<=1; H<=1; end
9 : begin TO <= 11'H5C8; CODE<=2; H<=1; end
10 : begin TO <= 11'H606; CODE<=3; H<=1; end
11 : begin TO <= 11'H640; CODE<=4; H<=1; end
12 : begin TO <= 11'H656; CODE<=5; H<=1; end
13 : begin TO <= 11'H684; CODE<=6; H<=1; end
14 : begin TO <= 11'H69A; CODE<=7; H<=1; end
15 : begin TO <= 11'H6C0; CODE<=1; H<=1; end
default : begin TO <= 11'H6C0; CODE<=1; H<=1;
end
endcase end
endmodule
【例 5-3】
module SPKER (CLK, TN, SPKS);
input CLK; input[10:0] TN; output SPKS;
reg SPKS; reg[10:0] CNT11;
always @(posedge CLK) begin : CNT11B_LOAD//11 位可预置计数器
if (CNT11==11'h7FF) begin CNT11=TN; SPKS<=1'b1; end
else begin CNT11=CNT11+1; SPKS<=1'b0 ; end
end
endmodule
【例 5-4】2kHz 至 4Hz 分频器
module FDIV (CLK, PM );
input CLK ; output PM ; reg [8:0] Q1; reg FULL; wire RST ;
always @(posedge CLK or posedge RST) begin
if (RST) begin Q1<=0; FULL<=1; end
else begin Q1 <= Q1+1; FULL<=0 ; end end
assign RST = (Q1==499) ; assign PM = FULL ;
assign DOUT = Q1 ; endmodule
【例 5-5】ROM初始化.Coe乐谱文件
memory_initialization_radix=16;
memory_initialization_vector=3 3 3 3 5 5 5 6 8 8 8 9 6 8 5 5 C C C F D C A C 9 9 9 9 9 9 0 9 9 9 A 7 7 6 6 5 5 5 6 8 8 9 9 3 3 8 8 6 5 6 8 5 5 5 5 5 5乐谱数据表的剩余部分原文如下(后续为大量休止符 0,用于收尾停顿):
| 5 | A | A | A | C | 7 | 7 | 9 | 9 | 6 | 8 | 5 | 5 | 5 | 5 | 5 | 5 | 3 | 5 | 3 | 3 | 5 | 6 | 7 | 9 | 6 | 6 | 6 | 6 | 6 | 6 | 5 | 6 | 8 | 8 | 8 | 9 | C | C | C | A |
| 9 | 9 | A | 9 | 8 | 8 | 6 | 5 | 3 | 3 | 3 | 3 | 8 | 8 | 8 | 8 | 6 | 8 | 6 | 5 | 3 | 5 | 6 | 8 | 5 | 5 | 5 | 5 | 5 | 5 | 5 | 5 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
逐模块拆解:
- CNT138T:计到 138 时
LD拉高,下一个时钟沿清零,从 0 重新计数,所以实际周期是 139 个地址——正好对上乐谱长度,曲子播完自动从头再来。 - F_CODE:一张 16 项的查表。
INX是 ROM 送来的简谱码,TO是给 SPKER 的 11 位预置数,CODE是给数码管显示的简谱码,H是高八度标志。注意INX=0时TO=11'H7FF,计数器几乎不翻转,输出接近直流——这就是「休止符」的实现方式:不发音,静默一拍。 - SPKER:核心三行——计到顶装载
TN并置位SPKS,否则加一。装载值决定音调。 - FDIV:9 位计数器计到 499 产生一个满脉冲,2kHz ÷ 500 = 4Hz,得到节拍时钟。
换一首歌要改三处
想播别的曲子:①ROM 初始化文件里的乐谱数据要换;②CNT138T 的计数最大值(
CNT==138)要改成新乐谱的简谱码个数;③若用到超出 14 个的音符或不同八度,F_CODE 查表值也要按基频对照图重算。三处缺一不可,否则轻则节奏错乱,重则循环点对不上。
实验仿真

图 5-4
仿真时重点确认三件事:FDIV 是否输出规整的 4Hz 脉冲;CNT138T 计到 138 后是否正确回零;F_CODE 在不同 INX 输入下 TO、CODE、H 是否与查表值一致。确认后再对定制好的 ROM 做一次读出仿真,确保乐谱数据完整进入存储器。
引脚分配
原文约束如下。
系统时钟约束:
create_clock -period 20 -name clk[ get_ports clk50mhz ]IO 管脚约束:
set_property -dict { PACKAGE_PIN Y18 IOSTANDARD LVCMOS33 } [get_ports clk50mhz] set_property -dict { PACKAGE_PIN E3 IOSTANDARD LVCMOS33 } [get_ports rst] set_property -dict { PACKAGE_PIN P20 IOSTANDARD LVCMOS33 } [get_ports spk] set_property -dict { PACKAGE_PIN AB18 IOSTANDARD LVCMOS33 } [get_ports high] set_property -dict { PACKAGE_PIN AA6 IOSTANDARD LVCMOS33 } [get_ports { LED[0] }] set_property -dict { PACKAGE_PIN V7 IOSTANDARD LVCMOS33 } [get_ports { LED[1] }] set_property -dict { PACKAGE_PIN W7 IOSTANDARD LVCMOS33 } [get_ports { LED[2] }] set_property -dict { PACKAGE_PIN AB7 IOSTANDARD LVCMOS33 } [get_ports { LED[3] }]整理成速查表:
| 信号 | 方向 | 引脚 | 说明 |
|---|---|---|---|
| clk50mhz | 输入 | Y18 | 50MHz 系统时钟,周期约束 20ns |
| rst | 输入 | E3 | 复位 |
| spk | 输出 | P20 | 蜂鸣器驱动 |
| high | 输出 | AB18 | 高八度指示 |
| LED[3:0] | 输出 | AA6/V7/W7/AB7 | 简谱码二进制显示 |
上板流程
- 完成锁相环(输出 2kHz 与 1MHz 两路时钟)与各模块的顶层连接,综合、布局布线。
- 定制音符数据 ROM(LPM_ROM),载入 .coe 乐谱文件并单独仿真确认数据正确。
- 按上表录入时钟与 IO 约束,生成比特流并下载。
- 准备好听音乐——这一步别省。
现象与总结
原文描述的下载测试现象:
程序下载完毕后,将 SW5 开关拨上(BUZ)端。此时应该能够观察到开发板左边 4 位 LED1~LED4 灯显示当前音调的简谱码二进制码,右边一位 LED6 灯显示高八度指示,蜂鸣器演奏音乐《梁祝》。

这个实验最有价值的地方在于它示范了「数据驱动硬件」的设计范式:乐谱是数据(ROM)、音符到频率的映射是数据(查表)、节拍是数据(计数时钟),电路本体只是固定的取数—译码—分频流水线。想换歌、换节奏、换音色,大部分时候改数据就行,不动电路结构。这正是 FPGA 相比固定逻辑芯片的魅力:电路行为由你灌进去的数据和逻辑共同决定。
回忆一下五个模块各自的分工,再想想两个基本要素分别由哪个模块落实——「发什么音」由 F_CODE + SPKER 落实,「发多久」由 FDIV + CNT138T + ROM 落实。抓住这两条线,这个电路就没有神秘感了。
自测
自测
- 乐曲演奏的两个基本要素是什么?分别由哪些模块实现?
- F_CODE 中
INX=0时TO=11'H7FF,为什么这就实现了休止符?- SPKER 输出后为什么要再过一级 D 触发器?代价是什么?
- CNT138T 为什么设计成 139 个计数值?如果新乐谱有 100 个音符,代码哪里要改?
- FDIV 把 2kHz 分频成 4Hz,
RST = (Q1==499)里的 499 是怎么算出来的?