C++类对象内存布局(一)

转载自:http://www.cnblogs.com/lindeshi/archive/2012/10/20/2732590.html

测试系统:Windows XP

编译器:VS2008

 

在写完了本章的初稿之后,我拜读了那本经典的《Inside The C++ Object Model》,深度探索C++对象模型,我在火车上大概花了20个小时就看完了,因为其实我对这个东西已经比较了解了,这本书给我带来的肯定多过给我带来的惊喜,当然书是有点老,但是其中很多地方也拓展了我的思维,“恩,就应该是这样的”。除了看了这本著作以外我还花了蛮多时间查找了网上的各种资料和博文,为的当然是更加全面的知识。当我写完了这篇文章之后,再去查看这些博文和资料,我发现网上的很多东西其实都略显得肤浅了,而且关键的地方基本上都错掉了,我不想说我就是最后正确的那一个,但是我确实可以证明他们是错的,这只是一种怀疑和实践的态度而已。这个课题的研究,从字节对齐开始到最终的对象模型,花了我整整一个月的时间,也写下了总共大概60页的笔记。

 

C++类因为有继承的存在要比C时代的struct复杂得一些,特别是加上有虚函数的时候,以及多继承等这些特性更是令其内存布局变得面目全非。说实在的我也把握不了,我只是在一个实际的平台上进行了一些探索而已,并用此篇笔记将我的探索成果记录下来。

虽然说有些东西在C++标准里面没有规定如何做,不同的实现可能会有不同的作法,但是了解一个实际的系统是如何做的也会有益于我们更加深入的了解C++或者举一反三地理解其他的实现,而且如果我们了解了自己所用的系统上的具体实现的话,就可以对其为所欲为。

为了对所有情况做一个相对全面的探讨,我主要分两组对其内存布局进行研究,以便循序渐进,最后万剑归宗。

在没有虚函数的情况下:普通类的内存布局、有继承的时候的内存布局、多重继承的时候的内存布局、有虚继承的情况下的内存布局。

在有虚函数的情况下:普通类的内存布局、有继承的时候的内存布局、多重继承的时候的内存布局、有虚继承的情况下的内存布局。

以上的各种情况基本上就把C++类的各种层次情况都涵盖了,了解完这些就可以有一个万剑归宗的对象模型了。

没有虚函数的情况下

普通类的情况:

在没有虚函数的情况下C++类对象的内存布局还算简单,基本上和C的结构体差不多,只是有几个东西是比C时期的结构体多出来的,其实在我看来是很自然而然的事情,但是为了能够全面一点还是说一下为好,新手看到这些东西还是会引起混乱的。

一个最明显的比C的结构体多出来的东西就是成员函数,成员函数,包括静态和非静态的,在类的对象里面是不占用内存空间的,那么当我们通过类对象调用它的成员函数的时候是怎么调用到的呢?一个必须的事情是一定要找到函数的地址,其实函数,也就是编译后的执行代码,是保存在代码段的,这些成员函数其实和普通的函数是一样的,只是调用的时候会传递隐藏参数而已,具体如何传参将在另一篇文章里面讲诉。所以,这些函数在编译的时候就可以确定其地址了,其地址是由编译器把握的,在编译期间就确定其地址也是自然的事情,当然最终的地址是要等到链接的时候才确定,不过也不影响。所以这些成员函数是不保存在对象里面的。普通的成员函数对对象的内存布局没什么影响。

还有一个就是访问控制的修饰符,就是那些public,什么的,这些也是不会影响对象的内存布局的,这些访问控制是只是语法上的约束而已,所以说你知道了对象的内存布局以后就算是私有成员变量也是可以通过地址运算强行访问到的,毕竟语法的约束只是为了防止你无意间的错误,不是为了防止你有意这么做的。当然在不同的平台上,C++对象模型的实现也许也会不同,而且C++标准里面只规定了在同一个访问域里面的成员要按照其声明顺序排放而没有规定在不同的访问控制域里面的成员的布局情况,但是至少在Windows 上它是这样搞的,所有的访问控制符不理,成员按照声明的顺序依次排放。

