第11章:对齐控制实战:#pragma pack 与联合体

编译器在结构体里偷偷塞的填充字节,平时是体贴,关键时刻是陷阱。这一章学会用 #pragma pack 把布局钉死、用联合体共享内存——网络包、文件头、跨平台结构体,从此手到擒来,每个数字都上机实测

🔨

本章导师:鲁班

核心方法论:工欲善其事

「工欲善其事,必先利其器。对齐这事,编译器默认帮你安排得妥妥帖帖——可一旦你的结构体要出门见人,上网络、落磁盘、跨平台,它自作主张塞的那些填充字节就成了绊脚石。这一章我们把『布局』这把尺子攥在自己手里:#pragma pack 怎么用、联合体怎么省内存,每一个数字都上机实测,量给你看。」

11.1 为什么需要手动控制对齐:网络协议 / 二进制文件读写 / 跨平台结构

第 10 章我们见识过编译器默认的对齐行为:结构体成员之间会被塞入填充字节(padding,为了让成员住进"对齐地址"而空出来的字节),整个结构体的大小再对齐成最大成员对齐值的整数倍。这种默认行为对绝大多数程序是体贴的——CPU 访问对齐的数据最省事。但有两种场合,它反而成了绊脚石:第一种,结构体必须和"外部世界的字节流"一一对应——网络协议、二进制文件格式都是纯字节流,没有填充字节的概念,线上格式规定"第几个字节是什么",就只能是第几个字节;第二种,结构体要在不同平台、不同编译器之间传输——默认对齐值随架构和编译器选项变化,A 机器上 12 字节的结构体,到 B 机器上可能变成 16 字节,写文件再读回来就全错位了。

最常见的真实场景是解析网络包头:协议文档规定数据包头"版本号 1 字节、负载长度 4 字节、序号 2 字节、类型 1 字节,共 8 字节",你用结构体去套它,编译器却按 4 字节对齐插填充字节,结构体变成 12 字节,字段全读错。读写二进制文件头同理:PNG 开头的 8 字节签名、BMP 的 BITMAPFILEHEADER、WAV 的 RIFF 头,每种格式都把每个字段的位置写死在规范里,多一个填充字节,整个文件就读歪了。先看一个"想当然"的写法错在哪:

// fileheader.cpp —— 读取自定义二进制文件头(场景节选)
#include <cstdio>
#include <cstdint>

// 文件头格式(文档规定):magic 4 字节 + version 2 字节 + count 4 字节 = 10 字节
struct FileHeader {
    uint32_t magic;      // 文件魔数
    uint16_t version;    // 版本号
    uint32_t count;      // 记录条数
};

int main() {
    FILE* fp = fopen("data.bin", "rb");
    FileHeader h;
    fread(&h, sizeof(FileHeader), 1, fp);  // 按结构体大小整块读
    fclose(fp);
    // 问题:默认对齐下 sizeof(FileHeader)=12,而文件头只有 10 字节!
    printf("magic=0x%08X version=%u count=%u\n", h.magic, h.version, h.count);
    return 0;
}

FileHeader 三个成员 4 + 2 + 4 = 10 字节,默认对齐下 sizeof = 12——uint32_t count 被推到偏移 8,末尾再补 2 字节凑 4 的倍数。fread 会多读 2 字节,把下一条记录的头两个字节也吞进来。网络包更夸张:int 成员的偏移完全错位,读出的数值和线上字节流对不上。下面这段就是 11.3 节要实测的"网络包头"结构体,先用默认对齐跑给你看(本机实测):

// netpkt.cpp —— 默认对齐解析网络包,会读出什么?
#include <cstdio>
#include <cstdint>

// 协议规定:ver 1 字节 + payload_len 4 字节 + seq 2 字节 + type 1 字节 = 8 字节
struct NetPktRaw { uint8_t ver; uint32_t payload_len; uint16_t seq; uint8_t type; };

