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

C++类对象内存布局

测试系统:Windows XP

编译器:VS2008

(三)

考虑有虚函数的情况下

普通类的情况:

    在有虚函数的情况下类会为其增加一个隐藏的成员,虚函数表指针,指向一个虚函数表,虚函数表里面就是类的各个虚函数的地址了。那么,虚函数表指针是以什么模型加入到类里面的,虚函数表里面又是怎么安排的呢。简单来看下就可以知道了。

#pragma pack(8)

class A

{

public:

    int a;double a2;

    A():a(0xaaaaaaaa),a2(0){}

    virtual void funA2(){}

    virtual ~A(){}

    virtual void funA(){}

};

 

    定义一个A的变量,然后看其内存布局:

sizeof(A)    0x00000018  

0x0012FF40  40 77 41 00 cc cc cc cc aa aa aa aa cc cc cc cc 

0x0012FF50  00 00 00 00 00 00 00 00

 

    最开始的4个字节就是虚函数表指针了,可以看到在其后又填充了4个字节,在《C++虚基类表指针字节对齐模型》里面我们已经讲过了虚基类表指针和虚函数表指针的字节对齐模型。放完虚函数表指针然后才到类A的成员变量。所以在普通类里面,如果有虚函数的话就会在最开始的地方添加一个隐藏的成员变量,虚函数表指针,然后才到正常的成员变量。然后我们再去看下虚函数表里面是什么样子的:

 

0x00417740  37 10 41 00 25 13 41 00 d5 12 41 00

    虚函数表也是以4字节为一项,每一项保存一个虚函数的地址。保存的虚函数的地址按照函数声明的顺序排放,第一项存放第一个声明的虚函数,第二项存放第二个,依此类推。我们看下这个表里面的每个项都是什么。

 

00411037  jmp         A::funA2 (411740h)

00411325  jmp         A::`scalar deleting destructor' (4118F0h)

004112D5  jmp         A::funA (4117B0h)

    可以看到这几个地址实际上也还不是虚函数的直接地址,而是一个跳转到相应虚函数的地址,不过其实都一样啦,编译器是可以这样安排的,而且我发现在VS2008里面如果类的定义是写在函数里面的话,再在函数里面定义类的变量的时候,这个变量的虚函数表里面的项就是直接的虚函数地址了,就没有这个跳转了,如果把类的定义移到函数外面,那么虚函数表里面的项就又变成跳转形式的了,当然这些都无所谓了随便编译器怎么安排了,反正最后能调用到正确的函数就行。还有就是第二个虚函数表项的地址看起来并不是直接跳转到A的虚析构函数,而是执行了一些其他的东西,这也是一样的其实,在这个里面还是会调用到A的析构函数的。

A::`scalar deleting destructor':

004118F0  push        ebp 

…………

00411916  call        A::~A (411311h)

…………

    所以在有虚函数的情况下类的安排也是很简单的,和没有虚函数的情况相比就是在最前面加一个虚函数表指针而已。其他的东西就和没有虚函数的类的情况的时候一样了。然后好像也没有什么然后了,复杂的是在后面~

 

单继承的情况:

    单继承大概又可以分为两种情况,一种是基类没有虚函数的情况,一种是基类已经有虚函数表指针的情况。我们分别来看下。

 

#pragma pack(8)

class F2{public: int f2; double f22; F2():f22(0),f2(0xf2f2f2f2){}};

class B: F2{public:

int b;

    B():b(0xbbbbbbbb){}

    virtual void funB(){}

};

    B的布局抓数据如下。

sizeof(B)    0x00000020  

0x0012FF18  54 77 41 00 cc cc cc cc f2 f2 f2 f2 cc cc cc cc 

0x0012FF28  00 00 00 00 00 00 00 00 bb bb bb bb cc cc cc cc

 

可以看到虚函数表指针还是放在最开始的地方,也遵循它自己的地址对齐规则,主动填充了4个字节在后面。然后就是F2作为一个整体结构存放在其后,最后才是成员变量b,整个结构也要自身对齐,所以填充了4个字节在最后。虚函数表里面的就是B的虚函数funB的地址了。因为只有一个虚函数,所以虚函数表里面也就只有一项。

0x00417754  59 11 41 00

00411159  jmp         B::funB (411870h)

    所以在基类没有虚函数的情况下,会产生一个虚函数表指针,而且也还是先存放类的虚函数表指针,然后才到基类等。其实在类有虚函数的情况下(暂不考虑虚继承),虚函数表指针都是会存放在最开始的。我们再来看下如果继承的基类已经有了虚函数表指针的情况会是什么样子。

 

class B: A{public:

    int b;

    B():b(0xbbbbbbbb){}

    virtual void funB(){}

    virtual void funA2(){}

};

 

   A的布局我们已经知道了,现在B继承A,而且还有覆盖了A的虚函数,来看下布局。

sizeof(B)    0x00000020  

0x0012FF18  68 77 41 00 cc cc cc cc aa aa aa aa cc cc cc cc 

