解析 ‘Fixed-point Arithmetic’ (定点运算):在没有 FPU 的嵌入式 CPU 上替代浮点数的优化逻辑

各位编程专家,大家好!

今天,我们将深入探讨一个在嵌入式系统开发中至关重要的话题:定点运算(Fixed-point Arithmetic)。在资源受限、没有浮点运算单元(FPU)的嵌入式CPU上,如何高效、精确地进行数值计算,是摆在我们面前的一个核心挑战。浮点数固然提供了宽广的动态范围和灵活的精度,但在没有硬件FPU支持的情况下,其软件模拟开销巨大,无论是执行速度、功耗还是代码体积,都难以满足严苛的嵌入式需求。定点运算正是为了解决这一困境而生,它以其可预测的性能和资源消耗,成为了许多高性能、低功耗嵌入式应用的首选。

本次讲座,我们将从定点数的原理出发,逐步深入到其基本运算、高级运算、实践策略与优化技巧,并通过具体的代码示例,帮助大家掌握在无FPU环境下驾驭数值计算的能力。

引言:嵌入式世界的浮点困境

想象一下,你正在为一款电池供电的物联网设备开发固件,或者为汽车电子控制单元(ECU)编写关键算法。这些场景中,CPU往往是成本敏感的、低功耗的,因此通常不包含专门的浮点运算单元(FPU)。

浮点数(Floating-point Numbers),如floatdouble类型,遵循IEEE 754标准,由符号位、指数位和尾数位组成,能够表示非常大或非常小的数值,并且在精度上具有一定的自适应性。它们对于科学家、工程师进行通用计算非常方便,因为无需过多关注数值的量级。

然而,在没有FPU的嵌入式CPU上使用浮点数,会带来以下几个严重问题:

  1. 性能瓶颈:CPU需要通过复杂的软件库来模拟浮点数的加减乘除等操作。这些模拟函数通常由成百上千条整数指令组成,执行速度比硬件FPU慢几个数量级,严重拖慢系统响应时间。
  2. 功耗增加:长时间运行复杂的软件浮点模拟会消耗更多的CPU周期,进而导致更高的能耗,这对于电池供电设备是致命的。
  3. 代码体积膨胀:为了支持浮点运算,编译器需要链接庞大的浮点运算库,这会显著增加最终固件的二进制文件大小,占用宝贵的Flash存储空间。
  4. 不可预测性:软件模拟的浮点运算可能引入额外的舍入误差,而且其性能波动可能较大,难以满足实时性要求高的系统。

正是基于这些痛点,定点运算(Fixed-point Arithmetic)应运而生。它是一种利用整数类型来表示带有小数部分的数值的方法,通过约定小数点的位置来模拟浮点数的行为。定点运算充分利用了嵌入式CPU高效的整数运算能力,从而在性能、功耗和代码体积上取得了显著优势。

定点数的本质:表示与格式

定点数的思想很简单:我们用一个整数来存储一个数值,并约定这个整数的低N位代表小数部分,其余高位代表整数部分。小数点的位置是“固定”的,因此得名“定点”。

1. Q 格式 (Q_m_n)

最常见的定点数表示方法是Q格式,通常记作 Q_m_nQm.n

  • m 表示整数部分的位数。
  • n 表示小数部分的位数(即小数点右侧的位数)。

对于一个有符号的定点数,通常其总位数为 m + n + 1,其中 1 位用于符号位。
例如,一个 32 位的有符号定点数,如果采用 Q15.16 格式,意味着:

  • 1 位符号位。
  • 15 位整数部分。
  • 16 位小数部分。
    总计 1 + 15 + 16 = 32 位。

在这种格式下,一个定点数值 X 实际代表的浮点值为 X / (2^n)
2^n 也被称为缩放因子(scaling factor)。

示例:Q15.16 格式 (32位有符号整数)

特性 描述 Q 格式 小数部分位数 (N) 整数部分位数 (M) 符号位 总位数 范围 最小非零值 (精度)
Q15.16 16 15 1 32 -32768.0+32767.99998 2^-16 (约 0.000015)
Q7.24 24 7 1 32 -128.0+127.9999999 2^-24 (约 0.00000006)
Q23.8 8 23 1 32 -8388608.0+8388607.996 2^-8 (约 0.0039)

