FPGA数字信号处理:FIR滤波器Verilog实现与Vivado优化实践
2026/9/21 16:23:19 网站建设 项目流程

1. 项目概述:从需求到实现的数字信号处理之旅

最近在做一个需要处理模拟信号的FPGA项目,信号里混入了不少高频噪声,直接用ADC采回来的数据根本没法看。这时候,数字滤波就成了必须跨过去的一道坎。在众多数字滤波器里,FIR(有限脉冲响应)滤波器以其绝对的稳定性和线性相位特性,成了我这种对信号相位有严格要求场景下的首选。虽然IIR滤波器能用更少的阶数达到类似的幅频特性,但它的非线性相位可能会把整个系统的时序搞得一团糟,想想还是FIR更让人安心。

这个项目的核心,就是在Xilinx的Vivado平台上,用Verilog硬件描述语言,手搓一个参数可配置的FIR低通滤波器,并且要配套一个能充分验证其功能的testbench。这听起来像是一个标准的数字信号处理实验,但真正做起来,从系数量化、结构选型到仿真验证,每一步都有不少细节需要琢磨。如果你也在FPGA上做过信号处理,或者正打算开始,那这篇从理论到代码、从仿真到思考的完整记录,或许能帮你避开一些我踩过的坑。

2. FIR滤波器核心原理与设计参数确定

在动手写代码之前,我们必须搞清楚要做一个什么样的滤波器。FIR滤波器的行为完全由其系数决定,输出是输入序列与滤波器系数序列的卷积和。用公式表示就是 y[n] = Σ (h[k] * x[n-k]),其中h[k]就是那组决定滤波器性能的系数。

2.1 滤波器指标定义与工具选型

首先得明确滤波器的性能指标。假设我的系统采样率Fs是100MHz,我需要滤除高于10MHz的频率分量,那么通带截止频率Fpass可以设为8MHz,阻带起始频率Fstop设为12MHz。为了在通带内信号尽量无失真,通带波纹(Ripple)希望控制在0.1dB以内;为了有效抑制阻带信号,阻带衰减(Attenuation)至少需要60dB。这些指标(Fs=100MHz, Fpass=8MHz, Fstop=12MHz, Ripple=0.1dB, Attenuation=60dB)就是滤波器设计的“宪法”。

有了指标,下一步就是获取滤波器系数h[k]。自己手算系数是不现实的,通常借助MATLAB的fdatool(Filter Design & Analysis Tool)或者Python的scipy.signal库。我更喜欢用MATLAB,因为它和硬件仿真的结合更成熟一些。在fdatool里,选择FIR滤波器类型(我选等波纹设计,因为它能在给定阶数下实现最优化),输入上述指标,工具会自动计算出所需的最小滤波器阶数(Order)。根据我的指标,算出来大概需要50多阶。阶数越高,滤波效果越好,但消耗的FPGA资源(查找表LUT、寄存器FF、乘法器DSP)也越多,这是一个需要权衡的地方。

注意:在fdatool中,记得将设计方法指定为“Equiripple”(等波纹)或“Least-squares”(最小二乘),并勾选“Scale Passband”以优化系数量化后的性能。设计完成后,将系数导出为.coe文件或MATLAB变量,方便后续在Verilog中使用。

2.2 系数量化:定点数与精度的博弈

MATLAB给出的系数是双精度浮点数,范围通常在(-1, 1)之间。FPGA无法直接处理浮点数,必须将其量化为定点数(Fixed-Point)。这是影响滤波器性能的关键一步,量化会引入误差,可能导致实际频率响应偏离设计目标。

量化位宽的选择是个技术活。位宽太窄(比如8位),量化误差大,可能导致阻带衰减不达标,通带波纹超标;位宽太宽(比如18位),虽然性能好,但会占用更多的DSP乘法器资源,并且可能降低系统最高运行频率。我的经验是,对于阻带衰减要求60dB的情况,系数位宽至少需要14到16位。我选择了16位有符号整数(Q1.15格式,即1位符号位,15位小数位)来量化我的系数。

