定点数优化提升运算性能
背景
在 Cortex-M0、M0+ 等无硬件浮点单元(FPU)的 MCU 上,float 运算依赖软件库,单次乘除可能消耗数十个时钟周期。将数值表示为定点整数后,乘除退化为整数运算,在精度可接受的前提下能显著降低延迟和代码体积。
本文给出一组 16.16 定点数的 C 宏定义,作为快速原型或教学参考。代码为简化版本,未经编译验证,也未在目标硬件上测试,直接用于生产前需逐项审查。
16.16 定点数格式
cfixed 底层是 int32_t,高 16 位存整数部分,低 16 位存小数部分:
- 整数 1 表示为
1 << 16 = 0x00010000 - 小数 0.5 表示为
0x00008000 - 可表示范围约为 [-32768, 32767.99998]
- 最小分辨率为 1/65536 ≈ 1.5 × 10⁻⁵
选择 16.16 是整数位与小数位各占一半的折中;若应用偏重小数精度,可改为 8.24 或 12.20,但需同步调整所有移位常量。
类型定义与转换
typedef int32_t cfixed;转换宏的核心思路:
| 方向 | 操作 | 宏 |
|---|---|---|
| int → cfixed | 左移 16 位 | cfixed_from_int(i) |
| float → cfixed | 乘 65536.0f 后截断 | cfixed_from_float(x) |
| cfixed → int | 算术右移 16 位 | cfixed_to_int(f) |
| cfixed → float | 除以 65536.0f | cfixed_to_float(x) |
注意事项:
cfixed_from_float/cfixed_from_double使用 C 的向零截断,不做四舍五入。若精度敏感,应改为(cfixed)lroundf(x * 65536.0f)。cfixed_to_float/cfixed_to_double内部仍调用浮点除法,仅适合调试打印或最终输出,不应出现在热路径中。热路径中若必须输出浮点,可拆成整数部分和小数部分分别处理。cfixed_from_int不仅要检查 [-32768, 32767] 的范围;在 ISO C 中,对负的有符号整数做左移本身就是未定义行为。更稳妥的实现是先扩展到int64_t,乘以 65536,做范围检查后再转回cfixed。
基本运算
#define cfixed_mul(x, y) ((cfixed)((((int64_t)(x)) * (y)) >> 16))
#define cfixed_div(x, y) ((cfixed)((((int64_t)(x)) << 16) / (y)))- 乘法:先提升到
int64_t避免 32 位中间结果溢出,乘完右移 16 位恢复缩放。当 |x|、|y| 均接近INT32_MAX时,64 位中间值最大约 2⁶²,仍在int64_t范围内,安全。 - 除法:原宏先把
(int64_t)x左移 16 位。数值范围虽然通常不会撑破int64_t,但x < 0时对负的有符号整数左移在 ISO C 中是未定义行为。可改用(int64_t)x * 65536并在运算前检查y != 0和结果范围。 - 两个宏中
>> 16作用于int64_t。C 标准规定对有符号负数右移是实现定义的,在 GCC/Clang 下为算术右移,但移植到非主流编译器时需查阅目标编译器文档确认。
取整与取小数
#define cfixed_const_1 (cfixed_from_int(1)) // 0x00010000
#define cfixed_const_half (cfixed_const_1 >> 1) // 0x00008000
#define cfixed_frac(f) ((f) & cfixed_const_1_m_e)
#define cfixed_floor(f) ((f) & (~cfixed_const_1_m_e))
#define cfixed_ceil(f) (cfixed_floor((f) + 0xffff))命名歧义说明: cfixed_const_e 被定义为 ((cfixed)(1)),即裸整数 1。在 Q16.16 格式中,裸整数 1 代表最小分辨率 1/65536(可理解为 epsilon),而非自然常数 e ≈ 2.718。代码注释也承认了命名上的简化。关键在于算术结果:
cfixed_const_1=1 << 16=0x00010000cfixed_const_e=1(裸整数,即 Q16.16 中的 1/65536)cfixed_const_1_m_e=0x00010000 - 1=0x0000FFFF
因此 cfixed_const_1_m_e 恰好是低 16 位掩码,cfixed_frac 和 cfixed_floor 的运算结果是正确的。命名容易误导读者以为此处涉及自然常数,但实际算术无误。
为消除歧义,更清晰的写法是直接使用显式掩码:
#define cfixed_frac_mask 0x0000FFFF
#define cfixed_frac(f) ((f) & cfixed_frac_mask)
#define cfixed_floor(f) ((f) & ~cfixed_frac_mask)这与原代码功能等价,但意图一目了然。
cfixed_ceil 利用“加低 16 位掩码后再清零小数位”实现向上取整。在常见的二进制补码与算术右移环境中,它对正负数都可得到预期方向;但接近 INT32_MAX 时的加法可能溢出,且该位运算依赖补码表示,应专门测试边界。
范围常量
#define cfixed_const_max ((int64_t)0x7fffffff)
#define cfixed_const_min (-((((int64_t)1) << 31)))这两个常量声明为 int64_t,但 cfixed 本身是 int32_t,类型不一致。若意图是表示 cfixed 能容纳的整数部分范围,应为 [-32768, 32767];若意图是 int32_t 存储的完整边界,则当前值正确但类型应为 int32_t。建议根据实际用途统一类型或重命名,避免隐式截断。
风险与边界汇总
| 问题 | 影响 | 建议 |
|---|---|---|
cfixed_const_e 命名易误导 | 读者误以为涉及自然常数 | 改用显式掩码 0x0000FFFF 并注释说明 |
| 转换宏无舍入 | 累积误差 | 使用 lroundf / llround |
cfixed_div 除零和负数左移 | 未定义行为 | 改用 64 位乘法缩放,并检查除数 |
| 负数右移实现定义 | 跨平台不一致 | 显式处理符号或查阅目标编译器文档 |
| 范围常量类型不匹配 | 隐式截断或比较错误 | 统一为 int32_t 或重命名 |
| 宏无参数括号保护 | 传入表达式时优先级出错 | 所有参数加 () |
适用场景
- 无 FPU 的 MCU 上实现 PID、数字滤波、坐标变换等迭代计算
- 对延迟有硬实时要求、不允许浮点库开销的场合
- 教学或原型阶段快速验证算法
不适合: 需要大动态范围(如科学计算)、频繁出现极小/极大值、或精度要求超过 16 位小数的场景。此时应考虑 Q 格式调整、双精度定点或引入 FPU。
验证建议
- 用单元测试覆盖边界值:0、±1、±32767、±32767.99998、
INT32_MIN、INT32_MAX。 - 对
cfixed_mul和cfixed_div与double参考实现做逐位对比,确认误差在 1 ULP 以内。 - 在目标 MCU 上用 DWT
CYCCNT(若内核支持)或硬件定时器实测关键路径延迟,确认优化收益。 - 开启
-Wall -Wextra -Wconversion编译,检查隐式截断和符号转换警告。
小结
16.16 定点数是无 FPU 平台上替代浮点的常用手段,核心优势在于运算可预测、无库依赖。本文给出的宏集合覆盖了基本转换和四则运算,但存在命名歧义、缺少舍入、除零未防护等问题,直接使用前必须修正。实际项目中建议将宏封装为内联函数,加入参数校验,并配合单元测试锁定行为。
<!-- csdn-article-id: 139058998 -->本文最初于 2024/5/20 发布在 CSDN。
相关文章
评论
正在读取评论…