做控制、做测量的同行十有八九遇到过这个场景ADC读数明明在物理量上很稳可一到代码里就抖得像筛子尤其是做电机电流环或者温度PID的时候一阶低通滤波压不住噪声硬件RC又要改板子、调电容麻烦得很。这时候能在固件里直接跑起来的三阶RC低通滤波器就是很实用的救场方案。要是手头还是低成本的M0内核单片机没有FPU、除法又是软算那就更得抠效率——用移位运算代替除法把三阶滤波器压到十几个周期以内。这篇文章就把这东西的数学原理、系数怎么定、代码怎么写以及我在实际项目里踩过的坑一次讲清楚。1. 项目定位这个滤波器到底解决什么问题1.1 从模拟RC到数字三阶RC为什么不是一阶也不是五阶RC低通滤波器是模拟电路里最基础的平滑环节一颗电阻加一颗电容就能把高频分量拉下来。一级RC的滚降斜率是20dB/dec两级叠起来是40dB/dec三级就能做到60dB/dec。60dB意味着什么频率翻10倍信号幅度衰减到原来的千分之一这对抑制ADC采样噪声、PWM斩波毛刺、传感器高频抖动来说基本够用了。一阶虽然简单但在很多现场噪声场景里衰减太浅滤波后的信号还是带着明显的毛边四阶、五阶当然滚降更陡但每多一阶就多一份相位滞后控制环路上相位裕度会被吃掉不少。三阶是工程上很典型的“甜点”衰减够了延迟还能接受实现也不复杂。数字实现相比模拟RC还有个天然优势没有元件容差没有温漂不需要改PCB。同一套代码想从50Hz截止改到5Hz截止只需改一个宏或者一个参数。做批量产品时不同机型用不同滤波参数也不用换BOM这对产线调试和售后维护都是实打实的便利。1.2 为什么非要在嵌入式里抠“移位代替除法”这点效率很多人觉得一个滤波器才三个递推式现在MCU主频动辄几十上百兆管他什么除法不除法跑就是了。但实际做嵌入式的人都知道场景不是这么简单。拿常见的M0内核来说硬件上根本没有除法指令C语言里写一个整数除法编译完会调用软件除法库一次除法轻则几十个周期慢则上百个周期。即便到了带除法指令的M3/M4内核除法周期也不是固定的有符号除法的开销往往比无符号更大。而移位运算说白了就是把数据往右挪几位硬件上1个周期就完成了。假设你的系统采样率是1kHz主频48MHz每次采样有48000个周期可用。一个三阶滤波器如果每个阶用一次软件除法三次除法可能花掉200个周期看起来占比不高但如果你有4个通道、8个通道要同时滤波或者采样率提升到10kHz、20kHz这个开销立刻膨胀。更关键的是在中断里待的时间越短系统响应其他紧急事件的能力越强。把除法换成移位三阶滤波器的总开销能压到十几个周期这才是真正的“嵌入式友好”实现。1.3 这个方案适合谁、用在哪适合的场景很明确ADC采样数据平滑、传感器信号调理、控制环路的反馈滤波、电池或电源的电流电压采样去抖。做音频EQ或者对相位失真敏感的高保真信号处理我不会推荐用这种简化IIR但在工业控制、仪器仪表、消费电子电源管理这些“波形看起来平了就行”的场合这种移位RC滤波器的表现非常稳。2. 数学模型与系数设计怎么把“RC”搬进代码2.1 一阶RC的离散化推演先不说三阶把一阶RC的递推公式推导清楚后面全是水到渠成的事。模拟一阶RC低通的微分方程是τ·dy/dt y x其中τRC是时间常数。把它离散化用后向差分近似导数dy/dt ≈ (y[k] - y[k-1]) / TT是采样周期。代入整理后得到y[k] y[k-1] α·(x[k] - y[k-1])其中α T/(Tτ)。这个递推式的结构非常有价值它不是用“上一次输出乘一个系数”的常规形式而是用“当前输入和上次输出的差”乘α再累加。这种增量式写法的好处很明显——当输入和输出相等时差为0输出自然不动直流分量天然保持不会因为系数量化而累积漂移。当采样率远高于截止频率时α还可以近似成α ≈ T/τ 2π·fc·T 2π·fc/fs其中fc是单级RC的-3dB截止频率fs是采样频率。这个近似在α小于0.3时误差很小正好覆盖我们常用的移位系数范围。2.2 三阶级联的结构与稳定性三阶RC低通不是直接去构造一个三阶差分方程而是把三个一阶RC递推节首尾串联x → 第一级 → 第二级 → 第三级 → y每一级都是标准的y_i[k] y_i[k-1] α_i·(y_{i-1}[k] - y_i[k-1])在传递函数上三级的乘积就是总传递函数。每一级在DC处的增益都是1所以整条链路的总增益也是1输入是4095稳态输出就是4095不会因为串联多级而把信号放大或缩小。每一级的极点都在单位圆内级联后系统天然稳定不需要额外的稳定性校验这也比直接设计高阶IIR省心得多。级联顺序在理论上不影响线性系统的最终传递函数但在定点实现里有讲究。我的习惯是让响应快的那一级放前面也就是把系数大的N小的那一级放在最前面。这样粗噪声会先被快速压制后面几级的输入范围相对平缓定点运算中的饱和风险更小。2.3 系数怎么快速定给忙人的估算公式和查表现在解决实际设计问题我想让三阶滤波器的-3dB截止频率是某个值f3采样率是fs每一级系数怎么定先算单级截止频率。三个相同的一阶节级联后总增益降到-3dB时每一级的增益大约是0.8909反推可以得到单级截止频率大约是总截止频率的1.96倍也就是说单级截止频率要设成目标值的两倍左右。再用α ≈ 2π·fc1/fs把两级关系合并α ≈ 2π × 1.96 × f3 / fs ≈ 12.3 × f3 / fs如果想让α能用NR次移位代替除法也就是α≈2^(-N)那么N可以直接这样估算N ≈ log2(fs / (12.3 × f3))举个例子。系统采样率fs1kHz希望三阶截止频率f35Hz那么α ≈ 12.3×5/1000 0.0615非常接近1/160.0625所以取N4完美匹配。再来一个fs1kHz希望三阶截止2Hzα≈0.0246最接近的是1/320.0313N5实际截止频率会略高于2Hz但偏差在工程允许范围内。采样率fs目标三阶截止f3理论α取2幂系数移位位数N1kHz5Hz0.06161/1641kHz2Hz0.02461/3252kHz10Hz0.06161/1644kHz20Hz0.06161/164看到规律没有只要截止频率和采样率等比例增大N不用变。这就是移位滤波器的另一个优点系数和采样率挂钩设计参数特别规整。2.4 三个级用不同的N可以吗当然可以。三阶不要求每一级参数相同你完全可以把三级分别设成不同的N比如第一级N2第二级N5第三级N8。这样第一级反应快先把大幅度的高频毛刺削掉后面两级做精细平滑整体感觉是“先粗后细”。但我的建议是除非你对系统的阶跃响应有明确要求否则优先用三个相同的N。相同N的级联在幅频特性上更接近常规设计直觉参数也容易调整。3. 用移位运算实现三阶RC滤波器的完整方案3.1 除法到移位的替换以及舍入这个关键细节当α1/16时α乘以误差d等价于d除以16除以16就是右移4位。所以代码核心可以写成y d 4;但这里有个非常容易踩的坑右移运算在C语言里对负数做的是算术右移结果是向下取整。比如d-3N4-34的结果是-1而不是0。这种向负无穷方向的舍入会让输出在稳态时出现几个LSB的偏差体现在波形上就是“输出比输入低一截”而且d接近0时会卡住不动滤波器就像“死”了一样。解决办法是在移位之前加一个半值偏置adj d (1 (N-1)); y adj N;这样就把向下取整变成了四舍五入。d为正数时加上半个步长再截断相当于四舍五入d为负数时同样能拿到最接近的整数商。这个操作只多一条加法指令却能把静差问题根治掉。3.2 可直接抄走的C语言实现结构体、初始化与调用下面给出一套可以实际用的C代码。状态量全部用int32_t目的是在中间运算中留足余量避免16位宽度在加半偏置时溢出。typedef struct { int32_t y0; int32_t y1; int32_t y2; uint8_t n0; uint8_t n1; uint8_t n2; } rc3_lpf_t; static inline int32_t rc3_step(int32_t input, int32_t *state, uint8_t n) { int32_t d input - *state; int32_t adj d (1 (n - 1)); *state adj n; return *state; } int32_t rc3_lpf_update(rc3_lpf_t *f, int32_t x) { int32_t s0 rc3_step(x, f-y0, f-n0); int32_t s1 rc3_step(s0, f-y1, f-n1); return rc3_step(s1, f-y2, f-n2); }如果用的编译器支持内联函数上面的写法足够好而且中间结果会被优化进寄存器。如果追求极致可以在编译期确定N值用宏展开#define RC3_ROUND_SHIFT(d, n) ((d (1 ((n) - 1))) (n)) int32_t rc3_lpf_update_fixed(rc3_lpf_t *f, int32_t x) { int32_t d0 x - f-y0; f-y0 RC3_ROUND_SHIFT(d0, 4); int32_t d1 f-y0 - f-y1; f-y1 RC3_ROUND_SHIFT(d1, 6); int32_t d2 f-y1 - f-y2; f-y2 RC3_ROUND_SHIFT(d2, 8); return f-y2; }这两种写法的核心逻辑完全一致后者的移位位数是编译期常量代码量更短更适合对性能要求苛刻的场景。初始化有一个细节容易被忽略不要在启动时把三个状态清零。如果输入是4095而状态是0滤波器的第一拍输出会从0猛然跳向4000多看起来就像上电过冲。正确的做法是用第一个有效采样值把三个状态全部填满void rc3_lpf_seed(rc3_lpf_t *f, int32_t x) { f-y0 x; f-y1 x; f-y2 x; }之后每次ADC转换完成在中断里调用一次更新函数即可。3.3 定点数据宽度与防溢出设置这是很多人写定点滤波器时最容易翻车的地方。12位ADC的原始值范围是0~4095误差d的范围是-4095~4095int16_t理论上够用。但加上加半偏置之后1(N-1)可能达到128甚至更大如果N比较大比如N10偏置512加上符号位int16的边界就变得很微妙。我的建议是内部状态一律用int32_t最后返回的y2再根据业务需要决定是否截断成int16。32位状态下即使输入是24位范围的ADC数据也不会出现溢出风险。代价仅仅是每次多占用一点寄存器和内存在48MHz的MCU上完全可接受。还有一档更进阶的操作如果你需要极低的截止频率比如N12α1/4096这时直接对12位ADC数据做移位每拍状态量可能只有1个LSB步进输出的量化感会非常明显。解决办法是把状态量预先放大2^K倍也就是用Q格式存储内部状态例如状态实际值是y28每次更新按放大后的尺度累加最后输出时右移8位还原。这种做法牺牲一点代码可读性换来的是超低截止频率下依然平滑的输出。我在做电池电流积分时需要零点几赫兹的平滑滤波用的就是这种Q格式变体。3.4 性能实测移位和除法到底差多少用一块主频48MHz的M0内核MCU做基准测试。滤波器函数放在定时器中断里用IO翻转加示波器测量三阶更新一次的实际耗时。实现方式三阶单次耗时周期备注软浮点模拟运算200~400没有FPU时极其不划算有符号整数除法60~150编译器调用软件除法库移位运算不加舍入6~10三条差值加三条移位移位运算加舍入10~16额外三条加半指令实测下来加舍入的移位版本在48MHz主频下一次完整三阶滤波只要零点几个微秒。放在1kHz采样中断里占用率不到百分之一。对低功耗、低成本MCU来说这种开销基本可以忽略这也是我敢在中断服务里放心调用它的底气。4. 实测调试、常见问题与优化技巧4.1 怎么验证滤出来的波形是“对的”写完代码别急着接控制环路先做一个简单的开环验证。用MCU的DAC或者串口把两路数据吐出来一路是原始ADC值一路是滤波后的值。给传感器一个阶跃输入观察波形。三阶RC滤波器的阶跃响应特征很明显输出会平滑地逼近目标值不会有超调也不会振荡。从阶跃开始到输出达到目标值的63%左右对应的时间就是系统总时间常数如果在示波器上看到明显的过冲多半不是你滤波器的问题而是信号链路里存在其他运算放大了抖动。有条件的话可以用信号发生器做扫频测试从1Hz扫到几百赫兹观察衰减趋势。但在工程现场没有信号发生器时阶跃响应加静置观察基本够用。我最常用的工具就是串口绘图把原始值和滤波值画在一起肉眼比对毛刺有没有被压平、相位延迟大概多少很快就能判断参数是否合理。4.2 现场排查一张表看清滤波器的常见问题现象可能原因对策上电瞬间输出跳到满量程状态变量初始化为0用首个采样值seed状态滤波后静态值比输入低几个LSB移位截断造成的负向偏差使用加半偏置的四舍五入移截止频率和理论值差很多实际采样周期与计算用的fs不一致用示波器测中断周期反过来修正N输出还是抖像台阶一样截止频率太低或者N太大单拍步进量过小改用Q格式放大内部状态在线更改N后输出突跳状态没有跟随系数变化重新对齐改系数前把三个状态重新seed到当前输入或当前输出波形有规律毛刺周期性出现滤波器可能被放在了主循环采样间隔不均匀把更新函数挪到ADC完成中断或定时器中断里最后一条特别值得强调。RC滤波器的系数本质上是按固定采样周期设计的如果调用时间间隔忽长忽短截止频率就会漂移。摆到主循环里轮询更新中断一多时间间隔乱跳滤波效果会大打折扣。我的习惯是让ADC完成中断直接触发滤波更新或者用定时器固定触发采样和滤波把时间基准牢牢锁定。4.3 我从实际项目里踩过的几个坑第一个坑就是舍入。最早我把所有状态变量定义成int16_t直接写y d N结果发现在静态输入下输出总是比输入小一些而且数据小的时候滤波输出干脆不动了。排查半天问题就出在负数右移是向下取整。加半偏置之后静态误差直接消失这个教训我之后写所有定点滤波都带着。第二个坑是初始化。某次调试电机转速采样上电瞬间转速反馈显示有一下猛跳我还以为是传感器问题后来才反应过来是滤波器状态全为0导致的启动阶跃。用首个采样值填充状态之后上电波形平滑得让人舒服。第三个坑是N取得过大。有一次想把截止频率降到很低直接把N从8改成12结果滤波输出变成了“楼梯波”一格格往上爬。原因前面说过数据量级和步长不匹配。后来用Q格式把状态放大到int32位再处理才算解决。4.4 让这个滤波器更“聪明”的扩展思路基础的三阶移位RC滤波已经能解决大部分问题但它也可以继续扩展。启动自适应是一个很实用的技巧上电时先把N设小比如N2让滤波器快速逼近目标值几百毫秒后再逐渐增大N过渡到正常截止频率。这样既避免了启动过慢的迟钝感又不会产生阶跃冲击。我在需要快速起停的设备上用过这个思路效果相当好。还有一个是和其他滤波方法级联。RC滤波擅长压制高频噪声但对尖峰脉冲的响应比较平淡如果数据里混着偶发的毛刺可以先做一次中值滤波窗口再送进三阶RC去平滑效果比单一滤波好很多。如果你想在一个MCU上同时处理多路信号就把结构体数组化每一路独立实例化。由于滤波器本身不依赖历史输入只依赖自身状态多路并行时完全不需要互斥保护只要不在中断和主循环同时操作同一个通道就行。最后分享一个我自己用的调参顺序先按N4起步观察波形如果觉得毛刺压不住把N往大调两档如果觉得响应太慢、控制跟手性差就减小N。每次只变一个参数记录波形变化10分钟就能收敛到一套合适的参数。很多工程师喜欢一次调到位反而容易被几个参数互相干扰带偏。嵌入式里做滤波其实不需要把数字信号处理的理论全部啃完掌握好一阶RC的递推思想、级联结构、移位量化这三个关键点就足够应付绝大多数现场噪声问题了。这个“移位代替除法”的思路也不只适用于RC滤波任何系数可以近似成2的负幂的递推算法都能用同样的手法优化一遍。希望这篇文章能让你下次面对ADC噪声时第一反应不是去改PCB而是先想起这段十几行代码。