资讯详情

数据在内存中如何存储:原码补码、大小端与浮点位模式

📅 2026/10/13 7:25:11 | 华诺云谱 👁 阅读
数据在内存中如何存储:原码补码、大小端与浮点位模式
很多朋友学C语言学到指针就卡住了但真正让一批人栽跟头的其实是在指针之前的一个概念数据在内存中到底是怎么存的。我见过不少写了两年C的开发者被一个char c 0xFF打印出负数的问题问住也说不出float的位模式长什么样。这个主题看起来基础却是理解指针、调试器、网络协议、嵌入式开发的共同地基。这篇文章我从整型、浮点、大小端、隐式转换几个维度把“数据在内存中的存储”这件事彻底拆开讲清楚。配合调试器看内存、用代码验证布局、排查真实bug的实操方法我会全部放出来。适合正在学C语言的学生、刚入行的嵌入式开发者以及那些写了好几年业务代码却一直没弄懂底层细节的软件工程师。1. 整数的三重身份原码、反码与补码1.1 先从一段诡异输出聊起先看一个经典场面。某次我在帮人调一个串口通信的程序对方说上位机收到的数据偶尔会是负的明明发送的是正数。我让他把收发缓冲区改成unsigned char问题立刻消失。他没想明白一个字节而已加不加unsigned怎么就影响结果了这就得从整数的三种二进制编码说起。计算机硬件层面只有高低电平也就是0和1它不认识“正号”“负号”这些符号。为了让负数也能用二进制表示人们设计了三套规则原码、反码、补码。这三套编码在C语言学习的教科书里通常只占两三页但你要是真搞懂了它们上面那种串口bug一眼就能看穿。1.2 三种编码的计算规则先规定一个前提以8位二进制为例最高位是符号位0表示正1表示负。剩余7位是数值位。原码最直观的编码。正数的原码就是符号位加绝对值二进制负数的原码是符号位置1数值位写绝对值的二进制。以-5为例5的二进制是00000101把最高位改成1得到10000101这就是-5的原码。反码正数的反码和原码一样。负数的反码是在原码基础上符号位保持不变其余各位全部取反。-5的反码就是把10000101的数值位取反得到11111010。补码正数的补码依然等于原码。负数的补码是反码加1。-5的补码就是11111010加1得到11111011。提示这里有个实用速算法。从右往左找到第一个1这个1左边所有位取反右边不动符号位不算。比如-5的补码11111011从右往左第一个1是最低位左边111110变000001合起来还是11111011。这个方法算负数补码比“取反加1”快很多。为了看得更清楚我把几个数的三种编码列成一张表。十进制原码反码补码5000001010000010100000101-510000101111110101111101100000000011111111或0000000000000000127011111110111111101111111-127111111111000000010000001注意看0这一行。原码有“正0”和“负0”两种写法也就是00000000和10000000这在数学上很尴尬。而补码里0只有一种表示00000000。这个看似细小的差异正是补码成为现代计算机整数存储标准的重要原因。1.3 为什么计算机最后选了补码教材会告诉你“补码可以把减法变成加法”但这背后的具体好处值得掰开揉碎说。第一硬件设计大幅简化。如果计算机用原码或反码存负数做5 - 3这样的运算CPU需要先判断符号再决定用加法还是减法还要处理借位电路逻辑复杂得多。用补码后5 - 3可以写成5 (-3)而-3的补码是11111101直接参与加法即可。CPU只需要一套加法器减法器都可以省掉。第二0的表示唯一。原码里存在“正0”和“负0”两个不同的位模式却表示同一个数值。CPU做比较运算时就得额外处理这两种情况。补码消灭了这个问题也让“0”和“-0”不再打架。第三表示范围多了一个数。8位原码和反码能表示的范围是-127到127因为10000000和00000000都用来了表示0。补码把10000000腾了出来定义为-128所以8位有符号数的范围是-128到127。这多出来的一个负数在很多时候非常关键比如图像数据的像素差值、音频采样数据经常要用到最大负值。1.4 边界值-128到底是怎么来的刚说补码的10000000表示-128很多人不理解它不像是“-0”吗其实它在补码体系里就是一个独立的值。推导一下-127的补码是10000001那么-128应该等于-127减1也就是10000001减去1结果是10000000。这在补码里是合法且唯一的编码。用char类型验证#include stdio.h int main(void) { char c 0x80; // 二进制10000000 printf(%d\n, c); // 输出 -128 return 0; }在这段代码里0x80是十六进制字面量它的类型是int值为128。把128赋值给char时发生截断低8位是10000000。由于实现环境是常见的8位有符号char这个位模式对应的就是-128。打印%d时char先整型提升为int而10000000按符号扩展变成11111111111111111111111110000000也就是-128。这就解释了为什么char存0x80会输出-128而不是128。注意C标准里char是否有符号由实现决定也就是“实现定义行为”。绝大多数桌面与嵌入式编译器默认char是有符号的但确实存在默认无符号的架构。跨平台开发时别假设char一定有符号用signed char或unsigned char把意图写清楚。2. 字节序同一块内存两种读法2.1 大小端到底是怎么个回事整数超过一个字节时比如int a 0x12345678它占据4个字节。这些字节在内存里按什么顺序排列这就是字节序问题。小端Little-Endian低字节存在低地址。0x12345678的4个字节从低地址到高地址依次是78 56 34 12。x86、ARM的主流模式都是小端。大端Big-Endian高字节存在低地址。内存布局是12 34 56 78。网络字节序规定用大端部分嵌入式处理器如早期的某些MIPS配置也用大端。举个例子假设变量a的地址是0x1000两种布局如下。地址小端存储大端存储0x10000x780x120x10010x560x340x10020x340x560x10030x120x78看到没同一个数值在内存里的字节顺序完全反过来了。调试网络程序、解析二进制协议时如果你忽略了这一点读出来的字段往往是“倒着”的。2.2 一行代码判断本机字节序判断机器是大端还是小端最简洁的办法是用联合体。联合体的所有成员共享同一块内存往int成员里写值再从char数组成员里读第一个字节就能拿到低地址处的数据。#include stdio.h int main(void) { union { int i; unsigned char bytes[4]; } u; u.i 0x01; if (u.bytes[0] 1) { printf(小端\n); } else { printf(大端\n); } return 0; }原理很简单联合体里int i占4字节unsigned char bytes[4]也占4字节二者起始地址相同。写入u.i 0x01后最低字节的值是1。如果bytes[0] 1说明低地址存的是低字节是小端如果bytes[0] 0而bytes[3] 1低地址存的是高字节是大端。我的实测结果在一台常见的x86-64电脑上运行输出“小端”。ARM开发板跑Linux默认也是小端。绝大多数开发场景你都会遇到小端但网络协议、某些文件格式依然约定大端所以这个概念躲不开。2.3 网络字节序与协议解析的坑TCP/IP协议栈规定网络字节序用大端。也就是说你通过socket发送一个int如果不做转换直接用send把本地内存里的4个字节发出去在接收方看来字节顺序就是反的。正确的做法是用字节序转换函数htonl把32位整数从主机字节序转成网络字节序ntohl反向转回来。htons和ntohs处理16位短整数。这些函数的内部实现其实很简单在小端机器上做字节交换在大端机器上原样返回。我踩过的最典型的坑是这样的某次在嵌入式设备上解析一个自定义协议帧帧头里有个2字节的长度字段文档写的是“高字节在前”。我直接用一个unsigned short*指针去强转然后打印长度结果发现长度值变成了原来的256倍。原因很直接设备是小端而协议要求大端我没有调ntohs。排查过程花了将近半天就因为没有先看协议文档的字节序约定。提示调试协议程序时抓包工具里看到的是“发送端实际字节”如果你的解析结果和抓包不一致先怀疑字节序再怀疑字段偏移。3. 隐式转换的坑整型提升与截断3.1 整型提升是什么C语言里有个隐藏规则char和short类型在参与表达式运算时会先被转换为int再参与计算。这叫作整型提升。比如char a 100; char b 50; char c a b;虽然a b的两个操作数都是char但它们在运算前都被提升为int相加结果是int类型的150然后赋值给char c时才截断回8位。150没有溢出所以c依然是150。为什么要设计这种规则因为CPU的寄存器、ALU算术逻辑单元通常以int为基本操作宽度。8位、16位的数据在32位或64位处理器上运算时先统一提升到标准宽度电路设计更简单性能也更好。另外两个char相加可能溢出8位范围提升到int后至少不会在小中间结果上溢出。3.2 符号扩展与零扩展整型提升最容易被忽略的细节是有符号数做符号扩展无符号数做零扩展。比如下面这段代码#include stdio.h int main(void) { signed char a 0x81; // 二进制10000001十进制-127 unsigned char b 0x81; // 二进制10000001十进制129 int x a; // 符号扩展x 0xFFFFFF81 -127 int y b; // 零扩展y 0x00000081 129 printf(x %d, y %d\n, x, y); printf(x hex %08x, y hex %08x\n, x, y); return 0; }输出x -127, y 129 x hex ffffff81, y hex 00000081同样的位模式0x81放在signed char里是负数提升时高位补1得到0xFFFFFF81放在unsigned char里是正数高位补0得到0x00000081。很多人在做数据拼接、协议解析时把收进来的字节按char处理然后直接转成int结果发现所有大于127的字节都变成了负数就是吃了符号扩展的亏。注意处理字节流、文件读写、网络数据时默认思路应该是先把缓冲区定义为unsigned char或uint8_t再参与运算。只有文本处理这类明确需要符号的场景才用char。3.3 截断数据丢了哪几位当你把一个大范围的整数赋值给小范围类型时发生的是截断只保留低位字节高位直接丢弃。比如int a 0x1234ABCD; unsigned char b a; printf(0x%02x\n, b); // 输出0xCDb只取低8位0xCD。这在很多场景下是符合预期的比如从32位寄存器里读一个8位字段。但如果你没意识到截断容易写出看起来莫名其妙的结果。还有一个反直觉的细节截断不会自动做符号调整。看这个例子signed char c 0x12F; // 0x12F超出8位截断为0x2F 47 printf(%d\n, c); // 输出47截断只保留低8位0x2F它的符号位是0所以c是47而不是某个负数。截断就是纯粹的位截断不牵扯补码转换。只有当这个位模式被当成有符号数读取比如后续做符号扩展时负数才出现。3.4 有符号与无符号混用的算术陷阱有符号数和无符号数混合运算时C语言会进行隐式转换有符号数先转换为无符号数再参与运算。这条规则造成的bug数量在C语言所有坑里排得进前三。经典例子unsigned int a 10; int b -20; if (a b) { printf(a b\n); } else { printf(a b\n); }直觉告诉你10大于-20应该输出a b。但实际输出是a b。原因是在比较时b被转换为unsigned int-20变成了一个很大的无符号数约等于4294967276当然大于10。解决办法有两种要么让两边类型一致要么显式转换。比如把条件改成(int)a b或者更稳妥地把b存为unsigned int。这类问题在高性能计算、图像处理、嵌入式状态机里特别常见因为状态标志、计数器和长度变量经常是无符号的。还有一个相关的坑是signed char和unsigned char混用。比如图形图像处理中一个像素值0xFF用unsigned char读是255用char读是-1如果直接相加或比较结果完全不同。统一类型比事后补救省心得多。4. 浮点数在内存中的真实模样4.1 IEEE 754格式拆解浮点数的存储和整数完全是两码事。C语言里的float一般遵循IEEE 754单精度格式double是双精度格式。以float为例32位分成三部分1位符号位8位指数位23位尾数位。double则是1位符号位、11位指数位、52位尾数位。类型总位数符号位指数位尾数位指数偏移量有效数字约数float3218231276-7位double6411152102315-16位这里的指数并不是用补码存的而是用移码也叫偏移二进制。存储的指数 真实指数 偏移量。这样做的目的是让指数部分可以直接按无符号数比较大小简化浮点数的排序与比较电路。尾数部分存储的是规格化后的有效数字。规格化意味着把二进制数写成1.xxx × 2^E的形式因为小数点前固定是1所以这个1不占存储位只存小数部分省出1位精度。这就是所谓的“隐藏位”。4.2 亲手算一遍9.25f的位模式纸上谈兵不如手算一遍。我们用float f 9.25f来还原它在内存里的32位。第一步把9.25转成二进制。整数部分9是1001小数部分0.25是0.01所以9.25 1001.01B。第二步规格化。1001.01B 1.00101 × 2^3。这里小数点向左移动3位所以真实指数E 3。第三步计算指数段。存储指数 3 127 130 10000010B占8位。第四步提取尾数段。规格化后是1.00101隐藏掉整数部分的1剩下00101末尾补0补齐23位得到00101000000000000000000。第五步确定符号位。正数符号位0。把三部分拼起来0 10000010 00101000000000000000000按4位一组分段0100 0001 0001 0100 0000 0000 0000 0000转成十六进制就是0x41140000。这意味着在内存里9.25f的位模式是0x41140000。在小端机器上字节顺序反过来从低地址到高地址是00 00 14 41。用代码验证一下#include stdio.h #include string.h int main(void) { float f 9.25f; unsigned char buf[4]; memcpy(buf, f, 4); for (int i 0; i 4; i) { printf(%02x , buf[i]); } printf(\n); return 0; }输出在小端机器上是00 00 14 41。你可能会问为什么不是41 14 00 00因为小端存储低字节在低地址所以0x41这个最高字节被放到了最高地址。这个例子能帮你把“字节序”和“浮点位模式”两个概念同时串起来。4.3 为什么0.1加0.2在很多语言里不等于0.3几乎所有编程语言初学者都遇到过这个问题0.1 0.2打印出来不是0.3而是0.30000000000000004。原因在于十进制小数转二进制时大多数小数无法精确表示。0.1的二进制是无限循环小数0.000110011001100...0.2是0.001100110011...。IEEE 754的尾数有限float只有23位double只有52位存不下无限循环只能截断或舍入于是产生误差。float精度大约是6到7位有效数字double大约是15到16位。你看到0.1在内存里实际是接近0.1但略大一点点的一个值。打印时float a 0.1f; printf(%.10f\n, a);输出可能是0.1000000015或类似的近似值。这个误差在小数位数少的时候看不见一旦做大量累加、比较误差就会放大。4.4 浮点数比较的正确姿势浮点数之间不要直接用比较这是所有数值计算场合的共识。正确思路是判断差值是否在可接受误差范围内#include stdio.h #include math.h int main(void) { float a 0.1f; float b 0.2f; float c a b; if (fabsf(c - 0.3f) 1e-6f) { printf(近似相等\n); } return 0; }这里的1e-6f是误差阈值具体取多少取决于你的场景。计算物理量、坐标、迭代结果时阈值要结合量级来定不能一个阈值走天下。还有个更隐蔽的坑float和double混合比较。比如float f 0.1f;然后写if (f 0.1)这里的0.1是double精确表示而f是float的近似值二者不等。正确的写法是if (f 0.1f)让两边都用float的近似值去比。或者干脆统一用double避免不必要的精度损失。5. 排查数据存储问题的实用工具与速查表5.1 调试器里的内存窗口怎么用纸上推导再多不如调试器里亲眼看一次。下面以最常见的调试器为例说明怎么看内存。Visual Studio调试时找到“调试”菜单下的“窗口”选择“内存”输入变量地址就能看到对应内存的十六进制字节。GDB则用x命令比如x/4xb var这条命令的意思是以十六进制格式显示var地址开始的4个单字节。x/4xb里4表示4个单位x表示十六进制b表示单位是字节。如果改成x/4xh就是以2字节为单位显示x/4xw则是4字节为单位。实际排查时我习惯先看一眼小端布局再和理论值对照。比如定义int a 0x12345678;在GDB里执行x/4xb a看到78 56 34 12就知道机器是小端内存布局正常。注意GDB显示的是“从低地址到高地址”。看到78 56 34 12时别以为数据错了它只是把低位放前面而已。5.2 用memcpy验证存储布局除了调试器我经常用memcpy配合printf来验证存储布局。memcpy从源地址拷贝若干字节到目标缓冲区再把缓冲区按字节打印出来能直观看到数据的内存模样。#include stdio.h #include string.h int main(void) { int a 0x12345678; unsigned char buf[4]; memcpy(buf, a, 4); for (int i 0; i 4; i) { printf(%02x , buf[i]); } printf(\n); return 0; }小端机器上输出78 56 34 12。为什么不直接用指针强转来访问字节unsigned char *p (unsigned char *)a;这种做法在小端机器上也能得到同样结果而且简单。但如果代码的别名分析比较复杂直接解引用强转指针在某些极端场景下会踩到严格别名规则。用memcpy是标准认可的方式也更安全。同样的方法也适用于浮点数。把float 9.25f拷贝进缓冲区打印出来是00 00 14 41。这和第4.2节手算的位模式完全对上了整个过程会很有成就感也会加深对存储布局的理解。5.3 常见问题速查表把前面几章讲到的典型问题整理成一张速查表遇到类似症状时先来这里对号入座。症状根源解决方案char存0xFF打印出来是-1有符号char的符号扩展改用unsigned char或统一用uint8_t网络协议解析数值比预期大256倍字节序没有转换使用ntohs/ntohl做字节序转换无符号和有符号比较结果反直觉隐式转换为无符号让比较双方类型一致或显式转换0.1 0.2 ! 0.3二进制无法精确表示十进制小数用误差阈值比较不要用float和double比较不相等精度不同导致近似值不同统一类型或显式比较同一精度的值int赋值给char丢失高位截断只保留低8位明确范围检查需要时先转成更大类型这张表看起来简单每一行背后都是我或身边同事踩过的真实坑。项目中一旦出现诡异的数值问题先不要急着怀疑算法或硬件按这张表从头排查一遍往往很快就能锁定原因。5.4 几个值得做的小实验如果读完前面内容还觉得不踏实建议动手做这几个小实验每个实验都能让你对内存存储有更直观的认识。第一个实验打印一个int数组每个元素的地址和值观察相邻元素地址差值。你会看到差值正好是4这能帮助你理解数组在内存中是连续存放的。第二个实验定义一个int a 0x01020304;然后用unsigned char*逐字节打印同时尝试修改中间某个字节再打印a的值。你会看到修改中间字节后整个值的变化规律这比任何文字解释都直观。第三个实验把float的十六进制位模式解读回浮点数。先取一个float值用memcpy拿到4字节再手动把4字节拼成0x开头的整数最后用这个整数当float打印。你会在这一步真正理解“位模式”这个词的含义。结尾这个知识点值回票价的时刻我个人的体会是C语言里很多“玄学”问题最后都能归结到内存存储这一层。早年我调试一个图像算法花了两天找不到颜色通道错乱的原因最后用内存视图一看发现缓冲区里字节顺序和小端转换搞反了。从那以后我写协议解析、图像处理、音视频相关的代码时都会先明确字节序再动手写逻辑。最后再分享一个小技巧遇到奇怪的数值结果先别急着打断点单步调试而是在可疑变量上直接看十六进制字节对照位模式推理一遍十次里能省下八次瞎折腾的时间。数据在内存里的存储说到底是0和1排列组合的问题你把这张底牌摸清了上层那些花哨的语法和框架就都只是包装而已。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