进制转换:十六进制、补码与浮点误差

开发与 Web发布于 2026-10-31

十六进制、二进制这些「换算题」中学就学过,真正让人栽跟头的是后面三件事:负数怎么存、小数为什么不精确、大整数什么时候会悄悄变形。

先记住对应关系

十进制 二进制 十六进制
0 0000 0
5 0101 5
10 1010 A
15 1111 F
255 1111 1111 FF
4096 1 0000 0000 0000 1000

十六进制的价值在于「4 个二进制位 = 1 个十六进制位」,所以它天生是二进制的缩写形式。一个字节(8 位)刚好写成两位十六进制,这就是为什么颜色 #FF8800 一眼能看出是三个字节:红 255、绿 136、蓝 0。

同理,0x 前缀是十六进制、0b 是二进制、0o 是八进制。八进制现在很少用,主要残留在文件权限(chmod 755)里。

负数:补码不是「加个负号」

计算机里没有负号,只有 0 和 1。表示负数用补码:取反加一。

以 8 位为例,-5 的推法:

 5  = 0000 0101
取反 = 1111 1010
加一 = 1111 1011   → 0xFB

好处是减法可以直接用加法器做:7 + (-5) 就是 0000 0111 + 1111 1011 = 1 0000 0010,最高位溢出丢掉,剩 0000 0010 = 2。

代价是表示范围不对称:8 位能表示 -128 ~ 127,不是 -127 ~ 127。-128 没有对应的正数,这一条在取绝对值时会造成经典 bug:

Math.abs(-2147483648) // 还是 -2147483648

因为 32 位有符号整数的 -2^31 取反加一之后还是它自己。

0.1 + 0.2 的真相

0.1 + 0.2 === 0.3        // false
0.1 + 0.2                // 0.30000000000000004

这不是 JS 的 bug,是所有用 IEEE 754 双精度的语言共同行为(Java、Python、C 都一样)。

原因:二进制只能精确表示「分母是 2 的幂」的分数。0.5、0.25、0.125 没问题;0.1 是 1/10,分母含质因数 5,二进制下是无限循环小数:

0.1 (十进制) = 0.0001100110011001100... (二进制,无限循环)

存进 64 位浮点数时被截断,于是存下来的其实是一个略大于 0.1 的数。两个近似值相加,误差就露出来了。

怎么办

场景 做法
金额 用整数分。12.34 元存 1234 分,全程整数运算,显示时除 100
比较大小 不用 ===,用 Math.abs(a - b) < 1e-9
展示 toFixed(2) 或 Intl.NumberFormat,只在最后一步格式化
高精度计算 用 decimal 库(decimal.js / big.js)

最危险的是金额用浮点累加:一万笔 0.01 累加下来,误差能到几分钱,而对账时几分钱就够查一整天。

需要批量处理数值的四舍五入、取整、保留位数,用 数值舍入 比手写 toFixed 更可控 —— 后者对 1.005 这类值的处理经常和预期不符(因为它先转成二进制再舍入)。

大整数:超过 53 位就不可靠

JS 的 Number 是双精度浮点,能精确表示的整数上限是 2^53 - 1 = 9007199254740991(16 位十进制数)。

9007199254740993        // 9007199254740992
JSON.parse('{"id":9007199254740993}').id  // 9007199254740992

18 位的订单号、雪花算法 ID、身份证号(18 位)都在这个边界之外。这就是「后端返回的 ID 到前端最后两位变了」的原因。

修法:接口里把大整数当字符串传("id": "9007199254740993"),前端不要 Number() 它。JS 侧需要运算时用 BigInt(字面量加 n 后缀)。

身份证相关的校验与解析,站内有身份证号校验与解析专门处理,不用自己实现加权求和。

parseInt 的两个坑

parseInt('08')      // 8,但老引擎会按八进制解析成 0
parseInt('10', 10)  // 永远显式传基数
parseInt('12px')    // 12 —— 遇到非法字符就停,不报错
parseInt('')        // NaN

第一条在 ES5 之后已经修了(前导 0 不再当八进制),但第二条仍然危险:parseInt('12px') 返回 12 而不是报错,脏数据会静默通过校验。要严格就先用正则确认格式,再用 Number()。

Number('') 是 0、Number(null) 是 0、Number(undefined) 是 NaN —— 这组不一致也是空值校验出错的高发区。

位运算的边界

JS 的位运算(& | ^ << >>)会把操作数先转成 32 位有符号整数,超过 32 位的高位直接丢:

1 << 31        // -2147483648(不是 2147483648)
2 ** 32 | 0    // 0

所以位运算只适合「确实在 32 位以内」的场景:权限位掩码、颜色通道拆合、简单哈希。涉及大数就用乘除法。

顺带一提,>> 是带符号右移(高位补符号位),>>> 是无符号右移(高位补 0)。对负数这两个结果完全不同,用错会让「取高 8 位」这类操作得到负数。

手算核对用工具

临时要验一个 hex 值、看某段二进制的十进制是多少、或者把一段字节序列转成可读形式,用 进制转换器 最快 —— 二、八、十、十六进制同时显示,输入任意一侧其余立刻更新。

要把一批数字做统一处理(补零、截取、加千分位、批量进制转换),数字处理 支持批量粘贴。做哈希值查看和比对时,文本哈希 的输出默认就是十六进制,长度对照表在哈希算法怎么选里。


三句话记住这一篇:

  1. 十六进制是二进制的 4 位缩写,两位十六进制 = 一字节。
  2. 浮点数不能精确表示 0.1,金额一律用整数分。
  3. 超过 2^53 的整数在 JS 里会变形,接口传字符串。