浅析C++中虚函数的调用及对象的内部布局

时间:2022-07-14 01:06:15
 

   在我那篇《浅析C++中的this指针》中,我通过分析C++代码编译后生成的汇编代码来分析this指针的实现方法。这次我依然用分析C++代码编译后生成的汇编代码来说明C++中虚函数调用的实现方法,顺便也说明一下C++中的对象内部布局。下面所有的汇编代码都是用VC2005编译出来的。虽然,不同的编译器可能会编译出不同的结果,对象的内部布局也不尽相同;但是,只要是符合C++标准的编译器,编译结果和对象的内部布局应该是大同小异。
    首先,是一个有着简单继承关系的两个类:

class  CBase
{
public :
    
virtual   void  VFun1()  =   0 ;
    
virtual   void  VFun2()  =   0 ;
    
void  Fun1();
};

//  这里仅仅是为了生成函数的汇编代码,因此函数体为空
void  CBase::Fun1()
{
}

class  CDerived :  public  CBase
{
public :
    
virtual   void  VFun1();
    
virtual   void  VFun2();
    
void  Fun2();
private :
    
int  m_iValue1;
    
int  m_iValue2;
};

//  这里仅仅是为了生成函数的汇编代码,因此函数体为空
void  CDerived::VFun1()
{
}

//  这里仅仅是为了生成函数的汇编代码,因此函数体为空
void  CDerived::VFun2()
{
}

//  这里是为了分析对象的内部布局,因此仅仅是给成员变量赋值
void  CDerived::Fun2()
{
    m_iValue1 
=   13 ;
    m_iValue2 
=   13 ;
}

    现在用下面的代码来调用成员函数:

CDerived derived;

//  用对象调用虚函数
derived.VFun1();
derived.VFun2();
//  用对象调用非虚函数
derived.Fun1();
derived.Fun2();

//  用指向派生类的基类的指针调用虚函数,实现多态
CBase  * pTest  =   & derived;
pTest
-> VFun1();
pTest
-> VFun2();

    下面就是用VC2005编译上面的代码后生成的汇编代码:

    CDerived derived;
0041195E  lea         ecx,[derived] 
00411961   call        CDerived::CDerived (411177h) 

//  代码段1
    derived.VFun1();
00411966   lea         ecx,[derived] 
00411969   call        CDerived::VFun1 (411078h) 
    derived.VFun2();
0041196E  lea         ecx,[derived] 
00411971   call        CDerived::VFun2 (4111B8h) 
    derived.Fun1();
00411976   lea         ecx,[derived] 
00411979   call        CBase::Fun1 (411249h) 
    derived.Fun2();
0041197E  lea         ecx,[derived] 
00411981   call        CDerived::Fun2 (4111BDh) 

//  代码段2
    CBase  * pTest  =   & derived;
00411986   lea         eax,[derived] 
00411989   mov         dword ptr [pTest],eax 
    pTest
-> VFun1();
0041198C  mov         eax,dword ptr [pTest] 
//  行1
0041198F  mov         edx,dword ptr [eax]  //  行2
00411991   mov         esi,esp 
00411993   mov         ecx,dword ptr [pTest] 
00411996   mov         eax,dword ptr [edx]  //  行3
00411998   call        eax  //  行4  
0041199A  cmp         esi,esp 
0041199C  call        @ILT
+ 495 (__RTC_CheckEsp) (4111F4h) 
    pTest
