做信号处理的同学基本都躲不过这个坎数字滤波器的基本结构。教材第5章放了一堆信号流图直接I型、直接II型、转置、级联、并联乍一看像是同一个传递函数换了几种画法纯粹在玩纸面游戏。我以前也这么觉得直到后来在一颗定点DSP上把一个4阶IIR滤波器从教材的标准形式搬到代码里输出怎么调都不对才意识到这一章讲的全是工程里最容易踩坑的东西。系数被量化后偏差多大、噪声从哪里进来、稳定性什么时候悄悄崩掉全都藏在这些看起来等价的结构图里。这篇文章不打算照着教材目录把每种结构念一遍而是想把这一章拆成几个真正值得花时间的问题为什么同一个系统函数需要这么多结构直接型、级联型、并联型各自在什么场合能活下来有限字长效应是怎么在暗处毁掉你的滤波器最后我会给出实际写代码验证这些结构的完整过程以及踩过的坑。适合正在学数字信号处理的学生、准备把滤波器搬进嵌入式或者FPGA的工程师哪怕是只想搞明白教材上那些框图到底有什么用的人读完都能对着具体问题挑出合适的结构。1. 从差分方程到结构图先搞清楚这章到底在讲什么1.1 同一个H(z)为什么需要好几种结构任何数字滤波器都能写成N阶差分方程y[n] a1*y[n-1] ... aN*y[n-N] b0*x[n] b1*x[n-1] ... bM*x[n-M]对应系统函数H(z) B(z) / A(z) (b0 b1*z^-1 ... bM*z^-M) / (1 a1*z^-1 ... aN*z^-N)第一次接触这章的人最容易产生一个疑问既然H(z)已经唯一确定了输入输出关系那直接型、级联型、并联型不是多此一举吗这个问题恰恰是这一章的核心。H(z)描述的是理想情况下的输入输出关系它假设系数是无限精度的运算过程也是无限精度的。但实际实现时系数要被量化成有限位宽乘法结果要舍入中间信号可能溢出噪声会沿着反馈回路被放大。换句话说H(z)是数学抽象结构图是物理实现同一个抽象在物理世界里的不同投影表现天差地别。举个生活化的例子同样一份食材先切再炒和先炒再切最后成品可能差不多但过程中需要的锅、铲、耗时完全不同。滤波器结构也一样H(z)是食谱结构图是后厨操作流程流程不同需要的存储量、运算量、出错风险都不一样。1.2 信号流图的基本元素和复杂度指标信号流图里只有三种基本元素延迟单元、乘法器、加法器。延迟单元在硬件里就是一个寄存器负责存储上一拍的信号乘法器和加法器对应运算单元负责完成乘加操作。三种元素的搭法决定了这个结构的成本和质量。评价一个滤波器结构工程上就看这几个指标指标含义对实现的影响延迟单元数量存储一个历史采样值需要多少寄存器决定内存/寄存器开销乘法器数量每个输出样本需要多少次乘法决定计算量和功耗加法器数量每个输出样本需要多少次加法决定计算量和延迟中间信号动态范围内部节点的信号可能放大多少倍决定定点实现是否容易溢出系数量化敏感度系数偏差一点频率响应和极点位置偏多少决定低精度下还能否稳定工作以N阶IIR滤波器分子分母同为N阶为例最常见的对比数值结构乘法器数加法器数延迟单元数直接I型2N12N2N直接II型2N12NN级联型每节二阶约2N1约2NN延迟单元从2N降到N这就是直接II型被称为典范型的原因。但省下来的存储不是免费的代价是内部信号动态范围变大、噪声特性变差这个后面会详细说。这一章的整个逻辑脉络其实就是怎么在成本和风险之间做交换。没有绝对最好的结构只有最匹配场景的结构。2. IIR滤波器结构直接型、级联型、并联型怎么选2.1 直接I型从差分方程抄出来的结构直接I型是最不用动脑子的实现。看差分方程一眼输入x[n]一路经过所有b系数加法器形成前馈支路输出y[n]再经过所有a系数反馈支路两条支路各自使用一条延迟线互不干扰。实现代码也最直观def df1(b, a, x): 直接I型输入延迟线和输出延迟线分开 y np.zeros_like(x, dtypenp.float64) xb np.zeros(len(b)) # 输入历史缓存 yb np.zeros(len(a) - 1) # 输出历史缓存 for n in range(len(x)): xb[1:] xb[:-1] xb[0] x[n] y[n] np.dot(b, xb) - np.dot(a[1:], yb) yb[1:] yb[:-1] yb[0] y[n] return y这段代码和差分方程几乎一字不差所以学生学习时最容易接受。它的一个实际好处是如果滤波器阶数不高、精度要求也不极端直接I型写起来最简单不容易出逻辑错误。但直接I型的缺点很明显需要两条延迟线存储量翻倍。而且前馈部分先对输入做一次FIR滤波再进入反馈部分中间信号可能会被b系数放大然后在反馈回路里继续折腾定点实现时动态范围控制比较麻烦。所以工程上除非就是写个demo验证算法否则很少直接上直接I型。2.2 直接II型省一半存储却把风险集中了直接II型的出发点是把H(z)拆成两部分H(z) B(z) * (1 / A(z))先做反馈分母再做前馈分子。因为两个部分共用同一条延迟线延迟单元从2N减到N。定义中间序列w[n]w[n] x[n] - a1*w[n-1] - a2*w[n-2] - ... - aN*w[n-N] y[n] b0*w[n] b1*w[n-1] ... bN*w[n-N]写成代码def df2(b, a, x): 直接II型典范型共用一条延迟线 N max(len(a) - 1, len(b) - 1) # 统一长度方便索引 a_pad np.zeros(N 1) b_pad np.zeros(N 1) a_pad[:len(a)] a b_pad[:len(b)] b w np.zeros(N) # w[n-1], w[n-2], ..., w[n-N] y np.zeros_like(x, dtypenp.float64) for n in range(len(x)): # 先算 w[n] w_new x[n] - np.dot(a_pad[1:], w) # 再算 y[n] y[n] b_pad[0] * w_new np.dot(b_pad[1:], w) # 状态更新 w[1:] w[:-1] w[0] w_new return y直接II型就是很多教材说的典范型因为它在保证同样系统函数的前提下用了最少的延迟单元。但这个最省是有代价的。代价第一点是动态范围。w[n]实际上是输入经过一个纯极点系统1/A(z)的输出。如果滤波器有靠近单位圆的极点也就是通常说的高Q现象w[n]的幅度可能远大于输入和输出。我在调试一个窄带带通滤波器时遇到过这种问题输出信号明明正常内部w[n]却已经大得快要溢出定点数一溢出输出全是毛刺。换成转置结构才把这个问题压下去。代价第二点是舍入噪声。直接II型的舍入误差产生在w[n]的反馈回路里会被极点回路一遍遍放大这个问题我会在第4节展开。2.3 转置直接II型工程实现里的默认选项转置直接II型TDF-II是信号流图转置定理的直接应用把所有箭头反向把求和节点和分支节点互换系统函数不变但内部特性变化很大。它的时间域迭代形式特别适合写循环对于二阶节biquadTDF-II的伪代码非常干净s1 0 s2 0 for 每个输入样本 x: y b0 * x s1 s1 b1 * x - a1 * y s2 s2 b2 * x - a2 * y对应的完整实现def tdf2(b, a, x): 转置直接II型误差注入点靠近输出噪声累积少 N max(len(a) - 1, len(b) - 1) a_pad np.zeros(N 1) b_pad np.zeros(N 1) a_pad[:len(a)] a b_pad[:len(b)] b state np.zeros(N) # s0, s1, ..., s_{N-1} y np.zeros_like(x, dtypenp.float64) for n in range(len(x)): y_n b_pad[0] * x[n] state[0] for k in range(N - 1): state[k] b_pad[k 1] * x[n] - a_pad[k 1] * y_n state[k 1] state[N - 1] b_pad[N] * x[n] - a_pad[N] * y_n y[n] y_n return y为什么工程上默认选它核心原因是舍入噪声的传播路径变了。TDF-II的每个乘法器舍入误差叠加在靠近输出的节点上后续只经过前馈路径到达输出不会再被反馈回路一遍遍回灌放大。尤其在高Q窄带滤波器里TDF-II比直接II型的输出噪声低很多这是我在实际项目里反复验证过的。另外TDF-II还有一个硬件上的好处每个输出样本的计算可以组织成一条流水线乘法器和加法器的关键路径短适合FPGA和ASIC实现。很多数字信号处理库默认用的就是它比如主流科学计算库里的lfilter底层实现就是转置直接II型。2.4 级联型和并联型高阶级联滤波器的正确打开方式阶数一上去直接型基本就不适合实战了。拿一个10阶切比雪夫滤波器举例直接用直接II型实现系数只要量化到16位极点就可能被推到大on the unit circle外面滤波器直接从稳定变成不稳定。原因在于高次多项式A(z)的系数和根之间的关系非常敏感极点越是聚集在一起系数上的一点点偏差对极点位置的扰动就越大。级联型的思路是把H(z)因式分解成多个一阶或二阶节的乘积H(z) H1(z) * H2(z) * ... * Hs(z)每一节通常是一个二阶节biquad分子分母分别对应一对共轭零点和一对共轭极点。这样原来一个10阶滤波器就变成了5个互不干扰的二阶节每个节的极点只受本节两三个系数影响量化敏感度大大降低。级联型工程上一般用scipy的tf2sos直接转换from scipy.signal import tf2sos, sosfilt sos tf2sos(b, a) # 自动做零极点配对和节顺序排列 y sosfilt(sos, x)这里有两个容易被忽略的经验点。第一个是极点-零点配对不是随便把任意零点配给任意极点而是把频率上相近的零点和极点配在同一节这样每节的幅频响应峰谷相互抵消中间信号不会有过大幅值。第二个是节的排列顺序通常把极点靠近单位圆的节放在后面防止前面的节先把信号放大到危险水平。scipy的tf2sos默认做了这些处理但如果你手写定点实现这两点必须自己考虑。并联型则是对H(z)做部分分式展开H(z) A0 H1(z) H2(z) ... Hs(z)每一节也是二阶节但各节的输出直接相加。并联型的优点是各节彼此独立适合并行计算缺点是需要的系数数量通常比级联型多而且部分分式展开后各节的分子系数可能有负数定点实现时动态范围控制更费心思。实际项目里我很少用它除非要并行化或者需要单独调节某个谐振峰的增益。从工程选择上看IIR滤波器的梯队很清楚低阶1到4阶用单个TDF-II biquad足够高阶一律拆成级联biquad每节内部用TDF-II遇到特定并行需求再考虑并联型。3. FIR滤波器结构直接型、转置型和线性相位3.1 直接型与转置直接型FIR滤波器没有反馈回路系统函数只有零点没有极点稳定性天然是好的。差分方程极其简单y[n] h0*x[n] h1*x[n-1] ... h_{M-1}*x[n-(M-1)]直接型实现就是一条延迟线每个延迟单元抽头乘上对应的h[k]再加起来。这个结构简单到很难出错也是理解卷积的最直观方式。转置直接型FIR是同一系统的转置网络。它没有减少乘法次数但改变了一个很重要的工程性质直接型FIR每来一个新样本要先把乘法结果全部累加完才能输出累加链路的延迟会随滤波器长度增长转置型FIR每个延迟单元的输入反而是上一级加法器的输出乘法可以并行执行每个抽头只需要一个乘法器加一个加法器关键路径短非常适合高吞吐量的FPGA实现。一个很容易混淆的点直接型FIR的状态是输入历史转置型FIR的状态是部分和。两者输出数学上完全一样但定点实现时转置型的舍入误差是各级加法误差的直接叠加不会像递归结构那样被放大所以FIR里我更倾向用转置型。3.2 线性相位结构对称系数带来的免费优化工程里绝大多数FIR都是线性相位滤波器它的单位脉冲响应满足对称或反对称关系h[n] ±h[M-1-n]因为系数对称乘法和加法可以配对y[n] h0 * (x[n] ± x[n-(M-1)]) h1 * (x[n-1] ± x[n-(M-2)]) ...以5抽头偶数对称FIR为例直接型需要5次乘法线性相位结构只需要3次y[n] h0*(x[n] x[n-4]) h1*(x[n-1] x[n-3]) h2*x[n-2]乘法次数从M降到ceil(M/2)。片上或者FPGA里乘法器数量往往比加法器更稀缺所以这个优化几乎是白捡的。如果你的滤波器恰好是对称系数却还用普通直接型等于白白浪费一半乘法器。要注意的是线性相位结构只改运算组织方式不改H(z)也不需要额外存储纯粹是免费午餐。3.3 级联与频率采样结构FIR的级联型就是把H(z)因式分解成若干低阶FIR节相乘。因为FIR本身就稳定级联不是为了稳定性更多是为了资源复用或者分块实现。但系数量化后各节的零点也会偏移级联型的零点分布敏感度同样比直接型低在高阶FIR里偶尔有用。频率采样结构在教材里会提但工程里几乎不用。它的思路是用H(z)在单位圆上的等间隔采样值也就是DFT系数来构造结构理论漂亮实现起来系数是复数的量化后零点偏移很严重数值性能差。我见过不少学生花很长时间去研究它最后在真实产品里一次都没用上。如果真的对某一类固定窄带滤波有需求频率采样结构可以做快速原型但作为产品实现还是绕道吧。4. 有限字长效应课本一笔带过的实战杀手4.1 系数量化极点在悄悄搬家实际硬件里系数不能存无限精度的小数要量化成有限位宽的定点数。系数一动系统的零点和极点就跟着动。对于IIR滤波器最危险的是极点。极点对系数误差的敏感度和分母多项式A(z)在这附近的导数值有关。换句话说当极点们靠得很近时那几个极点的位置对系数的微小变化极其敏感。高阶级联滤波器的极点通常挤在一起直接型实现相当于用一个高次多项式去支撑所有极点任何一个系数的量化误差都会分摊到所有极点上结果就是个别极点被推出单位圆滤波器失稳。同样的滤波器拆成级联biquad后每个二阶节只需要支撑一对共轭极点系数少、结构浅量化误差对极点的影响被限制在局部。这就是级联型在定点实现里的核心价值。我做16位定点实现时有个不成文的经验超过4阶的IIR一律级联绝不用直接型省下的每次折腾都是血泪教训换来的。4.2 舍入噪声与极限环振荡定点乘法器做完乘法后必须把结果舍入回目标位宽每舍入一次就引入一次误差。在递归结构里这些误差不是一次性消失的它们会被输入到反馈回路里被极点放大最终在输出端产生一个噪声底这就是舍入噪声。我第2节里说TDF-II比DF-II噪声低原理就在这里。DF-II的舍入误差直接落在w[n]上而w[n]本身是纯极点系统的输出对极点最敏感误差等于被最强的共振放大了一圈TDF-II的误差接入点更靠近输出端后续经过的路径短、增益小。高阶高Q滤波器两者噪声差异可能达到几十倍这不是理论上的边角差别是能直接用示波器看到的差别。还有一个诡异的现象叫极限环。递归滤波器加上舍入操作后系统变成非线性系统可能出现零输入但输出不为零的情况。输入变成0之后输出还在一圈圈绕着某个值打转就像钟摆最终停在某个位置附近而不是精确停在零。极限环的幅度跟舍入步长直接相关位宽越低越明显。在音频应用里这种情况可能表现为静音时背景有一层微弱的嗡嗡声。消除极限环最常用的手段是用幅度截断magnitude truncation替代四舍五入或者在结构上保证反馈路径里的状态逐步衰减。4.3 定点还是浮点工程取舍说一句大实话能用浮点就用浮点。浮点虽然也有舍入误差但动态范围大不用天天盯着中间信号会不会溢出开发效率高很多。现在的ARM Cortex-M系列带FPU的型号已经很便宜很多产品完全可以靠浮点糙着过。但定点还是绕不开原因有两个低功耗MCU和FPGA里浮点运算单元还是很奢侈另外浮点运算在多个处理器上不保证bit级一致而某些通信和音频系统要求完全确定性输出定点才是唯一选择。定点实现的核心就一句话先做缩放再做运算最后检查溢出。缩放是为了让内部信号的最大幅度不超过定点表示范围这就要分析中间各节点的动态范围通常用频谱估计或者直接跑最坏情况信号来测。千万不要想起来才加个移位滤波器的动态范围分析应该和滤波器设计同步做。5. 用Python亲手验证结构等价性不是嘴上说的5.1 三种直接型的手写实现理论讲再多不如跑一次。我建议每个人都亲手写一遍这三种结构再用同一个滤波器验证输出一致性。上面第2节已经给出了df1、df2、tdf2三个函数现在把它们放到一起做个测试。核心验证逻辑生成一个6阶IIR滤波器输入脉冲序列然后对比三种结构的输出和标准库lfilter的输出import numpy as np from scipy.signal import butter, lfilter b, a butter(6, 0.25) x np.zeros(64) x[0] 1.0 # 单位脉冲 y1 df1(b, a, x) y2 df2(b, a, x) y3 tdf2(b, a, x) y_ref lfilter(b, a, x) print(np.max(np.abs(y1 - y_ref))) print(np.max(np.abs(y2 - y_ref))) print(np.max(np.abs(y3 - y_ref)))在float64精度下三种结构的输出和lfilter的差异应该在1e-12量级。这才是结构等价的意思同一个H(z)在理想精度下给出相同结果。如果哪个实现对不上先检查状态初始化是不是全零再检查延迟线更新顺序多半是索引错了。5.2 级联型实现与一致性检查再用tf2sos把同一个6阶滤波器转成级联biquad手动逐节过滤来验证级联型的等价性from scipy.signal import tf2sos, freqz sos tf2sos(b, a) # 3个biquad def sos_filter(sos, x): y x.copy() for sec in sos: b_s sec[:3] a_s sec[3:] y tdf2(b_s, a_s, y) return y y4 sos_filter(sos, x) print(np.max(np.abs(y4 - y_ref)))这里注意一个细节tf2sos返回的每一行是[b0, b1, b2, a0, a1, a2]其中a0一般等于1.0。我直接把它传给tdf2代码里并不假设a0是1还是其他值所以没问题。如果这个一致性检查都过不了多半是节之间传递信号的时候出了问题或者某个节写成了从后往前过滤。级联型是串行结构上一节输出就是下一节输入这个顺序不能反。5.3 系数量化实验直接型和级联型的差距有多大接着做教材最喜欢讲但很少演示的实验把系数量化到16位看看直接型和级联型的频率响应各变成什么样。def quant(x, bits16): step 2 ** (-(bits - 1)) return np.round(x / step) * step b_q, a_q quant(b), quant(a) sos_q quant(sos) # 对每个biquad的系数分别量化 w np.linspace(0, np.pi, 2048) _, H_orig freqz(b, a, worNw) _, H_direct_q freqz(b_q, a_q, worNw) H_sos_q np.ones_like(w, dtypecomplex) for sec in sos_q: _, Hi freqz(sec[:3], sec[3:], worNw) H_sos_q * Hi把H_orig、H_direct_q、H_sos_q画在同一张图上你会看到非常典型的结果直接型量化后的幅频响应已经偏离原始曲线不少在阻带甚至可能出现奇怪的尖峰级联型量化后虽然也有偏差但整体形状还在。如果换成10阶滤波器直接型很可能在阻带出现明显凸起甚至超出幅值1那就是极点跑出单位圆的前兆。这个实验很便宜但说服力极强。我用它给不少初学DSP的朋友解释过为什么要避开高阶级联直接型看一眼曲线比背十遍口诀都有用。如果还想看舍入噪声的差异可以把三种结构全部用float32存储状态变量输入白噪声后再对比输出残差。只要极点Q值够高TDF-II的残差通常比DF-II小一个数量级以上。建议自己动手把df2和tdf2改成float32版本跑一遍亲眼见到这个差距之后后面选型心里就有底了。6. 常见问题排查与选择建议6.1 典型问题速查表实际操作中遇到的问题千奇百怪但归纳起来就那几类现象可能原因排查方向相同系数直接型输出噪声底特别高舍入噪声在反馈回路里被极点放大换用TDF-II或级联biquad高阶级联滤波器量化后失稳直接型的极点对系数量化太敏感拆成二阶节级联检查极点配对输入为零但输出仍有振荡极限环现象改用幅度截断量化检查零输入响应浮点仿真正常定点一跑就溢出中间动态范围没做缩放分析分析各节点满幅值设计缩放移位级联结构输出和直接型明显不同节间信号传递方向反了或状态未清零检查滤波顺序确认初始状态全零频率响应低频正常高频乱跳可能是级联排序不当导致中间信号过大重新做零极点配对和节排序这里我想单独强调最后一行。级联型的节顺序不是随便排的scipy的tf2sos默认有它自己的启发式规则但你在手动定点实现时必须自己考虑。原则是先把频响峰谷接近的极点和零点放在一起再把增益最大的节往后放避免中间节点信号爆掉。这两条规则没有绝对数学最优解很多情况下要靠仿真对比。6.2 实际项目里的选择建议给一套我踩了无数坑之后形成的选择模板可以直接抄FIR滤波器优先转置直接型能重新利用对称系数就上线性相位结构。FIR没有稳定性问题主要优化目标是乘法器和流水线深度。IIR滤波器阶数不超过4单个TDF-II biquad代码简洁、噪声特性好是默认选项。IIR滤波器阶数超过4无脑级联biquad每节内部用TDF-II。除非有并行计算需求否则先不碰并联型。定点实现先做动态范围分析再定缩放最后才写结构。顺序反了后面要么不停加浮点补丁要么天天跟溢出做斗争。浮点实现也别太得意float32一样有舍入误差。高Q滤波器照样用TDF-II或级联浮点只是把动态范围问题推迟了没有消灭它。选型的时候不要只看乘法器数量那是纸面功夫。真正决定项目成败的往往是两件小事中间信号会不会溢出噪声会不会被反馈放大。这两点恰好都是结构直接决定的。我个人在实际操作中的体会是数字滤波器结构这一章绝大多数人不是学不会而是学得太干净了。教材把每种结构画得整整齐齐却很少告诉你直接II型在定点芯片上可能因为中间状态溢出而彻底无法工作也很少告诉你级联型的零极点配对排序做不好照样出问题。如果你正在啃这一章建议看完这篇文章后做一件事把手头的某个滤波器用至少三种结构实现一遍再分别量化到16位跑一遍频率响应。做完这个对比你对这章的理解会比光看十遍书都深。之后在工程里再看到结构选择四个字你就知道选的不只是画法而是整个系统在有限精度世界里的生存方式。