1. 程式人生 > >C++虛擬函式之二:虛擬函式表與虛擬函式呼叫

C++虛擬函式之二:虛擬函式表與虛擬函式呼叫

繼續前一篇《C++ 虛擬函式之一:物件記憶體佈局》,這次來分析一下虛擬函式表的結構和虛擬函式的呼叫過程。

虛擬函式表結構

如何檢視虛擬函式表的結構?使用gdb直接檢視記憶體固然可以,但是不夠直觀,那麼有沒有更好的方法呢?使用gcc的-fdump-class-hierarchy選項是個不錯的選擇,在gcc手冊中對該選項的部分解釋如下:

-fdump-class-hierarchy-options (C++ only)
Dump a representation of each class’s hierarchy and virtual function table layout to a file.

它能夠生成類的繼承層次結構和虛擬函式表的佈局。
上篇文章已經貼過部分程式碼,現在我把整個原始檔貼在下面:

#include <stdint.h>
#include <string.h>

class Base1
{
public:
    Base1() { memset(&Base1Data, 0x11, sizeof(Base1Data)); }
    virtual void A() {};
    virtual void B() {};
    uint64_t Base1Data;
};

class Base2
{
public:
    Base2() { memset
(&Base2Data, 0x22, sizeof(Base2Data)); } virtual void C() {}; virtual void D() {}; uint64_t Base2Data; }; class Derived : public Base1, public Base2 { public: Derived() { memset(&DerivedData, 0x33, sizeof(DerivedData)); } virtual void A() {}; virtual void C() {}; uint64_t DerivedData; }; int
main() { Base1 *x = new Derived; x->A(); x->B(); Base2 *y = new Derived; y->C(); y->D(); Derived *z = new Derived; z->A(); z->B(); z->C(); z->D(); return 0; }

使用gcc的-fdump-class-hierarchy-options選項分析原始檔:

$ g++ -c -fdump-class-hierarchy call_function.cpp

生成了call_function.cpp.002t.class檔案(刪掉了在外部標頭檔案定義的類):

Vtable for Base1
Base1::_ZTV5Base1: 4 entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI5Base1)
16    (int (*)(...))Base1::A
24    (int (*)(...))Base1::B

Class Base1
   size=16 align=8
   base size=16 base align=8
Base1 (0x0x7ff358c9bc00) 0
    vptr=((& Base1::_ZTV5Base1) + 16)

Vtable for Base2
Base2::_ZTV5Base2: 4 entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI5Base2)
16    (int (*)(...))Base2::C
24    (int (*)(...))Base2::D

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

Vtable for Derived
Derived::_ZTV7Derived: 9 entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI7Derived)
16    (int (*)(...))Derived::A
24    (int (*)(...))Base1::B
32    (int (*)(...))Derived::C
40    (int (*)(...))-16
48    (int (*)(...))(& _ZTI7Derived)
56    (int (*)(...))Derived::_ZThn16_N7Derived1CEv
64    (int (*)(...))Base2::D

Class Derived
   size=40 align=8
   base size=40 base align=8
Derived (0x0x7ff358b424d0) 0
    vptr=((& Derived::_ZTV7Derived) + 16)
  Base1 (0x0x7ff358cfa180) 0
      primary-for Derived (0x0x7ff358b424d0)
  Base2 (0x0x7ff358cfa1e0) 16
      vptr=((& Derived::_ZTV7Derived) + 56)

我們得到了每個類的繼承層次結構和虛擬函式表佈局。可以看出來,這三個類每個類都有一個虛擬函式表。以Derived類為例,虛擬函式表共有9個條目,其佈局為:

Vtable for Derived
Derived::_ZTV7Derived: 9 entries
0     (int (*)(...))0                           #1
8     (int (*)(...))(& _ZTI7Derived)            #2
16    (int (*)(...))Derived::A                  #3
24    (int (*)(...))Base1::B                    #4
32    (int (*)(...))Derived::C                  #5
40    (int (*)(...))-16                         #6
48    (int (*)(...))(& _ZTI7Derived)            #7
56    (int (*)(...))Derived::_ZThn16_N7Derived1CEv  #8
64    (int (*)(...))Base2::D                    #9

條目1和條目6是整形常量,條目2和條目7是typeinfo for Derived,條目3、4、5和9是指向類成員函式的指標,條目8demangle後的名字是non-virtual thunk to Derived::C()。我們用gdb分析在進行虛擬函式呼叫時,是如何使用這些條目的。

虛擬函式呼叫

使用gdb反彙編,分析每個函式呼叫的具體過程。

