C++虛函數表與對象佈局(轉)講的很透徹

每個含有虛函數的類有一張虛函數表(vtbl),表中每一項指向一個虛函數的地址,實現上是一個函數指針的數組。

虛函數表既有繼承性又有多態性。每個派生類的vtbl繼承了它各個基類的vtbl,如果基類vtbl中包含某一項,則其派生類的vtbl中也將包含同樣的一項,但是兩項的值可能不同。如果派生類重載(override)了該項對應的虛函數,則派生類vtbl的該項指向重載後的虛函數,沒有重載的話,則沿用基類的值。

在類對象的內存佈局中,首先是該類的vtbl指針,然後纔是對象數據。在通過對象指針調用一個虛函數時,編譯器生成的代碼將先獲取對象類的vtbl指針,然後調用vtbl中對應的項。對於通過對象指針調用的情況,在編譯期間無法確定指針指向的是基類對象還是派生類對象,或者是哪個派生類的對象。但是在運行期間執行到調用語句時,這一點已經確定,編譯後的調用代碼能夠根據具體對象獲取正確的vtbl,調用正確的虛函數,從而實現多態性。分析一下這裏的思想所在,問題的實質是這樣,對於發出虛函數調用的這個對象指針,在編譯期間缺乏更多的信息,而在運行期間具備足夠的信息,但那時已不再進行綁定了,怎麼在二者之間作一個過渡呢?把綁定所需的信息用一種通用的數據結構記錄下來,該數據結構可以同對象指針相聯繫,在編譯時只需要使用這個數據結構進行抽象的綁定,而在運行期間將會得到真正的綁定。這個數據結構就是vtbl。可以看到,實現用戶所需的抽象和多態需要進行後綁定,而編譯器又是通過抽象和多態而實現後綁定的。

下面說一下多重繼承。多重繼承的兩個基類如果繼承了同一個類,則其派生類相當於繼承了該類兩次,vtbl也繼承了兩次。對象佈局中,該類的數據有兩份,vtbl指針有兩個,分別指向兩次被繼承的vtbl。但派生類重載該類的虛函數時只能重載一次,那麼重載後的函數地址將佔據vtbl的哪個位置?通過寫程序測試,我覺得應該是同時出現在所繼承的兩個vtbl的相應位置,有待進一步驗證。

說到虛函數機制,對象指針的類型轉換也是要弄清的,這裏就不說了。還有一個this指針的問題,提一下。虛函數調用的時候也是需要傳遞this指針的,這沒什麼奇怪,但是這時的this指針就隱含着一個問題,它要和實際調用的虛函數相一致,即this指針也要實現多態性。在多重繼承的情況下,這個問題不是那麼簡單的,請參考[《C++語言的設計和演化》p203]。

------------------------------

虛函數表深度分析- -

                                      

 

昨天聽完彭老師的C++的講座,感覺很不錯,但之後留了一個疑問,就是關於虛函數表的機制,課下和彭老師的討論似乎也沒能

完全解惑,我的疑問主要就是:
1:虛函數表到底是怎麼工作的,for類,還是for對象
2:如果for類,那麼基類和派生類是共用一表,還是各有各的表(物理上)
3:如果共用一表的話,總是後面的覆蓋前面的函數地址,那不是很容易出現混亂嗎?

帶着這三個疑問,趁着熱呼勁,我搜了搜關於虛函數表的DASM的文章,當然了,能搜到的幾篇都是for VC編譯器的
初步得出了以前結論:
1:虛表(虛函數表)是for類的
2:基類和派生類是各有各的表,也就是說他們的物理地址是分開的,基類和派生類的虛表的唯一關聯是:當派生類沒有實現

基類虛函數的重載時,派生類會直接把自己表的該函數地址值寫爲基類的該函數地址值.
3:任何一個有虛表的類,在實例化時不允許其虛表內有項爲空->純虛類不能初始化對象
4:帶虛表的類在對象構造函數中,會把一個指針指向該類虛表地址,我在這給它起個名字叫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));
}
結果是8

我把A類的兩個virtual都去掉後再運行一次
結果是4

這說明了有virtual比沒virtual的對象多了32位,在win32中,32位正好是一個地址,那麼這個地址就應該指向的是虛表

