ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

深入理解整数与浮点数在内存中的存储:从补码到IEEE 754精度陷阱

深入理解整数与浮点数在内存中的存储:从补码到IEEE 754精度陷阱

我印象挺深的一件事,是有一次排查一个对账程序,订单总价怎么都对不上,数据库里字段用的 FLOAT,差几分钱。一开始我以为舍入顺序有问题,后来把几个中间变量的内存二进制打出来,才发现根本原因:FLOAT 存 0.1 这个数的时候就没存准。这个经历让我下决心把整数和浮点数在内存中怎么存储这件事彻底过了一遍。后来再遇到类似的精度问题、溢出问题、类型转换问题,都是先看内存布局而不是在业务逻辑里瞎猜。这篇就把我梳理过的知识按实际使用顺序写出来,适合刚开始学编程、准备技术面试、以及写过不少代码但没系统看过底层的人。

1. 为什么整数负数不用直觉方案:从原码到补码的必然选择

1.1 原码的先天缺陷:硬件加法器的视角

先把问题拉回起点:一个整数在内存里到底怎么放?

最直觉的想法是“符号位 + 绝对值”。比如 8 位二进制下,用最高位表示正负,剩下 7 位表示大小。+5 就是00000101,-5 就是10000101。这种编码方式叫原码,很多教材会提一嘴,然后告诉你“现在不用了”。但很少有人把“为什么不用”说透。

问题出在加法电路上。如果计算机用原码做1 + (-1),直接把两个二进制数按位相加,会得到00000001 + 10000001 = 10000010,这个结果按原码读是 -2,不是 0。要想得到正确结果,硬件必须先判断两个数的符号是否相同,再比较绝对值谁大谁小,最后决定是做加法还是做减法,再补符号位。一套流程下来,同一个加法器要兼顾符号判断、绝对值比较、减法运算,电路复杂度肉眼可见地上升。

这还只是第二层问题。第一层更基本:原码里 0 有两种编码,00000000和10000000分别代表 +0 和 -0。计算机内部判断两个数“是否相等”时,被迫把这两串二进制都当成 0 处理,多一层额外判断。所以原码作为人类纸面上的记数方式没问题,放进计算机硬件里处处别扭。

1.2 模运算与钟面模型:“取反加一”不是口诀而是结论

补码为什么能解决这个问题?核心思想是“模”。

可以拿钟面来类比。假设现在是 3 点,想把它拨到 2 点。你既可以逆时针回拨 1 小时,也可以顺时针往前拨 11 小时。在模 12 的体系里,-1 和 +11 是同一个东西,因为 3 + 11 = 14,除以 12 取余数,得到 2。往前拨 11 小时和往回拨 1 小时,效果完全相同。

n 位二进制整数天然就是一个模 2^n 的钟。8 位整数总共 256 种编码,计算超过 255 的部分会自动溢出丢弃,剩下的部分恰好等价于“对 256 取模”。于是,如果想用“加上某个正数”来实现“减去一个负数”,这个正数应该满足:x + y ≡ 0 (mod 2^n)。这个 y 就是 -x 的补数。

问题变成怎么快速求这个补数。观察一下:一个二进制数和它的反码逐位相加,每一位都是 1,得到一个全 1 的数。全 1 再加 1,高位溢出后所有位归 0。这就解释了为什么“取反加一”能够求出负数:x 的反码 + 1,是唯一一个能让 x + (反码 + 1) 恰好等于 2^n 的数。在模 2^n 的世界里,这个数就是 -x 本身。

所以补码不是谁拍脑袋定下的规则。它是在“加法器想统一处理正负数”这个工程诉求下,被模运算逻辑逼出来的必然结果。

1.3 补码的隐性福利:统一加减、消灭正负零

补码一旦被采用,带来几个连锁好处。

第一个好处是减法直接消失。5 - 3变成5 + (-3),-3 用补码表示后和 5 相加,结果天然就是 2。CPU 里只需要保留一个加法器,加法指令可以完成一切加减运算,硬件设计大幅简化。

第二个好处是 0 的表示唯一。补码体系里 +0 和 -0 都对应00000000,不存在歧义。

