第10章:内存对齐:为什么结构体大小不是成员之和

一个 int 加一个 char 加一个 int,成员之和明明是 9 字节,结构体却要 12 字节——多出来的 3 个字节去哪了?这一章我们跟着编译器这位"画家"的笔触,把内存布局这张画布一寸一寸描出来

🎨

本章导师:达芬奇

核心方法论:艺术与工程

「结构体是编译器画的一张画布。真正的画家从不把颜料堆得越满越好——留白与构图,决定整幅画的呼吸。内存对齐就是画布上的留白:看似浪费的缝隙,恰恰是让每个成员各就其位的优雅。这一章,我带你把画布上的每一块砖、每一道缝都描出来:先看懂为什么必须有缝,再学会怎么把缝留得最少、最讲究。」

10.1 先看现象:两个结构体,成员一样,大小却不同

先抛出一个"反直觉"的现象。下面两个结构体,成员种类一模一样——都是一个 int(整数,4 字节)、一个 char(字符,1 字节)、一个 int,只是写的顺序不同。按常识,体积都该是 4 + 1 + 4 = 9 字节。可现实是:结构体的大小,并不等于成员大小之和。这不是编译器的 bug,而是内存对齐(memory alignment,让每种类型的对象都放在"地址是某固定值倍数"的位置上的一种硬件约定)在起作用。素材 S1 的两个变体原样保留:

// 变体一:int → char → int(素材 S1 原样保留)
struct S1
{
    int i;
    char c;
    int j;
};

// 变体二:int → int → char(素材 S1 原样保留)
struct S1
{
    int i;
    int j;
    char c;
};

眼见为实,用 sizeof(求对象字节数的运算符)测一测。下面这段程序在本机(macOS 64 位)编译运行,输出全部是真实测量值:两个变体的大小都恰好是 12 字节,而不是 9。它们大小相同,是因为成员种类相同;但它们都违背直觉,因为都大于成员之和。多出来的 3 个字节,就是编译器塞进去的填充字节(padding,为了让成员站到合法位置而插入的空隙):

// align.cpp —— 用 sizeof 实测两个结构体
#include <cstdio>
#include <cstddef>

struct S1A { int i; char c; int j; };  // 变体一:i, c, j
struct S1B { int i; int j; char c; };  // 变体二:i, j, c

int main()
{
    printf("sizeof(S1A) = %zu\n", sizeof(S1A));
    printf("sizeof(S1B) = %zu\n", sizeof(S1B));
    return 0;
}
// 本机真实输出(g++ -o align align.cpp 后运行):
// sizeof(S1A) = 12   (4 + 1 + 4 = 9,却占 12 字节)
// sizeof(S1B) = 12   (同样 9,同样占 12 字节)

再细看一层:两个变体虽然都是 12 字节,填充字节藏的位置不一样。用 offsetof(求成员相对结构体首地址偏移量的宏)实测:变体一的 j 从偏移 8 开始(cj 之间空了 5~7),变体二的 c 从偏移 8 开始(尾巴空了 9~11)。也就是说:同一批 9 字节的成员,编译器把 3 字节的空隙补在了不同的地方。

变体成员顺序成员裸大小之和sizeof 实测填充字节藏在哪
变体一int i → char c → int j4 + 1 + 4 = 912cj 之间(3 字节)
变体二int i → int j → char c4 + 4 + 1 = 912最末尾(3 字节)
达芬奇提示

把现象钉死成一句话:成员一样的两个结构体,大小相同(12);但它们的大小都不等于成员之和(9)。填充字节不是玄学——它是编译器在"布局画布"时,为了满足某种硬性规则而刻意留的空白。接下来两节回答:为什么必须留白?留白多少由谁决定?

注意sizeof 测量的是类型的大小,和成员存了什么值毫无关系——无论 i 里存 1 还是 2147483647sizeof(S1A) 永远是 12。另外,不同平台、不同编译器得出的 sizeof 可能不同(32 位系统上 double 的对齐要求就不同),所以永远以你自己机器上的实测为准——这也是本章所有数字都用本机真实输出的原因。

10.2 为什么需要对齐:硬件读取的块机制

