ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

三阶RC低通滤波器定点化与移位优化:无FPU MCU的高效实现

三阶RC低通滤波器定点化与移位优化:无FPU MCU的高效实现

做嵌入式信号处理的朋友,应该都绕不过滤波这件事。ADC采集回来的数据要平滑,IMU读出来的姿态要降噪,电流环采样要滤掉毛刺。以前我习惯直接用一阶RC低通,简单粗暴但不总是够用,遇到工频干扰叠在传感器信号上时,一阶的衰减斜率只有 -20dB/dec,要压住干扰就得把截止频率调得很低,结果真实信号也被削得不成样子。后来换三阶RC低通,衰减斜率变成 -60dB/dec,这才算把“要保留的信号”和“要滤掉的噪声”之间的墙真正立了起来。

不过三阶滤波器放到单片机上,麻烦就来了。如果MCU不带FPU,float算一次IIR看起来没什么,放到几kHz的中断里,再叠加几个通道,算力就吃紧。尤其是一些Cortex-M0内核、8位单片机的项目,浮点库和软除法都贵得要命。这篇文章要聊的就是一个很朴素的思路:把三阶RC低通滤波器的系数定点化,再用移位运算代替除法和通用乘法,让它在没有FPU、甚至没有硬件乘法器的平台上也能跑得飞快。

这个方案适合谁?正在做ADC采样平滑、传感器预处理、控制环路滤波,且芯片资源捉襟见肘的朋友。也适合那些对代码可预测性要求高、不想依赖浮点库的裸机项目。接下来我会从离散化原理讲起,逐步拆解定点化和移位优化,然后给出完整代码、实测思路和踩坑记录。

1. 三阶RC低通滤波器的原理与离散化设计

1.1 从模拟RC链路说起

模拟一阶RC低通的传递函数是:

H(s) = 1 / (1 + sRC)

如果三个一阶节理想级联,每一级之间用缓冲隔离,整体传递函数就是三个极点相乘:

H(s) = 1 / [(1 + sRC1)(1 + sRC2)(1 + sRC3)]

三个时间常数相同时:

H(s) = 1 / (1 + sRC)^3

在远高于截止频率的地方,衰减斜率是 -60dB/dec,这就是三阶相对一阶的核心优势。比如一阶在20倍频程外只衰减40dB,三阶能衰减120dB,对付带外噪声非常有效。

这里必须提醒一个模拟电路的坑:如果直接在面包板上把三个RC串起来,没有缓冲级,后级RC会向前级索取电流,等效负载会改变前级时间常数,实测截止频率和理论值对不上。数字实现没有这个问题,每一级滤波器都是独立计算、理想隔离的,这也是我推荐在MCU上用级联数字滤波器替代模拟多阶RC的原因。

三阶的代价也不是没有:相位滞后比一阶大,阶跃响应更慢。在控制环路里,相位裕度会受影响,所以设计时要在“滤得干净”和“跟得上变化”之间做取舍。

1.2 用后向欧拉法把微分方程变成差分方程

一阶RC惯性环节的连续域微分方程是:

τ · dy/dt = x - y

其中 τ = RC。用后向欧拉法离散化,把导数写成后向差分:

(y[n] - y[n-1]) / T = (x[n] - y[n]) / τ

整理之后得到每个采样周期的增量式更新:

y[n] = y[n-1] + α · (x[n] - y[n-1])

其中:

α = T / (τ + T)

系数 α 是一个 0 到 1 之间的小数,物理含义是“每个周期输出向输入靠近的比例”。α 越大,滤波器截止频率越高;α 越小,输出越平滑,但滞后越大。

增量式写法有个很实用的特点:状态变量直接就是上一次输出,输入和输出都是整数时,误差范围非常清晰,方便后面做定点化。

三阶级联就是把 x 送入第1级,取第1级输出喂给第2级,再喂给第3级:

y1[n] = y1[n-1] + α1·(x[n] - y1[n-1]) y2[n] = y2[n-1] + α2·(y1[n] - y2[n-1]) y3[n] = y3[n-1] + α3·(y2[n] - y3[n-1])

如果三个 α 相等,就是对称三阶滤波器;如果不等,可以组合出更灵活的幅频响应。

为什么选后向欧拉而不是前向欧拉?前向欧拉实现更简单,但稳定性有条件,采样率不够高时系数放大可能让滤波器发散。后向欧拉无条件稳定,即使 α 接近1也不会炸,工程上省心得多。

1.3 系数α与截止频率的换算关系

很多人在这一步翻车。α 不是随便拍的,它和截止频率、采样周期直接相关。由 α = T/(τ+T),可以反推出 τ:

