3、信号预处理技术:滑动平均滤波、中值滤波、卡尔曼滤波在嵌入式中的实现

做消防报警这么多年,我最大的体会就是:传感器信号太脏了。你想想看,一个烟雾探测器装在走廊天花板上,旁边就是空调出风口,偶尔还有人抽烟路过。ADC采回来的数据,那叫一个上蹿下跳。如果不做预处理,你根本没法判断到底是真着火了还是有人在煮火锅。

所以这一章,咱们就聊聊嵌入式里最常用的三种信号预处理方法。我按自己的经验把它们分成了三类:滑动平均滤波适合平滑噪声,中值滤波专治毛刺,卡尔曼滤波则是给那些需要预测的场景准备的。咱们一个一个来。

3.1 滑动平均滤波:最简单也最实用

滑动平均滤波的原理,说白了就是取最近N个采样值的平均值。你每采一个新数据,就把最老的那个丢掉,然后重新算一遍平均值。这样信号就平滑了。

我在项目中遇到过一个问题:一个感烟探测器,采样周期是200ms,但环境噪声导致数据波动有±5%左右。用滑动平均滤波,窗口取5个点,波动直接降到了±1%以内。效果立竿见影。

核心要点:窗口大小N的选择是关键。N越大,平滑效果越好,但响应延迟也越大。消防报警里,我一般取3~8个点,具体看你的采样频率和响应时间要求。

嵌入式实现时,我习惯用环形缓冲区来存数据。这样每次更新只需要O(1)的复杂度,不浪费CPU。

// 滑动平均滤波 - 环形缓冲区实现
#define WINDOW_SIZE 5

typedef struct {
    uint16_t buffer[WINDOW_SIZE];
    uint8_t index;
    uint32_t sum;
    uint8_t count;
} MovingAverageFilter;

void MovingAverage_Init(MovingAverageFilter *f) {
    f->index = 0;
    f->sum = 0;
    f->count = 0;
    memset(f->buffer, 0, sizeof(f->buffer));
}

uint16_t MovingAverage_Update(MovingAverageFilter *f, uint16_t new_sample) {
    // 减去最老的数据
    f->sum -= f->buffer[f->index];
    // 存入新数据
    f->buffer[f->index] = new_sample;
    f->sum += new_sample;
    // 移动指针
    f->index = (f->index + 1) % WINDOW_SIZE;
    // 计数
    if (f->count < WINDOW_SIZE) f->count++;
    // 返回平均值
    return (uint16_t)(f->sum / f->count);
}

我的小技巧:如果MCU没有硬件除法器,可以用移位代替除法。比如窗口大小取2的幂次(4、8、16),这样除法就变成了右移操作,速度能快不少。

3.2 中值滤波:专治突发毛刺

滑动平均滤波有个弱点:它对突发的大毛刺很敏感。比如一个1000的采样值里突然蹦出一个5000的尖峰,平均值会被拉高很多。这时候中值滤波就派上用场了。

中值滤波的做法是:取最近N个采样值,排序后取中间那个。这样不管毛刺有多大,只要它出现的次数不超过一半,就不会影响输出。

我曾经在一个项目里遇到过这种情况:探测器安装在电梯井旁边,电梯启动时会产生强烈的电磁干扰,ADC偶尔会采到离谱的值。用滑动平均滤波根本压不住,换成中值滤波后,问题迎刃而解。

注意:中值滤波的窗口大小一般取奇数(3、5、7),这样中间值才唯一。另外,排序算法在嵌入式里要小心,别用冒泡排序这种O(n²)的,数据量大了CPU扛不住。

我推荐用插入排序,因为每次只更新一个数据,其他数据基本有序,插入排序在这种场景下效率很高。

// 中值滤波 - 插入排序实现
#define MEDIAN_WINDOW 5

