☰
FPGA级联型IIR滤波器Verilog实现:从Biquad到Vivado工程
2026/10/20 20:57:26 网站建设 项目流程

简介:这份资源面向FPGA数字信号处理的学习者与工程实践者,聚焦级联型IIR滤波器的Verilog硬件实现,帮助读者理解如何用Vivado将递归滤波算法落地为可综合的硬件电路。内容围绕多个二阶节(biquad)级联结构展开,涉及乘法器、加法器、系数寄存器、状态变量以及环路稳定性等关键设计点,适合具备Verilog基础、希望深入DSP硬件实现的中级开发者。资源包共82个文件,约223KB,以Verilog源码(.v)、Vivado工程与综合实现文件(.xpr、.dcp、.rpt)、仿真与波形数据(.wdb、.wdf)、约束脚本(.tcl、.xdc类)以及日志和说明文档为主,另含MATLAB脚本与测试激励文本,便于对照仿真与综合结果。目前已有2550人学习下载。读者可从中获得完整的级联IIR滤波器工程结构、各二阶节子模块划分方式、testbench验证思路以及Vivado综合实现流程,适合作为DSP硬件设计的参考范例。

1. 级联型 IIR 滤波器为什么值得单独开一篇:从一次 FPGA 调试翻车说起

很多人做 FPGA 数字信号处理,FIR 滤波器一把梭就完事了,毕竟线性相位、结构简单、不用考虑反馈回路。但真到了要做窄带选频、要做低频段锐截止、要省 DSP 资源的时候,IIR 就绕不开了。问题在于,直接型 IIR 在 FPGA 里定点实现,极点位置对系数量化极其敏感,稍微一截位,频响就跑偏,甚至直接振荡。级联型结构就是把高阶传递函数拆成多个二阶节(Biquad)串联,每个二阶节单独量化系数,极点分布可控,稳定性好很多。这篇要拆的就是一份用 Verilog 在 Vivado 里实现级联型 IIR 滤波器的完整工程,适合已经写过 FIR、想往 IIR 方向推进的 FPGA 开发者,也适合正在做数字信号处理课程设计、需要一份能跑通的参考实现的人。下面从工程结构、核心模块、仿真验证到二次开发,一层层拆开讲。

2. 工程结构与技术选型:为什么是 Vivado + Verilog + 级联 Biquad

2.1 整体目录与模块划分

这份工程的核心思路是:把高阶 IIR 滤波器拆成 N 个二阶节,每个二阶节用一个独立的 Verilog 模块实现,然后串联起来。目录结构大致如下:

iir_cascade_filter/ ├── rtl/ │ ├── iir_cascade_top.v // 顶层,串联所有二阶节 │ ├── biquad_direct_form2.v // 单个二阶节,直接II型 │ ├── coeff_rom.v // 系数存储,从COE文件初始化 │ └── saturate.v // 饱和截位模块 ├── sim/ │ ├── tb_iir_cascade.v // 测试平台 │ └── stimulus.txt // 输入激励数据 ├── constraints/ │ └── top.xdc // 时钟与引脚约束 ├── coeff/ │ └── iir_coeff.coe // 滤波器系数文件 └── scripts/ └── run_sim.tcl // Vivado仿真脚本

这个划分方式很常见,RTL 和仿真分离,系数单独放 COE 文件方便用 MATLAB 或 Python 生成后直接替换。顶层模块只负责例化和连线,不包含任何运算逻辑,这样改滤波器阶数的时候只需要改顶层和系数文件,二阶节模块本身不用动。

2.2 为什么选直接II型二阶节

二阶节内部有两种常见实现:直接I型和直接II型。直接I型需要 4 个延迟单元,直接II型只需要 2 个。在 FPGA 里延迟单元就是寄存器,省寄存器就是省资源。直接II型的差分方程写成:

w[n] = x[n] - a1*w[n-1] - a2*w[n-2] y[n] = b0*w[n] + b1*w[n-1] + b2*w[n-2]

