1. 先从一个调了一整天的线上事故说起
1.1 一个整数溢出引发的签名失败
上个月我负责的一个数据上报服务突然在凌晨收到大量验签失败告警,而且只在部分设备上复现。客户端传上来的签名内容里有一个字段是"播放时长(毫秒)",这个值时大时小,一旦超过 2147483647 就会变成负数。后端拿到负数的毫秒数,再换算成秒,时间戳直接乱掉,验签用的字符串自然也对不上。排查了大半夜才发现是int durationMs = (int)(durationInSec * 1000)这一行在搞事——durationInSec 是个 double,乘以 1000 后超过 int 范围,转换成 int 时直接溢出。这个问题的根源,就是整数在内存中采用补码存储,溢出不会报错,而是顺着位模式绕到另一边。
1.2 0.1 加 0.2 不是 0.3 的现场
另一个经典现场是前端做金额展示,用户输入 0.1 和 0.2,点合计,页面上显示 0.30000000000000004。客服被打爆,开发一脸无辜:"double 就是这样,不是我算错了。"确实不是计算逻辑写错,而是浮点数在内存中以 IEEE 754 格式存储,十进制里平平无奇的 0.1,在二进制里根本没法用有限位数精确表示。只要理解了浮点数的存储结构,就能预测哪些小数会出问题,也能理解为什么做金额系统的老手都坚持"以分为单位存整数"。
1.3 这篇文章适合谁读
不管你做后端、前端、客户端还是嵌入式,只要写过可能溢出、可能丢精度、可能跨端对不上号的代码,都值得把这套底层存储逻辑理顺。下面我会从整数补码、IEEE 754 浮点格式、实际 Dump 内存字节、高频 Bug 四个方向展开。每一部分都可以用一段简短代码验证,不空谈概念,看完你就能自己复现,也能在实际项目里用同样的思路去定位问题。
2. 整数在内存里为什么是"补码"而不是"原码"
2.1 CPU 加法器只认加法,减法是靠补码实现的
很多初学者第一次看补码都会想:为什么不直接存符号位加绝对值?这就是原码,比如 +12 是 00001100,-12 是 10001100。原码很直观,但 CPU 硬件不喜欢它,因为它带来两个问题。第一,出现了两个零,00000000 表示 +0,10000000 表示 -0,判断一个数是否为 0、做相等比较都会变得复杂。第二,如果想做 12 - 8,硬件需要先比较绝对值大小、确定最终符号,再执行借位减法,电路设计非常繁琐。
工程师为了把减法变成加法,设计出了反码和补码。反码是"符号位不变,其余位全部取反",-12 的反码是 11110011;补码是"先取反再加 1",所以 -12 的补码是 11110100。在这个表示法下,12 + (-12)等于00001100 + 11110100 = 1 00000000,最高位进位被 8 位宽度截断,剩下 00000000。也就是说,一个负数加上它对应的正数,天然等于 0。CPU 内部的算术逻辑单元只需要一个加法器,a - b被翻译成a + (~b + 1),减法就从这个世界上消失了。
2.2 用具体位模式走一遍正负转换
我建议你在纸上跟我走一遍,这是理解补码最快的方法。假设 8 位有符号整数:
- 正数 5:二进制 00000101。
- 要得到 -5:先全部取反,得到 11111010,再加 1,得到 11111011。
- 验证:00000101 + 11111011 = 1 00000000,8 位截断后是 00000000。所以 11111011 就是 -5。
范围也很容易推出来:8 位补码能表示 -128 到 127。正数最大是 01111111,也就是 127;负数最小是 10000000,也就是 -128,比原码多出一个数。换成 32 位就是 -2147483648 到 2147483647。这就是INT_MAX + 1 == INT_MIN的底层原因:0111...1111 加 1 变成 1000...0000,在补码语义里恰好是最小负数。任何符合主流整数约定的语言,这个行为都一致。
2.3 大端与小端:同一段字节在不同机器上怎么摆
补码只解决了"数值怎么编码",还没解决"字节按什么顺序放进内存"。0x12345678 在内存里有两种摆法:大端先放高字节,内存依次是 12 34 56 78;小端先放低字节,内存依次是 78 56 34 12。x86 和小端模式的 ARM 都是小端,PowerPC、SPARC 这些老平台常用大端,网络协议里的整数几乎都规定用大端,所以它也叫网络字节序。
在内存 dump 里看到整数字节"倒着"排,这是小端机器的正常现象,不是 bug。真正的 bug 经常发生在跨端通信场景:A 机器是小端,直接把 int 用指针转成字符数组发出去,B 机器按大端解析,数值就错乱了。规范做法是发送前统一转成网络字节序,接收后再转回主机字节序,C 里的 htons、htonl 和 Python 里的 socket.ntohl 都是干这个的。
2.4 整数存储宽度不匹配带来的边界问题
整数还有一个容易忽略的特性:不同语言对"存储宽度"的默认值不一样。C 的 int 通常是 32 位,long 在 Windows 上是 32 位、Linux 上是 64 位;Java 的 int 固定 32 位、long 固定 64 位。数据库选字段时"多大范围用 int,多大范围用 bigint",本质上就是由这些存储宽度决定的,超过宽度就溢出。
比如用户 ID 从 int 迁移到 bigint 后,接口层如果还留着int,取出来就已经被截断成奇怪的正负数。我见过不止一个项目在 JSON 解析层把 long 强行转成 int,数据量一大就出现"ID 变成负数"的灵异事件,背后就是存储位宽不够。排查这类问题,第一件事就是检查每个变量在内存里实际占几个字节。
3. IEEE 754:浮点数存储就是一个"内存里的科学计数法"
3.1 float/double 的字段划分与偏置指数
不知道你有没有写过科学计数法:1.2345 × 10^5。浮点数的 32 位或 64 位本质就是这个,只不过底数换成 2。float 的 32 个 bit 分成三段:第 31 位是符号位 S,第 23 到 30 位是指数字段 E,第 0 到 22 位是尾数字段 F。double 则是 64 位:1 位 S,11 位 E,52 位 F。
指数为什么要偏移?如果直接存补码,指数比较大小的逻辑和整数比较会不一致。IEEE 754 选择给指数加一个偏置:float 的指数实际值是 E - 127,double 是 E - 1023。这样一来,指数段可以从全 0 到全 1 按无符号整数单调递增,"比较两个浮点数谁大"可以先看符号、再看指数、再看尾数,排序电路简单很多。尾数部分也不再存完整数值,规格化数的形式是 1.f,最前面的那个 1 被隐藏不存,相当于白拿一位精度。
3.2 规格化、非规格化和特殊值
指数段不全 0、不全 1 时是规格化数,数值大小是 (-1)^S × (1.F) × 2^(E-B)。比如 float 的 1.0:S=0,E=127,F=0,表示 1.0 × 2^0。再比如 1.25 = 1.01 × 2^0,尾数存 0100...0,指数还是 127。
当指数段全 0 时进入非规格化区间,此时表示的是 (-1)^S × (0.F) × 2^(1-B),用来填补靠近 0 的空白地带,不然最小的规格化数和 0 之间会断档。指数段全 1、尾数全 0 表示正负无穷;尾数不全 0 表示 NaN。这三个特殊区域是很多人踩坑的重灾区:float 除以 0 并不总是报错,它可能直接得到 inf,inf 再参与运算又可能产生 NaN。
3.3 为什么 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 乘 2 是 1.2,取 1;然后 0.2 又回来了。你会发现 0.1 的二进制是 0.000110011001100... 无限循环,就像十进制的 1/3 等于 0.333... 一样。所以只要用有限的二进制尾数,0.1 就不可能被精确存储,存进去的都是经过舍入的近似值。
这也是为什么 0.1 + 0.2 会得到 0.30000000000000004:两个近似值相加,误差在最后几位累积后暴露出来。浮点比较、金额计算、把浮点当循环步长都会撞上这堵墙。我的规矩是:涉及钱的业务一律用十进制整数(分)或十进制高精度库,涉及累加计数的循环尽量用整数索引,只在真正需要小数运算时才使用浮点。
3.4 float 和 double 分别能精确表示到什么程度
float 的尾数有 23 位,加上隐藏的 1 位就是 24 位有效二进制精度,折合约 7 位十进制精度;double 有 52 + 1 = 53 位有效精度,折合约 15 到 17 位十进制精度。注意"能精确表示"只针对特定范围内的二进制定点值,比如整数、1/2、3/16 这种分母是 2 的幂的数。十进制的 0.2 换算后是 1/5,分母含 5,不是 2 的幂,所以必然近似。
在实际存储里,一个 float 显示 3.14,内存里可能存的是 3.140000104904... 这个级别的值;如果你把它转成 double,它不会自动变成更高精度的 3.14,因为"不精确的值"已经在 float 阶段固定了。常见错误是先用 float 计算,最后 cast 成 double 想提高精度,内存里并不会发生"补齐"。
4. 实测:把内存字节 Dump 出来看
4.1 C/C++ 用 memcpy 查看原始字节
理论说再多,不如亲眼看看字节。C 里最稳妥的方法是 memcpy,因为直接强转指针可能违反严格别名规则,临时实验用 union 也可以,但我建议先看 memcpy 版本。
#include <stdio.h> #include <stdint.h> #include <string.h> void print_bytes(void *p, size_t n) { unsigned char *b = (unsigned char *)p; for (size_t i = 0; i < n; i++) { printf("%02X ", b[i]); } printf("\n"); } int main() { int32_t a = -5; float f = 1.25f; print_bytes(&a, sizeof(a)); // 小端机器上会输出 FB FF FF FF print_bytes(&f, sizeof(f)); // 1.25 会输出 00 00 A0 3F return 0; }小端存储下,-5 输出 FB FF FF FF,对应十六进制 0xFFFFFFFB。1.25 输出 00 00 A0 3F,把它按大端读回来是 3F A0 00 00,二进制是 0 01111111 01000000000000000000000,指数段 127,尾数最高两位是 01,表示 1.01×2^0,正好是 1.25。看到这个输出,你就把 IEEE 754 的字段对应关系落到了实处。
4.2 Python 一行代码看浮点数内部
如果不想碰编译,Python 的 struct 和 int.to_bytes 非常方便:
import struct # 整数补码:-5 转成 4 字节 print((-5).to_bytes(4, 'little', signed=True).hex()) # fbffffff # float 的 4 字节 print(struct.pack('<f', 1.25).hex()) # 0000a03f # double 的 8 字节 print(struct.pack('<d', 0.1).hex()) # 9a9999999999b93f # 也可以直接看十六进制表示 print((0.1).hex()) # 0x1.999999999999ap-4注意 double 0.1 的十六进制表示是 0x1.999999999999a × 2^-4,这里的 0x1.999999999999a 是 1.f 形式的十六进制表达,最后一位是 a 而不是 9999,说明 0.1 被舍入成了稍大于理论循环值的一个数。这就是"近似"在字节层面的具体呈现。
4.3 GDB 调试器里观察内存排列
调试崩溃的时候也可以不用写代码。GDB 里拿到一个变量地址后,用x/4xb &x查看前 4 个字节:x 表示十六进制,4 表示 4 个单元,b 表示每单元 1 字节。如果 x 是 int,x/4xb &x就能看到大小端排列。如果想看浮点,x/1fw会把这 4 个字节按 float 解释。
经验是:遇到"数字非常巨大、负数、无穷、NaN"这类异常值时,第一反应就是切到十六进制字节视图,一眼就能判断是数据错位、字节序反了,还是内存被覆盖。我之前排查过一个问题,浮点字段打印出 1.1754944E-38,这种接近 float 最小非规格化数的值,看着像随机数,其实是从字节序颠倒的数据里解析出来的,十五分钟就定位了。
5. 这些存储特性直接决定了五类高频 Bug
5.1 整数溢出与隐式类型转换
补码让溢出行为变得可预测,但可预测不等于正确。隐患最大的是 C/C++ 的隐式转换:当 int 和 unsigned int 混在同一个表达式里,int 会被转成 unsigned int,这叫"无符号整数提升"。比如a - b如果 a 是 int、b 是 unsigned,结果就变成无符号,负数会变成 4294967295 这样的巨大数字。循环判断for (i = n; i >= 0; i--),当 i 是 unsigned 时条件永远成立,这是嵌入式面试的经典送命题。
处理办法是在设计阶段明确每个变量的位宽和有无符号,涉及跨类型比较时显式 cast,并打开-Wsign-conversion这类编译选项。数值超过 2^31 或 2^63 本身就是信号,不要习惯性用更大的 float 去"兜底"——浮点数虽然范围大,但大数和小数混合运算时的精度损失可能更隐蔽。
5.2 浮点比较永远不要用 ==
因为浮点数存储的是近似值,0.1 + 0.2 == 0.3在绝大多数语言里返回 false。正确姿势是定义阈值:fabs(a - b) < 1e-9。如果是强实时系统或数值计算,应该使用相对误差:fabs(a - b) <= epsilon * fmax(fabs(a), fabs(b)),防止大数据上绝对误差阈值失效。更严谨的场景直接用有理数、十进制库或整数缩放。
浮点做循环步长也建议避免。比如for (float x = 0; x <= 1.0; x += 0.1)可能少循环一次,因为 0.1 的累加误差会让 x 落在 1.0000001 附近。我一般用整数循环变量,再在需要浮点时除以 10 或 100,既保证次数精确,又方便定位。
5.3 NaN 比你想的更"有毒"
IEEE 754 定义 NaN 表示"无意义的结果",但它在内存里是一堆特定 bit 模式。其危险之处在于:NaN 不等于自身,NaN == NaN为 false;任何比较NaN < x、NaN > x也都是 false;min/max函数对 NaN 的行为在不同实现里可能完全相反。一段排序代码里混入 NaN,结果会非常飘忽。
排查技巧:当计算结果出现 NaN,先检查除零、0/0、inf - inf、负数开方这些源头,用isnan()显式拦截;在协议解析层若收到类似全 0xFF 或全 0x7F 的浮点数据,也要警惕是不是 NaN。还有一个隐蔽点:把 NaN 转成整数时,不同 CPU 指令可能返回 0 或 INT_MIN,行为不可移植,跨架构代码要特别小心。
5.4 结构体内存对齐带来的"空穴"
整数和浮点的存储宽度还决定了结构体对齐。struct { char c; int i; }在多数 64 位平台上 size 是 8,而不是 5。原因是最佳对齐是 4,char 后面的 3 字节被填充,让 int 的地址落在 4 的倍数上。字段顺序改成struct { int i; char c; },整体仍是 8 字节;但struct { char c; char d; int i; }只需要 8 字节,而struct { char c; int i; char d; }需要 12 字节,白白多了 4 字节。
结构体字段按"从大到小"排,通常能减少填充;大量小结构体在内存排队列出时,这种优化非常明显。有些协议结构体要求 1 字节紧凑排布,用#pragma pack(1)或__attribute__((packed)),但代价是非对齐访问可能降低性能,甚至在某些架构上触发总线错误,不要把紧凑排布当成默认方案。
5.5 字节序不一致导致的对不上
跨端通信时的字节序问题我已经踩过很多次。常见故障形态是:服务端用 ByteBuffer 写了一个 int 长度,客户端直接 memcpy 成 int 来读,如果两端字节序不同,读出来的长度会变成天文数字,请求直接超时。排查这类问题前先问:两端的"网络字节序"约定到底有没有被执行。
我自己会在所有跨端协议代码里加上明确的转换函数,而不是指望"反正都是小端"。同时在报文格式定义里写明整数是大端还是小端、浮点是否发送、精度保留几位,这份约定和代码一样重要。还要注意浮点字节序没有统一的网络标准,跨端传 float 时建议先转成字符串或整数定点,避免不同硬件平台对字节序解释不同。
6. 最后分享几个我常用的排查习惯
6.1 遇到数值异常,先看它在内存里的十六进制形状
从业时间越久,我越习惯把"数值"和"位模式"分开看。一个 int 崩出来的 2147483647、0x80000000,一个 float 显示的 1.1754944E-38、全 F 的 NaN,都对应着内存中的具体 bit 模式。遇到诡异数据,先用 hexdump 或调试器看字节,再回到类型定义查位宽,通常比读半天日志更快定位。
6.2 建立一张自己的存储速查表
我会建议你长期保存下面这个表,排查问题时很快就能回忆起来:
| 类型 | 位宽 | 典型范围/表示 | 内存形状 |
|---|---|---|---|
| int8_t | 8 | -128 ~ 127 | 补码,单字节 |
| int32_t | 32 | -2147483648 ~ 2147483647 | 补码,4 字节 |
| uint32_t | 32 | 0 ~ 4294967295 | 原样二进制,4 字节 |
| float | 32 | ±3.4E38,约 7 位十进制精度 | S+8 位指数+23 位尾数 |
| double | 64 | ±1.7E308,约 15~17 位精度 | S+11 位指数+52 位尾数 |
这张表不是背出来就完事,而是在写每行代码时主动问一句:这个数在内存里恰好是 4 字节吗?0.1 真的能精确吗?它和另一端交互时字节序对吗?当你习惯用"位模式"审查数字,很多曾经玄学的线上问题,都会变成一眼就能看穿的小把戏。