uint16_t MedianFilter(uint16_t *buffer, uint16_t new_sample) {
    uint8_t i, j;
    uint16_t temp[MEDIAN_WINDOW];
    
    // 将新数据放入缓冲区(这里用循环移位简化)
    for(i = 0; i < MEDIAN_WINDOW - 1; i++) {
        buffer[i] = buffer[i+1];
    }
    buffer[MEDIAN_WINDOW - 1] = new_sample;
    
    // 复制到临时数组并排序
    memcpy(temp, buffer, sizeof(temp));
    
    // 插入排序(数据量小,效率高)
    for(i = 1; i < MEDIAN_WINDOW; i++) {
        uint16_t key = temp[i];
        j = i;
        while(j > 0 && temp[j-1] > key) {
            temp[j] = temp[j-1];
            j--;
        }
        temp[j] = key;
    }
    
    // 返回中间值
    return temp[MEDIAN_WINDOW / 2];
}

避坑指南:我曾经犯过一个错误——把中值滤波的窗口设得太大(比如15个点)。结果探测器对真实火情信号的响应延迟了好几秒,差点误事。记住,中值滤波适合去毛刺,不适合做平滑。毛刺宽度一般只有1~2个采样点,窗口取3或5就够了。

3.3 卡尔曼滤波:给信号一个预测

前面两种滤波都是基于历史数据的。但有时候,我们需要预测信号的变化趋势。比如在火灾早期,烟雾浓度是缓慢上升的,如果能提前预测出这个趋势,就能更早报警。

卡尔曼滤波就是干这个的。它把系统建模成一个状态方程,然后通过观测值不断修正预测值。说白了,就是「猜一个值,然后根据实际测量来调整」。迭代几次后,滤波结果就会收敛到真实值附近。

我刚开始学卡尔曼滤波时,觉得公式太复杂了。后来发现,在嵌入式里做一维卡尔曼滤波,其实就几个公式,代码量很小。

参数 含义 典型值(消防场景)
Q 过程噪声协方差 0.01 ~ 0.1
R 测量噪声协方差 0.1 ~ 1.0
P 估计误差协方差 初始值设为1.0

Q和R的取值很关键。Q越大,说明你越相信测量值;R越大,说明你越相信预测值。我一般先根据传感器的数据手册估算R,然后现场调试时微调Q。

// 一维卡尔曼滤波实现
typedef struct {
    float Q;      // 过程噪声协方差
    float R;      // 测量噪声协方差
    float P;      // 估计误差协方差
    float K;      // 卡尔曼增益
    float X;      // 滤波后的值
} KalmanFilter;

void Kalman_Init(KalmanFilter *kf, float init_value) {
    kf->Q = 0.01f;
    kf->R = 0.5f;
    kf->P = 1.0f;
    kf->X = init_value;
}

float Kalman_Update(KalmanFilter *kf, float measurement) {
    // 预测阶段
    // X_pred = X (一维情况下,预测值等于上一时刻的滤波值)
    // P_pred = P + Q
    
    // 更新阶段
    kf->K = (kf->P + kf->Q) / (kf->P + kf->Q + kf->R);
    kf->X = kf->X + kf->K * (measurement - kf->X);
    kf->P = (1.0f - kf->K) * (kf->P + kf->Q);
    
    return kf->X;
}

实际效果:我在一个吸气式感烟探测器上试过。原始信号噪声约±3%,卡尔曼滤波后噪声降到±0.5%以内,而且对烟雾上升趋势的响应比滑动平均快了将近1秒。这在早期火灾探测中,可能就是生与死的区别。

3.4 三种滤波怎么选?

嗯,这里我给大家一个简单的选择指南:

  • 滑动平均滤波:适合噪声平稳、没有突发干扰的场景。比如环境温度监测、缓慢变化的烟雾浓度。优点是计算量小,代码简单。
  • 中值滤波:适合有突发毛刺的场景。比如电磁干扰严重的工业环境、电源波动大的场合。缺点是会丢失一些细节信息。
  • 卡尔曼滤波:适合需要预测趋势、或者噪声特性已知的场景。比如火灾早期预警、气体浓度变化趋势分析。缺点是参数调起来比较麻烦。

我个人在实际项目中,经常把滑动平均和卡尔曼滤波结合起来用。先用滑动平均去掉高频噪声,再用卡尔曼滤波做趋势预测。效果比单独用任何一种都好。

最后说一句:滤波不是万能的。如果你的传感器本身就有问题,再好的滤波算法也救不了。我见过有人花了两周调卡尔曼滤波参数,最后发现是ADC参考电压漂了。所以,先确认硬件没问题,再谈算法优化。