2. 音频DSP基础
做音频DSP的朋友,迟早要面对一个现实:浮点运算虽然方便,但在嵌入式平台上,它太慢了,也太耗电了。我刚开始从PC端算法移植到ARM Cortex-M系列时,就吃过这个亏——算法在PC上跑得飞快,一上芯片就卡成PPT。后来才发现,罪魁祸首就是浮点运算。
那怎么办?用定点数。说白了,就是用整数来模拟小数运算。今天我们就聊聊定点数的那些事儿。
定点数表示:为什么不用浮点?
浮点数有指数部分和尾数部分,动态范围大,但硬件开销也大。定点数就简单了——固定的小数点位置,本质上就是个整数,只是我们约定好哪几位是小数部分。
举个例子:一个16位的定点数,如果我们约定高8位是整数部分,低8位是小数部分,那么二进制 00000001.10000000 就表示 1.5。就这么简单。
核心思想:定点数 = 整数 × 缩放因子。缩放因子通常是2的幂次,这样移位就能完成缩放,效率极高。
Q格式:约定大于配置
Q格式是业界约定俗成的定点数表示法。比如 Q15 表示16位有符号数,小数部分占15位(1位符号位 + 15位小数)。Q1.15 也是同样的意思。
常见的Q格式有这些:
| Q格式 | 总位数 | 整数位 | 小数位 | 取值范围 | 精度 |
|---|---|---|---|---|---|
| Q15 | 16 | 1(符号) | 15 | [-1, 0.99997] | 3.05e-5 |
| Q31 | 32 | 1(符号) | 31 | [-1, 0.9999999995] | 4.66e-10 |
| Q8.8 | 16 | 8 | 8 | [-128, 127.996] | 3.9e-3 |
| Q4.12 | 16 | 4 | 12 | [-8, 7.9998] | 2.44e-4 |
我个人习惯在音频处理中用 Q15 和 Q31。为什么?因为音频信号归一化后就在 [-1, 1] 之间,Q15 和 Q31 刚好覆盖这个范围,精度也够用。
选型建议:如果信号动态范围小但需要高精度,选小数位多的Q格式;如果信号范围大,就得多留整数位。音频信号通常选 Q15 或 Q31。
定点数加减法:简单但要注意溢出
定点数的加减法,其实就是整数的加减法。但有个坑——小数点对齐。
两个Q15数相加,直接加就行,因为小数点位置一样。但Q15和Q8.8相加呢?得先对齐。怎么对齐?把Q8.8左移7位变成Q15,或者把Q15右移7位变成Q8.8。我建议统一往高精度格式对齐,这样损失小。
// Q15 + Q15,直接加
int16_t a_q15 = 0x4000; // 0.5
int16_t b_q15 = 0x2000; // 0.25
int16_t sum_q15 = a_q15 + b_q15; // 0x6000 = 0.75
// Q15 + Q8.8,先对齐
int16_t a_q15 = 0x4000; // 0.5
int16_t b_q8_8 = 0x0040; // 0.25 in Q8.8
int16_t b_q15 = b_q8_8 << 7; // 左移7位变成Q15
int16_t sum_q15 = a_q15 + b_q15; // 0x6000 = 0.75
嗯,这里要注意:左移7位后,b_q15 可能溢出16位范围吗?不会,因为 Q8.8 的 0.25 左移7位后是 0x2000,还在Q15范围内。但如果 b_q8_8 是 2.0,左移7位就爆了。所以对齐前一定要确认数值范围。
定点数乘法:精度与性能的博弈
乘法是定点数运算的重头戏。两个Q15数相乘,结果是个Q30数(32位)。但我们需要把它存回16位,就得做一次缩放。
公式很简单:Q15 × Q15 = Q30 → 右移15位 → Q15
// Q15乘法,结果保留Q15
int16_t a_q15 = 0x4000; // 0.5
int16_t b_q15 = 0x6000; // 0.75
int32_t product = (int32_t)a_q15 * (int32_t)b_q15; // Q30: 0x18000000
int16_t result_q15 = (int16_t)(product >> 15); // Q15: 0x3000 = 0.375
我在项目中遇到过一个问题:两个负数相乘,结果应该是正数,但右移15位后符号位处理不对。后来发现,必须用算术右移(带符号扩展),不能是逻辑右移。C语言里对有符号整数做 >> 操作,通常是算术右移,但不同编译器行为可能不一样。保险起见,我一般这样写:
// 安全的Q15乘法
static inline int16_t q15_mul(int16_t a, int16_t b) {
int32_t product = (int32_t)a * (int32_t)b;
// 加上0x4000做四舍五入,提高精度
product += 0x4000;
return (int16_t)(product >> 15);
}
曾经踩过的坑:我曾经在乘法后忘记做饱和处理,结果两个大数相乘溢出后,信号直接反相了。调试了整整两天才发现是这个问题。从那以后,我所有定点乘法后面都加饱和判断。
定点数除法:能避免就避免
说实话,除法在定点数里是最麻烦的。能不除就不除,能用乘法替代就用乘法替代。
如果实在躲不开,有两种做法:
- 查表法:把除数倒数做成查找表,用乘法代替除法。比如
a / b = a * (1/b),提前算好 1/b 的定点数。 - 牛顿迭代法:用几次迭代逼近除法结果,适合没有硬件除法器的平台。
// 查表法实现Q15除法
// 假设除数b的范围是 [0.5, 1.0]
// 预计算 1/b 的Q15值
const int16_t inv_table[17] = {
0x7FFF, 0x7FFF, 0x7FFF, 0x7FFF, // 实际项目中需要精确计算
// ...
};
int16_t q15_div(int16_t a, int16_t b) {
// 先找b在表中的索引
int idx = (b >> 11) & 0xF; // 取高4位
int16_t inv_b = inv_table[idx];
return q15_mul(a, inv_b);
}
你看,最后还是回到了乘法。所以我说,定点数运算的核心就是乘法加移位。
溢出处理与饱和运算:保命符
溢出是定点数运算的头号杀手。两个0.8的Q15数相加,结果是1.6,但Q15最大只能表示0.99997,这就溢出了。
怎么办?饱和运算。就是当结果超出范围时,直接钳位到最大值或最小值。
// Q15饱和加法
int16_t q15_add_sat(int16_t a, int16_t b) {
int32_t sum = (int32_t)a + (int32_t)b;
if (sum > 0x7FFF) return 0x7FFF; // 正饱和
if (sum < -0x8000) return -0x8000; // 负饱和
return (int16_t)sum;
}
很多DSP芯片都有硬件饱和指令,比如ARM Cortex-M系列的 QADD、QSUB。用这些指令,一条就能搞定饱和运算,比软件判断快得多。
性能优化要点:
- 能用硬件饱和指令就别自己写if判断
- 乘法后立即做饱和,不要等到最后统一处理
- 连续加减法时,用32位中间变量累加,最后再饱和回16位
知识体系总览
下面这张图,把定点数运算的核心脉络串起来了:
这张图把定点数运算的四个核心模块串起来了。从定点数表示和Q格式出发,到具体的加减乘除运算,再到溢出处理和饱和运算,最后落到硬件加速技巧。你跟着这个脉络走,基本就能把定点数玩转了。
我的经验:刚开始做定点数移植时,别急着优化。先把所有浮点运算原封不动转成定点,保证功能正确。然后用性能分析工具找出热点,针对性地用硬件饱和指令和查表法优化。这样既稳又快。