☰
FPGA双平台FIR滤波器RTL实现:从系数生成到工程移植
2026/10/24 6:01:31 网站建设 项目流程

提到FIR数字滤波器,很多人第一反应是:这东西不是用IP核点两下就能生成吗?确实,Quartus和Vivado都自带现成的FIR IP,参数界面填一填就能出。但真到实际项目里,情况往往没这么简单——芯片型号受限、IP授权问题、需要低成本器件、或者干脆想彻底掌握滤波器内部逻辑,这时候手写RTL就成了绕不开的路。更麻烦的是,很多项目一开始就用某一家的环境开发,后面又要换平台,或者同一个滤波器要同时支撑两个平台,代码却没法直接通用。

这篇东西就是围绕这个痛点写的。我会完整做完一个FIR滤波器,从系数生成、架构选型、RTL实现到双平台工程迁移,给出能在Quartus和Vivado里共用同一套Verilog代码的完整方案。读者对象是已经写过一些Verilog、了解时序基础、但没系统做过信号处理模块的FPGA工程师。搞懂这套流程后,你不仅能应付双平台项目,以后做其他DSP类模块也能照这个思路来——把平台相关的东西隔离在外,核心逻辑保持纯RTL可移植。

1. FIR滤波器设计的核心思路拆解

1.1 为什么FIR是FPGA上最常用的滤波器结构

滤波器的基本任务就是让某些频率成分通过、让另一些衰减。IIR滤波器因为有反馈回路,用更少的阶数就能达到较陡的过渡带,但代价是非线性相位和稳定性隐患。FIR滤波器恰恰相反,它没有反馈结构,输出只依赖当前和过去的输入样本,天然稳定,而且只要系数设计为对称序列,就能获得严格的线性相位——信号经过滤波器后,各频率分量只是统一延迟了一段时间,波形形状不发生畸变。

这个特性在通信、语音、医疗信号处理里非常宝贵。比如心电信号中的P波和QRS波群,它们的相对位置反映了心脏传导过程,如果滤波器引入了相位失真,波形错位,诊断结论就会受影响。FPGA做FIR通常选择乘累加结构,每个输出样本等于输入序列与滤波器系数的卷积:

y[n] = h[0]*x[n] + h[1]*x[n-1] + ... + h[N-1]*x[n-N+1]

这里的h[k]就是滤波器系数,也叫抽头(tap)系数。N个抽头意味着每个输出需要N次乘法和N-1次加法。FPGA里有大量的DSP乘法器资源和查找表资源,做这种规则化的乘累加操作,正是它的强项。

1.2 FPGA实现FIR的三种典型架构对比

既然明确了用FIR,接下来就是怎么在FPGA里把卷积算出来。实际工程中主要看三种做法,各有各的适用场景。

全串行MAC结构很直观:只用一个乘法器和一个累加器,每个时钟周期算一个抽头,输出一个样本需要N个时钟周期。优点是资源消耗极小,逻辑利用率低,适合抽头数不多、采样率不高的情况。缺点是吞吐量受抽头数限制,采样率上不去。

全并行结构把N个乘法器同时铺开,N个乘积在一个周期内全部算完,再通过加法树合并。这种结构吞吐量最高,每个时钟周期都能出一个输出样本,但资源占用也最猛。32个抽头如果系数不压缩,就是32个DSP乘法器;如果利用线性相位系数对称的特性压到16个,能省一半。

半并行结构是折中方案,把N个抽头分成M组轮流计算,用时间换资源。怎么选,看的是“输出采样率 × 抽头数 × 每个样本预算的时钟周期数”这三者的关系。比如目标采样率是1MHz,系统时钟是100MHz,那么每个输出样本有100个时钟周期的预算,32个抽头全串行也只要32个周期,余量很大,直接用串行架构最省资源。

