做信号处理的同学基本都躲不过这个坎:数字滤波器的基本结构。教材第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型 | 2N+1 | 2N | 2N |
| 直接II型 | 2N+1 | 2N | N |
| 级联型(每节二阶) | 约2N+1 | 约2N | N |
延迟单元从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, dtype=np.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, dtype=np.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)是信号流图转置定理的直接应用:把所有箭头反向,把求和节点和分支节点互换,系统函数不变,但内部特性变化很大。它的时间域迭代形式特别适合写循环:
对于二阶节(biquad),TDF-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, dtype=np.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, bits=16): 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, worN=w) _, H_direct_q = freqz(b_q, a_q, worN=w) H_sos_q = np.ones_like(w, dtype=complex) for sec in sos_q: _, Hi = freqz(sec[:3], sec[3:], worN=w) 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位跑一遍频率响应。做完这个对比,你对这章的理解会比光看十遍书都深。之后在工程里再看到"结构选择"四个字,你就知道选的不只是画法,而是整个系统在有限精度世界里的生存方式。