τ = T·(1-α) / α

单节RC低通的截止频率:

fc = 1 / (2π·τ)

代入得到:

fc = α·fs / [2π·(1-α)]

反过来,给定目标截止频率和采样率,α 可以这样算:

α = 2π·fc / (fs + 2π·fc)

举个例子:fs=1000Hz,想要 fc=10Hz,那么 α = 62.8 / 1062.8 ≈ 0.0591,也就是大约 0.06。如果直接把这个 α 当 float 用,一阶低通的截止频率就差不多是10Hz附近。

另外还有一个必须注意的现象:三个相同的一阶节级联后,整体 -3dB 截止频率并不是单级 fc。设整体频率响应:

|H(j2πfc3)| = 1 / √2

可以解出:

(1 + (2π·fc3·RC)^2)^3 = 2

所以:

2π·fc3·RC = √(2^(1/3) - 1) ≈ 0.5098

也就是说:

fc3 ≈ 0.51 · fc1

如果按单级10Hz设计,级联三级后实际-3dB点大约只有5.1Hz,几乎腰斩。这个现象在第一次做多阶滤波器时容易踩坑,后面测试验证部分我会再提到怎么修正。

2. 移位运算替代除法的定点化核心思路

2.1 浮点乘法和除法在低端MCU上的代价

如果没有硬件FPU,float加减法底层是一段库函数,乘除法更是几十上百个周期。软除法更夸张,一个32位无符号除法在8位机上可能就是几百个周期。滤波算法每个采样周期都要执行,三阶就是3次乘加,几kHz采样率下CPU占用率会非常难看。

移位运算则便宜得多,在绝大多数架构上都是1个周期。所以只要能把滤波系数改成移位可实现的形态,性能提升立竿见影。这个思路不新鲜,但在实际工程里非常实用。

2.2 三种定点化系数方案对比

方案A是直接把 α 取为 2 的负幂次,即 α = 1/2^N。这种特殊取值让“乘以 α”变成“右移N位”:

y += (x - y) >> N

这个方案没有乘法器也能跑,代码极简。代价是截止频率只能落在离散档位上,不能任意设定。由 α = 1/2^N 推截止频率:

fc = fs / [2π·(2^N - 1)]

方案B是Q格式定点乘法。把 α 量化成整数:

α_q = round(α · 2^M)

计算时用 int32 乘法:

prod = α_q · (x - y) y += (prod + 2^(M-1)) >> M

这个方案本质上还是把“除以2^M”这个缩放用右移实现了,保留一个硬件乘法器的单周期乘法和一次移位。好处是 α 可以连续取值,截止频率精度高,适合M0/M3/M4这类有单周期乘法器但没有FPU的芯片。

方案C是把 α 拆成几个 2^-k 的和。比如 α=0.3125=1/4+1/16,那么:

y += (err >> 2) + (err >> 4)

这种写法完全不需要乘法器,适合老式8位机或对代码体积有极端要求的场合。代价是 α 的可选值比较稀疏,且指令条数取决于拆成几项,代码可读性也差一些。

三种方案的选择原则其实很简单:有单周期乘法器就优先方案B,没有乘法器再考虑方案C,方案A作为两者之间的过渡,最常用于采样率固定、截止频率要求不苛刻的传感器平滑场景。

2.3 三阶三级系数与整体截止频率

三阶滤波器的三级系数可以相同,也可以不同。做对称三阶低通时,三级都用同一个 N,整体 -3dB 频率大约等于单级的0.51倍,这一点在第一节里已经算过。

如果想让整体截止频率更接近目标值,可以先用目标频率算出等效 α,再套三阶公式反推每级应该用的单级 α。工程上更省事的做法是先按“单级 fc 略高于目标频率”来选 N,然后实测微调。

以 fs=1000Hz 为例,纯移位方案下不同 N 对应的单级截止频率和三阶整体截止频率大致如下:

N单级 fc (Hz)三阶整体 -3dB (Hz)
1159.281.2
253.127.1
322.711.6
410.65.4
55.12.6
62.51.3
71.250.64
80.620.32

这个表格我用过很多次,设计时直接查表选N,省掉反复试凑。注意这是后向欧拉离散化的近似结果,当 fc 远小于采样率时,误差很小;如果 fc 超过 fs/20,就要用双线性变换或实测校准。

3. 嵌入式代码实现:从浮点原型到纯移位版本

3.1 浮点原型,先把数学验证清楚

优化之前一定要先有一个能跑的浮点基准。我用C语言实现一个三阶级联结构体,每一级都是一阶惯性环节,状态就是上一次输出:

