ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

嵌入式无FPU上实现纯整数二阶IIR滤波器:Q格式定标与C代码实战

嵌入式无FPU上实现纯整数二阶IIR滤波器:Q格式定标与C代码实战

1. 为什么嵌入式环境要跟浮点较劲:纯整数方案的真实动机

先抛个大多数人都踩过的场景:小封装 MCU 上跑传感器采集,ADC 出来的原始数据带噪声,很自然地想到写一个二阶低通滤波器。顺手用 float 把差分方程抄了一遍,编译下载后发现事情不对——这块芯片根本没有 FPU,每个 float 乘法背后都是一长串软浮点库函数,一个 1kHz 的采样中断里跑一次滤波要吃掉几百个周期,还要看编译器脸色。更麻烦的是浮点库调用时机不可控,中断里偶尔蹦出个大延迟,采样节拍一乱,滤波器性能肉眼可见地变差。

“纯整数二阶滤波器”就是干这个的:把滤波器系数从 (-1, 1) 区间的小数放大成整数,用定点乘加代替浮点运算,整个滤波过程只有整数乘法、整数加法和移位。没有软浮点库调用,没有隐式函数跳转,执行时间固定到几十个周期以内,这在很多 M0/M0+ 单核小芯片上属于“降维打击”级别的优化。

这套东西解决的核心问题有三个:

  • 没有 FPU 的 MCU 上,浮点滤波器运行时间不可控、开销大;
  • 滤波处理必须和采样中断严格同步,允许的抖动窗口很窄;
  • 内存和代码空间受限,能省则省,每个栈字节都金贵。

适合看这篇的读者,基本是这几类:做 ADC 信号调理的嵌入式工程师、写电机或电源控制环的固件开发者、用单片机做简单音频处理的爱好者,以及刚接触定点 DSP、想把滤波器真正落到板子上的学生。文章不会绕理论绕太久,重点在怎么算系数、怎么定标、怎么写代码、上线前怎么验证。

纯整数方案也不是没有代价。它的精度受字长限制,动态范围不如 float;系数需要经过量化,搞不好会让滤波器的极点移动、甚至越过单位圆导致发散;累加过程还可能溢出。这些坑后面每一处都会展开讲。

2. 二阶 IIR 滤波器的结构:为什么嵌入式里默认选直接 I 型

2.1 差分方程与三种直接结构

二阶 IIR 滤波器,更常见的叫法是 biquad,也就是双二阶滤波器。它的输入输出关系可以用一条差分方程描述:

y[n] = b0*x[n] + b1*x[n-1] + b2*x[n-2] - a1*y[n-1] - a2*y[n-2]

x 是输入采样,y 是输出采样。分子系数 b0、b1、b2 控制零点,分母系数 a1、a2 控制极点。滤波器的频率响应、稳定性、群延迟,全部由这五个系数决定。

同一个差分方程在代码里可以翻译成好几种结构,最常见的是三种:

  • 直接 I 型(DF1):把输入历史和输出历史都存下来,按顺序乘加。状态变量是 x[n-1]、x[n-2]、y[n-1]、y[n-2],物理意义非常直观——“前两个输入、前两个输出”。
  • 直接 II 型(DF2):先算递归部分再算前馈部分,状态变量只有两个,省内存,但内部节点动态范围大,定点实现时容易溢出。
  • 转置直接 II 型(DF2T):状态更新公式和 DF2 反过来,稳定性更好,是很多专业 DSP 库的默认选择,但状态变量的物理含义不直观,调试时容易懵。

嵌入式定点开发,我推荐直接 I 型。原因是它的每个状态变量都对应着真实的信号历史,溢出分析和排查问题都能直接套用信号的幅值范围;而 DF2、DF2T 的内部状态可能比输入输出大得多,字长设计要额外小心。省那 4 字节内存,往往不值得那个调试成本。

2.2 系数从哪来:双线性变换与 RBJ 计算表

二阶滤波器的系数不是拍脑袋写的,一般路径是从模拟原型出发,经过双线性变换映射到数字域。双线性变换的核心思想是把模拟频率轴“卷”到数字频率轴,保证了模拟滤波器的稳定性在变换后依然成立。

