layout | title | date | categories |
---|---|---|---|
post |
内存对齐 |
2018-04-01 09:00:05 +0800 |
内存对齐 |
本机器 64 位操作系统
对时间和空间的双重优化,比如 64 位系统数据总线大小为 64 位,使用内存对齐方案就可以一次从内存中加载更多数据,比如 64 位中可以存放 char[4] + int
。CPU 中的高速缓存是非常小的,笔者当前电脑 CPU 是 4 核 8 线程,每一个线程的高速缓存是 6144KB。
对于程序员来说数据是分为一个个字节的,而对于 CPU 来说数据是分为一块一块的,比如 64-bit 架构下,CPU 一次可加载 64-bit 数据,而且对于数据的起始地址是有一定限制的。某些平台下,严格要求特定长度的块需要从特定地址加载,比如加载一个 64-bit int 要求加载的起始位置是 8 的倍数,如果地址不符合条件,需要转化为两次加载,最后将两次读取的数据拼接,会增大 CPU 的消耗。
所以一方面是减少内存使用,数据尽量小,增加高速缓存的命中率,一方面是改变默认数据对齐,增加 CPU 加载数据的时间消耗。没有哪个方案一定优于另一个方案,所以 GCC 提供了 #pragma pack(n)
编译选项,给予程序员选择的自由,代价是程序员需知道如何去驾驭它。
C/Cpp 中的内存对齐方案同样适用于 Golang 和 Rust。
int main() {
printf("int=%ld char=%ld float=%ld double=%ld long=%ld pointer=%ld long double=%ld\n", sizeof(int), sizeof(char), sizeof(float), sizeof(double), sizeof(long), sizeof(int *), sizeof(long double));
output:
int=4 char=1 float=4 double=8 long=8 pointer=8 long double=16
- 数组类型大小 = 类型大小 * 数组大小 # 数组长度为 0 很奇怪
- 指针大小 = 操作系统字长 # 本机器中为
64 / 8 = 8
C 的变量不是能够存放在任意地址的,限制:变量存放的起始位置必须是类型大小的整数倍。
- char:
sizeof(char) == 1
char 能够存放在任何起始地址 - short:
sizeof(short) == 2
short 的起始地址必须是 2 的整数倍 - int:
sizeof(int) == 4
int 的起始地址必须是 4 的整数倍 - float:与 int 一样
- double:
sizeof(double) == 8
double 的起始地址必须是 8 的整数倍 - long:与 doubel 一样
起始地址至于类型大小有关,与有无符号类型无关
填充 padding 的值不确定,这不影响程序员编程,因为我们根本不会访问到 padding。
// A
char *p;
char c;
// B
char c;
char *p;
使用 A 方案可能会比 B 方案浪费更多内存。因为 A 方案的指针需要进行内存对齐,p 的起始存储地址必须是 8 的整数倍,而 B 方案的字符类型 c 可以存放在任何起始地址。
结构体的大小会根据其内部最长基本类型对齐,这句话听起来确实比较别扭,举些例子:
基本类型最长为 16 字节,long double
有以下结构体:
struct foo {
char *p;
long x;
char c;
};
其对应的内存布局为:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|---|
p | p | p | p | p | p | p | p |
x | x | x | x | x | x | x | x |
c | pad | pad | pad | pad | pad | pad | pad |
第三行的 1~7 字节仍然属于结构体 foo。
typedef struct A {
int a;
}A;
sizeof(A) == 4
,因为其类型内部最长基本类型是 int,长度为 4 字节。
typedef struct B {
char b;
A a;
};
sizeof(B) == 8
其内部最长基本类型为 int,type(A.a) == int
,所以长度需要是 4 的倍数,最后 B 中的填充应该为:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|---|
b | pad | pad | pad | A.a | A.a | A.a | A.a |
在代码中使用 #pragma pack(n)
可以指定对齐系数,n=1,2,4,8,16(在不同平台下的不同编译器支持不一样),希望通过举几个例子让大家更好的理解其工作方式:
struct Test {
char a;
int b;
char c;
};
在 64 位系统中的对齐应该如下:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|---|
a(0) | pad | pad | pad | b(0) | b(1) | b(2) | b(3) |
c(0) | pad | pad | pad |
显而易见,sizeof(Test) == 12
,其中第二行的 4~7 字节已经不属于 Test 结构体的部分。
如果使用 #pragma pack(1)
#pragma pack(1)
struct Test {
char a;
int b;
char c;
};
sizeof(Test) == 6
,为什么?请看下面的内存对齐方式:
0 |
---|
c(0) |
b(0) |
b(1) |
b(2) |
b(3) |
c(0) |
如果使用 #pragma pack(2)
#pragma pack(2)
struct Test {
char a;
int b;
char c;
};
sizeof(Test) == 8
,为什么?请看下面内存对齐方式:
0 | 1 |
---|---|
a(0) | pad |
b(0) | b(1) |
b(2) | b(3) |
c(0) | pad |
如果使用 #pragma pack(4)
#pragma pack(4)
struct Test {
char a;
double b; // 注意这里改为了 double 类型
char c;
};
sizeof(Test) == 12
,为什么?请看下面内存对齐方式:
0 | 1 | 2 | 3 |
---|---|---|---|
a(0) | pad | pad | pad |
b(0) | b(1) | b(2) | b(3) |
b(4) | b(5) | b(6) | b(7) |
c(0) | pad | pad | pad |
#pragma pack(n),如果 n * 8 >= 当前操作系统位数,则与不使用 #pragma pack 是一样的效果。
前面提到长度为 0 的数组表现得很奇怪。
int main() {
int a[0];
printf("%ld\n", sizeof(a)); // 0
}
长度为 0 的数组有什么用呢?
可以实现动态分配数组长度。不知道读者写 C 的时候有没有一种很不爽的感觉,就是声明数组的时候一定需要声明常数长度数组,一点都不灵活。有兴趣的读者可以参考:Using the GNU Compiler Collection (GCC): Zero Length
typedef struct {
int x;
char c;
int y[0];
} data;
data *dp = (data *)malloc(100);
printf("%p\t%p\n", dp, dp->y); // 0xc44010 0xc44018
从输出结果可以看出,y 占位符也是遵循内存对齐原则的,而不是紧跟在 char c 之后。
64位 glic 下,malloc 函数返回的值(内存起始地址)总是 16 的倍数。
编译器
编译器能够为代码做很多优化,比如使生成的目标程序最小等,但是 C 编译器不会自动进行结构体的变量顺序的优化,因为 C 是一门主要面相操作系统等控制硬件的软件开发,如果 C 擅自进行了结构体中变量顺序的优化有可能导致异常行为。因为很多硬件信号都是通过某一特定位来控制的。
对于想更加深入了解内存模型的读者,可以深入看看 C++ 的内存模型,融入了更多虚函数表、继承等特性。