1. 为什么我选择从IIR滤波器切入FPGA信号处理
做FPGA信号处理这块,滤波器是绕不开的第一道坎。很多朋友刚学完Verilog语法、跑通几个流水灯和状态机之后,就卡在了"接下来该做什么"这个路口。我当初也一样,翻了一堆教程,发现大部分要么停留在理论推导层面,满篇的Z变换和差分方程,看完脑子嗡嗡的;要么直接甩一大段代码,连个注释都没有,更别提告诉你参数怎么来的、位宽怎么定的、仿真波形该怎么看。这种"两头不靠"的学习体验非常劝退。
IIR滤波器恰好是一个特别适合拿来练手的项目。它不像FIR那样只需要乘累加、结构简单到有点单调,IIR引入了反馈路径,意味着你必须认真对待位宽增长、量化误差、稳定性这些真实工程里躲不掉的问题。换句话说,把IIR滤波器从数学公式一路做到Vivado里能跑出正确波形,这个过程几乎覆盖了FPGA数字信号处理入门阶段的所有核心技能点:定点数表示、系数量化、流水线设计、时序收敛、仿真验证。你把这一个项目吃透了,后面再去做FFT、做CORDIC、做各种变换,底层思维是相通的。
这篇文章面向的是已经掌握Verilog基本语法、用过Vivado建过工程、但还没完整做过一个信号处理模块的朋友。我会把整个流程从设计思路、参数计算、代码结构、仿真验证到踩坑经验全部摊开讲,重点放在为什么这么做而不是照着抄就行。因为我自己在学的时候最烦的就是"你照着写就对了"这种话——对了也不知道为什么对,错了更不知道怎么改。
先说一下整体路线:我们采用二阶节级联(SOS, Second-Order Sections)的结构来实现一个低通IIR滤波器,设计方法用经典的双线性变换法从模拟原型推导数字系数,系数经过定点量化后写入Verilog的乘累加结构,最后在Vivado里用Testbench做行为级仿真验证。整个流程我会给出具体的参数计算过程、位宽选择依据和仿真结果判读方法。
2. 整体方案设计与关键取舍
2.1 为什么选IIR而不是FIR——一个真实的工程权衡
刚接触滤波器的时候,很多人会问:IIR和FIR到底选哪个?教科书上的标准答案是"IIR阶数低、资源省,但有相位非线性;FIR可以做到严格线性相位,但阶数高、资源消耗大"。这个答案没错,但太干了,我用自己的理解翻译一下。
想象你要筛沙子。FIR就像用一层一层不同孔径的筛网叠起来,每一层只负责筛掉特定大小的颗粒,层数多了自然筛得干净,但你需要很多层筛网(对应很多个乘法器和寄存器)。IIR则像是让沙子反复经过同一层筛网,利用反馈把没筛干净的再送回去重新筛,所以用很少的层数就能达到很好的筛选效果,代价是沙子在里面绕来绕去,出来的顺序和进去的顺序之间的关系变得复杂了(这就是相位非线性)。
在实际项目里,如果你的信号对相位不敏感——比如音频处理里的均衡器、传感器信号的去噪、通信系统里的包络检测——那IIR是性价比极高的选择。一个四阶IIR低通可能只需要几个乘法器就能实现,而同样性能的FIR可能要几十阶,资源差距是数量级的。我这次选IIR,一方面是想把反馈结构练明白,另一方面也是因为低通滤波这个场景对线性相位确实没有硬性要求。
2.2 二阶节级联结构的优势在哪里
直接实现一个高阶IIR滤波器(比如四阶、六阶)在数学上完全可行,就是把差分方程展开成一个长长的式子。但这么做在FPGA里会带来两个致命问题:系数量化敏感度极高和内部位宽爆炸。
高阶IIR的极点对系数误差非常敏感。你用MATLAB或者Python算出来的系数是浮点的,精度很高,但FPGA里必须转成定点数,一旦量化误差稍大,极点就可能跑到单位圆外面去,滤波器直接发散——输出越来越大,最后溢出成噪声。而把高阶系统拆成多个二阶节级联,每个二阶节只有一对共轭极点,量化误差对每个节的影响是局部的,整体稳定性好得多。这就好比一个复杂的机械系统,你把它拆成几个独立的小模块分别调试,比整体联调要可控得多。
另一个好处是模块复用。二阶节的结构是完全一样的,你写一个模块,例化多次就行,代码复用率高,调试也方便——哪个节出了问题,单独仿真那个节就能定位。我这次实现的是一个四阶低通,拆成两个二阶节级联,结构清晰,也足够体现级联的完整流程。
2.3 定点数量化策略的底层逻辑
这是整个项目里最容易翻车的地方,我单独拎出来说。浮点系数转定点,核心就一句话:用整数去近似小数,靠的是移位。比如系数0.625,你乘以2的4次方(也就是16),得到10,那么用4位小数精度来表示就是10/16=0.625。在FPGA里,乘法结果右移4位就等价于乘了0.625。
但问题在于,你不可能无限提高精度。小数位宽每增加1位,乘法器的位宽就增加1位,资源消耗跟着涨。我一般遵循的经验法则是:系数位宽取12到16位(含符号位),内部累加位宽在系数位宽基础上再加log2(节数×每节项数)的保护位。具体到这次的设计,系数用14位有符号定点(1位符号+13位小数),内部累加用32位,最后输出截取高16位。这个配置在资源和精度之间取得了不错的平衡,实测量化后的频率响应和浮点参考的偏差在0.1dB以内,完全够用。
注意:系数量化之后一定要重新算一遍极点的位置,确认还在单位圆内。我见过有人量化完直接仿真,结果输出发散,查了半天以为是代码写错了,其实是系数精度不够把极点推出了稳定区域。
3. 从数学公式到硬件结构——核心细节拆解
3.1 双线性变换推导数字系数(含完整计算过程)
我们从一个标准的二阶模拟低通原型出发。设截止频率对应的模拟角频率为Ωc,品质因数Q,那么二阶低通传递函数的标准形式是:
H(s) = Ωc² / (s² + (Ωc/Q)s + Ωc²)
这里我选截止频率fc=1kHz,采样率fs=48kHz,Q取0.7071(巴特沃斯响应,最平坦的通带)。先算模拟角频率:Ωc = 2π×fc = 6283.185 rad/s。双线性变换的映射关系是s = 2fs×(1-z⁻¹)/(1+z⁻¹),代入整理后得到数字域的系数。
具体计算过程我列一下,方便你对照:
预畸变处理:双线性变换会把频率轴压缩,所以要先做预畸变,Ωc' = 2fs×tan(π×fc/fs)。代入数值:π×1000/48000 = 0.06545 rad,tan(0.06545) ≈ 0.06554,所以Ωc' = 2×48000×0.06554 ≈ 6291.8 rad/s。和原始Ωc差别很小,因为fc远小于fs,但养成预畸变的习惯很重要,截止频率接近奈奎斯特频率时差别会非常大。
令K = Ωc'/(2fs) = 6291.8/96000 ≈ 0.06554,Q=0.7071。归一化后的数字系数(分子分母同除以相关项)为:
- b0 = K²/(1 + K/Q + K²) ≈ 0.004207
- b1 = 2×b0 ≈ 0.008414
- b2 = b0 ≈ 0.004207
- a1 = 2×(K²-1)/(1 + K/Q + K²) ≈ -1.9057
- a2 = (1 - K/Q + K²)/(1 + K/Q + K²) ≈ 0.9118
这就是单个二阶节的系数。四阶系统由两个这样的节级联,第二个节的Q值不同(巴特沃斯四阶的两个节Q分别是0.5412和1.3065),截止频率相同。我按同样的公式算出第二节的系数,这里不重复展开,思路完全一致。
实操心得:这些计算我强烈建议用Python脚本跑一遍,不要手算。手算容易在中间步骤丢精度,而且改参数的时候要重算一遍,脚本改个数字就出来了。我常用的做法是写一个函数,输入fc、fs、Q,直接输出量化前后的系数对比表。
3.2 系数量化与位宽确定的完整推演
拿到浮点系数后,下一步是量化。我选14位有符号定点,也就是数值范围[-1, 1)映射到[-8192, 8191]。量化公式很简单:coef_fixed = round(coef_float × 2^13)。以b0=0.004207为例,乘以8192得34.46,四舍五入为34,实际表示的系数是34/8192≈0.004150,误差约1.4%。a1=-1.9057乘以8192得-15611.5,取-15612,表示值-1.90576,误差极小。
你可能会问,b0的误差1.4%会不会太大?这里要理解一个关键点:滤波器的频率响应取决于所有系数的整体配合,单个系数的相对误差不等于响应的相对误差。而且b0本身数值很小,绝对误差只有0.000057,对输出的影响微乎其微。真正需要关注的是a1、a2这些接近±2的系数,它们决定了极点的位置,量化误差必须严格控制。14位精度下,a系数的绝对量化误差不超过1/8192≈0.000122,对应极点位置的偏移极小,稳定性有保障。
内部累加位宽我定为32位。推演逻辑是这样的:每个二阶节的一次输出计算涉及5次乘加(b0x[n]+b1x[n-1]+b2x[n-2]-a1y[n-1]-a2y[n-2]),每次乘法是两个14位数相乘,结果最多28位。5个28位数相加,需要额外3位保护位(log2(5)向上取整),总共31位。取32位留一点余量,同时32位是2的幂,截位方便。最终输出取高16位(相当于右移16位),保证输出动态范围合理。
| 参数项 | 取值 | 确定依据 |
|---|---|---|
| 系数位宽 | 14位有符号 | 精度与资源的平衡点,量化误差<0.02% |
| 输入数据位宽 | 16位有符号 | 匹配常见ADC输出格式 |
| 内部累加位宽 | 32位 | 乘法最大28位+3位保护位,取整到32 |
| 输出位宽 | 16位有符号 | 截取累加结果高16位 |
| 小数位宽 | 13位 | 14位系数中1位符号+13位小数 |
3.3 二阶节的硬件结构拆解
一个二阶节的差分方程是:y[n] = b0x[n] + b1x[n-1] + b2x[n-2] - a1y[n-1] - a2y[n-2]。映射到硬件,核心就是移位寄存器链+乘法器阵列+累加器。
输入x[n]进来后,经过两级寄存器延迟得到x[n-1]和x[n-2];输出y[n]同样经过两级延迟得到y[n-1]和y[n-2]。这四路延迟信号加上当前输入,分别和对应的量化系数相乘,五个乘积送进一个加法树求和,结果就是当前输出。输出再反馈回延迟链,形成闭环。
这里有个细节值得说:反馈路径的延迟必须严格对齐。y[n-1]和y[n-2]是在时钟沿更新后的值,如果你在组合逻辑里直接用当前周期的y去算,就会形成组合环路,综合工具会报错或者生成不可预期的电路。正确做法是把y[n]先打一拍再送入反馈延迟链,保证反馈用的是上一个时钟周期的稳定值。这个"打一拍"的代价是引入了一个额外的时钟延迟,对滤波器的频率响应有轻微影响,但在48kHz采样率、50MHz系统时钟下,这个延迟相对于采样周期可以忽略。
乘法器的实现方式我选的是并行乘法+流水线寄存器。五个乘法器并行工作,每个乘法器后面跟一级流水线寄存器,加法树也分级流水。这样虽然多用了一些寄存器,但能把关键路径压短,系统时钟跑到100MHz以上毫无压力。如果你资源紧张,也可以改成分时复用一个乘法器,但控制逻辑会复杂不少,对于学习项目来说没必要。
4. Vivado工程实操全流程
4.1 工程搭建与文件组织
打开Vivado,新建工程,器件选型按你手头的开发板来,我这里不绑定具体型号,选一个中等规模的就行。工程建好后,我习惯按功能分目录组织文件:rtl/放设计源码,sim/放Testbench,constr/放约束文件,scripts/放生成系数和仿真数据的Python脚本。这个习惯看着不起眼,但项目稍微大一点之后,文件乱放会让你找东西找到怀疑人生。
RTL部分我拆成三个文件:iir_biquad.v是单个二阶节的实现,iir_cascade.v是顶层级联模块,iir_top.v做输入输出的位宽适配和时序对齐。这种分层写法的好处是,二阶节模块可以单独仿真验证,确认没问题后再级联,出问题时定位范围小很多。
约束文件里主要约束系统时钟。我用的时钟频率是50MHz,周期20ns。输入输出端口不需要额外约束,因为这是纯内部处理模块,数据流由Testbench驱动。如果你要接实际ADC,那输入延迟约束得另外加,这里不展开。
4.2 二阶节模块的Verilog实现要点
先看核心的乘累加部分。系数我定义为parameter,方便例化时传入不同的值:
module iir_biquad #( parameter signed [13:0] B0 = 14'sd34, parameter signed [13:0] B1 = 14'sd69, parameter signed [13:0] B2 = 14'sd34, parameter signed [13:0] A1 = -14'sd15612, parameter signed [13:0] A2 = 14'sd7469 )( input wire clk, input wire rst_n, input wire signed [15:0] x_in, output wire signed [15:0] y_out );延迟链用移位寄存器实现,注意复位时全部清零。乘法部分,输入16位乘系数14位,结果用30位接(实际28位够,留2位余量)。五个乘积送加法树,加法树我写成两级:第一级三个相加,第二级两个相加再加第一级结果,这样关键路径短。
// 延迟链 reg signed [15:0] x_d1, x_d2; reg signed [15:0] y_d1, y_d2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin x_d1 <= 16'sd0; x_d2 <= 16'sd0; y_d1 <= 16'sd0; y_d2 <= 16'sd0; end else begin x_d1 <= x_in; x_d2 <= x_d1; y_d1 <= y_out; y_d2 <= y_d1; end end乘法结果我统一用30位有符号数接,然后做符号扩展对齐到32位再相加。这里有个坑:Verilog里不同位宽的有符号数相加,如果不做符号扩展,高位会被补零而不是补符号位,导致负数运算出错。我的做法是每个乘积先显式符号扩展到32位,再进加法树,虽然多写几行代码,但避免了隐蔽的位宽bug。
累加结果右移16位得到输出。注意右移有符号数在Verilog里默认是算术右移(补符号位),但为了保险我一般用$signed()显式转换一下。输出还要做饱和处理——虽然理论上滤波器输出不会超过输入范围,但量化误差和瞬态响应可能导致溢出,加个饱和逻辑更稳妥。
4.3 级联与顶层连接
顶层模块例化两个二阶节,第一个节的输出接第二个节的输入。这里要注意中间结果的位宽:第一个节输出16位,直接作为第二个节输入没问题,但如果你想让精度更高,可以保留第一个节累加结果的更多位传给第二个节,最后再截位。我这次为了结构清晰,每个节都截到16位输出,实测精度够用。
顶层还要处理输入数据的格式转换。Testbench送进来的数据我设计成16位有符号整数,直接对应ADC的原始码值。输出同样是16位有符号整数,方便和参考模型对比。
4.4 Testbench设计与仿真激励生成
Testbench的核心任务是:生成一段包含多个频率成分的测试信号,送进滤波器,同时用Python算一个浮点参考结果,把两者对比。测试信号我用三个正弦波叠加:200Hz(通带内)、1kHz(截止频率附近)、5kHz(阻带),幅度各占三分之一,避免叠加后溢出。
// 简化示意,实际用查找表或DDS生成 initial begin for (i = 0; i < 4800; i = i + 1) begin stim[i] = $rtoi(10000*$sin(2*3.14159*200*i/48000) + 10000*$sin(2*3.14159*1000*i/48000) + 10000*$sin(2*3.14159*5000*i/48000)); end end激励数据我提前用Python生成好,存成文本文件,Testbench用$readmemh读进来。这样比在Verilog里现算正弦要灵活得多,改测试信号只需要改Python脚本重新生成。参考输出同样用Python的scipy.signal.lfilter算好存文件,仿真时逐点对比。
注意:
$readmemh读的是十六进制文本,负数要写成补码形式。我一开始直接写十进制负数进去,读出来全是错的,查了半天才发现格式问题。Python生成文件时用format(val & 0xFFFF, '04x')转成16位补码十六进制最省事。
4.5 仿真结果判读与频响验证
跑完仿真,把滤波器的输出和Python参考输出画在一起对比。我一般导出成CSV,用Python的matplotlib画图,比Vivado自带的波形查看器方便。重点看三个指标:通带内的幅度误差、截止频率附近的衰减斜率、阻带抑制能力。
实测结果:200Hz分量通过后幅度基本不变(误差<0.5%),1kHz分量衰减约3dB(符合截止频率定义),5kHz分量衰减超过40dB。和浮点参考对比,定点实现的输出偏差在±2个LSB以内,这个精度对于16位系统来说完全合格。
如果发现偏差偏大,排查顺序是:先确认系数量化是否正确(把量化后的系数代回Python算一遍频响),再检查Verilog里的位宽和符号扩展,最后看截位方式有没有引入直流偏置。我遇到过一次输出整体偏大,最后发现是加法树里某个乘积忘了符号扩展,负数被当成大正数加进去了。
5. 踩坑实录与常见问题排查
5.1 输出发散——最吓人的那种bug
第一次跑仿真,波形前几百个点还正常,后面越来越大,最后直接顶到最大值不动了。当时心里一凉,以为滤波器不稳定。排查过程是这样的:先把量化后的系数代回Python算极点,发现极点在单位圆内,理论稳定;然后检查Verilog,发现反馈路径里y_d1的更新用的是组合逻辑算出来的y_out,而不是寄存后的值,形成了组合环路。改成寄存一拍后问题消失。
这个坑的教训是:IIR的反馈路径必须严格时序对齐,任何组合逻辑的直通都可能导致不稳定。另外,即使理论稳定,如果内部累加位宽不够导致溢出回绕,也会表现为发散。所以位宽一定要留足保护位,宁可多用几个寄存器。
5.2 输出有直流偏置
有一版仿真出来,输出波形整体往上抬了一个固定值。查下来是截位方式的问题:累加结果右移16位时,我用的是逻辑右移(补零),负数右移后高位补零变成了正数,相当于给每个负数加了一个偏移。改成算术右移(>>>)或者显式符号扩展后解决。
这个问题很隐蔽,因为波形形状看起来是对的,只是整体偏移。如果你只看波形不看数值,很容易忽略。我的建议是仿真时把输入输出都导出成数值,算一下均值,均值明显偏离零就要警惕。
5.3 频响和理论对不上
还有一次,通带内的衰减比理论值大了不少。排查发现是系数量化时小数位宽取少了,只用了10位,b0这种小系数量化后误差超过5%,导致整体增益偏低。把系数位宽从10位提到14位后,频响曲线和理论基本重合。
这里给一个经验值:系数位宽不要低于12位,推荐14到16位。10位以下基本没法用,除非你的滤波器指标非常宽松。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方式 |
|---|---|---|---|
| 输出发散 | 反馈组合环路/位宽溢出 | 检查反馈路径是否寄存,算累加最大范围 | 反馈打一拍,增加保护位 |
| 直流偏置 | 逻辑右移补零/截位不对称 | 导出数值算均值 | 改算术右移,对称截位 |
| 频响偏差大 | 系数量化精度不足 | 量化系数代回参考模型对比 | 提高系数位宽到14位以上 |
| 输出全零 | 复位未释放/系数全零 | 查复位信号和parameter传递 | 确认复位时序和例化参数 |
| 波形有毛刺 | 组合逻辑竞争/未打拍 | 看综合后的时序报告 | 关键路径加流水寄存器 |
| 仿真通过综合失败 | 组合环路/位宽不匹配 | 看综合警告信息 | 消除组合环路,统一位宽 |
5.5 几个容易被忽略的细节
第一个是复位策略。IIR滤波器有反馈,复位时必须把延迟链里的历史值全部清零,否则上电瞬间的随机值会被反馈放大。我见过有人只复位了输入延迟链,忘了输出反馈链,结果上电后输出乱跳一阵才稳定。
第二个是系数更新。如果你要做可配置滤波器(比如截止频率可调),系数不能直接改parameter,得用寄存器在运行时更新。这时候要注意更新时机——最好在输入数据流暂停的时候更新,否则新旧系数混用会导致瞬态异常。
第三个是仿真时长。IIR滤波器的瞬态响应比FIR长,因为反馈会持续一段时间。仿真至少要跑够采样率的几十倍周期,让瞬态完全衰减后再看稳态结果。我一般跑4800个点(对应48kHz采样率下的100ms),足够观察稳态。
6. 从能跑到好用——进阶优化方向
6.1 资源优化:乘法器复用与CSD编码
当前设计用了5个并行乘法器,资源消耗不算小。如果目标器件资源紧张,有两个优化方向。一是乘法器分时复用,用一个乘法器在多个时钟周期内依次完成5次乘法,代价是吞吐率下降,适合采样率不高的场景。二是CSD(规范符号数字)编码,把系数表示成少数几个非零位的移位加减组合,用移位器和加法器替代乘法器。比如系数0.625=0.5+0.125,就是右移1位加右移3位,完全不需要乘法器。这个方法对系数固定的滤波器特别有效,能省下大量DSP资源。
6.2 精度提升:级联顺序与内部位宽
两个二阶节级联,谁在前谁在后对精度有影响。一般原则是把Q值小(极点离单位圆远)的节放在前面,因为它的增益更平坦,不容易在中间级产生大幅度的信号导致溢出。我实测过两种顺序,差别不大,但在极端输入下顺序选对了确实能避免中间级饱和。
内部位宽方面,如果对精度要求极高,可以让第一个节的累加结果不截位,直接以32位精度传给第二个节,最后再统一截到16位。代价是第二个节的乘法器位宽要相应增加,资源换精度,看你的指标要求。
6.3 验证自动化:Python脚本一键对比
手动对比仿真结果和参考结果很繁琐,我写了一个Python脚本,自动读取Vivado导出的仿真数据CSV和参考数据,计算逐点误差、最大误差、均方误差,并画出对比图。每次改完代码跑一遍脚本,几秒钟就能知道结果对不对。这个脚本后来成了我所有滤波器项目的标配,强烈建议你也搞一个。
脚本的核心逻辑不复杂:用pandas读两个CSV,对齐长度,算差值,用matplotlib画三张图(时域对比、误差曲线、频响对比)。频响对比用scipy.signal.freqz分别算定点系数和浮点系数的响应,画在一起看偏差。
6.4 上板验证的注意事项
仿真通过不等于上板没问题。上板时要注意几点:输入数据的时钟域和系统时钟域是否一致,跨时钟域要加同步;输出数据的截位和饱和处理是否和下游模块匹配;如果接DAC,还要考虑DAC的建立时间和输出保持。我一般先在板子上跑一个已知频率的正弦输入,用逻辑分析仪抓输出,确认波形形状和幅度正确,再接入实际信号。
另外,上板调试时建议保留一个旁路模式,就是把输入直接送到输出,不经过滤波器。这样一旦输出异常,可以快速判断问题出在滤波器本身还是上下游接口。这个小小的设计在调试阶段能省很多时间。
7. 我个人在这个项目里的几点体会
做完这个IIR滤波器,最大的感受是:FPGA信号处理的门槛不在代码,在数学和工程直觉。Verilog语法就那么点东西,写多了自然熟,但系数量化取几位、位宽留多少保护、反馈路径怎么对齐,这些决策靠的是对定点运算和滤波器结构的理解。我建议每个学FPGA信号处理的人,都至少完整手推一次从模拟原型到数字系数、再到定点量化的全过程,不要直接抄现成的系数。推一遍之后,你对"为什么这个系数是这个值"就有了肌肉记忆,后面改参数、调指标都是水到渠成的事。
另一个体会是仿真验证的投入绝对值得。我一开始嫌写Testbench麻烦,想直接在板子上试,结果上板后输出不对,又没有参考数据,根本不知道错在哪。后来老老实实把Python参考模型和自动对比脚本搭起来,每次改代码几秒钟出结果,效率反而高得多。前期多花一小时搭验证环境,后期能省十小时debug时间,这笔账怎么算都划算。
最后分享一个小技巧:调试IIR滤波器时,先把系数设成最简单的形式验证通路——比如b0=1、其他系数全零,这时候输出应该等于输入(纯直通)。确认通路没问题后,再逐步加入其他系数,每加一个跑一次仿真。这种增量式的调试方法能把问题范围缩到最小,比一次性全加上去再大海捞针要高效得多。