第12章 从内存角度看继承和多重继承

在C++中,类之间的关系与现实社会非常相似。类的继承与派生是一个从抽象到具体的过程。

什么是抽象到具体的过程呢?我们以“表”为例,表是可以用来计时的,这是大家对表的第一印象。那么表是圆的还是方的?体积大还是小?卖多少钱?大家可能一时说不上来,因为此时的“表”是一个抽象概念,没有任何实体,仅仅只是一个概念。这在面向对象领域中被称为抽象类,抽象类同样没有实例。

以“表”为父类,派生出“手表”,手表类中包含的信息就更多了。首先,手表不仅继承了表的特点,而且更加具体:个头不会太大,是戴在手上的,由机芯、表盘、表带等组成……当然,手表类也属于抽象类,还是不够具体。

接着继承手表类,派生出“江诗丹顿牌Patrimony系列81180-000P-9539型手表”,这就属于具体类了,它当然拥有父类“手表”的所有特点,同时还派生出其他数据,以区别于其他品牌。当你想购买这款手表时,销售员拿出一款“江诗丹顿牌某系列某型号的手表”,被你识破了,这个识破过程就叫做RTTI(Run-Time Type Identification,运行时类型识别)。你成功购买了“江诗丹顿牌Patrimony系列81180-000P-9539型手表”后,经调试校正后戴在你手上的那块手表,就是“江诗丹顿牌Patrimony系列81180-000P-9539型手表”类的产品之一,在C++中,这块表被称为实例,也被称为对象。

抽象类没有实例。例如“东西”可以泛指世间万物,但是它过于抽象,我们无法找到“东西”的实体。具体类可以存在实例,如“江诗丹顿牌Patrimony系列81180-000P-9539型手表”存在具体的产品。

指向父类对象的指针除了可以操作父类对象外,还能操作子类对象,正如“江诗丹顿手表属于手表”,此逻辑正确。指向子类对象的指针不能操作父类对象,正如“手表属于江诗丹顿手表”,此逻辑错误。

如果强制将父类对象的指针转换为子类对象的指针,如下所示:


CDervie*pDervie=(CDervie*)&base;//base为父类对象,CDervie继承自base


这条语句虽然可以编译通过,但是存在潜在的危险。例如,如果说:“张三长得像张三他爹”,张三和他爹都能接受;如果说:“张三他爹长得像张三”,虽然也可以,但是不招人喜欢,可能会给你的社会交际带来潜在的危险。

介绍了以上的重要概念之后,我们来探索一下编译器实现以上知识点的技术内幕。

12.1 识别类和类之间的关系

在C++的继承关系中,子类具备父类所有的成员数据和成员函数。子类对象可以直接使用父类中声明为公有和保护的数据成员与成员函数。在父类中声明为私有(private)的成员,虽然子类对象无法直接访问,但是在子类对象的内存结构中,父类私有的成员数据依然存在。C++语法规定的访问控制仅限于编译层面,在编译的过程中由编译器进行语法检查,因此访问控制不会影响对象的内存结构。本节将以公有(public)继承为例进行讲解,首先来看一下代码清单12-1中的代码。

代码清单12-1 定义派生类和继承类—C++源码


class CBase{//基类定义

public:

CBase(){

printf("CBase\r\n");

}

~CBase(){

printf("~CBase\r\n");

}

void SetNumber(int nNumber){

m_nBase=nNumber;

}

int GetNumber(){

return m_nBase;

}

public:

int m_nBase;

};

class CDervie:public CBase{//派生类定义

public:

void ShowNumber(int nNumber){

SetNumber(nNumber);

m_nDervie=nNumber+1;

printf("%d\r\n",GetNumber());

printf("%d\r\n",m_nDervie);

}

public:

int m_nDervie;

};

//main函数实现

void main(int argc, char*argv[]){

CDervie Dervie;

Dervie.ShowNumber(argc);

}


代码清单12-1中定义了两个具有继承关系的类。父类CBase中定义了数据成员m_nBase、构造函数、析构函数和两个成员函数。子类中只有一个成员函数ShowNumber和一个数据成员m_nDervie。根据C++的语法规则,子类CDervie将继承父类中的成员数据和成员函数。那么,当申请了子类对象Dervie时,它在内存中如何存储,又是如何使用父类成员函数的呢?调试代码清单12-1,查看其内存结构及程序执行流程,其汇编代码如代码清单12-2所示。

代码清单12-2 代码清单12-1的调试分析—Debug版


//C++源码与汇编代码对比分析

void main(int argc, char*argv[]){

;函数入口部分略

CDervie Dervie;

0040108D lea ecx,[ebp-14h];获取对象首地址作为this指针

;调用类CDervie的构造函数,编译器为CDervie提供了默认的构造函数

00401090 call@ILT+50(CDervie:CDervie)(00401014)

00401095 mov dword ptr[ebp-4],0

Dervie.ShowNumber(argc);

0040109C mov eax, dword ptr[ebp+8]

0040109F push eax

004010A0 lea ecx,[ebp-14h];调用CDervie成员函数,传入this指针

004010A3 call@ILT+55(CDervie:ShowNumber)(0040101e)

}

004010A8 mov dword ptr[ebp-4],0FFFFFFFFh

004010AF lea ecx,[ebp-14h]

;调用类CDervie的析构函数,编译器为CDervie提供了默认的析构函数

004010B2 call@ILT+45(CDervie:~CDervie)(0040100f)

004010D1 ret

//子类CDervie的默认构造函数分析

CDervie:CDervie:

;函数入口部分略

00401219 pop ecx;还原this指针

0040121A mov dword ptr[ebp-4],ecx

;以子类对象首地址作为父类的this指针,调用父类构造函数

0040121D mov ecx, dword ptr[ebp-4]

00401220 call@ILT+35(CBase:CBase)(00401028)

00401225 mov eax, dword ptr[ebp-4]

;函数出口部分略

00401238 ret

//子类CDervie的默认析构函数分析

CDervie:~CDervie:

;函数入口部分略

004012B9 pop ecx

004012BA mov dword ptr[ebp-4],ecx

004012BD mov ecx, dword ptr[ebp-4]

;调用父类析构函数

004012C0 call@ILT+5(CBase:~CBase)(0040100a)

;函数出口部分略

004012D5 ret


对代码清单12-2进行分析后发现,编译器提供了默认构造函数与析构函数。当子类中没有构造函数或析构函数,而其父类却需要构造函数与析构函数时,编译器会为该父类的子类提供默认的构造函数与析构函数。

由于子类继承了父类,因此子类中需要拥有父类的各成员,类似于在子类中定义了父类的对象作为数据成员使用。代码清单12-1中的类关系如果转换成以下代码,则它们的内存结构等价。


