简介:这份资源面向FPGA数字信号处理的学习者与工程实践者,聚焦级联型IIR滤波器的Verilog硬件实现,帮助读者理解如何用Vivado将递归滤波算法落地为可综合的硬件电路。内容围绕多个二阶节(biquad)级联结构展开,涉及乘法器、加法器、系数寄存器与状态变量的并行描述,并配有测试平台用于仿真验证频率响应与环路稳定性。资源包共82个文件,约223KB,以Verilog源码、Vivado工程文件、仿真脚本、日志与报告为主,另含少量MATLAB脚本、文本与网页说明,便于对照工程结构逐步复现。目前已有2550人学习下载。读者可从中获得完整的级联IIR设计思路、可综合代码框架、testbench验证方法以及综合实现后的时序与资源分析参考,适合具备一定Verilog与数字信号处理基础、希望深入FPGA滤波器设计的开发者。
1. 从一次“玄学”调试说起:为什么级联型 IIR 滤波器值得单独写一版 Verilog
前阵子帮一个做音频采集的朋友看 FPGA 工程,他用的是一版直接型 IIR 滤波器,仿真波形漂亮得不行,结果上板之后输出全是毛刺,低频段还带着一股说不清的噪声。查了两天,最后发现问题出在系数量化后的极点漂移上——直接型结构对系数量化太敏感,阶数一高,极点稍微偏一点,整个频响就塌了。这件事之后,我重新翻出了级联型 IIR 的 Verilog 实现,也就是这篇要拆的这份 FPGA 数字信号处理工程。
这份资源干的事情很明确:用 Verilog 在 FPGA 上实现一个级联型 IIR 滤波器,配套 Vivado 工程,能仿真、能综合、能上板。适合谁?适合已经写过基本 Verilog、知道什么是 FIR、但一碰到 IIR 的反馈回路和系数量化就头大的开发者。它不教你 DSP 理论,它给你一套能跑的结构,让你把“为什么级联”这件事从论文里拽到波形图上。
2. 项目定位与技术选型:为什么是级联型,为什么是 Vivado + Verilog
2.1 直接型、级联型、并联型,到底差在哪
IIR 滤波器的传递函数是一个有理分式,分子分母都是多项式。实现的时候,这个分式可以拆成几种不同的结构:
- 直接型:把分子分母系数直接映射成一条差分方程,用最少的乘法器实现。缺点是高阶时系数量化误差会直接改变极点位置,极点一旦跑到单位圆外,系统就不稳定。
- 级联型:把高阶传递函数分解成若干个一阶或二阶子系统的乘积,每个子系统单独实现,再串起来。每个二阶节的极点只由自己那几个系数决定,量化误差被局部化,不会互相污染。
- 并联型:分解成部分分式之和,各子系统并行。极点同样被隔离,但加法器更多,资源换稳定性。
这份工程选的是级联型,核心原因就一个:用可控的资源增量换稳定性。二阶节(Biquad)是基本单元,每个节只处理两个极点,系数量化对极点的影响被限制在单个节内。对于音频、振动信号这类对低频段特别敏感的场景,级联型是更稳妥的选择。
2.2 技术栈与工具链
工程用的是 Xilinx Vivado 作为开发环境,Verilog 作为描述语言。这个组合在 FPGA 数字信号处理里算是标配,原因不复杂:
| 项目 | 说明 |
|---|---|
| 开发工具 | Vivado(综合、实现、仿真一体) |
| 描述语言 | Verilog-2001 |
| 滤波器结构 | 级联二阶节(Cascade Biquad) |
| 数据格式 | 定点数,常见 Q 格式 |
| 验证方式 | Testbench + 仿真波形 |
| 目标器件 | 通用 7 系列 FPGA(具体型号按工程约束) |
选 Verilog 而不是 SystemVerilog,是因为 IIR 的结构本身不复杂,用不着高级验证特性,Verilog 的可综合子集足够描述清楚。选 Vivado 而不是其他工具链,是因为工程里大概率会用到 IP 核做乘法器或累加器,Vivado 的 IP 集成流程最顺。
提示:如果你用的是其他厂商的 FPGA,Verilog 主体逻辑不用大改,但乘法器 IP 和时序约束需要按对应工具链替换。
2.3 定点数量化:IIR 绕不过去的第一道坎
IIR 和 FIR 最大的区别在于反馈。FIR 没有反馈,量化误差不会累积;IIR 有反馈,误差会在一轮一轮的乘加中滚雪球。所以定点数量化在 IIR 里不是“优化项”,是“生存项”。
常见做法是:系数用 Q1.15 或 Q2.14 格式,数据通路用更宽的位宽(比如 16 位输入、32 位累加),每个二阶节输出后做一次舍入或截断,再送进下一级。这份工程里,系数量化和位宽分配是需要在 MATLAB 或 Python 里先算好的,Verilog 只负责按固定格式执行。
我一般会先在 Python 里用scipy.signal设计滤波器,拿到二阶节的系数,再手动转成定点,仿真对比浮点和定点的频响,确认偏差在可接受范围内,才写进 Verilog。这一步偷懒,后面上板就是玄学调试。
3. 核心模块拆解:一个二阶节从系数到输出的完整链路
3.1 级联型 IIR 的整体架构
级联型 IIR 的顶层结构很清晰:输入信号依次穿过若干个二阶节,每个节的输出是下一个节的输入。每个二阶节内部是一个标准的 Biquad 差分方程:
y[n] = b0*x[n] + b1*x[n-1] + b2*x[n-2] - a1*y[n-1] - a2*y[n-2]其中a0通常归一化为 1,所以反馈系数是a1和a2。级联的阶数取决于滤波器阶数,一个 N 阶 IIR 通常分解成 N/2 个二阶节(奇数阶会多一个一阶节)。
工程里的模块划分大致是:
iir_cascade_top.v:顶层,实例化多个二阶节并串联biquad.v:单个二阶节的核心实现mul_acc.v:乘法累加单元(可能用 IP 或手写)clk_gen.v:时钟分频或使能生成iir_cascade_tb.v:Testbench
3.2 单个二阶节的 Verilog 实现
下面是一个典型的 Biquad 模块写法,我按工程里常见的风格整理了一版:
module biquad #( parameter DATA_WIDTH = 16, // 输入数据位宽 parameter COEFF_WIDTH = 16, // 系数字宽 parameter ACC_WIDTH = 40 // 累加器位宽 )( input wire clk, input wire rst_n, input wire valid_in, input wire signed [DATA_WIDTH-1:0] x_in, output reg signed [DATA_WIDTH-1:0] y_out, output reg valid_out ); // 系数,实际工程中由外部传入或参数化 parameter signed [COEFF_WIDTH-1:0] B0 = 16'sd8192; parameter signed [COEFF_WIDTH-1:0] B1 = 16'sd16384; parameter signed [COEFF_WIDTH-1:0] B2 = 16'sd8192; parameter signed [COEFF_WIDTH-1:0] A1 = -16'sd24576; parameter signed [COEFF_WIDTH-1:0] A2 = 16'sd8192; // 历史寄存器 reg signed [DATA_WIDTH-1:0] x1, x2; reg signed [DATA_WIDTH-1:0] y1, y2; // 累加器 reg signed [ACC_WIDTH-1:0] acc; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin x1 <= 0; x2 <= 0; y1 <= 0; y2 <= 0; y_out <= 0; valid_out <= 0; end else if (valid_in) begin // 差分方程:b0*x + b1*x1 + b2*x2 - a1*y1 - a2*y2 acc = B0 * x_in + B1 * x1 + B2 * x2 - A1 * y1 - A2 * y2; // 截位回 DATA_WIDTH,带舍入 y_out <= acc[ACC_WIDTH-1 - (COEFF_WIDTH-DATA_WIDTH) : ACC_WIDTH - (COEFF_WIDTH-DATA_WIDTH)]; // 更新历史 x2 <= x1; x1 <= x_in; y2 <= y1; y1 <= y_out; valid_out <= 1'b1; end else begin valid_out <= 1'b0; end end endmodule这段代码有几个关键点需要说明:
参数说明:DATA_WIDTH是输入数据位宽,通常 12 到 16 位;COEFF_WIDTH是系数字宽,Q1.15 格式下是 16 位;ACC_WIDTH是累加器位宽,要足够容纳所有乘积之和,一般取 40 位左右。
系数定义:B0、B1、B2是前馈系数,A1、A2是反馈系数。注意反馈系数在差分方程里是减号,所以代码里写的是- A1 * y1。实际工程中这些系数不会硬编码,而是通过参数或寄存器配置传入。
截位逻辑:acc是高位宽的累加结果,需要截回DATA_WIDTH。截位位置取决于系数的 Q 格式。如果系数是 Q1.15,乘积是 Q2.30,累加后需要右移 15 位再截断。代码里用了一个简化的截位表达式,实际工程中建议加舍入(rounding)而不是直接截断,否则会引入直流偏置。
时序:每个时钟周期处理一个样本,valid_in和valid_out做握手。如果采样率远低于时钟频率,可以用使能信号控制,不必每个周期都算。
3.3 级联顶层与位宽管理
顶层模块把多个biquad串起来,每个节的输出接下一个节的输入。这里最容易翻车的地方是位宽管理:如果每个节都截回 16 位,级联多了之后量化噪声会累积;如果中间不截位,位宽会一级一级膨胀。
常见做法是:每个二阶节输出截回DATA_WIDTH,但在截位前做饱和处理(saturation),防止溢出。饱和逻辑比截断多几个比较器,但能避免大信号时的wrap-around噪声。
module iir_cascade_top #( parameter NUM_STAGES = 4, parameter DATA_WIDTH = 16 )( input wire clk, input wire rst_n, input wire valid_in, input wire signed [DATA_WIDTH-1:0] x_in, output wire signed [DATA_WIDTH-1:0] y_out, output wire valid_out ); // 中间信号:每个节的输出 wire signed [DATA_WIDTH-1:0] stage_out [0:NUM_STAGES]; wire stage_valid [0:NUM_STAGES]; assign stage_out[0] = x_in; assign stage_valid[0] = valid_in; // 生成多个二阶节并串联 genvar i; generate for (i = 0; i < NUM_STAGES; i = i + 1) begin : cascade biquad #( .DATA_WIDTH(DATA_WIDTH) ) u_biquad ( .clk (clk), .rst_n (rst_n), .valid_in (stage_valid[i]), .x_in (stage_out[i]), .y_out (stage_out[i+1]), .valid_out (stage_valid[i+1]) ); end endgenerate assign y_out = stage_out[NUM_STAGES]; assign valid_out = stage_valid[NUM_STAGES]; endmodulegenerate 块:用genvar和generate循环实例化多个二阶节,这是 Verilog 里做级联的标准写法。NUM_STAGES决定滤波器阶数,4 个二阶节就是 8 阶 IIR。
中间信号数组:stage_out和stage_valid是数组,索引 0 是输入,索引NUM_STAGES是最终输出。每个节的输出直接接下一个节的输入,没有额外的缓冲。
位宽一致性:所有节的DATA_WIDTH保持一致,这样级联时不需要额外的位宽转换。如果你想让中间节用更宽的位宽,需要在节与节之间加转换逻辑,工程复杂度会上升。
3.4 Testbench 与仿真验证
Testbench 的作用是给滤波器喂一组已知信号,看输出是否符合预期。常见做法是生成一个正弦扫频信号,或者直接读一个音频文件的数据。
module iir_cascade_tb; reg clk; reg rst_n; reg valid_in; reg signed [15:0] x_in; wire signed [15:0] y_out; wire valid_out; // 实例化被测滤波器 iir_cascade_top #( .NUM_STAGES(4), .DATA_WIDTH(16) ) u_dut ( .clk (clk), .rst_n (rst_n), .valid_in (valid_in), .x_in (x_in), .y_out (y_out), .valid_out (valid_out) ); // 时钟生成 initial clk = 0; always #5 clk = ~clk; // 100MHz // 激励:生成一个正弦波 integer i; real pi = 3.1415926; initial begin rst_n = 0; valid_in = 0; x_in = 0; #100; rst_n = 1; #20; for (i = 0; i < 1000; i = i + 1) begin @(posedge clk); valid_in = 1; // 生成 1kHz 正弦波,假设采样率 48kHz x_in = $rtoi(20000.0 * $sin(2.0 * pi * 1000.0 * i / 48000.0)); end valid_in = 0; #1000; $finish; end endmodule激励生成:用$sin生成正弦波,幅度控制在 20000 左右,避免溢出。采样率假设 48kHz,信号频率 1kHz,这样能看出滤波器的通带和阻带行为。
验证方法:仿真跑完后,把y_out的数据导出,在 Python 或 MATLAB 里做 FFT,对比理论频响。如果阻带衰减不够或者通带纹波太大,说明系数量化有问题,需要回去调整 Q 格式或增加位宽。
注意:Testbench 里的
$sin和$rtoi是仿真系统函数,不可综合,只用于验证。实际工程中激励数据通常从文件读取,避免仿真器差异。
4. 避坑与排查:级联型 IIR 上板前必须过的几道关
4.1 输出全是毛刺,仿真却正常
现象:仿真波形干净,上板后输出信号上叠加了高频毛刺,示波器看像是时钟串扰。
原因:最常见的是时序约束没写全,或者乘法器 IP 的流水线级数没对齐。IIR 的反馈回路对时序特别敏感,如果y1、y2的更新路径上有组合逻辑延迟,反馈就会在错误的时间点采样。
解决:检查综合报告里的时序裕量,确保反馈路径的建立时间满足要求。如果不够,在乘法器后加一级流水线寄存器,或者降低时钟频率。另外,确认valid_in和valid_out的握手逻辑没有组合环路。
4.2 低频段噪声大,高频反而正常
现象:滤波器在低频段输出噪声明显,高频段反而干净。
原因:系数量化导致极点偏移。级联型虽然比直接型稳定,但如果二阶节的极点本身靠近单位圆,Q1.15 的精度可能不够,极点位置偏移后低频增益发生变化。
解决:把系数字宽从 16 位增加到 18 位或 20 位,或者改用 Q2.14 格式给整数部分留更多余量。另一个办法是重新设计滤波器,把极点稍微往单位圆内移一点,牺牲一点过渡带陡度换稳定性。
4.3 级联后输出饱和,波形削顶
现象:单节测试正常,级联后输出出现削顶,像是溢出。
原因:中间节点的信号幅度没有控制好。每个二阶节都有一定的增益,级联后总增益可能大于 1,导致中间节点或最终输出溢出。
解决:在 MATLAB 或 Python 里计算每个二阶节的增益,必要时在节与节之间加衰减因子(比如右移一位),或者调整系数的归一化方式。另外,截位逻辑要加饱和处理,不要直接截断。
4.4 仿真通过,综合报错“不可综合”
现象:仿真跑得好好的,综合时提示某些语句不可综合。
原因:Testbench 里用了$sin、$rtoi这类系统函数,或者real类型。这些只能用于仿真,不能出现在可综合模块里。
解决:把激励生成部分和被测模块严格分开。可综合模块里只用reg、wire、always、assign这些基本构造。如果需要初始化系数,用参数或initial块(部分 FPGA 支持)。
4.5 上板后输出延迟不对
现象:输出信号比预期晚了几个时钟周期,或者valid_out的时序和y_out对不上。
原因:每个二阶节都有一级寄存器延迟,级联 N 个节就是 N 个周期延迟。如果valid_out没有跟着延迟,数据对齐就会出错。
解决:在顶层用移位寄存器对valid_in做同样的延迟,或者用valid信号逐级传递(像上面代码里那样)。验证时把valid_out和y_out一起看,确保它们在同一个周期有效。
5. 二次开发与改造:从固定系数到可配置滤波器
5.1 把系数从硬编码改成寄存器配置
工程里如果系数是写死的,二次开发的第一步就是把它变成可配置的。常见做法是加一个 AXI4-Lite 从接口,用寄存器映射系数。每个二阶节需要 5 个系数(B0、B1、B2、A1、A2),4 个节就是 20 个寄存器,加上控制寄存器,地址空间不大,用 AXI4-Lite 足够。
改造的时候注意:系数更新最好在valid_in为低的时候进行,避免在滤波过程中切换系数导致输出跳变。如果必须动态切换,加一个双缓冲机制,等当前样本处理完再加载新系数。
5.2 用 Python 生成系数并导出 Verilog 参数
我一般会写一个 Python 脚本,用scipy.signal.iirfilter设计滤波器,拿到b、a系数后,用tf2sos转成二阶节形式,再量化成定点,最后生成一个 Verilog 头文件或参数列表。
import numpy as np from scipy import signal # 设计一个 8 阶低通 Butterworth,截止频率 0.2*Nyquist b, a = signal.iirfilter(8, 0.2, btype='low', ftype='butter') # 转成二阶节 sos = signal.tf2sos(b, a) # 量化成 Q1.15 def quantize_q15(x): return np.round(x * 32768).astype(int) for i, section in enumerate(sos): b0, b1, b2, a0, a1, a2 = section print(f"// Stage {i}") print(f"parameter B0_{i} = 16'sd{quantize_q15(b0)};") print(f"parameter B1_{i} = 16'sd{quantize_q15(b1)};") print(f"parameter B2_{i} = 16'sd{quantize_q15(b2)};") print(f"parameter A1_{i} = 16'sd{quantize_q15(a1)};") print(f"parameter A2_{i} = 16'sd{quantize_q15(a2)};")这个脚本的输出可以直接贴进 Verilog 的参数定义里。注意a0通常归一化为 1,所以量化时不用管。量化后建议在 Python 里再做一次频响对比,确认定点版本的频响和浮点版本偏差在可接受范围内。
5.3 验证改造效果:从仿真到上板的检查清单
改造完之后,别急着上板。我一般会走一遍这个清单:
| 检查项 | 方法 | 通过标准 |
|---|---|---|
| 定点频响 | Python 对比浮点和定点 | 通带纹波 < 0.5dB,阻带衰减符合设计 |
| 单节仿真 | Testbench 喂正弦 | 输出无削顶,延迟符合预期 |
| 级联仿真 | 多节串联 | 总频响符合设计,无溢出 |
| 时序裕量 | Vivado 时序报告 | 建立时间裕量 > 0.5ns |
| 上板测试 | 信号发生器 + 示波器 | 输出波形干净,频响与仿真一致 |
这个清单看起来啰嗦,但每一步都能挡住一类问题。尤其是定点频响对比,很多人跳过这一步,直接上板,结果就是开头说的那种玄学调试。
5.4 一个具体技巧:用使能信号代替时钟分频
IIR 滤波器通常不需要每个时钟周期都处理样本。如果采样率是 48kHz,时钟是 100MHz,那大部分时钟周期都是空闲的。常见做法是用一个使能信号(enable)控制,而不是分频出一个低频时钟。
用使能信号的好处是:整个设计仍然在同一个时钟域下,时序约束简单,不用处理跨时钟域问题。使能信号的生成可以用一个计数器,计数到clk_freq / sample_rate时拉高一个周期。
// 使能信号生成:每 N 个时钟周期产生一个采样使能 reg [15:0] cnt; reg sample_en; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 0; sample_en <= 0; end else begin if (cnt == CLK_DIV - 1) begin cnt <= 0; sample_en <= 1'b1; end else begin cnt <= cnt + 1; sample_en <= 1'b0; end end endCLK_DIV是时钟频率除以采样率。这个使能信号接到每个二阶节的valid_in,控制它们只在需要的时候更新。这样既省功耗,又避免了对时钟网络的折腾。
从那以后我每次做 IIR 滤波器,都强制走一遍“Python 定点仿真 → 单节 Testbench → 级联 Testbench → 时序检查 → 上板”这个流程,再也不敢跳过任何一步。希望这份拆解能帮到你,少走一点我当年走过的弯路。
本文还有配套的精品资源,点击获取