Vtable記憶體布局分析
- 2019 年 10 月 22 日
- 筆記
vtale 記憶體布局分析
虛函數表指針與虛函數表布局
考慮如下的 class:
class A { public: int a; virtual void f1() {} virtual void f2() {} }; int main() { A *a1 = new A(); return 0; } 首先明確,sizeof(A)的輸出是 16,因為:class A 中含有一個 int 是 4 位元組,然後含有虛函數,所以必須含有一個指向 vtable 的 vptr,而 vptr 是 8 位元組,8 + 4 = 12,對齊到 8 的邊界,也就是 16
上述 class 的 AST record layout 如下:
*** Dumping AST Record Layout 0 | class A 0 | (A vtable pointer) 8 | int a | [sizeof=16, dsize=12, align=8, | nvsize=12, nvalign=8] 可以證明對齊邊界為 8 位元組
需要注意的是:由於含有指針,而 64 位系統,指針為 8 位元組,所以對齊邊界是 8
虛函數表指針 vptr
為了完成多態的功能,現代的 C++編譯器都採用了表格驅動的對象模型,具體來說,所有虛函數的地址都存放在一個表格之中,而這個表格就被稱為虛函數表vtable,這個虛函數表的地址被存在放類中,稱為虛函數表指針vptr
使用 clang 導出上述 class A 的對象布局,有如下輸出:
*** Dumping AST Record Layout 0 | class A 0 | (A vtable pointer) 8 | int a | [sizeof=16, dsize=12, align=8, | nvsize=12, nvalign=8] 可以看到,在 class A 的對象布局中,第一個就是 vptr(8 位元組)
虛函數表 vtable
利用 clang 的導出虛函數表的功能,可以看到上述 class A 的虛函數表具體內容如下:
Vtable for 'A' (4 entries). 0 | offset_to_top (0) 1 | A RTTI -- (A, 0) vtable address -- 2 | void A::f1() 3 | void A::f2() VTable indices for 'A' (2 entries). 0 | void A::f1() 1 | void A::f2() 需要注意的是:-- (A, 0) vtable address -- 的意思是,class A 所產生的對象的 vptr 指向的就是這個地址
我們經常所說的vtable僅僅含有虛函數的地址,實際上,這不是完整的vtable
一個完整的 vtable,有以下內容(虛函數表中的內容被稱為條目或者實體,另外並不是所有的條目都會出現,但是如果出現,一定是按照下面的順序出現):
- virtual call (vcall) offsets:用於對虛函數執行指針調整,這些虛函數在虛基類或虛基類的子對象中聲明,並在派生自虛基類的類中重寫
- virtual base (vbase) offsets:用來訪問某個對象的虛基
- offset to top:記錄了對象的這個虛函數表地址偏移到該對象頂部地址的偏移量
- typeinfo pointer:用於 RTTI
- vitual function pointers:一系列虛函數指針
各種情況下的 vtable 布局
1 單一繼承
下面討論,單一繼承情況下,虛函數表裡面各種條目的具體情況,考慮如下程式碼:
class A { public: int a; virtual void f1() {} virtual void f2() {} }; class B : public A { public: int b; void f1() override {} }; int main() { A *a1 = new A(); B *b1 = new B(); return 0; } 首先需要明確的是:sizeof(A)與 sizeof(B)的大小:
- sizeof(A):4 + 8 = 12,調整到 8 的邊界,所以是 16
- sizeof(B):4 + 4 + 8 = 16,不需要進行邊界對齊,所以也是 16
利用 clang 查看 class A 與 class B 的所產生的對象 a1 與 b1 的布局,有如下輸出:
*** Dumping AST Record Layout 0 | class A 0 | (A vtable pointer) 8 | int a | [sizeof=16, dsize=12, align=8, | nvsize=12, nvalign=8] // 對於b1來說:在構造b1時,首先需要構造一個A父類對象,所以b1的布局最開始上半部分是一個A父類對象 // 但是b1中的 vtable pointer指向的是class B的虛表 *** Dumping AST Record Layout 0 | class B 0 | class A (primary base) 0 | (A vtable pointer) 8 | int a 12 | int b | [sizeof=16, dsize=16, align=8, | nvsize=16, nvalign=8] 利用 clang 查看 class A 與 class B 的虛函數表內容,有如下輸出:
Vtable for 'A' (4 entries). 0 | offset_to_top (0) 1 | A RTTI -- (A, 0) vtable address -- 2 | void A::f1() 3 | void A::f2() VTable indices for 'A' (2 entries). 0 | void A::f1() 1 | void A::f2() Vtable for 'B' (4 entries). 0 | offset_to_top (0) 1 | B RTTI -- (A, 0) vtable address -- -- (B, 0) vtable address -- 2 | void B::f1() 3 | void A::f2() VTable indices for 'B' (1 entries). 0 | void B::f1() 在 class B 的虛函數表內容中,有如下兩條:
-- (A, 0) vtable address --
-- (B, 0) vtable address --
意思是:
- 如果以 A 類型的引用或者指針來看待 class B 的對象,那麼此時的 vptr 指向的就是-- (A, 0) vtable address --
- 如果以 B 類型的引用或者指針來看待 class B 的對象,那麼此時的 vptr 指向的就是-- (B, 0) vtable address --
雖然在上述里例子中,這兩個地址是相同的,這也意味著單鏈繼承的情況下,動態向下轉換和向上轉換時,不需要對 this 指針的地址做出任何修改,只需要對其重新「解釋」
(這裡需要說明一下:指針或者引用的類型,真正的意義是影響編譯器如何解釋或者說編譯器如何看待該指針或者引用指向的記憶體中的數據)
此處還有另一種情況,即 class A 不含有虛函數,而 class B 含有虛函數,且 class B 繼承於 class A:
class A { public: int a; }; class B : public A { public: int b; virtual void f1() {} }; int main() { A *a1 = new A(); B *b1 = new B(); return 0; } 列印 class A 與 class B 的對象布局如下:
*** Dumping AST Record Layout 0 | class A 0 | int a | [sizeof=4, dsize=4, align=4, | nvsize=4, nvalign=4] *** Dumping AST Record Layout 0 | class B 0 | (B vtable pointer) 8 | class A (base) 8 | int a 12 | int b | [sizeof=16, dsize=16, align=8, | nvsize=16, nvalign=8] 在這種情況下,把一個 derived class object 指定給 base class 的指針或者引用,就需要編譯器的介入了(編譯器需要調整地址,因為 class B object 中多了一根 vptr)
但是這種情況很少出現,因為:如果一個類要作為基類,那麼它的析構函數基本上都要是虛的,否則通過指向基類的指針刪除對象將會觸發未定義的行為
單一繼承情況下的虛函數表所含條目也比較少,理解起來也很容易
2 多重繼承
考慮如下程式碼:
class A { public: int a; virtual void f1() {} }; class B { public: int b; virtual void f2() {} }; class C : public A, public B { public: int c; void f1() override {} void f2() override {} }; int main() { A *a1 = new A(); B *b1 = new B(); C *c1 = new C(); return 0; } 首先,依然討論一下 A,B,C 三個 class 的大小:
- sizeof(A):4 + 8 = 12,調整到 8 的邊界,即 16
- sizeof(B):4 + 8 = 12,調整到 8 的邊界,即 16
- sizeof(C):4 + 4 + 4 +8 + 8 = 28,調整到 8 的邊界,即 32
這裡有一個問題,為什麼計算 C 的大小時,加了兩次 8?因為這兩個 8 是兩個 vptr,那怎麼 C 會有兩根 vptr 呢,後面會進行解釋,此處先不討論
查看 class A、B、C 三個對象的布局,如下:
*** Dumping AST Record Layout 0 | class A 0 | (A vtable pointer) 8 | int a | [sizeof=16, dsize=12, align=8, | nvsize=12, nvalign=8] *** Dumping AST Record Layout 0 | class B 0 | (B vtable pointer) 8 | int b | [sizeof=16, dsize=12, align=8, | nvsize=12, nvalign=8] *** Dumping AST Record Layout 0 | class C 0 | class A (primary base) 0 | (A vtable pointer) 8 | int a 16 | class B (base) 16 | (B vtable pointer) 24 | int b 28 | int c | [sizeof=32, dsize=32, align=8, | nvsize=32, nvalign=8] 查看 class A、B、C 的虛函數表的所有條目:
Vtable for 'A' (3 entries). 0 | offset_to_top (0) 1 | A RTTI -- (A, 0) vtable address -- 2 | void A::f1() VTable indices for 'A' (1 entries). 0 | void A::f1() Vtable for 'B' (3 entries). 0 | offset_to_top (0) 1 | B RTTI -- (B, 0) vtable address -- 2 | void B::f2() VTable indices for 'B' (1 entries). 0 | void B::f2() Vtable for 'C' (7 entries). 0 | offset_to_top (0) 1 | C RTTI -- (A, 0) vtable address -- -- (C, 0) vtable address -- 2 | void C::f1() 3 | void C::f2() 4 | offset_to_top (-16) 5 | C RTTI -- (B, 16) vtable address -- 6 | void C::f2() [this adjustment: -16 non-virtual] Thunks for 'void C::f2()' (1 entry). 0 | this adjustment: -16 non-virtual VTable indices for 'C' (2 entries). 0 | void C::f1() 1 | void C::f2() 此時可以看到,在多重繼承下,虛函數表多出了許多單一繼承沒有的條目,接下來進行仔細討論
2.1 為什麼 C 的布局中有兩個 vptr?
與單鏈繼承不同,由於 A 和 B 完全獨立,它們的虛函數沒有順序關係,即 f1 和 f2 有著相同對虛表起始位置的偏移量,所以不可以按照偏移量的順序排布;並且 A 和 B 中的成員變數也是無關的,因此基類間也不具有包含關係;這使得 A 和 B 在 C 中必須要處於兩個不相交的區域中,同時需要有兩個虛指針分別對它們虛函數表索引
2.2 class C 對象的記憶體布局中 primary base 是何意義?
再次關注一下 class C 的對象的記憶體布局:
*** Dumping AST Record Layout 0 | class C 0 | class A (primary base) 0 | (A vtable pointer) 8 | int a 16 | class B (base) 16 | (B vtable pointer) 24 | int b 28 | int c | [sizeof=32, dsize=32, align=8, | nvsize=32, nvalign=8] 已經知道 class C 是 public 方式繼承了 class A 與 class B,而 class A 被標記為primary base,其意義是:class C 將 class A 作為主基類,也就是將 class C 的虛函數併入class A 的虛函數表之中
2.3 多重繼承情況下,class C 的虛函數表 vtable 的特點?
多重繼承情況下,class C 的虛函數表內容如下:
Vtable for 'C' (7 entries). 0 | offset_to_top (0) 1 | C RTTI -- (A, 0) vtable address -- -- (C, 0) vtable address -- 2 | void C::f1() 3 | void C::f2() 4 | offset_to_top (-16) 5 | C RTTI -- (B, 16) vtable address -- 6 | void C::f2() [this adjustment: -16 non-virtual] Thunks for 'void C::f2()' (1 entry). 0 | this adjustment: -16 non-virtual VTable indices for 'C' (2 entries). 0 | void C::f1() 1 | void C::f2() 可以看到,class C 的整個虛函數表其實是兩個虛函數表拼接而成(這也就對應了 class C 為什麼由兩個 vptr)
一步步分析,先看上半部分的虛函數表:
0 | offset_to_top (0) 1 | C RTTI -- (A, 0) vtable address -- -- (C, 0) vtable address -- 2 | void C::f1() 3 | void C::f2() 前面已經提到過,class C 會把 class A 當作主基類,並把自己的虛函數併入到 class A 的虛函數表之中,所以,可以才會看到如上的內容
所以,class C 中的一根 vptr 會指向這個虛函數表
再看下半部分的虛函數表:
4 | offset_to_top (-16) 5 | C RTTI -- (B, 16) vtable address -- 6 | void C::f2() [this adjustment: -16 non-virtual] Thunks for 'void C::f2()' (1 entry). 0 | this adjustment: -16 non-virtual 注意,此時的 offset_to_top 中的偏移量已經是 16 了
之前說過,offset_to_top 的意義是:將對象從當前這個類型轉換為該對象的實際類型的地址偏移量
在多繼承中,以 class A、B、C 為例,class A 和 class B 以及 class C 類型的指針或者引用都可以指向 class C 類型的實例,比如:
C cc = new C(); B &bb = cc; bb.f1(); // 我們知道,由於多態,此時實際調用的class C中的虛函數f1(),即相當於cc.f1() // 回顧class C的對象的記憶體布局 // 當我們用 B類型的引用接收cc對象時,this指針相當於指在了`16 | class B (base)`這個地方,要想實現多態,需要將this指針向上偏移16個位元組,這樣this指針才能指向cc對象的起始地址,編譯器才能以C類型來解釋cc這個對象而不會出錯 *** Dumping AST Record Layout 0 | class C 0 | class A (primary base) 0 | (A vtable pointer) 8 | int a 16 | class B (base) 16 | (B vtable pointer) 24 | int b 28 | int c | [sizeof=32, dsize=32, align=8, | nvsize=32, nvalign=8] 在多繼承中,由於不同的基類起點可能處於不同的位置,因此當需要將它們轉化為實際類型時,this 指針的偏移量也不相同,且由於多態的特性,cc 的實際類型在編譯時期是無法確定的;那必然需要一個東西幫助我們在運行時期確定 cc 的實際類型,這個東西就是offset_to_top。通過讓this指針加上offset_to_top的偏移量,就可以讓 this 指針指向實際類型的起始地址
class C 下半部分的虛函數表還有一個值得注意的地方:
6 | void C::f2() [this adjustment: -16 non-virtual] Thunks for 'void C::f2()' (1 entry). 0 | this adjustment: -16 non-virtual 意思是,當以 B 類型的指針或者引用接受了 class C 的對象並調用 f2 時:需要將 this 指針調整-16 個位元組,然後再進行調用(這跟上面所說的一樣,將 this 向上調整 16 個位元組就是讓 this 指向 class C 對象的起始地址,從而編譯器會以 class C 這個類型來看待 this 指針),然後再調用 f2,也就確保了調用的是 class C 的虛函數表中自己的 f2
3 虛擬繼承
首先考慮如下程式碼中的 class A、B、C、D:
class A { public: int a; virtual void fa() {} }; class B : public virtual A { public: int b; virtual void fb() {} }; class C : public virtual A { public: int c; virtual void fc() {} }; class D : public B, public C { public: int c; void fa() override {} virtual void fd() {} }; int main() { A *a1 = new A(); B *b1 = new B(); C *c1 = new C(); D *d1 = new D(); return 0; } class B、C 都是以虛擬繼承的方式繼承 class A
對於編譯器來說,要支援虛擬繼承實在要花費很大的一番功夫,因為編譯器不僅需要在 class D 中只保存一份 class A 的成員變數,還要確保多態行為的正確性
還是先列印出相應的對象布局以及 vtable 布局:
*** Dumping AST Record Layout 0 | class A 0 | (A vtable pointer) 8 | int a | [sizeof=16, dsize=12, align=8, | nvsize=12, nvalign=8] *** Dumping AST Record Layout 0 | class B 0 | (B vtable pointer) 8 | int b 16 | class A (virtual base) 16 | (A vtable pointer) 24 | int a | [sizeof=32, dsize=28, align=8, | nvsize=12, nvalign=8] *** Dumping AST Record Layout 0 | class C 0 | (C vtable pointer) 8 | int c 16 | class A (virtual base) 16 | (A vtable pointer) 24 | int a | [sizeof=32, dsize=28, align=8, | nvsize=12, nvalign=8] *** Dumping AST Record Layout 0 | class D 0 | class B (primary base) 0 | (B vtable pointer) 8 | int b 16 | class C (base) 16 | (C vtable pointer) 24 | int c 28 | int c 32 | class A (virtual base) 32 | (A vtable pointer) 40 | int a | [sizeof=48, dsize=44, align=8, | nvsize=32, nvalign=8] Vtable for 'A' (5 entries). 0 | offset_to_top (0) 1 | A RTTI -- (A, 0) vtable address -- 2 | void A::f1() 3 | void A::f2() 4 | void A::f3() VTable indices for 'A' (3 entries). 0 | void A::f1() 1 | void A::f2() 2 | void A::f3() Vtable for 'B' (14 entries). 0 | vbase_offset (16) 1 | offset_to_top (0) 2 | B RTTI -- (B, 0) vtable address -- 3 | void B::f1() 4 | void B::f2() 5 | void B::fb() 6 | vcall_offset (0) 7 | vcall_offset (-16) 8 | vcall_offset (-16) 9 | offset_to_top (-16) 10 | B RTTI -- (A, 16) vtable address -- 11 | void B::f1() [this adjustment: 0 non-virtual, -24 vcall offset offset] 12 | void B::f2() [this adjustment: 0 non-virtual, -32 vcall offset offset] 13 | void A::f3() Virtual base offset offsets for 'B' (1 entry). A | -24 Thunks for 'void B::f1()' (1 entry). 0 | this adjustment: 0 non-virtual, -24 vcall offset offset Thunks for 'void B::f2()' (1 entry). 0 | this adjustment: 0 non-virtual, -32 vcall offset offset VTable indices for 'B' (3 entries). 0 | void B::f1() 1 | void B::f2() 2 | void B::fb() Vtable for 'C' (14 entries). 0 | vbase_offset (16) 1 | offset_to_top (0) 2 | C RTTI -- (C, 0) vtable address -- 3 | void C::f1() 4 | void C::f2() 5 | void C::fc() 6 | vcall_offset (0) 7 | vcall_offset (-16) 8 | vcall_offset (-16) 9 | offset_to_top (-16) 10 | C RTTI -- (A, 16) vtable address -- 11 | void C::f1() [this adjustment: 0 non-virtual, -24 vcall offset offset] 12 | void C::f2() [this adjustment: 0 non-virtual, -32 vcall offset offset] 13 | void A::f3() Virtual base offset offsets for 'C' (1 entry). A | -24 Thunks for 'void C::f1()' (1 entry). 0 | this adjustment: 0 non-virtual, -24 vcall offset offset Thunks for 'void C::f2()' (1 entry). 0 | this adjustment: 0 non-virtual, -32 vcall offset offset VTable indices for 'C' (3 entries). 0 | void C::f1() 1 | void C::f2() 2 | void C::fc() Vtable for 'D' (21 entries). 0 | vbase_offset (32) 1 | offset_to_top (0) 2 | D RTTI -- (B, 0) vtable address -- -- (D, 0) vtable address -- 3 | void D::f1() 4 | void D::f2() 5 | void B::fb() 6 | void D::fd() 7 | vbase_offset (16) 8 | offset_to_top (-16) 9 | D RTTI -- (C, 16) vtable address -- 10 | void D::f1() [this adjustment: -16 non-virtual] 11 | void D::f2() [this adjustment: -16 non-virtual] 12 | void C::fc() 13 | vcall_offset (0) 14 | vcall_offset (-32) 15 | vcall_offset (-32) 16 | offset_to_top (-32) 17 | D RTTI -- (A, 32) vtable address -- 18 | void D::f1() [this adjustment: 0 non-virtual, -24 vcall offset offset] 19 | void D::f2() [this adjustment: 0 non-virtual, -32 vcall offset offset] 20 | void A::f3() Virtual base offset offsets for 'D' (1 entry). A | -24 Thunks for 'void D::f1()' (2 entries). 0 | this adjustment: -16 non-virtual 1 | this adjustment: 0 non-virtual, -24 vcall offset offset Thunks for 'void D::f2()' (2 entries). 0 | this adjustment: -16 non-virtual 1 | this adjustment: 0 non-virtual, -32 vcall offset offset VTable indices for 'D' (3 entries). 0 | void D::f1() 1 | void D::f2() 3 | void D::fd() Construction vtable for ('B', 0) in 'D' (14 entries). 0 | vbase_offset (32) 1 | offset_to_top (0) 2 | B RTTI -- (B, 0) vtable address -- 3 | void B::f1() 4 | void B::f2() 5 | void B::fb() 6 | vcall_offset (0) 7 | vcall_offset (-32) 8 | vcall_offset (-32) 9 | offset_to_top (-32) 10 | B RTTI -- (A, 32) vtable address -- 11 | void B::f1() [this adjustment: 0 non-virtual, -24 vcall offset offset] 12 | void B::f2() [this adjustment: 0 non-virtual, -32 vcall offset offset] 13 | void A::f3() Construction vtable for ('C', 16) in 'D' (14 entries). 0 | vbase_offset (16) 1 | offset_to_top (0) 2 | C RTTI -- (C, 16) vtable address -- 3 | void C::f1() 4 | void C::f2() 5 | void C::fc() 6 | vcall_offset (0) 7 | vcall_offset (-16) 8 | vcall_offset (-16) 9 | offset_to_top (-16) 10 | C RTTI -- (A, 32) vtable address -- 11 | void C::f1() [this adjustment: 0 non-virtual, -24 vcall offset offset] 12 | void C::f2() [this adjustment: 0 non-virtual, -32 vcall offset offset] 13 | void A::f3() 先分析 classB、C;由於 class B、C 基本相同,所以此處只分析 class B,先單獨看 class B 的對象的記憶體布局:
*** Dumping AST Record Layout 0 | class B 0 | (B vtable pointer) 8 | int b 16 | class A (virtual base) 16 | (A vtable pointer) 24 | int a | [sizeof=32, dsize=28, align=8, | nvsize=12, nvalign=8] 對比可以看出,在 class B 的對象的記憶體布局上,虛擬繼承與普通繼承的最大區別在於:虛擬繼承下,class B 的記憶體布局不再是 class A 的內容在最前面然後緊接著 class B 的內容,而是先是 class B 的內容,然後再接著 class A 的內容
這種布局看起來就像在 class B 對象的後面接上一個 class A 對象,觀察一下左邊顯示的偏移量:
可以看到 class A 的 vptr 的偏移量為 16,在 class A 之前,就是 class B 的內容了,class B 只含有一根 vptr(8 位元組)+一個 int(4 位元組)=12 位元組,然而 class A 的 vptr 的偏移量卻是 16,也就是說,class B 的對象完成了邊界調整(12 調整到 16),然後再在後面拼接上 class A 的對象
再分析一下 class B 的 vtable:
Vtable for 'B' (14 entries). 0 | vbase_offset (16) 1 | offset_to_top (0) 2 | B RTTI -- (B, 0) vtable address -- 3 | void B::f1() 4 | void B::f2() 5 | void B::fb() 6 | vcall_offset (0) 7 | vcall_offset (-16) 8 | vcall_offset (-16) 9 | offset_to_top (-16) 10 | B RTTI -- (A, 16) vtable address -- 11 | void B::f1() [this adjustment: 0 non-virtual, -24 vcall offset offset] 12 | void B::f2() [this adjustment: 0 non-virtual, -32 vcall offset offset] 13 | void A::f3() Virtual base offset offsets for 'B' (1 entry). A | -24 Thunks for 'void B::f1()' (1 entry). 0 | this adjustment: 0 non-virtual, -24 vcall offset offset Thunks for 'void B::f2()' (1 entry). 0 | this adjustment: 0 non-virtual, -32 vcall offset offset VTable indices for 'B' (3 entries). 0 | void B::f1() 1 | void B::f2() 2 | void B::fb() vbase_offset (16):用來訪問虛基類子對象的偏移量(結合 class B 的對象記憶體布局觀察)
vcall_offset(-16):當 class A 的引用 a 實際接受的是 class B 對象,然後執行 a→f1()(或 f2),由於 f1(或 f2)在 class B 中被重寫過了,而此時的 this 表示的是一個 class A 類型的對象,所以需要對 this 進行調整才能正確的調用到 B::f1()(或 f2),this 如何調整?靠的就是這個 vcall_offset(-16)即將 this 指針向上調整 16 個位元組,然後再調用 f1()(或 f2)
vcall_offset(0):當 class A 的引用 a 實際接受的是 class B 對象,然後執行 a→f3(),由於 f3 並沒有被 class B 重寫,所以此時的 this 不需要進行調整,所以 vcall_offset 為 0
對於 class D 的 vtable 來說,只是變得更加複雜而已,其中的條目在之前已經全部介紹過了,可以自行進行分析
PS:個人分析,不對的地方請指正