class CBase{……};//类定义见代码清单12-1

class CDervie{

public:

CBase m_Base;//原来的父类CBase成为成员对象

int m_nDervie;//原来的子类派生数据

};


原来的父类CBase成为了CDervie的一个成员对象,当产生CDervie类的对象时,将会先产生成员对象m_Base,这需要调用其构造函数。当CDervie类没有构造函数时,为了能够在CDervie类对象产生时调用成员对象的构造函数,编译器同样会提供默认构造函数,以实现成员构造函数的调用。

但是,如果子类含有构造函数,而父类不存在构造函数,则编译器不会为父类提供默认的构造函数。在构造子类时,由于父类中没有虚表指针,也不存在构造祖先类的问题,因此添加默认构造函数对父类没有任何意义。父类中含有虚函数的情况则不同,此时的父类需要初始化虚表工作,因此编译器会为其提供默认的构造函数,以初始化虚表指针。

当子类对象被销毁时,其父类也同时被销毁,为了可以调用父类的析构函数,编译器为子类提供了默认的析构函数。在子类的析构函数中,析构函数的调用顺序与构造函数相反,先执行自身的析构代码,再执行其父类的析构代码。

依照构造函数与析构函数的调用顺序,不仅可以顺藤摸瓜找出各类之间的关系,还可以根据调用顺序区别出构造函数与析构函数。

子类对象在内存中的数据排列为:先安排父类的数据,后安排子类新定义的数据。当类中定义了其他对象作为成员,并在初始化列表中指定了某个成员的初始化值时,构造的顺序会是怎样的呢?我们先来看下面的代码:


//源码对照

class CInit{

public:

CInit(){

m_nNumber=0;

}

int m_nNumber;

};

class CDervie:public CBase{

public:

CDervie():m_nDervie(1){

printf("使用初始化列表\r\n");

}

CInit m_Init;//在类中定义其他对象作为成员

int m_nDervie;

};

//main函数实现

void main(int argc, char*argv[]){

CDervie Dervie;

}

//反汇编代码分析

;函数入口代码略

00401068 lea ecx,[ebp-0Ch];传递this指针,调用CDervie的构造函数

0040106B call@ILT+10(CDervie:CDervie)(0040100f)

;进一步查看CDervie的构造函数,函数入口代码分析略

004010CF mov dword ptr[ebp-10h],ecx;[ebp-10h]保存了this指针

;传递this指针,并调用父类构造函数

004010D2 mov ecx, dword ptr[ebp-10h]

004010D5 call@ILT+25(CBase:CBase)(0040101e)

004010DA mov dword ptr[ebp-4],0;调试版产生的对象计数代码,不必理会

;根据this指针调整到类中定义的对象m_Init的首地址处,并调用其构造函数

004010E1 mov ecx, dword ptr[ebp-10h]

004010E4 add ecx,4

004010E7 call@ILT+30(CInit:CInit)(00401023)

;执行初始化列表,this指针传递给eax后,[eax+8]是对成员数据m_nDervie进行寻址

004010EC mov eax, dword ptr[ebp-10h]

004010EF mov dword ptr[eax+8],1

;最后才是执行CDervie的构造函数代码

004010F6 push offset string"使用初始化列表\r\n"(0042501c)

004010FB call printf(004012b0)

00401100 add esp,4

;其余代码分析略


根据以上分析,在有初始化列表的情况下,将会优先执行初始化列表中的操作,其次才是自身的构造函数。构造的顺序为:先构造父类,然后按声明顺序构造成员对象和初始化列表中指定的成员,最后才是自身的构造函数。读者可自行修改类中各个成员的定义顺序,初始化列表的内容,然后按以上方法分析并验证其构造的顺序。

回到代码清单12-2的分析中,在子类对象Dervie的内存布局中,首地址处的第一个数据是父类数据成员m_nBase,向后的4字节数据为自身数据成员m_nDervie,如表12-1所示。

有了这样的内存结构,不但可以使用指向子类对象的子类指针间接寻址到父类定义的成员,而且可以使用指向子类对象的父类指针间接寻址到父类定义的成员。在使用父类成员函数时,传递的this指针也可以是子类对象首地址。因此,在父类中,可以根据以上内存结构将子类对象的首地址视为父类对象的首地址来对数据进行操作,而且不会出错。由于父类对象的长度不超过子类对象,而子类对象只要派生新的数据,其长度即可超过父类,因此子类指针的寻址范围不小于父类指针。在使用子类指针访问父类对象时,如果访问的成员数据是父类对象所定义的,那么不会出错;如果访问的是子类派生的成员数据,则会造成访问越界。

我们先看看正确的情况,如代码清单12-3所示。

代码清单12-3 子类调用父类函数—Debug版


//ShowNumber源码对照代码清单12-1

void ShowNumber(int nNumber){

;函数入口代码略

0040ECC9 pop ecx

0040ECCA mov dword ptr[ebp-4],ecx;[ebp-4]中保留了this指针

41:SetNumber(nNumber);

0040ECCD mov eax, dword ptr[ebp+8];访问参数nNumber并保存到eax中

0040ECD0 push eax

;由于this指针同时也是对象中父类部分的首地址,因此在调用父类成员函数时,this指针的值和子类

;对象等同

0040ECD1 mov ecx, dword ptr[ebp-4]

0040ECD4 call@ILT+45(CBase:SetNumber)(00401032)

42:m_nDervie=nNumber+1;

0040ECD9 mov ecx, dword ptr[ebp+8]

0040ECDC add ecx,1;将参数值加1

0040ECDF mov edx, dword ptr[ebp-4];edx获得this指针

;参考内存结构,edx+4是子类成员m_nDervie的地址

0040ECE2 mov dword ptr[edx+4],ecx

43:printf("%d\r\n",GetNumber());

0040ECE5 mov ecx, dword ptr[ebp-4]

0040ECE8 call@ILT+60(CBase:GetNumber)(00401041)

0040ECED push eax

0040ECEE push offset string"%d\r\n"(0042501c)

0040ECF3 call printf(004012b0)

0040ECF8 add esp,8

44:printf("%d\r\n",m_nDervie);

0040ECFB mov eax, dword ptr[ebp-4];eax获得this指针

;参考内存结构,eax+4是子类成员m_nDervie的地址

0040ECFE mov ecx, dword ptr[eax+4]

0040ED01 push ecx

0040ED02 push offset string"%d\r\n"(0042501c)

0040ED07 call printf(004012b0)

0040ED0C add esp,8

;函数退出代码略

}

;父类成员函数SetNumber分析

