為什麼bs虛函數表的地址(int*)(&bs)與虛函數地址(int*)*(int*)(&bs) 不是同一個?

class base {
virtual void f1() {}
};
base bs;

為什麼bs虛函數表的地址(int*)(bs)與f1的地址(int*)*(int*)(bs) 不是同一個?f1不就是排在虛函數表的首地址處嗎?

虛函數真實的內存排列是什麼樣的?


題主的問題在 《Inside the C++ Object Model》 里有完美解答。這本書必讀。

另外放個相關問題的例子的傳送門:一道阿里實習生筆試題的疑惑? - RednaxelaFX 的回答

C++規範並沒有規定虛函數的實現方式。不過大部分C++實現都用虛函數表(vtable)來實現虛函數的分派。特別是對單繼承的情況,大家的實現都比較接近;對多繼承的情況可能需要多層虛函數表,這個大家有不少發揮空間。

下面給個單繼承情況下常見C++實現的布局的例子。

(代碼用了C++11的語法,不影響內容)

#include &
#include &

class Object {
int identity_hash_;

public:
Object(): identity_hash_(std::rand()) { }

int IdentityHashCode() const { return identity_hash_; }

virtual int HashCode() { return IdentityHashCode(); }
virtual bool Equals(Object* rhs) { return this == rhs; }
virtual std::string ToString() { return "Object"; }
};

class MyObject : public Object {
int dummy_;

public:
int HashCode() override { return 0; }
std::string ToString() override { return "MyObject"; }
};

int main() {
Object o1;
MyObject o2;
std::cout &<&< o2.ToString() &<&< std::endl &<&< o2.IdentityHashCode() &<&< std::endl &<&< o2.HashCode() &<&< std::endl; } /* Object vtable -16 [ offset to top ] __si_class_type_info -8 [ typeinfo Object ] --&> +0 [ ... ]
--&> +0 [ vptr ] --&> +0 [ Object::HashCode ]
+8 [ identity_hash_ ] +8 [ Object::Equals ]
+12 [ (padding) ] +16 [ Object::ToString ]

MyObject vtable
-16 [ offset to top ] __si_class_type_info
-8 [ typeinfo MyObject ] --&> +0 [ ... ]
--&> +0 [ vptr ] --&> +0 [ MyObject::HashCode ]
+8 [ identity_hash_ ] +8 [ Object::Equals ]
+12 [ dummy_ ] +16 [ MyObject::ToString ]

*/

這個布局是在64位(LP64)的Mac OS X上Clang++用的。我沒有禁用RTTI,所以在vtable的開頭還有一個隱藏欄位存著類型的typeinfo指針。C++的RTTI雖然畢竟弱,但好歹也算是一種反射的實現;每個編譯器會自己實現藏在std::type_info背後的反射用數據結構。

「offset-to-top」在多繼承的情況下有用,不過編譯器為了方便實現也可以在單繼承的時候用同樣的結構,把值填為0就不影響語義了。

即便在這種超級簡單的單繼承的情況下,不同C++實現可以在細節上發揮的空間還是相當多。

例如:

  • 對象的vptr是位於對象的+0偏移量,還是位於別的(例如負偏移量,-8之類)
  • vtable里是否存在typeinfo。如果關掉RTTI功能的話就沒特別的必要存typeinfo了。
  • 如果vtable里有存typeinfo,它位於什麼偏移量,是+0還是別的(例如負偏移量,-8之類)
  • 還有一個很微妙的:一般C++實現vtable里放發是虛函數的入口地址,該地址直接可調用;但也不排除奇葩實現從vtable項出發要再經過幾層間接才能訪問到真正的入口地址…這種做法在C++實現中不常見,但在VM實現中卻挺常見的。下面再舉例說。

在多繼承和虛擬繼承的情況下虛函數表要如何組織就有趣了…這裡不想展開,題主請讀《Inside the C++ Object Model》吧。

對象內有多個vptr、使用多層vtable是常見做法;有些實現把這種多層vtable叫做VTT(vtable table)。

===============================================================

GCC的文檔寫道:https://gcc.gnu.org/onlinedocs/gcc/Compatibility.html

Most platforms have a well-defined ABI that covers C code, but ABIs that cover C++ functionality are not yet common.

