做了这么多年嵌入式,信号滤波一直是绕不开的活儿。最近调一个传感器采样项目,原来用的滑动平均滤波在阶跃输入下滞后太大,高频毛刺衰减也不够,逼得我换成了三阶RC低通滤波器。实现的时候顺手做了一件事:把滤波系数限制成2的负幂次,用移位运算替代除法。整段滤波代码在MCU上跑得快,逻辑也简单,今天就把这套方案从原理到代码完整拆开聊一聊。如果你是做ADC采样、控制环前端处理、或者任何需要在嵌入式平台上做实时滤波的开发者,这篇文章基本可以让你少踩一半的坑。
1. 滤波选型:为什么滑动平均和单一RC都不够用
1.1 滑动平均的两个隐藏代价
滑动平均(Moving Average)是很多初学者最先接触的滤波方式,写法就是一个环形缓冲区,每次取最近N个采样值求平均。它确实能抑制周期性噪声,但有两个不太容易被注意的问题。
第一个问题是阻带衰减速度太慢。滑动平均的幅频响应是sinc函数形状,旁瓣衰减大约只有-20dB/dec,也就是说对远离截止频率的干扰,抑制能力提升得很慢。第二个问题更隐蔽:它本质上是FIR滤波器,需要把N个历史值全部存下来,在内存紧张的单片机上,N又不能太小,不然平滑效果直接失效。
更关键的是,当输入发生突变时,滑动平均的输出要经过整整N个采样周期才能完全跟上,这个滞后在实时控制类场景往往是致命的。而IIR滤波器(比如RC低通)的状态只需要几个变量就能存下,内存占用固定,响应特性也更可控。
1.2 一阶RC低通的基本形态和衰减不足
一阶RC低通滤波器,模拟域传递函数是:
H(s) = 1 / (1 + s * RC)它的幅频特性在高频段以-20dB/dec的斜率衰减。这个斜率意味着什么呢?假设你有一个1kHz的干扰,滤波器的截止频率是20Hz,那么干扰只被衰减了大约34dB,也就是50倍左右。对很多应用来说,这个衰减量并不够。
我实测过一阶RC在电机PWM产生的纹波噪声面前的表现,效果始终差一口气。把截止频率调低,信号本身的响应又变得迟钝。这就是单极点的限制:通带和阻带之间的矛盾,靠一个极点没法兼顾。
1.3 三级级联:以可接受的滞后换取更陡的阻带
把三个一阶RC滤波器串联起来,就得到三个极点,高频段的衰减斜率变成-60dB/dec。同样是1kHz干扰、20Hz截止频率,三阶结构能把干扰衰减到接近千分之一,这个量级在实际工程里基本就“看不见”了。
代价是相位滞后更大,阶跃响应也更慢。但很多传感器信号处理场景,比如温度采集、压力监测、惯性数据预处理,对实时性的要求远没有对噪声抑制的要求高。用一点滞后换取两个数量级的额外衰减,这笔账算得过来。
在数字域做级联,不用像模拟电路那样担心级间负载效应——用一个结构体存三个状态变量,按顺序更新就行,这也是数字滤波相比模拟滤波天生的优势。
2. 从模拟RC到差分方程:离散化推导与数字级联
2.1 后向欧拉离散化得到一阶递推式
模拟RC滤波器的微分方程是:
RC * dy/dt + y = x在嵌入式平台上,我们需要把它转成差分方程。用后向欧拉法离散化,把dy/dt近似为(y[n] - y[n-1]) / Ts,其中Ts是采样周期。代入整理后得到:
y[n] = y[n-1] + a * (x[n] - y[n-1])其中:
a = Ts / (RC + Ts)这个a就是滤波系数,取值范围在0到1之间。a越小,截止频率越低,滤波越强。把式子展开可以看到,当前输出等于上一次输出加上“输入与输出的差值乘以系数a”,也就是每次只朝输入方向靠近一点点。这个“靠近”的直观理解非常重要,后面定点和移位优化全都围绕着它展开。
2.2 数字三阶RC与模拟电路级联的区别
在模拟域,你直接把三个RC电路串联,如果中间不加缓冲放大器,后一级会吸收前一级的信号,导致实际截止频率和理论值偏离。数字域完全没这个问题,只要按顺序更新三个状态变量:
y1[n] = y1[n-1] + a * (x[n] - y1[n-1]) y2[n] = y2[n-1] + a * (y1[n] - y2[n-1]) y3[n] = y3[n-1] + a * (y2[n] - y3[n-1])y1是最靠近输入的一级,y3是最终输出。每一级都以上一级的输出作为输入,形成完整的级联。值得强调的是,这里每个状态变量只需要4字节(int32_t),三个状态一共12字节,比滑动平均动辄几十上百字节的缓冲区省太多。
还有一个模拟域不存在的细节:更新顺序必须是从y1到y3,不能反过来。因为y2的输入是y1刚更新完的值,如果先更新y3再更新y2,就相当于用旧值推新值,整体特性会变成一个奇怪的反馈结构,频率响应就变了。
3. 移位运算替代除法的定点化思路
3.1 除法/浮点在MCU上的真实开销
滤波系数a是浮点数,最直接的写法是用浮点乘法:
y += a * (x - y);在带FPU的Cortex-M4F或M7上,这条语句确实不慢,但在Cortex-M0、M3这类没有硬件浮点单元的内核上,一次浮点乘法要调用soft-float库函数,几十个甚至上百个周期就没了。尤其当滤波代码在中断里执行时,这个开销会被直接放大。
就算平台有FPU,浮点计算也牵扯到一个更麻烦的问题:ADC采样值是整数,浮点滤波结果需要转回整数输出,每次转换都有额外的类型转换开销和潜在的精度损失。
真正适合嵌入式整数域的优化思路是:把系数a限制成2的负幂次,即:
a = 1 / (2^K)其中K是整数。这样一来,乘法就变成了右移K位。在Cortex-M系列上,一条移位指令只有1个周期,编译器还可能把它和加减法合并调度,性能提升非常明显。
3.2 让系数等于2的负幂:K值与截止频率的关系
一阶RC的截止频率和系数a存在近似关系:
a ≈ 2π * fc / fs这个公式在a比较小(fc远低于fs)时成立,实际工程也基本都满足这个条件。把a替换成1/(2^K),得到:
fc ≈ fs / (2π * 2^K)这就是K值和单节截止频率之间的换算关系。我建议直接把它做成一张速查表贴在本子里,比每次现推公式快得多。
对于三个相同的一阶节级联,整体-3dB截止频率还会进一步降低。三个相同极点的级联,-3dB点大约在单节截止频率的0.51倍处,也就是说要让整体截止频率达到目标值,每一级的截止频率要配置成目标的约1.96倍。
3.3 负数右移的坑:取整方式决定滤波精度
写移位版本的时候会遇到一个隐藏很深的坑。C语言对有符号整数做右移,是算术右移,等价于向下取整的除法。而标准的整数除法是向零取整。这两者的区别在负数上就暴露了:
-3 / 2 = -1(向零取整)-3 >> 1 = -2(向下取整)
如果滤波误差(x - y)是负数,直接右移会让输出比理论值多偏离一点,长期运行会积累一个不被察觉的静态偏差。
解决办法是在右移前加一个偏置量做四舍五入:
static inline int32_t shift_div(int32_t v, int k) { return (v + (1 << (k - 1))) >> k; }这个公式对正负都成立,原理是给v加上2^(K-1),再右移K位,等效于“四舍五入除以2^K”。我所有滤波器代码里都强制使用这个宏,从不裸写>>k。
4. 参数计算与代码落地:从K值到可运行的滤波器
4.1 由目标截止频率反推移位位数K
综合单节截止频率和三级级联的偏移,反推K值的公式是:
2^K ≈ fs / (12.32 * fc_total)也就是说:
K ≈ log2(fs / (12.32 * fc_total))举个例子,采样率1000Hz,想要20Hz的截止频率:
fs / (12.32 * 20) = 4.06 log2(4.06) ≈ 2.02取K=2,三级级联后实测截止频率约20.3Hz,非常接近。下面这张表我测过很多次,可以直接参考,采样率1kHz:
| K值 | 单节a值 | 单节截止频率(Hz) | 三级级联后截止频率(Hz) |
|---|---|---|---|
| 1 | 0.5 | 79.6 | 40.6 |
| 2 | 0.25 | 39.8 | 20.3 |
| 3 | 0.125 | 19.9 | 10.1 |
| 4 | 0.0625 | 9.95 | 5.1 |
| 5 | 0.03125 | 4.97 | 2.5 |
| 6 | 0.015625 | 2.49 | 1.3 |
选K有个工程经验:K在2到8之间比较实用。K小于2几乎没滤波效果,K大于8会导致单步更新量太小,后面会讲到死区问题。
4.2 最小C实现与状态初始化
我用一个结构体封装三个状态变量和移位位数,完整实现如下:
typedef struct { int32_t y1; int32_t y2; int32_t y3; uint8_t k; } rc3_filter_t; static inline int32_t shift_div(int32_t v, uint8_t k) { return (v + (1 << (k - 1))) >> k; } void rc3_filter_init(rc3_filter_t *f, uint8_t k, int32_t initial_value) { f->k = k; f->y1 = initial_value; f->y2 = initial_value; f->y3 = initial_value; } int32_t rc3_filter_update(rc3_filter_t *f, int32_t x) { f->y1 += shift_div(x - f->y1, f->k); f->y2 += shift_div(f->y1 - f->y2, f->k); f->y3 += shift_div(f->y2 - f->y3, f->k); return f->y3; }初始化时把三个状态变量都设为当前输入值或中值,而不是0,这样可以避免上电瞬间的输出冲击。对于控制类应用,这一步省掉的话,系统启动时会看到一个从0猛冲向上的假信号。
很多文章喜欢把入口参数做得特别复杂,我的建议是K和初始值就够了,其他一切以最终可维护性为目标。
4.3 输出限幅、死区与溢出控制
定点滤波有两个必须面对的问题。第一个是死区问题。由于移位运算的取整性质,当误差量绝对值小于2^(K-1)时,四舍五入后的增量为0,输出会卡住不动。K越大,死区越大。比如12位ADC,信号在某个值附近小幅波动,K=6时差值经常在±1以内,增量为0,输出就是一条直线,看起来好像“卡死了”,其实是精度不够。
解决死区有两个办法:一是K不要选太大,二是在误差特别小的时候强行让输出跟随一次,或者改用“累积误差”结构,把每次被取整丢弃的余数保留下来,累加到下一次。
第二个问题是溢出。状态变量y1、y2、y3用int32_t时,能表示的信号范围很大,ADC最大值一般不超过4096或65535,远够用。但shift_div宏里的加法v + (1 << (k - 1))在极端输入下可能溢出,所以我要求所有输入先经过限幅,确保进入滤波器前信号范围可控。
输出端也要做限幅。如果滤波器后面接的是PWM比较寄存器或者DAC,饱和输出反而比满幅回绕更安全。一句if (y3 < 0) y3 = 0;就能避免很多诡异故障。
5. 实测验证与调参:阶跃响应、扫频结果和典型问题
5.1 阶跃响应:三阶滤波的滞后有多大
我在某Cortex-M3内核主控上,用信号发生器给滤波器灌了一个从0到1000的阶跃,记录三阶输出。K=2时,输出到达63%的时间大约需要1.6个采样周期;K=4时,同样的响应时间拉长到大约8个采样周期。整体滞后大概是一阶RC的三倍左右,这和理论预期一致。
如果你做的是闭环控制,这种滞后不能忽视。相位裕度本来就不够的系统,滤波滞后可能直接让系统震荡起来。这种情况我的做法是把滤波放在反馈通路的误差信号上,而不是在给定信号前端,效果会好很多。
5.2 扫频实测:确认-60dB/dec衰减
用DDS信号源做过一次从1Hz到100Hz的扫频,输出接滤波器和ADC,记录不同频率下的增益。K=2时,10Hz到80Hz的区间里,输出幅值每十倍频程下降约58dB,和理论-60dB/dec基本吻合。这说明三阶结构在阻带的行为是可预测的,设计指标可以直接按这个斜率估算。
扫频还有一个额外收获:20Hz附近有轻微的峰值隆起,幅度大约0.5dB。三个相同极点级联时Q值是固定的,说白了就是不算特别平坦。如果你的应用对通带平坦度有硬性要求,就得考虑三阶巴特沃斯结构了,这个我后面会说。
5.3 典型工程问题:启动冲击、饱和恢复、突变干扰
实测中遇到最典型的一个问题是启动冲击。一开始初始化时把y1、y2、y3全设成0,系统上电后输入立刻跳到满幅,输出要花很长时间爬升,期间下游逻辑误触发了好几次。后来改成初始化时把状态变量直接设成当前ADC值,问题立刻消失。
饱和恢复问题出现在强干扰把输入打到满幅的情况。三阶滤波器的状态变量都已经饱和,干扰消失后输出要慢慢“泄放”回来。应对办法是检测到输入连续超限时直接重置状态变量到当前输入值,而不是傻等着积分泄放。
还有一类容易忽略的是窄脉冲干扰。三阶RC对持续噪声效果很好,但对单次的窄尖峰,响应并不理想,因为尖峰的能量会被三个极点“记住”一段相当长的时间。碰到这种干扰,我一般会在滤波器前加一个简单的幅度限幅器,先砍掉离谱的毛刺,再进RC滤波。
6. 继续压榨性能:无分支写法与更高阶扩展
6.1 无分支移位除法与内联优化
shift_div宏里包含一个分支?并没有,我的宏是无分支的,很干净:
static inline int32_t shift_div(int32_t v, uint8_t k) { return (v + (1 << (k - 1))) >> k; }任何现代编译器都能把它内联成一个加法加一个移位,没有跳转,没有指令流水线冲刷。
如果K在编译期就是常量,性能还能更进一步。用宏或模板把K写成固定值,编译器可以直接把1 << (K-1)提前算好,并把移位次数作为立即数编码进指令。实测K固定为4时,三阶更新在72MHz的Cortex-M3上大约跑18个周期;换成运行时变量k,大约22个周期。差距不算夸张,但中断里每周期都要精打细算时,值得做。
6.2 三阶RC与三阶巴特沃斯的差异,何时该升级
三个相同一阶极点级联,构成的是“三阶RC”结构,不是三阶巴特沃斯。巴特沃斯要求幅频响应在通带内最平坦,需要一组特定的极点分布,其中包含一对共轭复数极点,单个一阶节无法实现。
如果你测出通带内有明显的峰值隆起,或者相位特性不满足要求,就该升级成真正的巴特沃斯滤波器。做法是用一个一阶节加一个Q=1的二阶节级联。代价是二阶节的系数不再是简单的1/2^K,需要做乘法和加法,代码复杂度上升一截。
我的经验是:大多数传感器采样场景用三阶RC就够,因为我们要的只是阻带衰减够陡,通带平整度没人在意。但如果是音频EQ或者高精度测量前端的抗混叠滤波,直接上三阶巴特沃斯,别在RC上硬撑。
6.3 实际周期对比与适用范围
同一套三阶滤波,我在这块Cortex-M3上分别测了浮点版和移位版,一个周期对比就能看出差距:
| 实现方式 | 内核 | 大约耗时(周期) |
|---|---|---|
| 浮点乘法 | Cortex-M3(无FPU) | 300+ |
| 浮点乘法 | Cortex-M4F(有FPU) | 45 |
| 移位版本 | Cortex-M3/M4 | 20 |
| 移位版本(K编译期固定) | Cortex-M3/M4 | 18 |
Cortex-M0、M0+等更小的内核上,浮点差距更夸张。也就是说,在低成本MCU上,移位版本可以把滤波代码从“中断里不敢放”变成“随便跑”。
这个方案适用的范围其实很广:传感器采样、电池电压监测、电机电流反馈、惯性数据预处理,基本上任何输入是整数、输出也要求整数的低通滤波场景,都可以这么干。唯一不适合的是那些需要高精度浮点系数的场合,比如要求截止频率精确到小数点后两位,那就老老实实用浮点,别为了炫技牺牲精度。
最后说一点个人体会。移位运算替代除法这套思路,表面上聊的是三阶RC滤波器的优化,本质上是一种定点化思维:在资源有限的嵌入式平台上,把系数设计成硬件友好形式,让编译器生成最精简的指令序列。我在实际项目中遇到的许多“性能问题”,最后都不是靠换更贵的芯片解决的,而是靠这种对算法表达形式的重新思考。下次你再遇到滤波太慢、中断超时的情况,不防先问问自己:这个系数,能不能让它变成移位?