实际选型时,我一般会先看有没有现成的DSP资源可用。如果目标芯片乘法器多、逻辑资源紧张,就优先并行结构;如果是低成本小芯片,乘法器少,就考虑DA算法或串行MAC。这里我特别推荐一个做法:设计时把滤波器核心写成参数化模块,选择串行还是并行只差一个参数,这样后续换芯片、换平台时不用重写代码。

1.3 系数生成与量化处理

滤波器系数通常在高层次工具里计算,我用得最多的是MATLAB的FDA Tool,或者直接用开源工具里的firwin函数设计。设计指标就是三个:通带截止频率、阻带起始频率、阻带衰减。比如要做一版低通滤波器,采样率50MHz,通带5MHz,阻带8MHz,阻带衰减60dB,用等纹波设计法得到的抽头数大概在31到35之间。

算出来的系数是浮点数,范围一般在-1到1之间。FPGA里没有浮点乘法器,必须先把系数量化成定点数。最常用的是二进制补码定点格式,每位系数用16bit表示,其中1bit符号位、15bit小数位,数值范围是-1到0.99997,刚好覆盖典型FIR系数空间。

量化过程要特别注意:截断还是四舍五入,对滤波器性能影响差别很大。截断会引入直流偏置,导致阻带衰减性能下降。实测下来,使用四舍五入方式把系数从浮点转成16bit定点,通常还能保持55dB左右的阻带衰减,如果只用截断,可能掉到48dB甚至更低。所以系数转换一律用round,不要用floor。

2. 双平台工程结构的统一规划

2.1 Quartus与Vivado的项目体系差异

很多做FPGA的人都是从单一平台入手的,等真正同时接触两个平台,才会发现它们的管理方式差别很大。Quartus工程的载体是.qpf工程文件加.qsf设置文件,源文件列表、器件型号、管脚分配、时序约束全写在.qsf这个文本里。Vivado工程则用.xpr文件管理,源文件、约束文件通过Tcl命令或图形界面动态添加,实际启动时的构建脚本可以用.tcl批量执行。

这种差异意味着,如果工程里用的是官方IP核,基本没法直接搬。Quartus的FIR Compiler IP和Vivado的FIR Compiler IP虽然都叫“FIR Compiler”,生成的接口、时序、Avalon/AXI总线协议完全不同,底层原语也不一样。换个平台,IP要重新配置、重新生成,RTL文件要重新接线。所以要做到“双支持版”,第一条设计原则就是:核心逻辑尽量不依赖平台专属的IP和原语。能用纯Verilog RTL实现的,绝不用IP;必须用到时钟管理、IO原语的,全部隔离在顶层,核心子模块保持完全的平台中立。

2.2 共享RTL代码的目录组织方式

我自己的做法是建立一个统一目录结构,让一个滤波器核心代码同时被两个工程引用。目录大致长这样:

fir_filter_design/ ├── rtl/ # 平台无关的RTL代码 │ ├── fir_core.v # 滤波器核心 │ ├── fir_da_lut.v # DA算法的查找表模块 │ └── sync_delay.v # 跨时钟域打拍 ├── sim/ # 仿真测试平台 │ ├── tb_fir_top.v │ └── data/ # 输入输出数据文件 ├── quartus/ # Quartus平台相关 │ ├── fir_top.qpf │ ├── fir_top.qsf │ └── fir_top.sdc ├── vivado/ # Vivado平台相关 │ ├── fir_top.xpr │ ├── fir_top.xdc │ └── scripts/ # 创建工程、综合实现脚本 └── coeff/ # 系数文件 ├── coeff_hex.mem # 十六进制系数表 └── gen_coeff.m # 系数生成脚本

Quartus工程创建时,把rtl目录下的.v文件直接添加进去;Vivado工程创建时,用add_files命令指向同一个rtl目录。这样任何RTL改动,两边同时生效。约束文件不共用,因为两家的管脚分配写法完全不同,后面单独说。系数文件用十六进制格式单独存放,RTL里通过$readmemh加载,这样换滤波器参数时不需要重新综合RTL代码,直接改.mem文件,重新生成比特流就行。