第三个好处是可表示范围略大一格。原码和反码在 8 位下能表示 -127 到 127,补码却能表示 -128 到 127。因为10000000这个编码不再被 -0 占据,它被释出来表示 -128。于是就有了一个经典现象:127 + 1在 signed char 里变成了 -128。

我经常用这个小代码演示:

#include <stdio.h> void print_bits(signed char x) { for (int i = 7; i >= 0; i--) { putchar((x >> i) & 1 ? '1' : '0'); } putchar('\n'); } int main(void) { signed char a = 5; signed char b = -5; print_bits(a); // 00000101 print_bits(b); // 11111011 return 0; }

-5的内存形态不是直觉上的10000101,而是11111011。看到这串二进制,你应当马上意识到:它存的是 -5 的补码。

2. 整数边界和内存解读方式:溢出、符号扩展与大小端

2.1 同一串二进制,不同解读:有符号与无符号的差异

同样的内存字节,按有符号解读和按无符号解读,结果可能完全不同。0xFF在 unsigned char 里是 255,在 signed char 里是 -1。内存里那 8 个 bit 一模一样,差别完全来自程序怎么解释它。

这个差异在实际项目里最容易踩坑的地方是符号扩展。当一个 signed char 转成 int 时,编译器不只拷贝 8 个 bit,还会把最高位(符号位)复制到新增的所有高位上。于是:

signed char sc = -1; unsigned char uc = 0xFF; int a = sc; // a == -1,内存 0xFFFFFFFF int b = uc; // b == 255,内存 0x000000FF

两段代码看起来都是“把一个字节变成 int”,结果完全不同。很多同学在拼网络协议、写文件解析器时,把字节抠出来再转成 int 参与计算,一不注意就把 0xFF 的字节变成了 -1,随后运算结果全是负数,调半天也找不到原因。

前面聊到127 + 1 = -128,这也是补码体系下整数回绕(wraparound)的典型表现。int 类型的最大值2147483647加上 1,在 Java 里会变成-2147483648,在 C 和 C++ 里这属于未定义行为,但绝大多数平台也是回绕。这种问题很难靠单元测试覆盖,因为只有在边界值附近才会出现,建议处理计数器、序列号、时间戳增量这类逻辑时,提前想清楚溢出策略。

2.2 字节序:大端和小端如何左右协议与文件解析

另一个整数存储的经典问题是多字节数据在内存中的排列顺序。

假设一个 32 位整数0x12345678,它的四个字节从高到低是 12、34、56、78。大端模式会按 12 34 56 78 的顺序存到内存低地址到高地址;小端模式则会反过来,按 78 56 34 12 的顺序存。两种模式没有谁绝对更好,只是历史选择。

我平时用的个人电脑基本都是小端,这是因为主流的 x86、ARM 处理器选择了小端。但网络协议里很多标准字节序用的是大端,比如 TCP/IP 协议头里的端口号、IP 地址等。于是程序员经常要做字节序转换:本机读出来的多字节数(小端)要先翻转,才能拼出协议要求的数值。

通常我会让团队记住一句话:没有绝对的对错,只有你处理的数据是不是按你预期的字节序排的。写序列化代码、解析二进制文件之前,一定要先确认文件/协议的字节序约定。判断本机字节序的经典代码也很简单:

#include <stdio.h> int main(void) { unsigned short x = 0x0001; unsigned char *p = (unsigned char *)&x; printf("%s\n", p[0] == 1 ? "little-endian" : "big-endian"); return 0; }

x的低字节是 01,如果它落在低地址,就是小端;如果落在高地址,就是大端。这个方法在嵌入式调试、内存 dump 检查时非常实用。

3. 小数转二进制:0.1精度损失的真正源头

3.1 乘2取整:小数的二进制展开方法

整数转二进制用“除 2 取余”,小数则反过来用“乘 2 取整”。

拿一个干净的例子说:0.625。按下述步骤:

0.625 * 2 = 1.25,整数部分取 1,剩余 0.25 0.25 * 2 = 0.5,整数部分取 0,剩余 0.5 0.5 * 2 = 1.0,整数部分取 1,剩余 0

所以0.625 = 0.101。这个二进制小数可以精确写出来。

理解了这个方法,再看9.75这种数:整数部分 9 转成1001,小数部分 0.75 转成.11,合起来就是1001.11。这些东西和十进制小数不同,不需要近似。

