引
先来看一下这个例子:
// 64位系统
#include<stdio.h>
struct{
int a;
char b;
}s;
int main ()
{
printf ("%d\n",sizeof(s);
return 0;
}
理论上,64位系统下,int占 4个byte,char占 1个byte,那么将它们放到一个结构体中应该占 4+1 = 5byte;但是实际上,通过运行程序得到的结果是 8byte,这就是内存对齐所导致的。
注:本文讨论的内容均是在64位系统下。
大纲
什么是内存对齐
为什么要内存对齐
内存对齐规则
一、什么是内存对齐
计算机中内存空间是按照byte划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但实际情况是:在访问特定类型变量
的时候通常在特定的内存地址
访问,这就需要对这些数据在内存中存放的位置有限制,各种类型数据按照一定的规则在空间上排列,而不是顺序的一个接一个的排放,这就是对齐。
内存对齐是编译器的管辖范围。表现为:编译器为程序中的每个“数据单元”安排在适当的位置上。
二、为什么要内存对齐
为了解释这个问题,我们先要了解一下处理器是如何读取内存的?
我们如果把内存看做是简单的字节数组,比如在C语言中,char *就可表示一块内存。那么或许我们会认为,它的内存读取方式可以按照1byte顺序读取,如下图。
然而,尽管内存是以字节为单位,但是大部分处理器并不是按字节块来存取内存的,这取决于数据类型和处理器的设置;它一般会以双字节,四字节,8字节,16字节甚至32字节的块
来存取内存,我们将上述这些存取单位称为内存存取粒度
.
现在我们知道,计算机的处理器是以一定大小的块来进行读取的,这作为我们的前提条件,那么为了解释为什么要内存对齐?我们不妨先看一看不对齐的情况会出现什么问题?
对齐跟数据在内存中的位置有关。如果一个变量的内存地址刚好位于它本身长度的整数倍,他就被称做自然对齐。例如一个整型变量(占4字节)的地址为0x00000016,那它就是自然对齐的。
现在假设一个整型变量(4字节)不是自然对齐的,它的起始地址落在0x00000002(图中蓝色区域),处理器想要访问它的值,按照4字节的块进行读取,从图中的0x0起读,读取4字节大小,读到0x3
这样的一次读取之后,我们并不能取到我们要访问的整型数据,紧接着处理器会继续再往下读,偏移4个字节,从0x4开始,读到0x7
到这里,处理器才能读取到了我们需要访问的内存数据,当然这中间还存在剔除与合并的过程。
所以,在此例中,当整型变量起始地址落在0x2时(不对齐),处理器需要两次读取才能取到我们要访问的内容。
那如果是对齐的呢?
显然,如果是对齐的,对于本例,仅需读取1次,我们便可以读取到目标数据。
可见,对齐与否会影响到我们的读取效率。
同时:
各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些
特定类型的数据
只能从某些特定地址
开始存取,而不是内存中任意地址都是可以读取的。比如有些架构的CPU在访问一个没有进行对齐的变量的时候会发生错误,那么在这种架构下编程必须保证字节对齐.其他平台可能没有这种情况,但是最常见的是如果不按照适合其平台要求对数据存放进行对齐,会在存取效率上带来损失。
也正是由于只能在特定的地址处读取数据,所以在访问一些数据时,对于访问未对齐的内存,处理器可能需要进行多次访问;而对于对齐的内存,只需要访问一次就可以。
这就是为什么要内存对齐的原因。
内存对齐不仅便于CPU快速访问,同时合理的利用字节对齐可以有效地节省存储空间。
我们可以同时对比一下,不同内存存取粒度对同一任务的不同影响。
设定一个相同的任务:分别从Address0 和 Address1 中从地址0读取4个字节到处理器的寄存器中。
- 先看单字节粒度情况
两图中左侧表示内存,右侧表示寄存器,中间的箭头表示读取的过程。因为是单字节存取粒度,读取内存是按照1个字节进行访问的,所以对于Address0 来讲,要想从0的位置读取4个字节,需要读取4次,对于 Address1 也是一样的。即使他不是内存对齐的也无妨。
- 再看双字节粒度情况
从Address0 读取4个字节,相比于存取粒度为1字节的处理器,存取次数变成了一半,只需读取2次。由于每个内存访问都需要固定的开销,因此最小化访问次数确实可以提高性能。同时Address0 是内存对齐的(数据的起始位置落在0的位置上),所以第一次读取地址01,第二次读取地址23即可取到目标数据。
但是,从Address1 读取时。由于该地址未均匀地落在处理器的内存访问边界上(Address1 中寄存器黑框区域是该数据的内存地址区域,起始位置为1,不是对齐的),该处理器去取数据时,要先从0地址开始读取第一个2字节块(01),剔除不想要的字节(0地址),然后从地址2开始读取下一个2字节块(23),再从地址4开始读取下一个2字节块(45),剔除不想要的字节(地址5)。这样读取3次之后将最后留下的3块数据合并放入寄存器,才能取到目标数据。
- 四字节粒度情况呢?
具有四字节粒度的处理器从Address0 读取时,可以一次读取地址0123 就从对齐的地址中提取四个字节。
然而从Address1 读取时,因为是不对齐的,读取地址0123,剔除0地址,继而读取地址4567,剔除地址5、地址6、地址7 ,这样读取2次后将留下的2块数据合并放入寄存器,取到目标数据。
从双字节粒度和四字节粒度中可见,对于没有对齐的内存,需要做更多次的读取与剔除和合并的过程。这显然是降低效率的。
同时我们还需要注意到一点:对于对齐的内存,不同的存取粒度也会影响到存取效率。粒度小则存取次数多,粒度大则浪费空间。所以在每个特定平台上的编译器都有自己的默认存取粒度。
了解了内存对齐以及原因,我们继续看一下内存对齐的原则是什么。
三、内存对齐规则
对于标准数据类型
它的地址只要是它的长度的整数倍就行了
对于结构体
在结构体中,编译器为结构体的每个成员按其自然边界(alignment)分配空间。各个成员按照它们被声明的顺序在内存中顺序存储,第一个成员的地址和整个结构体的地址相同。具体规则如下:
1.第一个成员在结构体变量偏移量为0 的地址处,也就是第一个成员必须从头开始。
2.以后每个成员相对于结构体首地址的 offset 都是该成员大小的整数倍,如有需要编译器会在成员之间加上填充字节。
3.结构体的总大小为 最大对齐数的整数倍(每个成员变量都有自己的对齐数),如有需要编译器会在最末一个成员之后加上填充字节。
4.如果嵌套结构体,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(包含嵌套结构体的对齐数)的整数倍。
搞个例子尝尝
示例1:
struct test_t {
int a;
long b;
short c;
};
第一个成员为int类型,占4字节,内存分布 00 01 02 03 ;用红色表示
第二个成员为long类型,占8字节,此时内存的的偏移量04不是8的整数倍,所以要填充字节(绿色表示填充字节),到0x7的位置,然后将long类型的数据b写入内存(黄色)
第三个成员是short类型,占2个字节,此时内存的偏移量16是short类型所占字节数的整数倍,所以直接写入内存(用蓝色表示)
至此,结构体内的数据数据成员已对齐,但是当前结构体的总大小为18,不满足规则3,所以需在最末的成员后面填充6个字节,使其总大小为24。
所以 此结构体的所占内存大小为24。
如果将结构体中的short类型与long类型换一下,
struct test_t {
int a;
short b;
long c;
};
会是一个什么结果?我们依然用红色表示int类型,蓝色表示short类型,黄色表示long类型,绿色表示填充字节。
结果为16。可见,对于成员相同的结构体,如果改变成员的顺序,对于结构体所占空间的大小是会产生影响的,所以,我们不但要了解内存对齐,还是正确的利用内存对齐。
对于结构体嵌套结构体,在规则4中已经给出,这里的图就不再画了,聪明的你看到这里一定可以得到正确的答案。
我们再来回顾上面的对齐规则:
各成员变量存放的起始地址, 相对于结构的起始地址的偏移量 ,必须为该变量的类型所占用的字节数的倍数;
各成员变量在存放的时候根据在结构中出现的顺序依次申请空间, 同时按照上面的对齐方式调整位置, 空缺的字节自动填充
同时为了确保结构的大小为结构的字节边界数(即该结构中占用最大的空间的类型的字节数)的倍数,所以在为最后一个成员变量申请空间后 还会根据需要自动填充空缺的字节
这就是内存对齐的整体规则,但仍需注意的是:
在不同架构的处理器下,我们运行同一个示例得到的结果可能不同,甚至不同的编译器配置,也会影响这个结果,我们需要了解影响内存对齐规则的因素有哪些?
影响内存对齐结果的因素 ?
1.#pragma pack(n)
每个特定平台上的编译器都有自己的默认“对齐系数”(也叫对齐模数)。程序员可以通过预编译命令#pragma pack(n)
,n=1,2,4,8,16来改变这一系数,其中的n就是你要指定的“对齐系数”。这里规定的是上界,只影响对齐单元大于n的成员,对于对齐字节不大于n的成员没有影响。
可以认为处理器一次性可以从内存中读/写n个字节。对于大小小于n的成员,按照自己的对齐条件对齐,因为不论怎么放都可以一次性取出。对于对齐条件大于n个字节的成员,成员按照自身的对齐条件对齐和按照n字节对齐需要相同的读取次数,但按照n字节对齐节省空间。
通过预编译命令#pragma pack()
取消自定义字节对齐方式。
也可以写成:
#pragma pack(push,n)
#pragma pack(pop)
2.__attribute__((aligned (n)))
__attribute__((aligned (n)))
,让所作用的结构成员对齐在n字节自然边界上。如果结构中有成员的长度大于n,则按照最大成员的长度来对齐。
__attribute__((packed))
,取消结构在编译过程中的优化对齐,按照实际占用字节数进行对齐。
需要注意的是:内存对齐的 对齐数 取决于 对齐系数 和 成员的字节数 两者之中的较小值。
举例说明:
struct test
{
char x1;
short x2;
float x3;
char x4;
}
默认情况下,结构的第一个成员x1,其偏移地址为0,占据了第1个字节。第二个成员x2为short类型,其起始地址必须2字节对界,因此,编译器在x2和x1之间填充了一个空字节。结构的第三个成员x3和第四个成员x4恰好落在其自然边界地址上,在它们前面不需要额外的填充字节。在test结构中,成员x3要求4字节对齐,是该结构所有成员中要求的最大边界单元,因而test结构的自然对齐条件为4字节,编译器在成员x4后面填充了3个空字节。整个结构所占据空间为12字节。
当使用:#pragma pack(1)
//让编译器对这个结构作1字节对齐
#pragma pack(1) //让编译器对这个结构作1字节对齐
struct test
{
char x1;
short x2;
float x3;
char x4;
};
#pragma pack() //取消1字节对齐,恢复为默认4字节对齐
这时候sizeof(struct test)的值为8。
同理:使用__attribute__((packed))
#define PACKED __attribute__((packed))
struct PACKED test
{
char x1;
short x2;
float x3;
char x4;
}test;
这时候sizeof( test)的值仍为8。
总结
其实,内存对齐就是定制了一套规则,以合理的利用内存空间并提高内存访问效率。
编译器通过适当增加padding,使每个成员的访问都在一个指令里完成,而不需要多次访问再拼接。
是一个以空间换时间的过程。