这里有个关键概念叫频率预畸变。模拟频率和数字频率的关系不是线性的,如果直接拿模拟截止频率去套,数字滤波器实际截止点会偏移。预畸变的公式是:

ωa = 2 * fs * tan(ωd / 2)

其中 ωd = 2π * fc / fs,fc 是想要的数字截止频率,fs 是采样率。所有正经的系数计算工具,包括音频行业广泛使用的 RBJ EQ 手册公式,内部都隐含了这一步。

我平时算系数很少手推传递函数,直接用 RBJ cookbook 的低通套路:

ω0 = 2π * fc / fs α = sin(ω0) / (2 * Q) 低通: b0 = (1 - cos(ω0)) / 2 b1 = 1 - cos(ω0) b2 = (1 - cos(ω0)) / 2 a0 = 1 + α a1 = -2 * cos(ω0) a2 = 1 - α

顺手把高通、带通、陷波也列在下面,做嵌入式信号调理常用。

类型b0b1b2a1a2
低通(1-cosω0)/21-cosω0(1-cosω0)/2-2cosω01-α
高通(1+cosω0)/2-(1+cosω0)(1+cosω0)/2-2cosω01-α
带通α0-α-2cosω01-α
陷波1-2cosω01-2cosω01-α

注意上表还没归一化。计算完要统一除以 a0,让分母常数项变成 1,才能得到最终可用的 b0'、b1'、b2'、a1'、a2'。这一步在浮点世界里只是除一下,到了整数世界直接影响定标结果。

2.3 一组可以直接抄的系数示例

理论公式看着干巴巴,直接给两个我几乎每版项目都会用到的例子,先跑通代码再说别的。

例一:采样率 1kHz,截止 50Hz,Q=0.707(这就是二阶 Butterworth 低通)。

代入公式算完并归一化,浮点系数是:

b0 = 0.02008 b1 = 0.04017 b2 = 0.02008 a1 = -1.56105 a2 = 0.64135

例二:采样率 500Hz,截止 10Hz,Q=0.707。截得很低,系数值更“贴近”极点边界,正好用来后面演示量化误差:

b0 = 0.00362 b1 = 0.00724 b2 = 0.00362 a1 = -1.82270 a2 = 0.83718

记住这两组数的量级规律:系数基本都是小于 1 的小数,但 a1 的绝对值很容易超过 1,甚至逼近 2。这个事实直接决定了我们后面选 Q 格式时不能盲目用 Q15。

3. 纯整数化实现:Q 格式、累加器与 C 代码全解

3.1 Q 格式是什么:把小数“放大”成整数再算

浮点滤波器和整数滤波器的本质差异,就是小数怎么表示。Q 格式的思路是用一个整数加一个固定缩放因子来表示小数。Q14 的意思是每个系数都乘上 2^14 = 16384,存成整数;实际值等于整数除以 16384。

选 Q 格式的核心矛盾在于动态范围和精度:

  • Q 值越大,步进越小,精度越高,但能表示的数值范围越小;
  • Q15 能表示的最大值接近 1.0,放不下 a1(可能到 1.56 甚至 1.9);
  • Q14 最大值接近 2.0,覆盖绝大多数稳定 biquad 的分母系数。

所以嵌入式定点 biquad 的甜点位是 Q14:系数精度约 6.1e-5,对应量化噪声在 -84dB 量级,对 16 位传感器数据绰绰有余,同时能容纳 a1 接近 2 的窄带滤波器。

以例一低通滤波器为例,把五个系数乘 16384 再四舍五入:

系数浮点值Q14 整数
b00.02008329
b10.04017658
b20.02008329
-a11.5610525576
-a2-0.64135-10508

这里有个不少人第一次写会蒙圈的符号约定。差分方程里分母项是减 a1y[n-1] 减 a2y[n-2],在代码里为了避免减法、也为了利用乘加的流水线,我习惯把 c1 = -a1、c2 = -a2 直接存进结构体。这样递推式就变成了标准的五项乘加:

acc = b0*x + b1*x1 + b2*x2 + c1*y1 + c2*y2

例一中存入结构体的 c1 是 +25576,c2 是 -10508。这个“取负号再存”的做法是很多定点 DSP 库通行的约定,理解以后查问题会顺畅很多。

