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等等”这并非随意列举背后有深刻的工程考量。N3是算法成立的前提因为要去掉一个最高分和一个最低分所以至少需要有3个数据才有“剩余”的数据可以求平均。为什么N3为好当N3时去掉最大和最小值就只剩下一个数据这个数据本身就是“中位数”此时算法退化为“中位值滤波”。虽然也能抗野值但失去了“平均”带来的对随机噪声的平滑能力。当N3时我们是用多个数据的平均值来输出对随机白噪声的抑制效果更好。N的推荐值4,6,10,34...的奥秘这些数字通常与计算效率有关。在嵌入式系统中除法尤其是浮点除法是昂贵的操作。如果N-2的值是2的整数次幂如2,4,8,16,32,64,128...那么最后的除法运算就可以用代价低得多的右移位操作来代替。例如当N10时有效数据量是N-28。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一次完成乘法和除法是高度优化的定点数运算技巧。因此选择N4(剩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. 判断是否达到一个滤波窗口N10 */ 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-10230x3FF。将AdcMin初始化为最大值0x3ff可以确保第一个采样值一定能进入if (AdcResult AdcMin)这个分支从而被正确更新为真实的最小值。如果初始化为0而实际采样值都大于0那么最小值将永远无法被更新。最大值与最小值更新的独立性 代码中特别强调“千万不敢写成else if”。为什么考虑一种边界情况如果当前采样值AdcResult同时是新的最大值和新的最小值在初始化后的第一次采样时必然发生那么如果用了else if当第一个if条件成立后第二个else if就不会被执行导致AdcMin无法被更新。因此必须用两个独立的if语句。累加和AdcSum的溢出问题 这是该算法一个重要的潜在风险点。AdcSum会随着采样次数N和ADC位数的增加而快速增长。例如10位ADC最大值1023。如果N100最坏情况下每次都是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时溢出。定点数运算与移位优化val val * AdcGain; val 13;这是经典的定点数运算。AdcGain是一个缩放因子用于将ADC的数字量转换为实际的物理量如电压毫伏值。为什么先乘后除在整数运算中除法会丢弃余数如果先除可能会损失掉乘法带来的精度提升。例如(255 * 1000) / 256 996而255 * (1000/256) 255*3 765精度损失严重。13是怎么来的这需要根据系统设计来推算。假设ADC分辨率10位 (满量程1024)目标输出单位为mV参考电压Vref5000mV。那么1个ADC字对应的电压是5000mV / 1024 ≈ 4.8828mV/LSB。增益AdcGain可以设置为一个定点数比如AdcGain 5000 * (1K) / 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]; // 窗口缓冲区N10 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个点M1再对剩下的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/采样频率应远小于被控系统的响应时间常数。最小值滤波失效始终为0AdcMin初始化错误。例如10位ADC却初始化为0xFFFF65535则第一个采样值比如500小于它AdcMin被更新为500后续若采样值都大于500则最小值永远停留在500而不是实际的最小值。正确初始化AdcMin为ADC量程的最大值如10位ADC为1023。确保第一个采样值能触发更新。5.2 实操心得与高级技巧初始化的重要性不仅AdcMin要初始化为最大值AdcMax应初始化为0AdcSum和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读数又在“跳舞”时不妨试试这个“跳水”算法或许它能给你带来意想不到的稳定。
嵌入式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等等”这并非随意列举背后有深刻的工程考量。N3是算法成立的前提因为要去掉一个最高分和一个最低分所以至少需要有3个数据才有“剩余”的数据可以求平均。为什么N3为好当N3时去掉最大和最小值就只剩下一个数据这个数据本身就是“中位数”此时算法退化为“中位值滤波”。虽然也能抗野值但失去了“平均”带来的对随机噪声的平滑能力。当N3时我们是用多个数据的平均值来输出对随机白噪声的抑制效果更好。N的推荐值4,6,10,34...的奥秘这些数字通常与计算效率有关。在嵌入式系统中除法尤其是浮点除法是昂贵的操作。如果N-2的值是2的整数次幂如2,4,8,16,32,64,128...那么最后的除法运算就可以用代价低得多的右移位操作来代替。例如当N10时有效数据量是N-28。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一次完成乘法和除法是高度优化的定点数运算技巧。因此选择N4(剩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. 判断是否达到一个滤波窗口N10 */ 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-10230x3FF。将AdcMin初始化为最大值0x3ff可以确保第一个采样值一定能进入if (AdcResult AdcMin)这个分支从而被正确更新为真实的最小值。如果初始化为0而实际采样值都大于0那么最小值将永远无法被更新。最大值与最小值更新的独立性 代码中特别强调“千万不敢写成else if”。为什么考虑一种边界情况如果当前采样值AdcResult同时是新的最大值和新的最小值在初始化后的第一次采样时必然发生那么如果用了else if当第一个if条件成立后第二个else if就不会被执行导致AdcMin无法被更新。因此必须用两个独立的if语句。累加和AdcSum的溢出问题 这是该算法一个重要的潜在风险点。AdcSum会随着采样次数N和ADC位数的增加而快速增长。例如10位ADC最大值1023。如果N100最坏情况下每次都是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时溢出。定点数运算与移位优化val val * AdcGain; val 13;这是经典的定点数运算。AdcGain是一个缩放因子用于将ADC的数字量转换为实际的物理量如电压毫伏值。为什么先乘后除在整数运算中除法会丢弃余数如果先除可能会损失掉乘法带来的精度提升。例如(255 * 1000) / 256 996而255 * (1000/256) 255*3 765精度损失严重。13是怎么来的这需要根据系统设计来推算。假设ADC分辨率10位 (满量程1024)目标输出单位为mV参考电压Vref5000mV。那么1个ADC字对应的电压是5000mV / 1024 ≈ 4.8828mV/LSB。增益AdcGain可以设置为一个定点数比如AdcGain 5000 * (1K) / 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]; // 窗口缓冲区N10 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个点M1再对剩下的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/采样频率应远小于被控系统的响应时间常数。最小值滤波失效始终为0AdcMin初始化错误。例如10位ADC却初始化为0xFFFF65535则第一个采样值比如500小于它AdcMin被更新为500后续若采样值都大于500则最小值永远停留在500而不是实际的最小值。正确初始化AdcMin为ADC量程的最大值如10位ADC为1023。确保第一个采样值能触发更新。5.2 实操心得与高级技巧初始化的重要性不仅AdcMin要初始化为最大值AdcMax应初始化为0AdcSum和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读数又在“跳舞”时不妨试试这个“跳水”算法或许它能给你带来意想不到的稳定。