2.3 参数化配置与平台无关的顶层模块框架

滤波器核心用Verilog的parameter写成参数化配置,方便匹配不同项目需求。比如这样定义:

module fir_core #( parameter DATA_WIDTH = 12, // 输入数据位宽 parameter COEF_WIDTH = 16, // 系数位宽 parameter TAP_NUM = 32, // 抽头数 parameter ARCH_MODE = "PARALLEL" // 结构模式: PARALLEL/SERIAL/DA )( input wire clk, input wire rst_n, input wire data_valid_in, input wire [DATA_WIDTH-1:0] data_in, output reg data_valid_out, output reg [DATA_WIDTH+COEF_WIDTH-1:0] data_out );

这种写法在很多项目里能直接复用。比如抽头数从32改成64,系数位宽从16改成18,只需要改例化参数。因为需要用同一个RTL在两家平台跑,我把所有平台相关的元素全集中在顶层模块里,核心模块的接口全部用标准wire/reg,不出现任何IBUF/OBUF、BUFG、PLL原语。时钟信号从顶层原语输出后,作为普通时钟进核心模块。这样做之后,核心代码在Quartus和Vivado里看到的综合结果几乎一样,移植成本基本为零。

3. 核心RTL实现与仿真验证

3.1 乘累加结构的标准实现

考虑工程上最常用的情况,我先做一个半并行乘累加版本。假设采样率不高、抽头数为32、每样本可用时钟周期为64拍,那就把32个抽头分成两组,每组16个乘法器,两个流水线并行计算。这样做比全并行省一半DSP资源,速度又比全串行快一倍。

核心代码结构大致是:

// 移位寄存器链,保存输入历史样本 reg [DATA_WIDTH-1:0] shift_reg [0:TAP_NUM-1]; always @(posedge clk) begin if (data_valid_in) begin shift_reg[0] <= data_in; for (int k = 1; k < TAP_NUM; k++) begin shift_reg[k] <= shift_reg[k-1]; end end end // 利用线性相位对称性,h[0]=h[N-1], h[1]=h[N-2]... // 先做对称样本相加,再乘系数 always @(posedge clk) begin if (data_valid_in) begin sum0 <= shift_reg[0] + shift_reg[TAP_NUM-1]; sum1 <= shift_reg[1] + shift_reg[TAP_NUM-2]; // ... end end

这里的for循环用了SystemVerilog的int声明,在Quartus和Vivado里都支持良好。有一种写法要避免:直接在循环里同时完成乘加,综合时可能推断出过长的组合逻辑链,导致时序违例。正确做法是拆流水线:第一级做对称相加,第二级做乘法,第三级做加法树。三级流水对应三个时钟周期延迟,数据有效性信号也要同步打三拍,否则valid和data会错开。

乘法器位宽要注意。输入12bit、系数16bit,乘积就是28bit。加法树如果直接加32个28bit数,位宽会增长到33bit。如果输出只要16bit,可以在加法树每一级做截断,也可以等全部加完再截断。工程上我建议先全精度累加,最后统一截断,这样量化误差最可控。

3.2 改用DA算法节省乘法器资源

乘累加结构最大的问题就是DSP乘法器数量。如果你的目标器件是几千逻辑单元的小规模FPGA,乘法器可能只有十几个,32抽头对称系数压掉一半后还需要16个DSP单元,很容易超。这时可以考虑用DA分布式算法。

DA算法的核心思路是把乘累加运算拆成按位运算。输入x的每个bit先和系数相乘,再用移位和累加合成结果。为了回避乘法,把所有可能的求和结果预先算好存进查找表。因为每次只查一个bit对应的系数组合,LUT就会很小。这种方法不用DSP乘法器,纯逻辑资源就能跑。