3.2 系数定标、舍入与量化后的稳定性检查

浮点系数转整数,最土也最稳妥的做法是四舍五入:round(coeff * 16384)。不要用截断,截断会引入系统性偏置,直流增益很容易飘掉 1%-2%。

量化以后必须做一次稳定性复核。二阶 IIR 的极点都在单位圆内才稳定,量化后的判定条件可以总结成两条,直接在整数域上验证:

1. |c2| < S (S = 16384) 2. |c1| < S - c2

拿例一验证:|c2| = 10508 < 16384,满足;|c1| = 25576,S - c2 = 16384 - (-10508) = 26892,25576 < 26892,满足。滤波器稳定。

例二就有意思了:量化后分子求和是 59 + 119 + 59 = 237,分母等效值是 S - c1 - c2 = 16384 - 29870 + 13717 = 231,直流增益变成了 237/231 ≈ 1.026,比理论值偏了大约 2.6%。这就是系数量化带来的直流增益误差。大多数传感器场景下这点偏差无所谓,但如果后面接的是高精度测量链路,就要注意预先补偿,或者在系数微调时手动找一组让直流增益更接近 1 的整数组合。

3.3 一套可直接移植的 C 实现

完整实现放出来,直接建工程就能跑。输入输出都是 int16_t,系数用 Q14,累加器用 int32_t:

#include <stdint.h> typedef struct { int16_t b0, b1, b2; /* Q14 分子系数 */ int16_t c1, c2; /* Q14 分母系数,注意已取负号 */ int16_t x1, x2; /* 历史输入 */ int16_t y1, y2; /* 历史输出 */ } biquad_q14_t; int16_t biquad_df1_process(biquad_q14_t *f, int16_t x) { int32_t acc; acc = (int32_t)f->b0 * x; acc += (int32_t)f->b1 * f->x1; acc += (int32_t)f->b2 * f->x2; acc += (int32_t)f->c1 * f->y1; acc += (int32_t)f->c2 * f->y2; /* Q14 还原:加半 LSB 做四舍五入,再右移 14 位 */ acc += (1 << 13); acc >>= 14; /* 状态移位与输出限幅 */ f->x2 = f->x1; f->x1 = x; f->y2 = f->y1; if (acc > 32767) acc = 32767; else if (acc < -32768) acc = -32768; f->y1 = (int16_t)acc; return f->y1; }

结构体的 x1、x2、y1、y2 就是滤波器状态。处理完当前样点后把状态往后挪一拍,等价于数学表达式里的 z^-1 延迟。整段代码没有除法、没有浮点、没有条件分支依赖数据,真正做到了定时确定。

初始化的时候要把整个结构体清零,尤其是 y1、y2 不能是随机值,否则上电头几百个样点会吐出吓人的毛刺。初始化代码一句话:

biquad_q14_t lpf = {329, 658, 329, 25576, -10508, 0, 0, 0, 0};

3.4 累加器溢出分析:什么时候必须上 64 位

int32_t 累加器到底够不够,是个必须较真的问题。每个乘积项最大是 int16 输入(32767)乘以 Q14 系数(最多 32767),单乘积最大约 1.07e9。五个乘积全取最大值相加约 5.4e9,已经超过 int32 上限 2.147e9。所以理论上最坏情况确实可能溢出。

但工程上要分场景。一个最常用的边界分析办法是算“三角形不等式上界”:

最大累加和 ≤ 输入满幅 × (|b0| + |b1| + |b2| + |c1| + |c2|)

例一的系数绝对值之和 329 + 658 + 329 + 25576 + 10508 = 37400。如果输入是 12 位 ADC 出来的 ±2048,最大累加和约 2048 × 37400 = 76.6e6,离 int32 上限差两个数量级,随便用。如果输入满幅是 16 位 ±32767,最大约 1.23e9,还在 int32 范围内,但余量只有 40% 左右,此时阶跃、谐振峰等瞬态响应可能把状态推到更恶劣的位置,不再安全。

我的实践规则是这样:

  • 传感器场景,信号来自 12 位或 16 位 ADC 但预处理后限制在 ±16 位以下,int32 累加器有余量,放心用;
  • 输出一路级联多级滤波、或者输入是 16 位满幅音频信号、或者滤波器 Q 值很高导致 a1 接近 2,直接换 int64 累加器,稳妥;
  • 任何拿不准的边界,用后面第 4 节的仿真办法先做最坏情况扫描。