void SetNumber(int nNumber){

00401199 pop ecx;还原this指针

0040119A mov dword ptr[ebp-4],ecx;[ebp-4]中保留了this指针

m_nBase=nNumber;

0040119D mov eax, dword ptr[ebp-4];eax得到this指针

004011A0 mov ecx, dword ptr[ebp+8];ecx得到参数

;这里的[eax]相当于[this+0],参考内存结构,是父类成员m_nBase

004011A3 mov dword ptr[eax],ecx

}


父类中成员函数SetNumber在子类中并没有被定义,但根据派生关系,子类中可以使用父类的公有函数。编译器是如何实现正确匹配的呢?

如使用对象或对象的指针调用成员函数,编译器可根据对象所属作用域来使用“名称粉碎法”[1],以实现正确匹配。在成员函数中调用其他成员函数时,可匹配当前作用域。

在调用父类成员函数时,虽然其this指针传递的是子类对象的首地址,但是在父类成员函数中可以成功寻址到父类中的数据。回想之前提及的对象内存布局,父类数据成员被排列在地址最前端,之后是子类数据成员。ShowNumber运行过程中的内存信息如图12-1所示。

图 12-1 子类对象Dervie的内存布局

这时,首地址处为父类数据成员,而父类中的成员函数SetNumber在寻址此数据成员时,会将首地址的4字节数据作为数据成员m_nBase。由此可见,父类数据成员被排列在最前端的目的是为了在添加派生类后方便子类使用父类中的成员数据,并且可以将子类指针当父类指针使用。按照继承顺序依次排列各个数据成员,这样一来,不管是操作子类对象还是父类对象,只要确认了对象的首地址,对父类成员数据的偏移量而言都是一样的。对子类对象而言,使用父类指针或者子类指针都可以正确访问其父类数据。反之,如果使用一个子类对象的指针去访问父类对象,则存在越界访问的危险,如代码清单12-4所示。

代码清单12-4 子类对象的指针访问父类对象存在的危险—Debug版


//C++源码说明:类型定义见代码清单12-1

int nTest=0x87654093;

CBase base;

CDervie*pDervie=(CDervie*)&base;

printf("%x\r\n",pDervie->m_nDervie);

对应的反汇编讲解如下:

54:int nTest=0x87654093;

00401138 mov dword ptr[ebp-4],87654093h;局部变量赋初值

55:CBase base;

0040113F lea ecx,[ebp-8];传递this指针

00401142 call@ILT+20(CBase:CBase)(00401019);调用构造函数

56:CDervie*pDervie=(CDervie*)&base;

00401147 lea eax,[ebp-8]

0040114A mov dword ptr[ebp-0Ch],eax;指针变量[ebp-0Ch]得到base的地址

57:printf("%x\r\n",pDervie->m_nDervie);

0040114D mov ecx, dword ptr[ebp-0Ch]

;注意,ecx中保留了base的地址,而[ecx+4]的访问超出了base的内存范围,实际上,这里访问局部变

;量nTest的内存空间

00401150 mov edx, dword ptr[ecx+4]

00401153 push edx

00401154 push offset string"%x\r\n"(0042201c)

00401159 call printf(00401210)

0040115E add esp,8


学习虚函数时,我们分析了类中的隐藏数据成员—虚表指针。正因为有这个虚表指针,调用虚函数的方式改为查表并间接调用,在虚表中得到函数首地址并跳转到此地址处执行代码。利用此特性即可通过父类指针访问不同的派生类。在调用父类中定义的虚函数时,根据指针所指向的对象中的虚表指针,可得到虚表信息,间接调用虚函数,即构成了多态。

以“人”为基类,可以派生出不同国家的人:中国人、美国人、德国人等。这些人有着一个共同的功能—说话,但是他们实现这个功能的过程不同,例如,中国人说汉语、美国人说英语、德国人说德语等。每个国家的人都有不同的说话方法,为了让“说话”这个方法有一个通用接口,可以设立一个“人”类将其抽象化。使用“人”类的指针或引用调用具体对象的“说话”方法,这样就形成了多态。此关系的描述如代码清单12-5所示。

代码清单12-5 人类说话方法的多态模拟类结构—C++源码


class CPerson{//基类—"人"类

public:

CPerson(){}

virtual~CPerson(){}

virtual void ShowSpeak(){//纯虚函数,后面会讲解

}

};

class CChinese:public CPerson{//中国人:继承自人类

public:

CChinese(){}

virtual~CChinese(){}

virtual void ShowSpeak(){//覆盖基类虚函数

printf("Speak Chinese\r\n");

}

};

class CAmerican:public CPerson{//美国人:继承自人类

public:

CAmerican(){}

virtual~CAmerican(){}

virtual void ShowSpeak(){//覆盖基类虚函数

printf("Speak American\r\n");

}

};

class CGerman:public CPerson{//德国人:继承自人类

public:

CGerman(){}

virtual~CGerman(){}

virtual void ShowSpeak(){//覆盖基类虚函数

printf("Speak German\r\n");

}

};

void Speak(CPerson*pPerson){//根据虚表信息获取虚函数首地址并调用

pPerson->ShowSpeak();

}

//main函数实现代码

void main(int argc, char*argv[]){

CChinese Chinese;

CAmerican American;

CGerman German;

Speak(&Chinese);

Speak(&American);

Speak(&German);

}


在代码清单12-5中,利用父类指针可以指向子类的特性,可以间接调用各子类中的虚函数。虽然指针类型为父类,但由于虚表的排列顺序是按虚函数在类继承层次中首次声明的顺序依次排列的,因此,只要继承了父类,其派生类的虚表中的父类部分的排列就与父类一致,子类新定义的虚函数将会按照声明顺序紧跟其后。所以,在调用过程中,我们给Speak函数传递任何一个基于CPerson的派生对象地址都能够正确调用虚函数ShowSpeak。在调用虚函数的过程中,程序是如何通过虚表指针访问虚函数的呢?具体分析如代码清单12-6所示。

代码清单12-6 虚函数调用过程—Debug版


//main函数分析略

//Speak函数讲解

void Speak(CPerson*pPerson){

pPerson->ShowSpeak();

00401108 mov eax, dword ptr[ebp+8]//eax获取参数pPerson的值

0040110B mov edx, dword ptr[eax]//取虚表首地址并传递给edx

0040110D mov esi, esp

0040110F mov ecx, dword ptr[ebp+8]//设置this指针

//利用虚表指针edx,间接调用函数。回顾父类CPerson的类型声明,其中第一个声明的虚函数是析构函数,

//第二个声明的虚函数是ShowSpeak,所以ShowSpeak在虚表中的位置排第二,[edx+4]即ShowSpeak

//的函数地址

00401112 call dword ptr[edx+4]

00401115 cmp esi, esp

00401117 call__chkesp(004017c0)

}