typedef struct { float y; float alpha; } LPF_1st; typedef struct { LPF_1st stage1; LPF_1st stage2; LPF_1st stage3; } LPF_3rd; float lpf3_process(LPF_3rd *f, float x) { f->stage1.y += f->stage1.alpha * (x - f->stage1.y); f->stage2.y += f->stage2.alpha * (f->stage1.y - f->stage2.y); f->stage3.y += f->stage3.alpha * (f->stage2.y - f->stage3.y); return f->stage3.y; }

初始化时有一个细节:如果输入信号带直流偏置,且三个状态变量默认是0,上电后输出会从0爬升到真实值,这个瞬态在控制回路里可能就是一次扰动。我一般把三级状态全部初始化为第一次采样值:

void lpf3_init(LPF_3rd *f, float x0, float alpha) { f->stage1.y = x0; f->stage2.y = x0; f->stage3.y = x0; f->stage1.alpha = alpha; f->stage2.alpha = alpha; f->stage3.alpha = alpha; }

浮点版本只用来验证算法,确认截止频率、阶跃响应都符合预期后,再往定点版迁移。

3.2 定点乘法版本,精度与速度的平衡

如果芯片有单周期硬件乘法器,强烈推荐这个版本。系数用Q15格式量化,计算中间用int32,避免int16溢出:

typedef struct { int16_t y1, y2, y3; int16_t a1q, a2q, a3q; } LPF3_Q15; void lpf3_q15_init(LPF3_Q15 *f, int16_t x0, float alpha1, float alpha2, float alpha3) { f->y1 = x0; f->y2 = x0; f->y3 = x0; f->a1q = (int16_t)(alpha1 * 32768.0f + 0.5f); f->a2q = (int16_t)(alpha2 * 32768.0f + 0.5f); f->a3q = (int16_t)(alpha3 * 32768.0f + 0.5f); } int16_t lpf3_q15_process(LPF3_Q15 *f, int16_t x) { int32_t err, prod; err = (int32_t)x - f->y1; prod = (int32_t)f->a1q * err; f->y1 += (int16_t)((prod + 16384) >> 15); err = (int32_t)f->y1 - f->y2; prod = (int32_t)f->a2q * err; f->y2 += (int16_t)((prod + 16384) >> 15); err = (int32_t)f->y2 - f->y3; prod = (int32_t)f->a3q * err; f->y3 += (int16_t)((prod + 16384) >> 15); return f->y3; }

这里必须解释一个容易溢出的边界:输入 x 最大32767,状态 y 最小 -32768,差值范围最大65535;α_q 最大32767。两者的乘积接近 65535 × 32767 ≈ 21.47亿,已经非常逼近 int32 的上限2,147,483,647。虽然理论上还没溢出,但为了保险,我在实际项目中通常把ADC采样值先右移一位,限制在 ±16384 范围内,或者直接把中间变量提升为 int64。大部分电信号没必要用满 int16 的整个范围,牺牲1bit动态完全值得。

加 16384 再右移15位是做四舍五入,避免直接截断带来的静态死区。这个细节在4.2节会展开。

3.3 纯移位版本,几行代码实现三阶滤波

如果目标是8位MCU或者想极尽压缩计算量,用 α = 1/2^N 的纯移位方案:

typedef struct { int32_t y1, y2, y3; uint8_t n1, n2, n3; } LPF3_Shift; void lpf3_shift_init(LPF3_Shift *f, int32_t x0, uint8_t n1, uint8_t n2, uint8_t n3) { f->y1 = x0; f->y2 = x0; f->y3 = x0; f->n1 = n1; f->n2 = n2; f->n3 = n3; } int32_t lpf3_shift_process(LPF3_Shift *f, int32_t x) { int32_t err; err = x - f->y1; f->y1 += err >> f->n1; err = f->y1 - f->y2; f->y2 += err >> f->n2; err = f->y2 - f->y3; f->y3 += err >> f->n3; return f->y3; }

每级就三行,没有乘法,没有除法,没有浮点。即使是在主频很低的8位机上,一秒钟跑几千次也是轻松的事。

要注意的是右移对负数在C标准里是常规的算术移位,在ARM GCC和大多数编译器上都能得到正确结果。但如果你用某个小众编译器,建议写个小的单元测试确认一下:x=-1,右移1位结果应该是-1,而不是变成一个大正数。

3.4 三种版本的实际性能对比