int main() {
    // 模拟收到的 8 字节:ver=1 | len=0x12345678 | seq=0x1234 | type=2
    unsigned char buf[8] = {0x01, 0x78, 0x56, 0x34, 0x12, 0x34, 0x12, 0x02};
    NetPktRaw* p = reinterpret_cast<NetPktRaw*>(buf);
    printf("sizeof(NetPktRaw)=%zu\n", sizeof(NetPktRaw));
    printf("ver=%u payload_len=0x%08X seq=0x%04X type=%u\n",
            p->ver, p->payload_len, p->seq, p->type);
    return 0;
}
# 本机实测输出(Apple clang 21,arm64):字段全部读错
sizeof(NetPktRaw)=12
ver=1 payload_len=0x02123412 seq=0x0000 type=0
场景默认对齐的后果真实例子
网络协议结构体布局 ≠ 线上格式,字段全读错TCP/IP 头、自定义 RPC 包、MQTT 报文
二进制文件读写文件头读歪,多读/少读字节PNG 签名、BMP/WAV 文件头、存档格式
跨平台 / 跨编译器同一结构体大小不同,二进制不兼容Windows 与 Linux 互传结构体文件、嵌入式上报
鲁班提示

一句话记住本章动机:结构体是给编译器看的,字节流是给世界看的。当你需要"结构体 = 字节流"时,就得亲自动手把布局钉死——这就是 #pragma pack 存在的意义。

注意fread 整块读结构体有两个坑——除了本章要解决的填充字节,还有第 7 章讲过的字节序:本机 x86/ARM 主流是小端,网络协议规定的是大端。fread 读进来的是本机字节序,跨平台文件还得自己转换。本章先解决 padding 这半边,字节序那半边继续用第 7 章的武器。

11.2 #pragma pack 语法与有效对齐值

先把"对齐"说人话:对齐(alignment)就是数据在内存里的"门牌号"规矩——某种类型只能住在特定倍数的地址上,比如 int(4 字节)的地址必须是 4 的倍数。偏移量(offset)是成员离结构体首地址隔了几个字节。为什么要有这个规矩?因为 CPU 读多字节数据时,地址是对齐值倍数就能一次内存访问取完;地址不对齐,可能要把数据拆成两次访问再拼起来(老架构甚至直接报错)。所以编译器默认"宁多塞几个填充字节,也要让每个成员住得舒服"。

#pragma pack(n) 是一条预编译指令(编译期生效的命令,#pragma 意思是"编译器,听我说"),用来修改编译器默认的对齐系数,n 可取 1、2、4、8。素材里的规则原样保留:每个特定平台上的编译器都有自己默认的"对齐系数"。可以通过预编译命令 #pragma pack(n)(n = 1,2,4,8)来改变这一系数。若没有手动指定,编译器就会默认将成员变量中最大的类型字节数设置为对齐值 m。n 是你能施加的"上限",它和结构体自身的最大成员对齐值取较小者,才是真正生效的有效对齐值(也叫对齐单位)。

有了对齐单位,内存对齐的两条规则(素材原样保留):(1) 结构体第一个成员的偏移量为 0,以后每个成员相对于结构体首地址的 offset 都是"该成员大小与有效对齐值中较小那个"(即对齐单位)的整数倍,如有需要编译器会在成员之间加上填充字节;(2) 结构体的总大小为有效对齐值的整数倍,如有需要编译器会在最末一个成员之后加上填充字节。注意规则 (1) 比较的是"该成员自身大小"与有效对齐值,不是最大成员——比如 pack(2) 时,char 按 min(1,2)=1 对齐,int 按 min(4,2)=2 对齐。

语法的关键是成对使用#pragma pack(n) 影响它之后定义的所有结构体,直到用 #pragma pack() 恢复默认。推荐 push/pop 写法,把当前设置压栈、用完弹出来,不污染头文件里后面的结构体:

// pack 的两种写法(推荐 push/pop 成对)
#pragma pack(1)               // 写法一:直接指定,影响其后所有结构体
struct A { char c; int i; };
#pragma pack()                // 恢复默认对齐(别忘了!)

#pragma pack(push, 1)        // 写法二:当前设置压栈,再设为 1
struct B { char c; int i; };
#pragma pack(pop)             // 弹出栈顶,恢复压栈前的设置

口说无凭,上机实测。取三成员结构体 Pchar 对齐 1、int 对齐 4、short 对齐 2,最大成员对齐值 4),分别用默认、pack(1)、pack(2) 定义三个副本,用 offsetof(标准库宏,取成员偏移)量出每个成员的门牌号:

// align_measure.cpp —— 量出不同 pack 值下每个成员的偏移(本机实测)
#include <cstdio>
#include <cstddef>

