程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> C++入門知識 >> 對象布局已知時 C++ 對象指針的轉換時地址調整,布局指針

對象布局已知時 C++ 對象指針的轉換時地址調整,布局指針

編輯:C++入門知識

對象布局已知時 C++ 對象指針的轉換時地址調整,布局指針


  在我調試和研究 netscape 系浏覽器插件開發時,注意到了這個問題。即,在對象布局已知(即對象之間具有繼承關系)時,不同類型對象的指針進行轉換(不管是隱式的從下向上轉換,還是強制的從上到下轉換)時,編譯器會根據對象布局對相應的指針的值進行調整。不管是 microsoft 的編譯器,還是 gcc 編譯器都會做這個動作,因為這和 C++ 對象模型有關。

 

  舉一個簡單的例子,如下代碼:

 

#include <stdio.h>
class A
{
public:
    int x;
    void foo1() { printf("A:foo1 \n"); };
};

class B : public A
{
public:
    double y;
    virtual void foo2() { printf("B:foo2 \n"); };
};

int _tmain(int argc, _TCHAR* argv[])
{
    B* pb = (B*)0x00480010;
    A* pa = pb;
    printf(" pb:%p\n pa:%p\n", pb, pa);
    getchar();
    return 0;
}

 

  上面的代碼內容為,B 繼承於 A,A 沒有虛函數,B 有虛函數。因此A對象的起始位置,不包含虛函數表指針。而 B 對象的起始位置,包含虛函數表指針。在 VC 2005 中,會輸出:

 

  pb:00480010
  pa:00480018

  可以看到兩個地址之間的差值為 8 bytes。兩個對象的地址並不相等,是因為虛函數表指針的關系。虛函數表指針通常占 4 Bytes。而輸出結果中這個差值和對象布局有關,即也和編譯器的選項中,對象的對齊的設置相關。但總之,這兩個地址存在一個編譯時確定的差值。在不同的條件下,這個差值也可能是 4 bytes。例如如果 B 對象的成員 y 改為 int 類型。這個差值就為 4 bytes。

 

  在上面的 demo 中,指針類型從 B* 隱式轉換到了 A*,地址值增加了 8 Bytes。如果指針類型從 A* 強制轉換到 B*,這個地址也會進行相反的調整。觀察匯編代碼可以看到,這個地址值的偏移調整是編譯器在編譯時插入的操作,由 ADD / SUB 指令完成。這裡,就不再顯示其匯編代碼了。

 

  值得一提的是,在 C++ 中,struct 和 class 本質上沒有區別,僅僅是成員的默認訪問級別不同。所以上面的代碼中,把任何一個對象在聲明時,使用 class 或者 struct 關鍵字,都不影響結論。

 

  上面的例子簡要的說明了在對象具有繼承關系時,指針轉換過程中,地址值可能發生調整,這個動作是編譯器完成的。上面的例子,對象之間的地址差異,是由對象頭部是否含有虛函數表指針造成的。下面我要舉一個更詳細的例子來進一步說明這個問題。即,如果一個對象實例包含多個子對象(具有多個父類)時的地址調整。以及為什麼在這種情況下,對象的析構函數必須為 virtual 函數。

 

  第二個例子的代碼如下:

 

  

#include <string.h>
#include <stdio.h>

//Parent 1
class P1
{
public:
    int m_x1;
    int m_x2;
    int m_x3;

public:
    P1()
    {
        m_x1 = 0x12345678;
        m_x2 = 0xAABBCCDD;
        m_x3 = 0xEEFF0011;
        printf("P1 constructor.\n");
    }
    virtual ~P1()
    {
        printf("P1 destructor.\n");
    }

    virtual void SayHi()
    {
        printf("P1: hello!\n");
    }
};

//Parent 2: 16 Bytes
class P2
{
public:
    char m_name[12];
public:
    P2()
    {
        strcpy(m_name, "Jack");
        printf("P2 constructor.\n");
    }

    virtual ~P2()
    {
        printf("P2 destructor.\n");
    }

    virtual void ShowName()
    {
        printf("P2 name: %s\n", m_name);
    }
};

//Parent 3: 16 Bytes
class P3
{
public:
    char m_nick[12];
public:
    P3()
    {
        strcpy(m_nick, "fafa");
        printf("P3 constructor.\n");
    }
    virtual ~P3()
    {
        printf("P3 destructor.\n");
    }

    virtual void ShowNick()
    {
        printf("P3 Nick: %s\n", m_nick);
    }
};

//Child1
class C1 : public P1, public P2, public P3
{
public:
    int m_y1;
    int m_y2;
    int m_y3;
    int m_y4;

public:
    C1()
    {
        m_y1 = 0x01;
        m_y2 = 0x02;
        m_y3 = 0x03;
        m_y4 = 0x04;
        printf("C1 constructor.\n");
    }

