简介:这份资源为嵌入式开发与单片机信号处理场景提供了一套完整的滤波算法参考实现,适合需要处理采样数据抖动、脉冲干扰或周期性噪声的工程师和学习者。压缩包内共3个文件,整体198KB,涵盖C语言源码文件、Word说明文档和PDF格式的滤波算法研究论文,兼顾代码实操与理论对照。
内容围绕10种常用滤波方法展开,包括限幅滤波、中位值滤波、算术平均滤波、递推平均滤波、中位值平均滤波、限幅平均滤波、一阶滞后滤波、加权递推平均滤波、消抖滤波及限幅消抖滤波。每种算法均给出原理说明、适用场景与优缺点分析,C源码可直接移植到主流单片机平台,便于按实际需求快速筛选和验证合适的滤波策略。
已有3189人学习下载,对于从事传感器数据采集、仪表显示或自动控制开发的读者,这份资源可作为日常编码中的速查手册,也可通过PDF论文加深对数字滤波原理的理解。
1. 滤波算法这碗饭,为什么值得你用 C 语言亲手做一遍
嵌入式采集、工业传感器、电机控制、音频前端……只要是 ADC 采出来的原始数据,几乎没人敢直接拿去做控制或显示。噪声、毛刺、脉冲干扰混在信号里,轻则曲线抖动,重则触发误动作。滤波算法 C 代码实现,是每个做底层开发的工程师绕不开的基本功,甚至可以说,你对数据的理解深度,就体现在这一层处理上。
很多人一开始图省事,直接调库、用 MATLAB 仿真一把就完事。但到了单片机上,内存只有几 KB,浮点运算慢得感人,库函数根本搬不进来,这时候手写一套可移植的 C 代码,反而是最可靠的路。这篇文章要讲的 10 种常用滤波算法,从最简单的限幅到相对复杂的滑动平均、一阶低通,每一种我都给出可直接抄进工程的结构体定义、函数实现和参数经验值。读过之后,你能根据信号特征选型、改参数,也能预判它会在什么场景下翻车。
2. 先分清这 10 种算法各治什么病:选型才是第一步
很多人一上来就调参,其实第一步错在选型。滤波算法没有绝对的优劣,只有适不适合当前信号的形态。选错了算法,参数调得再漂亮也是白费力气。
2.1 限幅滤波:专治“离谱的跳变”,不碰正常波动
限幅滤波的逻辑极其朴素:本次采样值和上次有效值的差超过阈值,就丢弃本次值,沿用上次值。它治的是那种明显不合理的跳变,比如传感器偶尔受雷击、电磁干扰产生的尖峰。
思路只有两条:一是限幅阈值要大于真实信号的最大变化率;二是判断失败时保留旧值,意味着输出会有一拍延迟。
以温度采集为例,正常升温每秒最多变化 0.1℃,如果采样周期是 1 秒,相邻两次的差值几乎不会超过 0.1℃。突然来一个 10℃ 的跳变,几乎可以断定是干扰,直接丢掉。
这种滤波对高频随机噪声完全无效,因为随机噪声每拍都可能小幅偏离真值,限幅阈值设小了会把正常信号也削掉,设大了又滤不掉噪声。
2.2 中位值滤波:对付尖峰脉冲的“老实人”
中位值滤波的思想很直白:连续采样 N 次,排序后取中间值作为有效值。它对偶然的脉冲干扰有着天然的免疫力——因为少数极端值不会改变排序后的中间位置。
但它有两个硬伤。一来,采样 N 次就要存 N 个值,排序还要耗时,实时性很差。二来,对快速变化的信号,这种滤波会引入严重的滞后,因为它输出的所谓“当前值”,实际上来自 N 次采样之后。
所以它的典型应用场景是温度、液位这类变化缓慢的过程量,而不是电机电流、振动加速度这类高速信号。
2.3 算术平均滤波与滑动平均滤波:压随机噪声,但有一个代价
算术平均滤波的做法是连续采样 N 次,求算术平均作为本次有效值。它对周期性噪声和随机噪声的抑制效果都很好,但 N 越大,信号的实时性就越差。比如 N=100 对 ADC 数据取平均,相当于输出天然滞后了 100 个采样周期。
滑动平均滤波则更聪明一点:维护一个长度固定的队列,每次采样进来一个新值,挤掉一个旧值,对队列里的 N 个值求平均。它的输出滞后是恒定的,大约(N-1)/2 个采样周期,而且计算量可以通过“滑窗和”来优化,不必每次重新累加全部 N 个数。
这两兄弟在工程上出场率极高:滑动平均用于示波器显示、平滑曲线;算术平均用于直流信号、弱信号测量。
2.4 一阶低通滤波:代价最小的“万金油”,但相位滞后明显
一阶低通滤波的离散形式是Y(n) = a*X(n) + (1-a)*Y(n-1),其中系数 a 在 0 到 1 之间。它的优点是只需要一个浮点变量存上次输出,内存开销极小,代码只有三行,是单片机上性价比最高的滤波手段。
缺点同样突出。a 越小,滤波越强,滞后越严重;a 越大,跟上真实信号的速度越快,但噪声残留也就越多。这是一种对“平滑度”和“跟随性”的妥协,参数整定全靠对信号的先验认知。
它对付高频随机噪声效果很好,但对付低频漂移无能为力,对付脉冲干扰则会把尖峰“拉宽”成一个缓变的小山丘——波峰还在,只是变钝了。
2.5 加权递推平均滤波:给滑动平均加上“偏心眼”
滑动平均滤波对队列里的 N 个值一视同仁,而加权递推平均滤波给每个值分配不同的权重。离当前时刻越近的数据,权重越大;越旧的数据,权重越小。这样做的目的是在平滑噪声的同时,减轻历史数据拖累导致滞后。
实现上可以先定义一个权重数组,比如 N=5,权重分别为 1、2、3、4、5,求和后再除以总权重。它比普通滑动平均的计算量略大,但换来了更好的动态响应。
要注意的是,权重系数并没有“最优解”,完全依赖你对信号变化速度的经验判断。如果给最近的数据权重太高,滤波效果会退化,噪声又冒出来了。
2.6 防脉冲干扰平均滤波和限幅平均滤波:处理工业现场的“组合拳”
这两种都属于复合滤波,思路是“先剔除坏值,再取平均”。
防脉冲干扰平均滤波的做法是连续采样 N 次,去掉一个最大值和一个最小值,对剩下的 N-2 个值求平均。它在平均滤波的基础上,把脉冲干扰对结果的影响降到最低。问题在于去掉的极值未必都是干扰,如果信号本身真的达到过最大值,也会被误杀。
限幅平均滤波则是先用限幅判断把本次采样值和上次有效值差值过大的样本扔掉,然后再对剩余样本求平均。它在防脉冲干扰平均滤波的基础上更进一步,通过把“异常跳变”在采样阶段就拦截掉,避免了“把正常峰值误当干扰”的问题。
这两种组合滤波是工业仪表、变送器里最常见的手段,因为它们能同时对抗脉冲噪声和随机噪声,只是实现上稍微费一点代码量和内存。
3. 用 C 代码实现这 10 种滤波器:结构体定义、函数实现、逻辑说明
选型思路定了,接下来就是把每个算法落成能直接编译运行的 C 代码。我会先给出每个滤波器用到的结构体和过滤函数,最后给一段 main 函数把它们串起来做演示。代码风格的取向是“可移植、占用少、不依赖外部库”。
3.1 准备一个公共的结构体集合
为了不让每个滤波函数都扛着一堆全局变量,常见做法是把滤波器的状态封装在结构体里。这样同一个滤波函数可以被实例化成多路,分别处理多个通道的数据。
typedef struct { float last_valid_value; // 上一次有效值 float limit_threshold; // 限幅阈值 int initialized; // 首次调用标记 } limit_filter_t; typedef struct { float buffer[10]; // 采样值缓冲区 int count; // 当前已采样的个数 } median_filter_t; typedef struct { float buffer[16]; // 滑动窗口 float sum; // 滑窗内值的和 int index; // 当前写入位置 int count; // 窗口内有效数据个数 } sliding_average_filter_t; typedef struct { float last_output; // 上一次输出值 float alpha; // 滤波系数,0.01~0.5 之间常见 } lowpass_filter_t;代码逻辑上做了几件事:每个滤波器通过结构体保存自己的状态,避免全局变量冲突;initialized标记用于首采时直接赋值,避免把未经滤波的 0 当成有效数据;缓冲区大小先写死成固定数组,方便在单片机上按需修改。
这里有一个值得注意的点:结构体里的缓冲区大小是魔术数字。在正式工程里,我通常会用宏定义或者 enum 把窗口大小抽出来,不然换一个场景就改一次结构体,很容易出低级错误。
3.2 限幅滤波与中位值滤波的核心函数
限幅滤波的代码很直接:
float limit_filter(limit_filter_t* flt, float raw_value) { if (!flt->initialized) { flt->last_valid_value = raw_value; flt->initialized = 1; return raw_value; } if (fabsf(raw_value - flt->last_valid_value) > flt->limit_threshold) { return flt->last_valid_value; // 丢弃异常值 } flt->last_valid_value = raw_value; return raw_value; }逻辑说明:首次调用直接把采样值当作有效值,避免 0 值污染。后续每一次采样,计算与上次有效值的差的绝对值,如果超过阈值就返回旧值,否则正常更新。参数limit_threshold完全取决于真实信号的物理变化率,并没有通用数值。
中位值滤波实现时,要处理缓冲区排序和取中间值两步:
float median_filter(median_filter_t* flt, float raw_value, int n) { if (flt->count < n) { flt->buffer[flt->count++] = raw_value; } if (flt->count < n) { return raw_value; // 样本不足时先直通 } // 拷贝一份做排序,避免破坏写入顺序 float temp[10]; memcpy(temp, flt->buffer, n * sizeof(float)); // 简单插入排序,n 很小,不用上快排 for (int i = 1; i < n; i++) { float key = temp[i]; int j = i - 1; while (j >= 0 && temp[j] > key) { temp[j+1] = temp[j]; j--; } temp[j+1] = key; } flt->count = 0; // 消费完本轮 N 个样本 return temp[n/2]; }参数说明:n是排序窗口宽度,通常取 3 到 7,工程上 5 最常用。窗口过小,抗脉冲能力不足;过大,内存和计算量都会上去,且滞后明显。排序部分拷贝了一份临时数组,是为了保持写入顺序的连续性,因为中位值滤波要求的是连续 N 次的原始采样,而不是窗口滑动后的混叠数据。
3.3 滑动平均与一阶低通的核心函数
滑动平均用滑窗和来降低计算量:
float sliding_average_filter(sliding_average_filter_t* flt, float raw_value, int n) { if (flt->count < n) { flt->count++; } else { flt->sum -= flt->buffer[flt->index]; // 挤掉最旧的值 } flt->buffer[flt->index] = raw_value; flt->sum += raw_value; flt->index = (flt->index + 1) % n; // 环形索引 return flt->sum / flt->count; }这段代码最关键的优化在于用环形索引和累计和,避免了每来一个数据就重新累加整个窗口。sum保存当前窗口内所有值的和,写入新值时先减掉被覆盖的旧值,再加上新值。这样的时间复杂度是 O(1),而朴素实现是 O(n),在中断里高频调用时差异会被放大。
一阶低通滤波的代码只有三行:
float lowpass_filter(lowpass_filter_t* flt, float raw_value) { flt->last_output = flt->alpha * raw_value + (1.0f - flt->alpha) * flt->last_output; return flt->last_output; }参数alpha的语义要理解透:alpha 越大,新采样值占的权重越大,滤波越弱、响应越快;alpha 越小,历史输出占的权重大,平滑效果好但滞后严重。如果说一个经验值,采样周期 10ms 的温控系统里,alpha 取 0.05 到 0.2 之间比较常见;而采样周期 1ms 的电流环里,alpha 可能需要到 0.5 左右才跟得上参考值。
3.4 加权递推、防脉冲干扰和限幅平均的复合实现
加权递推平均滤波需要预定义一组权重,代码里我用静态数组保存:
float weighted_average_filter(float* buffer, int n, const float* weights) { float sum = 0.0f; float weight_sum = 0.0f; for (int i = 0; i < n; i++) { sum += buffer[i] * weights[i]; weight_sum += weights[i]; } return sum / weight_sum; }调用前需要维护一个长度为 n 的缓冲区,每来一个新值覆盖最旧的值。权重数组的构造,常见做法是取 1 到 n 的等差数列,如 weights[] = {1, 2, 3, 4, 5},也可按指数衰减构造,但那样小数乘法多,在定点 MCU 上未必划算。
防脉冲干扰平均滤波,核心是先找最大值和最小值,然后剔除求和:
float anti_pulse_average_filter(float* buffer, int n) { float max = buffer[0], min = buffer[0]; float sum = 0.0f; for (int i = 0; i < n; i++) { if (buffer[i] > max) max = buffer[i]; if (buffer[i] < min) min = buffer[i]; sum += buffer[i]; } return (sum - max - min) / (n - 2); }这里默认只剔除一个最大值和一个最小值。如果现场脉冲干扰非常密集,光剔一对极值不够,需要先对数据排序再掐头去尾,那实现就会和中位值滤波的思想重合。限幅平均滤波则是在这个函数之前,先对每个新采样值做一次限幅判断,不合格的直接用上一次有效值补位,再做平均。这样做的优势是提前拦截了异常跳变,平均过程不容易被污染。
4. 参数调优:采样频率、窗口大小、滤波系数的协同整定
代码写完了,并不是直接跑就行。每个滤波器都有参数要定,而参数定得不合理,算法效果会远差于预期。这一章讲透参数调节的方法论。
4.1 采样频率决定滤波器的“视野”
滤波器看到的信号质量,一半取决于采样频率。采样频率太低,信号的真实变化会被扭曲,任何滤波算法都救不回来;采样频率太高,则噪声的频带会被展宽,需要更强烈的滤波才能压住。
工程上有一个朴素的判断标准:采样频率至少要达到信号最高频率的 10 倍以上,再谈滤波。以 50Hz 工频干扰为例,采样频率至少要 500Hz,滑动平均的窗口 N 才有意义。如果采样频率只有 100Hz,窗口 N=5 只能覆盖 0.05 秒,对 50Hz 工频干扰几乎无能为力。
4.2 滑动窗口 N 的选取:一个基于截止频率的经验公式
滑动平均本质上是一个 FIR 低通滤波器,它的等效截止频率大约可以用这个经验公式估算:
f_cutoff ≈ 0.443 * f_sample / N
这里 0.443 是滑动平均窗的主瓣带宽系数,可以直接拿来用。如果你希望滤除 100Hz 以上的噪声,采样频率是 1000Hz,那么 N 可以按0.443 * 1000 / 100 ≈ 4.43,取整为 5 左右。
这个公式定给你的是一阶近似,不要指望精确。但它的价值在于给你一个从“振动的频率特征”出发,反向推导窗口大小的路径,而不是靠感觉拍脑袋。注意,N 越大滞后越明显,如果滞后导致控制环震荡,就说明 N 过了,要减小。
4.3 一阶低通 alpha 的确定:先看阶跃响应,再微调
alpha 的整定,比 N 还要“玄学”。我一般会先给系统一个阶跃输入,观察输出从 10% 升到 90% 所需的上升时间。一阶低通的上升时间大约满足:
t_rise ≈ 2.2 / f_cutoff ≈ 2.2 * T_sample * (1 - alpha) / alpha
这个式子可以做粗略反推:想要上升时间 100ms,采样周期 10ms,那么 (1-alpha)/alpha ≈ 4.5,解出来 alpha ≈ 0.18。跑一次看看实际曲线,如果噪声压不住,alpha 调小;如果信号跟得太慢,alpha 调大。每次只动 20% 以内的幅度,多试几轮就能找到合适的点。
值得警惕的是,alpha 整定没有数学最优解,因为它本质上是平滑度和滞后之间的权衡。做控制的同事调 PID 的时候常说“参数是试出来的”,滤波器和它一模一样,你只需要带着上述的估算值入场,再用观察法收敛。
4.4 限幅阈值的确定:用“最大物理变化率”说话
限幅阈值不产生相位滞后,也不牺牲高频细节,它唯一要禁掉的是超出物理可能的变化。所以它的设定依据应该是对象本身的最大变化速率。
比如测压力,系统加压最快 2kPa/s,采样周期 10ms,那么相邻两次采样的最大差值是 0.02kPa。阈值取 0.03~0.05kPa,留出一点余量就够用。不要根据噪声幅度来定阈值,因为噪声分布在不同工况下会变,而物理变化率不会。
如果发现限幅阈值太小,导致信号变化被“削平”,先检查是不是采样频率太低,而不是一味放宽阈值。
各滤波器的参数依赖关系,我整理成一个简洁的对照表方便查阅:
| 滤波器 | 主要参数 | 依赖信号特征 | 滞后表现 |
|---|---|---|---|
| 限幅滤波 | 限幅阈值 | 最大物理变化率 | 无滞后,但会丢数据 |
| 中位值滤波 | 窗口 N | 脉冲干扰宽度 | 固定 (N-1)/2 拍 |
| 算术平均 | 窗口 N | 噪声频率分布 | 固定 (N-1)/2 拍 |
| 滑动平均 | 窗口 N | 目标截止频率 | 固定 (N-1)/2 拍 |
| 一阶低通 | alpha | 期望上升时间 | 可调,与 alpha 成反比 |
| 加权递推 | 权重数组 | 信号变化速度 | 比滑动平均小 |
| 防脉冲平均 | 窗口 N | 脉冲干扰宽度 | 固定 (N-1)/2 拍 |
5. 避坑指南:滤波算法在工程落地上最容易踩的 5 个坑
滤波算法的数学原理都不难,代码也就几十行,真正的血泪经验全在工程细节里。这一章把我见过、踩过的坑一条条拆开,每条按“现象 → 原因 → 解决”的方式记录。
5.1 第一个坑:首次调用输出 0 导致启动瞬间飞车
现象:系统上电后,滤波器前几拍输出 0,如果输出直接送给执行机构,电机会猛地蹿一下,阀门会瞬间全开。很多人 debug 半天,最后发现是滤波器的初始状态没设置。
原因:结构体是全局变量时,C 语言会默认清零;但如果定义在函数内部且未初始化,里面的值是随机的。滤波函数在第一次调用时,拿 0 当作历史值去算,输出自然异常。
解决:每个滤波函数都要加initialized标记或直接在初始化函数里把缓冲区和历史值赋成当前采样值。我的习惯是在创建滤波器后,先调用一次滤波函数,把当前 ADC 值“灌”进去,再做正常滤波。这段代码不值得省,它能避免大多数启动阶段的诡异行为。
5.2 第二个坑:浮点运算在定点 MCU 上把性能拖垮
现象:滤波算法一开,系统中断响应变慢,或者主循环掉帧。用示波器量发现单次滤波调用耗时几十微秒,频率一高直接爆。
原因:很多廉价 MCU 没有硬件浮点单元,float运算要编译器模拟,开销极高。再加上fabsf、memcpy这类库函数调用,耗时比想象中大得多。
解决:在 Cortex-M4 以上内核直接用 float 没问题;在 8 位、16 位 MCU 上,能转成定点就别用 float。比如一阶低通的alpha可以放大 256 倍用整数表示,Y = (alpha_q * X + (256 - alpha_q) * Y) >> 8,效果基本一致,速度提升明显。还要注意,不是所有编译器都自带fabsf,有的需要显式链接libm,否则编译报错。
5.3 第三个坑:滑动平均的环形缓冲区索引越界
现象:程序运行一段时间后,偶尔出现滤波输出跳变,或者内存被踩坏。表面上看起来完全没有规律,重启后又能跑很久。
原因:环形索引index在自增时没有正确处理回绕。假如n不是 2 的幂,用(index + 1) % n没问题;但有些人图省事写成index++,然后if (index == n) index = 0。逻辑上没错,但一旦index被外部修改或者类型是无符号数且n不匹配,就有隐患。
解决:严格使用取模运算,或者把窗口大小定为 2 的幂(比如 8、16、32),用按位与(index + 1) & (n-1)替代取模。后者的性能在中断里优势明显,也能杜绝边界问题。
5.4 第四个坑:滤波后的数据直接用于 PID 控制导致相位滞后振荡
现象:控制系统的反馈值经过强滤波后,肉眼看起来曲线很漂亮,但系统就是稳不住,轻微振荡甚至发散。把滤波撤掉,振荡反而消失。
原因:滤波带来的相位滞后,在控制环里相当于增加了延迟。控制理论对延迟非常敏感,滞后 2~3 拍就足以让 PID 的相位裕度耗尽,系统趋于不稳定。
解决:控制回路的滤波和显示用的滤波,应该分开。显示通道可以为了平滑加大窗口;控制回路则应该用轻微滤波甚至不滤波,或者补偿滞后。一阶低通在控制回路里,alpha 经验值不要低于 0.3,否则系统几乎必然振荡。这也是“滤波不是越强越好”的最典型场景。
5.5 第五个坑:参数是“调出来的”,但不要用同一组参数套所有工况
现象:设备在实验室跑得好好的,上了现场数据就烂掉。现场工况一变,之前的参数全部失效,滤波效果还不如不滤波。
原因:滤波参数天然依赖信号特征。实验室的纯正弦叠加白噪声和现场的变频器干扰、继电器火花、电机换向尖峰,频谱特征完全不一样。
解决:如果工况有数档,参数应该做成分档切换。比如转速高时用强滤波,转速低时用弱滤波或直接直通,用查表方式切换滤波器参数。这样写起来不复杂,但能避免设备在不同工况下出现“水土不服”的尴尬。
6. 验证与进阶:构造测试信号、量化滤波效果、再决定要不要学卡尔曼
最后一章回到一个关键问题:你怎么知道滤波器真的“工作正常”?靠眼睛看曲线当然可以,但更稳的做法是构造输入信号、量化评价指标,再根据结果决定下一步要不要上更高级的算法。
我在验证滤波器时,会先在 PC 上用 C 语言写一个测试程序,用三段信号模拟不同工况,然后把滤波前后的数据导出做对比。第一段是阶跃信号,用来测跟随性和上升时间;第二段是正弦信号叠加白噪声,用来测平滑效果和相位滞后;第三段是在稳定值上注入几个脉冲尖峰,用来测抗脉冲能力。
量化的指标有两个,一个是均方根误差(RMSE),反映滤波后与真值的偏差程度;另一个是滤波前后的方差比,方差降得越多,说明噪声压制越强。但不要只看方差,如果信号本身快速变化,方差降得漂亮,真实信号也被滤平了,这时候要结合阶跃响应的上升时间一起看。
// 量化滤波效果:计算滤波输出的均方根误差和方差 float calc_rmse(float* output, float* reference, int len) { float sum = 0.0f; for (int i = 0; i < len; i++) { float err = output[i] - reference[i]; sum += err * err; } return sqrtf(sum / len); }对于普通的嵌入式应用,10 种经典滤波器已经能覆盖 90% 以上的场景。如果做完上述验证,发现噪声频带和信号频带严重重叠,或者需要在线自适应改变滤波强度,这时候才值得认真考虑卡尔曼滤波。卡尔曼滤波需要建立运动模型和噪声协方差矩阵,代码量更大,调参难度也不是一个量级。我的看法是,先用经典滤波把问题定位清楚,再用卡尔曼去替换那些确实无法满足的部分,不盲目追新。
滤波器的代码看起来简单,真正决定成败的往往不在算法本身,而在调用时机、数据结构、参数整定这些细节里。有段时间我迷信“高级算法”,把一阶低通换成卡尔曼,结果因为 Q 和 R 矩阵没调好,输出比滤波前还抖。后来老老实实把经典滤波的参数吃透,再回头看复合滤波,才发现大部分现场噪声根本用不着上卡尔曼。希望这篇文章能帮你把这条路走稳,少踩那些我已经替你踩过的坑。
本文还有配套的精品资源,点击获取