定点数与浮点数的权衡

  • 范围 (Range):定点数的范围由整数部分的位数决定。位数越多,能表示的整数范围越大。
  • 精度 (Precision):定点数的精度由小数部分的位数决定。位数越多,能表示的小数越精细,最小可分辨值越小。

范围和精度是相互制约的。在总位数固定的情况下(例如,都用 int32_t 表示),增加整数部分的位数就会减少小数部分的位数,从而降低精度;反之亦然。选择合适的Q格式是定点运算成功的关键,这需要对应用中的数值范围和所需精度有深入的理解。

2. 有符号与无符号定点数

  • 有符号定点数:使用整数类型的最高位作为符号位(通常是补码表示)。这意味着一半的表示范围用于负数。这是最常见的选择,因为许多实际物理量既有正值也有负值。
  • 无符号定点数:使用无符号整数类型,所有位都用于表示非负数值。这会将整个表示范围向正方向扩展一倍,适用于只处理正数(如距离、光强)的场景。

本讲座主要关注有符号定点数,因为它涵盖了更广泛的应用场景。

3. 定义定点数类型

为了方便起见,我们通常会定义一个统一的定点数类型,以及一系列辅助宏和函数。

#ifndef FIXED_POINT_ARITHMETIC_H
#define FIXED_POINT_ARITHMETIC_H

#include <stdint.h>
#include <limits.h> // For INT32_MAX, INT32_MIN, etc.
#include <math.h>   // For float operations when converting (for testing/debugging)

// --- 配置部分 ---
// 定义小数部分的位数。
// 对于一个 N 位的有符号定点数,如果 FRAC_BITS 是 F,
// 那么它有 (N-1-F) 位整数部分,F 位小数部分,以及 1 位符号位。
// 常见的选择有 8, 16, 24。
// 这里的例子假设使用 int32_t 作为底层类型,因此 N=32。
// Q_I_F -> I + F + 1 = 32. 所以 I = 31 - F.
#define FRAC_BITS 16 // 例如,Q15.16 格式

// 底层定点整数类型。
// 通常选择与CPU原生寄存器宽度匹配的类型,例如 int32_t。
typedef int32_t fx_t;
// 用于中间计算的更宽的整数类型,防止溢出。
typedef int64_t fx_long_t;

// --- 由配置派生的常量 ---
#define FX_ONE (1L << FRAC_BITS)         // 浮点数 1.0 的定点表示
#define FX_HALF (1L << (FRAC_BITS - 1))  // 浮点数 0.5 的定点表示,用于舍入
#define FX_MAX INT32_MAX                 // fx_t 的最大值
#define FX_MIN INT32_MIN                 // fx_t 的最小值

// --- 转换函数 ---

/**
 * @brief 将浮点数转换为定点数。
 * @param f 待转换的浮点值。
 * @return 对应的定点数表示。
 */
static inline fx_t float_to_fx(float f) {
    return (fx_t)(f * FX_ONE);
}

/**
 * @brief 将定点数转换为浮点数。
 * @param x 待转换的定点值。
 * @return 对应的浮点数表示。
 */
static inline float fx_to_float(fx_t x) {
    return ((float)x) / FX_ONE;
}

/**
 * @brief 将整数转换为定点数。
 * @param i 待转换的整数值。
 * @return 对应的定点数表示。
 */
static inline fx_t int_to_fx(int i) {
    return (fx_t)(i * FX_ONE);
}

/**
 * @brief 将定点数转换为整数(截断小数部分)。
 * @param x 待转换的定点值。
 * @return 对应的整数表示。
 */
static inline int fx_to_int(fx_t x) {
    return (int)(x / FX_ONE);
}

/**
 * @brief 将定点数转换为整数(四舍五入到最近的整数,半数远离零)。
 * @param x 待转换的定点值。
 * @return 对应的整数表示。
 */
static inline int fx_to_int_round(fx_t x) {
    // 这种舍入方法(round half away from zero)对正数和负数都适用。
    // 例如:1.5 -> 2, -1.5 -> -2
    return (int)((x >= 0) ? ((x + FX_HALF) / FX_ONE) : ((x - FX_HALF) / FX_ONE));
}

#endif // FIXED_POINT_ARITHMETIC_H

上面的代码片段是定点运算的基石。FRAC_BITS 是最关键的宏,它定义了定点数的精度。fx_t 是我们实际使用的定点数类型。FX_ONEFX_HALF 是非常重要的常量,它们在转换和运算中频繁使用。

定点数的基础运算:构建基石