Starting with GCC 3.2, GCC binary conventions for C++ are based on a written, vendor-neutral C++ ABI that was designed to be specific to 64-bit Itanium but also includes generic specifications that apply to any platform. This C++ ABI is also implemented by other compiler vendors on some platforms, notably GNU/Linux and BSD systems. We have tried hard to provide a stable ABI that will be compatible with future GCC releases, but it is possible that we will encounter problems that make this difficult. Such problems could include different interpretations of the C++ ABI by different vendors, bugs in the ABI, or bugs in the implementation of the ABI in different compilers. GCC"s -Wabi switch warns when G++ generates code that is probably not compatible with the C++ ABI.

Clang也同樣在Linux和BSD系系統(包括Mac OS X的Darwin)實現Itanium C++ ABI,而在Windows上為了跟MSVC兼容實現MSVC C++ ABI。

那麼這個Itanium C++ ABI到底是怎樣的?這裡有一份文檔草案:Itanium C++ ABI

其中這段描述了非POD類的實例的布局:2.4 Non-POD Class Types

而這段描述了vtable的局部,包括多繼承情況下的布局:2.5 Virtual Table Layout

只要把這篇文檔讀了就可以知道GCC和Clang的C++ ABI。足夠解答題主的疑問。

===============================================================

MSVC的C++ ABI我不知道有啥特別詳細的文檔。有時候好奇會去看Clang所實現的MSVC C++ ABI是怎樣的;Clang的開發者們肯定對這此有很多逆向經驗了。

===============================================================

順帶一提一些JVM以及CLR對單繼承虛方法的實現。

基於類的面向對象、類在運行時結構不可變、類繼承只有單繼承、虛函數只能單分派的編程語言里,利用vtable實現虛函數/虛方法分派是很常見的技巧(不過不一定是首選技巧)。

有些同學可能被忽悠過說Java啊C#之類的沒有虛函數表。實際上高性能的JVM和CLR實現都還是有用虛函數表來實現虛方法分派。畢竟主要是單繼承的類體系。

(也確實存在完全不使用vtable的JVM實現。通常這種是特別糾結空間開銷的JVM,例如為低端嵌入式設備設計的JVM。這些略奇葩嗯。)

HotSpot VM:(以JDK8的、64位、不開壓縮指針為例)

instanceOopDesc
--&> +0 [ _mark ] InstanceKlass
+8 [ _klass ] --&> +0 [ ... ]
+16 [ ... fields ... ] +8 [ ... ]
... [ ... ]
+n [ vtable[0] ]
+n+8 [ vtable[1] ]
... [ vtable... ]
+m [ itable[0] ]
+m+8 [ itable[1] ]
... [ itable... ]

對象的頭16位元組是「對象頭」,對象頭的第二個欄位是跟vptr等價的一個指針,指向InstanceKlass。

InstanceKlass的開頭是一大堆固定長度的元數據,主要記錄該類型的反射相關的信息;末尾包含該類型的vtable和itable(介面虛方法表)。具體結構這裡就不說了,有興趣的同學歡迎另外開問題。

可以看到,HotSpot VM使用了vtable來實現單繼承虛方法分派,但是對應vptr的欄位並不在對象的+0偏移量而在+4(32位)或+8(64位)偏移量上;對應vtable的數據結構也不在InstanceKlass的+0偏移量開始,而是在一大塊定長的數據之後掛在末尾。

HotSpot VM里,只有虛方法(非private的實例方法)會出現在vtable里;非虛方法(靜態方法或private實例方法)則不會出現在vtable里。

HotSpot VM的itable(Interface Table)跟C++一些實現的VTT思路相似,也是多層vtable。畢竟要解決的問題一樣——多繼承的虛方法分派。

HotSpot VM的vtable項不是指向「可調用的方法入口的指針」,而是一個Method*。Method對象含有Java方法的元數據,其中一個欄位才是真正的「可調用的方法入口」。所以說HotSpot VM的vtable雖然作用跟C++類似,但訪問的間接層比C++要多一層。

CLR:(以32位CLRv2在x86上為例)

Object
-4 [ m_SyncBlockValue ] MethodTable
--&> +0 [ m_pMethTab ] --&> +0 [ ... ]
+4 [ ... fields ... ] +4 [ ... ]
... [ ... ] EEClass
+n [ m_pEEClass ] --&> [ ... ]
... [ ... ]
+m [ m_pDispatchMap ]
+m+4 [ vtable[0] ]
+m+8 [ vtable[1] ]
... [ vtable... ]
+x [ dispatchmap[0] ]
... [ dispatchmap... ]

