数据的存储
目录
3.2 存储结构(32 位 float/64 位 double)
练习 1:char/unsigned char 的取值范围陷阱
结论:符号位的存在和整型提升,导致相同二进制值解读出不同结果。
结论:% u 会将有符号整数的补码,按无符号数解读,导致结果远超预期。
结论:signed char 的溢出循环,导致数组中第一个 \0 出现在索引 255,strlen 结果为 255。
结论:无符号整数溢出后会循环,避免用 “<= 最大值” 作为循环条件。
结论:指针类型决定步长,强制类型转换会改变地址的解读方式,结合大小端会产生意想不到的结果。
结论:相同的二进制数据,按不同类型解读(int/float),结果完全不同,因为存储规则不同。
✨引言:
作为 C 语言学习者,你是否曾被这些 “诡异现象” 困扰?
- 同样是
a = -1,char类型打印是-1,unsigned char却打印255? - 明明写的是
int n = 0x11223344,调试时内存却显示44 33 22 11? - 浮点数
0.1 + 0.2结果不是0.3,而是0.30000000447?
这些问题的答案,都藏在内存存储的底层规则里。今天这篇博客,会用 “原理 + 实战” 的模式,把整数的原反补码、大小端字节序、浮点数 IEEE754 标准讲透,每个练习的讲解都很详细,让你不仅知其然,更知其所以然!
1. 🔢 整数存储:原码、反码、补码的 “加法逻辑”
整数在内存中存储的不是 “直接二进制”,而是补码—— 这是 CPU 硬件设计的必然选择:CPU 只有加法器,无法直接处理减法,补码能让 “减法” 转化为 “加法”,同时统一符号位和数值位的运算规则。
1.1 三大编码的核心逻辑
所有有符号整数的二进制都包含两部分:
- 符号位:最高位,
0表示正数,1表示负数; - 数值位:剩余位,存储数值的二进制信息。
三种编码的转换规则(以 32 位 int 为例):
| 编码类型 | 正数转换逻辑 | 负数转换逻辑 |
|---|---|---|
| 原码 | 符号位设为 0,数值位直接翻译十进制 | 符号位设为 1,数值位直接翻译十进制 |
| 反码 | 与原码完全相同 | 符号位不变,数值位按位取反(0→1,1→0) |
| 补码 | 与原码、反码完全相同 | 反码基础上 + 1(逢二进一) |
1.2 实战转换:以 int a = -5 为例
- 求原码:符号位 = 1(负),数值位 = 5 的二进制
00000101→原码:10000000 00000000 00000000 00000101; - 求反码:符号位保持 1,数值位取反 →反码:
11111111 11111111 11111111 11111010; - 求补码:反码 + 1,注意逢二进一 →补码:
11111111 11111111 11111111 11111011(内存中实际存储的最终值)。
1.3 补码的核心优势:减法变加法
为什么必须用补码?看 1 + (-1) = 0 的计算过程:
- 用原码计算:
00000001(1的原码) + 10000001(-1的原码) = 10000010→ 结果为-2(错误); - 用补码计算:
00000001(1的补码) + 11111111(-1的补码) = 100000000→ 舍弃溢出的最高位(32 位系统只保留 32 位),结果为0(正确)。
这就是补码的核心价值:让 CPU 无需额外硬件,就能统一处理正负数的加减运算。
2. 🔀 大小端字节序:字节的 “排列密码”
当数据占超过 1 字节(如 int、long)时,字节在内存中的 “排列顺序” 会影响数据读取 —— 这就是大小端字节序,是跨平台开发、数据传输的高频考点。
2.1 大小端的核心定义
把数据想象成 “一串数字钥匙”,内存地址是 “钥匙孔”,每个字节是 “一节钥匙”:
- 大端字节序:“高位字节”(钥匙的前半部分)插在 “低地址钥匙孔”,“低位字节”(钥匙的后半部分)插在 “高地址钥匙孔”;例:
int n = 0x11223344→ 内存排列:11 22 33 44(低地址→高地址); - 小端字节序:“低位字节”(钥匙的后半部分)插在 “低地址钥匙孔”,“高位字节”(钥匙的前半部分)插在 “高地址钥匙孔”;例:
int n = 0x11223344→ 内存排列:44 33 22 11(低地址→高地址); - 常用场景:x86 架构(PC、多数嵌入式开发板)为小端,C51 单片机、网络传输(TCP/IP)为大端。
2.2 实战:判断机器字节序
核心思路:利用 int(4 字节)和 char(1 字节)的类型转换,仅读取低地址的 1 字节数据,就能判断大小端。
#include <stdio.h>
int check_sys() {
int n = 1; // 32位补码:00000000 00000000 00000000 00000001
// 关键逻辑:char* 指针只能读取1字节,即低地址的那个字节
// 小端环境:低地址存01 → 返回1
// 大端环境:低地址存00 → 返回0
return *(char*)&n;
}
int main() {
int ret = check_sys();
if (ret == 1) {
printf("小端字节序\n");
} else {
printf("大端字节序\n");
}
return 0;
}
2.3 补充:char 类型的取值范围
char 占 1 字节(8 位),分为 signed char 和 unsigned char:
- signed char(默认):符号位 + 7 位数值位;最大值:
01111111(符号位 0,数值位全 1)→ 127;最小值:10000000(特殊规定,无对应原 / 反码)→ -128;取值范围:-128 ~ 127; - unsigned char:无符号位,8 位全为数值位;最大值:
11111111→ 255;最小值:00000000→ 0;取值范围:0 ~ 255。
3. 📐 浮点数存储:IEEE754 标准与精度丢失的真相
浮点数(float、double)的存储规则和整数完全不同,遵循 IEEE754 国际标准—— 这也是浮点数 “精度丢失” 的根源。
3.1 IEEE754 标准的核心公式
任意二进制浮点数 V 可表示为:
V = (-1)^S × M × 2^E
(-1)^S:符号位,S=0为正,S=1为负;M:有效数字,范围1 ≤ M < 2(格式为1.xxxxxx,xxxxxx是小数部分);2^E:指数位,控制数值的大小。
3.2 存储结构(32 位 float/64 位 double)
| 类型 | 符号位 S(1 位) | 指数位 E(8 位 / 11 位) | 有效数字 M(23 位 / 52 位) |
|---|---|---|---|
| float | 第 31 位 | 第 30~23 位 | 第 22~0 位 |
| double | 第 63 位 | 第 62~52 位 | 第 51~0 位 |
3.3 关键优化:M 和 E 的存储技巧
- 有效数字 M 的优化:因为
1 ≤ M < 2,M 的整数部分永远是1,存储时可省略,仅存小数部分(xxxxxx)—— 这样能节省 1 位空间,提升精度。例:M=1.011 → 存储时仅存011,读取时自动补回1.; - 指数位 E 的优化:E 是无符号整数,但实际指数可正可负(如
0.5=1.0×2^(-1))。IEEE754 规定: E 的存储值 = 真实值 + 中间值(float 中间值 = 127,double 中间值 = 1023) 例:E 真实值 = 2(float)→ 存储值 = 2+127=129(二进制10000001)。
3.4 精度丢失的本质
为什么 0.1 无法精准存储?
- 十进制
0.1转二进制:0.0001100110011...(无限循环); float的有效数字 M 只有 23 位,无法存下完整的循环部分,只能截断;- 截断后的 M 值对应的十进制,无限接近
0.1但不等于0.1→ 精度丢失。
✅ 避坑指南:浮点数比较不能用 ==,需判断两者差值是否小于允许的精度(如 fabs(f1-f2) < 1e-6)。
4. 🚨 6 个经典实战练习
练习 1:char/unsigned char 的取值范围陷阱
#include <stdio.h>
int main() {
char a = -1;
signed char b = -1;
unsigned char c = -1;
printf("a=%d, b=%d, c=%d\n", a, b, c); // 输出:-1 -1 255
return 0;
}
超详拆解:
-
变量 a(char = signed char):
-1的补码:11111111(8 位);- 用
%d打印时,发生整型提升(char→int):signed char 提升时,高位补符号位(1)→ 32 位补码:11111111 11111111 11111111 11111111; - 该补码对应的原码:
10000000 00000000 00000000 00000001→ 十进制-1;
-
变量 b(signed char):
- 逻辑和 a 完全相同,最终打印
-1;
- 逻辑和 a 完全相同,最终打印
-
变量 c(unsigned char):
-1的补码:11111111(8 位);- unsigned char 无符号位,
11111111直接解读为数值位; - 整型提升时,高位补 0 → 32 位值:
00000000 00000000 00000000 11111111→ 十进制255;
结论:符号位的存在和整型提升,导致相同二进制值解读出不同结果。
练习 2:char a=-128 的无符号打印
#include <stdio.h>
int main() {
char a = -128;
printf("%u\n", a); // 输出:4294967168
return 0;
}
超详拆解:
-
求 - 128 的补码(signed char):
- 原码:
10000000(符号位 1,数值位 0000000,特殊规定); - 反码:
11111111(符号位 1,数值位取反); - 补码:
10000000(反码 + 1 溢出,最终为 8 位10000000);
- 原码:
-
整型提升:
- signed char 提升为 int,高位补符号位(1)→ 32 位补码:
11111111 11111111 11111111 10000000;
- signed char 提升为 int,高位补符号位(1)→ 32 位补码:
-
% u 打印(无符号整数):
- 无符号整数不识别符号位,直接将 32 位二进制解读为数值;
- 32 位二进制
11111111 11111111 11111111 10000000是4294967295
结论:% u 会将有符号整数的补码,按无符号数解读,导致结果远超预期。
练习 3:char 数组的 strlen 陷阱
#include <stdio.h>
#include <string.h>
int main() {
char a[1000];
int i;
for (i = 0; i < 1000; i++) {
a[i] = -1 - i;
}
printf("%zd\n", strlen(a)); // 输出:255
return 0;
}
超详拆解:
-
循环赋值逻辑:
- i=0 → a[0] = -1;i=1 → a[1] = -2;...;i=127 → a[127] = -128;
- i=128 → a [128] = -129 → signed char 溢出:
-128 -1 = 127(因为 signed char 取值范围 - 128~127,溢出后循环); - i=129 → a[129] = 126;...;i=255 → a[255] = 0;
- i>255 → a [i] 继续循环赋值,但 strlen 只关心第一个
\0(ASCII=0);
-
strlen 的核心逻辑:
- strlen 统计
\0之前的字符数,遇到\0停止; - 从 a [0] 到 a [254],共 255 个非 0 字符,a [255] = 0 → 统计停止;
- strlen 统计
结论:signed char 的溢出循环,导致数组中第一个 \0 出现在索引 255,strlen 结果为 255。
练习 4:无符号整数的循环陷阱
#include <stdio.h>
unsigned char i = 0;
int main() {
for (i = 0; i <= 255; i++) {
printf("hello world\n"); // 死循环
}
return 0;
}
超详拆解:
- unsigned char 的取值范围:
0 ~ 255; - 循环条件分析:
- i=255 时,满足
i <= 255,执行循环体; - 执行
i++→ 255+1=256,unsigned char 溢出,循环回0; - 此时 i=0,再次满足
i <= 255,循环永不停止;
- i=255 时,满足
结论:无符号整数溢出后会循环,避免用 “<= 最大值” 作为循环条件。
练习 5:大小端 + 指针类型转换
#include <stdio.h>
int main() {
int a[4] = {1,2,3,4};
int* ptr1 = (int*)(&a + 1);
int* ptr2 = (int*)((int)a + 1);
printf("%#x,%#x\n", ptr1[-1], *ptr2); // 输出:0x4, 0x2000000
return 0;
}
超详拆解:
-
数组 a 的内存布局(小端环境):
- a [0] = 1 → 补码
00000001 00000000 00000000 00000000→ 内存:01 00 00 00; - a [1] = 2 → 补码
00000010 00000000 00000000 00000000→ 内存:02 00 00 00; - a [2] = 3 → 内存:
03 00 00 00; - a [3] = 4 → 内存:
04 00 00 00; - 完整内存:
01 00 00 00 02 00 00 00 03 00 00 00 04 00 00 00(低地址→高地址);
- a [0] = 1 → 补码
-
ptr1 的逻辑:
&a是数组指针,类型为int(*)[4],&a + 1跳过整个数组(4×4=16 字节);- ptr1 = (int*)(&a + 1) → 指向数组后面的地址;
- ptr1 [-1] = *(ptr1 - 1) → 指针后退 4 字节,指向 a [3](值为 4)→ 输出
0x4;
-
ptr2 的逻辑:
- a 是数组首地址(低地址),(int) a 把地址转为整数,+1 后还是整数;
- (int*)((int) a + 1) → 指向数组首地址 + 1 的位置(即
00这个字节); - *ptr2 读取 4 字节:
00 00 00 02(低地址→高地址); - 小端环境下,4 字节
00 00 00 02解读为0x02000000→ 输出0x2000000;
结论:指针类型决定步长,强制类型转换会改变地址的解读方式,结合大小端会产生意想不到的结果。
练习 6:整数与浮点数的内存视角转换
#include <stdio.h>
int main() {
int n = 9;
float* pFloat = (float*)&n;
printf("n=%d, *pFloat=%f\n", n, *pFloat); // 输出:9, 0.000000
*pFloat = 9.0;
printf("n=%d, *pFloat=%f\n", n, *pFloat); // 输出:1091567616, 9.000000
return 0;
}
超详拆解:
-
第一次打印:int n=9 按 float 解读:
- n=9 的补码:
00000000 00000000 00000000 00001001; - pFloat 是 float*,按 IEEE754 标准解读该二进制:
- S=0(符号位);
- E=00000000(8 位指数位)→ 真实 E=0-127+1= -126(E 全 0 时的特殊规则);
- M=00000000000000000001001(23 位有效数字)→ 真实 M=0.00000000000000000001001;
- V=(-1)^0 × 0.00000000000000000001001 × 2^(-126) → 数值极小,打印为
0.000000;
- n=9 的补码:
-
第二次打印:float 9.0 按 int 解读:
- float 9.0 的 IEEE754 存储:
- 9.0 → 二进制
1001.0→ 标准化:1.001 × 2^3; - S=0,M=001(省略整数 1),E=3+127=130(二进制
10000010); - 完整二进制:
01000001000100000000000000000000;
- 9.0 → 二进制
- 按 int 解读该二进制:直接转十进制 →
1091567616;
- float 9.0 的 IEEE754 存储:
结论:相同的二进制数据,按不同类型解读(int/float),结果完全不同,因为存储规则不同。
5. 📝 核心知识点总结(避坑指南 + 快速回顾)
- 整数存储:内存存补码,正整数三码合一,负整数补码 = 反码 + 1;
- 大小端:小端是主流,大端用于 C51 / 网络传输,多字节数据需注意顺序;
- 浮点数:遵循 IEEE754 标准,M 省略整数 1,E 加偏移,精度丢失不可避免;
- 字符类型:signed char(-128~127),unsigned char(0~255),溢出会循环;
- 无符号陷阱:无符号整数永远≥0,溢出循环,循环条件需谨慎;
- 指针转换:指针类型决定步长,强制转换可能改变地址解读方式,结合大小端需格外注意;
- 浮点数比较:不用
==,用fabs(f1-f2) < 精度(如 1e-6)。
🎯 最后总结
内存存储是 C 语言的 “底层地基”,这些规则看似抽象,但只要结合 “逐行拆解 + 实战推导”,就能彻底掌握。理解这些知识点,不仅能解决很多 “诡异 bug”,还能在面试中脱颖而出 —— 毕竟,面试官最看重的就是底层逻辑能力。如果这篇博客帮你打通了内存存储的 “任督二脉”,欢迎点赞收藏🌟~
786

被折叠的 条评论
为什么被折叠?