看來虛表果然存在,那麼虛表指針是在對象什麼時候生成的呢?我改一下main函數
int main(int argc, char* argv[])
{
        A *pa;
        B b;
        C c;
        A a;
        pa=&b;
        pa->output();
        getch();
        return 0;
}
這應該是一個很經典的教科書上講多態的例子,如果有virtual輸出Class B,如果沒virtual輸出Class A

現在看一下這段代碼的反編譯代碼,我把BCB6的full debug模式打開,在 B b; 處設斷點
圖片
http://gu.buct.edu.cn/my1.jpg


我們可以看到在b執行完基類的構造函數後,執行了
mov edx,0x0040c114
mov [ebp-0x0x],edx
而這兩句話經驗證,在沒有virtual關鍵字時是沒有的,讓我們記住0x0040c114這個地址先
[ebp-0x0x]是this指針,我們目前猜測這段話就是把虛表的地址寫入this指針

我們再看C c;後的反編譯代碼
mov eax,0x0040c0f8
mov [ebp-0x14],eax
看來不同的類具有不同的虛表地址,也就是不同的類的表從物理上是不同的

我們現在來探討虛表工作的原理
我們對比一下pa->output()在有沒有virtual修飾時候的區別
mov eax,[ebp-0x04]
push eax
mov edx,[eax]
call dword ptr [edx]
這是有virtual的

push dword ptr [ebp-0x04]
call A::output();
這是沒有virtual的

我們分析一下asm代碼,可以得出虛表的過程,先把根據this地址得到虛表地址,然後由虛表項裏存放的函數指針地址,訪問

相應的函數,如果有多個虛函數,且調用的是第N個虛函數,那麼上句call指令就會被更改爲這樣的形式:call dword ptr 

[edx-4*(N-1)])

一上是我們對dasm代碼做的一些推測,一會兒我們還要進一步驗證這些

我們仔細看反編譯的結果,發現在A a;的dasm結果中,好象沒有vp初始化的一步,我查了其他文獻針對VC編譯器的dasm結

果,發現VC編譯器的dasm結果裏是有初始化vp的一步的,類似
004010E8   mov         dword ptr [eax],offset Derive::`vftable' (0042201c)

我現在就得出這樣一個結論,在BC編譯器中很可能對於基類的對象構造函數作出了這樣的優化,就是默認把this指針指向

虛表地址,所以我們看不到這樣的dasm結果

我還發現,對於類的構造函數處理,VC和BC的編譯器也是不一樣的
如果我們在類裏面沒有寫構造函數,VC會自動爲我們加一個構造函數,比如
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)
可以看到自動生成構造函數地址

但在BC中,我們沒有看到這樣的代碼
當我們把上面的A類裏面的構造函數刪去後,這是得到的A a;的dasm
mov edx, 0x0040c0f0
mov [ebp-0x04],ecx
完全找不到構造函數的影子,我猜測這也是編譯器對構造函數所作出的優化

我這裏不評價兩種編譯器在這問題上的優次,我繼續回到正題,驗證我們的結論的正確性
因爲按照我們的推測,0x0040c114就是虛表地址

那麼按照此理,我們通過訪問虛表地址的內容裏的第一個函數地址,就能訪問output函數,而虛表的地址就是this地址,是這樣

嗎,我再編了個main函數

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的函數指針
void *pthis=&b;
用來得到b的this地址,它是指向虛表地址的
PF pf=(PF)(*(unsigned int*)pthis);
用來得到this地址的內容,也就是虛表地址
然後我們把虛表地址輸出
pf=(PF)(*(unsigned int*)pf);
用來得到虛表裏第一項的內容,也就是output的地址(表第一項目地址=表地址)
pf();  調用函數

我們來看結果

成功了!!!
雖然我們沒有在代碼裏寫output();但執行結果就是輸出了output的結果
另外輸出的虛表地址就是0x0040c114,也就是我們最早推測的虛表地址!!!

我把代碼改下一下,按照我們的推測,如果把表第一項地址偏移32位,應該就是表第二項地址,而第二項的內容就應該是

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指向類的虛表地址,如果本類沒

有重載基類的虛函數,那麼虛表的該項會寫爲基類的該項的內容,在調用虛表的時候,會根據虛表地址做適當的偏移以得到

相應的虛函數地址,再進行調用.

先分析到這,以後我會就修改虛表地址,以及如何應用虛表做hook,繼續分析

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章