写这篇文章的起因是我自己踩过的一个坑——某次用一个结构体存配置,里头明明只有几个 char 和 int,算出来也就是二十来字节的事,结果 sizeof 一打印差点以为代码写错了。后来翻了内存布局才发现,编译器往结构体里塞了一大堆“看不见的空洞”。这类问题往浅了说只是个 sizeof 对不上,往深了说涉及 CPU 读取内存的方式、结构体序列化、网络协议解析、甚至 AI 框架里张量分配的内存对齐策略。今天就把这块一次性说透,不绕弯子,直接讲清楚这些“凭空多出来的空间”到底是怎么来的,以及怎么在代码里把它们管住。
1. 内存对齐到底是什么:CPU取数规则与“地址格子”
1.1 为什么CPU读内存不是按字节随便读
很多人习惯把内存想象成一个大号的数组,一个字节一个字节连续排列,CPU 想读哪个地址就能直接读哪个地址。理论上确实是这样,但实际硬件并不是这么工作的。CPU 和内存之间通过总线传输数据,总线宽度决定了 CPU 一次能取多少字节。现代 64 位 CPU 从内存读数据时,一般以 8 字节为一个访问粒度,而 CPU 内部的缓存(L1、L2、L3)更狠,是按 64 字节的缓存行(cache line)来管理的。换句话说,硬件真正提供给你的“最小完整读写单元”并不是 1 个字节,而是 4、8、16、64 这样的一整段。
这就好像去快递柜取件,柜子的格口是固定大小的,你取件的时候只能按格口整体操作。如果一件货物横跨了两个格口,你就得先开这个柜门、再开那个柜门,把两个格口里的东西都取出来才能拼出一件完整的货。CPU 读内存也是这个道理:如果一个 4 字节的 int 恰好从内存地址 1 开始放,那么它的一部分在第一个 4 字节格子里,另一部分在第二个格子里,CPU 就必须做两次访问,再把拼接结果返回给寄存器。在 x86 平台的早期设计里,这种事虽然能做,但会额外多花好几个时钟周期;放到 ARM 等 RISC 平台上更严格,不少指令遇到未对齐访问会直接触发异常,程序当场崩掉。
结论很简单:为了让 CPU 一次就能把数据取完整、不产生“跨格口拼接”,数据在内存里的起始地址就必须满足一定的约束,比如 4 字节的数据从能被 4 整除的地址开始放,8 字节的数据从能被 8 整除的地址开始放。这种约束就是所谓的内存对齐。
1.2 对齐值、自然对齐与“末尾补齐”的含义
每个类型都有一个对齐值(alignment),表示这个类型的对象在内存里起始地址必须满足的倍数要求。C 和 C++ 标准里,最直观的规则是:一个类型的对齐值通常不会超过它自身的大小,比如 char 的对齐值是 1,int 是 4,double 在 64 位平台上是 8。当一个变量的地址等于它对齐值的整数倍时,就说它处于自然对齐状态。
但结构体的对齐规则要比单变量复杂一点,因为结构体里塞了多个不同类型的成员。编译器为了让每个成员都能满足它自己的对齐要求,会在成员之间插入一些不使用的填充字节;又为了让结构体数组能连续排列且每个元素都保持相同的对齐性质,还会在结构体末尾补上一些字节,保证整个结构体的大小是它“最大成员对齐值”的整数倍。这些填充字节就是你看到的“凭空多出来的空间”,它们既不是任何一个成员的数据,也完全不参与业务逻辑,但实实在在地占据内存。
理解这个概念,不能只停留在“编译器会帮我处理好”这个层面。一旦结构体要被写到文件、通过网络传输、或者被两种语言以不同编译器编译后互相解析,这些填充字节就会变成一颗定时炸弹,因为新旧版本、不同平台对填充规则的理解可能并不一致。接下来我们用一个具体例子来看这些空洞是怎么被一步步算出来的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 结构体里的“空洞”:一个例子算出凭空多出的空间
2.1 结构体大小为什么不是成员大小之和
先看一段非常常见的代码:
c复制#include <stdio.h>
#include <stddef.h>
struct S {
char a; // 1 字节
int b; // 4 字节
char c; // 1 字节
double d; // 8 字节
};
int main() {
printf("sizeof(struct S) = %zu\n", sizeof(struct S));
printf("offsetof(a) = %zu\n", offsetof(struct S, a));
printf("offsetof(b) = %
