上一篇我们学会了用结构体把不同数据打包在一起。但有一个谜题我没急着揭开:如果你用 sizeof 量一下结构体的大小,结果往往比所有成员大小之和要大。比如:
struct Test {
char a;
int b;
char c;
};
printf("%zu\n", sizeof(struct Test)); // 通常输出 12,而不是 6
为什么 1+4+1=6,实际却是 12?这不是 bug,而是编译器为了效率而采用的内存对齐。今天我们就来彻底搞懂结构体在内存中的真实布局,并认识一个特殊的结构体成员:柔性数组。
一、为什么需要内存对齐?
CPU 访问内存时,并不是逐字节读取的,而是按“块”读取的。在 32 位或 64 位系统中,CPU 通常一次读取 4 字节或 8 字节。
如果数据跨越了这些“字边界”,CPU 可能需要两次内存访问才能读完一个数据,然后还要拼接,这比一次访问慢得多。有些硬件平台甚至根本不允许非对齐访问,程序会直接崩溃。
因此,编译器会悄悄在结构体的成员之间以及末尾插入一些空白字节(padding),让每个成员都落在自己的“自然边界”上。这就是内存对齐。
简单说:对齐是拿空间换时间。作为系统级语言的使用者,你需要知道它在干什么,才能在一些对内存敏感的场景里做出正确判断。
二、对齐规则
各平台的对齐规则略有差异,但都遵循一个共同原则:
一个类型为 T 的成员,其起始地址必须是 sizeof(T) 的整数倍。结构体整体大小必须是其最宽成员大小的整数倍。
我们用 x86-64(GCC/Linux)为例,常见类型的对齐要求和大小:
| 类型 | 大小 | 对齐要求 |
|---|---|---|
char | 1 | 1 |
short | 2 | 2 |
int | 4 | 4 |
float | 4 | 4 |
double | 8 | 8 |
| 指针 | 8 | 8 |
下面,我们手工模拟编译器计算 struct Test 的大小。
三、手工计算结构体大小
回到开头的例子:
struct Test {
char a; // 1 字节
int b; // 4 字节
char c; // 1 字节
};
内存布局过程如下:
1. 分配 char a(偏移 0)
偏移: 0 1 2 3 4 5 6 7 8 9 10 11
[a] [?] [?] [?] [?] [?] [?] [?] [?] [?] [?] [?]
a 占 1 字节,放在偏移 0。
2. 分配 int b(对齐要求 4)
下一个可用偏移是 1。但 int 的起始地址必须是 4 的倍数。所以编译器插入 3 个填充字节,b 从偏移 4 开始。
偏移: 0 1 2 3 4 5 6 7 8 9 10 11
[a] [pad][pad][pad][ b (4 bytes) ] [?] [?] [?] [?]
3. 分配 char c(对齐要求 1)
b 结束于偏移 7,下一个可用偏移是 8。char 对齐要求 1,8 是 1 的倍数,可以放入。
偏移: 0 1 2 3 4 5 6 7 8 9 10 11
[a] [pad][pad][pad][ b (4 bytes) ] [c] [?] [?] [?]
4. 结构体整体对齐
当前总大小是 9 字节。但结构体整体大小必须是其最宽成员大小的整数倍。b 是 int,最宽,对齐要求 4。所以编译器在末尾补 3 个填充字节,总大小变为 12。
偏移: 0 1 2 3 4 5 6 7 8 9 10 11
[a] [pad][pad][pad][ b (4 bytes) ] [c] [pad][pad][pad]
sizeof(struct Test) = 12。成员顺序影响了最终大小。如果我们把 b 放中间,两侧的 char 都会造成填充。稍后我们会看到如何优化。
四、成员顺序对大小的影响
看两个结构体:
struct S1 {
char a;
int b;
char c;
};
struct S2 {
char a;
char c;
int b;
};
用刚才的方法计算:
struct S1:如上,12 字节。struct S2:a偏移 0(1 字节)c偏移 1(对齐 1,直接跟上)b偏移 4(对齐 4,从 4 开始)- 总大小:4 + 4 = 8 字节。整体对齐 4,8 是 4 的倍数,不补。
- 8 字节。
同样的成员,调换顺序就省了 4 字节。在大规模数组中,这一差异可能很可观。经验法则:把对齐要求大的成员放在前面,小的放在后面,或者同类聚拢,可以减少填充。
五、使用 #pragma pack 改变对齐
有时,你不想让编译器为了性能加填充——比如解析网络协议包、读取固定格式文件、与硬件寄存器结构匹配时,你需要严格的字节级控制。
可以使用 #pragma pack(n) 指令,强制设置最大对齐值为 n 字节:
#pragma pack(1) // 设置对齐为 1 字节(即无填充)
struct Packed {
char a;
int b;
char c;
};
#pragma pack() // 恢复默认对齐
printf("%zu\n", sizeof(struct Packed)); // 输出 6
#pragma pack(1)禁止所有填充,成员一个接一个紧密排列。#pragma pack()恢复为默认对齐。
当然,非对齐访问可能导致性能下降,或者在少数平台上引发硬件异常。仅在确实需要紧致内存布局时才使用,并测试目标平台的兼容性。
除此之外,GCC 也支持
__attribute__((__packed__)),效果类似:struct __attribute__((packed)) Packed { ... };。
六、结构体中的“假”数组:柔性数组
在 C99 标准中引入了一种特殊结构体成员:柔性数组(Flexible Array Member)。它允许结构体的最后一个成员是一个长度未知的数组。
struct DynamicBuffer {
int length;
char data[]; // 柔性数组成员,不占结构体本身的空间
};
注意几个要点:
- 柔性数组必须是结构体的最后一个成员。
- 前面必须至少还有一个其他成员。
- 声明时不写大小(
[]),也不占用sizeof的结果。 - 实际使用时,通过
malloc一次性分配“结构体 + 额外数组空间”。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct DynamicBuffer {
int length;
char data[]; // 柔性数组
};
int main(void) {
int data_len = 100;
// 分配结构体本身 + data 所需空间
struct DynamicBuffer *buf =
(struct DynamicBuffer*)malloc(sizeof(struct DynamicBuffer) + data_len);
if (buf == NULL) return 1;
buf->length = data_len;
strcpy(buf->data, "Hello, flexible array!");
printf("length=%d, data=%s\n", buf->length, buf->data);
free(buf);
return 0;
}
sizeof(struct DynamicBuffer) 通常等于 sizeof(int)(加上可能的填充),数组 data 的空间完全在 malloc 时额外申请。释放时只需一次 free(buf),因为整个空间是一次分配的。
为什么不用指针? 在柔性数组出现之前,常见的做法是:
struct OldBuffer {
int length;
char *data; // 指向另一块内存
};
但指针方式有两个缺点:
- 需要两次
malloc(结构体一次,数据区一次),两次free。 - 数据区和结构体可能位于内存的不同位置,缓存不友好。
- 多次调用增加失败风险和内存碎片。
柔性数组一次性分配连续内存,更高效、更简洁,是首选方案。
七、计算柔性数组结构体的大小
柔性数组成员不会贡献 sizeof 的值:
struct Flex {
int a;
double b;
char c[];
};
printf("%zu\n", sizeof(struct Flex));
// 输出 16(int 4 + 对齐 4 + double 8,c 不计入)
当你 malloc(sizeof(struct Flex) + 50),得到的是这 16 字节“头部” + 紧接其后的 50 字节 c 的空间。
八、常见错误与陷阱
1. 不关心成员顺序导致空间浪费
struct Waste {
char a;
double b;
char c;
int d;
}; // 通常 24 字节或更多
调整成员顺序往往能减小体积。如果你在嵌入式或大量数据存储场景,这会很关键。
2. 对柔性数组的结构体用 sizeof 认为包含数组
struct Flex { int len; char data[]; };
struct Flex f;
printf("%zu\n", sizeof(f)); // 只有 int 的大小
误以为 sizeof 包含 data 会导致分配不足或越界。
3. 对非柔性数组的结构体数组尾部越界
struct Fixed { int len; char data[10]; };
struct Fixed f;
f.data[10] = 'x'; // 越界
有固定大小数组的结构体,其数组大小已固定在 sizeof 内。而柔性数组则需要手动管理空间。
4. 错误地在柔性数组之前没有至少一个成员
struct Bad {
char data[]; // 错误!柔性数组前至少需要一个成员
};
编译会报错或警告。
5. 在栈上声明含柔性数组的结构体
struct Flex f; // f.data 没有有效空间
含柔性数组的结构体必须通过动态内存分配来使用,否则 data 其实没有任何可用的内存。柔性数组的真正空间来自 malloc 额外申请的部分。
九、小结
今天你看到了结构体底下的冰山:内存对齐是编译器的优化手段,会影响结构体大小。你可以调整成员顺序来节省空间,也可以用 #pragma pack 强制紧致排列(但要付出性能代价)。
柔性数组则是一种“结构体头部 + 可变长数据”的优雅方案,比分开使用结构体和指针更高效、更易管理。这两者都是你在系统编程、协议解析、数据库实现中会反复遇到的工具。
现在,结构体你已经相当熟悉。但有时候,多种数据类型需要共享同一块内存,比如一个值有时是整数,有时是浮点数,但不同时存在。这时候就需要共用体(union)。下一篇,我们就来认识这个结构体的“孪生兄弟”,以及让常量集合更优雅的 枚举(enum)。
课后小练习
- 编写一个结构体
struct A,包含char、short、int、double各一个。计算sizeof(struct A),然后尝试重新排列成员顺序,看看是否能得到不同的大小。用实际的代码验证你的推算。 - 使用
#pragma pack(1)对上一题的结构体进行紧致排列,观察sizeof的变化。并思考:什么时候你可能会需要这样做?什么时候不应该做? - 实现一个使用柔性数组的
String结构体(包含长度和字符数据),编写创建函数String* string_create(const char *init),释放函数void string_free(String *s),和拼接函数(在原基础上扩容)。 - (小挑战)设计一个简单的网络数据包结构体:固定头部(类型、长度) + 可变长载荷(柔性数组)。编写构造数据包、打印数据包内容的函数,模拟“组包”和“解析”的过程。
我们下期见!
779

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



