简介:针对单片机与嵌入式系统中的数字滤波需求,这里整理了一套包含10种常用滤波算法的C代码资源,适合需要处理传感器噪声、脉冲干扰或周期性波动的开发者使用。压缩包内共3个文件,包含可直接参考的C源程序、系统讲解10种软件滤波方法的Word文档,以及一篇单片机数字滤波算法研究的PDF论文,整体大小约198KB,目前已有3185人学习下载。内容覆盖限幅滤波、中位值滤波、算术平均滤波、递推平均滤波、中位值平均滤波、限幅平均滤波、一阶滞后滤波、加权递推平均滤波、消抖滤波和限幅消抖滤波,每种算法均列明实现方法、优点与缺点,便于在流量、压力、液位、温度等不同测量场景中评估滤波效果并完成选型,也能对照C代码直接移植到实际项目,快速验证滤波逻辑。 刚接手嵌入式项目那会儿,我最怕处理的就是ADC采样回来的数据。传感器明明接得好好的,数值却像蹦迪一样上下跳,加个滤波吧,数据是稳了,但真实变化也跟着变迟钝,调试时差点怀疑人生。后来才明白,滤波这事不该上来就写代码,得先搞清楚信号里混的是什么噪声、采样周期是多少、允许的响应延时有多长。这篇就把我用过的10种常用滤波算法从头到尾梳理一遍,全部给出C语言实现,附上选型思路、参数调优经验和实际对比数据,适合正在做传感器采集、单片机开发、仪器仪表的朋友收藏对照。
1. 先判断噪声再选算法:滤波第一步其实是“诊断”
很多朋友觉得滤波就是“低通”,拿一个平均或者一阶滞后到处用,结果遇到继电器干扰、电机火花时毫无办法,数据还是乱跳。问题不在代码,而在没有先分析信号里混杂的噪声形态。滤波选型的顺序应该是:先看噪声长什么样,再决定用哪种策略,最后才动手写代码。同样的信号,用对算法,几行代码就能压住几十条if判断。
1.1 毛刺、抖动、漂移:先认清你要对付的是谁
传感器信号里的噪声大致分三种形态。第一种是脉冲毛刺,也就是偶发的尖峰,通常来自电机启停、继电器动作、静电放电或者通信干扰。它的特点是幅值大、持续时间短,和真实值之间往往差了几百甚至上千个码值。第二种是随机抖动,信号围绕真实值上下快速波动,幅度不大但频率高,是传感器固有噪声的主要形态。第三种是缓慢漂移,例如温度变化引起的零点偏移,周期很长。
打个比方就很好理解:毛刺像朋友说话时旁边突然有人插了一嗓子,随机抖动像三个人同时小声嘀咕,而漂移像是背景音乐音量慢慢变大。三种噪声的处理思路完全不同,毛刺要靠限幅、中位值这类“不信它”的算法来剔除;随机抖动要靠平均、滞后这类“综合群众意见”的算法来平滑;漂移通常用标定、差分或高通思路解决,10种经典算法里没有专门对付它的,别指望平均或限幅能解决。
1.2 十种算法按策略分组:抗脉冲、平滑、组合
这10种算法按策略可以分成三组,这样记起来就轻松多了:
| 策略 | 包含算法 | 典型用途 |
|---|---|---|
| 抗脉冲干扰 | 限幅滤波、中位值滤波、消抖滤波、限幅消抖 | 电机干扰、继电器火花、静电、开关信号 |
| 平滑随机噪声 | 算术平均、滑动平均、加权递推平均、一阶滞后 | 传感器固有噪声、信号高频抖动 |
| 组合策略 | 中位值平均、限幅平均 | 既有脉冲又有随机噪声的复杂工况 |
同一款算法不存在全能的,组合策略往往更实用。正因如此,我建议把10种算法当成一组工具箱,而不是一张排名表。遇到问题先归类,再从中挑最合适的零件。
2. 抗脉冲干扰三件套:限幅、中位值与消抖的C实现
先来看抗脉冲干扰这一类。为什么这类噪声不能用平均类滤波?因为脉冲的幅值很大,平均计算会把一个尖峰“摊”到前后N个点上,相当于把毛刺变成了一段持续的凸起,数据虽然不跳了,但失真更隐蔽。限幅、中位值、消抖这些方法在脉冲尚未影响整体统计之前就直接拒绝它,思路完全不同。
2.1 限幅滤波:给相邻采样值的“信任额度”设门槛
限幅滤波的逻辑最简单:相邻两次采样值的差值超过设定阈值,就认为本次采样不正常,输出上一次的值;否则正常输出。它等于给每个新采样值设了一个“信任额度”,超出额度就不予通过。
#define LIMIT_VALUE 50 /* 阈值,按信号最大变化率设定 */ int limit_filter(int new_value, int last_output) { if ((new_value - last_output > LIMIT_VALUE) || (last_output - new_value > LIMIT_VALUE)) { return last_output; } return new_value; }这里有个容易踩的坑:上一次的参考值到底用原始采样值还是上一次的滤波输出值?我建议用上一次滤波输出值。如果拿上一次原始采样值做基准,一次脉冲之后连续几个正常点都会被误杀,因为它们的差值是相对脉冲值算的,而不是相对正常值。限幅滤波非常适合温度、液位、转速这类物理量不会突然跳变的场景,缺点是一旦真实信号快速变化就容易被误伤,阈值需要仔细设计,后面第5章专门讲。
2.2 中位值滤波:把排序结果的正中间那个数拿出来
中位值滤波的思路是连续采样N次,排序后取中间那个数作为输出。N必须取奇数,否则取中间偏左偏右都别扭。
int median_filter(int *buf, int len) { int temp, i, j; /* 冒泡排序,数据量小,够用 */ for (i = 0; i < len - 1; i++) { for (j = 0; j < len - i - 1; j++) { if (buf[j] > buf[j + 1]) { temp = buf[j]; buf[j] = buf[j + 1]; buf[j + 1] = temp; } } } return buf[len / 2]; }调用的方式通常是连续采集5个点存进数组,调用median_filter,然后把数组重置继续采。这里有三个注意事项:第一,排序会直接改动传入的数组,如果调用方还要用原始数据,就得先拷贝一遍;第二,N一般取3或5,最多取7,再大排序开销和输出滞后都会明显增加;第三,中位值滤波适合温度、液位这类变化缓慢的信号,用在振动或音频信号上,输出看起来就像被“削掉”了细节。
2.3 消抖滤波:连续N次一致才更新输出
消抖滤波在嵌入式里最常见的名字叫“软件消抖”,典型应用是按键、光电开关、霍尔传感器这类逻辑电平信号。它的规则是:连续N次采样都得到相同值,才更新输出;一旦中间出现一次不同,计数清零重来。
#define DBNCE_CNT 5 /* 连续相同N次才算稳定 */ int debounce_filter(int new_value, int *counter, int *last_valid) { if (new_value != *last_valid) { (*counter)++; if (*counter >= DBNCE_CNT) { *last_valid = new_value; *counter = 0; } } else { *counter = 0; } return *last_valid; }这个函数的返回值是上一次确认有效的值,不是本次采样值。中间的计数参数由调用方维护,这样同一个函数可以服务多个通道,互不干扰。消抖滤波的代价是响应延迟,N越大抗干扰越好,但输出越迟钝。对于按键这类人类操作,5到20次判断都感知不到延迟;对于霍尔测速这类高速计数,N就得尽量小。
2.4 限幅消抖:先做物理约束,再做状态确认
如果把限幅和消抖合起来,就得到限幅消抖滤波:先判断本次采样值和上次采样值是否超限,如果超限就认为这次采样不可信,直接清零计数;如果没超限,再走消抖逻辑。
int limit_debounce_filter(int new_value, int last_sample, int *counter, int *last_valid) { if ((new_value - last_sample > LIMIT_VALUE) || (last_sample - new_value > LIMIT_VALUE)) { *counter = 0; return *last_valid; } if (new_value != *last_valid) { (*counter)++; if (*counter >= DBNCE_CNT) { *last_valid = new_value; *counter = 0; } } else { *counter = 0; } return *last_valid; }注意这里的last_sample是上一次的原始采样值,不是滤波输出值,它做的是物理层面的“初步可信度验证”;last_valid才是上次确认有效的状态值。限幅消抖比单独消抖强的地方在于,物理上不可能出现的快速跳变会被提前拦截,不会给计数器“攒骗局”的机会。比如一个光电传感器正常情况下频率最多100Hz,结果一个采样周期内状态翻了好几次,这一定是干扰,先踢出去再做消抖判断。
3. 平滑随机噪声:平均类与一阶滞后滤波的C实现
平均类和一阶滞后是把随机噪声“磨平”的常用手段。它们对零均值随机噪声非常有效,噪声越大,效果越明显。跟抗脉冲策略不同,这类算法会处理每一个采样值,而不是选择性地相信或丢弃。
3.1 算术平均滤波:攒一批,算一次
算术平均滤波是很多人最早接触的滤波方式:连续采集N个点,求和取平均,输出一次结果,然后重新采集。
int average_filter(int *buf, int len) { int sum = 0, i; for (i = 0; i < len; i++) { sum += buf[i]; } return sum / len; }这个算法的特点是简单可靠,输出频率降低为采样率的1/N,每次都要等N个点凑齐才能输出,实时性较差。它适合温湿度记录仪、电池电压巡检这类周期性上报数据的场景。有个细节:如果采样值是12位ADC且N较大,int范围内的sum一般不会溢出,但如果你用的是16位MCU且N超过64,最好把sum声明为long,避免累加溢出。
3.2 滑动平均滤波:环形缓冲里的实时平均值
滑动平均滤波也叫递推平均滤波,它和算术平均最大的区别是“窗口滑动”。每采到一个新值,就覆盖掉窗口里最旧的值,立刻输出窗口内平均值,不用等人凑齐一批。
#define WIN_SIZE 8 static int history_buf[WIN_SIZE]; static int history_index = 0; int sliding_average_filter(int new_value) { int sum = 0, i; history_buf[history_index] = new_value; history_index = (history_index + 1) % WIN_SIZE; for (i = 0; i < WIN_SIZE; i++) { sum += history_buf[i]; } return sum / WIN_SIZE; }这里用环形缓冲区保存最近N个值,index永远指向下一次要覆盖的位置。滑动平均的响应延迟大约是(N-1)/2个采样周期,实时性比算术平均好很多,因此特别适合压力、倾角这类需要连续输出的系统。要注意的是,新值进来后被平均的权重只有1/N,真实突变会被“稀释”,如果信号本身变化快,窗口不能设得太大。
3.3 加权递推平均:给信任度加个权重梯度
滑动平均把窗口里所有数据一视同仁,但直觉告诉我们,越靠近当前时刻的数据越接近真实状态。加权递推平均就是给不同位置的数据分配不同权重,通常越新的数据权重越大。
#define WIN_SIZE 4 static int history_buf[WIN_SIZE]; static const int weight[WIN_SIZE] = {4, 3, 2, 1}; #define WEIGHT_SUM 10 int weighted_sliding_filter(int new_value) { int sum = 0, i; /* 数据整体后移,最新值放最前面 */ for (i = WIN_SIZE - 1; i > 0; i--) { history_buf[i] = history_buf[i - 1]; } history_buf[0] = new_value; for (i = 0; i < WIN_SIZE; i++) { sum += history_buf[i] * weight[i]; } return sum / WEIGHT_SUM; }权重设计有个小技巧:尽量用线性递增的整数权重,比如{1,2,3,4}或{1,2,4,8},权重总和可以整除,这样平均时直接用整数除法,不牵涉浮点。加权递推平均的滞后比普通滑动平均小,但因为让最近的数据“多说话”,对脉冲的抗性也变差了,实战里通常要在前级加限幅或中位值,否则一个尖峰可能带偏整个输出。
3.4 一阶滞后滤波:用一个系数实现无限记忆
一阶滞后滤波的公式是y[k] = a * x[k] + (1 - a) * y[k-1],a是滤波系数,范围0到1。它把上一拍的输出和本拍的输入按比例混合,内存占用小到极致。
#define FILTER_COEF 30 /* 相当于 a = 0.3 */ int first_order_filter(int new_value) { static int last_output = 0; static unsigned char initialized = 0; int output; /* 冷启动处理:第一次直接把采样值当作输出 */ if (!initialized) { last_output = new_value; initialized = 1; } output = (FILTER_COEF * new_value + (100 - FILTER_COEF) * last_output) / 100; last_output = output; return output; }一阶滞后滤波几乎是为单片机量身定做的,只要两个变量、一次乘加运算,没有数组也没有排序,而且系数是连续可调的。a越大,越信任新值,响应快但平滑弱;a越小,越依赖历史,平滑强但滞后明显。它本质是IIR滤波器,历史影响会一直存在,所以代码里专门做了冷启动处理,第一次采样直接赋值,避免从0慢慢爬导致的初始失真。
4. 组合打法:同时解决毛刺和平滑的两种策略
现实的传感器数据往往不是单一噪声。有的信号既有偶发脉冲,又有持续抖动,只用一种策略压不住。组合策略的思路就是先把脉冲剔除,再对剩余数据做平滑处理。
4.1 中位值平均滤波:排序后掐头去尾再平均
中位值平均滤波也叫防脉冲干扰平均滤波,它把中位值和算术平均结合:连续采样N个数,排序后去掉最大值和最小值,剩下的N-2个数求平均。
#define FILTER_N 7 int median_average_filter(int *buf, int len) { int temp, i, j, sum = 0; for (i = 0; i < len - 1; i++) { for (j = 0; j < len - i - 1; j++) { if (buf[j] > buf[j + 1]) { temp = buf[j]; buf[j] = buf[j + 1]; buf[j + 1] = temp; } } } /* 去掉最小值buf[0]和最大值buf[len-1] */ for (i = 1; i < len - 1; i++) { sum += buf[i]; } return sum / (len - 2); }这个组合的价值在于,去掉最大最小之后,剩下的数据基本剔除了偶发脉冲影响,再用平均处理剩余随机噪声,输出的稳定性比单独用中位值或算术平均都好。N一般取5或7,工业测量里用它处理热电阻、压力变送器这类缓慢变化信号,效果很稳。代价是要排序,N等于7时冒泡排序最坏要21次比较,对几十毫秒采样周期来说完全够用。
4.2 限幅平均滤波:拦截异常值后交给滑动平均
限幅平均滤波是“限幅+滑动平均”的组合,逐个采样值先进限幅关卡,通过后才进入滑动平均窗口,不满足限幅条件的值直接用上一轮输出顶替,避免异常值污染窗口。
int limit_average_filter(int new_value, int last_output) { int sum = 0, i; int valid_value = new_value; if ((new_value - last_output > LIMIT_VALUE) || (last_output - new_value > LIMIT_VALUE)) { valid_value = last_output; } history_buf[history_index] = valid_value; history_index = (history_index + 1) % WIN_SIZE; for (i = 0; i < WIN_SIZE; i++) { sum += history_buf[i]; } return sum / WIN_SIZE; }这个方案比中位值平均的实时性更好,滑动平均每采一个点就能输出一个值,不被N点一批的节奏拖住。它非常适合电流环、速度环这类实时闭环控制系统的采样通道,既要求滤波平滑,又要求输出延迟尽量小。需要重点提醒的是,限幅的那一步必须在进窗口之前做,如果把原始脉冲值直接塞进窗口,滑动平均会把脉冲摊到多个周期上,和没滤差不多。
5. 参数定夺与实测对比:同一串数据的不同归宿
算法选好了,参数定不对等于白选。窗口N、限幅阈值、滤波系数三个参数,分别对应响应时间、物理约束、平滑程度三个维度。
5.1 一组真实采样数据在三种算法下的对比
为了更直观地说明问题,我拿一组模拟的ADC采样值做了一组对比,序列中间故意放了一个1800的脉冲毛刺,真实值大约在1250附近:
| 采样序号 | 原始值 | 限幅输出(阈值50) | 滑动平均输出(N=4) | 一阶滞后(a=0.3) |
|---|---|---|---|---|
| 1 | 1250 | 1250 | 1250 | 1250 |
| 2 | 1248 | 1248 | 1249 | 1249 |
| 3 | 1270 | 1270 | 1256 | 1255 |
| 4 | 1800 | 1270 | 1392 | 1419 |
| 5 | 1260 | 1260 | 1394 | 1371 |
| 6 | 1253 | 1253 | 1395 | 1336 |
| 7 | 1249 | 1249 | 1390 | 1310 |
| 8 | 1258 | 1258 | 1255 | 1294 |
限幅把脉冲挡在了门外,输出几乎不受影响。但滑动平均被1800带偏了4个周期,直到第8个点才回到1255左右;一阶滞后输出虽然回落的趋势明显,但第8个点仍比真实值高约36个码值。这印证了前面反复提到的观点:纯平均类算法没办法独立对付脉冲干扰,实战中组合策略的价值就在这里。
5.2 窗口长度N:不要拍脑袋,从滞后时间倒推
窗口N决定的其实是“输出对真实变化的反应速度”。滑动平均的群延迟约为(N-1)/2个采样周期,也就是说,真实信号发生阶跃后,你要等大约(N-1)/2拍才能跟踪到变化的中点。所以N应该从需求反推:比如系统允许滤波延迟50ms,采样周期5ms,那N最大取21左右,再大就会明显滞后。
根据经验,不同信号可以参考以下取值:
| 信号类型 | 经验窗口N |
|---|---|
| 温度 | 8~20 |
| 压力、液位 | 4~8 |
| 加速度 | 2~4 |
| 电机电流 | 2~8,配合控制周期调整 |
另外有一个代码层面的提醒:如果N取2的幂,比如4、8、16,取平均时可以用右移代替除法,sum >> 2或者sum >> 3,运行速度会快不少。
5.3 限幅阈值:用最大物理变化率反算
限幅阈值是整个限幅滤波里最关键、也最容易拍脑袋定的参数。一个靠谱的办法是:阈值 = 信号最大物理变化率 × 采样周期,然后留1.5到3倍余量。
举个例子,某温度传感器最大升温速率是0.5℃每秒,采样周期100ms,那么一个采样周期内最多变化0.05℃。如果ADC分辨率是0.1℃每个码值,那理论上阈值取1就够了,但实际噪声和量化误差难免,建议取3到5。如果没有物理模型可以参考,就抓一段正常工况下的数据,统计相邻采样点差值的最大值,再乘1.5到2倍作为阈值。阈值太小会误杀正常信号,数据变得僵硬;阈值太大又等于没滤,毛刺长驱直入。
5.4 一阶滞后系数:从0.3起步盯着响应调
一阶滞后系数a给了一个连续可调的旋钮,但也正因如此,新手容易来回乱调。我的习惯是从0.3起步,然后给信号一个人为的阶跃输入,观察输出跟踪到目标90%需要多久。a等于0.3时,大约7个采样周期到90%;a等于0.1时,要20多个周期;a等于0.5时,3到4个周期就差不多了。
要求输出跟手就加大a,要求滤波结果平滑就减小a。实际项目里我还用过一种动态系数思路:数据平稳时用小系数,把噪声压住;当检测到输入与输出偏差大于某个阈值时,临时切到大系数,让输出尽快跟上真实变化,跟上了再切回去。这种“可变一阶滞后”在工业现场效果很好,但只在确实需要同时兼顾响应和平滑时才建议用。
6. 嵌入式工程落地:从能跑到好用的几个细节
代码能跑只是第一步。嵌入式环境资源有限,还要面对冷启动、浮点性能、调试手段等问题。下面几个细节是工程化时最容易踩坑的地方。
6.1 首采值处理:滤波器的“冷启动”问题
滑动平均窗口没填满时怎么输出?一阶滞后第一次输出要不要跳变?这些问题不加处理,开机头几百毫秒的数据基本不能看。我习惯用两个方案之一解决:一是先用第一个采样值填充整个窗口缓冲区,再开始正常滤波;二是启动阶段按实际已有数据量做部分平均,窗口填满后才完全进入滑动平均模式。
for (i = 0; i < WIN_SIZE; i++) { history_buf[i] = first_value; }一阶滞后的冷启动处理更简单,第一次调用时直接把last_output赋成当前采样值,也就是前面代码里initialized标志的作用。这样开机后的输出是从真实起点开始的,而不是从0慢慢爬上去,省去好几秒的收敛时间。
6.2 整数运算替代浮点:省下的不止是时间
很多单片机的内核没有FPU,浮点运算要靠软件库仿真,一次浮点乘法可能消耗几十甚至上百个时钟周期,代码体积也会增加。所以滤波参数我全部用整数表达。一阶滞后的系数a用0到100的整数表示百分比,换算成整式:
int output = last_output + (FILTER_COEF * (new_value - last_output)) / 100;这个写法和原式完全等价,还少了一次乘法。如果系数固定,编译器甚至会把除以100优化成乘法和移位。限幅判断里用两次减法比较差值,避免调用abs函数,也省掉了标准库依赖。这些优化对跑在STM32F103、STC8这类芯片上的项目,效果非常明显。
6.3 留一个调试后门:参数在运行时可调
最后分享一个贯穿我整个开发周期的小习惯:给滤波器开一个调试后门。哪怕只是临时加一个串口打印,把原始值、滤波值、当前参数周期性地打出来,调试效率都能翻倍。我通常先抓一段原始数据,在PC上用同样的算法离线试算,确定参数后再固化进单片机;如果现场工况复杂,把限幅阈值、滤波系数做成运行时可改的动态变量,通过串口命令调整,就不用反复烧录固件试错。
我现在做滤波方案基本是“先分类、再组合、后调参”,每次遇到新传感器,先花半天时间抓数据,剩下的就是套用这10种算法组合成一套合适的处理链。思路理顺了,这些算法就是一组随时可以调用的零件,真正难的从来不是代码本身,而是能不能判断出当下那个信号需要哪种策略。
本文还有配套的精品资源,点击获取