1. 为什么偏偏要聊“位移法”实现一阶低通滤波
搞过单片机信号采集的人都有个体会:传感器出来的数据,十有八九是“脏”的。不管是测距模块、称重传感器,还是陀螺仪、温度探头,原始值里总夹着高频噪声,跳来跳去,直接拿来用能把逻辑判断搞得鸡飞狗跳。这时候一阶低通滤波几乎是所有人的第一选择——简单、省资源、效果立竿见影。
但问题来了,很多教程一上来就甩公式,什么Y(n) = α·X(n) + (1-α)·Y(n-1),然后告诉你α取多少多少。公式本身没错,可到了单片机上,尤其是那种没有硬件浮点单元的低端MCU,一算浮点乘法就开始肉疼:要么占用大量CPU周期,要么引入浮点库把Flash撑爆。于是“位移法”就登场了——用整数移位代替浮点乘法,把滤波系数变成2的幂次分之一,一条移位指令搞定,速度快到飞起。
这篇内容就是围绕这个思路展开的。我会从一阶低通滤波的数学本质讲起,说清楚为什么位移法能成立、误差在哪里、怎么选移位位数,然后给出一套可以直接抄到项目里的整数滤波实现,最后聊聊实际调试中踩过的坑和排查技巧。适合正在做嵌入式信号采集、想优化滤波性能又不想上浮点的朋友,也适合刚接触数字滤波、想把理论落到实处的初学者。整篇内容基于常见的工程实践补充细节,不涉及任何特定平台或敏感信息,放心看。
2. 一阶低通滤波的核心原理与位移法切入点
2.1 从RC电路到差分方程:一阶低通的本质
一阶低通滤波器的物理原型就是一个电阻加一个电容。输入信号经过电阻给电容充电,电容上的电压就是输出。高频信号变化快,电容来不及跟随,就被“平滑”掉了;低频信号变化慢,电容能跟上,就保留下来。这个过程的数学描述是一个一阶微分方程,离散化之后就变成了我们熟悉的差分方程:
Y(n) = α · X(n) + (1 - α) · Y(n-1)其中X(n)是本次采样值,Y(n-1)是上次滤波输出,Y(n)是本次滤波输出,α是滤波系数,范围在0到1之间。α越小,滤波越强,但响应越慢;α越大,响应越快,但滤波效果越弱。这个公式的物理意义很直白:新的输出是“新采样值”和“旧输出值”的加权平均,权重由α决定。
理解这个公式有个生活化的类比:就像往一杯热水里兑冷水。α是你每次兑入冷水的比例,兑得越多(α大),水温变化越快,但波动也大;兑得越少(α小),水温变化越慢,但更稳定。滤波就是在“响应速度”和“平滑程度”之间找平衡。
2.2 为什么浮点实现让人头疼
在PC上写这个公式毫无压力,float随便用。但在单片机上,情况完全不同。以常见的Cortex-M0内核为例,它没有硬件浮点单元,一个单精度浮点乘法需要调用软件库函数,几十个时钟周期就没了。如果采样率是1kHz,每次采样都要做两次浮点乘法和一次浮点加法,CPU有相当一部分时间花在滤波上,这在资源紧张的项目里是不可接受的。
更麻烦的是,浮点运算会引入浮点库,代码体积可能增加几KB到十几KB。对于那些Flash只有32KB甚至16KB的低端芯片,这简直是割肉。还有一点容易被忽略:浮点运算的确定性不如整数运算,在某些对时序敏感的场景里,浮点库的执行时间波动会影响控制环路的稳定性。
所以,工程上迫切需要一种“不用浮点也能实现一阶低通”的方法。位移法就是在这个背景下被广泛采用的。
2.3 位移法的核心思想:把α变成2的幂次分之一
位移法的关键洞察是:如果α恰好是1/2^k(k是正整数),那么α · X(n)就可以用右移k位来实现,(1-α) · Y(n-1)可以写成Y(n-1) - Y(n-1)/2^k,同样用移位和减法搞定。整个滤波过程只需要整数加法、减法和移位,完全不需要浮点。
具体推导一下。令α = 1/2^k,则1-α = 1 - 1/2^k = (2^k - 1)/2^k。代入差分方程:
Y(n) = X(n)/2^k + Y(n-1) · (2^k - 1)/2^k整理一下:
Y(n) = Y(n-1) + (X(n) - Y(n-1)) / 2^k这个形式更直观:每次滤波输出在上次输出的基础上,加上“当前采样与上次输出的差值”的1/2^k。差值被缩小了2^k倍,相当于对变化量做了衰减,从而实现低通效果。
用移位表示就是:
Y(n) = Y(n-1) + ((X(n) - Y(n-1)) >> k)一条减法、一条右移、一条加法,三条整数指令,完事。在大多数8位或32位MCU上,这几条指令加起来也就几个时钟周期,比浮点实现快一个数量级以上。
2.4 位移法的精度损失与补偿思路
位移法不是没有代价的。最大的问题是α只能取1/2, 1/4, 1/8, 1/16...这些离散值,不能像浮点那样任意取0.1、0.05之类的值。这意味着滤波系数的选择范围变窄了,截止频率的调节粒度变粗。
还有一个更隐蔽的问题:右移操作会截断低位,引入量化误差。如果X(n) - Y(n-1)的值很小,右移k位后可能直接变成0,导致滤波器“卡死”,输出不再更新。这在信号变化缓慢或者已经稳定时特别明显。
解决这个问题的常见做法是引入“余数累积”或者“高精度中间变量”。比如用一个32位的累加器来保存滤波状态,实际输出时再取高16位。这样右移产生的余数不会丢失,而是累积到下一次运算中,等效于提高了内部精度。具体实现会在第3章展开。
3. 位移法滤波的整数实现与参数选择
3.1 定点数的Q格式表示
要在整数域里做滤波,首先得把物理量映射成整数。最常用的方法是Q格式定点数。简单说,Qn格式表示小数点在第n位之前,比如Q8表示低8位是小数部分,高24位是整数部分(以32位变量为例)。这样两个Q8数相乘,结果需要右移8位才能回到Q8格式。
对于位移法滤波,我们其实不需要完整的定点乘法,只需要保证滤波状态变量有足够的精度。一个实用的做法是:采样值用原始整数(比如ADC的12位值),滤波状态变量用32位整数,但内部隐含了额外的精度位。每次滤波时,差值右移k位后加到状态变量上,状态变量的高位就是滤波输出。
举个例子:假设ADC是12位,采样值范围0到4095。滤波状态变量用32位,初始值设为第一次采样值左移16位(相当于Q16格式)。每次滤波:
state += ((sample << 16) - state) >> k; output = state >> 16;这样state的低16位相当于“余数累积器”,右移k位产生的截断误差不会丢失,而是留在state里,下次继续参与运算。输出取高16位,等效于对state做了四舍五入(如果加个0.5的偏移)。这种做法的精度远高于直接用16位变量做移位。
3.2 移位位数k的选择与截止频率估算
k值决定了滤波强度。k越大,α越小,滤波越强,但响应越慢。那怎么根据想要的截止频率来选k呢?
一阶低通滤波器的截止频率fc和系数α的关系是:
α = 1 - exp(-2π · fc / fs)其中fs是采样频率。当fc << fs时,可以近似为:
α ≈ 2π · fc / fs令α = 1/2^k,则:
2^k ≈ fs / (2π · fc)两边取对数:
k ≈ log2(fs / (2π · fc))举个例子:采样率fs = 1000Hz,想要截止频率fc = 10Hz,则2^k ≈ 1000 / (2π · 10) ≈ 15.9,最接近的2的幂是16,所以k = 4,α = 1/16 = 0.0625。实际截止频率约为fc = fs · α / (2π) = 1000 · 0.0625 / 6.283 ≈ 9.95Hz,和10Hz非常接近。
如果想要的截止频率对应的k不是整数,就只能取邻近的整数值,截止频率会有偏差。这是位移法的固有局限。如果对截止频率精度要求很高,可以考虑用“移位+补偿”的方式,比如用两次移位组合出非2的幂系数,但那就复杂了,一般项目没必要。
下面给一个常用采样率下的k值参考表:
| 采样率 fs | 目标截止频率 fc | 计算 2^k | 选取 k | 实际 α | 实际 fc |
|---|---|---|---|---|---|
| 1000Hz | 50Hz | 3.18 | 2 | 0.25 | 39.8Hz |
| 1000Hz | 20Hz | 7.96 | 3 | 0.125 | 19.9Hz |
| 1000Hz | 10Hz | 15.9 | 4 | 0.0625 | 9.95Hz |
| 1000Hz | 5Hz | 31.8 | 5 | 0.03125 | 4.97Hz |
| 500Hz | 10Hz | 7.96 | 3 | 0.125 | 9.95Hz |
| 500Hz | 5Hz | 15.9 | 4 | 0.0625 | 4.97Hz |
| 100Hz | 2Hz | 7.96 | 3 | 0.125 | 1.99Hz |
| 100Hz | 1Hz | 15.9 | 4 | 0.0625 | 0.995Hz |
注意:这个表里的实际fc是按近似公式算的,低频时误差很小,高频时(fc接近fs/2)误差会变大,那时候需要用精确公式反推。
3.3 完整整数滤波函数实现
下面给一个可以直接用的C语言实现。这个版本用32位状态变量,支持任意k值(1到15),内部做了余数累积,精度有保障。
#include <stdint.h> typedef struct { int32_t state; // 内部状态,Q16格式 uint8_t k; // 移位位数 uint8_t init; // 初始化标志 } lpf_t; // 初始化滤波器,first_sample是第一次采样值 void lpf_init(lpf_t *f, int32_t first_sample, uint8_t k) { f->k = k; f->state = first_sample << 16; // 转为Q16 f->init = 1; } // 滤波,输入新的采样值,返回滤波输出 int32_t lpf_update(lpf_t *f, int32_t sample) { int32_t target = sample << 16; // 当前采样转Q16 int32_t diff = target - f->state; f->state += diff >> f->k; // 核心:移位代替乘法 return f->state >> 16; // 取高16位作为输出 }这段代码有几个细节值得说。第一,state用int32_t而不是uint32_t,因为差值diff可能是负数,有符号右移在大多数编译器上会做算术右移(保留符号位),这正是我们想要的。第二,sample << 16要求sample不能超过16位有符号数的范围,对于12位ADC来说绰绰有余。第三,diff >> f->k当diff为负时,右移会向负无穷方向取整,这和整数除法的截断方向不同,但在这个应用里影响可以忽略,因为余数会累积。
如果MCU的移位指令不支持变量移位(有些低端8位机只支持固定移位),可以把k写成宏或者用switch-case展开。不过现在大多数32位MCU都支持变量移位,问题不大。
3.4 初始值与启动策略
滤波器的初始值很关键。如果初始值设得不对,上电后输出会从错误的值慢慢“爬”到真实值,这个爬升过程可能持续好几秒,期间输出是不可信的。
最稳妥的做法是用第一次采样值直接初始化状态变量,就像上面代码里的lpf_init那样。这样上电后第一次输出就等于第一次采样,之后才开始滤波。如果第一次采样恰好有噪声,那输出也会带着这个噪声,但后续会很快收敛。
另一种做法是上电后先连续采样N次,取平均作为初始值,然后再启动滤波。这样初始值更干净,但启动延迟更长。具体选哪种,看项目对启动时间的要求。我一般用第一种,简单直接,而且一阶低通收敛很快,几个采样周期后就稳了。
实操心得:如果传感器上电后需要预热(比如某些气体传感器),那滤波器的初始值应该等预热完成后再设,否则预热期间的漂移会被滤波器“记住”,需要很长时间才能纠正。
4. 从原理到实战:位移法滤波的完整落地流程
4.1 需求分析与参数确定
拿到一个滤波需求,第一步不是写代码,而是搞清楚几个问题:信号带宽是多少?噪声频率大概在什么范围?采样率能设到多少?对响应延迟的容忍度是多少?
假设我在做一个超声波测距项目,传感器每50ms出一次距离值,也就是采样率20Hz。距离变化比较慢,人走路也就1m/s左右,对应距离变化率很小。噪声主要来自传感器本身的抖动和偶尔的误检,表现为单次跳变几十厘米的尖峰。我希望滤波后的距离平滑,延迟不超过200ms。
采样率20Hz,想要截止频率大概1Hz左右(能滤掉大部分抖动,又能跟上正常移动)。算一下:2^k ≈ 20 / (2π · 1) ≈ 3.18,最接近的2的幂是4,所以k=2,α=0.25。实际截止频率fc = 20 · 0.25 / (2π) ≈ 0.8Hz。延迟方面,一阶低通的时间常数τ = 1/(2π·fc) ≈ 0.2s,达到63%响应需要0.2s,达到95%需要3τ=0.6s。嗯,0.6s有点长,但考虑到距离变化本来就慢,可以接受。
如果觉得延迟太大,可以把k降到1,α=0.5,fc≈1.6Hz,τ≈0.1s,响应快一倍,但滤波效果会弱一些。这就是取舍。
4.2 代码集成与中断服务程序
滤波函数通常放在定时器中断或者ADC转换完成中断里调用。以定时器中断为例,假设定时器每50ms触发一次,在中断里读取传感器值,然后调用滤波函数。
volatile int32_t filtered_distance; lpf_t dist_filter; void TIMER_IRQHandler(void) { int32_t raw = read_distance_sensor(); // 读取原始值 filtered_distance = lpf_update(&dist_filter, raw); // 其他逻辑... }这里有个关键点:lpf_update的执行时间必须远小于中断周期。用位移法实现,这个函数在Cortex-M3上大概十几个时钟周期,50ms的中断周期完全无压力。如果用浮点实现,可能要几百个周期,虽然也够用,但留给其他任务的时间就少了。
另一个注意点是中断里的变量要用volatile修饰,防止编译器优化掉。filtered_distance被主循环读取,dist_filter被中断修改,都要注意可见性问题。如果主循环也要读写滤波器状态,最好加个简单的临界区保护,或者用原子操作。
4.3 实测数据与效果对比
我在一个模拟项目里对比了原始信号、浮点滤波和位移法滤波的效果。原始信号是12位ADC采集的,叠加了±20LSB的随机噪声,还有偶尔的±100LSB尖峰。采样率1kHz,目标截止频率10Hz。
浮点实现用α = 0.0625(和k=4等效),位移法用k=4。跑下来,两者的滤波输出几乎完全重合,差异在±1LSB以内,肉眼看不出来。但执行时间上,浮点版本每次滤波约120个时钟周期(Cortex-M0),位移法版本约8个时钟周期,差了15倍。
更关键的是代码体积。浮点版本链接了浮点库,Flash占用增加了约8KB;位移法版本纯整数运算,零额外库依赖。对于Flash只有64KB的芯片来说,这8KB可能就是能不能加个OTA功能的区别。
下面是一个简化的对比表:
| 指标 | 浮点实现 | 位移法实现 |
|---|---|---|
| 单次滤波时钟周期(M0) | ~120 | ~8 |
| 额外Flash占用 | ~8KB | 0 |
| 滤波效果 | 基准 | 几乎一致 |
| 系数调节粒度 | 任意 | 2的幂次 |
| 代码可移植性 | 依赖浮点库 | 纯整数,任意平台 |
4.4 多通道滤波的资源优化
实际项目里往往不止一路信号要滤波。比如一个姿态检测项目,加速度计三轴、陀螺仪三轴,六路信号都要滤波。如果每路都用一个32位状态变量,六个就是24字节,RAM占用不大。但如果MCU的RAM只有2KB,那就得精打细算了。
一个优化思路是共用移位位数k。如果六路信号的带宽要求差不多,可以用同一个k值,这样代码里只需要一个移位变量,省一点空间。另一个思路是降低状态变量的精度,比如用24位状态变量(Q8格式),RAM占用减半,但精度损失需要评估。
还有一种情况是滤波不需要一直运行。比如某些传感器只在特定模式下才开启,那滤波器状态可以复用同一块内存,用的时候初始化一下就行。这种“分时复用”在RAM紧张的项目里很常见。
注意事项:多通道滤波时,每个通道的状态变量必须独立,不能共用一个state。我曾经见过有人为了省内存,把六路信号轮流用同一个滤波器,结果通道之间互相干扰,数据完全不可用。这个坑千万别踩。
5. 位移法滤波的常见问题与排查技巧
5.1 输出“卡死”不动怎么办
这是位移法最典型的问题。现象是:输入信号明明在变,但滤波输出纹丝不动,或者变化极其缓慢。原因通常是差值右移后变成0了。
举个例子:k=8,α=1/256。如果X(n) - Y(n-1)的绝对值小于256,右移8位后就是0(正数)或-1(负数,算术右移)。如果一直是0,状态变量就不更新,输出自然卡死。
解决办法有两个。一是用我前面说的Q16状态变量,差值先左移16位再右移k位,等效于把差值放大了65536倍,小差值也不会被截断成0。二是如果不想用高精度状态,可以在差值上加一个“最小步进”,比如diff = (diff > 0) ? max(diff, 1) : min(diff, -1),保证每次至少动1。但这种方法会引入额外的非线性,一般不推荐。
5.2 滤波后仍有尖峰跳变
一阶低通对单次尖峰的抑制能力有限。如果尖峰幅度很大,滤波后虽然被衰减了,但可能仍然可见。比如原始尖峰100,α=1/16,滤波输出会跳变100/16 ≈ 6,如果信号本身只有±10的波动,这6的跳变就很明显了。
要彻底干掉尖峰,得用中值滤波或者限幅滤波配合。常见做法是“限幅+低通”:先判断本次采样和上次采样的差值,如果超过阈值就丢弃或者限幅,然后再送低通。这样尖峰在进入低通之前就被削掉了。
限幅阈值怎么定?看信号的正常变化率。比如温度信号,每秒变化不超过0.1度,采样间隔1秒,那阈值设0.5度就够。超过0.5度的变化肯定是噪声或者干扰,直接限幅到0.5度。
5.3 响应延迟太大怎么调
延迟大是因为α太小。解决办法无非是增大α(减小k),或者提高采样率。但提高采样率会增加CPU负担,而且如果信号本身带宽很低,提高采样率对滤波效果帮助不大,只是让延迟在时间轴上看起来小一点。
一个折中方案是“变系数滤波”:在信号快速变化时用大α(快速响应),信号稳定时用小α(强滤波)。判断信号是否快速变化,可以看原始采样值的差值。差值大就说明在变化,用大α;差值小就用小α。这种自适应滤波在跟踪类应用里很好用,但实现起来稍微复杂一点,需要维护两套k值。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决措施 |
|---|---|---|---|
| 输出卡死不动 | 差值右移后为0 | 打印差值,看是否小于2^k | 用Q16状态变量,或减小k |
| 滤波后仍有尖峰 | 一阶低通抑制能力不足 | 观察尖峰幅度和α的关系 | 前置限幅或中值滤波 |
| 响应延迟大 | α太小 | 计算时间常数τ | 减小k,或提高采样率 |
| 输出有周期性波动 | 采样与干扰同步 | 改变采样时刻,观察波动是否变化 | 调整采样率或加随机抖动 |
| 多通道互相干扰 | 状态变量共用 | 检查代码是否共用state | 每通道独立state |
| 上电后输出爬升慢 | 初始值设错 | 检查初始化逻辑 | 用第一次采样值初始化 |
| 滤波效果时好时坏 | 采样率不稳定 | 用示波器测中断周期 | 确保定时器配置正确 |
5.5 独家避坑经验
第一个坑:右移的符号问题。在C语言里,有符号负数的右移是implementation-defined,大多数编译器做算术右移(补符号位),但标准并没有强制。如果换了个编译器,行为可能不同。保险的做法是用除法代替右移,但除法更慢。或者加个判断:如果diff是负数,先取绝对值右移再取负。不过这样代码就多了,我一般直接假设算术右移,然后在代码注释里写明依赖这个行为。
第二个坑:状态变量的溢出。如果采样值范围很大,左移16位后可能溢出int32。比如采样值是16位有符号数,最大值32767,左移16位就是2147418112,接近int32上限2147483647,再加点余数就溢出了。所以采样值最好限制在15位以内,或者用int64状态变量。对于12位ADC,完全不用担心。
第三个坑:k=0的情况。如果k=0,diff >> 0就是diff本身,滤波输出直接等于采样值,完全没有滤波效果。有些代码里k是配置参数,如果配置成0就会出问题。建议在初始化时检查k的范围,至少为1,最大不超过15(对于Q16状态)。
第四个坑:中断里的浮点运算。虽然我们用了位移法避免了浮点,但如果项目里其他地方有浮点运算,而中断里又调用了浮点函数,可能会因为浮点上下文保存导致中断延迟增大。这个和位移法本身无关,但值得注意。
6. 位移法滤波的扩展玩法与性能边界
6.1 从一阶到二阶:级联实现更陡的滚降
一阶低通的阻带衰减是-20dB/十倍频程,也就是说频率每升高10倍,幅度衰减到1/10。如果觉得不够陡,可以把两个一阶低通级联起来,变成二阶,衰减变成-40dB/十倍频程。实现上就是跑两次滤波函数,第一次的输出作为第二次的输入。
但级联会带来额外延迟,而且两个滤波器的截止频率要匹配。如果两个都用相同的k,级联后的截止频率会略低于单个的截止频率,大约是单个的0.64倍。这个系数来自二阶系统的传递函数,感兴趣可以自己推导。
级联的代码很简单:
int32_t lpf2_update(lpf_t *f1, lpf_t *f2, int32_t sample) { int32_t y1 = lpf_update(f1, sample); return lpf_update(f2, y1); }注意两个滤波器的状态变量要独立,不能共用。
6.2 变采样率下的系数调整
有些项目里采样率不是固定的,比如根据信号变化动态调整采样间隔。这时候固定的k值就不适用了,因为截止频率会随采样率漂移。解决办法是维护一个k值的查找表,根据当前采样率查表得到k。或者更简单:固定采样率,别动态调。动态采样率带来的收益往往不值得增加的复杂度。
如果非要动态调,可以用“采样率归一化”的思路:把滤波器的差分方程改写成时间常数形式,然后根据实际采样间隔计算等效的α。但这又回到浮点了,失去了位移法的优势。所以我的建议是:位移法适合固定采样率的场景,动态采样率还是老老实实用浮点或者定点乘法。
6.3 位移法的性能边界在哪里
位移法不是万能的。它的优势在于极低的计算开销和零浮点依赖,劣势在于系数离散、精度受限于移位位数。对于大多数传感器信号滤波,这些劣势可以忽略。但在以下场景里,位移法可能不够用:
一是需要精确截止频率的场合。比如音频处理,截止频率偏差几Hz就能听出来,位移法的离散系数满足不了。二是需要极低截止频率的场合。比如采样率1kHz,想要0.1Hz截止频率,需要k≈11,α≈1/2048,这时候差值右移11位后很容易变成0,需要很高的状态变量精度。三是需要线性相位或者特定频响的场合,一阶低通本身就不满足,得用FIR或者双二阶滤波器。
所以,位移法适合的是“对滤波精度要求不高、但对计算资源和代码体积敏感”的场景。这恰好覆盖了大多数嵌入式传感器采集应用。
6.4 和其他滤波方法的对比选型
| 滤波方法 | 计算开销 | 滤波效果 | 资源占用 | 适用场景 |
|---|---|---|---|---|
| 位移法一阶低通 | 极低 | 一般 | 极少 | 传感器平滑、资源紧张 |
| 浮点一阶低通 | 高 | 一般 | 较多 | 有FPU、系数需精确 |
| 中值滤波 | 中 | 抗尖峰强 | 需要缓冲区 | 去除脉冲噪声 |
| 滑动平均 | 低 | 一般 | 需要缓冲区 | 周期性噪声 |
| 卡尔曼滤波 | 高 | 好 | 较多 | 多传感器融合 |
| 限幅滤波 | 极低 | 抗尖峰 | 极少 | 配合低通使用 |
实际项目里往往是组合使用。比如“限幅+位移法低通”就是很经典的搭配,限幅干掉大尖峰,低通平滑小波动,两者互补,计算开销都很低。
6.5 一个完整的实战案例:模拟项目X的传感器平滑
模拟项目X是一个环境监测节点,用模拟传感器采集温湿度,通过无线模块上报。传感器原始数据有±2的随机波动,偶尔有±10的尖峰。MCU是低端32位内核,RAM 4KB,Flash 32KB,没有FPU。采样率1Hz,要求滤波后波动不超过±0.5,延迟不超过5秒。
参数计算:采样率1Hz,想要截止频率0.05Hz(时间常数约3.2秒,5秒内能稳定)。2^k ≈ 1 / (2π · 0.05) ≈ 3.18,取k=2,α=0.25。实际截止频率fc = 1 · 0.25 / (2π) ≈ 0.04Hz,时间常数约4秒,满足要求。
代码实现:用Q16状态变量,k=2。前置一个限幅滤波,阈值设为5(超过5的变化认为是尖峰,限幅到5)。整个滤波函数不到20行,编译后代码体积增加不到200字节,RAM增加4字节。实测滤波后波动在±0.3以内,尖峰完全消除,延迟约3秒,满足项目要求。
这个案例里,位移法的优势体现得淋漓尽致:零浮点依赖、极低资源占用、效果达标。如果换成浮点实现,Flash可能要多占8KB,对于32KB的芯片来说就是25%的空间,代价太大。
6.6 调试工具与验证方法
调试滤波效果,最直接的方法是把原始值和滤波值都通过串口打印出来,然后在PC上画图对比。如果MCU没有串口,可以用PWM或者DAC输出模拟量,用示波器看波形。还有一种方法是把数据存到RAM缓冲区,触发后一次性读出。
验证滤波效果时,可以人为注入噪声:比如在传感器信号上叠加一个已知频率的正弦波,看滤波后的衰减是否符合预期。或者注入阶跃信号,看响应时间是否和计算的一致。这些测试能帮你确认滤波器是否按预期工作。
实操心得:我习惯在滤波函数里加一个调试变量,记录最近一次差值的绝对值。如果这个值长期为0,说明滤波器卡死了;如果这个值经常很大,说明噪声或者干扰严重。这个变量在调试时非常有用,正式发布时可以去掉。
7. 写在最后的一些个人体会
位移法实现一阶低通滤波,说到底就是一个“用空间换时间”的思路——牺牲系数的连续可调性,换取极致的计算效率和零浮点依赖。在嵌入式领域,这种取舍几乎无处不在。我做了这么多年项目,越来越觉得,没有最好的方案,只有最合适的方案。浮点滤波在PC上跑得好好的,到了单片机上可能就是灾难;位移法看起来“土”,但在资源受限的场景里就是最优解。
踩过几次坑之后,我养成了一个习惯:每次用位移法之前,先算清楚k值和时间常数,确认延迟在可接受范围内;然后一定要用Q16状态变量,避免卡死;最后一定要实测,用真实数据验证滤波效果,不能只看理论计算。理论算出来fc=10Hz,实际可能因为采样抖动变成8Hz,这些偏差只有实测才能发现。
另外,别迷信“一阶低通能解决所有噪声问题”。有些噪声的频谱很宽,一阶低通滤不干净;有些尖峰幅度太大,低通之后仍然可见。该上限幅就上限幅,该上中值就上中值,组合拳往往比单一滤波器效果好得多。
这个内容后续还可以这样扩展:把位移法用到PID控制器的微分项上,用移位代替微分增益的乘法;或者把位移法和查表法结合,实现任意系数的定点滤波。这些玩法都很有意思,等有机会再聊。