    virtual ~C1()
    {
        printf("C1 destructor.\n");
    }

    virtual void SayHi()
    {
        printf("C1: SayHi\n");
    }

    virtual void C1_Func_01()
    {
        printf("C1: C1_Func_01\n");
    }
};


int main(int argc, char* argv[])
{
    C1 *c1 = new C1();
    P1 *p1 = c1;
    P2 *p2 = c1;
    P3 *p3 = c1;
    p1->SayHi();
    printf("c1: %p\np1: %p\np2: %p\np3: %p\n", c1, p1, p2, p3);

    //show object's binary data
    unsigned char* pBytes = (unsigned char*)(c1);
    //_CrtMemBlockHeader *pHead = pHdr(pBytes);
    size_t cb = sizeof(C1);
    unsigned int i;
    for(i = 0; i < cb; i++)
    {
        printf("%02X ", pBytes[i] & 0xFF);
        if((i & 0xF) == 0xF)
            printf("\n");
    }
    printf("\n");

    //_CrtDumpMemoryLeaks();
    delete p2;
    return 0;
}

 

  第二個例子的主要內容是:子類 C1,具有三個父類:P1,P2,P3。所有類均具有虛析構函數,即對象實例有虛函數表指針。下圖顯示的是,類的繼承關系:

 

  

  

  圖 1. 類的繼承關系

 

  當類 C1 被構造時,它將含有三個子對象:P1,P2,P3。我們知道,第一個父類 P1 的虛函數表指針,是采用了 C1 的虛函數表指針的,即子類具有對父類虛函數的覆蓋能力,這就是 C++ 中實現多態的重要部分。因此在 C1 對象實例中,實際上沒有 P1 的虛函數表指針。而是直接采用了子類的。那麼 P2 和 P3 也是 C1 的父類,P2 和 P3 的虛函數表內容如何獲取呢?這就涉及到了 C++ 對象模型。

 

  P2,P3 的虛函數表不能和 C1 的虛函數表內容合並,這會使得編譯器很難實現對 P2,P3 的虛函數的調用。而是將其向後偏移,即除了第一個父類,其他父類要在對象中各自保留一個獨立的虛函數表指針。即對象具有 P2,P3 的獨立視角。在這個例子中,對象一共具有三個虛函數表指針,三個視角:P1/C1,P2,P3。對象模型如下圖所示:

 

  

  圖2. 具有多個子對象的對象模型

 

  上圖給出 C1 的實例的對象模型。當把指向 C1 的指針,轉換到指向 P2 或 P3 的指針時,前面已經說過,這時候編譯器已經插入了對地址值的調整。在這個例子中,我通過設置成員變量占用空間的大小,使得地址偏移值分別為 0x10,0x20。上面的代碼產生的輸出如下(在 Windows 中使用 VC 編譯或在 Linux 下使用 g++ 編譯得到的結果相似,僅對象被動態分配的地址值不同 ):

 

P1 constructor.
P2 constructor.
P3 constructor.
C1 constructor.
C1: SayHi

c1: 003E5068
p1: 003E5068
p2: 003E5078
p3: 003E5088


B8 76 41 00 78 56 34 12 DD CC BB AA 11 00 FF EE
A8 76 41 00 4A 61 63 6B 00 CD CD CD CD CD CD CD
98 76 41 00 66 61 66 61 00 CD CD CD CD CD CD CD
01 00 00 00 02 00 00 00 03 00 00 00 04 00 00 00