x->A():

   # x指標存在$rbp-0x28處,首先得到x指標,存放到rax
   0x000055555555497c <+34>:    mov    -0x28(%rbp),%rax
   # 取x指標指向的8個位元組的資料存放到rax,還記得物件前8個位元組存的是什麼嗎?是虛擬函式表指標
   # 不過虛表指標沒有指向虛擬函式表首地址,對於Derived物件,物件起始位置虛表指標指向虛表的起始位置+16處,也就是條目3
   0x0000555555554980 <+38>:    mov    (%rax),%rax
   # 取條目3的內容存放到rax,而條目3是Derived::A函式指標,也就是rax現在存放的是 Derived::A函式地址
   0x0000555555554983 <+41>:    mov    (%rax),%rax
   # 將x存放到rdx
   0x0000555555554986 <+44>:    mov    -0x28(%rbp),%rdx
   # 將x存放到rdi,rdi一般作為接下來函式呼叫的第一個引數,對於Derived::A來說,第1個引數是this指標
   0x000055555555498a <+48>:    mov    %rdx,%rdi
   # 呼叫Derived::A
   0x000055555555498d <+51>:    callq  *%rax

x->B():

   # 同x->A()
   0x000055555555498f <+53>:    mov    -0x28(%rbp),%rax
   # 同x->A()
   0x0000555555554993 <+57>:    mov    (%rax),%rax
   # rax被調整為指向虛擬函式表第4個條目:Base1::B
   0x0000555555554996 <+60>:    add    $0x8,%rax
   # 將條目4存放到rax,Base1::B的地址
   0x000055555555499a <+64>:    mov    (%rax),%rax
   # 同x->A()
   0x000055555555499d <+67>:    mov    -0x28(%rbp),%rdx
   # 同x->A()
   0x00005555555549a1 <+71>:    mov    %rdx,%rdi
   # 呼叫Base1::B
   0x00005555555549a4 <+74>:    callq  *%rax

y->C()和y->D()的過程與x->A()和x->B()過程幾乎完全相同,都是取Derived虛擬函式表條目內容,也就是函式地址,然後進行呼叫。你是否還記得條目8“non-virtual thunk to Derived::C()”,y->C()執行時呼叫了這個函式,我們看看這個函式到底是個什麼:

(gdb) x/2g y #檢視y指向內容,其首地址8個位元組是Derived虛表指標
0x555555768eb0: 0x0000555555755d08  0x2222222222222222
(gdb) x/g 0x0000555555755d08 #得到條目8內容
0x555555755d08 <_ZTV7Derived+56>:   0x0000555555554b95
(gdb) disassemble 0x0000555555554b95 #反彙編
Dump of assembler code for function _ZThn16_N7Derived1CEv:
   0x0000555555554b95 <+0>: sub    $0x10,%rdi
   0x0000555555554b99 <+4>: jmp    0x555555554b8a <Derived::C()>
End of assembler dump.

non-virtual thunk to Derived::C()只做了兩件事,首先將this指標向前調整16個位元組,調整到Derived物件首地址,然後跳轉到Derived::C()執行。這麼做的原因也好理解,y指標指向了Derived物件的中間部分,而傳給Derived::C()的this指標必然需要是一個指向Derived物件首地址的指標,否則訪問資料成員計算偏移量時會出問題。
z->A()、z->B()和z->C()都和x->A()和x->B()呼叫類似,z->D()則稍有不同,我把不同的地方註釋了一下:

   0x0000555555554a53 <+249>:   mov    -0x18(%rbp),%rax
   # 取$rax+16存放到rdx,rax是z指標,則$rax+16則指向了Derived物件Base2部分的首地址
   0x0000555555554a57 <+253>:   lea    0x10(%rax),%rdx
   # 其他部分都類似y->D()
   0x0000555555554a5b <+257>:   mov    -0x18(%rbp),%rax
   0x0000555555554a5f <+261>:   mov    0x10(%rax),%rax
   0x0000555555554a63 <+265>:   add    $0x8,%rax
   0x0000555555554a67 <+269>:   mov    (%rax),%rax
   0x0000555555554a6a <+272>:   mov    %rdx,%rdi
   0x0000555555554a6d <+275>:   callq  *%rax

可以看出來,這個過程相當於先把z指標轉型為Base2型別,然後按照Base2型別的呼叫過程來進行函式呼叫。這麼做的原因類似與y->C()呼叫non-virtual thunk to Derived::C(),都是因為我們呼叫成員函式時所用的物件指標與傳給成員函式的this指標型別不同,需要進行調整,以免訪問資料成員時訪問到了意外的內容。

虛擬函式表的其他內容

可以看出來,Derived虛擬函式表有9個條目,但我們目前只提到了5個,剩下4個是什麼呢?

0     (int (*)(...))0                           #1
8     (int (*)(...))(& _ZTI7Derived)            #2
40    (int (*)(...))-16                         #6
48    (int (*)(...))(& _ZTI7Derived)            #7

剩下這4個條目用於在執行時獲得某個物件指標的型別資訊,例如對於前面的y指標,其其首地址是虛表指標。我們把虛表指標向前調整8個位元組,就指向了條目7:typeinfo for Derived,就可以得到這個指標實際指向物件的型別;把虛表指標向前調整16個位元組,就指向了條目6:-16,這個-16的含義就是,把y指標向前調整16個位元組,就是其真正指向物件的首地址,x指標也是同樣的道理。C++dynamic_cast的實現就依賴於這幾個額外的條目,有興趣的可自己鑽研一下gcc原始碼。