内存字节对齐

结构体的字节对齐是我很早就想全面了解一下的东西,这个东西本质上是和硬件相关的,本来要想真正全面了解的话必须得知道CPU的结构、内存的结构、CPU指令是如何执行的等这些硬件层的东西才行,并不是说了解几个寄存器写两句汇编码就可以的,和这个没联系。在尝试了几次之后,迫于个人能力问题,无奈之下只好放弃深层次的了解,只能了解一下字节对齐的规则,至于为什么要对齐的话题,大概也就只能从网络上那几句肤浅的话了解到了:

 

 

 

“一些平台对某些特定类型的数据只能从某些特定地址开始存 ……最常见的是如果不按照适合其平台要求对 数据存放进行对齐,会在存取效率上带来损失……”

 

 

 

只是想了解字节对齐的规则也不是想象中那么容易的事情,对齐规则本身并不算复杂,而最困惑的是网上各种错误的文章,至少我看过的那几十篇包括一些重复的转载和引用,基本上是错的。当然,也并不是说他们错得多么的离谱,大致还是对的,只是一些困惑的地方从来没有人去证实,都自然而然的想当然了,而结果却是错误的。

 

所以我还是那句话,从来都不带怀疑和思考的精神去看别人的东西,从来不经过自己的证实就轻易相信,那么,误导了你也是你自己活该~~

 

我写这篇笔记,首先是要证明网上那些错误的地方,以及给出我自己的一些结论。然后就是给出我自己总结出来的一个字节对齐规则的版本。

 

 

 

1.  证明它是错的

 

我所说的他们那些错误的地方,基本上可以归结于一个问题上的错误,那就是对变量的起始地址的假设。

 

 

 

很多文章大概都有像这样的结论:

 

 

 

1. 数据项只能存储在地址是数据项大小的整数倍的内存位置上;

 

2. 结构体变量的首地址能够被其最宽基本类型成员的大小所整除;

 

3. 对齐在N上,也就是说该数据的"存放起始地址%N=0

 

 

 

很明显,如果对数据存放地址的把握错误了的话,那么由此推断出来的地址对齐规则也就全都是错的了,而事实上也是如此。我研究这个课题 80%的时间都是花在这个上面,而真正的对齐规则一个下午应该就可以解决了。

 

 

 

当然,像上面的结论在一般情况下基本上是正确的,也就是说:

 

 

 

char变量的地址 %1 =0;

 

short变量地址 %2 =0;

 

int变量的地址 %4 =0;

 

double变量的地址 %8 =0

 

 

 

这里假设:

 

 

 

sizeof(char) = 1;

 

sizeof(short) = 2;

 

sizeof(int)=4;

 

sizeof(double)=8

 

 

 

这是 win32 平台上的实际值,此篇都以此假设为基础。

 

 

 

当这些变量是处于内存的数据区(或只读数据区)或者是从堆上分配出来的话,应该都是正确的,因为编译器和堆管理可能会帮你把这件事情做得很好,而程序员在代码里面基本上控制不了这些区域的变量的起始地址,实则我的多次实际测试也都符合上面的结论,即变量存放起始地址%N=0,结构体也符合首地址能够被其最宽基本类型成员的大小所整除。

 

 

 

而唯一我们比较好灵活控制的就是栈上数据,也就是局部变量。在 32 位的系统上栈的单位大小是 32 bit,即 4 字节,每一个栈的地址肯定也是 %4 = 0 的,如果一个栈存放了 char / short / int 的话那么他们肯定也满足 % N = 0。我们唯一可以找到破绽的就是使用一个8字节的数据,也就是 double,通过对栈上数据的巧妙安排让 double 变量的地址处于一个可以让 4 整除而不可以让 8 整除的地址上,那么我们的目的就达到了,"存放起始地址 % N = 0 "的结论即可推翻。当然,熟悉栈布局的话这些是可以轻易做到的。具体可以按如下步骤实验。

 

 

 

首先编译这个代码看

 

#include "stdafx.h"

 

int _tmain(int argc, _TCHAR* argv[])

{

 

         double a = 0;

 

         double b = 0;

 

         double c = 0;

 

         printf("&a=0x%X, &b=0x%X, &c=0x%X", &a, &b, &c);

         getchar();

         return 0;

}

 

 

 

 