C1 destructor.
P3 destructor.
P2 destructor.
P1 destructor.

 

  在輸出的中間部分,給出了對象的二進制內容,即將其 dump。可以看到第一行為 P1/C1 視角。第二行為 P2 視角,第三行為 P3 視角。第四行為 C1 的成員變量。

 

  同時可以看到,再對 P2* 的指針調用 delete 時,對象能夠正確的析構。這是因為編譯器在構造 C1 對象時,因為 P2,P3 的析構函數是虛函數,所以編譯器對其析構函數也加入了地址調整處理。由於編譯器已知 P2,P3 相對於 C1 的布局,所以它知道對象真正的內存起點,因此它在代碼段中插入了對應的 trunk 代碼,即將對象地址減去偏移值後,得到對象實際地址,然後跳轉到 C1 的析構函數。以上結論是通過反匯編 debug 版本的輸出結果得到的。這裡,對匯編代碼的展示和分析省略。

 

  假設去掉 P2 的析構函數的 virtual 關鍵字,則運行上面的代碼就會彈出錯誤。因此這時編譯器直接把 P2 指針的值當做一個實際的 P2 對象地址,來進行析構,即它會嘗試 free 這個地址值。而很顯然這樣是錯誤的。在 debug 模式下,會彈出如下的 assertion fail 對話框:

 

  

 

  因此,從上面的例子中可以看到,類的虛構函數為什麼要定義成虛函數。在 effective c++ 書中,對此是這樣說的,如果虛構函數不是虛的,則這個對象可能只是被半析構。當然對於一個普通的單一繼承的對象來說,如果實例只有一個虛函數表指針,如果子類中都是基本數據類型不需要額外處理,實際上這樣也不會導致什麼問題。因為分配內存時,在內存前面的信息塊已經描述了內存的大小。所以釋放內存的環節不會出錯。但如果子類對象的成員中也需要釋放,則這時會發生問題。例如某個成員指向動態申請的內存,則很顯然這時它們會成為內存洩露狀態。

 

  結論:

 

  通過以上分析,可以看到,

 

   (1)在具有繼承關系的類型之間進行指針類型轉換,編譯器在轉換時添加了地址調整。

 

   (2)當存在多個父類且父類虛構函數是虛函數時,由於子對象相對於對象基址發生了偏移,所以編譯器也會為每個具有偏移的父類視角(沒有排在父類列表的首位),插入一段 trunk 代碼,先調整地址為實際對象地址,然後再跳轉到實際對象的析構函數,從而保證對象正確被析構。

 

  補充討論:

 

  在第二個例子中,編譯器在 C1 的構造和析構函數中,也會同樣進行相關的地址調整。例如在 C1 的構造函數中,編譯器負責插入對 C1 的所有父類的構造函數的調用(構造/析構函數只負責傳入的對象地址進行初始化,不負責內存分配/釋放)。由於 P2,P3 視角相對於對象 C1 的地址存在偏移,所以調用 P2,P3的構造函數時,也會相應的調整對象地址到對應視角,這是顯而易見的。如下是 C1 的構造函數的 VC debug 版本的反匯編片段:

 

  可以看到,在分別調用 P1,P2,P3 的構造函數時,構造函數實際上也為對象頭部填充了虛函數表的地址(這時候 P2,P3 構造函數填充的都是實際的 P2,P3 的虛函數表地址),然後編譯器負責的部分,對 P1,P2,P3 的虛函數表指針再次賦值。這時候 P1 的虛函數表指針實際指向了 C1 的虛函數表。P2,P3 視角的虛函數表指向了專為 C1 定制的虛函數表(這些定制的虛函數表,只有析構函數入口是特殊的,其他部分和原虛函數表內容相同)。

mov     [ebp+var_14], ecx
mov     ecx, [ebp+var_14]
call    sub_4110AA      ; 調用 P1_Constructor
mov     [ebp+var_4], 0
mov     ecx, [ebp+var_14]
add     ecx, 10h
call    sub_4110B9      ; 調用 P2_Contructor
mov     byte ptr [ebp+var_4], 1
mov     ecx, [ebp+var_14]
add     ecx, 20h
call    sub_4110BE      ; 調用 P3_Contructor
mov     eax, [ebp+var_14]
mov     dword ptr [eax], offset off_4176B8 ; 重設 P1/C1 vftable 地址
mov     eax, [ebp+var_14]
mov     dword ptr [eax+10h], offset off_4176A8 ; 重設 P2 視角 vftable 地址
mov     eax, [ebp+var_14]
mov     dword ptr [eax+20h], offset off_417698 ; 重設 P3 視角 vftable 地址
mov     eax, [ebp+var_14] ; 以下是用戶編寫的 C1 構造函數的內容
mov     dword ptr [eax+30h], 1
mov     eax, [ebp+var_14]
mov     dword ptr [eax+34h], 2
mov     eax, [ebp+var_14]
mov     dword ptr [eax+38h], 3
mov     eax, [ebp+var_14]
mov     dword ptr [eax+3Ch], 4
mov     esi, esp
push    offset aC1Constructor_ ; "C1 constructor.\n"
call    ds:printf
add     esp, 4

 

  如果父類 P1 的析構函數是非虛的,子類 C1 的析構函數是虛的,這時候的行為是比較古怪的,即 C1 的虛函數表中也沒有 C1 的析構函數了(看起來要讓子類具有虛析構函數,它的父類也必須首先具有虛析構函數才行)。這時候如果用 P1 指針,析構 C1 對象,則實際上只會調用 P1 的析構函數,然後(假設對象由 new 操作符分配)由 delete 運算符負責釋放對象所占用的內存。即造成 C1 對象被半析構的結果。這是 P1 的虛函數表被 C1 重疊覆蓋的較好結果。如果對象視角之間存在偏移(例如用 P2 指針 delete C1 對象,且 P2 的析構函數為非虛),則 delete 時,由於釋放內存時的地址,並不是實際分配時返回的地址,因此可以肯定,必然導致運行時錯誤。

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved