做FPGA的工程师,迟早会和一个模块正面相遇——数字滤波器。尤其是FIR(有限冲激响应)滤波器,它几乎是信号处理类FPGA工程的默认开场白。我在一个音频采集的工程里第一次认真做这个设计,当时就是在Quartus与Vivado两条工具链之间来回切换,同一个滤波器指标,从系数生成到RTL实现再到上板验证,两条路走下来踩了不少坑,但也顺势把滤波器的原理彻底吃透了。今天就拿一个具体的低通FIR设计当例子,把基于FPGA的数字滤波器设计从指标、系数、定点量化、RTL实现到两条EDA工具链的开发流程完整过一遍。这篇内容适合三类人:正在选毕业设计题目的通信/电子方向学生、想用FPGA做信号处理却还没怎么接触DSP的嵌入式工程师、以及遇到“同一套Verilog怎么在两个平台都能跑通”这类困惑的入门开发者。
1. 设计思路与整体方案拆解
1.1 为什么FPGA上的数字滤波器首选FIR
FPGA上做数字滤波器,绝大多数场景选FIR而不是IIR,这不是习惯问题,而是FIR结构先天适合硬件。FIR没有反馈支路,所有极点都在原点,系统天生稳定,不会出现IIR那种因为系数量化或舍入误差导致的极限环振荡。另一个关键点在线性相位:FIR的系数只要关于中心对称,就能保证群延迟恒定,意思是信号里各个频率分量经过滤波器后延迟时间一致,波形不发生相位失真。
用生活化类比理解:IIR像是一个说话有回音的喇叭,轻微干扰都会让回声反复叠加;FIR则像一段整齐排列的台阶,信号按顺序经过每一个台阶,每级台阶只跟前面的数据有关,不回头,所以行为可预测、稳定。在音频去噪、通信信道均衡、数字下变频这些场景里,线性相位是硬指标,比如音频波形过了滤波器如果相位被扭曲,声音听感就会发闷甚至发飘。所以FIR在FPGA工程里是默认选择。
IIR不是绝对不能用,但代价很直接:反馈环限制了流水线插入,时钟频率跑不高;极点位置对系数量化误差极其敏感,稍微量化几步可能就从稳定变成振荡。要在FPGA上把IIR的稳定性、精度和时序都照顾好,复杂度远超FIR。除非极端追求低阶数小资源,否则没必要给自己找这个麻烦。
1.2 我的完整设计路线:从系数到两条工具链的取舍
一个标准的FPGA数字滤波器工程,完整路线可以拆成四段:指标定义、系数准备、RTL或IP核实现、仿真与上板验证。指标定义就是定采样率、截止频率、阻带衰减和通带纹波;系数准备通常用Python或MATLAB计算,再把浮点系数定点化成FPGA能吃的二进制格式;实现阶段有两条路,一是调用Quartus和Vivado自带的FIR IP核,二是手写Verilog,两者各有用处;最后用仿真工具验证波形,再综合上板看真实输出。
很多新手问IP核和手写RTL到底选哪个。我的建议是:工程上优先用IP核,省时省力,时序和资源都是厂商调过的;但学习阶段一定要手写一遍。原因很简单,IP核配置界面里那些Sample Period、Coefficient Fraction、Output Width,如果不亲手写一遍乘法累加逻辑,你根本不知道这些参数背后在干什么。我在一个工程里就是先手动实现了8抽头FIR搞懂了数据流,然后回去再看Quartus的FIR Compiler II配置,很多选项瞬间就通透了。
至于Quartus和Vivado怎么选,我的体会是:Quartus平台更适合教学和存量工程维护,界面直观,SignalTap调试方便;Vivado平台更适合需要AXI总线集成的项目,IP核风格统一,脚本化流程也更成熟。同一个设计在两个平台走一遍,最大的好处是能逼你丢掉“工具特定写法”,写出真正可移植的RTL代码。
2. 滤波器系数设计与定点量化:动手前先把“料”备齐
2.1 用Python算一个32阶低通FIR的抽头系数
设计滤波器最容易犯的错误就是上来就打开FPGA工具,结果在IP核配置界面里对着“Coefficient”一栏发呆。正确顺序是先定指标,再用Python或MATLAB把系数算出来。我习惯用Python,因为同样的脚本还能顺手完成量化误差分析和预仿真,整个链路一个环境贯穿到底。
举一个实际例子:采样率fs为48kHz,低通截止频率fc为10kHz,要求32阶FIR滤波。这里要提一个关键概念,32阶FIR对应33个抽头系数,两者差1。如果想用窗函数法设计,Hamming窗是工程上最常用的默认选择,阻带衰减大约53dB,如果用Kaiser窗还能根据需求更精细地控制阻带衰减。
import numpy as np from scipy import signal fs = 48000 fc = 10000 ntaps = 33 # 32阶,共33个抽头 coeff = signal.firwin(ntaps, fc, fs=fs, window='hamming') coeff = coeff / coeff.sum() # 归一化直流增益为1 for i, c in enumerate(coeff): print(f"coeff[{i:02d}] = {c:.10f}")这里归一化那一步很关键。因为FPGA里做定点数相乘时,系数大小直接决定位宽和溢出风险。把直流增益归一化到1,意味着全通信号经过滤波器后幅度不放大也不衰减,后续的量化位宽评估才有意义。
算完系数别急着导出,先看频响是否符合预期。用signal.freqz(coeff, 1, worN=4096, fs=fs)计算幅频响应,重点看三点:通带内纹波是否够小、10kHz截止频率附近是否平滑下降、阻带衰减是否超过了40dB。如果阻带衰减不够,优先加大阶数或换Kaiser窗,而不是硬着头皮在FPGA侧通过量化去补救。系数这层没做对,后面所有工作都是在错误的地基上盖楼。
2.2 浮点系数转16位定点:Q格式与量化误差检查
浮点系数不能直接用于FPGA,Verilog里虽然有real类型但不能综合成硬件电路,必须把系数转成整数定点格式。最常见的做法是Q格式表示,比如Q1.15表示1位符号位加15位小数位,能表示的动态范围是-1到0.9999,恰好覆盖归一化后绝对值小于1的滤波器系数。
转换代码非常简单:
Q = 15 scale = 2**Q int_coeff = np.round(coeff * scale) int_coeff = np.clip(int_coeff, -2**15, 2**15 - 1) q_coeff = int_coeff / scale转换完一定要做两件事:第一,对比量化前后的幅频响应曲线,确认阻带衰减没有明显劣化;第二,检查量化后的系数总和是否还是接近2^15,这个值对应直流增益。如果舍入误差导致直流增益跑偏,滤波后的信号会出现整体幅度偏移。
这里有个我踩过几次的坑:直接np.round(coeff * scale)后,把量化误差最大的那个系数作为中心抽头做补偿,或者先转浮点再归一化会破坏对称性。FIR线性相位依赖系数对称,如果量化后对称性被破坏,相位响应就会失真。所以对称系数的量化要特别注意:先量化一半,另一半镜像复制,确保硬件里的对称关系一分不差。
16位宽是工程平衡点,低于12位阻带衰减会明显恶化,高于18位资源开销增加但收益递减。特殊情况比如宽带信号的通道化滤波,要求极高无杂散动态范围时才需要24位以上。对于大部分音频和通信基带处理场景,16位系数加16位输入数据已经足够。
3. Quartus实现FIR滤波器:IP核配置与手写RTL都要会
3.1 FIR Compiler II的关键参数实战配置
Quartus里实现FIR首选IP核,名字叫FIR Compiler II。建完工程后在IP Catalog里搜索就能找到,配置界面分好几页,但真正决定滤波性能的就那几个参数。
第一是滤波器类型和抽头数,选择Single Rate FIR、Lowpass,Number of Taps填33。第二是系数来源,可以加载.mif文件,也可以直接在Coefficient Values栏里粘贴刚才生成的定点整数。粘贴之前记得把系数从十进制整数改成十六进制或补码形式,Quartus支持多种格式,但补码最保险。第三是数据位宽,Input Data Width选16,Coefficient Width选16,Output Data Width建议留到34左右:16位输入乘16位系数是32位,33个抽头累加还需要额外几位防溢出。
最容易被忽略的是Clock Frequency和Input Sampling Frequency的关系。很多新手把两者填成一样,比如都填48k,结果IP核内部以为每个时钟周期都有新数据,综合出来的电路是全并行结构,乘法器数量爆炸。实际工程里系统时钟往往几十MHz,采样率只有几十kHz,两者差值就是数据有效信号的占空比。在FIR Compiler II界面里正确设置这两个频率,IP核会自动决定是用并行、半并行还是串行结构,资源占用差别可以达到一个数量级。
配置完成后生成IP,集成到顶层时记住数据接口不是简单的裸数据,而是一组data_in加data_in_valid。只有valid拉高的那一拍,输入数据才被当作有效采样参与滤波。我见过不少工程把valid直接接高电平,IP核倒是能跑,但系统时钟每拍都在采数据,实际截止频率完全错乱。正确做法是用一个采样率分频或计数器产生valid脉冲,比如48MHz时钟下每1000拍拉高一拍,代表一个48kHz采样点。
3.2 手写Verilog实现对称FIR:代码结构与仿真步骤
用IP核跑通工程后,强烈建议手写一遍RTL,才能真正理解数据路径。FIR最经典的结构是乘加结构,但FPGA上有更聪明的对称优化:利用线性相位FIR系数对称,先对延迟链两端的数据相加,再乘系数,这样乘法器数量能减一半。
一段简化的对称FIR结构示意如下:
// 以33抽头为例,h[0]=h[32], h[1]=h[31] // 第一步:输入数据移位寄存 // 第二步:对称抽头两两相加,得到17个相加结果 // 第三步:每个相加结果乘对应的对称系数,再逐级累加 // 第四步:输出寄存,同步输出valid代码层面最核心的写法是先用reg signed [DATA_WIDTH-1:0] delay[0:N-1]定义延迟链,然后用生成语句或者循环把首尾相加。这里数据类型必须用signed,否则负数乘出来的结果是错的。加法和乘法过程注意位宽扩展:两个16位有符号数相加需要17位,17位乘16位系数是33位,累加多级之后再增加对数级位宽,或者直接在最终输出寄存器截取。
数据流时序要严格同步:当din_valid为高时,把当前数据敲进延迟链,同时把计算结果寄存。输出valid和输出数据要打一拍对齐,方便下游模块使用。如果上游数据valid不是持续的,还要考虑只有当valid时延迟链才移动,否则无效数据会被当作有效采样参与滤波。
仿真测试推荐用ModelSim或QuestaSim。测试平台里生成一个混合信号:一个1kHz低频正弦和一个20kHz高频正弦叠加,经过截止10kHz的低通滤波器后,高频分量应该被明显削弱。然后用$display或波形窗口观察输出序列,最好在Python里也生成同样的激励,把期望输出算出来,和仿真波形逐点对比。这一步能同时验证系数、位宽、时序和结构四件事,比直接上板高效得多。
4. Vivado实现同一设计:流程差异与COE文件细节
4.1 Vivado FIR Compiler的界面、COE与参数坑
Vivado这边的FIR IP核官方名叫FIR Compiler,配置界面分好几页,但核心参数和Quartus大同小异,真正差异大的是系数文件格式和AXI接口风格。
Vivado使用COE文件描述系数,格式非常严格,一个典型文件长这样:
; Xilinx FIR COE file Radix=10; Coefficient_Width=16; Coefficient_Fraction=15; CoefData=1234, -5678, 90, ...;第一行分号开头的是注释,Radix表示系数是十进制,Coefficient_Width是系数位宽,Coefficient_Fraction对应Q格式小数位,最后CoefData按顺序列出所有系数。这个文件的坑在于:Coefficient_Fraction必须和你在Python里用的Q值一致。如果这里填错,Vivado不会报错,而是按错误的小数位来解释系数,最终频响完全不对,但仿真看起来又“挺正常”,非常迷惑。
在FIR Compiler配置界面的Implementation页,有一个叫Sample Period的参数需要填,含义是“系统时钟周期与输入采样周期的比值”。比如48MHz时钟、48kHz采样率,Sample Period就是1000。这个参数会指导IP核自动选择并行度,也就是多拍共享一个乘法器的比例。填1是全并行,1000就是极度串行化,资源占用天差地别。
还有就是接口协议。Vivado的FIR Compiler默认是AXI4-Stream接口,协议里有tvalid、tready、tdata信号。很多从Quartus转过来的开发者不习惯这套握手机制,直接把tvalid拉高、tready不管,结果IP核工作倒是正常,但吞吐率细节会被忽略。真正的工程中主设备通过tvalid告诉滤波器“这拍数据有效”,滤波器通过tready告诉主设备“我准备好了”,两边同时有效时数据才被真正接受。
4.2 同一套RTL迁移到Vivado后必须处理的事
手写的FIR Verilog代码如果只用了标准可综合语法,从Quartus迁到Vivado基本可以原样使用。我做过实测:同一个33抽头对称FIR模块,Quartus和Vivado综合出的资源数量接近,但时序表现会有差异,原因是两个工具对乘法器的映射策略不同。Quartus可能把乘法器映射到DSP Block的一级结构,Vivado则可能自动做了一级流水线插入,关键路径长度就不一样。
迁移时最常遇到的问题是复位风格和时钟约束。Quartus工程默认的altera_reset或altera_clock这些IP在Vivado里不存在,需要换成标准rst_n信号和create_clock约束。Vivado用XDC文件写约束:
create_clock -period 20.833 -name sys_clk [get_ports clk]如果工程里的clk引脚没有约束,综合器会把它当成普通IO信号,优化策略完全不同,时序报告也没有参考价值。
仿真平台也有差异:Quartus常用ModelSim或QuestaSim,Vivado自带XSim。对于纯RTL仿真,行为几乎一致,但如果用了厂商特定的Altera或Xilinx原语,就必须换成对应平台的仿真模型。所以我的建议是RTL只写标准Verilog,IP核才允许使用平台相关模块,这样迁移成本主要集中在IP重新配置和约束文件重写,RTL逻辑本身不用动。
还有一点容易被忽略:Vivado综合时默认会把*乘法推断到DSP48E1,资源报告中显示DSP块占用;如果DSP资源不够,代码里需要手动调整乘法器实现方式,比如用逻辑资源替代。Quartus里则通过DSP Block Inference的设置控制类似行为。同一个RTL在两个平台的默认实现策略不同,资源报告自然也不一样,这不代表代码写错了,需要对着资源报告逐一理解。
5. 资源、时序与上板调试:两套工具下的调优实测
5.1 DSP单元与逻辑资源占用对比
先说一组实测数据,同一份33抽头对称FIR设计,输入输出都是16位,全并行结构大约需要17个硬件乘法器(因为对称优化砍了一半),半并行结构把乘法器用量降到6个左右,全串行结构只需要1个乘法器加上大量寄存器。这个差距在选型阶段就能决定项目要不要换芯片。
Quartus和Vivado对DSP单元的叫法不同,前者叫DSP Block(Cyclone V上是18x18乘法器),后者叫DSP48E1/DSP48E2 slice,但本质都是硬件乘法器。查资源报告时,重点看DSP、FF和LUT三项。全并行33阶FIR,在通常的中等规模芯片上大概占不到10%的逻辑,DSP占可能在20%左右,串行化之后DSP占比可以压到2%以下。
选择并行度还要考虑采样率。如果你做的是4kHz采样率的慢速传感器信号,全并行结构完全是浪费,串行MAC在50MHz时钟下处理4kHz采样率绰绰有余,乘法器在一个采样周期内有上万拍空闲时间。如果你做的是高速ADC信号,采样率50MHz以上还要求全速滤波,就必须全并行或半并行。判断标准很简单:系统时钟频率除以采样率,如果比值远大于抽头数,就放心用串行结构;如果比值接近抽头数甚至更小,就老老实实用并行。
5.2 时序收敛的三个关键操作
FIR滤波器的关键路径通常在乘法器之后的加法树,因为多个乘积要顺序累加。33抽头全并行在只有一个加法器串行累加时,关键路径会有33级组合逻辑,时序必挂。解决手段按优先级排列。
第一是加法树并行化。把累加拆成多个小组,每组4个或8个加法先并行计算,再逐级合并。逻辑层级从N降到log2(N),时序改进非常可观。第二是在乘法器输出和加法树之间插入流水线寄存器。乘法器本身有延迟,插寄存器相当于“每一级的计算给它一整拍时间”,牺牲一个时钟周期的延时换关键路径缩短,对FIR这种流式处理模块几乎不影响功能。第三是让综合工具看到自动流水线机会,Vivado里确认DSP infer的流水线选项,Quartus里确认MULTIPLIER配置。
我在这两个工具上都遇到过“仿真全对,综合后时序报错”的情况,根因都是加法链太深。加流水线时记得同步调整valid时序:数据延迟了几拍,valid也得跟着延迟同样拍数,否则下游模块会在错误时刻采样。
5.3 用SignalTap和ILA做上板验证的共用思路
上板调试是整条链路里最容易让人崩溃的环节。Quartus对应工具是SignalTap II,Vivado对应ILA,两者操作方式不同但调试思路一致:抓取内部信号,观察真实数据流。
第一次上板建议抓以下几组信号:输入采样valid、延迟链第一级输出、乘法器输出、最终输出valid、最终输出数据。重点检查valid时序和数据对齐。我在一个实际工程里遇到的诡异问题是仿真完全正常,上板后输出幅度比预期小一半,查了很久发现是系统复位时间不够,FIR核内部延迟链没有完全清零,前几百个采样点混入了垃圾数据。后来把复位时间从100ns延长到1ms,问题立刻消失。
定点数据验证有个高效方法:在Python里用量化后的系数重新算一遍期望输出序列,保存成TXTC文件。上板后通过UART或者调试探针把FPGA实际输出抓下来,和期望输出逐点对比。偏差在几个LSB内说明链路正常,偏差很大就要回头查数据位宽、截位策略和系数对齐问题。这个“离线对比”法比用示波器看波形靠谱得多,能定量发现细微问题。
6. 常见问题速查表与踩坑记录
6.1 问题现象、原因与解决办法速查
| 问题现象 | 常见原因 | 解决办法 |
|---|---|---|
| 输出恒为0 | 复位时间不够或valid信号未拉高 | 延长复位时间,用计数器产生valid并确认时序 |
| 输出波形正常但幅度偏小 | 系数量化后直流增益不为1 | 用中心抽头补偿量化误差,检查系数归一化 |
| 截止频率严重偏离设计值 | Sample Period或采样频率参数填错 | 核对系统时钟与采样率比值,IP核重新配置 |
| 阻带衰减不达标 | 系数位宽太低或阶数不够 | 增大系数位宽到16位以上,或提升滤波器阶数 |
| 高频分量滤不干净 | 输入valid持续拉高,采样节拍混乱 | 按正确采样率生成valid脉冲,不要接恒定高电平 |
| 仿真正常上板时序违例 | 加法链组合逻辑过深 | 加法树并行化并插入流水线寄存器 |
| 输出出现明显直流偏置 | 输入数据未使用signed类型 | RTL中所有乘加信号定义为signed并做符号扩展 |
这里每种情况我都实际碰到过,最迷惑就是“仿真正常上板不对”,因为这类问题往往不是算错,而是时序概念没对齐:仿真里时钟和复位条件理想,上板后复位释放时序、valid生成逻辑、DSP延迟模型等因素全掺在一起,任何一环出错都会导致数据流错位。调试时不要一口咬定是某一个原因,而是先抓valid、数据、输出三者波形,确认数据流每一步都连续。
6.2 几条长期有效的FPGA滤波设计习惯
经历过Quartus和Vivado两边的折腾后,我总结出几条习惯,现在做任何FPGA数字信号处理项目都会遵守。
第一,所有系数生成脚本保留在工程目录下,并且用脚本同时输出COE、MIF和Verilog参数文件。手工从MATLAB窗口复制系数填进IP核配置界面,不但容易漏位还容易错序,全自动生成是刚需。第二,每次修改系数位宽或滤波器阶数后,强制自己在量化前后各画一遍幅频响应曲线,确认设计指标没有在量化环节被破坏。第三,RTL里所有data和valid信号“同步寄存、同步传输”,绝不出现数据比valid早一拍或晚一拍的情况,能省掉大量调试时间。
最后分享一个实际收获:因为同一个FIR模块在两个工具链都实现过,我把顶层模块做成了纯标准Verilog,只在顶层例化厂商IP核。后期换平台时,只需要换IP核实例和约束文件,RTL主体完全不动。这种“可移植”的设计思路比“只会用某一家工具”更有价值,也是做FPGA久了之后最值得积累的资产。