struct P  { char c; int i; short s; };   // 默认:有效对齐值 = 4
#pragma pack(1)                          // 有效对齐值 = min(4, 1) = 1
struct P1 { char c; int i; short s; };
#pragma pack()
#pragma pack(2)                          // 有效对齐值 = min(4, 2) = 2
struct P2 { char c; int i; short s; };
#pragma pack()

int main() {
    printf("P (default): size=%zu, c.off=%zu, i.off=%zu, s.off=%zu\n",
           sizeof(P), offsetof(P,c), offsetof(P,i), offsetof(P,s));
    printf("P1(pack 1): size=%zu, c.off=%zu, i.off=%zu, s.off=%zu\n",
           sizeof(P1), offsetof(P1,c), offsetof(P1,i), offsetof(P1,s));
    printf("P2(pack 2): size=%zu, c.off=%zu, i.off=%zu, s.off=%zu\n",
           sizeof(P2), offsetof(P2,c), offsetof(P2,i), offsetof(P2,s));
    return 0;
}
# 本机实测输出(Apple clang 21,arm64-apple-darwin25.5.0)
P (default): size=12, c.off=0, i.off=4, s.off=8
P1(pack 1): size=7,  c.off=0, i.off=1, s.off=5
P2(pack 2): size=8,  c.off=0, i.off=2, s.off=6
@验证:有效对齐值 = min(成员自身对齐值, n),对每个成员都成立

对照规则逐行验证:pack(1) 时有效对齐值 = 1,所有成员按 1 对齐——i 直接坐在偏移 1(奇数地址!),s 在偏移 5,零填充,总大小 7;pack(2) 时有效对齐值 = 2,i 按 min(4,2)=2 对齐坐在偏移 2,s 在偏移 6,总大小 8;默认时有效对齐值 = 4,i 在偏移 4、s 在偏移 8,总大小 12。三个版本,三种布局,公式对每个成员都成立。

结构体最长成员对齐值pack(n)有效对齐值效果
4不指定4(= 最长成员对齐值)默认行为,成员按各自对齐值排
4pack(4)min(4, 4) = 4和默认完全一样
4pack(2)min(4, 2) = 2int 也只能按 2 对齐,填充减半
4pack(1)min(4, 1) = 1所有成员紧凑排列,零填充
鲁班提示

口诀就一句:有效对齐值 = min(最大成员对齐值, n)。n 越大越"松",n = 1 最"紧"。另外 pack(8) 在绝大多数 64 位平台上和默认没区别——最长成员对齐值一般不超过 8,min 之后还是它自己。

注意#pragma pack 是预编译指令,影响它之后定义的所有结构体。写完忘了 #pragma pack() 恢复默认,头文件后面别人定义的结构体全被"打包",这是经典的隐形炸弹——所以生产代码一律用 push/pop 成对写法,把影响范围锁死在两行之间。

11.3 打包后的结构体:sizeof 对比表(本机实测)

现在拿一个"网络包头风格"的结构体做完整实测。四个成员:char version(对齐 1)、int seq(对齐 4)、short len(对齐 2)、char flags(对齐 1),正好覆盖 1/4/2/1 四种对齐值,最能看出 pack 的差别。我们定义四个副本:默认、pack(1)、pack(2)、pack(4),量出每个成员的偏移和整体大小。

先心算一遍。默认:有效对齐值 = 4,version@0、seq@4、len@8、flags@10,末尾补 2 字节凑 4 的倍数,总共 12。pack(1):有效对齐值 = 1,四成员一个挨一个:0、1、5、7,总共 8——和线上格式完全一致!pack(2):有效对齐值 = 2,seq 在偏移 2、len 在 6、flags 在 8,总共 10。pack(4):有效对齐值 = min(4,4) = 4,和默认一模一样,12。下面是真实源码和真实输出:

// packet_sizes.cpp —— 同一个结构体,四种 pack 值(本机实测)
#include <cstdio>
#include <cstddef>

struct Packet0 { char version; int seq; short len; char flags; };  // 默认
#pragma pack(1)
struct Packet1 { char version; int seq; short len; char flags; };
#pragma pack()
#pragma pack(2)
struct Packet2 { char version; int seq; short len; char flags; };
#pragma pack()
#pragma pack(4)
struct Packet4 { char version; int seq; short len; char flags; };
#pragma pack()

