ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从补码到IEEE 754:一文看懂整数与浮点数在内存中的存储

从补码到IEEE 754:一文看懂整数与浮点数在内存中的存储 先说个反直觉的事在大多数电脑上你用 C 语言写int a -1;然后把这 4 个字节以十六进制打印出来得到的是FF FF FF FF根本不是教科书里常画的符号位加绝对值那种朴素表示。再看float f 1.0f;它的 4 个字节是00 00 80 3F小端序下第一眼谁会觉得这是数字 1我第一次在内存窗口看到这堆字节时也愣了半天。搞清楚整数和浮点数在内存里到底怎么存不仅能看懂调试器里的十六进制更重要的是能解释很多实际工程问题为什么0.1 0.2不等于0.3为什么用比较浮点数会出事为什么结构体明明只有 5 个字节却占了 8 个字节的空间为什么大整数加小数会加了个寂寞。这篇文章就围绕内存中数字的存储展开适合刚接触底层原理的开发者也适合在项目里被浮点精度坑过、想彻底搞明白根源的人。我会从整数存储的补码设计、浮点数的 IEEE 754 格式、精度损失的根源、大小端与对齐这些实际布局问题一路讲到怎么用调试工具亲眼看到内存里的字节。内容尽量把为什么这样设计讲透因为理解了设计动机那些规则就不再是死记硬背。1. 整数存储为什么 -1 的二进制是 0xFFFFFFFF补码的设计动机整数在内存里的存储方式核心就三个词原码、反码、补码。很多人把这个当成面试题背但没想过为什么要绕这么大一圈用补码。这得从硬件设计的角度理解。1.1 原码和反码各自的硬伤最朴素的思路是用最高位表示符号其余位表示绝对值这就是原码。比如用 8 位存整数5是00000101-5是10000101。这个方案人类看着舒服但硬件做加减法时要先比较两个数的符号同号直接加异号要判断谁绝对值大再减。电路逻辑复杂不说还容易出错。另一个问题是0 有两种表示00000000代表010000000代表-0。程序里判断x 0时两个数都得专门处理这是个隐患。反码的改进是正数不变负数把每一位取反。比如-5变成11111010。这回加减法可以统一走一套电路了但0的表示问题还在00000000是011111111是-0。1.2 补码如何同时解决符号和零表示问题补码的设计思路是正数和 0 用原码表示负数等于对应正数取反再加一。以 8 位为例-1就是把00000001取反得到11111110再加一得到11111111也就是0xFF。这样做有两大好处。第一0 只有一种表示00000000取反加一还是00000000从11111111加一溢出后丢掉进位正好回到 0不存在-0。第二减法和加法用同一套电路x - y直接变成x (-y)。硬件只需要一个加法器规则简单且快。从数学角度看补码的本质是模运算8 位整数所有可能的取值是 0 到 255把它们整体平移一下让前一半表示非负数、后一半表示负数。所以 8 位有符号数的范围是-128到127而不是对称的-127到127——多出来的10000000就表示-128。这也是为什么int8_t的最小值是-128而它的绝对值128在 8 位有符号范围内根本装不下。1.3 溢出时发生了什么补码还有个很直观的结论溢出就是数值跨过了范围边界转到了另一头。比如127 1在 8 位里结果是10000000按补码理解是-128。这不是编译器算错了而是类型宽度不够。这个现象在 C/C 里属于未定义行为但在 Python 这类任意精度语言里则不存在——Python 的整数会自动扩容所以你永远看不到这个溢出。实际工程里要记住整数的存储范围由宽度决定32 位有符号整数范围是-2147483648到2147483647。做累加、乘法时先估一下量级否则很容易出现数越加越小的诡异 bug。我见过一个解析金融数据的项目日累计金额用了 int跑到一半金额变负数排查了半天才发现是溢出。2. 浮点数的 IEEE 754 布局一位符号、八位指数、二十三位尾数浮点数的存储比整数复杂得多因为它要同时表示极大、极小、带小数点的数。现代计算机普遍遵循 IEEE 754 标准单精度float占 32 位双精度double占 64 位。理解它建议先忘掉数轴思维转而想科学计数法。2.1 从科学计数法到内存布局任何一个数先改写。比如十进制123.456 1.23456 × 10²。浮点数在二进制里做同样的事任何一个规范化的数都可以写成(-1)^符号 × 1.尾数 × 2^指数。IEEE 754 的float将这 32 位分成三个部分第 31 位最高位符号位0 为正1 为负第 23 到 30 位指数位共 8 位第 0 到 22 位尾数位共 23 位。double同样结构但指数占 11 位尾数占 52 位。指数位更宽意味着能表示的范围更大尾数位更宽意味着精度更高。2.2 指数偏置为什么真实指数要加上 127指数位是 8 位无符号数可以表示 0 到 255。但实际指数可以是负数比如 0.5 1.0 × 2⁻¹。为了用无符号数表示有符号指数IEEE 754 引入偏置真实指数 指数位存储值 - 127。对float偏置是 127对double偏置是 1023。举个例子指数位存储值如果是10000010十进制 130那么真实指数是130 - 127 3。规格化浮点数还有个隐含约定尾数部分的小数点前面默认有一个1不存进内存所以 23 位尾数实际提供 24 位精度。这叫隐含最高位也是新手最容易忽略的细节。2.3 手工解析一个 float9.25 的完整转换理论讲完拿9.25f实际操作一遍。第一步转二进制9是10010.25是0.01因为 0.25 2⁻²合起来为1001.01。第二步规范化1001.01 1.00101 × 2³。第三步拆位符号为 0真实指数为 3存储值为3 127 130转成 8 位二进制是10000010尾数为小数点后的00101右侧补零到 23 位。拼起来得到二进制0 10000010 00101000000000000000000。十六进制是0x41140000。在小端机器上内存字节顺序反过来所以你看到的是00 00 14 41。可以用下面的代码验证#include stdio.h #include stdint.h int main() { float f 9.25f; uint32_t bits; // 把 float 按 32 位无符号整数读出来观察其比特模式 __builtin_memcpy(bits, f, 4); printf(0x%08X\n, bits); return 0; }输出0x41140000就说明转换正确。2.4 特殊值0、无穷和 NaN 的二进制身世指数位全 0 和全 1 是保留的分别用于表示特殊值。很多人以为 float 的指数范围是 -127 到 128实际上规格化的指数范围是 -126 到 127。0指数全 0、尾数全 0。有0.0和-0.0两个表示但一般判断相等时它们相等。非规格化数指数全 0、尾数非 0。此时隐含位不再是 1而是 0用于表示非常接近 0 的极小值。最小非规格化float约为1.4e-45。无穷指数全 1、尾数全 0。正无穷0x7F800000负无穷0xFF800000。运算溢出时得到它。NaN非数指数全 1、尾数非 0。0.0f / 0.0f或sqrt(-1.0f)的结果就是它。一张表看清这些特殊值场景符号指数位尾数位实际含义0.0000000000000...0000-0.0100000000000...000-0最小非规格化000000000000...001约1.4e-45最小规格化000000001000...000约1.2e-381.0001111111000...0001最大有限值011111110111...111约3.4e38无穷011111111000...000infNaN0或111111111非0NaN写代码时判断 NaN 要用x ! xNaN 是唯一不等于自身的数或者用isnan()判断无穷用isinf()。在 C 里直接比较浮点数相等本身就有隐患稍后展开。3. 浮点数精度的真相它不是 bug而是存储格式的必然结果学完 IEEE 754很多玄学问题就变成数学必然了。最经典的就是0.1 0.2在几乎所有编程语言里都不等于0.3。这不是哪门语言的 bug而是二进制浮点数的固有局限。3.1 0.1 在二进制里是无限循环小数十进制的 0.1 转换到二进制等于用 2 不断乘小数部分取整数位。0.1 × 2 0.2 取 00.2 × 2 0.4 取 00.4 × 2 0.8 取 00.8 × 2 1.6 取 10.6 × 2 1.2 取 1……这个序列会无限循环就像十进制里 1/3 0.3333 永远写不完一样。0.1 在二进制里也是无限循环小数。而float只有 23 位尾数double只有 52 位必须截断所以存进去的本来就是个近似值。0.1 0.2的结果在加法过程中还引入了舍入误差最终产物在小数点后第 17 位附近与0.3出现偏差。用double打印通常得到0.30000000000000004。凡是涉及判断两个浮点数是否相等的需求直接用都是危险的。3.2 float 的精度台阶大数加小数小数被吞掉精度问题比很多人以为的更隐蔽比如这个经典例子float x 16777216.0f; // 2^24 float y x 1.0f; // y x加了个寂寞为什么因为float的 23 位尾数加上隐含位共 24 位有效二进制位。要表示 2²⁴尾数刚好是 1.000...0共 24 位。此时能表示的下一个数是1.000...001× 2²⁴ 16777216 2步长已经变成 2 了。1.0 落不进两个可表示数之间直接被舍入成原来的数。这个现象揭示了一个重要规律浮点数的精度是相对的不是绝对的。数越大相邻两个可表示数的间距越大。有位同事在统计大整数数量时用 float 做累加数据量大之后每加一次数都不会变化最后结果差了上万条。凡是精确计数、货币金额、序号累加这类场景都应该用整数类型或定点数而不是 float。3.3 工程里的浮点比较与替代方案浮点数不能直接用那么怎么办常见做法是比较差值的绝对值是否小于一个很小的阈值 epsilon#include math.h #include stdio.h int nearly_equal(float a, float b, float eps) { return fabsf(a - b) eps; } int main() { float r 0.1f 0.2f; printf(r %.7f\n, r); printf(equal %d\n, nearly_equal(r, 0.3f, 1e-6f)); return 0; }epsilon 到底取1e-6还是1e-9要结合数值范围动态看。更严谨的做法是使用相对误差即比较fabs(a - b)与max(fabs(a), fabs(b)) * epsilon的大小。业务层面如果要求精确到分不要把金额存成 float直接用整数存分科学计算需要高精度时再考虑十进制库或任意精度库。记住一个原则整数能解决的精确问题不要引入浮点数。4. 落到内存里大小端、对齐和亲眼验证存储格式理解了数的二进制布局还不够真正在内存里落地时还有几个排布规矩要搞清楚。这些规矩平时被编译器掩盖但一旦跨平台、跨协议、排查崩溃时就会冒出来。4.1 大小端第一个字节是高位还是低位大小端描述的是多字节数据在内存中的排列顺序。大端模式把高位字节放低地址小端模式把低位字节放低地址。x86、ARM 常见的是小端网络字节序规定的是大端。用一个 4 字节整数0x12345678举例大端内存12 34 56 78低地址在前面小端内存78 56 34 12判断本机字节序写几行代码就行#include stdio.h #include stdint.h int main() { uint32_t x 0x12345678; unsigned char *p (unsigned char *)x; if (*p 0x78) printf(little-endian\n); else if (*p 0x12) printf(big-endian\n); return 0; }网络传输数据、解析二进制文件格式时字节序不一致会导致数值完全错乱。解决办法是统一转成网络字节序再传输接收端再转回主机字节序。C 里htons/ntohs这几个函数干的就是这件事。4.2 内存对齐结构体为什么比你算出来的大一个struct { char c; int i; };按第一直觉大小是 1 4 5 字节。实际sizeof通常是 8。多出来的 3 个字节是填充为了满足对齐规则基本类型变量的起始地址最好是自身大小的整数倍。x86 上非对齐访问通常也能跑但会有性能惩罚有些 RISC 架构直接抛出异常。这个规则源于硬件访问内存的方式——CPU 读取一个 4 字节 int一次性从对齐地址取最省事。把结构体字段按从大到小排列通常能减少填充节省内存。比如struct bad { char a; int i; char b; }; // 常见大小 12 struct good { int i; char a; char b; }; // 常见大小 8字段一样只是换了个顺序就省了 4 字节。在大量实例化结构体、嵌入式或者性能敏感场景这个优化非常值钱。4.3 用调试器亲眼验证一张表理论讲再多不如自己动手看一次。下面这段代码把整数和浮点数的内存字节按地址逐个打印#include stdio.h void dump_bytes(void *ptr, int len) { unsigned char *p (unsigned char *)ptr; for (int i 0; i len; i) { printf(%02x , p[i]); } printf(\n); } int main() { int a -1; int b 1; float f1 1.0f; float f2 9.25f; double d 0.1; printf(int -1 : ); dump_bytes(a, sizeof(a)); printf(int 1 : ); dump_bytes(b, sizeof(b)); printf(float 1.0 : ); dump_bytes(f1, sizeof(f1)); printf(float 9.25 : ); dump_bytes(f2, sizeof(f2)); printf(double 0.1 : ); dump_bytes(d, sizeof(d)); return 0; }在小端 x86 机器上输出大致如下int -1 : ff ff ff ff int 1 : 01 00 00 00 float 1.0 : 00 00 80 3f float 9.25 : 00 00 14 41 double 0.1 : 9a 99 99 99 99 99 b9 3f第一行对应补码的0xFFFFFFFF第三行对应1.0的0x3F800000反序排列第五行就是0.1这个无限循环小数被截断后的真实存在。排错真遇到棘手的内存内容用 GDB 也很方便p/x f看数值十六进制x/4bx f直接看变量起始地址的 4 个字节。配合刚才的位布局知识可以快速反推当前值到底是正常数、溢出还是 NaN。4.4 一份实践心得什么时候你真的需要关心这些日常业务开发编译器把底层细节都包住了似乎不知道这些也能写代码。但遇到以下场景时这些知识就是救命的。第一排查 NaN 或无穷大。运算结果突然变成nan用isnan()只能确认结果要定位是哪个操作产生的就得能读懂内存里的指数位和尾数位判断是否发生了溢出或 0 除。第二跨平台解析二进制协议。不同机器字节序不同结构体对齐不同按memcpy直接拷贝结构体再传输到了异种机器上就乱套了。第三内存泄露和性能剖析。理解对象内存布局才知道每个对象实际占用多少而不是看着sizeof加减想当然。信号量不够时用 union 把 float 和 int 放到同一块内存里写成一个辅助调试工具按十六进制看 float 的位模式比盯着一串小数直观得多。这是我在实际项目里验证过很好用的小技巧代码类似这样#include stdio.h #include stdint.h union FloatBits { float f; uint32_t u; }; int main() { union FloatBits fb; fb.f -0.0f; printf(0x%08X\n, fb.u); // 0x80000000 fb.f 1.0f / 0.0f; // 正无穷 printf(0x%08X\n, fb.u); // 0x7F800000 return 0; }看输出就能直接对应到上一节的特殊值表把抽象规则落到具体数字上。消化了整套存储逻辑之后再看到0xFF不一定是巨大的数可能是-1的补码看到0x3F800000不一定是天文数字它只是1.0f。这就是内存中数字存储的全部意义。
返回列表