内存对齐

1.引子

    在结构中,编译器为结构的每个成员按其自身的自然对界(alignment)条件分配空间。各个成员按照它们被声明的顺序在内存中顺序存储,第一个成员的地址和整个结构的地址相同。

    例如,下面的结构各成员空间分配情况(假设对齐方式大于2字节,即#pragma pack(n), n = 2,4,8...下文将讨论#pragmapack()):

struct test 
{
     char x1;
     short x2;
     float x3;
     char x4;
};
    结构的第一个成员x1,其偏移地址为0,占据了第1个字节。第二个成员x2为short类型,其起始地址必须2字节对界,即偏移地址是2的倍数。因此,编译器在x2和x1之间填充了一个空字节,将x2放在了偏移地址为2的位置。结构的第三个成员x3和第四个成员x4恰好落在其自然对界地址上,在它们前面不需要额外的填充字节。在test结构中,成员x3要求4字节对界,是该结构所有成员中要求的最大对界单元,因而test结构的自然对界条件为4字节,整个结构体的大小是最大对界单元大小的整数倍(结构体内部有结构体时也遵循这个规则,下文将提到),编译器在成员x4后面填充了3个空字节。整个结构所占据空间为12字节。

    关于为什么要内存对齐,参考http://blog.csdn.net/lgouc/article/details/8235471。看了这篇文章便可以更轻松的理解下面的内容。

    好了,下面说说#pragma pack:

2.#pragma pack()

    该预处理指令用来改变对齐参数。在缺省情况下,C编译器为每一个变量或数据单元按其自然对界条件分配空间。一般地,可以通过下面的方法来改变缺省的对齐参数:

     · 使用伪指令#pragma pack (n),C编译器将按照n字节对齐。

     · 使用伪指令#pragma pack (),取消自定义字节对齐方式。

也可以写成:

#pragma pack(push,n)

#pragma pack(pop)

#pragma pack (n)表示每个成员的对齐单元不大于n(n为2的整数次幂)。这里规定的是上界,只影响对齐单元大于n的成员,对于对齐字节不大于n的成员没有影响。其实从字面意思,pack是“包裹,打包”的意思,#pragma pack(n)规定n个字节是一个“包裹”,个人认为实在不理解的话可以认为处理器一次性可以从内存中读/写n个字节,这样好理解。对于大小小于n的成员,当然是按照自己的对齐条件对齐,因为不论怎么放都可以一次性取出。对于对齐条件大于n个字节的成员,成员按照自身的对齐条件对齐和按照n字节对齐需要相同的读取次数,但按照n字节对齐节省空间,何乐而不为呢。可以参考我上面提到的http://blog.csdn.net/lgouc/article/details/8235471。下面是一位大牛的观点,和我说的是一个意思:

   All it means is that each member of it will require alignment no greater than n.It doesn't mean that each member will have alignment requirement n.Notice, after all, it's called pack and not align for a reason-- precisely because it controls packing, not alignment.

另外,GNU C还有如下的一种方式:

     · __attribute__((aligned (n))),让所作用的结构成员对齐在n字节自然边界上。如果结构中有成员的长度大于n,则按照最大成员的长度来对齐。

     · __attribute__ ((packed)),取消结构在编译过程中的优化对齐,按照实际占用字节数进行对齐。

以上的n = 1, 2, 4, 8, 16... 第一种方式较为常见。

3.结构体内成员如何找出自己的位置

首先遵循以下规则:

1.  每个成员分别取自己的对齐方式和#pragma pack指定的对齐参数二者的较小值作为自己的对齐方式。

2.  复杂类型(如结构)的对齐方式是该类型声明时所使用的对齐方式,或者说是声明时它的所有成员使用的对齐参数的最大值,最后和此时的#pragma pack指定的对齐参数二者取极小值。大牛是这么说的:

The documentation for #pragma pack(n) says that "The alignment of a member will be on a boundary that is either a multiple of n or a multiple of the size of the member,whichever is smaller". However I think this is incorrect; the docs should say that the alignment of a member will be on a boundary that is either a multiple of n or the alignment requirement of the member, whichever is smaller.

3.  对齐后的长度必须是成员中最大的对齐参数(不是成员的大小)的整数倍,这样在处理数组时可以保证每一项都边界对齐。

4.  对于数组,比如:char a[3];这种,它的对齐方式和分别写3个char是一样的。也就是说它还是按1个字节对齐.

        如果写: typedef char Array3[3];

        Array3这种类型的对齐方式还是按1个字节对齐,而不是按它的长度。

5.  不论类型是什么,对齐的边界一定是1,2,4,8,16,32,64....中的一个。

看一个简单的例子:

#pragma pack(8)
struct s1
{
    short a;
    long b;
};
struct s2
{
    short c;
    s1 d;
    long long e;
};
#pragma pack()
    成员对齐有一个重要的条件:每个成员分别对齐。即每个成员按自己的方式对齐.

    也就是说上面虽然指定了按8字节对齐,但并不是所有的成员都是以8字节对齐。其对齐的规则是,每个成员按其类型的对齐参数(通常是这个类型的大小)和指定对齐参数(这里是8字节)中较小的一个对齐。并且结构的长度必须为所用过的所有对齐参数的整数倍(只要是最大的对齐参数的整数倍即可),不够就补空字节(视编译器而定)。

    S1中,成员a是2字节默认按2字节对齐,指定对齐参数为8,这两个值中取2,a按2字节对齐;成员b是4个字节,默认是按4字节对齐,这时就按4字节对齐,a后补2个字节后存放b,所以sizeof(S1)应该为8。8是4的倍数,满足上述的第3条规则。

    S2中,c和S1中的a一样,按2字节对齐,而d是个结构,它是8个字节,它按什么对齐呢?对于结构来说,它的默认对齐方式就是该结构定义(声明)时它的所有成员使用的对齐参数中最大的一个,S1的是4,小于指定的8。所以成员d就是按4字节对齐,c后补2个字节,后面是8个字节的结构体d。成员e是8个字节,它是默认按8字节对齐,和指定的一样,所以它对到8字节的边界上,这时,已经使用了12个字节了,所以d后又补上4个字节,从第16个字节开始放置成员e。这时,长度为24,已经可以被最大对齐参数8(成员e按8字节对齐)整除。这样,一共使用了24个字节。

   上面的不够复杂?再来一个:

#pragma pack(4)
struct s1
{
    char a;
    double b;
};
#pragma pack()
 
#pragma pack(2)
struct s2
{
    char c;
    struct s1 st1;
};
#pragma pack()
 
 
#pragma pack(2)
struct s3
{
    char a;
    long b;
};
#pragma pack()
 
#pragma pack(4)
struct s4
{
    char c;
    struct s3 st3;
};
#pragma pack()
    先看s1,a放在偏移地址为0的位置(第一个字节)。b默认8字节对齐,但指定对齐参数是4字节,所以b按4字节对齐,放在偏移地址为4的位置,a后补3个字节。所以sizeof(s1)是12。结构体s1的对齐参数是4,下面会用到。

    再看s2,c放在第一个字节。st1自己的对齐参数是4,但此时指定的对齐参数是2,所以st1按照2字节对齐,c后补一个字节后存放st1。注意,st1内部是不会变的,声明s1时是什么样就是什么样,因为我们要保证sizeof(s2.st1) == sizeof(s1),如果不这样就乱套了。这样sizeof(s2)是14。结构体s2的对齐参数是2,14是2的整数倍。

    再看s3,a放在第一个字节。b默认4字节对齐,但指定的对齐参数是2,所以b按2字节对齐,放在偏移地址为2的位置,a后补一个字节。sizeof(s3)是6。结构体s3的对齐参数是2(后面会用到),6是2的整数倍。

    最后看s4,c放在第一个字节。st3自己的对齐参数是2,指定的对齐参数是4,所以st3取极小值,按2字节对齐,放在偏移地址为2的位置,c后补一个字节。sizeof(s4)是8,结构体的对齐参数是2,8是2的整数倍。

 

1.内存对齐的概念:计算机中基本数据类型的存放地址只能从k(通常为4或者8)的倍数而非任意整数开始。
2.原因:尽管内存是以字节为单位,但处理器对于内存的读取却是以2字节,4字节,8字节,16字节甚至32字节为单位来存取内存,我们将上述这些存取单位称为内存存取粒度。假如没有内存对齐机制,数据可以任意存放,现在一个int变量存放在从地址1开始的连续四个字节地址中,该处理器去取数据时,要先从0地址开始读取第一个4字节块,剔除不想要的字节(0地址),然后从地址4开始读取下一个4字节块,同样剔除不要的数据(5,6,7地址),最后留下的两块数据合并放入寄存器。这需要做很多工作。有了内存对齐,数据从0开始存储,处理器可以一次性读出。
3.对齐系数:每个特定平台上的编译器都有自己的默认“对齐系数”(也叫对齐模数)。gcc中默认#pragma pack(4),可以通过预编译命令#pragma pack(n),n = 1,2,4,8,16来改变这一系数。

  1. 有效对其值:是给定值#pragma pack(n)和结构体中最长数据类型长度中较小的那个。有效对齐值也叫对齐单位。
    内存对齐需要遵循的规则:

(1) 结构体第一个成员的偏移量(offset)为0,以后每个成员相对于结构体首地址的 offset 都是该成员大小与有效对齐值中较小那个的整数倍,如有需要编译器会在成员之间加上填充字节。
(3) 结构体的总大小为有效对齐值的整数倍,如有需要编译器会在最末一个成员之后加上填充字节。

扩展:

内存对齐

  1. 为何要内存对齐
    1. 平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
    2. 性能原因:经过内存对齐后,CPU的内存访问速度大大提升。
  2. 内存对齐的规则

    许多实际的计算机系统对基本类型数据在内存中存放的位置有限制,它们会要求这些数据的首地址的值是某个数k(通常它为4或8)的倍数,这就是所谓的内存对齐,而这个k则被称为该数据类型的对齐模数(alignment modulus)。当一种类型S的对齐模数与另一种类型T的对齐模数的比值是大于1的整数,我们就称类型S的对齐要求比T强(严格),而称T比S弱(宽松)。这种强制的要求一来简化了处理器与内存之间传输系统的设计,二来可以提升读取数据的速度。

    比如这么一种处理器,它每次读写内存的时候都从某个8倍数的地址开始,一次读出或写入8个字节的数据,假如软件能保证double类型的数据都从8倍数地址开始,那么读或写一个double类型数据就只需要一次内存操作。否则,我们就可能需要两次内存操作才能完成这个动作,因为数据或许恰好横跨在两个符合对齐要求的8字节内存块上。某些处理器在数据不满足对齐要求的情况下可能会出错.

    但是Intel的IA32架构的处理器则不管数据是否对齐都能正确工作。不过Intel奉劝大家,如果想提升性能,那么所有的程序数据都应该尽可能地对齐。

    1. Win32平台下的微软C编译器(cl.exe for 80x86)在默认情况下采用如下的对齐规则:
      任何基本数据类型T的对齐模数就是T的大小,即sizeof(T)。比如对于double类型8字节),就要求该类型数据的地址总是8的倍数,而char类型数据(1字节)则可以从任何一个地址开始。
    2. Linux下的GCC对齐规则:
      char类型数据(1字节)起始位置任意,任何2字节大小的数据类型(比如short)的对齐模数是2,而其它所有超过2字节的数据类型(比如long,double)都以4为对齐模数。也就是说2字节数据类型(如short)的地址必须是2的倍数,而较大的数据类型(如int,double等)的地址必须是4的倍数,这意味着short类型的队形的地址最低位必须等于0,任何int类型的对象或指针的最低两位必须都是0.

