Filter这个词,在不同软件里代表完全不同的东西。你在同花顺里写filter,可能是在做条件选股;你在Fiddler里找filter,大概率是在配抓包过滤规则;但如果你在信号处理团队里说Filter Realizations,那我们聊的就不是“筛选数据”,而是怎么把一个数字滤波器从差分方程变成能在MCU上实时跑的代码。这篇内容围绕滤波器实现,把设计选型、结构选择、代码落地和排查经验完整过一遍,适合正在做音频处理、传感器调理、电机控制或者通信基带的工程师,也适合刚接触DSP、想把课本理论真正跑起来的学生。别急着抄代码,先想清楚一个问题:你要的到底是哪种滤波实现。
1. 滤波器实现:先想清楚选型再动手
1.1 FIR还是IIR:没有万能的滤波器
滤波实现的第一步不是打开代码编辑器,而是选类型。数字滤波器从大方向上分成FIR(有限冲激响应)和IIR(无限冲激响应)两类,这两兄弟的脾气完全不同。
FIR的输出只和当前及过去的输入有关,没有反馈回路。你可以把它理解成“把最近N个采样点做加权平均”,所以结构上天然稳定,还能做成严格线性相位。线性相位意味着不同频率分量通过滤波器后的延迟一致,波形形状不会变形,这在心电信号、音频分频、雷达信号处理这些场景里是硬需求。
IIR就不一样了,输出会反馈回输入端,相当于“今天的输出不仅看今天的输入,还看昨天自己算出来的结果”。因为反馈的存在,相同指标下IIR需要的阶数往往只有FIR的零头,CPU开销小一个数量级。代价是相位非线性,以及高阶时对系数量化极其敏感,稍不留神极点就跑到单位圆外,滤波器直接发散。
我做音频EQ和传感器低通时,默认用IIR双二阶节级联;做心电R波检测和高品质音频分频时,老老实实上FIR线性相位。选型没有绝对对错,关键看你的应用对相位敏不敏感、算力有多少预算。
| 维度 | FIR | IIR |
|---|---|---|
| 相位特性 | 可严格线性相位 | 非线性相位,群延迟畸变 |
| 稳定性 | 无反馈,天然稳定 | 极点必须在单位圆内 |
| 实现效率 | 过渡带越窄阶数越高 | 相同指标阶数通常低很多 |
| 存储开销 | 需要N级延迟线 | 状态变量少 |
| 典型场景 | 高保真音频、心电、通信 | 语音降噪、均衡器、控制环路 |
1.2 频率规划:关键参数怎么定
很多人拿到需求就写代码,结果写出来的滤波器和实际需要完全不搭。正确做法是先列指标,再谈实现。
需要定的参数至少有这五个:采样率fs、通带截止频率fp、阻带截止频率fst、通带纹波Rp、阻带衰减As。举个例子,我在一个音频预处理项目里要滤掉5kHz以上的高频噪声,最后定的指标是这样的:
| 参数 | 指标值 |
|---|---|
| 采样率fs | 48kHz |
| 通带截止fp | 4kHz |
| 阻带截止fst | 6kHz |
| 通带纹波 | 0.5dB以内 |
| 阻带衰减 | 60dB以上 |
这里最关键的就是过渡带,也就是fp到fst这中间的2kHz。过渡带越窄,滤波器的阶数就越高,实时计算的开销就越大。很多新手为了让滤波效果“更干净”,把过渡带设得特别窄,结果FIR阶数飙到几百阶,MCU根本跑不动。
还有个容易踩的坑是归一化频率。在scipy这些工具里,如果你用butter(4, 5000/48000)这种写法,这个数是相对Nyquist点归一化的结果,不是绝对的5kHz。后面我会详细讲怎么避免。
1.3 阶数估算:先算账再动手
指标一旦明确,阶数基本就定了,这个账要在动手前算清楚。
FIR阶数可以用一个经验公式粗估:N约等于(As-8)/(2.285×Δω)。其中As是阻带衰减,单位dB,Δω是过渡带归一化宽度,单位是rad/sample。拿上面的例子算一下,Δω=2π×(6000-4000)/48000=π/12≈0.2618,代入公式N≈(60-8)/(2.285×0.2618)≈87。所以FIR大概要取到89阶到91阶,才能同时满足阻带衰减60dB和过渡带2kHz的要求。
IIR就不需要这么夸张,4阶Butterworth通常就能达到阻带斜率要求,8阶椭圆甚至能压到更窄的过渡带。这也是为什么IIR在嵌入式里那么受欢迎——省算力、省内存。
所以说,Filter Realizations的第一步不是“怎么写”,而是“写什么”。先算清楚阶数和资源开销,后面才不会被实时性问题卡住。
2. 从差分方程到可运行代码:三种经典实现结构
2.1 直接I型、直接II型到级联二阶节
滤波器的数学本质是差分方程,但同样的方程换成不同实现结构,数值行为和效率差别巨大。
IIR的差分方程长这样:y[n]=b0x[n]+b1x[n-1]+...+bMx[n-M]-a1y[n-1]-...-aNy[n-N]。最直观的写法是直接I型,把所有的b系数和a系数一次性全部算掉。这种结构很简单,但有个致命弱点:对系数量化非常敏感。如果滤波器阶数一高,系数稍微量化一下,极点位置就飘了,最终导致输出发散。
所以高阶IIR在实践中几乎不用一次性的大滤波器,而是拆成一串二阶节(biquad)串联,每个二阶节只处理两个极点。这样每个节点的系数范围小,量化误差影响也被限制在局部。这也是为什么scipy的butter函数默认返回SOS格式,而不是传统的b,a数组。
二阶节内部还分直接I型、直接II型和转置直接II型。直接II型通过引入中间状态变量,可以减少延迟单元的个数,但最常用的是转置直接II型。它的好处是每个采样点处理时,状态变量更新和乘加运算顺序很顺,对缓存和流水线更友好,在Cortex-M这类处理器上能省不少周期。
2.2 浮点还是定点:MCU上的两个世界
在PC上用double跑滤波器,你基本不需要关心数值细节。但MCU环境不一样,很多芯片没有FPU,float运算全靠软件模拟,慢到心碎;有些即使有FPU,为了省电省内存,也倾向用定点。
定点实现的核心是Q格式。比如Q15,用16位有符号数表示[-1,1)范围内的数,定标就是S=2^15。两个Q15数相乘,乘积是Q30,需要左移15位回到Q15。累加的时候要用32位累加器,否则中途溢出就直接炸了。
下面是我在STM32上写过的一个16阶FIR定点实现:
#define N 16 static int16_t coeff[N] = { /* Q15格式系数 */ }; static int16_t delay_line[N]; int16_t fir_q15(int16_t input) { int32_t acc = 0; for (int i = N - 1; i > 0; i--) { delay_line[i] = delay_line[i - 1]; } delay_line[0] = input; for (int i = 0; i < N; i++) { acc += (int32_t)coeff[i] * delay_line[i]; } acc = acc >> 15; if (acc > 32767) acc = 32767; if (acc < -32768) acc = -32768; return (int16_t)acc; }第15行和第16行是饱和处理,不是可选项。如果直接把32位结果截断成16位,信号一旦稍微大点就会产生爆音,而且这种失真非常难听。另外一个经验:移位时最好做四舍五入而不是直接截断,简单点就是(acc + 0x4000) >> 15,这样量化噪声不会积累。
2.3 实时逐样本处理与块处理
实时滤波器的数据流方式,很多人一开始没搞明白。在MCU上,ADC每采一个样本就触发一次中断,你在中断里调用一次滤波函数,这是逐样本处理。它延迟最低,但要求中断处理函数足够短,不然会拖垮整个系统。
另一种方式是块处理。ADC通过DMA把一段数据搬进内存,攒够一帧后触发中断,你在中断里处理一整帧。音频场景基本都是这么干的,I2S+DMA传128个样本,处理完再DMA送出去。块处理的好处是吞吐高,缺点是会引入一帧的延迟。48kHz采样率下,128个样本就是2.67ms,人耳对延迟的感知阈值大概在10ms以上,所以128或256样本的帧长完全可以用。
逐样本处理的代码大概是这个逻辑:
void ADC_IRQHandler(void) { int16_t cur = (int16_t)ADC1->DR; int16_t out = biquad_process(&lp_biquad, cur); DAC1->DHR12R1 = out; }这里有个关键提醒:中断处理函数里千万别放打印日志、显示刷新这类操作,一次串口输出可能比滤波本身慢几百倍,采样间隔一旦抖动,滤波器特性就会劣化。
3. 实操:用Python设计一个低通滤波器,再移植到C语言
3.1 Python设计系数:用SOS而不是b,a
我在PC上做滤波器原型,基本都用SciPy。这里设计一个48kHz采样率下截止频率5kHz的4阶Butterworth低通:
import numpy as np from scipy.signal import butter, sosfilt, firwin fs = 48000 fc = 5000 order = 4 sos = butter(order, fc, btype="low", fs=fs, output="sos") print(sos) # 如果换成FIR,89阶Hamming窗 fir_coeff = firwin(89, fc, fs=fs, window="hamming")注意output="sos"这个参数。很多人习惯写b, a = butter(...),然后拿b,a去设计。这在低阶时没问题,但一高阶就容易出数值问题。SOS格式是二阶节级联的矩阵,在浮点和定点实现里都更稳健。
滤波验证也很简单:
t = np.arange(0, 0.1, 1 / fs) sig = np.sin(2 * np.pi * 1000 * t) + 0.5 * np.sin(2 * np.pi * 9000 * t) filtered = sosfilt(sos, sig)这里用的是sosfilt,对应因果实时滤波。离线数据分析时,如果允许非因果,可以用sosfiltfilt做零相位滤波,效果更好。但实时系统里只能用因果滤波,很多人把离线脚本的filtfilt直接搬上板子,结果产生预振铃和额外延迟,这就是对因果性和实时性没分清。
3.2 验证频响:不止看一条曲线
设计完不能直接相信系数,必须验证频响。我通常用sosfreqz画幅度响应,注意要对数频率轴,这样低频段看得更清楚:
from scipy.signal import sosfreqz import matplotlib.pyplot as plt w, h = sosfreqz(sos, worN=2048, fs=fs) plt.semilogx(w, 20 * np.log10(abs(h))) plt.axvline(5000, color="r", linestyle="--", label="5kHz") plt.axhline(-60, color="gray", linestyle=":", label="-60dB") plt.legend() plt.show()确认3dB点在5kHz附近,6kHz达到-60dB。除此之外,我强烈建议用合成信号做时域验证:把1kHz和9kHz的正弦波叠在一起,滤波后做FFT,看9kHz分量是否被抑制。肉眼观察波形加FFT频谱,比只看幅频理论曲线可靠得多。因为我曾经遇到过理论频响正常,但实际信号经过后底噪异常的情况,就是靠时域测试抓出来的。
3.3 把系数搬进C:双二阶级联实现
Python里算出的SOS系数,在C里最常用转置直接II型双二阶节实现。结构体就三块:系数、状态变量、处理函数。
typedef struct { float b0, b1, b2; float a1, a2; // 注意:这里存的是差分方程中移项后的“负系数” float z1, z2; } biquad_t; float biquad_process(biquad_t *f, float x) { float y = f->b0 * x + f->z1; f->z1 = f->b1 * x - f->a1 * y + f->z2; f->z2 = f->b2 * x - f->a2 * y; return y; }系数怎么填?拿Python的SOS输出逐行转。每行是[b0,b1,b2,a0,a1,a2],C结构体里要归一化除以a0,并且注意a1和a2字段要取负号。手动转换很烦,我通常直接写个小脚本:
for section in sos: b0, b1, b2, a0, a1, a2 = section print(f"{{ {b0/a0:.10f}f, {b1/a0:.10f}f, {b2/a0:.10f}f, " f"{-a1/a0:.10f}f, {-a2/a0:.10f}f }},")4阶Butterworth会有两个二阶节,4个系数对,放在一个数组里,每个输入样本依次过两节。注意每个二阶节都有自己的状态变量,初始化必须清零,否则输出会有很大的瞬态。
如果你用的是ST、NXP这些带CMSIS-DSP库的平台,不建议自己手写这个循环。直接调arm_biquad_cascade_df1_f32,底层已经针对ARM内核做了优化,指令周期比普通写法少很多,而且经过大量验证,比自己写的函数稳。手写版适合学习、无库平台,或者你需要完全控制实现细节的时候。
4. 工程落地中我遇到的坑与排查实录
4.1 滤波后信号相位不对:群延迟和预振铃
有次做心电信号预处理,滤波后R波的峰值位置总是往后偏,导致心率计算有偏差。一开始我还以为是算法问题,查到最后发现是滤波器群延迟在作怪。
IIR滤波器在截止频率附近的群延迟会突然增大,不同频率分量经过滤波器的延迟不一样,波形自然就变形了。R波含有一定的高频分量,被延迟得比低频基线更多,位置就看歪了。
排查办法很简单:画群延迟曲线。如果应用对波形形状、事件时刻敏感,IIR的相位特性会害死人。我当时直接换成线性相位FIR,问题立刻消失,代价是阶数高了几倍,但MCU算力尚可接受。如果你必须用IIR,至少要做延迟补偿,或者在系统级把这一部分延迟算进总预算里。
我后来在电机电流环里也栽过一次。为了抑制PWM噪声,我加了一阶低通滤波,截止频率设在500Hz。结果低速运行正常,高速工况下系统开始啸叫。原因就是滤波器的相位滞后吃掉了控制环路的相位裕度。把截止频率往上调到2kHz,噪声抑制效果虽然弱了一点,但系统稳定性完全恢复。这提醒我:滤波器不只是“信号调理工具”,在闭环系统里它就是一个会引入延迟的环节。
4.2 定点实现出现爆音和底噪
定点滤波器跑起来后,最典型的问题就是输出偶尔会冒出一个大尖峰,或者安静状态下底噪明显偏大。这两个问题我都遇到过。
大尖峰的原因基本是累加器溢出。Q15格式下,输入信号接近满幅,系数接近满幅,16阶累加器在32位范围内不会溢出,但如果你用的是16位累加器,或者把累加结果直接截断成16位,一旦信号幅度稍大就会削波。配合饱和处理能明显缓解,但最好的办法还是保证全程32位累加。
底噪大的原因通常是量化噪声积累。定点乘法有舍入误差,如果不做四舍五入直接截断,误差会累积起来。我在C代码里常用的处理是:acc = (acc + 0x4000) >> 15;,相当于在右移前加半个LSB,做四舍五入。另外,你可以在Python里把系数量化成Q15,再把乘法和累加过程模拟成整数运算,对比浮点输出。这个操作十几行代码就能写完,但能帮你在上板之前发现80%的定点问题。
4.3 切换滤波器系数瞬间发生爆音
设备运行中动态调整滤波参数,比如EQ切换、带宽切换,切换瞬间往往“啪”的一声。这个爆音不是滤波算法算错了,而是滤波器内部状态变量在新参数下不匹配。
假设滤波器工作在旧系数下,延迟线和状态变量已经稳定在某个值。这时候你把系数换成新值,但状态变量还停留在旧状态,输出就会产生一个很大的阶跃。解决办法很简单:切换前把每个双二阶节的z1、z2全部清零,再做平滑过渡;或者更稳一点,用双缓存系数,先算好所有新系数,再把运行指针一次性切换过去。
如果你做的是音量渐变或EQ渐变,还可以让新旧系数按比例交叉淡入淡出,每处理一个采样点都重新计算一次当前系数,这样完全听不到切换噪声明。代价是CPU开销高一些,但对音频产品来说非常值。
4.4 截止频率和设计值对不上
有朋友拿着设计好的滤波器来找我,说明明设的是5kHz低通,实测4.2kHz就开始衰减。我第一个问题就是:你的实际采样率确定是48kHz吗?
很多板子上ADC的时钟配置有分频误差,实际采样率可能只有44.1kHz,甚至采样率在抖动。滤波器性能对采样率极其敏感,采样率偏了,截止频率自然就偏了。排查方法是用示波器或逻辑分析仪测AD转换完成中断的实际频率,不要只看寄存器配置。
另一个隐藏很深的坑是归一化频率用错。比如butter(4, 5000/48000, output='sos'),这里的5000/48000不是5kHz,而是相对Nyquist频率归一化的结果。当fs=48000时,Nyquist是24000Hz,这个参数对应实际频率是5000/48000×24000=2500Hz。正确写法是butter(4, 5000, fs=48000),或者写成butter(4, 5000/(fs/2))。每次设计时我都直接用带fs=参数的写法,避免自己换算出错。
4.5 滤波实时性不够:算力瓶颈
滤波器算不过来,直接表现就是中断处理时间超标,采样间隔开始抖动,输出噪声变大,更严重时系统直接卡死。排查的时候要区分:是滤波器本身太重,还是中断里塞了太多其他任务。
滤波器本体的开销可以用一个简单公式估算:单样本处理周期数≈阶数×每阶乘加周期数。Cortex-M4不带FPU时,一个浮点双二阶节大概要几百个周期,但如果你用的是CMSIS-DSP库,这个数字能压到几十个周期。如果还是不够,把逐样本处理改成块处理,用DMA搬数据,就能把中断频率降下来。
压测方法我常用的是:在高优先级中断里翻转一个GPIO,用示波器测高电平时间,就能看到滤波函数实际占用了多少周期。别靠猜,实测最靠谱。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 输出相位偏、波形变形 | IIR相位非线性 | 看群延迟曲线,换FIR或做延迟补偿 |
| 定点输出大尖峰 | 累加器溢出 | 用32位累加器,加饱和处理 |
| 低噪明显偏大 | 量化误差累积 | 右移前做四舍五入,Python模拟定点 |
| 切换参数瞬间爆音 | 状态变量未清零 | 切换前清零状态或做系数渐变 |
| 截止频率不对 | 实际采样率偏差或归一化错误 | 测真实采样率,用fs=写法 |
| 中断超时/卡顿 | CPU算力不足 | 换库函数、改块处理、降低阶数 |
5. 工具链与调试验证:能少踩一半坑
5.1 常用工具链:从SciPy到CMSIS-DSP
做Filter Realizations,我常用的工具链组合是Python+SciPy做原型设计,CMSIS-DSP做端侧实现,必要时再用MATLAB对照验证。
SciPy免费、脚本化、可复现,非常适合回放数据和批量实验。MATLAB的Filter Designer是图形化界面,适合对滤波器指标做交互式调整,看零点极点图、群延迟曲线都方便,但商业授权很贵。Octave是免费的MATLAB替代品,基本功能齐全。CMSIS-DSP是ARM官方DSP库,里面有FIR、IIR、Biquad的各种定点和浮点实现,我只要在STM32/NXP平台上做音频和信号处理,基本都基于它来写。
还有个调试利器是Audacity这类音频编辑软件。你可以生成扫频信号丢进板子,录音回PC,直接看频响曲线。实测频响和设计频响一对比,问题立刻暴露。
5.2 离线到在线的正确验证姿势
很多问题其实可以在上板前就发现,关键是建立一套“离线验证→在线确认”的流程。
第一步,在Python里用sosfilt处理一段真实采集的原始数据,确认滤波效果符合预期。第二步,把同一段原始数据经过你写的C代码滤波,把输出导出回PC,和Python输出做比对。浮点实现误差应该在1e-5量级,定点实现误差也应控制在1%以内。第三步,再上板跑,用DAC输出或用串口导出滤波后数据回PC分析。
我每次做新滤波器都会走这套流程,虽然前期多花一点时间,但能避免直接在板子上盲调,效率反而高得多。尤其是定点实现,Python模拟到位了,板子上基本不会出大问题。
我自己在实际操作中的体会是:滤波器实现翻车的地方,很少是“数学公式看不懂”,而是设计、实现、验证三个环节脱节。设计时没算清楚阶数和延迟预算,实现时没注意结构选型和数值细节,验证时只看理论曲线不看真实信号,一环扣一环,问题就来了。每做一版滤波器,我都会先写清指标、算好开销、把定点量化在Python里模拟一遍再部署。这套习惯帮我在电机控制、音频前端、传感器调理上省了大量调试时间。最后一个建议是:尽量用现成库,比如CMSIS-DSP,除非你很清楚为什么要自己写。滤波实现这件事,稳定、可控、可复现,比什么都重要。