3.2 无限循环与近似存储:0.1在内存里的“真实身份”

问题在 0.1 这样的十进制小数上来了。按乘 2 取整一步步展开:

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 0.6 * 2 = 1.2,整数部分取 1,剩余 0.2 0.2 * 2 = 0.4,整数部分取 0 0.4 * 2 = 0.8,整数部分取 0 0.8 * 2 = 1.6,整数部分取 1,剩余 0.6 ……

可以看到 0011 开始无限重复,0.1的二进制是0.000110011001100110011...,一个无限循环小数。这不是程序 bug,而是数学上的必然:十进制下的有限小数,在二进制下可能根本没有有限表示。

所以不管用什么编程语言,只要用二进制浮点数类型存 0.1,存的都是一个近似值。在 float 里,0.1f的真实值其实是0.100000001490116119384765625;在 double 里,0.1的真实值是0.1000000000000000055511151231257827021181583404541015625。打印到屏幕上时因为默认小数位少,看起来是 0.1,但在内存里那个数就不是精确的 0.1。

4. IEEE 754浮点布局:用一份十六进制反推内存结构

4.1 符号、指数、尾数:为什么指数要用偏移量

浮点数在内存里的标准格式由 IEEE 754 定义。拿最常见的 float 来说,32 位划分成三块:

  • 第 31 位:符号位,0 正 1 负
  • 第 23 到 30 位:指数位,8 位,采用偏移量表示
  • 第 0 到 22 位:尾数位,23 位

double 则是 1 位符号、11 位指数、52 位尾数。

指数为什么要用“偏移量”而不是直接存正负号?因为指数本身代表科学计数法里的那个次方数,它可能为负。如果直接给这 8 位再加符号位,整个排序比较会变得麻烦。IEEE 754 采用的做法是:把指数整体偏移,float 偏移 127,double 偏移 1023。也就是说,真实指数 0 存成 127,真实指数 -2 存成 125,真实指数 3 存成 130。这样一来,指数位在内存里天然就是一个无符号数,比较两个浮点数大小时,可以先把符号位、指数位按整数规则比较,大多数情况下就能判断大小,硬件实现很顺手。

4.2 9.75的内存拆解:从十进制到二进制的完整推导

理论说完了一定要上手推一个具体数字,我常用 9.75 做演示,因为它的二进制形态足够干净。

先把 9.75 转成二进制小数:

  • 9 =1001
  • 0.75 =.11
  • 合起来是1001.11

写成二进制科学计数法,小数点左移到第一位左边:

1001.11 = 1.00111 × 2³

于是三个字段分别确定:

  • 符号位:0
  • 真实指数:3,加偏移 127,得到 130,转成二进制是10000010
  • 尾数:小数点后的00111,后面补零到 23 位

拼在一起:

0 10000010 00111000000000000000000

把这一串按 4 bit 分组:

0100 0001 0001 1100 0000 0000 0000 0000

就是十六进制0x411C0000。我用代码验证过很多次:

#include <stdio.h> #include <stdint.h> #include <string.h> void print_float_layout(float f) { uint32_t bits; memcpy(&bits, &f, 4); for (int i = 31; i >= 0; i--) { putchar((bits >> i) & 1 ? '1' : '0'); if (i == 31 || i == 23) { putchar(' '); } } putchar('\n'); } int main(void) { print_float_layout(9.75f); return 0; }

输出0 10000010 00111000000000000000000,和手推完全一致。你有任何诡异的浮点数转换结果,都能用这个思路拆开检查。

4.3 隐藏位机制:23位尾数为何代表24位精度

学 IEEE 754 时很多人会困惑:float 尾数明明只有 23 位,为什么资料上说它有 24 位有效二进制精度?

答案在“归一化”这件事上。二进制的科学计数法里,只要数字不是 0,归一化后小数位前面那个整数位一定是 1,这是个确定值。比如9.75归一化后必然是1.00111 × 2³,前面的整数位永远写成 1,不需要额外占用存储空间。IEEE 754 约定这个 bit 被隐含掉,存储时只保存它后面的 23 位,所以实际上 float 能表达 24 位精度的有效数字,double 则是 53 位。