Windows+GCC下内存对齐的常见问题

 

结构/类对齐的声明方式

gcc和windows对于modifier/attribute的支持其实是差不多的。比如在gcc的例子中,内存对齐要写成:

class X
{
  //...
} __attribute__((aligned(16)));

但是实际上你写成

class __attribute__((aligned(16))) X 
{
    /*...*/
};

gcc一样可以识别。这样MSVC和gcc就可以使用宏完成跨平台编译。

对齐类型的变量在堆与栈上的分配

对齐在以下场合都能提示编译器为它的变量分配对齐的地址:

 

void foo()
{
    X v; // v是个栈上的16字节对齐的变量
    X* p = new X; // p是堆上的16字节对齐的指针
    X* a = new X[ARRAY_SIZE]; // 那么这个呢?
}

 

栈上的变量堆上分配出的变量,因为align这个hint的存在,都能满足16字节对齐的要求。但是数组呢?按照一般规律来分析,对齐后的sizeof(X),一定是对齐的整数倍。比如16字节对齐的话,那么X的大小只能是16的倍数。所以对于本例的数组而言,编译器应该也能知道a应该是16字节对齐的。

但是事实上挺奇怪。在MSVC上,p和a都很好的遵守了对齐的要求;在gcc上,p是对齐的,但是a却不是。其实这个问题在2004年便有人提出来,只是到目前为止一直都没有人动手过。当然,标准也没有规定X的数组就一定是要对齐的。要解决这个问题,要么重载class的operator new/delete,要么用memalign/aligned_malloc分配出对齐的内存,再placement new。出于易用性,我选择的是操作符重载。

