http://blog.csdn.net/demon__hunter/article/details/5447111
学过c++一段时间都会知道,c++是依靠虚函数实现多态的,如下代码:
#include <iostream> using namespace std; class Base { public: virtual void Print() { cout<<"^-^"<<endl; } }; class Derive:public Base { public: virtual void Print() { cout<<"T-T"<<endl; } }; int main() { Base *p=new Derive(); p->Print(); }
呵呵,输出T-T~~~~~~
对于理解虚函数的实现原理,历来是一个c++新手到中手的必经之路之一,关于其实现原理,个人推荐《深入探索c++对象模型》这本书,
原理讲的很透彻的。现在分析一下一些主流的编译器的具体实现方式,并从汇编的角度来分析编译器的虚函数的实现原理(最近找c/c++工作,估计虚函数被问到可能性很高~~)。
首先c++标准仅仅规定了虚函数的行为,并没有规定这种行为的具体实现,但目前主流的编译器(vc,g++)在实现上达成了一定默契,都是通过在对象前4个字节安插一个虚表指针,
这个虚表指针指向对应类的虚表,在调用虚函数时,通过虚表指针查找虚表最终获得要调用的函数的,这也就是动态绑定的底层实现方式。
以下是vc10默认编译选项debug下上面程序的反汇编:
257: int main() 258: { 01031500 push ebp 01031501 mov ebp,esp 01031503 sub esp,0DCh 01031509 push ebx 0103150A push esi 0103150B push edi 0103150C lea edi,[ebp-0DCh] 01031512 mov ecx,37h 01031517 mov eax,0CCCCCCCCh 0103151C rep stos dword ptr es:[edi];开启堆栈帧(/RTCs)后,就会有类似的汇编,将未 ;初始化的局部变量值初始化为cc,一个int 3指令 ;如果输出未初始化的一个int 变量,值就是-858993460 ;因为数据以补码保存,-858993460补码就是0xcccccccc ;输出未初始化的字符则输出 烫,这就是我们debug时, ;内存里老多 烫烫烫烫烫烫的原因。 259: Base *p=new Derive(); 0103151E push 4 ;operator new函数参数入栈,即要为Derive对象分配四个字节的空间。 01031520 call operator new (1031208h) ;调用operator new函数 01031525 add esp,4 ;__cdecl调用约定,函数调用者,调整栈帧, 01031528 mov dword ptr [ebp-0D4h],eax;将operator new 函数返回结果存入dword ptr[ebp-0D4h] ;这段空间,operator new结果返回一个指针,指向分配的内 ;存的地址,vc中整形或者能隐式转化成整形的返回值放入eax 0103152E cmp dword ptr [ebp-0D4h],0 ;测试返回值是否为0 01031535 je main+4Ah (103154Ah) ;为0则调转 01031537 mov ecx,dword ptr [ebp-0D4h];将operator new 分配的内存地址放入ecx,vc中成员函数 ;调用时this指针存入ecx的。 0103153D call Derive::Derive (1031127h) ;调用构造函数,在构造函数里完成虚表指针的初始化, ;由于没有显时定义默认构造函数,所以编译器负责生成一个 01031542 mov dword ptr [ebp-0DCh],eax ;编译器生成的默认构造函数中,将构造好虚表指针的对象的地 ;址放入了eax,所以这句相当于取对象的地址。编译器生成的 ;默认构造函数代码稍后介绍 01031548 jmp main+54h (1031554h) ;跳过下一条指令的执行。 0103154A mov dword ptr [ebp-0DCh],0;如果走这条指令说明是je main+4Ah (103154Ah) ;跳转过来的,说明内存分配失败,这条指令的作用就是将p值设为0, ;也就是this值设为0,以期望this+偏移访问数据时触发一个异常。 01031554 mov eax,dword ptr [ebp-0DCh] ;如果内存分配没有问题的话,那么dword ptr [ebp-0DCh] ;保存的是对象的地址值。 0103155A mov dword ptr [p],eax ;把对象的地址值赋给dword ptr [p]这段空间,下面的代码就是 ;就是通过虚表指针查找虚表的关键代码了,要说关键点了 260: p->Print(); 0103155D mov eax,dword ptr [p] ;将对象的地址值存入eax,现在eax=p(p指向对象的起始地址) 01031560 mov edx,dword ptr [eax] ;通过eax寻址,对应的操作是从eax对应的地址值开始往高地址涵盖 ;双字,即4个字节,将这4个字节里的数据按照整形方式读出赋给edx ;相当于edx=*(int*)p,前面说了对象的前4个字节是为虚表指针所分 ;配的空间,这句指令相当于获取虚表指针的值。 01031562 mov esi,esp 01031564 mov ecx,dword ptr [p] ;this指针存入ecx 01031567 mov eax,dword ptr [edx];查找虚表的操作,跟上面的分析一样,从edx对应地址值开始,往高 ;地址涵盖4个字节的内存,读出这段内存里的数据,可以知道edx的值 ;即是虚表指针的值,虚表指针指向一个虚表,虚表的地址假设为 ;0x00100000,那么0x00100000~0x00100003是存储第一个虚函数的 ;地址,0x00100004~0x00100007是存储第二个虚函数的地址..... ;这条指令即是获取第一个虚函数的地址,eax=*(int*)*(int*)p ;现在eax值是一个合法函数指针的值了 01031569 call eax ;进行函数调用 0103156B cmp esi,esp 0103156D call @ILT+435(__RTC_CheckEsp) (10311B8h) 261: } 01031572 xor eax,eax 01031574 pop edi 01031575 pop esi 01031576 pop ebx 01031577 add esp,0DCh 0103157D cmp ebp,esp 0103157F call @ILT+435(__RTC_CheckEsp) (10311B8h) 01031584 mov esp,ebp 01031586 pop ebp 01031587 ret
通过上述分析,类似*(int*)*(int*)p这样的表达式来获取虚表中函数方法大家应该明白了吧,这个地方确实是考察指针应用的基本功的。
void(*f)()=(void(*)())*(int*)*(int*)p;
f();
最终调用的是Derive::Print();很显然*(int*)(*(int*)p+4)是虚表中第二个函数地址地址值,如果有的话~~~~~
下面来看下,编译器生成的构造函数里到底做了些什么,
Derive::Derive:
01031127 jmp Derive::Derive (10315B0h)
找到内存10315B0h处的汇编指令:
Derive::Derive: 010315B0 push ebp 010315B1 mov ebp,esp 010315B3 sub esp,0CCh 010315B9 push ebx 010315BA push esi 010315BB push edi 010315BC push ecx 010315BD lea edi,[ebp-0CCh] 010315C3 mov ecx,33h 010315C8 mov eax,0CCCCCCCCh 010315CD rep stos dword ptr es:[edi] 010315CF pop ecx 010315D0 mov dword ptr [ebp-8],ecx;将ecx中保存的this指针值存入dword ptr [ebp-8] 010315D3 mov ecx,dword ptr [this] ;this指针存入ecx,调用成员函数用,单继承下 ;dword ptr [this]和dword ptr [ebp-8]值是一样 010315D6 call Base::Base (1031131h);调用基类构造函数 010315DB mov eax,dword ptr [this] ;dword ptr [this]指向对象已通过Base::Base ;进行了初始化,此时虚表指针指向了父类的虚表 010315DE mov dword ptr [eax],offset Derive::`vftable' (1037834h) ;将dword ptr [this]指向对象的虚表指针修改成 ; Derive::`vftable' ,dword ptr [this]相当于 ;一个对象指针,假设为p,这句指令相当于*(int*)p= ;Derive::`vftable' . 010315E4 mov eax,dword ptr [this] ;将初始化好的对象地址存入eax,相当于设置返回值 010315E7 pop edi 010315E8 pop esi 010315E9 pop ebx 010315EA add esp,0CCh 010315F0 cmp ebp,esp 010315F2 call @ILT+435(__RTC_CheckEsp) (10311B8h) 010315F7 mov esp,ebp 010315F9 pop ebp 010315FA ret看一下Base::Base (1031131h);汇编代码
Base::Base: 01031690 push ebp 01031691 mov ebp,esp 01031693 sub esp,0CCh 01031699 push ebx 0103169A push esi 0103169B push edi 0103169C push ecx 0103169D lea edi,[ebp-0CCh] 010316A3 mov ecx,33h 010316A8 mov eax,0CCCCCCCCh 010316AD rep stos dword ptr es:[edi] 010316AF pop ecx 010316B0 mov dword ptr [ebp-8],ecx 010316B3 mov eax,dword ptr [this] 010316B6 mov dword ptr [eax],offset Base::`vftable' (1037844h) 010316BC mov eax,dword ptr [this] ;和Derive类似,也有一个设置虚表指针的操作 010316BF pop edi 010316C0 pop esi 010316C1 pop ebx 010316C2 mov esp,ebp 010316C4 pop ebp 010316C5 ret
分析到这里,相信大家对虚函数调用有个基本的认识了,编译器在实现虚函数时,主要有以下步骤:
1 编译时,根据类的声明,生成一个虚函数表
2 创建对象时,编译器会在类的构造函数内安插一部分代码,用来初始化对象的虚表指针,一般(vc g++)在进入构造函数
开始部分便安插代码。
3 当以指针或引用来调用虚函数时便激活动态绑定,实质是一个通过虚表指针查找函数的过程
所以类似这样代码Derive(){memset(this,0,sizeof(Derive));}将是灾难性的~~~
由于虚函数的实现要借助构造函数,所以构造函数不能是虚拟函数~~~
最后介绍两个关于c++虚函数的hack的简单程序,以加深编对译器实现虚函数机制的了解~~~~
#include <iostream> #include <vector> using namespace std; class Base { public: virtual void PrintA() { cout<<"^-^"<<endl; } virtual void PrintB() { cout<<"T-T"<<endl; } }; class Derive:public Base { public: virtual void PrintA() { cout<<":)"<<endl; } virtual void PrintB() { cout<<":("<<endl; } }; void Hack1() { cout<<"Hack1"<<endl; } void Hack2() { cout<<"Hack2"<<endl; } int main() { Base *p=new Derive(); int *pVtable[2]={(int*)Hack1,(int*)Hack2};//构造一个虚表 *(int*)p=(int)pVtable;//设置虚表指针 p->PrintA(); p->PrintB(); system("pause"); }
#include <iostream> #include <Windows.h> using namespace std; class Base { public: virtual void PrintA() { cout<<"^-^"<<endl; } virtual void PrintB() { cout<<"T-T"<<endl; } }; class Derive:public Base { public: virtual void PrintA() { cout<<":)"<<endl; } virtual void PrintB() { cout<<":("<<endl; } }; void Hack1() { cout<<"Hack1"<<endl; } int main() { Base *p=new Derive(); int PrintAAdress=*(int*)*(int*)p;//获取PrintA在虚表中的地址值 int PrintBAdress=*(int*)(*(int*)p+4);//获取PrintB在虚表中的地址值 //vc debug下函数指针值和函数名对应的地址开始,存放是一个jmp指令 //对应机器吗是0xe9 if (*(unsigned char*)PrintAAdress==0xe9) { DWORD d; int PrintBOffset=*(int*)(PrintBAdress+1);//获取jmp指令后立即数的值 int Hack1Offset=*(int*)((int)Hack1+1); //jmp 后立即数是相对于本条jmp指令的偏移,这里想把虚表的PrintA地址修 //改成PrintB,所以重新计算偏移 int diff=PrintBOffset-(PrintAAdress-PrintBAdress); WriteProcessMemory(GetCurrentProcess(),(int*)(PrintAAdress+1), &diff, 4, &d); diff=Hack1Offset-(PrintBAdress-(int)Hack1); WriteProcessMemory(GetCurrentProcess(),(int*)(PrintBAdress+1), &diff, 4, &d); //release下函数指针和函数名的值就是函数对应汇编指令的起始地址 }else{ DWORD dwIdOld; HANDLE hProcess=OpenProcess(PROCESS_ALL_ACCESS,1,GetCurrentProcessId()); //把对应的内存页修改成可读写的,debug下权限比较大,所以可以直接读写 VirtualProtectEx(hProcess,(int*)*(int*)p,4,PAGE_READWRITE,&dwIdOld); WriteProcessMemory(hProcess,(int*)*(int*)p, &PrintBAdress, 4, 0); VirtualProtectEx(hProcess,(int*)*(int*)p,4,dwIdOld,&dwIdOld); int Hack1Adress= (int)Hack1; VirtualProtectEx(hProcess,(int*)(*(int*)p+4),4,PAGE_READWRITE,&dwIdOld); WriteProcessMemory(hProcess,(int*)(*(int*)p+4), &Hack1Adress, 4, 0); VirtualProtectEx(hProcess,(int*)(*(int*)p+4),4,dwIdOld,&dwIdOld); } //现在成功改写了虚表,所有Derive对象动态绑定,都会转到PrintB和Hack1上 p->PrintA(); p->PrintB(); }
上面程序就是成功修改了编译器创建的虚表,可以真正算得上一个hack了~~~,上面程序vc9/10+win7 debug/release默认编译选项通过~~~~
如果您看懂上述两个程序,相信您对虚表的编译器实现的认识更加深刻了~~~~
原理就是这个样子了,在多继承情况下,可能麻烦一些,因为对象可能产生多个虚表指针,另外虚析构函数在虚表中布局,各个编译器差异也比较大,
也就是为什么com在实现时要有一个类似release的接口~~~~
好了,先写到这儿吧,有时间再补充~~~~~~~~~~~~~~~~