我曾在某Cortex-M0内核、主频48MHz的MCU上做过一个三通道三阶滤波的对比测试,滤波频率1kHz,不带任何优化编译选项:

  • 浮点版本,每通道每周期3次浮点乘法和减法,CPU占用大约12%到15%;
  • Q15乘法版本,每通道大约十几条整数指令,CPU占用约3%;
  • 纯移位版本,只有加减法和移位,CPU占用不到1.5%。

数字不是绝对,但趋势非常明显。在低端MCU上,这种优化能直接把滤波从“影响主流程”变成“几乎免费”。如果有10个通道同时滤波,浮点版可能直接吃满CPU,纯移位版还留有余量给其他逻辑。

选择建议:Cortex-M0/M3/M4且有硬件乘法器,用Q15版本,精度好;8位机或想省到极致,用纯移位版本;对截止频率精度要求高、采样率又不高时,用浮点或Q15。

4. 工程应用中的常见问题与排查技巧

4.1 启动瞬态与初始状态设置

这是多阶滤波器最容易忽略的问题。默认状态是0,输入却是一个带偏置的直流信号,三阶滤波器输出会缓慢爬升。如果后面的控制算法基于滤波值做差,上电瞬间会看到一个假的目标跳变。

解决办法是用第一个采样值初始化所有状态,甚至在初始化前先丢几个采样点不启用输出。具体做法看应用:传感器平滑场景可以接受0.1秒的“预充电”,但控制回路最好等状态稳定后再输出。

我处理过的一个案例:把三阶滤波用在电机电流采样上,上电瞬间滤波输出从0开始,电流环误以为有一个大误差,直接给了满占空比。改成首值初始化后问题消失。所以初始化这个动作在工程里不是小节,而是必要的保护。

4.2 右移截断导致死区与极限环

纯移位版本有个隐藏问题。假设 N=4,输入和状态的误差 err = 3,那么 err >> 4 等于0,状态完全不动。结果就是滤波输出停在某个值上不跟了,拿示波器看,信号不是平滑的,而是一段段阶梯,严重时会出现几Hz的极限环振荡。

解决方法是加舍入偏置。我一般用这样一个小函数:

static inline int32_t shr_round(int32_t v, int n) { int32_t bias; if (n <= 0) { return v; } bias = 1L << (n - 1); return (v + bias) >> n; }

这样 err=3、N=4 时,v + 8 = 11,右移4位等于0,还是0;err=9 时,9+8=17,右移4位等于1,滤波器能继续更新。死区从15个LSB缩小到8个LSB左右。

如果想做得更精细,可以用符号对称舍入,避免直流偏置。不过从这个公式可以反过来推导一个经验:纯移位方案的N别选太大,N超过8后,最小可分辨误差已经达到几个LSB,对于12位ADC通常还是够用的,但如果要求千分之一级的分辨率,就该换Q15方案了。

4.3 中间变量溢出

很多人在定点化时只在输出端做int16截断,结果某些中间状态悄悄溢出。错误的写法:

int16_t err = x - f->y1; // 溢出 int16_t prod = f->a1q * err; // 溢出

x=32767、y1=-32768时,差值65535,int16根本装不下。所以中间变量必须用int32。状态变量我也建议用int32,只在最终返回时钳位到int16。

这里有个心态值得说一下:为了省一点RAM,把所有变量都定义成int16,反而可能引入难查的偶发跳变。状态值多一点RAM占用,换来稳定,非常划算。

另外,Q15版本里(prod + 16384) >> 15中间结果会不会溢出,我在3.2节分析过,输入限制在±16384后安全。如果你不限制输入,就要用int64,或者把Q格式降到Q14、Q13,让乘法结果留更多余量。

4.4 采样周期抖动的影响

滤波系数 α 是采样周期 T 的函数。如果滤波在RTOS任务里执行,任务被更高优先级抢占,两次滤波之间的实际间隔不是恒定的,截止频率就跟着抖。噪声滤除效果变差,控制环路上还可能出现周期性扰动。

我的习惯是把ADC采集和滤波全部放在固定时基的中断里,比如1ms定时器中断;中断里只做采样、滤波、缓存结果,处理逻辑放主循环。如果因为功耗管理不能跑固定中断,那就用高分辨率定时器记录上次滤波的时刻,再动态算 α。但动态算 α 又回到浮点或除法的老路,所以还是固定中断最省心。

如果T抖动无法完全消除,至少让滤波在同一个任务里每次用相同的T,不要跨任务分散调用,否则A任务滤波一次、B任务再滤波一次,状态更新频率完全不可控。

4.5 量化误差造成的截止频率偏移

