邯郸市网站建设_网站建设公司_外包开发_seo优化
2025/12/23 19:47:50 网站建设 项目流程

各位编程专家,大家好!

今天,我们将深入探讨一个在嵌入式系统开发中至关重要的话题:定点运算(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.161615132-32768.0+32767.999982^-16(约0.000015)
Q7.24247132-128.0+127.99999992^-24(约0.00000006)
Q23.8823132-8388608.0+8388607.9962^-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. 调试定点代码:挑战与方法

调试定

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询