首页
社区
课程
招聘
[推荐]《C++反汇编与逆向分析技术揭秘》第12章 从内存角度看继承和多重继承
发表于: 2011-10-10 10:21 9811

[推荐]《C++反汇编与逆向分析技术揭秘》第12章 从内存角度看继承和多重继承

2011-10-10 10:21
9811
在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

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

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

[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课

收藏
免费 0
支持
分享
最新回复 (9)
雪    币: 231
活跃值: (10)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2
在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所示。

表12-1 Dervie对象内存结构
this+0        父类CBase部分        m_nBase
this+4        子类CDervie部分        m_nDervie

有了这样的内存结构,不但可以使用指向子类对象的子类指针间接寻址到父类定义的成员,而且可以使用指向子类对象的父类指针间接寻址到父类定义的成员。在使用父类成员函数时,传递的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在子类中并没有被定义,但根据派生关系,子类中可以使用父类的公有函数。编译器是如何实现正确匹配的呢?

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

在调用父类成员函数时,虽然其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+AFp
.text:00401080
.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+3Br

不难发现,这个地址就在刚才我们分析的虚函数表的首地址附近,这很可能是虚表中的一部分!不过现在只能是怀疑,我们还没有证据。先看看这个函数的功能。双击地址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+3Br

接着分析其后的代码:
.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+5Cr
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+5Cr

接着分析后面的代码:
.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+31j
.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所示。

图12-4 交叉参考

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

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

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

以代码清单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_40C0D0o
.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+Dj
.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的对象,在构造函数执行时虚表已经初始化完成了,在析构函数执行时,其虚表指针已经是子类的虚表了,为什么编译器还要在析构函数中再次将虚表设置为子类虚表呢?这是冗余操作吗?如果不这么做,会引发什么后果?答案见本章小结。
上传的附件:
2011-10-10 11:46
0
雪    币: 2503
活跃值: (10)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
3
谢谢楼主的分享!!!
2011-10-10 12:30
0
雪    币: 231
活跃值: (10)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
4
12.1节讲解了类与类之间的关系,但所接触的派生类都只有一个父类。当子类拥有多个父类(如类C继承自类A同时也继承自类B)时,便构成了多重继承关系。在多重继承的情况下,子类所继承的父类变为多个,但其结构与单一继承相似。

分析多重继承的第一步是了解派生类中各数据成员在内存中的布局情况。在12.1节中,子类继承自一个父类,其内存中首先存放的是父类的数据成员。当子类产生多重继承时,其父类数据成员在内存中又该如何存放呢?我们通过代码清单12-8来看看多重继承类的定义。

代码清单12-8 多重继承类的定义—C++源码
// 定义沙发类
class CSofa{		
public:
	CSofa(){
		m_nColor = 2;
	}
	virtual ~CSofa(){					// 沙发类虚析构函数
		printf("virtual ~CSofa()\r\n");
	}
	virtual int GetColor(){				// 获取沙发颜色 
		return m_nColor;
	}
	virtual int SitDown(){					// 沙发可以坐下休息 
		return printf("Sit down and rest your legs\r\n");
	}
protected:
	int m_nColor;						// 沙发类成员变量 
};

// 定义床类
class CBed {		
public:
	CBed(){
		m_nLength = 4;
		m_nWidth = 5;
}
virtual ~CBed(){						// 床类虚析构函数 
	printf("virtual ~CBed()\r\n");
}
virtual int GetArea(){					// 获取床面积 
		return m_nLength * m_nWidth;
}
virtual int Sleep(){					// 床可以用来睡觉 
		return printf("go to sleep\r\n");
}
protected:
	int m_nLength;						// 床类成员变量
	int m_nWidth;
};

// 子类沙发床定义,派生自CSofa类和CBed类
class CSofaBed :  public CSofa,  public CBed{
public:
	CSofaBed(){
	   m_nHeight = 6;
	}
	virtual ~CSofaBed(){					// 沙发床类的虚析构函数
		printf("virtual ~CSofaBed()\r\n");
	}
	virtual int SitDown(){					// 沙发可以坐下休息 
		return printf("Sit down on the sofa bed\r\n");
	}
	virtual int Sleep(){					// 床可以用来睡觉 
		return printf("go to sleep on the sofa bed\r\n");
	}
	virtual int GetHeight(){
		return m_nHeight;
	}
protected:
	int m_nHeight;						// 沙发类的成员变量
};	

代码清单12-8中定义了两个父类:沙发类和床类,通过多重继承,以它们为父类派生出沙发类,它们都拥有各自的属性以及方法。main函数中定义了子类SofaBed的对象,其中包含两个父类的数据成员,此时SofaBed在内存中占多少字节呢?如图12-7所示为对象SofaBed占用内存空间的大小。

图12-7 对象SofaBed占用内存空间的大小

根据图12-7所示,对象SofaBed占用的内存空间大小为0x18字节。这些数据的内容是什么?它们又是如何存放在内存中的?具体如图12-8所示。

图12-8 对象SofaBed的内存信息

如图12-8所示,对象SofaBed的首地址在0x0012FF5C处,在图中可看到子类的数据成员和两个父类中的数据成员。数据成员的排列顺序由继承父类的先后顺序所决定,从左向右依次排列。除此之外,还剩余两个地址值,分别为0x00426198与0x0042501C,这两个地址处的数据如图12-9所示。

图12-9 子类对象的虚表指针对应的虚表信息

图12-9中显示了Debug下两个虚表指针所指向的虚表信息。查看图12-9中的两个虚表信息后会发现,这两个虚表中保存了子类的虚函数与父类的虚函数,父类的这些虚函数都是在子类中没有实现的。由此可见,编译器将子类CSofaBed的虚函数制作了两份。为什么会产生两份虚函数呢?我们先从对象SofaBed的构造入手,循序渐进地进行分析,过程如代码清单12-9所示。

代码清单12-9 对象SofaBed的构造过程—Debug版
// 源码参考见代码清单12-7
	CSofaBed SofaBed;	// 定义对象
0040F72D   	lea    	ecx,[ebp-24h]	; 传递this指针
0040F730   	call  	@ILT+10(CSofaBed::CSofaBed) (0040100f)    ; 调用构造函数
// 分析构造函数CSofaBed
	CSofaBed(){
	; 部分代码分析略
004011FE   	pop   	ecx		; 还原this指针
004011FF   	mov  	dword ptr [ebp-10h],ecx
00401202   	mov    	ecx,dword ptr [ebp-10h]		; 以对象首地址作为this指针
00401205   		call 	@ILT+110(CSofa::CSofa) (00401073)	; 调用沙发父类的构造函数
0040120A   	mov  	dword ptr [ebp-4],0
00401211   	mov   	ecx,dword ptr [ebp-10h]
00401214   	add    	ecx,8			; 将this指针调整到第二个虚表指针地址处
00401217   	call   	@ILT+130(CBed::CBed) (00401087)	; 调用床父类的构造函数
0040121C   	mov 	eax,dword ptr [ebp-10h]	; 获取第二个虚表指针地址
	; 设置虚表指针
0040121F   	mov  	dword ptr [eax],offset CSofaBed::'vftable' (00426198)
00401225  		mov  	ecx,dword ptr [ebp-10h]		; 获取对象的首地址
	; 设置虚表指针
00401228   	mov   	dword ptr [ecx+8],offset CSofaBed::'vftable' (0042501c)
	; 部分代码分析略
0040125D   ret

在代码清单12-9的子类构造中,根据继承关系的顺序,首先调用了父类CSofa的构造函数。在调用另一个父类CBed时,并不是直接将对象的首地址作为this指针传递,而是向后调整了父类CSofa的大小,以调整后的地址值作为this指针,最后再调用父类CBed的构造函数。
由于有了两个父类,因此子类在继承时也将它们的虚表指针一起继承了过来,也就有了两个虚表指针。可见,在多重继承中,子类虚表指针的个数取决于所继承的父类的个数,有几个父类便会出现对应个数的虚表指针(虚基类除外,详见12.3节的讲解)。

这些虚表指针在将子类对象转换成父类指针时使用,每个虚表指针对应着一个父类,如代码清单12-10所示。

代码清单12-10 多重继承子类对象转换为父类指针
	CSofaBed SofaBed;
	CSofa *pSofa = &SofaBed;
0040F73C   	lea   	eax,[ebp-24h]			; 直接将首地址转换为父类指针
0040F73F   	mov  	dword ptr [ebp-28h],eax
	CBed *pBed = &SofaBed;
0040F742   	lea   	ecx,[ebp-24h]
0040F745   	test 	ecx,ecx		; 检查对象首地址
0040F747   	je     	main+51h (0040f751)
0040F749   		lea    	edx,[ebp-1Ch]	; 即lea edx, [ebp-24h+8h],调整为CBed的指针
0040F74C   	mov 	dword ptr [ebp-30h],edx
0040F74F   	jmp  	main+58h (0040f758)
0040F751   	mov	dword ptr [ebp-30h],0
0040F758   	mov   	eax,dword ptr [ebp-30h]
0040F75B   	mov 	dword ptr [ebp-2Ch],eax	; 保存调整后的this指针

在代码清单12-10中,在转换CBed指针时,会调整首地址并跳过第一个父类所占用的空间。这样一来,当使用父类CBed的指针访问CBed中实现的虚函数时,就不会错误地寻址到继承自CSofa类的成员变量。

了解了多重继承中子类的构造函数,以及父类指针的转换过程后,接下来通过分析代码清单12-11来学习多重继承中子类对象的析构过程。

代码清单12-11 多重继承的类对象析构函数—Debug版
; 子类析构函数的实现过程
virtual ~CSofaBed(){                    // 沙发床类的虚析构函数
	; 部分代码略
0040170E   	pop  	ecx				; 还原this指针
0040170F   	mov 	dword ptr [ebp-10h],ecx
00401712   	mov 	eax,dword ptr [ebp-10h]
	; 将两个虚表指针设置为各个父类的虚表首地址
00401715   	mov  	dword ptr [eax],offset CSofaBed::'vftable' (00426198)
0040171B   	mov   	ecx,dword ptr [ebp-10h]
0040171E   	mov 	dword ptr [ecx+8],offset CSofaBed::'vftable' (0042501c)
00401725   	mov 	dword ptr [ebp-4],0
	; 执行子类虚函数内的代码
printf("virtual ~CSofaBed()\r\n");
}
	; 比较对象地址,与子类对象转为父类指针相似
00401739  	cmp   	dword ptr [ebp-10h],0  ; 当this==NULL时不需调整
0040173D  	je    	CSofaBed::~CSofaBed+6Ah (0040174a)
0040173F   	mov   	edx,dword ptr [ebp-10h]
00401742   	add    	edx,8
00401745   	mov   	dword ptr [ebp-14h],edx ; 将调整后的this指针保存到[ebp-14h]
00401748   	jmp   	CSofaBed::~CSofaBed+71h (00401751)
0040174A   	mov   	dword ptr [ebp-14h],0
00401751   	mov   	ecx,dword ptr [ebp-14h]
	; 调用父类CBed的析构函数
00401754   	call 	@ILT+75(CBed::~CBed) (00401050)
00401759   	mov   	dword ptr [ebp-4],0FFFFFFFFh
00401760   	mov 	ecx,dword ptr [ebp-10h]
	; 无需转换this指针,直接调用父类CSofa的析构函数
00401763   	call   	@ILT+125(CSofa::~CSofa) (00401082)
00401768   	mov   	ecx,dword ptr [ebp-0Ch]
0040176B   	mov    	dword ptr fs:[0],ecx
	; 部分代码略
00401782   ret

代码清单12-11演示了对象SofaBed的析构过程。由于具有多个同级父类(多个同时继承的父类),因此在子类中产生了多个虚表指针。在对父类进行析构时,需要设置this指针,用于调用父类的析构函数。由于具有多个父类,当在析构的过程中调用各个父类的析构函数时,传递的首地址将有所不同,编译器会根据每个父类在对象中占用的空间位置,对应地传入各个父类部分的首地址作为this指针。

在Debug版下,由于侧重调试功能,因此使用了两个临时变量来分别保存两个this指针,它们对应的地址分别为两个虚表指针的首地址。在Release版下,虽然会进行优化,但原理不变,子类析构函数调用父类的析构函数时,仍然会传入在对象中父类对应的地址,当做this指针。

前面讲解了多重继承中子类对象的生成与销毁过程,以及在内存中的分布情况,对比单继承类,两者特征总结如下:
单继承类

  • 在类对象占用的内存空间中,只保存一份虚表指针。
  • 由于只有一个虚表指针,对应的也只有一个虚表。
  • 虚表中各项保存了类中各虚函数的首地址。
  • 构造时先构造父类,再构造自身,并且只调用一次父类构造函数。
  • 析构时先析构自身,再析构父类,并且只调用一次父类析构函数。

  • 多重继承类

  • 在类对象所占用的内存空间中,根据继承父类的个数保存对应的虚表指针。
  • 根据所保存的虚表指针的个数,对应产生相应个数的虚表。
  • 转换父类指针时,需要跳转到对象的首地址。
  • 构造时需要调用多个父类构造函数。
  • 构造时先构造继承列表中第一个父类,然后依次调用到最后一个继承的父类构造函数。
  • 析构时先析构自身,然后以与构造函数相反的顺序调用所有父类的析构函数。
  • 当对象作为成员时,整个类对象的内存结构和多重继承很相似。当类中无虚函数时,整个类对象内存结构和多重继承完全一样,可酌情还原;当父类或成员对象存在虚函数时,通过观察虚表指针的位置和构造函数、析构函数中填写虚表指针的数目及目标地址,来还原继承或成员关系。

  • 在对象模型的还原过程中,可根据以上特性识别出继承关系。对于有虚函数的情况,可利用虚表的初始化,使用IDA中的引用参考进行识别还原。引用参考的使用请回顾第11章的相关内容。
    上传的附件:
    2011-10-10 14:14
    0
    雪    币: 231
    活跃值: (10)
    能力值: ( LV2,RANK:10 )
    在线值:
    发帖
    回帖
    粉丝
    5
    虚基类也被称为抽象类,既然是抽象事物,就不存在实体。如平常所说的东西,它就不能被实例化。将某一物品描述为东西,等同于没有描述。

    在生活中,我们会经常遇到此类情况。例如,在你的书桌上有钢笔一支、水杯一个、书一本,这时你的同桌突然对你说:“把你桌子上的那个东西借我一下”,由于没有具体的描述,你无法知道他所指的“那个东西”到底是哪一件物品。

    在编码过程中,虚基类的定义需要配合虚函数使用。在虚函数的声明结尾处添加“=0”,这种虚函数被称为纯虚函数。纯虚函数是一个没有实现只有声明的函数,它的存在就是为了让类具有虚基类的功能,让继承自虚基类的子类都具有虚表以及虚表指针。在使用过程中,利用虚基类指针可以更好地完成多态的工作。多态的实现分析已经在前面介绍过,而这个纯虚函数是如何实现的呢?对于一个没有实现的函数,编译器又是如何处理的呢?关于纯虚函数的分析如代码清单12-12所示。

    代码清单12-12 纯虚函数的分析—Debug版
    // C++源码说明:定义虚基类和派生类 
    class CVirtualBase{
    public:
    	virtual void Show() = 0;				// 定义纯虚函数
    };
    class CVirtualChild : public CVirtualBase{		// 定义继承虚基类的子类
    public:
    	virtual void Show(){					// 实现纯虚函数
    		printf("虚基类分析\r\n");
    	}
    };
    void main(int argc, char* argv[]){
    	CVirtualChild VirtualChild;
    	VirtualChild.Show();
    }
    // 反汇编代码分析,跟踪到虚基类构造函数中,查看其虚表信息
    CVirtualBase::CVirtualBase:		// 虚基类构造函数
    00401829   pop   	ecx
    0040182A   mov   	dword ptr [ebp-4],ecx
    0040182D   mov    	eax,dword ptr [ebp-4]
    	; 设置虚基类虚表指针,虚表地址在0x00425068处,虚表信息如图12-10所示
    00401830   mov   	dword ptr [eax],offset CVirtualBase::'vftable' (00425068)
    00401836   mov    	eax,dword ptr [ebp-4]
    0040183F   ret
    
    	; 虚基类CVirtualBase中虚表信息的第一项所指向的函数首地址
    void __cdecl _purecall(void){
    00401E90   push        ebp
    00401E91   mov         ebp,esp
    _amsg_exit(_RT_PUREVIRT);
    00401E93   push        19h				; 压入错误编码
    00401E95   call        _amsg_exit (00401fd0)		; 结束程序
    00401E9A   add         esp,4
    }
    00401E9D   pop         ebp
    00401E9E   ret
    


    图12-10 虚基类CVirtualBase的虚表信息

    如代码清单12-12所示,在虚基类CVirtualBase的虚表信息中,由于纯虚函数没有实现代码,因此没有首地址。编译器为了防止误调用纯虚函数,将虚表中保存的纯虚函数的首地址项替换成函数_purecall,用于结束程序,并发出错误编码信息0x19。

    根据这一特性,在分析过程中,一旦在虚表中发现函数地址为_purecall函数的地址时,我们就可以高度怀疑此虚表对应的类是一个虚基类。当虚基类中定义了多个纯虚函数时,虚表中将保存相同的函数指针。在代码清单12-12中,插入新的纯虚函数,并在子类中予以实现。经过编译后,再次查看虚表信息,如图12-11所示。

    图12-11 存在多个纯虚函数的类虚表信息

    在Release版下,编译器会进行优化,纯虚函数将会被优化掉。
    上传的附件:
    2011-10-10 14:24
    0
    雪    币: 231
    活跃值: (10)
    能力值: ( LV2,RANK:10 )
    在线值:
    发帖
    回帖
    粉丝
    6
    菱形继承是最复杂的对象结构,菱形结构会将单一继承与多重继承进行组合,如图12-12所示。

    图12-12 菱形继承结构图

    在图12-12中,类D属于多重继承中的子类,其父类为类B和类C,类B和类C拥有同一个父类A,如代码清单12-13所示。

    代码清单12-13 菱形结构的类继承和派生—C++源码
    // 定义家具类,等同于类A
    class CFurniture{			
    public:
    	CFurniture(){
        	m_nPrice = 0;
    	}
    	virtual ~CFurniture(){				// 家具类的虚析构函数
    	printf("virtual ~CFurniture()\r\n");
      	}
    	virtual int GetPrice(){			// 获取家具价格
        	return m_nPrice;
      	};
    protected:
    	int m_nPrice;					// 家具类的成员变量
    };
    
    // 定义沙发类,继承自类CFurniture,等同于类B
    class CSofa : virtual public CFurniture{		
    public:
    	CSofa(){
    		m_nPrice = 1;
        	m_nColor = 2;
    	}
    	virtual ~CSofa(){				// 沙发类虚析构函数
        	printf("virtual ~CSofa()\r\n");
      	}
    	virtual int GetColor(){			// 获取沙发颜色 
    		return m_nColor;
    	}
    	virtual int SitDown(){				// 沙发可以坐下休息 
    		return printf("Sit down and rest your legs\r\n");
    	}
    protected:
    	int m_nColor;					// 沙发类成员变量 
    };
    
    // 定义床类,继承自类CFurniture,等同于类C
    class CBed : virtual public CFurniture{
    public:
    	CBed(){
    		m_nPrice = 3;
        	m_nLength = 4;
        	m_nWidth = 5;
    	}
    	virtual ~CBed(){				// 床类的虚析构函数 
        	printf("virtual ~CBed()\r\n");
      	}
    	virtual int GetArea(){					// 获取床面积 
    		return m_nLength * m_nWidth;
    	}
    	virtual int Sleep(){					// 床可以用来睡觉 
    		return printf("go to sleep\r\n");
    	}
    protected:
    	int m_nLength;						// 床类成员变量
    	int m_nWidth;
    };
    
    // 子类沙发床的定义,派生自类CSofa和类CBed,等同于类D
    class CSofaBed :  public CSofa,  public CBed{
    public:
    	CSofaBed(){
        m_nHeight = 6;
      }
    	virtual ~CSofaBed(){					// 沙发床类的虚析构函数
        printf("virtual ~CSofaBed()\r\n");
      }
    	virtual int SitDown(){					// 沙发可以坐下休息 
    		return printf("Sit down on the sofa bed\r\n");
    	}
    	virtual int Sleep(){					// 床可以用来睡觉 
    		return printf("go to sleep on the sofa bed\r\n");
    	}
      virtual int GetHeight(){
        return m_nHeight;
      }
    protected:
      int m_nHeight;						// 沙发类的成员变量
    };
    void main(int argc, char* argv[]){
    	CSofaBed  SofaBed;
    }
    

    代码清单12-13中一共定义了4个类,分别为CFurniture、CSofa、CBed和CSofaBed。CFurniture为祖父类,从CFurniture类中派生了两个子类:CSofa与CBed,它们在继承时使用了virtual的方式,即虚继承。

    使用虚继承可以避免共同派生出的子类产生多义性的错误。那么,为什么要将virtual加在两个父类上而不是它们共同派生的子类呢?这个问题与现实世界中动物的繁衍很相似,例如,熊猫在繁衍时要避免具有血缘关系的雄性与雌性“近亲繁殖”,因为“近亲繁殖”的结果会使繁殖出的后代出现基因重叠的问题,从而造成残缺现象。类CBed与类CSofa就如同是一对兄妹,它们的父亲为CSofaBed,当类CBed与类CSofa“近亲结合”后“生下”存在基因问题的子类CSofaBed时,也会存在基因重叠问题,因此通过虚继承来防止这个问题的发生。

    接下来介绍菱形结构中子类CSofaBed的对象在内存中是如何存放的,如图12-13所示。

    图12-13 CSofaBed的内存结构

    图12-13中显示了SofaBed在内存中的信息,初步观察内存中保存的数据可得知,有些数据类似地址值。这些地址值都有哪些含义呢?图12-14对各个地址数据进行了注解。

    图12-14 CSofaBed内存结构的注解

    通过图12-14虽然可以知道各个数据所具有的含义,但是还存在一些模糊不清的数据无法理解,如CSofaBed_vt(new)和vt_offset,它们又都代表着什么呢?带着这个疑问,我们将代码清单12-13转换成汇编代码,如代码清单12-14所示。

    代码清单12-14 菱形结构的虚表指针转换过程
    // C++源码对比,加入了父类指针的转换代码
    void main(int argc, char* argv[]){
    	CSofaBed SofaBed;
    	CFurniture * pFurniture = &SofaBed;			// 转换成祖父类指针
    	CSofa * pSofa = &SofaBed;				// 转换成父类指针
    	CBed * pBed = &SofaBed;				// 转换成父类指针
    }
    
    // C++源码与对应汇编代码讲解
    void main(int argc, char* argv[]){
    	CSofaBed  SofaBed;
    0040F718	push   	1    ; 是否构造祖父类的标志,TRUE表示构造,FALSE表示不构造
    0040F71A   	lea    	ecx,[ebp-28h]		; 传入对象的首地址作为this指针
    0040F71D		call   	@ILT+10(CSofaBed::CSofaBed) (0040100f) 	; 调用构造函数
    	CFurniture * pFurniture = &SofaBed;
    0040F722   	lea   	eax,[ebp-28h]			; 获取对象的首地址
    0040F725   	test  	eax,eax				; 检查代码
    0040F727   	jne    	main+32h (0040f732)	; 跳转到0x0040f732
    0040F729   	mov   	dword ptr [ebp-38h],0
    0040F730   	jmp 	main+3Fh (0040f73f)
    	;  取出对象的第二项数据vt_offset,此地址指向的数据如图12-14所示
    0040F732   	mov 	ecx,dword ptr [ebp-24h] 
    0040F735   	mov	edx,dword ptr [ecx+4]	; 取出偏移值后存入edx中
    0040F738   	lea    	eax,[ebp+edx-24h]		; 得到祖父类数据的所在地址
    0040F73C   	mov   	dword ptr [ebp-38h],eax	; 利用中间变量保存祖父类的首地址
    0040F73F   	mov   	ecx,dword ptr [ebp-38h]
    0040F742   	mov   	dword ptr [ebp-2Ch],ecx	; 赋值pFurniture
    	CSofa * pSofa = &SofaBed;
    0040F745   		lea  	edx,[ebp-28h]	; 直接转换SofaBed对象的首地址为父类CSofa的指针
    0040F748   	mov  	dword ptr [ebp-30h],edx
    	CBed * pBed = &SofaBed;
    0040F74B 		lea    	eax,[ebp-28h]			; 获取对象SofaBed的首地址
    0040F74E   	test   	eax,eax			; 地址检查
    0040F750   	je    	main+5Ah (0040f75a)	
    0040F752   	lea  	ecx,[ebp-1Ch]		; 获取第二个CSofaBed_vt(new)指针
    0040F755   	mov   	dword ptr [ebp-3Ch],ecx
    0040F758   	jmp  	main+61h (0040f761)
    0040F75A   	mov  	dword ptr [ebp-3Ch],0	
    0040F761   	mov  	edx,dword ptr [ebp-3Ch]
    0040F764   	mov  	dword ptr [ebp-34h],edx ; 保存转换后的SofaBed地址到pSofa中
    }
    

    从代码清单12-14中的指针转换过程可以看出,vt_offset指向的内存地址中保存的数据为偏移数据,如图12-15所示,图中每个vt_offset对应的数据有两项:第一项为-4,即vt_offset所属类对应的虚表指针相对于vt_offset的偏移值;第二项保存的是父类虚表指针相对于vt_offset的偏移值。

    图12-15 vt_offset指向的数据

    根据对代码清单12-13的分析可知,3个虚表指针分别为0x00425034、0x00425028、0x0042501C,它们所指向的数据如图12-16所示。

    图12-16 各个虚表信息

    如图12-16所示,这三个虚表指针所指向的虚表包含了子类CSofaBed含有的虚函数。有了这些记录就可以随心所欲地将虚表指针转换成任意的父类指针。在利用父类指针访问虚函数时,只能调用子类与父类共有的虚函数,子类继承自其他父类的虚函数是无法调用的,虚表中也没有相关的记录。当子类的父类也存在多个父类时,会在图12-15所显示的表格中依次记录它们的偏移。

    学习了菱形结构中子类的内存布局后,接下来分析其子类的构造函数,看看这些数据是如何产生的,如代码清单12-15所示。

    代码清单12-15 菱形结构的子类构造
    	CSofaBed SofaBed;
    0040F730  		push 1						; 压入参数1
    0040F732   	lea   	ecx,[ebp-34h]				; 传递this指针
    0040F735   	call   	@ILT+10(CSofaBed::CSofaBed) (0040100f); 调用构造函数
    	; 构造函数实现
    	CSofaBed(){
    	; 部分代码分析略
    004011FE   	pop 	ecx					; 还原this指针
    004011FF   	mov  	dword ptr [ebp-10h],ecx
    00401202   	mov  	dword ptr [ebp-14h],0			; 传入构造标记
    	; 比较参数是否为0,为0则执行JE跳转,防止重复构造
    00401209   	cmp 	dword ptr [ebp+8],0
    0040120D   	je     	CSofaBed::CSofaBed+6Eh (0040123e)
    0040120F   	mov 	eax,dword ptr [ebp-10h]
    	; 设置父类CSofa中的vt_offset域
    00401212   	mov  	dword ptr [eax+4],offset CSofaBed::'vbtable' (00425050)
    00401219   	mov  	ecx,dword ptr [ebp-10h]
    	; 设置父类CBed中的vt_offset域
    0040121C   	mov  	dword ptr [ecx+10h],offset CSofaBed::'vbtable' (00425044)
    00401223   	mov  	ecx,dword ptr [ebp-10h]
    00401226   	add  	ecx,20h				; 调整this指针
    	; 调用祖父类构造函数,祖父类为最上级,它的构造函数和无继承关系的构造函数相同,这里不予分析
    00401229   	call   	@ILT+45(CFurniture::CFurniture) (00401032)
    0040122E   	mov  	edx,dword ptr [ebp-14h]	; 获取构造标记
    00401231   	or    	edx,1				; 将构造标记置为1
    00401234   	mov    	dword ptr [ebp-14h],edx	; 修改构造标记
    00401237   	mov   	dword ptr [ebp-4],0
    0040123E   	push  	0				; 压入0作为构造标记
    00401240   	mov	ecx,dword ptr [ebp-10h]	; 获取对象首地址作为this指针
    00401243   	call   	@ILT+110(CSofa::CSofa) (00401073); 调用父类构造函数
    00401248   	mov   	dword ptr [ebp-4],1
    0040124F   	push   	0				; 压入0作为构造标记
    00401251   	mov   	ecx,dword ptr [ebp-10h]
    00401254   	add    	ecx,0Ch				; 调整this指针
    00401257   	call   	@ILT+130(CBed::CBed) (00401087)	; 调用父类构造函数
    0040125C 		mov  	eax,dword ptr [ebp-10h]
    	; CSofaBed对应CSofa的虚表指针
    0040125F   	mov  	dword ptr [eax],offset CSofaBed::'vftable' (00425034)
    00401265   	mov   	ecx,dword ptr [ebp-10h] 
    	; CSofaBed对应CBed的虚表指针
    00401268   	mov   	dword ptr [ecx+0Ch],offset CSofaBed::'vftable' (00425028)
    0040126F   	mov   	edx,dword ptr [ebp-10h] ;通过this指针和vt_offset定位到祖;父类的虚表指针
    00401272   	mov   	eax,dword ptr [edx+4] ; vt_offset存入eax中
    00401275   	mov  	ecx,dword ptr [eax+4] ; 父类虚表指针相对于vt_offset的偏移存入eax中
    00401278   	mov  	edx,dword ptr [ebp-10h] 
    	; CSofaBed对应CFurniture的虚表指针
    0040127B   	mov  	dword ptr [edx+ecx+4],offset CSofaBed::'vftable' (0042501c)
    m_nHeight = 6;
    00401283   	mov	eax,dword ptr [ebp-10h]
    00401286 		mov 	dword ptr [eax+1Ch],6
    }
    004012B1   	ret  	4
    

    代码清单12-15展示了子类CSofaBed的构造过程,它的特别之处是在调用时要传入一个参数。这个参数是一个标志信息。构造过程中要先构造父类,然后构造自己。CSofaBed的两个父类有一个共同的父类,如果没有构造标记,它们共同的父类将会被构造两次,因此需要使用构造标记来防止重复构造的问题,构造顺序如下:
      CFurniture
      CSofa(根据标记跳过CFurniture构造)
      CBed(根据标记跳过CFurniture构造)
      CSofaBed自身


    CSofaBed也使用了构造标记,当CSofaBed也是父类时,这个标记将产生作用,跳过所有父类的构造,只构造自身。当标记为1时,则构造父类;当标记为0时,则跳过构造函数。构造时可以使用标记来防止重复构造,同样也不能出现重复析构的错误,那么这又如何实现呢?我们来看一下代码清单12-16。

    代码清单12-16 菱形结构的子类析构
    // CSofaBed 调用析构代理函数,因为是编译器自动添加的,所以无源码对照
    CSofaBed::'vbase destructor':
    ; 部分代码分析略
    00401AE9   pop  	ecx
    00401AEA  	mov	dword ptr [ebp-4],ecx
    00401AED 	mov  	ecx,dword ptr [ebp-4]
    00401AF0  	add    	ecx,20h
    	; 调用CSofaBed的析构函数
    00401AF3  	call   	@ILT+90(CSofaBed::~CSofaBed) (0040105f)
    00401AF8  	mov   	ecx,dword ptr [ebp-4]
    00401AFB  	add    	ecx,20h
    	; 调用祖父类的析构函数
    00401AFE  	call    	@ILT+60(CFurniture::~CFurniture) (00401041)
    ; CSofaBed::~CSofaBed实现
    	virtual ~CSofaBed(){               
    ; 部分代码分析略
    00401B5E 	pop  	ecx				; 还原this指针
    00401B5F  	mov   	dword ptr [ebp-10h],ecx	; 调整this指针
    00401B62  	mov  	eax,dword ptr [ebp-10h]
    	; 设置自身虚表 
    00401B65  	mov  	dword ptr [eax-20h],offset CSofaBed::'vftable' (00425034)
    00401B6C 	mov 	ecx,dword ptr [ebp-10h]
    	; 设置自身虚表 
    00401B6F  	mov  	dword ptr [ecx-14h],offset CSofaBed::'vftable' (00425028)
    00401B76  	mov  	edx,dword ptr [ebp-10h]
    00401B79  	mov    	eax,dword ptr [edx-1Ch]
    00401B7C  	mov   	ecx,dword ptr [eax+4]
    00401B7F  	mov  	edx,dword ptr [ebp-10h]
    	; 设置自身虚表。到此为止,3个虚表指针设置完毕,执行析构函数内的代码
    00401B82  	mov  	dword ptr [edx+ecx-1Ch],offset CSofaBed::'vftable' (0042501c)
    00401B8A  	mov    	dword ptr [ebp-4],0
    printf("virtual ~CSofaBed()\r\n");
    }
    00401B9E   mov  	eax,dword ptr [ebp-10h]
    00401BA1   sub  	eax,20h		; 获取this指针
    00401BA4   test   	eax,eax		; 检查this指针
    00401BA6   je   	CSofaBed::~CSofaBed+83h (00401bb3)
    00401BA8   mov  	ecx,dword ptr [ebp-10h]
    00401BAB   sub   	ecx,14h
    00401BAE   mov   	dword ptr [ebp-14h],ecx
    00401BB1   jmp    	CSofaBed::~CSofaBed+8Ah (00401bba)
    00401BB3   mov 	dword ptr [ebp-14h],0
    00401BBA   mov 	ecx,dword ptr [ebp-14h]
    00401BBD   add 	ecx,10h		; 调整this指针
    00401BC0   call   	@ILT+75(CBed::~CBed) (00401050)	; 调用父类析构函数
    00401BC5   mov    	dword ptr [ebp-4],0FFFFFFFFh
    00401BCC 	mov  	ecx,dword ptr [ebp-10h]
    00401BCF  	sub   	ecx,14h		; 调整this指针
    00401BD2  	call  	@ILT+125(CSofa::~CSofa) (00401082)	; 调用父类析构函数
    	; 部分代码分析略
    00401BF1 		ret
    

    根据对代码清单12-16的分析可知,菱形结构中子类的析构函数执行流程并没有像构造函数那样使用标记来防止重复析构,而是将祖父类放在最后调用。先依次执行两个父类CBed和CSofa的析构函数,然后执行祖父类的析构函数。Release版下的原理也是如此,这里就不再重复分析了。
    上传的附件:
    2011-10-10 14:30
    0
    雪    币: 231
    活跃值: (10)
    能力值: ( LV2,RANK:10 )
    在线值:
    发帖
    回帖
    粉丝
    7
    本章讲解了对象之间发生继承和派生关系后的内存布局情况,以及相关的处理和操作。读者应该结合C++语法上机进行验证,以熟悉各种内存布局。因为对象之间的关系结构不同,所以它们的内存布局、构造函数、析构函数都有差别,这些是编译器作者为了实现C++的语法而设计的内存结构和执行代码。在其他的编译环境下,其内存结构和相关的处理会有差异,由于不同的C++编译器都需要满足C++的语法标准,故差异也不会太大。要分析由其他编译器创建的程序,可先编写一些简单的语法示例(类似于本章体现各个语法知识点的示例),然后用其他编译器编译,通过反汇编观察其内存布局、构造函数和析构函数的处理流程。

    思考题答案:
    为什么编译器要在子类析构函数中再次将虚表设置为子类虚表呢?这个操作非常必要,因为编译器无法预知这个子类以后是否会被其他类继承,如果被继承,原来的子类就成了父类,析构函数执行时会先执行当前对象的析构函数,然后向祖父类的方向按继承线路逐层调用各类析构函数,当前对象的析构函数开始执行时,其虚表也是当前对象的,因此执行到父类的析构函数时,虚表必须改写为父类的虚表。编译器所产生的类实现代码,必须能够适应将来不可预知的对象关系,故在每个对象的析构函数内,要加入填写自己虚表的代码。
    2011-10-10 14:32
    0
    雪    币: 291
    活跃值: (10)
    能力值: ( LV2,RANK:10 )
    在线值:
    发帖
    回帖
    粉丝
    8
    没有pdf格式的么?整理在一起的多好~
    2011-10-10 16:30
    0
    雪    币: 278
    活跃值: (709)
    能力值: ( LV15,RANK:520 )
    在线值:
    发帖
    回帖
    粉丝
    9
    真是舒服,跟着试了下感觉,太爽了!
    2011-10-10 18:16
    0
    雪    币: 231
    活跃值: (10)
    能力值: ( LV2,RANK:10 )
    在线值:
    发帖
    回帖
    粉丝
    10
    感谢反馈意见,已公布pdf版的样张
    请访问http://bbs.pediy.com/showthread.php?t=141356下载,谢谢
    2011-10-13 11:16
    0
    游客
    登录 | 注册 方可回帖
    返回
    //