什么是内存对齐
1. 概览
内存对齐是指数据在内存中存储时,需按照特定规则进行排列,从而使得数据的起始地址满足一定的对齐要求。具体来说就是计算机在访问内存时,通常以固定大小的块(如4字节、8字节)为单位进行数据读取。如果数据没有按照这些块的大小对齐,可能会导致CPU需要多次访问内存,从而降低效率。
简单来说,CPU 访问内存时,并不是一个字节一个字节地读取,而是一次性读取一个 "块"(word
),这个块的大小通常是 4 字节(32位系统)或 8 字节(64位系统)。内存对齐就是确保一个变量的起始地址,能够让 CPU 在一次读取操作中就获取到完整的数据。
每个数据类型都有一个对齐要求(Alignment Requirement)。你可以使用 C++11 中的 alignof
操作符来查询一个类型的对齐要求。
#include <iostream>
int main() {
std::cout << "alignof(char): " << alignof(char) << std::endl; // 通常是 1
std::cout << "alignof(short): " << alignof(short) << std::endl; // 通常是 2
std::cout << "alignof(int): " << alignof(int) << std::endl; // 通常是 4
std::cout << "alignof(double): " << alignof(double) << std::endl; // 通常是 8
std::cout << "alignof(void*): " << alignof(void*) << std::endl; // 通常是 4 或 8 (取决于架构)
return 0;
}
alignof(T)
为N
意味着类型T
的变量地址必须是 N 的倍数。char
的地址可以是任意字节地址。short
的地址必须是 2 的倍数 (例如 0, 2, 4, ...)。int
的地址必须是 4 的倍数 (例如 0, 4, 8, ...)。
2. 为什么要对齐
主要有两个原因:性能 和 硬件限制。
2.1 提升性能
这是现代计算机上为什么需要内存对齐的最主要的原因。假设一个 32 位 CPU,它一次可以读取 4 个字节(地址 0x00
到 0x03
,0x04
到 0x07
,以此类推)。
对齐的情况: 如果你要读取一个 4 字节的
int
,且它存放在地址0x04
。CPU 只需进行一次内存读取操作,就能从0x04
到0x07
获取全部数据。这非常高效。内存地址: | 00 01 02 03 | 04 05 06 07 | 08 09 0A 0B | CPU 读取: [--一次读取--] 数据 (int): [ D A T A ]
未对齐的情况: 如果这个
int
存放在地址0x02
。它的数据横跨了两个 CPU 读取块 (0x00-0x03
和0x04-0x07
)。内存地址: | 00 01 02 03 | 04 05 06 07 | CPU 读取: [--读取1--] [--读取2--] 数据 (int): [ D A | T A ]
为了获取这个
int
,CPU 必须:- 执行第一次内存读取(地址
0x00-0x03
),并提取后两个字节。 - 执行第二次内存读取(地址
0x04-0x07
),并提取前两个字节。 - 将两次读取的结果拼接起来,才能得到完整的
int
数据。
显然,这个过程比一次读取要慢得多。因此,为了性能,编译器会默认进行内存对齐。
- 执行第一次内存读取(地址
2.2 硬件限制
在一些(尤其是较旧的)硬件架构上,比如RISC 架构(SPARC、MIPS),根本就不支持未对齐的内存访问。如果尝试进行未对齐的地址访问,会直接触发硬件异常(通常是总线错误,Bus Error),导致程序崩溃。虽然 x86/x64 架构容忍未对齐访问(以性能为代价),但为了代码的可移植性,遵循对齐规则是最佳实践。
3 内存对齐的主要规则
当处理结构体(struct
)或类(class
)时,编译器会遵循以下两个核心规则来保证数据访问地址的对齐,这个过程通常通过插入不可见的 填充字节(Padding) 来完成。
规则1:结构体中的每个成员的 offset 必须是其自身对齐要求的整数倍;
注
offset: 成员的起始地址相对于结构体起始地址的偏移量。
规则2:结构体的总大小必须是所有成员中最大对齐要求的整数倍,这样做是为了确保当结构体作为数组元素时,每个元素的起始地址都是最大对齐要求的整数倍,从而避免访问未对齐的内存;
例外
上述是编译器的默认内存对齐规则,在某些情况下,编译器会允许开发者通过一些特殊的指令或属性来改变默认的对齐规则。例如,在 C++ 中,你可以使用 #pragma pack
指令来指定结构体的对齐方式。
#pragma pack(1)
struct MyStruct {
char a;
int b;
char c;
};
#pragma pack()
在这个例子中,#pragma pack(1)
指令告诉编译器使用 1 字节对齐,而不是默认的 4 字节对齐。因此,MyStruct
的大小将是 6 字节,而不是 8 字节。
需要注意的是,使用 #pragma pack
指令会改变结构体的对齐要求,可能会影响性能。因此,在使用时需要谨慎,确保不会引入对齐问题。
查看样例
让我们通过一个具体的例子来理解这两个规则。
考虑以下结构体:
struct MyStruct {
char a; // 1 字节, alignof(char) = 1
int b; // 4 字节, alignof(int) = 4
short c; // 2 字节, alignof(short) = 2
};
按对齐规则分析
- 成员
a
(char):- 对齐要求是 1。
- 放置在结构体起始位置,偏移量为 0。
0 % 1 == 0
,满足要求。 - 当前占用空间:
[a]
(1 字节)。
- 成员
b
(int):- 对齐要求是 4。
- 下一个可用偏移量是 1。但
1 % 4 != 0
,不满足要求。 - 编译器必须向后寻找第一个是 4 的倍数的偏移量,即 4。
- 因此,在
a
和b
之间插入 3 个填充字节。 b
被放置在偏移量 4 的位置。- 当前占用空间:
[a, pad, pad, pad, b, b, b, b]
(8 字节)。
- 成员
c
(short):- 对齐要求是 2。
- 下一个可用偏移量是 8。
8 % 2 == 0
,满足要求。 c
被放置在偏移量 8 的位置。- 当前占用空间:
[a, pad, pad, pad, b, b, b, b, c, c]
(10 字节)。
- 应用规则 2 (整体对齐):
- 结构体中最大的对齐要求是
alignof(int)
,即 4。 - 当前总大小为 10 字节。
10 % 4 != 0
,不满足要求。 - 编译器必须在结构体末尾添加填充字节,使总大小成为 4 的倍数。下一个 4 的倍数是 12。
- 因此,在
c
之后添加 2 个填充字节。 - 最终总大小为 12 字节。
- 结构体中最大的对齐要求是
所以,sizeof(MyStruct)
的结果是 12,而不是天真的 1 + 4 + 2 = 7
。
示例代码验证:
#include <iostream>
#include <cstddef> // for offsetof
struct MyStruct {
char a;
int b;
short c;
};
int main() {
std::cout << "sizeof(MyStruct): " << sizeof(MyStruct) << std::endl;
std::cout << "alignof(MyStruct): " << alignof(MyStruct) << std::endl;
std::cout << "Offset of a: " << offsetof(MyStruct, a) << std::endl;
std::cout << "Offset of b: " << offsetof(MyStruct, b) << std::endl;
std::cout << "Offset of c: " << offsetof(MyStruct, c) << std::endl;
return 0;
}
输出结果:
sizeof(MyStruct): 12
alignof(MyStruct): 4
Offset of a: 0
Offset of b: 4
Offset of c: 8