还有就是静态的成员变量,既然是静态的,当然是储存在数据区咯,也是不会占用对象的内存空间的,对象使用这些静态变量的时候也需要知道其地址,它和函数差不多,在编译完之后静态变量的地址就已经确定下来了,在使用到这些静态变量的地方,可以直接把静态变量的地址写死到那里去,因为在程序的整个运行过程中静态变量的地址和全局变量是一样的,都是不会变化的。所以这些静态变量的寻址也是不依赖于对象的。

还有一个比较特别的地方,就是在C时期的结构体是不允许没有成员变量的,没有成员变量的话就会编译报错。但是在C++的类里面却是可以没有成员变量的,甚至可以没有任何东西,像 class A{} 这样的类也是可以的,而且还可以定义这样的类对象。在C++里面,即使是空的类,编译器也会为其安排1Byte的长度,sizeof(A)=1,以确保类的每个实例都会有唯一的内存地址。空类除了必须保证它的每个实例都会有不同的地址以外,其他的东西比较依赖于实现怎么去做,对齐规则、编译器优化等,都会影响其对象模型。

然后在C++的类里面的一般对象的内存布局就遵循地址对齐规则了,和C的布局就一样了,按声明的顺序依次排放变量,而且要满足对齐规则,具体的布局规则和例子就不说了,另一篇文章已经有相对完整的讲述,见《C&C++结构的字节对齐》一篇。

 

单继承的情况:

在只有单继承的情况下,类对象的内存布局也还算不复杂。在class B 继承了class A 的情况下,我们大致也可以猜测到B的对象的内存分布,应该是先储存类A里面的成员,然后再依次存放类B本身的成员。因为这样的布局在转换父类指针的时候不需要做任何处理即可兼容C,而且这种对象模型的实现也比较简单合理。但是具体是怎么放的呢?可以想到的大概有4种情况。像class B:A{…}这种单继承关系,默认的继承是private继承,之前说过访问修饰不影响内存布局,内存布局的4种可能是:

一是先将A作为一块整块的结构存放,然后再将B作为一块整块的结构存放,即有点像这样class temp{A x; B y;}这种等价布局;

二是先将A作为一块整块的结构存放,然后再在其后依次排放类B本身的各个成员变量,像class temp{A x; …. B的各个成员变量…}这种等价布局;

三是先将A的各个成员变量依次排放,然后将B作为一个整体结构存放,就像这样class temp{ A的各个成员….; B y}这种等价布局;

四是先排放A的各个成员变量再跟着排放B的各个成员变量,即像是这样class temp{A的各个成员…..; B的各个成员…..;}

 

这4种布局有什么不同么?当然有~因为有字节对齐的存在~!这几种布局的意义显然是不同的,当然,如果把字节对齐设为1字节对齐的话这几种布局模型表面上看起来就是一样的了。

如果熟悉结构的字节对齐的话,就可以很容易找到一些合适的类来一个个地推翻他们,如果不是那样的话,估计找一些合适的类的过程会比较困惑。当然也不要刻意去找一些很巧妙的类,能够证明对错其实就可以了。 

 1 #pragma pack(8)
 2 class A1{public:
 3     double b1;
 4     char c1;
 5     A1():b1(0),c1(0xFF){}
 6 };
 7  
 8 class A2:A1{public:
 9     char a2;
10     int a3;
11     A2():a2(0xee),a3(0x22222222){}
12 };