0x0012FF28  00 00 00 00 00 00 00 00 bb bb bb bb cc cc cc cc

 

    很明显,在基类已经有虚函数表指针的情况下派生类不会再主动产生一个虚函数表指针,基类的虚函数表指针是可以和派生类共用的,因为基类的虚函数肯定也是属于派生类的,如果派生类有虚函数覆盖掉基类的虚函数的话就会把虚函数表里面的相应的项改成正确的地址,而且虚函数表指针刚好也是放在类的最开始的位置。所以在这种情况下就是先放基类然后再排放成员变量。我们来看下现在派生类和基类共用的虚函数表是什么样子的。

0x00417768  48 13 41 00 34 13 41 00 d5 12 41 00 59 11 41 00

00411348  jmp         B::funA2 (411870h)

00411334  jmp         B::`scalar deleting destructor' (414650h)

004112D5  jmp         A::funA (4117B0h)

00411159  jmp         B::funB (413EB0h)

   

虚函数表有4个项,可以看到第一个项的虚函数已经被B里面的那个funA2所取代了,因为B里面的funA2已经覆盖了基类A里面的funA2,所以在虚函数表里面也要相应的改变,这也正是虚函数得以正确调用的前提,关于虚函数是如何调用的会在另外一篇文章里面描述。然后看到第二个项,也被替换成了B的虚析构函数,我们在代码里面没明写出B的虚析构函数,编译器会自动生成一个,而且B的虚析构函数也是会覆盖掉基类A的虚析构函数的。虚函数表里面的第三项还是A里面的函数funA,因为在派生类里面没有被覆盖,所以还应该是基类里面的函数,最后一项是基类A没有的函数funB,所以在这个共用的虚函数表里面基类A只是用到了前3项而已,后面的项就是没有覆盖掉基类的其他虚函数了,而且是按照声明顺序依次排放的。

所以我们暂时可以得出的结论是,有虚函数的类在单继承的情况下,如果基类没有虚函数表指针的话会产生一个隐藏的成员变量,虚函数表指针,放在类的最前面,然后才是基类,最后是派生类的各个成员;如果基类已经有了虚函数表指针的话就不需要再产生一个虚函数表指针,派生类可以和基类共用一个虚函数表,此时派生类的布局是先放基类然后再放派生类的各个成员变量。如果派生类有函数覆盖了基类里面的虚函数的话,虚函数表里面的相应项就会改成这个函数的真正地址,其他没有覆盖的虚函数按照声明的顺序依次排放在虚函数表的后面各项中。

 

多继承的情况:

鉴于有虚函数的类的第一项都要是虚函数表指针,所以在多继承的情况下会跟普通情况有所不同。但是有虚函数的类多继承情况下的对象模型也还是比较简单和明确的。

大概也有两种情况,一种是所有的基类都没有虚函数的情况,一种是基类中有些有虚函数有些又没有虚函数的混杂情况。

对于第一种情况,内存布局大概是这样,比如类A的基类都是没有虚函数的话,class A:F0,F1,F2{int a; (其他成员变量)…… virtual void fun1(){} ……}; 那么A肯定也还是要生成一个虚函数表指针的,放在最开始的位置,这种情况下的等价模型大概是这样 class A{void * vf_ptr;F0{};F1{};F2{};int a; (其他成员变量)……}; 注意各个的字节对齐就可以了,特别是虚函数表指针。

对于第二种情况,基类是混杂的情况的时候,比如类A, class A:F0,F1,V0,V1,F2,V2{ int a; (其他成员变量)…… virtual void fun1(){} ……}; V0、V1、V2是有虚函数的基类,F是没虚函数的基类,而且继承的声明顺序随意。像这种情况的话类A的对象模型大概是这样的,先排放基类中有虚函数的基类,按照声明顺序,然后再排放基类中没有虚函数的基类,也是按照声明顺序。比如A此时的对象模型就大概是这样: class A{V0{};V1{};V2{};F0{};F1{};F2{};int a; (其他成员变量)……}; 因为基类已经有了虚函数表指针了,所以派生类A也是可以和第一个有虚函数表指针的基类共用一个虚函数表的,这个和单继承的时候的道理是一样的,自然派生类就不会在生成一个虚函数表指针了。我们来实际来下这两种情况的实例。

#pragma pack(8)

class F0{public: char f0; F0():f0(0xf0){}};

class F1{public: int f1; F1():f1(0xf1f1f1f1){}};

class C: F1, F0{public:

    int c;

    virtual void funC(){}

    virtual void funB(){}

    virtual void funA2(){}

    C():c(0x33333333){}

};

 

sizeof(C)    0x00000010 

0x0012FF00  50 77 4100 f1 f1 f1 f1 f0 cc cc cc 33 33 33 33

 

    在派生类有虚函数而基类都没有虚函数的情况下,派生类仍然会产生一个虚函数表指针放在最开始,然后才到各个基类,最后就是成员变量了。来看下此时虚函数表里面是些什么。

0x00417750  ea 11 41 00 5e 11 41 00 32 10 41 00

004111EA  jmp         C::funC (411B80h)

0041115E  jmp         C::funB (4119F0h)

00411032  jmp         C::funA2 (414310h)

 

    可以看到由于派生类的虚函数没有覆盖任何基类里面的虚函数所以虚函数表里面的各项就是各个虚函数按照声明的顺序的地址了。然后再来看下基类有虚函数而且派生类还有覆盖掉基类的虚函数的情况。

#pragma pack(8)

class F0{public: char f0; F0():f0(0xf0){}};

class F1{public: int f1; F1():f1(0xf1f1f1f1){}};

 

class A

{

public:

    int a;double a2;

    A():a(0xaaaaaaaa),a2(0){}

    virtual void funA2(){}

    virtual ~A(){}

    virtual void funA(){}

};

class B: A{public:

    int b;

    B():b(0xbbbbbbbb){}

    virtual void funB(){}

    virtual void funA2(){}

};

class C: F1, A, F0, B{public:

    int c;

    virtual void funC(){}

    virtual void funB(){}

    virtual void funA2(){}

    C():c(0x33333333){}

};

 

sizeof(A)    0x00000018 

sizeof(B)    0x00000020 

现在已经有能力推断A、B布局,不再列出。

 

sizeof(C)    0x00000048  

0x0012FEC8  24 78 41 00 cc cc cc cc aa aa aa aa cc cc cc cc 

0x0012FED8  00 00 00 00 00 00 00 00 50 77 41 00 cc cc cc cc 

0x0012FEE8  aa aa aa aa cc cc cc cc 00 00 00 00 00 00 00 00  

0x0012FEF8  bb bb bb bb cc cc cc cc f1 f1 f1 f1 f0 cc cc cc 

0x0012FF08  33 33 33 33 cc cc cc cc

 

类C的模型大概是这样:

class C{public:

    A a;

    B b;

    F1 f1;

F0 f0;

int c;

};

 

很明显,虽然F1声明在基类的最前面但是存放顺序还是先存放有虚函数的基类A然后到也是有虚函数的基类B,再才是各个没有虚函数的基类F1、F0。最后才是派生类C的成员变量。C的虚函数funB 覆盖了基类B里面的虚函数,而另一个虚函数funA2既覆盖了基类A里面的虚函数也覆盖了基类B继承自基类A里面的虚函数funA2,理论上基类A和基类B里面被覆盖掉的虚函数其在各自虚函数表里面的对应项都要被改变成正确的函数地址,也就是C里面的虚函数的真实地址。然后我们看下A和B的虚函数表是什么样子的。

A和C共用的虚函数表:

0x00417824  32 10 41 00 57 13 41 00 d5 12 41 00 ea 11 41 00

00411032  jmp         C::funA2 (414310h)

00411357  jmp         C::`scalar deleting destructor' (4143C0h)

