C++虚函数调用的反汇编解析
C++虚函数调用的反汇编解析
作者:阮建辉
虚函数的调用如何能实现其“虚”?作为C++多态的表现手段,估计很多人对其实现机制感兴趣。大约一般的教科书就说到这个C++强大机制的时候,就是教大家怎么用,何时用,而不会去探究一下这个虚函数的真正实现细节。(当然,因为不同的编译器厂家,可能对虚函数有自己的实现,呵呵,这就算是虚函数对于编译器的“多态”了:)。作为编译型语言,C++编译的最后结果就是一堆汇编指令了(这里不同于.NET的CLR)。今天,我就来揭开它的神秘面纱,从汇编的层面来看看虚函数到底怎么实现的。让大家对虚函数的实现不仅知其然,更知其所以然。(本文程序环境为:PC + Windows XP Pro + Visual C++6.0,文中所得出来的结果和反映的编译器策略也只针对VC6.0的编译器)
先看一段简单代码:
Code Segment:
Line01: #include <stdio.h>
Line02:
Line03: class Base {
Line04: public:
Line05: void __stdcall Output() {
Line06: printf("Class Base"n");
Line07: }
Line08: };
Line09:
Line10: class Derive : public Base {
Line11: public:
Line12: void __stdcall Output() {
Line13: printf("Class Derive"n");
Line14: }
Line15: };
Line16:
Line17: void Test(Base *p) {
Line18: p->Output();
Line19: }
Line20:
Line21: int __cdecl main(int argc, char* argv[]) {
Line22: Derive obj;
Line23: Test(&obj);
Line24: return 0;
Line25: }
程序的运行结果将是:
Class Base
那么将Base类的Output函数声明(Line05)更改为:
virtual void __stdcall Output() {
那么,很明显地,程序的运行结果将是:
Class Derive
Test函数这回算是认清楚了这个指针是一个指向Derive类对象的指针,并且正确的调用了其Output函数。编译器如何做到这一切的呢?我们来看看没有“virtual”关键字和有“virtual”关键字,其最终的汇编代码区别在那里。
(在讲解下面的汇编代码前,让我们对汇编来一个简单扫描。当然,如果你对汇编已经很熟练,那么goto到括号外面吧^_^。先说说上面的Output函数被声明为__stdcall的调用方式:它表示函数调用时,参数从右到左进行压栈,函数调用完后由被调用者恢复堆栈指针esp。其它的调用方式在文中描述。所谓的C++的this指针:也就是一个对象的初始地址。在函数执行时,它的参数以及函数内的变量将拥有如下所示的堆栈结构:
(图1)
如上图1所示,我们的参数和局部变量在汇编中都将以ebp加或者减多少来表示。你可能会有疑问了:有时候我的参数或者局部变量可能是一个很大的结构体或者只是一个char,为什么这里ebp加减的都是4的倍数呢?恩,是这样的,对于32位机器来说,采用4个字节,也就是每次传输32位,能够取得最佳的总线效率。如果你的参数或者局部变量比4个字节大,就会被拆成每次传4个字节;如果比4个字节小,那还是每次传4个字节。再简单解释一下下面用到的汇编指令,这些指令都是见名知意的哦:
①mov destination,source
将source的值赋给destination。注意,下面经常用到了“[xxx]”这样的形式,“xxx”对应某个寄存器加减某个数,“[xxx]”表示是取“xxx”的值对应的内存单元的内容。好比“xxx”是一把钥匙,去打开一个抽屉,然后将抽屉里的东西取出来给别人,或者是把别人给的东西放到这个抽屉里;
②lea destination,[source]
将source的值赋给destination。注意,这个指令就是把source给destination,而不会去把source对应的内存单元的内容赋给destination。好比是它就把钥匙给别人了;
在调试时如果想查看反汇编的话,你应该点击图2下排最右的按钮。
(图2)
其它指令我估计你从它的名字都能知道它是干什么的了,如果想知道其具体意思,这个应该参考汇编手册。:)
一. 没有virtual关键字时:
(1) main函数的反汇编内容:
Line22: Derive obj;
Line23: Test(&obj);
//如果你把断点设置在22行,开始调试的时候VC会告诉你这是一个无效行,而把断
//点自动移到下一行(Line23),这是因为代码中没有为Derive以及其基类定义构造函
//数,而且编译器也没有为它生成一个默认的构造函数的缘故,此行C++代码不会生成
//任何可实际调用的汇编指令;
004010D8 lea eax,[ebp-4]
//将对象obj的地址放入eax寄存器中;
004010DB push eax
//将参数入栈;
004010DC call @ILT+5(Test) (0040100a)
//调用Test函数;
//这里@ILT+5就是跳转到Test函数的的jmp指令的地址,一个模块中所有的
//函数调用都会是象这样@ILT+5*n,n表示这个模块中的第n个函数,而ILT的意思
//是Import Lookup Table,程序调用函数的时候就是通过这个表来跳转到相应函数而执
//行代码的。
004010E1 add esp,4
//调整堆栈指针,刚才调用了Test函数,调用方式__cdecl, 由调用者来恢复堆栈指针;
(2) Test函数的反汇编内容:
Line18: p->Output();
00401048 mov eax,dword ptr [ebp+8]
//这里的[ebp+8]其实就是Test函数最左边的参数,就是上面main函数中压栈的eax;
//将参数的值(也就是上面的main函数中的obj对象的地址)放入eax寄存器中。
//注意:对于C++类的成员函数,默认的调用方式为“__thiscall”,这不是一个由程
//序员指定的关键字,它所表示的的函数调用,参数压栈从右向左,而且使用ecx寄存
//器来保存this指针。这里我们的Output函数的调用方式为“__stdcall”,ecx寄存器
//并不被使用来保存this指针,所以得有额外的指令将this指针压栈,如下句:
0040104B push eax
//将eax入栈,也就是下面调用Output函数需要的this指针了;
0040104C call @ILT+0(Base::Output) (00401005)
//调用类的成员函数,没有任何悬念,老老实实地调用Base类的Output函数;
二. 有virtual关键字时:
(1) main函数的反汇编内容:
Line22: Derive obj;
//在有virtual关键字的时候,把断点设置在22行,调试时就会停在此处了。我们没有
//为Derive类或者它的基类声明构造函数,这说明编译器自动为类生成了一个构造函
//数,下面我们就可以看看编译器自动生成的这个构造函数干了什么;
00401088 lea ecx,[ebp-4]
//将对象obj的地址放入ecx寄存器中,为什么呢?上面说了哦~
0040108B call @ILT+25(Derive::Derive) (0040101e)
//编译器帮忙生成了一个构造函数,它在这里干了什么呢?等会再说吧,作个记号先://@_@1;上面要把obj的地址放入ecx中就是为这个函数调用做准备的;
Line23: Test(&obj);
//这个调用操作跟上面的没有virtual关键字时是一样的:
00401090 lea eax,[ebp-4]
00401093 push eax
00401094 call @ILT+5(Test) (0040100a)
004010C9 add esp,4
(2) Test函数的反汇编内容(跟上面的没有virtual关键字时可是大不一样哦):
Line18: p->Output();
00401048 mov eax,dword ptr [ebp+8]
//将Test的第一个参数的值放入eax寄存器中,其实你应该已经知道了,这就是obj的//地址了;
0040104B mov ecx,dword ptr [eax]
//喔噢,将eax寄存器中存的数对应的地址的内容取出来,你知道这是什么吗?等会再//说,做个记号先:@_@2
0040104D mov esi,esp
//这个是用来做esp指针检测的
0040104F mov edx,dword ptr [ebp+8]
//又把obj的地址存放到edx寄存器中,你该知道,其实就是this指针,而这个就是为 //调用类的成员函数做准备的;
00401052 push edx
//将对象指针(也就是this指针)入栈,为调用类的成员函数做准备;
00401053 call dword ptr [ecx]
//这个调用的就是类的成员函数,你知道调用的哪个函数吗?等会再说,做个记号先:
//@_@3
00401055 cmp esi,esp
//比较esp指针的,要是不相同,下面的__chkesp函数将会让程序进入debug
00401057 call __chkesp (00401110)
//检测esp指针,处理可能出现的堆栈错误(如果出错,将陷入debug)。
对一个C++类,如果它要呈现多态(一般的编译器会将这个类以及它的基类中是否存在virtual关键字作为这个类是否要多态),那么类会有一个virtual table,而每一个实例(对象)都会有一个virtual pointer(以下简称vptr)指向该类的virtual function table,如图3所示:
(下面右边表格中的VFuncAddr应该被理解为存放虚函数地址的内存单元的地址才准确。更准确地说,应该是跳转到相应函数的jmp指令的地址。)
(图3)
先来分析我们的main函数中的Derive类的对象obj,看看它的内存布局,由于没有数据成员,它的大小为4个字节,只有一个vptr,所以obj的地址也就是vptr的地址了。(之所以我这里举例的类没有数据成员,因为不同的编译器将vptr放置的位置在对象内存布局中有可能不一样,当然,一般不是放在对象的头部,比如微软编译器;就是放在对象的尾部。不管哪种情况,对于这个例子,我这里的“obj的地址也就是vptr的地址”都是成立的。)
一个对象的vptr并不由程序员指定,而是由编译器在编译中指定好了的。那么现在让我来分别解释上文中标记的@_@1 - @_@3。
@_@1:
也就是要解释这里为什么编译器会为我们生成一个默认的构造函数,它是用来干什么的?还是让我们从反汇编里寻找答案:
这是由编译器默认生成的Derive的构造函数中选取出来的核心汇编片段:
004010D9 pop ecx
//编译器默认生成的Derive的构造函数的调用方式为__thiscall,所以ecx寄存器,如前
//所说,保存的就是this指针,也就是obj对象的地址,在这里也是vptr的地址了;
//我发现即使你把一个构造函数声明为__stdcall,它跟默认的__thiscall的反汇编也是一
//样的,这一点跟成员函数是不一样的;
004010DA mov dword ptr [ebp-4],ecx
//对于__thiscall方式调用的类的成员函数,第一个局部变量总是this指针,ebp-4就是
//函数的第一个局部变量的地址
004010DD mov ecx,dword ptr [ebp-4]
//因为要调用基类的构造函数,所以又得把this指针赋给ecx寄存器了;
004010E0 call @ILT+30(Base::Base) (00401023)
//执行基类的构造函数;
004010E5 mov eax,dword ptr [ebp-4]
//将this指针放入eax寄存器;
004010E8 mov dword ptr [eax],offset Derive::`vftable' (0042201c)
//将虚函数表的首地址放入this指针所指向的地址,也就是初始化了vptr了;
大家看到了吧,编译器生成一个默认的构造函数,就是用来初始化vptr的;那么你大概也能想到其实Base的构造函数做了什么了,不出你所料,它也是用来做初始化vptr的:
0040D769 pop ecx
0040D76A mov dword ptr [ebp-4],ecx
0040D76D mov eax,dword ptr [ebp-4]
0040D770 mov dword ptr [eax],offset Base::`vftable' (00422020)
不用再解释了,跟Derive的构造函数功能一样,初始化vptr了。如果你自己声明和定义了一个构造函数的话,将先执行这些初始化vptr的代码后,再会来执行你的代码了。(如果你在构造函数中有作为构造函数的初始化列表形式出现的赋值代码,那么将先执行你的初始化列表中的赋值代码,然后再执行本类的vptr的初始化操作,再执行构造函数体内的代码)
@_@2和@_@3:
00401048 mov eax,dword ptr [ebp+8]
0040104B mov ecx,dword ptr [eax]
这里前一条指令是将obj的地址存放入eax中,那么你该知道obj地址对应的内存单元的前四个字节其实就是vptr地址?而vptr地址所对应的内存单元的内容其实就是vftable表格的起始地址,而vftable表格地址所对应的内存单元的内容就是虚函数地址。用下图更清楚地表示一下吧(如图4,该图表示地址和地址单元中的内容对应表。注意,右边的vftable表中的地址,其实并不是真正的函数地址,而是跳转到函数的jmp指令的地址,如0x0040EF12,并不是真正的Class::XXX函数的地址,而是跳转到Class::XXX函数的jmp指令的地址)。这样ecx其实就是存放Derive::Output函数地址的内存单元的地址,然后调用:
0040104F mov edx,dword ptr [ebp+8]
00401052 push edx
00401053 call dword ptr [ecx]
就跳转到相应函数执行该函数了。
(如果有多个虚函数,且调用的是第N个虚函数,那么上句call指令就会被更改为这样的形式:call dword ptr [ecx+4*(N-1)])
上面的汇编是不是象这样:我拿到一把钥匙,打开一个抽屉,取出里面的东西,不过这个东西还是一把钥匙,还得拿着这个钥匙去打开另一个抽屉,取出里面真正的东西。^_^
(图4)
知道了来龙去脉,别人这么调用用汇编能做到调用相应的虚函数,那么我如果要用C/C++,该怎么做呢?我想你应该有眉目了吧。看看我是怎么干的(下面用一个C的函数指针调用了一个C++类的成员函数,将一个C++类的成员函数转换到一个C函数,需要做这些:C函数的参数个数比相应的C++类的成员函数多出一个,而且作为第一个参数,而且它必须是类对象的地址):
将Base类的Output函数声明为virtual,然后将main函数更改为:
int __cdecl main(int argc, char* argv[]) {
Derive obj; //对象还是要有一个的
typedef void (__stdcall *PFUNC)(void*); //声明函数指针
void *pThis = &obj; //取对象地址,作为this指针用
//对应图4是将0x0012ff24赋给pThis
PFUNC pFunc = (PFUNC)*(unsigned int*)pThis; //取这个地址的内容,对应图4就应
//该是取地址0x0012ff24的内容为
//0x00400112了
pFunc = (PFUNC)*(unsigned int*)pFunc; //再取这个地址的内容,对应图4就
//应该是取地址0x00400112的内容为
//0x0040EF12,也就是函数地址了
pFunc(pThis); //执行函数,将执行Derive::Output
return 0;
}
运行一下,看看结果。我可没有使用对象或者指向类的指针去调用函数哦。J
这回你该知道虚函数是怎么回事了吧?这里介绍的都是基于微软VC++ 6.0编译器对虚函数的实现手段。编译器实现C++所使用的方法和策略,都是可以从其反汇编语句中一探究竟的。了解这些底层细节,将会对提高你的C/C++代码大有裨益!希望本文能对你有所帮助。任何问题或者指教,请mailto:tigger_211@sina.com。