在代码清单12-6中,虚函数的调用过程使用了间接寻址方式,而非直接调用一个函数地址。由于虚表采用间接调用机制,因此在使用父类指针pPerson调用虚函数时,没有依照其作用域调用CPerson类中定义的成员函数ShowSpeak。

对比第11章代码清单11-3中的虚函数调用后可以发现,当没有使用对象指针或者对象引用时,调用虚函数指令的寻址方式为直接调用方式,从而无法构成多态。由于代码清单12-6中使用了对象指针来调用虚函数,因此会产生间接调用方式,进而构成多态。代码清单11-3的代码片段如下:


MyVirtual.SetNumber(argc);

00401050 mov eax, dword ptr[ebp+8]

00401053 push eax

00401054 lea ecx,[ebp-8]

;这里是直接调用,无法构成多态

00401057 call@ILT+5(CVirtual:SetNumber)(0040100a)


当父类中定义有虚函数时,将会产生虚表。当父类的派生类产生对象时,根据代码清单12-2的分析,将会在调用子类构造函数前优先调用父类构造函数,并以子类对象的首地址作为this指针传递给父类构造函数。在父类构造函数中,会先初始化子类虚表指针为父类的虚表首地址。此时,如果在父类构造函数中调用虚函数,虽然虚表指针属于子类对象,但指向的地址却是父类的虚表首地址,这时可判断出虚表所属作用域与当前作用域相同,于是会转换成直接调用方式,从而造成构造函数内的虚函数失效。修改代码清单12-5,在CPerson类的构造函数中添加虚函数调用,如下所示。


class CPerson{

public:

CPerson(){

ShowSpeak();//调用虚函数,将失效

}

virtual~CPerson(){}

virtual void ShowSpeak(){

printf("Speak No\r\n");

}

};


以上代码执行过程如图12-2所示。

图 12-2 构造函数调用虚函数

图12-2演示了构造函数中使用虚函数的流程。按C++规定的构造顺序,父类构造函数会在子类构造函数之前运行,在执行父类构造函数时将虚表指针修改为当前类的虚表指针,也就是父类的虚表指针,因此导致虚函数的特性失效。如果父类构造函数内部存在虚函数调用,这样的顺序能防止在子类中构造父类时,父类会根据虚表错误地调用子类的成员函数。

虽然在构造函数和析构函数中调用虚函数会使其多态性失效,但是为什么还要修改虚表指针呢?编译器直接把构造函数或析构函数中的虚函数调用修改为直接调用方式,不就可以避免这类问题了吗?大家不要忘了,程序员仍然可以自己编写其他成员函数间接调用本类中声明的其他虚函数。假设类A中定义了成员函数f1()和虚函数f2(),而且类B继承自类A并重写了f2()。根据前面的讲解我们可以知道,在子类B的构造函数执行前会先调用父类A的构造函数,此时如果在类A的构造函数中调用f1(),显然不会构成多态,编译器会产生直接调用f1()的代码。但是,如果在f1()中又调用了f2(),此时就会产生间接调用的指令,形成多态。如果类B的对象的虚表指针没有更换为类A的虚表指针,就会导致在访问类B的虚表后调用到类B中的f2()函数,而此时类B的对象尚未完成构造,其数据成员是不确定的,这时在f2()中引用类B的对象中的数据成员是很危险的。

同理,在析构类B的对象时,会先执行类B的析构函数,然后执行类A的析构函数。如果在类A的析构函数中调用f1(),显然也不能构成多态,编译器同样会产生直接调用f1()的代码。但是,如果f1()中又调用了f2(),此时会构成多态,如果这个对象的虚表指针没有更换为类A的虚表指针,同样也会导致访问虚表并调用类B中的f2()。但是,此时B类对象已经执行过析构函数,所以B类中定义的数据已经不可靠了,对其进行操作同样是很危险的。

稍后我们会以IDA为分析工具将各个知识点串联起来一起讲解。

在析构函数中,同样需要处理虚函数的调用,因此也需要处理虚函数。按C++中定义的析构顺序,首先调用自身的析构函数,然后调用成员对象的析构函数,最后调用父类的析构函数。在对象析构时,首先设置虚表指针为自身虚表,再调用自身的析构函数。如果有成员对象,则按声明的顺序以倒序方式依次调用成员对象的析构函数。最后,调用父类析构函数。在调用父类的析构函数时,会设置虚表指针为父类自身的虚表。

我们来修改代码清单12-5中的构造函数和析构函数的实现过程,通过调试来分析其执行过程,如代码清单12-7所示。

代码清单12-7 构造函数和析构函数中调用虚函数的流程


//修改代码清单12-5后的示例,在构造函数与析构函数中添加虚函数调用

class CPerson{//基类—"人"类

public:

CPerson(){

ShowSpeak();//添加虚函数调用

}

virtual~CPerson(){

ShowSpeak();//添加虚函数调用

}

virtual void ShowSpeak(){

printf("Speak No\r\n");

}

};

//main函数实现过程

void main(int argc, char*argv[]){

CChinese Chinese;

}

//C++源码与汇编代码对比分析

//Chinese构造函数调用过程分析

CChinese(){}

00401139 pop ecx;还原this指针

0040113A mov dword ptr[ebp-4],ecx

0040113D mov ecx, dword ptr[ebp-4];传入当前this指针,将其作为父类的this指针

00401140 call@ILT+30(CPerson:CPerson)(00401023);调用父类构造函数

;执行父类构造函数后,将虚表设置为子类的虚表

00401145 mov eax, dword ptr[ebp-4];获取this指针,这个指针也是虚表指针

00401148 mov dword ptr[eax],offset CChinese:'vftable'(0042201c)

;设置虚表指针为子类的虚表

0040114E mov eax, dword ptr[ebp-4];将返回值设置为this指针

//父类构造函数分析

CPerson(){}

00401199 pop ecx;还原this指针,此时指针为子类对象的首地址

0040119A mov dword ptr[ebp-4],ecx

0040119D mov eax, dword ptr[ebp-4];取出子类的虚表指针,设置为父类虚表

004011A0 mov dword ptr[eax],offset CPerson:'vftable'(00422028)

ShowSpeak();

004011A6 mov ecx, dword ptr[ebp-4];虚表是父类的,可以直接调用父类虚函数

004011A9 call@ILT+15(CPerson:ShowSpeak)(00401014)

004011C1 ret

//Chinese析构函数调用过程分析

virtual~CChinese(){}

00401309 pop ecx;还原this指针

0040130A mov dword ptr[ebp-4],ecx

0040130D mov eax, dword ptr[ebp-4];再次设置子类的虚表

00401310 mov dword ptr[eax],offset CChinese:'vftable'(0042201c)

00401316 mov ecx, dword ptr[ebp-4];调用父类的析构函数

