浮点数精度问题全解析:从IEEE 754到嵌入式与深度学习部署
在嵌入式项目里调一个浮点数误差问题,是最容易让经验失效的场面之一。前段时间我在看一块传感器板卡的数据回传,串口助手收到的十六进制是 0x40 0x49 0x0F 0xDB ,按字节顺序拼起来应该是一个浮点数,但上位机显示的结果总是差那么一点。后来把十六进制转成十进制一看,落在 3.14159 附近,确认是 32 位 IEEE 754 浮点数。真正有意思的是,同样的数值在 PC 端用 double 计算没任何毛病,换到单片机上却像换了一门语言。这就是浮点数问题的典型样貌:表面是数值对不上,底层是表示格式、精度位宽、字节序和计算方式共同导致的。很多教程会把“0.1 + 0.2 不等于 0.3”当作全部知识点,但到了工程现场,你会发现这只是冰山一角。
浮点数最容易被低估的地方,是它表面上看起来像一个普通数字,实际上却是一套有符号位、指数位和尾数位的二进制协议。不理解这套协议,排查问题就只能在“打印结果”这一层反复试错。
1. 浮点数不是“有误差”,而是一套精确约定的二进制语义
1.1 十进制小数转二进制,第一步就断了
我们从小习惯的十进制小数,换成二进制并不总是能写尽。最简单的例子就是 0.1 。用“乘 2 取整”的方式转换:
0.1 * 2 = 0.2,取整数部分00.2 * 2 = 0.4,取00.4 * 2 = 0.8,取00.8 * 2 = 1.6,取1,余下0.6- 继续下去,会得到
0.0001100110011...,无限循环。
也就是说, 0.1 在二进制里其实是一个无限循环小数。计算机内存有限,尾数位一旦截断,存下来的就是一个非常接近、但不完全等于 0.1 的数。这不是某个编程语言的 bug,而是任何遵循 IEEE 754 浮点标准的硬件和编译器都会遇到的共同事实。
整数在普通范围内通常可以精确表示,因为整数二进制展开是有限的。但小数不一定,尤其是分母包含 5 以外的质因子时,比如十进制 0.1 、 0.2 、 0.3 ,都属于“二进制下不干净”的数。
1.2 IEEE 754 的三段式存储和“规格化”到底在讲什么
IEEE 754 是现在几乎所有处理器都支持的浮点数标准。单精度 float 占 32 位,双精度 double 占 64 位。以 float 为例,它的 32 位被分成三段:
- 符号位
s:1 位,决定正负。 - 指数位
e:8 位,用来存放偏移后的指数。 - 尾数位
f:23 位,用来存放小数部分的精度。
一个规格化浮点数的值可以写成:
(-1)^s * 1.f * 2^(e - bias)
其中 bias 是一个偏移量,单精度是 127 ,双精度是 1023 。尾数部分的 1.f 表示默认前面还有一个隐含的 1 ,不需要额外存储。这就是“规格化”的核心含义:通过调整指数,让尾数落在 [1, 2) 区间内,然后只保存小数点后面的部分。
比如十进制 1.5 ,二进制是 1.1 ,规格化后就是尾数位存储 1.0...0 这些内容。正因为隐含了前导 1 ,单精度的 23 位尾数实际能提供 24 位有效二进制精度,大约相当于 7 位十进制有效数字;双精度的 52 位尾数加上隐含位,大约提供 15 到 17 位十进制有效数字。
1.3 为什么“隐含1”让精度能多出一位
隐含 1 不是锦上添花,而是 IEEE 754 设计里非常关键的一步。它让尾数位在不增加存储空间的前提下多了一位有效精度。但这个隐含 1 只在“规格化数”里存在。
当指数位全为 0 时,浮点数进入“非规格化数”区间。这时候隐含 1 不再出现,尾数表示的是 0.f ,用来表示非常接近 0 的数。这样设计的目的,是为了让浮点数能在下溢时渐进损失精度,而不是直接跳到 0,保证“减法不会突然出现大跳跃”。
理解“规格化”和“非规格化”非常重要,因为很多嵌入式设备、PLC、模型导出工具在计算极小数时,精度表现差异都来自这里。如果你发现某个数值在 PC 上是正常小数,在嵌入式设备上成了 0,第一步不是怀疑算法,而是去看目标平台使用的浮点格式是 32 位还是 64 位,以及是否对非规格化数做了裁剪。
2. 真正让人头疼的四个浮点数陷阱
2.1 大数吃小数,加法不是按数学规则走的
当你把一个大数和一个小数相加时,小数的有效位可能被“挤掉”。比如在单精度 float 里执行 16777216.0f + 1.0f ,结果仍然是 16777216.0f 。原因很简单:单精度有效二进制位只有 24 位, 16777216 正好是 2^24 ,表示它已经占满了所有有效位,再加 1,尾数里已经没有位置放这个增量。
这不是四舍五入策略的问题,而是“可表示的精度数量”有限。类似情况在累加统计、物理仿真、传感器汇总时非常常见。如果一个大数不断累加很多小数,小数部分不会立刻报警,而是会在一段关键的计算里保持沉默。
实际工程建议是:如果数值跨越多个量级,优先使用双精度,或者先做量级归一化,再计算。只靠“后面多写几位小数”解决不了问题,因为问题出在表示空间,不在显示格式。
2.2 相近数相减,有效位被悄悄抹掉
两个数值非常接近的浮点数相减,结果的有效位数可能少得可怜。比如 x 很大时,计算 sqrt(x + 1) - sqrt(x) ,直接减会丢失很多有效位。数学上这个表达式可以改写成 1 / (sqrt(x + 1) + sqrt(x)) ,后者在数值上稳定得多。
相近数相减的本质是:两个数都只保留了 24 位或 53 位有效二进制位,它们的差异可能只落在最后几位上。相减之后,前面对齐的高位全部抵消,剩下的是一个位数很短的残差。这个残差看起来“干净”,实际上相对误差极大。
如果你在数值分析里看到“灾难性抵消”这个词,说的就是这个场景。它在控制算法、惯性导航、统计方差计算里很常见。排查时不要只看结果,要把中间过程的量级变化打出来:如果两个中间变量的值非常接近,就要警惕这个算式的数值稳定性。
2.3 累加误差会缓慢积累,循环次数越多越明显
浮点加法不是严格可结合的,也就是说 (a + b) + c 和 a + (b + c) 不一定相等。原因是每一步加法都会重新舍入一次。大量小数值加到一个大数上时,误差会顺着循环慢慢积累。循环次数少的时候看不出问题,循环一万次之后,结果可能偏出预期范围。
缓解办法有很多,比如:
- 使用 Kahan 求和算法,把每次的舍入误差单独存下来,下一次补偿回去。
- 先按数量级排序,再加小到大加。
- 把累加变量从 float 换成 double,减少每一步的相对舍入。
Kahan 求和不是银弹,它适合“大量同数量级数值累加”的场景。如果数值本身跨越多个数量级,还是要先考虑归一化。
2.4 溢出、下溢、INF 与 NaN,异常值为什么会出现
浮点运算在溢出时通常不会直接报错,而是产生 inf 或 nan 。比如正数除以 0 在 IEEE 754 里得到 inf ,而 0.0 / 0.0 得到 nan 。很多程序不会显式检查这些值,导致异常一路传播到显示层,表现为一个很离谱的数字或者一个空值。
排查这类问题时,建议按下面这个顺序走:
- 先看现象:是显示乱码、计算结果完全不对,还是某个阈值判断失效。
- 再看输入:原始数据是整数转浮点,还是串口字节拼出来的?有没有转错字节序?
- 再看计算过程:有没有一个大数和小数相加?有没有相近数相减?有没有除以一个可能接近 0 的变量?
- 最后看输出通道:printf 格式符用对了吗?平台支持的浮点打印是硬件还是软件实现的?
在浮点问题上,90% 的情况都能在“输入表示”和“计算过程”两层找到原因,真正需要怀疑编译器或硬件 bug 的情况非常少。
3. FP32、FP16、BF16、TF32:精度格式不是越短越快
3.1 一张表看懂四种浮点格式的位结构
深度学习模型部署之后,你会频繁遇到 FP32、FP16、BF16、TF32 这几个名词。它们都是浮点数,但位结构不同,动态范围和精度也不同。放一张表方便对照:
| 格式 | 总位数 | 符号位 | 指数位 | 尾数位 | 动态范围(约) | 典型用途 |
|---|---|---|---|---|---|---|
| FP32 | 32 | 1 | 8 | 23 | ±3.4e38,最小规格化约1.18e-38 | 训练、通用推理 |
| FP16 | 16 | 1 | 5 | 10 | 最大65504,最小规格化约6.10e-5 | 混合精度训练、推理加速 |
| BF16 | 16 | 1 | 8 | 7 | 与FP32类似,最大约3.39e38 | 大模型训练、梯度交换 |
| TF32 | 32 | 1 | 8 | 10 | 与FP32类似 | GPU矩阵乘加速、混合精度训练 |
这里最关键的不是“位数短所以快”,而是指数位和尾数位各自牺牲了什么。FP16 指数位只有 5 位,所以它最大的数只有 65504。你训练一个模型时如果激活值超过这个范围,直接变成 inf。BF16 保留了 FP32 的 8 位指数位,所以动态范围不受影响,但尾数只有 7 位,精度大幅下降。TF32 是在矩阵乘这个特定场景里做折中,指数范围和 FP32 一致,尾数砍到 10 位。
3.2 FP16 的“窄尾数”和“窄指数”是怎么伤害模型的
很多人第一次把 FP32 模型转成 FP16,发现效果立即变差,甚至直接发散。通常问题出在两点:
一是动态范围不够。梯度、激活、损失在训练中可能超过 65504。学习率稍大、batch 稍大,都可能触发溢出不报错,但模型权重开始变坏。
二是尾数不够。FP16 只有 10 位显式尾数,相当于大约 3 位十进制有效数字。权重更新时每次向前推进的“步长”可能比舍入误差还小,更新直接被吞掉。混合精度训练里的 loss scaling 就是为了先把损失放大,再转成 FP16 计算,防止梯度在转换中被清零。
如果你只是做推理,不训练,FP16 仍然可能因为激活范围过大、中间结果超过 65504 而产出 NaN。部署前必须用一批真实数据跑对比,不能只看离线精度从 98% 变成 97% 就急着上线。
3.3 BF16、TF32 分别解决的是哪一类问题
BF16 的出现,主要是为了解决超大模型训练中,梯度交换和显存占用成为瓶颈的问题。它保留了和 FP32 一样的动态范围,所以训练时只要把主权重保存在 FP32,用 BF16 做前向和梯度计算,模型不太容易出现 inf 问题。代价是尾数精度低,所以它不是简单地替代 FP32。
TF32 是 GPU Tensor Core 的一种方案,面向矩阵乘和卷积这类“乘加”密集运算。它的做法是把输入从 FP32 转成 TF32,减少尾数位,从而让硬件并行度更高,加速矩阵乘。开发者可以不用改模型结构就开启,但代价是中间计算精度下降。部分对精度敏感的任务,在 TF32 下会明显掉点,需要显式关闭这个选项或改用更高精度路径。
3.4 深度学习部署的精度选型步骤
我建议按这个顺序做,而不是一开始就追求最低精度:
- 先用 FP32 跑通模型和测试集,拿到基准指标。
- 记录一份固定输入下的中间激活分布,确认哪些层出现过大的值或极小的值。
- 把模型转成 FP16,先跑同一批输入,对比输出和基准的绝对误差、NAN/INF 比例。
- 如果 FP16 出现动态范围问题,尝试 BF16,或者使用支持 loss scaling 的混合精度框架。
- 如果目标是推理加速,优先考虑量化到 INT8,而不是单纯在 FP16/BF16 里选,因为整数量化配合校准集往往能带来更大的性能收益。
强调一下:这些格式没有绝对的“最好”。FP16 能加速,但在大模型训练中可能不如 BF16 稳;TF32 适合矩阵乘,但普通标量运算未必受益。选型之前,先想清楚瓶颈是显存、带宽、算力,还是精度。
4. 嵌入式、PLC、串口与在线调试:一个真实排查链路
4.1 三菱PLC浮点数运算为什么“感觉不够精确”
在 PLC 项目里,很多人会在浮点累加、PID 运算、数值显示时发现结果“差一点”。比如 32 位浮点运算,结果可能在最后一位上抖动,或者在串口屏上显示 0.30000001 。这通常不是 PLC 质量问题,而是单精度浮点的精度上限在起作用。
三菱 PLC 常见浮点格式是 32 位 IEEE 754 单精度,有效十进制精度只有大约 7 位。如果你用它存一个 8 位甚至 10 位的数值,显示端自然会看到抖动。这跟参数用 Float 还是 Double 有直接关系。需要注意的是,有些 PLC 的浮点指令和整数指令混合使用时会额外引入一次格式转换,转换本身也会造成精度损失。
实际落地时,我一般会建议:
- 能用整数或 DINT 运算的地方不要先转浮点,比如计数器、脉冲累计、固定步进位置。
- 必须用浮点时,不要用等号判断结果,用
ABS(value - expect) < threshold。 - 显示输出时保留 3 到 4 位有效数字,不要直接打印 float 默认精度。
- 如果整个控制系统需要微米级或更高分辨率,考虑把坐标值先放大成整数,再参与运算。
4.2 串口打印浮点数:格式化输出和字节序都会骗人
串口打印浮点数最常见的坑有两个。第一个是 printf 在嵌入式平台上不一定支持 %f ,因为很多裁剪过的 C 库不会链接浮点打印功能。你会看到 printf 无法正常工作,或者输出全是 0。第二个是字节序:内核使用的是小端序还是大端序,会影响你从串口包恢复浮点数的方式。
一个比较稳妥的做法是用联合体把 float 转成 uint32_t,逐字节发送或打印十六进制,例如:
#include <cstdint>
#include <cstdio>
union FloatBytes {
float f;
uint32_t u;
};
int main() {
FloatBytes fb;
fb.f = 3.14f;
printf("%08X\n", fb.u); // 打印小端序下内存中 uint32 表示
return 0;
}
这里的 %08X 输出的是内存中按 uint32 解释的位模式,不直接等于你在串口上看到的“字节顺序”。当你从串口收到四个字节时,如果不知道平台字节序,在线工具转换也会给出错误结果。排查时要先确认:
- 发送端是大端还是小端?
- 接收端收到字节后,是按
[低位字节,高位字节]还是[高位字节,低位字节]拼的? - 你使用的在线工具输入是“十六进制字符串”还是“字节数组”?它默认是按大端还是小端解析?
4.3 在线浮点数转换工具的正确用法
“32位浮点数转换工具”“十六进制转浮点数”“字节转浮点数在线工具”这类工具是很好的辅助诊断助手,但它们解决的是“位模式到十进制”的转换,不解决字节序和源数据格式问题。正确用法是:
- 确定你要转换的目标是单精度还是双精度。
- 把串口原始字节按照平台字节序拼成一个整数。
- 用转换工具把十六进制整数转成浮点数。
- 转完后再用一个小数确认,比如发送端发的是
1.0f,看是不是0x3F800000,不是就要检查字节序。
在线工具适合做离线分析和现场问题确认,不应该放进生产代码里。生产代码需要自己实现解析函数,并且用一组已知数值做单元测试。
4.4 排查顺序:先看现象,再查表示,最后查算法
如果你在嵌入式系统里遇到浮点数相关问题,不要急着改算法。按照下面这条链路排查,大部分问题能快速定位:
- 记录现象:打印值是多少?期望值是多少?两个值差多少?
- 确认数据类型:发送端和接收端用的是 float、double,还是 16 位半精度?不同格式位宽不同。
- 确认原始表示:如果能拿到十六进制字节,先手工或在线工具还原成一个十进制值,确认“数据本身”对不对。
- 确认字节序:检查大小端是否一致。
- 检查转换过程:是否经历了
uint -> float -> double,有没有截断和隐式精度变化。 - 检查计算过程:是否出现大数加小数、相近数相减、除以接近 0 的数。
- 最后用最小复现例,把出错数据固定下来,再改代码。
这条链路的核心是“先让数据表示正确,再让算法稳定”。如果第一步表示就错了,后面算法再聪明也没有用。
5. 更高精度不是“双精度”一步到位
5.1 Double、BigFloat、整数定点:三种思路的分工
双精度浮点数在很多桌面程序里是默认选择,因为 53 位有效二进制位约等于 15 到 17 位十进制有效数字。但它依然无法精确表示 0.1,依然会发生大数吃小数,只是误差更难被直观看到。
如果需要更高精度,有三个方向:
- 使用 BigFloat 或 BigDecimal,用任意精度十进制/二进制表示来避免二进制转换误差。
- 用整数定点数,把单位缩小到整数,如金额用分,坐标用毫米。
- 用有理数库,把数字表示成分数,适合特定数学场景。
这三种方案各有代价。BigFloat 慢且占内存,不适合实时控制和大规模推理;整数定点需要自己管理范围和溢出;有理数在某些运算也会膨胀成很大的分子分母。它们不是“更高配的 float”,而是完全不同的数据结构。
5.2 Julia 高精度浮点数适合干什么,不适合干什么
Julia 社区经常提到高精度浮点数和任意精度整数,这个方向对算法研究、数值验证、教学演示很友好。例如:
setprecision(80) do
println(big"0.1" + big"0.2")
end
这段代码通过 BigFloat 把结果算到 80 位精度,输出值会非常接近 0.3。它适合验证一个数值公式在数学上是不是真的稳定,也适合在论文复现时确认舍入误差的影响。
但它不适合直接搬到生产环境。实时控制系统里,BigFloat 的运算开销和不确定性会很致命;深度学习推理里,BigFloat 基本没有硬件加速,性能会断崖式下跌。我的建议是:把 BigFloat 当成“裁判”或“实验工具”,用来判断问题到底是算法不稳定,还是浮点格式精度不够。真正落地时,再用固定精度、硬件友好的格式去实现。
5.3 用整数/定点替代浮点数的典型场景
嵌入式传感器、PLC、电机控制、金融系统,都可以考虑用整数替代浮点数。最典型的方法是:确定一个最小单位,然后把所有数值放大成整数。
比如温度传感器采集到 25.36 摄氏度,可以用 2536 表示,单位是 0.01°C ;金额 19.99 元,可以用 1999 分。这样计算过程是整数运算,行为确定,不依赖浮点舍入规则。
这个方案需要注意两个地方。第一是溢出。int32 最大约 21 亿,如果数值范围大,需要选择合适的缩放因子或者用 int64。第二是中间计算精度。两个整数相乘可能翻倍,必须先预估范围,避免溢出。定点数只是把精度问题变成了“缩放问题”,不是消失了。
6. 把避坑经验沉淀成一份可复用规范
6.1 浮点数使用自检清单
如果你负责的项目里涉及浮点数,可以在代码评审和联调前过一遍这份清单:
- 是否使用
==直接比较浮点数?如果是,改成误差范围判断。 - 是否在循环里反复累加小数?如果是,评估是否需要 Kahan 求和或换 double。
- 是否出现过“大数加小数”和“相近数相减”?如果有,检查计算式的数值稳定性。
- 是否在不同精度的 float/double 之间做隐式转换?转换点有没有显式写出来?
- 是否知道目标平台的 float 是 32 位还是 64 位?嵌入式平台尤其要确认。
- 串口或网络传输浮点数时,有没有定义字节序和位宽?有没有用固定的已知数据做过回环测试?
- 模型部署时,有没有对比过 FP32 和低精度格式在同一批输入下的输出差异?
这份清单的价值不是列出来好看,而是每次排查问题时,能帮你从一开始就排除“数据表示是否错了”这个最常见变量。
6.2 三个最小动作:单测、位模式日志、精度回归
哪怕项目很小,也建议做三个最小动作。
第一,写浮点边界测试用例。至少覆盖: 0.1 + 0.2 、最大正数附近、最小规格化数附近、负零、INF、NaN、以及两个接近值的相减。这些用例不一定每次都能通过,但至少能让你知道当前平台浮点行为是什么样的。
第二,在关键计算节点记录位模式日志。直接在日志里打印十进制值,有时候看不出问题;同时打印十六进制位模式,能快速判断数据是否在某一环节被当作整数处理了。打印方式可以参考前面那段 C++ union 代码。
第三,做精度回归。每次改动数据类型、精度格式、字节序解析逻辑、编译器优化等级之后,都要让一套固定用例重新跑一遍,对比结果。浮点问题最容易在“看起来无关的小改动”之后爆发,精度回归是成本最低的防线。
6.3 一张选型决策表,直接抄
最后给一张可以直接放进团队文档的选型表:
| 使用场景 | 推荐方案 | 理由 |
|---|---|---|
| 普通桌面程序、数据分析 | double | 精度足够,性能代价低 |
| 嵌入式、PLC 控制 | float + 误差窗口,或整数/定点 | 32位主流,避免等号比较 |
| 深度学习训练 | FP32 主权重 + 混合精度 | 平衡精度和显存 |
| 深度学习推理 | FP16/BF16 或 INT8 量化 | 需要实测对比,不能只看速度 |
| 金融金额、精确计数 | 整数或十进制定点 | 避免二进制舍入导致对不上账 |
| 数值算法研究、公式验证 | 高精度浮点/有理数 | 用精度换结论可靠,不追求性能 |
选型表只是一个起点,真正决定成败的还是你对数据范围和精度需求的了解。换精度格式之前,先跑一遍边界测试;换完之后,再做一次全量回归。每一次浮点问题排查,都是在帮你补全这张表。
浮点数不是一门需要背很多规则的学科,它更像是工程里绕不过去的一层“底层协议”。当你开始关心它到底怎么表示、会在哪个环节丢精度、换平台之后会产生什么差异,你就已经比大多数只会在键盘上敲 float 的开发者往前走了一步。下一次再遇到一个对不上的数字,别急着骂编译器或硬件,先把位模式拉开,从表示层开始查。你会发现,浮点数带来的问题里,绝大多数都藏着一条清晰的因果链。
更多推荐


所有评论(0)