第4章 内存对齐基础:CPU为什么偏爱“整齐”的数据?

说到内存对齐,很多刚入行的朋友会觉得这是个“玄学”。

不就是变量在内存里放个位置嘛,有什么好讲究的?

嗯,我以前也这么想。直到有一次,我在一个ARM Cortex-M4的项目里,因为一个结构体没对齐,导致程序跑着跑着就硬fault了。排查了整整两天,最后发现罪魁祸首就是——一个uint32_t变量被放在了奇数地址上。

从那以后,我再也不敢小看内存对齐了。

4.1 什么是内存对齐?

说白了,内存对齐就是要求数据的存放地址,必须是它自身大小的整数倍。

举个例子:

  • char(1字节)—— 地址可以是任意值
  • short(2字节)—— 地址必须是2的倍数
  • int(4字节)—— 地址必须是4的倍数
  • double(8字节)—— 地址必须是8的倍数

这个“必须是N的倍数”的地址,就叫自然对齐地址。

核心定义:如果一个数据的地址 % 数据大小 == 0,那它就是对齐的。否则,就是未对齐。

你想想看,CPU从内存里读数据,不是一次读一个字节的。它是一次读4个字节(32位机)或者8个字节(64位机)。

如果数据刚好落在一个“读批次”里,那一次就读完了。如果数据跨了两个批次,那就得读两次,再拼起来。

这就是对齐和不对齐的本质区别。

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

这个问题,我换个角度给你讲。

假设你是一个快递员,你的快递箱一次只能装4个包裹。现在有一个大件货物,它占了4个包裹的位置。

如果这个货物刚好放在一个箱子里,你一次就搬走了。

但如果它横跨了两个箱子呢?你得先搬第一个箱子,取出前半部分,再搬第二个箱子,取出后半部分,然后拼在一起。

CPU读内存,就是这个道理。

访问方式 地址示例 读取次数 额外操作
对齐访问 0x1000(4字节int) 1次 无
未对齐访问 0x1001(4字节int) 2次 移位+拼接

为什么CPU喜欢对齐?因为硬件设计上,对齐访问就是“直通”的。

地址总线、数据总线、控制信号,全部都是为对齐访问优化的。不对齐的话,内存控制器得额外做一堆工作:先读前半段,再读后半段,再拼起来。这不仅仅是慢,还增加了硬件的复杂度。

我个人习惯:在设计结构体时,我会刻意把大尺寸的成员往前放。比如把uint32_t、uint64_t放在前面,uint8_t、char放在后面。这样编译器填充的padding最少,内存利用率最高。

4.3 不对齐的后果

不对齐的后果,分三个层次:

4.3.1 性能下降

这是最轻的后果。就像我刚才说的,一次能搞定的事,非要搞两次。

在x86架构上,不对齐访问只是慢一点。但在ARM、RISC-V这些嵌入式常用架构上,情况就没那么乐观了。

4.3.2 硬件异常(硬fault)

我曾经在一个STM32F4的项目里,用了一个外部的传感器数据缓冲区。数据是从SPI DMA搬进来的,地址是随机的。我直接用指针强转成uint32_t*去读——结果程序直接飞了。

为什么?因为ARM Cortex-M系列默认不允许不对齐访问。一旦检测到,直接触发UsageFault。

避坑指南:在ARM Cortex-M上,如果你必须处理不对齐的数据,要么开启UNALIGN_TRP(但会触发异常),要么用memcpy逐字节拷贝。千万别直接强转指针!

4.3.3 原子操作失效

这个坑更深一些。在多任务或者中断环境下,我们经常用原子操作来保护共享变量。

但原子操作(比如LDREX/STREX)要求地址必须对齐。如果地址不对齐,原子操作的行为是未定义的。

换句话说,你辛辛苦苦写的锁,可能根本就没锁住。

4.4 内存对齐的底层原理(SVG图解)

下面这张图,把对齐和不对齐的访问过程画清楚了。

内存对齐 vs 未对齐访问对比 对齐访问(地址0x1000) 一次读取:0x1000 ~ 0x1003 0x1000 0x1001 0x1002 0x1003 1次读取,直接返回 未对齐访问(地址0x1001) 需要两次读取 + 拼接 0x1000 0x1001 0x1002 0x1003 第1次:读0x1000~0x1003 第2次:读0x1004~0x1007 取第1次的高3字节 + 第2次的低1字节 → 拼接 结论 对齐访问:1次内存事务,无额外开销 未对齐访问:2次内存事务 + 移位拼接,性能下降50%以上

4.5 编译器与内存对齐

好消息是,大多数情况下,编译器会帮我们自动处理对齐。

比如你定义一个结构体:

struct example {
    char a;      // 1字节
    int b;       // 4字节
    short c;     // 2字节
};

编译器会在a后面填充3个字节的padding,让b对齐到4字节边界。结构体总大小变成12字节,而不是7字节。

但有些时候,编译器不会帮你处理:

  • 指针强转:你把一个char*强转成uint32_t*,编译器认为你知道自己在做什么,它不干预。
  • packed结构体:你主动用了__attribute__((packed)),告诉编译器“别给我填充,我要紧凑”。
  • 从网络或文件读入的缓冲区:数据是流式的,地址可能任意。

我建议:在嵌入式开发中,尽量让编译器帮你做对齐。除非你明确知道自己在做什么,否则不要轻易用packed。如果必须处理不对齐的数据,用memcpy逐字节拷贝到对齐的变量里,这是最安全的方式。

4.6 不同架构的对齐策略

架构 不对齐访问 后果 推荐做法
x86 / x86-64 允许(但慢) 性能下降2~3倍 尽量对齐,但不会崩溃
ARM Cortex-M 默认禁止 触发UsageFault 必须对齐,或用memcpy
ARM Cortex-A 可配置 可能异常或性能下降 开启对齐检查,避免踩坑
RISC-V 部分支持 可能触发异常 参考手册,通常建议对齐

你看,除了x86这个“老油条”能容忍不对齐,其他架构基本都不惯着。

所以,在嵌入式开发里,默认就按对齐来写,这是最省心的习惯。

4.7 本章小结

内存对齐不是什么高深的理论,它就是CPU的一个“洁癖”——数据必须放在整齐的位置上。

  • 对齐:地址 % 大小 == 0,一次读取,性能拉满。
  • 不对齐:两次读取 + 拼接,性能打折,甚至直接崩掉。
  • 编译器:大部分时候帮你搞定,但指针强转和packed结构体是雷区。
  • 架构差异:x86能忍,ARM/RISC-V不能忍,嵌入式开发按最严格的来。

记住一句话:能对齐,就别不对齐。这不是教条,是我踩过坑之后的血泪经验。