int main() {
    printf("Packet0(default): size=%zu, v=%zu seq=%zu len=%zu flags=%zu\n",
           sizeof(Packet0), offsetof(Packet0,version), offsetof(Packet0,seq),
           offsetof(Packet0,len), offsetof(Packet0,flags));
    // Packet1/2/4 同理(量法见 11.2)
    return 0;
}
# 本机实测输出(素材当时在 ubuntu 64 位验证,标量对齐规则一致)
Packet0(default): size=12, v=0 seq=4 len=8 flags=10
Packet1(pack 1):  size=8,  v=0 seq=1 len=5 flags=7
Packet2(pack 2):  size=10, v=0 seq=2 len=6 flags=8
Packet4(pack 4):  size=12, v=0 seq=4 len=8 flags=10
结构体version 偏移seq 偏移len 偏移flags 偏移sizeof填充字节
Packet0(默认)04810124
Packet1(pack 1)015780
Packet2(pack 2)0268102
Packet4(pack 4)04810124

这张表就是本章的核心战果。pack(1) 的 Packet1 是 8 字节 = 线上 8 字节,结构体即协议,直接强转就能正确解析网络包;pack(2) 是折中——字段按 2 对齐,绝大多数 CPU 上访问更稳,只多花 2 字节;pack(4) 和默认完全一致,因为有效对齐值没变。把 11.1 节那个"读出垃圾值"的网络包用打包结构体重跑一遍,立刻药到病除:

# 同一个 8 字节缓冲区,三种解析方式(本机实测,完整源码见 11.6 模板)
sizeof(NetPktRaw)=12  sizeof(NetPkt)=8
强转未打包结构体: ver=1 payload_len=0x02123412 seq=0x0000 type=0   # 错!padding 把字段挤歪了
强转打包结构体:   ver=1 payload_len=0x12345678 seq=0x1234 type=2   # 对!布局 = 线上格式
memcpy 拷贝:       ver=1 payload_len=0x12345678 seq=0x1234 type=2   # 最稳,推荐
鲁班提示

pack(1) 让结构体大小正好等于线上字节数,这是"结构体即协议"的关键。pack(2) 是工程里的常见折中:牺牲 2 字节,换来成员都按 2 对齐——在 ARM 这类对未对齐访问较敏感的平台上是性价比很高的选择。

注意:pack(4) 的结果和默认一模一样(4 ≤ 4,min 之后没变),容易让人误以为"pack 没生效"——它生效了,只是有效对齐值没变化。判断 pack 有没有实际效果,永远看有效对齐值,而不是 n 本身。

11.4 联合体 union:共享内存的存储方式

联合体(union)说人话:所有成员共用同一块内存。struct 是"每人一间房",union 是"一间房大家轮流住"——同一时刻只有一个成员"活着",你写哪个成员,那块内存就按哪个成员的类型来解释。所以 union 的大小 = 最大成员的大小(再对齐到最大成员对齐值的整数倍),union 的对齐 = 最大成员的对齐。struct 的大小是成员大小的"加法",union 是"取最大值"。

素材里的经典例子原样保留,上机验证。union A 有三个成员:char c(1 字节,对齐 1)、int a[5](20 字节,对齐 4)、double d(8 字节,对齐 8)。最大成员大小 20,最大对齐 8——20 不是 8 的倍数,向上取整到 24,所以 sizeof(A) = 24。同样的成员放进 struct B,成员各住各的,sizeof(B) = 32。素材当年在 ubuntu 64 位验证,本机(Apple clang,arm64)实测完全一致:

// union_vs_struct.cpp —— 素材原例:union 与 struct 的存储对比(本机实测)
#include <cstdio>

union A {                        // 联合体:成员共享 24 字节
    char c;
    int a[5];
    double d;
};
struct B {                       // 结构体:成员各住各的,32 字节
    char c;
    int a[5];
    double d;
};

int main() {
    printf("union A : sizeof=%zu, alignof=%zu\n", sizeof(A), alignof(A));
    printf("struct B: sizeof=%zu, alignof=%zu\n", sizeof(B), alignof(B));
    return 0;
}
# 本机实测输出(素材验证过:ubuntu 64 位同样 sizeof(A)=24、sizeof(B)=32)
union A : sizeof=24, alignof=8
struct B: sizeof=32, alignof=8
成员类型大小对齐值union A 中偏移struct B 中偏移
cchar1100
aint[5]2040(与 c、d 重叠)4
ddouble880(与 c、a 重叠)24
整体24 / 328 / 8union 取最大值对齐到 8 的倍数;struct 成员排排坐

