1. 内存对齐基础:对齐、边界与自然对齐
各位同学,咱们今天聊内存对齐。说实话,这玩意儿我早年做嵌入式时根本没当回事——直到有一次,一个跑在 ARM Cortex-M4 上的传感器采集程序,莫名其妙每隔几秒就卡顿一下。查了三天,最后发现是一个未对齐的 uint32_t 变量在作怪。从那以后,我写结构体之前都会先画个内存布局草图。
好,咱们从头捋一遍。
什么是内存对齐?
内存对齐,说白了就是:数据在内存中的存放地址,必须满足某种“对齐边界”的约束。比如一个 4 字节的 int,它的起始地址必须是 4 的倍数;一个 8 字节的 double,起始地址必须是 8 的倍数。
你想想看,CPU 从内存读数据,不是一次读一个字节,而是一次读一个字(比如 4 字节或 8 字节)。如果数据刚好落在 CPU 的“读取窗口”内,一次内存访问就能搞定。如果数据跨了两个窗口,CPU 就得读两次,甚至还要做移位和拼接操作。
为什么需要内存对齐?
原因其实就两条:性能 和 硬件强制。
- 性能原因:对齐的数据,CPU 一次内存访问就能拿到。不对齐的数据,轻则多读一次内存,重则触发异常处理,性能直接打折扣。我在项目中实测过,一个未对齐的
float数组遍历,比对齐版本慢了将近 2.5 倍。 - 硬件强制:有些处理器(比如早期的 ARM、MIPS)干脆不支持非对齐访问。你一旦写了未对齐的地址,直接触发
HardFault或Bus Error。嗯,这里要注意——x86 虽然能容忍非对齐访问,但代价是性能下降;而嵌入式平台往往直接崩溃。
memcpy 把一个字节数组强制转换成 uint32_t* 来读取。结果程序跑着跑着就进 HardFault 了。后来才发现,那个字节数组的起始地址是奇数,根本没法对齐。从那以后,我所有强制类型转换前都会先检查地址对齐。
对齐边界与自然对齐
咱们把这两个概念拆开讲。
对齐边界,也叫对齐粒度。它指的是数据存放地址必须满足的倍数条件。比如:
| 数据类型 | 大小(字节) | 对齐边界 |
|---|---|---|
char |
1 | 1 字节对齐(任意地址) |
short |
2 | 2 字节对齐(地址为偶数) |
int / float |
4 | 4 字节对齐(地址能被 4 整除) |
double / long long |
8 | 8 字节对齐(地址能被 8 整除) |
void*(32 位) |
4 | 4 字节对齐 |
void*(64 位) |
8 | 8 字节对齐 |
自然对齐,就是数据存放在它“天然”的对齐边界上。比如一个 int 放在地址 0x2000_0004,这就是自然对齐。如果放在 0x2000_0002,那就是非对齐。
我个人习惯用一个简单的宏来判断是否对齐:
#define IS_ALIGNED(addr, align) (((uintptr_t)(addr) & ((align) - 1)) == 0)
这个宏的原理很简单:如果地址的低 align-1 位全是 0,说明地址是 align 的倍数。比如 align=4,就看地址的低 2 位是不是 0。
内存对齐的视觉化理解
光说概念可能有点抽象,我画了一张图,帮你直观理解对齐与非对齐的区别。
编译器与对齐
你可能会问:那我写 C 代码的时候,编译器会帮我处理对齐吗?答案是:会,但不完全会。
编译器在分配结构体成员时,会自动插入填充字节(padding)来保证每个成员都对齐。但这也带来了一个副作用——结构体的大小可能比你想象的要大。
举个例子:
// 结构体 A
struct A {
char c; // 1 字节
int i; // 4 字节
short s; // 2 字节
};
// 结构体 B(调整顺序)
struct B {
int i; // 4 字节
short s; // 2 字节
char c; // 1 字节
};
你猜猜这两个结构体的大小分别是多少?
| 结构体 | 成员大小总和 | 实际大小(含填充) | 浪费空间 |
|---|---|---|---|
struct A |
1 + 4 + 2 = 7 字节 | 12 字节 | 5 字节 |
struct B |
4 + 2 + 1 = 7 字节 | 8 字节 | 1 字节 |
看到了吗?仅仅调整了成员的声明顺序,结构体大小就从 12 字节降到了 8 字节。在嵌入式系统中,如果结构体实例有成千上万个,这个差距就是几十 KB 的 RAM 浪费。
__attribute__((packed)) 强制取消填充,但代价是访问速度会变慢。
对齐与硬件平台的关系
不同平台的对齐要求不一样。我整理了一个对比表:
| 平台 | 对齐要求 | 非对齐访问后果 |
|---|---|---|
| x86 / x86-64 | 推荐对齐,但不强制 | 性能下降(2-3 倍) |
| ARM Cortex-M (v7-M 及以上) | 部分支持非对齐访问 | 性能下降,某些指令仍会触发异常 |
| ARM Cortex-M0 / M0+ | 强制对齐 | 直接 HardFault |
| RISC-V (RV32I) | 强制对齐 | 触发异常 |
| MIPS | 强制对齐 | 触发异常 |
我个人建议:不管目标平台是否支持非对齐访问,一律按对齐要求来写代码。这样代码的可移植性更好,性能也更稳定。
小结一下
内存对齐不是什么高深的概念,但忽视它往往会付出惨痛代价。记住三点:
- 自然对齐:数据地址是其大小的整数倍。
- 对齐边界:每种数据类型都有固定的对齐要求。
- 编译器填充:结构体成员顺序影响内存占用,合理安排顺序能省空间。