每個含有虛函數(shù)的類有一張?zhí)摵瘮?shù)表(vtbl),表中每一項指向一個虛函數(shù)的地址,實現(xiàn)上是一個函數(shù)指針的數(shù)組。
虛函數(shù)表既有繼承性又有多態(tài)性。每個派生類的vtbl繼承了它各個基類的vtbl,如果基類vtbl中包含某一項,則其派生類的vtbl中也將包含同樣的一項,但是兩項的值可能不同。如果派生類重載(override)了該項對應的虛函數(shù),則派生類vtbl的該項指向重載后的虛函數(shù),沒有重載的話,則沿用基類的值。
在類對象的內(nèi)存布局中,首先是該類的vtbl指針,然后才是對象數(shù)據(jù)。在通過對象指針調(diào)用一個虛函數(shù)時,編譯器生成的代碼將先獲取對象類的vtbl指針,然后調(diào)用vtbl中對應的項。對于通過對象指針調(diào)用的情況,在編譯期間無法確定指針指向的是基類對象還是派生類對象,或者是哪個派生類的對象。但是在運行期間執(zhí)行到調(diào)用語句時,這一點已經(jīng)確定,編譯后的調(diào)用代碼能夠根據(jù)具體對象獲取正確的vtbl,調(diào)用正確的虛函數(shù),從而實現(xiàn)多態(tài)性。分析一下這里的思想所在,問題的實質(zhì)是這樣,對于發(fā)出虛函數(shù)調(diào)用的這個對象指針,在編譯期間缺乏更多的信息,而在運行期間具備足夠的信息,但那時已不再進行綁定了,怎么在二者之間作一個過渡呢?把綁定所需的信息用一種通用的數(shù)據(jù)結(jié)構記錄下來,該數(shù)據(jù)結(jié)構可以同對象指針相聯(lián)系,在編譯時只需要使用這個數(shù)據(jù)結(jié)構進行抽象的綁定,而在運行期間將會得到真正的綁定。這個數(shù)據(jù)結(jié)構就是vtbl。可以看到,實現(xiàn)用戶所需的抽象和多態(tài)需要進行后綁定,而編譯器又是通過抽象和多態(tài)而實現(xiàn)后綁定的。
下面說一下多重繼承。多重繼承的兩個基類如果繼承了同一個類,則其派生類相當于繼承了該類兩次,vtbl也繼承了兩次。對象布局中,該類的數(shù)據(jù)有兩份,vtbl指針有兩個,分別指向兩次被繼承的vtbl。但派生類重載該類的虛函數(shù)時只能重載一次,那么重載后的函數(shù)地址將占據(jù)vtbl的哪個位置?通過寫程序測試,我覺得應該是同時出現(xiàn)在所繼承的兩個vtbl的相應位置,有待進一步驗證。
說到虛函數(shù)機制,對象指針的類型轉(zhuǎn)換也是要弄清的,這里就不說了。還有一個this指針的問題,提一下。虛函數(shù)調(diào)用的時候也是需要傳遞this指針的,這沒什么奇怪,但是這時的this指針就隱含著一個問題,它要和實際調(diào)用的虛函數(shù)相一致,即this指針也要實現(xiàn)多態(tài)性。在多重繼承的情況下,這個問題不是那么簡單的,請參考[《C++語言的設計和演化》p203]。
C++虛函數(shù)表深度分析
昨天聽完彭老師的C++的講座,感覺很不錯,但之后留了一個疑問,就是關于虛函數(shù)表的機制,課下和彭老師的討論似乎也沒能完全解惑,我的疑問主要就是:
1:虛函數(shù)表到底是怎么工作的,for類,還是for對象
2:如果for類,那么基類和派生類是共用一表,還是各有各的表(物理上)
3:如果共用一表的話,總是后面的覆蓋前面的函數(shù)地址,那不是很容易出現(xiàn)混亂嗎?
帶著這三個疑問,趁著熱呼勁,我搜了搜關于虛函數(shù)表的DASM的文章,當然了,能搜到的幾篇都是for VC編譯器的
初步得出了以前結(jié)論:
1:虛表(虛函數(shù)表)是for類的
2:基類和派生類是各有各的表,也就是說他們的物理地址是分開的,基類和派生類的虛表的唯一關聯(lián)是:當派生類沒有實現(xiàn)
基類虛函數(shù)的重載時,派生類會直接把自己表的該函數(shù)地址值寫為基類的該函數(shù)地址值.
3:任何一個有虛表的類,在實例化時不允許其虛表內(nèi)有項為空->純虛類不能初始化對象
4:帶虛表的類在對象構造函數(shù)中,會把一個指針指向該類虛表地址,我在這給它起個名字叫vp;
5:僅對于VC和BC兩種編譯器論,如果該類帶有虛表,那么該類的對象的首地址就是虛表地址,也是this指針指向虛表
下面我就用IDE Borland C++ Builder 6.0 sp4,編譯器版本Borland C++ 5.5,來驗證一下:
首先打開BCB6建立一個控制臺程序,寫上下面幾個備用類
#include <conio.h>
#include <stdio.h>
#pragma hdrstop
#pragma argsused
class A
{
public:
__stdcall A()
{
}
virtual void __stdcall output()
{
printf("Class An");
}
virtual void __stdcall output2()
{
}
};
class B :public A
{
public:
void __stdcall output()
{
printf("Class Bn");
}
};
class C:public A
{
public :
void __stdcall output()
{
printf("Class Cn");
}
};
幾個類很簡單,B和C是A的派生
下面先寫一個引子主程序,用來驗證虛表的存在:
int main(int argc, char* argv[])
{
B b;
printf("%d",sizeof(b));
}
結(jié)果是8
我把A類的兩個virtual都去掉后再運行一次
結(jié)果是4
這說明了有virtual比沒virtual的對象多了32位,在win32中,32位正好是一個地址,那么這個地址就應該指向的是虛表
看來虛表果然存在,那么虛表指針是在對象什么時候生成的呢?我改一下main函數(shù)
int main(int argc, char* argv[])
{
A *pa;
B b;
C c;
A a;
pa=&b;
pa->output();
getch();
return 0;
}
這應該是一個很經(jīng)典的教科書上講多態(tài)的例子,如果有virtual輸出Class B,如果沒virtual輸出Class A
現(xiàn)在看一下這段代碼的反編譯代碼,我把BCB6的full debug模式打開,在 B b; 處設斷點
圖片
我們可以看到在b執(zhí)行完基類的構造函數(shù)后,執(zhí)行了
mov edx,0x0040c114
mov [ebp-0x0x],edx
而這兩句話經(jīng)驗證,在沒有virtual關鍵字時是沒有的,讓我們記住0x0040c114這個地址先
[ebp-0x0x]是this指針,我們目前猜測這段話就是把虛表的地址寫入this指針
我們再看C c;后的反編譯代碼
mov eax,0x0040c0f8
mov [ebp-0x14],eax
看來不同的類具有不同的虛表地址,也就是不同的類的表從物理上是不同的
我們現(xiàn)在來探討虛表工作的原理
我們對比一下pa->output()在有沒有virtual修飾時候的區(qū)別
mov eax,[ebp-0x04]
push eax
mov edx,[eax]
call dword ptr [edx]
這是有virtual的
push dword ptr [ebp-0x04]
call A::output();
這是沒有virtual的
我們分析一下asm代碼,可以得出虛表的過程,先把根據(jù)this地址得到虛表地址,然后由虛表項里存放的函數(shù)指針地址,訪問
相應的函數(shù),如果有多個虛函數(shù),且調(diào)用的是第N個虛函數(shù),那么上句call指令就會被更改為這樣的形式:call dword ptr
[edx-4*(N-1)])
一上是我們對dasm代碼做的一些推測,一會兒我們還要進一步驗證這些
我們仔細看反編譯的結(jié)果,發(fā)現(xiàn)在A a;的dasm結(jié)果中,好象沒有vp初始化的一步,我查了其他文獻針對VC編譯器的dasm結(jié)
果,發(fā)現(xiàn)VC編譯器的dasm結(jié)果里是有初始化vp的一步的,類似
004010E8 mov dword ptr [eax],offset Derive::`vftable' (0042201c)
我現(xiàn)在就得出這樣一個結(jié)論,在BC編譯器中很可能對于基類的對象構造函數(shù)作出了這樣的優(yōu)化,就是默認把this指針指向
虛表地址,所以我們看不到這樣的dasm結(jié)果
我還發(fā)現(xiàn),對于類的構造函數(shù)處理,VC和BC的編譯器也是不一樣的
如果我們在類里面沒有寫構造函數(shù),VC會自動為我們加一個構造函數(shù),比如
class Base {
public:
void __stdcall Output() {
printf("Class Basen");
}
};
我們得到這樣的dasm:
004010D9 pop ecx
004010DA mov dword ptr [ebp-4],ecx
004010DD mov ecx,dword ptr [ebp-4]
004010E0 call @ILT+30(Base::Base) (00401023)
可以看到自動生成構造函數(shù)地址
但在BC中,我們沒有看到這樣的代碼
當我們把上面的A類里面的構造函數(shù)刪去后,這是得到的A a;的dasm
mov edx, 0x0040c0f0
mov [ebp-0x04],ecx
完全找不到構造函數(shù)的影子,我猜測這也是編譯器對構造函數(shù)所作出的優(yōu)化
我這里不評價兩種編譯器在這問題上的優(yōu)次,我繼續(xù)回到正題,驗證我們的結(jié)論的正確性
因為按照我們的推測,0x0040c114就是虛表地址
那么按照此理,我們通過訪問虛表地址的內(nèi)容里的第一個函數(shù)地址,就能訪問output函數(shù),而虛表的地址就是this地址,是這樣
嗎,我再編了個main函數(shù)
int main(int argc, char* argv[])
{
A *pa;
B b;
C c;
A a;
//pa=&b;
//pa->output();
//printf("%d",sizeof(b));
typedef void (__stdcall *PF)(void);
void *pthis=&b;
PF pf=(PF)(*(unsigned int*)pthis);
printf("%x",pf);
printf("n");
pf=(PF)(*(unsigned int*)pf);
pf();
getch();
return 0;
}
先來解釋一下這段代碼
typedef void (__stdcall *PF)(void);
聲明了配搭output的函數(shù)指針
void *pthis=&b;
用來得到b的this地址,它是指向虛表地址的
PF pf=(PF)(*(unsigned int*)pthis);
用來得到this地址的內(nèi)容,也就是虛表地址
然后我們把虛表地址輸出
pf=(PF)(*(unsigned int*)pf);
用來得到虛表里第一項的內(nèi)容,也就是output的地址(表第一項目地址=表地址)
pf(); 調(diào)用函數(shù)
我們來看結(jié)果
成功了!!!
雖然我們沒有在代碼里寫output();但執(zhí)行結(jié)果就是輸出了output的結(jié)果
另外輸出的虛表地址就是0x0040c114,也就是我們最早推測的虛表地址!!!
我把代碼改下一下,按照我們的推測,如果把表第一項地址偏移32位,應該就是表第二項地址,而第二項的內(nèi)容就應該是
output2的地址,驗證一下:
typedef void (__stdcall *PF)(void);
void *pthis=&b;
PF pf=(PF)(*(unsigned int*)pthis);
printf("%x",pf);
printf("n");
pf=(PF)(*( (unsigned int*)pf-0x04 ) );
pf();
完全不出我們所料,輸出就是Class A output2
到這里,應該對虛表的機制很清楚了,每個類都有各的虛表,每個類生成的各對象分別把this指向類的虛表地址,如果本類沒
有重載基類的虛函數(shù),那么虛表的該項會寫為基類的該項的內(nèi)容,在調(diào)用虛表的時候,會根據(jù)虛表地址做適當?shù)钠埔缘玫?
相應的虛函數(shù)地址,再進行調(diào)用.
先分析到這,以后我會就修改虛表地址,以及如何應用虛表做hook,繼續(xù)分析