union 最经典的实战用法,是把同一块内存按不同视角解读——比如第 7 章的大小端判断:往 int 成员写一个数,再从 char 数组成员读出来,就看到这个整数在内存里真实的字节顺序。本机实测:写 0x01020304,读出来是 04 03 02 01——低字节在低地址,小端序(第 7 章讲过:主流 x86/ARM 都是小端,网络协议用大端,跨平台要转换):

// endian_check.cpp —— 用 union 判断大小端(复用第 7 章知识,本机实测)
#include <cstdio>

union Endian { int i; unsigned char b[4]; };

int main() {
    Endian e;
    e.i = 0x12345678;               // 写一个 int
    printf("0x12345678 在内存中: %02x %02x %02x %02x\n",
           e.b[0], e.b[1], e.b[2], e.b[3]);
    if (e.b[0] == 0x78) printf("=> 小端序:低字节在低地址\n");
    else printf("=> 大端序:高字节在低地址\n");
    return 0;
}
# 本机实测输出
0x12345678 在内存中: 78 56 34 12
=> 小端序:低字节在低地址
鲁班提示

记忆法:struct 是加法,union 是取最大。union 的大小 = 最大成员大小对齐到最大成员对齐值的整数倍——union A 最大成员 20 字节、最大对齐 8,20 对齐到 8 的倍数就是 24。这个"对齐到整数倍"的尾巴最容易漏算,动手算 union 大小前先看最大对齐值。

注意:写一个成员、读另一个成员,严格来说在标准里是未定义行为(UB,标准不保证结果)——只有读 char/unsigned char 数组这种"把内存当字节看"的用法是工程上广泛接受的惯例(大小端判断就是)。把 float 的位模式读出来重解释这类操作,C++20 之后更干净的做法是 std::bit_cast。惯例能用,但要清楚它的边界。

11.5 对齐控制的风险提示

pack(1) 省了字节,代价是什么?破坏了成员的"舒适区"int 住进了奇数地址(比如偏移 1),CPU 读它可能要把内存访问拆成两次再拼起来——这是未对齐访问(misaligned access,地址不是对齐值倍数的访问)的性能代价。x86 和 ARM64 都"允许"未对齐访问(只是慢),但一些老 ARM 内核、部分嵌入式架构会直接抛硬件异常让程序崩溃。所以 #pragma pack 是"用性能换空间/兼容",正确用法是只把 pack(1) 用在和外部字节流打交道的边界类型上,而不是全项目无脑打包。

第二层风险是移植性#pragma pack 是事实标准(MSVC 与 GCC/Clang 都认),但 __attribute__((packed)) 只有 GCC/Clang 认;不同编译器对"打包结构体"的细节处理历史上也有差异。更隐蔽的是:打包结构体整块 memcpy 进文件,换编译器、换平台后布局假设可能悄悄失效。所以二进制格式的黄金法则不是"整块拷结构体",而是"逐字段序列化 + 编译期断言钉死布局"。

第三层风险,也是本机实测最有意思的发现:对打包结构体成员取地址,编译器未必会警告。在 Apple clang 21 上,#pragma pack(1) 结构的成员取址触发任何警告,而 __attribute__((packed)) 结构的成员取址会触发 -Waddress-of-packed-member 警告。这意味着:你拿到 int* 指针后再解引用,编译器不会帮你兜底,未对齐访问的后果全在自己手里。实测如下:

// packed_addr.cpp —— 对打包结构体成员取址,编译器会说什么(本机实测)
#include <cstdio>

#pragma pack(1)
struct PackedPragma { char tag; int value; };   // 写法一:#pragma pack
#pragma pack()

struct PackedAttr {
    char tag;
    int value;
} __attribute__((packed));              // 写法二:attribute(仅 GCC/Clang)

int main() {
    PackedPragma p1;
    p1.value = 42;
    int* q1 = &p1.value;      // pragma 版:本机不告警
    printf("%d\n", *q1);

    PackedAttr p2;
    p2.value = 43;
    int* q2 = &p2.value;      // attribute 版:本机告警
    printf("%d\n", *q2);
    return 0;
}
# 编译(真实警告,Apple clang 21,-Wall -Wextra):
packed_addr.cpp:22:16: warning: taking address of packed member
      'value' of class or structure 'PackedAttr' may result in an
      unaligned pointer value [-Waddress-of-packed-member]
    22 |     int* q2 = &p2.value;
      |                ^~~~~~~~
