写这篇东西的起因,是我有一次在评审一个网络协议结构体时,同事突然盯着代码问:“为什么这个结构体定义里只写了 3 个字段,sizeof 却告诉我它占了 12 个字节?那 9 个字节去哪了?” 我一看,就是经典的 char + int + char 组合,在默认对齐规则下输出 12。他不是没见过内存对齐,只是那一刻突然觉得,这多出来的空间仿佛是“凭空冒出来”的。其实内存对齐不仅不“凭空”,它是 CPU、编译器、缓存体系共同谈判出来的结果。这篇文章就打算把这个“凭空多出来”的空间彻底讲透:它从哪来、凭什么占位、怎么算、怎么控制,以及一个现在特别热的话题——深度学习中张量的内存对齐是怎么回事。不管你是写 C/C++、做嵌入式、搞网络协议,还是在折腾推理框架底层优化,这篇都能给你一些能直接上手的结论。
1. 内存对齐的本质:不是浪费,是性能和安稳之间的“找平衡”
1.1 一个结构体引发的疑问
先回到那个经典例子。假设你有这么一段结构体定义:
c复制struct Packet {
char type; // 1 字节
uint32_t length; // 4 字节
char checksum; // 1 字节
};
按照常理,1 + 4 + 1 = 6,可你在 x86_64 的 Linux 上用 sizeof(struct Packet) 一打印,结果是 12。你要是再逐个打印成员偏移量,会发现 length 的偏移是 4,而 checksum 的偏移是 8,type 后面整整空了 3 个字节。这三个字节看起来没用,但它们就是编译器硬垫进去的“填充”。很多人第一次遇到时都怀疑是不是编译器开了什么奇怪的优化,其实这只是默认的对齐规则在起作用。
这个现象在所有主流 C/C++ 编译器里都存在,而且不是某一个平台的毛病,是整个体系刻意设计的结果。想要理解它,就要先搞清楚一个问题:CPU 到底是怎么从内存里取数据的。
1.2 CPU 为什么非要数据“站得整整齐齐”
我们经常说 CPU 是 64 位、32 位,这个“位”不只是寄存器宽度,还决定了内存控制器和总线一次能搬运多少数据。以 64 位系统为例,CPU 和内存之间的数据通路一般是 64 位,也就是 8 字节,每次访存都按“8 字节一块”来读。更进一步,CPU 和内存之间还有多级缓存,而缓存和内存交换的最小单位是 cache line,通常是 64 字节。也就是说,内存从硬件角度看,是一个被切成固定大小格子的大仓库,CPU 一次取东西的粒度是固定的。
这时候就出现了一个问题:假设一个 8 字节的 double 变量,它从地址 3 开始放,那么它就会横跨两个 8 字节的块。CPU 想要完整拿到它,就得发出两次内存读取请求,再把两个块里各自的一部分拼起来。如果这个变量恰好跨了 cache line,麻烦更大:一次读取要加载两个 cache line 到缓存里,然后在缓存层合并。这不光是慢,在一些精简指令集架构上,某些指令遇到这种非对齐访问直接触发异常,根本不跟你商量。
所以我们说的“对齐”,本质上就是让每个变量的起始地址,恰好落在一块内存的“整数倍”位置上,让 CPU 一次就能把整个数据读出来。为了做到这一点,编译器必须在成员之间插入一些没人用的空洞字节,把下一个成员“推到”合适的位置。这些空洞,就是用户眼里的“凭空多出的空间”。
你可以把内存想象成按固定间隔摆好的停车位,车位线就是对齐边界。一个变量如果正好停在自己的车位里,后车也能顺利停进下一个车位;如果它斜着停在两个车位中间,谁都停不舒服,而且后车一定要绕开它才能停。对齐就是在给每个变量“画停车线”,代价是线之间的间隙不能被车用,但换来的是每一辆车都能一次停稳、一次开走。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 结构体大小的计算:三条规则加两个手算案例
2.1 三条硬性对齐规则
很多文章一上来就抛公式,但我想先把规则拆开讲透,因为这三条规则是所有结构体大小计算的根。第一条是关于成员偏移量的:结构体里每个成员,它的偏移地址必须是“该成员自身对齐值”和“当前编译器对齐系数”二者中较小那个的整数倍。所谓对齐值,对内置类型来说一般就是它自身的大小,比如 int 是 4、double 是 8,编译器会在成员前面自动插入填充字节来满足这个条件。
第二条是关于结构体总大小的:结构体