定点数的魅力在于,许多运算可以像普通整数一样直接进行,而不需要复杂的处理。然而,乘法和除法需要特别的注意,因为它们会改变小数点的位置。

1. 加法和减法

定点数的加法和减法是最简单的。只要两个定点数使用相同的Q格式(即 FRAC_BITS 相同),它们可以直接像整数一样相加或相减。结果也保持相同的Q格式。

// 定义在上面的 FIXED_POINT_ARITHMETIC_H 中
/**
 * @brief 两个定点数相加。
 * @param a 第一个操作数。
 * @param b 第二个操作数。
 * @return a 和 b 的和。
 * @note 可能会发生溢出,此处未检查。
 */
static inline fx_t fx_add(fx_t a, fx_t b) {
    return a + b;
}

/**
 * @brief 两个定点数相减。
 * @param a 第一个操作数。
 * @param b 第二个操作数。
 * @return a 和 b 的差。
 * @note 可能会发生溢出,此处未检查。
 */
static inline fx_t fx_sub(fx_t a, fx_t b) {
    return a - b;
}

溢出问题:虽然加减法本身简单,但仍然存在溢出(overflow)的风险。如果两个非常大的定点数相加,结果可能超出 fx_t 类型能表示的最大范围。例如,在 Q15.16 格式下,FX_MAX 大约为 32767.99998。如果 20000.0 加上 20000.0,结果 40000.0 就超出了范围。处理溢出通常需要额外的检查或使用饱和运算(saturation arithmetic)。

2. 乘法

定点数乘法是核心挑战之一,因为直接将两个定点整数相乘会导致小数点位置的改变。
假设我们有两个 Q_I_F 格式的定点数 AB
它们实际代表的浮点值是 A_float = A / (2^F)B_float = B / (2^F)
它们的乘积的浮点值是 A_float * B_float = (A / 2^F) * (B / 2^F) = (A * B) / (2^(2F))
这意味着直接的整数乘积 A * B 的小数部分位数为 2F。如果我们将 A * B 存储回 Q_I_F 格式,我们需要将结果右移 F 位,以恢复小数点到原来的位置。

关键点

  1. 中间结果的位宽:两个 N 位整数相乘,其结果可能需要 2N 位来存储。例如,两个 32 位的 int32_t 相乘,结果可能需要 64 位的 int64_t 来存储,以避免中间结果溢出。
  2. 右移截断与舍入:将 2F 位小数的结果右移 F 位,会丢失低 F 位的信息。这等同于浮点乘法后的精度损失。为了提高精度,通常需要进行适当的舍入(rounding)。
// 定义在上面的 FIXED_POINT_ARITHMETIC_H 中
/**
 * @brief 两个定点数相乘。
 * @param a 第一个操作数。
 * @param b 第二个操作数。
 * @return a 和 b 的乘积。
 * @note 使用 64 位中间结果以保持精度。实现的是四舍五入到最近的整数,半数远离零。
 */
static inline fx_t fx_mul(fx_t a, fx_t b) {
    // 将两个 fx_t (int32_t) 相乘,结果可能达到 64 位。
    // 必须使用 fx_long_t (int64_t) 来存储中间结果,防止溢出。
    fx_long_t product = (fx_long_t)a * b;

    // 舍入到最近的整数,半数远离零:
    // 如果 product 为正,加 FX_HALF (0.5),然后右移 FRAC_BITS 位。
    // 如果 product 为负,减 FX_HALF (0.5),然后右移 FRAC_BITS 位。
    if (product >= 0) {
        return (fx_t)((product + FX_HALF) >> FRAC_BITS);
    } else {
        return (fx_t)((product - FX_HALF) >> FRAC_BITS);
    }
}

舍入解释
FX_HALF 实际上是 0.5 的定点表示。
对于正数 X.Y,如果 Y >= 0.5,我们希望向上舍入。在整数运算中,这相当于 (X * 2^F + 0.5 * 2^F) / 2^F。所以,在右移之前加上 FX_HALF
对于负数 -X.Y,如果 Y >= 0.5,我们希望向下(更负)舍入。例如 -1.5 舍入为 -2。在整数运算中,这相当于 (-X * 2^F - 0.5 * 2^F) / 2^F。所以,在右移之前减去 FX_HALF
这种舍入策略被称为“四舍五入到最近的整数,半数远离零(round half away from zero)”,是常见的舍入方式之一。

