简介:一套面向嵌入式开发与单片机应用的实用滤波算法代码包,针对传感器数据采集中的脉冲干扰、周期性波动、快速变化信号等场景,整理了限幅滤波、中位值滤波、算术平均滤波、递推平均、一阶滞后、消抖等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~(1<<shift) */ } 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 -std=c99 -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, 1<<shift) */ } 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的换算是整套代码里最需要耐心调试的地方。浮点a=0.1、定点精度shift=10时,alpha换算成整数取0.1×1024≈102,量化误差会带来一个小幅直流偏置。中间乘法用int64_t是因为alpha×(1<<shift)×last_y在特定组合下会接近百万量级,虽然int32也能装下,但一旦采样值扩到24位就危险了,一律用int64_t最稳妥。
alpha不对应某个直观的“力度”,它对应截止频率。采样频率fs下,一阶低通的截止频率近似为fc ≈ a·fs/(2π)。比如采样率1kHz、a=0.1,截止频率约16Hz;想滤掉50Hz工频纹波,同样采样率下a要压到0.03左右。下面的换算表可以省去反复试参的功夫。
| 浮点a | shift=10定点值 | 等效截止频率@fs=1kHz |
|---|---|---|
| 0.03 | 31 | 4.8Hz |
| 0.05 | 51 | 8.0Hz |
| 0.10 | 102 | 15.9Hz |
| 0.20 | 205 | 31.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约几百,count=64,算完还能剩几个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, dtype=np.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倍标准差重新标定阈值,才稳定下来。从那以后我每次调完滤波参数,都强制自己走一遍阶跃和零漂两组用例再发布,省下的返工时间远超写脚本的时间。希望这个习惯和这组代码能帮到你。
本文还有配套的精品资源,点击获取