再議內存佈局

你好,我是雨樂!

在上一篇文章 C++:從技術角度聊聊 RTTI 中聊到了虛函數表,以及內部的部分佈局。對於 c++ 對象的內存佈局一直處於似懂非懂似清非清的階段,沒有去深入瞭解過,所以藉着這個機會,一併分析下。

多態在我們日常工作中用的算是比較多的一種特性,業界編譯器往往是通過虛函數來實現運行時多態,而涉及到虛函數的內存佈局往往是最麻煩且容易出錯的,本文從一個簡單的例子入手,藉助 gcc 和 gdb,對內存佈局進行分析,相信看完本文,對內存佈局會有一個清晰的認識。

多態

衆所周知,C++ 爲了實現多態 (運行期),引進了虛函數 (語言標準支持的,其它實現方式不在本文討論範圍內),而虛函數的實現機制則是通過虛函數表。這塊的知識點不算多,卻非常重要,因此往往是面試必問之一,當然,對於我也不例外。作爲候選人,如果沒有把運行期多態的實現機制講清楚,那麼此次面試基本涼涼~~

仍然以上一篇文章的代碼爲例,代碼如下:

class Base1 {
public:
  virtual void fun() {}
  virtual void f1() {}
  int a;
};

class Derived : public Base {
public:
  void fun() {}  // override Base::fun()
  int b;
};

void call(Base *b) {
  b->fun();
}

在上述示例 call() 函數中,當 b 指向 Base 對象時候,call() 函數實際調用的是 Base::fun();當 b 指向 Derived 對象時候,call() 函數實際調用的是 Derived::fun()。之所以可以這麼實現,是因爲虛函數後面的實現機制 --虛函數表(後面稱爲Vtable):

vtable_Base = {&Base::func, ...}
vtable_Derived = {&Derived::func, ...}

那麼,call() 函數在運行的時候,因爲不知道其參數 b 所指向具體類型是什麼,所以只能通過其它方式進行調用。在前面的內容中,有提到過每個對象會有一個指針指向其類的虛函數表,那麼就可以通過該虛函數表進行相應的調用。因此,call() 函數中的 b->fun() 就類似於如下:

((Vtable*)b)[0]()

在現在編譯器對多態的實現中,原理與上述差不多,只是更爲複雜。比如在在虛函數指針的索引 (如上述例子中的 index 0),這個 index 是根據函數的聲明順序而來,如果在 Derived 中再新增一個 virtual 函數 fun2(),那麼其在虛函數表中的 index 就是 1。

實現

本節中以一個多繼承作爲示例,代碼如下:

class Base1 {
public:
  void f0() {}
  virtual void f1() {}
  int a;
};

class Base2 {
public:
  virtual void f2() {}
  int b;
};

class Derived : public Base1, public Base2 {
public:
  void d() {}
  void f2() {}  // override Base2::f1()
  int c;
};

int main() {
  Base2 *b2 = new Base2;
  Derived *d = new Derived;
}

後面的內容將分別從基類和派生類的角度進行分析。

基類

首先,我們通過 g++ 的命令 - fdump-class-hierarchy 進行編譯,以便在佈局上有一個宏觀的認識,然後通過 gdb 進行更加詳細的分析。

Base2 內存佈局如下:

Vtable for Base2
Base2::_ZTV5Base2: 3u entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI5Base2)
16    (int (*)(...))Base2::f2

Class Base2
   size=16 align=8
   base size=12 base align=8
Base2 (0x0x7ff572e6b600) 0
    vptr=((& Base2::_ZTV5Base2) + 16u)

在上述代碼中,Base2 的 Vtable 名爲 _ZTV5Base2 ,經過c++filt處理之後,發現其爲 vtable for Base2。之所以是這種是因爲被編譯器進行了 mangled。其中,TV 代表 Table for Virtual,後面的數字 5 是類名的字符數,Base2 則是類名。