以下是 VS2013 上的输出

 

 

 

 

 

可以看到,变量地址的最后一位是 4,不能被 8 整除(double 为 8 字节),因此已经可以推翻结论1了。

 

 

 

得到的这些地址可能有些随机性,这些地址依赖于具体的环境和编译参数等,但是,无论如何,我们实际看到的东西已经可以推翻上面结论 1 对地址假设的错误的结论了。我们再来看结构体的情况,把他们彻底推翻!为了让内部类型的变量一定可以按照其自身的对齐参数对齐,我们指定对齐参数设为16。   

 

#include "stdafx.h"

 

#pragma pack(16)

 

struct A{

 

         char a;

 

         double b;

 

};

 

 

 

int _tmain(int argc, _TCHAR* argv[]){

 

         struct A a;

 

         struct A b;

 

         struct A c;

 

         printf("&a=0x%X, &b=0x%X, &c=0x%X\n", &a, &b, &c);

 

         printf("&a.b=0x%X, &b.b=0x%X, &c.b=0x%X", &a.b, &b.b, &c.b);

 

         getchar();

         return 1;

 

}

 

 

 

VS2013输出的结果:

 

 

 

 

 

由上面可以看到,结构体 A 的最大长度成员的类型是 double,就是成员变量 b 。 可以看到,结构体变量的起始地址不能被8整除,结构体中的 double 成员的地址也不能被 8 整除,当然这个的原因还是结构体变量的起始地址不能被8整除导致 double 的成员也不能被 8 整除。

 

 

 

我们实际看到的东西已经可以推翻上面结论 2 对地址假设的错误的结论了

 

 

 

尽管在不同的编译环境和系统上会有不同的值,但是从上面的两个实验我们确实得到了不满足那些结论的值,也就是说,无论如何,那些对变量起始地址的假设和结论一定错了~!

 

 

 

我本来以为我对栈布局还比较熟悉,通过多次试验是不是可以完全预测,但是后来证明这些一切都是徒劳,在不同的编译环境下,特别是编译器的优化选项,栈的存放简直就是五花八门,根本预测不到,我们确实不能对变量的地址做过多的假设。 

 

 

 

2.  地址的字节对齐规则

地址对齐的规则也不是很复杂,只要把对起始地址有假设的那些结论稍微改一下基本上就差不多了。以下我先给出我自己总结的一个版本,然后再慢慢论证与解析。

 

 

 

编译器都有一个指定的对齐参数用于 structure, union, and class 成员,在 win32 平台上的编译器都是默认为 8,这个指定的对齐参数可以在代码里面使用 pack(n) 指令指定,n合法的值是1,2,4,8,16。

 

 

 

每个内部类型自身也都有一个自己的对齐参数,一般来说这个对齐参数就是 sizeof(具体type) 的值,在 win32 平台上就是采用sizeof作为具体类型的自身对齐参数的,也就是讲,char 的自身对齐参数是 1 , short 是 2 , int 是 4, float 也是 4, double 是 8 等。

 

 

 

地址对齐是相对于结构的成员来说的,单个内部类型的变量这种就没什么对齐不对齐的说法了。

 

 

 

结构的成员按照结构中声明的顺序依次排放,对齐的意思是成员相对于结构变量的起始地址的相对对齐,关键是在于相对于结构变量的起始地址的偏移。

 

 

 

有效对齐参数,内部类型的有效对齐是指它的自身对齐参数和指定对齐参数中较小的那个对齐参数;结构类型的有效对齐参数是指它的成员中,有效对齐参数最大的那个值。数组的有效对齐就是它的成员类型的有效对齐。

 

 

 

有了这些就可以得出对齐规则了:

 

 

 

1.   (成员的起始地址相对于结构的起始地址的偏移) % (成员的有效对齐) == 0

 

2.   (结构的总大小) % (结构的有效对齐) == 0

 

3.   如果无法满足对齐规则的话就填充字节直到满足对齐规则 

 

 

 

