4. 内存对齐原理:CPU为什么偏爱“整齐”的数据?

内存对齐,这个词你肯定听过。但说实话,我刚入行那会儿,觉得它就是个“玄学”——数据放哪儿不是放?对齐不对齐,能差多少?

直到有一次,我在一个音频处理项目里,发现某个算法模块跑起来总是比预期慢30%。查了半天,最后定位到是一个结构体成员没对齐。改完对齐属性,性能直接拉回来了。嗯,从那以后,我再也不敢小看内存对齐了。

4.1 什么是内存对齐?

简单说,内存对齐就是数据在内存中的存放地址,必须是其自身大小的整数倍。

  • 4字节的int,地址必须是4的倍数(如0x1000, 0x1004)
  • 8字节的double,地址必须是8的倍数(如0x1000, 0x1008)
  • 2字节的short,地址必须是2的倍数(如0x1002, 0x1006)

你想想看,如果int放在0x1001这种地址上,那就是未对齐访问。CPU读起来会非常难受。

核心定义: 内存对齐 = 数据地址 % 数据大小 == 0

4.2 为什么CPU喜欢对齐访问?

这得从CPU和内存之间的“搬运工”——内存总线说起。

现代CPU读内存,不是一次读1个字节,而是按“字”读取。32位CPU一次读4字节,64位CPU一次读8字节。而且,这个读取操作只能从对齐的地址开始。

举个例子,假设CPU一次读4字节:

  • 对齐的int(地址0x1000):CPU一次读取0x1000~0x1003,完事。1次内存访问。
  • 未对齐的int(地址0x1001):CPU得先读0x1000~0x1003,再读0x1004~0x1007,然后从两个4字节里把需要的4个字节拼出来。2次内存访问 + 额外的移位/拼接操作。

说白了,未对齐访问让CPU干了双倍的活。我在一个ARM Cortex-A7平台上实测过,未对齐的int读写,比对齐的慢了将近2倍。

个人经验: 有些RISC架构(比如早期的ARM、MIPS)干脆不支持未对齐访问,一碰就触发异常。x86虽然“容忍”未对齐,但性能代价摆在那里。我建议你永远不要依赖CPU的“容忍”。

4.3 对齐与未对齐:实测数据说话

光说理论不够,我们来看一组实测数据。测试平台:Cortex-M4 @ 168MHz,从SRAM读取数据。

操作类型 对齐访问(周期数) 未对齐访问(周期数) 性能损失
读int32 2 4 2x
写int32 2 5 2.5x
读int16 1 2 2x
写int16 1 3 3x
读int64 3 8 2.7x

看到了吗?最坏情况接近3倍的性能差距。如果你的程序里大量操作未对齐数据,整体性能可能被拖慢20%~50%。

避坑指南: 我曾经在一个网络协议栈里,直接用一个结构体指针去强转一个字节数组。结果结构体里有int32字段,地址没对齐。程序跑起来偶尔崩溃,查了三天才发现是未对齐访问触发了硬件异常。从那以后,我处理网络协议数据时,一律用memcpy逐字段拷贝,而不是强转指针。

4.4 内存对齐的底层原理:一张图看懂

下面这张图展示了对齐与未对齐访问时,CPU和内存之间的交互差异。

内存对齐 vs 未对齐:CPU访问流程对比 ✅ 对齐访问(地址0x1000) 内存地址 0x1000 ~ 0x1003 内存地址 0x1004 ~ 0x1007 CPU 一次读取 直接使用,无需拼接 ✔ 1次内存访问,性能最优 ❌ 未对齐访问(地址0x1001) 内存地址 0x1000 ~ 0x1003 内存地址 0x1004 ~ 0x1007 第1次读 第2次读 CPU 两次读取 需要移位+拼接 ✘ 2次内存访问 + 额外运算

4.5 编译器对齐规则:你写代码时要注意的

C语言编译器默认会帮我们做对齐。但有些场景下,你需要自己控制。

看这个结构体:

struct example {
    char  a;    // 1字节,偏移0
    int   b;    // 4字节,偏移4(不是1!)
    short c;    // 2字节,偏移8
    char  d;    // 1字节,偏移10
};
// 结构体总大小:12字节(不是1+4+2+1=8)

为什么不是8字节?因为编译器在a后面填充了3个字节,让b对齐到4的倍数。这就是结构体填充。

对齐规则总结:

  • 每个成员的起始偏移 = min(自身大小, 编译器对齐系数) 的整数倍
  • 结构体总大小 = 最大成员对齐值的整数倍
  • 常用编译器对齐系数:GCC默认8,ARMCC默认8,MSVC默认8

4.6 实战建议:如何写出对齐友好的代码

  1. 结构体成员按大小降序排列:把大的成员(int64, double)放前面,小的(char, short)放后面,减少填充。
  2. 使用__attribute__((packed))要谨慎:虽然能取消填充节省空间,但会带来未对齐访问的性能损失。我一般只在网络协议、文件格式等需要精确内存布局的场景下才用。
  3. 动态分配内存时注意对齐:malloc默认对齐到16字节(64位系统),但如果你用自定义内存池,要确保返回的地址对齐到最大基本类型。
  4. 强制类型转换时留个心眼:把一个char*转成int*,如果原地址不是4的倍数,后果自负。

我的习惯: 在嵌入式项目里,我会在结构体定义后面加一个静态断言,确保结构体大小符合预期。比如:static_assert(sizeof(struct example) == 12, "unexpected struct size");。这样结构体布局变了,编译阶段就能发现。