維基百科以 g++3.4.6 爲示例,示例中之處 Vtable 應該只包含指向 Base2::f2 的指針,但在我的本地環境 (g++5.4.0,佈局如上述) 中,B2::f2 爲第三行:首先是 offset,其值爲 0;然後包含一個指向名爲_ZTI5Base2 的結構的指針(這個在上節 RTTI 一文中有講,在本文後面也會涉及);最後是函數指針 B2::f2。

g++ 3.4.6 from GCC produces the following 32-bit memory layout for the object b2:[nb 1]

b2:   +0: pointer to virtual method table of Base2   +4: value of b virtual method table of B2:   +0: Base2::f2()   

繼續看 Class Base2 部分,我們注意到有一句vptr=((& Base2::_ZTV5Base2) + 16u),通過這句可以知道,Base2 類中其虛函數指針 vptr 指向其虛函數表的首位 + 16 處

在下面的內容中,將通過 gdb 來分析其內存佈局。

(gdb) disas
Dump of assembler code for function main:
   0x00000000004006f8 <+0>: push   %rbp
   0x00000000004006f9 <+1>: mov    %rsp,%rbp
   0x00000000004006fc <+4>: push   %rbx
   0x00000000004006fd <+5>: sub    $0x18,%rsp
=> 0x0000000000400701 <+9>: mov    $0x10,%edi
   0x0000000000400706 <+14>: callq  0x400578 <_Znwm@plt>
   0x000000000040070b <+19>: mov    %rax,%rbx
   0x000000000040070e <+22>: mov    %rbx,%rdi
   0x0000000000400711 <+25>: callq  0x40076a <_ZN5Base2C2Ev>
   0x0000000000400716 <+30>: mov    %rbx,-0x18(%rbp)
   0x000000000040071a <+34>: mov    $0x20,%edi
   0x000000000040071f <+39>: callq  0x400578 <_Znwm@plt>
   0x0000000000400724 <+44>: mov    %rax,%rbx
   0x0000000000400727 <+47>: mov    %rbx,%rdi
   0x000000000040072a <+50>: callq  0x40079a <_ZN7DerivedC2Ev>
   0x000000000040072f <+55>: mov    %rbx,-0x20(%rbp)
   0x0000000000400733 <+59>: mov    $0x0,%eax
   0x0000000000400738 <+64>: add    $0x18,%rsp
   0x000000000040073c <+68>: pop    %rbx
   0x000000000040073d <+69>: pop    %rbp
   0x000000000040073e <+70>: retq
End of assembler dump.
(gdb) b *0x0000000000400716
Breakpoint 2 at 0x400716: file abc.cc, line 22.
(gdb) c
Continuing.

Breakpoint 2, 0x0000000000400716 in main () at abc.cc:22
22   Base2 *b2 = new Base2;
(gdb) disas
Dump of assembler code for function main:
   0x00000000004006f8 <+0>: push   %rbp
   0x00000000004006f9 <+1>: mov    %rsp,%rbp
   0x00000000004006fc <+4>: push   %rbx
   0x00000000004006fd <+5>: sub    $0x18,%rsp
   0x0000000000400701 <+9>: mov    $0x10,%edi
   0x0000000000400706 <+14>: callq  0x400578 <_Znwm@plt>
   0x000000000040070b <+19>: mov    %rax,%rbx
   0x000000000040070e <+22>: mov    %rbx,%rdi
   0x0000000000400711 <+25>: callq  0x40076a <_ZN5Base2C2Ev>
=> 0x0000000000400716 <+30>: mov    %rbx,-0x18(%rbp)
   0x000000000040071a <+34>: mov    $0x20,%edi
   0x000000000040071f <+39>: callq  0x400578 <_Znwm@plt>
   0x0000000000400724 <+44>: mov    %rax,%rbx
   0x0000000000400727 <+47>: mov    %rbx,%rdi
   0x000000000040072a <+50>: callq  0x40079a <_ZN7DerivedC2Ev>
   0x000000000040072f <+55>: mov    %rbx,-0x20(%rbp)
   0x0000000000400733 <+59>: mov    $0x0,%eax
   0x0000000000400738 <+64>: add    $0x18,%rsp
   0x000000000040073c <+68>: pop    %rbx
   0x000000000040073d <+69>: pop    %rbp
   0x000000000040073e <+70>: retq
