ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FPGA级联型IIR滤波器Verilog实现与定点量化避坑指南

FPGA级联型IIR滤波器Verilog实现与定点量化避坑指南

简介:这份资源面向FPGA数字信号处理的学习者与工程实践者,聚焦级联型IIR滤波器的Verilog硬件实现,帮助读者理解如何用Vivado将递归滤波算法落地为可综合的硬件电路。内容围绕多个二阶节(biquad)级联结构展开,涉及乘法器、加法器、系数寄存器与状态变量的并行运算描述,并配有测试平台用于仿真验证频率响应与环路稳定性。资源包共82个文件,约223KB,以Verilog源码(.v)、Vivado工程与综合实现文件(.xpr、.dcp、.rpt)、仿真与波形数据(.wdf、.pb)、脚本与日志(.tcl、.bat、.log)以及说明文档(.txt、.m)为主,覆盖从源码、仿真到综合实现的完整流程。目前已有2550人学习下载,适合希望掌握IIR滤波器FPGA实现、对照工程结构进行复现与二次开发的读者参考。

1. 级联型 IIR 滤波器为什么值得在 FPGA 里单独拆出来做

很多人做 FPGA 数字信号处理,FIR 滤波器写得飞起,一碰到 IIR 就开始犯怵。原因不复杂:FIR 只有零点,结构规整,流水线一插就完事;IIR 既有极点又有零点,反馈回路天然带时序约束,量化误差还会被极点放大,稍不注意就振荡。但 IIR 的诱惑又实实在在——同样过渡带指标下,IIR 的阶数往往只有 FIR 的几分之一,乘法器资源紧张的时候,这个差距能决定方案能不能落地。

级联型结构(Cascade Form)是把高阶 IIR 拆成若干二阶节(Biquad)串联,每个二阶节单独处理一对共轭极点或零点。相比直接型,级联型的系数灵敏度低得多,定点量化后频率响应不容易跑偏,而且每个二阶节可以独立做流水线,时序更容易收敛。这套 Verilog 设计就是围绕这个思路展开的:用 Vivado 做综合实现,把级联型 IIR 拆成可复用的二阶节模块,逐级串联,配合定点量化策略,最终在 FPGA 上跑通一个可参数化的滤波器链路。适合已经写过基本 Verilog 状态机、想从 FIR 跨到 IIR 的工程师,也适合手头有滤波器指标但不知道怎么落到 RTL 的人。

2. 级联型 IIR 的结构拆解与定点量化策略

2.1 从传递函数到二阶节串联

一个 N 阶 IIR 滤波器的系统函数可以写成有理分式形式,直接型实现就是把分子分母系数直接映射成差分方程。但高阶直接型在定点实现里有个致命问题:系数量化误差会直接扰动极点位置,极点一旦跑到单位圆外,系统就发散了。级联型的做法是把分子分母多项式因式分解,每一对共轭极点配一对共轭零点,组成一个二阶节:

H(z) = ∏ (b0 + b1·z⁻¹ + b2·z⁻²) / (1 + a1·z⁻¹ + a2·z⁻²)

每个二阶节只负责一对极点,系数灵敏度从 O(N²) 降到 O(1) 量级。实际工程里,偶数阶直接两两配对,奇数阶会多出一个一阶节,这个一阶节可以单独实现,也可以补一个零极点对变成二阶节统一处理。我一般倾向于统一成二阶节,代码复用率高,综合时也不用为了一阶节单独写一套逻辑。

配对顺序有讲究。常见做法是把极点离单位圆最近的二阶节放在最前面,因为前级信号动态范围最大,先处理最敏感的极点能减少后面各级的溢出风险。零点配对则尽量让每个二阶节的幅频响应平坦,避免中间某级增益过大导致位宽爆炸。

2.2 定点量化:Q 格式选择与位宽分配

