0. 开头
浮点数转二进制这事儿,几乎每个写代码的人都会撞上那么一次两次。可能面试前翻书看过一遍,感觉懂了,回头一写又卡住;可能在调接口时发现0.1 + 0.2不等于0.3,被这个结果搞得怀疑人生;也可能纯粹是在帮学弟学妹讲题,自己讲着讲着就心虚了。我把话放这儿:只要把IEEE 754这套规格化数存储逻辑彻底吃透,浮点数转二进制就是一道送分题——它跟“背模板”完全没关系,跟“打表”也没关系,它就是一套固定的数学转换流程,理解了每个位的作用之后,手算、程序验证、口头解释,全都能拿得下来。
这篇文章我会从最基础的十进制小数转二进制开始讲,然后带你拆开 IEEE 754 单精度/双精度的存储结构,接着手算一个完整例子并写代码验证,最后把精度丢失、面试常问的坑一次性说透。适合谁看?准备校招笔试的在校生、被浮点数精度坑过的后端开发、想给学生讲清楚原理的同行,都可以。看到最后你会发现,浮点数这玩意儿不但不可怕,反而是计算机基础里逻辑最通顺的一块内容之一。
1. 先搞清楚底层操作:十进制小数到底怎么变成二进制
1.1 整数部分转二进制,你已经会的那个方法
整数转二进制大家都熟,短除法就行。比如 13 转二进制:
13 ÷ 2 = 6 余 1
6 ÷ 2 = 3 余 0
3 ÷ 2 = 1 余 1
1 ÷ 2 = 0 余 1
倒着取余数:1101。这个没问题,我默认你会。
真正让大多数人栽跟头的,是小数部分。
1.2 小数部分转二进制:乘2取整法
小数的转换逻辑跟整数恰好反过来。整数用“除2取余”,小数用“乘2取整”。
规则就一句:每次把小数部分乘以 2,取走整数位作为二进制位,剩下的小数部分继续乘 2,直到小数部分变成 0 或者达到你想要的精度。
举个例子,0.25 转二进制:
0.25 × 2 = 0.5,取整数位 0
0.5 × 2 = 1.0,取整数位 1,小数部分清零
从第一次乘到第二次依次往下排,结果是 0.01。
0.75 呢?
0.75 × 2 = 1.5,取 1
0.5 × 2 = 1.0,取 1
结果是 0.11。
这里注意一个细节:顺序是正的,第一轮乘出来的位是最高位。很多人容易跟整数方法搞混,整数是倒着取的,小数是顺着取的,别记反了。
1.3 为什么 0.1 在二进制里“永远写不完”
真正要命的例子在 0.1 这里。
0.1 × 2 = 0.2,取 0
0.2 × 2 = 0.4,取 0
0.4 × 2 = 0.8,取 0
0.8 × 2 = 1.6,取 1
0.6 × 2 = 1.2,取 1
0.2 × 2 = 0.4,取 0
0.4 × 2 = 0.8,取 0
0.8 × 2 = 1.6,取 1
0.6 × 2 = 1.2,取 1
……
看到没有?从 0.2 开始进入无限循环了。0.1 的二进制展开是
0.00011001100110011001100110011001100110011……
这个循环会一直持续下去,根本停不下来。所以十进制的 0.1 在二进制世界里根本没有精确表示,只有近似值。这不是计算机偷懒,是进制本身的性质决定的——十进制里能写尽的小数,二进制的分母是 2 的幂次,它只对“分母只含 2 的质因数”的分数有穷尽表示。比如 0.5、0.25、0.125 这种,写成分数是 1/2、1/4、1/8,分母全是 2 的幂,所以二进制能写尽;而 0.1 是 1/10,分母含 5,二进制的幂次分母永远凑不出 10 来,所以只能循环。
判断一个十进制小数能否被二进制精确表示,就看它化成最简分数之后,分母的质因数是不是只有 2。分母还有别的质因数,二进制就写不尽。0.3(3/10)、0.6(3/5)、0.7(7/10)统统写不尽;0.5、0.25、0.75 都能写尽。
这个认知太重要了。你理解了这条,后面理解0.1 + 0.2 !== 0.3就完全不费劲——不是它俩“算错了”,是它俩在系统里从一开始就不是精确的“0.1”和“0.2”,只是两个近似值的相加,得到一个近似值,这个近似值和 0.3 的近似值不相等,仅此而已。
2. IEEE 754 是关键:浮点数在内存里到底怎么排布
2.1 从科学计数法说起
光知道小数怎么转二进制还不够。你写一个13.25,它在 C 语言里是double,在 Java 里默认也是double,在 Python 里所有浮点都是double。它存进内存的不是简单地把“整数部分的二进制 + 小数部分的二进制”拼一起,那样太蠢了,位数没法统一,大数小数全没法处理。
真实做法跟你高中的科学计数法一模一样。十进制的 13.25 可以写成 1.325 × 10^1,二进制的 1101.01 可以写成 1.10101 × 2^3。任何一个二进制数,都可以归一化成“1.某几位 × 2的某次方”的形式。那存储一个浮点数,本质上只需要存三样东西:
- 这个数是正是负(符号)
- 这个数归一化之后的指数是多少(阶码)
- 归一化后小数点后面的那串“尾数”是什么
这就是 IEEE 754 的存储逻辑。
2.2 单精度和双精度的位分配
IEEE 754 规定了两种最常见的格式:
| 类型 | 总位数 | 符号位 | 指数位 | 尾数位 | 常用语言默认 |
|---|---|---|---|---|---|
| float(单精度) | 32 | 1 | 8 | 23 | C 的 float |
| double(双精度) | 64 | 1 | 11 | 52 | Java/C++/Python 默认 |
符号位最好懂,0 是正,1 是负。
指数位不能直接存“3”或者“-2”,因为指数可能是负数,而这一块通常按无符号整数处理。IEEE 754 引入了偏移量(Bias)的概念:单精度偏移 127,双精度偏移 1023。存储值 = 真实指数 + 偏移量。
举例:单精度里真实指数是 3,那么指数位存 3 + 127 = 130;真实指数是 -2,指数位存 -2 + 127 = 125。这样做的好处是:不用额外的符号位处理指数,比较两个浮点数大小的时候,直接把整个 32 位当作无符号整数比较都能排对顺序(先比符号,再比指数位,再比尾数位,恰好就是从高位到低位的字典序)。
偏移量为什么是 127 而不是 128,后面在特殊值那一节说。
尾数位更有意思。归一化之后一定是“1.xxx”的形式,那个最高位的 1 是必然存在的——那干脆不存了。23 位的尾数位实际上可以表达 24 位的精度,52 位的尾数位实际上表达 53 位精度。这个被省掉的 1 叫“隐含位”或者“隐藏位”。你手算的时候要记住把它补回来。
0 在科学计数法里是没法归一化成 1.xxx 的,所以 IEEE 754 规定:指数位全 0、尾数位全 0,表示的就是 0(符号位决定正 0 负 0,这俩还相等)。这属于特殊值,后面统一说。
2.3 位分配图与存储逻辑的直观理解
下面这个存储布局多看几遍,整个章节的骨架就在这儿。
单精度 float(32位):
| 符号位(1bit) | 指数位(8bit) | 尾数位(23bit) | | 0 | 10000010 | 1010100... |双精度 double(64位):
| 符号位(1bit) | 指数位(11bit) | 尾数位(52bit) |存储顺序是:符号位最高位,指数位中间,尾数位最低位。跟网络字节序没关系,这个就是内存里的标准位序。
为什么要这样设计?把符号位放第一位,意味着判断正负只看第一个 bit;把指数位放中间而不是最后,是为了让浮点数按位比较时能近似按数值大小排序。这种设计在硬件比较器里面非常友好,整个数值可以直接当整数排序来近似处理。这些细节你要是面试聊到,加分效果非常明显。
3. 手算完整例子:13.25 转二进制单精度
3.1 第一步:把整数部分和小数部分分别转二进制
13 的二进制上面算过,1101。
0.25 的二进制:
0.25 × 2 = 0.5,取 0
0.5 × 2 = 1.0,取 1
得 0.01。
拼起来,13.25 = 1101.01。
3.2 第二步:归一化
把 1101.01 的小数点往前挪 3 位,变成 1.10101 × 2^3。
所有归一化数的格式都是 1.xxx,小数点在第一个非零位后面。这里指数是 3,没问题。
尾数是小数点后面的部分:10101。后面补零凑满 23 位。
3.3 第三步:算阶码
真实指数是 3,单精度偏移 127,所以阶码 = 3 + 127 = 130。
130 转二进制:130 ÷ 2 = 65 余 0,65 ÷ 2 = 32 余 1,32 ÷ 2 = 16 余 0,16 ÷ 2 = 8 余 0,8 ÷ 2 = 4 余 0,4 ÷ 2 = 2 余 0,2 ÷ 2 = 1 余 0,1 ÷ 2 = 0 余 1。
倒着取:10000010。刚好 8 位。
3.4 第四步:组装
13.25 是正数,符号位 0。
符号位 0 + 阶码 10000010 + 尾数 10101000000000000000000(10101 补零到 23位):
0 10000010 10101000000000000000000
整理成 4 字节十六进制:
0100 0001 0101 0100 0000 0000 0000 0000
= 0x41540000
你可以用 C 语言的指针打印一下 float 内存验证,或者用 Python 的struct.pack('<f', 13.25)转成字节再看,结果百分百是41 54 00 00。
3.5 完整逆向验证:从二进制字节还原十进制
这个步骤必须练一遍。咱们拿刚才的0x41540000原路返回:
先把字节展开:
0 10000010 10101000000000000000000
符号位 0,正数。
阶码 10000010 = 130,真实指数 = 130 - 127 = 3。
尾数 10101000000000000000000,补回隐含位 = 1.10101。
1.10101 × 2^3 = 1101.01 = 13.25。
闭环成立。你把正推和反推这两趟路线走顺了,浮点数转换这道题就算彻底焊死在脑子里了。
3.6 再练一个负数和一个特殊值:-2.5 和 1.0
-2.5 怎么处理?符号位变 1,绝对值 2.5 照常转换。
2.5 = 10.1(整数 2 转 10,小数 0.5 转 0.1,拼起来),归一化 = 1.01 × 2^1。
符号位 1,阶码 = 1 + 127 = 128 = 10000000,尾数 = 01000000000000000000000(01 补零)。
组装出来:1 10000000 01000000000000000000000 = 0xC0200000。
1.0 呢?1.0 = 1.0 × 2^0,尾数是全 0,阶码 = 0 + 127 = 127 = 01111111。
看出来没有?1.0的 IEEE 754 表示是 0x3F800000。这个值很多老程序员张口就来,因为它太常用了。你记住它,调试的时候看到3F800000就知道内存里存的是 1.0,不用再傻傻地换算。
4. 用代码和工具验证:手算结果对不对,机器说了算
4.1 用 Python 验证二进制位
Python 里最直接的方案是用struct把浮点数按 IEEE 754 规则打包成 4 字节,再转成二进制字符串看位。
import struct def float_to_bits(f: float) -> str: # 打包成单精度 4 字节,再解析成无符号整数 bits = struct.unpack('!I', struct.pack('!f', f))[0] # 按 32 位展开 return f"{bits:032b}" for v in [13.25, -2.5, 1.0, 0.1]: b = float_to_bits(v) sign = b[0] exp = b[1:9] mant = b[9:] print(f"{v:>8}: {b}") print(f" 符号={sign} 阶码={exp} 尾数={mant}") # 输出示例: # 13.25: 01000001010101000000000000000000 # -2.5: 11000000001000000000000000000000 # 1.0: 00111111100000000000000000000000 # 0.1: 00111101110011001100110011001101注意到没有,0.1的尾数是我们前面用乘 2 取整算出来的循环体110011001100...被截断后的近似结果。这就是为什么 0.1 在内存里是个“非常接近 0.1 但并不是 0.1”的数。
4.2 用 JavaScript 的 DataView 验证
前端同学也有办法,用 ArrayBuffer 加 DataView 读取字节,再手工拼位。
function floatToBits(f) { const buf = new ArrayBuffer(4); const view = new DataView(buf); view.setFloat32(0, f, false); // false 表示大端字节序 const bits = view.getUint32(0, false).toString(2).padStart(32, '0'); console.log(`${f}: ${bits}`); } floatToBits(13.25); // 13.25: 01000001010101000000000000000000 floatToBits(0.1); // 0.1: 00111101110011001100110011001101如果你想看 double,把setFloat32/getUint32改成setFloat64/getBigUint64就行。原理一模一样,只是位数从 32 变成 64。
4.3 看懂调试器里的十六进制值
写 C/C++ 或者调试 Go、Rust 的时候,经常在内存窗口看到一串十六进制。比如0x3FF0000000000000是双精度的 1.0,0x4045000000000000是双精度的 42.0。
快速心算一个 double 的套路:
- 先把十六进制换成二进制,或者直接记几个常用值。
- 找到阶码区(第 2 到第 12 bit),减 1023 得到真实指数。
- 尾数区(剩下 52 bit)加上隐含位 1,按 1.xxx 展开还原。
练得多了你会发现,double 的常见整数在内存里长得很规律。比如 2 的整数幂:1.0 是 0x3FF0000000000000,2.0 是指数位加 1,对应 0x4000000000000000,4.0 是 0x4010000000000000。这种规律看多了,调试时扫一眼内存就能估出数的量级。
调试经验:看到指数位等于 0x7FF(double 的 11 位全 1),说明这个数是无穷大或 NaN,别当普通数去算。看到指数位全是 0,说明是个次正规数或者 0,数值精度会急剧下降。
5. 精度丢失问题再深挖一层:原理、范围和应用对策
5.1 经典问题复盘:0.1 + 0.2 为什么不等于 0.3
现在你完全有能力把这个问题讲干净了。
0.1 在二进制里是无限循环,IEEE 754 的 52 位尾数(double)塞不下,截断了。0.2 同理,截断后存的是一个近似值。两个近似值相加,得到的还是一个被截断后的近似值,它不等于十进制 0.3 对应的那个近似值。
用 Python 看一下:
print(0.1 + 0.2) # 0.30000000000000004这个0.30000000000000004不是 Python 的 bug,是所有采用 IEEE 754 的语言都逃不掉的必然结果。Java 里System.out.println(0.1 + 0.2)打印一样的结果,C 里printf("%.17f", 0.1 + 0.2)也是。写代码的人唯一能做的,是理解它、绕开它,而不是幻想它消失。
5.2 有效精度到底是多少位
单精度 float 的 24 位有效二进制精度,换算成十进制大约是 7 位有效数字。双精度 double 的 53 位有效二进制精度,换算成十进制大约是 15~17 位有效数字。
这就是为什么你会看到这种经典建议:不要用 float 存金额,不要用 double 判断相等,金额一律用整数(按分存储)或者 Decimal 类型。
float 存商品单价 0.1 元,累加 1000 次,误差会积累得肉眼可见。用 double 稍微好点,但也不保险。工程上最稳的办法是:金额全部换算成最小单位整数,运算用整型,展示时再除以 100。你要是在金融支付业务里写if (a + b === c)这种浮点比较,迟早要出事。
5.3 比较两个浮点数相等,正确姿势是什么
如果你必须比较浮点数,标准做法是约定一个误差范围(epsilon),判断两个数的差的绝对值是否小于这个范围。
def approx_equal(a, b, epsilon=1e-9): return abs(a - b) < epsilon print(approx_equal(0.1 + 0.2, 0.3)) # True注意 epsilon 的取值不要固定死。数量级差的很大的数,得按相对误差来比较。比如比较 1e20 和 1e20 + 1,这俩在 double 里压根是同一个数,用绝对误差 1e-9 比较会得到“不相等”,但实际存储值就是一样的,你应该用相对误差或者直接意识到这种场景就不该用浮点数。
5.4 工程上的实际对策
后端计算金额,首选方案是Decimal(Python)、BigDecimal(Java)或者numeric(数据库)。这些类型是十进制精度计算的,不存在二进制转换误差。
前端展示金额,后端直接给分(整数)最稳,前端自己除以 100 再展示。
科学计算和图形学里,浮点误差无法完全消除,只能控制传播。比如矩阵变换累积误差,每步都做归一化,限制误差增长。
做协议解析或者网络传输时,浮点数序列化要注意大小端,IEEE 754 的字节序在不同平台可能不一样,最好统一用大端传输,或者用专门序列化框架帮你处理。
6. 常见问题排查与面试高频点速查
6.1 手算时最容易犯的 4 个错误
第一个错误:小数转二进制顺序搞反。整数部分倒着取余数,小数部分顺着取整数位,这俩经常被搞混。你记住一个特征:整数短除法的余数,最后一步得出来的才是最高位;小数乘 2 取整,第一次算出来的就是最高位。方向天然相反。
第二个错误:负数的处理。负号只改符号位,绝对值部分的转换流程完全不变。不要试图把负数按补码处理然后再转浮点,IEEE 754 的负数不需要补码,符号位独立表示正负。
第三个错误:阶码忘记加偏移。真指数 3 直接写 011,这是新手最容易犯的。3 + 127 = 130,这是 IEEE 754 跟定点数最大的区别。
第四个错误:尾数漏补隐含位或者多存了隐含位。规格化数的整数部分 1 不参与存储,组装时尾数区只有小数点后面的部分;逆向还原时先补回1.再乘以 2 的指数。
6.2 面试高频问题速查表
| 问题 | 一句话答案 |
|---|---|
| 0.1 + 0.2 为什么不等于 0.3 | 0.1 和 0.2 在二进制里是无限循环小数,存储时被截断,运算结果是近似值 |
| float 的有效精度是多少位 | 24 位二进制有效精度,约 7 位十进制有效数字 |
| double 的有效精度是多少位 | 53 位二进制有效精度,约 15~17 位十进制有效数字 |
| 为什么指数位要加偏移量 | 为了让指数能表示负数,同时让浮点数按位比较时能近似按大小排序 |
| 偏移量为什么是 127/1023 | 8 位指数可表示 0~255,去掉全 0 和全 1 两个特殊值,剩 254 个值,区间设为 -126~127,所以偏移 127 |
| 尾数位为什么只有 23/52 位却能表示更多精度 | 规格化数强制最高位为 1,不存储,隐含位省出来了 |
| NaN 是什么 | 指数位全 1 且尾数位非 0,表示非数值 |
| Infinity 是什么 | 指数位全 1 且尾数位全 0,符号位决定正负无穷 |
| 次正规数是什么 | 指数位全 0 且尾数位非 0,表示接近 0 的小数,精度逐级降低 |
上面偏移量那段,补一句为什么不是 128:8 位无符号数范围 0~255,如果偏移 128,真实指数范围是 -128~127。但 IEEE 754 把指数位全 0 和全 1 留作特殊用途(0、次正规数、无穷、NaN),所以普通规格化数的真实指数范围是 -126~127,上界和下界各削掉 1,取偏移 127 恰好让阶码存储范围是 1~254。
6.3 快速记忆技巧
我个人的记法是这样的:
- 单精度 32 位 = 1 + 8 + 23,偏移 127,二进制有效精度 24。
- 双精度 64 位 = 1 + 11 + 52,偏移 1023,二进制有效精度 53。
- 手算流程五步:拆整数小数、分别转二进制、拼起来、归一化、组装。
面试时如果要在纸上推一个数,按这五步走绝对不会乱:
- 定符号
- 整数转二进制
- 小数转二进制
- 拼数并归一化,得指数
- 指数加偏移写进阶码位,尾数补隐含位后写进尾数区
写完了反向验证一遍,把算出来的位还原回十进制,对得上就是满分。
6.4 实际调试中的一个踩坑回忆
有次我在查一个图像处理的像素计算差异,同一套算法用 C 语言写和用 Python 写,结果差了几个像素值。排查到最后,问题出在两端用了不同精度的浮点:C 里某个中间变量定义成了float,Python 默认全double。不是算法错了,是精度不够导致累积误差超过阈值。
从那以后我的习惯改了:凡是计算密集型且对精度有要求的代码,要么统一切到 double,要么干脆全转整数运算,绝不让 float 和 double 混着用。这个小教训放这儿,希望你不用像我一样再踩一遍。
我实际操作中的体会是:浮点数这块知识,最大的价值不在应付面试,而是你真正理解之后,遇到诡异 bug 时能快速锁定原因。所谓精度问题、比较问题、序列化问题,本质上都是同一个根源——二进制里没有十进制的“干净小数”。想明白这一层,很多曾经玄学的问题都变得通顺了。把这篇文章存下来,下次有人问你,直接扔给他就行。
最后再送一个小技巧:遇到一个数不确定怎么转时,先用 Python 的print(f"{f.hex()}"),它能输出一个带0x1.前缀的十六进制浮点格式,直接告诉你隐含位、尾数和指数,用来验证手算结果非常快。有这招兜底,你手算的时候胆子可以大一点。