简介面向FPGA数字信号处理进阶学习者的级联型IIR滤波器Verilog设计资源包覆盖二阶节分解、系数生成到Vivado工程落地的完整链路适合有一定Verilog基础、希望掌握IIR滤波器硬件化方法的开发者。资源共82个文件7z压缩包仅223KB主要内容为Vivado工程与Verilog源码、仿真波形、MATLAB系数脚本和说明文档工程内CascadeIIR_liuqi按四个二阶节拆分FirstTap至FourthTap模块便于逐级理解级联结构。配套测试平台与仿真结果可验证频率响应和稳定性SinIn.txt提供输入测试样例综合实现报告则有助于分析资源占用与时序收敛。整个包体结构清晰既可作为级联IIR设计的入门参照也能直接用于工程实验或二次开发。已有2541人学习适合音频、通信等需要落地IIR滤波算法的FPGA工程师参考。 做FPGA数字信号处理的人都知道IIR滤波器这关迟早要过。FIR虽然稳定、好实现但同样阻带指标下阶数是IIR的几倍甚至几十倍资源开销吃不消。IIR效率高可一上FPGA问题就来了反馈结构、有限字长、极点位置任何一个没处理好滤波器要么发飘要么干脆振荡。这篇文章是《FPGA数字信号处理》系列的第七篇专门聊级联型IIR滤波器的Verilog实现。我会先讲清楚为什么选级联结构再给出完整架构、二阶节核心代码、级联顶层、testbench以及调试经验适合已经把FIR跑通、想挑战IIR的FPGA开发者也适合做音频、通信、传感器信号处理的工程师用来快速落地。1. 为什么IIR滤波器要采用级联型结构1.1 直接型结构的软肋先看最简单的直接I型。一个N阶IIR滤波器的差分方程是y[n] b0x[n] b1x[n-1] ... bNx[n-N] - a1y[n-1] - ... - aNy[n-N]直接把这个公式翻译成Verilog最多就是一堆乘法器和延迟链看起来没什么问题。但实际工程里阶数超过4阶以后直接型会暴露出两大毛病。第一个是系数量化敏感。FPGA里做不了浮点系数要量化成定点数。高阶直接型的分母多项式系数和极点位置之间是非线性映射系数差1个LSB极点就可能偏移很大。尤其是多个极点聚集在一段频带里的时候量化误差会被放大滤波器的幅频响应和理论设计差得很远严重时极点直接跑到单位圆外系统就变成振荡器了。我做过一个6阶的直接型低通仿真时一切正常换成18bit定点系数后阻带抬高了快30dB当时排查了好久才想到是系数敏感度的问题。第二个是动态范围难控制。IIR有反馈中间状态值可能比输入大很多。直接型结构里各级信号混在一起你很难判断哪一级溢出。溢出一次状态量被截断后面输出全乱。你总不能在每一级乘法器后面都加饱和逻辑那样电路又大又难调。级联型就绕开了这两个问题。思路朴素把高阶传递函数因式分解成若干个二阶节的乘积每个二阶节独立实现、独立控制动态范围、独立检查稳定性。极点被拆开之后每个二阶节的极点分布变得分散系数量化对极点位置的影响也小得多。1.2 级联型结构的优势与系数设计流程级联型的系统函数长这样H(z) H1(z) * H2(z) * ... * HM(z)每一级都是标准二阶节SOS形式是Hk(z) (b0k b1k z^-1 b2k z^-2) / (1 a1k z^-1 a2k z^-2)整个滤波器就是M个二阶节串起来。数据的流向是输入x先进第一级第一级输出作为第二级输入依此类推最后一级的输出就是整个滤波器的输出。用MATLAB做系数设计非常方便。我的习惯是先用fdatool或者designfilt把滤波器设计出来得到传递函数B/A再用tf2sos转成级联型。一个4阶滤波器会得到2行SOS一个6阶就是3行依次类推。fs 48000; [b, a] butter(8, 5000/(fs/2)); % 示例8阶巴特沃斯低通 [sos, g] tf2sos(b, a);sos矩阵的每一行格式是[b0, b1, b2, 1, a1, a2]这里要特别小心符号问题。MATLAB里的a1、a2是分母多项式系数差分方程里用的是减法也就是y[n] b0x[n] b1x[n-1] b2x[n-2] - a1y[n-1] - a2y[n-2]Verilog代码里一定不能把符号写反。sos矩阵的系数通常不是完全归一化的第一级会带一个总的增益g。增益处理有个常见做法把增益分摊到每一级或者只乘在第一级输入上目的是让每一级中间节点的信号幅度在合理范围内防止中间级溢出。fdatool里可以设置缩放方式工程上我一般选Passband缩放得到的系数直接用省得再手动调。拿到SOS矩阵后下一步是定点量化。系数是小数需要用定点表示。比如用18bit有符号数表示[-1,1)范围就是Q1.17格式乘以2^17后取整。注意有些二阶节的a1会超过1这时候要么扩大定点格式要么把整级系数整体缩小并补偿增益这一步要检查max(abs(sos(:)))。cscale 2^17; sos_q round(sos * cscale); disp(max(abs(sos_q(:)))); % 确认不超过2^17-1提示量化后务必检查稳定性。对每一行sos计算roots([1 a1 a2])所有根的模必须小于1。这一步几百个采样点就能看出来但很多人图省事跳过结果板子上一跑输出乱飞。2. FPGA实现前的架构决策与位宽规划2.1 三种实现架构怎么选级联型IIR在FPGA里的实现架构归纳下来有三种全串行、全并行、每级并行内部串行。全串行是指整个滤波器共用一个或少数几个乘法器按时间顺序依次计算每一级。资源最省但吞吐率低每算一个输出采样要M个时钟周期。适合采样率几十kHz到几百kHz的场合比如音频信号处理、低频振动监测。全并行是每一级分配独立的乘法器和寄存器数据像流水线一样一级一级往下走。吞吐率等于时钟频率灵活、代码直观缺点是资源占得多。一个N阶IIR滤波器大约要5*(N/2)个乘法器8阶就要20个。对多数中端FPGA来说20个DSP48还能接受。每级并行内部串行是折中方案每级内部时分复用乘法器先算反馈部分再算前馈部分比全并行省资源又比全串行快。代价是控制逻辑复杂需要状态机和数据有效信号对齐。从工程落地角度我建议初学者先做全并行。为什么IIR滤波器本身级数少4到8阶居多20个乘法器对FPGA来说算不了什么。架构简单就意味着好验证你先把功能跑通把量化、饱和这些核心问题吃透再来做资源优化。上来就搞时分复用控制逻辑写两三百行出个bug连仿真波形都看不懂。三种架构的区别可以用下面的表快速对比架构乘法器数吞吐率控制复杂度适用场景全串行1个复用每样点M拍低低速信号、资源受限每级复用5个左右每级内部串行中中速信号、面积与速度兼顾全并行5*M个每拍1个样点低高速信号、实时流处理2.2 定点量化和位宽设计位宽规划是整个IIR设计里最容易被低估的部分。很多第一次做的人只把输入输出位宽留好中间级和状态变量随便给个宽度结果滤波效果不对查半天发现是内部乘法器截位截出了问题。我常用的位宽分配策略是输入信号16bit系数18bit。16bit乘18bit的乘积是34bit这个精度是底子一般不再往下砍。状态变量是递归量在IIR里会反复参与后续运算必须留足余量建议用40bit。也就是说w[n] x[n] - a1w[n-1] - a2w[n-2]这一步的累加结果至少保留40bit。每一级输出处在送往下一级之前做饱和截位输出的位宽建议保留24bit。最后一级再根据系统需求截到16bit或24bit。为什么中间级不能直接截到16bit因为每截一次位就引入一次量化噪声级联起来噪声会逐级累积信号信噪比下降很快。中间级保留24bit噪声底要低得多。这种“宽状态变量、逐级饱和截位”的做法本质上是用FPGA的寄存器资源换动态范围。IIR反馈结构决定了中间值天然会比输入大给足位宽再饱和是兼顾性能和稳定性的稳妥方案。实际项目中40bit状态变量加24bit中间级输出基本能满足大多数信号处理系统的需求。3. 二阶节的Verilog实现与级联顶层3.1 基于直接II型的SOS核心模块二阶节的实现方式有好几种业界常用的有直接I型、直接II型、转置直接II型。我推荐用直接II型原因是它的差分方程直观状态变量和输出解耦后面做流水线优化也方便。直接II型的公式是w[n] x[n] - a1w[n-1] - a2w[n-2] y[n] b0w[n] b1w[n-1] b2*w[n-2]先算反馈部分得到中间变量w再由w线性组合得到输出。这样反馈回路里只有w参与递归结构清晰状态变量就是w的两个延迟寄存器。下面是一个完整的二阶节模块代码系数位置留了注释实际使用时替换成fdatool算出来的值。module iir_sos #( parameter DIN_W 16, parameter DOUT_W 24, parameter COEF_W 18, parameter ACC_W 40 )( input clk, input rst_n, input data_valid_in, input signed [DIN_W-1:0] din, output reg signed [DOUT_W-1:0] dout, output reg data_valid_out ); // 系数由MATLAB fdatool生成后填到这里 // 格式b0 b1 b2, a1 a2注意差分方程中是减法 localparam signed [COEF_W-1:0] B0 18sd0; // TODO: 替换为实际量化系数 localparam signed [COEF_W-1:0] B1 18sd0; localparam signed [COEF_W-1:0] B2 18sd0; localparam signed [COEF_W-1:0] A1 18sd0; localparam signed [COEF_W-1:0] A2 18sd0; // 饱和上下限位宽对齐到ACC_W localparam signed [ACC_W-1:0] SAT_MAX {{(ACC_W-DOUT_W){1b0}}, {DOUT_W{1b1}}}; localparam signed [ACC_W-1:0] SAT_MIN {{(ACC_W-DOUT_W){1b1}}, {DOUT_W{1b0}}}; // 状态变量保留宽位宽 reg signed [ACC_W-1:0] w1, w2; // 反馈部分 wire signed [COEF_WACC_W-1:0] mul_a1 A1 * w1; wire signed [COEF_WACC_W-1:0] mul_a2 A2 * w2; wire signed [ACC_W-1:0] w_next din - mul_a1[ACC_W-1:0] - mul_a2[ACC_W-1:0]; // 前馈部分 wire signed [COEF_WACC_W-1:0] mul_b0 B0 * w_next; wire signed [COEF_WACC_W-1:0] mul_b1 B1 * w1; wire signed [COEF_WACC_W-1:0] mul_b2 B2 * w2; wire signed [ACC_W-1:0] y_full mul_b0[ACC_W-1:0] mul_b1[ACC_W-1:0] mul_b2[ACC_W-1:0]; always (posedge clk or negedge rst_n) begin if (!rst_n) begin w1 d0; w2 d0; dout d0; data_valid_out 1b0; end else if (data_valid_in) begin w1 w_next; w2 w1; dout y_full[DOUT_W-1:0]; // 这里用饱和替代直接截位 data_valid_out 1b1; end else begin data_valid_out 1b0; end end endmodule代码里乘法结果的位宽我写成了COEF_WACC_W赋值给ACC_W时取了低40位。这种做法在系数和状态量都有界的前提下是够用的但正式的工程代码里最好把乘法结果完整保留在加法器输入端再做对齐避免精度损失。模块里的data_valid信号是IIR设计里很重要的一环。data_valid_in有效时模块完成一次运算输出在下一个时钟沿更新同时data_valid_out拉高告诉下一级数据可用。IIR不像FIR可以随时灌数据它依赖时序关系valid信号如果漏打一拍级联后数据流就错位了。3.2 顶层级联展开与Data Valid处理有了二阶节模块顶层要做的事情就是按顺序把M个二阶节串起来。这里用generate循环例化方便调整级数。module iir_cascade #( parameter STAGE_N 4, // 二阶节数量 parameter DIN_W 16, parameter DOUT_W 24 )( input clk, input rst_n, input data_valid_in, input signed [DIN_W-1:0] din, output signed [DOUT_W-1:0] dout, output data_valid_out ); wire signed [DOUT_W-1:0] stage_din [0:STAGE_N]; wire signed [DOUT_W-1:0] stage_dout [0:STAGE_N-1]; wire stage_valid [0:STAGE_N]; assign stage_din[0] din; assign stage_valid[0] data_valid_in; genvar i; generate for (i 0; i STAGE_N; i i 1) begin : u_stage iir_sos #( .DIN_W (DOUT_W), .DOUT_W (DOUT_W) ) u_iir_sos ( .clk (clk), .rst_n (rst_n), .data_valid_in (stage_valid[i]), .din (stage_din[i]), .dout (stage_dout[i]), .data_valid_out (stage_valid[i1]) ); end for (i 0; i STAGE_N-1; i i 1) begin : u_conn assign stage_din[i1] stage_dout[i]; end endgenerate assign dout stage_dout[STAGE_N-1]; assign data_valid_out stage_valid[STAGE_N]; endmodule顶层代码看起来简单但有两个细节要注意。第一个是每一级的输入位宽。第二级开始输入不再是原始的16bit而是上一级输出的24bit。所以例化二阶节时DIN_W参数要改成DOUT_W。如果你把所有级都写成16bit输入中间级的24bit输出被截成16bit动态范围优势就全没了。第二个是valid信号的清零。每一级在data_valid_in无效时data_valid_out必须拉低否则valid一直高电平下一级会把无效数据当成有效数据来处理。很多新手在这里丢拍结果输出波形整体延迟和预期不符。3.3 饱和截位最容易翻车的地方为什么单独把饱和截位拎出来讲因为我在调试中见过太多输出波形在接近满幅时突然反向的情况。举个例子。40bit的有符号数截位到24bit最直接的做法是取低24位。但如果40bit数超出了24bit能表示的范围低24位就会变成溢出后的错误值正数溢出会变成负数波形上表现就是正弦波顶部被切掉一块然后直接跳到负半周。你在时域上看那已经不是“失真”而是完全乱掉。这个现象用Modelsim仿真时特别容易看到。正确的做法是先饱和再截位。判断逻辑很朴素如果累加结果大于24bit能表示的最大值就输出最大值小于最小值就输出最小值在范围内才取低24位。我的代码里用SAT_MAX和SAT_MIN实现了这一点实际使用中可以把这部分写成function多个模块共用。另一个容易忽略的点是状态变量w1、w2不要做饱和截位。它们参与递归如果饱和相当于在反馈环路里插入了一个非线性环节会改变系统的瞬态行为严重时引入极限环振荡。状态变量要的是“给足位宽、不截位”输出处才做饱和。4. 仿真验证ModelSim与MATLAB联动4.1 激励生成与Testbench设计IIR滤波器仿真和FIR有个明显区别不能只给一个脉冲响应看波形。IIR有反馈暂态过程长需要给连续激励并且要把输出数据拉回到MATLAB做频响对比才能判断滤波效果是否满足指标。我的标准做法是先用MATLAB生成一段混合频率的正弦信号作为输入激励写到文本文件ModelSim读这个文件驱动DUT仿真结束后把输出写入另一个文本文件最后用MATLAB对比输入输出频谱。testbench的核心代码如下module tb_iir; reg clk 0; reg rst_n 0; reg valid_in 0; reg signed [15:0] din 0; wire signed [23:0] dout; wire valid_out; integer fid_in, fid_out; always #5 clk ~clk; initial begin fid_in $fopen(stimulus.txt, r); fid_out $fopen(result.txt, w); #30 rst_n 1; valid_in 1; repeat(20000) begin (posedge clk); $fscanf(fid_in, %d, din); end valid_in 0; #100; $fclose(fid_in); $fclose(fid_out); $stop; end always (posedge clk) begin if (valid_out) $fwrite(fid_out, %d\n, dout); end iir_cascade #( .STAGE_N(4), .DIN_W(16), .DOUT_W(24) ) dut ( .clk(clk), .rst_n(rst_n), .data_valid_in(valid_in), .din(din), .dout(dout), .data_valid_out(valid_out) ); endmodule这里的激励文件每行一个整数范围是-32768到32767对应16bit有符号数。注意$fscanf的时机是每个有效时钟沿先读一个数再驱动din也就是说din的变化会滞后一拍这个现象在频率高的时候无关紧要但在验证流水线时序时需要留意。验证时我建议分两步。第一步先做“直通测试”把输入直接接到输出或者只让一个二阶节工作在b01、a0的状态确认testbench数据通路本身没问题。第二步再做真正的滤波验证。这样能隔离testbench的bug和滤波器本身的bug。4.2 数据回写与频响对比仿真结束后result.txt里的数据要拉回MATLAB分析。简单粗暴的做法是直接FFT对比输入输出频谱但前提是输入信号要精心设计。我常用的激励是叠加多个单频正弦频率分别落在通带和阻带。这样在频谱图上可以直观看到通带保留、阻带衰减了多少dB。代码大概是这样data_out importdata(result.txt) / 2^23; data_in importdata(stimulus.txt) / 2^15; nfft 8192; win hann(nfft, periodic); [px_in, f] pwelch(data_in, win, [], nfft, 48000); [px_out, ~] pwelch(data_out, win, [], nfft, 48000); plot(f, 20*log10(px_in), f, 20*log10(px_out)); legend(输入频谱, 输出频谱);注意把输出数据除以2^23因为DOUT_W是24bit有符号数。如果输入输出归一化基准不一致比较幅度时会差出一个固定增益干扰判断。有时候你会看到输出频谱和MATLAB理想仿真对不上尤其是高频段出现不该有的抬升。这种情况十有八九是系数量化精度不够或者中间级饱和限幅引入了高次谐波。先回头查系数位宽再查各级中间信号有没有顶到SAT_LIMIT。如果是溢出把中间级DOUT_W从24bit加到28bit基本能压下去。5. 常见问题与调试经验5.1 五个高频问题的定位思路做IIR滤波器我几乎每次都会被下面这几个问题缠上。整理成表方便排查。现象可能原因排查方法输出恒为0valid信号没打拍状态变量始终不更新检查data_valid_out时序对比输入输出valid延迟输出波形有明显毛刺/正负翻转乘法器结果位宽不够溢出后截位加宽乘法结果位宽检查每一级y_full输出频谱通带增益不对系数符号写反尤其a1、a2的减法拉出sos矩阵逐项核对差分方程滤波器输出慢慢发散量化后极点跑到单位圆外MATLAB检查roots降低阶数或提高系数位宽时域正确但阻带衰减不够系数位宽太低量化噪声抬高了噪声底系数从18bit提到24bit或优化SOS节排序最让我印象深刻的是一次“输出波形正常但频谱怎么看都不对”的案例。滤波器是4阶级联仿真时通带增益平坦阻带也有衰减但输入一个10kHz正弦时输出幅度比理论值大了几个dB。查到最后发现是fdatool导出的SOS矩阵里某一行有负数增益而我在顶层没有处理每一级增益伸缩。IIR级联的增益分配不是可有可无的它直接影响中间节点动态范围最终影响输出幅度。另一个高频问题是verilog里signed乘法位宽写错。比如写了wire [39:0] mul_a1 A1 * w1但A1和w1都是signed乘法结果却被当成无符号数负数相乘的结果直接错掉。Verilog里signed乘法要求两个操作数和赋值变量都要显式声明signed这个细节踩过的人都知道没踩过的迟早会踩。5.2 在线调试的实用技巧上板调试IIR滤波器最实用的工具就是ILA集成逻辑分析仪。但ILA不能直接看频谱所以我的经验是分步骤验证。第一步输入单频正弦比如10kHz用ILA抓取输出时域波形。先看幅度是否正常相位是否连续有没有明显的截顶或跳变。这个观察比看FFT快得多能迅速暴露饱和和符号问题。第二步做“单级使能”。在顶层加一个测试模式信号可以把某个二阶节旁路掉。做法很简单通过一个MUX把该级的输入直接接到输出同时仍然让模块时钟运行、valid正常透传。这样可以快速定位是第几级出了问题不用逐级拉内部信号出来看。第三步确认无溢出后再用串口或以太网把滤波后的数据传回上位机做FFT分析。在线频谱分析通常用稀疏FFT够用不用追求高分辨率。注意IIR滤波器是有反馈的上板调试时千万不要用那种“改一版代码跑一次”的调法。每次修改系数、位宽或者架构先在仿真里复现再上板。因为IIR的时序错误不像FIR那样直观仿真能看到的诡异现象在板上只会更诡异。最后再分享一个我个人的工程习惯不要把Verilog代码直接当设计文档。我在实现级联型IIR之前一定先在纸上画出数据流图把每一级的输入位宽、输出位宽、状态变量位宽、系数来源、valid方向全部标出来。代码只是这张图的“翻译”。这样做的好处是顶层例化时不会因为少了一个连接或者错了一拍valid而返工。级联型IIR本身不算难难的是数据流和位宽在级联过程中别断。先画图、再写代码、后仿真这个流程走顺了IIR滤波器在FPGA上落地基本就是一天的事。本文还有配套的精品资源点击获取