编译器为什么放着"紧凑摆放"不做,非要插空隙?答案在硬件里。很多计算机系统对基础数据类型的"允许地址"做了一个限制:某种类型的对象,地址必须是某个值 k(通常是 2、4、8)的倍数。这种对齐限制,简化了处理器和存储器系统之间接口的硬件设计。说人话:CPU 去内存取数,是按"块"取的——它一次只从"块边界"(地址是块大小整数倍的位置)开始整块搬。这个固定大小的块叫(word,CPU 一次搬运的基本单位);64 位系统的字长是 8 字节。

素材里有个经典例子:假如一个处理器总是从存储器中取 8 字节的数据,那它读数据的起始地址必须为 8 的倍数。如果能保证所有 double(双精度浮点数,本机 8 字节)的地址都是 8 的倍数,一次存储器操作就能读写完;否则就可能要执行两次存储器访问——对象横跨了两个 8 字节块。两次访问意味着慢一倍,某些平台上甚至直接触发硬件异常。读错位的场景,用文字示意:

# 64 位系统:CPU 按 8 字节一个字读取,字边界在 0、8、16……
      ┌───────────────┬───────────────┐
      │     字块 0     │     字块 1     │
      └───────────────┴───────────────┘

# 对齐的 double(地址 8,占 8~15):正好一个块 —— 一次读取 ✓
                                  └── d ──┘
                                  ↑ 一次存储器访问

# 未对齐的 double(地址 4,占 4~11):横跨两个块 —— 两次读取 ✗
            └─ d 前半(4~7) ─┘  └─ d 后半(8~11) ─┘
            ↑ 第一次访问        ↑ 第二次访问,拼起来才能用