定点化之后,α不再是精确值。纯移位方案尤其明显,截止频率只能选离散档位。如果系统要求截止频率必须精确到0.1Hz,比如某个闭环带宽约束很严格的项目,纯移位方案是做不到的,必须用Q15定点乘法或者干脆浮点。

Q15本身也有量化误差。α_q 的步长是 1/32768 ≈ 0.003%,大多数场景完全够了。但要注意,Q15不能表示1.0,最大是32767/32768,如果初始化时 alpha 很接近1(对应极高的截止频率),a1q会略小于理想值。低通滤波里很少把α设到1附近,但如果确实需要,就用Q14或者uint16格式。

5. 测试验证与调参建议

5.1 用阶跃响应快速检查系统

滤波器写完,第一步别急着扫频,先做阶跃响应。在板子上给一个固定的输入偏置翻转,比如ADC输入从0突然变到满量程的50%,用串口或DAC观察输出。

三阶级联低通的阶跃响应有几个特征:没有过冲,上升速度比一阶慢,从信号跳变到输出稳定到95%大约需要3到5倍等效时间常数。如果看到明显的过冲,多半是级联顺序或系数用错了;如果输出爬得太慢,说明N偏大,截止频率低于预期;如果输出一跳就到位,说明N偏小,滤波效果不够。

阶跃响应能直接暴露4.1节的初始化问题。上电后第一次输出前状态是0,波形会出现一段“爬坡”,正常滤波器应该从首值开始平滑跟踪,而不是从0爬起。

5.2 用正弦扫频实测截止频率

阶跃响应只能看个大概,要确认-3dB点还得扫频。我的土办法是让滤波器输入一个固定幅值的正弦波,幅值例如1V,用逻辑分析仪或串口记录输出幅值,频率从低到高扫。当输出幅值降到输入幅值的0.707倍时,对应的频率就是实测截止频率。

用这个方法验证时,如果发现实测比理论低约一半,大概率是我在1.3节说的那个问题:把单级fc直接当成了三阶整体fc,忘记乘0.5098。

另一种常见偏差来自采样率设置错误:代码里以为T是1ms,实际定时器配成了2ms,fs只剩500Hz,所有截止频率都会往下降。这时候检查定时器比检查滤波器更有效。

5.3 调参顺序,建立起标准动作

我现在的标准流程已经固定了,分享出来供参考:

  • 第一步,根据信号最高频率和MCU算力确定采样率fs。采样率至少要大于信号最高频率的4倍,否则滤波做什么都白搭。
  • 第二步,根据要抑制的噪声频率fn确定截止频率。一般取fc = fn/5到fn/10,比如要压50Hz的工频噪声,fc选5Hz到10Hz。
  • 第三步,用公式 α = 2πfc/(fs+2πfc) 计算α,如果决定用纯移位,就近取α = 1/2^N,查表选N。
  • 第四步,考虑三阶效应对整体fc的修正,必要时把单级fc提高,补偿0.5098的下移。
  • 第五步,浮点基准验证通过后,改成Q15或移位版本。
  • 第六步,板级实测阶跃和扫频,确认偏差可接受。

这套流程看起来慢,实际效率最高。每次跳过一步,后面调试时间一定会加倍还回来。

5.4 多通道实现时的代码组织

实际项目很少只滤一个通道。多通道时,不要复制粘贴三套结构体,而是把滤波器状态放进数组,中断里用循环调用。纯移位版本每级状态都是int32,多通道也只是多几个数组元素。

如果三个通道需要的系数不同,比如ADC通道1要强平滑,通道2要快速响应,初始化时给每一路不同的N就行。运转时同一个process函数,代码量不会增加。

有一类项目为了省RAM,把所有通道共用一组状态,这绝对是错误的。每个独立信号必须有独立的历史状态,共用状态会让两路信号互相串扰,滤波结果完全失去物理意义。一阶RC都需要自己的状态,三阶更不用说。

最后说点个人体会

用纯移位版本的时候,我一开始心里也有点不安,总感觉滤波这种数学味道很重的东西,不舍得用浮点就不够“正统”。后来在一次电流采样项目里,我把浮点版和Q15版分别跑了一遍,时域波形几乎重合,阶跃响应和噪声抑制效果也一致,这才放心地把浮点版本从工程里删掉。对采样系统来说,真正重要的不是系数在数学上多精确,而是闭环传递特性是否满足工程要求。

最后再分享一个小技巧:如果调试时发现滤波效果时好时坏,不要只盯算法,先用示波器看ADC采样波形本身有没有混叠。采样率不够时,高频噪声折返到低频,三阶滤波器再强也滤不掉。先把采样率提上去,把模拟端的抗混叠做好,数字滤波器才能安心工作。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进