int64 版本改动很小,把 acc 类型换成 int64_t,乘法的强制转换改成(int64_t),移位常数加LL后缀即可:

int64_t acc; acc = (int64_t)f->b0 * x; acc += (int64_t)f->b1 * f->x1; acc += (int64_t)f->b2 * f->x2; acc += (int64_t)f->c1 * f->y1; acc += (int64_t)f->c2 * f->y2; acc += (1LL << 13); acc >>= 14;

3.5 性能账本:M0+ 和 M3/M4 上的实测量级

精度之外,性能是整数方案的卖点。我按工程经验给一组量级参考,不同工具链会有差异,但数量级不会变。

Cortex-M3/M4 有单周期的 32x32 乘 64 位乘法指令,编译器会把整段乘加翻译成几条 SMLAL 类指令。一个 Q14 biquad 处理一个样点大概在 20-40 个周期,主频 168MHz 的 M4 上换算不到 0.25 微秒,48kHz 双声道音频也才占几个点 CPU,余量非常大。

Cortex-M0+ 没有 32x32 乘 64 位指令,int64 累加会退化成库函数调用,一个样点可能到 80-150 周期。但主频 48MHz 上依然不到 3 微秒,跑 1kHz 采样绰绰有余。如果 M0+ 上用 int32 累加器,周期数能砍到 40 以内,代价是需要按 3.4 节的规则做满幅分析。

作为对照,同一块 M0+ 上软浮点跑一个 biquad,我一个量产项目里实测量级在 5-15 微秒之间,取决于编译器浮点库的优化程度。纯整数方案在无 FPU 芯片上的优势不是“好一点点”,而是差了一个数量级。

4. 系数生成、验证与上线流程

4.1 用 Python 直接生成 Q14 整数系数

手算系数容易算错,我把生成 Q14 整数系数的脚本固化下来了,每次设计新滤波器直接改参数。脚本实现 RBJ 低通公式,不依赖 scipy,任何环境都能跑:

import math def rbj_lpf(fc, fs, q=0.7071, bits=14): w0 = 2.0 * math.pi * fc / fs cw = math.cos(w0) sw = math.sin(w0) alpha = sw / (2.0 * q) b0 = (1.0 - cw) / 2.0 b1 = 1.0 - cw b2 = (1.0 - cw) / 2.0 a0 = 1.0 + alpha a1 = -2.0 * cw a2 = 1.0 - alpha # 归一化 s = b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0 b0n, b1n, b2n, a1n, a2n = s scale = 1 << bits b0i = int(round(b0n * scale)) b1i = int(round(b1n * scale)) b2i = int(round(b2n * scale)) c1i = int(round(-a1n * scale)) # 注意负号 c2i = int(round(-a2n * scale)) return b0i, b1i, b2i, c1i, c2i, scale # 例一:1kHz 采样,50Hz 截止 print(rbj_lpf(50.0, 1000.0)) # 例二:500Hz 采样,10Hz 截止 print(rbj_lpf(10.0, 500.0))

输出就是可以直接填进 C 结构体的初始化值。高通、带通用同样的套路,把 2.2 节的公式对应替换即可。

如果想设计四阶、六阶巴特沃斯,手写公式很烦,建议直接上 scipy:

from scipy.signal import butter fs = 1000.0 fc = 100.0 sos = butter(4, fc, btype='low', fs=fs, output='sos') print(sos)

sos返回的每一行就是一个二阶节的 [b0, b1, b2, a0, a1, a2],再用上面的脚本逐行转成 Q14 整数,级联使用。

4.2 定点仿真:两类误差都要看

系数量化以后,必须把“浮点设计”和“Q14 定点实现”摆在一起对比,不能只看波特图理论值。两类误差需要分别验证:

幅度响应误差。量化后的滤波器在截止频率附近的增益可能偏零点几分贝,窄带滤波器尤其敏感。我一般直接在仿真里生成白噪声,分别过浮点滤波器和整数滤波器,对比输出频谱。整数滤波器不要直接在 C 里跑,先在 Python 里按 Q14 规则模拟一遍,改参数成本为零。