IIR 的定点量化比 FIR 敏感得多,核心原因是反馈回路会把量化噪声累积放大。Q 格式的选择直接决定滤波器能不能用。假设输入信号范围是 [-1, 1),系数绝对值一般小于 2,我通常这样分配:

信号/系数格式位宽说明
输入信号 x[n]Q1.1516 bit1 位符号 + 15 位小数
滤波器系数 b/aQ2.1416 bit留 2 位整数位防止系数超过 1
二阶节输出Q1.1516 bit每级输出重新截位回 Q1.15
累加器Q4.2832 bit中间乘积累加,保留足够精度

系数用 Q2.14 是因为级联型每个二阶节的增益不一定小于 1,有些节的 b0 可能接近 2,用 Q1.15 会溢出。累加器用 32 位是血泪经验:16 位乘 16 位得 32 位,如果累加器只给 24 位,多级串联后截位误差会累积到肉眼可见。

量化系数的具体步骤:先用 MATLAB 或 Python 算出浮点系数,然后乘以 2^14 取整,再转成补码。注意取整方式用四舍五入而不是截断,截断会引入直流偏置。

import numpy as np def quantize_coeff(b, a, frac_bits=14): """ 将浮点滤波器系数量化为定点补码 b: 分子系数数组 a: 分母系数数组(a[0] 通常为 1) frac_bits: 小数位宽 """ scale = 2 ** frac_bits b_q = np.round(b * scale).astype(int) a_q = np.round(a * scale).astype(int) # 转为补码表示(假设 16 bit) def to_twos_complement(val, bits=16): if val < 0: val = (1 << bits) + val return val & ((1 << bits) - 1) b_tc = [to_twos_complement(v) for v in b_q] a_tc = [to_twos_complement(v) for v in a_q] return b_tc, a_tc # 示例:一个低通二阶节 b = [0.2, 0.4, 0.2] a = [1.0, -0.5, 0.3] b_fixed, a_fixed = quantize_coeff(b, a) print("b 补码:", [hex(v) for v in b_fixed]) print("a 补码:", [hex(v) for v in a_fixed])

这段代码的关键在to_twos_complement:Verilog 里系数用signed声明,但初始化时如果直接写负数,不同综合工具处理方式可能不一致,统一转成补码十六进制最稳妥。frac_bits=14对应 Q2.14,如果系数动态范围小可以改成 15 位小数,但整数位只剩 1 位,系数超过 1 就溢出。

2.3 二阶节的 Verilog 实现骨架

每个二阶节的差分方程是:

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

注意 a1、a2 前面的负号,很多新手在这里翻车——MATLAB 的filter(b, a, x)里分母系数是正号写的,但差分方程展开后反馈项要取负。我一般直接在量化阶段就把 a1、a2 取反,RTL 里统一用加法,少一层思维负担。

module biquad #( parameter DATA_WIDTH = 16, parameter COEFF_WIDTH = 16, parameter B0 = 16'sh0CCC, // Q2.14 格式 parameter B1 = 16'sh1999, parameter B2 = 16'sh0CCC, parameter A1 = 16'sh0800, // 已取反 parameter A2 = 16'shFD9A // 已取反 )( input wire clk, input wire rst_n, input wire signed [DATA_WIDTH-1:0] x_in, output reg signed [DATA_WIDTH-1:0] y_out ); reg signed [DATA_WIDTH-1:0] x_d1, x_d2; reg signed [DATA_WIDTH-1:0] y_d1, y_d2; reg signed [2*DATA_WIDTH-1:0] acc; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin x_d1 <= 0; x_d2 <= 0; y_d1 <= 0; y_d2 <= 0; y_out <= 0; end else begin // 移位寄存器更新 x_d1 <= x_in; x_d2 <= x_d1; y_d1 <= y_out; y_d2 <= y_d1; // 累加:前馈 + 反馈 acc = B0 * x_in + B1 * x_d1 + B2 * x_d2 + A1 * y_d1 + A2 * y_d2; // 截位回 Q1.15,带饱和处理 if (acc[31:14] > 32767) y_out <= 32767; else if (acc[31:14] < -32768) y_out <= -32768; else y_out <= acc[29:14]; end end endmodule

