C&C++结构的字节对齐
测试系统:Windows XP
编译器:VC6.0,VS2008
结构体的字节对齐是我很早就想全面了解一下的东西,这个东西本质上是和硬件相关的,本来要想真正全面了解的话必须得知道CPU的结构、内存的结构、CPU指令是如何执行的等这些硬件层的东西才行,并不是说了解几个寄存器写两句汇编码就可以的,和这个没联系。在尝试了几次之后,迫于个人能力问题,无奈之下只好放弃深层次的了解,只能了解一下字节对齐的规则,至于为什么要对齐的话题,大概也就只能从网络上那几句肤浅的话了解到了:
“一些平台对某些特定类型的数据只能从某些特定地址开始存 ……最常见的是如果不按照适合其平台要求对 数据存放进行对齐,会在存取效率上带来损失……”
不过无所谓了,对于写程序来说知其然其实也可以了,是否知其所以然那是修养问题了。
但是就只是想了解字节对齐的规则也不是想象中那么容易的事情,对齐规则本身并不算复杂,而最困惑的是网上各种错误的文章,至少我看过的那几十篇包括一些重复的转载和引用,基本上是错的。当然,也并不是说他们错得多么的离谱,大致还是对的,只是一些困惑的地方从来没有人去证实,都自然而然的想当然了,而结果却是错误的。
所以我还是那句话,从来都不带怀疑和思考的精神去看别人的东西,从来不经过自己的证实就轻易相信,那么,误导了你也是你自己活该~~
我写这篇笔记,首先是要证明网上那些错误的地方,以及给出我自己的一些结论。然后就是给出我自己总结出来的一个字节对齐规则的版本。
证明它是错的
我所说的他们那些错误的地方,基本上可以归结于一个问题上的错误,那就是对变量的起始地址的假设。
很多文章大概都有像这样的结论:
“数据项只能存储在地址是数据项大小的整数倍的内存位置上”
“结构体变量的首地址能够被其最宽基本类型成员的大小所整除”
“对齐在N上,也就是说该数据的"存放起始地址%N=0"”
很明显,如果对数据存放地址的把握错误了的话,那么由此推断出来的地址对齐规则也就全都是错的了,而事实上也是如此。我研究这个课题80%的时间都是花在这个上面,而真正的对齐规则一个下午应该就可以解决了。我们先从一些常规的情况说起,最后到一个最BT的情况。
当然,像上面的结论在一般情况下基本上是正确的,也就是说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位的系统上栈的单位大小是32bit,即4字节,每一个栈的地址肯定也是%4=0的,如果一个栈存放了char/short/int的话那么他们肯定也满足%N=0。我们唯一可以找到破绽的就是使用一个8字节的数据,也就是double,通过对栈上数据的巧妙安排让double变量的地址处于一个可以让4整除而不可以让8整除的地址上,那么我们的目的就达到了,"存放起始地址%N=0"的结论即可推翻。当然,熟悉栈布局的话这些是可以轻易做到的。具体可以按如下步骤实验。
首先编译这个代码看
int main(){
double a = 0;
double b = 0;
double c = 0;
printf("&a=0x%X_&b=0x%X_&c=0x%X", &a, &b, &c);
return 1;
}
以下分别是VC6和VS2008上的输出
VC6:&a=0x12FF78_&b=0x12FF70_&c=0x12FF68
VS2008:&a=0x12FF5C_&b=0x12FF4C_&c=0x12FF3C
可以看到,在VS2008上其实已经可以推翻这个结论了,我们进一步把它在VC6的情况下也推翻,我们让它挪4个字节。随便加一个4字节或4字节以下长度的变量,放在函数的最开始位置,如下:
int main(){
int i;
double a = 0;
double b = 0;
double c = 0;
printf("&a=0x%X_&b=0x%X_&c=0x%X", &a, &b, &c);
return 1;
}
以下分别是VC6和VS2008上的输出
VC6:&a=0x12FF74_&b=0x12FF6C_&c=0x12FF64
VS2008:&a=0x12FF50_&b=0x12FF40_&c=0x12FF30
可以看到,VC6上的地址都向前挪了4个字节,因为我们加了一个局部变量i把本来应该是a开始存放的地方给占据了一个栈的位置,也就是4个字节,因为栈是向下增长的,所以a的地址以及bc的地址都相应的向前挪了4个字节,一眼就可以看到此时abc的地址已经不能满足%8=0的结论了,只能被4整除不能被8整除,编译器并没有主动把double变量的地址调整到可以被8整除的地址上。不熟悉栈的人可能会问,如果把i的类型改成char的话,abc的地址会只是往前挪动一个字节么?答案是不会,还是挪4个字节,就算是只有1个字节的char类型的变量它还是会占用一片4字节的栈来保存。
而在VS2008上的结果并不是挪动4个字节,而是挪动了12个字节,导致它又刚好满足%8=0了,在VS08上是由原来的不满足变成满足,VS08上可能对栈布局有更高端的作法,但是仍然也没有把double变量每次都强行对齐到8的地址上。
得到的这些地址可能有些随机性,这些地址依赖于具体的环境和编译参数等,但是,无论如何,我们实际看到的东西已经可以推翻上面那些对地址假设的错误的结论了。我们再来看结构体的情况,把他们彻底推翻!还是老样子,测试两个不同的栈布局的代码,就可以得到期望的结果了。为了让内部类型的变量一定可以按照其自身的对齐参数对齐,我们把指定对齐参数设为16,这个到后面会讲,其实在VC上不设也可以的,默认的就是8。
#pragma pack(16)
struct A{
char a;
double b;
};
int main(){
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);
return 1;
}
VC6输出的结果:
&a=0x12FF70_&b=0x12FF60_&c=0x12FF50
&a.b=0x12FF78_&b.b=0x12FF68_&c.b=0x12FF58
VS08输出的结果:
&a=0x12FF54_&b=0x12FF3C_&c=0x12FF24
&a.b=0x12FF5C_&b.b=0x12FF44_&c.b=0x12FF2C
#pragma pack(16)
struct A{
char a;
double b;
};
int main(){
int i;
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);
return 1;
}
VC6输出的结果:
&a=0x12FF6C_&b=0x12FF5C_&c=0x12FF4C
&a.b=0x12FF74_&b.b=0x12FF64_&c.b=0x12FF54
VS08输出的结果:
&a=0x12FF48_&b=0x12FF30_&c=0x12FF18
&a.b=0x12FF50_&b.b=0x12FF38_&c.b=0x12FF20
在一些环境下,编译器会先安排结构体变量,然后再安排内部变量,它并不按定义的顺序来存放,这种情况下我们可以用一个4字节及以下长度的结构体来代替我们那个用于占位的int i ,比如这种结构体变量代替内部类型变量int i 也可以达到预期效果:
struct B{
char a;
char b;
};
由上面可以看到,结构体A的最大长度成员的类型是double,就是成员变量b。而在两个版本的4个输出中可以看到,结构体变量的起始地址不能被8整除的情况有,结构体中的double成员的地址也不能被8整除的情况也有,当然这个的原因还是结构体变量的起始地址不能被8整除导致double的成员也不能被8整除。
尽管在不同的编译环境和系统上会有不同的值,但是从上面的几个实验我们确实得到了不满足那些结论的值,也就是说,无论如何,那些对变量起始地址的假设和结论一定错了~!
我本来以为我对栈布局还比较熟悉,通过多次试验是不是可以完全预测,但是后来证明这些一切都是徒劳,在不同的编译环境下,特别是编译器的优化选项,栈的存放简直就是五花八门,根本预测不到,我们确实不能对变量的地址做过多的假设。
由下面一个例子,我们可以看到什么是BT,我甚至认为它是编译器的一个BUG,在VC6下,相同的编译指令,相同的代码,只是变量的名字不同,注意,是变量的名字不同而已,得出的结果居然会不同~!我开始也怀疑是不是我的电脑有问题了,结果我在其他地方测试也是得到这样的值。这次我是手动编译,没有使用IDE。当然这个问题只在VC6下发现,其他的没再测试过。
H:\Bin_83025>type align.c
struct AA{
char a;
double b;
};
struct BB{
char a;
char b;
};
int main(){
struct BB bb;
struct AA aa;
printf("&a=%x\n&a.a=%x\n&a.b=%x\n",&aa, &aa.a, &aa.b);
return 1;
}
H:\Bin_83025>cl align.c -TC -c –nologo
H:\Bin_83025>link align.obj libc.lib KERNEL32.LIB -nologo
H:\Bin_83025>align
&a=12ff6c
&a.a=12ff6c
&a.b=12ff74
H:\Bin_83025>type align.c
struct AA{
char a;
double b;
};
struct BB{
char a;
char b;
};
int main(){
struct BB bb_;
struct AA aa;
printf("&a=%x\n&a.a=%x\n&a.b=%x\n",&aa, &aa.a, &aa.b);
return 1;
}
H:\Bin_83025>cl align.c -TC -c –nologo
H:\Bin_83025>link align.obj libc.lib KERNEL32.LIB -nologo
H:\Bin_83025>align
&a=12ff70
&a.a=12ff70
&a.b=12ff78
看到没有,同样的一个C文件align.c,同样的代码,只是把变量bb的名字改成了bb_ ,然后使用同样的编译指令,链接同样的库,居然可以得到不同的输出~!这个我经过了反复的测试,以及在不同的电脑上测试,得到的值都是这两个。不信的话可以自己测试一下,当然,不能在IDE下测试,因为在IDE下它会强行帮你加上一堆的编译指令,这招就不灵了,除非你把那些编译指令都去掉,留下以上几个,这样得到的输出应该是和上面的一样的。或者就手动自己来编译。
所以,哎~在这个地址的问题上确实是花了我很多时间和精力,也没得到什么实际的东西,只得到一个结果:无法预测~……
地址的字节对齐规则
地址对齐的规则也不是很复杂,只要把对起始地址有假设的那些结论稍微改一下基本上就差不多了。以下我先给出我自己总结的一个版本,然后再慢慢论证与解析。
编译器都有一个指定的对齐参数用于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 CC CC 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 CC CC 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 CC CC 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 00 00 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 CC CC 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 11 11 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;
原文链接: https://www.cnblogs.com/lindeshi/archive/2012/10/20/2732585.html
欢迎关注
微信关注下方公众号,第一时间获取干货硬货;公众号内回复【pdf】免费获取数百本计算机经典书籍
原创文章受到原创版权保护。转载请注明出处:https://www.ccppcoding.com/archives/66433
非原创文章文中已经注明原地址,如有侵权,联系删除
关注公众号【高性能架构探索】,第一时间获取最新文章
转载文章受原作者版权保护。转载请注明原作者出处!