CLRv2這種對象布局方式跟前面提到的C++的例子非常相似。

對象的+0偏移量上存著跟vptr等價的類型指針,指向MethodTable。

MethodTable里主要存著一些涉及代碼執行、分派還有GC所需的數據;這些數據通常比較熱。它的開頭是一塊定長的數據,末尾掛著可變長的vtable和DispatchMap(相當於itable)。

EEClass存著類型的反射相關元數據;這些數據相對MethodTable里的相對來說比較冷,所以把類型數據分離為兩個對象。EEClass對應到前面C++的例子就是typeinfo,只不過前者包含的反射信息遠多於後者。

CLRv2的MethodTable + EEClass的作用等於HotSpot VM的InstanceKlass。

相比HotSpot VM,CLRv2的MethodTable稍微更純粹一些,更接近C++那種vtable,而把跟執行關係不大的、反射相關的元數據挪到一個單獨的對象里。

MethodTable內嵌的vtable可以看作兩部分:前半部分跟C++的常見實現類似,按順序排列虛方法;後半部分則排列著該類型所定義的非虛方法。也就是說一個類型所定義的所有方法都會在對應的MethodTable的vtable里出現,但只有前半部分參與虛方法分派。

MethodTable里的vtable項跟C++的類似,是方法的「可調用方法入口」。

JIT編譯過的方法就會有真正可調用的方法入口;但在CLRv2上,除非一個方法有被NGen或者是native方法,不然它得等到第一次被調用的時候才會被JIT編譯。某個方法在被JIT編譯前,其對應的MethodTable的vtable項會是一個pre-JIT stub,用於實現「JIT編譯的觸發」。

請參考另一個回答:什麼是樁代碼(Stub)? - RednaxelaFX 的回答

CLRv4里對象布局有細微變化。

Sun Classic VM:(以32位Sun JDK 1.0.2在x86上為例)

HObject ClassObject
-4 [ hdr ]
--&> +0 [ obj ] --&> +0 [ ... fields ... ]
+4 [ methods ]
methodtable ClassClass
&> +0 [ classdescriptor ] --&> +0 [ ... ]
+4 [ vtable[0] ] methodblock
+8 [ vtable[1] ] --&> +0 [ ... ]
... [ vtable... ]

這是一種使用「句柄」(handle)的對象模型。Java的引用實現為指向handle的指針,通過handle進一步訪問對象的實例欄位或虛方法表。除了handle和GC的實現外,所有對Java對象的訪問都必須通過handle這個間接層,而不能使用直接指向對象實例的指針。對象的欄位內容存儲在ClassObject類型的結構體里。

上圖中的HObject是對java.lang.Object及大部分其它類實例的handle;數組實例和少量特殊類的實例有特殊的handle實現,這裡不展開講。

所有實例方法都會出現在methodtable的vtable里。

ClassObject的對象頭hdr主要用於存儲這個ClassObject的大小,便於實現GC堆的前向線性遍歷。

這種做法下,handle是固定大小的,而包含對象實例欄位的ClassObject是可變長的。兩者被分配在不同的區域:handle區與對象區。Handle區不會碎片化,因為所有handle都一樣大;這樣對handle的自動內存管理只需要用mark-sweep而不需要移動這些handle,那麼handle的地址就是固定的,指向它的指針也就是穩定的。

反之,對象區里的對象不一定一樣大,有可能在長時間運行、反覆分配和釋放內存之後出現碎片化,所以需要偶爾做compaction來消除碎片化,於是對象就有可能移動。

優點:

通過固定地址的handle去指向可變地址的對象實例數據,這個額外的間接層允許Sun Classic VM實現保守式的mark-sweep/compact GC,也就是說就算不能精確知道哪些數據是Java對象的引用,也可以安全地移動對象。這是一種相當偷懶的做法。

缺點:

顯然,相比使用直接指針,這種使用handle的做法多了一個間接層。它的效率實在不太好,無論是空間效率(handle佔用了額外的空間)還是時間效率(訪問對象欄位和虛方法分派等操作)都比使用直接指針的做法差。

具體到Sun Classic VM的handle的具體設計,有趣的一點是它把methodtable的指針放在handle里而不是跟對象實例數據放在一起。這樣的「fat handle」設計至少執行效率上比只包含對象實例指針的handle要快一些——前者訪問虛方法表用兩次間接:handle-&>methods-&>vtable[index];後者則要三次間接:handle-&>obj-&>methods-&>vtable[index]。

