FPGA做数字滤波器这个方向,算是我个人很偏爱的“硬核玩具”。它不像跑算法那样只讲究数值对不对,也不像点个IP核那样调完就完事,而是要真正地把差分方程变成电路、把反馈环路变成存储器与运算器的联动,每一步都得脚踏实地。做这个“基于FPGA的滤波器设计:IIR、FIR与自适应滤波器”项目时,我最大的感受是:滤波这件事本身不难,难的是在硬件资源、时序、精度和实时性之间找到平衡。下面我就把这一整套从原理到实操的经验拆开讲一讲,给正在入门或准备啃这块硬骨头的朋友做个参考。
1. 三种滤波器的特性分析与选型思路
1.1 为什么FPGA适合做实时滤波器
先说个经常被问到的问题:为什么非要用FPGA做滤波器,直接在DSP上跑不行吗?我的回答通常是,看延迟和吞吐量。DSP或者ARM跑滤波器是“顺序执行”的思路,一个节拍算一个乘法累加,阶数上去之后延迟会明显增长。FPGA则完全不同,它天生是并行的,可以把一整条乘加链摊开成硬件电路,数据从输入端进去,几个时钟周期后从输出端出来,整体延迟可以做到微秒级甚至亚微秒级。这个特性在通信、雷达、音频处理和测量仪器这些场景里特别吃香。
另一个关键优势是接口的灵活性。FPGA可以挂高速ADC、DAC,也可以直接跑在以太网、光纤这类接口后面。滤波器和上下变频、解调、抽值这些模块可以放在同一个时钟域里做流式处理,数据一路流水下去,不会像DSP那样频繁地进中断、搬数据。所以只要是要求“数据不停流、延迟不能高、接口能定制”的滤波需求,FPGA基本是第一选择。
当然,FPGA也有让人头疼的地方,主要在于开发周期长、调试难度高。同样的算法,在PC仿真里写几行代码就能跑,在FPGA上要考虑位宽、溢出、时序收敛、复位策略、跨时钟域一堆问题。但换个角度看,把这些问题全部捋顺之后,你对信号处理本身的理解都会提升一个档次。
1.2 IIR、FIR与自适应滤波器:性能、资源与稳定性对比
做滤波设计之前,需要先把三种滤波器摆在台面上做一次彻底的对比。它们各自有不可替代的领域,选错类型后面全是坑。
FIR滤波器走的是非递归结构,当前输出只依赖当前和过去的输入,不存在反馈回路。这个特性带来两个巨大优势:一是稳定,二就是可以做严格线性相位。线性相位在实际工程里太重要了,比如通信里的码间串扰抑制、音频里的波形保真、测量仪器里的时间延迟一致性,这些都要求群延迟恒定。FIR的代价是相同指标下阶数通常比IIR高好几倍,资源消耗更大。
IIR滤波器依靠反馈回路,用较少的阶数就能实现陡峭的过渡带。两个二阶IIR节拍下去可能就能达到十几阶FIR的效果。省资源这点很诱人,但IIR的缺点也很明显:没有线性相位,过渡带和通带的相位变化是比较扭的。而且反馈结构天生对系数量化误差更敏感,极点稍微偏移一点就可能让滤波器性能急剧变差,甚至不稳定。
自适应滤波器则是另一个维度的东西。它的系数不是敲定之后就不管,而是随着输入数据的统计特性实时调整。常见的应用有噪声抵消、回声消除、信道均衡、系统辨识。FPGA上最主流的自适应算法是LMS,因为它简单、计算量小、容易硬件化。缺点是收敛速度受信号功率和步长影响很大,选不好参数容易发散或收敛太慢。
为了更直观,我把这三种结构的对比整理成了一张表:
| 特性 | FIR | IIR | 自适应(LMS) |
|---|---|---|---|
| 结构 | 非递归,无反馈 | 递归,有反馈 | 递归+系数更新回路 |
| 输出依赖 | 当前及若干历史输入 | 当前及历史输入+历史输出 | 输入+输出+误差反馈 |
| 线性相位 | 可以严格实现 | 一般无法实现 | 取决于具体配置 |
| 稳定性 | 无条件稳定 | 极点必须在单位圆内 | 收敛条件受制于步长与信号功率 |
| 相同指标下的阶数 | 较高 | 较低 | 视场景差别很大 |
| 资源消耗 | 乘法器多,结构规整 | 乘法器少,反馈链路复杂 | 运算量和系数存储量都较大 |
| 典型场景 | 通信前端、音频、抗混叠 | 低延迟滤波、窄带选频 | 噪声抵消、均衡、辨识 |
表格一列出来就很清楚,设计人的核心工作其实是“按场景选结构、按指标定阶数、按平台定精度”。有一回我要做一个音频前级抗混叠滤波,带宽和延迟要求都很死,我当时第一反应是常规IIR,因为省资源,但一评估群延迟和相位失真就直接否掉了,老老实实上了14阶FIR。资源多用了一倍多,但至少相位曲线干净,后续算法不用额外补偿。
2. 设计前的关键功课:定点格式、精度与资源规划
2.1 定点格式选择:Q值、位宽与量化噪声
FPGA本身不怎么适合直接吃浮点数。虽然现在一些高端芯片也有浮点运算单元,但在大多数中低端器件上,浮点运算是靠通用逻辑去“模拟”的,资源消耗高得离谱。所以我们要把算法里的浮点系数和变量转换成定点格式,这一步就是设计成败的关键。
最实用的表示方式是Q格式,也就是“整数位宽+小数位宽”的固定拆分。举个例子,Q1.15表示总位宽16位,最高位是符号位,接着1位整数位,剩下15位小数位。这样表示的范围是-1到0.999969,精度大约是0.0000305。滤波器系数绝大多数都小于1,用Q1.15绰绰有余。但中间累加结果就未必了,几个16位数相乘再累加,位宽很快会爆,所以内部累加器一般要扩展到32位以上。
位宽规划有一个核心原则:从头到尾估算每一级运算的动态范围。输入信号是ADC采样值,可能是12位有符号数;系数是Q1.15格式;乘出来的结果可能达到Input_W + Coef_W位;累加之后再加个log2(N)的增长位。我一般喜欢留个20%的余量,宁可多占一两个bit,也不要让溢出把整个系统打回原形。
有个容易被忽视的点是DAC或者后级处理模块只接收16位定点数。如果你内部用了32位累加器,在截断输出时必须做饱和处理,而不是简简单单把低16位丢掉。丢掉低位会导致极低电平信号失真;直接截高位则会让信号“卷绕”,产生可怕的削波噪声。工程上稳妥的做法是“钳位+舍入”:超上限就饱和到最大值,低于下限就饱和到最小值,同时把低位的MSB作为舍入进位。
2.2 资源规划:DSP硬核、查找表与寄存器之间的取舍
FPGA做乘法其实就三块资源可以选:专用的乘法器硬核、LUT搭出来的分布式乘法器、以及用移位相加的方式实现乘法。这三者各有各的适用场景。
现代FPGA基本都内建了不少专用乘法器块,我们习惯叫它DSP块。每个DSP块可以在一个时钟周期完成一个乘法,还能顺便做乘加。FIR滤波器的本质是“乘累加”,正好踩在DSP块的舒适区上。一个16阶FIR,把16个系数展开成16路乘法,再接一个加法树,一两拍就能消耗掉十几个DSP块。如果你用的是小芯片且还需要挂其他算法,就得重新扳指头算资源。
如果DSP块不够用,可以用LUT+移位寄存器实现乘法。一个16位乘16位的乘法大概要消耗上百个LUT,而且逻辑延迟更长,速度上不去。这种做法适合阶数很低或者时钟频率不高的场景。还有一个老派思路是分布式算法,把乘法拆成位运算后预先存进查找表,再用查表+累加实现滤波器。分布式算法非常省乘法器,但系数多之后查找表容量会指数增长,比较适合固定系数的场合。
我自己做规划时习惯先画一个资源估算表:滤波器阶数、每路位宽、输入路数、时钟频率,四项填进去之后,大概能推算出DSP块和寄存器的占用。这里有几条经验:
- 如果单通道采样率不高,可以把卷积运算分时复用,用一套乘加器轮流算多个抽头,DSP块用量可以砍到原来的四分之一。
- 多通道滤波器(比如同时处理I/Q两路信号)可以考虑把两个通道的数据拼接成复信号,用复数乘加结构一次性处理,资源能省不少。
- 阶数超过32的时候,加法树要拆成多级流水,不然组合逻辑延迟会让你根本跑不到目标时钟频率。
2.3 时序收敛:流水线划分是躲不过的坎
FPGA工程里最折腾人的一件事不是逻辑错了,而是“功能正确但时序收敛不了”。滤波器电路里存在大量乘法和累加运算,组合逻辑路径过长后,时钟频率就撑不上去。
解决思路非常直白:在运算链里插入流水线寄存器。比如FIR的完整乘累加链,可以拆成“乘法”和“加法”两大阶段。乘法器固件本身内部也有时钟级延迟,你只要把它的输出先寄存一拍,再进加法树,加法树每加一级插一级寄存器,时序就能轻松收敛。带来的代价是输出延迟增加几个时钟周期,对于大多数滤波器应用来说完全无伤大雅。
IIR滤波器的时序收敛更麻烦一点,因为它存在反馈路径。反馈路径上“当前输出”经过组合逻辑算出来之后,马上又被用来算下一个周期的输出,如果这条路径的延迟超过一个时钟周期,那就无论如何也收敛不了。处理手段通常是把反馈计算尽可能压缩,用高基数的乘加结构减少组合逻辑级数,或者干脆降低时钟频率来换取收敛。后面实操部分我会给一个具体的处理方式。
3. 动手实操:IIR、FIR与自适应滤波器的FPGA实现
3.1 FIR滤波器实现:抽头延时线加乘加链
FIR滤波器最标准的实现就是抽头延时线。简单说,把输入数据串进一串寄存器,每个寄存器输出和对应系数相乘,再把所有乘积加起来。下面的Verilog代码展示了一个参数化的16阶FIR主体结构:
module fir_top #( parameter DATA_W = 16, // 输入数据位宽 parameter COEF_W = 16, // 系数位宽 parameter TAP_N = 16 // 滤波器阶数 )( input wire clk, input wire rst_n, input wire signed [DATA_W-1:0] din, output reg signed [DATA_W+COEF_W-1:0] dout ); reg signed [DATA_W-1:0] delay_line [0:TAP_N-1]; wire signed [COEF_W-1:0] coeff [0:TAP_N-1]; wire signed [DATA_W+COEF_W-1:0] prod [0:TAP_N-1]; integer i; // 系数表,这里先以简单的低通系数占位,实际项目用工具生成 assign coeff[0] = 16'h000F; assign coeff[1] = 16'h003D; assign coeff[2] = 16'h00A4; assign coeff[3] = 16'h0111; assign coeff[4] = 16'h0140; assign coeff[5] = 16'h0111; assign coeff[6] = 16'h00A4; assign coeff[7] = 16'h003D; assign coeff[8] = 16'hFFC3; // 负数用补码表示 // 实际16阶应从0到15取值,这里仅示意 // 延时线 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin for (i = 0; i < TAP_N; i = i + 1) delay_line[i] <= {DATA_W{1'b0}}; end else begin delay_line[0] <= din; for (i = 1; i < TAP_N; i = i + 1) delay_line[i] <= delay_line[i-1]; end end // 乘法结果 genvar g; generate for (g = 0; g < TAP_N; g = g + 1) begin : mul_stage assign prod[g] = delay_line[g] * coeff[g]; end endgenerate // 两级加法树,输出寄存 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin dout <= {DATA_W+COEF_W{1'b0}}; end else begin dout <= prod[0] + prod[1] + prod[2] + prod[3] + prod[4] + prod[5] + prod[6] + prod[7] + prod[8] + prod[9] + prod[10] + prod[11] + prod[12] + prod[13] + prod[14] + prod[15]; end end endmodule这份代码的关键点有三个:一是延时线用移位寄存器实现,每来一个时钟,所有数据整体后移一格;二是系数是固定常量,综合工具会把它优化到乘法器的常系数输入端;三是输出不仅需要寄存,位宽也扩展到了输入加系数的总位宽,避免累加溢出。
实际项目里建议用MATLAB这类工具先把滤波器系数算出来,然后量化成整数,导成Verilog的coefficient array。别再像我早期那样手填系数,又慢又容易抄错。做16阶滤波器时,我建议先把加法树做成多级:先将相邻两两相加,得到一个八组结果,再把八组两两相加,依次类推。多级加法树虽然会让输出多延迟几拍,但逻辑级数低,时序收敛快得多。
3.2 IIR滤波器实现:反馈环路与直接I型结构
IIR的设计核心在于把差分方程变成电路。以二阶直接I型为例:
y[n] = b0·x[n] + b1·x[n-1] + b2·x[n-2] - a1·y[n-1] - a2·y[n-2]
对应的Verilog模块可以这样组织:
module biquad #( parameter W = 16 )( input wire clk, input wire rst_n, input wire signed [W-1:0] x, output reg signed [2*W-1:0] y ); reg signed [W-1:0] x_d1; reg signed [W-1:0] x_d2; reg signed [2*W-1:0] y_d1; reg signed [2*W-1:0] y_d2; wire signed [W-1:0] b0 = 16'h0200; wire signed [W-1:0] b1 = 16'h0400; wire signed [W-1:0] b2 = 16'h0200; wire signed [W-1:0] a1 = 16'h0C00; wire signed [W-1:0] a2 = 16'h0E00; wire signed [2*W+W-1:0] sum; assign sum = x * b0 + x_d1 * b1 + x_d2 * b2 - y_d1 * a1 - y_d2 * a2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin x_d1 <= {W{1'b0}}; x_d2 <= {W{1'b0}}; y_d1 <= {2*W{1'b0}}; y_d2 <= {2*W{1'b0}}; y <= {2*W{1'b0}}; end else begin x_d1 <= x; x_d2 <= x_d1; y_d1 <= y; y_d2 <= y_d1; y <= sum[$bits(y)-1:0]; end end endmoduleIIR最容易出问题的地方就在反馈路径。我上面写的是直接I型,虽然直观,但反馈路径上要同时完成两次乘法和五次加法,组合逻辑延迟不小。如果时钟频率要求高于100MHz,我一般会改用直接II型转置结构,它能利用流水线寄存器把反馈路径拆开,时序收敛容易一些。
还有一个特别值得强调的细节:反馈运算用到的系数a1、a2通常是负数转补码存的,符号处理一旦弄错,整个滤波器就会变成自激振荡器。我第一次做IIR的时候,把负系数当成无符号数处理,结果上板后输出一浪高过一浪,最后直流顶死。之后我再也不依赖手工转换,一律用脚本或者配合数值工具生成二进制补码,既省心又不出错。
3.3 自适应滤波器实现:LMS算法的硬件化改造
自适应滤波在FPGA里最常用的就是LMS算法。它的核心迭代公式可以写成:
y[n] = W[n]·X[n] e[n] = d[n] - y[n] W[n+1] = W[n] + μ·e[n]·X[n]
这里W是权重向量,X是输入向量,d是期望信号,e是误差,μ是步长。硬件实现LMS最核心的矛盾是:权重更新这个环节里自带组合逻辑回路,如果图省事把计算直接串在一起,时序一定爆炸。我下面给一个简化但结构正确的两抽头LMS写法:
module lms_2tap #( parameter W = 16 )( input wire clk, input wire rst_n, input wire signed [W-1:0] x, // 当前输入 input wire signed [W-1:0] d, // 期望信号 output reg signed [W-1:0] y, // 滤波器输出 output reg signed [2*W-1:0] e // 误差 ); reg signed [W-1:0] x_old; reg signed [W-1:0] w0, w1; reg signed [2*W-1:0] y_acc; reg signed [2*W-1:0] e_acc; localparam MU = 16'h0200; // 步长,视实际信号幅度调整 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin x_old <= 0; w0 <= 0; w1 <= 0; y <= 0; e <= 0; end else begin x_old <= x; y_acc <= w0 * x + w1 * x_old; y <= y_acc[W-1:0]; e_acc <= d - y; e <= e_acc; w0 <= w0 + MU * e_acc * x; w1 <= w1 + MU * e_acc * x_old; end end endmodule这段代码示意了将整个LMS流水化成三个时钟周期的过程:先算出滤波输出,再算误差,最后用误差更新权重。每一步都有寄存器隔开,时序可收敛。但注意,LMS本质上要求在“同一个采样点”完成这三步,流水会让权重更新滞后一拍,这就是文献里常说的“延迟LMS”。工程上完全能用,只要你对滞后一拍带来的性能损失心里有数。
步长的选择是另一个大坑。μ太小收敛慢,μ太大直接发散。理论上的上限跟输入信号功率有关,但FPGA里算信号功率很麻烦,我通常的做法是先用PC端工具把目标信号的幅度范围测出来,再把μ设成上限的一半左右,留足余量。还有一个技巧是在硬件里加一个信号幅度统计模块,如果误差连续多拍超过阈值,就自动把μ减半,这个保护机制能极大提高稳定性。
自适应滤波还有一个容易被忽略的资源点:权重存储。抽头数少可以像上面那样用寄存器,但抽头数一多就得用块内存。内存接口如果做异步读,时序会很紧张,我一般会把权重放在同步RAM里,读权重和更新权重分别用两个时钟周期完成,经典的自适应系统在时钟频率和抽头数之间做个取舍,这样最干脆。
4. 常见问题与排查技巧实录
4.1 仿真正常、板卡失真:最容易被忽略的三个环节
这类问题的出现频率相当高。我总结下来,板级效果和仿真对不上的原因,集中在以下三处:
一是位宽截断。仿真用的输入是浮点或者宽位定值,板上从ADC送进来的数据却是实实在在的有限位宽,比如12位或14位。如果你仿真时用的是16位正弦,实际12位信号进来,信噪比天然就差一截,这不是滤波器的问题。建议仿真时务必用真实位宽的信号源去激励。
二是复位状态没处理干紧。滤波器这种有记忆的系统,复位之后延时线和反馈寄存器里必须清零。如果复位只清了部分寄存器,上电后的前几百拍输出可能带着未知的初始状态,表现出来就是“前几个输出莫名其妙地乱跳”。尤其IIR,反馈寄存器不清零,可能让系统一开始就输出一个巨大的毛刺,直接把后面的DAC打保护。
三是系数符号位被综合工具优化错了。有些情况下,工具看到常数乘法器就自动改成移位相加,如果你系数是负数,又没声明signed,优化出来的结果就可能完全错误。所以Verilog里所有跟系数相乘的信号,一律标成signed,不要含糊。
4.2 反馈环路优化:如何把闭环设计做成可收敛的流水
IIR和自适应滤波器的时序收敛是硬骨头,我的做法是用“重定时”来拆反馈路径。重定时是一种标准的结构变换,简单来说,在保证传递函数不变的前提下,把组合逻辑里的延时重新分配,让关键路径上的运算级数变短。
以二阶IIR为例:可以把反馈项里a1和a2的乘法分别放到两个乘法器硬核里,乘法器自带输出寄存器,等于把反馈路径切成了两段。不过要注意,重定时会改变输出相对输入的延迟,也会略微改变环路时序关系,设计者需要判断这一点对系统指标的影响是否可接受。
实际调试时序时,我习惯把综合工具里时序报告里的最差负余量路径展开,逐个看是哪一级组合逻辑太长。如果看到关键路径经过了一个很宽的加法器,我会把加法器拆成两级流水,中间加寄存器。拆完再综合,路径延迟通常会降三分之一左右。但拆流水不能乱拆,在反馈环路上多插一拍可能让算法本身收敛性尽失,所以只能拆那种不参与反馈的路径,反馈路径则要靠重定时。
4.3 频响验证与调试心得:从仿真结果到实测
滤波器做完了,不能只看时域波形拍个手就完事。我习惯的验证步骤是:先把系数和结构在PC端工具里仿真出频响曲线,再把这个频响曲线作为基准;FPGA实现之后,用同样系数的正弦扫频信号灌进去,在输出端采集点值,然后做FFT看幅度变化。
具体操作上,我会在模块输入端加一个扫频信号发生器,从低频一直扫到Nyquist频率附近。每到一个频点,记录输出幅度,最后把这些点连起来形成实测频率响应曲线。这条曲线如果和理论曲线基本贴合,滤波器就算基本过关;否则就要回头查系数量化误差、输出位宽是否不够、累加器是否溢出。
还有一个容易被忽略的细节是数据有效标志。FPGA内部数据是连续的,但ADC/DAC的采样率未必和FPGA时钟完全一致。如果接口跨时钟域处理得不好,输出会偶发一个周期的跳变,在频谱上看就像毛刺。所以我在设计滤波器模块时,总会附加一条valid信号,只有valid拉高时输出才有效,后续模块统统按这个标志取数。
5. 一些实操体会
做了这么多年FPGA滤波器,我个人最大的体会是:不要在一开始就埋头写RTL,花时间把算法仿真、定点和资源规划做扎实,后面几乎不会有推倒重来的情况。我见过太多同学先是浮点算法仿真做得爽,一上硬件就疯狂抓瞎,最后来回改代码,项目一拖再拖。反过来,如果先把位宽算明白,把流水级数定好,再把仿真激励做成贴近真实板级的格式,整个开发过程会非常顺。
最后再分享一个小技巧:把滤波器的系数和位宽参数都定义成parameter,不要硬编码在逻辑里。这样以后换指标、换采样率、换系数,只需要改顶层参数,RTL一行不动。配合厂商的综合工具把每个模块的资源报告自动导出,你就能非常轻松地对比不同阶数、不同位宽下的资源占用。这套流程我用了很多年,任何时候拿出来都很稳妥。