这也是为什么 float 大约对应 6 到 7 位十进制有效数字,double 对应 15 到 16 位。拿 float 做金额累计时,几位有效数字根本不够业务用,这就是我开头那个对账 bug 的根源之一:数据库里用 FLOAT 存金额,累计一万条后精度误差就攒出来了,四舍五入函数救不回来。

5. 浮点数比较与特殊值:那些教科书没写的坑

5.1 为什么不要用==比较浮点数,以及正确的比较姿势

既然浮点数是近似值,直接==比较就等于在问“这两个近似值是否完全相等”,绝大多数情况会得到 false。

经典案例不用多解释,在几乎所有主流语言里,0.1 + 0.2都不等于0.3。我用 C 打印过真实结果:

#include <stdio.h> int main(void) { double a = 0.1; double b = 0.2; printf("%.17g\n", a + b); // 0.30000000000000004 return 0; }

为什么不是正好 0.3?因为 0.1 和 0.2 的二进制近似值相加,再把二进制近似结果转回十进制,额外多了一点误差。这属于用二进制小数表达十进制有限小数的数学代价。

业务上处理金额,我个人的经验是:不要用浮点数存钱。在数据库里用整型存最小单位(分),或者用专门的高精度十进制类型。浮点数适合做物理计算、图形渲染、机器学习这类对“近似”不敏感的领域。

如果某些算法确实需要用浮点,并要判断是否接近,常见的做法是设定一个误差阈值:

#include <math.h> int nearly_equal(double a, double b, double eps) { return fabs(a - b) <= eps; }

这里的 eps 不能拍脑袋取。绝对值阈值适合比较接近 0 的数,但比较 1 亿和 1 亿零 0.5 时,同样的 eps 就不合适;相对误差判断会更通用。我最常用的是fabs(a - b) <= max(fabs(a), fabs(b)) * eps,配合一个较小的eps,能覆盖大多数工程场景。

5.2 特殊值的自洽规则:-0.0、Infinity与NaN

IEEE 754 在有限表示之外留了几个特殊编码,很多人直到踩坑才发现它们的存在:

  • 指数位全 1、尾数位全 0:表示 Inf,也就是无穷大,符号位区分 +Inf 和 -Inf。
  • 指数位全 1、尾数位不全为 0:表示 NaN,也就是“非数值”。
  • 指数位全 0、尾数位全 0:表示 0,同样有 +0 和 -0。
  • 指数位全 0、尾数位不全为 0:表示次正规数,用于填补下溢附近的一段精度区间。

举几个反直觉现象:1.0 / 0.0得 +Inf,1.0 / -0.0得 -Inf;0.0 == -0.0判断为真;但1.0 / 0.0与1.0 / -0.0不相等。In C,0.0 / 0.0产生 NaN,而且NaN != NaN成立,所以不能靠x == x判断 x 是否合法,要专门用isnan()。

这些特殊值在数据处理里经常突然冒出来。比如用户输入了除数为 0,没做保护,公式结果变成 Inf,后续排序、比较、JSON 序列化可能全部乱掉。又比如某些统计模型在计算过程中产生 NaN,debug 时第一眼看到的是“输出 NaN”,根本想不到是输入数据里有个 -0.0 或 0.0 引发的。

我处理这类问题时的一般套路是:先查输入数据中是否有 0 或负值异常,再在关键计算步骤对结果做一次有限性检查。比如 C 里有isfinite(),可以一次同时过滤 Inf 和 NaN。养成这个习惯后,由特殊值引发的隐性 bug 能少很多。


把整数和浮点数在内存中存储这件事彻底过一遍,是我过去几年里收益比较高的技术投入之一。现在回答数值相关的问题,第一反应都是“先看它的二进制形态”,再讨论业务逻辑。写代码时也会下意识想清楚存储单元是几字节、字节序是什么、符号会不会扩展、浮点比较是否用了容差。这些东西在日常开发里不会天天用到,但一旦用到,就是那种查通宵也未必定位的顽固问题。如果你还在初学阶段,建议找个周末,拿自己熟悉的语言把整数负数、大小端、0.1 的内存形态都打印一遍。这类基础一旦彻底打通,后面看很多奇怪的现象都会豁然开朗。

RELATED READING

延伸阅读

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