ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

浮点数存储原理:单精度float与双精度double完全解析

浮点数存储原理:单精度float与双精度double完全解析 平时跟数据打交道多了你会慢慢习惯一个事实整数在计算机里老老实实小数却总爱跟你玩虚的。为什么磁盘、内存里存一个3.14这么费劲为什么明明写了0.1 0.2打印出来却是0.30000000000000004这些问题的答案全都指向浮点数在计算机内部的存储方式——也就是单精度浮点数和双精度浮点数那套底层布局。这篇博文我会彻底讲清楚IEEE 754 标准下 float32 和 float64 的位是怎么分配的、指数偏移量为什么要存在、隐藏位是怎么回事、特殊值0、无穷大、NaN怎么编码以及最关键的——给你完整的转换思路和排查经验。无论你是写 C/C、做嵌入式解析协议还是写 Java 存数据库、在游戏引擎里调坐标这套底层逻辑都逃不掉。1. 为什么小数在计算机里这么难存从整数存储说起1.1 整数那套补码体系为什么救不了小数计算机存储整数靠的是二进制补码。比如int32里的-7在内存里是11111111 11111111 11111111 11111001这套规则简单、统一、运算方便加法减法都能直接靠 CPU 的加法器完成。但是遇到小数这套东西就崩了。原因很简单十进制小数转成二进制小数经常是无限循环的。比如0.1你用短除法去算0.1 × 2 0.2 取 00.2 × 2 0.4 取 00.4 × 2 0.8 取 00.8 × 2 1.6 取 1余0.6……算下去永远算不完得到的二进制序列是0.000110011001100110011...后面会无限循环。可内存是有限的你不可能真的存一个无限循环的小数进去。所以硬件的做法只能是截断或舍入存一个尽量接近原值的东西。这个尽量接近就是浮点数存储的核心困境——你永远在精度和位宽之间做权衡。1.2 浮点数的老祖宗科学计数法的二进制版本很多人一看到浮点两个字就懵觉得这是个很高深的概念。其实你小学就接触过它的十进制版本——科学计数法。3.14 × 10^5这个数由三部分组成底数3.14数学上叫尾数基数10这是固定的指数5负责小数点往哪边挪正负号由尾数前面的符号决定计算机里面的二进制浮点数就是这套东西的二进制复刻版(-1)^S × M × 2^ES符号位0 表示正1 表示负M尾数mantissa表示有效数字通常是1.xxxxx这种规格化形态E指数exponent决定小数点在二进制有效数字上挪多少位因为M × 2^E里的基数2是固定的不需要存真正要存进内存的只有三样符号位、指数、尾数。这也是单精度浮点数存储和双精度浮点数存储两个概念的全部出发点——在这三样东西的位宽上做文章。1.3 单精度和双精度的名字其实就暴露了位宽单精度在英文里叫single precision对应 C 语言的float总共 32 位4 字节双精度叫double precision对应 C 语言的double总共 64 位8 字节。再往上还有扩展精度 80 位甚至四精度 128 位不过日常开发几乎用不到主流就是这两档。这 32 位和 64 位怎么分这是理解存储格式的关键下面两章我会分别拆开讲。先给一个总览表类型符号位S指数位E尾数位F总位宽C/C 类型Java 类型单精度 float32182332floatfloat双精度 float641115264doubledouble这三部分的组合方式就是 IEEE 754 这个标准的核心也是你深入理解为什么有些小数对、有些小数不对的钥匙。2. 单精度float32位布局逐个拆解1 8 232.1 每一位都干嘛符号、指数、尾数三块区域单精度float用 32 位二进制表示从高位到低位依次是第 31 位最高位S符号位1 bit第 30 ~ 23 位E指数位8 bits第 22 ~ 0 位F尾数位23 bits画出来是这样31 0 ┌─┬──────────┬───────────────────────┐ │S│ E (8bit) │ F (23bit) │ └─┴──────────┴───────────────────────┘ 高──────────────────────────────→低这里要注意这个排列是说在逻辑上、在文档里是这个顺序。实际写进内存的时候还要分大端Big Endian和小端Little Endianx86、ARM 主流架构一般内存里是反着来的后面专门讲这个坑。2.2 指数偏移量Bias机制为什么指数要加 127这是新手最容易卡住的地方。指数位是 8 位按照常规思路8 位无符号能表示0~255有符号补码能表示-128~127。但 IEEE 754 没用补码它用的是无符号加偏移量的方式。对于单精度偏移量bias 127。意思是说真正存进内存的指数 实际指数 127。举个例子3.14 × 10^0转成二进制规格化表示之后假设实际指数是3那么指数位存储的值就是3 127 130二进制为10000010。为什么要这么干两个原因比较简单IEEE 754 设计时希望能直接比较两个浮点数的二进制大小而无符号整数天然可以直接按大小比较省去补码比较的复杂逻辑。好用指数范围-126 ~ 127全 0 和全 1 有特殊用途见第 4 章刚好覆盖大部分科学计算场景。所以双精度也沿用这套思路只是偏移量变成了1023因为指数位有 11 位。2.3 隐藏位23 位尾数实际上能表示 24 位精度这是整个浮点存储里最精妙的地方。单精度的尾数位明明只有 23 位但实际能提供 24 位有效二进制精度。为什么因为规格化normalized要求尾数的最前面一位必须是 1。在二进制里M × 2^E的规格化形式永远是1.xxxxxx × 2^E就像十进制科学计数法要求3.14不能写成0.314×10^1一样。既然规格化之后第一个二进制位一定是1那就没必要把它存下来了——省出来的这 1 位让 23 位存储空间实际表达了 24 位精度。这个不存储的 1就叫隐藏位implicit leading bit或隐含位。它是理解单精度到底能精确到几位小数的关键23 位尾数 1 位隐藏位 24 位二进制有效精度2^24 16777216约 1677 万也就是说单精度浮点数大约能精确表示 7 位十进制有效数字log10(2^24) ≈ 7.22超过这个精度后面的位数就是近似值了。举例16777216.0f即2^24是能精确表示的但16777217.0f就存不进去了——它会被舍入成16777216.0f。3. 双精度double位宽翻倍到底多出了什么3.1 双精度的位布局1 11 52双精度double总位宽 64 位分配方式如下第 63 位S符号位1 bit第 62 ~ 52 位E指数位11 bits第 51 ~ 0 位F尾数位52 bits指数偏移量是1023。实际能表达的指数范围大约是-1022 ~ 1023换算成十进制大小覆盖从2^-1022 ≈ 2.23 × 10^-308到2^1023 ≈ 1.80 × 10^308。这个范围对于绝大多数物理世界的数据已经绰绰有余。比如宇宙中可观测原子总数大约是10^80这个量级double 的上限是10^308差着几百个数量级。而 float 的上限才3.4 × 10^38遇到某些天文数字就会溢出成Infinity无穷大。这就是为什么科学计算、金融计算、物理模拟领域不能省这 4 个字节的原因。3.2 精度能力的量化从 24 位到 53 位双精度尾数位 52 位加上隐藏位 1 位实际有效二进制精度是 53 位。log10(2^53) ≈ 15.95所以 double 的十进制有效数字大约是15 ~ 17 位。这个差距在写代码时感受非常明显。我用一个简单的例子来说明#include stdio.h int main() { float f 0.1f; double d 0.1; // 注意这里直接写 0.1自动是 double printf(float : %.20f\n, f); printf(double : %.20f\n, d); return 0; }在我机器上的输出是float : 0.10000000149011611938 double : 0.10000000000000000555两次打印的内容都不是真正的 0.1但能明显看出double 里存的0.1和真实0.1的误差比 float 小了将近 8 个数量级。这就是 23 位尾数和 52 位尾数的实质差距。3.3 单双精度实际能存的最大整数无精度损失很多人会关心用浮点数存整数最多能存到多大而不出错这里要区分两个概念最大值float 能存到3.4×10^38double 能存到1.8×10^308但超过精度范围后存的就不是精确整数了精确整数上限float 是2^24 16777216double 是2^53 9007199254740992约 9 千万亿所以在 Java 里如果拿 double 当 id 用超过9007199254740992就开始丢精度网上很多JS 大整数相加出错数据库主键变成 xxx.x的经典 bug根源就在这里。对于精确整数、金额这类场景正确选择是long或BigDecimal/DECIMAL而不是指望浮点类型。4. 特殊值也能存非规格化数、无穷大与 NaN 的处理规则4.1 指数全 0从 ±0.0 到非规格化数之前说了指数偏移量机制下指数位存的二进制值有0~255和0~2047的范围。标准规定指数位全 0 的情况不按常规规格化数解释而是分两种情况尾数位也全 0表示0.0。注意符号位还区分0.0和-0.0。它们在数值上相等但在 IEEE 754 里位模式不同。打印出来的效果一样但某些除法运算里1/0.0 Infinity、1/-0.0 -Infinity方向不一样。我用 C 语言验证过1.0 / -0.0输出-inf很多人在做除法判空时没注意这个细节。尾数位不全 0表示非规格化数subnormal / denormal。这是 IEEE 754 为了填补0 和最小规格化数之间的空洞而设计的。常规规格化数的最小值是1.0 × 2^-126 ≈ 1.18×10^-38单精度如果比这个还小就只能用非规格化数表示此时隐藏位规则失效实际数值是0.xxx × 2^-126越接近 0有效位数越少精度逐渐丢失直到最小的2^-149 ≈ 1.40×10^-45。有个工程上的坑有些 CPU 对非规格化数的处理非常慢要额外微码处理在循环计算大量小数值时性能会暴跌。x86 上有MXCSR寄存器里的FTZ/DAZ标志位可以把非规格化数直接 flush 成 0换取性能但代价是精度进一步丢失。游戏引擎和深度学习里常见这种优化设置。4.2 指数全 1无穷大与 NaN 的判定规则与全 0 对应指数位全 1也是保留模式尾数位全 0表示无穷大Infinity结合符号位就是Inf/-Inf。典型来源浮点数除以 0、数值上溢。尾数位不全 0表示NaNNot a Number即不是一个数。典型来源0.0/0.0、对负数开平方根、读取未初始化的浮点内存。这里要注意两个容易踩的坑NaN 和任何数比较都不相等甚至NaN ! NaN也是 true。所以判断一个值是不是 NaN不能写if (x NAN)而要用isnan(x)这类函数。无穷大参与运算会传染Infinity 1 InfinityInfinity - Infinity NaN。如果程序里出现了 NaN它会在计算链里一路传播最后打印出离奇的结果。排查这类问题时最快的方法是检查输入数据里有没有 NaN。4.3 用一张表记住特殊值的位模式我把单精度float的全部位模式情况整理成一张表双精度同理指数 8 变 11偏移 127 变 1023指数位8 bit尾数位23 bit含义例子全 0全 0±0.00x00000000 / 0x80000000全 0不全 0非规格化数0x00000001 ≈ 1.4e-451 ~ 254任意普通规格化数0x3F800000 1.0f全 1全 0±Infinity0x7F800000 / 0xFF800000全 1不全 0NaN0x7FC00000标准 quiet NaN这个表建议存脑子里。做协议解析、固件开发、分析内存 dump 的时候看到这几个特征字节基本能立刻判断数据类型。5. 手把手实战十进制小数转换成单精度存储格式5.1 转换全流程整数、小数分开处理再加总纸上谈兵没意思我拿一个具体数12.375走一遍完整的单精度转换过程。第一步判断符号。12.375是正数S 0。如果负数S 1。第二步整数部分转二进制。12 ÷ 2 6 余 06 ÷ 2 3 余 03 ÷ 2 1 余 11 ÷ 2 0 余 1倒序得到1100。第三步小数部分转二进制。0.375 × 2 0.75取整数00.75 × 2 1.5取整数1余0.50.5 × 2 1.0取整数1。正序得到011。所以12.3751100.011二进制。第四步规格化。把小数点挪到第一个 1 后面1100.011 1.100011 × 2^3。此时实际指数E_real 3。第五步算存储指数。E_stored 3 127 130。转二进制130 128 2 10000010。第六步填尾数。规格化后尾数二进制是100011这是从小数点后开始数的也就是去掉隐藏位1。占满 23 位10001100000000000000000。第七步按S E_stored F拼起来0 10000010 10001100000000000000000第八步按字节分组并写成十六进制0100 0001 0100 0110 0000 0000 0000 0000 0x41460000这就是12.375f在 IEEE 754 单精度下的标准表示。内存里如果按小端序存放你会看到00 00 46 41。5.2 反向解析从字节还原出数值拿到0x41460000怎么还原五个步骤逆向就行转二进制0100 0001 0100 0110 0000 0000 0000 0000符号位0正数指数位10000010 130实际指数130 - 127 3尾数位100011...前面补上隐藏位 1得到1.100011还原数值1.100011 × 2^3 1100.011 12.375分毫不差。整个转换逻辑本质上就三个动作拆位、减偏移、加隐藏位。5.3 用脚本自测转换结果我自己一直习惯用 Python 的struct模块来验证这类转换比自己手算快得多也省得踩二进制手工拼接的坑import struct # 十进制小数 → IEEE 754 单精度十六进制 f 12.375 packed struct.pack(f, f) # 表示小端序f 表示 float32 print(packed.hex()) # 输出 00004641读成 0x41460000 # 反过来解析 hex_str 41460000 raw bytes.fromhex(hex_str) value struct.unpack(f, raw)[0] print(value) # 输出 12.375 # 顺便看看双精度 12.375 是什么表现 packed_d struct.pack(d, f) print(packed_d.hex()) # 输出 0000000000002840即 0x4028400000000000用这个脚本可以快速验证任何一个数的位模式。我建议初学者在学这一章时随手挑几个数自己手算一遍再用脚本对答案比干看十篇教程都管用。6. 经典问题 0.1 0.2 不等于 0.3 的完整真相6.1 0.1 在二进制下是无限循环小数现在我们可以彻底解释全文开头那个问题了。先看十进制0.1它在二进制下无休止地循环单精度float里存的是截断后的近似值0.100000001490116119384765625双精度double里存的近似值是0.1000000000000000055511151231257827021181583404541015625没错double 版本的 0.1 精确到小数点后 19 位才开始偏离真实值但它仍旧不是精确的 0.1。同理0.2在两种精度下也都是近似值。6.2 误差如何在计算中被放大当计算0.1 0.2double 情况时CPU 做的实际上是两个近似值的真值加法0.1000000000000000055511151231257827021181583404541015625 0.200000000000000011102230246251565404236316680908203125 0.3000000000000000166533453693773481063544750213623046875这个和再经过一次舍入到 double 可表示的最接近值得到0.3000000000000000444089209850062616169452667236328125打印成默认精度时就是0.30000000000000004。整个链条里每一环都微小的不完全累积到打印这一环就漏了馅。float 版本更离谱0.1f 0.2f大约是0.30000001192092896。6.3 浮点数比较的正确姿势因为底层的近似性浮点数用做比较是典型的危险操作。不同精度的比较还会引入额外的坑请记住float 参与运算时往往会被隐式提升为 double所以0.1f 0.1的结果通常为 false因为左边是约等于 0.1 的 float 近似值右边是约等于 0.1 的 double 近似值两者根本不是同一个数。常用的稳妥做法是取绝对误差#include math.h int nearly_equal(float a, float b, float epsilon) { return fabsf(a - b) epsilon; }唯一的细节是 epsilon 怎么选。我的一般经验比较普通量级1附近的数float 用1e-6double 用1e-12还算合理涉及超大或超小数量级的数绝对误差不靠谱要用相对误差fabs(a-b) / max(fabs(a), fabs(b))需要严格等价的场景比如判断浮点计算结果是否等于某个快照最好直接比较二进制位把 float 转成uint32_t再比整数7. 开发中怎么选单精度还是双精度以及实际工程里的几个坑7.1 按场景选型游戏、科学计算、协议解析各不相同先说我的个人原则不是能 double 就 double而是看清楚每个字段的语义再选。图形学、游戏引擎、深度学习推理默认 float。GPU 对 float32 的处理速度远快于 float64消费级显卡的 FP64 吞吐量常常只有 FP32 的 1/32 甚至 1/64而且显存、内存带宽有限float 省一半空间。很多计算视觉算法里用双精度不仅没变快反而因为带宽瓶颈变慢。科学计算、数值模拟、统计、物理引擎的精确阶段用 double。那些动辄迭代上万步的数值积分单精度的累积误差会大到让你怀疑人生。数据库字段如果你在 MySQL/PostgreSQL 里存金额请用DECIMAL/NUMERIC不要用FLOAT/DOUBLE。浮点类型适合存比例、率、坐标这种本来就有测量误差的量不适合存一分钱都不能差的账目。文件格式和协议看对方规范怎么定。很多跨平台文件格式如 STL、OBJ、HDF5明确规定了数据是 float32 还是 float64这里不能凭喜好来必须严格按格式解析。7.2 性能对比的真相float 真的比 double 快吗这是个容易被误解的问题。早期 x87 浮点单元时代float 运算会被提升为 80 位扩展精度再计算最后截断回 float所以用 float 更快根本不成立——CPU 内部处理精度是一样的。到了现代 SSE/AVX 时代情况又不同了单条标量指令如addssvsaddsd的延迟和吞吐在多数现代 CPU 上几乎一致单看一条指令 float 并不占优但 SIMD 向量化时一个 256 位 AVX 寄存器可以装 8 个 float 或 4 个 double批量计算时 float 的理论吞吐量是 double 的两倍更关键的是内存同样一万个数的数组float 占 40 KBdouble 占 80 KB缓存命中率差异直接反映在最终速度上所以结论是如果要做大规模数值数组计算float 大概率更快如果只是零星几个浮点变量性能差异可忽略优先用 double 保证精度。7.3 大小端与内存布局最容易翻车的两个细节最后聊两个实战里踩过无数次坑的点。第一个是字节序。IEEE 754 只规定了逻辑上的二进制位布局没规定内存里哪个字节在前。x86、ARM 主流小端PowerPC、网络协议常用大端。所以当你把float写进文件或通过 socket 发送出去内存里看到的字节顺序和文档里写的十六进制可能是反的float 1.0f 的标准十六进制0x3F800000 x86 内存里的字节顺序 00 00 80 3F跨平台传输浮点数时必须统一约定字节序要么转成字符串/整数传输要么明确写清楚是大端还是小端否则收端解析出来就是天文数字。第二个是结构体对齐。C/C 里struct的成员顺序会影响实际占用内存在一个 64 位平台上如果结构体里有 double编译器默认按 8 字节对齐int后面接double可能产生填充字节padding。比如这个结构体struct Bad { char c; // 1 字节 double d; // 8 字节需要从偏移 8 开始 float f; // 4 字节 }; // sizeof 24实际数据只用了 13 字节浪费 11 字节如果把 double 提到最前面能省下不少空间。这在写嵌入式代码、网络协议缓冲区的解析结构体时非常关键。所以我说理解浮点存储不仅是搞懂一个数学公式它直接关系到你写的结构体在内存里长什么样、网络包里怎么拼。单精度和双精度的存储原理说到底就是上述符号位、指数、尾数的排布博弈。数值分析里有句名言浮点运算不会给你精确答案只会给你一个足够接近的答案关键是你要知道这个答案有多接近、在哪些情况下会背叛你。搞清楚位级存储的来龙去脉之后很多诡异问题精度突降、出现 NaN、大小端错乱、结构体尺寸不对都会变成一眼能看穿的常规问题。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进