00401319 call@ILT+20(CPerson:~CPerson)(00401019)

//父类析构函数分析

virtual~CPerson(){

004012B9 pop ecx

004012BA mov dword ptr[ebp-4],ecx

004012BD mov eax, dword ptr[ebp-4]

;由于当前虚表指针指向了子类虚表,需要重新修改为父类虚表,以防止调用子类的虚函数

004012C0 mov dword ptr[eax],offset CPerson:'vftable'(00422028)

ShowSpeak();

004012C6 mov ecx, dword ptr[ebp-4];虚表是父类的,可以直接调用父类虚函数

004012C9 call@ILT+15(CPerson:ShowSpeak)(00401014)

}

004012DE ret


在代码清单12-7的子类构造函数代码中,首先调用了父类的构造函数,然后设置虚表指针为当前类的虚表首地址。而析构函数中的顺序却与构造函数相反,首先设置虚表指针为当前类的虚表首地址,然后再调用父类的析构函数。其构造和析构的过程描述如下:

通过上面的分析可知构造和析构的顺序如下:

构造:基类→基类的派生类→……→当前类

析构:当前类→基类的派生类→……→基类

在代码清单12-5中,析构函数被定义为虚函数。为什么要将析构函数定义为虚函数呢?由于可以使用父类指针保存子类对象的首地址,因此当使用父类指针指向子类堆对象时,就会出问题。当使用delete释放对象的空间时,如果析构函数没有被定义为虚函数,那么编译器将会按指针的类型调用父类的析构函数,从而引发错误。而使用了虚析构函数后,会访问虚表并调用对象的析构函数。两种析构函数的调用过程如以下代码所示。


//没有声明为虚析构函数

CPerson*pPerson=new CChinese;

delete pPerson;//部分代码分析略

mov ecx, dword ptr[ebp-1Ch];直接调用父类的析构函数

call@ILT+10(CPerson:'scalar deleting destructor')(0040100f)

//声明为虚析构函数

CPerson*pPerson=new CChinese;

delete pPerson;//部分代码分析略

mov ecx, dword ptr[ebp-1Ch];获取pPerson并保存到ecx中

mov edx, dword ptr[ecx];取得虚表指针

mov ecx, dword ptr[ebp-1Ch];传递this指针

call dword ptr[edx];间接调用虚析构函数


以上代码对普通析构函数与虚析构函数进行了对比,说明了为什么类在有了派生与继承关系后,需要声明虚析构函数的原因。对于没有派生和继承关系的类结构,是否将析构函数声明为虚析构函数不会影响调用的过程,但是在编写析构函数时应养成习惯,无论当前是否有派生或继承关系,都应将析构函数声明为虚析构函数,以防止将来更新和维护代码时发生析构函数的错误调用。

了解了派生和继承的执行流程与实现原理后,又该如何利用这些知识去识别代码中类与类之间的关系呢?最好的办法还是先定位构造函数,有了构造函数就可根据构造的先后顺序得到与之有关的其他类。在构造函数中只构造自己的类很明显是个基类。对于构造函数中存在调用父类构造函数的情况时,可利用虚表,在IDA中使用引用参考的功能便可得到所有的构造函数和析构函数,进而得到了它们之间的派生和继承关系。

将代码清单12-5修改为如下所示的代码,我们以Release选项组对这段代码进行编译,然后利用IDA对其进行分析。


//综合讲解(建议读者先用VC++分析一下Debug选项组编译的过程,然后再看本内容)

class CPerson{//基类—人类

public:

CPerson(){

ShowSpeak();//注意,构造函数调用了虚函数

}

virtual~CPerson(){

ShowSpeak();//注意,析构函数调用了虚函数

}

virtual void ShowSpeak(){//在这个函数里调用了其他的虚函数GetClassName()

printf("%s:ShowSpeak()\r\n",GetClassName());

return;

}

virtual char*GetClassName()

{

return"CPerson";

}

};

class CChinese:public CPerson{//中国人,继承自"人"类

public:

CChinese(){

ShowSpeak();

}

virtual~CChinese(){

ShowSpeak();

}

virtual char*GetClassName(){

return"CChinese";

}

};

void main(int argc, char*argv[]){

CPerson*pPerson=new CChinese;

pPerson->ShowSpeak();

delete pPerson;

}

;反汇编讲解

;在IDA中打开执行文件,载入sig,定位到main函数,得到如下代码

.text:00401080;int__cdecl main(int argc, const char**argv, const char**envp)

.text:00401080_main proc near;CODE XREF:start+AF

.text:00401080 var_10=dword ptr-10h

.text:00401080 var_C=dword ptr-0Ch

.text:00401080 var_4=dword ptr-4

.text:00401080 argc=dword ptr 4

.text:00401080 argv=dword ptr 8

.text:00401080 envp=dword ptr 0Ch

.text:00401080

.text:00401080 push 0FFFFFFFFh

.text:00401082 push offset unknown_libname_35;Microsoft VisualC 2-9/net runtime

.text:00401087 mov eax, large fs:0

.text:0040108D push eax

.text:0040108E mov large fs:0,esp;注册C++异常处理

.text:00401095 push ecx

.text:00401096 push esi;保存寄存器环境

.text:00401097 push 4;unsigned int

.text:00401099 call??2@YAPAXI@Z;operator new(uint)申请4字节堆空间

.text:0040109E mov esi, eax;esi保存new调用的返回值

.text:004010A0 add esp,4;平衡new调用的参数

.text:004010A3 mov[esp+14h+var_10],esi;new返回值保存到局部变量var_10中

;编译器插入了检查new返回值的代码,如果返回值为0,则跳过构造函数的调用

.text:004010A7 test esi, esi

;在IDA中单击var_4,引用处会高亮显示,可以观察出这个变量是计数标记

.text:004010A9 mov[esp+14h+var_4],0

;单击下面这个跳转指令的标号loc_4010F2,目标处会高亮显示,结合目标处上面的一条指令(地址

;004010F0处),可以看出这是一个分支结构,跳转的目标是new返回值为0时的处理(将esi置为0)。读

;者可以按照命名规范重新定义这些标号(IDA中重命名的快捷键是N,选中标号以后按N键即可)

.text:004010B1 jz short loc_4010F2

;如果new返回值不为0,则ecx保存堆地址,结合004010BB地址处的call指令,可推测是thiscall

;的调用方式,需要到004010BB处看看有没有访问ecx才能进一步确定

.text:004010B3 mov ecx, esi

;这个地方很关键,需要查看off_40C0DC中的内容

.text:004010B5 mov dword ptr[esi],offset off_40C0DC


off_40C0DC中的内容为:


.rdata:0040C0DC off_40C0DC dd offset sub_401170;DATA XREF:_main+35↑o

.rdata:0040C0DC;sub_40ACFB:loc_401120↑o sub_401170+3↑o sub_4011E0+49↑o

.rdata:0040C0E0 dd offset sub_401140


IDA以注释的形式给出了反汇编代码中所有引用了标号off_40C0DC的指令地址,以便于我们分析时参考。如“;DATA XREF:_main+35这表示在main函数的首地址偏移35h字节处的指令引用了标号off_40C0DC,最后的上箭头“↑”表示引用处的地址在当前标号的上面,也就是说引用处的地址值比这个标号的地址值小。

接着观察sub_401170和sub_401140中的内容,双击后可以看到这两个名称都是函数名称,可证实off_40C0DC是函数指针数组的首地址,而且其中每个函数都有对ecx的引用,在引用前没有给ecx赋值,说明这两个函数都是将ecx作为参数传递的。结合004010B5处的指令“mov dword ptr[esi],offset off_40C0DC”,其中esi中保存的是new调用所申请的堆空间首地址,这条指令在首地址处放置了函数指针数组的地址。

结合以上种种信息,我们可以认定,esi中的地址是对象的地址,而函数指针数组就是虚表。退一步讲,即使源码不是这样,我们按此还原后的C++代码在功能和内存布局上也是等价的。

接着按N键将off_40C0DC重命名,这里先命名为vTable_40C0DC,在接下来的分析中如果找到更详细的信息,还可以继续修改这个名称,使代码的可读性更强。


.text:004010B5 mov dword ptr[esi],offset vTable_40C0DC


既然是对虚表指针进行初始化,就要满足构造函数的充分条件,但是我们看到这里并没有调用构造函数,而是直接在main函数中完成了虚表指针的初始化,这说明构造函数被编译器内联优化了。接下来我们来看一个内存间接调用:


.text:004010BB call ds:off_40C0E4

off_40C0E4中的内容如下:

.rdata:0040C0DC vTable_40C0DC dd offset sub_401170;DATA XREF:_main+35↑o

.rdata:0040C0DC;sub_40ACFB:loc_401120↑o sub_401170+3↑o sub_4011E0+49↑o

.rdata:0040C0E0 dd offset sub_401140

.rdata:0040C0E4 off_40C0E4 dd offset sub_401160;DATA XREF:_main+3B↑r


不难发现,这个地址就在刚才我们分析的虚函数表的首地址附近,这很可能是虚表中的一部分!不过现在只能是怀疑,我们还没有证据。先看看这个函数的功能。双击地址0040C0E4处“off_40C0E4 dd offset sub_401160”中的sub_401160,定位到sub_401160的代码实现处,此处内容如下所示:


.text:00401160 sub_401160 proc near

.text:00401160 mov eax, offset aCperson;"CPerson";功能很简单,返回名称字符串

.text:00401165 retn

.text:00401165 sub_401160 endp


顺手修改sub_401160的名称,这里先修改为GetCPerson,以后有更多信息时再进一步修改。对应地,由于在off_40C0E4中保存了函数GetCPerson的地址,说明它是一个函数指针,因此也可以将其名称修改为pfnGetCPerson,修改完毕后如下所示:


.rdata:0040C0E4 pfnGetCPerson dd offset GetCPerson;DATA XREF:_main+3B

接着分析其后的代码:


.text:004010C1 push eax

.text:004010C2 push offset aSShowspeak;"%s:ShowSpeak()\r\n"

.text:004010C7 call_printf

.text:004010CC add esp,8;调用printf,并平衡参数

.text:004010CF mov ecx, esi

.text:004010D1 mov byte ptr[esp+14h+var_4],1;计数器加1

.text:004010D6 mov dword ptr[esi],offset off_40C0D0;写入虚表指针,分析过程与上

;面的内容一致,略

.text:004010DC call ds:off_40C0D8;内存间接调用


双击off_40C0D8,查看调用目标:


.rdata:0040C0D8 off_40C0D8 dd offset sub_4011B0;DATA XREF:_main+5C

off_40C0D8中保存了函数sub_4011B0的地址,双击sub_4011B0,其功能如下所示:


.text:004011B0 sub_4011B0 proc near

.text:004011B0 mov eax, offset aCchinese;"CChinese";功能很简单,返回名称字符串

.text:004011B5 retn

.text:004011B5 sub_4011B0 endp


修改一下这个函数的名称,这里改为GetCChinese,也对应修改函数指针off_40C0D8的名称为pfnGetCChinese,修改完毕后如下所示:


.rdata:0040C0D8 pfnGetCChinese dd offset GetCChinese;DATA XREF:_main+5C

接着分析后面的代码:


.text:004010E2 push eax

.text:004010E3 push offset aSShowspeak;"%s:ShowSpeak()\r\n"

.text:004010E8 call_printf

.text:004010ED add esp,8;调用printf并平衡参数

.text:004010F0 jmp short loc_4010F4;跳过else分支

.text:004010F2;--------------------------------------------------------------

.text:004010F2

.text:004010F2 loc_4010F2:;CODE XREF:_main+31

.text:004010F2 xor esi, esi;如果new调用的返回值为0,则esi为0

到此为止,我们分析了new调用后的整个分支结构。当new调用成功时,会执行对象的构造函数,而编译器对这里的构造函数进行了内联优化,但这不会影响我们对构造函数的鉴定。首先存在写入虚表指针的充分条件,同时也满足前面章节讨论的必要条件,还要出现在new调用的正确分支中,因此,我们可以把new调用的正确分支中的代码判定为构造函数的内联方式。在new调用的正确分支内,由于esi所指向的对象有两次写入虚表指针的代码,如下所示:


.text:004010B5 mov dword ptr[esi],offset vTable_40C0DC

;中间代码略

.text:004010D6 mov dword ptr[esi],offset vTable_40C0D0


我们可以借此得到派生关系,在构造函数中先填写父类的虚表,然后按继承的层次关系逐层填写子类的虚表,由此可以判定vTable_40C0DC是父类的虚表,vTable_40C0D0是子类的虚表。以写入虚表的指令为界限,可以粗略划分出父类的构造函数和子类的构造函数的实现代码,但是细节上要按照程序逻辑找到界限之内其他函数传递参数的几行代码,并排除在外,如下所示:


;先定位到new调用的正确分支处

.text:00401099 call??2@YAPAXI@Z;operator new(uint);调用new

.text:0040109E mov esi, eax

.text:004010A0 add esp,4

.text:004010A3 mov[esp+14h+var_10],esi

.text:004010A7 test esi, esi;判定new调用后的返回值

.text:004010A9 mov[esp+14h+var_4],0

.text:004010B1 jz short loc_4010F2;返回值为0,则跳转到错误逻辑处

