在我的另外一篇文章中 ,我提到了要通过汇编语言来分析虚函数调用的真相。我们现在就开始踏上这次艰辛却非常有意思的旅程。其他闲话少说,直接进入主题。本文中使用的C++代码:
#include <iostream>
class CBase {
public:
virtual void callMe();
};
class CDerived: public CBase {
public:
virtual void callMe();
};
void CBase::callMe() {
std::cout<<"Hello,I'm CBase. "<<std::endl;
}
void CDerived::callMe() {
std::cout<<"Hello,I'm CDerived. "<<std::endl;
}
int _tmain(int argc, _TCHAR* argv[])
{
CDerived dObj;
CBase bObj = dObj;
CBase* pBase = &dObj;
((CBase)dObj).callMe();
(*pBase).callMe();
(*((CDerived*)pBase)).callMe();
return 0;
}
我将对每一个C++语句逐句分析,在每个C++语句后面有相应的汇编代码,便于比较。
第一个语句:
从语法的角度,这里应该是调用dObj的无参数的构造函数。
从汇编的角度,我们发现两点需要注意的地方:
1.需要调用的函数在编译阶段就已经确定下来了,并且采用直接取函数地址的方式调用它。
2.类的成员函数的调用采用"this"调用约束,隐式的this指针被存放在EXC寄存器中。
第二个语句:
lea eax, DWORD PTR _dObj$[ebp]
push eax
lea ecx, DWORD PTR _bObj$[ebp]
call ??0CBase@@QAE@ABV0@@Z
从语法的角度,这里是用一个派生类对象去构造一个基类对象,应该调用基类对象的拷贝构造函数去构造它.
从汇编的角度,我们又发现了一点需要注意的地方:构造函数的第二个参数被存放在EAX中。EAX中存放的是被拷贝对象的首地址。
第三个语句:
mov eax, DWORD PTR _pBase$[ebp]
mov edx, DWORD PTR [eax]
mov esi, esp
mov ecx, DWORD PTR _pBase$[ebp]
call DWORD PTR [edx]
cmp esi, esp
call __RTC_CheckEsp
从语法的角度,这里是用基类指针去调用虚函数,实际被调用的函数依赖于基类指针所指的内存空间中的虚函数表指针。这是标准的使用基类指针调用派生类的虚函数的例子。
从汇编的角度,虚函数调用的所有秘密就隐藏在这里:
1.取出pBase指针所指的内容空间的第一个DWORD元素的值,并将它存放在EAX中。EAX中实际存放的应该就是"虚函数表指针"。
2.取出EAX的值,得到虚函数表的地址,取出虚函数表中的第一个元素的值,并将它存放在EDX中。EDX中实际上存放的应该就是需要被调用的虚函数的地址。本例中由于只有一个虚函数,所以虚函数表中的第一个元素就是我们需要的函数,如果虚函数表中有多个虚函数,在调用第二个,第三个虚函数的时候,我们需要在虚函数表的首地址后面添加一个偏移量从而获得相应的虚函数地址。
第四个语句:
lea ecx, DWORD PTR _dObj$[ebp]
call ?callMe@CDerived@@UAEXXZ ; CDerived::callMe
从语法的角度,这里是使用类对象调用类的成员函数,并且这个成员函数被声明成虚函数。
从汇编的角度,这里的调用情况和第一个语句一样,采用"this"调用约束在编译阶段直接获取被调用函数的地址,并调用这个函数。
第五个语句:
lea eax, DWORD PTR _dObj$[ebp]
push eax
lea ecx, DWORD PTR $T1758[ebp]
call ??0CBase@@QAE@ABV0@@Z
;-----------------------------------
mov DWORD PTR tv72[ebp], eax
mov ecx, DWORD PTR tv72[ebp]
mov edx, DWORD PTR [ecx]
mov esi, esp
mov ecx, DWORD PTR tv72[ebp]
call DWORD PTR [edx]
cmp esi, esp
call __RTC_CheckEsp
从语法的角度,这里是将dObj转化到基类,并调用虚函数。
从汇编的角度,这里比我们猜测的情况要稍微复杂一点。仔细分析一下,这里其实可以被分为两个步骤:
1.使用dObj构造一个临时基类变量。
2.使用这个临时基类变量的地址去调用虚函数
所以上面的C++代码可以写成这样的形式:
pTempbase->callMe();
第六个语句:
mov eax, DWORD PTR _pBase$[ebp]
mov edx, DWORD PTR [eax]
mov esi, esp
mov ecx, DWORD PTR _pBase$[ebp]
call DWORD PTR [edx]
cmp esi, esp
call __RTC_CheckEsp
从语法的角度,这里是通过pBase所指的对象来调用虚函数。
从汇编的角度,这里和常见的标准的虚函数调用方式是一样的。
第七个语句:
mov eax, DWORD PTR _pBase$[ebp]
mov edx, DWORD PTR [eax]
mov esi, esp
mov ecx, DWORD PTR _pBase$[ebp]
call DWORD PTR [edx]
cmp esi, esp
call __RTC_CheckEsp
从语法的角度,这里是将pBase指针转化到派生类指针,并通过所指的对象来调用虚函数。
从汇编的角度,这里和常见的标准的虚函数调用方式是一样的。
通过比较以上形形色色的函数调用方式,我们可以深刻的认识到:
1.虚函数并不因被声明成虚函数就能够在调用的时候表现出"虚"性,虚函数本质上和普通的成员函数是一样的,具有确定的函数地址。
2.虚函数要表现出"虚"性,本质上只有一种方式:通过对象的地址获得类对象的虚函数表指针,从而获得虚函数表的地址,间接获得被调用虚函数的地址。
而且,通过这次学习,我也有了这样的感觉:
1.在看似简单的语法背后有时候却可能隐藏这巨大的秘密
2.再看似复杂的语法背后有时候却是难以相信的简单和"干净"
3.当我们对底层,对汇编了解的越多,我们对语法的理解就会越深,对语法的驾驭能力会越来越强。
在本文涉及到的函数调用中,被调用函数和调用函数处在同一个模块的,在随后的文章中,我会涉及到处于不同模块中函数调用的问题,最常见的例子就是应用程序调用DLL的情况。先在这里作个预告吧。
特别注释:
1.在VC环境下我们可以通过这样的方式获得程序的汇编代码:
打开项目-》在“解决方案资源管理器"中选择需要编译的项目,点右键,选择"属性"-》C/C++-》输出文件-》汇编输出-》选择"带源代码的程序集"。然后编译这个项目,在项目的输出目录中就可以看到以.asm结尾的文件,这个就是于C/C++源码对应的汇编代码。
2.C++编译在编译的过程中会对函数名,变量名等符号名进行"修饰",这个叫"Name Mangling"。修饰的结果是我们再很难识别这些符号名了,例如我们就很难判断出??0CDerived@@QAE@XZ指的是那个函数。VC开发包中提供了一个小工具,可以帮我们"反修饰"那些已经被修饰的符号名。这个工具位于:VS安装目录\VC目录\bin\undname.exe。有了这个工具,我们可以使用这样的命令方式进行"反修饰":
得到的结果是:
is :- "public: __thiscall CDerived::CDerived(void)"
10/15/2006 于家中