具体实现时,一个输入样本如果是12bit,就需要12个LUT,每个LUT的地址是抽头数位。32抽头的话,每个LUT有2^32种组合,不可能全存。所以实际工程都采取分块查表:把32抽头切成4组8抽头,每组8bit地址,LUT规模是256x16bit,4个LUT一组,12bit样本需要12组,一共48个256深度LUT。FPGA的块存储器可以轻松实现。

DA算法的代码是真的“查表”风格:

// 8抽头DA子块 always @(posedge clk) begin case ({x0_bit, x1_bit, ..., x7_bit}) 8'h00: sum <= 0; 8'h01: sum <= coef_0; 8'h02: sum <= coef_1; // ... endcase end

这种代码手写极其容易出错。我有一个更稳的写法:写一个Python脚本,根据系数自动生成Verilog的case分支,把生成的代码直接粘进工程。手写几十行还能忍,几百行迟早写错。这里强烈建议所有读者用脚本生成DA查找表代码,不要手敲。

3.3 仿真测试平台与双平台仿真流程

RTL写好后,第一件事不是上板,而是跑仿真。我用统一的testbench,不加任何平台专属的仿真原语。标准的$readmemh读输入激励,$writememh把输出结果写出来,然后用Python脚本对比输出波形和MATLAB算出来的理想输出。

仿真架构大概是:

initial begin $readmemh("../data/input_hex.txt", stim_mem); repeat (TAP_NUM) begin @(posedge clk); data_valid_in <= 1; data_in <= stim_mem[i]; i <= i + 1; end end always @(posedge clk) begin if (data_valid_out) begin $writememh("../data/output_hex.txt", {output_buf, data_out}); end end

这里有个容易踩的坑:$readmemh的文件路径在Quartus的ModelSim和Vivado的xsim里解析规则不太一样。ModelSim默认从仿真工程的工作目录找相对路径,Vivado则从项目文件所在目录找。为了避免这个问题,我习惯把所有数据文件复制到两个平台各自的sim目录下,路径写在tb里时用相对路径,但基于平台做两个tb版本。这样tb文件本身在不同平台略有差异,但核心RTL完全一致,不影响双平台复用性。

仿真通过后,还有个重要检查:比较仿真的输出延迟和实际的理论延迟。32抽头三级流水线,输出滞后输入3个时钟周期,这是固定的。如果仿真结果发现数据对不上,先数一下valid延迟,往往问题就出现在流水线打拍数量不一致。

4. 时序约束、上板调试与常见问题实录

4.1 SDC约束在双平台中的通用写法

时序约束是双平台代码复用里最麻烦的一块。Quartus和Vivado都支持SDC格式,但管脚约束完全不通用。Quartus通常用qsf里的set_location_assignment指定管脚,Vivado用xdc里的set_property PACKAGE_PIN指定。我会把约束拆成两部分:核心时序约束写成标准SDC,管脚约束各自写。

核心时序约束两个平台通用,比如:

create_clock -period 10.000 -name sys_clk [get_ports clk] set_input_delay -clock sys_clk -max 4.0 [get_ports data_in] set_input_delay -clock sys_clk -min 1.0 [get_ports data_in] set_output_delay -clock sys_clk -max 5.0 [get_ports data_out] set_output_delay -clock sys_clk -min 0.5 [get_ports data_out]

这段约束在两家工具里都能直接读入。但clock的物理位置和IO标准要分开处理。Quartus的qsf里写:

set_location_assignment PIN_AB16 -to clk set_instance_assignment -name IO_STANDARD "3.3-V LVCMOS" -to clk

Vivado的xdc里写:

set_property PACKAGE_PIN AB16 [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports clk]

一个常见误区是只约束时钟频率,不设输入输出延时。如果不设set_input_delay / set_output_delay,工具的时序分析就会默认数据在时钟沿处完美对齐或者拥有无限裕量,导致时序报告失真。等上板发现采集数据偶尔出错,再去排查信号时序,效率极低。

