C++虚函数的实现
http://blog.kongfy.com/2015/08/探索c虛函數在g中的實現/?utm_source=tuicool&utm_medium=referral
https://blog.csdn.net/haoel/article/details/1948051
目錄
一、虛函數表解析
前言
虛函數表
一般繼承(無虛函數覆蓋)
一般繼承(有虛函數覆蓋)
多重繼承(無虛函數覆蓋)
多重繼承(有虛函數覆蓋)
安全性
1、通過父類型的指針訪問子類自己的虛函數
2、訪問non-public的虛函數
結束語
附錄一:VC中查看虛函數表
二、通過匯編探索C++虛函數在g++中的實現
先寫個例子
g++如何實現虛函數的動態綁定?
vtbl在何時被創建?vptr又是在何時被初始化?
在Linux中運行的C++程序虛擬存儲器中,vptr、vtbl存放在虛擬存儲的什么位置?
總結
一、虛函數表解析
前言
C++中的虛函數的作用主要是實現了多態的機制。關于多態,簡而言之就是用父類型別的指針指向其子類的實例,然后通過父類的指針調用實際子類的成員函數。這種技術可以讓父類的指針有“多種形態”,這是一種泛型技術(更多時候泛型特指模板編程)。所謂泛型技術,說白了就是試圖使用不變的代碼來實現可變的算法。比如:模板技術,RTTI技術,虛函數技術,要么是試圖做到在編譯時決議,要么試圖做到運行時決議。
關于虛函數的使用方法,我在這里不做過多的闡述。大家可以看看相關的C++的書籍。在這篇文章中,我只想從虛函數的實現機制上面為大家 一個清晰的剖析。
當然,相同的文章在網上也出現過一些了,但我總感覺這些文章不是很容易閱讀,大段大段的代碼,沒有圖片,沒有詳細的說明,沒有比較,沒有舉一反三。不利于學習和閱讀,所以這是我想寫下這篇文章的原因。也希望大家多給我提意見。
言歸正傳,讓我們一起進入虛函數的世界。
虛函數表
對C++ 了解的人都應該知道虛函數(Virtual Function)是通過一張虛函數表(Virtual Table)來實現的。簡稱為V-Table。在這個表中,主是要一個類的虛函數的地址表,這張表解決了繼承、覆蓋的問題,保證其容真實反應實際的函數。這樣,在有虛函數的類的實例中這個表被分配在了這個實例的內存中,所以,當我們用父類的指針來操作一個子類的時候,這張虛函數表就顯得由為重要了,它就像一個地圖一樣,指明了實際所應該調用的函數。
這里我們著重看一下這張虛函數表。C++的編譯器應該是保證虛函數表的指針存在于對象實例中最前面的位置(這是為了保證取到虛函數表的有最高的性能——如果有多層繼承或是多重繼承的情況下)。 這意味著我們通過對象實例的地址得到這張虛函數表,然后就可以遍歷其中函數指針,并調用相應的函數。
聽我扯了那么多,我可以感覺出來你現在可能比以前更加暈頭轉向了。 沒關系,下面就是實際的例子,相信聰明的你一看就明白了。
假設我們有這樣的一個類:
class Base {public:virtual void f() { cout << "Base::f" << endl; }virtual void g() { cout << "Base::g" << endl; }virtual void h() { cout << "Base::h" << endl; } };按照上面的說法,我們可以通過Base的實例來得到虛函數表。 下面是實際例程:
typedef void(*Fun)(void); Base b; Fun pFun = NULL; cout << "虛函數表地址:" << (int*)(&b) << endl; cout << "虛函數表 — 第一個函數地址:" << (int*)*(int*)(&b) << endl; // Invoke the first virtual function? pFun = (Fun)*((int*)*(int*)(&b)); pFun();實際運行經果如下:(Windows XP+VS2003,??Linux 2.6.22 + GCC 4.1.3)
虛函數表地址:0012FED4 虛函數表 — 第一個函數地址:0044F148 Base::f通過這個示例,我們可以看到,我們可以通過強行把&b轉成int *,取得虛函數表的地址,然后,再次取址就可以得到第一個虛函數的地址了,也就是Base::f(),這在上面的程序中得到了驗證(把int* 強制轉成了函數指針)。通過這個示例,我們就可以知道如果要調用Base::g()和Base::h(),其代碼如下:
(Fun)*((int*)*(int*)(&b)+0);??// Base::f() (Fun)*((int*)*(int*)(&b)+1);??// Base::g() (Fun)*((int*)*(int*)(&b)+2);??// Base::h()這個時候你應該懂了吧。什么?還是有點暈。也是,這樣的代碼看著太亂了。沒問題,讓我畫個圖解釋一下。如下所示:
注意:在上面這個圖中,我在虛函數表的最后多加了一個結點,這是虛函數表的結束結點,就像字符串的結束符“/0”一樣,其標志了虛函數表的結束。這個結束標志的值在不同的編譯器下是不同的。在WinXP+VS2003下,這個值是NULL。而在Ubuntu 7.10 + Linux 2.6.22 + GCC 4.1.3下,這個值是如果1,表示還有下一個虛函數表,如果值是0,表示是最后一個虛函數表。
下面,我將分別說明“無覆蓋”和“有覆蓋”時的虛函數表的樣子。沒有覆蓋父類的虛函數是毫無意義的。我之所以要講述沒有覆蓋的情況,主要目的是為了給一個對比。在比較之下,我們可以更加清楚地知道其內部的具體實現。
一般繼承(無虛函數覆蓋)
下面,再讓我們來看看繼承時的虛函數表是什么樣的。假設有如下所示的一個繼承關系:
請注意,在這個繼承關系中,子類沒有重載任何父類的函數。那么,在派生類的實例中,其虛函數表如下所示:
對于實例:Derive d; 的虛函數表如下:
我們可以看到下面幾點:
- 虛函數按照其聲明順序(C++所有初始化順序都是按照聲明的順序)放于表中。
- 父類的虛函數在子類的虛函數前面。
- (還有很重要的一點就是在子類的內存布局中,父類和子類共用一個虛表,在多繼承中子類和第一個父類共享)
我相信聰明的你一定可以參考前面的那個程序,來編寫一段程序來驗證。
一般繼承(有虛函數覆蓋)
覆蓋父類的虛函數是很顯然的事情,不然,虛函數就變得毫無意義。下面,我們來看一下,如果子類中有虛函數重載了父類的虛函數,會是一個什么樣子?假設,我們有下面這樣的一個繼承關系。
為了讓大家看到被繼承過后的效果,在這個類的設計中,我只覆蓋了父類的一個函數:f()。那么,對于派生類的實例,其虛函數表會是下面的一個樣子:
我們從表中可以看到下面幾點,
1)覆蓋的f()函數被放到了虛表中原來父類虛函數的位置。
2)沒有被覆蓋的函數依舊。
這樣,我們就可以看到對于下面這樣的程序,
Base *b = new Derive(); b->f();由b所指的內存中的虛函數表的f()的位置已經被Derive::f()函數地址所取代,于是在實際調用發生時,是Derive::f()被調用了。這就實現了多態。
多重繼承(無虛函數覆蓋)
下面,再讓我們來看看多重繼承中的情況,假設有下面這樣一個類的繼承關系。注意:子類并沒有覆蓋父類的函數。
對于子類實例中的虛函數表,是下面這個樣子:
我們可以看到:
1)??每個父類都有自己的虛表。
2)??子類的成員函數被放到了第一個父類的表中。(所謂的第一個父類是按照聲明順序來判斷的)
這樣做就是為了解決不同的父類類型的指針指向同一個子類實例,而能夠調用到實際的函數。
多重繼承(有虛函數覆蓋)
下面我們再來看看,如果發生虛函數覆蓋的情況。
下圖中,我們在子類中覆蓋了父類的f()函數。
下面是對于子類實例中的虛函數表的圖:
我們可以看見,三個父類虛函數表中的f()的位置被替換成了子類的函數指針。這樣,我們就可以任一靜態類型的父類來指向子類,并調用子類的f()了。如:
Derive d; Base1 *b1 = &d; Base2 *b2 = &d; Base3 *b3 = &d;b1->f(); //Derive::f() b2->f(); //Derive::f() b3->f(); //Derive::f()b1->g(); //Base1::g() b2->g(); //Base2::g() b3->g(); //Base3::g()安全性
每次寫C++的文章,總免不了要批判一下C++。這篇文章也不例外。通過上面的講述,相信我們對虛函數表有一個比較細致的了解了。水可載舟,亦可覆舟。下面,讓我們來看看我們可以用虛函數表來干點什么壞事吧。
1、通過父類型的指針訪問子類自己的虛函數
我們知道,子類沒有重載父類的虛函數是一件毫無意義的事情。因為多態也是要基于函數重載的。雖然在上面的圖中我們可以看到Base1的虛表中有Derive的虛函數,但我們根本不可能使用下面的語句來調用子類的自有虛函數:
Base1 *b1 = new Derive(); b1->f1();??//編譯出錯任何妄圖使用父類指針想調用子類中的未覆蓋父類的成員函數的行為都會被編譯器視為非法,所以,這樣的程序根本無法編譯通過。但在運行時,我們可以通過指針的方式訪問虛函數表來達到違反C++語義的行為。(關于這方面的嘗試,通過閱讀后面附錄的代碼,相信你可以做到這一點)
2、訪問non-public的虛函數
另外,如果父類的虛函數是private或是protected的,但這些非public的虛函數同樣會存在于虛函數表中,所以,我們同樣可以使用訪問虛函數表的方式來訪問這些non-public的虛函數,這是很容易做到的。如:
class Base { private:virtual void f() { cout << "Base::f" << endl; } };class Derive : public Base{};typedef void(*Fun)(void);void main() {Derive d;Fun??pFun = (Fun)*((int*)*(int*)(&d)+0);pFun(); }結束語
C++這門語言是一門Magic的語言,對于程序員來說,我們似乎永遠摸不清楚這門語言背著我們在干了什么。需要熟悉這門語言,我們就必需要了解C++里面的那些東西,需要去了解C++中那些危險的東西。不然,這是一種搬起石頭砸自己腳的編程語言。
附錄一:VC中查看虛函數表
我們可以在VC的IDE環境中的Debug狀態下展開類的實例就可以看到虛函數表了(并不是很完整的)
附錄 二:例程
下面是一個關于多重繼承的虛函數表訪問的例程:
#include <iostream> using namespace std;class Base1 { public:virtual void f() { cout << "Base1::f" << endl; }virtual void g() { cout << "Base1::g" << endl; }virtual void h() { cout << "Base1::h" << endl; } };class Base2 { public:virtual void f() { cout << "Base2::f" << endl; }virtual void g() { cout << "Base2::g" << endl; }virtual void h() { cout << "Base2::h" << endl; } };class Base3 { public:virtual void f() { cout << "Base3::f" << endl; }virtual void g() { cout << "Base3::g" << endl; }virtual void h() { cout << "Base3::h" << endl; } };class Derive : public Base1, public Base2, public Base3 { public:virtual void f() { cout << "Derive::f" << endl; }virtual void g1() { cout << "Derive::g1" << endl; } };typedef void(*Fun)(void);int main() {Fun pFun = NULL;Derive d;int** pVtab = (int**)&d;//Base1's vtable//pFun = (Fun)*((int*)*(int*)((int*)&d+0)+0);pFun = (Fun)pVtab[0][0];pFun();//pFun = (Fun)*((int*)*(int*)((int*)&d+0)+1);pFun = (Fun)pVtab[0][1];pFun();//pFun = (Fun)*((int*)*(int*)((int*)&d+0)+2);pFun = (Fun)pVtab[0][2];pFun();//Derive's vtable//pFun = (Fun)*((int*)*(int*)((int*)&d+0)+3);pFun = (Fun)pVtab[0][3];pFun();//The tail of the vtablepFun = (Fun)pVtab[0][4];cout<<pFun<<endl;//Base2's vtable//pFun = (Fun)*((int*)*(int*)((int*)&d+1)+0);pFun = (Fun)pVtab[1][0];pFun();//pFun = (Fun)*((int*)*(int*)((int*)&d+1)+1);pFun = (Fun)pVtab[1][1];pFun();pFun = (Fun)pVtab[1][2];pFun();//The tail of the vtablepFun = (Fun)pVtab[1][3];cout<<pFun<<endl;//Base3's vtable//pFun = (Fun)*((int*)*(int*)((int*)&d+1)+0);pFun = (Fun)pVtab[2][0];pFun();//pFun = (Fun)*((int*)*(int*)((int*)&d+1)+1);pFun = (Fun)pVtab[2][1];pFun();pFun = (Fun)pVtab[2][2];pFun();//The tail of the vtablepFun = (Fun)pVtab[2][3];cout<<pFun<<endl;return 0; }二、通過匯編探索C++虛函數在g++中的實現
本文是我在追查一個詭異core問題的過程中收獲的一點心得,把公司項目相關的背景和特定條件去掉后,僅取其中通用的C++虛函數實現部分知識記錄于此。
在開始之前,原諒我先借用一張圖黑一下C++:
“無敵”的C++
如果你也在寫C++,請一定小心…至少,你要先有所了解:當你在寫虛函數的時候,g++在寫什么?
?
先寫個例子
為了探索C++虛函數的實現,我們首先編寫幾個用來測試的類,代碼如下:
#include <iostream> using namespace std; class Base1 { public:virtual void f() {cout << "Base1::f()" << endl;} };class Base2 { public:virtual void g() {cout << "Base2::g()" << endl;} };class Derived : public Base1, public Base2 { public:virtual void f() {cout << "Derived::f()" << endl;}virtual void g() {cout << "Derived::g()" << endl;}virtual void h() {cout << "Derived::h()" << endl;}};int main(int argc, char *argv[]) {Derived ins;Base1 &b1 = ins;Base2 &b2 = ins;Derived &d = ins;b1.f();b2.g();d.f();d.g();d.h(); }代碼采用了多繼承,是為了更多的分析出g++的實現本質,用UML簡單的畫一下繼承關系:
示例代碼UML圖
代碼的輸出結果和預期的一致,C++實現了虛函數覆蓋功能,代碼輸出如下:
Derived::f() Derived::g() Derived::f() Derived::g() Derived::h()我寫這篇文章的重點是嘗試解釋g++編譯在底層是如何實現虛函數覆蓋和動態綁定的,因此我假定你已經明白基本的虛函數概念以及虛函數表(vtbl)和虛函數表指針(vptr)的概念和在繼承實現中所承擔的作用,如果你還不清楚這些概念,建議你在繼續閱讀下面的分析前先補習一下相關知識,陳皓的《C++虛函數表解析》系列是一個不錯的選擇。開始分析!
通過本文,我將嘗試解答下面這三個問題:
首先是第一個問題:
g++如何實現虛函數的動態綁定?
這個問題乍看簡單,大家都知道是通過vptr和vtbl實現的,那就讓我們刨根問底的看一看,g++是如何利用vptr和vtbl實現的。
第一步,使用?-fdump-class-hierarchy?參數導出g++生成的類內存結構:
Vtable for Base1Base1::_ZTV5Base1: 3u entries0???? (int (*)(...))04???? (int (*)(...))(& _ZTI5Base1)8???? Base1::fClass Base1size=4 align=4base size=4 base align=4Base1 (0xb6acb438) 0 nearly-emptyvptr=((& Base1::_ZTV5Base1) + 8u)Vtable for Base2Base2::_ZTV5Base2: 3u entries0???? (int (*)(...))04???? (int (*)(...))(& _ZTI5Base2)8???? Base2::gClass Base2size=4 align=4base size=4 base align=4Base2 (0xb6acb474) 0 nearly-emptyvptr=((& Base2::_ZTV5Base2) + 8u)Vtable for DerivedDerived::_ZTV7Derived: 8u entries0???? (int (*)(...))04???? (int (*)(...))(& _ZTI7Derived)8???? Derived::f12????Derived::g16????Derived::h20????(int (*)(...))-0x00000000424????(int (*)(...))(& _ZTI7Derived)28????Derived::_ZThn4_N7Derived1gEvClass Derivedsize=8 align=4base size=8 base align=4Derived (0xb6b12780) 0vptr=((& Derived::_ZTV7Derived) + 8u)Base1 (0xb6acb4b0) 0 nearly-emptyprimary-for Derived (0xb6b12780)Base2 (0xb6acb4ec) 4 nearly-emptyvptr=((& Derived::_ZTV7Derived) + 28u)如果看不明白這些亂七八糟的輸出,沒關系(當然能看懂更好),把上面的輸出轉換成圖的形式就清楚了:
vptr和vtbl
其中有幾點尤其值得注意:
有了內存布局后,接下來觀察g++是如何在這樣的內存布局上進行動態綁定的。
g++對每個類的指針或引用對象,如果是其類聲明中虛函數,使用位于其內存空間首地址上的vptr尋找找到vtbl進而得到函數地址。如果是父類聲明而子類未覆蓋的虛函數,使用對應父類的vptr進行尋址。
先來驗證一下,使用?objdump -S?得到?b1.f()?的匯編指令:
????b1.f();8048734:?????? 8b 44 24 24???????????? mov????0x24(%esp),%eax????# 得到Base1對象的地址8048738:?????? 8b 00?????????????????? mov????(%eax),%eax????????# 對對象首地址上的vptr進行解引用,得到vtbl地址804873a:?????? 8b 10?????????????????? mov????(%eax),%edx????????# 解引用vtbl上第一個虛函數的地址804873c:?????? 8b 44 24 24???????????? mov????0x24(%esp),%eax8048740:?????? 89 04 24????????????????mov????%eax,(%esp)8048743:?????? ff d2?????????????????? call?? *%edx??????????????# 調用函數其過程和我們的分析完全一致,聰明的你可能發現了,b2怎么辦呢?Derived類的實例內存首地址上的vptr并不是Base2類的啊!答案實際上是因為g++在引用賦值語句?Base2?&b2?=?ins?上動了手腳:
????Derived ins;804870d:?????? 8d 44 24 1c???????????? lea????0x1c(%esp),%eax8048711:?????? 89 04 24????????????????mov????%eax,(%esp)8048714:?????? e8 c3 01 00 00??????????call?? 80488dc <_ZN7DerivedC1Ev>Base1 &b1 = ins;8048719:?????? 8d 44 24 1c???????????? lea????0x1c(%esp),%eax804871d:?????? 89 44 24 24???????????? mov????%eax,0x24(%esp)Base2 &b2 = ins;8048721:?????? 8d 44 24 1c???????????? lea????0x1c(%esp),%eax?? # 獲得ins實例地址8048725:?????? 83 c0 04????????????????add????$0x4,%eax???????? # 添加一個指針的偏移量8048728:?????? 89 44 24 28???????????? mov????%eax,0x28(%esp)?? # 初始化引用Derived &d = ins;804872c:?????? 8d 44 24 1c???????????? lea????0x1c(%esp),%eax8048730:?????? 89 44 24 2c???????????? mov????%eax,0x2c(%esp)雖然是指向同一個實例的引用,根據引用類型的不同,g++編譯器會為不同的引用賦予不同的地址。例如b2就獲得一個指針的偏移量,因此才保證了vptr的正確性。
PS:我們順便也證明了C++中的引用的真實身份就是指針…
接下來進入第二個問題:
vtbl在何時被創建?vptr又是在何時被初始化?
既然我們已經知道了g++是如何通過vptr和vtbl來實現虛函數魔法的,那么vptr和vtbl又是在什么時候被創建的呢?
vptr是一個相對容易思考的問題,因為vptr明確的屬于一個實例,所以vptr的賦值理應放在類的構造函數中。g++為每個有虛函數的類在構造函數末尾中隱式的添加了為vptr賦值的操作。
同樣通過生成的匯編代碼驗證:
class Derived : public Base1, public Base2{80488dc:?????? 55??????????????????????push?? %ebp80488dd:?????? 89 e5?????????????????? mov????%esp,%ebp80488df:?????? 83 ec 18????????????????sub????$0x18,%esp80488e2:?????? 8b 45 08????????????????mov????0x8(%ebp),%eax80488e5:?????? 89 04 24????????????????mov????%eax,(%esp)80488e8:?????? e8 d3 ff ff ff??????????call?? 80488c0 <_ZN5Base1C1Ev>80488ed:?????? 8b 45 08????????????????mov????0x8(%ebp),%eax80488f0:?????? 83 c0 04????????????????add????$0x4,%eax80488f3:?????? 89 04 24????????????????mov????%eax,(%esp)80488f6:?????? e8 d3 ff ff ff??????????call?? 80488ce <_ZN5Base2C1Ev>80488fb:?????? 8b 45 08????????????????mov????0x8(%ebp),%eax80488fe:?????? c7 00 48 8a 04 08?????? movl?? $0x8048a48,(%eax)8048904:?????? 8b 45 08????????????????mov????0x8(%ebp),%eax8048907:?????? c7 40 04 5c 8a 04 08????movl?? $0x8048a5c,0x4(%eax)804890e:?????? c9??????????????????????leave804890f:?????? c3??????????????????????ret可以看到在代碼中,Derived類的構造函數為實例的兩個vptr賦初值,可是,這兩個初值居然是立即數!立即數!立即數!這說明了vtbl的生成并不是運行時的,而是在編譯期就已經確定了存放在這兩個地址上的!
這個地址不出意料的屬于.rodata(只讀數據段),使用?objdump -s -j .rodata?提取出對應的內存觀察:
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | 80489e0 03000000 01000200 00000000 42617365??............Base 80489f0 313a3a66 28290042 61736532 3a3a6728??1::f().Base2::g( 8048a00 29004465 72697665 643a3a66 28290044??).Derived::f().D 8048a10 65726976 65643a3a 67282900 44657269??erived::g().Deri 8048a20 7665643a 3a682829 00000000 00000000??ved::h()........ 8048a30 00000000 00000000 00000000 00000000??................ 8048a40 00000000 a08a0408 34880408 68880408??........4...h... 8048a50 94880408 fcffffff a08a0408 60880408??............`... 8048a60 00000000 c88a0408 08880408 00000000??................ 8048a70 00000000 d88a0408 dc870408 37446572??............7Der 8048a80 69766564 00000000 00000000 00000000??ived............ 8048a90 00000000 00000000 00000000 00000000??................ 8048aa0 889f0408 7c8a0408 00000000 02000000??....|........... 8048ab0 d88a0408 02000000 c88a0408 02040000??................ 8048ac0 35426173 65320000 a89e0408 c08a0408??5Base2.......... 8048ad0 35426173 65310000 a89e0408 d08a0408??5Base1.......... |
由于程序運行的機器是小端機,經過簡單的轉換就可以得到第一個vptr所指向的內存中的第一條數據為0x08048834,如果把這個數據解釋為函數地址到匯編文件中查找,會得到:
| 1 2 3 4 5 6 7 8 9 10 | 08048834 <_ZN7Derived1fEv>: }; ? class Derived : public Base1, public Base2 { public: ????virtual void f() { 8048834:?????? 55??????????????????????push?? %ebp 8048835:?????? 89 e5?????????????????? mov????%esp,%ebp 8048837:?????? 83 ec 18????????????????sub????$0x18,%esp |
Bingo!g++在編譯期就為每個類確定了vtbl的內容,并且在構造函數中添加相應代碼使vptr能夠指向已經填好的vtbl的地址。
這也同時為我們解答了第三個問題:
在Linux中運行的C++程序虛擬存儲器中,vptr、vtbl存放在虛擬存儲的什么位置?
直接看圖:
虛函數在虛擬存儲器中的位置
圖中灰色部分應該是你已經熟悉的,彩色部分內容和相關聯的箭頭描述了虛函數調用的過程(圖中展示的是通過new在堆區創建實例的情況,與示例代碼有所區別,小失誤,不要在意):當調用虛函數時,首先通過位于棧區的實例的指針找到位于堆區中的實例地址,然后通過實例內存開頭處的vptr找到位于.rodata段的vtbl,再根據偏移量找到想要調用的函數地址,最后跳轉到代碼段中的函數地址執行目標函數。
總結
研究這些問題的起因是因為公司代碼出現了非常奇葩的行為,經過追查定位到虛函數表出了問題,因此才有機會腳踏實地的對虛函數實現進行一番探索。
也許你會想,即使我不明白這些底層原理,也一樣可以正常的使用虛函數,也一樣可以寫出很好的面相對象的代碼啊?
這一點兒也沒有錯,但是,C++作為全宇宙最復雜的程序設計語言,它提供的功能異常強大,無異于武俠小說中鋒利無比的屠龍寶刀。但武功不好的菜鳥如果胡亂舞弄寶刀,卻很容易反被其所傷。只有了解了C++底層的原理和機制,才能讓我們把C++這把屠龍寶刀使用的更加得心應手,變化出更加華麗的招式,成為真正的武林高手。
總結
- 上一篇: pygame 实现键盘鼠标映射
- 下一篇: 短学期微机接口课程设计