3. 除法

定点数除法也需要进行缩放以保持精度。
假设我们计算 A / B,其中 AB 都是 Q_I_F 格式。
A_float / B_float = (A / 2^F) / (B / 2^F) = A / B
直接进行整数除法 A / B,结果的小数部分将全部被截断,精度损失严重。

为了保持精度,我们需要在除法前将分子 A 放大 2^F 倍,使其具有更多的“小数位”,然后再进行除法。
A_float / B_float = ((A / 2^F) * 2^F * 2^F) / (B / 2^F) / 2^F = (A * 2^F) / B
所以,运算步骤是:A * FX_ONE / B

关键点

  1. 分子放大:将分子左移 FRAC_BITS 位(乘以 FX_ONE)。
  2. 位宽:放大后的分子可能需要 fx_long_t 来存储。
  3. 除零处理:必须避免除以零。
  4. 舍入:与乘法类似,需要进行适当的舍入。
// 定义在上面的 FIXED_POINT_ARITHMETIC_H 中
/**
 * @brief 两个定点数相除。
 * @param a 被除数(分子)。
 * @param b 除数(分母)。
 * @return a 除以 b 的商。
 * @note 使用 64 位中间结果以保持精度。处理除零。实现的是四舍五入到最近的整数,半数远离零。
 */
static inline fx_t fx_div(fx_t a, fx_t b) {
    if (b == 0) {
        // 处理除零错误。根据应用需求,可以返回 0、FX_MAX、FX_MIN 或触发错误。
        // 这里简单返回 0,但在实际系统中应有更健壮的错误处理。
        return 0;
    }

    // 将分子左移 FRAC_BITS 位,使其具有足够的精度。
    // 这可能导致 fx_long_t 溢出,如果原始 a 接近 INT32_MAX / (1 << FRAC_BITS) 的话。
    // 但通常 a 已经是一个定点数,其整数部分位宽是 (31 - FRAC_BITS),
    // 所以 a * FX_ONE (即 a << FRAC_BITS) 不会超过 int64_t 的范围。
    fx_long_t numerator_scaled = (fx_long_t)a << FRAC_BITS;

    // 执行整数除法
    fx_long_t result_val = numerator_scaled / b;
    fx_long_t remainder = numerator_scaled % b;

    // 四舍五入到最近的整数,半数远离零
    if (remainder != 0) {
        // 检查余数是否达到或超过除数的一半
        // abs(remainder) * 2 >= abs(b)
        // 如果满足,则需要进行舍入
        if ( (fx_long_t)abs(remainder) * 2 >= (fx_long_t)abs(b) ) {
            // 判断结果的符号,决定是向上(远离零)还是向下(远离零)舍入
            // (numerator_scaled > 0 && b > 0) || (numerator_scaled < 0 && b < 0) 意味着商为正
            if ( (numerator_scaled > 0 && b > 0) || (numerator_scaled < 0 && b < 0) ) {
                result_val++; // 商为正,向上舍入
            } else {
                result_val--; // 商为负,向下舍入
            }
        }
    }
    return (fx_t)result_val;
}

除零处理:在嵌入式系统中,除零可能导致CPU异常,甚至系统崩溃。因此,在进行除法前必须检查分母是否为零。

4. 移位操作

移位操作是定点运算中非常高效的工具,因为它等同于乘以或除以2的幂。

// 定义在上面的 FIXED_POINT_ARITHMETIC_H 中
// 左移 n 位,等价于乘以 2^n
#define FX_LSHIFT(x, n) ((x) << (n))
// 右移 n 位,等价于除以 2^n
#define FX_RSHIFT(x, n) ((x) >> (n))

例如,如果 aQ15.16 格式的 1.5 (即 1.5 * (1<<16)),那么 FX_LSHIFT(a, 1) 就是 3.0FX_RSHIFT(a, 1) 就是 0.75
移位操作在调整定点数格式或进行快速乘除2时非常有用。

5. 格式转换(不同Q格式之间)

有时我们需要在不同的Q格式之间转换定点数,例如从 Q_I1_F1 转换为 Q_I2_F2
转换的核心是调整小数点的位置。

  • Q_I1_F1 转换为 Q_I2_F2
    如果 F2 > F1 (增加小数位数,提高精度),需要左移 (F2 - F1) 位。
    new_fx = old_fx << (F2 - F1);
    如果 F2 < F1 (减少小数位数,降低精度),需要右移 (F1 - F2) 位,并可能需要舍入。
    new_fx = old_fx >> (F1 - F2); // 简单截断
    new_fx = (old_fx + (1L << (F1 - F2 - 1))) >> (F1 - F2); // 简单舍入
