1. 滑动窗口滤波:嵌入式开发者的“降噪神器”

如果你玩过嵌入式开发,尤其是搞过传感器数据采集,肯定遇到过这种头疼事:传感器读回来的数据,总是跳来跳去,像心电图一样。明明温度没变,读数却忽高忽低;明明设备静止,加速度计却总有微小波动。这些“毛刺”就是噪声,直接拿来用,轻则显示数值闪烁,用户体验差,重则导致控制逻辑误判,设备抽风。

这时候,你就需要一个“滤波器”。在众多滤波算法里,滑动窗口滤波(也叫滑动平均滤波)绝对是嵌入式工程师的老朋友。它原理简单到令人发指:你不是有一串连续的数据吗?我每次只关心最近一段时间(比如最近的5个数据点)的情况,把这几个数加起来求个平均值,作为当前的“靠谱”输出。然后,当新数据到来时,我就把窗口“滑动”一下,扔掉最旧的那个数据,把最新的数据加进来,再求一次平均。

听起来是不是和普通的均值滤波很像?这里有个关键区别,也是它名字里“滑动”二字的精髓。普通的均值滤波,比如每5个原始数据才输出1个平均值,会直接导致输出数据频率降低。原始数据如果是100Hz,5点均值滤波后可能就只剩20Hz了,这对于需要快速响应的实时系统是致命的。而滑动窗口滤波不同,它每进来一个新数据,就计算并输出一个新平均值,输出频率和输入频率保持一致,完美保留了信号的实时性。这个特性,让它成为了处理实时流数据的绝佳选择。

我在很多项目里都用过它,从STM32读取温湿度传感器,到ESP32处理麦克风的音频信号,再到一些工业PLC里处理模拟量输入。它的优势太明显了:代码极其简单,几乎不消耗额外的CPU计算资源(就是加法和除法),而且对内存的需求固定且很小。在资源捉襟见肘的MCU世界里,这种“物美价廉”的算法,简直就是为我们量身定做的。接下来,我就带你从零开始,手把手实现一个高效、省内存的C语言版本,并分享几个我踩过坑才总结出来的优化技巧。

2. 从原理到代码:手把手实现你的第一个滤波器

光说不练假把式,咱们直接上代码。理解原理最好的方式,就是自己写一遍。我们先来实现一个最基础、最直观的版本,确保你能完全看懂整个流程。

想象你有一个固定长度的队列(就是我们的“窗口”),比如长度 N=5。一开始队列是空的。数据 x 一个一个地进来:

  1. 前4个数据进来时(数据量 < N),队列还没满,我们没法求平均,所以直接原样输出,同时把数据存进队列。
  2. 第5个数据进来时,队列刚好满了。我们把5个数加起来,除以5,得到第一个平均值并输出。这个平均值,就代表了从第1个到第5个数据这段时间的“整体水平”。
  3. 关键的第6步来了!当第6个新数据 x6 到达时,我们需要把最老的数据 x1 从窗口里“踢出去”,然后把 x6 “请进来”。此时窗口里的数据变成了 [x2, x3, x4, x5, x6]。我们再计算这5个数的平均值并输出。

这个过程就像是一个固定长度的观察框,在时间轴上平滑地向前移动,始终聚焦于最新的N个数据点。下面是最直接的C语言实现:

/**
 * @brief 基础版滑动窗口滤波器
 * @param new_value 新采集到的数据
 * @return float 滤波后的输出值
 */
#define WINDOW_SIZE 5

float sliding_window_filter_basic(float new_value) {
    // 静态数组,用于存储窗口内的历史数据
    static float data_window[WINDOW_SIZE] = {0};
    // 静态索引,记录当前窗口内存储了多少个有效数据,以及下一个数据该放哪里
    static int count = 0;
    // 静态变量,记录窗口内数据的累加和,避免每次都从头求和
    static float sum = 0.0f;

    float output;

    if (count < WINDOW_SIZE) {
        // 阶段一:窗口未填满,直接存储,累加,并输出原值
        data_window[count] = new_value;
        sum += new_value;
        count++;
        output = new_value;
    } else {
        // 阶段二:窗口已满,进行滑动操作
        // 1. 从累加和中减去即将被移出的最旧数据(即数组第一个数据)
        sum -= data_window[0];
        // 2. 将整个窗口中的数据向前移动一位,空出最后一个位置
        for (int i = 0; i < WINDOW_SIZE - 1; i++) {
            data_window[i] = data_window[i + 1];
        }
        // 3. 将新数据放入窗口末尾
        data_window[WINDOW_SIZE - 1] = new_value;
        // 4. 将新数据加入累加和
        sum += new_value;
        // 5. 计算平均值作为输出
        output = sum / WINDOW_SIZE;
    }

    return output;
}

