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格式约定 加减乘除 溢出与饱和 整数部分 小数部分 Q15 / Q31 Q8.8 / Q4.12 加减法 乘法 除法 溢出检测 饱和运算 小数点对齐 乘法后右移缩放 除法转查表+乘法 硬件饱和指令 (QADD/QSUB) 32位中间变量累加 核心:乘法 + 移位 + 饱和 = 高效定点运算

这张图把定点数运算的四个核心模块串起来了。从定点数表示和Q格式出发,到具体的加减乘除运算,再到溢出处理和饱和运算,最后落到硬件加速技巧。你跟着这个脉络走,基本就能把定点数玩转了。

我的经验:刚开始做定点数移植时,别急着优化。先把所有浮点运算原封不动转成定点,保证功能正确。然后用性能分析工具找出热点,针对性地用硬件饱和指令和查表法优化。这样既稳又快。