// 假设 FRAC_BITS_OLD 和 FRAC_BITS_NEW 是已定义的旧和新小数位数
// 示例:将 Q_OLD_FRAC_BITS 格式的定点数转换为 Q_NEW_FRAC_BITS 格式
static inline fx_t fx_convert_format(fx_t x, int old_frac_bits, int new_frac_bits) {
    if (new_frac_bits > old_frac_bits) {
        return x << (new_frac_bits - old_frac_bits);
    } else if (new_frac_bits < old_frac_bits) {
        // 舍入到最近,半数远离零
        int shift_amount = old_frac_bits - new_frac_bits;
        fx_long_t intermediate = (fx_long_t)x;
        fx_long_t half = 1L << (shift_amount - 1);
        if (intermediate >= 0) {
            return (fx_t)((intermediate + half) >> shift_amount);
        } else {
            return (fx_t)((intermediate - half) >> shift_amount);
        }
    }
    return x; // 格式相同,无需转换
}

定点数的高级运算:扩展能力

除了基本的四则运算,许多复杂算法还需要平方根、三角函数、指数和对数等高级运算。在定点运算中实现这些功能,通常需要采用迭代算法、查找表(Lookup Table, LUT)或专门的算法如CORDIC。

1. 平方根 (Square Root)

在定点领域计算平方根通常比浮点数复杂。常见的实现方法有:

  • 牛顿迭代法 (Newton’s Method):这是一种快速收敛的迭代算法,需要多次乘法和除法,适合精度要求较高的情况。
  • 二分法 (Binary Search):通过不断缩小搜索区间来逼近结果,相对简单但迭代次数可能较多。
  • 查找表 (Lookup Table, LUT):对于输入范围有限的场景,可以预先计算所有可能的平方根值并存储在数组中。这提供了极快的速度,但会占用大量内存。
  • 倒数平方根的快速近似 (Fast Inverse Square Root):在某些图形学应用中,会使用位操作技巧来近似计算倒数平方根,然后通过乘法得到平方根。

以牛顿迭代法为例,计算 sqrt(X)
x_n+1 = 0.5 * (x_n + X / x_n)
这个迭代过程需要定点数的乘法、除法和加法。

// 伪代码或简化的定点平方根 (牛顿迭代法)
// 假设输入 x 是 Q_I_F 格式,结果也是 Q_I_F 格式
// 需要一个初始猜测值,通常是 x 的一半或一个固定值
// 这里的实现是一个概念性示例,实际应用需要更精细的优化和收敛判断。
static inline fx_t fx_sqrt(fx_t x) {
    if (x < 0) return 0; // 负数没有实数平方根
    if (x == 0) return 0;

    // 初始猜测:通常可以取 x/2 或一个经验值
    // 为了防止过早收敛或不收敛,通常将初始猜测值放大
    // 注意:这里的 X 需要是原始定点数的两倍精度,因为 X / x_n 会再次缩放
    // 为了保持精度,我们需要在迭代过程中使用更高的精度
    fx_long_t val_long = (fx_long_t)x << FRAC_BITS; // 将 x 提升到两倍小数位宽,以便进行除法

    // 初始猜测值,例如 x 的近似一半,并调整到正确的 Q 格式
    // 假设结果也是 Q_I_F,那么初始猜测值也应该是 Q_I_F
    fx_long_t guess = (fx_long_t)(x >> 1) + int_to_fx(1); // 简单猜测,x/2 + 1.0
    if (guess == 0) guess = int_to_fx(1); // 避免初始猜测为0

    for (int i = 0; i < 10; ++i) { // 迭代次数通常根据精度需求确定
        // x_n+1 = 0.5 * (x_n + X / x_n)
        // fx_t term = fx_div(x, guess); // 这会降低精度,我们需要更精细的除法
        // fx_t term = (fx_t)((val_long / guess)); // 这里的除法结果是 Q_I_F
        // guess = fx_mul(FX_HALF, fx_add(guess, term)); // 这里的乘法是 fx_mul(0.5, ...)

        // 避免在循环中反复调用 fx_div, fx_mul,因为它们有舍入。
        // 直接使用 64 位整数运算。
        fx_long_t term_div = (val_long / guess); // 结果是 Q_I_F
        fx_long_t next_guess = (guess + term_div); // 和是 Q_I_F

        // 乘以 0.5 (右移 1 位)
        next_guess >>= 1; // 结果是 Q_I_F

        if (next_guess == guess) break; // 收敛判断
        guess = next_guess;
    }
    return (fx_t)guess;
}

