
简介一套面向嵌入式开发与单片机应用的实用滤波算法代码包针对传感器数据采集中的脉冲干扰、周期性波动、快速变化信号等场景整理了限幅滤波、中位值滤波、算术平均滤波、递推平均、一阶滞后、消抖等10种常用处理方法。每种算法均按方法、优点、缺点给出说明便于开发者结合流量、压力、液位、温度等被测参数的特点快速选型。压缩包共3个文件约198KB含C源程序、Word版算法说明文档及一篇数字滤波方法研究的PDF论文。C代码可直接参考移植到STM32、51等常见MCU工程中Word文档便于离线查阅完整思路PDF论文可辅助理解滤波原理与选型依据。资源已有3000余人学习下载适合刚接触数字滤波的嵌入式初学者也适合在项目中需要快速对比算法特性的工程师可节省整理和测试时间。1. 数字滤波不是套公式这份C代码包解决的是ADC毛刺和传感器噪声有一次调一块温控采集板恒温箱里放着的温度传感器原始ADC读数却在稳态下上下跳了十几个LSB输出曲线看起来和心电图差不多。网上找来的滤波算法大多是片段不是缺数据结构就是没考虑嵌入式环境抄下来根本没法直接用。这份“10种常用的滤波算法(C代码)”把十种经典数字滤波算法整理成了可直接编译的C函数不依赖第三方库扔进工程就能跑。它适合刚接触嵌入式数据采集的开发者照着配参数也适合老工程师直接借用状态结构体和组合策略。真正值钱的不是那几行公式而是每种算法对应的数据结构、边界条件和工程参数取舍。2. 十种滤波算法全景与代码包结构先看清单再动手2.1 十种算法的三个家族去脉冲、压噪声、组合策略拿到代码包先别急着编译先把这十种算法分清楚。它们不是十个平级方案按处理目标可以分成三组去脉冲、压噪声、组合策略。去脉冲组解决偶发尖峰和触点抖动压噪声组解决随机噪声和周期性纹波组合组则把两种需求拼在一起。分组算法适用信号特征关键参数去脉冲限幅滤波偶发尖峰、脉冲噪声最大允许跳变值L去脉冲中位值滤波缓慢变化信号上的离群点窗口N常用5~15去脉冲消抖滤波按键、开关量计数阈值压噪声算术平均高斯噪声、周期性噪声窗口N压噪声递推平均连续采集流要求O(1)更新窗口N建议2的幂压噪声加权递推平均需要强调近期数据权重表压噪声一阶滞后工频纹波、慢变化模拟量滤波系数a组合限幅平均尖峰和白噪声同时存在L、N组合中位值平均缓慢信号加强脉冲加噪声N奇数组合限幅消抖开关量防抖加限幅L、计数阈值代码包的文件结构是典型的嵌入式工程布局静态数组分配内存不用malloc。根目录下是算法源文件test目录里是演示程序和验证数据生成器README里有每种算法的适用场景和参数表。filter/ ├── filter.h # 公共接口与状态结构体定义 ├── filter.c # 十种算法实现 ├── test/ │ ├── main.c # 演示程序生成合成信号并打印滤波前后序列 │ └── gen_data.py # 生成带毛刺和噪声的模拟数据 └── README.md # 每种算法的适用场景与参数表下载后先在PC上编译验证再把filter.c和filter.h拷贝到目标工程里。README里已经写好了每种算法对应的场景但参数必须按你自己的采样周期和信号物理量重新标定后面第4章会详细说怎么换算。2.2 接口设计状态结构体是十个算法的公共骨架滤波算法和普通函数最大的区别在于“有记忆”。中位值滤波要记住窗口内的历史样本递推平均要维护累计和一阶滞后要记住上一次输出。如果用全局变量存这些状态十路采集通道就得复制十份代码所以代码包统一用状态结构体承接记忆每个通道独立实例化一个结构体。/* 一阶滞后滤波器状态 */ typedef struct { int32_t last_y; /* 上一次滤波输出 */ uint8_t shift; /* 定点精度取值8~14 */ uint16_t alpha; /* 滤波系数范围0~(1shift) */ } lowpass_t; /* 滑动平均滤波器状态 */ typedef struct { uint16_t ring[64]; /* 环形缓冲窗口最大64 */ uint8_t head; /* 当前写入位置 */ uint8_t count; /* 已积累的样本数 */ uint32_t sum; /* 窗口内累计值 */ } sliding_avg_t;以这两个结构体为典型其余八个算法基本是它们的变体或组合。结构体实例化放在调用方可以静态分配也可以放进另外的结构体里。启动时清零结构体再调用一次初始化函数把首采值填入last_y避免滤波从0开始爬升。接口上用函数指针封装统一入口是常见的做法。如果采集循环里同时跑八路传感器每路用不同的滤波策略可以定义一个函数指针表让调度代码不关心具体算法。typedef uint16_t (*filter_func_t)(void *state, uint16_t x); typedef struct { filter_func_t func; void *state; } filter_chan_t;这样做的工程价值在于切换滤波策略不用改采集主循环只换函数指针。代价是函数指针调用有少量开销但在几十kHz采样率以下完全无所谓。2.3 编译与数据类型选型ADC采样进uint16_t累加进uint32_t数据类型直接决定这个代码包在MCU上跑不跑得动。ADC采样值常用12位或16位原始值放uint16_t窗口内的累计值上限要按“样本数×满量程”预估。比如窗口64、每个样本最大65535累计上限约419万uint32_t足够。但如果做32位ADC或累加256个样本就要小心边界的累积。一阶滞后的中间乘加用int64_t防止两个16位数值相乘后累加溢出。gcc -stdc99 -O2 -Wall -o test_filter test/main.c filter.c要说明的是这段代码依赖C99标准因为用到了stdint.h和指定初始化器。老旧的单片机工程如果还停留在C89需要先检查编译器是否支持C99。编译命令建议加-Wall让隐式类型转换的警告暴露出来。浮点运算在验证阶段没问题但放到没有FPU的MCU上会拖慢中断响应代码包里的核心算法统一采用定点或整数实现。3. 滑动窗口滤波家族移动平均、中位值与组合窗口的参数权衡3.1 算术平均和递推平均把O(N)降成O(1)的关键是环形缓冲普通算术平均每次滤波都要把窗口内N个样本重新加一遍复杂度O(N)。100kHz采样率下窗口64每秒钟要额外做640万次加法中断里根本撑不住。递推平均的优化思路是维护一个滚动累计值来一个新样本就加进去同时把最旧样本减掉计算量与窗口大小无关。/* 窗口长度必须是2的幂方便位运算取模 */ #define AVG_WINDOW 32 uint16_t sliding_average(sliding_avg_t *st, uint16_t x) { if (st-count AVG_WINDOW) { /* 窗口未满直接累加并记录样本 */ st-sum x; st-ring[st-head] x; st-head (st-head 1) (AVG_WINDOW - 1); st-count; } else { /* 窗口已满减掉最旧的样本加入新样本 */ uint8_t old_pos st-head; st-sum - st-ring[old_pos]; st-sum x; st-ring[old_pos] x; st-head (old_pos 1) (AVG_WINDOW - 1); } return (uint16_t)(st-sum / st-count); }代码里最关键的两行是 (AVG_WINDOW - 1)。这个位运算只在窗口长度为2的幂时才等价于取模如果窗口长度改成了50这里就必须换成if (head 50) head 0;。sum字段放结构体里而不是局部变量是为了让多次调用之间保持累计值这也是O(1)算法能成立的前提。初始化时清零sum和head第一个样本进来走count AVG_WINDOW分支直接累加不会出现除以0的情况。递推平均适合采样率稳定、中断频繁的场景。但要注意窗口时间跨度带来的延迟以一个采样点进入、经过半个窗口后才能影响输出窗口越大信号突变看起来越像斜坡。后面第6章会给一个缓解方案。3.2 中位值滤波排序窗口的临时区拷贝是防止缓冲区错乱的底线中位值滤波的思路简单取窗口内所有样本排序输出中间值。它对脉冲毛刺的抑制效果比平均滤波好得多一个极大的离群点不会污染输出代价是排序需要额外内存和计算时间。窗口小的时候插入排序比快排更合适因为几乎有序的数据插入排序接近O(N)。typedef struct { uint16_t buf[16]; /* 窗口数据FIFO顺序 */ uint8_t count; /* 当前样本数 */ } median_filter_t; uint16_t median_filter(median_filter_t *st, uint16_t x) { uint16_t temp[16]; /* 先复制不破坏原FIFO数据 */ memcpy(temp, st-buf, st-count * sizeof(uint16_t)); if (st-count 16) { temp[st-count] x; } else { memmove(temp, temp 1, 15 * sizeof(uint16_t)); temp[15] x; } /* 插入排序16个样本以内排序开销很低 */ for (uint8_t i 1; i st-count; i) { uint16_t key temp[i]; uint8_t j i; while (j 0 temp[j - 1] key) { temp[j] temp[j - 1]; j--; } temp[j] key; } return temp[st-count / 2]; }这里最容易翻车的地方是把排序直接作用在st-buf上。buf里的数据代表时间顺序下一次滤波时窗口要剔除的“最旧样本”是buf[0]如果排过序下一次就找不到真正的最旧样本了输出会出现随机毛刺。所以代码里先用memcpy拷贝到临时数组排序在临时区完成buf保持FIFO原序代价是每次滤波多一次16字节的内存拷贝在MCU上完全可以接受。窗口长度的选择要和信号变化率匹配。温度、压力这类缓变信号窗口15左右能有效削掉脉冲且不产生明显相位滞后振动信号如果窗口开太大峰值会被削平后续特征提取就没意义了。3.3 中位值平均滤波先剔除最大最小再平均工业采集的常青树中位值平均滤波是中位值和算术平均的组合先排序去掉一个最大值和一个最小值剩下的样本做算术平均。它对“脉冲噪声高斯噪声”混合的场景非常实用既不像纯中位值那样丢失信号能量也不像纯平均那样被一个尖峰带偏。这类组合算法在工业现场用得最多振动烈度监测、称重变送器里经常能看到它的变体。uint16_t median_average(median_filter_t *st, uint16_t x) { uint16_t tmp[16]; uint16_t min_v, max_v; uint32_t sum 0; uint8_t i; memcpy(tmp, st-buf, st-count * sizeof(uint16_t)); if (st-count 16) { tmp[st-count] x; } else { memmove(tmp, tmp 1, 15 * sizeof(uint16_t)); tmp[15] x; } min_v tmp[0]; max_v tmp[0]; for (i 0; i st-count; i) { if (tmp[i] min_v) min_v tmp[i]; if (tmp[i] max_v) max_v tmp[i]; sum tmp[i]; } sum - min_v max_v; /* 剔除一个最大一个最小 */ return (uint16_t)(sum / (st-count - 2)); }注意它和中位值滤波的区别中位值输出的是排序后正中间那一个点而中位值平均输出的是剔除极值后的均值保留了更多信号能量。窗口长度必须是奇数而且至少要大于5否则剔除两个极值后剩余样本太少平均结果波动大。实际使用中窗口取15即一次滤波要处理15个样本计算量和内存都可控。对16位ADC数据sum累加最多15×65535≈98万uint32_t安全。4. 一阶滞后与限幅滤波低通、防突变与组合拳的工程设定4.1 一阶低通滤波的定点实现alpha 的物理意义是截止频率一阶滞后滤波的公式是Y[n] a·X[n] (1-a)·Y[n-1]在所有十种算法里它是最平滑的一种也是坑最多的一种。浮点实现直接照抄这个公式MCU没有FPU的时候执行时间可能膨胀五倍以上中断周期稍紧就会溢出。代码包里的定点版本把这个公式改成了整数运算。typedef struct { int32_t last_y; /* 上次输出需初始化为首个采样值 */ uint8_t shift; /* 定点精度8~14 */ uint16_t alpha; /* 系数范围[0, 1shift) */ } low_pass_t; int16_t low_pass_update(low_pass_t *f, uint16_t x) { int64_t y; y (int64_t)f-alpha * x; y (int64_t)((1u f-shift) - f-alpha) * f-last_y; y f-shift; f-last_y (int32_t)y; return (int16_t)y; }alpha的换算是整套代码里最需要耐心调试的地方。浮点a0.1、定点精度shift10时alpha换算成整数取0.1×1024≈102量化误差会带来一个小幅直流偏置。中间乘法用int64_t是因为alpha×(1shift)×last_y在特定组合下会接近百万量级虽然int32也能装下但一旦采样值扩到24位就危险了一律用int64_t最稳妥。alpha不对应某个直观的“力度”它对应截止频率。采样频率fs下一阶低通的截止频率近似为fc ≈ a·fs/(2π)。比如采样率1kHz、a0.1截止频率约16Hz想滤掉50Hz工频纹波同样采样率下a要压到0.03左右。下面的换算表可以省去反复试参的功夫。浮点ashift10定点值等效截止频率fs1kHz0.03314.8Hz0.05518.0Hz0.1010215.9Hz0.2020531.8Hz初始化时把last_y设为首采值而不是0。如果按0启动前几十个采样点会看到一条从0爬向真实值的斜坡这在仪表开机阶段很容易被误判为真实跳变。4.2 限幅滤波阈值按物理最大变化率换算不做玄学拍脑袋限幅滤波的逻辑很直白当前值跟上一次输出之差超过阈值就丢弃不超过就接受。它专门对付偶发尖峰对缓慢漂移和高频噪声无计可施。阈值L是唯一参数很多人把L当成“噪声幅度”来拍结果要么滤不住脉冲要么把真实变化也削成了平头。typedef struct { uint16_t last; uint16_t threshold; /* 最大允许跳变量单位是ADC LSB */ } limit_filter_t; uint16_t limit_filter_update(limit_filter_t *f, uint16_t x) { uint16_t delta; if (x f-last) delta x - f-last; else delta f-last - x; if (delta f-threshold) return f-last; /* 判定为脉冲丢弃 */ f-last x; return x; }阈值应该从物理量的最大变化率换算。测温度传感器最大升温速率是1℃/s采样周期100ms即一个采样周期最多变0.1℃12位ADC满量程对应100℃每个LSB约0.0244℃阈值取0.1/0.0244≈4 LSB。把这个换成工程写法L ceil(max_slew_rate / ADC_scale * sample_period)把阈值写死成常数是个常见错误。同一个温度模块采样率从100Hz改到1kHz每周期允许变化量缩小十倍阈值不跟着改真实升温过程会被限幅切成台阶状。4.3 限幅一阶滞后串行组合先拦脉冲、再压噪声单独的限幅滤波器输出带有明显的高频噪声单独的一阶滞后会把脉冲污染拖成持续几个周期的隆起。把两者串起来是工程上最常用的组合策略先限幅剔除脉冲再低通压噪声。这个组合覆盖了大多数工业模拟量采集场景。typedef struct { limit_filter_t lim; low_pass_t lp; } lim_lowpass_t; uint16_t lim_lowpass_update(lim_lowpass_t *f, uint16_t x) { uint16_t x1 limit_filter_update(f-lim, x); return (uint16_t)low_pass_update(f-lp, x1); }顺序不能反。如果先低通后限幅脉冲经过一阶滞后会保留能量限幅看到的是一个被拖宽的“假台阶”阈值再小也拦不住。串行组合的参数标定方法是先用纯限幅去掉尖峰观察静态噪声峰峰值再将一阶滞后的alpha设到对应该噪声衰减需要的截止频率。整个过程有点像先把大石子捡掉再筛细沙。5. 滤波算法避坑与排查窗口缓冲、整数除法和阶跃滞后的五条现场记录这一章的内容来自实际调试中反复出现的问题每一条都是“现象→原因→解决”的结构。代码包能编译能跑只是第一步真正麻烦的是参数和内存上的这些隐蔽问题。5.1 现象中位值滤波输出偶发跳变窗口越大越严重现象是滤波输出在稳态下偶尔冒出一个偏离真实值很远的点而且加大窗口长度后跳变频率反而变高。原因是实现时直接把排序作用在了状态结构体的数据缓冲区上buf里保存的时间顺序被排序破坏了。下一次滤波把排过序的“中间值”当成最旧样本剔除窗口内的数据同一时间可能混了几个不同时刻的样本输出自然乱跳。解决方法是排序前先拷贝到临时数组保持缓冲区时间顺序不被破坏。代码包里默认就是这么做的如果自己重写务必把memcpy保住不要为了省那几十个字节的内存去掉。5.2 现象滑动平均输出长期为0只有阶跃瞬间有值现象出现在窗口较大、输入信号幅度较小的时候比如12位ADC在低量程段工作滑动平均结果恒为0。原因是整数除法截断sum/count中sum小于count时商直接截成0。稳态信号本身幅度只有几个LSB窗口64时sum约几百count64算完还能剩几个LSB如果信号幅度1~2LSB平均结果就归零了。解决方法是输出前做四舍五入(sum count/2) / count。但对极低幅度信号更要检查的是窗口长度是不是开得过大。窗口时间跨度超过信号本身的变化周期平均就是在抹平有效信息不是噪声。5.3 现象一阶滞后放在中断里中断周期从100us膨胀到200us现象是系统原本100us的中断周期在加入一阶滞后滤波后整整翻倍CPU空闲时间骤降。原因是浮点运算在无FPU的MCU上由软浮点库处理一个浮点乘加可能消耗几十个甚至上百个周期中断上下文里反复调用自然拖垮实时性。解决方法是改用定点实现同时把滤波从中断搬到主循环。中断里只做采样和置标志位主循环检测到标志后把新采样值推进滤波器。这样做还有一个额外好处滤波器的状态结构体不会被中断和主循环同时访问省去了加锁的麻烦。5.4 现象大窗口把真实突变当成噪声阶跃响应变成斜坡现象是输入一个阶跃跳变输出却拉成了一条斜线达到目标值要等半个窗口长度。原因是递推平均的窗口太长窗口64意味着每个采样点要经过32个周期才能在平均值里占主导地位阶跃看起来自然成了斜坡。滤波器对真实变化和脉冲噪声在响应上的区别是滤波器只认幅度不认语义大窗口天然把快速变化抑制掉。解决方法是并联一个快窗口两个窗口输出差值超过阈值时直接启用快窗口结果。这就是第6章双窗口联动的基础思路代码包test目录里的演示程序也包含了这组逻辑。参数上快窗口8慢窗口64阈值按静态噪声峰峰值标定。5.5 现象多路采集通道串扰A通道的脉冲跑到B通道输出里现象是两路传感器共用一个滤波函数A通道来了一个尖峰B通道的滤波输出也出现了毛刺。原因是全局只定义了一个状态结构体A通道的数据把结构体里的历史值更新掉了B通道下一拍进来时看到的是A通道的残留数据。解决方法是给每一路采集通道分配独立状态结构体实例用数组或直接命名区分。初始化时用首采值填充历史状态而不是用0初始化后等待爬升。这个错误在代码量大的工程里特别隐蔽因为滤波函数本身没有任何并发保护。6. 双窗口联动滤波现场调参技巧和验证用例6.1 快速响应和长期平滑的切换逻辑递推平均窗口越大越平滑但越平滑越迟钝。要同时保住稳态精度和阶跃响应可以同时跑快慢两个窗口正常输出慢窗口检测到两者差值超过阈值就切入快窗口。typedef struct { sliding_avg_t fast; /* 快窗口建议8~16 */ sliding_avg_t slow; /* 慢窗口建议48~64 */ uint16_t threshold; /* 切换阈值单位LSB */ } dual_window_t; uint16_t dual_window_update(dual_window_t *f, uint16_t x) { uint16_t fast sliding_average(f-fast, x); uint16_t slow sliding_average(f-slow, x); if (fast slow) { if (fast - slow f-threshold) return fast; } else { if (slow - fast f-threshold) return slow; } return slow; }注意两边比较差值时要先判断大小关系再相减无符号数直接做减法会借位得到的结果会是一个巨大的假差值。切换阈值建议取静态噪声峰峰值的一半。比如稳定状态下原始噪声是±3LSB阈值取6如果阈值取到2正常噪声就会频繁触发切换输出反而出现小台阶。6.2 验证手法阶跃用例和零漂用例调完参数先别急着上产线先跑两组用例。第一组是阶跃测试给传感器输入端一个物理阶跃记录滤波输出从开始变化到稳定在最终值63%处的时间这就是实际延迟。第二组是零漂测试保持输入恒定连续记录五分钟滤波输出看峰峰值有没有压到预期范围内。资源包test目录里的数据生成脚本大致长这样可以生成合成信号验证算法行为import numpy as np # 生成2000点合成数据基线5000 阶跃 脉冲噪声 高斯噪声 rng np.random.default_rng(0) x np.full(2000, 5000, dtypenp.uint16) x[800:1200] 200 # 阶跃段 x[400] 800 # 脉冲噪声 x rng.normal(0, 15, 2000).astype(np.int16).clip(0, 65535) np.savetxt(sim_signal.csv, x, fmt%d)把生成的数据喂进test/main.c里的滤波循环对比滤波前后的数组阶跃段看响应时间稳态段看噪声峰峰值脉冲点看抑制幅度。这两组用例我每次调完参数都会强制跑一遍只有两者都达标才敢说这组参数能上线。以前在某项目上把双窗口的切换阈值调得过小结果轻微噪声就让两个窗口来回“打架”输出锯齿状跳动。后来按静态噪声的3倍标准差重新标定阈值才稳定下来。从那以后我每次调完滤波参数都强制自己走一遍阶跃和零漂两组用例再发布省下的返工时间远超写脚本的时间。希望这个习惯和这组代码能帮到你。本文还有配套的精品资源点击获取