深蓝黑背景封面。左侧两条横向字节布局条对比:第一条按声明顺序 char、uint64_t、uint32_t,青色 char 占 1 字节,随后灰色「填充 7」,紫色 uint64 占 8 字节,青色 uint32 占 4 字节,末尾灰色「尾填充 4」,合计 24 字节;第二条重排为 uint64_t、uint32_t、char,紫色 8 字节、青色 4 字节、1 字节,末尾「填充 3」,合计 16 字节,右侧青色标注「省 8 字节」。图例:青色为成员、灰色为对齐填充。底部一行注「规则:成员偏移是自身对齐的倍数,整体大小是最大对齐的倍数」。右侧大标题「char、uint64、uint32,结构体 sizeof 怎么算」,副标题「对齐三条规则,重排立省 8 字节」。左上标注「笔试复盘 · C/C++」,右上胶囊标注「Day 04 · 11-21」。 图 1:同样三个成员,换个顺序从 24 字节降到 16 字节。

先说明属性:这是一篇历史笔试真题复盘,题目原型出自一份校园招聘笔试卷,与任何公司的当前招聘无关。结构体对齐是 C/C++ 笔试的钉子户,题目形式固定:给几个不同类型的成员,问 sizeof 是多少。会算只是及格,能讲清三条规则、顺手给出重排优化才是满分答法。

先把三条对齐规则钉死。

以 GCC/Clang 在 64 位平台的默认对齐为例:

  1. 成员对齐:每个成员的偏移量必须是它自身对齐值的倍数。char 对齐 1、uint32_t 对齐 4、uint64_t 对齐 8;
  2. 整体对齐:结构体的 sizeof 必须是最大成员对齐值的倍数,不够就在尾部补填充;
  3. 填充归编译器:成员之间、结构体末尾的空洞都是编译器自动插入的,你的代码不能假设它们为零。

对齐的意义一句话:让 CPU 按自然边界读内存,避免跨 cache line 的两次访问和某些架构上的总线错误。

逐字节推导:声明顺序 24 字节。

结构体按 char a、uint64_t b、uint32_t c 声明。从偏移 0 开始排:

  • a 是 char,落在偏移 0,占 1 字节;
  • b 是 uint64_t,对齐 8,下一个可用偏移 1 不是 8 的倍数,编译器在 1~7 插 7 字节填充,b 落在偏移 8,占 8 字节;
  • c 是 uint32_t,对齐 4,偏移 16 恰好是 4 的倍数,c 落在 16,占 4 字节,到偏移 20 结束;
  • 整体对齐:当前 20 字节,最大成员对齐值是 8,补齐到 24。

所以 sizeof = 24,其中 11 字节全是填充——将近一半是空气。

重排一下:16 字节。

把成员按对齐值从大到小重排:uint64_t b、uint32_t c、char a。b 在 0 占 8 字节,c 在 8 占 4 字节,a 在 12 占 1 字节,当前 13,整体补齐到 8 的倍数即 16。填充从 11 字节缩到 3 字节,省下 8 字节。一个结构体省 8 字节看似不多,百万级实例的数组就是 8 MB,还附带 cache 命中率的收益。

用编译器实测,不靠口算。

写个小程序验证,clang 编译运行(clang align.c -o align && ./align):

#include <stdio.h>
#include <stdint.h>
#include <stddef.h>
struct Before { char a; uint64_t b; uint32_t c; };   // 声明顺序
struct After  { uint64_t b; uint32_t c; char a; };   // 大成员靠前
int main(void) {
    printf("sizeof(Before) = %zu\n", sizeof(struct Before));
    printf("offsetof: a=%zu b=%zu c=%zu\n",
        offsetof(struct Before, a), offsetof(struct Before, b),
        offsetof(struct Before, c));
    printf("sizeof(After)  = %zu\n", sizeof(struct After));
    printf("offsetof: b=%zu c=%zu a=%zu\n",
        offsetof(struct After, b), offsetof(struct After, c),
        offsetof(struct After, a));
    return 0;
}

输出:

sizeof(Before) = 24
offsetof: a=0 b=8 c=16
sizeof(After)  = 16
offsetof: b=0 c=8 a=12

偏移量与逐字节推导完全一致。

深蓝黑背景概念图。主体是两行字节地址网格:第一行 24 格,编号 0 到 23,第 0 格青色标注 char a,第 1 至 7 格灰色填充(规则一:uint64_t b 的偏移必须是 8 的倍数,所以空出 1–7),第 8 至 15 格紫色标注 uint64_t b,第 16 至 19 格青色标注 uint32_t c,第 20 至 23 格灰色尾填充(规则二:整体必须是最大对齐值 8 的倍数,补到 24)。第二行 16 格展示重排结果:0 至 7 为 uint64_t,8 至 11 为 uint32_t,12 为 char,13 至 15 为尾填充,合计 16 字节。右侧三张规则卡:成员对齐——偏移必须是自身对齐值的倍数(char 1、uint32 4、uint64 8);整体对齐——sizeof 必须是最大成员对齐值的倍数;重排优化——大成员靠前,填充自然消失,24 变 16。底部总结条:口诀 —— 先算每个成员的落位偏移,再把整体补齐,大成员靠前最省。 图 2:两行字节网格把 24 和 16 的每一格归属画清楚了。

笔试与面试里常见的追问。

一是 #pragma pack(1) 或 __attribute__((packed)) 能取消对齐,结构体变成 13 字节紧凑排列,代价是成员访问可能变慢甚至在个别架构上出错,网络协议封包场景才会用;二是嵌套结构体按「最大成员对齐值」递归算,内层结构体整体作为一个对齐单位;三是重排优化有个边界——如果成员顺序承载语义(比如硬件寄存器映射、协议字段顺序),就不能动,只能靠显式保留字段。笔试遇到四成员、含指针或数组的变体也别慌,规则不变:先算每个成员的落位偏移,再把整体补齐到最大对齐的倍数。

再回答一个笔试卷上的经典变体。

原卷那道题给的是四成员版本——char、uint64_t 加两个 uint32_t——以及一个 char 加两个 uint32_t 的小结构体,让选两个 sizeof。用同样的方法推:第一个 1 加 7 填充加 8 加 4 加 4,正好 24,无需尾填充;第二个 1 加 3 填充加 4 加 4,整体对齐 4,得 12。可见成员越多、大小类型穿插越乱,填充越可观;把两个 uint32_t 挪到一起、char 垫底,两个小结构体都能再省。答题时把每个成员的落位偏移写在草稿纸上,比盯着选项硬猜快得多。

口诀收个尾:成员偏移是自身对齐的倍数,整体大小是最大对齐的倍数,大成员靠前最省。你当年笔试遇到的是几个成员的组合?有没有被 pack 相关的追问难住过?评论区交流。