眼見為實(1):C++基本概念在編譯器中的實現
眼見為實(1):C++基本概念在編譯器中的實現
對于C++對象模型,相信很多程序員都耳熟能詳。 本文試圖通過一個簡單的例子演示一些C++基本概念在編譯器中的實現,以期達到眼見為實的效果。
本文的演示程序(http://www.fmddlmyy.cn/cpptest.zip)可以從我的個人主頁下載。程序包中包含用VC6、VC7、BCB、Dev-C++和MinGW建立的項目。下文中的打印輸出和匯編代碼主要引自VC6環境。
1 對象空間和虛函數
1.1 對象空間
在我們為對象分配一塊空間時,例如:
CChild1 *pChild = new CChild1();
這塊空間里放著什么東西?
在CChild1沒有虛函數時,CChild1對象空間里依次放著其基類的非靜態成員和其自身的非靜態成員。沒有任何非靜態成員的對象,會有一個字節的占位符。
如果CChild1有虛函數,VC6編譯器會在對象空間的最前面加一個指針,這就是虛函數表指針(Vptr:Virtual function table pointer)。我們來看這么一段代碼:
class CMember1 {
public:
CMember1(){a=0x5678;printf("構造 CMember1/n");}
~CMember1(){printf("析構 CMember1/n");}
int a;
};
class CParent1 {
public:
CParent1(){parent_data=0x1234;printf("構造 CParent1/n");}
virtual ~CParent1(){printf("析構 CParent1/n");}
virtual void test(){printf("調用CParent1::test()/n/n");}
void real(){printf("調用CParent1::test()/n/n");}
int parent_data;
};
class CChild1 : public CParent1 {
public:
CChild1(){printf("構造 CChild1/n");}
virtual ~CChild1(){printf("析構 CChild1/n");}
virtual void test(){printf("調用CChild1::test()/n/n");}
void real(){printf("調用CChild1::test()/n/n");}
CMember1 member;
static int b;
};
CChild1對象的大小是多少?以下是演示程序的打印輸出:
---->派生類對象
對象地址 0x00370FE0
對象大小 12
對象內容
00370FE0: 00410104 00001234 00005678
vptr內容
00410104: 004016a0 00401640 00401f70
CChild1對象的大小是12個字節,包括:Vptr、基類成員變量parent_data、派生類成員變量member。Vptr指向的虛函數表(VTable)就是虛函數地址組成的數組。
1.2 Vptr和VTable
如果我們用VC自帶的dumpbin反匯編Debug版的輸出程序:
dumpbin /disasm test_vc6.exe>a.txt
可以在a.txt中找到:
?test@CChild1@@UAEXXZ:
00401640: 55 push ebp
...
??_ECChild1@@UAEPAXI@Z:
004016A0: 55 push ebp
可見VTable中的兩個地址分別指向CChild1的析構函數和CChild1的成員函數test。這兩個函數是CChild1的虛函數。如果打印兩個CChild1對象的內容,可以發現它們Vptr是相同的,即每個有虛函數的類有一個VTable,這個類的所有對象的Vptr都指向這個VTable。
這里的函數名是不是有點奇怪,附錄二簡略介紹了C++的Name Mangling。
1.3 靜態成員變量
在C++中,類的靜態變量相當于增加了訪問控制的全局變量,不占用對象空間。它們的地址在編譯鏈接時就確定了。例如:如果我們在項目的Link設置中選擇“Generate mapfile”,build后,就可以在生成的map文件中看到:
0003:00002e18 ?b@CChild1@@2HA 00414e18 test1.obj
從打印輸出,我們可以看到CChild1::b的地址正是0x00414E18。其實類定義中的對變量b的聲明僅是聲明而已,如果我們沒有在類定義外 (全局域) 定義這個變量,這個變量根本就不存在。
1.4 調用虛函數
通過在VC調試環境中設置斷點,并切換到匯編顯示模式,我們可以看到調用虛函數的匯編代碼:
16: pChild->test();
(1) mov edx,dword ptr [pChild]
(2) mov eax,dword ptr [edx]
(3) mov esi,esp
(4) mov ecx,dword ptr [pChild]
(5) call dword ptr [eax+4]
語句(1)將對象地址放到寄存器edx,語句(2)將對象地址處的Vptr裝入寄存器eax,語句(5)跳轉到Vptr指向的VTable第二項的地址,即成員函數test。
語句(4)將對象地址放到寄存器ecx,這就是傳入非靜態成員函數的隱含this指針。非靜態成員函數通過this指針訪問非靜態成員變量。
1.5 虛函數和非虛函數
在演示程序中,我們打印了成員函數地址:
printf("CParent1::test地址 0x%08p/n", &CParent1::test);
printf("CChild1::test地址 0x%08p/n", &CChild1::test);
printf("CParent1::real地址 0x%08p/n", &CParent1::real);
printf("CChild1::real地址 0x%08p/n", &CChild1::real);
得到以下輸出:
CParent1::test地址 0x004018F0
CChild1::test地址 0x004018F0
CParent1::real地址 0x00401460
CChild1::real地址 0x00401670
兩個非虛函數的地址很容易理解,在dumpbin的輸出中可以找到它們:
?real@CParent1@@QAEXXZ:
00401460: 55 push ebp
...
?real@CChild1@@QAEXXZ:
00401670: 55 push ebp
為什么兩個虛函數的“地址”是一樣的?其實這里打印的是一段thunk代碼的地址。通過查看dumpbin的輸出,我們可以看到:
??_9@$B3AE:
(6) mov eax,dword ptr [ecx]
(7) jmp dword ptr [eax+4]
如果我們在跳轉到這段代碼前將對象地址放到寄存器ecx,語句(6)就會將對象地址處的Vptr裝入寄存器eax,語句(7)跳轉到Vptr指向的VTable第二項的地址,即成員函數test。基類和派生類VTable的虛函數排列順序是相同的,所以可以共用一段thunk代碼。
這段thunk代碼的用途是通過函數指針調用虛函數。如果我們不取函數地址,編譯器就不會產生這段代碼。請注意不要將本節的thunk代碼與VTable中虛函數地址混淆起來。Thunk代碼根據傳入的對象指針決定調用哪個函數,VTable中的虛函數地址才是真正的函數地址。
1.6 指向虛函數的指針
我們試驗一下通過指針調用虛函數。非靜態成員函數指針必須通過對象指針調用:
typedef void (Parent::*PMem)();
printf("/n---->通過函數指針調用/n");
PMem pm = &Parent::test;
printf("函數指針 0x%08p/n", pm);
(pParent->*pm)();
得到以下輸出:
---->通過函數指針調用
函數指針 0x004018F0
調用CChild1::test()
我們從VC調試環境中復制出這段匯編代碼:
13: (pParent->*pm)();
(8) mov esi,esp
(9) mov ecx,dword ptr [pParent]
(10) call dword ptr [pm]
語句(9)將對象指針放到寄存器ecx中,語句(10)調用函數指針指向的thunk代碼,就是1.5節的語句(6)。下面會發生什么,前面已經說過了。
1.7 多態的實現
經過前面的分析,多態的實現應該是顯而易見的。當用指向派生類對象的基類指針調用虛函數時,因為派生類對象的Vptr指向派生類的VTable,所以調用的當然是派生類的函數。
通過函數指針調用虛函數同樣要經過VTable確定虛函數地址,所以同樣會發生多態,即調用當前對象VTable中的虛函數。
2 構造和析構
2.1 構造函數
下面的語句:
printf("---->構造派生類對象/n");
CChild1 *pChild = new CChild1();
產生以下輸出:
---->構造派生類對象構造 CParent1
構造 CMember1
構造 CChild1
編譯器會在用戶定義的構造函數中加一些代碼:先調用基類的構造函數,然后構造每個成員對象,最后才是程序中的構造函數代碼(以下稱用戶代碼)。下面這段匯編代碼就是編譯器修改過的CChild1類的構造函數:
??0CChild1@@QAE@XZ:
004014D0 push ebp
...
(11) call CParent1::CParent1 (004013b0)
...
(12) call CMember1::CMember1 (00401550)
(13) mov eax,dword ptr [this]
(14) mov dword ptr [eax],offset CChild1::`vftable' (00410104)
(15) push offset string "/xb9/xb9/xd4/xec CChild1/n" (004122a0)
call printf (004022e0)
...
ret
語句(11)調用基類的構造函數,語句(12)構造成員對象,語句(15)以后是用戶代碼。語句(13)和(14)也值得一提:語句(13)將對象地址放到寄存器eax,語句(14)將CChild1類的VTable指針放到對象地址(eax)的起始處。它們建立的正是對象的Vptr。
如果對象是通過new操作符構造的,編譯器會先調用new函數分配對象空間,然后調用上面這個構造函數。
2.2 析構函數
刪除指向派生類對象的指針產生以下輸出:
---->刪除指向派生類對象的基類指針
析構 CChild1
析構 CMember1
析構 CParent1
編譯器會在用戶定義的析構函數中加一些代碼:即先調用用戶代碼,然后析構每個成員對象,最后析構基類的構造函數。下面這段匯編代碼就是編譯器修改過的CChild1類的析構函數:
??1CChild1@@UAE@XZ:
00401590 push ebp
...
push offset string "/xce/xf6/xb9/xb9 CChild1/n" (004122c0)
call printf (004022e0)
...
(16) call CMember1::~CMember1 (00401610)
...
(17) call CParent1::~CParent1 (004013f0)
...
ret
前面是用戶代碼,語句(16)調用成員對象的析構函數,語句(17)調用基類的析構函數。細心的朋友會發現這里的析構函數的地址與前面VTable中析構函數地址不同。其實,它們的名字也不一樣,它們是兩個函數:
??_ECChild1@@UAEPAXI@Z:
004016A0 push ebp
...
(18) call CChild1::~CChild1 (00401590)
...
(19) call operator delete (004023a0)
...
ret 4
如果在調試器中看(或者用dem工具Demangling),第二個析構函數的名字是CChild1::`scalar deleting destructor',前一個析構函數的名字是CChild1::~CChild1。函數CChild1::`scalar deleting destructor'在語句(18)上調用前面的析構函數,在語句(19)上調用delete函數釋放對象空間。
在通過delete刪除對象指針時,需要在析構后釋放對象空間,所以編譯器合成了第二個析構函數。通過VTable調用析構函數,肯定是delete對象指針引發的,所以VTable中放的是第二個析構函數。在析構堆棧上的對象時,只要調用第一個析構函數就可以了。
2.3 虛析構函數
千萬不要將析構函數和虛函數混淆起來。不管析構函數是不是虛函數,編譯器都會按照2.2節的介紹合成析構函數。將析構函數設為虛函數是希望在通過基類指針刪除派生類對象時調用派生類的析構函數。如果析構函數不是虛函數,派生類對象沒有Vptr,編譯器會調用基類的析構函數(在編譯時就確定了)。
這樣,用戶在派生類析構函數中填寫的代碼就不會被調用,派生類成員對象的析構函數也不會被調用。不過,派生類對象空間還是會被正確釋放的,堆管理程序知道對象分配了多少空間。
3 不同的實現
本文的目的只是通過對編譯器內部實現的適當了解,加深對C++基本概念的理解,我們的代碼不應該依賴可能會改變的內部機制。其實各個編譯器對相同機制的實現也會有較大差異。例如:Vptr的位置就可能有多種方案:
- VC的編譯器把Vptr放在對象頭部
- BCB的編譯器將Vptr放在繼承體系中第一個有Vptr的對象頭部
- Dev-C++的編譯器以前將Vptr放在繼承體系中第一個有Vptr的對象尾部
Dev-C++的最新版本(4.9.9.2)也將Vptr放在對象頭部。其實第1個方案有一個小問題:如果基類對象沒有Vptr,而派生類對象有Vptr,讓基類指針指向派生類對象時,編譯器不得不調整基類指針的地址,讓其指向Vptr后的基類非靜態成員。以后如果通過基類指針delete派生類對象,由于delete的地址與分配地址不同,就會發生錯誤。讀者可以在演示程序中找到研究這個問題的代碼(其實是CSDN上一個網友的問題)。將Vptr放在其它兩個位置,因為不用調整基類指針,就可以避免這個問題。
g++編譯器(v3.4.2)產生的程序在打印虛函數地址時會輸出:
CParent1::test地址 0x00000009
CChild1::test地址 0x00000009
在通過函數指針調用函數時,編譯器會通過這個數字9在對象的虛函數表中找到虛函數test。
附錄1 增量鏈接和ILT
為了簡化表述,演示程序的VC6項目設置(Debug版)關閉了“Link Incrementally”選項。如果打開這個選項,編譯器會通過一個叫作ILT的數組間接調用函數。數組ILT的每個元素是一條5個字節的jmp指令,例如:
@ILT+170(?test@CChild2@@QAEXXZ):
004010AF: E9 1C 10 00 00 jmp ?test@CChild2@@QAEXXZ
編譯器調用函數時:
call @ILT+170(?test@CChild2@@QAEXXZ)
通過ILT跳轉到函數的實際地址。這樣,在函數地址變化時,編譯器只需要修改ILT表,而不用修改每個引用函數的語句。ILT是編譯器開發者起的變量名,據網友Cody2k3猜測,可能是Incremental Linking Table的縮寫。
附錄2 C++的Name Mangling/Demangling
C++編譯器會將程序中的變量名、函數名轉換成內部名稱,這個過程被稱作Name Mangling,反過程被稱作Name Demangling。內部名稱包含了變量或函數的更多信息,例如編譯器看到?g_var@@3HA,就知道這是:
int g_var
"3H"表示int型的全局變量。編譯器看到?test@CChild2@@QAEXXZ,知道這是:
public: void __thiscall CChild2::test(void)
編譯器廠商一般不會公布Mangling的規則,因為這些規則可能會根據需求變化。不過,微軟提供了一個Demangling的函數UnDecorateSymbolName。我用這個函數寫了一個叫作“dem”的小工具,可以從內部名稱得到變量或函數的聲明信息。讀者可以從我的個人主頁下載這個工具(http://www.fmddlmyy.cn/dem.zip)。
關于“C++的Name Mangling/Demangling”的更多介紹,讀者可以參見http://www.kegel.com/mangle.html。
附錄3 關于thunk
據說一個Algol-60程序員第一次使用“thunk”這個詞匯,最初的語義源自"thought of (thunked)" 。這個單詞的主要語義是“地址轉換、替換程序”,一般是指通過一小段匯編代碼,轉調另一個函數。調用者在調用thunk代碼時以為自己在調用一個函數,thunk代碼會將控制轉交給一個它選擇的函數。例如:附錄一介紹的ILT數組的每個元素都是一小段thunk代碼。
附錄4 在g++中生成mapfile
在通過gcc/g++間接調用鏈接程序ld時,所有的ld選項前必須加上“-Wl,”。所以,要讓g++生成mapfile,需要增加編譯參數“ -Wl,-Map,mapfile”。
from:http://blog.csdn.net/fmddlmyy/article/details/1510176
RFID管理系統集成商 RFID中間件 條碼系統中間層 物聯網軟件集成