瞬态与极限环误差。给两个滤波器喂同一个阶跃信号,对比稳定后的残差。如果整数输出在零输入时还残留小幅振荡,那就是第 5 节要讲的极限环问题,要在仿真阶段就发现,不要拖到硬件上查。

量化对极点位置的扰动有个简单经验:Q14 的步进是 6.1e-5,二阶节的极点半径偏移通常在 1e-4 量级,对 Q 值不高的滤波器完全无感;但 Q 值超过 10、带宽极窄的滤波器,极点本来就压在单位圆边缘,量化后可能越界。这种情况要么降 Q 值,要么用 Q13 甚至 Q12 换取更大的系数表示范围。

4.3 上线前必做的三步检查

代码合入固件之前,我的固定流程就三步:

第一步,直流增益检查。算一下 (b0i + b1i + b2i) / (S - c1i - c2i),确认在可接受偏差内。这一步能拦住绝大多数系数符号搞反的错误。

第二步,阶跃响应与最坏情况扫描。输入按预期最大物理量换算成整数,再叠加一个正弦扫频信号,跑 10 万个样点,观察 acc 是否有逼近 int32 边界的时刻。顺手把滤波器初始化状态设成满幅,测启动瞬态会不会冲过头。

第三步,稳定性复核。用 3.2 节的两条不等式在整数域验证,别只在浮点上验证。极点是否在单位圆内,要以量化后的系数为准,而不是以设计稿为准。

5. 实战踩坑与排障实录

5.1 案例一:零输入下输出冒出的 ±1 LSB“鬼信号”

某次用整数滤波器处理压力传感器数据,把传感器断开、输入置零,示波器上看输出仍有幅度 1-2 个 LSB 的小幅振荡,频率还和滤波器谐振频率相关。排查半天,是典型的极限环现象。

极限环的成因在反馈路径的舍入误差。零输入时,输出状态全靠上一拍的历史值通过 c1、c2 反馈回来。如果舍入方向总是让这个反馈量“偏向”同一边,状态就不会彻底衰减到零,而是停在某个极小幅度上自我维持。DF1 结构配合四舍五入,极限环的幅度通常不超过 1-2 个 LSB,所以在 16 位数据链路里常常被当成噪声忽略。

处理方式我一般按场景选:

  • 传感器场景:1 个 LSB 的振荡远小于传感器本身噪声,直接接受,不加代码;
  • 音频场景:会变成可闻的本底噪声,建议在反馈路径改用“朝零截断”(magnitude truncation),即 acc 为正向下取整、为负向上取整,用牺牲微小直流偏差换极限环消失;
  • 实在敏感的场合:输出端加一个死区,幅度小于 2 LSB 直接清零。

5.2 案例二:采样间隔抖动导致截止频率漂移

另一个真实教训:滤波代码放在主循环里,用HAL_GetTick()判断“差不多到 1ms 了就跑一次”。功能看起来正常,但实测截止频率和设计值对不上,低通变成了“低通带一点锯齿”。原因是主循环被其他任务打断,实际采样间隔在 0.9ms 到 1.3ms 之间随机抖动。IIR 滤波器所有系数都建立在固定采样率假设上,间隔一变,截止频率跟着变。

正确做法是把滤波器挂在定时器中断或者 ADC 完成中断里,保证调用间隔固定。如果主循环里干很多活,就用 DMA + 定时器触发 ADC,转换完成中断里直接读数据、滤波、输出,一气呵成。滤波器的时钟就是采样节拍,节拍乱了一切白搭。

5.3 案例三:高 Q 窄带滤波器量化后不稳定

做某个音频均衡功能时,浮点仿真完全正常,切到 Q14 整数实现后,输出越来越大,直到饱和。问题出在窄带高 Q 滤波器的系数本来就贴着稳定性边界。浮点设计时 a2 是 0.9971,量化后变成 0.9976,少了一位有效数字,极点就挪出了单位圆。

这类问题的排查方法很直接:用 3.2 节的两条不等式去验整数系数,一旦第二条不满足,基本就是量化后的极点越界。解决路径有三条:换 Q13 或 Q12,牺牲一点精度换取系数表示范围;把窄带滤波器拆成两个宽带二阶节级联;或者改用 DF2T 结构加整节后缩放。

