当前位置: 首页 > news >正文

嵌入式ADC滤波:跳水算法原理、实现与优化

1. 项目概述:从“跳水评分”到嵌入式滤波

在嵌入式系统,尤其是MCU(微控制器)的应用中,模拟信号采集是再基础不过的操作。无论是读取温度传感器的微弱电压,还是监测电池的剩余电量,我们都需要通过ADC(模数转换器)将连续的模拟世界转换为离散的数字量。然而,现实世界充满了噪声——电源纹波、电磁干扰、甚至MCU自身的数字噪声都会叠加在信号上,导致ADC的读数“上蹿下跳”。直接使用这个跳动的原始值,轻则让显示的数字闪烁不停,重则导致控制逻辑误判,系统行为异常。

因此,滤波算法就成了嵌入式工程师的必备技能。大家最熟悉的莫过于“平均滤波法”,即连续采样N次,然后求算术平均值。这个方法简单有效,但它有一个天生的弱点:对“野值”(异常值)非常敏感。想象一下,在10次采样中,有9次是稳定的1000,但有一次因为一个强烈的干扰脉冲变成了3000,那么最终的平均值就会被严重拉偏到1200,完全失真。为了解决这个问题,工程师们从生活中汲取了灵感,比如体育比赛中的“跳水评分算法”——去掉一个最高分,去掉一个最低分,再计算剩余分数的平均值。这个思路被巧妙地移植到了嵌入式领域,也就是我们今天要深入探讨的“跳水”滤波算法。

我第一次接触这个算法,是在十多年前的一个电机控制项目里。当时需要精确测量电机的相电流,但功率MOS管开关引起的巨大噪声让ADC读数根本无法直视。尝试了简单的移动平均,效果不佳;上更复杂的卡尔曼滤波,MCU的算力又捉襟见肘。直到看到论坛里一位昵称“hotpower”的前辈分享的这段代码,才豁然开朗。它用极小的资源开销(仅需4个寄存器),实现了对野值鲁棒性极强的滤波效果,而且“点数无限”,可灵活适配不同场景。这么多年过去,这个算法依然是我在资源受限环境下进行ADC滤波的首选方案之一。它不仅仅是一段代码,更体现了一种“用巧劲解决大问题”的嵌入式设计哲学。

2. 算法核心思想与优势解析

“跳水”滤波算法的核心思想,正如其名,直接借鉴了体育比赛的评分规则。其操作流程可以概括为:在一组采样数据中,主动剔除一个最大值和一个最小值(即可能由突发干扰产生的“野值”),然后对剩余的数据求取平均值,作为本次滤波的有效输出。

2.1 与传统滤波算法的对比

