资讯动态

纯整数二阶IIR滤波器:低端MCU上的高效低通实现方案

发布时间:2026/10/15 3:34:35 来源:尧图企业网站定制
前不久我调试一块小板上的振动传感器信号时遇到了很典型的嵌入式滤波场景ADC原始数据毛刺多想加个低通但手头MCU内核不带FPU直接上浮点二阶IIR一次滤波要做十几遍float乘加放到定时器中断里跑完后主循环都快被挤没了。后来我把滤波器改成纯整数版本整个处理变成几个32位整数乘法和移位CPU占用和代码体积一起掉下来波形还干净了。这篇文章就把我从原理到落地的完整过程摊开写围绕纯整数二阶滤波器这条主线把系数怎么算、代码怎么写、溢出怎么防、验证怎么做、部署时哪些细节会翻车都讲清楚。如果你正在做传感器信号调理、ADC平滑、电机电流滤波这类嵌入式应用或者只是好奇在8位/16位小MCU上怎么实现一个像样的IIR滤波器这篇应该能帮你省下不少试错时间。1. 为什么嵌入式里绕不开“纯整数二阶”这个组合1.1 浮点IIR在低端MCU上的真实成本先算一笔实际账。一个典型的二阶IIR滤波器差分方程长这样y[n] b0x[n] b1x[n-1] b2x[n-2] - a1y[n-1] - a2*y[n-2]如果直接用浮点每个采样点需要5次浮点乘法和4次浮点加法。听起来不多但注意浮点不是一次乘法那么简单。在没有任何FPU的Cortex-M0内核上一次float乘法的软件模拟可能要几十甚至上百个周期一次滤波下来几百个周期就没了。采样率如果到1kHz每秒就是几十万周期这对很多低功耗或高负载应用来说是完全不可接受的。我在实际项目里碰到过一个例子设备采集6路模拟信号采样率2kHz每路想做个二阶低通。用浮点版本在中端M0芯片上跑中断服务函数里光是滤波就占了将近一半CPU时间剩下还要做协议解析、显示刷新、按键扫描整个系统被拖得很紧。换成纯整数实现后同样的滤波任务只用了不到原来五分之一的周期而且不需要任何浮点库支持链接体积也小了一截。这就是标题里“纯整数”三个字的价值所在。1.2 二阶是性价比最高的折中点有人会问一阶不行吗一阶确实简单只有一个状态变量运算是 y[n] y[n-1] k*(x[n]-y[n-1]) 这类形式但它的衰减斜率只有20dB/dec很多信号和噪声靠得比较近的场合一阶滤波根本分不开。四阶、八阶滤波器精度当然更好但状态变量成倍增加系数对量化误差更敏感溢出管理也更复杂在8位或16位MCU上属于给自己找麻烦。二阶巴特沃斯低通是工程上最常见的折中滚降斜率40dB/dec临界阻尼因子Q0.707时域上过冲很小阶跃响应超调大约在4%左右相位滞后也不至于太离谱。更关键的是二阶IIR的系数关系非常清晰极点可以直接算量化后的稳定性也容易检查。如果需要更高阶还可以用两个二阶节级联工程上叫Biquad级联思路是一模一样的。所以先把二阶吃透等于给后续扩展打好了基础。1.3 纯整数带来的确定性红利除了省CPU纯整数实现还有一个经常被人忽略的好处确定性。浮点运算在不同编译器优化级别下乘法顺序稍微变化结果最后一位可能不一样这在某些需要严格回放记录的场合很敏感。纯整数实现只要数据类型一致、运算顺序固定在什么芯片上跑出来结果完全一样。这个特性在做设备离线分析、故障回放、产线标定的时候特别有用。比如你把MCU采集的原始数据导入上位机做离线处理上位机用同样的整型算法复现现场结果能和设备端逐位对上。浮点版本就很难做到这种一致性。所以即便MCU带FPU很多对可复现性有要求的项目也宁可选择纯整数滤波器。2. 从模拟低通到整型系数一次完整的推导2.1 差分方程与直接I型结构的选择IIR滤波器的数字实现有好几种结构最常用的是直接I型和直接II型。两者的传递函数完全一样区别在于存储中间状态的方式不同。直接I型的差分方程就是文章开头那个式子y[n] b0x[n] b1x[n-1] b2x[n-2] - a1y[n-1] - a2*y[n-2]它保存的是输入历史x[n-1]、x[n-2]和输出历史y[n-1]、y[n-2]每个状态变量都是真实可测的信号值范围天然受限于信号量程。直接II型把两个差分方程合并少存两个状态变量但中间那个状态节点的动态范围可能远大于输入输出范围需要额外做位宽预算一旦忽略就容易在中间环节溢出。我在这篇文章里选择直接I型原因很简单状态变量可控、位宽好分析、调试时随便抓一个变量都能看懂物理含义。对嵌入式工程来说可预测性比省那一点点RAM更重要。RAM不够一个二阶滤波器多4个int32也就16字节几乎可以忽略。2.2 100Hz采样、10Hz截止的系数计算全过程数字滤波器的系数不能直接从模拟RC公式套必须从模拟原型转换过来。这里用最经典的双线性变换配合预畸变处理这样数字滤波器在截止频率附近的频率响应能和模拟原型对得更准。一个二阶巴特沃斯低通在s域的传递函数是H(s) 1 / (1 1.4142*(s/ωc) (s/ωc)²)其中ωc 2πFc是模拟截止角频率。采用双线性变换时先把截止频率做预畸变得到数字域的K值K tan(π*Fc/Fs)然后代入公式算出各系数b0 K² / (1 1.4142K K²) b1 2K² / (1 1.4142K K²) b2 K² / (1 1.4142K K²) a1 2(K²-1) / (1 1.4142K K²) a2 (1 - 1.4142K K²) / (1 1.4142K K²)用Fs100Hz、Fc10Hz来演算。先算K tan(π*10/100) tan(0.1π) ≈ 0.32492 K² ≈ 0.10557 1.4142K ≈ 0.45951分母1 1.4142K K² ≈ 1 0.45951 0.10557 1.56508于是b0 0.10557 / 1.56508 ≈ 0.06745 b1 2b0 ≈ 0.13491 b2 b0 ≈ 0.06745 a1 2(0.10557-1) / 1.56508 ≈ -1.14298 a2 (1 - 0.45951 0.10557) / 1.56508 ≈ 0.41277顺手验证一下直流增益所有直流分量经过滤波器后增益理论上应该等于1。直流对应z1增益公式是(b0b1b2)/(1a1a2)。算一下分子≈0.26982分母≈0.26979几乎等于1说明这组系数没算错。这个验证强烈建议每次都做手一抖算错一位后面全白费。2.3 定点量化Q15和Q14的取舍与位宽警戒线浮点系数不能直接用必须变成整数。最常用的方式是Q格式本质就是“把小数乘以2的N次方再四舍五入取整”。处理差分方程时所有乘加操作全部在整数域完成最后统一右移N位还原。如果选Q15也就是乘以32768b0 ≈ 2210b1 ≈ 4420b2 ≈ 2210a1 ≈ -37452a2 ≈ 13527看起来没问题但这里有一个特别容易翻车的点a1量化后的绝对值超过32767超过int16_t的表示范围。你要是在代码里顺手把系数定义成int16_t类型a1会直接被截断成错误的值滤波器立刻失效甚至变成振荡器。这是很多初学者第一次做整型IIR时崩溃的原因。所以我更推荐Q14也就是乘以16384。它的精度虽然比Q15低一位但所有系数都压进了int16_t的范围b0 ≈ 1105b1 ≈ 2210b2 ≈ 1105a1 ≈ -18727a2 ≈ 6763这组系数用int16_t存完全没问题中间累加用int32_t布局非常舒服。Q14损失的精度对多数传感器滤波场景微不足道换来的是存储简单、代码直观。如果一定要Q15系数就必须用int32_t存内存开销会大一点但也不是不行。工程上没有绝对好坏关键是清楚自己选的是哪条路以及这条路需要什么样的存储类型。2.4 稳定性检查量化后极点还在不在圈内IIR滤波器的稳定性要求所有极点都落在z平面的单位圆内。二阶系统的特征方程是z² a1*z a2 0对上面这组浮点系数判别式a1²-4a2 ≈ 1.3065 - 1.6511 0说明是一对共轭复根。共轭复根的模长等于sqrt(a2)也就是sqrt(0.41277)≈0.6425明显小于1稳定。关键问题是量化成整数之后a2变成6763/16384≈0.41278模长变化极小稳定性没有问题。我习惯把量化后的系数代回特征方程再算一次模长确认没有越过单位圆。尤其是低截止频率滤波器极点本身就很靠近单位圆量化误差稍微大一点就可能把极点推出圈外滤波器变成自激振荡。这个检查就是一行计算的事但能省下整晚的调试时间。3. 可直接落地的C实现与边界控制3.1 核心处理函数含饱和与舍入有了系数实现就水到渠成了。我用一个结构体保存滤波器状态和系数处理函数每调用一次处理一个采样点#define IIR_Q_SHIFT 14 #define IIR_ROUND (1 (IIR_Q_SHIFT - 1)) typedef struct { int16_t b0, b1, b2; int16_t a1, a2; int32_t x1, x2; int32_t y1, y2; } iir2_t; void iir2_init(iir2_t *f, int16_t b0, int16_t b1, int16_t b2, int16_t a1, int16_t a2) { f-b0 b0; f-b1 b1; f-b2 b2; f-a1 a1; f-a2 a2; f-x1 f-x2 0; f-y1 f-y2 0; } int16_t iir2_process(iir2_t *f, int16_t x) { int32_t acc; acc (int32_t)f-b0 * x; acc (int32_t)f-b1 * f-x1; acc (int32_t)f-b2 * f-x2; acc - (int32_t)f-a1 * f-y1; acc - (int32_t)f-a2 * f-y2; acc IIR_ROUND; acc IIR_Q_SHIFT; if (acc 32767) { acc 32767; } else if (acc -32768) { acc -32768; } f-x2 f-x1; f-x1 x; f-y2 f-y1; f-y1 acc; return (int16_t)acc; }这里有两个细节值得说。第一差分方程里减号后面跟的是符号系数a1和a2。我们算出来的a1本身是负数所以“减去负数”实际是在做加法。符号不要弄反否则滤波器会变成完全不同的东西。第二我是先加半个LSB再右移也就是做四舍五入。直接右移的话所有小数部分都被截断输出会带一个稳定的负偏置在低频小信号场景下那个偏置看得特别明显。3.2 位宽预算表与中间变量检查很多人写整型滤波器的最大顾虑就是溢出。我把各项乘积的最大量级列出来你一看就明白为什么int32_t累加是必要且合理的。假设输入和输出都限制在int16_t满量程范围即±32767使用Q14系数乘积项系数典型值信号满量程乘积最大量级b0*x1105±32767约0.036Gb1*x12210±32767约0.072Gb2*x21105±32767约0.036Ga1*y118727±32767约0.614Ga2*y26763±32767约0.222G最极端情况下所有项同号叠加大约接近1G而int32_t能表示±2.147G余量是够的。但注意极限波形叠加确实有可能让累加值逼近int32_t的上界所以我强烈建议如果输入信号可能长时间接近满量程就把输入先做一次比例衰减比如右移一位给中间变量留出更多余量或者输出饱和处理不要省。还有一个我必须强调的坑所有乘积都要先升到int32_t再运算。如果你写的是(int16_t)((int16_t)b0 * x)中间结果会先被截断成int16_t再参与后续累加轻则精度损失重则完全错误。我在代码里每个乘项明确写了(int32_t)强制转换就是为了避免编译器按默认的int规则做隐式转换后丢精度。3.3 无符号ADC数据的接入方式很多MCU的ADC寄存器读出来是无符号数比如12位ADC的范围是0~409516位ADC是0~65535。如果你把这个无符号数直接丢进滤波器问题就大了信号里的直流分量会变成偏置滤波器输出很容易被顶到饱和而且你在零点附近看到的也不是真正的零点而是半个量程的偏置。正确做法是先把原始ADC值转换成有符号的偏移量。以12位ADC为例中值在2048附近uint16_t raw_adc; int16_t x_signed (int16_t)(raw_adc - 2048);这样振动、交流之类的信号就变成了以0为中心的±2048范围。滤波后的结果如果是y_signed要还原成实际ADC尺度就再加回2048int16_t y_adc y_signed 2048;不过这里要注意y_signed接近满偏时加上2048可能会超过int16_t范围所以最终输出到ADC/DAC之前还要做一次饱和处理。滤波器的状态变量始终保存有符号值这样整个计算一致性最好也最容易调试。4. 验收方法怎么证明整数滤波器没有做歪4.1 浮点仿真通过并不等于整数实现通过我在给一批系数做验证时经常发现一种情况在MATLAB、Python里用浮点模型仿真幅频响应、相频响应都非常漂亮但把量化后的系数烧进MCU实测波形就是不对。原因无非三类系数量化误差改变了极点位置累加过程出现溢出状态更新顺序写错。这三类问题在浮点仿真阶段都看不见因为仿真环境根本不会模拟int16_t和int32_t的截断行为。所以正确做法是先浮点仿真确认设计目标再用整数差分方程做一次纯整数仿真对比两种结果。重点对比的不是理想幅频曲线而是同样输入序列下的逐点输出偏差。这个偏差必须在一个可接受的范围内通常我会控制在满量程的0.5%以内具体看应用。4.2 我实际用的三组测试与判定标准我给整数滤波器定了三组必测项全部通过才敢真正往项目里放。第一组是阶跃响应测试。输入从一个稳定的0瞬间跳到10000观察滤波器输出。二阶巴特沃斯低通的理论超调量大约4.3%也就是说输出峰值应该在10430附近稳态最终收回到10000。如果整数实现的稳态有几十个LSB的固定偏差多半是舍入处理有问题如果超调被削顶说明中间变量或输出发生了饱和。第二组是正弦扫频对比。在0.1倍截止频率到0.8倍采样频率之间取几个频点每个频点输入固定幅度的正弦波记录若干周期后的稳态输出幅度和浮点模型算出来的理论增益对比。我常用的判定标准是增益误差在±0.2dB以内。超过这个范围优先检查系数量化误差。第三组是极值冲击测试。输入在32767和-32767之间快速来回切换模拟传感器被猛烈撞击的极端场景。这时最能看到溢出和饱和处理的真实表现。合格的表现是输出被饱和在有限范围切回正常信号后滤波器能很快恢复不出现持续振荡或直流偏移。我把三组测试的判定标准整理成一张表之后每次换参数就跑一遍脚本非常方便测试项通过标准常见失败表现阶跃响应稳态无偏差超调约4%稳态偏移数个LSB超调被削顶正弦扫频增益误差±0.2dB以内某频点明显偏高或偏低极值冲击无溢出振荡恢复快速切回信号后仍有固定偏移4.3 上位机离线对比与回归测试思路我通常在Python里用numpy实现同一差分方程的浮点版本和整数版本然后把一组随机生成的测试信号同时喂给两个版本计算最大绝对误差和均方根误差。这样做的好处是能在上位机快速迭代参数不用每次烧录MCU才能验证。测试信号别只用一个正弦波我会把几类信号拼起来低频正弦段、方波段、白噪声段、脉冲段混合成一个长序列。这样一次跑完就能覆盖主要使用场景。每次修改系数或者改变Q格式之后重新跑一遍确保回归没有引入新问题。整个验证流程跑顺之后后面所有滤波器项目都能直接复用这套脚本省下的时间相当可观。5. 部署时真正会翻车的几个细节5.1 采样节拍的稳定性IIR数字滤波器的系数是在固定采样率下设计出来的。如果实际调用滤波函数的时间点忽快忽慢滤波器的频率响应也会跟着漂移。尤其是截止频率比较低的滤波器对采样周期抖动非常敏感因为每个采样间隔对应的相位变化不均匀输出波形上会出现肉眼可辨的毛刺。所以采样一定要由定时器中断或DMA搬运触发不要在while主循环里随手调用。我见过有人在主循环里读一个ADC就滤波一次结果滤波效果时好时坏换定时器驱动后问题立刻消失。这不是滤波器的问题是采样节奏的问题。另外如果MCU在滤波过程中被更高优先级中断打断或者滤波本身耗时波动很大也要注意。纯整数滤波器耗时非常稳定这本身就是它的一大优势。5.2 在线更新系数必须先清状态某些应用需要现场调整截止频率比如用户可调的噪声抑制档位。很多人图省事直接往滤波器结构体里写新系数结果输出突然跳了一大截甚至短暂振荡。原因很好解释滤波器状态变量x1、x2、y1、y2还保存着旧系数下的历史数据新系数突然接管相当于一个不连续的初始条件被强行塞进了系统。我自己的处理习惯是改系数之前先把所有状态变量清零再写入新系数从下一拍开始滤波输出恢复。如果应用允许短暂输出无效就把输出保持前一拍的值等状态稳定后再放开。这个习惯帮我挡掉过好几次现场调试里的灵异现象。任何整改系数的操作都必须在系统状态可控的前提下进行。5.3 低截止频率下系数会被“挤爆”当截止频率比采样频率小很多时K tan(π*Fc/Fs)会变得很小分母趋近于1a1会非常接近-1.5甚至-1.9。乘以Q14缩放因子16384之后a1的整数绝对值可能达到29000甚至31000虽然还在int16_t范围内但余量越来越小。如果采样率再降低或截止频率再降低Q14也扛不住就不得不降级到Q12、Q13或者用int32_t存系数。这个问题在设计阶段就该意识到。我的经验是Fs/Fc至少保持在10倍以上最好20倍以上K值不会太小系数动态范围也好控制。如果在项目早期就定了很低的采样率滤波设计会处处受限后面加再多的滤波技巧也救不回来。5.4 不同内核上的移植注意点同样的C代码在16位MCU和32位MCU上的表现会差很多。16位MCU的int类型通常也是16位表达式(int32_t)b0*x必须确认编译器真正生成了32位乘法指令而不是调用软件乘法库。有些低端编译器对32位乘法优化不好速度优势会被吃掉一截。32位MCU上这个顾虑小很多int32_t乘法本身就是一条指令这也是我优先推荐32位内核来做信号处理的原因。另外有符号数右移在C标准里是实现定义的但几乎所有嵌入式编译器都实现为算术右移也就是符号位会扩展。若你的编译器不是这个行为用acc IIR_Q_SHIFT会出问题。我一般会在移植后在目标平台上跑一遍极值冲击测试如果输出异常第一个怀疑对象就是右移行为。最后分享一个我自己形成的小习惯每次给新项目移植这份代码时先不改任何参数用一组已知正确的测试序列跑一遍确认输出和上一次完全一致再开始改应用逻辑。这个习惯源于一次教训——当时换了一颗芯片没想到编译器默认对无符号右移的处理有差异滤波器输出漂了上百个LSB排查了很久才发现是移植问题。现在先做一致性验证再改别的整个移植过程踏实很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