简介:面向单片机与嵌入式开发者,这套C语言十大滤波算法代码围绕STM32 ADC采样场景,覆盖移动平均、滑动平均、中值、指数平均、巴特沃斯、卡尔曼、自适应、维纳、窗函数及傅里叶变换滤波,用于抑制传感器与模拟量采集中常见的随机噪声、脉冲干扰和动态波动,帮助提高信号质量与系统稳定性。压缩包共82个文件,以35个C源文件与37个头文件为主,另有启动文件、Keil工程配置、hex固件、txt说明等,整体仅206KB,结构清晰,便于直接查看算法实现或参考工程集成。资源基于STM32F10x标准库构建,包含ADC、USART、LCD等硬件驱动模块与README说明,可结合示例观察不同滤波算法在实际采样数据上的处理效果,也方便按需裁剪到自己的工程。已有1984人学习下载,适合正在做STM32数据采集、ADC底层开发或需要快速对比滤波效果的工程师参考。
1. 为什么ADC采样数据要过一遍C语言滤波算法
ADC采集到的12位数据常态下就在几个LSB之间跳动,这不是故障,而是参考电压纹波、采样电容电荷注入、电源噪声和走线耦合共同作用的结果。模拟前端加RC低通或EMI滤波电路能压掉一部分高频干扰,但压不掉工频纹波和传感器本身的白噪声,而且每一路都加硬件成本。C语言十大滤波算法就是这个分工里软件端的那一半:在MCU内部用一段可预测的代码,把“有噪声的ADC采样值”还原成“更接近物理量的估计值”。它适合STC、STM32、51这类资源受限的MCU工程,特别适合ADC采样率不高、又要做实时控制的场景。下面给出一套能直接落地的STM32滤波方案,十个算法全部用纯C实现,重点讲清参数为什么这么设、不同场景怎么选,避免把滤波做成抄一段代码就完事。
2. 十大滤波算法按干扰模型分组与统一C语言接口
2.1 十大滤波算法按干扰模型分三组
没有全能的滤波算法。十大滤波算法流传广,是因为它们覆盖了ADC数据里最常见的三类干扰:脉冲毛刺、白噪声和缓慢漂移。按干扰模型分,十个算法可以归为三组:去毛刺、平滑噪声、缓变跟随。
| 分组 | 算法 | 处理思路 | 典型代价 |
|---|---|---|---|
| 去毛刺 | 限幅滤波、中值滤波、中位值平均滤波、限幅平均滤波、限幅消抖滤波 | 用变化率或排序剔除异常点 | 窗口排序滞后或阈值误判 |
| 平滑噪声 | 算术平均滤波、递推平均滤波、加权递推平均滤波 | 多采样点取均值,压低噪声方差 | 平滑越强响应越慢 |
| 缓变跟随 | 一阶滞后滤波、消抖滤波 | 用历史输出修正当前值 | 相位滞后或稳态死区 |
十个算法之间没有谁比谁强的线性关系。做项目时最常见的坑是抱着一个递推平均走天下:遇到脉冲干扰被毛刺带偏,遇到阶跃信号横竖追不上。工程上的标准做法是组合使用,限幅先掐掉粗大误差,递推平均再平滑细粒度噪声。
2.2 为滤波算法设计统一C语言接口
十种算法写起来都不难,难在切换。上电想对比两种算法效果时,如果每个算法暴露的调用参数不一样,就得反复改调用点。常见做法是给滤波器做一个统一C接口,每个算法只暴露一个入口函数和一份状态结构体:
/* flt_common.h */ #include <stdint.h> typedef struct flt_obj flt_obj_t; /* 前向声明 */ typedef struct { int16_t (*proc)(flt_obj_t *obj, int16_t raw); /* 处理一个采样点 */ } flt_ops_t; struct flt_obj { flt_ops_t ops; /* 算法入口 */ void *state; /* 各算法私有状态 */ void *cfg; /* 各算法私有参数 */ }; static inline int16_t flt_run(flt_obj_t *o, int16_t raw) { return o->ops.proc(o, raw); }接口约定是每次传入一个ADC原始值,返回一个滤波值,调用方不关心内部是排序还是环形缓冲。这样采样回调里写v = flt_run(&flt, adc_val);,切换算法时只改初始化函数,业务代码一行不动。参数类型统一用int16_t,12位ADC最大4095能覆盖;如果换成24位外部ADC,把类型改成int32_t即可,排序和平均逻辑完全不变。这个设计的隐藏收益是所有算法都可被静态分配,不在堆上申请内存,适合裸机工程。
2.3 ADC采样周期与滤波窗口的关系
动手写代码前,先定ADC采样周期。滤波窗口N指的是N个采样点,不是N秒;一阶滞后滤波的时间常数τ与系数α的关系为 τ ≈ T·(1−α)/α,T是采样周期;递推平均窗口的等效时间滞后约为(N+1)/2个采样周期。提高ADC采样率并不能绕过滤波滞后,只会让滞后在时间维度上变短。例如ADC从1kHz提高到2kHz,同样的窗口N=8时,滞后从4ms降为2ms,代价是DMA搬运和CPU平均处理次数翻倍。
提示:在STM32上配置ADC采样周期时,把采样时间和窗口长度放在同一张表里标注,避免换采样率后忘了同步改滤波参数。
3. 五个基础ADC滤波算法的C语言实现与参数标定
下面的实现顺序按推荐给ADC数据使用的优先级排列,同时也按从简单到复杂排列。每个算法给最小可用实现、参数标定方法和适用边界。
3.1 限幅滤波:阈值取相邻最大差值的1/3
/* limit.c */ typedef struct { int16_t last; /* 上一点输出 */ int16_t max_delta; /* 相邻两点最大允许偏差 */ } limit_state_t; int16_t flt_limit_proc(flt_obj_t *o, int16_t in) { limit_state_t *s = (limit_state_t *)o->state; int16_t d = in - s->last; if (d > s->max_delta || d < -s->max_delta) { return s->last; /* 超限,丢点 */ } s->last = in; return in; }实现上就是一次差值和一次比较,没有任何数组,几条指令完成。第一次调用前把last初始化为第一次采样值。max_delta怎么定是关键:如果信号是温度、液位这类变化缓慢的量,可以把10到20个采样周期内能发生的最大物理变化换算成ADC码值;如果是电机电流这类快速变化量,阈值取太小会导致阶跃时输出长时间停在旧值上。工程经验是先把信号源快速推到目标状态,记下相邻两次ADC采样的最大差值,取该差值的三分之一作为阈值。
3.2 中值滤波:窗口N取奇数,MCU上N=5最划算
/* median.c */ #define MED_WIN 5 typedef struct { int16_t buf[MED_WIN]; uint8_t idx; } median_state_t; int16_t flt_median_proc(flt_obj_t *o, int16_t in) { median_state_t *s = (median_state_t *)o->state; int16_t t[MED_WIN]; int i, j; s->buf[s->idx] = in; s->idx = (s->idx + 1) % MED_WIN; memcpy(t, s->buf, sizeof(t)); for (i = 0; i < MED_WIN - 1; i++) { /* 冒泡排一次 */ for (j = 0; j < MED_WIN - 1 - i; j++) { if (t[j] > t[j + 1]) { int16_t tmp = t[j]; t[j] = t[j + 1]; t[j + 1] = tmp; } } } return t[MED_WIN / 2]; /* 返回中间值 */ }中值滤波的窗口必须为奇数,否则中间位置存在二义性。窗口越大抗脉冲能力越强,但响应滞后同步加大,输出滞后约为(N−1)/2个采样点,N=5时滞后2拍。对STM32这类MCU,N=5是常用选择,冒泡排序5个元素最坏情况下只需比较10次,几微秒结束,不会影响1kHz到10kHz的ADC中断节奏。如果信号里周期性毛刺非常多,再上调到N=7。
3.3 算术平均滤波:攒齐一个窗口再输出
#define AVG_WIN 8 typedef struct { int16_t buf[AVG_WIN]; int32_t acc; uint8_t cnt; int16_t last_out; } avg_state_t; int16_t flt_avg_proc(flt_obj_t *o, int16_t in) { avg_state_t *s = (avg_state_t *)o->state; s->acc += in; if (++s->cnt >= AVG_WIN) { s->last_out = (int16_t)(s->acc / AVG_WIN); s->acc = 0; s->cnt = 0; } return s->last_out; /* 窗口攒满之前维持上一次输出 */ }算术平均的本质是每N个采样周期只输出一次,输出率被降为采样率的1/N。如果业务侧要求每个ADC采样周期都出数据,算术平均就不合适,这正是递推平均存在的理由。累加器用int32_t,8个4095累加是32760,不会溢出,但窗口到64时最好换成int64_t或分段求和。对噪声以白噪声为主的场景,窗口N每翻倍,噪声标准差降为原来的 1/√2。初始化时同样需要预热:前N个周期输出0会误导控制逻辑,通常让last_out直接等于第一个样本。
3.4 递推平均滤波(滑动窗口滤波):每个采样点都输出
#define SLIDE_WIN 8 typedef struct { int16_t buf[SLIDE_WIN]; uint8_t pos; int32_t sum; } slide_state_t; int16_t flt_slide_proc(flt_obj_t *o, int16_t in) { slide_state_t *s = (slide_state_t *)o->state; s->sum -= s->buf[s->pos]; /* 减掉最旧样本 */ s->buf[s->pos] = in; /* 写入最新样本 */ s->sum += in; s->pos = (s->pos + 1) % SLIDE_WIN; return (int16_t)(s->sum / SLIDE_WIN); }递推平均也叫滑动窗口滤波、滑动平均。它维护一个环形缓冲,每来一个样本就踢掉最旧的一个,输出与输入同步率为1:1,实时性明显好于算术平均。初次启动时buf全为0,会导致头N个输出偏低,所以初始化阶段要把第一个样本灌满整个窗口,或者直接丢弃前N个输出。窗口宽度是平滑度和滞后的折中,STM32上做1kHz ADC采样,窗口取8或16;上位机要求波形平滑度更高时可以取32,但阶跃响应会更钝。
3.5 中位值平均滤波:先掐尖再平均
#define REM_WIN 5 /* 窗口至少为3的倍数+2 */ typedef struct { int16_t buf[REM_WIN]; uint8_t idx; } rem_state_t; int16_t flt_rem_proc(flt_obj_t *o, int16_t in) { rem_state_t *s = (rem_state_t *)o->state; int16_t t[REM_WIN]; int32_t sum = 0; int i, j; s->buf[s->idx] = in; s->idx = (s->idx + 1) % REM_WIN; memcpy(t, s->buf, sizeof(t)); for (i = 0; i < REM_WIN - 1; i++) for (j = 0; j < REM_WIN - 1 - i; j++) if (t[j] > t[j + 1]) { int16_t x = t[j]; t[j] = t[j + 1]; t[j + 1] = x; } for (i = 1; i < REM_WIN - 1; i++) /* 跳过t[0]最小和t[WIN-1]最大 */ sum += t[i]; return (int16_t)(sum / (REM_WIN - 2)); }中位值平均滤波也叫防脉冲干扰平均滤波,思路是先对窗口内排序,删掉一个最大和一个最小,再对中间样本做算术平均。它能同时对付脉冲毛刺和随机噪声:毛刺落在最值位置被删掉,随机噪声被平均压低。N越大能抵抗的毛刺数量越多,但排序耗时变长,且最大/最小两个位置最多挡住两处离群点。窗口N=5时平均用3个点,N=7时平均5个点,建议从5起步。
3.6 五个基础算法的选型对照
上面五个基础算法分别解决:限幅处理异常跳变、中值处理脉冲、算术平均压低白噪声、递推平均实时平滑、中位值平均兼顾脉冲加噪声。实际项目里大多数ADC通道用限幅加递推平均组合就够了,剩下的是给特定信号准备的。
| 场景 | 推荐算法 | 说明 |
|---|---|---|
| 温度/NTC热敏电阻 | 一阶滞后 或 递推平均N=16 | 信号缓慢,滞后影响小 |
| 电机电流/力传感器 | 限幅 + 递推平均N=8 | 快速变化中压毛刺 |
| 按键/开关量 | 消抖滤波 | 对连续模拟量不适用 |
4. 五个进阶滤波算法的C语言写法与参数取舍
后五个算法大多是前五位的组合或变种,代码量小但参数意义深,越是老工程师越会在α系数和加权数组上较真。
4.1 限幅平均滤波:两级组合的代码结构
限幅平均就是先限幅、再进递推平均。它的价值在于递推平均会把毛刺平均进结果里:一个巨大的毛刺被N−1个正常样本稀释后,会留下一个小凸起。先限幅把这个凸起拦在门外,递推平均再去处理小扰动:
#define LA_LIMIT 120 #define LA_WIN 8 static int16_t la_last; static int16_t la_buf[LA_WIN]; static uint8_t la_pos; static int32_t la_sum; int16_t flt_lim_avg_proc(int16_t in) { int16_t d, v; d = in - la_last; if (d > LA_LIMIT || d < -LA_LIMIT) { /* 第一级:限幅 */ v = la_last; } else { la_last = in; v = in; } la_sum -= la_buf[la_pos]; /* 第二级:滑动窗口平均 */ la_buf[la_pos] = v; la_sum += v; la_pos = (la_pos + 1) % LA_WIN; return (int16_t)(la_sum / LA_WIN); }两个参数的标定顺序是:先标限幅阈值,用上一章说的1/3经验法;再标窗口N。如果限幅阈值偏小,阶跃响应会出现阶梯状爬升;反之毛刺拦不住。出现阶梯时先加大阈值,而不是缩小窗口。这个组合非常适合STM32做电机电流采样,既有快速变化又有斩波毛刺。
4.2 一阶滞后滤波(一阶低通滤波):定点化α系数与截止频率
typedef struct { int16_t y; /* 上一拍输出 */ uint8_t alpha; /* 0~255,表示alpha/256 */ } lag_state_t; int16_t flt_lag_proc(flt_obj_t *o, int16_t in) { lag_state_t *s = (lag_state_t *)o->state; int32_t v = ((int32_t)s->alpha * in + (256 - s->alpha) * s->y) >> 8; s->y = (int16_t)v; return s->y; }这里把浮点系数α映射成0到255的定点数,避免在MCU上跑浮点。α的物理意义是本次采样值的信任度:α=1时直通不过滤,α→0时输出几乎不变化。它与采样周期T和滤波时间常数τ的关系为 τ ≈ T·(1−α)/α,反解出 α ≈ T/(T+τ)。例如采样周期1ms,想要τ=100ms,则 α≈1/101≈0.01,映射到定点就是3左右。注意α过小时输出非常钝,PID环路上慎用,容易把相位裕度吃光。如果被滤对象有明显可预测的运动模型,一阶滞后不够用,卡尔曼滤波是更完整的替代方案,本质是把预测值和新息按方差动态分配权重。
4.3 加权递推平均滤波:旧样本按距离打折
递推平均的弱点是窗口内老样本和新样本权重相同,信号连续上升时输出滞后明显。加权递推平均给越新的样本分配越大权重,改善滞后:
#define WWIN 8 /* 窗口长度和权重数组一一对应 */ static const uint8_t wtab[WWIN] = {1, 2, 3, 4, 5, 6, 7, 8}; static const uint16_t wsum = 36; /* 1+2+...+8 */ static int16_t wbuf[WWIN]; static uint8_t wpos; int16_t flt_wslide_proc(int16_t in) { int32_t acc = 0; int i, idx; wbuf[wpos] = in; wpos = (wpos + 1) % WWIN; for (i = 0; i < WWIN; i++) { idx = (wpos + i) % WWIN; /* 从最旧到最新 */ acc += (int32_t)wbuf[idx] * wtab[i]; } return (int16_t)(acc / wsum); }权重数组按越旧越小排列,加权后的输出对新数据倾斜。代价是每次输出都要循环N次做乘法,8个点的乘法在STM32上开销可忽略。权重分配没有统一标准,线性递增数组是最常用的;想更钝就把尾部权重加大,想更跟手就把权重集中到最新2到3个点。总权重不能超过int16范围,8点线性数组最大值为36×4095,落在安全区间。同样的,环形缓冲初始为0,需要预热窗口。
4.4 消抖滤波:连续N次一致才认可
#define DEBOUNCE_N 8 typedef struct { uint8_t first; uint8_t cnt; int16_t last; } debounce_state_t; int16_t flt_debounce_proc(flt_obj_t *o, int16_t in) { debounce_state_t *s = (debounce_state_t *)o->state; if (s->first) { s->last = in; /* 首拍直接用真实值兜底 */ s->first = 0; return in; } if (in == s->last) { if (s->cnt < DEBOUNCE_N) s->cnt++; } else { s->cnt = 0; } if (s->cnt >= DEBOUNCE_N) s->last = in; return s->last; }消抖滤波的思路是连续N次采样值完全相同才承认稳定,用来处理按键电平、光电开关、限位信号这类稳态加跳变的输入非常合适,N=8对应8个采样周期约8到16ms的确认时间。但对连续模拟量,它几乎没有平滑能力,ADC噪声导致相邻值总在变,cnt永远清零,输出就是原始值。把它用到模拟量上是常见误用,这组代码里特意加了first标志,避免首采值是满量程时消抖逻辑卡死。
4.5 限幅消抖滤波:阈值加计数的双判据
最后一个是限幅消抖,本质是限幅和消抖叠加:
typedef struct { int16_t last; int16_t max_delta; uint8_t cnt; uint8_t first; } ld_state_t; int16_t flt_lim_debounce_proc(flt_obj_t *o, int16_t in) { ld_state_t *s = (ld_state_t *)o->state; int16_t d; if (s->first) { s->last = in; s->first = 0; return in; } d = in - s->last; if (d > s->max_delta || d < -s->max_delta) { s->cnt = 0; /* 跳变太大,不承认 */ return s->last; } if (in == s->last) { if (s->cnt < DEBOUNCE_N) s->cnt++; } else { s->cnt = 0; } if (s->cnt >= DEBOUNCE_N) s->last = in; return s->last; }与消抖滤波不同的是多了一道限幅门,超过max_delta的瞬间变化直接丢弃,不进消抖计数,因此能对付单个大幅毛刺加缓慢漂移的叠加信号。代价是参数有两个,阈值和计数N。标定时先固定N=8,用信号源注入幅值为阈值1.2倍的阶跃,观察输出是否出现半个周期的伪响应。到这里十种算法的实现与参数讨论结束,真实项目不会全上,多数是选2到3种做组合。
5. STM32里接ADC滤波:DMA配置、调用时机与选型
十大滤波算法本身和芯片厂商无关,51、STM32、K210都能用,但要做成稳定的ADC采样链路,需要处理采样触发、数据搬运和滤波调用时机三个问题。
5.1 定时器触发ADC+DMA的STM32最小配置
ADC连续采样在STM32里最省CPU的接法是:定时器TRGO事件触发ADC启动,ADC完成转换后自动DMA搬运到内存,CPU只关心DMA传输完成标志。CubeMX里三处配置:
- TIM3在update事件上输出TRGO,周期设为采样周期,1kHz就是1ms
- ADC1开启Regular conversion,触发源选Timer 3 Trigger Out
- DMA设为Circular循环模式,数据宽度Half Word,内存地址指向uint16_t数组
初始化完成后CPU主循环不参与单点采样,只有DMA回调和滤波函数在跑:
#define ADC_BUF_LEN 64 static uint16_t adc_buf[ADC_BUF_LEN]; static volatile uint8_t frame_flag; void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { frame_flag = 1; /* 通知主循环:一帧数据已就绪 */ } }5.2 滤波调用时机:中断做轻量、主循环做重活
DMA循环模式会自动填满数组,无需每条数据都触发CPU中断。可以在中断里做一次限幅,防止毛刺污染缓冲区;到主循环里再做平均,不要在DMA回调里做排序,排序耗时不确定,放在主循环反而安全:
int main(void) { while (1) { if (frame_flag) { frame_flag = 0; for (int i = 0; i < ADC_BUF_LEN; i++) { adc_buf[i] = flt_limit(adc_buf[i]); /* 第一级:掐毛刺 */ } int32_t acc = 0; for (int i = 0; i < ADC_BUF_LEN; i++) { acc += adc_buf[i]; /* 第二级:整帧平均 */ } control_value = (int16_t)(acc / ADC_BUF_LEN); } } }用frame_flag做握手,保证DMA写数组时主循环不会读同一位置。如果DMA长度超过一帧,可以考虑双缓冲Ping-Pong配合DMA半满中断,哪半边写完就处理哪半边。这个结构在稳定性和实时性上远好于在定时器中断里逐点滤波,是ADC加滤波链路的常用框架。
5.3 十大滤波算法在STM32上的选型对照表
下表给出一套可直接抄的选型思路,按信号类型到算法组合到参数组织。参考采样周期在1ms量级时可以照抄,采样周期差异大时按2.3节的公式换算。
| 信号类型 | 典型应用 | 算法组合 | 参数 |
|---|---|---|---|
| 温度/湿度 | NTC采集、温室控制、鱼缸恒温 | 一阶滞后 或 递推平均N=16 | α≈0.01,N=16 |
| 压力/称重 | 电子秤、液位传感器 | 中位值平均N=5 + 递推平均N=8 | 先中值再去抖动 |
| 电机电流 | 变频驱动、堵转检测 | 限幅 + 递推平均N=8 | 阈值取满量程2% |
| 按键/开关 | 键盘扫描、限位开关 | 消抖或限幅消抖 | N=8 |
| 电池电压 | 充电监控、低功耗设备 | 限幅 + 加权递推N=8 | 阈值0.5V对应ADC码 |
5.4 外部ADC和内部ADC的滤波差异
用外部ADC时滤波策略要调整。外部ADC单次读取往往走I2C或SPI串行完成,采样周期远长于内部ADC,可能到几十毫秒量级,此时滑动窗口N=8会让阶跃响应滞后接近0.4秒。这种情况下优先用限幅加一阶滞后,小幅牺牲平滑度保住响应速度。另外外部ADC的增益误差和偏移误差要用三点校准去修正,滤波只管噪声,管不了基准误差,这点和内部ADC的校准流程是一样的。
6. 阶跃响应验证与滤波参数自适应技巧
滤波参数标得对不对,不能靠肉眼观察串口波形。最有效的验证方法是阶跃响应加脉冲注入双测试:用电阻分压器产生可瞬间切换的电压,从0V直接跳到满量程半程,同时用串口或调试器记录滤波前后的值。打印出的序列里看三件事:第一,阶跃到来后输出经过多少个采样周期进入2%误差带,这个周期数应当约等于窗口N,或一阶滞后时的3τ;第二,输出是否出现超调或阶梯爬升,出现阶梯说明限幅阈值偏小,出现超调说明加权系数过于集中;第三,在稳态段手动碰一下信号线,看尖峰是否被压到满量程1%以内。
参数自适应可以做得更细。对ADC输入做一阶差分,当差分绝对值连续3次超过限幅阈值时,认为发生的是真实阶跃而不是噪声,自动把一阶滞后系数从强平滑临时切到快速跟随,输出跟上后再切回强平滑:
uint8_t warm; /* 连续超阈值次数 */ int16_t last_raw; uint8_t alpha_now; int16_t y; static int16_t adaptive_lag(int16_t in) { int16_t diff = in - last_raw; int16_t absd = (diff > 0) ? diff : (int16_t)-diff; last_raw = in; if (absd > LIMIT_THR) { if (++warm >= 3) alpha_now = 77; /* 0.3*256,加速跟随 */ } else { warm = 0; alpha_now = 3; /* 0.01*256,强平滑 */ } y = ((int32_t)alpha_now * in + (256 - alpha_now) * y) >> 8; return (int16_t)y; }三个参数要一起调:LIMIT_THR决定什么算真实阶跃,warm决定需要几拍确认,alpha_now的切换值决定跟随多快。切换瞬间输出不会跳变,因为y的递推式是连续的,α变化只改变后面每一拍的收敛速度。这个方法用在PID启动阶段和电池电压跳变场景里,能同时拿到稳态低噪声和阶跃快响应。
最后留一个自查点:当滤波序列在静置时均值仍然缓慢漂移,而外部示波器确认电压稳定,问题不在算法,去查参考电压和PCB布局。滤波算法只能处理采样值里的噪声,处理不了采样基准本身的漂移;电源噪声和时钟抖动引起的基准偏移,交给模拟域的布局和去耦电容解决。
本文还有配套的精品资源,点击获取