工具拆解

结构体的大小为什么不等于成员之和

三个成员一共 6 个字节,sizeof 却是 12。从对齐规则、填充位置讲到成员重排和 #pragma pack,顺带解释为什么跨平台不能直接 memcpy 结构体。

写过这样一段代码的人大概都愣过一下:

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~15
  • c 要对齐 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~11
  • a 占 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 把预期钉住, 一旦有人改动成员顺序,编译期就会报错。

想快速试各种组合,用结构体内存对齐计算器: 它会画出每个字节归谁、填充在哪、以及重排之后能省多少。位级别的字段划分用 位域计算器,两者正好覆盖寄存器手册里最常见的两种布局。

打开「结构体内存对齐」→ 更多文章