Skip to content

Latest commit

 

History

History
233 lines (169 loc) · 7.05 KB

2018-04-01-内存对齐.md

File metadata and controls

233 lines (169 loc) · 7.05 KB
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。

C 中基本类型大小

 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 可以存放在任何起始地址。

struct 结构体

结构体的大小会根据其内部最长基本类型对齐,这句话听起来确实比较别扭,举些例子:

基本类型最长为 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)

在代码中使用 #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 的数组

前面提到长度为 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++ 的内存模型,融入了更多虚函数表、继承等特性。