为了理解它的优势,我们将其与几种常见滤波算法进行对比:

  1. 算术平均滤波

    • 做法:连续采样N次,求和后除以N。
    • 优点:算法极其简单,对周期性干扰有良好抑制。
    • 缺点:对野值(脉冲干扰)的抑制能力很差,一个异常值会显著影响最终结果。计算需要保存所有N个历史数据或进行累加。
  2. 中位值滤波

    • 做法:连续采样N次(N为奇数),将这N个值按大小排序,取中间值作为结果。
    • 优点:对野值抵抗能力极强,适合消除偶然的脉冲干扰。
    • 缺点:需要排序,当N较大时计算开销大(时间复杂度通常为O(N log N)),且需要存储所有N个历史数据。对于慢变信号,实时性会受影响。
  3. 滑动平均滤波

    • 做法:维护一个长度为N的队列,每次新采样值进入队列,最老的采样值出队,计算队列中所有数据的平均值。
    • 优点:实时性好,每来一个新数据就能输出一个结果。
    • 缺点:同样需要存储N个历史数据,对野值敏感,且响应速度与滤波效果(平滑度)存在矛盾:N越大越平滑,但响应越慢。
  4. “跳水”滤波(去极值平均滤波)

    • 做法:连续采样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)=8Gain和分母被合并处理,最终用>>13一次完成乘法和除法,是高度优化的定点数运算技巧。
    • 因此,选择N=4(剩2)、6(剩4)、10(剩8)、34(剩32)、66(剩64)、130(剩128)等,都是为了使得(N-2)是2的幂,从而优化计算。在实际项目中,如果对计算速度有极致要求,应优先考虑这些N值。

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 关键代码点剖析

  1. AdcMin的初始化(0x3ff: 这是非常关键的一步。对于10位ADC,其有效范围是0-1023(0x3FF)。将AdcMin初始化为最大值0x3ff,可以确保第一个采样值一定能进入if (AdcResult < AdcMin)这个分支,从而被正确更新为真实的最小值。如果初始化为0,而实际采样值都大于0,那么最小值将永远无法被更新。

  2. 最大值与最小值更新的独立性: 代码中特别强调“千万不敢写成else if”。为什么?考虑一种边界情况:如果当前采样值AdcResult同时是新的最大值和新的最小值(在初始化后的第一次采样时必然发生),那么如果用了else if,当第一个if条件成立后,第二个else if就不会被执行,导致AdcMin无法被更新。因此,必须用两个独立的if语句。

  3. 累加和AdcSum的溢出问题: 这是该算法一个重要的潜在风险点。AdcSum会随着采样次数N和ADC位数的增加而快速增长。

    • 例如:10位ADC,最大值1023。如果N=100,最坏情况下每次都是1023,那么AdcSum将达到102300,这已经超过了16位无符号整数(65535)的范围。
    • 解决方案:必须根据NADC最大值来选择合适的变量类型。
      • 计算最大可能累加和:Max_Sum = N * (ADC_Max_Value)
      • 选择变量类型:确保AdcSum(以及中间计算变量val)的数据类型(如unsigned int(16位),unsigned long(32位))能够容纳这个最大值而不溢出。
    • hotpower代码中使用了unsigned long val来存放中间结果,就是为了防止在乘法val * AdcGain时溢出。
  4. 定点数运算与移位优化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)缓冲区。每次新数据到来时:

  1. 从累加和AdcSum中减去即将被移出窗口的那个最老的数据。
  2. 更新最大值AdcMax和最小值AdcMin(这是滑动窗口实现的难点)。
  3. 将新数据加入缓冲区,并加到AdcSum中。
  4. 重新检查新数据是否成为新的最大/最小值。
  5. 如果被移出的数据恰好是当前的最大值或最小值,则需要遍历当前窗口内所有数据,重新找出最大和最小值
  6. 计算当前窗口的去极值平均值并输出。
// 简化的滑动窗口去极值平均滤波结构体 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 问题排查清单

现象可能原因排查方法与解决方案
滤波输出始终为01.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 实操心得与高级技巧

  1. 初始化的重要性:不仅AdcMin要初始化为最大值,AdcMax应初始化为0,AdcSumAdcCount初始化为0。在系统启动或ADC通道切换后,最好连续进行N次无效采样并丢弃,让滤波器的状态变量被真实数据填充,避免第一个滤波窗口输出错误结果。

  2. 中断安全与可重入性:如果ProcessADC_Sample函数在中断服务程序(ISR)中被调用,而主循环中也会读取AdcVal,那么AdcVal的访问可能存在竞态条件。虽然在这个简单例子中,AdcVal在ISR中只被完整地赋值一次,问题不大。但对于更复杂的滤波器状态变量,如果主循环在读的时候ISR正在写,就可能读到不一致的数据。对于8位或16位MCU,简单变量通常是原子操作,但32位变量在8位机上可能不是。必要时可以使用临界区保护(暂时关闭中断)或标志位同步。

  3. 结合其他滤波方法:“跳水”滤波擅长处理偶发的脉冲野值。对于高频随机噪声,可以将其与“一阶低通滤波(惯性滤波)”结合。例如,将“跳水”算法的输出作为一阶低通滤波器的输入,这样既能抵抗突发干扰,又能对常规噪声进行平滑。公式为:Y(n) = α * X(n) + (1-α) * Y(n-1),其中X(n)是本次“跳水”滤波的输出,Y(n)是最终结果,α是滤波系数。

  4. 调试可视化:在开发阶段,如果条件允许,可以将ADC原始数据、滤波中间变量(如AdcSum,AdcMax,AdcMin)和最终结果AdcVal通过串口发送到上位机,用绘图工具(如Python的Matplotlib)实时绘制曲线。这是调试滤波算法、确定最佳N值和观察噪声特征的终极利器。亲眼看到一个个尖峰被“去掉最高分”的过程,对算法的理解会深刻得多。

  5. 理解算法的局限性:“跳水”滤波本质是一种非线性滤波器(因为剔除极值的操作是非线性的)。这意味着它不能像均值滤波器那样,在频域上有明确的理解。在要求信号相位严格保真或者需要进行频谱分析的场合,需要谨慎使用。它最适合的应用场景是获取稳定的直流或慢变信号的有效幅值,比如电池电压、环境温度、压力传感器读数等。