从上面可以看到,如果指定的对齐参数大于了变量的自身对齐参数的话,指定的对齐参数将不起作用,这就是之前为什么要 #pragma pack(16) 的原因了,使得指定对齐参数没用,各个变量按照自己的类型的自身对齐参数对齐。

 

 

 

结构的总大小也要求符合对齐规则,主要是考虑到了结构体数组的情况,数组的各个成员是紧密排列的,不会有空隙,如果结构总大小满足对齐要求的话那么整个数组就自然满足对齐要求了,如果总大小不满足对齐要求的话,数组各个成员又要紧密排列,那么这个对齐就又没意义了,CPU 读取这些数组成员还是要花多余的开销。

 

 

 

说了这么多,还是举例子讲话来得实在。

 

 

 

# pragma pack(16)

struct A{

      char a;

      double b;

};

 

 

 

 

第一个成员的地址就是结构的起始地址,所以它的地址相对于结构的起始地址的偏移是 0,而 a 是 char 类型,它的自身对齐是 1 小于指定的对齐参数 16,所以 a 的有效对齐是1,a 的起始地址偏移也满足 0 % 1 = 0;第二个成员是 double 类型,其自身对齐参数是 8,也小于指定的对齐参数,所以它的有效对齐是 8,这样我们指定的 # pragma pack(16) 就相当于一点用都没有了。而 double 类型的成员 b 要想满足对齐规则就必须在 a 的后面填充字节以使得 b 的地址相对于结构的起始地址的偏移至少为 8。所以结构 A的内存布局会是这样:

 

00 CC CC CC CC CCCC CC 00 00 00 00 00 00 00 00 

 

 

而 sizeof(A) = 16;0 分别表示 a 和 b 的位置,CC就是填充的 7 个字节。

 

 

 

# pragma pack(16)

struct A{

      char a;

      short c;

      double b;

};

 

 

 

 

同样指定的对齐参数仍然没任何作用,a 还是在偏移为 0 的地址上,c 在 a 之后,c 的有效对齐就是自身对齐 2,位于相对起始地址偏移为 2 的地址上,满足对齐要求 2 % 2 = 0,c 和 a 之间填充了 1 个字节。b  仍然位于偏移地址为 8 的地址上,b 和 c 之间填充了 4 个字节。结构 A 的内存布局如下:

 

00 CC 00 00 CC CCCC CC 00 00 00 00 00 00 00 00 

 

 

而 sizeof(A) = 16;0 分别代表 a,c,b的位置,CC 就是填充字节。

 

 

 

# pragma pack(16)

struct A{

      char a;

      double b;

      short c;

};

 

 

 

 

指定对齐仍然没用,a 在偏移为 0 的地址上,b 在偏移为 8 的地址上,c 紧紧挨着 b 的屁股,因为此时的地址偏移 16 已经满足 c 的对齐要求 16 % 2 = 0;所以就没必要填充字节了。但是结构体 A 的总大小也要满足对齐规则的第二条,即 (结构的总大小)%(结构的有效对齐) == 0;而结构 A 的有效对齐就是各个成员中有效对齐最大的那个数,也就是 b的对齐参数 8,所以 A 的有效对齐就是 8,结构的总大小要满足对齐要求还必须在 c 后面填充 6 个字节。此时 A 的内存布局如下:

 

00 CC CC CC CC CCCC CC 00 00 00 00 00 00 00 00 11 11 CC CC CC CC CC CC

 

 

而 sizeof(A) = 24;0 分别代表 a,b 的位置,1111 代表 c 的位置。

 

 

 

# pragma pack(4)

struct A{

      char a;

      double b;

      short c;

};

 

 

 

 

把指定对齐参数设置成 4,此时 a 和 c 的有效对齐仍然是其自身对齐,而 b 因为它的自身对齐 8 大于了指定的对齐 4,所以 b 的有效对齐现在变成了 4 而不再是 8 了。a 仍然位于偏移 0,b 要满足对齐规则的话,地址偏移必须是其有效对齐的整数倍,所以 b 的偏移应该是 4,c 仍然紧紧跟在 b 的后面,因为此时的偏移 12 满足了 c 的对齐要求12 % 2 = 0;结构 A 的有效对齐现在也变成了 4,即等于成员中最大的有效对齐,b 的有效对齐。A 的总大小要满足对齐规则的话还必须在 c 的后面填充 2 个字节,让总大小变为 16 字节。此时 A 的内存布局如下:

 