这个版本非常清晰,完全对应了我们刚才讲的逻辑。它使用了三个静态变量:数组 data_window 作为窗口容器,count 记录填充状态,sum 记录当前窗口内所有数据的和。使用 sum 是第一个关键优化点:我们不需要在每次输出时都用循环把窗口里的数加一遍,只需要在数据更新时维护好这个和,输出时做一次除法就行了。在MCU上,乘除法是相对耗时的操作,能少一次循环就大大提升了效率。

你可以写个简单的 main 函数测试一下,模拟一些有噪声的数据(比如正弦波加随机数)输入进去,看看输出是不是平滑多了。这个基础版已经能解决80%的简单滤波需求。但是,如果你追求极致的效率和更严谨的处理,特别是窗口长度 WINDOW_SIZE 比较大的时候,里面的 for 循环移动数据就成了性能瓶颈。别急,我们马上来优化它。

3. 核心优化:告别数据搬运,拥抱环形缓冲区

上面基础版的代码有个明显的效率问题:每次新数据到来,都需要用一个 for 循环把整个数组的数据向前移动一位。当 WINDOW_SIZE 为5时,移动4次赋值,问题不大。但如果你的窗口长度是50、100呢?每次滤波都要执行99次内存赋值操作,这在实时性要求高的场景(比如电机控制、高速采样)下是不可接受的。

怎么解决?这里就要请出嵌入式领域的经典数据结构——环形缓冲区(Circular Buffer)。它的思想非常巧妙:我们不再物理上移动数据,而是通过移动“指针”(索引)来改变数据的“逻辑顺序”。

我们维护两个关键索引:

  • head(或 start):指向窗口内最旧的数据。
  • tail(或 end):指向下一个可写入新数据的位置。

窗口在物理内存中是一个固定的数组,但 head 和 tail 会在数组内循环移动。当 tail 到达数组末尾时,再前进就回到数组开头(索引0)。这样,窗口内的有效数据就是 head 到 tail 之间(考虑循环)的那一段。

这样做的好处是什么?插入新数据时,我们只需要覆盖掉 head 指向的最旧数据,然后将 head 和 tail 都向前移动一步(循环)即可。完全不需要任何数据拷贝! 这带来了巨大的性能提升。下面是基于环形缓冲区的优化实现:

/**
 * @brief 高效环形缓冲区版滑动窗口滤波器
 * @param new_value 新采集到的数据
 * @return float 滤波后的输出值
 */
#define WINDOW_SIZE 5

float sliding_window_filter_ringbuf(float new_value) {
    // 静态环形缓冲区
    static float buffer[WINDOW_SIZE] = {0};
    // 静态索引:head指向最旧数据,tail指向下一个写入位置,sum为当前和
    static int head = 0, tail = 0;
    static float sum = 0.0f;
    static int is_buffer_full = 0; // 标志位,指示缓冲区是否已满
    float output;

    // 将新数据写入tail位置
    buffer[tail] = new_value;
    sum += new_value;

    if (!is_buffer_full) {
        // 缓冲区未满阶段
        tail = (tail + 1) % WINDOW_SIZE;
        if (tail == head) { // 当tail追上head时,说明缓冲区刚被填满
            is_buffer_full = 1;
        }
        output = new_value; // 未满时输出原值
    } else {
        // 缓冲区已满,进入滑动模式
        // 从和中减去即将被覆盖的(最旧的)数据
        sum -= buffer[head];
        // 移动head和tail指针
        head = (head + 1) % WINDOW_SIZE;
        tail = (tail + 1) % WINDOW_SIZE; // 此时tail == head,移动后指向新的写入位
        // 计算并输出平均值
        output = sum / WINDOW_SIZE;
    }

    return output;
}

