你是不是也遇到过这种情况:用单片机采集传感器数据时,数值总是跳来跳去,像心电图一样不稳定?明明温度没变,ADC读回来的值却在±5个LSB之间波动;电机转速恒定,但测速脉冲计数却忽高忽低。这些“毛刺”和“噪声”不仅让显示界面闪烁,更会直接导致控制算法误判,让整个系统变得不可靠。
问题的根源往往不是传感器坏了,而是无处不在的环境电磁干扰、电源纹波以及传感器自身的量化误差。直接使用这些“原始数据”进行逻辑判断或控制输出,无异于在沙地上盖楼。今天要讨论的移动平均滤波,就是解决这个问题的第一道,也是最常用、最有效的“防线”。
与那些听起来高大上但实现复杂的卡尔曼滤波、IIR滤波不同,移动平均滤波的核心思想极其朴素:用历史数据的“集体智慧”来平滑当前数据的“个体波动”。它不涉及复杂的数学推导,计算量极小,特别适合51、STM32这类资源有限的单片机。但朴素不等于简单,窗口长度怎么选?数据类型用int还是float?如何避免累积误差?这些细节才是决定滤波效果和系统稳定性的关键。
本文将带你彻底搞懂单片机上的移动平均滤波。我们不只讲“是什么”,更聚焦“怎么用”和“为什么这么用”。你会看到从最基本的算法推导,到针对单片机优化的队列式实现,再到防止溢出的定点数技巧,以及如何根据信号特性科学选择窗口大小。最后,我们将用一个完整的、可复制的51单片机ADC采集滤波实例,把理论变成你屏幕上的稳定波形。
1. 移动平均滤波:为什么它是单片机信号处理的“第一课”?
在深入代码之前,我们必须先建立正确的认知:移动平均滤波不是一个“高级”算法,而是一个工程必需品。它的首要目标不是追求最优的数学性能,而是在有限的CPU资源和内存下,快速、稳定地提升信号质量。
它解决了什么问题?
- 抑制随机噪声:对于叠加在真实信号上的高频随机噪声(如白噪声),平均运算能有效将其幅度降低。噪声是随机的,有正有负,多次平均后倾向于相互抵消。
- 平滑数据抖动:使输出曲线变得平滑,便于后续的阈值判断、显示或记录。这对于制作数码管显示、波形绘制等场景至关重要。
- 降低误触发概率:在按键检测、边缘触发等场景中,未经滤波的信号可能因噪声产生多次误触发,平均滤波可以提供一个稳定的判断基准。
它的局限性是什么?(先知道坑在哪)
- 滞后性(相位延迟):这是移动平均滤波最核心的代价。输出是过去N个点的平均,因此输出变化永远比输入真实变化“慢半拍”。窗口越大,滞后越严重。这对于实时性要求高的控制系统(如无人机姿态控制)可能是致命的。
- 对周期性干扰抑制有限:对于工频干扰(50/60Hz)这种有规律的周期性噪声,普通移动平均滤波效果不佳,需要根据干扰周期专门设计窗口长度或使用其他滤波方法。
- 无法区分噪声与突变:如果真实信号本身突然发生大幅度阶跃,滤波器会把这个突变“平滑”成一个缓慢上升的过程,从而扭曲了真实信息。
所以,移动平均滤波的适用场景非常明确:处理变化相对缓慢,但对瞬时噪声敏感的信号。例如温度、湿度、压力、电池电压、低速下的转速测量等。
2. 核心原理:从“算术平均”到“移动窗口”
我们先从最基础的算术平均说起。假设我们采集了10个数据:data[0]到data[9]。它们的算术平均值是:average = (data[0] + data[1] + ... + data[9]) / 10
这叫做一次平均。但我们的数据是持续不断来的(第11个、第12个……)。最笨的方法是每次收到新数据,都重新计算最近10个数的和再求平均。计算量是O(N),每次都需要进行N-1次加法、1次除法,对于单片机来说,当N较大时(比如50),在中断服务程序里这么干很可能导致中断处理超时。
移动平均滤波的优化精髓在于:它维护了一个“数据窗口”和一个“窗口和”。当新数据data_new到来时:
- 从“窗口和”中减去最早的那个旧数据
data_old。 - 将新数据
data_new加入“窗口和”。 - 将新数据放入窗口,并淘汰最早的数据(通常是循环队列)。
- 用新的“窗口和”除以窗口长度N,得到当前输出。
这样,每次更新只需要一次加法、一次减法和一次除法,计算量是O(1),与窗口大小N无关!这是它能应用于实时单片机的关键。
我们可以用一个表格来对比两种思路:
| 特性 | 每次重算的笨办法 | 移动窗口的优化法 |
|---|---|---|
| 计算复杂度 | O(N) | O(1) |
| 所需存储空间 | 可只存数组,和临时计算 | 需存数组(窗口)和一个累加和变量 |
| 实时性 | 差,随N增大而变差 | 极好,恒定时间完成 |
| 适合场景 | 非实时、后处理 | 单片机实时采样 |
3. 环境与思维准备:单片机的资源约束
在电脑上写算法,我们可以随意使用float、动态数组。在单片机上,我们必须“斤斤计较”。
数据类型选择:
int还是float?优先使用整数(int16_t,int32_t)。大多数8位或16位单片机没有硬件浮点单元(FPU),浮点运算靠软件模拟,速度慢百倍。ADC采集的结果通常是整数(如0-4095),用整数处理天经地义。- 决定数据范围:根据ADC位数和可能的最大累加值选择。例如12位ADC最大值4095,窗口长度N=10,则最大窗口和约为40950,仍在16位整数(-32768~32767)范围内吗?不,溢出了!所以必须使用
uint32_t或int32_t来存储累加和。
窗口数据结构:
- 循环队列是标准答案:用一个固定长度的数组
buffer[N]和一个指向当前写入位置的索引index来实现。新数据覆盖最老的数据。 - 避免使用链表:动态内存分配在单片机中是大忌,容易产生碎片且效率低。
- 循环队列是标准答案:用一个固定长度的数组
除法运算优化:
- 如果窗口长度N是2的幂次(如2,4,8,16,32),那么除法可以用右移运算来代替。
average = sum >> 3;等价于average = sum / 8;,速度极快。 - 如果N不是2的幂次,对于性能极其苛刻的场景,可以考虑使用定点数或近似算法。
- 如果窗口长度N是2的幂次(如2,4,8,16,32),那么除法可以用右移运算来代替。
4. 两种经典实现:从基础版到优化版
4.1 基础实现(易懂但低效)
我们先看一个最直观但低效的实现,用于理解流程:
// 低效的移动平均滤波示例(仅用于理解,不推荐实际使用) #define N 10 uint16_t buffer[N]; uint8_t count = 0; uint16_t Moving_Average_Simple(uint16_t new_value) { uint32_t sum = 0; uint8_t i; // 将新值存入缓冲区 if(count < N) { buffer[count] = new_value; count++; } else { // 缓冲区已满,整体前移一位(效率最低的操作!) for(i = 0; i < N-1; i++) { buffer[i] = buffer[i+1]; } buffer[N-1] = new_value; } // 计算总和 for(i = 0; i < count; i++) { sum += buffer[i]; } // 返回平均值 return (uint16_t)(sum / count); }问题分析:每次插入新数据,如果缓冲区满了,都需要进行N-1次数据搬移(for循环),这是一个O(N)的操作。在高速采样的中断里,这是不可接受的。
4.2 优化实现(循环队列法)
这是单片机中的标准实现,务必掌握。
// 高效的移动平均滤波实现(循环队列) #define FILTER_WINDOW_SIZE 10 // 窗口长度,建议为2的幂次以便优化 typedef struct { uint16_t buffer[FILTER_WINDOW_SIZE]; // 数据窗口 uint32_t sum; // 窗口内数据的和 uint8_t index; // 当前写入位置索引 uint8_t is_full; // 窗口是否已填满标志 } MovingAverageFilter; // 初始化滤波器 void MovingAverageFilter_Init(MovingAverageFilter* filter) { uint8_t i; for(i = 0; i < FILTER_WINDOW_SIZE; i++) { filter->buffer[i] = 0; } filter->sum = 0; filter->index = 0; filter->is_full = 0; } // 输入新数据,并返回滤波后的值 uint16_t MovingAverageFilter_Update(MovingAverageFilter* filter, uint16_t new_value) { uint16_t old_value; // 如果窗口已满,需要从总和中减去即将被覆盖的旧值 if(filter->is_full) { old_value = filter->buffer[filter->index]; filter->sum = filter->sum - old_value + new_value; } else { // 窗口未满,直接累加 filter->sum += new_value; } // 将新值存入窗口 filter->buffer[filter->index] = new_value; // 更新索引,实现循环 filter->index++; if(filter->index >= FILTER_WINDOW_SIZE) { filter->index = 0; filter->is_full = 1; // 索引回到0,说明窗口第一次被填满 } // 计算并返回平均值 if(filter->is_full) { // 窗口已满,除数固定为窗口大小 // 如果 FILTER_WINDOW_SIZE 是2的幂次,这里可以改为:return (uint16_t)(filter->sum >> LOG2(FILTER_WINDOW_SIZE)); return (uint16_t)(filter->sum / FILTER_WINDOW_SIZE); } else { // 窗口未满,除数为当前有效数据个数 return (uint16_t)(filter->sum / filter->index); } }代码精讲:
- 使用结构体封装:将滤波器状态(数组、和、索引、标志位)打包,方便管理多个滤波通道。
- 循环队列:
index指针在到达数组末尾后回到0,逻辑上形成一个环,避免了数据搬移。 is_full标志位:这是关键技巧。它用来区分窗口“填充阶段”和“稳定运行阶段”。在填充阶段,平均值分母是递增的index;填满后,分母固定为N,计算更规整。- O(1)更新:无论窗口多大,每次
Update只做一次减法、一次加法、一次赋值、两次判断和一次除法,效率极高。
5. 项目实战:51单片机ADC采集与滤波显示
让我们结合一个具体的场景:用STC89C52(或类似51单片机)采集电位器电压(通过ADC0804或单片机自带的ADC),经移动平均滤波后,将稳定的电压值显示在LCD1602上。
5.1 硬件与工程概览
- MCU: STC89C52RC
- ADC: ADC0804(并行接口)或 STC单片机内置的ADC(需具体型号支持)
- 显示: LCD1602
- 信号源: 电位器分压,模拟传感器电压变化
- 开发环境: Keil C51
5.2 核心代码集成
我们假设你已经完成了ADC驱动和LCD1602驱动的底层函数。
uint16_t Read_ADC_Value(void): 读取一次ADC原始值(0-255或0-4095)。LCD_ShowNum(uint8_t x, uint8_t y, uint16_t num, uint8_t len): 在LCD指定位置显示数字。
主程序滤波流程:
#include <reg52.h> #include <intrins.h> // ... 其他头文件和驱动函数声明 ... #define ADC_FILTER_WINDOW_SIZE 8 // 使用8,方便用移位代替除法 MovingAverageFilter adc_filter; void main() { uint16_t raw_adc, filtered_adc; // 初始化 Init_ADC(); Init_LCD1602(); MovingAverageFilter_Init(&adc_filter); // 初始化滤波器结构体 LCD_ShowString(0, 0, "Raw:"); LCD_ShowString(0, 1, "Filtered:"); while(1) { // 1. 读取ADC原始值 raw_adc = Read_ADC_Value(); // 2. 使用移动平均滤波器处理 filtered_adc = MovingAverageFilter_Update(&adc_filter, raw_adc); // 3. 显示结果(可以转换为电压值显示,此处简化为显示数字) LCD_ShowNum(5, 0, raw_adc, 4); LCD_ShowNum(10, 1, filtered_adc, 4); // 4. 延时,控制采样率。例如,每100ms采样一次。 Delay_ms(100); } }5.3 优化:使用移位代替除法
由于我们选择了窗口大小N=8(2的3次方),可以将除法优化为右移3位。
修改MovingAverageFilter_Update函数中的返回部分:
// 计算并返回平均值 - 优化版(仅当N为2的幂次时可用) if(filter->is_full) { // 窗口已满,使用右移代替除法。8 = 2^3,所以右移3位。 return (uint16_t)(filter->sum >> 3); } else { // 窗口未满,无法简单移位,仍需除法。但此阶段很短。 return (uint16_t)(filter->sum / filter->index); }注意:filter->sum必须是uint32_t类型,确保右移前有足够的位数,防止溢出信息丢失。
6. 效果验证与调试技巧
如何知道你的滤波器是否在工作?光看LCD上数字变“稳定”了还不够。
1. 串口绘图验证(强烈推荐)将原始数据raw_adc和滤波后数据filtered_adc通过串口发送到电脑,使用串口绘图工具(如SerialPlot、匿名上位机、VOFA+)查看波形。
- 预期现象:原始信号波形充满高频毛刺,滤波后的信号是一条紧贴原始信号趋势但平滑得多的曲线。
- 调整窗口大小N:你会直观地看到,N越大,曲线越平滑,但滞后也越明显。如果信号本身变化快,滞后会导致滤波后的曲线“跟不上”。
2. 注入测试信号用程序模拟一个“阶跃信号”加“随机噪声”,来测试滤波器性能。
// 在循环中模拟一个带噪声的阶跃信号 static uint16_t simulate_true_value = 1000; if(some_condition) { // 例如按键按下 simulate_true_value = 2000; // 信号发生阶跃 } // 在真实值上叠加一个随机噪声(-20 ~ +20) raw_adc = simulate_true_value + (rand() % 41) - 20; // 然后将raw_adc送入滤波器...观察滤波器输出是否能平滑地过渡到新的稳定值,以及过渡过程的时间(滞后性)。
7. 常见问题与深度优化指南
7.1 问题排查表
| 问题现象 | 可能原因 | 排查思路 | 解决方案 |
|---|---|---|---|
| 滤波后输出为0或恒定值 | 1. 累加和sum溢出后归零。2. 窗口长度 N为0或除数错误。3. 数据全部为0。 | 1. 检查sum的数据类型是否足够大(uint32_t)。2. 单步调试,查看 is_full标志和index是否正确更新。3. 检查ADC读取函数是否正常。 | 1. 使用更大类型(uint64_t)或减小N。2. 仔细检查循环索引和满标志逻辑。 3. 先验证原始数据是否正确。 |
| 滤波效果不明显,仍有抖动 | 1. 窗口长度N太小。2. 噪声幅度过大,超出了平均的抑制能力。 3. 噪声不是白噪声,而是周期性干扰。 | 1. 增大N值观察效果。 2. 通过串口绘图观察噪声频率和幅度。 3. 观察波形,看是否有规律波动。 | 1. 适当增加N。 2. 结合硬件滤波(如RC低通)。 3. 考虑使用中值滤波先去除脉冲,再用移动平均。 |
| 输出响应严重滞后,感觉“很肉” | 窗口长度N太大。 | 测量系统阶跃响应时间,与采样周期*N进行对比。 | 减小N值。必须在“平滑度”和“实时性”之间权衡。 |
| 系统运行一段时间后异常 | 1. 内存越界,index超出数组范围。2. 在中断和非中断中同时调用滤波器,导致数据错乱。 | 1. 检查数组大小和index自增逻辑。2. 检查是否在中断和主循环都调用了 Update。 | 1. 确保index在0到N-1之间循环。2. 避免共享数据,或使用临界区保护。 |
7.2 高级优化技巧
1. 加权移动平均普通移动平均对所有历史数据一视同仁。有时我们希望“越新的数据越重要”。这就是加权移动平均,公式为:output = (w0*new + w1*data[-1] + ... + w(N-1)*data[-(N-1)]) / sum(wi)其中w0 > w1 > ... > w(N-1)。这能在平滑噪声和快速跟踪信号之间取得更好平衡,但计算量稍大。
2. 一阶滞后滤波(低通滤波)这是一种特殊的“窗口大小为无穷大”的加权平均,但计算极其简单:output = (1-alpha) * output + alpha * new_input其中alpha是一个介于0和1之间的系数(通常很小,如0.1)。它不需要存储历史数组,内存占用极小,但滞后性随alpha减小而增大。它是移动平均的一种近似和变体。
3. 移动平均 + 中值滤波 组合对于含有突发性尖峰脉冲噪声的信号(如开关抖动),先使用中值滤波(取窗口内数据的中位数)去除脉冲,再使用移动平均平滑随机噪声,效果极佳。
8. 工程实践与选择建议
如何科学选择窗口大小N?
- 经验法则:N应远大于噪声的主要周期,但小于你关心的信号变化周期。通常从4、8、16、32开始尝试。
- 采样定理的考虑:如果你的采样频率是Fs,那么移动平均滤波大致相当于一个截止频率为
Fc ≈ Fs / N的低通滤波器。你可以根据你想保留的信号最高频率来反推N。 - 实时性约束:在控制系统中,滞后时间
T_lag ≈ N * T_sam(T_sam为采样周期)。确保这个滞后时间在你的系统允许范围内。
单片机资源评估表
| 资源类型 | 基础移动平均滤波 (N=10) | 注意事项 |
|---|---|---|
| RAM | 数组: 10 * sizeof(data_type) 变量: 1个sum, 1个index, 1个flag | 对于51单片机(128B RAM),如果数据是uint16_t,数组就占20B,需谨慎。STM32则通常无压力。 |
| ROM/Flash | 约50-100字节代码空间 | 非常小。 |
| CPU时间 | 每次更新约10-20个指令周期(整数运算) | 在MHz级别的单片机中,几乎可忽略不计。非常适合在定时器中断中调用。 |
最后的建议:移动平均滤波是你单片机信号处理工具箱中的“瑞士军刀”——简单、可靠、无处不在。掌握它的优化实现和理解其滞后性的本质,比你盲目追求更复杂的滤波算法更重要。在大多数单片机应用场景中,一个正确实现和参数调优的移动平均滤波,足以解决80%的信号噪声问题。
下次当你面对跳动的传感器数据时,不要只想着换更贵的传感器或抱怨电路设计。首先,请熟练地敲出那个循环队列的滤波结构体,它可能就是你项目从“不稳定”走向“可靠”的关键一步。