;从这里开始就是正确的逻辑,同时也是父类构造函数的起始代码处

.text:004010B3 mov ecx, esi

.text:004010B5 mov dword ptr[esi],offset vTable_40C0DC

.text:004010BB call ds:pfnGetCPerson

.text:004010C1 push eax

.text:004010C2 push offset Format;"%s:ShowSpeak()\r\n"

.text:004010C7 call_printf

.text:004010CC add esp,8

;注意这里的传参(this指针),从这里开始就不是父类的构造函数实现代码了

.text:004010CF mov ecx, esi

.text:004010D1 mov byte ptr[esp+14h+var_4],1

.text:004010D6 mov dword ptr[esi],offset vTable_40C0D0

.text:004010DC call ds:pfnGetCChinese

.text:004010E2 push eax

.text:004010E3 push offset Format;"%s:ShowSpeak()\r\n"

.text:004010E8 call_printf

.text:004010ED add esp,8

;new调用的正确分支末尾,同时也是子类构造函数的结束处

.text:004010F0 jmp short loc_4010F4


继续看后面的代码:


.text:004010F4

.text:004010F4 loc_4010F4:;CODE XREF:_main+70↑j

.text:004010F4 mov eax,[esi];取得虚表指针

.text:004010F6 mov ecx, esi;传递this指针

.text:004010F8 mov[esp+14h+var_4],0FFFFFFFFh;修改计数器

.text:00401100 call dword ptr[eax+4];调用虚表第二项的函数


分析一下这里的虚函数调用,先看看最后一次写入虚表的地址,单击esi,往上观察高亮处,寻找最后一次写入的指令,如图12-3所示。

图 12-3 寻找最后一次写入虚表的指令

细心的读者一定找到了!没错,正是004010D6地址处!指令“call dword ptr[eax+4]”揭示出虚表中至少有两个元素。接下来分析在004010D6处写入虚表vTable_40C0D0中的第二项内容到底是什么。


.rdata:0040C0D0 vTable_40C0D0 dd offset sub_4011C0;虚表偏移0处,也就是虚表的第一项

.rdata:0040C0D4 off_40C0D4 dd offset sub_401140;虚表偏移4处,也就是虚表的第二项

.rdata:0040C0D8 pfnGetCChinese dd offset GetCChinese;现在不能确定这一项是否为虚表的内容


双击sub_401140,得到以下代码:


.text:00401140 sub_401140 proc near

;未赋值就直接使用ecx,说明ecx是在传递参数

.text:00401140 mov eax,[ecx];eax得到虚表

.text:00401142 call dword ptr[eax+8];调用虚表第三项,形成了多态


指令“call dword ptr[eax+8]”揭示出虚表中至少有三个元素!接下来分析虚表第三项是什么内容。


.rdata:0040C0D0 vTable_40C0D0 dd offset sub_4011C0;虚表偏移0处,也就是虚表的第一项

.rdata:0040C0D4 off_40C0D4 dd offset sub_401140;虚表偏移4处,也就是虚表的第二项

;虚表偏移8处,也就是虚表的第三项,现在可以确定GetCChinese是虚表的元素之一

.rdata:0040C0D8 pfnGetCChinese dd offset GetCChinese


接着往下看:


.text:00401145 push eax;向printf传入GetCChinese的返回值,是个字符串首地址

.text:00401146 push offset Format;"%s:ShowSpeak()\r\n"

.text:0040114B call_printf

.text:00401150 add esp,8;调用printf显示字符串,并平衡参数

.text:00401153 retn

.text:00401153 sub_401140 endp


这个函数的作用是调用虚表第三项元素,得到字符串,并将字符串格式化输出。由于是按虚表调用的,因此会形成多态性。顺便把这个函数的名称修改为ShowShtring,对应的虚表内的函数指针off_40C0D4修改为pfnShowShtring,修改后虚表结构如下所示:


.rdata:0040C0D0 vTable_40C0D0 dd offset sub_4011C0

.rdata:0040C0D4 pfnShowShtring dd offset ShowShtring

.rdata:0040C0D8 pfnGetCChinese dd offset GetCChinese


我们回到main函数处,继续分析:


.text:00401103 test esi, esi

.text:00401105 jz short loc_40110F;检查堆指针,不为0则往下执行

.text:00401107 mov edx,[esi];edx得到虚表

.text:00401109 push 1;传入参数

.text:0040110B mov ecx, esi;传递this指针

.text:0040110D call dword ptr[edx];调用虚表中的第一项

.text:0040110F;从00401105处跳转到此,其上没有jmp,所以这里是个单分支结构

.text:0040110F loc_40110F:

.text:0040110F mov ecx,[esp+14h+var_C];函数退出,恢复环境,还原SEH

.text:00401113 pop esi

.text:00401114 mov large fs:0,ecx

.text:0040111B add esp,10h

.text:0040111E retn

.text:0040111E_main endp


call dword ptr[edx]命令调用虚表的第一项。在详细分析虚表的第一项之前,我们体验一下IDA中的交叉参考功能,一次性定位所有的构造函数和析构函数,先定位到虚表vTable_40C0D0处,然后右击,如图12-4所示。

在右键菜单中选择“Chart of xrefs to”,得到所有直接引用这个地址的位置,如图12-5所示。

可以看到,除了main函数访问了虚表vTable_40C0D0之外,sub_4011E0也访问了虚表vTable_40C0D0。通过前面的分析可知,是因为main函数中内联的构造函数存在写入虚表的操作,从而导致vTable_40C0D0被访问到。由于存在虚表,就算类中没有定义析构函数,编译器也会产生默认的析构函数,因此,毫无疑问另一个访问虚表的函数sub_4011E0就是析构函数。交叉参考这个功能很好用,如果你发现了一个父类的构造函数,想知道这个父类有多少个派生类,也能利用这个功能快速定位。

图 12-4 交叉参考

图 12-5 IDA自动生成的交叉参考图示

以代码清单12-5的Debug版为例,使用IDA对其进行分析,先找到某个子类的构造函数。由于子类的构造函数必然会先调用父类的构造函数,因此我们利用交叉参考功能即可查询出所有引用这个父类构造函数的指令的位置,这当然包括这个父类的所有直接子类构造函数的位置,借此即可判定父类派生的所有直接子类,如图12-6所示。

图 12-6 父类派生关系图

接下来分析sub_4011E0函数的功能,反汇编代码如下所示:


;注意这里的引用提示:是在sub_4011C0函数中调用本函数,稍后会带领读者去这个地址"探险"

.text:004011E0 sub_4011E0 proc near;CODE XREF:sub_4011C0+3↑p

.text:004011E0

.text:004011E0 var_10=dword ptr-10h