这段代码有几个参数需要说明。B0到A2是量化后的系数,用parameter传入,方便例化时替换不同滤波器。acc用 32 位是因为 16 位乘 16 位得 32 位,五个乘积相加需要额外位宽,但实际有效位在acc[29:14]附近。饱和处理不能省——IIR 反馈回路一旦溢出,错误会循环放大,几拍之内输出就变成方波。截位位置[29:14]对应 Q2.14 乘 Q1.15 得 Q3.29,再截到 Q1.15 需要右移 14 位,同时保留 2 位整数位防止中间级溢出。

3. 在 Vivado 里搭起级联链路:例化、时序与仿真

3.1 顶层模块的级联例化

单个二阶节跑通后,顶层就是把 N 个biquad串起来。每个节的输出接下一节的输入,时钟和复位共用。这里有个细节:如果所有节用同一个时钟,反馈回路的组合逻辑延迟会随级数增加而累积,时序容易挂。常见做法是在每个二阶节内部再插一级流水线,把乘加和截位分成两拍做。

module iir_cascade #( parameter NUM_STAGES = 4, parameter DATA_WIDTH = 16 )( 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] stage_out [0:NUM_STAGES]; assign stage_out[0] = x_in; genvar i; generate for (i = 0; i < NUM_STAGES; i = i + 1) begin : cascade biquad #( .DATA_WIDTH(DATA_WIDTH), .B0(16'sh0CCC), .B1(16'sh1999), .B2(16'sh0CCC), .A1(16'sh0800), .A2(16'shFD9A) ) u_biquad ( .clk(clk), .rst_n(rst_n), .x_in(stage_out[i]), .y_out(stage_out[i+1]) ); end endgenerate assign y_out = stage_out[NUM_STAGES]; endmodule

generate块让级联数变成参数,改NUM_STAGES就能调整阶数。注意每个biquad的系数目前是写死的,实际项目里应该把系数做成参数数组或者从寄存器配置,这里为了简洁先固定。stage_out数组用wire声明,综合工具会自动推断出级联路径。

3.2 时序约束与流水线插入

级联型 IIR 的时序瓶颈在反馈路径:y[n] 依赖 y[n-1] 和 y[n-2],这两个值又来自上一拍的输出。如果乘加逻辑太长,建立时间不够,Vivado 会报 timing violation。我一般用两种手段解决:

第一种是在二阶节内部把乘法和加法分开。第一拍算五个乘积,第二拍做累加和截位。代价是输出延迟增加一拍,但 IIR 本来就有反馈延迟,多一拍不影响滤波特性,只是群延迟变大。

第二种是给系数乘法加输入寄存器。x_in、x_d1、x_d2、y_d1、y_d2在进入乘法器之前先打一拍,这样乘法器的输入路径变短,时序余量更大。

在 Vivado 里创建约束文件时,至少要把时钟周期约束写上:

create_clock -period 10.000 -name sys_clk [get_ports clk] set_input_delay -clock sys_clk 2.000 [get_ports x_in] set_output_delay -clock sys_clk 2.000 [get_ports y_out]

10ns 周期对应 100MHz,对大多数中低端 FPGA 来说,级联 4 到 6 级二阶节在这个频率下能收敛。如果跑 200MHz 以上,建议每级都插流水线,或者用 DSP48 硬核做乘加。

3.3 Testbench 与仿真验证

仿真验证不能只看输出波形“像不像”滤波结果,要拿浮点参考做逐点对比。我一般用 Python 生成一组扫频信号,同时算出浮点滤波输出,存成文本文件,Verilog testbench 读入后逐点比较。