004112D5  jmp         A::funA (4117B0h)

004111EA  jmp         C::funC (411B80h)

 

B的虚函数表

0x00417750  4d 13 41 00 61 13 41 00 d5 12 41 00 5e 11 41 00

0041134D  jmp         [thunk]:C::funA2`adjustor{24}' (411850h)

[thunk]:C::funA2`adjustor{24}':

00411850  sub         ecx,18h

00411853  jmp         C::funA2 (411032h)

00411361  jmp         [thunk]:C::`vector deleting destructor' (4118A0h)

[thunk]:C::`vector deleting destructor':

004118A0  sub         ecx,18h

004118A3  jmp         C::`scalar deleting destructor' (411357h)

004112D5  jmp         A::funA (4117B0h)

0041115E  jmp         C::funB (4119F0h)

 

    可以看到派生类和基类A共享的虚函数表里面的各个项已经修改成了函数的真正的地址,在最后还加了一个没有覆盖掉任何基类虚函数的虚函数地址项。而基类B里面的项就有点意外了,它并不是直接修改成跳转到正确的地址上去,而是使用了一个调整块的东西,把EAX寄存器减去相应的值,然后再跳转到正确的函数里面去,这个暂时不在这里赘述,反正最后还是跳转到了C里面的那个函数里面去就是了。其他的项有覆盖的也还是一样都要修改成正确的函数地址。

 

 

原文链接: https://www.cnblogs.com/lindeshi/archive/2012/10/20/2732594.html

欢迎关注

微信关注下方公众号,第一时间获取干货硬货;公众号内回复【pdf】免费获取数百本计算机经典书籍

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

原创文章受到原创版权保护。转载请注明出处:https://www.ccppcoding.com/archives/66435

非原创文章文中已经注明原地址,如有侵权,联系删除

关注公众号【高性能架构探索】,第一时间获取最新文章

转载文章受原作者版权保护。转载请注明原作者出处!

(0)
上一篇 2023年2月9日 下午12:20
下一篇 2023年2月9日 下午12:20

相关推荐