clang对于对齐的支持更干脆:16B的对齐已经够用了。所以align完全被编译器忽视了。结果Intel出来了AVX,Clang就傻逼了。不知道这个问题3.4会不会修正。

编译器如何实现内存对齐

MSVC在x86下默认是支持的4B的内存对齐。也就是说在函数入口处,ESP和EBP只保证是4字节对齐的。这时,当前函数域栈上变量的地址都是ESP + 4 * x的形式。如果函数体内有对齐的变量,例如:

void foo()
{
    int __declspec(align(16)) x;
    // ...
}

那么编译器在代码生成时,会在函数的前部插入一段称为prolog的代码,这段代码会将堆栈修正为16B对齐,比如

PUSH EBP
MOV  EBP, ESP
SUB  ESP, XXX
AND  ESP, 0xFFFFFFF0h

这样ESP就一定是16字节对齐的。这个时候给x分配的地址,就可以是ESP + 0x10 * n的形式,这样就满足了对齐的需要。

在GCC上,gcc认为所有的函数都有义务在调用其它函数的时候,ESP是16字节对齐的(当然,可以通过编译选项修改这一要求)。不光是调用方会这样保证,被调用方也是这样默认的。所以GCC为了调用效率更高一点,便根据调用方的假设,去掉了“堆栈修正”这个步骤。