module tb_iir_cascade; reg clk, rst_n; reg signed [15:0] x_in; wire signed [15:0] y_out; reg signed [15:0] x_mem [0:1023]; reg signed [15:0] y_ref [0:1023]; integer i, fid, err_cnt; iir_cascade #(.NUM_STAGES(4)) dut ( .clk(clk), .rst_n(rst_n), .x_in(x_in), .y_out(y_out) ); initial begin clk = 0; forever #5 clk = ~clk; // 100MHz end initial begin // 读入激励和参考输出 fid = $fopen("stimulus.txt", "r"); for (i = 0; i < 1024; i = i + 1) fscanf(fid, "%d", x_mem[i]); $fclose(fid); fid = $fopen("reference.txt", "r"); for (i = 0; i < 1024; i = i + 1) fscanf(fid, "%d", y_ref[i]); $fclose(fid); rst_n = 0; #20 rst_n = 1; err_cnt = 0; for (i = 0; i < 1024; i = i + 1) begin x_in = x_mem[i]; @(posedge clk); // 等待流水线延迟后比较 if (i > 10) begin if ($abs(y_out - y_ref[i-5]) > 4) err_cnt = err_cnt + 1; end end $display("Error count: %d", err_cnt); $finish; end endmodule

$abs(y_out - y_ref[i-5]) > 4里的 4 是容差,定点量化必然有误差,要求逐位相等不现实。容差设多少取决于 Q 格式和级数,一般 16 位 Q1.15 下,4 到 8 个 LSB 的误差可以接受。i-5是流水线延迟补偿,具体拍数要看 RTL 里插了几级寄存器,仿真时先跑一遍看波形对齐。

4. 级联型 IIR 的避坑与排查清单

4.1 输出发散或振荡

现象:仿真跑几百个点后输出幅度越来越大,最终饱和成方波。原因通常是极点跑到单位圆外,或者反馈系数符号搞反了。先检查量化后的 a1、a2 是否在稳定范围内——二阶节稳定的条件是 |a2| < 1 且 |a1| < 1 + a2。如果浮点系数稳定但定点后发散,多半是 Q 格式整数位不够,系数被截断成了错误值。解决:把系数位宽加到 18 或 20 位,或者改用 Q3.13 格式。

4.2 输出一直为零

现象:输入有信号,输出始终是 0。先看复位逻辑,rst_n是不是一直拉低。如果复位正常,检查截位表达式acc[29:14]是不是取错了位。32 位累加器里,有效数据可能在高位,如果截位位置偏了,取到的全是符号扩展位。用 Vivado 的 ILA 抓一下acc的原始值,确认有效位在哪一段。

4.3 频率响应和 MATLAB 对不上

现象:仿真出来的幅频响应和浮点参考差很多,通带增益不对或者阻带衰减不够。最常见的原因是系数配对顺序错了。级联型虽然整体传递函数和直接型等价,但中间级的动态范围受配对顺序影响。如果某个二阶节的极点非常靠近单位圆,放在后面会导致前级信号已经衰减,后级信噪比恶化。解决:把极点 Q 值最高的节放在最前面,用 Python 重新排序后重新量化。

4.4 时序不收敛

现象:Vivado 实现后报 setup violation,路径集中在反馈回路。原因是乘加逻辑太长。解决:在二阶节内部插入流水线寄存器,把acc的计算拆成两拍。代价是输出延迟增加,但 IIR 对延迟不敏感。如果插了流水线还不行,检查是不是用了*运算符让综合工具推了太多逻辑,可以手动例化 DSP48 原语。

4.5 多级串联后噪声累积

现象:单级仿真没问题,级联 4 级以上后输出噪声明显变大。原因是每级截位都会引入量化噪声,级联后噪声逐级放大。解决:中间级用更高的位宽,比如每级输出保留 Q1.17,只在最后一级截回 Q1.15。或者用舍入代替截断,舍入的噪声功率比截断低 6dB 左右。

5. 用 Python 做系数生成与定点验证的闭环

5.1 从滤波器指标到二阶节系数

