第2章:嵌入式音频处理基础:定点数 vs 浮点数、Q格式详解、避免溢出的技巧

好,咱们进入正题。

做助听器算法,第一个绕不开的坎就是——数字表示。你想想看,一颗助听器芯片,主频可能就几十兆赫兹,RAM 可能只有几十 KB。在这种资源下跑音频算法,浮点数?想都别想。我刚开始从 PC 端算法转到嵌入式时,第一个被坑的就是这个。

2.1 定点数 vs 浮点数:为什么嵌入式必须选定点?

浮点数,说白了就是用科学计数法存小数。比如 3.14159,存成 3.14159 × 10^0。好处是动态范围大,精度高。但坏处也明显——硬件开销大,功耗高。

定点数呢?就是固定小数点位置。比如我们用 16 位整数来表示 -1 到 1 之间的数,小数点约定在第 15 位后面。这样加减乘除就是整数运算,快得很。

我个人的经验是:在助听器这种低功耗场景下,浮点运算单元(FPU)基本是奢侈品。就算有,为了省电,我也会尽量用定点。我在项目中遇到过一款芯片,开了 FPU 功耗直接翻倍,电池续航从 18 小时掉到 10 小时。你说你敢用吗?

核心结论:助听器算法落地,99% 的情况用定点数。浮点数只用于 PC 端原型验证。

2.2 Q格式详解:小数点到底放哪?

Q 格式,就是约定小数点位置的规则。比如 Q15 表示 16 位有符号数,小数点在第 15 位后面,范围是 -1 到 0.999969。Q1.15 也是同样的意思,1 位整数位,15 位小数位。

常见的 Q 格式有这些:

Q格式 位数 整数位 小数位 范围 精度
Q15 16 1 15 -1 ~ 0.999969 3.05e-5
Q1.15 16 1 15 -1 ~ 0.999969 3.05e-5
Q8.8 16 8 8 -128 ~ 127.996 3.9e-3
Q4.12 16 4 12 -8 ~ 7.9997 2.44e-4

怎么选?我一般遵循两个原则:

  • 看动态范围:音频信号一般归一化到 ±1,所以 Q15 最常用。但中间计算可能会放大,比如滤波器系数可能大于 1,这时候就要用 Q8.8 或 Q4.12。
  • 看精度需求:自适应滤波器的系数更新,精度要求高,我会用 Q31(32 位)。但输出到 DAC 时,16 位就够了。
我的小技巧:写代码时,我会在变量名里带上 Q 格式。比如 coeff_q15input_q31。这样三个月后回来看代码,一眼就知道数据在什么范围。

2.3 定点数的加减乘除:别踩坑

定点数的加减法很简单,只要两个数 Q 格式一样,直接加就行。但要注意——结果可能溢出。

举个例子:

int16_t a = 0x7FFF;  // Q15 格式,约等于 0.999969
int16_t b = 0x7FFF;  // Q15 格式
int16_t c = a + b;   // 结果 0xFFFE,变成 -2!

为什么会这样?因为 0x7FFF + 0x7FFF = 0xFFFE,在 16 位有符号数里是 -2。这就是溢出。

乘法更要注意。两个 Q15 数相乘,结果是 Q30,但只有 32 位寄存器。所以乘完后要右移 15 位,再转回 Q15。

int16_t a_q15 = 0x4000;  // 0.5
int16_t b_q15 = 0x4000;  // 0.5
int32_t temp = (int32_t)a_q15 * b_q15;  // 结果是 Q30
int16_t c_q15 = (int16_t)(temp >> 15);  // 右移15位,变回Q15
// c_q15 = 0x2000,即 0.25,正确
我曾经踩过的坑:做 LMS 自适应滤波器时,忘记对乘法结果做饱和处理。结果系数更新时偶尔溢出,导致滤波器发散,输出全是噪声。查了三天才找到原因。从那以后,我所有定点乘法后面都加饱和。

2.4 避免溢出的技巧:实战经验

溢出是定点算法的头号敌人。我总结了几个实用技巧:

  1. 预留保护位:中间计算用 32 位甚至 64 位。比如两个 16 位数相乘,用 32 位存结果。累加时用 64 位。
  2. 饱和截断:不要直接截断高位,要做饱和。比如结果大于 0x7FFF 就钳位到 0x7FFF。
  3. 动态缩放:根据信号能量动态调整 Q 格式。信号大时右移,信号小时左移。我在 AGC 算法里常用这招。
  4. 块浮点:一组数据共用一个指数。比如 16 个采样点,找到最大值,统一缩放。这样既保证精度,又避免溢出。

嗯,这里要注意——块浮点虽然好用,但实现起来有点麻烦。我一般只在 FFT 里用,因为 FFT 的中间结果动态范围特别大。

2.5 实战:一个简单的定点 FIR 滤波器

光说不练假把式。咱们写一个定点 FIR 滤波器:

#define TAP_NUM 32
#define Q_SHIFT 15

int16_t fir_q15(int16_t *coeff_q15, int16_t *buffer, int16_t input) {
    int32_t acc = 0;
    int i;
    
    // 更新缓冲区
    for(i = TAP_NUM - 1; i > 0; i--) {
        buffer[i] = buffer[i-1];
    }
    buffer[0] = input;
    
    // 乘累加,用32位累加器
    for(i = 0; i < TAP_NUM; i++) {
        acc += (int32_t)coeff_q15[i] * buffer[i];
    }
    
    // 饱和截断回Q15
    acc >>= Q_SHIFT;
    if(acc > 0x7FFF) acc = 0x7FFF;
    if(acc < -0x8000) acc = -0x8000;
    
    return (int16_t)acc;
}

你看,核心就是三步:乘、累加、饱和截断。我在项目中用这个结构跑了无数个滤波器,从来没出过问题。

总结一下:定点数不是洪水猛兽。只要理解 Q 格式,做好溢出保护,它比浮点数更可靠、更省电。你想想看,一颗纽扣电池要撑一周,每一微安电流都得省。定点运算就是你的最佳选择。

下一章,咱们聊聊音频信号处理中最常用的——FFT 和滤波器设计。到时候我会分享一些我在助听器反馈抑制算法里的实战经验,保证让你少走弯路。