1. 为什么偏偏选位移法来实现一阶低通滤波搞过单片机信号采集的人都有一个共同的痛:传感器出来的数据永远在跳。你盯着串口助手里那一串上下翻飞的数字,明明被测对象稳如泰山,采样值却能给你演出心电图的效果。这时候老工程师通常会拍拍你肩膀说,加个滤波吧。于是你翻开教材,一阶低通滤波的公式赫然在目:Y(n) = α × X(n) + (1 - α) × Y(n-1)公式简单到令人发指,但真正落到单片机上,问题就来了。α 这个系数通常是个小于 1 的小数,比如 0.1、0.05 甚至 0.01。如果你用的是没有硬件浮点单元的 8 位或低端 32 位芯片,每一次滤波都要做一次浮点乘法和一次浮点加法,中断频率一高,CPU 的算力就被这个小小的滤波吃掉了相当一部分。更别提有些场景下你压根不想引入浮点运算库,代码体积和运行效率都要精打细算。位移法就是在这种背景下被大量一线工程师采用的一种实现技巧。它的核心思路非常朴素:把 α 取成 1/2 的整数次幂,然后用右移操作代替除法或浮点乘法。比如 α = 1/16,那么 α × X(n) 就等价于 X(n) >> 4。移位指令在几乎所有单片机上都是单周期或极少周期的操作,速度快到可以忽略不计。这个内容适合谁看?如果你正在做传感器数据采集、电机控制、电源管理、音频预处理这类需要实时滤波的嵌入式项目,并且对代码执行效率有要求,那这套方法你大概率用得上。哪怕你是刚接触单片机的初学者,只要理解二进制和移位的基本概念,也能跟着把整套逻辑跑通。我会从原理推导讲起,把参数怎么选、代码怎么写、定点化怎么处理、实际调试中会遇到什么坑,全部掰开揉碎说清楚。2. 一阶低通滤波的核心原理拆解2.1 从模拟RC电路到数字差分方程一阶低通滤波这个概念最早来自模拟电路里的 RC 低通网络。一个电阻串一个电容,输入信号的高频成分被电容旁路到地,输出就变得平滑。这个电路的传递函数是:H(s) = 1 / (1 + RC·s)其中 RC 是时间常数 τ。到了数字域,我们用离散的采样点来近似这个连续过程,就得到了那个经典的差分方程。它的物理意义很直观:新的输出值 = 一部分新的输入 + 剩下部分旧的输出。α 越大,越信任新数据,响应快但滤波效果弱;α 越小,越信任历史数据,输出平滑但滞后严重。这里有个容易被忽略的点:α 和截止频率的关系。在采样周期为 T 的系统中,截止频率 fc 近似满足:α ≈ 2π·fc·T / (1 + 2π·fc·T)反过来,如果你先确定了想要的截止频率,可以据此反推 α。比如采样率 1kHz(T = 1ms),想要 10Hz 的截止频率,那么 2π×10×0.001 ≈ 0.0628,α ≈ 0.059,取个近似值 1/16 = 0.0625 就非常合适。这也是为什么位移法里 α 常取 1/8、1/16、1/32 这些值——它们既接近常用的截止频率需求,又能用移位干净利落地实现。2.2 位移法为什么能替代浮点运算关键就在于 α 的取值。当 α = 1/2^n 时,α × X(n) 就是 X(n) 右移 n 位。而 (1 - α) = (2^n - 1)/2^n,这个系数乘以 Y(n-1) 稍微麻烦一点,但可以变形处理。把原公式展开:Y(n) = X(n)/2^n + Y(n-1) - Y(n-1)/2^nY(n) = Y(n-1) + (X(n) - Y(n-1)) / 2^n这个变形后的公式是位移法实现的精髓。它把两次乘法变成了一次减法加一次移位。具体来说:计算当前输入与上次输出的差值:diff = X(n) - Y(n-1)把差值右移 n 位:diff >> n累加到上次输出上:Y(n) = Y(n-1) + (diff >> n)整个过程只有减法、移位、加法三种操作,全部是整数运算。在 8 位单片机上,如果数据类型选得合适,这几条指令加起来也就十几个机器周期,比浮点运算快一到两个数量级。注意:右移操作对负数的处理在不同编译器上行为可能不同。diff 完全可能是负数,算术右移和逻辑右移的结果天差地别。这一点后面会专门讲。2.3 滤波效果与相位的取舍关系任何低通滤波都不是免费的午餐。α 越小,输出越平滑,但相位滞后越大。在控制系统中,这个滞后可能直接导致环路不稳定。我见过不少人在电机电流环里把滤波加得太狠,结果响应迟钝、震荡加剧,回头还找不到原因。从经验上看,对于一般的传感器去噪,α 取 1/16 到 1/64 是比较舒服的区间。对于需要快速响应的场合,比如过流保护,α 不要小于 1/8。如果你实在拿不准,可以先在 MATLAB 或者 Python 里把阶跃响应仿真一下,看看上升时间和超调量能不能接受,再决定移位位数。3. 位移法实现的完整技术细节3.1 数据类型选择与溢出防范位移法虽然简单,但数据类型选错了照样翻车。假设你用 8 位无符号数存 ADC 采样值,范围 0 到 255。如果 Y(n-1) 是 200,X(n) 是 50,那么 diff = 50 - 200 = -150。在 8 位无符号运算里,这个 -150 会变成一个巨大的正数(比如 106),右移之后加回去,输出直接飞掉。所以第一条铁律:中间变量必须用有符号类型。推荐用 int16_t 或 int32_t,具体取决于你的 ADC 位数和精度要求。对于 12 位 ADC(0 到 4095),int16_t 完全够用,diff 的范围在 -4095 到 4095 之间,不会溢出。对于 16 位 ADC,就得用 int32_t 了。另外,Y(n) 的存储类型也要和中间变量一致。很多人习惯把滤波结果存在 uint16_t 里,然后在计算时临时转成 int,这样容易在类型转换的边界上出问题。统一用有符号类型,省心。3.2 移位位数的选择与截止频率对照移位位数 n 直接决定了 α = 1/2^n,也就决定了滤波强度。下面这张表是我在实际项目中总结的常用对照关系,采样率假设为 1kHz:移位位数 nα 值近似截止频率适用场景30.125约 20Hz快速响应,轻度滤波40.0625约 10Hz一般传感器去噪50.03125约 5Hz温度、压力等慢变量60.015625约 2.5Hz高精度静态测量80.00390625约 0.6Hz极慢变化信号这张表不是死规矩,但能给你一个快速定位的参考。实际选的时候,先想清楚你的信号带宽是多少,噪声主要分布在哪个频段,然后反推需要的截止频率,再对照选 n。3.3 定点化处理的精度损失分析位移法本质上是定点运算,精度损失主要来自两个方面。一是右移操作直接丢弃低位,相当于截断而非四舍五入。二是当 diff 很小时,diff >> n 可能直接变成 0,导致输出停止更新,出现所谓的“死区”。举个例子,n = 8,diff = 100,100 >> 8 = 0。也就是说,当输入和输出的差值小于 256 时,滤波器就不再响应了。对于 12 位 ADC 来说,256 相当于满量程的 6.25%,这个死区相当可观。解决办法有两个。一是减小 n,让死区变小。二是采用带舍入的移位,比如:Y(n) = Y(n-1) + ((diff + (1 << (n-1))) >> n)这样相当于加了半个步长的偏移,能有效减小死区,代价是多一次加法和一次移位。在算力允许的情况下,我强烈建议加上这个舍入。4. 单片机实战:从零搭建一个可用的滤波模块4.1 硬件平台与开发环境说明为了把内容讲得具体,我这里以一个常见的 32 位 Cortex-M0 内核单片机为例,主频 48MHz,没有硬件浮点单元,ADC 为 12 位、采样率 1kHz。开发环境用常见的嵌入式 C 工具链。这套配置在工业传感器、小家电控制板里非常普遍,参考价值比较高。代码本身不依赖任何特定厂商的库,纯标准 C,移植到 8 位平台上只需要改一下数据类型即可。4.2 核心滤波函数的三种写法对比先看最直观的浮点写法,作为对照:float alpha = 0.0625f; float y_prev = 0.0f; float lowpass_float(float x) { float y = alpha * x + (1.0f - alpha) * y_prev; y_prev = y; return y; }这段代码清晰,但每次调用至少两次浮点乘法加一次浮点加法。在没有 FPU 的芯片上,一次浮点乘法可能就要几十个周期。再看直接移位写法:int32_t y_prev = 0; #define SHIFT_N 4 int32_t lowpass_shift(int32_t x) { int32_t diff = x - y_prev; y_prev = y_prev + (diff >> SHIFT_N); return y_prev; }简洁到极致,但存在前面说的截断死区问题。最后是带舍入的改进版:int32_t y_prev = 0; #define SHIFT_N 4 #define ROUND_BIAS (1 << (SHIFT_N - 1)) int32_t lowpass_shift_round(int32_t x) { int32_t diff = x - y_prev; y_prev = y_prev + ((diff + ROUND_BIAS) >> SHIFT_N); return y_prev; }这个版本多了一次加法和一个常量,但死区明显减小,输出更接近浮点版本的效果。我在实际项目里基本都用这个版本。4.3 初始化与运行时参数调整滤波器的初始值 y_prev 怎么设?如果设成 0,而实际信号在 2000 左右,那么开机后输出会从 0 慢慢爬升到 2000,这个爬升过程可能持续好几秒。对于某些应用来说,这几秒的过渡是不可接受的。常见的做法是用第一次采样值直接初始化:void lowpass_init(int32_t first_sample) { y_prev = first_sample; }这样开机瞬间输出就等于输入,之后才开始滤波。如果你希望更平滑一点,也可以用前几次采样的平均值来初始化。运行时调整移位位数也是常见需求。比如系统在快速模式和慢速模式之间切换,滤波强度也要跟着变。这时候可以把 SHIFT_N 改成变量,但要注意移位位数是变量的情况下,编译器可能无法优化成单条移位指令,性能会打折扣。如果对性能敏感,可以用 switch-case 把几种常用位数写死。4.4 实测数据与效果验证我在一个温度采集项目里实测过这套滤波。传感器是常见的数字温度芯片,分辨率 0.0625 度,采样率 1Hz。原始数据在 25.0 到 25.5 度之间跳动,峰峰值约 0.5 度。用 n = 4 的位移法滤波后,输出稳定在 25.2 到 25.3 度之间,峰峰值压缩到 0.1 度以内,而且响应时间在可接受范围内。用示波器观察阶跃响应,从 25 度跳到 30 度时,滤波输出大约经过 15 个采样周期达到最终值的 90%。这个结果和理论计算基本吻合,说明位移法在慢变量场景下完全够用。5. 常见问题与排查技巧实录5.1 输出震荡或飞掉的排查思路这是位移法最容易踩的坑,根源几乎都出在数据类型上。排查顺序建议如下:检查 diff 的计算是否用了有符号类型。如果 x 和 y_prev 都是无符号,减法结果会回绕。检查右移操作符作用在什么类型上。对无符号数右移是逻辑移位,对负数会补 0 而不是补 1,结果完全错误。检查 y_prev 的更新是否在中断和主循环之间共享。如果中断里在写、主循环里在读,没有保护的话可能读到半更新的值。我遇到过一次典型情况:某开发者在 8 位机上用 uint8_t 存 y_prev,diff 也是 uint8_t,结果输入从 100 降到 50 时,diff 算出来是 206,右移后加回去,输出直接冲到 250 以上,然后系统就失控了。改成 int16_t 之后问题立刻消失。5.2 滤波滞后过大的参数调整如果发现输出跟不上输入变化,先确认是不是 n 选大了。把 n 减小 1 或 2,滞后会明显改善。但减小 n 会削弱滤波效果,需要在两者之间找平衡。另一个容易被忽视的因素是采样率。如果采样率远高于信号带宽,那么即使 α 不小,等效截止频率也会很低,造成不必要的滞后。这时候可以考虑降低采样率,或者改用滑动平均等其他滤波方式。还有一种情况是信号本身含有较大的直流偏置,而噪声是高频的。这时候可以先减去偏置再做滤波,或者用高通加低通的组合,效果比单纯加大滤波强度好得多。5.3 多通道滤波的资源优化实际项目里往往不止一路信号要滤波。如果每路都独立写一套变量和函数,代码会显得很臃肿。更好的做法是把状态封装成结构体:typedef struct { int32_t y_prev; uint8_t shift_n; } lpf_t; int32_t lpf_update(lpf_t *f, int32_t x) { int32_t diff = x - f->y_prev; int32_t bias = 1 << (f->shift_n - 1); f->y_prev += (diff + bias) >> f->shift_n; return f->y_prev; }这样每路信号只需要一个 lpf_t 实例,代码复用率高,也方便统一管理。代价是 shift_n 变成变量后,移位不再是编译期常量,性能略有下降。如果通道数不多且算力充裕,这点损失可以接受。5.4 常见问题速查表现象可能原因解决方法输出飞掉或震荡数据类型无符号导致回绕改用 int16_t 或 int32_t输出长期停在某个值不动移位死区过大加舍入偏置或减小 n滤波效果不明显n 太小或采样率过高增大 n 或降低采样率响应太慢n 太大减小 n,或改用变系数方案多通道互相干扰变量未隔离或中断冲突用结构体封装,加临界区保护开机输出爬升慢初始值设为 0用首次采样值初始化提示:在中断里调用滤波函数时,尽量保证滤波变量只在该中断中被访问。如果主循环也要读滤波结果,建议读完后立即拷贝到另一个变量,避免数据竞争。6. 位移法的扩展玩法与进阶思路6.1 变步长滤波:快慢自适应的实现固定 n 的位移法有个天然矛盾:快和稳不可兼得。一个实用的改进是根据信号变化率动态调整 n。当 diff 很大时,说明信号在快速变化,此时用较小的 n 快速跟踪;当 diff 很小时,说明信号稳定,用较大的 n 强力滤波。实现上可以设两个阈值:int32_t lpf_adaptive(int32_t x) { int32_t diff = x - y_prev; int32_t abs_diff = (diff >= 0) ? diff : -diff; uint8_t n; if (abs_diff > 500) { n = 2; } else if (abs_diff > 100) { n = 4; } else { n = 6; } int32_t bias = 1 << (n - 1); y_prev += (diff + bias) >> n; return y_prev; }这段代码在信号突变时响应快,稳态时又足够平滑。代价是多了一次绝对值计算和几次比较,但相比浮点运算还是轻量得多。我在一个手持设备项目里用过这个思路,效果比固定系数好不少。6.2 级联位移滤波实现更陡的滚降一阶滤波的滚降是 -20dB/十倍频,对某些应用来说不够。把两个一阶位移滤波器级联起来,就能得到接近二阶的效果,滚降变成 -40dB/十倍频。实现上就是连续调用两次滤波函数,每级有自己的状态变量。需要注意的是,级联后相位滞后会累加,总滞后大约是单级的两倍。所以级联适合对相位不敏感的场景,比如静态测量、显示平滑等。控制环路里要慎用。6.3 与滑动平均的对比与组合使用滑动平均是另一种常见的轻量滤波,它把最近 N 个采样值求平均。相比位移法,滑动平均对周期性噪声的抑制更好,但需要存储 N 个历史值,内存开销大,而且每次都要做 N 次加法。两者可以组合:先用滑动平均去掉明显的周期性干扰,再用位移法做最后的平滑。或者反过来,先用位移法快速压制高频噪声,再用滑动平均进一步稳定输出。具体怎么组合,取决于你的噪声特性和资源预算。我个人在大多数场景下更倾向位移法,因为它内存占用极小,代码简单,调试直观。只有在噪声频谱比较特殊、位移法效果不理想时,才会考虑引入滑动平均。6.4 在中断服务函数中的部署要点滤波函数经常放在 ADC 中断里调用。这时候有几个细节要注意。第一,滤波函数本身要尽量短,避免在中断里做复杂判断。第二,如果用了自适应变步长,阈值比较和分支会增加执行时间,要评估中断频率和 CPU 负载。第三,滤波变量如果也要在主循环里用,记得加 volatile 或者用临界区保护。我一般的做法是:中断里只做滤波和存结果,主循环里读结果做后续处理。滤波变量用结构体封装,中断和主循环各操作各的副本,通过一个标志位或者队列来传递数据。这样既保证了实时性,又避免了竞争。7. 一些实操心得与踩坑记录位移法看起来简单,但真正在项目里用好,还是有一些只可意会的东西。我把自己这些年踩过的坑和总结的经验列几条,供你参考。第一条,移位位数不要超过数据有效位数的一半。比如 12 位 ADC,有效数据 12 位,n 最好不要超过 6。超过之后死区会大到影响正常测量,滤波输出会显得“迟钝”甚至“卡死”。这个界限不是绝对的,但可以作为快速判断的依据。第二条,调试时先把滤波关掉,确认原始数据正常再开滤波。我见过有人花半天调滤波参数,最后发现是传感器接线松动导致数据本身就在乱跳。滤波只能处理噪声,不能修复错误的数据源。第三条,用串口或者调试器把滤波前后的数据同时输出,画成曲线对比。光看数字很难判断滤波效果,画成波形一目了然。我通常会用简单的上位机工具把两组数据实时绘制出来,调参效率高很多。第四条,不同信号不要共用一套滤波参数。温度、压力、电流的变化速率差异很大,用同一个 n 必然有一路效果不好。多花几行代码给每路独立配置,后期维护会轻松很多。第五条,记得处理滤波器的复位。系统重启、传感器重新初始化、量程切换时,滤波器的历史状态应该清零或者用新数据重新初始化,否则会有一段莫名其妙的过渡过程。最后再分享一个小技巧:如果你不确定 n 取多少合适,可以先用浮点版本在 PC 上仿真,把不同 α 对应的阶跃响应和噪声抑制效果都跑一遍,找到满意的 α 之后,再换算成最接近的 1/2^n。这样比在单片机上反复烧录试参数快得多。换算的时候记住,α = 1/2^n,n 取整数,所以实际 α 和仿真值会有偏差,选最接近的那个就行。
1. 为什么偏偏选位移法来实现一阶低通滤波搞过单片机信号采集的人都有一个共同的痛:传感器出来的数据永远在跳。你盯着串口助手里那一串上下翻飞的数字,明明被测对象稳如泰山,采样值却能给你演出心电图的效果。这时候老工程师通常会拍拍你肩膀说,加个滤波吧。于是你翻开教材,一阶低通滤波的公式赫然在目:Y(n) = α × X(n) + (1 - α) × Y(n-1)公式简单到令人发指,但真正落到单片机上,问题就来了。α 这个系数通常是个小于 1 的小数,比如 0.1、0.05 甚至 0.01。如果你用的是没有硬件浮点单元的 8 位或低端 32 位芯片,每一次滤波都要做一次浮点乘法和一次浮点加法,中断频率一高,CPU 的算力就被这个小小的滤波吃掉了相当一部分。更别提有些场景下你压根不想引入浮点运算库,代码体积和运行效率都要精打细算。位移法就是在这种背景下被大量一线工程师采用的一种实现技巧。它的核心思路非常朴素:把 α 取成 1/2 的整数次幂,然后用右移操作代替除法或浮点乘法。比如 α = 1/16,那么 α × X(n) 就等价于 X(n) >> 4。移位指令在几乎所有单片机上都是单周期或极少周期的操作,速度快到可以忽略不计。这个内容适合谁看?如果你正在做传感器数据采集、电机控制、电源管理、音频预处理这类需要实时滤波的嵌入式项目,并且对代码执行效率有要求,那这套方法你大概率用得上。哪怕你是刚接触单片机的初学者,只要理解二进制和移位的基本概念,也能跟着把整套逻辑跑通。我会从原理推导讲起,把参数怎么选、代码怎么写、定点化怎么处理、实际调试中会遇到什么坑,全部掰开揉碎说清楚。2. 一阶低通滤波的核心原理拆解2.1 从模拟RC电路到数字差分方程一阶低通滤波这个概念最早来自模拟电路里的 RC 低通网络。一个电阻串一个电容,输入信号的高频成分被电容旁路到地,输出就变得平滑。这个电路的传递函数是:H(s) = 1 / (1 + RC·s)其中 RC 是时间常数 τ。到了数字域,我们用离散的采样点来近似这个连续过程,就得到了那个经典的差分方程。它的物理意义很直观:新的输出值 = 一部分新的输入 + 剩下部分旧的输出。α 越大,越信任新数据,响应快但滤波效果弱;α 越小,越信任历史数据,输出平滑但滞后严重。这里有个容易被忽略的点:α 和截止频率的关系。在采样周期为 T 的系统中,截止频率 fc 近似满足:α ≈ 2π·fc·T / (1 + 2π·fc·T)反过来,如果你先确定了想要的截止频率,可以据此反推 α。比如采样率 1kHz(T = 1ms),想要 10Hz 的截止频率,那么 2π×10×0.001 ≈ 0.0628,α ≈ 0.059,取个近似值 1/16 = 0.0625 就非常合适。这也是为什么位移法里 α 常取 1/8、1/16、1/32 这些值——它们既接近常用的截止频率需求,又能用移位干净利落地实现。2.2 位移法为什么能替代浮点运算关键就在于 α 的取值。当 α = 1/2^n 时,α × X(n) 就是 X(n) 右移 n 位。而 (1 - α) = (2^n - 1)/2^n,这个系数乘以 Y(n-1) 稍微麻烦一点,但可以变形处理。把原公式展开:Y(n) = X(n)/2^n + Y(n-1) - Y(n-1)/2^nY(n) = Y(n-1) + (X(n) - Y(n-1)) / 2^n这个变形后的公式是位移法实现的精髓。它把两次乘法变成了一次减法加一次移位。具体来说:计算当前输入与上次输出的差值:diff = X(n) - Y(n-1)把差值右移 n 位:diff >> n累加到上次输出上:Y(n) = Y(n-1) + (diff >> n)整个过程只有减法、移位、加法三种操作,全部是整数运算。在 8 位单片机上,如果数据类型选得合适,这几条指令加起来也就十几个机器周期,比浮点运算快一到两个数量级。注意:右移操作对负数的处理在不同编译器上行为可能不同。diff 完全可能是负数,算术右移和逻辑右移的结果天差地别。这一点后面会专门讲。2.3 滤波效果与相位的取舍关系任何低通滤波都不是免费的午餐。α 越小,输出越平滑,但相位滞后越大。在控制系统中,这个滞后可能直接导致环路不稳定。我见过不少人在电机电流环里把滤波加得太狠,结果响应迟钝、震荡加剧,回头还找不到原因。从经验上看,对于一般的传感器去噪,α 取 1/16 到 1/64 是比较舒服的区间。对于需要快速响应的场合,比如过流保护,α 不要小于 1/8。如果你实在拿不准,可以先在 MATLAB 或者 Python 里把阶跃响应仿真一下,看看上升时间和超调量能不能接受,再决定移位位数。3. 位移法实现的完整技术细节3.1 数据类型选择与溢出防范位移法虽然简单,但数据类型选错了照样翻车。假设你用 8 位无符号数存 ADC 采样值,范围 0 到 255。如果 Y(n-1) 是 200,X(n) 是 50,那么 diff = 50 - 200 = -150。在 8 位无符号运算里,这个 -150 会变成一个巨大的正数(比如 106),右移之后加回去,输出直接飞掉。所以第一条铁律:中间变量必须用有符号类型。推荐用 int16_t 或 int32_t,具体取决于你的 ADC 位数和精度要求。对于 12 位 ADC(0 到 4095),int16_t 完全够用,diff 的范围在 -4095 到 4095 之间,不会溢出。对于 16 位 ADC,就得用 int32_t 了。另外,Y(n) 的存储类型也要和中间变量一致。很多人习惯把滤波结果存在 uint16_t 里,然后在计算时临时转成 int,这样容易在类型转换的边界上出问题。统一用有符号类型,省心。3.2 移位位数的选择与截止频率对照移位位数 n 直接决定了 α = 1/2^n,也就决定了滤波强度。下面这张表是我在实际项目中总结的常用对照关系,采样率假设为 1kHz:移位位数 nα 值近似截止频率适用场景30.125约 20Hz快速响应,轻度滤波40.0625约 10Hz一般传感器去噪50.03125约 5Hz温度、压力等慢变量60.015625约 2.5Hz高精度静态测量80.00390625约 0.6Hz极慢变化信号这张表不是死规矩,但能给你一个快速定位的参考。实际选的时候,先想清楚你的信号带宽是多少,噪声主要分布在哪个频段,然后反推需要的截止频率,再对照选 n。3.3 定点化处理的精度损失分析位移法本质上是定点运算,精度损失主要来自两个方面。一是右移操作直接丢弃低位,相当于截断而非四舍五入。二是当 diff 很小时,diff >> n 可能直接变成 0,导致输出停止更新,出现所谓的“死区”。举个例子,n = 8,diff = 100,100 >> 8 = 0。也就是说,当输入和输出的差值小于 256 时,滤波器就不再响应了。对于 12 位 ADC 来说,256 相当于满量程的 6.25%,这个死区相当可观。解决办法有两个。一是减小 n,让死区变小。二是采用带舍入的移位,比如:Y(n) = Y(n-1) + ((diff + (1 << (n-1))) >> n)这样相当于加了半个步长的偏移,能有效减小死区,代价是多一次加法和一次移位。在算力允许的情况下,我强烈建议加上这个舍入。4. 单片机实战:从零搭建一个可用的滤波模块4.1 硬件平台与开发环境说明为了把内容讲得具体,我这里以一个常见的 32 位 Cortex-M0 内核单片机为例,主频 48MHz,没有硬件浮点单元,ADC 为 12 位、采样率 1kHz。开发环境用常见的嵌入式 C 工具链。这套配置在工业传感器、小家电控制板里非常普遍,参考价值比较高。代码本身不依赖任何特定厂商的库,纯标准 C,移植到 8 位平台上只需要改一下数据类型即可。4.2 核心滤波函数的三种写法对比先看最直观的浮点写法,作为对照:float alpha = 0.0625f; float y_prev = 0.0f; float lowpass_float(float x) { float y = alpha * x + (1.0f - alpha) * y_prev; y_prev = y; return y; }这段代码清晰,但每次调用至少两次浮点乘法加一次浮点加法。在没有 FPU 的芯片上,一次浮点乘法可能就要几十个周期。再看直接移位写法:int32_t y_prev = 0; #define SHIFT_N 4 int32_t lowpass_shift(int32_t x) { int32_t diff = x - y_prev; y_prev = y_prev + (diff >> SHIFT_N); return y_prev; }简洁到极致,但存在前面说的截断死区问题。最后是带舍入的改进版:int32_t y_prev = 0; #define SHIFT_N 4 #define ROUND_BIAS (1 << (SHIFT_N - 1)) int32_t lowpass_shift_round(int32_t x) { int32_t diff = x - y_prev; y_prev = y_prev + ((diff + ROUND_BIAS) >> SHIFT_N); return y_prev; }这个版本多了一次加法和一个常量,但死区明显减小,输出更接近浮点版本的效果。我在实际项目里基本都用这个版本。4.3 初始化与运行时参数调整滤波器的初始值 y_prev 怎么设?如果设成 0,而实际信号在 2000 左右,那么开机后输出会从 0 慢慢爬升到 2000,这个爬升过程可能持续好几秒。对于某些应用来说,这几秒的过渡是不可接受的。常见的做法是用第一次采样值直接初始化:void lowpass_init(int32_t first_sample) { y_prev = first_sample; }这样开机瞬间输出就等于输入,之后才开始滤波。如果你希望更平滑一点,也可以用前几次采样的平均值来初始化。运行时调整移位位数也是常见需求。比如系统在快速模式和慢速模式之间切换,滤波强度也要跟着变。这时候可以把 SHIFT_N 改成变量,但要注意移位位数是变量的情况下,编译器可能无法优化成单条移位指令,性能会打折扣。如果对性能敏感,可以用 switch-case 把几种常用位数写死。4.4 实测数据与效果验证我在一个温度采集项目里实测过这套滤波。传感器是常见的数字温度芯片,分辨率 0.0625 度,采样率 1Hz。原始数据在 25.0 到 25.5 度之间跳动,峰峰值约 0.5 度。用 n = 4 的位移法滤波后,输出稳定在 25.2 到 25.3 度之间,峰峰值压缩到 0.1 度以内,而且响应时间在可接受范围内。用示波器观察阶跃响应,从 25 度跳到 30 度时,滤波输出大约经过 15 个采样周期达到最终值的 90%。这个结果和理论计算基本吻合,说明位移法在慢变量场景下完全够用。5. 常见问题与排查技巧实录5.1 输出震荡或飞掉的排查思路这是位移法最容易踩的坑,根源几乎都出在数据类型上。排查顺序建议如下:检查 diff 的计算是否用了有符号类型。如果 x 和 y_prev 都是无符号,减法结果会回绕。检查右移操作符作用在什么类型上。对无符号数右移是逻辑移位,对负数会补 0 而不是补 1,结果完全错误。检查 y_prev 的更新是否在中断和主循环之间共享。如果中断里在写、主循环里在读,没有保护的话可能读到半更新的值。我遇到过一次典型情况:某开发者在 8 位机上用 uint8_t 存 y_prev,diff 也是 uint8_t,结果输入从 100 降到 50 时,diff 算出来是 206,右移后加回去,输出直接冲到 250 以上,然后系统就失控了。改成 int16_t 之后问题立刻消失。5.2 滤波滞后过大的参数调整如果发现输出跟不上输入变化,先确认是不是 n 选大了。把 n 减小 1 或 2,滞后会明显改善。但减小 n 会削弱滤波效果,需要在两者之间找平衡。另一个容易被忽视的因素是采样率。如果采样率远高于信号带宽,那么即使 α 不小,等效截止频率也会很低,造成不必要的滞后。这时候可以考虑降低采样率,或者改用滑动平均等其他滤波方式。还有一种情况是信号本身含有较大的直流偏置,而噪声是高频的。这时候可以先减去偏置再做滤波,或者用高通加低通的组合,效果比单纯加大滤波强度好得多。5.3 多通道滤波的资源优化实际项目里往往不止一路信号要滤波。如果每路都独立写一套变量和函数,代码会显得很臃肿。更好的做法是把状态封装成结构体:typedef struct { int32_t y_prev; uint8_t shift_n; } lpf_t; int32_t lpf_update(lpf_t *f, int32_t x) { int32_t diff = x - f->y_prev; int32_t bias = 1 << (f->shift_n - 1); f->y_prev += (diff + bias) >> f->shift_n; return f->y_prev; }这样每路信号只需要一个 lpf_t 实例,代码复用率高,也方便统一管理。代价是 shift_n 变成变量后,移位不再是编译期常量,性能略有下降。如果通道数不多且算力充裕,这点损失可以接受。5.4 常见问题速查表现象可能原因解决方法输出飞掉或震荡数据类型无符号导致回绕改用 int16_t 或 int32_t输出长期停在某个值不动移位死区过大加舍入偏置或减小 n滤波效果不明显n 太小或采样率过高增大 n 或降低采样率响应太慢n 太大减小 n,或改用变系数方案多通道互相干扰变量未隔离或中断冲突用结构体封装,加临界区保护开机输出爬升慢初始值设为 0用首次采样值初始化提示:在中断里调用滤波函数时,尽量保证滤波变量只在该中断中被访问。如果主循环也要读滤波结果,建议读完后立即拷贝到另一个变量,避免数据竞争。6. 位移法的扩展玩法与进阶思路6.1 变步长滤波:快慢自适应的实现固定 n 的位移法有个天然矛盾:快和稳不可兼得。一个实用的改进是根据信号变化率动态调整 n。当 diff 很大时,说明信号在快速变化,此时用较小的 n 快速跟踪;当 diff 很小时,说明信号稳定,用较大的 n 强力滤波。实现上可以设两个阈值:int32_t lpf_adaptive(int32_t x) { int32_t diff = x - y_prev; int32_t abs_diff = (diff >= 0) ? diff : -diff; uint8_t n; if (abs_diff > 500) { n = 2; } else if (abs_diff > 100) { n = 4; } else { n = 6; } int32_t bias = 1 << (n - 1); y_prev += (diff + bias) >> n; return y_prev; }这段代码在信号突变时响应快,稳态时又足够平滑。代价是多了一次绝对值计算和几次比较,但相比浮点运算还是轻量得多。我在一个手持设备项目里用过这个思路,效果比固定系数好不少。6.2 级联位移滤波实现更陡的滚降一阶滤波的滚降是 -20dB/十倍频,对某些应用来说不够。把两个一阶位移滤波器级联起来,就能得到接近二阶的效果,滚降变成 -40dB/十倍频。实现上就是连续调用两次滤波函数,每级有自己的状态变量。需要注意的是,级联后相位滞后会累加,总滞后大约是单级的两倍。所以级联适合对相位不敏感的场景,比如静态测量、显示平滑等。控制环路里要慎用。6.3 与滑动平均的对比与组合使用滑动平均是另一种常见的轻量滤波,它把最近 N 个采样值求平均。相比位移法,滑动平均对周期性噪声的抑制更好,但需要存储 N 个历史值,内存开销大,而且每次都要做 N 次加法。两者可以组合:先用滑动平均去掉明显的周期性干扰,再用位移法做最后的平滑。或者反过来,先用位移法快速压制高频噪声,再用滑动平均进一步稳定输出。具体怎么组合,取决于你的噪声特性和资源预算。我个人在大多数场景下更倾向位移法,因为它内存占用极小,代码简单,调试直观。只有在噪声频谱比较特殊、位移法效果不理想时,才会考虑引入滑动平均。6.4 在中断服务函数中的部署要点滤波函数经常放在 ADC 中断里调用。这时候有几个细节要注意。第一,滤波函数本身要尽量短,避免在中断里做复杂判断。第二,如果用了自适应变步长,阈值比较和分支会增加执行时间,要评估中断频率和 CPU 负载。第三,滤波变量如果也要在主循环里用,记得加 volatile 或者用临界区保护。我一般的做法是:中断里只做滤波和存结果,主循环里读结果做后续处理。滤波变量用结构体封装,中断和主循环各操作各的副本,通过一个标志位或者队列来传递数据。这样既保证了实时性,又避免了竞争。7. 一些实操心得与踩坑记录位移法看起来简单,但真正在项目里用好,还是有一些只可意会的东西。我把自己这些年踩过的坑和总结的经验列几条,供你参考。第一条,移位位数不要超过数据有效位数的一半。比如 12 位 ADC,有效数据 12 位,n 最好不要超过 6。超过之后死区会大到影响正常测量,滤波输出会显得“迟钝”甚至“卡死”。这个界限不是绝对的,但可以作为快速判断的依据。第二条,调试时先把滤波关掉,确认原始数据正常再开滤波。我见过有人花半天调滤波参数,最后发现是传感器接线松动导致数据本身就在乱跳。滤波只能处理噪声,不能修复错误的数据源。第三条,用串口或者调试器把滤波前后的数据同时输出,画成曲线对比。光看数字很难判断滤波效果,画成波形一目了然。我通常会用简单的上位机工具把两组数据实时绘制出来,调参效率高很多。第四条,不同信号不要共用一套滤波参数。温度、压力、电流的变化速率差异很大,用同一个 n 必然有一路效果不好。多花几行代码给每路独立配置,后期维护会轻松很多。第五条,记得处理滤波器的复位。系统重启、传感器重新初始化、量程切换时,滤波器的历史状态应该清零或者用新数据重新初始化,否则会有一段莫名其妙的过渡过程。最后再分享一个小技巧:如果你不确定 n 取多少合适,可以先用浮点版本在 PC 上仿真,把不同 α 对应的阶跃响应和噪声抑制效果都跑一遍,找到满意的 α 之后,再换算成最接近的 1/2^n。这样比在单片机上反复烧录试参数快得多。换算的时候记住,α = 1/2^n,n 取整数,所以实际 α 和仿真值会有偏差,选最接近的那个就行。