实际项目里不会手算系数,都是用scipy.signal生成。比如要设计一个采样率 48kHz、通带 4kHz、阻带 8kHz、阻带衰减 40dB 的低通滤波器:

import numpy as np from scipy import signal fs = 48000 wp = 4000 / (fs / 2) ws = 8000 / (fs / 2) N, Wn = signal.ellipord(wp, ws, 0.5, 40) b, a = signal.ellip(N, 0.5, 40, Wn, btype='low') # 转为二阶节级联形式 sos = signal.tf2sos(b, a) print("二阶节数量:", sos.shape[0]) for i, section in enumerate(sos): print(f"Section {i}: b={section[:3]}, a={section[3:]}")

signal.tf2sos自动完成零极点配对和排序,输出的sos数组每行是一个二阶节的系数。ellipord算最小阶数,椭圆滤波器在同样指标下阶数最低,适合 FPGA 资源紧张的场景。如果对相位线性有要求,换成butterord和butter,但阶数会上去。

5.2 定点仿真的闭环验证

拿到sos后,不要直接写进 Verilog,先用 Python 做一遍定点仿真,确认量化后频率响应没跑偏:

def fixed_point_filter(sos, x, frac_bits=14): """定点仿真级联型 IIR""" scale = 2 ** frac_bits y = x.astype(np.float64) for section in sos: b = np.round(section[:3] * scale).astype(np.int64) a = np.round(section[3:] * scale).astype(np.int64) # 差分方程逐点计算 y_out = np.zeros_like(y) x1 = x2 = y1 = y2 = 0 for n in range(len(y)): acc = (b[0]*int(y[n]*scale) + b[1]*x1 + b[2]*x2 - a[1]*y1 - a[2]*y2) out = acc >> frac_bits # 饱和 out = max(min(out, 32767), -32768) y_out[n] = out / scale x2, x1 = x1, int(y[n]*scale) y2, y1 = y1, out y = y_out return y # 生成扫频信号验证 t = np.arange(0, 0.1, 1/fs) x = np.sin(2 * np.pi * 1000 * t) + 0.5 * np.sin(2 * np.pi * 6000 * t) y_fixed = fixed_point_filter(sos, x)

这段代码逐点模拟了 RTL 的运算过程,包括乘累加、截位和饱和。跑完后用 FFT 对比浮点输出和定点输出的频谱,如果通带波动小于 0.5dB、阻带衰减和设计值差不超过 3dB,就可以把系数写进 Verilog 了。我一般会把这个脚本和 RTL 仿真结果再对一遍,两边误差在几个 LSB 以内才算过关。

5.3 系数导出与 Verilog 参数替换

验证通过后,把sos里的系数转成补码十六进制,直接替换biquad模块的parameter默认值。如果级数多,手改容易出错,写个脚本自动生成例化模板:

def gen_verilog_params(sos, frac_bits=14): """生成 Verilog parameter 定义""" lines = [] for i, section in enumerate(sos): b = np.round(section[:3] * (2**frac_bits)).astype(int) a = np.round(section[3:] * (2**frac_bits)).astype(int) # a1, a2 取反用于加法 a1_neg = -a[1] a2_neg = -a[2] def to_hex(v): return f"16'sh{v & 0xFFFF:04X}" lines.append(f"// Stage {i}") lines.append(f".B0({to_hex(b[0])}), .B1({to_hex(b[1])}), .B2({to_hex(b[2])}),") lines.append(f".A1({to_hex(a1_neg)}), .A2({to_hex(a2_neg)})") return "\n".join(lines) print(gen_verilog_params(sos))

这个脚本输出的就是可以直接粘贴到generate块里的参数列表。注意a1_neg和a2_neg已经取反,和前面 RTL 里统一用加法的约定一致。从那以后我每次改滤波器系数,都强制走一遍「Python 定点仿真 → 导出补码 → RTL 替换 → 联合仿真」这个闭环,再也没出现过系数写错导致输出发散的情况。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进