不可不知的内存对齐(Memory Alignment)

内存对齐的原因

  • 平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
  • 性能原因:数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。


上图中,左边蓝色的方框是CPU,右边绿色的方框是内存,内存上面的0~3是内存地址。这里我们这张图是以32位CPU作为代表,我们都知道,32位CPU是以双字(DWORD)为单位进行数据传输的,也正因为这点,造成了另外一个问题,那么这个问题是什么呢?这个问题就是,既然32位CPU以双字进行数据传输,那么,如果我们的数据只有8位或16位数据的时候,是不是CPU就按照我们数据的位数来进行数据传输呢?其答案是否定的,如果这样会使得CPU硬件变的更复杂,所以32位CPU传输数据无论是8位或16位都是以双字进行数据传输。那么也罢,8位或16位一样可以传输,但是,事情并非像我们想象的那么简单,比如,一个int类型4字节的数据如果放在上图内存地址1开始的位置,那么这个数据占用的内存地址为1~4,那么这个数据就被分为了2个部分,一个部分在地址0~3中,另外一部分在地址4~7中,又由于32位CPU以双字进行传输,所以,CPU会分2次进行读取,一次先读取地址0~3中内容,再一次读取地址4~7中数据,最后CPU提取并组合出正确的int类型数据,舍弃掉无关数据。那么反过来,如果我们把这个int类型4字节的数据放在上图从地址0开始的位置会怎样呢?读到这里,也许你明白了,CPU只要进行一次读取就可以得到这个int类型数据了。没错,就是这样,这次CPU只用了一个周期就得到了数据,由此可见,对内存数据的摆放是多么重要啊,摆放正确位置可以减少CPU的使用资源。

每个特定平台上的编译器都有自己的默认“对齐系数”(也叫对齐模数)。程序员可以通过预编译命令#pragma pack(n),n=1,2,4,8,16来改变这一系数,其中的n就是你要指定的“对齐系数”。编译器在给结构体开辟空间时,首先找到结构体中最宽的基本数据类型,之后与系统的对齐模数相比较,选则其中较小者,然后寻找内存地址能是较小者的整倍的位置,作为结构体的首地址。

1. #pragma pack(show)
    以警告信息的形式显示当前字节对齐的值.
2. #pragma pack(n)
    将当前字节对齐值设为 n .
3. #pragma pack()
    将当前字节对齐值设为默认值(通常是8) .
4. #pragma pack(push)
    将当前字节对齐值压入编译栈栈顶.
5. #pragma pack(pop)
    将编译栈栈顶的字节对齐值弹出并设为当前值.
6. #pragma pack(push, n)
    先将当前字节对齐值压入编译栈栈顶, 然后再将 n 设为当前值.
7. #pragma pack(pop, n)
    将编译栈栈顶的字节对齐值弹出, 然后丢弃, 再将 n 设为当前值.
8. #pragma pack(push, identifier)
    将当前字节对齐值压入编译栈栈顶, 然后将栈中保存该值的位置标识为 identifier .
9. #pragma pack(pop, identifier)
    将编译栈栈中标识为 identifier 位置的值弹出, 并将其设为当前值. 注意, 如果栈中所标识的位置之上还有值, 那会先被弹出并丢弃.


内存对齐的好处,通俗地讲,就是用空间换效率。一般来说,系统默认的对齐模数是8,你也可以将其设为1,不过,内存占用是小了,程序的性能却明显下降了。

内存对齐原则

1、数据成员对齐规则:结构(struct)(或联合(union))的数据成员,第一个数据成员放在offset为0的地方,以后每个数据成员的对齐按照#pragma pack指定的数值和这个数据成员自身长度中,较小的那个进行。即每个数据成员的地址必须是较小者的整数倍。

2、结构(或联合)的整体对齐规则:在数据成员完成各自对齐之后,结构(或联合)本身也要进行对齐,对齐将按照#pragma pack指定的数值和结构(或联合)最大数据成员长度中,比较小的那个进行。结构体末尾之后的那个地址必须是较小者的整数倍。第二条也叫做补齐。补齐的目的就是多个结构变量挨着摆放的时候也满足对齐的要求。

3、结合1、2可推断:当#pragma pack的n值等于或超过所有数据成员长度的时候,这个n值的大小将不产生任何效果。


以例说明

接下来我们通过一个例子来加深对内存对齐概念的理解,下面是一个结构体,我们动手算出下面结构体一共占用多少内存?假设我们以32位平台并且以4字节对齐方式:

#pragma pack(4)
typedef struct MemAlign {
	char a[18];
	double b;	
	char c;
	int d;	
	short e;	
}MemAlign;

下图为对齐后结构如下:


我们就以这个图来讲解是如何对齐的:

第一个成员(char a[18]):首先,假设我们把它放到内存开始地址为0的位置,由于第一个成员占18个字节,所以第一个成员占用内存地址范围为0~18。
第二个成员(double b):由于double类型占8字节,又因为8字节大于4字节,所以就以4字节对齐为基准。由于第一个成员结束地址为18,那么地址18并不是4的整数倍,我们需要再加2个字节,也就是从地址20开始摆放第二个成员。
第三个成员(char c):由于char类型占1字节,任意地址是1字节的整数倍,所以我们就直接将其摆放到紧接第二个成员之后即可。
第四个成员(int d):由于int类型占4字节,但是地址29并不是4的整数倍,所以我们需要再加3个字节,也就是从地址32开始摆放这个成员。
第五个成员(short e):由于short类型占2字节,地址36正好是2的整数倍,这样我们就可以直接摆放,无需填充字节,紧跟其后即可。


这样我们内存对齐就完成了。但是离成功还差那么一步,那是什么呢?对,是对整个结构体补齐,接下来我们就补齐整个结构体。那么,先让我们回顾一下补齐的原则:“以4字节对齐为例,取结构体中最大成员类型倍数,如果超过4字节,都以4字节整数倍为基准对齐。”在这个结构体中最大类型为double类型(占8字节),又由于8字节大于4字 节,所以我们还是以4字节补齐为基准,整个结构体结束地址为38,而地址38并不是4的整数倍,所以我们还需要加额外2个字节来填充结构体,如下图红色的就是补齐出来的空间:


到此为止,我们内存对齐与补齐就完毕了!


比如你有这样的结构型:

  struct  {
    char a[3];
    short int b;
    long int c;
    char d[3];
  };

现在,你或者想它可能在内存中是这样存放的:

  +-------+-------+-------+-------+
  |           a           |   b   |
  +-------+-------+-------+-------+
  |   b   |           c           |
  +-------+-------+-------+-------+
  |   c   |           d           |
  +-------+-------+-------+-------+

但是,如果编译器这样做对处理器更容易:

  +-------+-------+-------+
  |           a           |
  +-------+-------+-------+
  |       b       |
  +-------+-------+-------+-------+
  |               c               |
  +-------+-------+-------+-------+
  |           d           |
  +-------+-------+-------+

在填充版本,注意b和c成员是怎样停止的呢?它看起来对你我都有一点困难。总之,它对处理器也是。因此大部分编译器会填充结构型对象(好似有额外的,不可见的域成员):

  +-------+-------+-------+-------+
  |           a           | pad1  |
  +-------+-------+-------+-------+
  |       b       |     pad2      |
  +-------+-------+-------+-------+
  |               c               |
  +-------+-------+-------+-------+
  |           d           | pad3  |
  +-------+-------+-------+-------+


编程验证

接下来我们用实验来证明真理,程序如下:

#include <stdio.h>
#include <memory.h>

// 由于VS2010默认是8字节对齐,我们
// 通过预编译来通知编译器我们以4字节对齐
#pragma pack(4)

// 用于测试的结构体
typedef struct MemAlign {
	char a[18];	// 18 bytes
	double b;	// 08 bytes	
	char c;		// 01 bytes
	int d;		// 04 bytes
	short e;	// 02 bytes
}MemAlign;

int main() {
	// 定义一个结构体变量
	MemAlign m;
	// 定义个以指向结构体指针
	MemAlign *p = &m;
	// 依次对各个成员进行填充,这样我们可以
	// 动态观察内存变化情况
	memset( &m.a, 0x11, sizeof(m.a) );
	memset( &m.b, 0x22, sizeof(m.b) );
	memset( &m.c, 0x33, sizeof(m.c) );
	memset( &m.d, 0x44, sizeof(m.d) );
	memset( &m.e, 0x55, sizeof(m.e) );
	// 由于有补齐原因,所以我们需要对整个
	// 结构体进行填充,补齐对齐剩下的字节
	// 以便我们可以观察到变化
	memset( &m, 0x66, sizeof(m) );
	// 输出结构体大小
	printf( "sizeof(MemAlign) = %d", sizeof(m) );
}

通过调试程序,查看内存如下:

在memset( &m, 0x66, sizeof(m) ); 语句之前添加断点


其中,每个成员被分别设置了0x11~0x55不同的值以便区分。

可以看到,图中蓝色方框中的0xcc 就是编译器添加的无意义占位字节。

由于这里看不到补齐效果,我们接下来看下图,我们又将结构体中所有的字节全设置为0x66,包括编译器补足的占位字节。

所以下图篮框包围的字节就是补齐字节,即 与上图的交集以外的部分就是补齐所填充的字节。




在最后,再谈一谈关于补齐的作用,补齐其实就是为了让这个结构体定义的数组变量时候,数组内部,也同样满足内存对齐的要求,为了更好的理解这点,请看下图:



最后,说一下怎么禁止编译器进行内存对齐。说来也很简单,只要把对齐模数设为1就OK了。

#pragma pack(1),即指定按1字节对齐,也就是禁止内存对齐。

没有更多推荐了,返回首页