.text:004011E0 var_C=dword ptr-0Ch

.text:004011E0 var_4=dword ptr-4

.text:004011E0

.text:004011E0 push 0FFFFFFFFh

.text:004011E2 push offset unknown_libname_36;Microsoft VisualC 2-9/net runtime

.text:004011E7 mov eax, large fs:0

.text:004011ED push eax

.text:004011EE mov large fs:0,esp

.text:004011F5 push ecx

.text:004011F6 push esi;以上注册异常处理,保留寄存器环境

.text:004011F7 mov esi, ecx

.text:004011F9 mov[esp+14h+var_10],esi

;在虚表指针处写入子类虚表地址

.text:004011FD mov dword ptr[esi],offset vTable_40C0D0

.text:00401203 mov[esp+14h+var_4],0;计数器置为0

.text:0040120B call ds:pfnGetCChinese

.text:00401211 push eax;获取字符串,并向printf传递参数

.text:00401212 push offset Format;"%s:ShowSpeak()\r\n"

.text:00401217 call_printf

.text:0040121C add esp,8;执行printf,并平衡参数

.text:0040121F mov ecx, esi;传递this指针

.text:00401221 mov[esp+14h+var_4],0FFFFFFFFh;将计数器置为-1

;在虚表指针处写入父类虚表地址

.text:00401229 mov dword ptr[esi],offset vTable_40C0DC

.text:0040122F call ds:pfnGetCPerson

.text:00401235 push eax;获取字符串,并向printf传递参数

.text:00401236 push offset Format;"%s:ShowSpeak()\r\n"

.text:0040123B call_printf

;流水线优化,因为mov large fs:0,ecx和当前指令依赖同一个寄存器ecx,会造成指令相关性,所以

;提前到add esp,8之上,以提高流水线的并行能力

.text:00401240 mov ecx,[esp+1Ch+var_C]

.text:00401244 add esp,8;执行printf,并平衡参数

.text:00401247 mov large fs:0,ecx;恢复环境并还原SEH

.text:0040124E pop esi

.text:0040124F add esp,10h

.text:00401252 retn

.text:00401252 sub_4011E0 endp


以上代码中存在虚表的写入操作,其写入顺序和前面分析的构造函数相反,先写入子类自身的虚表,然后写入父类的虚表,满足了析构函数的充分条件。我们将虚构函数命名为Destructor_4011E0,IDA会提示符号名称过长,不必理会,单击“确定”按钮即可。

Destructor_4011E0被sub_4011C0调用,因此接下来分析sub_4011C0,这个函数有一个参数,IDA给出的名称为arg_0。


;查看引用参考可得知,这个函数是在虚表vTable_40C0D0中定义的第一个虚函数

.text:004011C0 sub_4011C0 proc near;DATA XREF:.rdata:vTable_40C0D0


.text:004011C0

.text:004011C0 arg_0=byte ptr 4

.text:004011C0

.text:004011C0 push esi

.text:004011C1 mov esi, ecx;esi保留了this指针

.text:004011C3 call Destructor_4011E0;先调用析构函数

.text:004011C8 test[esp+4+arg_0],1

;如果参数为1,则以对象首地址为目标释放内存,否则本函数仅仅执行对象的析构函数

.text:004011CD jz short loc_4011D8

.text:004011CF push esi;传入对象的首地址

.text:004011D0 call??3@YAXPAX@Z;operator delete(void*)

.text:004011D5 add esp,4;调用delete,并平衡参数

.text:004011D8

.text:004011D8 loc_4011D8:;CODE XREF:sub_4011C0+D

.text:004011D8 mov eax, esi

.text:004011DA pop esi

.text:004011DB retn 4

.text:004011DB sub_4011C0 endp


显而易见,这是一个析构函数的代理,它的任务是负责调用析构函数,然后根据参数值调用delete。将这个函数重命名为_Destructor_4011E0,重命名后,虚表结构是这个样子:


.rdata:0040C0D0 vTable_40C0D0 dd offset_Destructor_4011E0

.rdata:0040C0D4 pfnShowShtring dd offset ShowShtring

.rdata:0040C0D8 pfnGetCChinese dd offset GetCChinese


_Destructor_4011E0函数是虚表的第一项,我们可以回到main函数中来观察其参数传递的过程:


.text:00401103 test esi, esi

;当对象指针esi不为0时执行_Destructor_4011E0

.text:00401105 jz short loc_40110F

.text:00401107 mov edx,[esi];edx获得虚表

.text:00401109 push 1;传递参数值1

.text:0040110B mov ecx, esi;传递this指针

.text:0040110D call dword ptr[edx];调用_Destructor_4011E0

.text:0040110F

.text:0040110F loc_40110F:


在main函数中调用虚表第一项时传递的值为1,那么在_Destructor_4011E0函数中,执行完析构函数后就会调用delete释放对象的内存空间。为什么要用这样一个参数来控制函数内释放空间的行为呢?为什么不能直接释放呢?

因为析构函数和释放堆空间是两回事,有的程序员喜欢自己维护析构函数,或者反复使用同一个堆对象,这时显式调用析构函数的同时不能释放堆空间,如下代码所示:


void main(int argc, char*argv[]){

CPerson*pPerson=new CChinese;

pPerson->ShowSpeak();

pPerson->~CPerson();//显式调用析构函数

//将堆内存中pPerson指向的地址作为CChinese的新对象的首地址,并调用CChinese的构造函数。这

//样可以重复使用同一个堆内存,以节约内存空间

pPerson=new(pPerson)CChinese();

delete pPerson;

}


由于显式调用析构函数时不能马上释放堆内存,因此在析构函数的代理函数中通过一个参数来控制是否释放内存,以便于程序员自己管理析构函数的调用。这个代理函数的反汇编代码很简单,请读者自己上机验证。

在通过分析反汇编代码来识别类关系时,对于含有虚函数的类而言,利用IDA的交叉参考功能可简化分析识别过程。根据以上分析可知,具有虚函数,必然存在虚表指针。为了初始化虚表指针必然要准备构造函数,有了构造函数就可利用以上方法,顺藤摸瓜得到类关系,还原出对象模型。

思考题 大家在调试以上程序时会发现,比如CChinese的对象,在构造函数执行时虚表已经初始化完成了,在析构函数执行时,其虚表指针已经是子类的虚表了,为什么编译器还要在析构函数中再次将虚表设置为子类虚表呢?这是冗余操作吗?如果不这么做,会引发什么后果?答案见本章小结。

[1]“名称粉碎”(name mangling)是C++编译器对函数名称的一种处理方式,即在编译时对函数名进行重组,新名称中会包含函数的作用域、原函数名、每个参数的类型、返回值以及调用约定等。