這種布局跟《Inside the C++ Object Model》Section 1.1的「A Table-driven Object Model」(Fig. 1.2)方案非常相似。略奇葩。

有趣的是採用這種布局的還不只有這個Sun Classic VM,還有:

  • DVM_ObjectRef 參考1:ポリモルフィズム 參考2:Diksamのポリモルフィズム

  • & 回頭繼續追加

值得一提的是,CLR的對象模型追根溯源可以追到這個Sun Classic VM上。

微軟從Sun獲得了Java的授權,並通過授權得到了Sun JDK 1.0.x的源碼,以此為基礎開發了MSJVM。

MSJVM為了改善性能,把Sun Classic VM的HObject和ClassObject合併回到一起,改用直接指針實現Java對象的引用。對象模型被改造成了這樣:

Object
-4 [ sync block index ] methodtable ClassClass
--&> +0 [ methods ] --&> +0 [ class desc ] --&> +0 [ ... ]
+4 [ ... fields ... ] +4 [ vtable[0] ]
+8 [ vtable[1] ]
... [ vtable... ]

是不是看起來跟前面的CLRv2的對象模型看起來非常相似了?

這個歷史的一些片段請參考另一個問題的回答:微軟當年的 J++ 究竟是什麼?為什麼 Sun 要告它? - RednaxelaFX 的回答

JRockit VM:(以32位JRockit R28在x86上為例)

JRockit x86
Object ClassBlock Class
--&> +0 [ Class block ] --&> +0 [ clazz ] --&> +0 [ ... ]
+4 [ Lock word ] +4 [ ... ]
+8 [ ... fields ... ] ... [ ... ]
+n [ vtable[0] ]

《Oracle JRockit: The Definitive Guide》第4章第124頁提到了JRockit里的對象布局。

乍一看這跟前面提到的幾個例子很相似。所謂「Class block」指針跟C++的vptr作用類似,而且位於+0偏移量上。

實際上JRockit的ClassBlock里包含的vtable/itable設計有許多精妙的地方,採用了雙向布局,其itable實現了constant time lookup。可惜沒有任何公開文檔描述它,所以這裡也沒辦法展開講。

JRockit VM:(以32位JRockit R28在SPARC上為例)

Object
--&> +0 [ Lock word ] ClassBlock Class
+4 [ Class block ] --&> +0 [ clazz ] --&> +0 [ ... ]
+8 [ ... fields ... ] +4 [ ... ]
... [ ... ]
+n [ vtable[0] ]

上面提到JRockit的vtable/itable設計沒有公開文檔詳細描述,那這裡為啥要舉這個例子?

因為JRockit在SPARC上實現的對象頭跟在x86上的順序相反,挺有趣的。這演示了就算是同一個名字的VM,在不同平台或者說不同條件下也可能有不同的實現。

SPARC上的JRockit的「Class block」欄位就不是對象頭的第一個欄位,而是第二個。

無獨有偶,類似的差異設計在Maxine VM里也存在:Objects - Maxine VM

Sable VM

SableVM是由加拿大的McGill大學研發的研究性JVM。它實現了許多有趣的概念。

這裡相關的兩個概念是:雙向對象布局(bidirectional object layout)和稀疏介面方法分派表(sparse interface method dispatch table)。

Sable VM的雙向對象布局把所有引用類型欄位放在對象頭的一側,而把原始類型欄位放在對象頭的另一側。像這樣的感覺:

_svmt_object_instance_struct
-8 [ ... reference field ... ]
-4 [ reference field 0 ]
--&> +0 [ lockword ] _svmt_vtable _svmt_type_info
+4 [ vtable ] --&> +0 [ type ] --&> +0 [ ... ]
+8 [ non-reference field 0 ] +4 [ ... ]
+12 [ ... non-reference field ... ]

引用類型的欄位全部在相對於對象頭的負偏移量上,而非引用類型(原始類型)欄位則全部在正偏移量上。這樣,在實現類繼承的時候,可以保證以下兩點同時滿足:

  • 在同一個繼承鏈上,同一個欄位總是在同一個偏移量上
  • 所有引用類型欄位都在連續的內存塊里

於是GC掃描對象中的引用時就可以很高效地掃描連續的內存塊。

上圖更直觀。傳統設計可能如下:

而Sable VM的雙向對象布局則是:

(圖片引用自 SableVM: A Research Framework for the Efficient Execution of Java Bytecode)

Sable VM的vtable設計也很精妙。跟對象布局相似也採用雙向布局,正向(正偏移量)上放的是跟C++實現類似的vtable,而負偏移量上放的是稀疏的介面方法分派表,如圖:

(圖片引用自 SableVM: A Research Framework for the Efficient Execution of Java Bytecode )先寫這麼多。


沒事做,來答一下吧。。。(話說以下結論,只是cl編譯出來的結果,g++沒實驗) 寫了個小程序:

#include&

using namespace std;

class B1

{

public:

virtual void f1()

{

cout &<&< "this virtual f1" &<&< endl;

}

int int_in_b1;

};

int main()

{

B1 b1;

b1.f1();

}

以下是用windbg來debug的結果,輸入??b1(讓windbg計算b1的地址)

0:000:x86&> ??b1

class B1 * 0x00d6f9bc

+0x000 __VFN_table : 0x00c481e0

+0x004 int_in_b1 : 0n12650832

你看b1的地址其實是0x00d6f9bc, 換到你那個程序就是(int *)(bs), 但你別忘了這個只指向vftable的地址的地址, 如果你把0x00fff894指向所有的內容連續打出來是,你會發現第一個4 byte的內容是00c481e0, 這個才是vftable的實際地址。

0:000:x86&> dd(列印後面地址的內容) 0x00d6f9bc

00d6f9bc 00c481e0 00c10950 00d6fa0c 00c06892

下面看看vftable里都有啥

0:000:x86&> dd 00c481e0

00c481e0 00be132a 00000000 73696874 72697620

如果你查看00be132a地址上的指令:

0:000:x86&> u 00be132a L1

Source!ILT+805(?f1B1UAEXXZ):

00be132a e921440000 jmp Source!B1::f1 (00be5750)

看終於跑到f1去了。

希望對你有幫助,話說怎麼感覺把簡單的問題說複雜了。。。


lz其實是把vtable地址、對象地址和成員函數地址搞混了。。


其實從語義上講,首先你把(int*)(bs)化簡為x,那麼(int*)(bs)和(int*)*(int*)(bs) 就是x和*x。那答案很明顯了,x和*x顯然是不一樣的,除非你故意搞出來。至於原理方面就看 @RednaxelaFX 的答案好了。


顯然題主明白虛函數表地址中存放的是什麼東西.--------是虛函數的地址

虛函數表中存放的是虛函數的地址,那麼(int*)(bs)代表了虛函數表的地址,指向第一個變數,也就是第一個虛函數的地址。

--------------------------------那麼函數的執行呢?--------------------

所以*(int*)(bs)取到了函數指令開始執行的位置(即存放的函數地址對應的內容),也就是函數地址當中的內容,然後就可以拿它開始執行了,

也就是*(int*)(bs)指向了Base:f()-----------------------------------------------------------------------------------


感謝樓上同學讓我知道了原來c++沒規定虛函數的實現方式,學到了。

……接下來是解答,僅討論vc下的虛函數。

其實很簡單,先把你那些礙眼的強制轉換拿掉,bs是對象的地址(不知道你提這個幹嘛),bs是對象,*bs是虛表,(*bs)+0,4,8……就是虛函數的地址了。

去看看彙編吧,簡單明了

ps:*bs和bs,是一個東西


根據《inside the c++ object model》,大部分編譯器實際上vtable的首地址存放的是typeinfo,而非函數。


第一個是對象的地址,第二個是從這個地址取了4位元組的內容,而這個內容就是虛表的地址,可以參考下這個也談 C++ 的虛函數表.pdf


建議lz先搞清楚C指針


(int*)(bs)存儲虛函數表地址的地址,該地址上存儲的值是虛函數表的地址,對其取值*(int*)(bs)就是虛函數表的地址,然後只是進行了指針類型轉換(int*)*(int*)(bs),該地址上存儲的值是虛函數的地址,對其取值*((int*)*(int*)(bs))就獲得了首個虛函數地址。


函數名是地址,變數名是數值,你拿個變數名和值比較能一樣嗎?


推薦閱讀:

C++ 鏈接時間過長,如何找到原因?
C 語言比 C++ 更強大嗎?
如何評價 C++14 ?
【for(int i=10, j=1; i=j=0; i++, j--)()】將循環幾次?

TAG:程序員 | 面向對象編程 | C | 虛函數C |