在MATLAB中,量化过程可以这样操作:

% 假设 coeff_float 是浮点系数 coeff_width = 16; % 量化位宽 coeff_fixed = round(coeff_float * (2^(coeff_width-1)-1)); % 四舍五入到最接近的整数 % 将系数转换为16进制字符串,便于写入Verilog代码或ROM coeff_hex = dec2hex(mod(coeff_fixed + 2^coeff_width, 2^coeff_width), 4); % 处理负数的补码

量化后,务必在MATLAB中重新计算量化后系数的频率响应,确认其仍然满足通带波纹和阻带衰减的要求。这是一个不能省略的验证步骤。

3. Verilog实现:三种主流结构的权衡与选择

系数准备好了,接下来就是用Verilog在硬件上实现卷积运算。FIR的实现结构主要有三种:直接型(Direct Form)、转置型(Transposed Form)和对称结构(Symmetric Structure)。选择哪种,取决于你的资源、速度和系数特性。

3.1 直接型结构:最直观但非最优

直接型结构就是严格按照卷积公式,用一组移位寄存器存储输入数据,然后每个时钟周期将寄存器阵列与系数阵列对应相乘并求和。这种结构非常直观,Verilog代码好写。但是,它的关键路径很长:从输入经过所有乘法器再到加法树。对于高阶滤波器,这个路径会严重限制系统能够运行的最高时钟频率(Fmax)。

module fir_direct #( parameter ORDER = 63, parameter COEFF_WIDTH = 16, parameter DATA_WIDTH = 16 )( input wire clk, input wire rst_n, input wire signed [DATA_WIDTH-1:0] data_in, output reg signed [DATA_WIDTH+COEFF_WIDTH-1:0] data_out // 输出位宽扩展 ); reg signed [DATA_WIDTH-1:0] delay_line [0:ORDER-1]; wire signed [DATA_WIDTH+COEFF_WIDTH-1:0] prod [0:ORDER-1]; integer i; // 系数常量定义,实际应从一个ROM或参数文件加载 localparam signed [COEFF_WIDTH-1:0] coeff [0:ORDER-1] = '{...}; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin for (i=0; i<ORDER; i=i+1) delay_line[i] <= 0; data_out <= 0; end else begin // 移位寄存器更新 delay_line[0] <= data_in; for (i=1; i<ORDER; i=i+1) delay_line[i] <= delay_line[i-1]; // 乘积累加 (这个组合逻辑路径很长!) data_out <= 0; for (i=0; i<ORDER; i=i+1) data_out <= data_out + (delay_line[i] * coeff[i]); end end endmodule

如上所示,巨大的组合逻辑加法链data_out <= data_out + ...是性能瓶颈。在实际工程中,除非滤波器阶数很低(比如小于16),否则不建议使用这种纯直接型。

3.2 转置型结构:优化关键路径的实用选择

转置型结构是工程上更常用的选择。它改变了数据流的方向,使得每一个乘法器的输出都直接与最终的加法树相连,而加法树通过流水线寄存器被打断。这样,关键路径就缩短为一个乘法器加一个加法器(或者甚至只是乘法器),极大地提高了Fmax。

module fir_transposed #( parameter ORDER = 63, parameter COEFF_WIDTH = 16, parameter DATA_WIDTH = 16 )( input wire clk, input wire rst_n, input wire signed [DATA_WIDTH-1:0] data_in, output reg signed [DATA_WIDTH+COEFF_WIDTH-1:0] data_out ); // 声明中间累加寄存器阵列,每个寄存器存储部分和 reg signed [DATA_WIDTH+COEFF_WIDTH-1:0] acc [0:ORDER-1]; wire signed [DATA_WIDTH+COEFF_WIDTH-1:0] mult_result [0:ORDER-1]; integer i; localparam signed [COEFF_WIDTH-1:0] coeff [0:ORDER-1] = '{...}; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin for (i=0; i<ORDER; i=i+1) acc[i] <= 0; data_out <= 0; end else begin // 第一级:输入与最后一个系数相乘,结果存入最后一个累加器 acc[ORDER-1] <= data_in * coeff[ORDER-1]; // 中间级:当前输入与系数相乘,再加上一级的累加值 for (i=ORDER-2; i>=0; i=i-1) begin acc[i] <= (data_in * coeff[i]) + acc[i+1]; end // 输出就是第一个累加器的值 data_out <= acc[0]; end end endmodule

