1. 项目概述:为什么我们需要软件滤波?
在嵌入式开发、数据采集和信号处理领域,我们经常会遇到一个头疼的问题:传感器读回来的数据“跳”得厉害。比如你用单片机读取一个温度传感器的值,理想中它应该是一条平滑的曲线,但实际屏幕上显示的却是一条上下乱窜的“毛刺”线。这些毛刺,就是噪声。噪声的来源五花八门,可能是电源纹波、电磁干扰,也可能是传感器本身的精度限制。如果直接用这些“脏数据”去做控制、显示或者判断,轻则导致屏幕闪烁、控制不稳,重则可能引发系统误动作,造成不可预知的后果。
这时候,软件滤波算法就派上用场了。它的核心思想,就是在不增加硬件成本(比如更贵的传感器、更复杂的滤波电路)的前提下,纯粹通过软件算法,对采集到的原始数据进行“清洗”和“平滑”,提取出我们真正关心的、反映物理量真实变化趋势的信号。对于资源受限的单片机(MCU)环境,C语言是实现这些算法的首选。它高效、直接,能让我们清晰地看到算法每一步是如何操作内存和数据的,这对于理解滤波本质和优化代码至关重要。今天,我就结合自己多年在工控和物联网设备开发中的实战经验,为你系统梳理10种最常用、最经典的软件滤波算法,并附上可直接移植的C语言代码和避坑指南。
2. 滤波算法核心思路与选型逻辑
面对一堆滤波算法,新手最容易犯的错就是“手里有把锤子,看什么都像钉子”,不分场景乱用。实际上,每种算法都有其鲜明的性格和适用场景。选择哪种算法,主要取决于三个关键因素:信号特性、噪声特性和系统资源。
信号特性:你的有效信号变化是快是慢?比如水温变化慢,是低频信号;电机转速脉冲变化快,是高频信号。噪声特性:干扰噪声主要是高频的随机毛刺,还是低频的工频干扰,或者是偶尔出现的巨大跳变(野值)?系统资源:你的MCU RAM和计算能力(MIPS)是否充裕?算法是否需要在中断服务程序中快速完成?
根据这些,我们可以把滤波算法分为几个大类:对付随机噪声的平滑类(如均值、滑动平均)、对付脉冲噪声的抗野值类(如中值、限幅)、兼顾响应速度与平滑度的递归类(如一阶滞后、卡尔曼),以及用于特定信号提取的复合类。下面这张选型速查表,可以帮你快速建立第一印象:
| 算法类型 | 典型算法 | 核心优势 | 主要缺点 | 适用场景 |
|---|---|---|---|---|
| 平滑/均值类 | 算术平均、滑动平均 | 算法简单,对高斯白噪声抑制效果好 | 滞后明显,RAM占用大 | 信号变化缓慢,噪声随机且频繁,如温度、压力 |
| 抗野值类 | 中值滤波、限幅滤波 | 能有效剔除偶发的脉冲干扰 | 对持续随机噪声效果一般 | 数据中存在偶发跳变,如按键去抖、存在开关干扰的AD采样 |
| 递归反馈类 | 一阶滞后滤波 | 节省RAM,平滑效果好 | 相位滞后,参数调整需经验 | 信号惯性大,需连续平滑,如电池电压采样 |
| 自适应类 | 卡尔曼滤波 | 最优估计,能预测趋势 | 模型复杂,计算量大 | 对精度和实时性要求高的动态系统,如姿态解算、导航 |
| 复合类 | 限幅平均滤波 | 兼具抗脉冲和平滑能力 | 参数较多,调优复杂 | 环境复杂,噪声多样化的工业现场 |
提示:没有“最好”的算法,只有“最合适”的。通常我们需要在“平滑度”、“实时性”和“抗干扰能力”之间做权衡(Trade-off)。
3. 十大滤波算法深度解析与C实现
接下来,我们进入核心部分。我会为每种算法拆解其原理、给出直白的C语言实现,并附上我实际调试中的心得和坑点。假设我们处理的数据类型是float,但你也可以轻松改为int以适应定点数场景。
3.1 限幅滤波法(程序判断滤波法)
原理:这是最直观的“常识”滤波。根据经验或系统最大可能变化速度,设定一个最大允许偏差A。本次采样值new_value与上次有效值last_value之差超过A,则认为本次是干扰,舍弃并用上次值代替;反之则认可本次采样。
核心思想:物理量不可能突变。
#define A 10 // 最大允许偏差值,需根据实际信号变化率设定 float LimiterFilter(float new_value, float last_value) { float filtered_value; if (fabs(new_value - last_value) > A) { // 变化超限,视为干扰,返回上次有效值 filtered_value = last_value; } else { // 变化合理,采用新值 filtered_value = new_value; } return filtered_value; }实操要点与避坑:
- 参数A的设定是关键。A设大了,滤波效果弱;设小了,系统响应变迟钝,可能滤掉真实突变信号。最好通过观察历史数据或计算信号的理论最大变化率(如:温度每分钟最多升1度,采样周期1秒,则A≈1/60)来初步确定,再微调。
- 单独使用效果有限。它只能滤除大的脉冲,对于小幅连续波动无能为力。因此常作为其他滤波算法的“前置卫士”,组成复合滤波。
- 注意首次调用。首次调用时
last_value需要有一个合理的初始值(如第一次采样值),否则第一次判断可能出错。
3.2 中值滤波法
原理:取一组采样值(通常为奇数个),按大小排序,取中间那个值作为本次滤波结果。它非常擅长干掉那种“鹤立鸡群”的野值。
核心思想:少数服从多数,野值通常处于两端。
#define N 5 // 滤波窗口大小,建议取3, 5, 7等奇数 float MedianFilter(float* data_buf) { float temp_buf[N]; float temp; int i, j; // 1. 拷贝数据到临时数组进行操作,避免破坏原缓冲区 for (i = 0; i < N; i++) { temp_buf[i] = data_buf[i]; } // 2. 使用冒泡排序进行排序(数据量小,效率可接受) for (i = 0; i < N - 1; i++) { for (j = 0; j < N - 1 - i; j++) { if (temp_buf[j] > temp_buf[j + 1]) { temp = temp_buf[j]; temp_buf[j] = temp_buf[j + 1]; temp_buf[j + 1] = temp; } } } // 3. 返回中值 return temp_buf[N / 2]; } // 使用示例:需要维护一个长度为N的循环缓冲区`data_buf[]` // 每次采样后,将新数据存入缓冲区,并调用此函数。实操要点与避坑:
- 窗口大小N的选择:N越大,平滑效果越好,但滞后也越严重,且排序耗时剧增(时间复杂度O(N²))。对于MCU,N=3或5是常见选择。对于实时性要求高的场景(如高速ADC),需谨慎评估排序耗时,或考虑使用更快的排序算法(如选择排序)。
- 适合非连续快速变化信号。对于连续缓变信号(如温度),效果很好。但对于本身就在快速、规律变化的信号(如正弦波),中值滤波会严重失真。
- 内存与计算开销:需要开辟一个大小为N的数组,且每次都要排序。在资源紧张的8位MCU上,当N较大或采样频率高时,这可能成为瓶颈。
3.3 算术平均滤波法
原理:最简单直接的平滑算法。连续采样N次,求和后取平均值。其数学本质是低通滤波,能有效抑制周期性干扰和随机噪声。
核心思想:用数据量换取精度,噪声的期望值为零。
#define N 10 // 平均次数 float MeanFilter(float* data_buf) { float sum = 0; int i; for (i = 0; i < N; i++) { sum += data_buf[i]; } return sum / N; } // 同样需要维护一个长度为N的缓冲区。实操要点与避坑:
- 灵敏度与平滑度的矛盾:N越大,输出越平滑,但对信号变化的反应也越迟钝。这是所有均值类滤波的通病。
- RAM占用:需要存储N个历史数据。如果N很大(比如50),每个数据是4字节float,那就需要200字节RAM,对于小内存MCU是笔不小开销。
- 适用于信号本身波动不大的场景。比如稳态工况下的温度、压力测量。对于跟踪快速变化的信号(如电机速度),此法不适用。
3.4 滑动平均滤波法(递推平均滤波法)
原理:算术平均法的改进版。它维护一个长度为N的队列(循环缓冲区)。每次得到新数据时,将其放入队尾,并丢弃队首最老的数据,然后计算当前队列中所有数据的平均值。这样,每次输出都是基于最新的N个数据,实现了“滑动窗口”。
核心思想:实时更新,既平滑又有一定的实时性。
#define N 10 typedef struct { float data[N]; int index; // 指向下一个要写入的位置 float sum; // 当前窗口内数据的和 } MovingAverageFilter; void MAF_Init(MovingAverageFilter* filter, float init_value) { int i; for (i = 0; i < N; i++) { filter->data[i] = init_value; } filter->index = 0; filter->sum = init_value * N; } float MAF_Update(MovingAverageFilter* filter, float new_value) { // 减去即将被丢弃的旧值,加上新值 filter->sum = filter->sum - filter->data[filter->index] + new_value; // 用新值覆盖旧值 filter->data[filter->index] = new_value; // 更新索引,实现循环缓冲区 filter->index = (filter->index + 1) % N; // 返回平均值 return filter->sum / N; }实操要点与避坑:
- 初始化很重要。滤波器启动时,缓冲区里是随机值或零,这会导致前N次输出不准。常见的初始化策略是用第一次采样值填充整个缓冲区,如上例所示。
- “和”的溢出问题。如果数据是整数且N较大,
sum可能会溢出。使用float或double可以避免,但会牺牲速度。对于整数,可以使用更大位宽的类型(如int32_t存int16_t的和)或定期重新计算总和来防止累积误差。 - 这是最常用、最实用的平滑滤波之一。在嵌入式领域应用极广,因为它平衡了效果、速度和资源。
3.5 中位值平均滤波法(防脉冲干扰平均滤波法)
原理:算术平均法怕野值,中值法对随机噪声平滑不够。那就结合一下:连续采样N个数据,先去掉一个最大值和一个最小值(疑似野值),再对剩下的N-2个数据求算术平均。
核心思想:融合中值和平均,抗干扰能力更强。
#define N 10 // 采样次数,建议>=4 float MedianMeanFilter(float* data_buf) { float temp_buf[N]; float sum = 0, min_val, max_val; int i, min_idx = 0, max_idx = 0; // 拷贝并找出最值索引 for (i = 0; i < N; i++) { temp_buf[i] = data_buf[i]; if (temp_buf[i] < temp_buf[min_idx]) min_idx = i; if (temp_buf[i] > temp_buf[max_idx]) max_idx = i; } // 求和时跳过最值 for (i = 0; i < N; i++) { if (i != min_idx && i != max_idx) { sum += temp_buf[i]; } } return sum / (N - 2); }实操要点与避坑:
- 计算量稍大。需要遍历找最值,但比全排序的中值滤波要快。适用于对平滑度和抗脉冲都有要求,且计算资源尚可的场景。
- N的取值。N不能太小,否则去掉两个后数据量太少,平均意义不大。一般N取6-14。
- 注意极端情况。如果信号本身就在快速升降,最大值和最小值可能就是真实信号,此时此法会引入误差。因此它更适合背景噪声为随机噪声、叠加偶发脉冲的场景。
3.6 限幅平均滤波法
原理:在算术平均或滑动平均之前,先加一道“安检”——限幅滤波。即,每次新采样值先经过限幅判断,如果合理则送入缓冲区参与平均;如果被判为干扰,则用上次滤波输出值或上次有效采样值代替它送入缓冲区。
核心思想:限幅把关,平均平滑,双重保险。
#define N 10 #define A 5 // 限幅阈值 typedef struct { float buf[N]; int index; float last_valid; // 上次的有效值(用于限幅判断) } LimitingMeanFilter; void LMF_Init(LimitingMeanFilter* filter, float init_value) { int i; for (i = 0; i < N; i++) { filter->buf[i] = init_value; } filter->index = 0; filter->last_valid = init_value; } float LMF_Update(LimitingMeanFilter* filter, float new_sample) { float input_to_buffer; float sum = 0; int i; // 限幅判断 if (fabs(new_sample - filter->last_valid) > A) { input_to_buffer = filter->last_valid; // 超限,用旧值 } else { input_to_buffer = new_sample; // 正常,用新值 filter->last_valid = new_sample; // 更新有效值 } // 更新滑动平均缓冲区 filter->buf[filter->index] = input_to_buffer; filter->index = (filter->index + 1) % N; // 计算平均值 for (i = 0; i < N; i++) { sum += filter->buf[i]; } return sum / N; }实操要点与避坑:
- 参数调优更复杂。现在你有两个参数要调:限幅阈值A和平均窗口N。需要根据信号和噪声特性反复试验。
- 对连续干扰的抵抗力。如果干扰不是单次脉冲,而是一连串的坏数据(持续时间小于N个采样周期),限幅环节可能会连续使用旧值填充缓冲区,导致输出在一段时间内“卡住”不变。这是其缺点。
- 实战常用组合。在工业现场,传感器信号线较长易受干扰,限幅+滑动平均是非常经典且有效的组合。
3.7 一阶滞后滤波法(一阶低通滤波法)
原理:这是一个递归滤波器(IIR)。本次输出结果是上次输出结果与本次采样值的加权和:Y(n) = a * X(n) + (1-a) * Y(n-1)。其中,a是滤波系数(0<a≤1),X(n)是本次采样值,Y(n-1)是上次滤波输出值。
核心思想:相信历史,也相信现在,但更相信历史。用系数a来调节信任比例。
#define ALPHA 0.1f // 滤波系数,越小越平滑,滞后越大 float FirstOrderLagFilter(float new_sample, float last_output) { return ALPHA * new_sample + (1 - ALPHA) * last_output; } // 使用:需要全局变量或结构体保存`last_output`实操要点与避坑:
- 系数a是灵魂。a越接近1,滤波器越“信任”新数据,响应快但平滑差;a越接近0,越“信任”历史,平滑好但响应迟钝。a的选取没有严格公式,通常根据经验从0.01到0.3之间尝试。一个粗略的指导:a ≈ 采样周期T / (滤波时间常数τ)。如果你想滤掉周期小于τ的波动,可以按此估算。
- 极其节省资源。只需要保存一个状态量
last_output,计算量是一次乘法和一次加法,非常适合在高速采样或资源紧张的场合使用。 - 相位滞后。这是低通滤波器的固有特性,会导致输出在时间上落后于真实信号。在控制回路中使用时,需要考虑这个滞后对系统稳定性的影响。
- 初始化。第一次调用时,
last_output需要一个合理的初值,通常用第一次采样值。
3.8 加权递推平均滤波法
原理:滑动平均的升级版。它认为“近的数据比旧的数据更重要”。所以给窗口内的每个数据赋予一个权重,越新的数据权重越大,然后求加权平均。
核心思想:厚今薄古,在平滑度和实时性间取得更好平衡。
#define N 5 // 权重数组,和为1.0,索引0对应最老数据,N-1对应最新数据 static const float weight[N] = {0.05f, 0.1f, 0.15f, 0.2f, 0.5f}; float WeightedMovingAverage(float* circular_buf, int current_index) { float sum = 0; int i, buf_index; for (i = 0; i < N; i++) { // 从当前索引向前推算,获取从老到新的数据 buf_index = (current_index - i + N) % N; sum += circular_buf[buf_index] * weight[i]; // weight[i]对应第i老的数据 } return sum; // weight已归一化,和为1,直接返回sum即可 } // 注意:此例中weight[i]对应的是第i老的数据。你也可以让weight[0]对应最新数据,取决于你的缓冲区组织方式。实操要点与避坑:
- 权重的设计:权重分布是关键。常见的有线性递增、指数递增等。权重之和必须为1。你可以通过调整权重分布来灵活控制滤波器特性,例如让最新一两个点占很大比重,以获得快速响应。
- 计算量:比普通滑动平均多了一次乘法运算,但通常可以接受。
- 适用于非平稳信号。当信号本身的变化趋势在改变时(比如开始加速上升),加权平均能更快地跟上变化,而普通平均会拖后腿。
3.9 消抖滤波法
原理:此方法专治“抖动”。它设定一个计数器,当本次采样值与当前稳定值之差小于某个阈值时,计数器加1;当计数器累计达到一个设定上限N时,才认为采样值稳定有效,并更新输出。一旦差值超过阈值,计数器清零。
核心思想:连续多次确认稳定,才采信。
#define THRESHOLD 2.0f // 稳定判断阈值 #define N 4 // 稳定确认次数 typedef struct { float stable_value; // 当前稳定输出值 int count; // 稳定计数器 } DebounceFilter; void DebounceFilter_Init(DebounceFilter* filter, float init_value) { filter->stable_value = init_value; filter->count = 0; } float DebounceFilter_Update(DebounceFilter* filter, float new_sample) { if (fabs(new_sample - filter->stable_value) < THRESHOLD) { // 在阈值范围内,计数器累加 filter->count++; if (filter->count >= N) { // 连续N次稳定,确认新状态,更新稳定值并重置计数器 filter->stable_value = new_sample; filter->count = 0; // 重置,为下一次变化准备 } } else { // 变化超出阈值,认为是新的一次抖动或真实变化,重置计数器 filter->count = 0; // 可选:立即更新stable_value为new_sample,或保持不变直到稳定确认。 // 保持不变得更“顽固”,立即更新则响应快但可能被单次干扰误触发。 // filter->stable_value = new_sample; // 激进策略 } return filter->stable_value; }实操要点与避坑:
- 经典应用场景:机械开关、按键去抖。物理开关闭合时,触点会弹跳产生多个脉冲,消抖滤波能确保只输出一次稳定的状态变化。
- 参数选择:
THRESHOLD要略大于正常波动范围,小于有效变化幅度。N是延迟的采样周期数,N越大,抗抖动能力越强,但响应也越慢。 - 对快速连续变化的信号不友好。如果信号本身就在快速变化(如高频交流),它可能永远无法满足“连续N次稳定”的条件,导致输出卡住。
3.10 卡尔曼滤波法
原理:这是一种最优递归状态估计器。它通过系统的动态模型(预测步骤)和实际的观测值(更新步骤),结合两者不确定性(协方差),来估计系统状态。其强大之处在于,它不仅平滑了数据,还能预测下一时刻的状态。
核心思想:利用模型预测,再用测量修正,动态评估谁更可信。
这里给出一个极度简化的、适用于一维标量数据的“简化版”卡尔曼滤波,常用于滤除单个传感器噪声。
typedef struct { float x; // 估计值 float p; // 估计误差协方差 float q; // 过程噪声协方差(预测噪声) float r; // 测量噪声协方差(传感器噪声) float k; // 卡尔曼增益 } SimpleKalmanFilter; void Kalman_Init(SimpleKalmanFilter* kf, float init_x, float init_p, float process_noise, float measure_noise) { kf->x = init_x; kf->p = init_p; kf->q = process_noise; // Q调大,表示模型预测不准,更相信测量 kf->r = measure_noise; // R调大,表示传感器噪声大,更相信预测 kf->k = 0; } float Kalman_Update(SimpleKalmanFilter* kf, float measurement) { // 预测步骤(对于一维静止模型,预测值就是上一时刻估计值) // x = x; // 状态不变,这里省略 kf->p = kf->p + kf->q; // 预测误差协方差增大 // 更新步骤 kf->k = kf->p / (kf->p + kf->r); // 计算卡尔曼增益 kf->x = kf->x + kf->k * (measurement - kf->x); // 用残差修正估计值 kf->p = (1 - kf->k) * kf->p; // 更新估计误差协方差 return kf->x; }实操要点与避坑:
- 这不是完整的卡尔曼滤波。这是一个针对“恒定值”估计的特殊简化版。完整的卡尔曼滤波涉及状态转移矩阵F、控制矩阵B、观测矩阵H等。这个简化版假设系统状态是基本不变的(如静止物体的位置),只有噪声。
- 参数Q和R的调参是核心。
Q(过程噪声)代表你对你预测模型的信任程度,Q越大,滤波器越相信测量值,响应越快。R(测量噪声)代表你对传感器的信任程度,R越大,滤波器越相信自己的预测,输出越平滑。通常需要通过实验数据来调整这两个参数。 - 初始化很重要。
init_x和init_p(初始协方差)会影响收敛速度。init_p不能为0。 - 计算资源。即使是简化版,也涉及浮点乘除。完整版计算量更大。在低端MCU上需评估性能。
- 适用场景:对精度和实时性要求都很高的动态系统,如无人机姿态估计、导航定位。对于简单的温度滤波,可能“杀鸡用牛刀”。
4. 算法组合与进阶应用策略
在实际项目中,单一算法往往难以应对复杂情况。我常用的策略是“组合拳”和“分而治之”。
1. 级联滤波:将多个简单滤波器串联。例如,采样值 -> 限幅滤波 -> 中值滤波 -> 一阶滞后滤波。限幅先干掉离谱的脉冲,中值再滤掉小的毛刺,最后用一阶滞后进行平滑。顺序不同,效果不同,需要试验。
2. 条件滤波:根据信号特征动态选择算法或参数。例如,在系统上电或剧烈变化阶段,使用较小的平均窗口或较大的滤波系数(a值),让响应更快;进入稳态后,改用更大的窗口或更小的系数,获得更好的平滑性。这需要有一个对信号变化率进行判断的逻辑。
3. 频域思考:虽然我们在做时域滤波,但要有频域的概念。如果你知道噪声的主要频率(比如50Hz工频干扰),而你的信号是缓慢变化的,那么你可以选择采样周期,使得噪声频率高于奈奎斯特频率,然后通过一个截止频率合适的低通滤波器(如一阶滞后或滑动平均)将其滤除。滑动平均的窗口长度N,其实就决定了其低通截止频率。
5. 嵌入式实战中的常见问题与调试技巧
问题1:滤波后响应太慢,系统感觉“很肉”。
- 排查:检查平均窗口N是否过大,或一阶滞后的系数a是否过小。
- 解决:尝试减小N或增大a。也可以尝试加权平均,给新数据更高权重。
问题2:滤波后数据仍有小幅度高频“颤抖”。
- 排查:平滑力度不够。可能窗口N太小,或系数a太大。
- 解决:增大N或减小a。或者考虑级联滤波,在第一级平滑后再加一级轻度的滤波。
问题3:输出出现阶梯状,不平滑。
- 排查:可能出现在将浮点滤波结果转换为整型显示或使用时。例如,用
int类型存放滑动平均的sum,由于整除舍入导致量化误差。 - 解决:在滤波计算全程使用浮点数,直到最后一步需要输出时再转换。或者使用定点数运算技巧。
问题4:滤波器启动时,输出有很长一段异常数据。
- 排查:缓冲区初始化问题。滑动平均、中值等算法的缓冲区在开始时充满了无效数据(0或随机值)。
- 解决:在系统启动后,用最初几次的采样值(或一个合理的默认值)填充整个缓冲区,再进行正常滤波更新。
问题5:在中断服务程序(ISR)中调用滤波函数,导致中断执行时间过长。
- 排查:中值滤波的排序、大量数据的浮点运算在ISR中都是耗时大户。
- 解决:遵循“ISR快进快出”原则。在ISR中只做最必要的事:将采样值存入一个全局缓冲区(或队列)。在主循环或低优先级任务中,再从缓冲区取出数据进行滤波计算。这是嵌入式滤波的经典架构。
调试技巧:
- 数据可视化:如果条件允许,将原始数据和滤波后的数据通过串口发送到电脑,用工具(如SerialPlot、MATLAB、Python matplotlib)画图对比,这是最直观的调试方法。
- 参数记录:将关键的滤波参数(如a、N)设计成可在线调整的(通过串口命令或上位机),在不重新烧录程序的情况下实时观察滤波效果变化,能极大提高调参效率。
- 性能测试:在目标板上运行滤波算法,用IO口翻转或定时器测量函数执行时间,确保满足实时性要求。
最后,分享一个我个人在多个低功耗物联网传感器项目中验证有效的组合:限幅 + 滑动平均。具体做法是:采样频率设为实际需求的2-4倍,先进行限幅滤波剔除野值,然后进行滑动平均(窗口N=4~8),最后将结果以需求频率输出。这样既保证了数据的稳定可靠,又通过过采样略微提升了分辨率,同时计算量完全在低功耗MCU的可承受范围内。记住,好的滤波设计不是追求最复杂的算法,而是在资源、性能和效果之间找到那个恰到好处的平衡点。