4.2 双平台综合差异引发的常见问题排查速查表

两个平台各自综合、实现,即使代码完全一样,出来的结果也可能有区别。我在实际迁移过程中遇到一些问题,整理成一张速查表,给读者做个参考:

问题现象可能原因解决办法
Vivado综合通过,Quartus报错“declared as wire but used as reg”两家Verilog解析器严格度不同,部分代码隐式声明不一致所有变量显式声明类型,避免依赖隐式net声明
同一份代码,Vivado时序收敛,Quartus时序不收敛加法树工具优化策略不同,映射后逻辑层级不同手动把关键路径加法树拆成尽量规则的二进制结构,并增加输出寄存器
仿真波形正确,上板输出数据偶发错位跨时钟域或复位释放不稳定增加异步复位同步释放逻辑,数据有效信号与数据同拍打拍
用DA算法时某些平台LUT占用暴增综合器把case语句推导为优先编码器而非LUT使用casez配合独热编码,或者引入parallel_case综合指导
不同平台生成比特流后,滤波器频率响应与仿真差异大系数文件加载时序不一致,手动加载失误用$readmemh只在初始化时加载一次,验证加载地址与数据文件顺序
上板后发现阻塞赋值导致累加级联错误仿真器对阻塞/非阻塞处理不一致时序逻辑一律使用非阻塞赋值,组合逻辑使用阻塞赋值,严格区分

最常见也最隐蔽的问题,其实是复位策略。Quartus的默认复位推荐是异步复位、同步释放;Vivado则更偏好同步复位。我吃过一次亏:代码在Vivado上按同步复位写的,迁到Quartus上仿真没问题,上板后总有几个寄存器在复位释放瞬间处于亚稳态。后来把复位逻辑统一改成异步复位同步释放,两个平台都稳了。这个复位模块也很小,我直接在rtl目录下建了一个reset_sync.v,两个平台共用。

4.3 一个真实的双平台调试过程记录

之前做一个信号采集前端,采样率25MHz,低通滤波截止频率3MHz,需要32抽头。我先在Quartus那边完成仿真和上板验证,输出正常。迁移到Vivado时,我以为只要把约束改了就完事,结果综合、实现全部通过,上板却出现一个令人困惑的现象:滤波后的信号在特定输入频率下出现周期性毛刺。

排查过程很典型。我先是怀疑时序余量不够,打开Vivado时序报告,发现关键路径确实比较紧张,setup余量只有0.1ns左右。但真正的问题不在这——我用逻辑分析仪抓内部数据,发现数据有效信号和滤波输出总是相差一拍,导致后续模块采集时有时采到有效位拉高的那拍,有时采到下一拍。回头检查RTL,原来是流水线寄存器打拍层级没有完全同步:加法树的余数路径多了半拍,valid只打了三拍,但数据路径实际用了四拍。

解决方案也很简单,把valid打拍寄存器数量和数据路径对齐,多打一拍。这个问题在仿真里没有暴露,是因为testbench只看了最终输出,没有检查输出与valid的配对关系。从那以后,我的testbench里都会增加一个辅助任务:每来一个valid,比较它对应的data和理想值。这样流水线是否对齐一目了然。

调试心得总结起来就一句话:双平台移植,不要急着对比时序报告,先把复位策略和流水线对齐规则定死,否则你花两小时查时序,最后问题往往出在打拍数量上。

最后分享一个小技巧。手写FIR滤波器时,我建议在RTL里加一个“bypass”测试模式:通过一个寄存器控制,让数据不经过滤波器核心直接输出。上板时先发一个单频正弦波,确认bypass模式输出和输入完全一致,再切到滤波模式。如果bypass正常、滤波异常,问题基本可以锁定在系数偏序或系数加载上;如果bypass就不对,那大概率是时序、IO或复位问题。这个旁路开关占的资源很少,但在双平台调试验证时能省掉大量猜测时间,值得每个滤波器都加上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询