进制转换里的隐藏陷阱:浮点二进制、负数补码、IEEE 754

· 约 4 分钟 🔢 进制计算器

进制转换看着简单,但背后藏着 CPU 是怎么真正存数字的——浮点为什么不准、负数怎么编码、IEEE 754 标准做了什么取舍。理解这些既能解释程序里的”诡异结果”,也是面试常问。

二进制为什么存不下 0.1

十进制 0.1 在二进制是无限循环小数

0.1 (十进制) = 0.0001100110011001100110011… (二进制,循环节 0011)

类比:十进制写不完 1/3 = 0.3333…,因为分母 3 不是 10 的因子。二进制写不完 0.1,因为分母 10 不是 2 的因子。

能在二进制精确表示的小数只有”分母是 2 的幂”的那些:

十进制二进制精确?
0.50.1
0.250.01
0.1250.001
0.750.11✓(0.5 + 0.25)
0.10.0001100110011…✗(无限循环)
0.20.0011001100110…
0.30.0100110011001…

实战影响

0.1 + 0.2;        // 0.30000000000000004
0.1 + 0.2 === 0.3; // false
1.005 * 100;      // 100.49999999999999
Math.round(1.005 * 100) / 100;  // 1(不是 1.01)

修复方法

场景方案
显示用(0.1 + 0.2).toFixed(2) → “0.30”
比较用Math.abs(a - b) < Number.EPSILON
金额计算转整数分(150 分而非 1.5 元)
高精度BigDecimal(Java)、decimal.js(JS)、Decimal(Python)

IEEE 754 双精度浮点的内部结构

64 位双精度浮点的存储布局:

[1 位符号 S][11 位指数 E][52 位尾数 M]
   └─ 0=正  └─ -1023 偏移  └─ 隐含 1.xxx

值的计算公式:

value = (-1)^S × (1 + M/2^52) × 2^(E - 1023)

举例:1.5 的存储

1.5 = 1.1₂ × 2⁰
S = 0
E = 0 + 1023 = 1023 = 01111111111
M = .1000…0(去掉隐含的 1,只存小数部分)
存储:0 01111111111 1000000000000000000000000000000000000000000000000000

特殊值

EM用途
±0全 0全 0零的两种表示
±∞全 1全 0除以 0、溢出
NaN全 1非全 0无效计算
次正规数全 0非全 0接近 0 的极小数