其中 a1、a2 是反馈系数,b0、b1、b2 是前馈系数。注意这里的 a 系数符号,不同教材可能差一个负号,移植的时候要对清楚。直接II型把反馈和前馈的延迟链合并成一条,这是它省寄存器的关键。

2.3 定点位宽与系数量化策略

IIR 对系数量化敏感,位宽选太窄,极点偏移导致频响畸变;位宽选太宽,DSP 乘法器不够用。这份工程里输入数据 16 位有符号,系数 18 位有符号,累加器 32 位。为什么系数用 18 位?因为 Xilinx 的 DSP48E1 原生支持 25×18 乘法,18 位系数刚好卡在硬件乘法器的最佳位宽上,不用额外拆分。累加器 32 位是为了防止多个二阶节串联时中间结果溢出。

注意:系数量化前一定要在 MATLAB 或 Python 里做定点仿真,确认频响偏差在可接受范围内再生成 COE 文件。直接拿浮点系数截位,大概率翻车。

3. 核心模块拆解:二阶节与级联顶层的 Verilog 实现

3.1 二阶节模块 biquad_direct_form2.v

这是整个工程最核心的模块,每个二阶节都是它的例化。先看代码:

module biquad_direct_form2 #( parameter DATA_WIDTH = 16, parameter COEFF_WIDTH = 18, parameter ACC_WIDTH = 32 )( input wire clk, input wire rst_n, input wire signed [DATA_WIDTH-1:0] x_in, input wire signed [COEFF_WIDTH-1:0] b0, input wire signed [COEFF_WIDTH-1:0] b1, input wire signed [COEFF_WIDTH-1:0] b2, input wire signed [COEFF_WIDTH-1:0] a1, input wire signed [COEFF_WIDTH-1:0] a2, output wire signed [DATA_WIDTH-1:0] y_out ); // 延迟链寄存器,存储 w[n-1] 和 w[n-2] reg signed [ACC_WIDTH-1:0] w_d1, w_d2; // 组合逻辑计算 w[n] wire signed [ACC_WIDTH-1:0] w_cur; wire signed [ACC_WIDTH-1:0] acc_fb; // 反馈部分:a1*w[n-1] + a2*w[n-2] wire signed [ACC_WIDTH-1:0] prod_a1, prod_a2; assign prod_a1 = w_d1 * a1; assign prod_a2 = w_d2 * a2; assign acc_fb = prod_a1 + prod_a2; // w[n] = x[n] - a1*w[n-1] - a2*w[n-2] assign w_cur = {{(ACC_WIDTH-DATA_WIDTH){x_in[DATA_WIDTH-1]}}, x_in} - acc_fb; // 前馈部分:b0*w[n] + b1*w[n-1] + b2*w[n-2] wire signed [ACC_WIDTH-1:0] prod_b0, prod_b1, prod_b2; wire signed [ACC_WIDTH-1:0] acc_ff; assign prod_b0 = w_cur * b0; assign prod_b1 = w_d1 * b1; assign prod_b2 = w_d2 * b2; assign acc_ff = prod_b0 + prod_b1 + prod_b2; // 饱和截位输出 assign y_out = saturate(acc_ff); // 延迟链更新 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin w_d1 <= 0; w_d2 <= 0; end else begin w_d1 <= w_cur; w_d2 <= w_d1; end end endmodule

这段代码有几个关键点。第一,w_cur的计算里用了符号扩展,把 16 位输入扩展到 32 位再参与减法,防止位宽不匹配导致高位丢失。第二,反馈和前馈的乘法都是组合逻辑,实际综合时 Vivado 会自动推断 DSP 乘法器,如果时序紧张可以在乘法后插一级流水线寄存器。第三,saturate模块负责把 32 位累加结果饱和截位回 16 位,不能直接截断,否则大信号时会产生严重的非线性失真。