这个版本看起来稍微复杂一点,但理解了环形缓冲区的运作机制后,就会觉得非常优雅。% WINDOW_SIZE 这个取模操作是实现“环形”的关键。is_buffer_full 标志位帮助我们清晰地区分“填充期”和“稳定滑动期”。在稳定期,每次滤波操作仅涉及:一次数组写入、两次索引更新、两次加法/减法、一次除法。计算复杂度是常数级的 O(1),与窗口大小无关!这是我强烈推荐在实际项目中使用的版本。

4. 深入细节:定点数、窗口选择与内存管理

实现了一个高效的核心函数后,我们还需要考虑一些工程实践中的细节,这些细节往往决定了滤波器的最终效果和稳定性。

4.1 浮点数与定点数的抉择

上面的代码用的都是 float。在带有FPU(浮点运算单元)的Cortex-M4、M7等高端MCU上,这没问题。但在很多M0、M3内核的芯片,或者一些低端8位MCU上,浮点运算会由软件模拟,速度非常慢,会成为系统的瓶颈。

这时候,定点数运算就是你的救星。其核心思想是:用一个整数来表示小数。例如,我们约定所有数据都放大 2^10 = 1024 倍,用 int16_t 或 int32_t 来存储和计算。原来 1.25 的浮点数,我们用 1.25 * 1024 = 1280 这个整数来表示。加减法直接进行,乘法后需要右移(除以1024),除法前需要左移(乘以1024)。这样,所有的运算都变成了整数操作,速度极快。

/**
 * @brief 使用Q10格式定点数的滑动窗口滤波
 * @param new_value_q10 新数据,已转换为Q10格式(即实际值*1024)
 * @return int16_t 滤波输出,Q10格式,需要时再转换为浮点数
 */
#define WINDOW_SIZE 5
#define Q (10) // Q格式的缩放因子,2^10 = 1024

int16_t sliding_window_filter_fixed(int16_t new_value_q10) {
    static int16_t buffer[WINDOW_SIZE] = {0};
    static int head = 0, tail = 0;
    static int32_t sum_q10 = 0; // 使用32位防止累加溢出
    static int is_buffer_full = 0;
    int16_t output_q10;

    buffer[tail] = new_value_q10;
    sum_q10 += new_value_q10;

    if (!is_buffer_full) {
        tail = (tail + 1) % WINDOW_SIZE;
        if (tail == head) {
            is_buffer_full = 1;
        }
        output_q10 = new_value_q10;
    } else {
        sum_q10 -= buffer[head];
        head = (head + 1) % WINDOW_SIZE;
        tail = (tail + 1) % WINDOW_SIZE;
        // 定点数除法:先累加和,然后除以窗口大小。这里直接做整数除法。
        // 因为sum_q10是Q10格式,除以整数N后,结果仍是Q10格式。
        output_q10 = (int16_t)(sum_q10 / WINDOW_SIZE);
    }
    return output_q10;
}
// 使用时:
// float real_value = 1.25;
// int16_t input_q10 = (int16_t)(real_value * 1024);
// int16_t filtered_q10 = sliding_window_filter_fixed(input_q10);
// float filtered_real = (float)filtered_q10 / 1024.0f;

4.2 如何选择窗口大小?

窗口大小 N 是滤波器的唯一参数,它直接决定了滤波器的特性:

  • N 越大:滤波效果越强,输出曲线越平滑,对高频噪声的抑制越好。但滞后性(Latency)也越大。系统响应变慢,比如温度实际已经变化,滤波后的值要过一会儿才跟上。就像一个反应迟钝的人。
  • N 越小:滤波效果弱,输出更跟随原始数据,滞后小。但对噪声的抑制能力也差。