-> VFun2();
004119A1  mov         eax,dword ptr [pTest] 
004119A4  mov         edx,dword ptr [eax] 
004119A6  mov         esi,esp 
004119A8  mov         ecx,dword ptr [pTest] 
004119AB  mov         eax,dword ptr [edx
+ 4 //  行5
004119AE  call        eax  
004119B0  cmp         esi,esp 
004119B2  call        @ILT
+ 495 (__RTC_CheckEsp) (4111F4h) 

    通过对代码段1的观察我们可以发现:通过对象调用类的虚成员函数和调用非虚成员函数是相同的(对调用成员函数的汇编代码的分析可以看我的那篇《浅析C++中的this指针》)。也就是说,用对象是无法实现多态的。
    下面主要来分析实现多态的代码段2。
    行1、将pTest指针指向的地址前2个字(4个字节,也就是32位系统中一个指针的大小)的内容当成一个指针放到eax寄存器中
    行2、将eax寄存器中的指针的值放入edx寄存器
    行3、将dex寄存器中的指针的值放入eax寄存器
    行4、调用eax寄存器指向的函数
    这样分析似乎对怎样调用对象derived的虚函数VFun1()并不是很清楚。那么我们先来看下面的这张图:

浅析C++中虚函数的调用及对象的内部布局

    这张图是一个假设的对象derived在内存中的内部布局图。指针pTest指向对象derived,而对象derived的前4个字节是一个虚表指针,指向虚函数表。
    看着这张图再来分析上面的汇编代码就会清晰很多:
    行1、取得虚表指针值放入eax寄存器中
    行2、取得虚表指针的值放入edx寄存器中
    行3、取得虚表指针指向的地址的值(也就是VFun1)放入eax寄存器中
    行4、调用eax寄存器指向的函数
    行5证明了上面图中对虚函数表的假设。第二个虚函数VFun2()的地址就是通过在第一虚函数VFun1()的地址加4(32位系统中一个指针的大小)而得到的。
    通过上面的分析,可以得出C++中虚函数的调用方法:首先,取得对象中的虚表指针;然后,通过虚表指针找到相应的虚表;最后,通过在虚表内的偏移量找到相应的函数来调用。
    下面通过分析类CDerived的非虚成员函数Fun2()来证明上面图中虚函数表指针的存在。

void  CDerived::Fun2()
{
004118F0  push        ebp  
004118F1  mov         ebp,esp 
004118F3  sub         esp,0CCh 
004118F9  push        ebx  
004118FA  push        esi  
004118FB  push        edi  
004118FC  push        ecx  
004118FD  lea         edi,[ebp
- 0CCh] 
00411903   mov         ecx,33h 
00411908   mov         eax,0CCCCCCCCh 
0041190D  rep stos    dword ptr es:[edi] 
0041190F  pop         ecx  
00411910   mov         dword ptr [ebp - 8 ],ecx 
    m_iValue1 
=   13 ;
00411913   mov         eax,dword ptr [ this //  行6
00411916   mov         dword ptr [eax + 4 ],0Dh  //  行7
    m_iValue2  =   13 ;
0041191D  mov         eax,dword ptr [
this
00411920   mov         dword ptr [eax + 8 ],0Dh 
}
00411927   pop         edi  
00411928   pop         esi  
00411929   pop         ebx  
0041192A  mov         esp,ebp 
0041192C  pop         ebp  
0041192D  ret  

    上面是类CDerived的非虚成员函数Fun2()的汇编代码。可以看到,行6是将this指向的地址放入eax寄存器,而行7是给this指针指向的地址加4的地址赋值(具体的分析,可以看《浅析C++中的this指针》),而这个地址里面存放的是类CDerived的第一个成员变量。我们知道this指针是指向对象首地址的,那么为什么要给第一个成员变量赋值的时候要向后移动4个字节?答案是因为对象的前4个字节是用来存放虚表指针的。
    下面的代码是《浅析C++中的this指针》一文中的不含虚函数的类的C++代码和编译后的汇编代码:

class  CTest
{
public :
    
void  SetValue();

private :
    
int  m_iValue1;
    
int  m_iValue2;
};

void  CTest::SetValue()
{
    m_iValue1 
=   13 ;
    m_iValue2 
=   13 ;
}

void  CTest::SetValue()
{
004117E0  push        ebp  
004117E1  mov         ebp,esp 
004117E3  sub         esp,0CCh 
004117E9  push        ebx  
004117EA  push        esi  
004117EB  push        edi  
004117EC  push        ecx  
004117ED  lea         edi,[ebp
- 0CCh] 
004117F3  mov         ecx,33h 
004117F8  mov         eax,0CCCCCCCCh 
004117FD  rep stos    dword ptr es:[edi] 
004117FF  pop         ecx  
00411800   mov         dword ptr [ebp - 8 ],ecx 
    m_iValue1 
=   13 ;
00411803   mov         eax,dword ptr [ this //  行8
00411806   mov         dword ptr [eax],0Dh  //  行9
    m_iValue2  =   13 ;
0041180C  mov         eax,dword ptr [
this
0041180F  mov         dword ptr [eax
+ 4 ],0Dh 
}
00411816   pop         edi  
00411817   pop         esi  
00411818   pop         ebx  
00411819   mov         esp,ebp 
0041181B  pop         ebp  
0041181C  ret  

    通过行8、行9和行6、行7的比较就可以看出:类CTest的对象前4个字节存放的是自己的第一个成员变量;而类CDerived的对象从第5个字节开始才是存放的自己的第一个成员变量,它的前4个字节是用来存放虚表指针的。这再一次证明了上面图中对象内部布局的正确性。