嵌入式输入滤波算法实践:滑动平均、中值与低通滤波详解 📅 发布时间:2026/8/27 6:55:30 👁 浏览次数: 做嵌入式或者信号处理的朋友应该都遇到过这种场景传感器数据读进来波形毛刺多得没法看ADC采到的电压值跳来跳去PID根本稳不住或者音频信号里混着一股高频嘶嘶声怎么调都去不掉。这些问题的根源基本都是输入信号没做滤波处理。我最早接触“数字信号处理里的输入滤波算法”这个方向就是因为一块压力采集板上读数抖动厉害折腾了一整天才发现不是硬件问题而是软件里少了滤波器。这篇文章就把我这些年用过的、调试过的输入滤波算法整理一遍包括滑动平均、中值滤波、一阶低通、以及更进阶的FIR/IIR设计思路。我会从原理讲到代码再讲实际工程里的选型依据和调试技巧。适合正在做传感器采集、音频处理、控制环路或任何需要处理ADC采样数据的开发者参考不管你是刚开始接触DSP还是已经写了几年代码想系统地梳理一下滤波器的选法这篇都能给你一些有用的东西。1. 滤波需求从哪里来先搞清楚输入信号到底烂在哪在选滤波算法之前最忌讳的就是上来就套一个低通滤波。你得先分析信号里到底混了什么东西才能确定用什么手段。这一步看着简单但大部分人都会跳过最后滤波效果自然不理想。1.1 输入信号的三类典型问题我把实际工程中遇到的输入信号问题归结为三类第一类是平稳随机噪声比如电阻热噪声、ADC量化噪声、环境电磁耦合进来的白噪声分量。这类噪声的特点是频谱宽、幅度随机叠加在有用信号上看着就是一条“毛茸茸”的曲线。第二类是脉冲型毛刺典型来源是电机换向、继电器动作、静电放电甚至数字信号线串扰。这类干扰的特点是持续时间短、幅度大在示波器上就是一个尖峰移动平均拿它几乎没办法但中值滤波特别好使。第三类是周期性干扰比如50Hz工频及其谐波或者开关电源的开关频率纹波。这类干扰有明确的频率点针对性地用陷波器或者低通滤波就能压下去。区分这三类问题的方法很简单把ADC采样值通过串口连续打印出来或者直接在调试器里看波形。观察一段时间噪声到底是均匀分布的细小抖动还是偶尔冒出来的大尖刺曲线形态一看便知。我见过不少人把脉冲毛刺当随机噪声处理上了个很大的滑动平均窗口结果毛刺是压下去了有用信号的快速变化也跟着被抹平了纯属南辕北辙。1.2 搞清楚需求再选算法延迟、平滑度、精度怎么取舍滤波不是越平滑越好任何滤波都会带来信号失真区别只是失真的形式。摆在开发者面前有三个互相矛盾的指标平滑程度、响应速度和实现成本。平滑度就是滤波后信号的波动幅度响应速度是指信号突变后滤波输出能跟上的快慢实现成本包括计算量、内存占用和代码复杂度。举个典型的例子一个温度采集系统温度本身变化很慢每秒采一次用大窗口滑动平均完全没问题延迟个几秒根本无所谓。但如果是一个电机电流控制环路电流采样信号是给PID用的每一毫秒都要用这时候滤波延迟就很致命。你在滤波上引入10毫秒的相位滞后PID整定能让你调到怀疑人生。所以选滤波算法前先问自己三个问题信号带宽是多少系统能容忍的最大延迟是多少MCU的算力和内存余量有多大这三个问题的答案直接决定了滤波器的选择范围。有一个很朴素的判断标准如果信号变化比噪声慢得多优先考虑滑动平均或一阶低通如果信号里混有明显的脉冲毛刺中值滤波是必备手段如果对频率选择性有严格需求就得考虑FIR或者更高阶的IIR滤波器。2. 常用输入滤波算法拆解原理、代码和选型依据这一节我把实际项目里最常用到的五种输入滤波算法挨个讲一遍。每种算法我会给出简化版的C代码、适用场景以及我实际使用中的感受。这些算法都是经过时间检验的基础方案把它们吃透至少能应付八成以上的工程问题。2.1 滑动平均最直白但也好用的手段滑动平均的核心思想是维护一个长度为N的窗口每次新采样进来就把窗口内所有采样值取平均作为输出。它本质上是FIR滤波器的一个特例所有系数相等。每来一个新数据最简单的实现方式是把窗口里N个值重新求和再除以N但这样计算量是O(N)窗口大了浪费算力。更好的做法是维护一个累加和新数据进来加上它减去窗口里最旧的那个数据输出等于总和除以N。这样每次计算量就是两次加法和一次除法O(1)复杂度。typedef struct { float buf[16]; float sum; int index; int count; } MovingAverage; float moving_average_filter(MovingAverage *f, float x) { if (f-count 16) { f-sum x; f-buf[f-index] x; f-index (f-index 1) % 16; f-count; return f-sum / f-count; } else { float oldest f-buf[f-index]; f-sum f-sum - oldest x; f-buf[f-index] x; f-index (f-index 1) % 16; return f-sum / 16; } }这里要注意几点窗口大小N一般取2的幂可以优化除法为移位运算但如果N不大编译器也会自动优化除法不用太纠结。环形缓冲区的下标更新一定要写对容易出bug的就是窗口满了之后忘了先取出最旧的数再覆盖。另外一个坑是初始化阶段窗口还没填满输出会偏小。如果程序启动时信号从0开始那这个问题不大但如果启动时信号已经是满量程就得做预热处理我后面专门讲。滑动平均的截止频率大概近似为fs/N。比如采样率1000Hz窗口N10等效截止频率约100Hz左右。但它对频率的衰减比较慢阻带特性不好。高频成分会被压下去但不会干净利落地切掉会有一部分残留。所以滑动平均更适合用在信号带宽远低于采样率、且不在乎精确频率响应的场合。2.2 加权滑动平均给新旧数据不同的地位普通滑动平均所有的历史采样一视同仁这在某些场景下不太合理。信号突变时最新的数据应该承担更大的责任所以就有了加权滑动平均。给新的采样分配更大的权重旧的采样权重指数衰减这就演变成了一种简单的FIR滤波器。加权滑动平均有两种常见做法。一种是定义一组系数比如N5时系数可以是[0.1, 0.1, 0.2, 0.2, 0.4]越新的采样权重越大所有权重加起来要等于1。另一种更常用的方法是用指数权重这其实和一阶低通滤波有异曲同工之处。实际项目里我用到加权滑动平均的场景是机器人陀螺仪数据的预处理。陀螺仪数据里既有缓慢漂移又有突发抖动如果全用均匀滑动平均响应太迟钝机器人稍微一转头输出就滞后但完全不用滤波零漂又大到没法看。加权滑动平均能把静态噪声压住同时保留一部分动态响应。不过说句实话加了权重之后参数就多了调起来比普通滑动平均麻烦而且收益并没有想象中高。如果你只是想平滑数据普通滑动平均往往才是最简单的选择。2.3 中值滤波对付脉冲毛刺的王牌中值滤波的思路极其简单粗暴取窗口内所有采样值排序取中间值作为输出。比如窗口N5取5个值排序后第3个就是输出。它对于幅度大、持续时间短的脉冲毛刺有奇效因为无论毛刺多大只要它占少数排序后都会被挤到序列的两端不会影响中位数。#define MEDIAN_WIN 5 float median_filter(float data[], int len) { float tmp[MEDIAN_WIN]; for (int i 0; i len; i) tmp[i] data[i]; // 简单位插入排序窗口小的时候够用 for (int i 1; i len; i) { float key tmp[i]; int j i - 1; while (j 0 tmp[j] key) { tmp[j 1] tmp[j]; j--; } tmp[j 1] key; } return tmp[len / 2]; }中值滤波有一个很直观的特点它不会让信号边沿变模糊。滑动平均会把阶跃信号变成斜坡而中值滤波能完整保留阶跃。这个特性在光电传感器、接近开关这类信号上是很大的优势。它的代价是计算量每来一个数据就要排序一次。窗口小3到7的时候插入排序足够快窗口大了以后可以考虑用滑动窗口更新中值的算法或者干脆用更大粒度的方式降低滤波频率。但中值滤波不是万能的。它对付平稳随机噪声的效果很一般因为白噪声每个点都随机跳动中位数和均值差异不大。还有一点容易忽略如果窗口内出现多个连续的脉冲毛刺那中值滤波也会失效。比如窗口N5连续3个采样点都是被干扰的异常值那排序后中位数可能还是异常值。毛刺的宽度和频率超出窗口容忍范围时中值滤波就无能为力了。所以我在实际工程里中值滤波常常不是单独用的而是先过一遍中值滤波干掉毛刺再用滑动平均或者低通滤波压制随机噪声。这种组合拳看着土但效果非常好而且每一级都可以独立调参好理解也好维护。2.4 一阶低通滤波资源开销最小的经典IIR一阶低通IIR滤波器是嵌入式信号处理里应用最广的滤波器没有之一。它只有一条递归公式y[n] alpha * x[n] (1 - alpha) * y[n-1]。意思是当前的输出等于当前输入和上一次输出的加权和alpha越大新数据占比越大滤波越弱alpha越小历史占比越大滤波越强。typedef struct { float alpha; float y_prev; } LowPass1st; float lowpass_1st(LowPass1st *f, float x) { f-y_prev f-alpha * x (1.0f - f-alpha) * f-y_prev; return f-y_prev; }alpha怎么选这是有严格推导的。一阶RC低通滤波器的截止频率fc和时间常数tau的关系是tau 1 / (2pifc)。用前向欧拉法离散化alpha ts / (tau ts) 2pifc / (2pifc fs)其中ts是采样周期fs是采样率。比如采样率1000Hz想要100Hz的截止频率alpha 2pi100 / (2pi100 1000) ≈ 0.386。如果采样率远大于截止频率alpha也可以近似为2pifc/fs。一阶低通滤波最大的优点是计算量极小一个乘加就完成占用内存也只有一个状态变量任何MCU都能轻松跑。它的缺点也明确频率选择性差过渡带很宽。如果你需要“在20Hz以内通过在50Hz以上迅速衰减”这种锐利的频率响应一阶低通做不到得用更高阶的滤波器。我在实际项目中对一阶低通最深的体会是它的alpha对采样率极其敏感。同一个alpha在100Hz采样率和1000Hz采样率下表现的滤波强度天差地别。所以代码里alpha最好根据实际采样率算出来不要拍脑袋写死。另一个坑是启动时y_prev应该初始化为第一个采样值x[0]而不是0。否则启动瞬间输出会从0慢慢爬到真实值这个上升过程在一个控制电路里足以触发一次误动作。2.5 高阶FIR和IIR什么时候才需要上强度如果基础滤波器不满足需求比如你需要一个截止特性特别陡峭的滤波器或者需要针对特定频率点做陷波那就得进入更系统的滤波器设计领域——FIR和更高阶的IIR。FIR滤波器形式上是输入信号的历史加权求和y[n] b0x[n] b1x[n-1] ... bN*x[n-N]。它最大的优点是相位特性是严格线性的不同频率成分经过滤波器后延迟一致波形不会发生相位失真。这一点在音频和振动分析里很关键。但代价是阶数高同样的过渡带陡峭度FIR需要的阶数远高于IIR。比如采样率1kHz、截止频率50Hz、过渡带只有10Hz的FIR低通可能需要上百个抽头每次采样要做上百次乘加运算。IIR滤波器则用递归结构实现阶数低计算省但幅频响应越陡相位非线性越严重甚至在参数极端时可能不稳定。经典的设计包括Butterworth、Chebyshev和Elliptic。Butterworth最大优点是通带内最平坦没有纹波适合大多数测量场景。设计这些滤波器可以手动计算系数也可以用工具生成我之前常用的方式是先用Python的scipy.signal库算出系数再移植到C代码里。高阶滤波器在工程上直接手写系数是错误的利用现成工具生成系数然后嵌入代码既快又不容易出错。3. 从仿真到落地以实际传感器信号为例的滤波选型过程光讲概念容易飘我拿一个我最近调试过的实际案例来串一遍完整的流程。场景是这样的一个温度采集系统传感器输出经过运放放大后接到MCU的ADC采样率1kHz。客户反馈说系统显示的数值跳得厉害肉眼可见地跳动大概正负3到4个码值。我拿示波器抓了ADC输入端的波形发现确实有高频毛刺而且每格波形之间还能看到周期性的干扰尖峰。3.1 先用Python把信号和噪声模拟出来拿到原始数据后我的习惯是先导出一段数据到电脑上用Python做一个快速分析而不是直接在MCU里盲调。Python的好处是可以边画图边试不同的滤波参数迭代速度极快。我先把采到的数据画出来观察时域波形和频谱确定噪声类型和主要频率成分。import numpy as np from scipy import signal import matplotlib.pyplot as plt fs 1000 t np.arange(0, 5, 1/fs) true_signal 20 * np.sin(2 * np.pi * 0.5 * t) 25 # 真实信号0.5Hz慢变 noise np.random.normal(0, 1.5, len(t)) glitch np.zeros(len(t)) glitch[::200] 8 # 每200个点来一个脉冲毛刺 raw true_signal noise glitch plt.plot(t[:500], raw[:500]) plt.xlabel(Time (s)) plt.ylabel(ADC raw value) plt.show()从频谱和时域看图这个信号里的问题是典型的两层宽频随机噪声加上周期性脉冲。随机噪声幅度大约1.5个码值脉冲毛刺幅度8个码值。这个时候如果只用滑动平均毛刺虽然会被压低但依然会有残留而且噪声平滑需要大窗口响应变慢如果只用中值滤波随机噪声的压制效果很有限。所以我的方案是先用窗口5的中值滤波干掉脉冲再用一阶低通滤掉随机噪声。这就是前面说的组合拳。3.2 滤波器对比看波形也要看量化指标在实际选型时不能只凭肉眼感觉最好量化几个指标。我一般看三个滤波后输出与真实信号的均方根误差RMSE、对阶跃信号的响应时间、以及计算耗时。RMSE反映了滤波精度响应时间反映了实时性这两个指标往往是对着干的。我在Python里把几种方案都跑了一遍方案输出RMSE阶跃响应上升时间单次计算量无滤波4.20ms0滑动平均 N161.1约8ms1次加法1次减法1次除法滑动平均 N320.8约16ms同上中值滤波 窗口53.0约2ms排序15次比较一阶低通 fc20Hz1.3约25ms1乘1加中值5 一阶低通 fc20Hz0.9约26ms排序1乘1加从表格能看到中值滤波单独用对随机噪声压制效果很差RMSE还有3.0但和低通组合后能达到0.9和滑动平均N32差不多但阶跃响应时间还更快。这就是组合方案的实际意义。同时要注意一阶低通的响应时间跟截止频率直接相关fc越低越平滑但越慢。如果你的实时性要求高就得牺牲一部分平滑度这是没法两全的。3.3 参数是怎么算出来的截止频率、窗口长度关于参数选择有些经验值和推导值得写下来。滑动平均窗口N的选择经验公式是取信号周期的一个小比例。比如信号是50Hz工频干扰采样率1000Hz信号周期20ms想用滑动平均滤掉50Hz窗口至少要覆盖一个完整周期也就是N20否则衰减不够。但窗口N20对应的输出延迟大约10ms这在控制环路里往往已经偏大。一阶低通的截止频率选择要从信号本身的频率范围来推。假设有效信号最高频率是10Hz我可以把滤波器截止频率设在20Hz这样对有效信号影响不大同时能压住更高频的噪声。按之前的公式fs1000Hzfc20Hzalpha 2pi20/(2pi201000) ≈ 0.112。注意这个alpha算出来是0.112左右很多新手看到这么小的alpha以为滤波效果不强实际测试下来20Hz已经是相当明显的平滑了。我在选参数时的建议是先按理论算一个初始值然后做成可调参数在真实系统上以50%的步进调整观察波形和系统行为找到平滑度和响应速度的平衡点。理论公式给的是起点不是终点。不同系统的噪声特性千差万别参数最终还是要靠实测和完善来迭代。4. 嵌入式环境里的实现细节定点数、启动瞬态、计算量到了真实工程里滤波算法最大的敌人不是数学而是MCU的算力、内存和时钟限制。同样的算法在PC上跑得飞快换个8位单片机就完全另一码事。这一节谈谈嵌入式中滤波实现的具体难点和我的处理办法。4.1 浮点还是定点算力和精度的取舍现代Cortex-M4以上的MCU自带FPU处理float没压力但很多小家电、传感器模块还在用Cortex-M0或者8位8051内核浮点运算是通过软件库模拟的一次浮点乘法可能要几十个周期太慢了。这时候有两个选择一是把滤波器系数放大成整数再用定点运算模拟小数二是直接用整数做。以移动平均为例如果ADC是12位的值域0-4095用16位累加器最多只能累加16个点就溢出了。稳妥的做法是使用32位累加器然后右移分频。如果窗口是16且是2的幂可以直接右移4位比除法快很多。这里有个细节累加后直接右移在负数的情况下会有截断误差但实际上ADC无符号的情况下都是正数问题不大。如果是有符号的音频信号就得考虑用带舍入的除法或者加偏置。一阶低通滤波器在定点环境下的实现更讲究。alpha是个小数比如0.112不能直接用整数乘。我常用的办法是把alpha转成Q格式的定点数。比如用Q15格式把0.112乘以32768得到约3670然后用32位中间量做乘加最后右移15位。这样精度基本够而且乘法只在32位范围内不会溢出。参考代码大概是#define ALPHA_Q15 3670 // 0.112 * 32768 int16_t lowpass_fixed(int32_t y_prev, int32_t x) { int32_t result (ALPHA_Q15 * x (32768 - ALPHA_Q15) * y_prev) 15; return (int16_t)result; }4.2 启动瞬态滤波器开机那一瞬间的问题滤波器的状态变量在启动时必须初始化。很多人直接把状态变量清零这在信号从0开始爬升时问题不大但如果系统上电时信号已经是满量程那滤波输出要从0慢慢爬到真实值这个过渡过程轻则显示滞后重则触发控制误动作。解决办法是在滤波器初始化时把第一个采样值直接赋给历史输出也就是y_prev x_first。这样输出从一开始就贴近真实信号没有爬升过程。对滑动平均也一样在窗口未填满时用当前已有数据求平均而不是把缺失的窗口当作0处理。我的滑动平均代码里count变量就是干这个的未满时用sum/count满了以后用sum/N。这个细节看似简单但能避免好多启动瞬间的怪异问题。4.3 计算量分配不要在中断里做重活输入滤波通常在ADC采样完成中断里执行。如果中断频率很高比如音频采样48kHz滤波器代码必须极简否则会占满中断时间。我的建议是把重型的滤波器如大窗口滑动平均、中值排序放到主循环或者低优先级任务里执行ADC中断里只做数据的原始搬运和备份。举个我踩过的坑有个项目用了窗口21的中值滤波直接在1kHz的ADC中断里跑插入排序每次中断耗时约300个周期占了MCU将近30%的算力。后来我把中值滤波挪到主循环用双缓冲接收ADC数据CPU占用率直接降到了5%以下。如果你的系统也面临中断负载过高的问题优先考虑挪滤波位置而不是优化算法本身。4.4 数值溢出的隐患整数实现里累加器和乘法中间量的溢出是隐形杀手。尤其是滑动平均的累加和以及FIR滤波器的卷积累加。如果不加验证等产品在极端工况下出现随机跳变时排查起来会非常痛苦。我的做法是在代码里加一个调试用的最大最小值统计运行一段时间后主动检查这些中间变量有没有逼近类型上限。比如12位ADC采样值最大4095如果滑动平均窗口N64累加和最大是4095*64262080刚好超过16位上限必须用32位。但如果你用16位累加器当输入信号接近满程时累加和溢出输出就会突然变成负数表现就是读数瞬间跳变到一个离谱的值。这种问题看代码不一定能发现必须靠运行时检查或者预计算类型范围来排除。5. 我踩过的坑与排查思路滤波器的坑十有八九不是数学算错而是工程约束没考虑好。我把这些年遇到过的典型问题和我的排查思路整理成一张表再针对几个高频问题展开讲一下。异常现象可能原因排查方向滤波后波形仍抖截止频率太高没压住噪声降低fc看频谱是否还有高频峰值滤波后波形严重滞后窗口太大或fc太低对比原信号测阶跃响应时间输出启动时爬升状态变量初始化为0第一个采样值赋给状态变量毛刺没滤干净中值窗口比毛刺宽度还小增大中值窗口或先降采样高频噪声越滤越多采样率不足发生混叠检查ADC采样率添加模拟抗混叠滤波输出偶发跳变整数溢出检查累加器类型范围用调试统计验证滤波器在中断里跑太久重型算法放中断挪到主循环用双缓冲5.1 相位滞后把控制环路搞乱了这是我在做电流环项目时最惨痛的一次教训。当时为了把ADC采样噪声压下去上了一阶低通fc设在500Hz采样率50kHz。看起来参数很合理但调试PID时发现无论怎么调增益系统在某个频率附近总会振荡。后来用示波器同时看给定和反馈才发现反馈信号被滤波器延迟了整整约1ms。这个延迟对慢系统无所谓但在高带宽控制环路里直接改变了系统的相位裕度导致振荡。解决办法有两种一是降低滤波器截止频率到噪声实际所在的频段而不是给有用信号造成不必要的相位滞后二是换成相位特性更好的滤波器比如Bessel型的IIR或者线性相位的FIR。当然FIR在这里的代价是计算量大幅上升。最重要的教训是反馈通路上的滤波器延迟几乎是和控制稳定性直接挂钩的不能随便加。5.2 滑动平均窗口太大导致信号变形曾经有个温度采集项目为了追求显示稳定把滑动平均窗口设成64采样率1Hz。结果是温度显示确实稳定得像条直线但实际温度已经升了5度显示还在慢慢爬。用户反馈说“系统反应迟钝”。我后来把窗口改成8显示的稳定性虽然略降但系统响应跟得上了用户反而满意了。这里的关键是显示平滑的收益和响应速度的损失必须权衡。一个实用的技巧是显示端可以做平滑而控制端和报警判断必须使用快速路径或者不加滤波的数据。把不同的数据需求分开处理不要一个滤波器通吃所有下游逻辑。5.3 高频噪声没滤掉问题竟然出在混叠有一次外场测试设备在电机附近工作采样率1kHz我加了一阶低通fc100Hz但采集到的波形里还是有一个稳定的高频分量怎么都滤不掉。后来分析频谱发现那个高频分量的频率是1.2kHz它通过ADC采样后被“折叠”到了200Hz左右——这已经是数字滤波无法处理的混叠现象。数字滤波有一个前提采样之前信号不能含有超过采样率一半的频率成分否则这些高频成分会被混叠到低频段和有效信号混在一起任何数字滤波都分不开。解决办法是在ADC之前加模拟低通滤波比如一个简单的RC低通把高于采样率一半的分量在模拟域压下去。这个点很容易被忽略因为大家天然认为“数字滤波就够用了”实际上抗混叠必须发生在模拟前端。5.4 调试技巧把中间量直接打印出来最后分享一个特别实用的调试习惯在调滤波器时不要把滤波器的最终输出直接当结果使用而是把原始采样、滤波输出、以及滤波器内部的关键状态量比如滑动平均的sum、中值排序的中间数组通过调试口打印出来。很多时候光看最终输出你无法判断异常是发生在输入端的物理噪声还是滤波器本身的实现 bug。我经历过一次很奇怪的现象滤波输出每隔64个点跳变一次肉眼可见的规律。排查了很久才发现是滑动平均的环形缓冲区索引在窗口填满和未填满边界切换时有一行代码写错了导致最旧数据和最新数据被重复计算。如果我只盯着最终输出看可能一整天都找不到原因。把中间量打出来之后问题一眼就暴露了。所以建议在工程代码里预留一个调试输出口尤其在滤波器这类算法代码上带来的收益远远大于那一点点日志开销。写在最后滤波不是越狠越好而是越合适越好折腾了这么多年滤波器最大的感触就是滤波器的本质是信息取舍你要保留的信号特征和要抑制的噪声特征决定了选择哪个算法、设多大参数。做嵌入式与信号处理最忌讳的是一上来就用一个看起来很厉害的算法把自己的系统调得很“平滑”却忽略了响应速度和稳定性。先把需求分析清楚再选算法然后通过仿真和现场实测迭代参数——这一套流程走下去绝大多数滤波问题都能被稳定地解决。另外一个小建议每个项目里最好把滤波器的参数全部做成可配置项存储在配置结构体里而不是散落在代码里的魔法数字。因为你永远不知道现场会出现什么新噪声到时候客户一个电话过来你要能远程给他调参而不是重新编译一版固件。这个经验是我吃了几次亏之后才总结出来的希望你能少走这些弯路。