
你肯定遇到过这种情况单片机采集的传感器数据比如温度、电压、电流在串口助手或者屏幕上显示时总是不停地“跳舞”——数值在真实值上下乱跳偶尔还会冒出几个离谱的异常值。你盯着这些跳动的数字试图找出规律却发现它们更像是在随机波动而不是稳定的信号。这时候你可能会想是传感器坏了还是电路有干扰或者是不是我的代码写错了其实很多时候问题不在于硬件而在于我们如何处理这些原始数据。传感器信号天生就带有噪声可能是电源纹波、电磁干扰也可能是ADC模数转换器本身的量化误差。直接使用这些“毛糙”的原始数据不仅让显示界面显得不专业更会严重影响后续的判断和控制逻辑。比如一个基于温度阈值的风扇控制系统如果温度读数在±5℃范围内跳动风扇就会频繁启停既浪费能源又损耗设备。面对这个问题一个最直接、最经典也往往被低估的解决方案就是移动平均滤波。它不像卡尔曼滤波那样充满数学公式也不像中值滤波那样需要排序它的核心思想简单到可以用一句话概括用最近一段时间的历史数据“投票”来决定当前时刻的输出值从而平滑掉短期、随机的波动让长期、稳定的趋势浮现出来。很多人对移动平均滤波的印象停留在“求个平均值而已”觉得它太简单、太初级甚至不屑于使用。但恰恰是这种简单让它成为了单片机嵌入式开发中处理实时数据流的“瑞士军刀”——可靠、高效、资源消耗极低。这篇文章我们就来彻底“速通”单片机上的移动平均滤波。我们不止要讲清楚它是什么、怎么写更要深入探讨为什么它在资源受限的单片机上是首选它平滑数据背后的数学和物理意义是什么如何根据你的具体场景数据频率、噪声特性、实时性要求来设计和优化它以及在把它从“实验室Demo”升级到“稳定产品功能”的过程中有哪些必须注意的“坑”1. 移动平均滤波不止是“求平均”更是“时间窗口”的艺术让我们先抛开代码从最根本的概念理解开始。移动平均滤波的核心在于“移动”和“平均”这两个词。“平均”好理解就是把一组数加起来再除以个数。它的作用是“削峰填谷”。一个异常的高值或低值噪声被放进一组正常值里一起平均其对结果的影响就会被大大稀释。假设我们采集了5个数据[100, 102, 101, 150, 103]。其中150很可能是一个噪声脉冲。直接取最后一个值103或150都不靠谱。但如果计算这5个数的平均值(100102101150103)/5 111.2你会发现虽然150把平均值拉高了一些但结果111.2仍然比150更接近真实水平假设在101左右。这就是“平均”对抗随机噪声的基本原理。“移动”则是实现实时处理的关键。它不是对固定的一组历史数据求平均而是维护一个“时间窗口”。每获得一个新的数据就把这个新数据放入窗口的末尾同时把窗口里最旧的那个数据“挤出去”然后对窗口内更新后的所有数据重新计算平均值并把这个新的平均值作为当前时刻的滤波输出。这个过程就像一个持续滑动的窗口始终聚焦于“最近一段时间”的数据。例如一个长度为5的移动平均窗口在时刻t它包含数据[D(t-4), D(t-3), D(t-2), D(t-1), D(t)]输出平均值Avg(t)。到了时刻t1新数据D(t1)到来窗口变为[D(t-3), D(t-2), D(t-1), D(t), D(t1)]输出新的平均值Avg(t1)。1.1 为什么是“移动平均”而不是其他滤波在单片机领域选择算法永远是在效果、资源和实时性之间做权衡。移动平均滤波在这三者间取得了极佳的平衡效果可接受对于常见的白噪声随机、均值为零的噪声移动平均有很好的平滑效果。它能有效抑制高频毛刺保留信号的总体变化趋势。计算资源消耗极低核心运算只有加法和除法甚至可以用移位代替除法。不需要存储大量历史状态如IIR滤波也不需要复杂的排序如中值滤波或矩阵运算如卡尔曼滤波。实现简单确定性好代码逻辑清晰执行时间固定不会出现因数据不同而导致计算时间大幅波动的情况这对于实时系统很重要。无需复杂调参主要参数只有一个——窗口长度N。调参直观N越大平滑效果越强但响应速度越慢滞后越大。相比之下卡尔曼滤波效果最优但需要矩阵运算对单片机算力要求高且模型参数过程噪声、测量噪声调校复杂。中值滤波对脉冲型噪声椒盐噪声效果极佳但需要排序计算量随窗口增大而显著增加。IIR无限脉冲响应滤波可以用较少的阶数实现较好的效果但涉及浮点乘加和递归计算可能引入稳定性问题且相位响应非线性。因此当你的主要需求是平滑随机噪声且单片机资源RAM、Flash、CPU时间紧张时移动平均滤波通常是第一选择也是性价比最高的选择。1.2 窗口长度N滤波效果的“总开关”窗口长度N是移动平均滤波唯一且最重要的参数。它直接决定了滤波器的特性平滑度 vs. 响应速度这是一个经典的权衡。N越大参与平均的数据越多单个噪声点的影响被稀释得越厉害输出曲线越平滑。但代价是当信号真实变化时滤波器需要更多的新数据来“覆盖”旧数据导致输出变化滞后于输入变化响应变慢。反之N越小响应越快但平滑效果变差。噪声频率理想情况下窗口覆盖的时间长度应大于噪声的主要周期。例如如果你的噪声是50Hz工频干扰周期20ms而你的采样周期是1ms那么窗口长度N至少需要20才能保证窗口内包含至少一个完整的噪声周期从而通过平均将其抵消。RAM占用在常规实现中需要开辟一个长度为N的数组来存储窗口数据。N越大占用的RAM越多。这对于RAM以KB计的单片机如51单片机、某些STM32是需要慎重考虑的。选择N没有万能公式但有一个实用的工程方法先估算根据采样频率和你想滤除的噪声频率粗略估算。例如采样率100Hz想平滑掉周期小于0.2秒5Hz以上的波动则窗口时间可取0.2秒N 100Hz * 0.2s 20。再实测在真实系统上用不同的N值比如5, 10, 20, 50进行测试。观察滤波后的数据曲线曲线是否足够平滑当信号发生阶跃变化时滤波输出需要多少时间或多少个采样点才能跟上这个滞后是否可接受系统的控制性能如PID控制器的稳定性是否因滤波滞后而变差最终确定在平滑度和响应速度之间找到一个满足你应用需求的折中点。2. 从原理到代码三种实现三种境界理解了原理我们来看代码实现。移动平均滤波的实现有多种方式从最直观但低效的到最经典通用的再到针对特定场景优化的。2.1 朴素实现清晰但低效这是最好理解的实现方式直接对应“移动平均”的定义。#define N 10 // 窗口长度 float moving_average_naive(float new_sample) { static float buffer[N] {0}; // 静态数组存储窗口数据 static int index 0; // 当前写入位置 float sum 0; int i; // 1. 新数据存入缓冲区覆盖旧数据 buffer[index] new_sample; index (index 1) % N; // 循环缓冲区索引回绕 // 2. 计算缓冲区中所有数据的和 for(i 0; i N; i) { sum buffer[i]; } // 3. 返回平均值 return sum / N; }优点逻辑极其清晰易于理解和调试。缺点效率低下。每次计算输出都需要遍历整个长度为N的数组进行求和计算复杂度为O(N)。当N较大或采样频率很高时这会消耗大量CPU时间。2.2 优化实现滑动窗口法经典这是最常用、最经典的实现核心技巧是维护一个窗口数据和sum每次更新时只做一次加法和一次减法避免遍历整个数组。#define N 10 typedef struct { float buffer[N]; int index; float sum; } MovingAverageFilter; void moving_average_init(MovingAverageFilter *filter) { int i; for(i 0; i N; i) { filter-buffer[i] 0; } filter-index 0; filter-sum 0; } float moving_average_update(MovingAverageFilter *filter, float new_sample) { // 1. 从总和中减去即将被覆盖的旧数据 filter-sum - filter-buffer[filter-index]; // 2. 将新数据存入缓冲区 filter-buffer[filter-index] new_sample; // 3. 将新数据加入总和 filter-sum new_sample; // 4. 更新索引循环缓冲区 filter-index; if(filter-index N) { filter-index 0; } // 5. 计算并返回平均值 return filter-sum / N; }优点效率极高无论窗口长度N多大每次更新只进行两次加法/减法、一次除法和索引更新计算复杂度为O(1)。资源可控需要固定长度的数组和几个状态变量。实时性好执行时间恒定且极短。缺点需要额外的结构体来维护状态。在初始化后或窗口未填满时sum除以N得到的平均值可能不代表真实的“最近N个点”的平均因为缓冲区里还有初始零值。这可以通过记录有效数据计数来解决但通常影响不大因为系统启动后很快窗口就会被填满。这是你绝大多数情况下应该采用的实现方式。2.3 进阶实现适用于整数和定点数的变体上面的例子使用float类型。但在很多单片机应用中为了追求极致的速度和避免浮点运算某些单片机没有硬件FPU我们会使用整数或定点数。整数版本思路相同但sum可能会溢出这是整数实现最大的坑。#define N 10 #define DATA_TYPE int16_t // 假设ADC采集的数据是16位有符号整数 typedef struct { DATA_TYPE buffer[N]; int index; int32_t sum; // 使用更大位宽的变量存储和防止溢出 } MovingAverageFilterInt; DATA_TYPE moving_average_update_int(MovingAverageFilterInt *filter, DATA_TYPE new_sample) { filter-sum - filter-buffer[filter-index]; filter-buffer[filter-index] new_sample; filter-sum new_sample; filter-index (filter-index 1) % N; // 返回平均值。注意这里是整数除法会截断小数部分。 // 如果需要更高精度可以考虑四舍五入(filter-sum N/2) / N return (DATA_TYPE)(filter-sum / N); }关键点sum必须使用比DATA_TYPE位宽更大的类型如int32_t存int16_t的和否则当N较大时累加和极易溢出导致结果完全错误。定点数版本如果你想保留小数精度又不想用浮点可以使用定点数运算。例如将数据放大2^Q倍Q为定点数的小数位数进行整数运算最后再缩小。#define N 10 #define Q 10 // 小数位数精度为 1/(2^10)0.000976 #define FIXED_MUL (1 Q) typedef struct { int32_t buffer[N]; // 存储放大后的数据 int index; int32_t sum; } MovingAverageFilterFixed; int32_t moving_average_update_fixed(MovingAverageFilterFixed *filter, int32_t new_sample_fixed) { filter-sum - filter-buffer[filter-index]; filter-buffer[filter-index] new_sample_fixed; filter-sum new_sample_fixed; filter-index (filter-index 1) % N; // 返回的是放大后的平均值。使用时需要右移Q位得到真实值。 return filter-sum / N; // 注意这里除法会损失一些精度 } // 使用示例真实值value1.5, 转换为定点数: value_fixed (int32_t)(1.5 * FIXED_MUL) // 滤波后得到 filtered_fixed真实值 (float)filtered_fixed / FIXED_MUL3. 不止于平滑移动平均滤波的工程化实践与陷阱把移动平均滤波函数写出来只是第一步。要让它在实际项目中稳定、可靠地工作还需要考虑一系列工程化问题。3.1 初始化与启动瞬态滤波器启动时缓冲区里是无效数据通常是0。在缓冲区被填满的前N-1次调用中输出的平均值是基于部分有效数据和部分初始值的这会导致启动阶段的输出不准确。解决方案预热填充在系统启动后、正式使用滤波数据前先连续调用N次更新函数用实际采集的数据填满缓冲区。在这期间可以暂时不使用输出或者给一个“未就绪”标志。动态窗口记录当前缓冲区中有效数据的数量count当count N时平均值计算为sum / count当count N时再切换为sum / N。这保证了从第一个采样点开始输出就是有意义的基于所有已采集的数据。3.2 应对异常值与数据溢出异常值野值移动平均对偶发的、幅度不大的随机噪声效果好但对持续时间短、幅度巨大的脉冲噪声野值抑制能力有限。一个野值会污染整个窗口需要N个周期才能被完全“移出”。如果应用场景中野值较多可以考虑先进行限幅滤波判断新数据是否在合理范围内或中值滤波再进行移动平均组成复合滤波器。数据溢出在整数实现中sum溢出是致命错误。务必根据DATA_TYPE的最大值、最小值和窗口长度N计算sum可能的最大范围并选择足够位宽的变量类型如int64_t。例如int16_t范围是[-32768, 32767]N100则sum的理论范围是[-3276800, 3276700]这已经超出了int32_t的正范围不对int32_t范围约±21亿完全足够。但若N更大或数据范围更大就需要仔细计算。3.3 实时性、采样率与窗口长度的关系这是一个系统级的设计问题。假设你的控制循环周期是T例如10ms你在每个循环中采样并滤波。采样率Fs 1/T(100Hz)。窗口时间Tw N * T。滤波器滞后移动平均滤波器会引入大约Tw / 2的群延迟可以粗略理解为信号延迟。也就是说滤波后的输出反映的是大约Tw/2时间之前的信号趋势。设计检查清单你希望平滑掉的噪声其周期是否小于Tw你的控制系统能容忍Tw/2的延迟吗延迟是否会影响系统的稳定性或性能N Tw * Fs计算出的N值是否在你的单片机RAM和实时计算能力允许范围内3.4 进阶变体加权移动平均标准的移动平均给窗口内每个数据的权重是相同的1/N。但有时我们可能认为“越新的数据越重要”。这时可以使用加权移动平均。例如线性加权给最新的数据权重为N次新为N-1...最旧为1。权重总和为S N (N-1) ... 1 N*(N1)/2。 则输出 (1*D_oldest 2*D_oldest1 ... N*D_newest) / S。加权移动平均能更快地响应信号的变化因为新数据权重大但对噪声的平滑能力会稍弱于等权移动平均。实现上它需要存储权重系数并做乘加运算计算量稍大。4. 从滤波函数到可靠模块一个完整的嵌入式滤波方案最后我们跳出单行代码看看如何将移动平均滤波集成为一个健壮的、可复用的软件模块。这对于拥有多个传感器、需要多种滤波参数的项目至关重要。4.1 模块化设计我们可以设计一个头文件filter_ma.h和一个源文件filter_ma.c。filter_ma.h:#ifndef __FILTER_MA_H #define __FILTER_MA_H #ifdef __cplusplus extern C { #endif #include stdint.h // 滤波器结构体通用版本使用float typedef struct { float *buffer; // 指向动态分配或外部传入的缓冲区 uint16_t size; // 窗口长度 N uint16_t index; // 当前写入索引 float sum; // 窗口内数据和 uint16_t count; // 当前有效数据计数用于处理启动瞬态 } MovingAverageFilterF; // 初始化滤波器关联缓冲区设置窗口大小可选初始化值 void MAF_Init(MovingAverageFilterF *filter, float *buf, uint16_t size, float init_value); // 更新滤波器并返回输出 float MAF_Update(MovingAverageFilterF *filter, float new_sample); // 重置滤波器清空缓冲区重新计数 void MAF_Reset(MovingAverageFilterF *filter, float init_value); // 获取当前滤波器输出不更新 float MAF_GetOutput(MovingAverageFilterF *filter); #ifdef __cplusplus } #endif #endif /* __FILTER_MA_H */filter_ma.c:#include filter_ma.h void MAF_Init(MovingAverageFilterF *filter, float *buf, uint16_t size, float init_value) { filter-buffer buf; filter-size size; filter-index 0; filter-sum init_value * size; // 假设初始值填充整个窗口 filter-count 0; // 或 size取决于你是否认为初始值有效 for(uint16_t i 0; i size; i) { filter-buffer[i] init_value; } } float MAF_Update(MovingAverageFilterF *filter, float new_sample) { // 减去旧值加上新值 filter-sum - filter-buffer[filter-index]; filter-sum new_sample; // 更新缓冲区 filter-buffer[filter-index] new_sample; filter-index (filter-index 1) % filter-size; // 处理有效计数如果使用动态窗口 if(filter-count filter-size) { filter-count; } // 返回平均值。使用动态窗口或固定窗口。 // 动态窗口return filter-sum / filter-count; // 固定窗口推荐return filter-sum / filter-size; return filter-sum / filter-size; } // ... 其他函数实现这样设计的好处是解耦滤波算法与具体应用分离。灵活可以创建多个滤波器实例用于不同的传感器通道每个实例可以有不同的窗口大小。可配置缓冲区可以从外部传入可以使用静态数组或动态分配适应不同内存管理策略。可测试模块接口清晰易于编写单元测试。4.2 在实际系统中的调用示例假设你在一个STM32的ADC采样中断中需要对通道1的电压进行滤波。// 1. 定义滤波器和缓冲区 #define FILTER_WINDOW_SIZE 20 static float adc1_filter_buffer[FILTER_WINDOW_SIZE]; static MovingAverageFilterF adc1_filter; // 2. 系统初始化时初始化滤波器 void Sensor_Init(void) { // 假设初始电压为0或根据实际情况设定 MAF_Init(adc1_filter, adc1_filter_buffer, FILTER_WINDOW_SIZE, 0.0f); } // 3. 在ADC采样完成中断或任务中 void ADC_ConversionComplete_Callback(uint32_t raw_adc_value) { float voltage (raw_adc_value * 3.3f) / 4095.0f; // 12位ADC参考电压3.3V float filtered_voltage MAF_Update(adc1_filter, voltage); // 使用 filtered_voltage 进行后续显示、控制或上传 // ... }4.3 调试与验证如何知道滤波效果好不好数据可视化这是最有效的方法。通过串口将滤波前后的数据同时打印出来导入到Excel、Python (Matplotlib) 或任何绘图工具中。直观对比曲线你能立刻看出平滑效果、滞后程度以及是否存在问题。指标量化如果条件允许标准差计算一段稳定信号滤波前后的标准差标准差减小越多说明平滑效果越好。阶跃响应给系统一个阶跃输入如突然改变传感器测量值观察滤波输出从10%上升到90%所需的时间上升时间以及最终稳态值。这反映了滤波器的动态响应特性。系统级验证最终滤波器的好坏要由它所在的上层系统如控制系统、状态机的性能来判断。例如加入滤波后电机控制是否更平稳温度显示是否不再频繁跳动这才是终极检验标准。移动平均滤波这个看似简单的算法在单片机的世界里扮演着“定海神针”的角色。它的价值不在于算法的复杂性而在于以极小的资源代价解决了嵌入式系统中最常见、最恼人的数据噪声问题。真正掌握它意味着你不仅会写那段滑动求和的代码更理解了数据、时间与系统资源之间的微妙平衡并能根据具体场景做出恰当的设计和调整。下次当你面对跳动不安的传感器数据时不妨先问问自己窗口长度N我选对了吗