用FPGA演奏《小星星》:EGO1开发板上的音乐之旅
你有没有想过,一块看起来冷冰冰的FPGA开发板,其实可以“唱歌”?在数字逻辑课的大作业中,很多同学都遇到过这样一个任务:让EGO1开发板通过蜂鸣器播放一段音乐。听起来像魔法,但其实它背后是一套严谨而优美的硬件设计逻辑。
今天,我们就以“播放《小星星》”为例,带你从零开始,一步步实现一个完整的基于Vivado的蜂鸣器音乐播放系统。不堆术语、不讲空话,只说你能听懂、能复现、能调试清楚的实战细节。
为什么选蜂鸣器音乐作为大作业?
在众多FPGA课程项目中,“点亮LED”太简单,“UART通信”又偏底层,而“音乐播放”恰好卡在一个完美的平衡点上:
- 它需要你理解时序控制;
- 要掌握状态机建模;
- 涉及频率生成与定时精度;
- 还得会用ROM存储数据(乐谱);
- 最关键的是——你能听见结果!
没错,这是少有的能让老师和室友同时听到你“成功了”的项目。失败时是刺耳杂音,成功那一刻,当熟悉的“Do Do Sol Sol La La Sol”响起,那种成就感,值回所有熬夜。
核心挑战拆解:三个关键技术模块
要让FPGA发出悦耳旋律,不能靠瞎试。我们必须把问题拆成三块来解决:
- 怎么让蜂鸣器响?
- 怎么让它发出不同的音高(Do、Re、Mi)?
- 怎么控制节奏,让每个音符按时长准确播放?
这三问,对应的就是我们整个系统的三大核心模块:蜂鸣器驱动器 + 音符频率发生器 + 节拍控制器 + 主控状态机。
我们一个一个来看。
第一步:让蜂鸣器发声——无源蜂鸣器的本质
EGO1开发板上通常接的是无源电磁式蜂鸣器,它不像有源蜂鸣器那样通电就“嘀”一声,而是像个微型喇叭,必须给它输入一定频率的方波信号才能发声。
🔧 类比理解:就像你对着笛子吹气,频率决定音调高低;FPGA就是那个“吹气的人”,只不过吹的是电平翻转。
Xilinx Artix-7 的 IO 可以直接驱动这种蜂鸣器,但要注意:
- 最大输出电流约12mA;
- 建议串联一个220Ω~1kΩ 的限流电阻,保护 FPGA 引脚;
- 输出信号为 50% 占空比的方波即可。
所以我们的目标很明确:产生指定频率的方波。
第二步:把“Do Re Mi”变成数字信号
音乐的本质是频率。标准音阶中,每个音符都有对应的科学频率:
| 音符 | 频率 (Hz) |
|---|---|
| C4 (Do) | 261.63 |
| D4 (Re) | 293.66 |
| E4 (Mi) | 329.63 |
| F4 (Fa) | 349.23 |
| G4 (Sol) | 392.00 |
| A4 (La) | 440.00 |
| B4 (Si) | 493.88 |
我们要做的,就是把这些频率“翻译”成 FPGA 能处理的计数周期。
假设主时钟为100MHz,要生成 261Hz 的方波,意味着每秒要翻转 522 次(上升沿+下降沿),即每半个周期持续约 1,915,708 个时钟周期。
于是我们可以这样设计:
// 音符频率发生器 module tone_generator ( input clk, input rst, input [6:0] note, // 音符编码 input enable, output reg beep // 蜂鸣器输出 ); parameter CLK_FREQ = 100_000_000; // 查表法:音符 → 频率(简化取整) localparam [18:0] freq_lookup[13] = '{ 0, // 0: 休止符 261, // 1: C4 294, // 2: D4 330, // 3: E4 349, // 4: F4 392, // 5: G4 440, // 6: A4 494, // 7: B4 523, // 8: C5 587, // 9: D5 659, // 10: E5 698, // 11: F5 784 // 12: G5 }; reg [31:0] counter; reg [18:0] target_period; // 计算半周期计数值:N = f_clk / (2 * f_note) always @(*) begin if (note == 0 || note >= 13) target_period = 0; else target_period = CLK_FREQ / freq_lookup[note] / 2; end always @(posedge clk or posedge rst) begin if (rst) begin counter <= 0; beep <= 0; end else if (enable && target_period > 0) begin if (counter >= target_period - 1) begin counter <= 0; beep <= ~beep; // 翻转输出 end else begin counter <= counter + 1; end end else begin beep <= 0; // 关闭输出 end end endmodule📌关键点说明:
- 使用freq_lookup数组做音符映射,清晰易扩展;
- 计算的是“半周期”,因为每次翻转才构成完整波形;
- 当note=0或无效时,关闭输出,避免误响。
第三步:节奏怎么控?节拍定时器来了
光有音高还不够,还得知道这个音该“唱多久”。比如四分音符0.5秒,八分音符0.25秒。
我们可以设定一个基础节拍单位(例如每拍500ms,对应120BPM),然后用计数器精确倒计时。
module beat_timer #( parameter BEAT_MS = 500, parameter CLK_FREQ = 100_000_000 )( input clk, input rst, input start, output reg done ); localparam COUNT_MAX = CLK_FREQ * BEAT_MS / 1000; reg [31:0] count; always @(posedge clk or posedge rst) begin if (rst) begin count <= 0; done <= 0; end else if (start) begin count <= 0; done <= 0; end else if (count < COUNT_MAX - 1) begin count <= count + 1; done <= 0; end else begin done <= 1; end end endmodule🎯 参数化设计的好处:换首歌只需要改BEAT_MS,不用动逻辑。
第四步:谁来指挥全局?有限状态机登场
现在我们有了“发声引擎”和“节拍器”,但谁来协调它们工作?答案是:有限状态机(FSM)。
我们定义几个状态,让播放流程自动流转:
IDLE:等待播放指令FETCH:从ROM读取当前音符PLAY:启动蜂鸣器WAIT:等待节拍结束NEXT:索引+1,准备下一音STOP:播放完毕
下面是完整控制器代码:
module music_player ( input clk, input rst, input play, output reg [6:0] note_out, output reg enable_tone, output wire done_play ); typedef enum logic [2:0] { IDLE, FETCH, PLAY, WAIT, NEXT, STOP } state_t; state_t state, next_state; reg [7:0] current_index; wire beat_done; assign done_play = (state == STOP); // 实例化节拍定时器 beat_timer #(.BEAT_MS(500)) u_timer ( .clk(clk), .rst(rst), .start(state == PLAY), .done(beat_done) ); // 状态寄存 always @(posedge clk or posedge rst) begin if (rst) state <= IDLE; else state <= next_state; end // 状态转移逻辑 always @(*) begin case (state) IDLE: next_state = play ? FETCH : IDLE; FETCH: next_state = PLAY; PLAY: begin enable_tone = 1; note_out = song_rom[current_index]; next_state = WAIT; end WAIT: next_state = beat_done ? NEXT : WAIT; NEXT: begin current_index = current_index + 1; if (song_rom[current_index] == 7'h7F) next_state = STOP; else next_state = FETCH; end STOP: next_state = IDLE; default: next_state = IDLE; endcase end // 存储乐谱:《小星星》前两行 reg [6:0] song_rom[0:15] = '{ 1, 1, 5, 5, 6, 6, 5, // Do Do Sol Sol La La Sol 4, 4, 3, 3, 2, 2, 1, // Fa Fa Mi Mi Re Re Do 7'h7F // 结束标志 }; endmodule🎵乐谱编码规则:
-1=C4,5=G4,6=A4…
-7’h7F表示结束
你可以轻松修改这段数组换成《欢乐颂》《生日快乐》,甚至加入附点节奏(通过调整BEAT_MS动态传参实现)。
系统整合与EGO1部署要点
整体架构图(文字版)
clk (100MHz) ↓ [music_player FSM] ↙ ↘ note_out start_timer ↓ ↓ tone_generator ← beat_timer ↓ beep → [EGO1 JB[0]] → 蜂鸣器Vivado工程关键步骤
- 创建RTL工程,添加上述三个模块;
- 使用Clocking Wizard IP锁相环生成稳定100MHz时钟(若板载50MHz需倍频);
- 将
beep信号绑定到实际引脚(如JB[0]); - 编写XDC约束文件:
set_property PACKAGE_PIN J1 [get_ports {beep}]; set_property IOSTANDARD LVCMOS33 [get_ports {beep}]; set_property PACKAGE_PIN D9 [get_ports {play}]; # 按键输入 set_property IOSTANDARD LVCMOS33 [get_ports {play}];- 综合 → 实现 → 生成比特流 → 下载到EGO1。
常见坑点与调试秘籍
🔧问题1:完全没声音?
- ✅ 检查蜂鸣器是否接对(正负极别反);
- ✅ 查看XDC是否正确绑定引脚;
- ✅ 用LED测试
beep是否翻转(可用ILA抓信号);
🔧问题2:声音沙哑或频率不准?
- ⚠️ 查表频率是否四舍五入过度?建议保留更多位宽计算;
- 💡 改用更精确公式:
target_period = CLK_FREQ / (2 * freq),使用实数运算预计算;
🔧问题3:节奏忽快忽慢?
- ✅ 确保
beat_timer的start只在进入PLAY状态时触发一次; - ❌ 避免在组合逻辑里反复置位
start导致重置计数器;
🔧问题4:播完不停?
- ✅ 检查ROM结尾是否有明确结束标记(如
7’h7F); - ✅ 在
NEXT状态判断索引越界;
💡加分技巧:
- 加一个LED,在PLAY状态亮起,直观看到播放进度;
- 用两个按键:一个“播放”,一个“暂停/继续”;
- 扩展多首歌曲选择(用拨码开关选曲目);
写在最后:这不是终点,而是起点
当你第一次听到FPGA奏出《小星星》时,别急着关电脑。想想下一步:
- 能不能加个低音伴奏通道?
- 能不能解析MIDI文件自动播放?
- 能不能做个简易电子琴,用按键实时弹奏?
这个看似简单的“大作业”,其实是通往音频DSP、嵌入式系统、软硬协同设计的大门钥匙。
更重要的是,它教会你一件事:硬件不是冰冷的逻辑门,它可以有旋律,也可以有温度。
下次答辩时,不必只展示波形截图。按下按钮,让评委听见你的作品——那才是最动人的“运行成功”。
🎶 用代码谱写旋律,让硅片奏响乐章。这才是FPGA的魅力所在。
如果你在实现过程中遇到了其他挑战,欢迎在评论区分享讨论。