End of assembler dump.

在上述彙編中 <+14> 處,調用了 operator new 進行內存分配,然後將地址放於寄存器 rax 中,在 <+25> 處調用 Base2 構造函數,繼續分析:

(gdb) p/x $rax
$2 = 0x612c20
(gdb) x/2xg 0x612c20
0x612c20: 0x0000000000400918 0x0000000000000000
(gdb) p &(((Base2*)0)->b)
$3 = (int *) 0x8

首先通過 p/x $rax 獲取 b2 的地址 0x612c20,然後通過 x/4xg 0x612c20 打印內存地址,地址信息包含存儲的屬性;接着通過 p &(((Base2)0)->b)* 來獲取變量 b 的佈局,其值爲 0x8,因此可以說明變量 b 在類 Base2 的第八字節處,即 vptr 之後,那麼 class base2 的結構佈局如下:

在上述 x/2xg 0x612c20 的輸出中,有個地址 0x0000000000400918, 其指向 Base2 類的虛函數表,這個可以通過如下方式進行驗證:

(gdb) p *((Base2*)0x612c20)
$6 = {_vptr.Base2 = 0x400918, b = 0}

但是需要注意的是,其並不是指向虛函數表的首位,而是指向 Vtable + 0x10 處,下面是類 Base2 虛函數表的內容:

(gdb) x/4xg 0x0000000000400918-0x10
0x400908 <_ZTV5Base2>: 0x0000000000000000 0x0000000000400980
0x400918 <_ZTV5Base2+16>: 0x000000000040074c 0x0000000000000000
(gdb) x/2i 0x000000000040074c
0x40074c <_ZN5Base22f2Ev>: push   %rbp
0x40074d <_ZN5Base22f2Ev+1>: mov    %rsp,%rbp

其中,0 代表 offset,第三項 0x400918 值與_vptr.Base2 一致,其中的內容通過 x/2i 0x000000000040074c 分析可以看出爲 Base2::f2() 函數地址。那麼第二項又代表什麼呢?

還記得上篇文章中的 RTTI 信息麼?對!第二項就是指向 RTTI 信息的地址,可以通過如下命令:

(gdb) x/2xg 0x0000000000400980
0x400980 <_ZTI5Base2>: 0x0000000000600da0 0x0000000000400990
(gdb) x/s 0x0000000000400990
0x400990 <_ZTS5Base2>:  "5Base2"

其中,_ZTI5Base2 代表 typeinfo for Base2,其指向的地址有兩個內容,分別是 0x0000000000600da0 和 0x0000000000400990,其中 0x400990 存儲的是類名,可以通過 x/s 來證明。

然後接着分析 0x0000000000600da0 存儲的內容,如下:

(gdb) x/2xg 0x0000000000600da0
0x600da0 <_ZTVN10__cxxabiv117__class_type_infoE@@CXXABI_1.3+16>: 0x0000003e9628b210 0x0000003e9628b230

_ZTVN10__cxxabiv117__class_type_infoE 解析之後爲 vtable for __cxxabiv1::__class_type_info

綜上,類 Base2 的內存佈局如下圖所示:

多重繼承

跟上節一樣,仍然通過 -fdump-class-hierarchy 參數獲取 Derived 類的詳細信息,如下:

Vtable for Derived
Derived::_ZTV7Derived: 7u entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI7Derived)
16    (int (*)(...))Base1::f1
24    (int (*)(...))Derived::f2
32    (int (*)(...))-16
40    (int (*)(...))(& _ZTI7Derived)
48    (int (*)(...))Derived::_ZThn16_N7Derived2f2Ev

Class Derived
   size=32 align=8
   base size=32 base align=8