参数说明:DATA_WIDTH是输入输出数据位宽,默认 16;COEFF_WIDTH是系数字宽,默认 18;ACC_WIDTH是内部累加器位宽,默认 32。改滤波器阶数时这些参数不用动,只需要在顶层增加或减少二阶节例化。

3.2 级联顶层 iir_cascade_top.v

顶层负责把多个二阶节串起来,同时从系数 ROM 里读出每个二阶节的系数。代码结构如下:

module iir_cascade_top #( parameter NUM_SECTIONS = 4, // 二阶节数量 parameter DATA_WIDTH = 16, parameter COEFF_WIDTH = 18 )( input wire clk, input wire rst_n, input wire signed [DATA_WIDTH-1:0] x_in, output wire signed [DATA_WIDTH-1:0] y_out ); // 每个二阶节的输出,作为下一级的输入 wire signed [DATA_WIDTH-1:0] section_out [0:NUM_SECTIONS]; assign section_out[0] = x_in; // 系数从ROM读出,每个二阶节5个系数 wire signed [COEFF_WIDTH-1:0] b0 [0:NUM_SECTIONS-1]; wire signed [COEFF_WIDTH-1:0] b1 [0:NUM_SECTIONS-1]; wire signed [COEFF_WIDTH-1:0] b2 [0:NUM_SECTIONS-1]; wire signed [COEFF_WIDTH-1:0] a1 [0:NUM_SECTIONS-1]; wire signed [COEFF_WIDTH-1:0] a2 [0:NUM_SECTIONS-1]; // 例化系数ROM coeff_rom u_coeff_rom ( .clk (clk), .b0 (b0), .b1 (b1), .b2 (b2), .a1 (a1), .a2 (a2) ); // 级联例化所有二阶节 genvar i; generate for (i = 0; i < NUM_SECTIONS; i = i + 1) begin : gen_sections biquad_direct_form2 #( .DATA_WIDTH (DATA_WIDTH), .COEFF_WIDTH (COEFF_WIDTH) ) u_biquad ( .clk (clk), .rst_n (rst_n), .x_in (section_out[i]), .b0 (b0[i]), .b1 (b1[i]), .b2 (b2[i]), .a1 (a1[i]), .a2 (a2[i]), .y_out (section_out[i+1]) ); end endgenerate assign y_out = section_out[NUM_SECTIONS]; endmodule

这里用generate循环来例化二阶节,改阶数只需要改NUM_SECTIONS参数。系数 ROM 的输出是数组形式,每个二阶节从对应索引取自己的系数。注意section_out数组声明为[0:NUM_SECTIONS],比二阶节数量多一个,因为第一个元素是输入,最后一个元素是最终输出。

3.3 饱和截位模块 saturate.v

饱和截位的逻辑不复杂,但很容易写错。核心判断是:如果累加结果大于输出位宽能表示的最大值,就钳位到最大值;小于最小值就钳位到最小值;否则直接截取低位。

module saturate #( parameter IN_WIDTH = 32, parameter OUT_WIDTH = 16 )( input wire signed [IN_WIDTH-1:0] din, output wire signed [OUT_WIDTH-1:0] dout ); // 最大值和最小值 localparam signed [IN_WIDTH-1:0] MAX_VAL = (1 <<< (OUT_WIDTH-1)) - 1; localparam signed [IN_WIDTH-1:0] MIN_VAL = -(1 <<< (OUT_WIDTH-1)); assign dout = (din > MAX_VAL) ? MAX_VAL[OUT_WIDTH-1:0] : (din < MIN_VAL) ? MIN_VAL[OUT_WIDTH-1:0] : din[OUT_WIDTH-1:0]; endmodule

参数说明:IN_WIDTH是输入位宽,接累加器的 32 位;OUT_WIDTH是输出位宽,接下一级的 16 位输入。MAX_VAL和MIN_VAL用移位运算生成,避免手写具体数值出错。这个模块虽然简单,但少了它,IIR 在大信号输入时输出会严重失真,这是很多新手容易忽略的地方。

