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 虽然能容忍非对齐访问,但代价是性能下降;而嵌入式平台往往直接崩溃。
避坑指南: 我曾经在一个 STM32F4 项目里,用 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。

内存对齐的视觉化理解

光说概念可能有点抽象,我画了一张图,帮你直观理解对齐与非对齐的区别。

内存对齐 vs 非对齐访问对比 ✅ 对齐访问 0x00 0x04 0x08 0x0C 0x10 0x14 int a CPU 一次读取 8 字节 一次访问,数据完整 ❌ 非对齐访问 0x00 0x04 0x08 0x0C int b 第一次读取 第二次读取 需要两次访问 + 拼接 💡 关键结论 对齐访问:数据完全落在 CPU 的一次读取窗口内,效率最高。 非对齐访问:数据跨两个读取窗口,CPU 需要读两次并拼接,性能下降甚至触发异常。

编译器与对齐

你可能会问:那我写 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 浪费。

我的习惯: 写结构体时,先把所有成员按类型大小从大到小排列(double → int → short → char),这样能最大程度减少填充字节。当然,如果结构体需要与硬件寄存器一一对应,那就不能随便调整顺序了——这时候可以用 __attribute__((packed)) 强制取消填充,但代价是访问速度会变慢。

对齐与硬件平台的关系

不同平台的对齐要求不一样。我整理了一个对比表:

平台 对齐要求 非对齐访问后果
x86 / x86-64 推荐对齐,但不强制 性能下降(2-3 倍)
ARM Cortex-M (v7-M 及以上) 部分支持非对齐访问 性能下降,某些指令仍会触发异常
ARM Cortex-M0 / M0+ 强制对齐 直接 HardFault
RISC-V (RV32I) 强制对齐 触发异常
MIPS 强制对齐 触发异常

我个人建议:不管目标平台是否支持非对齐访问,一律按对齐要求来写代码。这样代码的可移植性更好,性能也更稳定。

小结一下

内存对齐不是什么高深的概念,但忽视它往往会付出惨痛代价。记住三点:

  • 自然对齐:数据地址是其大小的整数倍。
  • 对齐边界:每种数据类型都有固定的对齐要求。
  • 编译器填充:结构体成员顺序影响内存占用,合理安排顺序能省空间。