原来的代码可能就变成了

PUSH EBP             ; 假设这里的ESP是16B对齐的,Push了EBP,ESP就是16x-4了。
MOV  EBP, ESP
SUB  ESP, 0x0000023Ch ; 减完以后这里又是16字节对齐了

那么当被调用方遵守这个约定的时候,ESP当然就是16字节对齐的。但是有一种情况例外。在MinGW下,线程的入口函数是被API回调的。这个函数很可能是按照Windows的标准4个字节对齐的。这样,在没有堆栈修正的情况下,整个线程调用链16B对齐的默契就被打破了。如果这个时候出现了SSE代码试图存取“16字节对齐”的变量,那可能就会发生segment fault的异常,因为这些变量的地址并不是对齐的。

解决这个问题,有两种常见的办法:第一,写一个Wrapper函数,对齐ESP后转发调用;第二,使用编译选项-mstackrealign。这个选项会为所有函数增加堆栈修正的PROLOG代码,以保证函数栈帧一定是按照16字节或用户指定大小对齐。

The End.
参考:

https://zhuanlan.zhihu.com/p/30007037 

https://www.jianshu.com/p/50d459bbd532 

https://www.cnblogs.com/yanghong-hnu/p/5700858.html

https://www.cnblogs.com/lingjingqiu/p/3446457.html

http://light3moon.com/2015/01/19/%5B%E8%BD%AC%5D%20%E5%86%85%E5%AD%98%E5%AF%B9%E9%BD%90/

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值