这种结构天然适合用FPGA的DSP Slice实现,因为Xilinx的DSP48E1/E2单元内部就包含一个乘法器后接一个加法器,正好匹配转置型FIR的一级结构。通过合理使用(* use_dsp48 = "yes" *)等综合属性,可以引导Vivado将逻辑映射到DSP硬核上,获得更好的性能和功耗。

3.3 针对线性相位FIR的优化:对称结构

如果FIR滤波器具有线性相位(通常是我们追求的目标),那么其系数会呈现对称或反对称的特性。对于低通滤波器,系数通常是偶对称的,即 h[n] = h[N-1-n]。利用这个特性,我们可以将乘法器的数量几乎减少一半。

思路是:将对称位置的两个输入数据先相加,然后再与同一个系数相乘。这样,对于N阶滤波器(N为奇数),只需要 (N+1)/2 个乘法器,而不是N个。

module fir_symmetric #( parameter ORDER = 63, // 假设为奇数 parameter COEFF_WIDTH = 16, parameter DATA_WIDTH = 16 )( input wire clk, rst_n, input wire signed [DATA_WIDTH-1:0] data_in, output reg signed [DATA_WIDTH+COEFF_WIDTH-1:0] data_out ); localparam HALF_ORDER = (ORDER+1)/2; reg signed [DATA_WIDTH-1:0] delay_line [0:ORDER-1]; wire signed [DATA_WIDTH:0] pre_add [0:HALF_ORDER-1]; // 预加结果,位宽+1 wire signed [DATA_WIDTH+COEFF_WIDTH:0] mult_result [0:HALF_ORDER-1]; // 乘法结果 integer i; localparam signed [COEFF_WIDTH-1:0] coeff_half [0:HALF_ORDER-1] = '{...}; // 一半的系数 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin for (i=0; i<ORDER; i=i+1) delay_line[i] <= 0; data_out <= 0; end else begin // 更新移位寄存器 delay_line[0] <= data_in; for (i=1; i<ORDER; i=i+1) delay_line[i] <= delay_line[i-1]; // 预加与乘法 data_out <= 0; for (i=0; i<HALF_ORDER-1; i=i+1) begin // 处理对称对 pre_add[i] = delay_line[i] + delay_line[ORDER-1-i]; data_out <= data_out + (pre_add[i] * coeff_half[i]); end // 处理中心点(如果阶数为奇数) data_out <= data_out + (delay_line[HALF_ORDER-1] * coeff_half[HALF_ORDER-1]); end end endmodule

这种结构在资源节省上效果显著,是线性相位FIR滤波器的首选实现方式。在我的项目中,最终采用了基于转置型的对称结构,在保证性能的同时最大化资源利用率。

4. Vivado工程创建、综合与实现要点

代码写好了,接下来就是在Vivado里把它变成真正的硬件电路。这个过程远不止点一下“Run Synthesis”那么简单。

4.1 工程设置与IP核的巧妙使用