这个从体育比赛评分规则演化而来的小算法,以其惊人的简洁和高效,在资源紧张的嵌入式世界里闪耀了十多年。它教会我们,好的工程解决方案往往不是最复杂的,而是最贴合问题本质的。下次当你的ADC读数又在“跳舞”时,不妨试试这个“跳水”算法,或许它能给你带来意想不到的稳定。

http://www.jsqmd.com/news/970395/

相关文章:

  • 深度解析Realtek RTW89无线网卡驱动:Linux系统下WiFi 6/7设备完整技术指南
  • 发物流怎么收费?2026最新计费标准全解析 - 快递物流资讯
  • 【毕业设计】SpringBoot+Vue+MySQL 实习管理系统平台源码+数据库+论文+部署文档
  • ModelSim仿真中(vsim-3601)无限循环错误的根源与解决方法
  • 销售总撞单、跟进全靠记忆?中小企业CRM销售管理 5 大痛点的系统化解法
  • 从LED到单片机:硬件焊接与编程实践全解析
  • 2026番禺搬家公司终极评测指南|口碑性价比双维度实测排行+本地避坑全攻略 - gzdjxd
  • 如何实现《塞尔达传说:旷野之息》存档的跨平台迁移:BotW-Save-Manager实用指南
  • 如何在macOS上实现NTFS读写:免费开源工具的终极解决方案
  • 如何在iOS 14-16.6.1上快速安装TrollStore:TrollInstallerX终极指南
  • 从诗词到词元:青年见证传统文化与数字文明的时代交融
  • “照得标”文档页面
  • 嵌入式AI伴侣系统:长期记忆与个性化交互技术解析
  • Python 列表去重竟有这么多坑,你的写法可能一直不对
  • Windows安卓应用安装器:3分钟实现电脑运行安卓应用
  • 091、编队飞行:虚拟结构法
  • 云原生技术07-Ansible vs Terraform:我该用哪个?2026年IaC工具选型指南
  • 终极Burp Suite汉化指南:3分钟实现中文界面零门槛安全测试
  • Docker镜像、容器、仓库超详细讲解(核心原理深度解析)
  • 嵌入式I2C驱动设计:从轮询到中断状态机的实战解析
  • Protel 99 SE元件叠加问题:根源剖析与高效解决指南
  • 峰岹FU6832L双核电机控制芯片实战:从FOC算法到BLDC/PMSM驱动开发
  • 一条慢 SQL 引发的血案,索引优化远比你想象的复杂
  • 092、编队飞行:一致性理论
  • 2026年国内区域优质深山天然饮用水厂家精选榜单 - 企业推荐师
  • 如何5分钟搞定Mac Boot Camp驱动自动化部署:Brigadier终极方案
  • 手把手教你用Docker+Jenkins搭建前端自动化部署流水线
  • 汽车电子潜在路径分析:从航天技术到工程实践的防漏电设计
  • 成都旧房翻新价格多少?2026年报价明细+避坑指南+公司对比 - 优家闲谈
  • P1081 [NOIP 2012 提高组] 开车旅行