我在这个案例里的最终方案是改用 Q13 再把 Q 值从 12 降到 9,输出波形和浮点设计偏差小于 0.1dB,稳定跑完整个老化测试。

5.4 常见问题速查表

现象原因处理
输出满幅饱和或发散累加器溢出换 int64 累加器,或压低输入幅值
零输入仍有小振荡极限环反馈路径朝零截断,或加死区
截止频率不对采样间隔抖动滤波放到定时器/ADC 中断里
窄带滤波器量化后发散极点越界换 Q13/Q12,降低 Q 值,或拆级联
直流增益明显偏大/偏小系数截断误差改用四舍五入,手动微调一两个 LSB
上电开头出现大毛刺状态变量未清零结构体初始化时全部填 0

6. 把整数滤波器做成一个可复用的模块

6.1 接口设计的两个原则

实际项目里滤波器不会只有一个,ADC 去噪一个、电压环一个、诊断通道一个。把 biquad 封装成独立模块,接口设计我坚持两个原则:

第一,状态和数据分离。系数字段在运行期不该被修改,状态字段每拍都在变。初始化时用结构体赋值一次性写入系数,处理函数只碰状态。这样多个滤波器实例共用同一组系数时,可以在内存里只存一份系数表,状态分开。

第二,处理函数不做任何 I/O。滤波函数只负责“输入一个样点、输出一个样点”,调用者决定数据从哪来、送到哪去。这样做的好处是滤波函数天然可重入、可单元测试,后续无论接 ADC 还是接通信接口都不用改。

一个顺手的小设计是把处理函数做成内联的,因为每个采样周期都要调用,函数调用开销在极端场景下也值得省。

6.2 级联二阶节:四阶及以上滤波的实现方式

需要更陡的衰减曲线时,直接写高阶差分方程容易数值不稳定,工程标准做法是把高阶滤波器分解成多个二阶节级联。以四阶 Butterworth 低通为例,scipy 输出两行 SOS 数据,每行生成一个 biquad_q14_t 实例,按顺序处理:

biquad_q14_t sec1 = { /* 第一节系数 */ }; biquad_q14_t sec2 = { /* 第二节系数 */ }; int16_t process_4th(int16_t x) { x = biquad_df1_process(&sec1, x); x = biquad_df1_process(&sec2, x); return x; }

级联时每一级的输出就是下一级的输入。中间信号可能超过 16 位范围,所以中间传递我建议用 int32_t 或者把各级输入限制在 14 位以内。级联顺序也有讲究,一般把 Q 值高、动态范围大的环节放在后面,降低中间饱和概率。这个规律在仿真里可以验证。

6.3 选型:整数 IIR 和滑动平均到底怎么选

常有人问我,传感器去噪是不是用滑动平均就够了?答案是看需求。滑动平均(FIR 类)没有反馈,绝对不会不稳定,线性相位也无失真,但它衰减斜率固定,对采样率附近的噪声抑制能力弱,而且需要 FIFO 缓冲区。

IIR biquad 用几个状态变量换来陡峭的滚降,适合需要干净频带的场景,代价是相位非线性和潜在稳定性问题。我的习惯是这个:只压白噪声、不关心过渡带形状,用滑动平均或一阶 FIR 省事;需要在某个频点以下保留信号、之上彻底压掉,比如 50Hz 工频之外的振动噪声,就上 biquad。

纯整数 biquad 这个组件我前后用了三四年,踩过溢出、踩过极限环、踩过采样抖动,最后沉淀下来的就是这套“公式算系数 + Q14 定标 + 仿真验证 + 中断里跑”的流程。它最大的价值不在于省那几微秒,而在于让滤波器的行为变得可预测:同样的输入,永远在同样的周期数里给出同样的输出,这在控制环和实时系统里比任何精度提升都重要。

最后分享一个我一直保留的土办法:每一版系数合入固件前,把第 4 节的 Python 脚本跑出的整数系数,连同浮点参考值一起贴到注释里。三个月后回来改代码的人(通常就是你自己)看到那两行注释,能省掉一整个下午的重新推导时间。

RELATED READING

延伸阅读

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