1. 从一个除法瓶颈说起:为什么三阶RC滤波要用移位来算
做嵌入式信号采集的朋友大概率都遇到过这个场景:ADC采回来的数据毛刺多到没法看,上位机曲线像心电图一样乱跳,于是你决定加一个低通滤波器。一阶RC滤波确实简单,一个公式就搞定,但它的滚降斜率只有-20dB/十倍频程,面对稍微复杂一点的噪声环境根本不够用。这时候三阶RC低通滤波器就进入了视野——-60dB/十倍频程的衰减能力,对高频噪声的压制效果立竿见影。
但问题也随之而来。三阶RC滤波器的离散化实现涉及多个系数乘法,而这些系数往往不是整数,需要做浮点运算或者定点除法。在资源受限的MCU上,一次除法运算动辄消耗十几个甚至几十个时钟周期,如果滤波器的采样率是1kHz,每次采样要跑三阶级联,那就是每毫秒要做多次除法。对于主频只有几兆赫兹的低端芯片来说,这个开销足以让整个系统的实时性崩掉。
我最初在一个电机电流采样项目里就踩过这个坑。当时用的是一颗主频8MHz的MCU,ADC以10kHz的频率采样,加上三阶滤波之后CPU占用率直接飙到40%以上,电机控制环路的响应明显变迟钝。后来把除法全部换成移位运算,CPU占用率降到了不到8%,控制效果立刻恢复正常。这个经历让我意识到,在嵌入式中做滤波器,算法本身的数学正确性只是一半,另一半全在运算效率上。
这篇内容适合所有在MCU上做信号处理的开发者阅读,不管你是刚接触嵌入式滤波的新手,还是已经用过FIR/IIR但想进一步优化性能的老手,下面这些从实际项目中摸出来的经验应该都能帮到你。核心思路就一条:用移位运算替代除法,把三阶RC低通滤波器的计算开销压到最低。
2. 三阶RC低通滤波器的离散化与系数设计
2.1 从模拟RC电路到数字滤波器的映射逻辑
三阶RC低通滤波器在模拟域里就是三个RC环节级联,每个环节的传递函数是1/(1+sRC)。三个级联之后总传递函数是1/(1+sRC)³。要把它搬到数字域,最常用的方法是双线性变换或者冲激响应不变法。但在嵌入式实践中,我更倾向于用后向差分法来做离散化,因为它计算简单,不会引入频率混叠问题,而且离散后的差分方程形式非常规整。
具体来说,对于一阶RC环节,后向差分法给出的差分方程是:
y[n] = y[n-1] + (Ts/(RC+Ts)) * (x[n] - y[n-1])
其中Ts是采样周期,RC是时间常数。令α = Ts/(RC+Ts),这个α就是滤波系数,取值范围在0到1之间。三阶级联就是把前一级的输出作为后一级的输入,连续做三次同样的运算。
这里有一个关键点需要注意:三阶级联之后,滤波器的-3dB截止频率并不等于单级的截止频率。因为每级都会对信号产生衰减,三级叠加之后实际截止频率会往低频方向偏移。根据经验,三阶RC滤波器的等效截止频率大约是单级截止频率的0.51倍左右。也就是说,如果你想要一个100Hz的三阶低通滤波器,每一级的截止频率应该设计在接近200Hz的位置。这个关系在做参数设计的时候必须考虑进去,否则实际滤波效果会和预期差很多。
2.2 滤波系数的定点化处理与误差分析
在浮点MCU上,α直接算成浮点数就行,简单省事。但大多数低成本MCU只有定点运算单元,甚至只有整数运算单元,这时候就必须把α转成定点数。常见的做法是用Q格式表示,比如Q15格式就是把一个小数乘以32768然后取整。
假设我们算出来α = 0.0731,用Q15表示就是0.0731 × 32768 ≈ 2395。那么滤波运算就变成了:
y[n] = y[n-1] + (2395 * (x[n] - y[n-1])) >> 15
这里的>>15就是右移15位,等效于除以32768。移位运算在几乎所有MCU上都是单周期指令,比除法快得多。但这里有一个精度问题:Q15格式的分辨率是1/32768 ≈ 0.0000305,对于α = 0.0731来说,量化误差大约是0.0000305/0.0731 ≈ 0.04%,这个精度对于大多数嵌入式滤波应用来说完全够用。
如果你需要更高的精度,可以用Q31格式,但要注意中间乘法结果会变成64位,需要MCU支持64位运算或者手动处理高低位。对于三阶RC滤波这种对精度要求不是极端苛刻的场景,Q15基本是性价比最高的选择。
2.3 三阶级联的系数分配策略
三阶级联的时候,每一级的α可以相同也可以不同。如果三级用相同的α,实现最简单,但滤波器的频率响应会稍微有点不够陡峭。如果每级用不同的α,可以更灵活地调整频率响应形状,但计算量会增加。
我的建议是:对于大多数应用场景,三级用相同的α就足够了。原因有两个:第一,三阶RC滤波器本身的滚降特性已经足够陡峭,每级系数不同带来的额外优化效果有限;第二,相同系数意味着只需要计算一次α,代码更简洁,也更容易用移位来实现。
但有一种情况例外:如果你需要在通带内获得更平坦的响应,可以考虑把第一级的α设得稍微大一点,后两级设得小一点。这样做的原理是第一级先做一次轻度滤波,避免信号在进入后两级之前就被过度衰减。不过这种调整需要配合实际的频响测试来做,不能凭感觉拍脑袋。
3. 移位运算替代除法的核心实现细节
3.1 为什么移位能替代除法:二进制视角下的数学原理
在二进制世界里,右移一位等效于除以2,右移N位等效于除以2的N次方。这个性质是移位运算替代除法的数学基础。但要注意,移位只能替代除以2的幂次,不能替代任意除数的除法。所以关键在于把滤波系数设计成2的幂次形式,或者把除法表达式改写成移位形式。
回到滤波公式:y[n] = y[n-1] + α * (x[n] - y[n-1])。如果α恰好是1/2的幂次,比如1/8、1/16、1/32,那么α * (x[n] - y[n-1])就可以直接写成(x[n] - y[n-1]) >> 3、(x[n] - y[n-1]) >> 4、(x[n] - y[n-1]) >> 5。这样整个滤波运算就只剩下减法、加法和移位,全部是单周期指令。
但实际应用中,α很少恰好是2的幂次。这时候有两种处理策略:第一种是近似取最近的2的幂次,比如α = 0.0731最接近1/16 = 0.0625,那就用>>4来近似。这种做法的优点是计算极快,缺点是滤波系数有偏差,实际截止频率会偏离设计值。第二种是用Q格式定点数配合移位来实现,比如前面说的Q15方案,虽然多了一次乘法,但精度高得多,而且乘法在大多数MCU上也是单周期或双周期指令,比除法快很多。
3.2 Q15定点数配合移位的完整实现方案
下面给出一个完整的三阶RC低通滤波器C语言实现,采用Q15定点数格式,所有除法都用移位替代:
#include <stdint.h> #define ALPHA_Q15 2395 // 对应浮点系数约0.0731 #define SHIFT_BITS 15 typedef struct { int32_t y1; // 第一级输出 int32_t y2; // 第二级输出 int32_t y3; // 第三级输出 } RC3Filter; void rc3_filter_init(RC3Filter *f, int32_t init_val) { f->y1 = init_val; f->y2 = init_val; f->y3 = init_val; } int32_t rc3_filter_update(RC3Filter *f, int32_t input) { int32_t diff; // 第一级 diff = input - f->y1; f->y1 += (ALPHA_Q15 * diff) >> SHIFT_BITS; // 第二级 diff = f->y1 - f->y2; f->y2 += (ALPHA_Q15 * diff) >> SHIFT_BITS; // 第三级 diff = f->y2 - f->y3; f->y3 += (ALPHA_Q15 * diff) >> SHIFT_BITS; return f->y3; }这段代码里,所有的除法都被>>SHIFT_BITS替代了。ALPHA_Q15 * diff是一次32位乘法,在Cortex-M系列MCU上通常是1到2个周期。整个滤波更新函数在72MHz的M3内核上实测大约需要20到30个周期,也就是不到0.5微秒。如果用浮点除法来实现同样的功能,至少需要100个周期以上。
3.3 移位方向与舍入误差的处理技巧
右移运算有一个容易被忽视的问题:对于负数,右移是向下取整还是向零取整,不同编译器的行为可能不一样。在C语言标准里,有符号负数的右移是实现定义的行为,有的编译器做算术右移(保持符号位),有的做逻辑右移。这会导致滤波结果在输入为负时出现偏差。
解决方法是:在做移位之前,先给乘法结果加上一个偏移量,保证移位后的结果向零取整。具体来说,如果SHIFT_BITS是15,就在乘法结果上加(1 << 14),然后再右移15位。这个操作等效于四舍五入,可以消除负数右移带来的偏差。
#define ROUND_OFFSET (1 << (SHIFT_BITS - 1)) f->y1 += (ALPHA_Q15 * diff + ROUND_OFFSET) >> SHIFT_BITS;这个小小的改动看起来不起眼,但在实际项目中,如果不加这个偏移,滤波后的信号在零点附近会出现明显的直流偏置,对于需要精确测量微小信号的应用来说是个致命问题。我在一个压力传感器项目里就因为这个细节排查了整整一个下午,最后用示波器看滤波前后的波形对比才发现零点偏移了十几个LSB。
4. 运算效率优化与资源占用实测
4.1 不同实现方案的周期消耗对比
为了给大家一个直观的参考,我在一颗72MHz的Cortex-M3内核MCU上做了几组对比测试。测试条件是ADC以10kHz采样,每次采样后执行一次三阶滤波,测量滤波函数的平均执行周期数。
| 实现方案 | 系数表示 | 核心运算 | 平均周期数 | CPU占用率(10kHz采样) |
|---|---|---|---|---|
| 浮点除法 | float | 除法+乘法 | 约180 | 约2.5% |
| 浮点乘法 | float | 乘法 | 约90 | 约1.25% |
| Q15定点+除法 | int32_t | 除法+乘法 | 约120 | 约1.7% |
| Q15定点+移位 | int32_t | 乘法+移位 | 约25 | 约0.35% |
| 纯移位(2的幂次近似) | int32_t | 移位+加减 | 约12 | 约0.17% |
从表格可以清楚看到,Q15定点配合移位的方案,周期数只有浮点除法的七分之一左右。而纯移位方案虽然更快,但系数精度损失较大,适合对滤波精度要求不高的场景。
这里要说明一下,CPU占用率的计算方式是:周期数 × 采样率 / 主频。比如25个周期 × 10000Hz / 72MHz ≈ 0.35%。这个数字看起来很小,但如果你系统里还有别的任务要跑,比如电机控制、通信协议处理、显示刷新,那省下来的每一个周期都是宝贵的。
4.2 内存占用与栈空间的优化
三阶RC滤波器本身的内存占用非常小,三个int32_t的状态变量加上一个系数常量,总共不到20字节。但如果你用的是浮点版本,每个状态变量是4字节float,系数也是4字节,总共也就20字节左右,差别不大。真正需要注意的是函数调用时的栈空间消耗。
如果滤波函数被频繁调用,而且里面用了局部变量做中间计算,每次调用都会在栈上分配空间。对于栈空间紧张的MCU(比如只有1KB RAM的低端芯片),建议把滤波器的状态变量声明为全局变量或静态变量,避免反复压栈弹栈。另外,把滤波函数声明为inline或者用宏来实现,可以进一步减少函数调用的开销。
// 用宏实现,避免函数调用开销 #define RC3_FILTER_UPDATE(f, input) do { \ int32_t diff; \ diff = (input) - (f).y1; \ (f).y1 += (ALPHA_Q15 * diff + ROUND_OFFSET) >> SHIFT_BITS; \ diff = (f).y1 - (f).y2; \ (f).y2 += (ALPHA_Q15 * diff + ROUND_OFFSET) >> SHIFT_BITS; \ diff = (f).y2 - (f).y3; \ (f).y3 += (ALPHA_Q15 * diff + ROUND_OFFSET) >> SHIFT_BITS; \ } while(0)用宏的好处是编译器可以直接把代码内联展开,没有任何函数调用开销。缺点是代码可读性稍差,调试的时候不太方便。我的建议是在开发阶段用函数版本方便调试,在最终发布版本里换成宏版本压榨最后一点性能。
4.3 采样率与滤波系数的联动调整
滤波系数α和采样率Ts是绑定的,α = Ts/(RC+Ts)。如果你改变了采样率,必须重新计算α,否则滤波器的截止频率会跟着变。这一点在项目后期调整采样率的时候特别容易忘记。
举个例子:原本采样率是1kHz,设计截止频率100Hz,算出来α ≈ 0.386。后来因为要采集更快的信号,把采样率提到了10kHz,如果还用原来的α = 0.386,实际截止频率就变成了大约1000Hz,滤波效果几乎消失。正确的做法是把α重新算成0.0386左右,保持RC时间常数不变。
在实际项目中,我习惯把截止频率和采样率作为两个独立的配置参数写在头文件里,然后在初始化函数里自动计算α。这样即使后期改了采样率,只要改一个宏定义,α会自动跟着变,不容易出错。
#define SAMPLE_RATE_HZ 10000 #define CUTOFF_FREQ_HZ 100 #define RC_TIME_CONST (1.0f / (2 * 3.14159f * CUTOFF_FREQ_HZ * 0.51f)) #define ALPHA_FLOAT (1.0f / (SAMPLE_RATE_HZ * RC_TIME_CONST + 1.0f)) #define ALPHA_Q15 ((int32_t)(ALPHA_FLOAT * 32768.0f))注意上面代码里的0.51f就是三阶级联的截止频率修正系数。这个系数不是精确值,是根据经验总结出来的近似值,实际项目中可以根据实测频响做微调。
5. 常见问题与排查技巧实录
5.1 滤波后信号出现自激振荡怎么办
自激振荡是数字滤波器最让人头疼的问题之一。现象是滤波输出信号在没有输入变化的情况下持续波动,或者输入一个阶跃信号后输出持续振荡不收敛。造成这个问题的原因通常有三个:一是α系数大于1,导致滤波器不稳定;二是定点运算溢出,中间结果超出了int32_t的表示范围;三是移位方向搞反了,把右移写成了左移。
排查步骤很简单:首先检查α是否在0到1之间,如果α大于1,滤波器极点跑到单位圆外面去了,必然振荡。其次检查中间乘法结果是否溢出,ALPHA_Q15最大32767,diff最大可能是输入范围的2倍,两者相乘可能超过int32_t的范围。解决办法是把中间结果用int64_t来存,或者把输入信号先缩小再滤波。最后检查移位方向,>>是右移(除以2的幂次),<<是左移(乘以2的幂次),千万别搞反。
5.2 滤波效果不明显或过度滤波的调整方法
滤波效果不明显通常是因为α太大,滤波器带宽太宽,噪声没被有效压制。过度滤波则是α太小,有用信号也被衰减了。调整的方法很简单:增大α则滤波变弱,减小α则滤波变强。
但要注意,三阶RC滤波器的截止频率和单级不一样。如果你想要100Hz的截止频率,单级α应该按照200Hz左右来设计。具体计算方法是:先确定目标截止频率fc,然后单级截止频率取fc/0.51,再根据采样率算出α。
如果调整α之后效果还是不对,那就要检查是不是采样率设置错了,或者滤波器根本没被调用。我遇到过好几次是因为ADC中断里忘了调用滤波函数,数据直接透传了,查了半天代码才发现。
5.3 定点数精度不够导致的量化噪声问题
Q15格式的精度对于大多数应用够用,但如果你处理的信号动态范围很大,比如同时要检测微伏级的小信号和伏级的大信号,Q15可能就不够了。量化噪声会表现为输出信号上叠加了一层细小的纹波,看起来像随机噪声但实际上是确定性的。
解决方法是升级到Q31格式,或者用浮点运算。Q31格式的精度是Q15的65536倍,代价是乘法结果变成64位,需要MCU支持64位运算或者手动处理。如果你的MCU有FPU(浮点运算单元),那直接用float最省事,性能也不差。
还有一个折中方案:用Q15格式但配合噪声整形技术,把量化噪声推到高频段,然后用后续的滤波级把它滤掉。不过这个方案实现起来比较复杂,一般项目用不上。
5.4 多通道滤波时的资源竞争与数据保护
如果你有多个ADC通道需要同时滤波,每个通道都需要独立的状态变量。这时候要注意两个问题:一是状态变量不能共用,否则通道之间会互相干扰;二是如果滤波函数在中断里调用,而状态变量在主循环里被读取,需要做临界区保护。
最简单的做法是每个通道分配一个独立的RC3Filter结构体,滤波函数只操作传入的结构体指针,不碰全局变量。这样天然就是线程安全的,只要不同通道不在同一个中断里同时调用同一个结构体就行。
RC3Filter filter_ch1; RC3Filter filter_ch2; RC3Filter filter_ch3; // 在ADC中断里分别调用 int32_t val_ch1 = rc3_filter_update(&filter_ch1, adc_buf[0]); int32_t val_ch2 = rc3_filter_update(&filter_ch2, adc_buf[1]); int32_t val_ch3 = rc3_filter_update(&filter_ch3, adc_buf[2]);如果中断优先级不同,高优先级中断可能打断低优先级中断的滤波过程,导致状态变量被破坏。这时候需要在滤波函数入口关中断,出口开中断,或者用双缓冲机制让中断和主循环操作不同的缓冲区。
6. 实际项目中的参数整定与效果验证
6.1 用阶跃响应快速判断滤波器是否正常工作
验证滤波器是否正常,最直观的方法就是给一个阶跃输入,看输出的响应曲线。正常的阶跃响应应该是一条平滑的指数上升曲线,没有过冲,没有振荡,最终稳定在输入值附近。
具体操作方法是:在代码里临时把滤波器输入强制设为一个固定值(比如满量程的一半),然后观察输出。如果输出在几个采样周期内单调上升到该值,说明滤波器工作正常。如果输出有振荡或者上升太慢,说明α需要调整。
三阶RC滤波器的阶跃响应上升时间大约是2.3倍的时间常数。如果你设计的时间常数是10ms,那上升时间大约是23ms。这个可以作为快速验证的参考。
6.2 频响测试的简易方法
没有专业仪器的情况下,可以用MCU自己产生正弦波信号,经过滤波器后再采集回来,比较幅度变化。具体做法是:用DAC或者PWM加RC电路产生不同频率的正弦波,输入到滤波器,同时用ADC采集滤波前后的信号,计算幅度比。
测试频率点建议选在截止频率的0.1倍、0.5倍、1倍、2倍、10倍处。理论上三阶RC滤波器在截止频率处的幅度衰减是-3dB,在10倍截止频率处的衰减是-60dB。如果实测结果和理论值偏差超过20%,说明滤波器参数有问题。
这个测试方法虽然简陋,但对于嵌入式项目来说足够用了。我在好几个项目里都是用这个方法验证滤波器设计的,比用仿真软件更贴近实际运行环境。
6.3 温度漂移与长期稳定性的考量
RC滤波器的系数是固定的,不会随温度变化。但ADC的参考电压可能会随温度漂移,导致滤波后的数值整体偏移。这个问题不是滤波器本身的问题,但会影响最终的测量精度。
解决办法是在软件里做温度补偿,或者定期校准ADC的零点。如果对精度要求特别高,可以用外部基准电压源代替MCU内部的参考电压。这些措施和滤波器本身无关,但做嵌入式信号采集的时候必须一起考虑。
另外,定点运算的舍入误差在长期运行中可能会累积。虽然三阶RC滤波器的反馈结构本身有抑制误差累积的作用,但在极端情况下(比如输入信号一直为零),输出可能会缓慢漂移到非零值。解决方法是定期把滤波器的状态变量向零方向做微小修正,或者用更高精度的定点格式。
7. 从三阶到更高阶:移位方案的扩展思路
三阶RC滤波器用移位实现之后,如果想进一步提升滤波效果,可以考虑扩展到四阶或五阶。但阶数越高,相位延迟越大,对于需要快速响应的控制系统来说可能得不偿失。
扩展的方法很简单:在结构体里增加状态变量,在滤波函数里增加级联级数。每增加一级,计算量增加大约8个周期,内存增加4字节。对于大多数MCU来说,跑到五阶滤波也不会对系统造成明显负担。
但要注意,阶数越高,滤波器的稳定性越难保证。三阶的时候α的允许范围还比较宽,到了五阶,α稍微大一点就可能振荡。我的建议是:如果三阶不够用,优先考虑提高采样率或者换用FIR滤波器,而不是盲目增加RC级数。
FIR滤波器虽然计算量更大,但它是无条件稳定的,而且可以实现线性相位,对于需要精确控制相位关系的应用来说更合适。当然,FIR的系数设计比RC复杂得多,需要借助MATLAB或者Python工具来计算。
8. 我踩过的坑与最后分享的几个小技巧
第一个坑是忘了做四舍五入偏移,导致滤波信号在零点附近有直流偏置。这个问题的隐蔽性很强,因为信号幅度大的时候看不出来,只有在小信号的时候才会暴露。后来养成了习惯,所有右移操作都加上ROUND_OFFSET。
第二个坑是采样率改了但α没改,滤波效果完全不对。这个问题的排查花了不少时间,因为代码逻辑看起来完全正确,最后是打印了α的值才发现和预期差了一个数量级。现在我的做法是把α的计算放在初始化函数里自动完成,不手动填数值。
第三个坑是多通道共用状态变量,导致通道之间串扰。这个问题的现象是通道1的信号会出现在通道2的滤波结果里,看起来像幽灵信号。后来改成每个通道独立的结构体就解决了。
最后分享一个小技巧:如果你不确定α该取多少,可以先用一个电位器或者可调参数在运行时动态调整α,观察滤波效果,找到合适的值之后再固化到代码里。这个方法比反复编译烧录快得多,特别是在调试阶段非常实用。
还有一个技巧是:在滤波器输出后面加一个简单的滑动平均,可以进一步平滑输出,而且滑动平均也可以用移位来实现(比如8点滑动平均就是求和后右移3位)。两级滤波配合使用,效果比单纯提高RC阶数更好,计算量也更可控。