浮点数与自定义类型:从 IEEE 754 到位段联合体
1. 一句话总结
C 语言中,浮点数在内存里的存储方式与整数完全不同(IEEE 754 标准),而结构体、位段、联合体这些自定义类型则通过不同的内存组织策略,帮我们在性能、空间和灵活性之间做取舍。
2. 知识地图
浮点数与自定义类型
│
├── 浮点数存储(IEEE 754)
│ ├── 符号位 S(1 bit)
│ ├── 指数位 E(8 bits / float)
│ ├── 尾数位 M(23 bits / float)
│ ├── 特殊值:E 全 0 / 全 1 的含义
│ └── 整数视角 vs 浮点视角的差异
│
├── 结构体 struct
│ ├── 声明与匿名结构体
│ ├── 自引用(链表节点)
│ ├── 内存对齐规则
│ │ ├── 对齐数 = min(自身大小, 编译器默认对齐数)
│ │ ├── 偏移量必须是对齐数的整数倍
│ │ ├── 总大小是最大对齐数的整数倍
│ │ └── #pragma pack(n) 修改默认对齐数
│ └── 传值 vs 传址
│
├── 位段(bit-field)
│ ├── 按比特位分配空间,节省内存
│ ├── 不能对位段成员取地址(&)
│ └── 跨平台风险
│
└── 联合体 union
├── 所有成员共用同一块内存
├── 大小 = 最大成员的大小(对齐后)
├── 同一时间只能使用一个成员
└── 典型应用:配合 struct 实现多类型商品
3. 核心概念深度解析
3.1 IEEE 754 浮点数存储格式
这是整篇博客最重要的概念。先看一个颠覆直觉的事实:
int n = 9;
float* pFloat = (float*)&n;
printf("%d\n", n); // 输出 9
printf("%f\n", *pFloat); // 输出 0.000000 —— 为什么??
9 这个整数,被当成浮点数来解读,就变成了一个极小的、接近 0 的数。
原因在于浮点数在内存中的存储结构。以 float(32 位单精度)为例,IEEE 754 标准将其划分为三个部分:
31 30 23 22 0
┌───┬──────────┬─────────────────────────┐
│ S │ E │ M │
│ 1 │ 8 bits │ 23 bits │
└───┴──────────┴─────────────────────────┘
- S(Sign):符号位,1 位。0 表示正数,1 表示负数。
- E(Exponent):指数位,8 位。采用移码表示,实际指数 = E 的二进制值 - 127。
- M(Mantissa):尾数位,23 位。存储的是小数点后的二进制小数,隐含了一个整数部分的 1(特殊情况除外)。
整数 9 被当成浮点数解读的过程:
整数 9 在内存中的二进制补码是:
00000000 00000000 00000000 00001001
按 IEEE 754 重新划分:
0 00000000 00000000000000000001001
S E M
- S = 0(正数)
- E = 0(全 0),触发"非规格化数"规则:此时隐含的整数部分不再是 1,而是 0,且实际指数固定为 -126
- M = 0…01001,也就是 2^-20 + 2^-23
所以这个浮点数 = (-1)^0 × 0.00000000000000000001001 × 2^(-126),这是一个极其微小的数字,printf("%f") 默认只显示 6 位小数,所以看起来是 0.000000。
反过来,浮点数 9.0 存入内存的过程:
9.0 的二进制 = 1001.0
科学计数法 = 1.001 × 2^3
S = 0
M = 001 0000 0000 0000 0000 0000 (小数点后的部分,前面省略了 1.)
E = 3 + 127 = 130 = 10000010
最终拼起来:
0 10000010 00100000000000000000000
S E M
这串二进制当作整数来读,就是 01000001 00010000 00000000 00000000,也就是十进制的 1091567616。所以代码中 printf("%d", n) 输出这个值而不是 9。
我的理解:浮点数和整数在内存中的同一串比特,换一个"视角"解读,结果天差地别。这就像同一段摩斯电码,用英文和中文去翻译,得到的是完全不同的内容。C 语言中指针类型的强制转换不会改变内存中的比特,只会改变"解读规则"。
3.2 结构体内存对齐
结构体不是简单地"把成员一个接一个塞进内存",而是遵循一套对齐规则。
对齐规则三步走:
- 第一个成员放在偏移量为 0 的位置。
- 后续每个成员,放置的偏移量必须是该成员对齐数的整数倍。对齐数 =
min(成员自身大小, 编译器默认对齐数)。VS 默认对齐数是 8,GCC 也是 8。 - 整个结构体的大小必须是所有成员中最大对齐数的整数倍。
用一个例子详细拆解:
struct Node {
double p; // 自身大小 8,对齐数 8
char c; // 自身大小 1,对齐数 1
int i; // 自身大小 4,对齐数 4
};
ASCII 内存布局图:
偏移量: 0 8 9 12 16
┌────────┬───┬───────┬────────┐
│ p │ c │ 填充 │ i │
│ 8 字节 │1B │ 3B │ 4 字节 │
└────────┴───┴───────┴────────┘
详细拆解:
- p:放在偏移量 0,占 8 字节(偏移 0~7)。
- c:自身大小 1,对齐数 1。偏移量 8 是 1 的整数倍,直接放在偏移 8,占 1 字节。
- i:自身大小 4,对齐数 4。当前偏移 9,不是 4 的整数倍。向后找最近的 4 的倍数 —— 偏移 12。所以 i 放在偏移 12,占 4 字节(偏移 12~15)。中间 3 字节(9~11)被浪费。
- 总大小:目前到了偏移 15,即 16 字节。最大对齐数是 8(来自 double),16 是 8 的整数倍,所以最终大小就是 16。
为什么要有内存对齐?
这是用空间换时间的设计。CPU 读取内存时一次读取一个"字"(比如 4 字节或 8 字节),如果数据跨越了对齐边界,CPU 需要两次读取再拼接,效率会降低。
踩坑感悟:设计结构体时,把占用空间小的成员尽量放在一起(比如把 char 和 short 放一起),可以减少填充字节,节省内存。像上面这个例子,如果把
char c放在int i后面,结构体大小可能就变成 24 了。
3.3 位段(Bit-field)的内存布局
位段可以按比特位来指定成员占用空间,极度节省内存,但代价是操作复杂且跨平台不安全。
struct S {
char a : 3; // a 占 3 个比特位
char b : 4; // b 占 4 个比特位
char c : 5; // c 占 5 个比特位
char d : 4; // d 占 4 个比特位
};
在 VS 环境下(小端字节序,从低位向高位分配),内存布局如下:
第一次申请 1 字节(8 bits):
┌──────────────────────────────┐
│ 0 1 1 0 0 0 1 0 │
│ 填充 │ b │ a │
│ │ 4bit │ 3bit │
└──────────────────────────────┘
a=10(1010) 截断为 010(3bit)
b=12(1100) 完整放入
第二次申请 1 字节(8 bits):
┌──────────────────────────────┐
│ 0 0 0 0 0 0 1 1 │
│ 填充 │ c │
│ 3bit │ 5bit │
└──────────────────────────────┘
c=3(0011) 完整放入,剩余 3bit 不够放 d
第三次申请 1 字节(8 bits):
┌──────────────────────────────┐
│ 0 0 0 0 0 1 0 0 │
│ 填充 │ d │
│ 4bit │ 4bit │
└──────────────────────────────┘
总共 3 字节,sizeof = 3
⚠️ 踩坑模块:位段的跨平台风险
- int 位段被视为有符号还是无符号,不同编译器行为不同。
- 位段中最大位的数目不确定。 16 位机器上
int : 30可能非法。 - 位段成员在内存中是从左到右分配还是从右到左分配,标准未定义。
- 当一个位段剩余空间不够存放下一个成员时,是舍弃剩余位还是跨字节存放,标准未定义。
因此,位段不能对成员取地址(&),因为位段成员可能不在字节边界上,而地址是以字节为单位的。
// 错误示范
scanf("%d", &(sa._b)); // 编译错误!位段成员没有地址
// 正确做法
int b = 0;
scanf("%d", &b);
sa._b = b;
4. 代码实战
4.1 浮点数双视角解读
#include <stdio.h>
int main()
{
int n = 9;
float* pFloat = (float*)&n;
// 以整数视角读取整数 9
printf("n的值为:%d\n", n); // 输出:9
// 以浮点数视角读取同一块内存
printf("*pFloat的值为:%f\n", *pFloat); // 输出:0.000000
// 以浮点数格式写入 9.0
*pFloat = 9.0;
// 以整数视角读取浮点数 9.0 的内存
printf("num的值为:%d\n", n); // 输出:1091567616
// 以浮点数视角验证
printf("*pFloat的值为:%f\n", *pFloat); // 输出:9.000000
return 0;
}
4.2 结构体内存对齐验证
#include <stdio.h>
struct Node {
double p; // 8 字节
char c; // 1 字节
int i; // 4 字节
};
int main()
{
int sz = sizeof(struct Node);
printf("%d\n", sz); // 输出:16(不是 8+1+4=13)
return 0;
}
修改默认对齐数:
#include <stdio.h>
#pragma pack(1) // 将对齐数改为 1 字节
struct S {
char c1; // 1 字节,对齐数 1
int i; // 4 字节,对齐数 1
char c2; // 1 字节,对齐数 1
};
int main()
{
printf("%zd\n", sizeof(struct S)); // 输出:6(1+4+1,无填充)
return 0;
}
4.3 结构体传值 vs 传址
#include <stdio.h>
struct S {
int arr[9];
double gpa;
int age;
};
// 传值:拷贝整个结构体,占栈空间,修改不影响原数据
void print1(struct S s)
{
for (int i = 0; i < sizeof(s.arr) / sizeof(s.arr[0]); i++)
{
printf("%d ", s.arr[i] = i);
}
printf("%lf\n", s.gpa = 9.0);
printf("%d\n", s.age);
}
// 传址:只传一个指针(8 字节),修改影响原数据
void print2(struct S *ps)
{
for (int i = 0; i < sizeof(ps->arr) / sizeof(ps->arr[0]); i++)
{
printf("%d ", ps->arr[i] = i);
}
printf("%lf\n", ps->gpa = 9.0);
printf("%d\n", ps->age);
}
int main()
{
struct S s = {{1, 2, 3, 4, 5}, 3.14, 18};
print1(s); // 传值,函数内修改不影响 main 中的 s
print2(&s); // 传址,函数内修改会影响 main 中的 s
return 0;
}
4.4 位段使用
#include <stdio.h>
struct S {
char a : 3;
char b : 4;
char c : 5;
char d : 4;
};
int main()
{
struct S s;
s.a = 10; // 二进制 1010,截断为 010(3 bits)
s.b = 12; // 二进制 1100,刚好 4 bits
s.c = 3; // 二进制 0011,5 bits 够放
s.d = 4; // 二进制 0100,4 bits 够放
printf("%zd\n", sizeof(struct S)); // 输出:3(字节)
// 位段不能取地址,必须通过临时变量中转
int b = 0;
scanf("%d", &b);
s.b = b;
return 0;
}
4.5 联合体 + 结构体实现多类型商品
#include <stdio.h>
struct gift_list {
int stock_number; // 库存量
double price; // 定价
int item_type; // 商品类型
// 联合体:不同商品类型共用同一块内存
union {
struct {
char title[20]; // 书名
char author[20]; // 作者
int num_pages; // 页数
} book;
struct {
char design[30]; // 设计图案
} mug;
struct {
char design[30]; // 设计图案
int colors; // 颜色数
int sizes; // 尺寸数
} shirt;
} item;
};
int main()
{
struct gift_list gl;
// 根据 item_type 决定使用哪个成员
// 比如 item_type == 1 用 book,== 2 用 mug,== 3 用 shirt
return 0;
}
5. 注释提炼
5.1 知识注解类:小端字节序
代码中有一段关于小端字节序的演示:
int a[4] = {1, 2, 3, 4};
int *ptr1 = (int *)(&a + 1);
int *ptr2 = (int *)((int)a + 1);
科普扩展:所谓小端字节序(Little-Endian),是指数据的低位字节存储在内存的低地址。比如整数 1 在内存中表示为 01 00 00 00(而不是 00 00 00 01)。x86 和 ARM(大部分模式)都是小端。
&a + 1:&a是数组指针(int (*)[4]),+1等于跳过整个数组(4 个 int = 16 字节),指向数组末尾之后。ptr1[-1]等于*(ptr1 - 1),即数组最后一个元素 4。(int)a + 1:a是数组首元素地址,强制转为 int 后变成了一个数值,+1就是数值加 1,跳过 1 个字节,指向了00 00 00 01 00 ...的第二个字节。再解引用就会得到一个跨越字节边界的值。
5.2 个人理解类:匿名结构体
我的理解:匿名结构体就像没有名字的一次性容器。你无法用它的"类型名"去声明新变量,只能在定义时顺手创建实例。这在你只需要一次性使用、不想污染全局类型命名空间时有用。但如果需要多次使用,要么给名字,要么用 typedef 重命名。
// 匿名结构体,只能在这里创建 x
struct {
double d;
int i;
char c;
} x;
// 用 typedef 重命名,解决"只能用一次"的问题
typedef struct {
double s;
int i;
char c;
} X;
X x1, x2; // 现在可以多次使用了
5.3 对比总结类:传值 vs 传址
| 对比维度 | 传值 | 传址 |
|---|---|---|
| 传递内容 | 整个结构体的拷贝 | 结构体的地址(8 字节) |
| 内存开销 | 大(结构体越大开销越大) | 小(固定 8 字节) |
| 函数内修改 | 不影响原数据 | 影响原数据 |
| 使用场景 | 结构体很小且不需要修改 | 结构体较大或需要修改 |
| 参数写法 | void func(struct S s) | void func(struct S *ps) |
| 成员访问 | s.member | ps->member |
5.4 对比总结类:结构体 vs 联合体
| 对比维度 | 结构体 struct | 联合体 union |
|---|---|---|
| 内存分配 | 各成员独立内存,总大小 >= 各成员之和 | 所有成员共用一块内存 |
| 大小计算 | 遵循对齐规则,各成员累加 | 等于最大成员的大小(对齐后) |
| 同时使用 | 所有成员可以同时使用 | 同一时间只能使用一个成员 |
| 一个成员改变 | 不影响其他成员 | 会影响其他成员的值 |
| 典型场景 | 描述一个实体的多个属性 | 多选一的数据(如商品分类) |
5.5 踩坑感悟类
⚠️ 踩坑:无符号数循环陷阱
unsigned int i;
for (i = 9; i >= 0; i--) // 死循环!
{
printf("%u\n", i);
}
unsigned int 永远 >= 0。当 i 减到 0 后再减 1,会回绕到 UINT_MAX(4294967295),条件 i >= 0 永远为真,形成死循环。
正确写法:用 int 控制循环变量,或者在循环体内做边界判断。
⚠️ 踩坑:位段不能取地址
位段成员可能不在字节边界上,而 C 语言中地址的最小单位是字节。因此 &(位段成员) 是非法的。解决办法是先读入一个临时变量,再赋值给位段成员。
⚠️ 踩坑:结构体大小不等于各成员大小之和
由于内存对齐的存在,sizeof(struct) 往往大于各成员大小之和。设计结构体时,把小成员放在一起可以减少填充字节。
6. 代码修正
修正 1:结构体自引用中的类型重定义
修改前:
typedef struct Node {
int data;
struct Node *next;
} Node;
struct Node { // 错误:Node 已经在上面定义过了
int data;
struct Node *next;
};
typedef struct Node Node; // 多余
修改后:
// 方案一:用 typedef 一次性搞定
typedef struct Node {
int data;
struct Node *next;
} Node;
// 方案二:先定义结构体,再 typedef
struct Node {
int data;
struct Node *next;
};
typedef struct Node Node;
原因:原代码中 typedef struct Node { ... } Node; 已经同时定义了结构体类型 struct Node 和别名 Node。后面再次定义 struct Node 是重复定义,编译器会报错。两个方案选一个即可。
修正 2:联合体示例中的拼写错误
修改前:
int mian() // 拼写错误
修改后:
int main()
7. 小结
浮点数与整数在内存中的"同一串比特、不同解读方式"是理解 C 语言类型系统的核心认知;结构体、位段、联合体则分别代表了"用空间换便利"“用复杂度换空间”"用排他性换空间"三种自定义类型的设计哲学——掌握它们的内存布局规则,你就掌握了 C 语言中"数据如何组织"这个关键问题的答案。
398



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



