C++虛函數(shù)調(diào)用的反匯編解析
作者:阮建輝 虛函數(shù)的調(diào)用如何能實(shí)現(xiàn)其“虛”?作為C++多態(tài)的表現(xiàn)手段,估計很多人對其實(shí)現(xiàn)機(jī)制感興趣。大約一般的教科書就說到這個C++強(qiáng)大機(jī)制的時候,就是教大家怎么用,何時用,而不會去探究一下這個虛函數(shù)的真正實(shí)現(xiàn)細(xì)節(jié)。(當(dāng)然,因?yàn)椴煌木幾g器廠家,可能對虛函數(shù)有自己的實(shí)現(xiàn),呵呵,這就算是虛函數(shù)對于編譯器的“多態(tài)”了:)。 作為編譯型語言,C++編譯的最后結(jié)果就是一堆匯編指令了(這里不同于.NET的CLR)。今天,我就來揭開它的神秘面紗,從匯編的層面來看看虛函數(shù)到底怎么實(shí)現(xiàn)的。讓大家對虛函數(shù)的實(shí)現(xiàn)不僅知其然,更知其所以然。(本文程序環(huán)境為:PC + Windows XP Pro + Visual C++6.0,文中所得出來的結(jié)果和反映的編譯器策略也只針對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: } 程序的運(yùn)行結(jié)果將是: Class Base 那么將Base類的Output函數(shù)聲明(Line05)更改為: virtual void __stdcall Output() { 那么,很明顯地,程序的運(yùn)行結(jié)果將是: Class Derive Test函數(shù)這回算是認(rèn)清楚了這個指針是一個指向Derive類對象的指針,并且正確的調(diào)用了其Output函數(shù)。編譯器如何做到這一切的呢?我們來看看沒有“virtual”關(guān)鍵字和有“virtual”關(guān)鍵字,其最終的匯編代碼區(qū)別在那里。 (在講解下面的匯編代碼前,讓我們對匯編來一個簡單掃描。當(dāng)然,如果你對匯編已經(jīng)很熟練,那么goto到括號外面吧^_^。先說說上面的Output函數(shù)被聲明為__stdcall的調(diào)用方式:它表示函數(shù)調(diào)用時,參數(shù)從右到左進(jìn)行壓棧,函數(shù)調(diào)用完后由被調(diào)用者恢復(fù)堆棧指針esp。其它的調(diào)用方式在文中描述。所謂的C++的this指針:也就是一個對象的初始地址。在函數(shù)執(zhí)行時,它的參數(shù)以及函數(shù)內(nèi)的變量將擁有如下所示的堆棧結(jié)構(gòu): (圖1) 如上圖1所示,我們的參數(shù)和局部變量在匯編中都將以ebp加或者減多少來表示。你可能會有疑問了:有時候我的參數(shù)或者局部變量可能是一個很大的結(jié)構(gòu)體或者只是一個char,為什么這里ebp加減的都是4的倍數(shù)呢?恩,是這樣的,對于32位機(jī)器來說,采用4個字節(jié),也就是每次傳輸32位,能夠取得最佳的總線效率。如果你的參數(shù)或者局部變量比4個字節(jié)大,就會被拆成每次傳4個字節(jié);如果比4個字節(jié)小,那還是每次傳4個字節(jié)。再簡單解釋一下下面用到的匯編指令,這些指令都是見名知意的哦: ①mov destination,source 將source的值賦給destination。注意,下面經(jīng)常用到了“[xxx]”這樣的形式,“xxx”對應(yīng)某個寄存器加減某個數(shù),“[xxx]”表示是取“xxx”的值對應(yīng)的內(nèi)存單元的內(nèi)容。好比“xxx”是一把鑰匙,去打開一個抽屜,然后將抽屜里的東西取出來給別人,或者是把別人給的東西放到這個抽屜里; ②lea destination,[source] 將source的值賦給destination。注意,這個指令就是把source給destination,而不會去把source對應(yīng)的內(nèi)存單元的內(nèi)容賦給destination。好比是它就把鑰匙給別人了; 在調(diào)試時如果想查看反匯編的話,你應(yīng)該點(diǎn)擊圖2下排最右的按鈕。
其它指令我估計你從它的名字都能知道它是干什么的了,如果想知道其具體意思,這個應(yīng)該參考匯編手冊。:) 一. 沒有virtual關(guān)鍵字時: (1) main函數(shù)的反匯編內(nèi)容: Line22: Derive obj; Line23: Test(&obj); //如果你把斷點(diǎn)設(shè)置在22行,開始調(diào)試的時候VC會告訴你這是一個無效行,而把斷 //點(diǎn)自動移到下一行(Line23),這是因?yàn)榇a中沒有為Derive以及其基類定義構(gòu)造函 //數(shù),而且編譯器也沒有為它生成一個默認(rèn)的構(gòu)造函數(shù)的緣故,此行C++代碼不會生成 //任何可實(shí)際調(diào)用的匯編指令; 004010D8 lea eax,[ebp-4]//將對象obj的地址放入eax寄存器中; 004010DB push eax//將參數(shù)入棧; 004010DC call @ILT+5(Test) (0040100a) //調(diào)用Test函數(shù); //這里@ILT+5就是跳轉(zhuǎn)到Test函數(shù)的的jmp指令的地址,一個模塊中所有的 //函數(shù)調(diào)用都會是象這樣@ILT+5*n,n表示這個模塊中的第n個函數(shù),而ILT的意思 //是Import Lookup Table,程序調(diào)用函數(shù)的時候就是通過這個表來跳轉(zhuǎn)到相應(yīng)函數(shù)而執(zhí) //行代碼的。 004010E1 add esp,4 //調(diào)整堆棧指針,剛才調(diào)用了Test函數(shù),調(diào)用方式__cdecl, 由調(diào)用者來恢復(fù)堆棧指針; (2) Test函數(shù)的反匯編內(nèi)容: Line18: p->Output(); 00401048 mov eax,dword ptr [ebp+8] //這里的[ebp+8]其實(shí)就是Test函數(shù)最左邊的參數(shù),就是上面main函數(shù)中壓棧的eax; //將參數(shù)的值(也就是上面的main函數(shù)中的obj對象的地址)放入eax寄存器中。 //注意:對于C++類的成員函數(shù),默認(rèn)的調(diào)用方式為“__thiscall”,這不是一個由程 //序員指定的關(guān)鍵字,它所表示的的函數(shù)調(diào)用,參數(shù)壓棧從右向左,而且使用ecx寄存 //器來保存this指針。這里我們的Output函數(shù)的調(diào)用方式為“__stdcall”,ecx寄存器 //并不被使用來保存this指針,所以得有額外的指令將this指針壓棧,如下句: 0040104B push eax//將eax入棧,也就是下面調(diào)用Output函數(shù)需要的this指針了; 0040104C call @ILT+0(Base::Output) (00401005) //調(diào)用類的成員函數(shù),沒有任何懸念,老老實(shí)實(shí)地調(diào)用Base類的Output函數(shù); 二. 有virtual關(guān)鍵字時: (1) main函數(shù)的反匯編內(nèi)容: Line22: Derive obj; //在有virtual關(guān)鍵字的時候,把斷點(diǎn)設(shè)置在22行,調(diào)試時就會停在此處了。我們沒有 //為Derive類或者它的基類聲明構(gòu)造函數(shù),這說明編譯器自動為類生成了一個構(gòu)造函 //數(shù),下面我們就可以看看編譯器自動生成的這個構(gòu)造函數(shù)干了什么; 00401088 lea ecx,[ebp-4]//將對象obj的地址放入ecx寄存器中,為什么呢?上面說了哦~ 0040108B call @ILT+25(Derive::Derive) (0040101e) //編譯器幫忙生成了一個構(gòu)造函數(shù),它在這里干了什么呢?等會再說吧,作個記號先://@_@1;上面要把obj的地址放入ecx中就是為這個函數(shù)調(diào)用做準(zhǔn)備的; Line23: Test(&obj); //這個調(diào)用操作跟上面的沒有virtual關(guān)鍵字時是一樣的: 00401090 lea eax,[ebp-4]00401093 push eax00401094 call @ILT+5(Test) (0040100a) 004010C9 add esp,4(2) Test函數(shù)的反匯編內(nèi)容(跟上面的沒有virtual關(guān)鍵字時可是大不一樣哦): Line18: p->Output(); 00401048 mov eax,dword ptr [ebp+8] //將Test的第一個參數(shù)的值放入eax寄存器中,其實(shí)你應(yīng)該已經(jīng)知道了,這就是obj的//地址了; 0040104B mov ecx,dword ptr [eax] //喔噢,將eax寄存器中存的數(shù)對應(yīng)的地址的內(nèi)容取出來,你知道這是什么嗎?等會再//說,做個記號先: @_@2 0040104D mov esi,esp //這個是用來做esp指針檢測的 0040104F mov edx,dword ptr [ebp+8] //又把obj的地址存放到edx寄存器中,你該知道,其實(shí)就是this指針,而這個就是為 //調(diào)用類的成員函數(shù)做準(zhǔn)備的; 00401052 push edx //將對象指針(也就是this指針)入棧,為調(diào)用類的成員函數(shù)做準(zhǔn)備; 00401053 call dword ptr [ecx] //這個調(diào)用的就是類的成員函數(shù),你知道調(diào)用的哪個函數(shù)嗎?等會再說,做個記號先: //@_@3 00401055 cmp esi,esp //比較esp指針的,要是不相同,下面的__chkesp函數(shù)將會讓程序進(jìn)入debug 00401057 call __chkesp (00401110) //檢測esp指針,處理可能出現(xiàn)的堆棧錯誤(如果出錯,將陷入debug)。 對一個C++類,如果它要呈現(xiàn)多態(tài)(一般的編譯器會將這個類以及它的基類中是否存在virtual關(guān)鍵字作為這個類是否要多態(tài)),那么類會有一個virtual table,而每一個實(shí)例(對象)都會有一個virtual pointer(以下簡稱vptr)指向該類的virtual function table,如圖3所示: (下面右邊表格中的VFuncAddr應(yīng)該被理解為存放虛函數(shù)地址的內(nèi)存單元的地址才準(zhǔn)確。更準(zhǔn)確地說,應(yīng)該是跳轉(zhuǎn)到相應(yīng)函數(shù)的jmp指令的地址。) (圖3) 先來分析我們的main函數(shù)中的Derive類的對象obj,看看它的內(nèi)存布局,由于沒有數(shù)據(jù)成員,它的大小為4個字節(jié),只有一個vptr,所以obj的地址也就是vptr的地址了。(之所以我這里舉例的類沒有數(shù)據(jù)成員,因?yàn)椴煌木幾g器將vptr放置的位置在對象內(nèi)存布局中有可能不一樣,當(dāng)然,一般不是放在對象的頭部,比如微軟編譯器;就是放在對象的尾部。不管哪種情況,對于這個例子,我這里的“obj的地址也就是vptr的地址”都是成立的。) 一個對象的vptr并不由程序員指定,而是由編譯器在編譯中指定好了的。那么現(xiàn)在讓我來分別解釋上文中標(biāo)記的@_@1 - @_@ 3。 @_@1: 也就是要解釋這里為什么編譯器會為我們生成一個默認(rèn)的構(gòu)造函數(shù),它是用來干什么的?還是讓我們從反匯編里尋找答案: 這是由編譯器默認(rèn)生成的Derive的構(gòu)造函數(shù)中選取出來的核心匯編片段: 004010D9 pop ecx //編譯器默認(rèn)生成的Derive的構(gòu)造函數(shù)的調(diào)用方式為__thiscall,所以ecx寄存器,如前 //所說,保存的就是this指針,也就是obj對象的地址,在這里也是vptr的地址了; //我發(fā)現(xiàn)即使你把一個構(gòu)造函數(shù)聲明為__stdcall,它跟默認(rèn)的__thiscall的反匯編也是一 //樣的,這一點(diǎn)跟成員函數(shù)是不一樣的; 004010DA mov dword ptr [ebp-4],ecx //對于__thiscall方式調(diào)用的類的成員函數(shù),第一個局部變量總是this指針,ebp-4就是 //函數(shù)的第一個局部變量的地址 004010DD mov ecx,dword ptr [ebp-4] //因?yàn)橐{(diào)用基類的構(gòu)造函數(shù),所以又得把this指針賦給ecx寄存器了; 004010E0 call @ILT+30(Base::Base) (00401023) //執(zhí)行基類的構(gòu)造函數(shù); 004010E5 mov eax,dword ptr [ebp-4] //將this指針放入eax寄存器; 004010E8 mov dword ptr [eax],offset Derive::`vftable' (0042201c) //將虛函數(shù)表的首地址放入this指針?biāo)赶虻牡刂?,也就是初始化?/span>vptr了; 大家看到了吧,編譯器生成一個默認(rèn)的構(gòu)造函數(shù),就是用來初始化vptr的;那么你大概也能想到其實(shí)Base的構(gòu)造函數(shù)做了什么了,不出你所料,它也是用來做初始化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的構(gòu)造函數(shù)功能一樣,初始化vptr了。如果你自己聲明和定義了一個構(gòu)造函數(shù)的話,將先執(zhí)行這些初始化vptr的代碼后,再會來執(zhí)行你的代碼了。(如果你在構(gòu)造函數(shù)中有作為構(gòu)造函數(shù)的初始化列表形式出現(xiàn)的賦值代碼,那么將先執(zhí)行你的初始化列表中的賦值代碼,然后再執(zhí)行本類的vptr的初始化操作,再執(zhí)行構(gòu)造函數(shù)體內(nèi)的代碼) @_@2 和 @_@ 3: 00401048 mov eax,dword ptr [ebp+8] 0040104B mov ecx,dword ptr [eax] 這里前一條指令是將obj的地址存放入eax中,那么你該知道obj地址對應(yīng)的內(nèi)存單元的前四個字節(jié)其實(shí)就是vptr地址?而vptr地址所對應(yīng)的內(nèi)存單元的內(nèi)容其實(shí)就是vftable表格的起始地址,而vftable表格地址所對應(yīng)的內(nèi)存單元的內(nèi)容就是虛函數(shù)地址。用下圖更清楚地表示一下吧(如圖4,該圖表示地址和地址單元中的內(nèi)容對應(yīng)表。注意,右邊的vftable表中的地址,其實(shí)并不是真正的函數(shù)地址,而是跳轉(zhuǎn)到函數(shù)的jmp指令的地址,如0x0040EF12,并不是真正的Class::XXX函數(shù)的地址,而是跳轉(zhuǎn)到Class::XXX函數(shù)的jmp指令的地址)。這樣ecx其實(shí)就是存放Derive::Output函數(shù)地址的內(nèi)存單元的地址,然后調(diào)用: 0040104F mov edx,dword ptr [ebp+8] 00401052 push edx 00401053 call dword ptr [ecx] 就跳轉(zhuǎn)到相應(yīng)函數(shù)執(zhí)行該函數(shù)了。 (如果有多個虛函數(shù),且調(diào)用的是第N個虛函數(shù),那么上句call指令就會被更改為這樣的形式:call dword ptr [ecx+4*(N-1)]) 上面的匯編是不是象這樣:我拿到一把鑰匙,打開一個抽屜,取出里面的東西,不過這個東西還是一把鑰匙,還得拿著這個鑰匙去打開另一個抽屜,取出里面真正的東西。^_^
(圖4) 知道了來龍去脈,別人這么調(diào)用用匯編能做到調(diào)用相應(yīng)的虛函數(shù),那么我如果要用C/C++,該怎么做呢?我想你應(yīng)該有眉目了吧。看看我是怎么干的(下面用一個C的函數(shù)指針調(diào)用了一個C++類的成員函數(shù),將一個C++類的成員函數(shù)轉(zhuǎn)換到一個C函數(shù),需要做這些:C函數(shù)的參數(shù)個數(shù)比相應(yīng)的C++類的成員函數(shù)多出一個,而且作為第一個參數(shù),而且它必須是類對象的地址): 將Base類的Output函數(shù)聲明為virtual,然后將main函數(shù)更改為: int __cdecl main(int argc, char* argv[]) { Derive obj; //對象還是要有一個的 typedef void (__stdcall *PFUNC)(void*); //聲明函數(shù)指針 void *pThis = &obj; //取對象地址,作為this指針用 //對應(yīng)圖4是將0x0012ff24賦給pThis PFUNC pFunc = (PFUNC)*(unsigned int*)pThis; //取這個地址的內(nèi)容,對應(yīng)圖4就應(yīng) //該是取地址0x0012ff24的內(nèi)容為 //0x00400112了 pFunc = (PFUNC)*(unsigned int*)pFunc; //再取這個地址的內(nèi)容,對應(yīng)圖4就 //應(yīng)該是取地址0x00400112的內(nèi)容為 //0x0040EF12,也就是函數(shù)地址了 pFunc(pThis); //執(zhí)行函數(shù),將執(zhí)行Derive::Output return 0; } 運(yùn)行一下,看看結(jié)果。我可沒有使用對象或者指向類的指針去調(diào)用函數(shù)哦。J 這回你該知道虛函數(shù)是怎么回事了吧?這里介紹的都是基于微軟VC++ 6.0編譯器對虛函數(shù)的實(shí)現(xiàn)手段。編譯器實(shí)現(xiàn)C++所使用的方法和策略,都是可以從其反匯編語句中一探究竟的。了解這些底層細(xì)節(jié),將會對提高你的C/C++代碼大有裨益!希望本文能對你有所幫助。任何問題或者指教,請mailto:tigger_211@sina.com。 。 |
|