首先,根据你的FPGA型号(比如我用的Kintex-7 xc7k325t)创建工程。将写好的Verilog模块(如fir_filter.v)添加到设计源文件中。这里有一个小技巧:对于滤波器系数,如果阶数很高,不建议像上面例子那样用localparam写在模块内部,这会使代码冗长且难以修改。更好的做法是:

  1. 使用COE文件与Distributed Memory Generator:将量化后的系数保存为一个.coe文件,格式如下:
    memory_initialization_radix=16; memory_initialization_vector= FFFF, 0002, FFF5, ... ; // 你的16进制系数
    然后在Vivado中调用Distributed Memory GeneratorIP核,选择Single Port ROM,指定数据宽度和深度,加载这个.coe文件。在Verilog中实例化这个ROM来读取系数。这样做修改系数时只需更新文件,无需修改代码。
  2. 使用System Generator或FIR Compiler:对于Xilinx FPGA,最高效的方法是使用FIR CompilerIP核。它在图形界面中让你指定滤波器参数(甚至可以直接导入MATLAB的.mat文件),自动生成高度优化的、使用DSP48单元的滤波器结构,支持多种接口和舍入模式。对于生产项目,我强烈推荐使用IP核,它能保证最佳的性能和资源利用。

4.2 综合策略与约束管理

点击综合后,需要关注综合报告。关键看两点:

  • 资源利用率:在Utilization报告中,查看LUT、FF、DSP的占用率。一个设计良好的64阶16位对称FIR,大约会消耗30-50个DSP48E1、几百个LUT和FF。如果DSP占用远超预期,检查是否因为代码描述问题导致综合器没有识别出乘法操作,从而用LUT搭建了乘法器。
  • 时序性能:在Timing报告中,看WNS(最差负裕量)。如果为负,说明设计不满足时钟约束。对于FIR,关键路径通常在乘法-加法链或长的组合逻辑上。

提示:在综合设置中,可以尝试将-flatten_hierarchy设置为rebuilt,并启用-fsm_extraction-keep_equivalent_registers,有时能获得更好的优化结果。但最重要的还是代码本身的结构优化。

如果时序不满足,首先检查时钟约束是否合理。创建一个.xdc文件,添加:

create_clock -period 10.000 -name clk [get_ports clk] # 100MHz时钟约束 set_input_delay -clock clk 2.000 [get_ports data_in] set_output_delay -clock clk 2.000 [get_ports data_out]

如果约束正确但WNS仍为负,就需要回到代码层面进行优化:1)确保使用了转置型等短关键路径结构;2)在长的组合逻辑路径中插入流水线寄存器(Pipeline Registers);3)对于大的加法树,使用(* register_balancing = "yes" *)属性或手动打拍。

4.3 实现与布线后仿真

综合通过后,运行Implementation(包括布局布线)。这一步会把逻辑网表映射到芯片的实际物理资源上。实现完成后,一定要进行Post-Implementation Timing Simulation(布线后时序仿真)。这一步会使用布局布线后的真实延迟信息进行仿真,最能反映设计在硬件上的真实行为。之前功能仿真(Behavioral Simulation)通过的代码,在这里可能会因为建立/保持时间违例而出错。

如果布线后仿真失败,通常是因为存在时序违例。需要查看实现后的时序报告,找到违例的路径。解决方法可能包括:降低时钟频率、进一步优化代码、使用pblock对关键逻辑进行区域约束(Floorplanning),或者更换速度等级更高的FPGA芯片。

5. Testbench设计与深度验证策略

一个可靠的testbench是FPGA设计的生命线。对于FIR滤波器,testbench不仅要验证功能正确性,还要验证其性能指标是否达标。

5.1 基础功能验证:脉冲与阶跃响应

首先,我们可以用最简单的激励信号来验证滤波器的基本逻辑。