4. 仿真验证与上板调试:从 Testbench 到 ILA 抓波形

4.1 Testbench 怎么写才能验证 IIR 的正确性

IIR 滤波器的 Testbench 不能只给几个脉冲看输出,要喂一段真实信号,比如正弦叠加噪声,然后把输出和 MATLAB 的浮点参考结果对比。测试平台的核心逻辑:

module tb_iir_cascade; reg clk, rst_n; reg signed [15:0] x_in; wire signed [15:0] y_out; // 例化顶层 iir_cascade_top #( .NUM_SECTIONS (4), .DATA_WIDTH (16), .COEFF_WIDTH (18) ) u_dut ( .clk (clk), .rst_n (rst_n), .x_in (x_in), .y_out (y_out) ); // 时钟生成 initial clk = 0; always #5 clk = ~clk; // 100MHz // 从文件读取激励数据 integer fid, i; reg signed [15:0] stimulus [0:999]; initial begin $readmemh("stimulus.txt", stimulus); rst_n = 0; #100; rst_n = 1; for (i = 0; i < 1000; i = i + 1) begin @(posedge clk); x_in = stimulus[i]; end #1000; $finish; end // 输出到文件,方便和MATLAB对比 integer fout; initial fout = $fopen("output.txt", "w"); always @(posedge clk) begin if (rst_n) $fwrite(fout, "%d\n", y_out); end endmodule

这个 Testbench 的关键是$readmemh从文件读激励,$fwrite把输出写到文件,然后在 MATLAB 或 Python 里做逐点对比。对比的时候要注意定点截位带来的误差,不能要求完全一致,一般看信噪比或者最大绝对误差。

4.2 Vivado 仿真脚本与波形观察

Vivado 里跑仿真可以用 Tcl 脚本自动化,省得每次点鼠标。run_sim.tcl的内容大致是:

# 创建工程 create_project -force iir_sim ./iir_sim -part xc7a100tcsg324-1 # 添加源文件 add_files -norecurse [list \ ../rtl/iir_cascade_top.v \ ../rtl/biquad_direct_form2.v \ ../rtl/coeff_rom.v \ ../rtl/saturate.v \ ] # 添加仿真文件 add_files -fileset sim_1 -norecurse ../sim/tb_iir_cascade.v # 设置顶层 set_property top tb_iir_cascade [get_filesets sim_1] # 运行仿真 launch_simulation run all

跑完仿真后,在波形窗口里重点看几个信号:每个二阶节的w_cur是否在合理范围内、y_out是否饱和、复位后延迟链是否正确清零。如果某个二阶节输出一直是最大值或最小值,大概率是系数符号搞反了,反馈变成了正反馈。

4.3 上板调试:ILA 抓什么信号

上板之后,仿真和实际不一定完全一致,尤其是时序紧张的时候。用 Vivado 的 ILA 抓信号,建议抓这几组:输入x_in、每个二阶节的输出section_out[i]、最终输出y_out。触发条件设成输入幅度超过某个阈值,这样能抓到饱和发生的时刻。如果发现输出有毛刺,检查一下乘法器的流水线级数,组合逻辑路径太长会导致时序违例,表现就是输出随机跳变。

提示:ILA 的采样深度有限,抓 1024 个点就够了,太多反而不好分析。触发条件用输入信号的绝对值超过阈值,比用时钟计数触发更有针对性。

5. 避坑与常见问题:级联 IIR 调试中容易翻车的几个点

5.1 输出一直振荡不收敛

现象:复位后给一个脉冲输入,输出持续振荡,幅度不衰减。原因:反馈系数符号搞反了。IIR 的差分方程里反馈项是减号,如果代码里写成了加号,极点跑到单位圆外,系统就不稳定了。解决:对照差分方程检查w_cur的计算,确认是x_in - a1*w_d1 - a2*w_d2而不是加号。另外检查系数量化时有没有把符号位搞错,18 位有符号数的最高位是符号位,COE 文件里负数要用补码表示。

