资讯动态

整数与浮点数内存存储全解析:原码补码、IEEE 754与精度丢失

发布时间:2026/10/14 20:26:38 来源:尧图企业网站定制
很多人对“整数和浮点数在内存中的存储方式”这件事的理解基本停留在“int占4字节float占4字节double占8字节”这个层面。平时写业务代码倒也没问题可真到了调内存、解析协议、做序列化或者面试聊底层的时候立马露馅为什么int存进去是0x00000001float存进去变成0x3F800000为什么0.1加0.2不等于0.3为什么float能表示几亿的大数却连个16777217都存不精确这些问题不是靠背结论能解决的得真正理解二进制在内存里的排布逻辑。这篇内容就是把整数和浮点数从“值”到底层“字节”彻底拆开讲清楚原码、补码、IEEE 754、精度丢失、大小端这些概念到底是怎么回事还会给出一些直接用调试器看内存的实战方法。适合刚开始学计算机基础的同学也适合那些写了两三年代码但从来没认真看过内存的开发者。看完之后你再遇到浮点精度问题、整型溢出问题、网络字节序问题心里会踏实很多。1. 先把整数在内存里翻个底朝天整数在内存里的存储方式表面上看是“把一个数转成二进制放进去”实际上牵扯到符号怎么处理、负数的减法怎么统一成加法、边界值溢出之后会变成什么。这背后有一整套设计逻辑不是随便定的。1.1 原码、反码、补码为什么一定要用补码新手接触整数存储首先会碰到三个名词原码、反码、补码。原码最简单就是符号位加绝对值。以8位为例1是00000001-1是10000001。但这套方案在计算机里没法直接做减法运算因为CPU的加法器只会做加法如果要算1-1它得把减法单独拆一套电路来处理。而且原码还有一个恶心的问题0有两种表示0是00000000-0是10000000每次判断0都要做两次比较。反码解决了部分问题正数不变负数在原码基础上按位取反。-1变成11111110。但反码还是存在0的二义性问题而且反码做加法会出现进位要回绕的麻烦。真正的解法是补码。补码的定义很干脆正数不变负数取反加1也就是先求反码再加1。8位-1的补码就是00000001按位取反得到11111110再加1得到11111111。这样一来减法就变成了加上一个负数的补码CPU只需要一套加法器就能搞定所有加减法。更重要的是0的补码只有00000000一种表示-0的问题被彻底消灭了。注意补码中“取反加一”只是给人类用的速算口诀计算机里做的是模运算。8位整数所有数在模256的环里-1就是255的别名。今天常说的“溢出变成循环”本质上就是模运算在起作用。这里要深挖一个很多资料没讲透的点补码的范围为什么是不对称的8位有符号整数能表示-128到127负数那边多一个数。这是因为补码把0归入正数区而且10000000这个数算出来是-128再没有正数128能给它配对了。同理32位int的INT_MIN是-2147483648INT_MAX是2147483647负数最小值比正数最大值绝对值大1就是这个原因。很多新手写代码不理解为什么INT_MIN取绝对值会溢出根源就在这里。1.2 用十六进制看几个经典值理解了补码规则最好动手把几个经典值的字节序列写出来形成肌肉记忆。int a 1在内存里是0x00000001。这没什么可说的。int b -1直接按“取反加一”算1是0x00000001取反得到0xFFFFFFFE加1得到0xFFFFFFFF。所以-1在内存里是全F不是概念里那个“1000...001”这就是补码和原码的直观差别。int c INT_MAX也就是2147483647内存里是0x7FFFFFFF。符号位是0剩下31位全是1。你给这个数再加1会发生什么0x7FFFFFFF加1变成了0x80000000在无符号视角下这是2147483648但按有符号int来解释它是INT_MIN。这就是整数溢出最经典的场景而且C/C里这种行为属于未定义行为编译器优化后可能出现你完全想不到的结果。顺着这个思路继续推算一组值0x80000000有符号int的INT_MIN0xFFFFFFFF无符号int的4294967295有符号int的-10xFFFFFFFE无符号int的4294967294有符号int的-2。同一份字节用有符号和无符号两套解释规则读出来数值完全不同。所有涉及协议解析、文件格式读取、二进制序列化的场景都必须明确字段到底是有符号还是无符号否则数据出来就是错的。1.3 溢出、截断和符号位那些坑整数存储里最容易踩的坑有三个溢出、隐式转换、截断。溢出分有符号和无符号两种情况。无符号整数的溢出是定义好的回绕行为比如unsigned char的255加1变成0有符号整数的溢出在C/C里是未定义行为编译器可能直接假设你永远不会让int溢出然后据此优化掉一些判断逻辑。实际排查问题的时候看到“这代码怎么没进if分支”的情况往往就是编译器利用了未定义行为做优化。隐式转换是另一个重灾区。看这个例子int a -1; unsigned int b 1; if (a b) { printf(a b); } else { printf(a b); }这段代码会输出a b因为a和b比较时a先被转成unsigned int-1变成了4294967295当然大于1。这种问题在混用有符号和无符号的代码里非常隐蔽尤其是循环条件里用了size_t的时候。我的习惯是在写比较逻辑前先确认两边类型一致或者在编译时打开-Wsign-compare这类警告。截断发生在把宽类型收窄成窄类型时。比如把int的0x12345678强制转成char只会保留低8位0x78。很多人调网络协议时遇到“收到的字节对不上”就是没意识到结构体里的int被对齐成4字节而协议里实际是2字节字段强制转换后数据全错位了。正确的做法是用明确宽度的固定类型比如int32_t、uint16_t并且用memcpy或逐字节赋值来解析。2. 浮点数按IEEE 754标准存核心是科学计数法浮点数和整数最大的区别在于整数是精确地把数值映射到二进制浮点数是用有限的比特位去近似一个实数。这就决定了它天生不精确存储规则也比整数复杂。2.1 三个字段和背后的设计逻辑以最常见的32位float为例内存里这32位被分成三段第31位符号位S0代表正数1代表负数第30到23位指数位E共8位第22到0位尾数位M共23位。整个数值的计算公式是(-1)^S × 1.M × 2^(E-127)。这个公式看着抽象实际上就是在用二进制科学计数法。任何一个二进制浮点数都可以改写成1.xxx的形式比如十进制13.5是二进制1101.1科学计数法就是1.1011 × 2^3。这里1.1011中的“1.”这个整数部分是固定为1的所以IEEE 754干脆不存它只存小数部分叫隐含位或隐藏位。这就是float明明只有23位尾数却能达到约24位十进制精度的原因多出来那1位就是隐含的。指数段为什么要偏移127因为指数有正有负为8位二进制。如果直接用补码存比较大小还要额外处理符号。IEEE 754选了一个更简单的方案存“真实指数值127”。真实指数3就存130真实指数-5就存122。这样指数部分变成了一个单调递增的无符号数直接按字典序比较浮点大小就够了。double则是11位指数偏移1023。提示看任何一个float的十六进制只要先把指数位减掉偏移量再结合尾数位就能反推出原数。这个技能在逆向、调试、解析二进制协议时非常重要。2.2 完整拆解一个float17.625的存储过程理论说完必须上手拆一个实际数字我选17.625因为它能精确转成二进制小数方便对照验证。第一步把17.625转成二进制。整数部分17是10001小数部分0.625是0.101因为0.625等于1/2加1/8即二进制0.101。拼起来是10001.101。第二步规范成科学计数法形式10001.101等于1.0001101 × 2^4。小数点向左移了4位所以真实指数是4。第三步计算指数存储值真实指数4加偏移127得131转成8位二进制是10000011。第四步尾数位取小数点后的部分0001101然后右侧补零到23位即00011010000000000000000。第五步符号位为0拼接三个字段 0 10000011 00011010000000000000000把这串按4位分组0100 0001 1000 1101 0000 0000 0000 0000十六进制就是0x418D0000。你在C语言里定义一个float f 17.625然后看它在内存里的原始字节高四个字节正是41 8D 00 00小端序下字节倒排存放。这就是整个存储逻辑看起来复杂但你会发现“指数”和“尾数”本质上就是在还原二进制科学计数法。2.3 指数偏置、规格化和非规格化数接下来的细节决定了很多边界行为。如果指数存储值E在1到254之间对应的就是一个规格化数也就是正常的1.M × 2^(E-127)形式。但如果E等于0就有额外含义。此时隐含位不再是1而是0公式变成0.M × 2^(-126)。这类数叫非规格化数用来表示绝对值非常小的数。举个例子1.2e-38的数量级下很多规格化数都覆盖不到非规格化数通过牺牲精度换来了向0靠拢的能力。当然代价是计算速度会变慢因为硬件处理非规格化数需要额外操作。如果E等于255则表示特殊值尾数M全0时是无穷大正负取决于符号位S尾数M不是全0时是NaNNot a Number比如0/0、sqrt(-1)之类的结果。浮点数的“动态范围”要分开看它能表示的最大规格化数大约是3.4e38最小正规格化数大约是1.2e-38但这不是连续覆盖的。数字越大相邻两个可表示浮点数之间的间隔越大数字越小间隔越小。这就像用一把刻度不均匀的尺子量长度量程很大但每个位置上的精度不一样。3. 精度问题不是玄学是二进制表示绕不过去的坎浮点数最让开发者头疼的就是精度问题。今天这个软件里0.1加0.2不等于0.3明天那个系统里累加一百万次金额错了0.01元。要定位这些bug先得理解到底哪里丢了精度。3.1 0.1加0.2为什么不是0.3很多人以为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取整数1小数部分变成0.60.6乘2得1.2取整数1小数部分0.2……后面开始循环0.1的二进制小数是0.00011001100110011...它是一个无限循环小数。既然无限float和double都存不下完整循环只能四舍五入到有限的53位或24位。这个舍入误差在计算0.10.2时会被放大最后得到一个略大于0.3的近似值。同样的问题还体现在3.14也存不精确1/3在二进制里更是无限循环。不要在脑子里把这些小数当成精确值它们从进内存那一刻起就是近似值。如果你写下下面这段代码#include stdio.h int main() { float a 0.1f; float b 0.2f; float c a b; printf(0.1f 0.2f %.10f\n, c); if (c 0.3f) { printf(equal\n); } else { printf(not equal\n); } return 0; }输出大概率是0.3000000119而且显示not equal。这就是浮点比较的经典坑。3.2 安全比较的实操指南既然不能直接用比较浮点数那怎么办最常用的是绝对误差法判断两个数差的绝对值是否小于一个极小阈值。比如#include math.h #include stdio.h int main() { double a 0.1 0.2; double b 0.3; if (fabs(a - b) 1e-9) { printf(close enough\n); } else { printf(not close\n); } return 0; }这个阈值选多少要视场景定。单价是元级别、误差在0.01元以内可以接受阈值就设0.01科学计算需要更高精度就设1e-9甚至更小。但绝对误差法有个问题当数本身很大时比如比较1e20和1e201两个数的绝对差是1但1e20数量级下float的精度根本区分不出这种差别绝对误差阈值就失效了。这时候要用相对误差法或ULP比较法。ULPUnit in the Last Place表示两个浮点数之间有多少个“最小步长”即两个数在浮点数可表示的集合里隔了多少档。C语言里可以用nextafterC里可以用std::nextafter或者直接写一个基于整数表示的比较函数把float的位模式转成整数然后比较差值的绝对值。这也是很多库内部真正用的方法。如果业务上能不用浮点数就别用浮点数尤其是金额计算用整数分、整数厘存储最后才转成小数展示这是最稳的方案。3.3 float和double混用带来的隐性错误float精度大约6到7位十进制有效数字double大约15到16位。混用它们的主要风险体现在赋值和参数传递上。看这段代码float f 0.1f; double d f; double result d * 10.0;你以为result等于1.0不是的。因为f里的0.1已经被舍入成0.100000001490116...转成double不会“找回”精度只会把这个不精确的值“扩展”到更长比特。real result是1.000000014901161...。很多人以为float转double能提升精度这是一个很大的误区。提升精度只发生在你用一个任意精度的十进制字符串重新解析时而不是发生在从float直接提升到double时。混合计算时C/C会把float自动提升为double再运算计算结果的类型是double。但如果最终赋值给float就会发生二次舍入。这种二次舍入是很多累积误差的来源。我的习惯是全项目统一用double作为浮点默认类型只有在明确需要节省内存的大数组场景才用float。这样能少掉大量莫名其妙的精度问题。如果想要更高精确度可以考虑十进制浮点数库或用高精度整数运算但大多数业务场景用double加合理比较策略已经足够了。4. 实战把内存里的字节捞出来看前面讲了那么多理论和推算方法最终还是要落到实际调试上。有几个小工具和代码技巧能让你直接看到浮点数和整数在内存里的“真面目”排查问题特别管用。4.1 用C语言把float当整数打出来最快的方法是借助union让同一块内存既可以按float解释又可以按uint32_t解释。代码如下#include stdio.h #include stdint.h int main() { union { float f; uint32_t u; } v; v.f 17.625f; printf(bits 0x%08X\n, v.u); return 0; }这样就能打印出0x418D0000。这个技巧的本质是类型双关。不同语言玩法类似在Python里可以用struct包import struct bits struct.unpack(I, struct.pack(f, 17.625))[0] print(hex(bits))在真正调试时用调试器直接在内存窗口里看会更直观。比如用gdb设置断点后执行print /x f x/4bx f第一句能以十六进制打印变量的值第二句能按字节逐个显示变量地址处的原始内容。看到那4个字节和理论推算的0x418D0000完全对上这个知识才算真正掌握了。4.2 大端小端一眼定字节序问题在涉及网络协议、二进制文件格式时一定会碰到。大端Big-Endian是把高字节放低地址小端Little-Endian是把低字节放低地址。x86、ARM都是小端但网络协议绝大部分用大端的“网络字节序”。判断当前机器是大端还是小端可以定义一个int x 0x01020304然后把它的首字节取出来看。如果首字节是0x04说明内存里是从低到高放04 03 02 01小端如果首字节是0x01说明大端。#include stdio.h #include stdint.h int main() { uint32_t x 0x01020304; unsigned char *p (unsigned char *)x; printf(%02X %02X %02X %02X\n, p[0], p[1], p[2], p[3]); return 0; }小端机器上输出04 03 02 01。写序列化代码时记得统一转换成字节序再传给对端不要直接把本地内存写进网络流里。注意C语言强制类型转换基于地址取低字节这在解析协议时很容易出错。更稳的方式是用memcpy或者联合体显式指定字节序解析逻辑避免依赖编译器和平台的差异。4.3 特殊值NaN、Infinity 在内存里的样子把特殊值的位模式记下来也很有用。float正无穷是0x7F800000负无穷是0xFF800000指数部分全是1尾数全是0。NaN是一个大类别只要指数全1、尾数不全为0的都是NaN。比如0x7FC00000是最常见的quiet NaN0x7F800001是一个signaling NaN。用现成的代码可以直接验证#include stdio.h #include stdint.h #include math.h int main() { union { float f; uint32_t u; } v; v.f 0.0f / 0.0f; printf(0/0 as float 0x%08X\n, v.u); v.f INFINITY; printf(inf as float 0x%08X\n, v.u); v.f -INFINITY; printf(-inf as float 0x%08X\n, v.u); return 0; }在解析外部二进制数据时如果取出来的指数字段是255就说明遇到了特殊值必须单独处理不能当作普通数值参与运算。很多解析器崩溃就是没做这个检查。5. 检查清单排查整型和浮点问题时的固定思路这部分是给实际工程排错用的。当你再遇到“数值不对”的bug可以用一套固定流程定位比乱猜快得多。5.1 三个高频题自己动手算一遍有的面试题会问int和float同样是4字节为什么float能表示的数值范围远大于int答案在于两者的布局完全不同int用31位表示数值符号占1位float用8位指数表示“量级”23位尾数表示“精度”。float能表示几十亿的大数但大数区间的精度很差相邻两个float之间可能差几百。int虽然范围小但在这个范围内每个整数都是精确的。还有一道题int i 16777217; float f i; 之后f等于多少16777217是2^241而float有效精度只有24位含隐含位1所以超出部分被舍入。在默认舍入模式下float会把它表示成16777216.0也可能变成16777218.0但绝不是精确的16777217。这就是为什么大整数转浮点也会丢精度不要以为只有小数才有精度问题。第三道题以下代码输出什么这个问题我在前面已经给过答案unsigned int a 1; int b -1; if (a b) { ... }答案是a b成立因为b被转成无符号数后会变成4294967295。这类题目本质上考的是整型转换优先级和补码表示。5.2 排查浮点问题时的固定思路在实际代码里定位到浮点异常不要先怀疑库函数按下面的顺序排查首先确认输入的数值是否已经丢失精度。看数据是来自用户输入的十进制字符串还是经过多次浮点运算的中间值。如果是后者先想办法用高精度类型或整数重新建模绕开累积误差。其次检查比较逻辑。全项目搜一下有没有直接浮点数的代码全部改成epsilon比较或者整型化比较。比较之前想清楚阈值的量级不要在数值特别大的时候还用固定绝对误差。再看混合运算。float赋值给double不会提升精度double转float一定会舍入出现精度问题时不要在这两类变量之间来回复制。最后考虑编译优化。宽松浮点环境和不支持FMA的指令集可能导致同一段代码在不同机器上结果不一样。如果涉及到跨平台分发建议固定浮点优化选项并在文档里写明一致性预期。我见过一个非常经典的案例一个订单系统用float存折扣用户领了0.1折优惠系统里存的是0.1f。等到计算金额时0.1f的近似误差被放大最后总额比手工算的多了0.01。这种问题用浮点调参永远治标不治本改成用整数存千分比之后所有对账问题一次清干净。排查整型和浮点存储问题最终要回到“你脑子里的数和内存里的字节是不是一回事”。先看字节再谈算法这个习惯能帮你少走太多弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