简介:这是一份面向嵌入式开发、单片机应用与信号采集方向的滤波算法代码合集,围绕工程中常见的采样噪声与脉冲干扰问题,整理了十种经典软件滤波方法的实现思路与C语言代码。内容涵盖限幅滤波、中位值滤波、算术平均、递推平均、中位值平均、限幅平均、一阶滞后、加权递推平均、消抖以及限幅消抖等算法,并逐项说明各自的方法、优缺点与适用参数场景,便于读者按被测信号特性选型。资源包共3个文件,包含1个C源码文件、1个doc说明文档和1个pdf研究资料,分别用于代码实现、方法讲解与理论参考,压缩包约198KB,体积轻便易于查阅。目前已有3190人学习下载,适合需要快速对比滤波效果、为温度、压力、流量等采集通道挑选合适算法的开发者参考,也可作为课程设计与项目调试的实用素材。
1. 嵌入式信号处理里,为什么我建议你手写这10种滤波算法
做单片机采集的朋友大概率遇到过这种场景:ADC 读回来的数据跳得厉害,屏幕上曲线像心电图,客户看一眼就说“你这东西是不是坏了”。第一反应是加个平均滤波,结果发现响应慢半拍,按键按下去要等两秒才有反应。换中值滤波,遇到连续脉冲干扰又压不住。折腾一圈才明白,滤波算法不是越复杂越好,而是要看噪声类型和信号特征来选。
这份 C 代码资源整理了 10 种工程里最常用的滤波算法,从最基础的限幅、中值、算术平均,到一阶滞后、加权递推、卡尔曼这些进阶方案,全部用纯 C 实现,不依赖任何第三方库,直接丢进 STM32、GD32、ESP32 或者 51 单片机的工程里就能编译。适合正在做传感器数据采集、电机控制、电源监测的嵌入式开发者,也适合刚接触信号处理、想搞明白“滤波到底在滤什么”的新手。代码风格偏底层,没有花哨的封装,每个函数独立可用,方便你按需裁剪。
2. 算法选型与工程结构:10种滤波器的适用边界
2.1 从噪声类型反推算法选择
拿到一份滤波代码,最忌讳的就是“哪个名字顺眼用哪个”。我一般会先问三个问题:噪声是高频毛刺还是低频漂移?信号本身变化快不快?单片机 RAM 和算力还剩多少?
高频毛刺,比如电机换向时 ADC 上的尖峰,中值滤波和限幅滤波是首选,它们对孤立脉冲的抑制效果立竿见影。低频漂移,比如温漂导致的缓慢偏移,一阶滞后滤波和卡尔曼滤波更合适,因为它们能跟踪信号趋势。如果信号本身变化很快,比如音频或振动信号,就不能用太重的滤波,否则相位延迟会把有用信息吃掉。
这份资源里的 10 种算法,按计算量和效果大致可以分成三档:
| 档位 | 算法 | RAM占用 | 适用场景 |
|---|---|---|---|
| 轻量 | 限幅、中值、算术平均 | 极小 | 低速ADC、按键消抖 |
| 中等 | 加权递推、一阶滞后、防脉冲干扰平均 | 小 | 温度、压力、电池电压 |
| 较重 | 卡尔曼、粒子滤波简化版 | 中等 | 姿态解算、电机闭环 |
注意:卡尔曼滤波虽然效果好,但需要调 Q 和 R 两个参数,调不好反而比一阶滞后还差。新手建议先从一阶滞后入手。
2.2 目录结构与文件组织
这份代码没有复杂的工程结构,就是一个典型的嵌入式 C 模块组织方式。解压后你会看到类似这样的目录:
filter_algorithms/ ├── inc/ │ ├── filter.h // 所有滤波函数的声明 │ └── filter_config.h // 参数配置宏 ├── src/ │ ├── filter_limit.c // 限幅滤波 │ ├── filter_median.c // 中值滤波 │ ├── filter_average.c // 算术平均 │ ├── filter_weighted.c // 加权递推 │ ├── filter_lag.c // 一阶滞后 │ ├── filter_kalman.c // 卡尔曼 │ └── ... └── example/ └── main.c // 使用示例filter.h里统一用typedef定义了数据类型,方便你在不同平台间移植。比如:
// filter.h 中的类型定义 typedef float filter_data_t; // 默认用 float,资源紧张可改 int32_t typedef struct { filter_data_t last_value; filter_data_t threshold; } limit_filter_t;这里filter_data_t默认是float,如果你用的是没有 FPU 的 51 单片机,可以改成int32_t或者int16_t,但要注意定点运算的溢出问题。limit_filter_t结构体里存了上一次的有效值和限幅阈值,调用时把结构体指针传进去就行,不需要全局变量。
2.3 编译与集成步骤
把代码集成到你的工程里,步骤不复杂,但有几个地方容易翻车。我一般按这个顺序来:
第一步,把inc和src两个目录拷贝到你的工程目录下,在 IDE 里把inc加入头文件搜索路径,把src下的.c文件加入编译列表。如果你用的是 Keil 或 IAR,直接在工程设置里添加即可。
第二步,根据你的单片机型号修改filter_config.h里的宏定义。比如:
// filter_config.h 中的可配置项 #define FILTER_USE_FLOAT 1 // 1: 使用float,0: 使用定点数 #define FILTER_MEDIAN_WINDOW 5 // 中值滤波窗口大小,必须是奇数 #define FILTER_KALMAN_Q 0.01f // 卡尔曼过程噪声协方差 #define FILTER_KALMAN_R 0.1f // 卡尔曼测量噪声协方差FILTER_MEDIAN_WINDOW必须是奇数,因为中值滤波要取中间那个值。如果你设成偶数,代码里会有一个#error提示你改回来。FILTER_KALMAN_Q和R的初始值可以先不动,后面调参时再改。
第三步,在main.c里包含filter.h,然后按需调用。比如你要用限幅滤波,先定义一个结构体变量,初始化阈值,然后在 ADC 中断或主循环里调用:
#include "filter.h" limit_filter_t limit; filter_data_t adc_value; int main(void) { limit_init(&limit, 5.0f); // 阈值设为5.0 while (1) { adc_value = read_adc(); adc_value = limit_filter(&limit, adc_value); // 使用滤波后的值 } }limit_init把last_value清零,threshold设为你传入的值。limit_filter每次比较新值和上一次有效值的差,如果超过阈值就认为这次是干扰,直接返回上一次的值;否则更新last_value并返回新值。这个逻辑对抑制突发尖峰特别有效,但阈值设太小会把正常信号也限掉,设太大又没效果,一般取信号最大变化率的 1.5 到 2 倍。
3. 核心滤波算法逐行拆解:从限幅到卡尔曼
3.1 限幅滤波与中值滤波的配合使用
限幅滤波的代码很短,但里面有个细节容易写错。先看实现:
// filter_limit.c filter_data_t limit_filter(limit_filter_t *f, filter_data_t new_value) { filter_data_t diff = new_value - f->last_value; if (diff < 0) diff = -diff; // 取绝对值 if (diff > f->threshold) { return f->last_value; // 超过阈值,丢弃新值 } f->last_value = new_value; // 更新有效值 return new_value; }diff取绝对值那一步,有些实现会用fabs(),但在嵌入式里能不用浮点库就不用,手动判断正负更省周期。threshold是唯一需要调的参数,我一般先用示波器看原始信号的最大正常跳变幅度,然后乘以 1.5 作为初始值。
限幅滤波单独用有个问题:如果干扰持续存在,比如连续几个采样点都是尖峰,限幅会一直返回旧值,导致输出“卡死”。这时候就要配合中值滤波。中值滤波的实现稍微长一点:
// filter_median.c filter_data_t median_filter(filter_data_t *buf, uint8_t size) { filter_data_t temp[FILTER_MEDIAN_WINDOW]; uint8_t i, j; // 拷贝到临时数组,避免破坏原缓冲区 for (i = 0; i < size; i++) { temp[i] = buf[i]; } // 冒泡排序,窗口小的时候够用 for (i = 0; i < size - 1; i++) { for (j = 0; j < size - 1 - i; j++) { if (temp[j] > temp[j + 1]) { filter_data_t t = temp[j]; temp[j] = temp[j + 1]; temp[j + 1] = t; } } } return temp[size / 2]; // 返回中间值 }buf是外部维护的采样缓冲区,每次新数据进来时用memmove或循环移位把旧数据往前挪,把新数据放到末尾。size就是窗口大小,必须是奇数。冒泡排序在窗口小于 10 的时候完全够用,没必要上快排。返回temp[size/2]就是中值。
把限幅和中值串起来用,效果比单独用好很多:先限幅去掉离谱的尖峰,再中值平滑剩下的毛刺。我一般会写一个组合函数:
filter_data_t limit_median_filter(limit_filter_t *l, filter_data_t *buf, uint8_t size, filter_data_t new_value) { filter_data_t limited = limit_filter(l, new_value); // 把限幅后的值推入中值窗口 for (uint8_t i = 0; i < size - 1; i++) { buf[i] = buf[i + 1]; } buf[size - 1] = limited; return median_filter(buf, size); }这个组合在电机电流采样里特别管用,换向尖峰被限幅吃掉,剩下的随机噪声被中值抹平。
3.2 一阶滞后滤波的参数整定
一阶滞后滤波,也叫指数加权平均,是工程里性价比最高的平滑算法。它的公式很简单:
// filter_lag.c filter_data_t lag_filter(lag_filter_t *f, filter_data_t new_value) { // alpha 越大,跟随越快,平滑越弱 f->last_value = f->alpha * new_value + (1.0f - f->alpha) * f->last_value; return f->last_value; }alpha是唯一参数,范围 0 到 1。alpha越接近 1,输出越跟随输入,平滑效果越弱;越接近 0,输出越平滑,但延迟越大。我一般用这个经验公式来选初始值:
alpha = 采样周期 / (采样周期 + 时间常数)比如你每 10ms 采样一次,希望时间常数是 100ms,那alpha = 0.01 / (0.01 + 0.1) ≈ 0.09。这个值可以先烧进去看波形,如果还是抖,就减小alpha;如果响应太慢,就增大alpha。
有个坑要注意:alpha用浮点乘法在低端单片机上很费时间。如果主频低于 24MHz,建议改成定点实现:
// 定点版一阶滞后,alpha 用 Q8 格式表示 int32_t lag_filter_fixed(lag_filter_t *f, int32_t new_value) { // alpha_q8 范围 0~256,对应 0~1.0 f->last_value = (f->alpha_q8 * new_value + (256 - f->alpha_q8) * f->last_value) >> 8; return f->last_value; }alpha_q8是alpha乘以 256 后的整数,比如alpha=0.1就填 26。右移 8 位代替除法,速度比浮点快很多。
3.3 卡尔曼滤波的简化实现与调参
卡尔曼滤波在这份代码里是一个简化版,只处理一维数据,适合单片机跑。完整实现大概三十行:
// filter_kalman.c filter_data_t kalman_filter(kalman_filter_t *f, filter_data_t new_value) { // 预测 f->p = f->p + f->q; // 更新 f->k = f->p / (f->p + f->r); f->x = f->x + f->k * (new_value - f->x); f->p = (1.0f - f->k) * f->p; return f->x; }q是过程噪声协方差,r是测量噪声协方差。q越大,越相信测量值,响应越快但噪声越大;r越大,越相信模型预测,输出越平滑但延迟越大。我一般先把q设成 0.001,r设成 0.1,然后看波形微调。
调参有个血泪经验:不要同时调q和r,固定一个调另一个。比如先固定r=0.1,把q从 0.0001 慢慢往上加,直到输出既能跟上信号变化,又不会明显抖动。如果怎么调都不满意,大概率是模型本身不适合你的信号,换一阶滞后反而更省事。
卡尔曼的初始化也很关键。x应该设成第一次采样值,p设成 1.0,k不用管,第一次迭代会自动算。如果x初始为 0 而实际信号在 100 左右,前几个输出会慢慢爬上去,看起来像“启动延迟”,其实不是算法问题,是初始值没给对。
4. 避坑与排查:滤波代码移植时最容易翻车的5个地方
4.1 现象:滤波后数据完全没变化
原因:结构体变量没有初始化,last_value是随机值,导致每次比较都超阈值,一直返回旧值。或者alpha设成了 0,输出永远是初始值。
解决:所有滤波结构体在使用前必须调用对应的init函数。如果代码里没有init,就手动把last_value设成第一次采样值。alpha不要设 0 或 1,取中间值。
4.2 现象:中值滤波输出偶尔跳变
原因:窗口缓冲区没有正确移位,新数据覆盖了旧数据但顺序乱了。或者窗口大小设成了偶数,size/2取到了偏大的那个中间值。
解决:检查memmove的方向,确保旧数据往前挪。窗口大小强制用奇数,可以在filter_config.h里加编译期检查:
#if (FILTER_MEDIAN_WINDOW % 2 == 0) #error "FILTER_MEDIAN_WINDOW must be odd" #endif4.3 现象:卡尔曼滤波输出发散
原因:p没有在每次迭代后更新,或者q和r设成了负数。浮点数溢出也会导致发散。
解决:检查p = (1 - k) * p这一行有没有漏掉。q和r必须大于 0。如果用的是float,确保编译器开启了浮点支持;如果是定点,检查移位有没有溢出。
4.4 现象:多个滤波通道互相干扰
原因:用了全局变量存last_value,两个通道共用了同一个结构体。
解决:每个通道独立定义一个结构体变量,不要共用。如果 RAM 紧张,可以把结构体放在数组里,用索引区分通道。
4.5 现象:滤波后信号相位延迟太大
原因:一阶滞后的alpha太小,或者卡尔曼的r太大,导致输出跟不上输入变化。
解决:先确认延迟是不是算法本身造成的。用信号发生器输入一个方波,看输出上升沿滞后多少。如果滞后超过应用允许的范围,就增大alpha或减小r。实在不行就换限幅+中值组合,这两种算法几乎没有相位延迟。
5. 二次开发与参数整定:把这套代码改成你自己的滤波库
这份代码最大的价值不是直接拿来用,而是作为一个起点,让你按自己的硬件和信号特征去改。我一般会做三件事:统一接口、加运行时调参、写自动化测试。
统一接口的意思是,把所有滤波函数都包装成同样的签名:
typedef filter_data_t (*filter_func_t)(void *ctx, filter_data_t new_value); typedef struct { filter_func_t func; void *ctx; } filter_t; filter_data_t apply_filter(filter_t *f, filter_data_t new_value) { return f->func(f->ctx, new_value); }这样上层代码不用关心具体用的是哪种滤波,换算法只需要换func和ctx。ctx指向对应的结构体,比如limit_filter_t或kalman_filter_t。
加运行时调参是为了在不重新烧录的情况下改参数。我一般会留一个串口命令解析器,收到SET ALPHA 0.05就修改对应结构体的alpha。调试阶段特别有用,不用反复编译下载。
自动化测试是很多人忽略的一步。我会在 PC 上写一个简单的测试程序,把滤波函数编译进去,喂入模拟的噪声数据,看输出是否符合预期。比如:
// test_filter.c void test_limit_filter(void) { limit_filter_t f; limit_init(&f, 5.0f); assert(limit_filter(&f, 10.0f) == 10.0f); // 第一次直接通过 assert(limit_filter(&f, 20.0f) == 10.0f); // 跳变10,超过阈值5,丢弃 assert(limit_filter(&f, 12.0f) == 12.0f); // 跳变2,通过 }这个测试在 PC 上跑一遍只要几毫秒,但能提前发现逻辑错误,比在单片机上用串口打印调试快得多。
最后说一个我踩过的坑:不要迷信“高级算法”。有一次我做电池电压采样,用了卡尔曼滤波,调了两天参数,效果还不如一阶滞后加中值。后来想明白了,电池电压本身变化就慢,卡尔曼的预测模型根本用不上,反而引入了不必要的计算量。从那以后我每次选滤波算法,都强制先问自己:信号带宽是多少?噪声是什么类型?单片机还剩多少算力?这三个问题答不上来,就不动手写代码。
希望这份代码和上面的经验能帮到你,少走一些我当年走过的弯路。
本文还有配套的精品资源,点击获取