注意:上述 fx_sqrt 示例是概念性的。实际的定点平方根函数需要更精密的初始猜测、更严格的迭代收敛条件以及对中间结果精度更细致的控制。通常,为了避免反复的 fx_div 调用,会将 X 预先放大,使得 X/x_n 能够直接用长整数除法得到所需精度的结果。

2. 三角函数 (Sine, Cosine, Tangent, Arctan)

三角函数在信号处理、控制系统、图形学中非常常见。在定点领域实现它们的主要方法有:

  • 查找表 (Lookup Table, LUT):最直接、最快速的方法。预先计算一个周期内(例如 0PI/2)的 sin 值并存储在数组中。运行时通过查表和插值(线性插值、二次插值等)获取结果。
    • 优点:速度快,简单。
    • 缺点:占用内存,插值会引入误差,精度受限于表的大小。
  • CORDIC 算法 (COordinate Rotation DIgital Computer):一种迭代算法,通过一系列预计算的旋转角度和移位操作来计算三角函数、双曲函数、对数、平方根等。
    • 优点:无需乘法器,只用移位和加法,硬件实现高效,软件实现也相对快速。
    • 缺点:迭代次数多,软件实现可能比查找表慢。
  • 泰勒级数/Maclaurin级数展开 (Taylor/Maclaurin Series):通过多项式近似计算。
    • 优点:理论上可以达到任意精度。
    • 缺点:收敛速度慢,需要大量乘法,计算开销大。通常只在少量迭代能达到足够精度时使用,或者作为LUT的备用计算。

LUT 实现 fx_sin 示例(概念性)

// 假设我们有一个预计算的 sin 值查找表
// 表格通常只存储 0 到 PI/2 范围内的值,其他象限通过对称性获得。
// 例如,256个点,覆盖 0 到 PI/2,每个点都是 Q15.16 格式。
#define LUT_SIZE 256
#define ANGLE_RANGE_PI_HALF int_to_fx(90) // 90度,或 PI/2 弧度
// 实际使用时,LUT的索引值需要与角度范围对应
// 例如,如果 LUT 覆盖 0 到 PI/2 (90度),且有 LUT_SIZE 个点
// 那么每个索引代表的角度是 (90.0 / LUT_SIZE) 度

// 这是一个简化的 sin LUT 示例,假设 LUT 已经存在并填充了 Q15.16 的值
// 这里的 LUT_DATA 仅为演示,实际应由工具生成
// const fx_t fx_sin_lut[LUT_SIZE] = { ... };

// 假设输入角度 angle 是定点数,表示弧度,范围 -PI 到 PI
// 或者度数,范围 -180 到 180
// 这里的示例假设输入是 Q_I_F 格式的弧度,范围 0 到 2*PI
static inline fx_t fx_sin(fx_t angle_rad) {
    // 实际实现需要角度归一化到 0..2*PI,然后映射到 0..PI/2 象限,进行查表和插值
    // 这一步非常复杂,这里只提供概念性的查表框架。

    // 1. 角度归一化到 [0, 2*PI)
    // 假设 PI_FX = float_to_fx(M_PI)
    // 假设 TWO_PI_FX = float_to_fx(2 * M_PI)
    // angle_rad %= TWO_PI_FX; // 需要考虑负数模运算
    // if (angle_rad < 0) angle_rad += TWO_PI_FX;

    // 2. 映射到 [0, PI/2) 象限,并确定符号和象限
    // int quadrant = ...; // 0, 1, 2, 3
    // fx_t normalized_angle = ...; // 0 到 PI/2 范围内的角度

    // 3. 计算查找表索引和插值
    // float lut_index_float = fx_to_float(normalized_angle) / fx_to_float(PI_HALF_FX) * (LUT_SIZE - 1);
    // int index = (int)lut_index_float;
    // float frac_part = lut_index_float - index;

    // fx_t val1 = fx_sin_lut[index];
    // fx_t val2 = fx_sin_lut[index + 1]; // 需要边界检查
    // fx_t interpolated_val = fx_add(val1, fx_mul(fx_sub(val2, val1), float_to_fx(frac_part)));

    // 4. 根据象限调整符号
    // if (quadrant == 1 || quadrant == 3) return fx_negate(interpolated_val);
    // else return interpolated_val;

    // 这是一个非常简化的占位符,实际需实现完整的角度归一化、象限映射、查表和插值逻辑。
    // 为了满足讲座字数要求且不引入过多复杂性,这里不再展开完整实现。
    // 一般来说,LUT_SIZE 的选择和插值方法(线性、二次)是关键。
    return float_to_fx(sin(fx_to_float(angle_rad))); // 实际代码会避免此行,直接计算
}