5.2 频响和 MATLAB 对不上

现象:仿真输出和 MATLAB 浮点参考对比,低频段还行,高频段偏差很大。原因:系数量化位宽不够,或者二阶节的配对顺序不对。IIR 级联时,零极点的配对方式会影响中间结果的动态范围,配对不好会导致中间某级溢出。解决:在 MATLAB 里用tf2sos函数生成二阶节时,指定'up'或'down'排序,把增益大的节放在前面。系数量化位宽从 18 位加到 20 位试试,看频响是否改善。

5.3 综合后时序违例

现象:Vivado 综合实现后报时序违例,WNS 为负。原因:二阶节里的乘法和累加都是组合逻辑,路径太长。解决:在乘法器后面插一级流水线寄存器,把组合路径打断。代价是输出会延迟一个时钟周期,Testbench 里对比的时候要把这个延迟补偿掉。或者在 Vivado 里给乘法器设USE_DSP属性,让工具自动用 DSP 硬核。

5.4 上板后输出全是零

现象:仿真正常,上板后输出一直是零。原因:复位信号没接对,或者时钟约束没加导致时钟根本没进来。解决:检查 XDC 文件里时钟约束和引脚约束,用 ILA 抓一下rst_n和clk信号,确认复位已经释放、时钟在翻转。另外检查系数 ROM 的初始化文件路径,Vivado 综合时如果找不到 COE 文件,系数会全零,输出自然也是零。

5.5 改阶数后输出异常

现象:把NUM_SECTIONS从 4 改成 6,输出完全不对。原因:系数 ROM 里只存了 4 组系数,改成 6 之后后两组读到的是零或者随机值。解决:改阶数的同时要重新生成 COE 文件,确保系数数量和NUM_SECTIONS匹配。另外检查section_out数组的位宽声明,[0:NUM_SECTIONS]要跟着改。

6. 二次开发与改造:从固定系数到可配置滤波器的进阶思路

这份工程目前是固定系数的,COE 文件在综合时就写死了。如果想做成可配置的,比如通过 AXI 接口在线改系数,需要做几件事。第一,把coeff_rom换成寄存器组,每个系数一个寄存器,通过 AXI4-Lite 写。第二,加一个系数更新握手信号,确保所有系数同时生效,避免更新过程中滤波器输出异常。第三,如果要做自适应滤波,还需要加一个系数计算模块,根据误差信号实时更新系数,那就是 LMS 或者 RLS 的范畴了。

改造的时候有个技巧:先用 Vivado 的 Block Design 把 AXI 接口搭好,生成一个带 AXI 的模板,然后把二阶节模块挂上去。这样比手写 AXI 状态机省事得多。系数位宽建议保留 18 位,和 DSP48 对齐,不要为了省资源降到 12 位,IIR 对系数量化太敏感,降位宽省的那点 DSP 不值得。

验证改造是否成功,我一般会走一遍这个流程:先用 MATLAB 生成一组系数,通过 AXI 写入,然后喂一段扫频信号,用 ILA 抓输出,在 MATLAB 里做 FFT 看频响。如果频响和设计值偏差在 1dB 以内,基本就没问题。如果偏差大,先检查系数写入是否正确,再检查定点截位有没有引入额外失真。

还有一个容易忽略的点:级联 IIR 的中间结果动态范围。每个二阶节输出都是 16 位,但中间某级的实际值可能超过 16 位能表示的范围,饱和截位会引入非线性。解决办法是在 MATLAB 里仿真时看每一级的最大值,如果某级经常饱和,就在那一级后面加一个增益缩放,把信号压回安全范围,最后再统一放大回来。

从那以后我每次改 IIR 滤波器,都强制走一遍「MATLAB 定点仿真 → Verilog 仿真对比 → 上板 ILA 验证」三步,少一步都可能翻车。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询