00 CC CC CC 00 0000 00 00 00 00 00 11 11 CC CC

 

 

而 sizeof(A)  = 16;0 分别代表 a,b 的位置,1111 代表c的位置。

 

 

 

# pragma pack(8)

struct A{

      char a;

      double b;

};

struct B{

int i;

   struct A sa;

int c;

};

 

 

 

 

我们来看结构 B 的布局,把指定对齐参数设置成 8,其实也还是没起作用,我们最大的内部类型就是 8 的 double 了,刚好和指定的对齐参数相等。第一个成员 i  肯定是位于偏移为 0 的地址上了。然后第二个成员是一个结构成员,我们要找到这个成员的有效对齐参数,结构的有效对齐参数是其成员中最大的那个对齐参数,对于结构 A 来说就是 b 的对齐参数 8,所以 A 的有效对齐是 8。结构成员 sa 要想满足对齐要求,即 偏移 % 有效对齐 8 = 0;它的地址偏移应该为 8。所以 sa 和 i 之间需要填充 4 个字节。成员 c 仍然紧紧跟在 sa 后面,因为 sa 占 16 字节,此时的地址偏移 24 已经可以满足 c 的对齐要求 24 % 4 = 0 ;而结构 B 的总大小也要满足对齐规则,B 的有效对齐就是成员中最大的,sa的有效对齐 8。所以 B 的总大小要能被 8 整除,就必须在 c 的后面再填充 4 个字节。此时结构 B 的内存布局如下:

 

00 00 00 00 CC CCCC CC 00 CC CC CC CC CC CC CC 00 00 00 00 00 00 00 00 11 11 11 11 CC CC CC CC

 

 

而 sizeof(A) = 32;0 分别代表 i,sa.a,sa.b 的位置,11111111代表 c 的位置。

 

 

 

# pragma pack(8)

struct A{

      char a;

      double b;

};

struct B{

int i;

int c;

   struct A sa;

};

 

 

 

 

把 c 移到 sa 的上面,这样就不需要填充任何字节了。B的所有的成员刚好满足对齐规则。注意,结构 A 中的 b 和 a 之间还是要填充字节的,它内部要满足自己的对齐要求。此时 B 的内存布局如下:

 

00 00 00 00 11 1111 11 00 CC CC CC CC CC CC CC 00 00 00 00 00 00 00 00

 

 

而 sizeof(A) = 24;0分别代表 i ,sa.a,sa.b的位置,11111111 代表 c 的位置。

 

 

 

# pragma pack(4)

struct A{

      char a[9];

      double b;

char c[29];

int d[7];

};

 

 

 

 

数组成员 a 的有效对齐是和其成员类型 char 一样,1。成员 b 的有效对齐是指定的对齐参数 4,因为指定的比它自身的小。c 数组同样也是 1 字节对齐,数组 d 是 4 字节对齐,指定的对齐和它自身的对齐一样,都是 4。数组的各个成员是紧密排列的,所以,b 和 a 之间填充了 3 个字节,c 和 b之间不填充字节,d 和 c 之间填充3个字节,c 之后不填充字节。sizeof(A) = 80;

 

 

 

然后基本上就这么多了,一些复杂的例子按照对齐规则,慢慢的找到各个有效对齐参数,都是可以迎刃而解的。联合体和类都是差不多的。还有一些位域的结构的话道理也是一样的,你知道在你自己的系统上位域是怎么安排的就可以了,对齐规则还是一样。下面是我随便写的一个,在 Windows 上的布局,可以参考下。注释有字节的占用大小,+ 表示有填充。

 

 

 

# pragma pack(4)

struct A{

   char a[5];  //5+1

   short b:2;  // 2

   int c:5;    // 4

   int :8;     //

   int d:20;   // 4

   int :0;     //

   int e:8;    // 4

   short f:1;  // 2

   short g;    // 2

   short h:5;  // 2+2

   double i;   // 8

   char j:2;   // 1+3

};

   sizeof(A) = 40;

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值