3. 饱和与舍入 (Saturation and Rounding)

  • 饱和 (Saturation):当计算结果超出 fx_t 类型所能表示的范围时,不是发生溢出(绕回),而是将结果限制在最大值 FX_MAX 或最小值 FX_MIN。这在控制系统中非常重要,可以防止系统变量失控。

    // 定义在上面的 FIXED_POINT_ARITHMETIC_H 中
    /**
     * @brief 两个定点数相加,带饱和处理。
     * @param a 第一个操作数。
     * @param b 第二个操作数。
     * @return a 和 b 的和,溢出时饱和到 FX_MAX 或 FX_MIN。
     */
    static inline fx_t fx_add_sat(fx_t a, fx_t b) {
        fx_long_t res = (fx_long_t)a + b; // 使用更宽的类型进行中间计算
        if (res > FX_MAX) return FX_MAX;
        if (res < FX_MIN) return FX_MIN;
        return (fx_t)res;
    }
    
    /**
     * @brief 两个定点数相减,带饱和处理。
     * @param a 第一个操作数。
     * @param b 第二个操作数。
     * @return a 和 b 的差,溢出时饱和到 FX_MAX 或 FX_MIN。
     */
    static inline fx_t fx_sub_sat(fx_t a, fx_t b) {
        fx_long_t res = (fx_long_t)a - b; // 使用更宽的类型进行中间计算
        if (res > FX_MAX) return FX_MAX;
        if (res < FX_MIN) return FX_MIN;
        return (fx_t)res;
    }
    
    /**
     * @brief 两个定点数相乘,带饱和处理。
     * @param a 第一个操作数。
     * @param b 第二个操作数。
     * @return a 和 b 的乘积,溢出时饱和到 FX_MAX 或 FX_MIN。
     */
    static inline fx_t fx_mul_sat(fx_t a, fx_t b) {
        fx_long_t product = (fx_long_t)a * b;
        fx_long_t res_long;
        // 四舍五入到最近的整数,半数远离零
        if (product >= 0) {
            res_long = (product + FX_HALF) >> FRAC_BITS;
        } else {
            res_long = (product - FX_HALF) >> FRAC_BITS;
        }
    
        if (res_long > FX_MAX) return FX_MAX;
        if (res_long < FX_MIN) return FX_MIN;
        return (fx_t)res_long;
    }
  • 舍入 (Rounding):在降低精度(如右移操作)时,如何处理被丢弃的最低位,会影响最终结果的准确性。常见的舍入模式包括:
    • 截断 (Truncation):直接丢弃小数部分,例如 1.7 变为 1-1.7 变为 -1。这是最简单也是默认的整数除法行为。
    • 四舍五入到最近的整数 (Round to Nearest)
      • 半数远离零 (half away from zero)1.5 变为 2-1.5 变为 -2。这是我们在 fx_mul, fx_divfx_to_int_round 中实现的策略。
      • 半数向偶数 (half to even)1.5 变为 22.5 变为 2。这种方法在统计上可以减少累积误差。
    • 向上舍入 (Round Up / Ceiling):总是向正无穷方向舍入,例如 1.1 变为 2-1.1 变为 -1
    • 向下舍入 (Round Down / Floor):总是向负无穷方向舍入,例如 1.1 变为 1-1.1 变为 -2

选择合适的舍入方法对于算法的数值稳定性至关重要。

实践策略与优化技巧

定点运算的强大之处在于其可控性,但这也意味着开发者需要更细致地管理数值。

1. Q 格式的选择:如何平衡范围和精度