1 warning generated.
风险表现对策
未对齐访问性能下降(一次访问拆两次);老架构/嵌入式直接崩溃pack 只用在 IO 边界类型,算完立刻拷进对齐结构体
成员取址后解引用编译器可能不告警(本机实测 pragma 版不告警),未对齐指针四处流传用 memcpy 拷到本地对象再访问,不拿打包成员的地址
跨编译器 / 跨平台布局假设悄悄失效,二进制不兼容static_assert 编译期钉死 sizeof,逐字段序列化
忘记恢复 pack污染后续所有结构体定义push/pop 成对,作用域锁死
鲁班提示

给 pack(1) 立个规矩:"边界打包,内部解包"——pack(1) 只用来描述"和外部世界交换的字节格式",数据一旦进入程序内部,立刻 memcpy 转成普通(对齐良好)的结构体,业务代码永远只碰对齐良好的对象。既享受紧凑布局,又躲开未对齐访问。

重要static_assert 是打包结构体最好的朋友——结构体定义完,立刻写一句 static_assert(sizeof(NetPkt) == 8, "布局被破坏了!")。任何平台、任何编译器改动导致布局变化,都会在编译期炸出来,而不是等程序跑到线上读出垃圾值。这是"布局假设"唯一的保险丝。

11.6 章节小结

这一章我们攥住了三件工具:默认对齐(编译器自动安排的"舒适区")、#pragma pack(n)(手动钉死布局的"卡尺")、联合体 union(共享内存的"合租屋")。三个决策问题都有了实测答案:和外部字节流打交道时手动控对齐;能 pack(2) 就不 pack(1);push/pop 成对 + static_assert 收尾

回到鲁班的方法论:工欲善其事,必先利其器。#pragma pack 是一把利器,也是凶器——用对地方(IO 边界)事半功倍,8 字节的包头一行搞定;用错地方(全项目 pack(1)、到处拿打包成员的地址)后患无穷。匠人用工具的第一原则是"知道每一件工具的脾气":pack 的脾气是"省空间、破对齐",union 的脾气是"共内存、靠自觉"。下面这张决策清单和最小安全模板,就是本章给你打的工具匣:

# 对齐控制决策清单(鲁班工具匣)
if (结构体只在程序内部使用) {
    交给默认对齐,别动;          // 编译器比你更懂 CPU 的脾气
}
else if (要与网络/文件字节流对应) {
    #pragma pack(push, 1);      // 钉死布局,结构体 = 字节流
    ...结构体定义...
    #pragma pack(pop);
    static_assert(sizeof(结构体) == 预期字节数);  // 保险丝
    解析时 memcpy 进本地对象,再访问;  // 不拿打包成员的地址
}
else (省内存且字段少、访问不频繁) {
    pack(2) 起步,别一上来就 pack(1);  // 折中优先
}
// safe_packet.cpp —— 最小安全模板:打包 + static_assert + memcpy 解包
#include <cstdio>
#include <cstring>
#include <cstdint>

#pragma pack(push, 1)
struct NetPkt {                      // 只描述线上格式
    uint8_t  ver;
    uint32_t payload_len;
    uint16_t seq;
    uint8_t  type;
};
#pragma pack(pop)
static_assert(sizeof(NetPkt) == 8, "线上格式变了?");  // 编译期保险丝

int main() {
    unsigned char buf[8] = {0x01, 0x78, 0x56, 0x34, 0x12, 0x34, 0x12, 0x02};
    NetPkt pkt;
    memcpy(&pkt, buf, sizeof(pkt));   // 边界解包:拷进对齐良好的本地对象
    printf("ver=%u len=0x%08X seq=0x%04X type=%u\n",
            pkt.ver, pkt.payload_len, pkt.seq, pkt.type);
    return 0;
}
概念一句话章节
填充字节(padding)编译器为对齐塞的空字节,是"结构体 ≠ 字节流"的根源11.1
有效对齐值min(最大成员对齐值, n),也叫对齐单位11.2
pack(1)零填充,结构体大小 = 线上字节数,结构体即协议11.3
union成员共享内存,大小 = 最大成员对齐到最大对齐值的整数倍11.4
未对齐访问性能损失甚至崩溃,pack 是"用性能换空间"11.5
static_assert编译期钉死布局假设的保险丝11.5
鲁班提示

