简介:基于FPGA的音乐播放器设计资料是一份面向数字系统设计学习者的完整工程包,适合具备Verilog基础、希望实践状态机、ROM读取与音频输出等环节的读者。项目围绕EP4CE6E22C8芯片展开,使用Verilog实现音乐简谱解码、播放控制、时序管理和音频接口驱动,覆盖从Quartus工程建立到仿真验证的典型流程。压缩包共271个文件,约63.07MB,以.v/.vhd源文件、.mif音乐数据、.qpf/.qsf工程配置、.sof配置文件及仿真脚本与波形记录为主,便于直接打开工程查看代码层次与运行结果。已有2271人学习下载。通过阅读源码与工程结构,可掌握FPGA音乐播放器的模块划分、状态机设计、时钟分频和IP核调用方法,也能为后续扩展音频处理功能提供可复用的设计参考。
1. 把音乐播放器搬进FPGA:Verilog从点灯到出声的完整路径
FPGA上跑一个能放歌的播放器,比想象中更能检验Verilog功底。很多人刷完点灯、计数器、串口收发之后,再做状态机总觉得是纸面功夫,而这套基于FPGA的音乐播放器工程把“数据流、状态机、时钟分频”三件事全串在一起:SD卡里的WAV文件怎么被读出来,采样值怎么通过FIFO缓冲,最后按I2S时序送到DAC变成耳机里的声音。任何一个环节的时序错了,喇叭里出来的都是刺耳噪声。它能直接解决“模块能仿真但一上板就翻车”的典型问题,适合正在学数字系统设计的本科生,也适合平时写图像或通信逻辑、没碰过音频时序的从业者。跟着拆一遍,功能划分、参数计算和调试方法都能落到具体代码上。
2. 工程架构与Verilog模块划分:从WAV文件到音频输出的完整链路
这套工程不是一个单一的大模块,而是把播放流程拆成了五个各司其职的部分:SD卡读取、WAV解析、FIFO缓冲、播放控制、I2S输出。我在看这类资源时最关心的就是模块边界怎么切,因为边界切得清楚,后面调时序、加功能都省力。整个数据通路可以概括成一句话:SD卡以块为单位把音频字节读进FPGA,WAV解析器抽取出采样率和位深,解析后的PCM数据写入FIFO,播放状态机负责决定什么时候读、什么时候停,I2S发射器则按固定节奏把FIFO里的采样值一位一位移出去。
顶层模块的实例化关系并不复杂,关键是理解每个模块的输入输出契约。下面这个表是工程里最常见的模块划分方式,我建议照这个骨架去读源码,会轻松很多:
| 模块名 | 主要职责 | 关键参数 |
|---|---|---|
| spi_master | 产生SPI时序,读写SD卡 | SPI时钟分频系数、CPOL/CPHA模式 |
| sd_init | 上电初始化SD卡 | 命令序列状态机、超时计数 |
| wav_parser | 解析WAV文件头 | 采样率、声道数、位深、数据偏移 |
| fifo | 缓冲PCM数据 | 数据位宽、深度、半满阈值 |
| player_fsm | 播放/暂停/切歌控制 | 状态编码、按键输入 |
| i2s_tx | 按I2S时序输出音频 | BCLK分频、LRCK分频、位深 |
2.1 为什么用单时钟域加同步FIFO而不是异步FIFO
不少类似的工程一上来就上异步FIFO,理由是SD卡读取速率和播放速率不一致,必须做跨时钟域。这个说法没错,但对刚接触音频工程的开发者来说,异步FIFO的读时钟、写时钟、格雷码同步、空满标志,每一个都是坑,而且空满标志的延迟很容易导致播放突然断流。
这套工程推荐的做法是单时钟域:系统时钟统一用板载晶振,SD卡的SPI时钟和I2S的BCLK都用时钟使能或分频计数器从系统时钟派生出来。这样整颗芯片只有一个时钟域,FIFO直接用同步FIFO,空满标志一拍就有效,调试难度低一个数量级。代价是BCLK会有一定频率误差,但人耳对几十赫兹以内的偏差并不敏感,用整数分频产生的1.5%左右误差属于可接受范围,课程设计、个人作品完全没问题。
我一般会建议初学者按这个思路做:只要SPI读取速率高于音频数据消耗速率,就一定能用同步FIFO。44.1kHz双声道16位音频,数据率大概是1.4Mbit/s,而SPI跑10MHz的话读一个扇区只要几毫秒,瞬间突发速率远高于播放消耗,剩下的问题只是FIFO深度够不够、预加载策略对不对。
2.2 SD卡初始化与WAV文件头解析要点
SD卡在SPI模式下读取WAV文件,最麻烦的不是SPI本身,而是初始化序列和WAV文件头解析。SD卡上电后需要先发送至少74个时钟脉冲完成内部上电,然后依次发送CMD0进入SPI模式、CMD8检查电压、CMD55加ACMD41启动初始化流程。初始化完成后读单个扇区用CMD17带上块地址就行。这里常见翻车点是把CMD0的CRC字节算错,或者跳过了CMD8直接发CMD1,导致读回来的全是0xFF。
| 步骤 | 命令 | 参数 | 说明 |
|---|---|---|---|
| 1 | 时钟脉冲 | 无 | 上电后至少74个时钟 |
| 2 | CMD0 | 0x00 | 进入SPI模式 |
| 3 | CMD8 | 0x000001AA | 检查电压范围 |
| 4 | CMD55 | 0x00 | 通知下一命令是ACMD |
| 5 | ACMD41 | 0x40000000 | 启动卡初始化并轮询 |
| 6 | CMD17 | 扇区地址 | 读取一个扇区数据 |
WAV头解析就相对常规了。PCM格式的WAV文件头固定是44字节,RIFF标记在偏移0,WAVE标记在偏移8,声道数在偏移22,采样率在偏移24,位深在偏移34,数据块起始在偏移36。解析器只需要把SD卡读回来的前44字节逐字节检查,遇到fmt块再往后跳,直到找到data标记。很多资源包里的源码直接用固定偏移量跳转,因为大部分WAV文件都是标准PCM没压缩,但对于某些软件导出的非标准头,固定偏移就会解析出错的采样率,所以买资源时优先选带块扫描逻辑的版本。
2.3 播放状态机代码:IDLE、LOAD、PLAY、PAUSE、SWITCH
播放控制是整个工程的骨架,我见过很多人把逻辑散落在按键中断和FIFO标志位里,结果切歌时FIFO没清、暂停时I2S还在移位,声音爆音加串音。正确做法是收敛到一个状态机里,任何外部输入都只改变状态,再由状态去控制FIFO写使能、SPI请求和I2S使能。下面是一个简化但完整的播放状态机骨架:
module player_fsm #( parameter DATA_WIDTH = 16 )( input wire clk, input wire rst_n, input wire key_play, input wire key_next, input wire fifo_half_full, input wire fifo_empty, output reg spi_req, output reg fifo_wr_en, output reg i2s_en, output reg [1:0] track_addr, output reg [2:0] state ); localparam IDLE = 3'd0; localparam LOAD = 3'd1; localparam PLAY = 3'd2; localparam PAUSE = 3'd3; localparam SWITCH = 3'd4; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; spi_req <= 1'b0; fifo_wr_en <= 1'b0; i2s_en <= 1'b0; track_addr <= 2'd0; end else begin case (state) IDLE: begin i2s_en <= 1'b0; if (key_play) state <= LOAD; end LOAD: begin spi_req <= 1'b1; // 请求SPI连续读数据 fifo_wr_en <= 1'b1; // 允许FIFO写入 if (fifo_half_full) begin state <= PLAY; i2s_en <= 1'b1; // 预加载到半满再出声 spi_req <= 1'b0; end end PLAY: begin fifo_wr_en <= 1'b0; if (key_play) begin state <= PAUSE; i2s_en <= 1'b0; // 暂停时I2S必须停 end else if (key_next) begin state <= SWITCH; i2s_en <= 1'b0; end end PAUSE: begin if (key_play) begin state <= PLAY; i2s_en <= 1'b1; end end SWITCH: begin track_addr <= track_addr + 1'b1; // 切换到下一曲 state <= LOAD; end default: state <= IDLE; endcase end end always @(*) begin if (state == SWITCH) fifo_wr_en = 1'b0; // 切歌时禁止写入,等待外部清空FIFO else fifo_wr_en = 1'b1; end endmodule这个状态机的核心思路是:LOAD阶段先把FIFO填到半满再启动I2S,避免播放刚开始的几百毫秒因为FIFO读空而出现咔哒声;暂停时直接把I2S使能拉低,并且状态不再允许FIFO写入,防止暂停期间数据和播放进度错位;SWITCH状态只负责改曲目地址,FIFO的清空动作要放在状态机外部用同步复位逻辑完成,否则可能产生半个字的残留数据。实际工程中还需要把按键输入的消抖结果接进来,否则按一次可能触发两次曲目切换。
3. I2S时序与参数计算:采样率、位时钟与FIFO深度如何配合
FPGA音乐播放器能否顺利出声,关键在三根I2S信号的时序上。I2S总线比SPI简单,但正因为简单,参数错一个就满盘皆输。这一章把协议本身、发送模块代码和FIFO参数三者放到一起讲,参数计算清楚了,后面排错能省一半时间。
3.1 I2S协议:BCLK、LRCK、DATA三根线的分工
I2S只有三根线:位时钟BCLK、帧时钟LRCK和串行数据DATA。BCLK的频率等于采样率乘以位深乘以声道数,例如44.1kHz采样、16位、双声道,BCLK就是44.1k乘以32,约1.4112MHz。LRCK的频率就是采样率本身,44.1kHz,低电平时传输左声道数据,高电平时传输右声道数据,左右声道数据按MSB先出的顺序逐位移出。数据本身在BCLK下降沿变化,接收端在上升沿采样,这个相位关系是I2S调试里最容易忽略的点。
实际设计里,有些DAC还要求MCLK主时钟,频率一般是采样率的256倍或512倍。如果工程里用的DAC芯片需要MCLK而你又没接,芯片内部锁存逻辑不工作,常常表现为上板完全无声。所以看资源包的源码时,先确认它的i2s_tx模块是否同时生成了MCLK、BCLK和LRCK三根信号,很多自行设计的最小系统只带前两路就能驱动某些DAC,但依赖MCLK的芯片就必须三路齐全。
| 采样率 | 位深 | 声道数 | BCLK理论值 | LRCK | 常见MCLK倍数 |
|---|---|---|---|---|---|
| 44.1kHz | 16 | 2 | 1.4112MHz | 44.1kHz | 256倍FS约11.2896MHz |
| 48kHz | 16 | 2 | 1.536MHz | 48kHz | 256倍FS约12.288MHz |
| 8kHz | 16 | 1 | 128kHz | 8kHz | 256倍FS约2.048MHz |
3.2 可综合的I2S发送模块代码
下面这个I2S发送模块是典型的同步分频实现。它不是异步FIFO读时钟方案,而是所有逻辑都在系统时钟上升沿工作,BCLK和LRCK由计数器从系统时钟派生。这样综合后时序收敛容易,仿真也不容易出现亚稳态。
module i2s_tx #( parameter SAMPLE_RATE = 44_100, parameter BIT_DEPTH = 16, parameter CHANNELS = 2, parameter CLK_FREQ = 50_000_000 )( input wire clk, input wire rst_n, input wire [15:0] fifo_data, input wire fifo_empty, output reg fifo_rd_en, output reg bclk, output reg lrck, output reg data_out ); localparam integer HALF_PERIOD = (CLK_FREQ / (SAMPLE_RATE * BIT_DEPTH * CHANNELS * 2)) - 1; reg [15:0] bclk_cnt; reg [7:0] bit_cnt; reg [15:0] shift_reg; reg lrck_reg; reg bclk_d1; reg bclk_d2; wire bclk_pos = bclk_d1 && !bclk_d2; // BCLK生成:半周期分频 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin bclk_cnt <= 16'd0; bclk <= 1'b0; end else if (bclk_cnt >= HALF_PERIOD) begin bclk_cnt <= 16'd0; bclk <= ~bclk; end else begin bclk_cnt <= bclk_cnt + 1'b1; end end // 边沿检测:两级同步,避免组合竞争 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin bclk_d1 <= 1'b0; bclk_d2 <= 1'b0; end else begin bclk_d1 <= bclk; bclk_d2 <= bclk_d1; end end // LRCK生成:每个声道帧翻转一次 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin lrck_reg <= 1'b0; bit_cnt <= 8'd0; end else if (bclk_pos) begin if (bit_cnt >= (BIT_DEPTH * CHANNELS - 1)) begin bit_cnt <= 8'd0; lrck_reg <= ~lrck_reg; end else begin bit_cnt <= bit_cnt + 1'b1; end end end // 数据移位:在每个BCLK上升沿移出一位 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin shift_reg <= 16'd0; data_out <= 1'b0; end else if (bclk_pos) begin if (bit_cnt == 8'd0) begin shift_reg <= fifo_data; data_out <= fifo_data[BIT_DEPTH-1]; end else begin shift_reg <= {shift_reg[BIT_DEPTH-2:0], 1'b0}; data_out <= shift_reg[BIT_DEPTH-2]; end end end // 在帧的倒数第二位提前拉高读请求,预取下一帧数据 assign fifo_rd_en = bclk_pos && (bit_cnt == (BIT_DEPTH * CHANNELS - 2)); endmodule参数计算逻辑是这样的:HALF_PERIOD等于系统时钟频率除以目标BCLK频率再除以2,得到BCLK半周期对应的系统时钟个数。以50MHz系统时钟、44.1kHz双声道16位为例,目标BCLK约1.4112MHz,半周期约17.7个系统时钟,取整为18,实际BCLK约1.3889MHz,误差约1.6%,听感上只是轻微偏低,能接受。LRCK在每十六个BCLK翻转一次,也就是左右声道各16位,一个完整帧32位。数据移位在bclk_pos边沿把fifo_data的最高位先送出,接着每个BCLK周期输出次高位,帧末位再重新加载新数据。fifo_rd_en提前一拍拉高,是为了预取下一帧数据,避免FIFO读数据一个时钟周期的延迟造成帧间空隙。
需要特别注意的是,这里数据在BCLK上升沿变化,严格I2S规范要求在下降沿变化。大多数DAC对边沿极性不敏感,但如果外接的DAC要求严格下降沿更新,只需把移位逻辑中的bclk_pos换成下降沿检测,也就是bclk_d1与bclk_d2的组合反过来。具体判断方法:播放纯音测试频率,如果音调明显不对,优先查采样率参数;如果有明显失真但音高正确,优先查边沿极性。
3.3 FIFO深度与预加载阈值如何配合
FIFO资源包里的常见参数是深度4096、数据位宽16、半满阈值2048。为什么选4096而不是512?因为SD卡SPI读取是突发式的,读一个扇区512字节到FIFO可能只花几十微秒,但播放这512字节却需要大约11.6毫秒。这期间SPI完全空闲,下一次读取从发命令到数据返回有几百微秒的延迟。如果FIFO深度太小,播放器就会在SPI下一次突发到来之前把FIFO读空,声音卡顿。
预加载阈值的作用是避免卡顿出现在最容易被感知的播放开头。在LOAD状态里,FIFO写入到半满才使能I2S,等于给播放器留出了至少半满的缓冲量。播放过程中,SPI每次读到一批数据就有空间写,FIFO始终在半满上下波动,而不是频繁触底。工程中半满标志通常由FIFO内部的计数比较器产生,代码里直接用fifo_half_full信号即可。如果实际使用中连续切歌两次后出现卡顿,很可能是切歌时FIFO没有被完全清空,下一曲的解析数据混入了上一曲残余数据,此时要在SWITCH状态下强制清空FIFO并等待两个空的写周期。
4. 排查实战:声音异常、断续与无声的四个典型坑
音频调试很容易让人怀疑人生,因为耳朵听到的结果不像仿真波形那样有明确的好坏标准。我把这类工程里最容易翻车的四个问题按现象、原因、解决方式拆开,每一条都是实际调试中反复出现的固定套路,遇到对应现象可以直接按这个顺序排查。
4.1 现象:放出来只有刺耳的白噪声,隐约能听到一点音乐
现象描述:I2S输出已经接了DAC和喇叭,也能听到声音,但内容大部分是噪声,偶尔能辨别出旋律。用示波器看DATA引脚,发现波形每个位之间有明显毛刺或宽度不一致。
原因分析:数据位序错误是最大嫌疑。I2S要求MSB先移出,如果代码把fifo_data的最低位先送出去,DAC会把低位数据当成高位解析,输出噪声明显但还残留原旋律的影子。第二个常见原因是数据更新沿不对,发送端在BCLK上升沿更新数据,而接收端也在上升沿采样,数据就处于不稳定状态,DAC采样到的经常是跳变瞬间的电平。
解决办法:先用示波器或者逻辑分析仪抓三根线,确认每个BCLK周期DATA只有一次稳定变化。然后检查移位逻辑里shift_reg的初始值,确保先输出的是fifo_data[BIT_DEPTH-1]。最后检查WAV解析出来的数据字节序,小端格式下16位采样值的低字节在前,SPI读回来之后要组合成{高字节,低字节},反了也会出现类似噪声。
4.2 现象:播放几秒后突然停止,重新上电又能播一会
现象描述:刚上电时声音正常,播放三五秒后突然断掉,Log里FIFO状态显示empty。按键暂停再播放偶尔能恢复,但很快又断。
原因分析:FIFO读空是直接原因,但读空背后有两种可能。一种是SPI读取速率不够,连续读扇区时单个扇区传输时间太长,小于播放消耗时间;另一种是FIFO深度太小,SPI还没有完成下一个突发读,缓冲就见底了。用仿真统计spi_req到fifo_wr_en拉高的时间间隔,如果发现间隔超过播放3毫秒的数据量,就必须加大FIFO。
解决办法:把FIFO深度从2048加到8192,半满阈值设为4096,同时把SPI时钟从1MHz提到8MHz或10MHz。还要核对SPI读命令之间是否加了超长延时,有些时序模板里每读一个字节就延时几十微秒,突发速率被拖垮。另一个细节:切歌状态切回来时要重新预加载,不能直接从PLAY开始,否则FIFO从空开始跑,必然卡顿。
4.3 现象:音调偏高或偏低,声音像卡通人物或者明显拖慢
现象描述:播放的旋律能辨认,音高整体偏了,男声变童声或变低沉。用频率计测LRCK,发现不是标称的44.1kHz。
原因分析:采样率参数不匹配是最常见的原因。WAV文件本身是44.1kHz,但播放器按48kHz的参数生成了LRCK,那么声音整体被拉快,音调变高。反过来则是音调变低。还有一种情况是参数虽然填对了,但BCLK用整数分频产生了1.5%左右的偏差,这种偏差放大器对专业耳朵来说是可感的,但课程设计一般注意不到。
解决办法:先确认WAV文件的真实采样率,然后用十六进制工具查文件头偏移24处的四个字节,再对照工程参数。对于44.1kHz这类不太适合整数分频的采样率,如果板子带PLL或MMCM,直接生成11.2896MHz作为MCLK,再从MCLK二分频得到BCLK,这样误差为零。若没有PLL,就接受整数分频的误差,别在参数上反复折腾。
4.4 现象:仿真波形完美,上板之后完全无声
现象描述:Testbench里I2S输出波形正确,LRCK和BCLK频率都对,但板上接喇叭或者耳机后一点声音都没有,DAC数据手册上的供电和信号路径也检查过。
原因分析:仿真通过但板级无声,九成出在引脚约束和电平匹配上。I2S的DATA、LRCK、BCLK引脚没有绑定到FPGA的特定物理引脚,或者绑定时把DATA绑到了一个被其他外设占用的引脚上。另外,FPGA IO电压是1.8V还是3.3V,和DAC的I2S输入电压范围是否兼容,这个经常被忽略。
解决办法:打开约束文件检查三个信号是否分别绑定到目标引脚的物理编号,且没有被复用到其他功能。用示波器确实看到三根线都有信号但DAC不工作,就把MCLK补上。如果是面包板加杜邦线的接法,优先怀疑接地没共地,FPGA开发板和DAC模块需要共地,否则所有信号都悬空。最稳妥的做法是先接一个最简单的I2S DAC评估板,排除自制的电平转换电路问题。
4.5 排查工具链:示波器、逻辑分析仪与在线逻辑分析的选择
普通示波器就能覆盖I2S时序检查的大部分需求:看BCLK频率是否在预期范围,看LRCK低电平结束时数据是否为高电平,这些在示波器上都能直接读帧结构。逻辑分析仪适合抓多通道交互,比如同时观察SPI的MISO和I2S的DATA,确认数据从SD卡到FIFO再到I2S的顺序。在线逻辑分析仪在FPGA调试中更实用,因为它不需要外接探头,直接看内部FIFO空满标志和状态机跳转。我的习惯是先跑仿真解决逻辑问题,再上板抓BCLK和LRCK确认参数,最后才用耳机去听效果,这样每一步都有可测量的依据,不至于把所有问题混在一起。
5. Verilog工程整合与验证:按键消抖、音量调节与Testbench设计
状态机和I2S模块都是基本功,真正决定这套工程能不能交给导师验收的,是外围交互的细节。按键抖动导致的误触发、音量调节时的削波失真、仿真环境里拿什么数据去驱动FIFO,这三个问题在资源包里往往被一笔带过,但恰恰是它们决定着播放器好不好用。
5.1 按键消抖:参数怎么设,为什么需要两级同步
按键直接接入状态机输入端,最典型的表现是按一次播放键,状态在PLAY和PAUSE之间弹跳好几次,因为机械触点闭合时的抖动持续几十毫秒,而FPGA时钟是几十纳秒级别,一次按键会被采样成大量的0和1交替序列。消抖的本质是只认稳定的电平,常见的做法是等待电平连续稳定一段时间后再认为按键有效。
module key_debounce #( parameter CLK_FREQ = 50_000_000, parameter DEBOUNCE_MS = 20 )( input wire clk, input wire rst_n, input wire key_in, output reg key_press ); localparam integer CNT_MAX = CLK_FREQ / 1000 * DEBOUNCE_MS; reg key_sync0; reg key_sync1; reg key_stable; reg key_stable_d; reg [31:0] cnt; // 两级同步,消除亚稳态 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin key_sync0 <= 1'b1; key_sync1 <= 1'b1; end else begin key_sync0 <= key_in; key_sync1 <= key_sync0; end end // 稳定电平检测:目标电平持续CNT_MAX个时钟后才切换 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 32'd0; key_stable <= 1'b1; end else if (key_sync1 != key_stable) begin if (cnt >= CNT_MAX) begin key_stable <= key_sync1; cnt <= 32'd0; end else begin cnt <= cnt + 1'b1; end end else begin cnt <= 32'd0; end end // 边沿检测:只在稳定电平由高变低时产生一个脉冲 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin key_stable_d <= 1'b1; key_press <= 1'b0; end else begin key_stable_d <= key_stable; key_press <= !key_stable && key_stable_d; end end endmodule参数说明:CLK_FREQ是系统时钟频率,DEBOUNCE_MS是稳定时间,20毫秒对机械按键足够,既能滤掉抖动,又不会让按下的感觉变迟钝。代码里cnt从0数到CNT_MAX,也就是20毫秒内目标电平始终不变才切换状态。同步两级的作用是先把按键输入异步信号打成与时钟同步的信号,虽然不能完全消除亚稳态,但能把亚稳态传播的概率压到工程可接受范围。实际接入状态机时,把key_press作为player_fsm的key_play触发信号,那边检测的是脉冲而不是电平,否则按住按键会反复触发。
如果按键是低电平有效,按下时key_in为0,key_press在按下的瞬间输出一个高脉冲。如果你手上的板子按键是高电平有效,需要把key_sync0 <= ~key_in或者把边沿检测条件取反。判断依据是看原理图的按键接法:一端接GND、一端接IO时是低有效;一端接VCC、一端接IO时是高有效。
5.2 音量调节:移位代替乘法器和饱和截断的取舍
音量调节最容易犯的错误是直接写乘法器,用vol_level乘以采样值,然后在输出端直接截断。乘法的结果是位宽增加,截断时如果没做饱和处理,音量调到最大时高位的溢出会让声音变成刺耳的破音。另一种错误是右移实现音量,把符号移位当成逻辑移位,导致负数采样值的绝对值被破坏,声音会出现明显的直流偏移。
module volume_ctrl #( parameter DATA_WIDTH = 16 )( input wire signed [DATA_WIDTH-1:0] sample_in, input wire [1:0] vol_level, output reg signed [DATA_WIDTH:0] sample_out ); always @(*) begin case (vol_level) 2'd0: sample_out = sample_in >>> 2; // 25%音量,右移两位 2'd1: sample_out = sample_in >>> 1; // 50%音量,右移一位 2'd2: sample_out = sample_in; // 100%音量 2'd3: sample_out = sample_in <<< 1; // 200%音量,需饱和 default: sample_out = sample_in; endcase end // 200%音量做饱和:超出16位范围直接限幅 always @(*) begin if (sample_out > 16'sd32767) sample_out = 16'sd32767; else if (sample_out < -16'sd32768) sample_out = -16'sd32768; end endmodule这里用得最多的是算术右移运算符>>>,它保证了负数采样值右移时保持符号位填充,音量的变化符合听感直觉。饱和逻辑是为了防止200%音量时高位移到符号位导致输出从正数瞬间变成负数,产生幅度极大的跳变。注意sample_out位宽是17位,因为200%音量需要多出来的一个保护位。实际接入信号链时,如果后续I2S发射模块只接受16位输入,在sample_out截断到16位时也要加同样的饱和判断。工程里如果不需要200%音量档位,可以只保留0档、1档、2档,这样省掉饱和逻辑,但保留代码结构对以后扩展更有价值。
5.3 Testbench设计:模拟WAV数据注入与I2S波形检查
仿真环境里没有真正的SD卡读取,所以Testbench要扮演两个角色:一个是SD卡模型,按SPI时序把构造好的WAV文件数据吐给解析器;另一个是I2S波形检查器,自动统计BCLK频率和LRCK频率,判断是否与目标值一致。下面是一个针对I2S模块的轻量级Testbench,重点是统计和保护语句的位置。
module tb_i2s_tx; reg clk = 1'b0; reg rst_n = 1'b0; reg [15:0] test_sample = 16'h1234; wire fifo_rd_en; wire bclk; wire lrck; wire data_out; always #10 clk = ~clk; initial begin #20 rst_n = 1'b1; #5000; $finish; end // 模拟FIFO:每次读请求后提供一个固定采样值 reg fifo_empty_reg; always @(posedge clk or negedge rst_n) begin if (!rst_n) fifo_empty_reg <= 1'b0; else if (fifo_rd_en) fifo_empty_reg <= 1'b0; // 数据足够,始终非空 end i2s_tx #( .CLK_FREQ(50_000_000), .SAMPLE_RATE(44_100), .BIT_DEPTH(16), .CHANNELS(2) ) uut ( .clk(clk), .rst_n(rst_n), .fifo_data(test_sample), .fifo_empty(fifo_empty_reg), .fifo_rd_en(fifo_rd_en), .bclk(bclk), .lrck(lrck), .data_out(data_out) ); // 运行时检查:统计BCLK上升沿次数 reg [31:0] bclk_rise_cnt; always @(posedge bclk) begin bclk_rise_cnt <= bclk_rise_cnt + 1; end // 检查在LRCK低电平期间,data_out是否等于采样值最高位 always @(posedge bclk) begin if (!lrck && (bclk_rise_cnt % 32 == 0)) if (data_out !== test_sample[15]) $display("ERROR: MSB mismatch at %0t", $time); end endmoduleTestbench里两个检查点很有价值:第一个是bclk_rise_cnt统计,跑完5微秒后如果BCLK上升沿数量接近预期,说明分频参数没有算错;第二个是MSB检查,在LRCK为低时第一个BCLK周期DATA必须是采样值最高位,这个检查能抓住数据位序错误。真正做全系统仿真时,我会把WAV文件转成文本格式,用Verilog的readmemh读进一个仿真数组,再按SPI时序把数组内容逐字节送出来。这样仿真覆盖了解析、FIFO、状态机、I2S全链路,比单独模块仿真多了一层保障,虽然仿真耗时很长,但能减少上板反复试错的次数。
6. 进阶玩法:双声道改造、外接DAC与ROM合成音色
这套工程能跑通单声道播放之后,再往上有三条性价比很高的路:改成双声道、换更好的DAC、绕开SD卡直接用ROM合成音色。每一条都会遇到新的参数问题,但原理都是前面讲过的那些。
6.1 从单声道升级双声道:数据位宽与FIFO宽度的联动修改
单声道版本的FIFO数据位宽是16位,双声道需要把左右声道两个采样值拼接成32位一次性写入FIFO。常见做法是把两个16位声道拼成{left_sample, right_sample},FIFO位宽从16改成32,I2S发射器对32位数据先移出高16位再移出低16位。LRCK低电平期间输出FIFO数据的高16位,高电平期间输出低16位。这个改造的关键是FIFO深度不用翻倍,因为一次读写32位,但注意半满阈值也要相应调整,否则预加载触发时刻会变。还有一个容易漏的点:SPI读取WAV数据时是左右声道交错排列的,解析器需要先缓存一个声道采样值,等下一个到达再拼接,否则FIFO里会出现左右声道错位。
6.2 外接DAC与I2S接线:MCLK和电压域检查清单
入门工程通常用板载DAC,效果一般。换外接DAC时,先把三根线对应关系确认一遍,然后查MCLK是否必须。多数入门级DAC可以在无MCLK模式下工作,但部分专用音频DAC芯片内部需要MCLK做数字滤波,没有MCLK就会静音。接机建议按照MCLK大于等于256倍采样率的原则来生成,44.1kHz对应11.2896MHz,可以从FPGA的PLL直接产生。电压域方面,检查FPGA IO的BANK电压是否为3.3V或与DAC一致,差一个等级都可能让数据无法识别。接完之后用示波器测MCLK和BCLK的相对相位,确保MCLK比BCLK早建立,否则部分DAC上电时锁不到主时钟。
6.3 用ROM合成音色:省掉SD卡的最小播放器
如果手头没有SD卡模块,或者只是验证I2S时序,完全可以用ROM存一段合成音色。先把一段16位单声道44.1kHz的PCM数据手工生成或者用工具转成coe文件,在FPGA里例化一个只读存储器,播放状态机只需要从0地址顺序读到末尾再循环,不需要SPI、解析和FIFO。这个玩法特别适合新手理解I2S时序,因为ROM读取没有突发延迟,空标志永远不会拉高,调试难度又降了一级。我自己调试这类工程时,通常先用ROM放一段1kHz正弦波确认链路通畅,再切换回SD卡读取真实音乐,这样一旦出问题能立刻区分是时序链路问题还是SD卡读取问题。从那以后我每次上板前都强制走一遍“ROM正弦波到真实WAV”的切换流程,这个习惯把一半以上的音频故障拦截在耳机之前。希望帮到你。
本文还有配套的精品资源,点击获取