Derived (0x0x7f2708268af0) 0
    vptr=((& Derived::_ZTV7Derived) + 16u)
  Base1 (0x0x7f2708127840) 0
      primary-for Derived (0x0x7f2708268af0)
  Base2 (0x0x7f27081278a0) 16
      vptr=((& Derived::_ZTV7Derived) + 48u)

接着繼續使用 gdb 進行分析:

(gdb) disas
Dump of assembler code for function main:
   0x00000000004006f8 <+0>: push   %rbp
   0x00000000004006f9 <+1>: mov    %rsp,%rbp
   0x00000000004006fc <+4>: push   %rbx
   0x00000000004006fd <+5>: sub    $0x18,%rsp
   0x0000000000400701 <+9>: mov    $0x10,%edi
   0x0000000000400706 <+14>: callq  0x400578 <_Znwm@plt>
   0x000000000040070b <+19>: mov    %rax,%rbx
   0x000000000040070e <+22>: mov    %rbx,%rdi
   0x0000000000400711 <+25>: callq  0x40076a <_ZN5Base2C2Ev>
   0x0000000000400716 <+30>: mov    %rbx,-0x18(%rbp)
   0x000000000040071a <+34>: mov    $0x20,%edi
   0x000000000040071f <+39>: callq  0x400578 <_Znwm@plt>
   0x0000000000400724 <+44>: mov    %rax,%rbx
   0x0000000000400727 <+47>: mov    %rbx,%rdi
   0x000000000040072a <+50>: callq  0x40079a <_ZN7DerivedC2Ev>
=> 0x000000000040072f <+55>: mov    %rbx,-0x20(%rbp)
   0x0000000000400733 <+59>: mov    $0x0,%eax
   0x0000000000400738 <+64>: add    $0x18,%rsp
   0x000000000040073c <+68>: pop    %rbx
   0x000000000040073d <+69>: pop    %rbp
   0x000000000040073e <+70>: retq
End of assembler dump.
(gdb) p/x $rax
$8 = 0x612c40
(gdb) p sizeof(Derived)
$9 = 32
(gdb)  x/6xg 0x612c40
0x612c40: 0x00000000004008e0 0x0000000000000000
0x612c50: 0x0000000000400900 0x0000000000000000
0x612c60: 0x0000000000000000 0x00000000000203a1
(gdb) p &(((Derived*)0)->a)
$15 = (int *) 0x8
(gdb) p &(((Derived*)0)->b)
$16 = (int *) 0x18
(gdb) p &(((Derived*)0)->c)
$17 = (int *) 0x1c
p *((Derived*)0x612c40)
$13 = {<Base1> = {_vptr.Base1 = 0x4008e0, a = 0}, <Base2> = {_vptr.Base2 = 0x400900, b = 0}c = 0}

從上述代碼可以看出,Derived 的結構佈局如下:

接着,我們分析類 Derived 的虛函數表:

(gdb) x/7xg 0x00000000004008e0 - 0x10
0x4008d0 <_ZTV7Derived>: 0x0000000000000000 0x0000000000400938
0x4008e0 <_ZTV7Derived+16>: 0x0000000000400740 0x0000000000400758
0x4008f0 <_ZTV7Derived+32>: 0xfffffffffffffff0 0x0000000000400938
0x400900 <_ZTV7Derived+48>: 0x0000000000400763

其對應如下:

Vtable for Derived
Derived::_ZTV7Derived: 7u entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI7Derived)
16    (int (*)(...))Base1::f1
24    (int (*)(...))Derived::f2
32    (int (*)(...))-16
40    (int (*)(...))(& _ZTI7Derived)
48    (int (*)(...))Derived::_ZThn16_N7Derived2f2Ev

爲了驗證如上,繼續使用 gdb 進行操作:

(gdb) x/2xg 0x0000000000400938
0x400938 <_ZTI7Derived>: 0x0000000000600df8 0x0000000000400970
(gdb) x/4xi 0x0000000000400740
   0x400740 <_ZN5Base12f1Ev>: push   %rbp
   0x400741 <_ZN5Base12f1Ev+1>: mov    %rsp,%rbp
   0x400744 <_ZN5Base12f1Ev+4>: mov    %rdi,-0x8(%rbp)
   0x400748 <_ZN5Base12f1Ev+8>: nop
(gdb) x/2xi 0x0000000000400740
   0x400740 <_ZN5Base12f1Ev>: push   %rbp
   0x400741 <_ZN5Base12f1Ev+1>: mov    %rsp,%rbp
(gdb) x/2xi 0x0000000000400758
   0x400758 <_ZN7Derived2f2Ev>: push   %rbp
   0x400759 <_ZN7Derived2f2Ev+1>: mov    %rsp,%rbp
(gdb) x/4xi 0x0000000000400763
   0x400763 <_ZThn16_N7Derived2f2Ev>: sub    $0x10,%rdi
   0x400767 <_ZThn16_N7Derived2f2Ev+4>: jmp    0x400758 <_ZN7Derived2f2Ev>
   0x400769: nop
   0x40076a <_ZN5Base2C2Ev>: push   %rbp

在上面的內存佈局中,_ZThn16_N7Derived2f2Ev 在上篇文章中沒有進行分析,那麼這個標記代表什麼意思麼,其作用又是什麼呢?

通過 c++filt 將其 demanged 之後,non-virtual thunk to Derived::f2()。那麼這個 thunk 的目的或者意義在哪呢?

我們看下如下代碼:

Derived *d = new Derived;
Base1 *b1 = (Base1*)d;
Base2 *b2 = (Base2*)d;

std::cout << d << " " << b1 << " " << b2 << std::endl;

((Base2*)d)->f2();

輸出如下:

0x1cc0c20 0x1cc0c20 0x1cc0c30

可以看出,同樣是一個地址,使用 Base1 轉換的地址和使用 Base2 轉換的地址不同,這是因爲在轉換的時候,對指針進行了偏移,即加上了 sizeof(Base1)。

好了,言歸正傳。

分析下如下情況:

Base1* b1 = new Derived();
b1->f1();

其正常工作,不需要移動任何指針,這是因爲 b1 指向 Derived 對象的首地址。

那麼如下是下面這種情況呢?

Base2* b2 = new Derived();
// 相當於 Derived *d = new Derived;
// Base2* b2 = d + sizeof(Base1);
b2->f2();

對於創建對象操作,在上述代碼中有大致解釋,那麼對於 b2->f2() 操作,編譯器又是如何實現的呢?

必須將 b2 所指向的指針調整爲具體的 Derived 對象的其實指針,這樣才能正確的調用 f2。此操作可以在運行時完成,即在運行時候通過調整指針指向進行操作,但這樣效率明顯不高。所以爲了解決效率問題,編譯器引入了 thunk,即在編譯階段進行生成。那麼針對上面的 b2->f2() 操作,編譯器會進行如下:

void thunk_to_Derived_f2(Base2* this) {
    this -= sizeof(Base1);
    Derived::f2(this);
}

我們仍然通過 gdb 來驗證這一點,如下:

(gdb) x/2i 0x0000000000400763
   0x400763 <_ZThn16_N7Derived2f2Ev>: sub    $0x10,%rdi
   0x400767 <_ZThn16_N7Derived2f2Ev+4>: jmp    0x400758 <_ZN7Derived2f2Ev>

其中,寄存器 rdi 中存儲的是 this 指針,對 this 指針進行 - 16 操作,然後進行調用 Derived::f2(this) 。

繼續分析虛函數表的內容,其第二項爲 TypeInfo 信息:

(gdb) x/2xg 0x0000000000400938
0x400938 <_ZTI7Derived>: 0x0000000000600df8 0x0000000000400970
(gdb) x/2xg 0x0000000000600df8
0x600df8 <_ZTVN10__cxxabiv121__vmi_class_type_infoE@@CXXABI_1.3+16>: 0x0000003e9628df70 0x0000003e9628df90
(gdb) x/s 0x0000000000400970
0x400970 <_ZTS7Derived>:  "7Derived"

