资讯动态

FPGA音乐播放器设计:PWM发声与ROM存储的RTL实现

发布时间:2026/10/14 16:58:20 来源:尧图企业网站定制
简介基于FPGA的音乐播放器完整工程资料包面向电子设计竞赛选手、FPGA初学者以及数字系统设计爱好者专门解决如何在Altera Cyclone IV系列EP4CE6E22C8芯片上利用Verilog语言读取ROM中存储的音乐简谱并通过状态控制与音频接口输出播放的问题。资源共包含271个文件压缩包总大小约63.07MB其中既有时序分频、音乐读取、数码管显示等核心模块的源码也有Quartus工程配置、仿真测试脚本、IP核文件以及可直接下载的烧录文件覆盖从编写代码、功能仿真到上板验证的完整开发流程。目前已有2271人学习下载配套工程目录结构清晰便于按模块检索。资源包中的完整工程组织方式以及状态机、时钟管理、D/A转换和IP核例化等细节集中展示了FPGA数字音频系统的设计思路配合源码与仿真波形学习者能够快速理解Verilog如何驱动硬件播放音乐并在此基础上扩展按键控制、显示交互等更多功能。1. 基于FPGA的音乐播放器到底在做什么先把“纯硬件放歌”的边界说清楚很多人看到“基于FPGA的音乐播放器”第一反应是让 FPGA 跑嵌入式处理器再移植一个音频解码库。但真正能放进课堂演示、能在一两周内从零跑通、又能把数字电路知识串起来的方案往往反过来不用 CPU、不跑操作系统、不碰解码算法只用一段只读存储存音频采样值靠分频器、计数器和脉宽调制器这些基础时序逻辑发声。这个方案的听感上限不高但它的价值在于把“模拟信号怎么从数字电路里出来”这件事完整走了一遍。它适合刚学完组合逻辑和时序逻辑、想在开发板上做一个能听、能调、能讲清内部结构的完整 Demo 的人。先明确一件事它播放的是几秒到几十秒的短音频片段不是无损曲库看清这个边界后面所有设计取舍才有意义。2. 架构与选型输出方案、存储容量、模块划分三件事先定下来动手写 RTL 之前有三个决定必须先做声音从哪里出来、音频数据放在哪里、模块怎么切。这三件事定错了后面要么推倒重来要么把大量时间耗在反复改参数上。2.1 音频输出三选一PWM 直推、低压功放还是 I2S 解码芯片FPGA 引脚只能输出数字电平要让喇叭或耳机出声本质上是要在片外把数字信号变成模拟电压。最常见的做法有三种先看对比再选。方案信号链路音质片外硬件成本适合场景PWM 直推FPGA 引脚 → RC 低通 → 耳机/小喇叭底噪较大音量小两颗阻容元件第一次跑通验证链路PWM 放大FPGA 引脚 → 低通 → 三极管或低压功放 → 喇叭音量正常仍有量化底噪一个放大级课堂演示最常用I2S DAC 芯片FPGA 引脚按 I2S 时序输出 → 解码芯片 → 功放接近 CD 级解码与功放模块进阶改造想认真听音质我一般会建议第一步走 PWM 直推听个响、看波形然后再加放大级。PWM 在这里扮演的角色就是一个最便宜的 DAC采样值越大输出高电平的时间占比越高经过低通滤波后平均值就还原成了音频包络。8 位分辨率的 PWM 就能达到 256 级电压台阶对旋律型音频演示足够。不要一开始就上 I2S它的时序约束和外部模块调试成本对新手并不友好。PWM 方案里有一个细节要记住FPGA 引脚扇出电流通常只有几毫安到十几毫安直接推 8 欧姆喇叭会严重削顶甚至无声所以“PWM 能出声”和“PWM 能推喇叭”是两回事后面避坑章会专门讲。2.2 存储先于逻辑片内 ROM、SPI Flash、SD 卡各自能播几秒音频数据必须先转换成某种 FPGA 能直接读的形式。存哪里决定了这个项目能播多长、复杂度差多少。三个层次对比如下。存储方案能播多长按 8kHz、8bit 单声道算复杂度说明片内 ROMBlock RAM几十秒以内常见入门器件几十到一百多 KB约合 1020 秒最低用 mif 或初始化数组直接烧进 FPGA无需外部时序SPI Flash分钟级中需要写 SPI 控制器还要解决读取速率和缓存问题SD 卡小时级高要处理 SD 协议和文件系统基本是另一个项目结论很明确第一步只做片内 ROM。8kHz 采样率、8 位位宽、单声道每秒音频正好 8KB也就是 64Kb 的存储。选一段 48 秒的旋律片内存储完全够用。这里有个容易被忽略的约束Block RAM 是按存储块粒度分布的地址空间按 2 的幂对齐不是你算出来 8KB 就一定能连续放 8KB 数据。所以设计曲目长度时宁可留 20% 余量。如果你的目标是“循环播放一段 6 秒旋律”先把 ROM 深度定成 8192 或 16384而不是卡着算出来的 48000 采样点。2.3 最小系统的模块划分与关键参数表把整个播放器切成六个模块每个模块职责单一方便单独测试。这是我反复用下来比较顺手的划分方式。模块输入输出职责关键参数示例sample_clk_gen输入时钟输出采样使能脉冲产生 8kHz 的采样节奏100MHz 主时钟DIV12500key_debounce按键输入输出消抖脉冲按键同步、消抖消抖窗口 20msplay_ctrl按键脉冲输出状态播放/暂停/切歌状态机IDLE/PLAY/PAUSEaddr_ctrl状态采样使能输出地址产生 ROM 读地址支持暂停和循环地址位宽按 ROM 深度算song_rom地址输入数据输出存储音频采样值8bit 位宽深度 16384pwm_audio采样数据使能输出 PWM把采样值转成占空比8 位分辨率载波 390kHz每个模块单独跑仿真通过后再连起来。采样率、位宽、PWM 分辨率这三组参数是互相咬合的采样率决定音调时间基准位宽决定音量台阶PWM 分辨率决定载波频率改一个就要重新算另外两个。下面各章按这个架构逐步实现你会发现所有代码加起来不超过两百行。3. 把 WAV 变成 FPGA 能读的系数转码脚本、mif 格式与容量账本FPGA 不认识 WAV 文件头也不认识 PCM 编码。把音频变成可综合的存储内容是整个项目里第一个真正需要动手的环节。这个过程分为三步选一段合适的音频、用脚本抽取采样值、生成厂商开发环境能识别的初始化文件。3.1 音频文件先降级为什么演示样本都是 8kHz 单声道 8bit不要拿一首 44.1kHz、16 位立体声的流行歌直接往 FPGA 里灌。算一笔账就明白44.1kHz × 16bit × 2 声道每秒约 1411.2Kb也就是约 176KB。入门级 FPGA 片上存储按一百多 KB 算一秒都放不下。所以演示音频必须降级8kHz 采样率、8 位位宽、单声道。这个规格每秒只占 8KB能放十几秒。8kHz 的带宽对旋律型音乐够用吗人耳可听上限是 20kHz8kHz 采样只能覆盖到 4kHz 以下相当于给音频加了一个低通滤镜高音会闷一点但旋律、节奏、音准完全保留。对新项目跑链路来说这个音质损失是值得的。转码时有一个音质坑直接抽稀等于在时域上丢样本频谱上会发生混叠。旋律类音频因为高频能量本来就少问题不大如果素材里有明显齿音或镲片声会出现轻微金属感。严格做法是先经过低通滤波器再抽稀你现在的阶段可以先不做。3.2 用 Python 把 WAV 转成 mif脚本与每行参数解释转码这一步没必要用复杂工具Python 标准库就能完成避免引入额外依赖。下面这段脚本读取 WAV 文件把 16 位有符号采样降为 8 位无符号采样再输出 mif 初始化文件。import wave, struct SRC melody.wav # 输入 wav 文件建议 8kHz/16bit/单声道 OUT song.mif # 输出 mif 文件 TARGET_RATE 8000 # 目标采样率 DEPTH 16384 # 输出采样点数决定播放时长 OUT_WIDTH 8 # 输出位宽和 ROM 位宽保持一致 with wave.open(SRC, rb) as w: rate w.getframerate() nch w.getnchannels() sw w.getsampwidth() raw w.readframes(w.getnframes()) # 按 16bit 有符号整数解析并取出左声道 if sw 2: samples struct.unpack(%dh % (len(raw) // 2), raw) samples samples[0::nch] # 双声道时只取左声道 else: samples struct.unpack(%dB % len(raw), raw) samples [s - 128 for s in samples] samples samples[0::nch] # 抽稀降采样到目标采样率 ratio max(1, round(rate / TARGET_RATE)) samples samples[::ratio][:DEPTH] # 16bit 有符号右移 8 位再加 128 偏置为无符号 8bit data [(s 8) 128 for s in samples] data [max(0, min(255, v)) for v in data] # 写 mif 文件 with open(OUT, w) as f: f.write(WIDTH%d;\n % OUT_WIDTH) f.write(DEPTH%d;\n % len(data)) f.write(ADDRESS_RADIXHEX;\n) f.write(DATA_RADIXHEX;\n) f.write(CONTENT BEGIN\n) for i, v in enumerate(data): f.write(%X : %X;\n % (i, v)) f.write(END;\n)脚本逻辑分四步读 WAV、解出有符号整数、抽稀、转 8 位无符号。关键在最后一步16 位采样值右移 8 位后范围是 −128127加上 128 偏置变成 0255这样就能直接作为无符号数送进 ROM。如果源文件本身就是 8 位无符号 WAV直接取原值即可不要再做右移。DEPTH 参数就是 ROM 深度16384 个点配合 8kHz 采样率播放时长约 2 秒你可以按实际曲目把它改成 32768 或其他 2 的幂。生成的 mif 里每行是“地址 : 数据”地址十六进制递增数据十六进制表示这个格式是厂商开发环境通用的导入时只需要指定 ROM 位宽和深度其余按文件内容加载。3.3 容量账本一段音频到底占多少片上存储片内存储不是无限的设计之前要把账算清楚。下面这张表按 8kHz、8 位单声道直接出结果你可以按曲目时长反推 ROM 深度。播放时长采样点数ROM 容量说明1 秒80008000×8bit 64Kb 8KB最短演示只够一句旋律2 秒1638416384×8bit 128Kb 16KB适合跑通链路4 秒3276832768×8bit 256Kb 32KB常见课堂演示长度8 秒6553665536×8bit 512Kb 64KB需要留意器件存储上限注意这里的“KB”指的是存储容量不是音频文件大小。mif 文件在电脑里是文本格式每条“地址 : 数据”占大约 10 个字符字节数远大于实际 ROM 容量这是正常的烧录时综合工具只取其中的地址和数据。还有一个容易误判的点有些开发板的“存储容量”同时包含逻辑资源和 Block RAM你在看器件手册时认准 Block RAM 或嵌入式存储这一项别拿总资源数去算音频容量。容量账本还有一个现实用法如果你做完 4 秒版本发现资源不够优先把采样率降到 6kHz而不是去优化代码——因为 ROM 占用和采样率是线性关系降采样率是最直接的省钱方式。提示mif 文件里的 DEPTH 必须和实际 ROM 深度一致如果脚本生成的深度是 16384而 RTL 里 ROM 深度写的 32768综合会报越界或自动扩展这种不一致很多时候要到下载进板子才暴露先检查这里。4. 核心逻辑实现采样时钟、PWM 发声与控制状态机的最小 RTL 链路转出 mif 之后剩下的活就是写 RTL。整个播放器的核心逻辑就三层用分频器产生 8kHz 采样节拍、用计数器把采样值变成 PWM 波、用状态机控制播放和暂停。每层都很短但每层都有一个容易翻车的细节。4.1 采样时钟分频计数器写法差一个周期音调就偏一个八度音频的“时间基准”不是靠延时循环而是靠分频器产生采样使能脉冲。这里有一个原则不要在 FPGA 内部用分频后的信号当全局时钟而是用主时钟跑逻辑用使能脉冲控制数据更新。这样能避免一系列跨时钟域问题。下面是采样使能生成模块。module sample_clk_gen #( parameter CLK_HZ 100_000_000, parameter SAMPLE_RATE 8_000 )( input wire clk, input wire rst_n, output reg en_sample // 每 12500 个时钟周期拉高一次 ); localparam DIV CLK_HZ / SAMPLE_RATE; reg [$clog2(DIV)-1:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 0; en_sample 0; end else if (cnt DIV - 1) begin cnt 0; en_sample 1; // 采样节拍脉冲 end else begin cnt cnt 1; en_sample 0; end end endmodule这段代码里DIV 是主时钟频率除以采样率。以 100MHz 主时钟、8kHz 目标采样率为例DIV 12500计数器从 0 计到 12499 后归零每 12500 个时钟周期产生一个使能脉冲。计数器位宽用$clog2自动算DIV12500 时位宽是 14 位不用手数。最容易翻车的写法是if (cnt DIV)才归零这样实际分频周期变成 DIV1即 12501 个时钟周期采样率会偏低约 0.008%。单独看误差不大但如果有人凭记忆把主时钟设错比如板子实际是 50MHz 却按 100MHz 写采样率会翻倍播放速度加快一倍音调整体高八度这个非常明显。所以第一件事是打开工程里的时钟约束文件确认实际频率不要凭印象。4.2 PWM 发声锁存采样值、自然回绕计数与 RC 低通参数采样值从 ROM 里读出来之后要把它变成 PWM 占空比。PWM 模块本身是一个自由运行的计数器和一个比较器计数器从 0 循环加到大采样值作为比较阈值计数器小于阈值时输出高电平。这个过程里有一个细节比代码本身更重要采样值不能用组合逻辑直接接比较器做实时比较而是要在一个采样使能脉冲到达时先锁存到比较寄存器里否则采样值在变化过程中PWM 输出会出现毛刺。module pwm_audio #( parameter W 8 )( input wire clk, input wire rst_n, input wire en_sample, // 采样使能脉冲 input wire [W-1:0] sample_data, // 来自 ROM 的采样值 output reg pwm_out ); reg [W-1:0] cmp_val; reg [W-1:0] pwm_cnt; // 在采样节拍锁存比较值 always (posedge clk or negedge rst_n) begin if (!rst_n) cmp_val 0; else if (en_sample) cmp_val sample_data; end // 计数器自由运行自然回绕不清零 always (posedge clk or negedge rst_n) begin if (!rst_n) pwm_cnt 0; else pwm_cnt pwm_cnt 1; end assign pwm_out (pwm_cnt cmp_val) ? 1b1 : 1b0; endmodule计数器位宽是 8 位从 0 加到 255 后自然回绕到 0每个完整周期正好 256 个时钟因此 PWM 载波频率是 100MHz ÷ 256 ≈ 390.625kHz。有人会想“既然有采样使能干脆在使能到来时把计数器清零让 PWM 周期和采样周期对齐”实际上不要这么做。每次清零会让 PWM 周期被截断载波频率抖动听感上的高频噪声反而更明显。自然回绕虽然会让采样值更新点相对 PWM 相位缓慢漂移但这种漂移只是轻微量化噪声听感可接受。PWM 输出之后要过 RC 低通常见取 R1kΩ、C10nF截止频率约 15.9kHz对 4kHz 以下音频信号几乎没有衰减对 390kHz 的 PWM 载波有约 28dB 衰减残余分量经耳机或后级放大后基本听不见。R 和 C 不要随手选R 太大会衰减信号C 太大会把高音一起滤掉。4.3 播放控制按键消抖状态机与 ROM 地址的延迟错拍有了采样节拍和 PWM 发声还差一个“知道自己该放哪一段”的控制器。播放控制用三段式状态机写最稳按键消抖后产生脉冲状态机在播放和暂停之间切换地址计数器在播放状态下跟随采样使能递增。localparam IDLE 2d0; localparam PLAY 2d1; localparam PAUSE 2d2; reg [1:0] state, next_state; always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end always (*) begin next_state state; case (state) IDLE: if (key_play_pulse) next_state PLAY; PLAY: if (key_pause_pulse) next_state PAUSE; PAUSE: if (key_play_pulse) next_state PLAY; default: next_state IDLE; endcase end reg [ADDR_W-1:0] addr; always (posedge clk or negedge rst_n) begin if (!rst_n) addr 0; else if (state PLAY en_sample) begin if (addr LAST_ADDR) addr 0; // 播完循环 else addr addr 1; end end这段代码有一个关键错拍问题ROM 是同步读地址在 en_sample 沿变化数据要过一个时钟周期才出现在输出端口。如果直接用同一个 en_sample 去锁存 ROM 数据锁到的是上一拍地址的数据第一个采样点会丢更麻烦的是每次暂停后恢复播放都会错位。解决办法是让使能打一拍再用。reg en_d; always (posedge clk) en_d en_sample; always (posedge clk or negedge rst_n) begin if (!rst_n) cmp_val 0; else if (en_d) cmp_val rom_data; // ROM 数据稳定后再锁存 end按键消抖也不能省。机械按键按下时电平会抖动几十毫秒直接进状态机可能一次按下被识别成多次。常见做法是两级同步器加计数窗口先同步两次打掉亚稳态再用计数器统计电平稳定时间持续 20ms 不变才输出一个脉冲。20ms 窗口按 100MHz 时钟算需要计数到 200 万计数器位宽用$clog2算是 21 位别写成 16 位否则消抖窗口会缩水到 65.5ms 以内还会让消抖失效。5. 上电不出声FPGA 音乐播放器的避坑与排查清单这个项目我前后帮不同人排查过不少次最典型的现象不是代码跑不通而是“代码仿真没问题下载到板子上就是不出声”。下面五条是按出现频率排序的翻车现场每条都按现象、原因、解决三步写清楚你可以直接当排查清单用。5.1 声音小到听不清PWM 直接驱动喇叭驱动能力不够现象听筒或小喇叭里能听到极微弱的声音贴到耳朵上才能分辨旋律调大音量占空比也没用电流声比音乐声还响。原因FPGA 引脚输出的是数字电平驱动能力通常只有几毫安到十几毫安而 8 欧姆喇叭要发出正常音量需要几十毫安到上百毫安电流引脚直接被拉垮电压跌落声音自然小。解决在 PWM 输出后加一级放大。最简单的做法是用一个 NPN 三极管做开关放大PWM 信号经基极电阻驱动三极管喇叭接在集电极和电源之间注意续流二极管保护。或者用手头有的低压功放模块输入接 RC 滤波后的模拟信号比三极管方案省事音量也更饱满。这一步是“出声”和“正常音量”的分水岭不要跳过。如果示波器测 PWM 引脚波形正常但喇叭没声基本都是这一步的问题而不是 RTL 的问题。5.2 音调整体偏高或偏低分频差一错误和时钟频率记错现象旋律听起来完全不对整体偏高八度或偏低几度节奏也明显偏快或偏慢但波形并没有明显失真。原因采样率分频参数算错。最常见的是把开发板主时钟频率记错比如板子实际是 50MHz代码里写 100MHzDIV 小了一半采样节拍快一倍所有音符频率整体翻倍听感就是高八度。次常见的是 4.1 节提到的比较条件写错cnt DIV比cnt DIV - 1多了一个周期虽然单次误差只有 0.008%但累计后音准偏了约 1.4 音分耳力好的人能听出来。解决先查工程里的时钟约束文件确认主时钟频率再打开仿真看 en_sample 脉冲间隔是不是正好等于CLK_HZ / SAMPLE_RATE个时钟周期。如果手头有逻辑分析仪直接测分频后的使能脉冲频率和理论值对比。这个坑最隐蔽的地方在于你以为错在音频数据其实错在时间基准。5.3 每次播放开头都有爆音ROM 读取使能错拍与采样值跳变现象每次按下播放键第一声都是“啪”的一声短促爆音之后旋律正常暂停后恢复播放也同样爆一下。原因两个因素叠加。一是 4.3 节说的使能错拍第一个采样值没有锁存到正确数据输出直接从一个随机值开始。二是 ROM 里上一首曲目末尾的采样值和下一首开头采样值可能差得很大PWM 占空比瞬间从 90% 跳到 10%经过隔直电容和低通滤波后形成阶跃脉冲听感就是爆音。解决先把 en_sample 打一拍再用保证锁存的数据和地址对应。再在状态机里加一个静音过渡从 IDLE 进入 PLAY 时让 PWM 比较值先保持在 12850% 占空比大约 50ms再放开到真实采样值。50ms 在人耳感知里几乎是瞬时的但足以让模拟电路完成电平迁移。不要小看这个爆音它是模拟链路和数字电路交接处最常见的玄学问题很多人会怀疑是电源纹波其实根子在数字逻辑。5.4 按键一跳好几首异步信号没有同步和消抖直接进了状态机现象按下切换键偶尔会连跳两首或者播放暂停混乱多按几次还会卡住。原因按键输入是异步信号直接接到状态机里没有做同步处理FPGA 内部触发器进入亚稳态状态机跳到了根本没定义的状态再加上机械抖动松开时又产生一次误触发。解决任何按键信号先进两级同步器消除亚稳态再接消抖计数器。两级同步器就两个触发器串联消抖计数窗口给 20ms 即可。这里有个常见误用有人把按键直接接到 always 块的事件列表里当异步复位用这种写法在仿真里可能正常综合后行为会变得非常不稳定。按键必须作为普通电平信号处理经过同步、消抖、产生单脉冲再进状态机。改完这个坑之后播放器的控制才会给人“确定”的感觉否则每次操作都像在抽奖。5.5 声音发破、像削波有符号无符号位宽混用现象播放出来的声音在音量大的段落明显失真像喇叭破了一样音量调低一点又正常。原因转码阶段和 RTL 阶段对数据格式的理解不一致。WAV 文件里的 16 位采样是有符号整数范围 −3276832767转成 8 位时先右移 8 位得到 −128127必须加 128 偏置变成无符号 0255。如果转码脚本没有加偏置直接把负数写进了 ROM而 RTL 里把数据当成无符号数处理负值会变成一个很大的正数PWM 占空比到处乱跳表现就是削波失真。解决检查 mif 文件里的数据如果出现大量 0x80128以下的数值且负值没有经过偏置转换就是转码问题。另外如果后续做多段混音两个 8 位无符号数相加会溢出到 9 位一定要先扩展位宽再相加然后截回 8 位时加 clap 保护别直接丢弃高位。这类问题用仿真查不出来因为仿真波形上 PWM 依然有输出只有耳朵能告诉你它错了。注意以上五条排查顺序建议固定为“先查电气驱动能力、再查时钟分频参数、再查数据位宽格式、最后查控制按键和状态机”不要一上来就怀疑代码逻辑。这个项目 80% 的翻车都发生在 RTL 之外。6. 用示波器验证播放器再谈两个顺手的小升级声音能被耳朵听到只说明链路通了但通得对不对要用示波器读数确认。最值得测的有三个点第一个是采样使能脉冲频率应该是 8kHz脉宽约 10ns这是整条链路的节拍基准第二个是 PWM 输出引脚频率约 390kHz 的方波占空比会随着旋律变化第三个是 RC 低通滤波之后能看到和旋律包络对应的缓慢起伏波形。这三个点如果读数都对RTL 就没有硬伤剩下的只是音质打磨。没有示波器的情况下可以用逻辑分析仪测前两个数字点第三个模拟点测不了但耳朵已经能给你足够反馈。验证通过后有两个性价比很高的小升级。第一个是音量控制在数据进 PWM 之前做一次算术右移右移 n 位相当于除以 2 的 n 次方音量按台阶降低。注意右移后 8 位数据可取值范围变小占空比分辨率下降音量低时底噪会相对变大这是量化噪声的必然结果降到一半音量左右比较合适。第二个是往 I2S 方向探路如果手上有一个支持 I2S 输入的音频解码模块把 PWM 模块替换成 I2S 发送器数据线按时钟节拍把采样值串行发出去解码芯片会完成真正的数模转换音质会比 PWM 高一个档次。I2S 只有三根关键线BCLK 位时钟、LRCLK 声道时钟、DATA 数据线16 位双声道时 BCLK 采样率 × 32LRCLK 采样率。改这部分时把现有的采样使能直接当作 LRCLK 的节拍参考BCLK 用 32 倍频的使能生成代码量不大但对时序的理解要求明显上了一个台阶。做这个项目最让我受益的习惯是先想办法让电路出声再谈优化。我最早做类似方案时默认 PWM 一定能驱动喇叭结果花了两天时间反复检查 RTL最后才发现是引脚驱动电流不够从那以后我每做一个新模块都会先把电气参数写在笔记第一页。这个习惯帮我避开了不少同类问题也希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