做FPGA的人迟早会碰到数字滤波器。你可能只是想把一段带噪信号里的低频部分干净地摘出来,结果发现Quartus和Vivado两套工具链给出的实现路径完全不同,仿真里跑得欢的东西,一上板就开始闹脾气。这篇文章把我自己实际用FPGA做数字滤波器的完整思路写下来,从FIR和IIR怎么选、系数怎么定、IP核怎么配,到两套开发工具里的仿真和排坑,希望能帮正在折腾这件事的人少走一点弯路。
这里有个判断先放在前面:FPGA里的数字滤波器,绝大多数场景应该用FIR,IIR要有足够理由才碰。而无论用哪家工具链,最后都会撞上两个硬问题——时序能不能收敛,输出截断会不会吃掉信噪比。把这两件事想明白,剩下的基本都是体力活。
1. 为什么要在FPGA上做数字滤波器,而不是用DSP或MCU
1.1 一个典型的实时处理需求:卡在“每一拍都要有结果”
我拿一个很常见的场景来拆解。ADC以48kHz采样,输出16位数据,系统要做低通滤波,只保留10kHz以下的信号,然后送到DAC或者后级调制器。单通道的话,一块普通MCU都能扛住;可如果系统要同时处理8路信号,每路都是128阶FIR,那么每个采样周期需要的乘加次数是8乘128等于1024次。一个采样周期只有约20.8微秒,按这个算,MCU主频50MHz、单周期乘加,理论上有余量,但实际上还要做中断管理、数据搬运、滤波后的其他处理,任何一个调度抖动都会让输出数据的时间间隔不再均匀。
很多人对“实时”的理解停留在“算得够快”,其实数字信号处理里更苛刻的是“每一拍都有输出”以及“输出和输出之间的间隔严格恒定”。DSP或MCU做滤波时,计算耗时随缓存命中率、中断优先级、总线仲裁而波动,做不到周期级确定。FPGA则是把128个乘法器和树形加法器全部在硬件里铺开,每个时钟周期都吐一个样点,从数据进来到结果出去,延迟是固定的几个时钟周期,和CPU负载、调度完全无关。音视频同步、电机电流环、通信基带这类场景,对确定性的要求远高于平均吞吐。
1.2 并行度、确定性时延和位宽自由
FPGA的第一优势是并行。128阶FIR的全并行实现看起来要吃128个乘法器,其实利用线性相位FIR的系数对称性,可以砍到64个;再算上16位乘16位的乘法器,中等规模FPGA完全放得下。如果对吞吐量没有极端要求,还能改成半并行:用少数几个DSP块分时复用,系统时钟跑得比采样率快N倍,照样满足每个采样周期输出一个结果。这种吞吐与资源之间的折中是FPGA特有的。
第二优势是确定性时延。你不需要知道系统有多忙,只要固定配置好滤波器,输入到输出的延迟就是一个确定的常值,比如“12拍数据加上7级流水线”这种可预测数。多通道时,每路各例化一份FIR,天然同步对齐。
第三优势是位宽自由。AD/DA固定是16位,但FPGA内部累加器完全可以用40位、48位,中间过程几乎不掉精度。这是MCU上做定点库很难比的,因为你受限于寄存器和指令集,扩展精度往往要考虑架构改动。
1.3 反过来泼一盆冷水
不过FPGA不是万能钥匙。单通道、采样率几十kHz、FIR阶数六七十以内,用MCU/DSP明显更省事:开发环境便宜、调试方便、功耗更低、板上面积也小。FPGA那一套配置芯片、时钟管理、电源时序、PCB布线,成本和复杂度都比MCU方案高一个量级。我见过的项目里,不少是把“用FPGA显得很专业”当成了理由,最后发现维护成本极高。
所以我的建议是,动手前先问三个问题:采样率能不能降下来?加一片DSP/MCU能不能解决?对时延抖动有没有硬性要求?三个问题如果都是否定答案,再考虑FPGA。这个话题展开还能写一整篇,这里先打住。
2. FIR与IIR的硬件之争:为什么我默认选FIR
2.1 从系统函数看硬件实现代价
IIR滤波器的系统函数带分母,换到硬件语言就是存在反馈回路:当前输出依赖之前的输出。反馈回路天生不好流水化,因为你要等上一次计算结果出来,才能算下一次,临界路径至少是一个“乘加+进位判断”的完整循环。想在FPGA上把时钟频率做高,最怕这种递归依赖。何况定点IIR还要面对系数误差敏感、极限环振荡、溢出恢复等问题,听起来功能更强大,做起来并不轻松。
FIR没有反馈项,只有输入延迟线、一组乘法器、一棵加法树。因为数据从输入流向输出是单向的,综合工具可以在每一级运算之间自由插入流水寄存器。这意味着硬件实现可以做到高频率、低延迟、完全确定。所以FIR在FPGA工程里几乎成了默认选项,大多数情况下并非因为FIR的滤波效果更好,而是因为它更适合硬件。
2.2 一组可复现的设计参数:48kHz下的128阶低通
实际设计时应该怎么定参数?拿音频里很常见的需求举例:采样率48kHz,通带0到10kHz,阻带从14kHz起,阻带衰减至少60dB,通带纹波小于0.1dB。过渡带只有4kHz,用Kaiser窗参数估算或直接调用工具,大概需要100阶以上,工程上我会直接取128阶,给后续量化留余量。
我一般在MATLAB或Octave里先出浮点系数:
Fs = 48000; Fpass = 10000; Fstop = 14000; % 指定阶数设计等波纹滤波器,127是128阶的阶数-1 b = firpm(127, [0 Fpass Fstop Fs/2]/(Fs/2), [1 1 0 0], [1 5]); freqz(b, 1, 4096, Fs);权重[1 5]表示阻带衰减权重更高,直接看幅频响应,若60dB不够就把权重再调大,或者改成kaiserord算出来的阶数。窗函数方面,Hamming在同样阶数下旁瓣约-53dB,Blackman能到-74dB但过渡带变宽,Kaiser可以通过β参数在两者之间做平衡。要求边缘频率分明、又有衰减目标时,我优先用等波纹设计firpm,而不是简单加窗。
下表是三种常见窗的参考特性,方便选型时快速对照:
| 窗类型 | 旁瓣衰减近似 | 过渡带宽度近似(相对主瓣) | 典型用途 |
|---|---|---|---|
| Hamming | 约-53dB | 较窄 | 一般FIR设计,衰减要求60dB附近 |
| Blackman | 约-74dB | 较宽 | 需要更强阻带抑制、但过渡带可放宽 |
| Kaiser | 可调(β控制) | 可调 | 衰减和过渡带需要灵活权衡时 |
2.3 系数定点化:从浮点到Q14格式
浮点系数不能直接用,得定点化。FIR系数经过归一化后,乘2^15转成16位有符号数,这就是常说的Q15格式。注意留一点余量,避免某些系数正好卡在边界产生截断误差。转换代码可以这样写:
max_abs = max(abs(b)); scale = (2^15 - 1) / max_abs * 0.95; % 留5%余量 bq = round(b * scale);然后把bq写入.coe或.mif文件。16位系数配合16位输入,乘法器就是16乘16,DSP块用得最划算。更重要的一点:线性相位FIR的系数是镜像对称的,128阶只有64个独立系数,全并行时乘法器数量直接减半。内部累加器的宽度取决于动态范围:16位乘16位先得到32位乘积,128个乘积累加最多增加7位,所以累加器至少要39位。厂商IP核一般会自动算输出宽度,自己写RTL时千万不能拍脑袋放一个16位累加器,否则每算几次就溢出一次。
具体实现时,如果输出端接的是16位DAC,通常是把累加结果先截成20位甚至24位,再统一缩放到16位。截断这事看着简单,实际上和信噪比强相关,第5章会专门展开。
3. Quartus侧的实操记录:FIR MegaCore与ModelSim仿真
3.1 为什么第一次用IP核而不是手写RTL
新手阶段不要试图用RTL手写128阶FIR。厂商IP核的价值不仅仅在于省事,更在于它把最难的部分做完了:乘法器映射、流水线插入、DSP块分配、时序约束,甚至仿真模型都一并生成。你自己写的RTL逻辑上可能完全正确,但综合后临界路径很长,Fmax上不去,回头还得插流水寄存器,等于把厂商已经解决的问题重新做一遍。
当然,IP核也有缺点:它是一个黑盒,接口复杂,版本升级后IP要重新生成,出现问题排起来费劲。我的建议是先用IP核打通一次完整流程,理解输入输出行为,再决定要不要手写。手写RTL更适合追求极致资源利用、接口定制、或者在IP不适应现有协议链路的场景。
3.2 FIR MegaCore的配置要点
在Quartus里创建FIR核的路径一般是Tools菜单下的MegaWizard Plug-In Manager,选择FIR Compiler。第一步是系数导入。用.coe文件是最省事的,格式如下:
Coefficient_Width = 16; Coefficient_Fraction = 15; Coefficients = 2949, -2140, 518, -101, 17, ...;将MATLAB生成的bq按顺序填进去。接下来设置滤波器类型、输入输出位宽、结构参数。MegaCore里有一个“Channels per clock cycle”或类似选项,决定全并行还是半并行:选1代表一个时钟周期处理一个采样点,选更高代表多通道并行复用,资源消耗也不同。配套设置里还要填两个频率:输入数据的采样频率和系统时钟频率。工具会利用这个比值自动算出每个采样周期内可用的时钟节拍,如果时钟频率远高于采样率,它会自动选择串行化实现,大幅压缩DSP块用量。
生成时务必把仿真模型也勾上,生成后会得到顶层.v、仿真模型.v和需要加入编译列表的.qip文件。不要急着综合,先把时钟频率的约束写到SDC里。很多人跑仿真一切正常,一综合就时序违例,问题往往出在工程里根本没有为这个时钟建约束。
3.3 在ModelSim里做仿真
仿真时我不会把IP核当黑盒直接扔上板。先写一个testbench,生成一路正弦输入,观察输出波形是否符合预期。基本骨架类似:
`timescale 1ns / 1ps module fir_tb; reg clk = 1'b0; reg rst_n = 1'b0; reg signed [15:0] din = 16'd0; wire signed [31:0] dout; always #10 clk = ~clk; // 50MHz initial begin rst_n = 1'b0; #200 rst_n = 1'b1; repeat (4096) begin @(posedge clk); din = 16'd3000 * $sin(2 * 3.14159 * 5000 * $realtime * 1e-9); end $stop; end fir_inst u_fir ( .clk(clk), .rst_n(rst_n), .din(din), .dout(dout) ); endmodule这里$sin里的$realtime只是示意,实际testbench里更稳妥的做法是用计数器做正弦查找表,避免仿真时间依赖带来的误差。重点是波形里看两点:输出稳定后,5kHz正弦在通带内幅度应与输入接近;如果把激励改成20kHz,输出幅度应明显衰减。能直接观察这两个现象,说明滤波器通路没接错。
需要看频响的话,把输入做成扫频信号,从1kHz扫到24kHz,观察波形包络在10kHz附近开始下降、14kHz以上被压住,这比一句“仿真通过”有说服力得多。也可以导出数据到MATLAB做FFT对比,但工程上扫频波形已经够用。
4. 换到Vivado之后:FIR Compiler与AXI4-Stream的适应过程
4.1 工具链习惯最不同的三个地方
从Quartus切到Vivado,我花了整整一个下午才适应。第一个不同是工程组织方式。Quartus用.qpf工程文件,SDC约束,工程结构相对简单;Vivado则是project目录制,所有IP都在独立工程里生成、综合、生成输出产品,约束使用XDC。语法上SDC与XDC高度相似,但工具的操作路径完全不同。
第二个不同是IP接口哲学。Quartus的FIR MegaCore常用Avalon-ST接口,Vivado的FIR Compiler几乎都是AXI4-Stream。两者握手概念同源,valid/tready一脉相承,但信号名、位宽扩展、时序余量都不同。照搬一份RTL到另一个工程,接口部分几乎必然要重写。
第三个是仿真环境。Quartus习惯外挂ModelSim,Vivado则有自带XSim。两边testbench语法通用,但脚本、波形操作和仿真库设置不一样。如果团队之前积累了大量ModelSim脚本,切到Vivado后并不能直接用,需要重新封装一层。
4.2 FIR Compiler配置:界面逻辑和MegaCore几乎相反
打开IP Catalog搜索FIR Compiler,配置界面第一个停留点就是“采样频率和系统时钟频率”。这个设置比MegaCore更直白:48kHz采样率、128阶FIR、系统时钟100MHz,意味着一次采样到下一次采样之间有大约2083个时钟周期,完全足够串行执行128次乘加。选择多通道复用或折叠结构,资源可以压到很低。如果系统时钟只有1MHz,那它就只能全并行铺乘法器,别无他法。理解了这一点,再看界面上的Resource Estimation心里就有数了。
系数导入同样支持.coe,导入后Vivado会直接画出滤波器的频率响应曲线。我强烈建议在导入后瞄一眼这个曲线:通带是否平坦、阻带是否掉到60dB以下。不要等到综合完才发现系数文件和设计意图不一致。
接口方面,FIR Compiler支持AXI4-Stream,选好之后IP会告诉你确切的Latency。比如128阶FIR,不同实现结构下会出现几十到一百多拍的延迟。这个数一定记下来,仿真时对照tvalid输出时间差,能快速判断数据通路是否正确。
4.3 用最简单的读写验证打通AXI数据通路
用AXI4-Stream之前,必须把握手规则放进肌肉记忆:数据有效信号tvalid与tready同时拉高时,这个周期的数据才算被接收。驱动侧绝不能只拉tvalid不理tready。很多人在仿真里看到tvalid高、tready低,数据却丢了,就是因为没按握手规则保持数据。一个基本驱动片段:
always @(posedge clk) begin if (!rst_n) begin s_axis_tvalid <= 1'b0; s_axis_tdata <= 16'd0; end else if (s_axis_tready) begin s_axis_tvalid <= 1'b1; s_axis_tdata <= next_sample; end end如果不想从零写驱动,直接用IP自动生成的Example Design更快:它会替你接好寄存器、FIFO、输出总线和计数器。我一般生成Example Design后先跑一遍RTL仿真,确认能看到tdout数据在预期延时后有效,再删掉多余的演示逻辑,替换成自己的数据源。这个方法在带AXI接口的任何IP上都通用。
5. 仿真和上板的差距:三处我在两套平台上都踩过的坑
5.1 异步输入的同步化:不做就是偶发错值
仿真环境里激励和时钟天然同源,板子上完全不是这样。ADC采样时钟通常由外部晶振或锁相环产生,与FPGA内部系统时钟不是同一棵树。直接把ADC输出接入FIR的输入,第一个采样触发器会有亚稳态风险。亚稳态的结果不是每次都错,而是偶发:仿真一万次都对,上板跑十分钟出一次毛刺,最让人头疼。
标准做法是打两拍同步,或者当多bit总线跨越时钟域时使用异步FIFO。滤波器要求数据严格连续,我建议在ADC侧就把时钟域转换做干净,保证进入FIR的每一个数据都被时钟对齐过。宁可多花两拍延迟,也不要顶着偶发错值上线。
5.2 流水线级数决定时序:不是逻辑对就能收敛
仿真通过不代表综合后能跑到目标频率。128阶全并行FIR,如果所有乘法和加法都挤在一拍里完成,加法树的组合逻辑深度大约有7到8层,再加上16位乘法器本身,临界路径长度会非常可观。Fmax上不去的关键就在这里。
用IP核的目的就是让厂商把这些流水级替你插好。自己写RTL时,拆流水线有套路:乘法器输出先打一拍,再做第一级加法,加法结果再打一拍,继续下一级,直到树形累加完成。每插一级流水,Fmax就上一个台阶,但Latency也会增加。具体取舍要拿时序报告说话。综合后打开时序报告,找到最长路径,看是落在乘加链的哪一段,再决定在哪一级拆。“仿真通过不一定时序收敛”这个亏我吃过之后,已经把“综合跑完再看时序报告”列为每次项目的准出条件之一。
5.3 输出截断策略:信噪比就是这么被吃掉的
输出端是从内部宽位到外部16位的截断,这是最容易无声无息扣精度的地方。前面算过,内部累加器至少39位,厂商IP输出位宽很多会做成32位或更高。直接把这32位砍成16位,等于把低位噪声一股脑倒进信号里。线性相位FIR通带内的真实有效位并不等于输出位宽,截断方式直接决定最终SNR。
工程上我一般先设置成“四舍五入”而不是“直接截断”,如果噪声还明显,再用输出端的动态缩放:用一个寄存器统计一段时间内的峰值,按峰值调整右移位数。输入16位系统的理论SNR约98dB,但在输出截断时如果处理草率,很可能连90dB都保不住。这个差值在音频设备里能听到明显的底噪。
6. 同一份RTL分别综合的结果:数据对比和我的最终建议
6.1 128阶FIR在两个工具链下的资源占用实录
下面是我在同等规模的FPGA器件、同样的约束条件下,用128阶FIR、16位输入输出、16位系数、对称系数复用后得到的一组参考数据。工具版本不同、选项不同,数字会有出入,但量级关系可以参考。
| 资源项 | Quartus | Vivado |
|---|---|---|
| 查找表逻辑 | 约18500 | 约16800 |
| 触发器 | 约14200 | 约13600 |
| DSP块(16x16) | 64 | 66 |
| BRAM(36Kb) | 0 | 2 |
| 最高可达到时钟频率 | 约187MHz | 约195MHz |
| 估算功耗 | 约0.42W | 约0.39W |
注意Vivado侧多出来两块BRAM,一般是AXI接口缓冲和示例设计带来的,不是滤波运算本身。这点在资源规划时容易被忽略。如果改成半并行结构,DSP块会大幅减少,查找表和寄存器则会上涨,具体数值取决于折叠倍数。两套工具都能达到百MHz级别,真正的瓶颈往往在接口电路和时钟管理,而不是滤波器本身。
6.2 时序与功耗:两本报告里的关键差异
只看两个Fmax数字,会得出“Vivado稍快”这种误导性结论。其实不同厂商的DSP块结构和时钟树设计不同,而且时序报告里“时序裕量”的口径也有差别。更重要的是,工具版本一升级,同一份代码的时序可能相差几十MHz。所以我更关注的是“是否留够了裕量”而不是“谁的Fmax更高”。
功耗方面,两家的估算是模型化的,近似程度差别大,尤其BRAM占比高的时候。对于手持设备这类功耗敏感场景,最终还是要靠板级实测。我的经验是:FPGA做滤波的功耗主要来自时钟翻转和DSP计算,动态功耗与采样率并不完全成正比,因为内部逻辑时钟往往远高于采样率,半并行结构会在低频输入下依然消耗可观功耗。如果为省DSP块选了高度折叠,记得在功耗仿真里多看一眼。
6.3 以后再做类似模块,我会怎么选
我现在选型逻辑很直接:如果团队已经绑定某家FPGA生态,就用它对应的IP核,Quartus就MegaCore,Vivado就FIR Compiler,别为“可移植性”牺牲掉厂商优化带来的时序和资源收益。如果是从零开始选平台,优先考虑团队熟悉度,Quartus和Vivado在滤波这一点上功能几乎等价。
最后再分享一个我在两个平台都沿用的习惯:在顶层例化一组可在线配置的寄存器,把滤波器的旁路开关、输入数据源选择、输出缩放系数暴露出来。上板调试时不用反复改RTL,直接通过内部逻辑分析仪或软核写寄存器,就能快速对比滤波前后效果。滤波器本身其实不难,真正耗时间的永远是调试流程,把调试抓手做足,比多会几个IP配置技巧更能提高效率。