
在单片机开发中传感器数据采集总会遇到各种噪声干扰比如电源纹波、环境电磁干扰等。直接使用原始数据往往会导致系统误判、控制不稳。最近在做一个基于51单片机的智能小车测速项目就卡在了编码器脉冲计数因抖动而跳变的问题上一个简单的移动平均滤波算法就轻松解决了。本文将系统性地拆解移动平均滤波的原理并提供从基础到进阶、从51到STM32的多种C语言实现方案附带可复用的代码模块和避坑指南。无论你是正在学习单片机滤波的新手还是需要在项目中快速集成滤波功能的老手都能从中找到直接可用的解决方案。1. 移动平均滤波的核心概念与价值1.1 什么是移动平均滤波移动平均滤波是一种最经典、最直观的数字信号处理算法属于线性滤波的一种。它的核心思想非常朴素对于一个连续采样的数据序列每次计算时只取最近一段时间内或最近若干个采样值的算术平均值作为当前的有效输出值。这个“时间窗口”或“数据窗口”会随着新数据的到来而不断向后“移动”故得名“移动平均”。通俗理解想象一下你在记录每天的温度但某一天的温度计突然抽风显示了一个离谱的数值。如果你只相信当天的读数可能会做出错误的判断。但如果你取最近7天的平均温度那么这一天的异常值对“平均温度”这个结果的影响就会被大大稀释得到的数据就更平滑、更接近真实的趋势。移动平均滤波在单片机里干的就是这个“取多天平均”的活儿只不过把“天”换成了“毫秒”或“微秒”级的采样周期。1.2 它解决了什么问题在嵌入式系统中移动平均滤波主要应对两类问题高频随机噪声这是最常见的应用场景。例如ADC采集电压时存在的白噪声、传感器信号中的毛刺、按键消抖等。这些噪声通常频率较高幅值随机通过取平均可以有效抑制。数据平滑对于一些变化缓慢的物理量如温度、湿度我们可能更关心其变化趋势而非瞬时值。移动平均可以平滑掉微小的、快速的波动让数据曲线更美观也便于后续的趋势判断或显示。1.3 为什么单片机开发者必须掌握它资源消耗极低相比其他高级滤波算法如卡尔曼滤波、IIR滤波移动平均滤波不涉及复杂的乘除运算进阶的加权平均除外尤其适合RAM和计算能力有限的8位或16位单片机。实现极其简单算法逻辑清晰代码通常只有寥寥数行易于理解、调试和集成。效果立竿见影对于符合其适用场景的噪声滤波效果非常明显能快速提升系统稳定性。应用场景广泛从最简单的按键消抖到ADC采样、电机转速计算、传感器数据融合等几乎无处不在。需要注意的局限性移动平均滤波会引入一定的相位滞后输出响应慢于输入变化且对周期性干扰如工频干扰的抑制能力有限。对于要求实时性高或噪声特性复杂的场景需要评估其适用性。2. 环境与版本说明本文的代码示例将覆盖两种最主流的单片机平台以确保广泛的适用性51单片机系列以经典的STC89C52为例使用Keil uVision 5进行开发。其RAM资源非常有限我们将演示如何在此约束下实现滤波。STM32系列以STM32F103C8T6Blue Pill为例使用STM32CubeIDE或Keil MDK开发。其拥有更丰富的RAM和更高的性能可以实现更复杂的窗口管理。核心要点编程语言纯C语言确保可移植性。算法核心与具体硬件外设如ADC、定时器无关专注于滤波算法本身的实现。你可以轻松地将这些函数集成到你的ADC读取、编码器计数等函数中。版本无关性本文提供的算法是通用的不依赖特定版本的库或IDE。重点在于理解思路你可以根据自己使用的芯片和开发环境进行微调。3. 算法原理与关键参数拆解在动手写代码之前必须彻底理解算法背后的数学模型和关键参数的影响。3.1 数学表达对于一个长度为N的窗口在时刻k的输出值 y[k] 由过去N个输入值 x[k], x[k-1], ..., x[k-N1] 计算得出y[k] (x[k] x[k-1] ... x[k-N1]) / N3.2 关键参数窗口长度 N窗口长度N是移动平均滤波唯一且最重要的参数它直接决定了滤波器的特性N值越大优点平滑效果越好抑制高频噪声的能力越强。缺点相位滞后越严重系统响应速度越慢需要存储的历史数据越多消耗的RAM越多。N值越小优点响应速度快相位滞后小占用RAM少。缺点平滑效果差抑制噪声能力弱。如何选择N没有绝对标准需要权衡。一个实用的方法是N ≈ (采样频率) / (你希望保留的信号最高频率)。例如采样率100Hz你想保留10Hz以下的信号那么N可选10左右。通常从较小的N如5、10开始调试观察效果后再调整。3.3 基础实现方式对比实现方式优点缺点适用场景简单平均逻辑最简单每次重新求和。每次计算需遍历整个数组计算复杂度O(N)效率低。窗口N很小如5或对实时性要求不高的场合。滑动窗口队列利用队列结构每次更新只需进行一次加法和一次减法计算复杂度O(1)效率高。需要维护队列索引和总和变量代码稍复杂。最常用、最推荐的方式尤其适合N较大的情况。递推平均计算量小无需存储全部历史数据。不是严格的移动平均是所有历史数据的加权平均滞后效应会累积。适用于对历史所有数据都有一定权重的场景如慢变信号。本文将重点讲解滑动窗口队列法因为它在效率和效果上取得了最佳平衡。4. 滑动窗口队列法完整实现C语言我们将实现一个通用的、可重用的移动平均滤波器模块。4.1 数据结构定义 (filter.h)首先我们定义一个结构体来封装滤波器的所有状态和数据。这样设计便于管理多个需要滤波的通道。// filter.h #ifndef __FILTER_H__ #define __FILTER_H__ typedef struct { float *buffer; // 指向数据缓冲区的指针 uint16_t window_size; // 窗口长度 N uint16_t index; // 当前写入位置的索引 float sum; // 窗口内数据的累加和 uint8_t is_full; // 标志位缓冲区是否已填满 } MovingAverageFilter; // 函数声明 void MovingAverageFilter_Init(MovingAverageFilter *filter, float *buf, uint16_t size); float MovingAverageFilter_Update(MovingAverageFilter *filter, float new_sample); #endif /* __FILTER_H__ */4.2 核心函数实现 (filter.c)接下来是具体的初始化与更新函数。// filter.c #include filter.h #include string.h // 用于memset /** * brief 初始化移动平均滤波器 * param filter: 滤波器结构体指针 * param buf: 用户提供的缓冲区指针大小至少为 window_size * sizeof(float) * param size: 窗口大小 N * retval 无 */ void MovingAverageFilter_Init(MovingAverageFilter *filter, float *buf, uint16_t size) { // 参数检查 if (filter NULL || buf NULL || size 0) { // 在实际项目中这里应该进行错误处理例如断言或返回错误码 return; } filter-buffer buf; filter-window_size size; filter-index 0; filter-sum 0.0f; filter-is_full 0; // 将缓冲区清零 memset(filter-buffer, 0, size * sizeof(float)); } /** * brief 更新滤波器输入新采样值并返回当前滤波输出 * param filter: 滤波器结构体指针 * param new_sample: 新的采样值 * retval 滤波后的输出值 */ float MovingAverageFilter_Update(MovingAverageFilter *filter, float new_sample) { float old_sample; // 减去即将被覆盖的旧数据 old_sample filter-buffer[filter-index]; filter-sum - old_sample; // 加入新数据 filter-buffer[filter-index] new_sample; filter-sum new_sample; // 更新索引实现环形缓冲区 filter-index; if (filter-index filter-window_size) { filter-index 0; filter-is_full 1; // 缓冲区第一次被填满后标志位置位 } // 计算平均值 // 如果缓冲区还未填满用当前实际数据个数除填满后用窗口大小除。 uint16_t divisor filter-is_full ? filter-window_size : filter-index; // 防止除零错误理论上index0且is_full0时发生但初始化后第一次调用index为1 if (divisor 0) { divisor 1; } return filter-sum / divisor; }4.3 在51单片机上的应用示例ADC滤波51单片机RAM紧张我们使用unsigned int类型和较小的窗口N8来节省资源。// main.c (for 51 MCU) #include reg52.h // 根据你的51芯片头文件调整 #include filter.h #define ADC_CHANNEL 0 // 假设使用P1.0作为ADC输入具体需根据ADC芯片或电路调整 #define WINDOW_SIZE 8 // 为滤波器分配存储空间 float adc_filter_buffer[WINDOW_SIZE]; MovingAverageFilter adc_filter; // 模拟ADC读取函数实际需根据你的ADC芯片驱动编写 unsigned int Read_ADC_Value(void) { // 此处应包含启动转换、等待完成、读取结果的代码 // 例如对于ADC0804等芯片 // 这是一个模拟返回实际值范围0-102310位ADC或0-409512位ADC static unsigned int mock_value 500; // 模拟噪声真实值500 ± 随机噪声20 return mock_value (rand() % 41) - 20; } void main() { unsigned int raw_adc; float filtered_adc; // 1. 初始化滤波器 MovingAverageFilter_Init(adc_filter, adc_filter_buffer, WINDOW_SIZE); // 2. 初始化ADC等硬件此处省略具体代码 // Init_ADC(); while(1) { // 3. 读取原始ADC值 raw_adc Read_ADC_Value(); // 4. 更新滤波器并获取滤波后结果 filtered_adc MovingAverageFilter_Update(adc_filter, (float)raw_adc); // 5. 使用滤波后的值例如控制PWM、显示等 // 例如通过串口打印观察 // printf(Raw: %d, Filtered: %.2f\n, raw_adc, filtered_adc); // 6. 延时控制采样频率例如10ms采样一次 Delay_ms(10); } }4.4 在STM32上的应用示例结合HAL库ADCSTM32资源丰富我们可以使用DMA定时器触发ADC并高效地进行滤波。// main.c (for STM32, using HAL) #include main.h #include filter.h #include stdio.h #define WINDOW_SIZE 16 #define ADC_BUFFER_SIZE 100 // DMA循环接收的缓冲区大小 // 定义滤波器和缓冲区 float adc_filter_buf[WINDOW_SIZE]; MovingAverageFilter voltage_filter; // DMA循环接收的ADC原始值缓冲区 uint16_t adc_dma_buffer[ADC_BUFFER_SIZE]; uint32_t adc_dma_index 0; // 用于从DMA缓冲区取数据的索引 // 在ADC DMA传输完成半满或全满中断中这个索引会更新 // 此处为简化在主循环中模拟新数据到达 float Get_New_ADC_Sample(void) { // 模拟从DMA缓冲区获取一个新值 // 实际项目中这里应该是一个线程安全的FIFO或标志位检查 float sample (float)adc_dma_buffer[adc_dma_index]; adc_dma_index (adc_dma_index 1) % ADC_BUFFER_SIZE; // 假设12位ADC参考电压3.3V转换为电压值 return sample * 3.3f / 4095.0f; } int main(void) { HAL_Init(); SystemClock_Config(); MX_ADC1_Init(); // 初始化ADC配置为DMA循环模式 MX_USART1_UART_Init(); // 初始化串口用于打印 // 初始化移动平均滤波器 MovingAverageFilter_Init(voltage_filter, adc_filter_buf, WINDOW_SIZE); // 启动ADC DMA采集 HAL_ADC_Start_DMA(hadc1, (uint32_t*)adc_dma_buffer, ADC_BUFFER_SIZE); while (1) { // 假设我们以一定频率如由定时器中断触发来处理滤波 // 这里简化为主循环延时处理 HAL_Delay(50); // 每50ms处理一次 float new_voltage Get_New_ADC_Sample(); // 获取最新电压采样值 float filtered_voltage MovingAverageFilter_Update(voltage_filter, new_voltage); // 通过串口输出观察效果 printf(Raw: %.3fV, Filtered: %.3fV\r\n, new_voltage, filtered_voltage); } }5. 进阶优化与变体基础的移动平均滤波有时不能满足需求这里介绍几种常见的变体。5.1 加权移动平均滤波普通移动平均对所有历史数据一视同仁。加权移动平均则给不同时刻的数据赋予不同的权重通常越新的数据权重越高。这能在平滑噪声的同时减少相位滞后。实现示例指数加权近似#define ALPHA 0.2f // 平滑因子越小越平滑滞后越大。范围(0,1] float Exponential_Moving_Average(float old_filtered_value, float new_sample, float alpha) { return alpha * new_sample (1 - alpha) * old_filtered_value; } // 调用filtered_value Exponential_Moving_Average(filtered_value, new_adc, ALPHA);这种方法甚至不需要数组只保存上一个输出值节省大量RAM适用于RAM极度受限的场合如某些51单片机项目。但它不是严格的移动平均而是一种IIR滤波器。5.2 去极值移动平均滤波在传感器数据中偶尔会出现远超正常范围的跳变野值。这种野值会严重污染移动平均的结果。去极值法在计算平均前先去掉窗口内的最大值和最小值。实现思路在MovingAverageFilter结构体中增加两个变量记录当前窗口的max和min。在Update函数中加入新值时更新max和min。当旧值被移出时如果它等于当前的max或min则需要重新遍历窗口寻找新的极值这会增加计算量。输出时计算(sum - max - min) / (window_size - 2)。5.3 固定点数与整数运算优化对于没有硬件浮点单元FPU的单片机如大多数51和STM32F1浮点数运算速度很慢。我们可以使用整数运算。方法一全部使用整数。如果ADC是12位值范围0-4095窗口大小N取2的幂次如8、16、32。这样sum / N可以用右移运算sum 3(N8) 或sum 4(N16) 来代替速度极快。方法二定点数运算。例如使用int32_t类型将实际值放大2^101024倍进行存储和计算最后结果再右移10位。这能在保持一定精度的前提下提高速度。整数优化示例窗口N16typedef struct { uint16_t buffer[16]; uint16_t index; uint32_t sum; // 累加和可能超过16位用32位存储 uint8_t is_full; } MovingAverageFilterInt; uint16_t MovingAverageFilterInt_Update(MovingAverageFilterInt *filter, uint16_t new_sample) { uint16_t old_sample filter-buffer[filter-index]; filter-sum filter-sum - old_sample new_sample; filter-buffer[filter-index] new_sample; filter-index (filter-index 1) 0x0F; // 索引循环N16掩码为0x0F if (!filter-is_full filter-index 0) { filter-is_full 1; } uint16_t divisor filter-is_full ? 16 : filter-index; if (divisor 0) divisor 1; // 使用整数除法或如果N是2的幂次用右移return filter-sum 4; return (uint16_t)(filter-sum / divisor); }6. 常见问题与调试技巧6.1 滤波后数据反应“变慢”了怎么办这是相位滞后的典型表现。排查与解决检查窗口大小NN越大滞后越严重。尝试减小N值。检查采样频率采样频率太低即使N很小窗口覆盖的物理时间也会很长导致滞后。在系统资源允许的情况下提高采样频率。考虑其他算法如果对实时性要求极高可以考虑一阶滞后滤波低通滤波或滑动窗中值滤波。中值滤波在去除脉冲噪声时滞后很小。6.2 滤波效果不明显噪声依然很大排查与解决噪声类型判断移动平均主要对付随机高频噪声。如果你的噪声是周期性的如50Hz工频可能需要硬件滤波如RC电路或数字陷波滤波器。窗口大小N是否太小适当增大N值。检查数据源用示波器或逻辑分析仪查看传感器原始信号确认噪声是否真的来自外部而非你的代码逻辑错误如数值溢出、数据类型转换错误。6.3 单片机RAM不够窗口开不大怎么办排查与解决使用整数类型用uint16_t代替float可以节省一半空间。减小窗口N这是最直接的方法但会影响效果。使用递推平均一阶滞后滤波只保存上一个输出值几乎不占额外RAM。公式y[k] α * x[k] (1-α) * y[k-1]。α是平滑系数接近0时平滑效果好但滞后大。优化数据结构如果多个通道需要滤波可以尝试共用缓冲区或使用更紧凑的数据类型。6.4 初始化时输出有跳变在缓冲区未填满的阶段除数实际数据个数小于N导致平均值计算不稳定。解决我们的示例代码已经处理了这种情况通过is_full标志和divisor变量确保了初始化阶段的正确性。另一种策略是用初始值如第一个采样值预先填充整个缓冲区。7. 工程实践与最佳建议模块化设计如示例所示将滤波器封装成结构体和独立的.c/.h文件。这样可以在不同项目、不同通道间轻松复用提高代码整洁度。参数可配置将窗口大小N定义为宏或通过函数参数传入便于调试时快速调整而无需修改多处代码。注重数据类型根据ADC分辨率选择uint16_t或uint32_t。在32位带FPU的MCU上可大胆用float。在8/16位MCU上优先考虑定点数运算或整数运算。避免在中断服务程序(ISR)中进行复杂滤波移动平均滤波涉及数组和累加操作虽然计算量不大但如果窗口N很大仍可能耗时。建议在ISR中只采集原始数据存入缓冲区在主循环或低优先级任务中进行滤波计算。结合其他传感器技术对于特别关键的数据不要只依赖软件滤波。结合硬件滤波如RC低通、传感器冗余和软件层面的传感器融合算法如互补滤波、卡尔曼滤波才能构建鲁棒性极强的系统。调试与可视化务必通过串口、OLED或调试工具将原始数据和滤波后数据同时绘制或打印出来对比。这是调整参数N值和验证效果最直观的方式。移动平均滤波是嵌入式开发者工具箱里的一把瑞士军刀简单但实用。理解其原理掌握其实现并能根据具体场景资源约束、实时性要求、噪声特性进行选择和调整是提升单片机系统稳定性和可靠性的关键一步。建议你将文中的代码模块保存下来在下一个需要处理传感器数据的项目中直接应用并根据实际效果微调参数积累属于你自己的工程经验。