`timescale 1ns / 1ps module tb_fir(); reg clk, rst_n; reg signed [15:0] data_in; wire signed [31:0] data_out; // ... 实例化DUT ... initial begin clk = 0; forever #5 clk = ~clk; // 100MHz时钟 end initial begin rst_n = 0; data_in = 0; #100 rst_n = 1; // 测试1:单位脉冲响应 #10 data_in = 16'sd1000; // 输入一个脉冲 #10 data_in = 0; // 观察data_out输出,应该就是滤波器系数的缩放序列 #500; // 测试2:阶跃信号 #10 data_in = 16'sd500; #200; // 保持一段时间 data_in = 0; #500; $finish; end endmodule

通过观察脉冲响应输出,我们可以直观地看到滤波器的系数形状(可能会有量化误差导致的微小变形)。阶跃响应则可以帮助我们观察滤波器的建立时间和稳定性。

5.2 频域性能验证:使用正弦扫频信号

最关键的验证是频域特性。我们需要生成不同频率的正弦波作为输入,观察输出幅度,从而绘制出滤波器的幅频响应曲线。

在testbench中,我们可以用MATLAB或Python生成一个包含多个频率点的正弦波测试向量,保存为文本文件,然后在Verilog testbench中用$readmemh$readmemb读取。但更高效的方法是利用Vivado和MATLAB的协同仿真。

  1. 在MATLAB中生成测试向量
    Fs = 100e6; % 采样率 t = 0:1/Fs:0.001; % 1ms时长 freqs = logspace(5, 7, 200); % 从100kHz到10MHz,取200个对数间隔点 for idx = 1:length(freqs) f = freqs(idx); sig = 0.9 * sin(2*pi*f*t); % 生成正弦波,幅度小于1防止溢出 sig_fixed = round(sig * (2^15-1)); % 量化为16位有符号整数 % 将sig_fixed写入文件,作为一次仿真输入 end
  2. 在Verilog testbench中读取并输入
  3. 将仿真输出写入文件
  4. 将输出数据读回MATLAB进行分析
    output_data = load('filter_output.txt'); for idx = 1:length(freqs) % 取对应频率段的输出 segment = output_data(...); amp_out(idx) = max(abs(segment)); // 简单估算幅度 amp_db(idx) = 20*log10(amp_out(idx)/amp_in); end semilogx(freqs, amp_db); % 绘制幅频响应曲线

将这条实测曲线与MATLAB中基于量化系数理论计算的频率响应进行对比,两者应该基本吻合。如果实测阻带衰减比理论差很多,可能是系数量化位宽不够,或者滤波器结构实现有误(比如对称性利用错误导致乘法器实际系数不对)。

5.3 自动化验证与覆盖率收集

对于更严谨的项目,可以搭建一个自动化的验证环境。使用SystemVerilog的约束随机化(Constraint Randomization)生成不同幅度、频率、相位的输入序列。同时,利用assert语句在仿真中自动检查输出是否合理(例如,在输入为直流时,输出应稳定在某个值;在输入频率远高于截止频率时,输出幅度应非常小)。

此外,可以开启代码覆盖率(Code Coverage)分析,查看toggle coverage(信号翻转覆盖率)、branch coverage(分支覆盖率)和expression coverage(表达式覆盖率),确保testbench触发了所有关键的代码路径和条件分支。

6. 实战中的坑与性能优化经验谈

纸上得来终觉浅,绝知此事要躬行。下面分享几个我在实现FIR滤波器过程中踩过的坑和总结的经验。

6.1 数据位宽扩展与溢出处理

这是一个新手极易出错的地方。两个N位的有符号数相乘,会产生一个2N位的积。在FIR中,多个这样的积相加,结果可能更大。如果输出位宽不够,就会发生溢出,导致结果完全错误。

安全做法:假设输入数据位宽为DIW,系数位宽为CW,滤波器阶数为N

  • 乘法结果位宽:DIW + CW
  • 累加过程最大位宽:(DIW + CW) + ceil(log2(N))ceil(log2(N))是累加过程中可能需要的额外位宽,以防所有乘积同号相加导致数值翻倍。
  • 最终输出位宽:通常我们不会保留全精度,会进行截断或舍入。一个常见的做法是,将累加结果右移CW-1位(因为系数是Q1.15格式,其绝对值小于1),然后取DIW位输出,这样输出数据格式就和输入一致了。

在Verilog中,必须使用足够宽的寄存器来进行中间运算,并在最终输出前进行饱和处理(Saturation)或截断,而不是任由其溢出。

// 假设acc是累加器,宽度为 FULL_WIDTH localparam FULL_WIDTH = DATA_WIDTH + COEFF_WIDTH + $clog2(ORDER); reg signed [FULL_WIDTH-1:0] acc; wire signed [DATA_WIDTH-1:0] truncated_output; // 截断与饱和处理 assign truncated_output = (acc > MAX_POS) ? MAX_POS : ((acc < MIN_NEG) ? MIN_NEG : acc[FULL_WIDTH-1 -: DATA_WIDTH]); // 选择合适的高位部分

注意:$clog2是SystemVerilog函数,用于计算以2为底的对数并向上取整。在纯Verilog中可能需要自己定义。

6.2 时钟域与流水线平衡

FIR滤波器通常是一个纯同步设计,工作在单一时钟域下。但如果输入数据来自异步时钟域(比如另一个模块或外部ADC),就必须先进行异步FIFO或双寄存器同步处理,否则亚稳态(Metastability)会导致数据错误,这种错误是随机的、难以复现的。

另一个重点是流水线平衡。在转置型结构中,我们插入了寄存器来缩短关键路径。但要确保所有并行的数据路径具有相同的延迟(寄存器级数),否则会导致功能错误。例如,如果acc[i] <= (data_in * coeff[i]) + acc[i+1];这个操作被拆分成两级流水,那么data_inacc[i+1]必须同步地延迟相应的周期才能对齐相加。Vivado在综合时会尝试保持同步,但最好的做法是在代码中显式地、平衡地插入寄存器。

6.3 资源与速度的折衷:时分复用

当滤波器阶数很高(比如256阶以上)而系统时钟频率要求又不是特别高时,可以考虑使用时分复用(Time-Division Multiplexing, TDM)技术。即用一个物理乘法器,在多个时钟周期内分时计算多个乘积项。这能极大节省DSP资源,但代价是数据吞吐率会降低N倍(N为复用次数),并且会产生固定的处理延迟。

实现TDM FIR时,需要一个控制状态机来调度乘加操作,并需要一个RAM来存储中间数据和系数。这增加了设计的复杂性,但对于资源紧张的低速应用场景是值得的。在Vivado中,可以清晰地看到TDM设计将大量乘法操作映射到了同一个DSP48单元上。

6.4 仿真与调试技巧

  • 使用$display$monitor:在testbench中关键节点打印数据,虽然原始但有效。
  • 利用Vivado的波形调试器:将仿真结果保存为.wdb文件,在Vivado中打开波形查看器。可以添加模拟信号(如将数字总线转换为模拟波形显示),直观地看到滤波效果。
  • ILA(集成逻辑分析仪)的使用:对于板级调试,在设计中实例化ILA IP核,抓取FPGA运行时的真实信号。这对于验证滤波器在真实环境下的表现,以及调试时序问题至关重要。记得要合理设置触发条件,比如当输入信号超过某个阈值时开始抓取。
  • 警惕仿真与实际的差异:行为仿真(无延迟)和布线后时序仿真结果可能不同。如果布线后仿真出错,而行为仿真正确,几乎可以肯定是时序问题。此外,仿真模型是理想的,而实际ADC输入可能存在噪声、偏置等问题,需要在滤波器前端考虑直流偏置去除等预处理。

从浮点系数的设计,到定点化的权衡,再到Verilog结构的选择与优化,最后通过严谨的testbench验证,实现一个可靠的FIR滤波器是一个环环相扣的系统工程。它考验的不仅是信号处理的理论知识,更是对硬件描述语言、EDA工具和硬件底层资源的理解。当我第一次在示波器上看到经过自己设计的滤波器处理后,从满是毛刺的波形变为光滑的正弦波时,那种成就感是纯粹的。希望这篇详细的梳理,能让你在实现自己的FIR滤波器时,少走一些弯路,多一份笃定。

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

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

立即咨询