没有通用的“最佳值”。你需要根据你的信号特性和系统要求来权衡。我的一般经验是:

  1. 先分析噪声频率:如果你的信号变化很慢(如温度),噪声是高频的(如50Hz工频干扰),那么可以选择较大的N(比如20-50)。
  2. 考虑系统带宽:你的控制环路需要多快的响应?如果控制周期是10ms,那么滤波引入的滞后绝对不能超过10ms。假设采样率是1kHz,那么N最好小于10。
  3. 实测调整:这是最靠谱的方法。在真实环境中采集一段数据,在PC上用Matlab或Python写个脚本,快速尝试不同的N值,观察滤波效果和滞后,找到平衡点。记住,嵌入式开发离不开桌面仿真工具。

4.3 内存的精细化管理

在资源极度受限的单片机上,每一个字节都值得珍惜。我们的滤波器结构体可以优化:

  • 使用最小的合适类型:如果数据范围在-128~127,就用 int8_t;在-32768~32767,就用 int16_t。buffer 数组和 sum 变量都如此。
  • 慎用静态变量:上面的例子用了 static,方便但会让函数不可重入。如果系统中有多个通道需要独立滤波(比如三轴加速度计),更好的做法是封装一个结构体,将滤波器状态作为参数传入。这样内存分配更清晰,也支持多实例。
typedef struct {
    int16_t buffer[WINDOW_SIZE];
    int head;
    int tail;
    int32_t sum; // 定点数累加和,或用float
    uint8_t is_full;
} SlidingFilter_t;

void SlidingFilter_Init(SlidingFilter_t *filt) {
    memset(filt->buffer, 0, sizeof(filt->buffer));
    filt->head = 0;
    filt->tail = 0;
    filt->sum = 0;
    filt->is_full = 0;
}

int16_t SlidingFilter_Update(SlidingFilter_t *filt, int16_t new_val) {
    // ... 更新逻辑,操作filt结构体内的成员 ...
    return output;
}

这样,你在主程序中可以定义 SlidingFilter_t temp_filter, accel_filter[3];,分别用于温度和三个轴的加速度,内存使用一目了然,代码也更模块化、更安全。

5. 移植与实战:让你的滤波器“跑起来”

代码写好了,怎么把它用到你的实际项目里?这里分享几个我常用的套路和避坑指南。

第一步:剥离与封装 不要把滤波算法和你的具体硬件采样代码耦合在一起。像上面那样,把它写成一个独立的 .c 和 .h 文件。头文件里清晰定义好结构体和函数接口。这样,无论你今天是读ADC,明天是读I2C传感器,后天是处理串口数据,都可以调用同一个滤波器模块。

第二步:处理初始值问题 滤波器启动时,窗口是空的,输出的是原始值。如果你的系统一上电就对滤波后的值有严格要求(比如不能有突变),可以考虑在初始化后,用第一个采样值快速填充整个窗口缓冲区,让滤波器立刻进入稳定状态。

第三步:应对异常值(野点) 滑动平均对突发的大幅度异常值(野点)非常敏感,一个坏数据会污染整个窗口,导致后续多次输出异常。在实际应用中,我常常会结合一个简单的“限幅滤波”作为前置关卡:如果新数据与当前滤波输出的差值超过某个阈值,就认为它是野点,可以选择丢弃,或者用上一个输出值或窗口内中位数替代,而不是直接加入窗口。

第四步:性能测试与调试 在MCU上,要关心两件事:执行时间和内存占用。

  • 执行时间:可以用GPIO翻转+示波器测量的“土办法”。在滤波器函数入口和出口拉高拉低一个IO口,用示波器看脉冲宽度,就是函数执行时间。确保它远小于你的采样周期。
  • 内存占用:查看编译后的map文件,找到你的滤波器变量和数组,确认它们的大小是否符合预期。使用结构体封装后,内存占用非常清晰。

最后,分享一个我在电机电流采样中的真实案例。电流信号噪声大,且需要快速进行过流保护。我使用了窗口大小为4的滑动滤波(因为控制频率高,滞后必须小),并采用了Q15定点数运算。滤波后的电流值既平滑了许多,又能在一个控制周期内(50us)完成计算,及时触发保护。关键点在于,滤波器的参数(窗口大小、数据类型)必须为你的具体应用场景服务,而不是追求理论上的完美。多测试,多观察,根据实际波形调整,这才是嵌入式调试的真谛。

更多推荐