所以,綜合以上內容,class Derived 的內存佈局如下圖所示:

通過上圖,可以看出 class Derived 對象有兩個 vptr,那麼有沒有可能將這倆 vptr 合併成一個呢?

答案是不行。這是因爲與單繼承不同,在多繼承中,class Base1 和 class Base2 相互獨立,它們的虛函數沒有順序關係,即 f1 和 f2 有着相同對虛表起始位置的偏移量,所以不可以按照偏移量的順序排布;並且 class Base1 和 class Base2 中的成員變量也是無關的,因此基類間也不具有包含關係;這使得 class Base1 和 class Base2 在 class Derived 中必須要處於兩個不相交的區域中,同時需要有兩個虛指針分別對它們虛函數表索引。

偏移 (offset)

在前面的內容中,我們多次提到了 top offset,在上節 Derived 的虛函數表中,有兩個 top offset,其值分別爲 0 和 - 16,那麼這個 offset 起什麼作用呢?

在此,先給出結論:將對象從當前這個類型轉換爲該對象的實際類型的地址偏移量

仍然以前面的 class Derived 爲例,其虛函數表佈局如下:

Vtable for Derived
Derived::_ZTV7Derived: 7u entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI7Derived)
16    (int (*)(...))Base1::f1
24    (int (*)(...))Derived::f2
32    (int (*)(...))-16
40    (int (*)(...))(& _ZTI7Derived)
48    (int (*)(...))Derived::_ZThn16_N7Derived2f2Ev

爲了能方便理解本節內容,我們不妨將 Derived 虛函數表認爲是 class Base1 和 class Base2 兩個類的虛函數表拼接而成 。因爲是多重繼承,所以編譯器將先繼承的那個認爲是 主基類 (primary base) ,因此 Derived 類的主基類就是 class Base1。

在多繼承中,當最左邊的類中沒有虛函數時候,編譯器會將第一個有虛函數的基類移到對象的開頭,這樣對象的開頭總是有 vptr

首先看虛函數表的前半部分,如下:

0     (int (*)(...))0
8     (int (*)(...))(& _ZTI7Derived)
16    (int (*)(...))Base1::f1
24    (int (*)(...))Derived::f2

正是因爲編譯器將 class Base1 作爲 Derived 的主基類,並將自己的函數加入其中。從上述可以看出 offset 爲 0,也就是說 Base1 類的指針不需要偏移就可以直接訪問 Derived::f2()。

接着看虛函數表的下半部分:

32    (int (*)(...))-16
40    (int (*)(...))(& _ZTI7Derived)
48    (int (*)(...))Derived::_ZThn16_N7Derived2f2Ev

偏移值爲 - 16,因爲是多重繼承,所以 class Base1 和 class Base2 類型的指針或者引用都可以指向 class Derived 對象,那麼又是如何調用正確的成員函數呢?

Base2* b2 = new Derived;
b2->f2(); //最終調用Derived::f2();

由於不同的基類起點可能處於不同的位置,因此當需要將它們轉化爲實際類型時,this 指針的偏移量也不相同,且由於多態的特性,b2 的實際類型在編譯時期是無法確定的;那必然需要一個東西幫助我們在運行時期確定 b2 的實際類型,這個東西就是offset_to_top。通過讓this指針加上offset_to_top的偏移量,就可以讓 this 指針指向實際類型的起始地址。

結語

寫這塊的時候,感覺需要寫的還是很多的,也有很多內容沒寫,比如虛擬繼承、菱形繼承的佈局都在本文中沒有體現,後面有機會再接着分析。

你好,我是雨樂,從業十二年有餘,歷經過傳統行業網絡研發、互聯網推薦引擎研發,目前在廣告行業從業 8 年。目前任職某互聯網公司高級技術專家一職,負責廣告引擎的架構和研發。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/DzCRppLO_qm99CB25bMtzA