第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图解)
下面这张图,把对齐和不对齐的访问过程画清楚了。
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不能忍,嵌入式开发按最严格的来。
记住一句话:能对齐,就别不对齐。这不是教条,是我踩过坑之后的血泪经验。