第11章 关于虚函数
虚函数是面向对象程序设计的关键组成部分。第10章介绍了构造函数和析构函数的识别方法。对于具有虚函数的类而言,构造函数和析构函数的识别流程更加简单。而且,在类中定义了虚函数之后,如果没有提供默认的构造函数,编译器必须提供默认的构造函数。
对象的多态性需要通过虚表和虚表指针来完成,虚表指针被定义在对象首地址的前4字节处,因此虚函数必须作为成员函数使用。由于非成员函数没有this指针,因此无法获得虚表指针,进而无法获取虚表,也就无法访问虚函数。
为什么类有了虚函数后需要提供默认的构造函数呢?构造函数内发生了哪些变化呢?本章将详细分析虚函数的实现原理以及它与构造函数和析构函数之间的关系,从而为大家解开这些疑问。
11.1 虚函数的机制
在C++中,使用关键字virtual声明函数为虚函数。当类中定义有虚函数时,编译器会将该类中所有虚函数的首地址保存在一张地址表中,这张表被称为虚函数地址表,简称虚表。同时,编译器还会在类中添加一个隐藏数据成员,称为虚表指针。该指针中保存着虚表的首地址,用于记录和查找虚函数。我们先来看一个包含虚函数的类的定义,如代码清单11-1所示。
代码清单11-1 包含虚函数的类的定义—C++源码
class CVirtual{
public:
virtual int GetNumber(){//虚函数定义
return m_nNumber;
}
virtual void SetNumber(int nNumber){//虚函数定义
m_nNumber=nNumber;
}
private:
int m_nNumber;
};
代码清单11-1中的类定义了两个虚函数和一个数据成员。如果这个类没有定义虚函数,则其长度为4,定义了虚函数后,由于还含有隐藏数据成员(虚表指针),因此大小为8,如图11-1所示。
图 11-1 含有虚函数的类的大小
根据图11-1中的显示,类CVirtual确实多出了4字节数据,这4字节数据用于保存虚表指针。在虚表指针所指向的函数指针数组中,保存着虚函数GetNumber和SetNumber的首地址。对于开发者而言,虚表和虚表指针都是隐藏的,在常规的开发过程中感觉不到它们的存在。对象中的虚表指针和虚表的关系如图11-2所示。
图 11-2 虚表指针存储信息
通过对图11-2的分析可以得出结论,有了虚表指针,就可以通过该指针得到该类中的所有虚函数的首地址。下面通过一个示例来分析图11-2中的虚表指针的实现过程,如代码清单11-2所示。
代码清单11-2 虚表指针的初始化过程—Debug版
//C++源码说明:类CVirtual的定义见代码清单11-1
int main(int argc, char*argv[]){
CVirtual MyVirtual;//对象定义
return 0;
}
//C++源码与对应汇编代码讲解
int main(int argc, char*argv[]){
CVirtual MyVirtual;
00401048 lea ecx,[ebp-8];获取对象首地址
;调用构造函数,类CVirtual中并没有定义构造函数,此调用为默认构造函数
0040104B call@ILT+15(CVirtual:CVirtual)(00401014)
return 0;
}
//默认构造函数分析
CVirtual:CVirtual:
;Debug初始化保存环境略
00401089 pop ecx;还原this指针
0040108A mov dword ptr[ebp-4],ecx;[ebp-4]存储this指针
;取出this指针并保存到eax中,这个地址将会作为指针保存到虚表的首地址中
0040108D mov eax, dword ptr[ebp-4]
;取虚表的首地址,保存到虚表指针中
00401090 mov dword ptr[eax],offset CVirtual:'vftable'(0042201c)
00401096 mov eax, dword ptr[ebp-4];返回对象首地址
00401099 pop edi
0040109A pop esi
0040109B pop ebx
0040109C mov esp, ebp
0040109E pop ebp
0040109F ret
在代码清单11-2中,编译器为类CVirtual提供了默认的构造函数。该默认构造函数先取得虚表的首地址0x0042201C,然后赋值到虚表指针中。虚表信息如图11-3所示。
图 11-3 虚表信息
图11-3的Memory窗口中显示了虚表中的两个地址信息,分别为成员函数GetNumber和SetNumber的地址。因此,得到虚表指针就相当于得到了类中所有虚函数的首地址。对象的虚表指针初始化是通过编译器在构造函数内插入代码来完成的。在用户没有编写构造函数时,由于必须初始化虚表指针,因此编译器会提供默认的构造函数,以完成虚表指针的初始化。
由于虚表信息在编译后会被链接到对应的执行文件中,因此所获得的虚表地址是一个相对固定的地址。虚表中虚函数的地址的排列顺序依据虚函数在类中的声明顺序而定,先声明的虚函数的地址会被排列在虚表中靠前的位置。第一个被声明的虚函数的地址在虚表的首地址处。
代码清单11-2展示了默认构造函数初始化虚表指针的过程。对于含有构造函数的类而言,其虚表初始化过程和默认构造函数相同,都是以对象首地址的前4字节数据保存虚表的首地址。
在虚表指针的初始化过程中,对象执行了构造函数后,就得到了虚表指针,当其他代码访问这个对象的虚函数时,会根据对象的首地址,取出对应虚表元素。当函数被调用时,会间接访问虚表,得到对应的虚函数首地址,并调用执行。此种调用方式是一个间接调用过程,需要多次寻址才能完成。
这种通过虚表间接寻址访问的情况只有在使用对象的指针或引用来调用虚函数时候才会出现。当直接使用对象调用自身的虚函数时,没有必要查表访问。这是因为已经明确调用的是自身成员函数,根本没有构成多态性,查询虚表只会画蛇添足,降低程序执行效率,所以将这种情况处理为直接调用方式,如代码清单11-3所示。
代码清单11-3 调用自身类中的虚函数—Debug版
//C++源码说明:类CVirtual的定义见代码清单11-1
int main(int argc, char*argv[]){
CVirtual MyVirtual;
MyVirtual.SetNumber(argc);
printf("%d\r\n",MyVirtual.GetNumber());
return 0;
}
//C++源码与对应汇编代码讲解
int main(int argc, char*argv[]){
CVirtual MyVirtual;
00401048 lea ecx,[ebp-8]
0040104B call@ILT+15(CVirtual:CVirtual)(00401014)
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)
printf("%d\r\n",MyVirtual.GetNumber());//调用虚函数
0040105C lea ecx,[ebp-8]
;直接调用函数
0040105F call@ILT+0(CVirtual:GetNumber)(00401005)
;printf调用过程略
return 0;
00401072 xor eax, eax
}
;虚函数SetNumber分析
virtual void SetNumber(int nNumber){
;函数入口代码略
004010F9 pop ecx
004010FA mov dword ptr[ebp-4],ecx
m_nNumber=nNumber;
004010FD mov eax, dword ptr[ebp-4]
00401100 mov ecx, dword ptr[ebp+8]
00401103 mov dword ptr[eax+4],ecx
}
;函数出口代码略
0040110C ret 4
;分析显示,虚函数与其他非虚函数的成员函数的实现流程一致,函数内部无差别
代码清单11-3直接通过对象调用自身的成员虚函数,因此编译器使用了直接调用函数的方式,没有访问虚表指针,间接获取虚函数地址。对象的多态性常常在派生和继承关系中体现,派生和继承关系的详细讲解见第12章。
仔细分析虚表指针的原理后发现,编译器隐藏了初始化虚表指针的实现代码,当类中出现虚函数时,必须在构造函数中对虚表指针执行初始化操作,而没有虚函数的类对象在构造时,不会进行初始化虚表指针的操作。由此可见,在分析构造函数时,又增加了一个新特征—虚表指针初始化。根据以上分析,如果排除开发者伪造编译器生成的代码来误导分析人员的可能,我们就可以给出一个结论:对于单线继承的类结构,在其某个成员函数中,将this的地址赋值为虚表首地址时,可以判定这个成员函数为构造函数。前面讲解了构造函数的识别要领,这个知识点是对它的补充。前面章节中给出的条件是判定构造函数的必要条件,而这里的虚表指针初始化是充分条件。
构造函数可以通过识别虚表指针的初始化来简化分析,那么析构函数中是否有对虚表指针的操作呢?我们先来看一个示例,如代码清单11-4所示。
代码清单11-4 析构函数分析—Debug版
//C++源码说明:修改代码清单11-1中类CVirtual定义,添加析构函数
~CVirtual(){
printf("~CVirtual");
}
//main函数C++源码
int main(int argc, char*argv[]){
CVirtual MyVirtual;
return 0;//分析return后的反汇编代码
}
//C++源码与对应汇编代码讲解
int main(int argc, char*argv[]){
CVirtual MyVirtual;
return 0;
;构造函数分析略,直接看析构函数的调用
00401060 mov dword ptr[ebp-0Ch],0
00401067 lea ecx,[ebp-8]
0040106A call@ILT+15(CVirtual:~CVirtual)(00401014);析构函数调用
0040106F mov eax, dword ptr[ebp-0Ch]
}
00401014 jmp CVirtual:~CVirtual(00401100)//析构函数分析
~CVirtual(){
;函数入口代码略
00401119 pop ecx
0040111A mov dword ptr[ebp-4],ecx;[ebp-4]保存this指针
0040111D mov eax, dword ptr[ebp-4];eax得到this指针,这是虚表的位置
;将当前类的虚表首地址赋值到虚表指针中
00401120 mov dword ptr[eax],offset CVirtual:'vftable'(00425024)
printf("~CVirtual");
;printf分析略
}
;函数出口代码略
00401143 ret
通过比较代码清单11-2和代码清单11-4中构造函数与析构函数的分析流程得知,两者对虚表的操作过程几乎相同,都是将虚表指针设置为当前对象所属类中的虚表首地址。两者看似相同,事实上差别很大。
构造函数中完成的是初始化虚表指针的工作,此时虚表指针并没有指向虚表地址,而执行析构函数时,其对象的虚表指针已经指向了某个虚表首地址。大家是否觉得在析构函数中填写虚表是没必要的?这里实际上是在还原虚表指针,让其指向自身的虚表首地址,防止在析构函数中调用虚函数时取到非自身虚表,从而导致函数调用错误。关于在析构函数中填写虚表是否有必要,大家可以结合继承关系思考一下,这部分内容将在第12章进行详细讲解。
鉴定析构函数的依据和虚表指针相关,识别析构函数的充分条件是—写入虚表指针,但是请注意,它与前面讨论的虚表指针初始化不同。所谓虚表指针初始化,是指对象原来的虚表指针位置不是有效的,初始化后才指向了正确的虚函数表;而写入虚表指针,是指对象的虚表指针可能是有效的,已经指向了正确的虚函数表,将对象的虚表指针重新赋值后,其指针可能指向了另一个虚表,其虚表的内容不一定和原来的一样。
结合IDA中的引用参考可以得知,只要确定一个构造函数或者析构函数,我们就能顺藤摸瓜找到其他的构造函数以及类之间的关系。