精度极限

  • 双精度可精确表示的最大整数 = 2⁵³ = 9007199254740992(JS 的 Number.MAX_SAFE_INTEGER
  • 超过后整数也不准了:9007199254740993 === 9007199254740992 → true
  • 这就是 JS 处理大整数要用 BigInt 的原因

负数的三种编码

历史上有三种方式表示负数:

原码(Sign-Magnitude)

最直接的方式:第一位 0 表示正,1 表示负:

+5 = 0 0000101
-5 = 1 0000101

问题

  1. 零有两种:+0 = 00000000、-0 = 10000000,运算时要特判
  2. 加减法要特殊电路:算 5 + (-3) 不能直接按位加(结果会错),要判断符号

反码(Ones’ Complement)

负数 = 原码按位取反:

+5 = 00000101
-5 = 11111010

仍然有零的两种表示问题。

补码(Two’s Complement)

现代 CPU 实际用的——负数 = 反码 + 1:

+5 = 00000101
-5 = 11111011  (反码 11111010 + 1)

优点

  1. 零只有一种00000000
  2. 加减法共用电路5 + (-3) = 00000101 + 11111101 = 00000010 = 2(自然进位舍弃)
  3. 比较用普通整数比较就行

代价:表示范围不对称。8 位补码:

  • 最大 = 01111111 = +127
  • 最小 = 10000000 = -128(不是 -127!)
  • 范围 [-128, 127],比正数多一个负数槽位

这就是为什么:

INT32 范围: [-2147483648, 2147483647]
INT64 范围: [-9223372036854775808, 9223372036854775807]

补码转回十进制的速算

对负数补码:

  1. 方法 A:再次取反 + 1,得到原码绝对值
  2. 方法 B:从右往左找第一个 1,它左边的位全部取反,得到正数对应的二进制

举例 11111011 转十进制:

方法 A:取反 = 00000100,+1 = 00000101 = 5,所以是 -5
方法 B:从右第一个 1 在第 0 位(11111011)→ 左边 7 位取反 = 0000010 → 加上原 011 = 00000101 = 5,所以 -5

进制转换的快速心算

熟手在十六进制 ↔ 二进制之间秒切换——窍门是 4 位二进制 = 1 位十六进制

HEXBINDEC
000000
100011
200102
401004
810008
F111115

举例:0xCAFE → BIN:

C = 1100
A = 1010
F = 1111
E = 1110
0xCAFE = 1100 1010 1111 1110

8 进制 ↔ 二进制类似,但 3 位二进制 = 1 位八进制(用于 Linux 文件权限:rwx = 3 bit = 1 个 8 进制位,所以 chmod 755 = rwxr-xr-x)。

十进制 ↔ 二进制没有这种快速对应——必须算。常用:

  • 1024 = 2¹⁰(KB / 内存常用)
  • 65536 = 2¹⁶(端口数 / UTF-16 BMP)
  • 4294967296 = 2³² ≈ 43 亿(IPv4 地址数)

位运算的常见用法

1. 交换两个数(不用临时变量)

a = a ^ b;
b = a ^ b;  // a^b ^ b = a
a = a ^ b;  // a^b ^ a = b

实战极少用——可读性差,编译器优化也不依赖它。

2. 判断奇偶

n & 1  // 末位是 1 = 奇数

n % 2 略快(但现代编译器都会优化)。

3. 乘除 2 的幂

n << 3   // n × 8
n >> 2   // n ÷ 4(向下取整,注意负数)

4. 设置 / 清除 / 切换某一位

n |  (1 << k)   // 设第 k 位为 1
n & ~(1 << k)   // 清第 k 位为 0
n ^  (1 << k)   // 翻转第 k 位
n &  (1 << k)   // 读第 k 位(结果非 0 = 1)

5. 权限位掩码(业务常用):

const READ = 1, WRITE = 2, DELETE = 4;
let perm = READ | WRITE;        // 拥有读写
perm & READ;                    // 检查是否有读权限
perm |= DELETE;                 // 加删除权限
perm &= ~WRITE;                 // 撤销写权限

NaN 的奇怪行为

IEEE 754 规定 NaN ≠ 任何值,包括 NaN 自己

NaN === NaN;     // false
NaN !== NaN;     // true(唯一一个 x !== x 成立的值)
isNaN(NaN);      // true
Number.isNaN(NaN); // true(更严格)

理由:NaN 是”无效计算”的占位符,两个 NaN 不一定来自同一个计算,没理由相等。

Number.isNaN vs isNaN

isNaN("abc");          // true(先转 Number → NaN,再判)
Number.isNaN("abc");   // false(严格判,"abc" 不是 NaN,是字符串)

新代码用 Number.isNaN,行为可预期。

NaN 的传染性

NaN + 1;       // NaN
Math.max(NaN, 1, 2);  // NaN
JSON.stringify({x: NaN});  // '{"x":null}'  ← JSON 不支持 NaN

数据处理时遇 NaN 要么跳过、要么替换成默认值,不能让它顺利往下传。

一句话总结

二进制存不下 0.1 不是 bug 是数学、负数靠补码不靠正负号、INT 范围不对称、NaN 不等于自己——四个事实记住,再不会被进制和浮点坑到。

❓ 常见问题

为什么 0.1 + 0.2 = 0.30000000000000004?

0.1 在二进制是无限循环小数。十进制 0.1 用二进制表示是 0.0001100110011001100…(无限循环),就像十进制写不完 1/3 = 0.3333…一样。IEEE 754 双精度只能保留 52 位小数,超出部分截断——这就引入了误差。0.2 同理是无限循环。两个误差相加结果就 ≠ 0.3。不是 JS 的 bug——Python、Java、C 全部这样,因为 CPU 浮点运算单元是按 IEEE 754 标准做的。修法:金融场景用 BigDecimal / decimal.js / 整数分(把"1.5 元"存成 150 分);普通场景显示时 toFixed(2) 就够。

负数为什么不是直接在前面加个负号位?

加负号位(叫"原码")有两个大坑:(1) 零有两种表示:+0 和 -0,运算时要特判;(2) 加法器要特殊电路:CPU 算 5 + (-3) 时不能直接按位加,要判断符号决定加还是减。补码(two's complement)解决了这两个:负数 = 该数的二进制按位取反 + 1。比如 -3 在 8 位下是 11111101(5 是 00000101,加 -3 后变 00000010 = 2,符合)。结果:(1) 零只有一种表示 00000000;(2) 加减法可以共用同一套电路。代价:第一位是符号位但算式上正负不对称——8 位补码范围是 [-128, 127],不是 [-127, 127]。

为什么 INT32 最大是 2147483647,不是 2147483648?

INT32 是有符号 32 位整数,最高位是符号位(0 = 正,1 = 负),剩 31 位表示数值。最大正数 = 2³¹ - 1 = 2147483647(全 1 是 01111111…1111)。最小负数 = -2³¹ = -2147483648(用补码表示是 10000000…0000)。正负不对称:正数有"-1 是因为 0 占了一位";负数没占位(0 在正数侧)。经典翻车:Math.abs(INT_MIN) 在 32 位下 = 自身(因为 +2147483648 不能用 32 位表示,溢出回到 -2147483648)——C 标准明确这是 undefined behavior。Python / JS 不这样因为它们用任意精度整数。

浮点数的 NaN 为什么不等于自己?

IEEE 754 规定 NaN ≠ 任何值,包括 NaN 自己。理由:NaN 是"无效计算结果"的占位符,比如 0/0√(-1)——两个不同的 NaN 来源没有理由相等。实用价值:可以用 x !== x 来判断 x 是不是 NaN(这是 isNaN 之外的旧 trick,至今仍能用)。:(1) 排序数组含 NaN 结果未定义;(2) Math.max(NaN, 1) = NaN(NaN 会"传染");(3) JSON 不支持 NaN,序列化会变 null。新版 API:JS 用 Number.isNaN(x) 判断,比 isNaN(x) 严格——后者会把 "abc" 也算 NaN(先转 number 再判)。

🔢 打开 进制计算器 HEX/DEC/OCT/BIN 实时同步·位运算·有符号·位翻译·IEEE754