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原始碼。