把本章的实战流程串成一句口诀:边界打包、内部解包、编译期断言、push/pop 成对。下次再写网络协议解析、二进制文件读写,先背这句口诀,再动手写结构体。

注意:本章所有 sizeof / 偏移数字都是 Apple clang 21(arm64)本机实测,素材当年在 ubuntu 64 位(gcc)验证过,结果一致——x86-64 与 arm64 的标量对齐规则相同。但不要默认"换台机器数字不变":规则是平台相关的,换编译器、换架构后务必重新实测,并用 static_assert 把关键布局钉死。

章末练习

练习 1:有效对齐值速算 入门

一个结构体最长成员是 double(对齐值 8)。分别计算:① 不指定 pack;② pack(2);③ pack(1);④ pack(8) 时的有效对齐值各是多少?

提示

有效对齐值 = min(最大成员对齐值, n);不指定时 n 视为无穷大。

参考答案

① 8(默认取最大成员对齐值);② min(8,2) = 2;③ min(8,1) = 1;④ pack(8) 时 min(8,8) = 8,和默认一样。记住:n 只是上限,取较小者才是真正生效的对齐单位

练习 2:手算布局 进阶

结构体 struct S { char a; int b; char c; };。请手算三种情况下每个成员的偏移和 sizeof:① 默认对齐;② pack(1);③ pack(2)。再对照 11.3 节的实测方法写程序验证。

提示

套 11.2 节的两条规则:成员偏移 = 对齐单位的整数倍,总大小 = 有效对齐值的整数倍。pack(2) 时 int 只能按 min(4,2)=2 对齐。

参考答案

① 默认(有效对齐值 4):a@0,b@4,c@8,末尾补 3 字节,sizeof = 12;② pack(1):a@0,b@1,c@5,sizeof = 6;③ pack(2):a@0,b@2,c@6,sizeof = 8(6 向上取整到 2 的倍数)。用 offsetof + sizeof 实测验证,数字应完全一致。

练习 3:union 大小推理 进阶

素材原例:union A { char c; int a[5]; double d; }; 为什么 sizeof(A) = 24 而不是 20?如果把成员改成 int a[5]char c 两个(去掉 double),sizeof 又是多少?

提示

union 大小 = 最大成员大小,但要对齐到最大成员对齐值的整数倍;去掉 double 后最大对齐值变成 4。

参考答案

union A 最大成员是 int a[5](20 字节),但最大对齐值是 double 的 8——20 不是 8 的倍数,向上取整到 24。去掉 double 后,最大成员还是 20,最大对齐值变成 4,20 恰好是 4 的倍数,sizeof = 20。这就是"对齐到整数倍"的尾巴,最容易漏。

练习 4:实战——用打包结构体解析真实文件头 挑战

找一张真实的 PNG 图片。规范规定:文件以 8 字节签名 89 50 4E 47 0D 0A 1A 0A 开头,紧接着是 IHDR 块(长度 4 字节 + 类型 4 字节 + 数据 13 字节 + CRC 4 字节)。写一个程序:用 #pragma pack(1) 定义 IHDR 块头结构体,fread 读入,static_assert 钉死大小,打印出图片的宽度和高度(IHDR 数据区前 8 字节,大端序——记得用第 7 章的字节序武器转换)。

提示

IHDR 数据区 13 字节:宽 4 字节(大端)+ 高 4 字节(大端)+ 位深/颜色类型等 5 字节。大端转小端:ntohl 或手写 (b[0]<<24)|(b[1]<<16)|(b[2]<<8)|b[3]

参考答案

用 pack(1) 定义 struct IHDR { uint32_t len; char type[4]; uint32_t width; uint32_t height; } 后 fread 前 16 字节,宽度和高度按大端读出(例如宽 0x00000320 = 800、高 0x00000258 = 600 的 PNG,实测输出 width=800 height=600)。要点:pack(1) 保证 len 在偏移 0、width 在偏移 8——正好和 IHDR 布局吻合;忘了 pack,width 会被推到偏移 12 直接读错。做完你会体会到:结构体即协议,前提是先把布局钉死