各位编程专家,大家好!
今天,我们将深入探讨一个在嵌入式系统开发中至关重要的话题:定点运算(Fixed-point Arithmetic)。在资源受限、没有浮点运算单元(FPU)的嵌入式CPU上,如何高效、精确地进行数值计算,是摆在我们面前的一个核心挑战。浮点数固然提供了宽广的动态范围和灵活的精度,但在没有硬件FPU支持的情况下,其软件模拟开销巨大,无论是执行速度、功耗还是代码体积,都难以满足严苛的嵌入式需求。定点运算正是为了解决这一困境而生,它以其可预测的性能和资源消耗,成为了许多高性能、低功耗嵌入式应用的首选。
本次讲座,我们将从定点数的原理出发,逐步深入到其基本运算、高级运算、实践策略与优化技巧,并通过具体的代码示例,帮助大家掌握在无FPU环境下驾驭数值计算的能力。
引言:嵌入式世界的浮点困境
想象一下,你正在为一款电池供电的物联网设备开发固件,或者为汽车电子控制单元(ECU)编写关键算法。这些场景中,CPU往往是成本敏感的、低功耗的,因此通常不包含专门的浮点运算单元(FPU)。
浮点数(Floating-point Numbers),如float或double类型,遵循IEEE 754标准,由符号位、指数位和尾数位组成,能够表示非常大或非常小的数值,并且在精度上具有一定的自适应性。它们对于科学家、工程师进行通用计算非常方便,因为无需过多关注数值的量级。
然而,在没有FPU的嵌入式CPU上使用浮点数,会带来以下几个严重问题:
- 性能瓶颈:CPU需要通过复杂的软件库来模拟浮点数的加减乘除等操作。这些模拟函数通常由成百上千条整数指令组成,执行速度比硬件FPU慢几个数量级,严重拖慢系统响应时间。
- 功耗增加:长时间运行复杂的软件浮点模拟会消耗更多的CPU周期,进而导致更高的能耗,这对于电池供电设备是致命的。
- 代码体积膨胀:为了支持浮点运算,编译器需要链接庞大的浮点运算库,这会显著增加最终固件的二进制文件大小,占用宝贵的Flash存储空间。
- 不可预测性:软件模拟的浮点运算可能引入额外的舍入误差,而且其性能波动可能较大,难以满足实时性要求高的系统。
正是基于这些痛点,定点运算(Fixed-point Arithmetic)应运而生。它是一种利用整数类型来表示带有小数部分的数值的方法,通过约定小数点的位置来模拟浮点数的行为。定点运算充分利用了嵌入式CPU高效的整数运算能力,从而在性能、功耗和代码体积上取得了显著优势。
定点数的本质:表示与格式
定点数的思想很简单:我们用一个整数来存储一个数值,并约定这个整数的低N位代表小数部分,其余高位代表整数部分。小数点的位置是“固定”的,因此得名“定点”。
1. Q 格式 (Q_m_n)
最常见的定点数表示方法是Q格式,通常记作 Q_m_n 或 Qm.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_ONE 和 FX_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 格式的定点数 A 和 B。
它们实际代表的浮点值是 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 位,以恢复小数点到原来的位置。
关键点:
- 中间结果的位宽:两个
N位整数相乘,其结果可能需要2N位来存储。例如,两个32位的int32_t相乘,结果可能需要64位的int64_t来存储,以避免中间结果溢出。 - 右移截断与舍入:将
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,其中 A 和 B 都是 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。
关键点:
- 分子放大:将分子左移
FRAC_BITS位(乘以FX_ONE)。 - 位宽:放大后的分子可能需要
fx_long_t来存储。 - 除零处理:必须避免除以零。
- 舍入:与乘法类似,需要进行适当的舍入。
// 定义在上面的 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))
例如,如果 a 是 Q15.16 格式的 1.5 (即 1.5 * (1<<16)),那么 FX_LSHIFT(a, 1) 就是 3.0,FX_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):最直接、最快速的方法。预先计算一个周期内(例如
0到PI/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_div和fx_to_int_round中实现的策略。 - 半数向偶数 (half to even):
1.5变为2,2.5变为2。这种方法在统计上可以减少累积误差。
- 半数远离零 (half away from zero):
- 向上舍入 (Round Up / Ceiling):总是向正无穷方向舍入,例如
1.1变为2,-1.1变为-1。 - 向下舍入 (Round Down / Floor):总是向负无穷方向舍入,例如
1.1变为1,-1.1变为-2。
- 截断 (Truncation):直接丢弃小数部分,例如
选择合适的舍入方法对于算法的数值稳定性至关重要。
实践策略与优化技巧
定点运算的强大之处在于其可控性,但这也意味着开发者需要更细致地管理数值。
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_t或int32_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. 调试定点代码:挑战与方法
调试定