嵌入式ADC滤波:跳水算法原理、实现与优化
1. 项目概述:从“跳水评分”到嵌入式滤波
在嵌入式系统,尤其是MCU(微控制器)的应用中,模拟信号采集是再基础不过的操作。无论是读取温度传感器的微弱电压,还是监测电池的剩余电量,我们都需要通过ADC(模数转换器)将连续的模拟世界转换为离散的数字量。然而,现实世界充满了噪声——电源纹波、电磁干扰、甚至MCU自身的数字噪声都会叠加在信号上,导致ADC的读数“上蹿下跳”。直接使用这个跳动的原始值,轻则让显示的数字闪烁不停,重则导致控制逻辑误判,系统行为异常。
因此,滤波算法就成了嵌入式工程师的必备技能。大家最熟悉的莫过于“平均滤波法”,即连续采样N次,然后求算术平均值。这个方法简单有效,但它有一个天生的弱点:对“野值”(异常值)非常敏感。想象一下,在10次采样中,有9次是稳定的1000,但有一次因为一个强烈的干扰脉冲变成了3000,那么最终的平均值就会被严重拉偏到1200,完全失真。为了解决这个问题,工程师们从生活中汲取了灵感,比如体育比赛中的“跳水评分算法”——去掉一个最高分,去掉一个最低分,再计算剩余分数的平均值。这个思路被巧妙地移植到了嵌入式领域,也就是我们今天要深入探讨的“跳水”滤波算法。
我第一次接触这个算法,是在十多年前的一个电机控制项目里。当时需要精确测量电机的相电流,但功率MOS管开关引起的巨大噪声让ADC读数根本无法直视。尝试了简单的移动平均,效果不佳;上更复杂的卡尔曼滤波,MCU的算力又捉襟见肘。直到看到论坛里一位昵称“hotpower”的前辈分享的这段代码,才豁然开朗。它用极小的资源开销(仅需4个寄存器),实现了对野值鲁棒性极强的滤波效果,而且“点数无限”,可灵活适配不同场景。这么多年过去,这个算法依然是我在资源受限环境下进行ADC滤波的首选方案之一。它不仅仅是一段代码,更体现了一种“用巧劲解决大问题”的嵌入式设计哲学。
2. 算法核心思想与优势解析
“跳水”滤波算法的核心思想,正如其名,直接借鉴了体育比赛的评分规则。其操作流程可以概括为:在一组采样数据中,主动剔除一个最大值和一个最小值(即可能由突发干扰产生的“野值”),然后对剩余的数据求取平均值,作为本次滤波的有效输出。
2.1 与传统滤波算法的对比
为了理解它的优势,我们将其与几种常见滤波算法进行对比:
算术平均滤波:
- 做法:连续采样N次,求和后除以N。
- 优点:算法极其简单,对周期性干扰有良好抑制。
- 缺点:对野值(脉冲干扰)的抑制能力很差,一个异常值会显著影响最终结果。计算需要保存所有N个历史数据或进行累加。
中位值滤波:
- 做法:连续采样N次(N为奇数),将这N个值按大小排序,取中间值作为结果。
- 优点:对野值抵抗能力极强,适合消除偶然的脉冲干扰。
- 缺点:需要排序,当N较大时计算开销大(时间复杂度通常为O(N log N)),且需要存储所有N个历史数据。对于慢变信号,实时性会受影响。
滑动平均滤波:
- 做法:维护一个长度为N的队列,每次新采样值进入队列,最老的采样值出队,计算队列中所有数据的平均值。
- 优点:实时性好,每来一个新数据就能输出一个结果。
- 缺点:同样需要存储N个历史数据,对野值敏感,且响应速度与滤波效果(平滑度)存在矛盾:N越大越平滑,但响应越慢。
“跳水”滤波(去极值平均滤波):
- 做法:连续采样N次,找出其中的最大值和最小值并剔除,对剩下的N-2个数据求平均。
- 优点:
- 抗野值能力强:主动去除了最可能受干扰的两个极端值,效果显著。
- 资源消耗极低:无需存储所有历史数据,仅需4个变量(累加和、最大值、最小值、计数器),空间复杂度为O(1)。
- 计算效率高:无需排序,每次采样仅进行简单的比较和加法,在滤波周期结束时做一次减法和除法,时间复杂度为O(1)。
- 灵活性好:滤波点数N可以动态调整(理论上无限,受累加和变量长度限制),适应不同响应速度和平滑度要求。
注意:这里说的“资源消耗低”是相对于需要存储全部历史数据的算法而言。在“跳水”算法中,我们是在一个采样窗口内进行“一次性”滤波,窗口结束后变量清零,开始下一个窗口。这与滑动平均那种持续更新的方式在实现和效果上都有区别。
2.2 算法关键参数N的选择与考量
原始代码中提到了“N最好取4, 6, 10, 34, 66, 130等等”,这并非随意列举,背后有深刻的工程考量。
- N>=3是算法成立的前提:因为要去掉一个最高分和一个最低分,所以至少需要有3个数据,才有“剩余”的数据可以求平均。
- 为什么N>3为好?当N=3时,去掉最大和最小值,就只剩下一个数据,这个数据本身就是“中位数”,此时算法退化为“中位值滤波”。虽然也能抗野值,但失去了“平均”带来的对随机噪声的平滑能力。当N>3时,我们是用多个数据的平均值来输出,对随机白噪声的抑制效果更好。
- N的推荐值(4,6,10,34...)的奥秘:这些数字通常与计算效率有关。在嵌入式系统中,除法(尤其是浮点除法)是昂贵的操作。如果N-2的值是2的整数次幂(如2,4,8,16,32,64,128...),那么最后的除法运算就可以用代价低得多的右移位(>>)操作来代替。
- 例如,当N=10时,有效数据量是N-2=8。8是2^3。所以
val / 8可以等价于val >> 3。 - 原始代码中
val >>= 13,是因为它先乘了一个增益AdcGain,并且ADC是10位(最大值1024)。其完整逻辑是:val = (Sum - Max - Min) * Gain / (N-2)。为了将除法变为移位,需要让(N-2) * 某个因子等于2的整数次幂。这里(10-2)=8,Gain和分母被合并处理,最终用>>13一次完成乘法和除法,是高度优化的定点数运算技巧。 - 因此,选择N=4(剩2)、6(剩4)、10(剩8)、34(剩32)、66(剩64)、130(剩128)等,都是为了使得
(N-2)是2的幂,从而优化计算。在实际项目中,如果对计算速度有极致要求,应优先考虑这些N值。
- 例如,当N=10时,有效数据量是N-2=8。8是2^3。所以
3. 算法实现细节与代码逐行解析
让我们回到hotpower前辈提供的代码,逐行拆解其精妙之处。这段代码是一个典型的“周期触发式”滤波,即攒够N个点后,统一处理一次。
/* 假设全局变量定义 */ unsigned int AdcSum = 0; // 累加和 unsigned int AdcMax = 0; // 最大值 unsigned int AdcMin = 0x3ff; // 最小值,初始化为ADC最大值(10位ADC) unsigned char AdcCount = 0; // 采样计数器 unsigned int AdcVal = 0; // 滤波输出值 const unsigned int AdcGain = 1234; // 增益系数,用于标定到实际物理量(如mV) /* ADC中断服务程序或主循环采样函数中调用 */ void ProcessADC_Sample(unsigned int AdcResult) { /* 1. 取ADC转换电压 */ AdcResult = AdcResult & 0x3ff; // 屏蔽高6位,确保是10位有效值 /* 2. 求累加和 */ AdcSum += AdcResult; // 累加 /* 3. 求最大值 */ if (AdcResult > AdcMax) { AdcMax = AdcResult; } /* 4. 求最小值 */ // 注意:这里必须是独立的if,不能是else if! if (AdcResult < AdcMin) { AdcMin = AdcResult; } AdcCount++; // 计数加1 /* 5. 判断是否达到一个滤波窗口(N=10) */ if (AdcCount >= 10) { /* 5.1 求平均值(去极值) */ unsigned long val = AdcSum - AdcMax - AdcMin; // 去掉最高分和最低分 /* 5.2 乘增益并转换为实际值(定点数运算优化) */ val = val * AdcGain; // 先乘后除,避免精度损失 val = val >> 13; // 等价于除以 (10-2) * 1024? 这里需要根据Gain具体计算 // 更通用的写法:val = (val * Gain) / (N-2); // 若(N-2)是2的幂,则用移位:val = (val * Gain) >> log2(N-2); AdcVal = (unsigned int)val; // 得到最终结果 /* 5.3 下一轮初始化(重置状态) */ AdcSum = 0; AdcMax = 0; AdcMin = 0x3ff; AdcCount = 0; } }3.1 关键代码点剖析
AdcMin的初始化(0x3ff): 这是非常关键的一步。对于10位ADC,其有效范围是0-1023(0x3FF)。将AdcMin初始化为最大值0x3ff,可以确保第一个采样值一定能进入if (AdcResult < AdcMin)这个分支,从而被正确更新为真实的最小值。如果初始化为0,而实际采样值都大于0,那么最小值将永远无法被更新。最大值与最小值更新的独立性: 代码中特别强调“千万不敢写成else if”。为什么?考虑一种边界情况:如果当前采样值
AdcResult同时是新的最大值和新的最小值(在初始化后的第一次采样时必然发生),那么如果用了else if,当第一个if条件成立后,第二个else if就不会被执行,导致AdcMin无法被更新。因此,必须用两个独立的if语句。累加和
AdcSum的溢出问题: 这是该算法一个重要的潜在风险点。AdcSum会随着采样次数N和ADC位数的增加而快速增长。- 例如:10位ADC,最大值1023。如果N=100,最坏情况下每次都是1023,那么
AdcSum将达到102300,这已经超过了16位无符号整数(65535)的范围。 - 解决方案:必须根据
N和ADC最大值来选择合适的变量类型。- 计算最大可能累加和:
Max_Sum = N * (ADC_Max_Value)。 - 选择变量类型:确保
AdcSum(以及中间计算变量val)的数据类型(如unsigned int(16位),unsigned long(32位))能够容纳这个最大值而不溢出。
- 计算最大可能累加和:
- hotpower代码中使用了
unsigned long val来存放中间结果,就是为了防止在乘法val * AdcGain时溢出。
- 例如:10位ADC,最大值1023。如果N=100,最坏情况下每次都是1023,那么
定点数运算与移位优化:
val = val * AdcGain; val >>= 13;这是经典的定点数运算。AdcGain是一个缩放因子,用于将ADC的数字量转换为实际的物理量(如电压毫伏值)。- 为什么先乘后除?在整数运算中,除法会丢弃余数,如果先除,可能会损失掉乘法带来的精度提升。例如,
(255 * 1000) / 256 = 996,而255 * (1000/256) = 255*3 = 765,精度损失严重。 >>13是怎么来的?这需要根据系统设计来推算。假设:- ADC分辨率:10位 (满量程1024)
- 目标:输出单位为mV,参考电压Vref=5000mV。
- 那么,1个ADC字对应的电压是
5000mV / 1024 ≈ 4.8828mV/LSB。 - 增益
AdcGain可以设置为一个定点数,比如AdcGain = 5000 * (1<<K) / 1024,其中K是定点数的小数位精度。 - 最终计算:
mV = (去极值和) * AdcGain / (N-2)。 - 如果
(N-2)是2的幂(比如8),并且AdcGain也包含了2的幂次因子,那么整个除法就可以合并为一次右移。>>13就是综合了AdcGain的缩放和除以8的操作。在实际应用中,你需要根据自己系统的Vref、ADC位数、N值来重新计算这个移位常数。
- 为什么先乘后除?在整数运算中,除法会丢弃余数,如果先除,可能会损失掉乘法带来的精度提升。例如,
4. 算法变体、扩展与实战调整
基本的“跳水”算法已经很强大了,但在实际工程中,我们还可以根据具体需求进行变体和扩展。
4.1 滑动窗口式“跳水”滤波
原始代码是“批处理”模式,攒够N个点才输出一次,这会导致输出更新频率降低为采样频率的1/N。对于需要实时输出的场景,我们可以将其改造成滑动窗口模式。
思路:维护一个长度为N的先进先出(FIFO)缓冲区。每次新数据到来时:
- 从累加和
AdcSum中减去即将被移出窗口的那个最老的数据。 - 更新最大值
AdcMax和最小值AdcMin(这是滑动窗口实现的难点)。 - 将新数据加入缓冲区,并加到
AdcSum中。 - 重新检查新数据是否成为新的最大/最小值。
- 如果被移出的数据恰好是当前的最大值或最小值,则需要遍历当前窗口内所有数据,重新找出最大和最小值。
- 计算当前窗口的去极值平均值并输出。
// 简化的滑动窗口去极值平均滤波结构体 typedef struct { unsigned int buffer[10]; // 窗口缓冲区,N=10 unsigned int sum; // 窗口内数据和 unsigned int max; // 窗口内最大值 unsigned int min; // 窗口内最小值 unsigned char index; // 当前写入位置 unsigned char is_full; // 窗口是否已满标志 } SlidingDivingFilter; unsigned int SlidingDivingFilter_Update(SlidingDivingFilter* filter, unsigned int new_sample) { unsigned int oldest_sample; // 1. 获取并移除最老样本(如果窗口已满) if (filter->is_full) { oldest_sample = filter->buffer[filter->index]; filter->sum -= oldest_sample; // 难点:如果被移除的正好是最大值或最小值,需要重新查找 if (oldest_sample == filter->max || oldest_sample == filter->min) { filter->max = 0; filter->min = 0xFFFF; // 假设16位ADC for (int i = 0; i < 10; i++) { unsigned int s = filter->buffer[i]; if (s > filter->max) filter->max = s; if (s < filter->min) filter->min = s; } } } // 2. 存入新样本 filter->buffer[filter->index] = new_sample; filter->sum += new_sample; // 3. 更新最大最小值 if (new_sample > filter->max) filter->max = new_sample; if (new_sample < filter->min) filter->min = new_sample; // 4. 更新索引和满标志 filter->index = (filter->index + 1) % 10; if (filter->index == 0) { filter->is_full = 1; } // 5. 计算并输出(仅当窗口满时) if (filter->is_full) { unsigned long val = filter->sum - filter->max - filter->min; // ... 进行增益乘法和移位,得到最终结果 ... return (unsigned int)(val >> 3); // 示例:除以8 } else { return 0xFFFF; // 或返回一个无效值,表示窗口未满 } }滑动窗口的优缺点:
- 优点:每输入一个新数据,就能输出一个滤波后的值,实时性好。
- 缺点:实现复杂,尤其是在处理最大最小值更新时,最坏情况下(每次移除的都是当前极值)需要遍历整个窗口,计算开销增大。这牺牲了原始算法“计算量恒定”的优点。
4.2 自适应N值调整
在某些场景下,信号噪声水平是变化的。我们可以让N值根据信号的“稳定程度”动态调整。
- 思路:计算本次滤波窗口内数据的方差或极差(最大值-最小值)。
- 如果极差很小,说明信号很稳定,可以减小N值,让滤波器响应更快。
- 如果极差很大,说明噪声大或信号在快速变化,可以增大N值,增强平滑效果,但响应会变慢。
- 实现:设定几个极差阈值,对应不同的N值。这种方法增加了算法的自适应性,但也引入了状态判断的逻辑。
4.3 去除多个极值点
对于噪声特别严重,可能出现多个野值的情况,可以扩展算法,去掉最大和最小的各M个点(M>1),再对剩下的N-2M个点求平均。但这需要排序或使用更复杂的数据结构(如维护两个最小堆和最大堆)来高效地找出多个极值,会显著增加资源消耗,背离了算法“简洁”的初衷。一般情况下,去除一个最大值和一个最小值已经能应对绝大多数工业现场的干扰。
5. 常见问题、调试技巧与避坑指南
在实际部署“跳水”滤波算法时,我踩过不少坑,也总结了一些调试技巧。
5.1 问题排查清单
| 现象 | 可能原因 | 排查方法与解决方案 |
|---|---|---|
| 滤波输出始终为0 | 1.AdcCount未正确递增。2. 判断条件 if (AdcCount >= N)中的N设置错误或从未满足。3. AdcSum,AdcMax,AdcMin在初始化或计算后意外被其他代码修改。 | 1. 检查AdcCount++是否被执行。2. 检查 N的值,并确认采样频率和滤波调用频率是否匹配。3. 将状态变量( AdcSum,AdcMax,AdcMin,AdcCount)定义为static(函数内)或加强访问保护(全局变量时),避免重入或并发问题。使用调试器观察其变化。 |
| 输出值明显偏大或溢出 | 1.累加和AdcSum溢出。这是最常见的问题!2. 增益 AdcGain设置过大。3. 移位操作 >>的位数计算错误,导致除法结果放大而非缩小。 | 1.务必验算:N_max * ADC_MAX_VALUE是否超出AdcSum变量类型的范围。将AdcSum和中间变量val改为更大的类型(如uint32_t)。2. 重新计算增益系数,确保乘法 val * Gain不会溢出(使用更大类型)。3. 用实际数据代入公式,验证移位位数。可以先直接用除法 / (N-2)验证逻辑正确,再替换为移位优化。 |
| 滤波后噪声仍然很大 | 1. 采样点数N太小,平滑效果不足。2. 信号本身的噪声频率与采样频率接近,产生了混叠。 3. 硬件噪声过大,软件滤波治标不治本。 | 1. 适当增大N,观察效果。注意权衡响应速度。2.在ADC采样前增加硬件RC低通滤波,或者在软件上提高采样率(远高于信号频率)并结合本算法。 3. 检查PCB布局、电源去耦、信号走线,从源头降低噪声。 |
| 响应速度过慢 | 采样点数N太大。 | 减小N值。根据信号变化频率和系统控制周期来选择合适的N。一个经验法则:滤波窗口时间(N/采样频率)应远小于被控系统的响应时间常数。 |
| 最小值滤波失效(始终为0) | AdcMin初始化错误。例如10位ADC却初始化为0xFFFF(65535),则第一个采样值(比如500)小于它,AdcMin被更新为500,后续若采样值都大于500,则最小值永远停留在500,而不是实际的最小值。 | 正确初始化AdcMin为ADC量程的最大值(如10位ADC为1023)。确保第一个采样值能触发更新。 |
5.2 实操心得与高级技巧
初始化的重要性:不仅
AdcMin要初始化为最大值,AdcMax应初始化为0,AdcSum和AdcCount初始化为0。在系统启动或ADC通道切换后,最好连续进行N次无效采样并丢弃,让滤波器的状态变量被真实数据填充,避免第一个滤波窗口输出错误结果。中断安全与可重入性:如果
ProcessADC_Sample函数在中断服务程序(ISR)中被调用,而主循环中也会读取AdcVal,那么AdcVal的访问可能存在竞态条件。虽然在这个简单例子中,AdcVal在ISR中只被完整地赋值一次,问题不大。但对于更复杂的滤波器状态变量,如果主循环在读的时候ISR正在写,就可能读到不一致的数据。对于8位或16位MCU,简单变量通常是原子操作,但32位变量在8位机上可能不是。必要时可以使用临界区保护(暂时关闭中断)或标志位同步。结合其他滤波方法:“跳水”滤波擅长处理偶发的脉冲野值。对于高频随机噪声,可以将其与“一阶低通滤波(惯性滤波)”结合。例如,将“跳水”算法的输出作为一阶低通滤波器的输入,这样既能抵抗突发干扰,又能对常规噪声进行平滑。公式为:
Y(n) = α * X(n) + (1-α) * Y(n-1),其中X(n)是本次“跳水”滤波的输出,Y(n)是最终结果,α是滤波系数。调试可视化:在开发阶段,如果条件允许,可以将ADC原始数据、滤波中间变量(如
AdcSum,AdcMax,AdcMin)和最终结果AdcVal通过串口发送到上位机,用绘图工具(如Python的Matplotlib)实时绘制曲线。这是调试滤波算法、确定最佳N值和观察噪声特征的终极利器。亲眼看到一个个尖峰被“去掉最高分”的过程,对算法的理解会深刻得多。理解算法的局限性:“跳水”滤波本质是一种非线性滤波器(因为剔除极值的操作是非线性的)。这意味着它不能像均值滤波器那样,在频域上有明确的理解。在要求信号相位严格保真或者需要进行频谱分析的场合,需要谨慎使用。它最适合的应用场景是获取稳定的直流或慢变信号的有效幅值,比如电池电压、环境温度、压力传感器读数等。
这个从体育比赛评分规则演化而来的小算法,以其惊人的简洁和高效,在资源紧张的嵌入式世界里闪耀了十多年。它教会我们,好的工程解决方案往往不是最复杂的,而是最贴合问题本质的。下次当你的ADC读数又在“跳舞”时,不妨试试这个“跳水”算法,或许它能给你带来意想不到的稳定。
