3. 滤波器基础
做 DSP 算法移植,说白了就是把 PC 上跑得好好的浮点代码,硬塞进一个没有 FPU 的嵌入式芯片里。我早年第一次干这事的时候,心想不就是把 float 换成 int 嘛,结果板子一跑,出来的全是噪声。嗯,从那以后我再也不敢小看定点化了。
这一节,咱们就把浮点转定点的底裤扒干净。你搞懂了 Q 格式和溢出处理,后面写滤波器代码才不会翻车。
3.1 为什么非要做定点化?
很多低成本的 DSP 芯片、MCU,压根没有硬件浮点单元。你写个 float y = a * x + b;,编译器会给你生成一坨软浮点库调用,跑一次乘法要几十个周期。对于降噪滤波器这种实时流处理,延迟根本扛不住。
定点运算用整数加减乘除,一个周期搞定。代价就是——你得自己管小数点的位置,自己防溢出。
核心矛盾:浮点靠硬件管小数点,定点靠程序员管小数点。你不管,它就乱给你看。
3.2 浮点数 vs 定点数:到底差在哪?
浮点数用科学记数法存,尾数 + 指数,动态范围大。定点数就是整数,只不过我们约定好:从最低位往左数,第几位是小数。
举个例子:
- 浮点:
3.14159→ 存成0x40490FDB(IEEE 754 单精度) - 定点 Q16:
3.14159→ 存成0x0003243F(整数 205887,实际值 = 205887 / 65536 ≈ 3.14157)
你看,定点数本质上就是个整数,除以 2^Q 才是真实值。这个 Q 就是小数部分的位数。
| 特性 | 浮点 (float) | 定点 (Q格式) |
|---|---|---|
| 硬件支持 | 需要 FPU | 普通 ALU 即可 |
| 运算速度 | 慢(软浮点更慢) | 快(1~2 周期) |
| 动态范围 | 极大(±10^38) | 有限(由位宽决定) |
| 精度 | 均匀分布 | 均匀分布(但范围小) |
| 溢出风险 | 几乎不会 | 天天见 |
我的习惯:只要芯片有硬件浮点,我绝不主动做定点化。但很多音频 DSP 芯片(比如 ADAU1701、CS47048)只支持定点,没得选。
3.3 Q 格式:小数点的契约
Q 格式的写法是 Qm.n,m 是整数位,n 是小数位。对于 16 位定点数,Q1.15 表示 1 位符号 + 1 位整数 + 15 位小数。实际取值范围是 [-1, 1 - 2^-15]。
我常用的 Q 格式就三种:
- Q1.15(16位):音频信号标准格式,范围 [-1, 1),精度 3.05e-5
- Q1.31(32位):中间累加结果,范围 [-1, 1),精度 4.66e-10
- Q8.8(16位):系数或增益,范围 [-128, 127.996),精度 3.9e-3
为什么音频信号爱用 Q1.15?因为 ADC 出来的数据归一化到 [-1, 1),Q1.15 刚好装得下,而且乘法结果直接是 Q2.30,右移 15 位就回到 Q1.15,非常规整。
我曾经踩过的坑:把滤波器系数用 Q1.15 存,结果系数绝对值大于 1,直接截断成 0x7FFF。滤波器变成了一个纯放大器。后来我改用 Q2.14 存系数,才解决问题。
3.4 浮点转定点的标准流程
别一上来就对着代码改类型。我总结了一套流程,你照着做,基本不会翻车。
- 数据范围摸底:跑一遍浮点算法,记录所有变量的最大值、最小值。用
printf打出来,或者用 IDE 的 watch 窗口。 - 选 Q 格式:根据最大值选整数位,根据精度需求选小数位。公式:整数位 = ceil(log2(|max|)) + 1(符号位)。
- 系数转换:
coeff_q = (int16_t)(coeff_f * (1 << Q)),注意四舍五入。 - 运算重写:乘法后右移 Q 位,加法注意扩展位宽。
- 溢出保护:关键路径加饱和截断。
举个例子,一个一阶 IIR 低通滤波器:
// 浮点版本
float y = a0 * x + b1 * y_prev;
// 定点版本 (Q1.15)
int16_t a0_q = (int16_t)(a0 * 32768.0f + 0.5f);
int16_t b1_q = (int16_t)(b1 * 32768.0f + 0.5f);
int32_t acc;
acc = (int32_t)a0_q * (int32_t)x; // Q2.30
acc += (int32_t)b1_q * (int32_t)y_prev; // Q2.30
y = (int16_t)(acc >> 15); // 截断回 Q1.15
注意:乘法前一定要把操作数扩展成 32 位,否则 16x16 的结果只有 16 位,溢出直接丢高位。我见过有人写 int16_t acc = a * b;,结果算出来全是错的。
3.5 溢出处理策略:别让数据静默损坏
溢出是定点化的头号杀手。浮点溢出会变成 Inf,一眼就能看出来。定点溢出会回绕,比如 0x7FFF + 1 = 0x8000,从正最大变成负最大。滤波器里出现这种回绕,输出直接炸裂。
我常用的策略有三种:
| 策略 | 做法 | 适用场景 |
|---|---|---|
| 饱和截断 | 结果超过最大值就钳位到最大值 | 输出级、DAC 前 |
| 扩展位宽 | 中间计算用 32/64 位,最后再截断 | 滤波器累加、乘法链 |
| 动态缩放 | 根据信号能量自动调整 Q 值 | AGC、自适应滤波器 |
饱和截断的代码实现:
int16_t sat16(int32_t x) {
if (x > 32767) return 32767;
if (x < -32768) return -32768;
return (int16_t)x;
}
我曾经犯过的错:在 IIR 反馈路径里用了饱和截断,结果滤波器变成了非线性系统,低频自激振荡。后来我改成只在最终输出做饱和,反馈路径用扩展位宽,问题解决。
3.6 知识体系总览
下面这张图把定点化的核心逻辑串起来了。你从浮点出发,经过范围摸底、Q 格式选择、运算重写、溢出保护,最后落地到定点代码。每一步都有对应的坑和策略。
3.7 几个实战建议
最后,分享几个我这些年攒下来的经验:
- 先仿真再上板:在 PC 上用定点 C 库(比如 CMSIS-DSP 的定点函数)跑一遍,跟浮点结果对比。误差在 1% 以内就算合格。
- 留足头空间:滤波器中间节点的最大值往往比输入大好几倍。我习惯在 Q 格式里多留 2~3 个整数位,防止突发信号溢出。
- 别迷信 Q1.15:虽然它是音频标准,但滤波器系数、增益值经常超过 1。该用 Q2.14 或 Q3.13 就别犹豫。
- 测试边界值:用满幅正弦波(0dBFS)输入,观察滤波器内部节点有没有溢出。没有溢出才算通过。
一句话总结:定点化就是跟小数点签合同——你定好 Q 值,编译器按合同执行。合同签错了,代码跑得再快也是废的。