选择 FRAC_BITS 是定点运算中最关键的设计决策之一。

  • 分析数值范围:首先确定你的应用中可能出现的最大和最小数值。这决定了整数部分 I 的最小位数。例如,如果你的传感器读数范围是 -100.0+100.0,那么你需要至少 7 位来表示 100 (2^6=64, 2^7=128),所以 I >= 7
  • 分析所需精度:确定你的应用所需的最小可分辨值。这决定了小数部分 F 的最小位数。例如,如果需要 0.01 的精度,那么 2^-F <= 0.01,即 F >= log2(1/0.01) = log2(100) ≈ 6.64,所以 F >= 7
  • 总位宽限制:通常底层整数类型是 int16_tint32_t。对于 int32_t,总位宽是 32,有符号数可用 31 位表示数值 (I + F = 31)。
  • 权衡
    • FRAC_BITS 越大,精度越高,但整数范围越小。
    • FRAC_BITS 越小,整数范围越大,但精度越低。
  • 经验法则
    • 对于大多数通用目的,FRAC_BITS = 16 (Q15.16) 是一个很好的起点,它提供了相对平衡的范围和精度。
    • 如果需要更大的整数范围(例如处理天文数字),可以考虑 FRAC_BITS = 8 (Q23.8)。
    • 如果需要更高的精度(例如DSP滤波器),可以考虑 FRAC_BITS = 24 (Q7.24),但这会显著限制整数范围。
    • 在某些极端情况下,可能需要使用 int64_t 作为底层类型,以提供更大的总位宽。

2. 宏与结构体:两种实现方式的对比

在C语言中,定点运算可以基于宏或结构体实现。我们上面使用的是静态内联函数,它兼具宏的内联效率和函数的类型检查与封装。

  • 宏 (Macros)

    • 优点:代码简洁,直接替换,无函数调用开销,编译器优化空间大。
    • 缺点:缺乏类型安全,可能出现宏展开的副作用(例如参数多次求值),调试困难。
    • 示例#define FX_ADD(a, b) ((a) + (b))
  • 结构体 (Structs)

    • 优点:提供类型抽象和封装,可以定义自定义运算符(C++),便于调试。
    • 缺点:引入结构体开销,可能不如宏直接。C语言中自定义运算符需要通过函数实现。
    • 示例

      typedef struct {
          fx_t value;
      } fx_num_t;
      
      fx_num_t fx_add_struct(fx_num_t a, fx_num_t b) {
          fx_num_t res;
          res.value = a.value + b.value;
          return res;
      }
      // 这种方式更像 C++ 的运算符重载,但在 C 中,每次操作都需要显式调用函数。
  • 静态内联函数 (Static Inline Functions)

    • 优点:兼具宏的内联效率(如果编译器选择内联)和函数的类型安全、参数检查等优点,可调试性好。这是我推荐的方式。
    • 缺点:如果编译器不内联,会有函数调用开销。但在现代编译器中,对于小型函数通常会内联。

3. 位操作与编译器内建函数:提升性能

  • 位操作:定点运算本身就是位操作的艺术。熟练使用左移 <<、右移 >>、与 &、或 |、非 ~、异或 ^ 等运算符,可以实现高效的缩放、掩码、提取等操作。
  • 编译器内建函数 (Compiler Intrinsics):许多编译器(如GCC/Clang)提供了一些内建函数,可以映射到特定的CPU指令,以实现高性能操作。
    • __builtin_clz(x) (Count Leading Zeros):计算前导零的数量,对于归一化、溢出检测等非常有用。
    • __builtin_mul_overflow(a, b, &res):检查乘法是否溢出。
    • 对于ARM Cortex-M系列CPU,有专门的DSP指令(如 SMULL 有符号长乘法),编译器可以利用这些指令加速定点乘法。

4. 避免除法:乘逆或查找表

除法通常是所有算术运算中最慢的。在性能关键的代码中,应尽量避免除法。

  • 乘逆 (Multiply by Inverse):如果除数是常数,或者可以在运行时预先计算,那么可以将除法 A / B 转换为 A * (1/B)。计算 1/B 的定点表示,然后进行乘法。
    fx_t inverse_b = fx_div(FX_ONE, b); // 计算 1/B
    fx_t result = fx_mul(a, inverse_b);
  • 查找表 (Lookup Table):对于输入范围有限的除法,可以构建一个查找表,直接存储 A/B 的结果。例如,在归一化向量时,可以预先计算 1/sqrt(x^2 + y^2)

5. 调试定点代码:挑战与方法

调试定

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注