像上面的两个类,按照字节对齐的规则可以推翻那4个可能的分布中至少两个内存布局情况。按照字节对齐规则,在上面的两个类中,布局类型1和2是等价的布局,而第3种可能和第4种可能都有其不同的布局情况。在有IDE的情况下我们可以很方便的写代码然后就直接抓数据,我在这里用的是VS2008。一同测试的代码还有下面的一些,和上面是一起的:

 

 1 class A2_{public:   //  A2不带A1的继承的情况的等价类
 2     char a2;
 3     int a3;
 4     A2_():a2(0xee),a3(0x22222222){}
 5 };
 6 class temp1{public:     // 与情况1等价布局的类
 7     A1 a1;
 8     A2_ a2;
 9 };
10 class temp2{public:     // 与情况2等价布局的类
11     A1 a1;
12     char a2;
13     int a3;
14     temp2():a2(0xee),a3(0x22222222){}
15 };
16 class temp3{public:     // 与情况3等价布局的类
17     double b1;
18     char c1;
19     A2_ a2;
20     temp3():b1(0),c1(0xFF){}
21 };
22 class temp4{public:     // 与情况4等价布局的类
23     double b1;
24     char c1;
25     char a2;
26     int a3;
27     temp4():b1(0),c1(0xFF),a2(0xee),a3(0x22222222){}
28 };
29  
30 int res;    // 在main函数里打断点抓内存数据
31 int main(){
32     A2 x;res = sizeof(x);
33     temp1 y;res = sizeof(y);
34     temp2 z;res = sizeof(z);
35     temp3 m;res = sizeof(m);
36     temp4 n;res = sizeof(n);
37  
38     return 0;
39 }

以上代码在VS08里面的布局抓包如下:

A2  sizeof=24
0x0012FF4C  00 00 00 00 00 00 00 00 ff cc cc cc cc cc cc cc 
0x0012FF5C  ee cc cc cc 22 22 22 22
 
temp1  sizeof=24
0x0012FF2C  00 00 00 00 00 00 00 00 ff cc cc cc cc cc cc cc 
0x0012FF3C  ee cc cc cc 22 22 22 22
 
temp2 sizeof=24
0x0012FF0C  00 00 00 00 00 00 00 00 ff cc cc cc cc cc cc cc 
0x0012FF1C  ee cc cc cc 22 22 22 22
 
temp3 sizeof=24
0x0012FEEC  00 00 00 00 00 00 00 00 ff cc cc cc ee cc cc cc 
0x0012FEFC  22 22 22 22 cc cc cc cc
 
temp4 sizeof=16
0x0012FED4  00 00 00 00 00 00 00 00 ff ee cc cc 22 22 22 22

很明显,第3种和第4种情况的内存分布方式已经被推翻了,还有第1和第2种情况中的一种是正确的,要想进一步推翻其中一个得改一下我们的类里面的成员类型了。我修改的思路是这样的:让基类 class A1 的最宽的成员小于8字节,而且还得让sizeof(A1)不能为8的整数倍,然后让class A2的最宽成员变成8字节的double。

 

 1 #pragma pack(8)
 2 class A1{public:
 3     short b1;
 4     char c1;
 5     A1():b1(0xaaaa),c1(0xFF){}
 6 };
 7 class A2:A1{public:
 8     char a2;
 9     double a3;
10     A2():a2(0xee),a3(0){}
11 };
12  
13 class A2_{public:   // A2不带A1的继承 的情况的等价类
14     char a2;
15     double a3;
16     A2_():a2(0xee),a3(0){}
17 };
18 class temp1{public:     // 与情况1等价布局的类
19     A1 a1;
20     A2_ a2;
21 };
22 class temp2{public:     // 与情况2等价布局的类
23     A1 a1;
24     char a2;
25     double a3;
26     temp2():a2(0xee),a3(0){}
27 };
28  
29 int res;    // 在main函数里打断点抓内存数据
30 int main(){
31     A2 x;res = sizeof(x);
32     temp1 y;res = sizeof(y);
33     temp2 z;res = sizeof(z);
34  
35     return 0;
36 }

 测试结果如下:

A2  sizeof=16
0x0012FF54  aa aa ff cc ee cc cc cc 00 00 00 00 00 00 00 00
 
temp1  sizeof=24
0x0012FF34  aa aa ff cc cc cc cc cc ee cc cc cc cc cc cc cc 
0x0012FF44  00 00 00 00 00 00 00 00
 
temp2  siezof=16
0x0012FF1C  aa aa ff cc ee cc cc cc 00 00 00 00 00 00 00 00

现在我们可以得出结论了,单继承的类的内存布局是和第二种情况等价的:先将A作为一块整块的结构存放,然后再在其后依次排放类B本身的各个成员变量,像class temp{A x; …. B的各个成员变量…}这种等价布局。

其实这也是最合理的一种情况,因为C++标准规定需要保证基类子对象的完整性,所以基类就必须作为一个完整的结构进行存储。

知道了这些对象的内存布局的情况我们就可以对任意的单继承类进行把握其在内存里面的样子了,一直递归地推理下去就可以得到最终的内存布局。单继承还是比较简单的,还有一种比较特殊的情况就是继承有空类的时候,空类这个东西各个系统应该会有不同的实现,在Windows 上的实现我测试了一下,除了一些基本的东西比较好估计以外,其他的很多种情况貌似找不到很好的理由去解释它,就是比较难总结,而且在多继承的情况下会有各种特殊情况而且VS2008上的实现还有BUG,虽然这种BUG不会导致什么,但是至少我认为肯定是一个不合理的地方。所以,索性我就不说空类这种情况了,没什么意义。

 

多继承的情况

既然单继承的情况已经明了了,多继承的情况也自然可以推理出来了,自然是按照继承的声明顺序,逐个类依次排放,最后到派生类本身的成员。比如类B继承了A1、A2、A3,即class B:A1,A2,A3{…}; 其内存布局就是等价于class temp{A1 a1; A2 a2; A3 a3; … }; 论证过程和单继承的时候差不多,也不难就是比较多东西而已,在这里我就不摆出来了,随便摆个例子吧。

 1 #pragma pack(8)
 2 class A1{public:
 3     int a1;
 4     A1():a1(0xa1a1a1a1){}
 5 };
 6 class A2{public:
 7     int a2;
 8     A2():a2(0xa2a2a2a2){}
 9 };
10 class A3{public:
11     int a3;
12     A3():a3(0xa3a3a3a3){}
13 };
14 class B: A1, A2, A3 {public:
15     int b;
16     B():b(0xbbbbbbbb){}
17 };
18  
19 int main()
20 {
21     B bb;
22     return 1;
23 }
24  
25 sizeof(B)    0x00000010 
26 0x0012FF54  a1 a1 a1 a1 a2 a2 a2 a2 a3 a3 a3 a3 bb bb bb bb

在多继承这里我一定要说一下,空类的情况下,VS08的处理不太合理的地方,比如有下面类 class A{};class B{};classC:A,B{}; 这3个类,C同时继承了A和B,在这种情况下,sizeof(C)=1; 当将C的指针转换成其基类B的指针的时候,理论上基类一定是在整个派生类里面的,但是在这种类似的情况下,基类B的指针会指到C的外面去,举个实例, 

1 C c;
2 printf("size=%u pC=0x%x pA=0x%x pB=0x%x",sizeof(c),&c,(A *)&c ,(B *)&c);

输出结果:

size=1 pC=0x12ff63 pA=0x12ff63 pB=0x12ff64

    由输出可以看到,类C的整个大小才是1字节,而在转换其基类B的指针的时候却指到了类的外面的地址去了,虽然在实际中不会有人故意这样来***难自己,但是我觉得这里确实是一个不合理的地方,一个BUG。所以我要抛开空类来讨论事情。

posted @ 2018-02-19 21:08  shuziluoji  阅读(210)  评论(0编辑  收藏  举报