所以"对齐"的本质一句话:让每个对象都住在"块内",而不是"跨块"。跨块意味着一次读变两次读——对追求极致性能的 C/C++ 来说,这是不可接受的浪费。编译器会在汇编代码里放入指令,指明全局数据所需要的对齐;结构体内部的布局,则由编译器完全按对齐规则自动安排(我们自己也能干预,#pragma pack 第 11 章专门讲)。下面用一个小程序验证:编译器确实会把 double 的地址放在 8 的倍数上。

// address.cpp —— 验证 double 的地址落在 8 的倍数上
#include <cstdio>
#include <cstdint>

int main()
{
    double d = 3.14;
    std::uintptr_t addr = reinterpret_cast<std::uintptr_t>(&d);
    printf("地址 = 0x%llx,地址 %% 8 = %llu\n",
           (unsigned long long)addr,
           (unsigned long long)(addr % 8));
    return 0;
}
// 本机真实输出(地址数值每次运行都变,但 %% 8 恒为 0):
// 地址 = 0x16b4fe130,地址 % 8 = 0
平台字长CPU 一次读取典型对齐要求
32 位系统4 字节(32 位)4 字节int 地址为 4 的倍数
64 位系统8 字节(64 位)8 字节double 地址为 8 的倍数
本机实测8 字节8 字节alignof(int)=4alignof(double)=8
达芬奇提示

把硬件逻辑画成一句话:块是固定的,对象是移动的——那就让对象去凑块,而不是让块来凑对象。对齐就是"对象地址必须是块大小的整数倍"这条铁律。理解了它,你就明白为什么 alignof(int) 在 64 位系统上是 4 而不是 1:一个 4 字节的 int,只要地址是 4 的倍数,就永远不可能横跨两个 4 字节的读取单元。

注意:别把"未对齐 = 慢一点"当小事。在某些古老架构(比如部分 RISC 处理器)上,未对齐访问直接触发硬件异常,程序当场崩溃——根本没有"慢"这个选项。即便在 x86 / ARM 上,跨块访问也意味着更多内存事务。另外,栈上的局部变量和堆上的对象,编译器同样会自动对齐(上面的 d 就是例子)——对齐规则约束的是所有对象,不只是结构体成员。

10.3 对齐规则:有效对齐值

现在进入"规则"本身。每个特定平台上的编译器,都有自己默认的对齐系数(alignment factor,编译器心里那把衡量对齐的"尺子")。可以通过预编译命令 #pragma pack(n)(n = 1, 2, 4, 8)来改变这个系数;若没有手动指定,编译器就会默认将成员变量中最大的类型字节数设置为对齐值 m。注意,对齐系数和 m 不一定相等——真正说了算的是有效对齐值

有效对齐值(effective alignment,也叫对齐单位),是给定值 #pragma pack(n) 和结构体中最长数据类型长度中较小的那个。说人话:尺子和最胖的成员,谁短听谁的。默认没有 #pragma pack 干预时,有效对齐值就等于最长成员的长度——比如只有 intchar 的结构体,有效对齐值就是 4。素材里的对齐规则原样如下,两条,背下来:

# 对齐规则(素材原样重写为清单)
# 先算有效对齐值(对齐单位):
有效对齐值 = min( #pragma pack(n) 的值, 结构体中最长数据类型的长度 )

# 规则一:成员摆放
(1) 结构体第一个成员的偏移量(offset)为 0;
    以后每个成员相对于结构体首地址的 offset,
    都是「该成员大小 与 有效对齐值 中较小那个」(即对齐单位)的整数倍;
    如有需要,编译器会在成员之间加上填充字节。

# 规则二:总大小
(2) 结构体的总大小,为 有效对齐值 的整数倍;
    如有需要,编译器会在最末一个成员之后加上填充字节。

把规则翻译成人话。规则一管"成员怎么排":第一个成员站 0 号位(画布最左边),之后每个成员必须站到"自己尺寸和对齐单位取小者"的整数倍位置上——站不上,就垫空隙。规则二管"整体多大":排完所有成员后,如果总长度不是有效对齐值的整数倍,就在尾巴上继续垫到凑整。为什么要凑整?因为结构体通常要组成数组——数组里第二个元素的起始地址必须仍然是合法的,尾巴上的填充就是给"下一个元素"留的接力棒。

两条规则之间还有一个微妙的联动:每个成员的对齐需求,也可能比有效对齐值小。比如有效对齐值是 4 时,一个 char(1 字节)只需要站在 1 的倍数上——而 1 的倍数就是任何位置,所以 char 可以"见缝插针"。这正是填充字节的诞生机制:大块成员必须站"整点",小块成员只能挤进缝里,缝不够大时编译器再补新缝。下面这个 #pragma pack 的预告示例,让你直观看到"尺子换短了,布局立刻变"(完整玩法第 11 章再展开):

// pack.cpp —— 把对齐系数压到 1,看看会发生什么
#pragma pack(1)              // 把对齐系数改成 1 字节(n = 1)
struct Packed
{
    int i;
    char c;
    int j;
};
#pragma pack()               // 恢复默认对齐系数

// 有效对齐值 = min(1, 4) = 1 → 所有成员站 1 的倍数即可,谁都不用等谁
// 本机真实输出:sizeof(Packed) = 9 —— 三成员紧挨着,零填充(4 + 1 + 4)
概念定义说人话
对齐系数编译器默认值,可用 #pragma pack(n) 修改(n = 1, 2, 4, 8)编译器手里那把"尺子"
最长成员长度 m结构体中最大的成员类型字节数全组里最胖的成员
有效对齐值(对齐单位)min(对齐系数, m)尺子和最胖成员,取短的那个
填充字节(padding)编译器插入的"空隙",让成员站到合法位置画布上的留白
达芬奇提示

给新手一个抓手:这一节你只需要记住一个公式——有效对齐值 = min(尺子, 最胖成员)。默认情况下没有 #pragma pack,尺子不参与,有效对齐值就等于最胖成员的字节数。全 int/char 的结构体,有效对齐值 = 4;一旦出现 double(8 字节),有效对齐值立刻升到 8,整张画布的节奏都要跟着变。

注意#pragma pack 是"能改规则"的规则,但改规则的代价很昂贵。把对齐系数压小(比如 pack(1))会让结构体变紧凑、省内存,但成员的地址可能不再满足硬件最舒服的对齐——读写变慢,甚至在某些平台不可移植。工程上这叫"用性能换内存"。第 11 章会详细讲什么时候值得这么干(网络协议、文件格式、跨平台数据交换),以及 #pragma pack(push/pop) 的正确打开方式。现在先知道它的存在即可。

10.4 手算结构体大小:padding 怎么产生

规则背下来不算会,能徒手推演才算会。现在用 10.3 的两条规则,把 10.1 的两个变体一步一步推出来。本机有效对齐值 = 4(最长成员 int 是 4 字节,无 #pragma pack),每个成员的"站队条件"是:int 必须站 4 的倍数(4 和 4 取小还是 4),char 只需站 1 的倍数(1 和 4 取小是 1,即任意位置)。

变体一 struct S1A { int i; char c; int j; }。① i 是第一个成员,站偏移 0,占 0~3;② c 对齐需求 1,紧挨着站偏移 4;③ j 对齐需求 4,可它想站的位置是 5——5 不是 4 的倍数,不行!于是编译器在 cj 之间插入 3 字节填充,把 j 推到偏移 8,占 8~11;④ 总长 12,已经是有效对齐值 4 的整数倍,尾巴不用再补。最终 sizeof = 12。文字版内存布局:

# S1A:int i; char c; int j; —— 填充在中间
偏移: 0~3 | 4 | 5~7 | 8~11
      [    i    ] [c] [ 填充3B ] [    j    ]
      @规则一:j 不能站 5(不是 4 的倍数),垫 3 字节站 8

变体二 struct S1B { int i; int j; char c; }。① i 站偏移 0,占 0~3;② j 对齐需求 4,4 是 4 的倍数,直接站 4~7;③ c 对齐需求 1,紧挨着站偏移 8;④ 排完总长 9——但规则二来了:9 不是有效对齐值 4 的整数倍,于是编译器在最末成员之后补 3 字节,凑成 12。注意:这次不是"成员之间有缝",而是"成员排完,尾巴多出一截"。这截尾巴不是给 c 用的,是给下一个对象用的——想想 S1B arr[2]:第二个元素的 i 必须站在 4 的倍数上,如果第一个元素只占 9 字节,第二个元素就从偏移 9 开始,直接违规。文字版布局:

# S1B:int i; int j; char c; —— 填充在尾巴
偏移: 0~3 | 4~7 | 8 | 9~11
      [    i    ] [    j    ] [c] [ 填充3B ]
      @规则二:总长 9 不是 4 的倍数,尾巴补到 12

素材还留了一组"对照组"——联合体和结构体的对比。联合体(union,所有成员共用同一块内存、容量取最大者的类型)A 的三个成员:char c(1 字节)、int a[5](20 字节)、double d(8 字节),全部从偏移 0 开始共享内存,实际需要 20 字节;但 20 不是有效对齐值 8 的倍数(出现了 double,有效对齐值升到 8),于是补到 24。而结构体 B 是"每人一块地":c 站 0、垫 3 字节、a[5] 站 4~23、d 站 24~31,总共 32。同一批成员,union 24、struct 32——差出来的 8 字节,就是"共享"和"独占"的差别。素材原例 + 本机实测:

// union_vs_struct.cpp —— 素材原例:同一批成员,两种容器
union A {
    char c;
    int a[5];
    double d;
};

struct B {
    char c;
    int a[5];
    double d;
};
// 本机真实输出:sizeof(A) = 24  (union:20 字节按 8 对齐凑整)
//              sizeof(B) = 32  (struct:c(1)+填充(3)+a[5](20)+d(8))
步骤S1A:int i; char c; int j;S1B:int i; int j; char c;
① 第一个成员i 站偏移 0(0~3)i 站偏移 0(0~3)
② 第二个成员c 对齐需求 1,紧挨着站偏移 4j 对齐需求 4,4 是 4 的倍数,站 4~7
③ 第三个成员j 想站 5,5 不是 4 的倍数 → 填充 3 字节,站 8~11c 对齐需求 1,紧挨着站偏移 8
④ 收尾12 已是 4 的倍数,无需尾部填充9 不是 4 的倍数 → 尾部填充 3 字节,凑成 12
sizeof1212
达芬奇提示

手算的通用套路,四步走:① 确定有效对齐值(默认 = 最长成员);② 按成员顺序逐个放,算每个成员的对齐需求;③ 站不上就垫填充;④ 排完看总长是不是有效对齐值的整数倍,不是就补尾巴。画布局图时,用偏移量(0、4、8……)对账,一眼就能看出"哪个成员在等谁"。

注意:填充字节里的内容是什么?没有定义——是"脏数据"。C/C++ 标准从不承诺填充字节的值,它们可能是上次残留的任意字节。所以:① 不要用 memcmp 直接比较两个结构体变量(填充字节不同会导致误判);② 不要试图把结构体用 memcpy 原样写进文件当"通用格式"(不同机器布局不同,第 11 章讲怎么安全序列化);③ 填充字节是初学者最容易忽略的"隐藏内存"——一个 12 字节的结构体,真正"有主"的只有 9 字节。

10.5 成员顺序的学问:重新排列减少浪费

10.1 的两个变体都是 12 字节——那是不是说"顺序无所谓"?不,那是因为它们只有 intchar,有效对齐值只有 4,腾挪空间小。一旦混入 double(8 字节),顺序立刻变成真金白银。看下面这对结构体:成员完全一样(一个 char、一个 double、一个 int),仅仅顺序不同,本机实测 sizeof 差了整整 8 字节

// order.cpp —— 同成员,不同顺序,sizeof 实测
#include <cstdio>

struct Mis { char c; double d; int i; };  // 乱序版:char 开头
struct Fit { double d; int i; char c; };  // 重排版:double 开头

int main()
{
    printf("sizeof(Mis) = %zu\n", sizeof(Mis));  // char c; double d; int i;
    printf("sizeof(Fit) = %zu\n", sizeof(Fit));  // double d; int i; char c;
    return 0;
}
// 本机真实输出 + offsetof 实测:
// sizeof(Mis) = 24   (c=0, d=8, i=16,尾巴又补了 4 字节)
// sizeof(Fit) = 16   (d=0, i=8, c=12,只补了 3 字节尾巴)

拆开看 Mis 为什么会胖:c(1 字节)站偏移 0 后,d 的对齐需求是 8,只能从偏移 8 开始——中间白白垫了 7 字节i 站 16~19 后,总长 20,不是 8 的倍数,尾巴再补 4 字节。而 Fit 让最胖的 d 打头阵,后面 i 正好续在 8 上,c 见缝插针站 12,最后只补 3 字节尾巴。规则没变,只是顺序变了,缝就少了。布局对账图:

# Mis:char c; double d; int i; —— 24 字节,浪费 11 字节
偏移: 0 | 1~7 | 8~15 | 16~19 | 20~23
      [c] [ 填充7B ] [    d    ] [   i   ] [ 填充4B ]
      @c 之后 d 站不上 1,只能等 8

# Fit:double d; int i; char c; —— 16 字节,只浪费 3 字节
偏移: 0~7 | 8~11 | 12 | 13~15
      [    d    ] [   i   ] [c] [ 填充3B ]
      @d 打头,i 续上,c 见缝插针,尾巴只补 3

那 10.1 的变体三呢?struct S1C { char c; int i; int j; } 本机实测也是 12 字节(c=0,垫 3 字节后 i 站 4,j 站 8,总长 12 正好)。这说明:成员全是对齐需求 4 及以下的类型时,无论怎么排都是 12;只有引入 8 字节的大家伙,顺序的价值才会显现。工程经验法则:把成员按对齐需求从大到小排列doubleintshortchar),通常能拿到最小体积——相当于把画布上最大的色块先铺好,小色块再填缝。

# 结构体"瘦身"经验法则
# 1. 按对齐需求从大到小排:double(8) → int(4) → short(2) → char(1)
# 2. 同类成员尽量相邻,避免"大块等小块、小块留大缝"
# 3. 排完用 sizeof 实测验证,别靠感觉——不同编译器结果可能不同
# 4. 警告:改顺序会改变结构体的二进制布局,跨进程/跨平台交换数据时慎动
版本成员顺序内存布局sizeof 实测浪费的字节
Mis(乱序)char c → double d → int ic(1) + 填充7 + d(8) + i(4) + 填充42411 字节
Fit(重排)double d → int i → char cd(8) + i(4) + c(1) + 填充3163 字节
变体三 S1Cchar c → int i → int jc(1) + 填充3 + i(4) + j(4)123 字节
达芬奇提示

把这一节浓缩成一句画家的心得:留白是必须的,但留多少白,取决于你落笔的顺序。数组场景下收益还会放大——Fit arr[1000]Mis arr[1000] 少占 8KB。嵌入式、网络服务器这种内存紧张的场景里,重排成员顺序是性价比最高的"免费优化"之一:不动算法、不动逻辑,只动声明顺序。

注意:重排顺序不是免费的午餐。① ABI / 序列化约束:如果这个结构体要和别的进程、别的语言、别的机器交换(网络协议、文件格式、共享内存),成员顺序就是协议的一部分,私自重排等于撕毁协议;② 可读性权衡:为省 8 字节把结构体排得面目全非,团队维护成本可能更高——先按逻辑分组写,再用注释标注"已按对齐重排";③ 别迷信"越小越快":访问已对齐成员永远比访问紧凑但未对齐的成员快,某些情况下 pack(1) 的结构体反而拖慢程序。

10.6 章节小结与悬念:class 也有同样的对齐问题

把这一章的链路串起来:现象(sizeof 不是成员之和)→ 原因(硬件按块读内存,对象必须站在块的倍数上)→ 规则(有效对齐值 + 两条铁律)→ 手算(padding 怎么产生、藏在哪)→ 优化(重排成员顺序省内存)。这五步就是"内存布局"这门手艺的完整闭环。达芬奇式的收尾:先把画布结构画对,再谈笔触的省俭——对齐规则是画布的结构,重排是省俭的笔触。

最后埋一个必须埋的钩子。你可能会想:"struct 才需要对齐,class 总该特殊吧?"——恰恰相反:在成员布局这件事上,class 和 struct 是同一张画布。class 的成员变量遵守一模一样的对齐规则;更刺激的是,一旦 class 里有虚函数,编译器会悄悄塞进一个隐藏成员 vptr(虚函数表指针,指向虚函数表的指针,本机 8 字节),它也要参与排布、也要对齐。看一个预告实验,全部本机实测:

// class_preview.cpp —— 预告:class 的对齐问题(第 12 章主战场)
#include <cstdio>

class Empty {};                          // 空类:没有任何成员
class WithVirtual
{
public:
    virtual void run();                // 一个虚函数 → 隐藏的 vptr
};
class Config
{
public:
    char level;                        // 1 字节
    int timeout;                       // 4 字节
};

int main()
{
    printf("sizeof(Empty)       = %zu\n", sizeof(Empty));      // 空类不能是 0!
    printf("sizeof(WithVirtual) = %zu\n", sizeof(WithVirtual));
    printf("sizeof(Config)      = %zu\n", sizeof(Config));     // 和 struct {char;int;} 一样?
    return 0;
}
// 本机真实输出:
// sizeof(Empty)       = 1   (空类占 1 字节:保证每个对象地址都不同,C++ 规定)
// sizeof(WithVirtual) = 8   (没有数据成员,但藏了一个 8 字节的 vptr)
// sizeof(Config)      = 8   (char(1) + 填充3 + int(4):和 struct 一模一样的规则)

看到了吗?Config 的 8 字节里,同样藏着 3 字节 padding——class 不是结构体的例外,而是结构体的延伸。那么问题来了:空类为什么必须是 1 字节而不是 0?vptr 到底站在哪个偏移?多继承、虚继承时,vptr 又该怎么排?这些"对象内部怎么住"的问题,就是第 12 章「类的大小:空类、成员函数与虚函数指针」的主战场——包青天导师会把每个字节的来龙去脉都审清楚。另外预告一句:第 11 章「对齐控制实战:#pragma pack 与联合体」会教你把本章的两条规则"掰弯"的完整手法,网络协议解析、跨平台二进制格式都靠它。

# 本章知识地图
10.1 现象:  sizeof = 12 ≠ 成员之和 9 → 填充字节
10.2 原因:  CPU 按字读内存,地址须是块大小的倍数
10.3 规则:  有效对齐值 = min(尺子, 最胖成员)
10.4 手算:  四步推演 + offsetof 对账
10.5 优化:  按对齐需求从大到小排,Mis(24) → Fit(16)
10.6 悬念:  class 同规则 + vptr → 第 12
小节一句话核心带走的能力
10.1 先看现象sizeof 不等于成员之和,padding 藏在中间或尾巴会用 sizeof / offsetof 实测
10.2 硬件块机制CPU 按字读内存,跨块要两次访问理解对齐的动机,不再觉得它是玄学
10.3 有效对齐值有效对齐值 = min(尺子, 最胖成员),两条铁律背下规则,能解释任何布局
10.4 手算推演四步走:定值 → 摆成员 → 垫缝 → 凑整能徒手画出结构体内存布局图
10.5 重排瘦身按对齐需求从大到小排,Mis 24 → Fit 16会做"免费优化",也懂其代价
达芬奇提示

给整章一个"画家视角"的收束:内存布局是编译器画的画,但执笔的是你——结构体怎么声明,画就怎么长。规则是死的(硬件决定),手法是活的(顺序、pack 由你决定)。从下一章开始,你将学会主动"改画笔":#pragma pack 让你能精确控制每个字节的落点,第 11 章见。

注意:本章所有 sizeof 数字都是在作者本机(macOS 64 位)实测的真实输出,素材原例与规则已原样保留。你的机器、你的编译器(GCC / Clang / MSVC)可能给出不同数字——特别是 32 位平台或开了 #pragma pack 时。请务必亲手跑一遍本章的示例程序,以你自己机器上的输出为准,这比背任何数字都可靠。

章末练习

练习 1:快速心算 sizeof 入门

本机默认对齐下(int 4 字节、char 1 字节),心算两个结构体的 sizeof:① struct E1 { char a; char b; char c; };;② struct E2 { char a; int b; };。再用程序实测验证。

提示

回忆 10.3 的两条规则:每个成员站"对齐单位"的整数倍;总大小是有效对齐值的整数倍。全 char 时有效对齐值是多少?

参考答案

sizeof(E1) = 3:三个 char 各 1 字节,有效对齐值 = 1,总长 3 已是 1 的倍数,零填充。② sizeof(E2) = 8a 站 0,b 对齐需求 4 不能站 1,垫 3 字节后站 4~7,总长 8 正好。本机实测:E1 = 3,E2 = 8。

练习 2:徒手画布局 进阶

不运行程序,画出 struct E3 { char c; int i; char d; int j; }; 的内存布局图(标出每个成员的偏移量和填充字节的位置),并算出 sizeof。

提示

按 10.4 的四步走:c 站 0 → i 要对齐 4 怎么办 → d 站哪 → j 站哪 → 总长是否凑整。注意中间有两个 1 字节的小成员,它们各自见缝插针。

参考答案

布局:c 站 0;垫 3 字节;i 站 4~7;d 对齐需求 1,站 8;垫 3 字节;j 站 12~15。总长 16,正好是有效对齐值 4 的倍数。sizeof(E3) = 16,成员裸大小之和只有 10,浪费 6 字节。本机实测:16。

练习 3:重排瘦身实战 进阶

struct E4 { char c; long long x; char d; };(本机 long long 为 8 字节)实测是 24 字节。请把成员重新排列,写出一个成员完全相同但体积最小的版本,并说明为什么。

提示

用 10.5 的经验法则:按对齐需求从大到小排——8 字节的打头,1 字节的垫后。算算重排后总长是多少、还要不要补尾巴。

参考答案

重排为 struct E5 { long long x; char c; char d; };x 站 0~7,c 站 8,d 站 9,总长 10,补 6 字节尾巴凑成 16。sizeof(E5) = 16,比 24 省了 8 字节。原因:char 开头的版本里,x 被迫等 8 的倍数,白白垫了 7 字节;让最胖的成员打头,缝就只剩尾巴。本机实测:E4 = 24,E5 = 16。

练习 4:挑战——用代码审问布局 挑战

写一个程序:① 用 offsetof 打印 struct E3 每个成员的偏移量,和练习 2 的手算结果对账;② 用 #pragma pack(1) 重新定义 E3,打印 sizeof 前后的变化;③ 解释为什么 pack(1) 之后的 E3 不再是 16 字节,并思考这种"紧凑"的代价是什么。

提示

offsetof 需要 <cstddef> 头文件;#pragma pack(1) 的写法见 10.3 节的 pack.cpp;"代价"从 10.2 的硬件块机制里找答案。

参考答案

① 实测输出:offsetof: c=0, i=4, d=8, j=12,与手算完全一致。② pack(1) 后有效对齐值 = min(1, 4) = 1,所有成员紧挨着:0、1~4、5、6~9,总长 10,sizeof = 10。③ 代价:成员地址不再满足 4 字节对齐,访问 int 成员可能触发跨块读取(10.2 说过的"两次访问"),性能变差,且布局依赖编译器行为,跨平台不可移植。这正是第 11 章要深入的主题——什么时候该用、怎么用得安全。