写过这样一段代码的人大概都愣过一下:
struct { char a; double b; int c; char d; } s;
printf("%zu", sizeof(s)); // 打印 24,而 1 + 8 + 4 + 1 = 14 多出来的 10 个字节不存任何数据,也不报错。它们是填充(padding), 由对齐规则强制插入。搞清这三条规则,sizeof 就不再是玄学。
三条规则
- 成员偏移必须是该成员对齐值的整数倍,不够就在前面补字节
- 结构体总大小必须是最大成员对齐值的整数倍,不够就在尾部补字节
#pragma pack(n)把每个成员的对齐上限压到 n(协议报文常用)
在 64 位 Linux(LP64)上,char 对齐 1、int 对齐 4、double 对齐 8。把上面那四个成员摆一遍:
a放 0 号字节,占 1 字节b要对齐 8,只能从 8 开始——1~7 全是填充,共 7 字节b占 8~15c要对齐 4,16 正好是 4 的倍数,占 16~19,没填充d占 20- 结构体最大对齐是 8,总长 21 要补到 24——尾部再填 3 字节
7 + 3 = 10 字节填充。一个 double 被塞在 char 后面,就凭空吃掉 7 个字节。
尾部为什么也要填
内部填充好理解:偏移必须对齐。尾部填充常被忽略,理由在数组:
struct S { char a; int b; }; // 1 + pad3 + 4 = 8
struct S arr[2]; // 两个元素紧挨着
如果 sizeof 是 5 而不补齐到 8,那 arr[1] 就落在 5 号地址上,
它的 b 偏移 1 后是 6 —— 不是 4 的倍数,对齐直接崩了。
所以结构体大小必须是「成员最大对齐」的整数倍,这样数组里每个元素的成员都能对齐。
换个顺序就省 8 字节
同样的四个成员,按对齐值从大到小排:double、int、char、char。
b占 0~7,不需要前置填充c占 8~11a占 12,d占 13- 总长 14,补到 16
24 变 16,省了 33%,一个成员都没少。原因是填充来自「小成员顶歪了大成员的对齐边界」—— 先放大成员,它们的偏移天然对齐,小成员随后只需落在剩余空隙里。
这对结构体数组是乘法效应:100 万个元素,每省 8 字节就是 8MB。 嵌入式项目里 RAM 就几十 KB,这个优化经常是「能不能塞进去」的分界线。
pack:用访问速度换紧凑
协议报文不能有随机填充,于是用 #pragma pack(1) 取消对齐要求。
同样那个 char/int/char,pack(1) 之后正好 6 字节。
代价是真实的:成员可能跨缓存行,甚至跨页边界;在要求严格对齐的核(老 ARM、部分 DSP)上, 访问未对齐地址会触发异常,编译器只能生成「拆成多次读写再拼接」的代码,慢且占空间。 C 标准把「取成员地址再强转成更宽类型」定义为未定义行为,就是给这种布局留的坑。
所以判断标准很简单:这块内存是要频繁算,还是要原样收发?算就用默认对齐,收发才用 pack。
同一个结构体,32 位和 64 位不一样大
char、short、int 在两套 ABI 下都一样,
变的是 long 和指针:
- LP64(x86-64、AArch64 Linux/macOS):long 和指针 8 字节、对齐 8
- ILP32(ARM AAPCS、多数 32 位 MCU):long 和指针 4 字节、对齐 4
所以含指针的结构体在两边大小不同。把结构体裸内存直接写文件、发网络、放共享内存,
换平台就炸——不只是大小,字节序还会把字段顺序翻过来。
正确做法是按字段逐个序列化,或者用明确的定宽类型(int32_t)+ 固定字节序。
怎么验证
别靠手算。两个宏就够:offsetof(struct S, b) 查偏移,
sizeof(struct S) 查总大小,再用 _Static_assert 把预期钉住,
一旦有人改动成员顺序,编译期就会报错。
想快速试各种组合,用结构体内存对齐计算器: 它会画出每个字节归谁、填充在哪、以及重排之后能省多少。位级别的字段划分用 位域计算器,两者正好覆盖寄存器手册里最常见的两种布局。