编译器在结构体里偷偷塞的填充字节,平时是体贴,关键时刻是陷阱。这一章学会用 #pragma pack 把布局钉死、用联合体共享内存——网络包、文件头、跨平台结构体,从此手到擒来,每个数字都上机实测
核心方法论:工欲善其事
「工欲善其事,必先利其器。对齐这事,编译器默认帮你安排得妥妥帖帖——可一旦你的结构体要出门见人,上网络、落磁盘、跨平台,它自作主张塞的那些填充字节就成了绊脚石。这一章我们把『布局』这把尺子攥在自己手里:#pragma pack 怎么用、联合体怎么省内存,每一个数字都上机实测,量给你看。」
第 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 章的武器。
先把"对齐"说人话:对齐(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) // 弹出栈顶,恢复压栈前的设置
口说无凭,上机实测。取三成员结构体 P(char 对齐 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(= 最长成员对齐值) | 默认行为,成员按各自对齐值排 |
| 4 | pack(4) | min(4, 4) = 4 | 和默认完全一样 |
| 4 | pack(2) | min(4, 2) = 2 | int 也只能按 2 对齐,填充减半 |
| 4 | pack(1) | min(4, 1) = 1 | 所有成员紧凑排列,零填充 |
口诀就一句:有效对齐值 = min(最大成员对齐值, n)。n 越大越"松",n = 1 最"紧"。另外 pack(8) 在绝大多数 64 位平台上和默认没区别——最长成员对齐值一般不超过 8,min 之后还是它自己。
注意:#pragma pack 是预编译指令,影响它之后定义的所有结构体。写完忘了 #pragma pack() 恢复默认,头文件后面别人定义的结构体全被"打包",这是经典的隐形炸弹——所以生产代码一律用 push/pop 成对写法,把影响范围锁死在两行之间。
现在拿一个"网络包头风格"的结构体做完整实测。四个成员: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(默认) | 0 | 4 | 8 | 10 | 12 | 4 |
| Packet1(pack 1) | 0 | 1 | 5 | 7 | 8 | 0 |
| Packet2(pack 2) | 0 | 2 | 6 | 8 | 10 | 2 |
| Packet4(pack 4) | 0 | 4 | 8 | 10 | 12 | 4 |
这张表就是本章的核心战果。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 本身。
联合体(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 中偏移 |
|---|---|---|---|---|---|
| c | char | 1 | 1 | 0 | 0 |
| a | int[5] | 20 | 4 | 0(与 c、d 重叠) | 4 |
| d | double | 8 | 8 | 0(与 c、a 重叠) | 24 |
| 整体 | — | 24 / 32 | 8 / 8 | union 取最大值对齐到 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。惯例能用,但要清楚它的边界。
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, "布局被破坏了!")。任何平台、任何编译器改动导致布局变化,都会在编译期炸出来,而不是等程序跑到线上读出垃圾值。这是"布局假设"唯一的保险丝。
这一章我们攥住了三件工具:默认对齐(编译器自动安排的"舒适区")、#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 把关键布局钉死。
一个结构体最长成员是 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 只是上限,取较小者才是真正生效的对齐单位。
结构体 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 实测验证,数字应完全一致。
素材原例: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。这就是"对齐到整数倍"的尾巴,最容易漏。
找一张真实的 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 直接读错。做完你会体会到:结构体即协议,前提是先把布局钉死。