资讯动态

FPGA上的IIR数字滤波器设计:从系数计算到RTL实现与工程实践

发布时间:2026/10/3 10:15:54 来源:尧图企业网站定制
做FPGA数字信号处理这块逃不开数字滤波器。带通、低通、去噪、抽取、插值、锁相环环路滤波哪哪都有它的影子。我最早接触“基于FPGA的IIR数字滤波器设计”是在一个音频信号预处理项目里当时实时性要求很高ARM上软算顶不住只能把滤波算法下沉到FPGA上硬算。做完之后回头再看IIR滤波器本身不复杂但它牵扯到的结构选择、定点量化、时序约束、仿真对标这些问题才是真正决定一个工程能不能稳定跑起来的关键。这篇东西我打算从为什么要用FPGA做IIR讲起把滤波器系数怎么算、怎么量化、RTL怎么写、怎么仿真、上板会遇到什么坑一条线完整过一遍。适合两类人看一类是想系统入门FPGA数字信号处理的同学另一类是已经写过一点RTL、但没实际做过滤波器的工程师。看完至少知道整套流程该怎么走哪些环节容易翻车。1. 项目背景与整体方案选择1.1 为什么在FPGA上做数字滤波器很多人一上来就问数字滤波器用DSP或者ARM做不就行了为什么非得上FPGA这个问题我实际做完项目之后才有更深的理解。DSP和ARM跑滤波器本质是串行执行。一条指令算一次乘加就算有SIMD或者MAC指令说白了还是在同一个ALU里排队。采样率一旦上去比如中频信号几十上百兆采样率每个采样点要算几十个乘加指令周期数量级就对不上了。FPGA不一样它天然是空间并行的一个时钟周期可以把所有乘法器、加法器同时拉起来跑流水线一铺开每个时钟周期出一个滤波结果这种吞吐率是串行处理器很难比的。另一个关键因素是确定性延迟。ARM跑实时滤波中断、Cache缺失、总线仲裁都会造成抖动的毛刺放在通信链路里可能就是灾难。FPGA是硬件逻辑从采样数据进来到滤波结果出去延迟是固定且可预先计算的周期数。这个特性在做环路控制、数据同步、多通道一致性要求高的场景里非常有价值。讲到这我得说句实话FPGA做数字滤波器的门槛不在“会写滤波器的公式”而在于你具备把算法转换成硬件数据通路的思维。这和数码管动态显示那种状态机思路完全不同一个是“事件驱动”一个是“数据流驱动”。一旦你从这种数据通路的视角去看问题后面的路会顺很多。1.2 为什么选IIR而不是FIRIIR和FIR怎么选是每一个做数字滤波器的人都要面对的经典问题。我做这个项目时也仔细权衡过后来选了IIR核心原因就四个字效率优先。IIR滤波器全称无限脉冲响应滤波器核心特征是它的输出不仅依赖于输入还依赖于过去的输出也就是存在反馈。IIR只要用较低阶数就能达到比较陡的过渡带。做个低通滤波器FIR想要做到同等阻带衰减和过渡带宽度动不动就要上百阶而IIR四阶八阶就能打住。在FPGA里每一阶几乎对应一组乘加器和寄存器高阶FIR的资源消耗是很扎眼的。如果你要在一片不大不小的FPGA里同时处理多路信号、并行跑多个滤波器IIR在资源上的优势就更明显了。IIR也有它的短板。一个是相位非线性IIR在通带内相位响应是弯曲的如果信号对相位敏感比如某些解调链路就不能直接用得用Bessel滤波器或者加全通校正。另一个是稳定性问题因为存在反馈一旦系数量化不当或者输入溢出容易产生极限环甚至振荡。这两个问题在后面的设计里都要重点处理。做这个取舍的时候我的经验是如果你对相位没有硬性要求、资源又紧张那就优先IIR如果信号是脉冲波形、对线性相位有要求那就老老实实上FIR。别抱着一个选型走到黑不同场景选型逻辑完全不同。1.3 系统总体架构与设计指标项目一开始先别急着写代码把需求指标定清楚。我这里定的指标是采样率48kHz低通滤波器截止频率10kHz阻带衰减大于40dB通带纹波小于1dB。这个指标设定源自音频信号处理里非常典型的“去掉高频噪声”场景既能体现IIR的选型价值又不会难到系数算不出来。系统架构上我把它拆成几个大块数据采集/输入接口负责把ADC采样的数据或者PC下发的测试数据整理成连续的数据流送进滤波器滤波核心由两个级联的二阶节组成每个二阶节完成一次IIR递归运算参数配置与系数存储把量化后的系数固化成常量或者通过寄存器配置动态更新输出接口把滤波结果送出去给后续模块或上位机显示。其中滤波核心是灵魂其它都是外围。你不需要一上来就把整个系统设计得多复杂先把核心滤波通路跑通再往上面套接口工程节奏会更舒服。2. 滤波器系数设计从指标到二阶节2.1 设计指标与原型选择IIR滤波器的系数不是拍脑袋定的一般走“模拟原型离散化”这条路。典型的模拟原型有巴特沃斯、切比雪夫I型/II型、椭圆滤波器。它们各有特点。巴特沃斯滤波器最大优点是通带内没有纹波幅频特性单调代价是过渡带相对宽要用更高阶数才能达到某个阻带衰减。切比雪夫I型在通带内有等波纹但过渡带更窄同阶数下阻带衰减比巴特沃斯大。椭圆滤波器性能最猛通带阻带都有纹波过渡带极窄代价是相位响应更差设计也更复杂。我在这个项目里选了巴特沃斯理由是相位特性相对平滑通带单调不容易出现振铃作为第一个FPGA IIR项目非常稳妥。如果你后续做椭圆滤波器方法类似只是系数会难看一些量化容差更小调试难度会更高。另外一个需要明确的概念是阶数。四阶滤波器就是两个二阶节六阶就是三个二阶节。为什么一定要拆成二阶节而不是把整个四阶传递函数直接实现这点在下一节详细说这里先有个概念就行。2.2 双线性变换与系数导出模拟滤波器设计出来是一堆连续的零极点要用在数字系统里必须通过某种映射把它转换到z域。常用方法有冲激响应不变法和双线性变换法。冲激响应不变法会引入频谱混叠不适合设计高通、带阻这种高频段滤波器。双线性变换法用双线性映射把整个s平面映射到z平面单位圆内没有混叠问题代价是频率轴发生非线性压缩所以设计前要做频率预畸变。具体到我们这个指标设计一个四阶巴特沃斯低通滤波器采样率48kHz截止频率10kHz。先算预畸变频率ω公式是ω tan(π × fc / fs) tan(π × 10000 / 48000) ≈ 0.7673四阶巴特沃斯原型可以拆成两个二阶节归一化原型分别是第一个二阶节s² 0.7654s 1第二个二阶节s² 1.8478s 1然后把s (1 - z⁻¹) / (ω(1 z⁻¹))代入每个二阶节整理成z域的传递函数。以第一个二阶节为例代入后得到分母系数a0 1 0.7654ω ω² ≈ 2.1759a1 (-2 2ω²) / a0 ≈ -0.3781a2 (1 - 0.7654ω ω²) / a0 ≈ 0.4603分子系数为b0 ω² / a0 ≈ 0.2705b1 2ω² / a0 ≈ 0.5411b2 ω² / a0 ≈ 0.2705第二个二阶节用同样的方式代入1.8478替换掉0.7654得到一组不同的系数。这就是MATLAB或者Python里butter、tf2sos这些函数背后干的事。你完全可以用下面这两行Python代码直接算出来from scipy.signal import butter, sos sos butter(4, 10000, fs48000, outputsos, btypelow) print(sos)不过我要提醒一句工具能直接给系数不假但如果不理解背后的预畸变过程你会看不懂量化后系数的微小偏移为什么会引起频率响应那么大变化。这块原理不能省。2.3 级联二阶节的拆分与系数表前面已经给出了四阶巴特沃斯低通的两个二阶节浮点系数把参数整理成表格级联节b0b1b2a1a2SOS10.27050.54110.2705-0.37810.4603SOS20.19580.39160.1958-0.27360.0568为什么一定要把高阶传递函数拆成多个二阶节级联而不是用一个直接型的高阶传递函数一把梭这个问题的答案在数值精度里。考虑一个四阶直接型IIR滤波器反馈系数有4个误差会被反复递归放大。在浮点运算下可能不明显但到了FPGA里一旦系数从浮点量化成有限位宽的定点数高阶直接型的极点位置对系数误差极度敏感。一个在理想情况下稳定的四阶系统量化后可能极点直接跑出单位圆变成振荡器。拆成两个二阶节后每一节的极点对系数的敏感度大幅下降两个节彼此隔离一个节的量化误差不会经过另一个节的递归放大。这是工程实践里非常重要的一条经验。系数表出来之后硬件上就要准备做定点量化了。这一步是FPGA实现里最容易理解错、也最容易踩坑的一环。3. FPGA实现细节结构、定点化与RTL代码3.1 滤波器结构选择直接型还是转置直接II型有了二阶节系数接下来要决定每个二阶节内部的运算结构。IIR二阶节常见结构有直接I型、直接II型、转置直接II型。不同结构在同样的系数和位宽下数值精度和时序特性差异很大。直接II型先把输入做反馈累加再做前馈输出用的寄存器少但累加路径长容易在反馈路径上形成长组合逻辑时序容易紧张。转置直接II型把前馈和反馈并行计算关键路径更短每个寄存器后面只有一个乘法器和一个加法器时序更友好。我实际项目里选的是转置直接II型。它在FPGA里的实现思路是这样y b0 × x s1s1_new b1 × x - a1 × y s2s2_new b2 × x - a2 × y这里的s1、s2是两个状态寄存器每一拍用输入x和前一个状态的y更新。这种流水线布局天然就是把乘法器铺开不存在先算完A再算B的串行依赖综合器跑起来很舒服。你可能会问直接I型看着挺规整的为什么不好直接I型把延迟链放在输入输出上反馈系数穿过整条延迟链组合逻辑路径长时钟频率上不去。尤其在采样率高的项目里这点影响会被放大。3.2 定点量化与Q格式FPGA里没有浮点至少普通逻辑资源上没有硬件浮点单元。所以浮点系数必须转成定点数。这里要介绍一个广泛应用的概念Q格式也叫定点小数格式。Q2.14表示2位整数位、14位小数位总共16位有符号数。它能够表示的数值范围是-2到2精度是2的负14次方足够容纳我们这组绝对值小于1的滤波器系数。用Q2.14去量化前面的浮点系数做法是乘163842的14次方后取整。比如b00.2705乘以16384得到4430对应的十六进制就是0x114E。把两个二阶节的系数全部量化级联节b0十六进制b1十六进制b2十六进制a1十六进制a2十六进制SOS10x114E0x22A10x114E0xE7CD0x1D76SOS20x0C880x19100x0C880xEE7E0x03A3其中负系数a1-0.3781在Q2.14下对应-6195用16位补码表示就是0xE7CD。这里特别要注意如果你在Verilog里定义的是有符号数那么0xE7CD直接赋给一个signed reg就能表示负数。如果你粗心定义成无符号数整个运算结果就全乱了。定点化的第二个关键是数据位宽设计。滤波器输入数据我定的是16位有符号数。每个乘法是两个16位相乘输出32位累加时要把前面的乘法结果和状态寄存器的值相加位宽继续往上扩。我的做法是中间结果一律用“全精度”保存也就是说32位乘法结果加上状态寄存器扩展位加法器位宽按实际需要逐级扩展。只有到最后输出的时候才通过截位或饱和把数据压回16位。这么做的好处是中间运算不丢有效位滤波精度最大化。3.3 二阶节RTL实现示例下面给一段基于转置直接II型的可综合Verilog代码系数用localparam固化实际工程中你也可以把它们做成可配置寄存器方便动态调参。module biquad_iir #( parameter DW 16, parameter CW 16 )( input wire clk, input wire rst_n, input wire vld_in, input wire signed [DW-1:0] din, output reg signed [DW-1:0] dout, output reg vld_out ); // SOS1 系数Q2.14格式 localparam signed [CW-1:0] B0 16h114E; localparam signed [CW-1:0] B1 16h22A1; localparam signed [CW-1:0] B2 16h114E; localparam signed [CW-1:0] A1 16hE7CD; localparam signed [CW-1:0] A2 16h1D76; // 状态寄存器 reg signed [DW-1:0] s1; reg signed [DW-1:0] s2; // 乘法结果 wire signed [DWCW-1:0] p0 din * B0; wire signed [DWCW-1:0] p1 din * B1; wire signed [DWCW-1:0] p2 din * B2; wire signed [DWCW-1:0] p3 y_tmp * A1; wire signed [DWCW-1:0] p4 y_tmp * A2; // 定点输出 wire signed [DW-1:0] y_tmp; wire signed [DWCW-1:0] acc0; assign acc0 p0 {{CW{1b0}}, s1}; assign y_tmp acc0 14; // 算术右移回到16bit wire signed [DWCW1:0] acc1 p1 - p3 {{2{CW[0] ? 1b0 : 1b0}}, s2}; wire signed [DWCW1:0] acc2 p2 - p4; always (posedge clk or negedge rst_n) begin if (!rst_n) begin s1 d0; s2 d0; dout d0; vld_out 1b0; end else if (vld_in) begin s1 acc1[DWCW1:2]; // 按需截位工程上要统一管理 s2 acc2[DWCW1:2]; dout y_tmp; vld_out 1b1; end else begin vld_out 1b0; end end endmodule这段代码最大的问题是我为了版面简洁把截位和饱和逻辑简化掉了你实际做的时候不能直接照抄尤其要注意三点。第一状态寄存器s1和s2的更新值位宽比DW大直接截取高位会损失精度工程做法是先把中间结果全精度保存再统一做舍入或饱和。第二acc1里s2做了位拼接扩展实际写法里要严格计算位宽不然综合时会报位宽不匹配。第三如果要求更高精度可以在截位前加一个“0.5LSB”的偏置实现四舍五入而不是直接截断。直接截断会让输出带一个直流偏移在某些音频或测量场景下听起来或者测起来很别扭。3.4 级联与多级处理一个二阶节只是一个biquad四阶IIR要把两个biquad串起来也就是把第一个节的输出接到第二个节的输入。这里有个非常容易踩的问题第一个节的输出已经做了截位精度损失会被第二个节当成输入误差继续处理错误会传递。所以实际工程里级联时尽量让中间数据保持更宽的位宽不要一来一回都在16位上折腾。我通常会让第一节输出保留到24位或者32位接到第二节第二节做完之后再统一压回16位。你可以理解为中间环节尽量用“宽通道”传输只在最后对外接口处收紧这样能把有限字长效应压到最低。多级滤波还有另外一个问题就是增益分配。我在2.3节给出的系数是每节直流增益为1的所以级联起来总增益是1输出幅值和输入接近。但某些设计工具导出的SOS系数每节增益可能不是1级联后总增益会偏离设计值甚至发散。这种情况要在链路里增加一个全局增益校正模块用乘法或移位把整体增益拉回预期范围。4. 仿真、验证与板上调试实录4.1 用Python/MATLAB做浮点与定点对标写完RTL先别急着上板第一步是建模验证。我的习惯是先用Python或者MATLAB做一套浮点模型再模拟定点量化行为最后跟RTL仿真结果对比。因为FPGA里面最终是定点运算浮点模型跟定点模型之间本来就有误差你要清楚这个误差有多大才能判断RTL对不对。以Python为例核心验证脚本大概长这样import numpy as np from scipy.signal import butter, sosfilt fs 48000 fc 10000 sos butter(4, fc, fsfs, outputsos, btypelow) # 生成测试信号叠加高频噪声 t np.arange(0, 0.1, 1/fs) x np.sin(2*np.pi*1000*t) 0.5*np.sin(2*np.pi*15000*t) # 浮点滤波 y_float sosfilt(sos, x) # 定点化把每个SOS系数转成Q2.14并模拟截位 # 这一步要和RTL里的数据通路一一对应我这里故意没写完整的定点模拟函数因为你真正做的时候定点模型必须跟RTL数据通路完全一致包括乘法位宽、中间截位位置、饱和处理方式。这个模型就叫“参考模型”它是你验证RTL的标尺。仿真时喂同样的激励进去把RTL输出跟参考模型输出拉曲线对比误差在几个LSB以内就算正常。这一步很重要不要偷懒。我见过不少朋友写完RTL直接拿随机激励看波形看得眼花缭乱却根本不知道结果对不对。没有参考模型RTL仿真的正确性无从谈起。4.2 Testbench设计要点Testbench要解决的核心问题是给RTL喂什么样的激励以及如何比对结果。激励至少包含三类。第一类是直流信号用来验证稳态输出是否收敛检测有没有极限环第二类是正弦波扫频信号从100Hz到20kHz扫过去看输出幅值是否符合低通特性第三类是脉冲或阶跃信号用来观察暂态响应和振铃。每种激励下都要记录输入和输出方便后续对齐分析。比对方式上推荐把RTL仿真的输出用$fwrite写进文本文件然后在Python里跟参考模型结果做逐点对比。写文件的好处是数据量可以很大不用盯着波形一点点看。两个序列之间的最大绝对误差、均方根误差都能直接算出来。如果误差突然在某一段变大多半就是截位逻辑出了问题。Modelsim和Vivado自带的XSim都支持跑这种仿真我一般用Vivado的XSim就够了流程短启动快。如果你的工程要跑大量用例、想更顺手的调试脚本再去考虑Modelsim或者Questa。4.3 硬件调试ILA与信号源仿真通过只能说明逻辑行为对了一大半真正的考验在上板。我的习惯是先做一个简单的回环测试用FPGA内部产生一个DDS信号里面揉进低频有用信号和高频噪声直接灌进IIR滤波器然后用ILA抓滤波前后的波形在Vivado的波形窗口里肉眼看效果。ILA抓数据时要注意深度设置默认的1024个采样点经常不够看完整波形。我一般设成8192或者16384触发条件用输入信号某个幅度值或者valid_in上升沿。抓完之后可以直接导出CSV拿到Python里做FFT分析看频谱上高频分量是不是被压下去了低频分量幅度有没有变化。如果你手头有信号发生器和示波器那就更直观。信号发生器输出一个扫频正弦波ADC采集或者直接通过开发板上的DAC送进FPGA滤波器输出接示波器你能清楚看到幅频响应曲线的变化。这种“看得见”的验证方式比纯仿真更有说服力。实际上我做这个项目时最后就是用示波器拍摄屏幕上的滤波前后波形的给别人讲起来也直观得多。5. 踩坑记录与工程经验5.1 系数量化导致极点偏移与不稳定先说一个最容易让新手崩溃的问题浮点仿真好好的定点仿真也正常一上板输出就变成三角波甚至饱和振荡。这时候十有八九是系数量化后极点跑出了单位圆。原因在于双线性变换后的某些系数尤其是高Q值滤波器对量化误差极其敏感。你让a2误差几个LSB就可能让一对共轭极点从单位圆内挪到圆外。处理办法有几个第一尽量提高反馈系数的量化精度比如系数用Q3.12或者Q1.15而不是Q2.14具体看数值范围第二用级联二阶节结构每节只承担两个极点敏感度显著低于高阶直接型第三仿真阶段专门做“极点位置扫描”把量化后的系数代回z域传递函数算出极点幅值是否严格小于1。注意是严格小于1因为工程上还要留裕量我一般要求极点模值小于0.995。如果已经出现了振荡最简单的排查方法是把输入置零观察输出。零输入下输出不收敛还在乱跳基本就可以定性为系数量化后的稳定性问题。把系数再调一两个LSB往往就能救回来。5.2 极限环与零输入振荡极限环这个坑是在定点IIR里独有的。简单说就是输入为0之后因为量化截位带来的非线性让状态寄存器里残留了一点点数值经过反馈不断循环输出变成固定幅度的小周期振荡。频率通常很低但如果你做音频听起来是那种“嘶嘶”的底噪做测量就表现为无法归零的零点漂移。解决极限环没有万能药我的经验是组合拳一是状态更新时尽量保留更多有效位避免每拍都截到16位二是合理设置输出饱和和死区就是当输出绝对值低于某个阈值时直接清零三是加一点高通去直流处理把零点漂移滤掉。最稳妥的还是保证状态寄存器位宽足够让量化步长尽量小极限环的幅度也会小到可以忽略。5.3 截位与饱和策略FPGA里做乘法加法位宽会越滚越大最后往外输出时必然要做截位或者舍入。这里有一个工程误区很多人直接截掉低14位图省事结果输出的直流偏置大得离谱。原因很简单直接截断相当于“向零取整”对于正负对称的信号平均值会向下偏表现在频谱上就是直流分量。我在项目里用的做法是截位前给累加器结果加上一个半LSB偏置也就是加0.5个最低有效位再做算术右移这就实现了简单的四舍五入。如果是音频数据再补一级饱和判断防止溢出造成刺耳的削波。饱和判断的优先级要最高先判断是否溢出再决定是截位还是饱和输出。否则一旦溢出补码翻转会把小的正数变成大的负数输出直接跳变那就不只是失真是明显的爆音。5.4 资源与时序优化经验最后说说资源占用和时序优化。IIR滤波器本质是“乘加反馈”资源消耗主要在DSP乘法器上。一个二阶节五个系数对应五个乘法器四阶就是十个。如果你用的FPGA DSP48数量不多要想想优化办法。我的经验是把相邻的乘加合并到DSP48内部的乘累加结构里去Xilinx和Altera的综合器都能自动做这种推断前提是你代码风格必须规范别在乘法器之间插入太多乱七八糟的逻辑。时序方面如果时钟频率上不去优先检查反馈路径。IIR的反馈决定了同一个周期的输出依赖前一拍的输出路径一旦长了就很难收敛。解决办法是给关键的乘法器输出加流水寄存器让反馈路径被切短。但加了流水寄存器会引入额外延迟级联节数多时总延迟会变长你在设计valid和ready握手逻辑时要考虑这个延迟对齐。另外一个实际经验是采样率不高的时候可以用“时分复用”的方式来节省乘法器也就是一个物理乘法器分时处理多个系数乘法。48kHz的采样率对FPGA来说太低了一个DSP48跑几百兆时钟完全可以在一个采样周期内轮询算完所有乘加。不过这会增加控制逻辑的复杂度工程项目里看资源紧张程度再做决定。做IIR滤波器这个项目说实话一开始并不难难的是把每个细节都撸顺。系数量化、位宽管理、截位饱和、级联精度这些都不是公式能直接给你的得靠一轮一轮仿真和上板实测去磨。我当时最深刻的体会是DSP算法写出来只需要一天但让它稳定、精准、不跳变地跑起来花了一个多星期。这中间最值得的投资就是老老实实写参考模型、做定点对标、把每一路中间信号的位宽和取值都搞清楚。你愿意在这上面花时间后面调试就会顺畅很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