C++虛函數表雜談

通過分析windows平臺簡單c++程序來驗證C++虛函數網上的相關說法,相關彙編是win32 x86彙編,不過都比較簡單。同時回顧內存對齊相關知識,以及函數調用的具體過程的相關知識。


要分析的代碼如下:

#include <iostream>
#pragma pack(16)

using namespace std;

struct MyStruct1
{
	char a;
	int s_data1;
	float s_data2;
	double s_data3;
	char s_data4;
	void sfunc1() {};
};

class Class1 {
public:
	int m_data1;
	int m_data2;
	int m_data3;
	virtual void vfunc1() { 
		cout << "class1_test" << endl; 
	};
	virtual void vfunc2() {};
	virtual void vfunc3() {
		cout << "class1_test3" << endl;
	};
	virtual void vfunc4() {};
	virtual void vfunc5() {};
};

class Class2 : public Class1 {
public:
	int n_data1;
	int n_data2;
	int n_data3;
	void vfunc1() { 
		cout << "class2_test" << endl; 
	};
	virtual void vfunc7() {};
	void vfunc3() {
		cout << "class2_test3" << endl;
	};
	void vfunc4() {};
	void vfunc5() {};
	int test;
};

void Test() {
	int a = 0;
	a = a + 10;
	MyStruct1 ms1;
	return;
}

int main() {
	cout << sizeof(MyStruct1) << endl;
	cout << sizeof(Class1) << endl;
	Class1 *c1 = new Class1();
	c1->vfunc1();
	c1->vfunc2();
	c1->vfunc3();
	Class2 *c2 = new Class2();
	c2->vfunc3();
	Test();
	return 0;
}

內存對齊

  1. 數據成員對齊規則:結構(struct)(或聯合(union))的數據成員,第一個數據成員放在offset爲0的地方,以後每個數據成員的對齊按照#pragma pack指定的數值和這個數據成員自身長度中,比較小的那個進行
  2. 結構(或聯合)的整體對齊規則:在數據成員完成各自對齊之後,結構(或聯合)本身也要進行對齊,對齊將按照#pragma pack指定的數值和結構(或聯合)最大數據成員長度中,比較小的那個進行
  3. 結合1、2可推斷:當#pragma pack的n值等於或超過所有數據成員長度的時候,這個n值的大小將不產生任何效果。

Win32平臺下的微軟C編譯器(cl.exefor 80×86)的對齊策略:

  1. 結構體變量的首地址是其最長基本類型成員的整數倍;
    備註:編譯器在給結構體開闢空間時,首先找到結構體中最寬的基本數據類型,然後尋找內存地址能是該基本數據類型的整倍的位置,作爲結構體的首地址。將這個最寬的基本數據類型的大小作爲上面介紹的對齊模數。
  2. 結構體每個成員相對於結構體首地址的偏移量(offset)都是成員大小的整數倍,如有需要編譯器會在成員之間加上填充字節(internal adding)
    備註:爲結構體的一個成員開闢空間之前,編譯器首先檢查預開闢空間的首地址相對於結構體首地址的偏移是否是本成員的整數倍,若是,則存放本成員,反之,則在本成員和上一個成員之間填充一定的字節,以達到整數倍的要求,也就是將預開闢空間的首地址後移幾個字節。

以上說明摘自 百度百科


根據以上說明,我們對 MyStruct1 結構體進行分析:
代碼中我們寫的是按16字節對齊,偏移結構體成員變量的生成文件。

  • 成員變量a是char類型,1個字節,從0字節開始;
  • 成員變量s_data1是int類型,4個字節,每個成員相對於結構體首地址的offset都是成員大小的整數倍,則對齊後的偏移應該是4字節,中間空3個字節;
  • s_data2同理
  • s_data3是double類型,8個字節,每個成員相對於結構體首地址的offset都是成員大小的整數倍,對齊後偏移應該是16字節,中間空4個字節;
  • s_data4是char類型,16+8=24就是它的位置

最後結構體又進行對齊,最大的成員長度是8,所以對齊是32個字節。

1>class MyStruct1	size(32):
1>	+---
1> 0	| a
1>  	| <alignment member> (size=3)
1> 4	| s_data1
1> 8	| s_data2
1>  	| <alignment member> (size=4)
1>16	| s_data3
1>24	| s_data4
1>  	| <alignment member> (size=7)
1>	+---
1>

但是從visual studio的生成中,我們看到結構體 MyStruct1 是用class表示的。這又從側面說明了struct與class的相似性,struct所有的成員默認都是public的

如果我們在給 MyStruct1 加一個虛函數的話,我們發現生成中顯示的,跟class一樣會有虛函數表存在。

struct MyStruct1
{
	char a;
	int s_data1;
	float s_data2;
	double s_data3;
	char s_data4;
	virtual void sfunc1() {};
};

生成

1>class MyStruct1	size(40):
1>	+---
1> 0	| {vfptr}
1> 8	| a
1>  	| <alignment member> (size=3)
1>12	| s_data1
1>16	| s_data2
1>  	| <alignment member> (size=4)
1>24	| s_data3
1>32	| s_data4
1>  	| <alignment member> (size=7)
1>	+---
1>
1>MyStruct1::$vftable@:
1>	| &MyStruct1_meta
1>	|  0
1> 0	| &MyStruct1::sfunc1
1>
1>MyStruct1::sfunc1 this adjustor: 0

虛函數表

c++的面向對象的多態特性,可以通過虛函數來實現。
具體的可以看 https://www.cnblogs.com/malecrab/p/5572730.html

當子類覆蓋了父類的虛函數之後,子類對象賦值給父類指針,但是父類指針在調用響應函數的時候,還是調用的子類的實現。

我們來看一下生成文件
父類 Class1

1>class Class1	size(16):
1>	+---
1> 0	| {vfptr}
1> 4	| m_data1
1> 8	| m_data2
1>12	| m_data3
1>	+---
1>
1>Class1::$vftable@:
1>	| &Class1_meta
1>	|  0
1> 0	| &Class1::vfunc1
1> 1	| &Class1::vfunc2
1> 2	| &Class1::vfunc3
1> 3	| &Class1::vfunc4
1> 4	| &Class1::vfunc5
1>
1>Class1::vfunc1 this adjustor: 0
1>Class1::vfunc2 this adjustor: 0
1>Class1::vfunc3 this adjustor: 0
1>Class1::vfunc4 this adjustor: 0
1>Class1::vfunc5 this adjustor: 0

繼承並覆蓋了虛函數的子類 Class2

1>class Class2	size(32):
1>	+---
1> 0	| +--- (base class Class1)
1> 0	| | {vfptr}
1> 4	| | m_data1
1> 8	| | m_data2
1>12	| | m_data3
1>	| +---
1>16	| n_data1
1>20	| n_data2
1>24	| n_data3
1>28	| test
1>	+---
1>
1>Class2::$vftable@:
1>	| &Class2_meta
1>	|  0
1> 0	| &Class2::vfunc1
1> 1	| &Class1::vfunc2
1> 2	| &Class2::vfunc3
1> 3	| &Class2::vfunc4
1> 4	| &Class2::vfunc5
1> 5	| &Class2::vfunc7
1>
1>Class2::vfunc1 this adjustor: 0
1>Class2::vfunc7 this adjustor: 0
1>Class2::vfunc3 this adjustor: 0
1>Class2::vfunc4 this adjustor: 0
1>Class2::vfunc5 this adjustor: 0

首先,虛函數表是在類的內存的最前面,單繼承只有一個虛函數表,因爲多繼承很不常用,所以就沒做分析。
子類覆蓋了父類的虛方法後,在子類的虛函數表中響應方法都被替換成了子類實現的方法。
這裏的c++代碼反彙編後如下:

    59: 	Class1 *c1 = new Class1();
000F2BD8 6A 10                push        10h  
000F2BDA E8 6E E7 FF FF       call        operator new (0F134Dh)  
000F2BDF 83 C4 04             add         esp,4  
000F2BE2 89 85 20 FF FF FF    mov         dword ptr [ebp-0E0h],eax  
000F2BE8 83 BD 20 FF FF FF 00 cmp         dword ptr [ebp-0E0h],0  
000F2BEF 74 26                je          main+0B7h (0F2C17h)  
000F2BF1 33 C0                xor         eax,eax  
000F2BF3 8B 8D 20 FF FF FF    mov         ecx,dword ptr [ebp-0E0h]  
000F2BF9 89 01                mov         dword ptr [ecx],eax  
000F2BFB 89 41 04             mov         dword ptr [ecx+4],eax  
000F2BFE 89 41 08             mov         dword ptr [ecx+8],eax  
000F2C01 89 41 0C             mov         dword ptr [ecx+0Ch],eax  
000F2C04 8B 8D 20 FF FF FF    mov         ecx,dword ptr [ebp-0E0h]  
000F2C0A E8 94 E6 FF FF       call        Class1::Class1 (0F12A3h)  
000F2C0F 89 85 0C FF FF FF    mov         dword ptr [ebp-0F4h],eax  
000F2C15 EB 0A                jmp         main+0C1h (0F2C21h)  
000F2C17 C7 85 0C FF FF FF 00 00 00 00 mov         dword ptr [ebp-0F4h],0  
000F2C21 8B 95 0C FF FF FF    mov         edx,dword ptr [ebp-0F4h]  
000F2C27 89 55 F8             mov         dword ptr [c1],edx  
    60: 	c1->vfunc1();
000F2C2A 8B 45 F8             mov         eax,dword ptr [c1]  
000F2C2D 8B 10                mov         edx,dword ptr [eax]  
000F2C2F 8B F4                mov         esi,esp  
000F2C31 8B 4D F8             mov         ecx,dword ptr [c1]  
000F2C34 8B 02                mov         eax,dword ptr [edx]  
000F2C36 FF D0                call        eax  
000F2C38 3B F4                cmp         esi,esp  
000F2C3A E8 83 E5 FF FF       call        __RTC_CheckEsp (0F11C2h)  
    61: 	c1->vfunc2();
000F2C3F 8B 45 F8             mov         eax,dword ptr [c1]  
000F2C42 8B 10                mov         edx,dword ptr [eax]  
000F2C44 8B F4                mov         esi,esp  
000F2C46 8B 4D F8             mov         ecx,dword ptr [c1]  
000F2C49 8B 42 04             mov         eax,dword ptr [edx+4]  
000F2C4C FF D0                call        eax  
000F2C4E 3B F4                cmp         esi,esp  
000F2C50 E8 6D E5 FF FF       call        __RTC_CheckEsp (0F11C2h)  
    62: 	c1->vfunc3();
000F2C55 8B 45 F8             mov         eax,dword ptr [c1]  
000F2C58 8B 10                mov         edx,dword ptr [eax]  
000F2C5A 8B F4                mov         esi,esp  
000F2C5C 8B 4D F8             mov         ecx,dword ptr [c1]  
000F2C5F 8B 42 08             mov         eax,dword ptr [edx+8]  
000F2C62 FF D0                call        eax  
000F2C64 3B F4                cmp         esi,esp  
000F2C66 E8 57 E5 FF FF       call        __RTC_CheckEsp (0F11C2h) 

簡單說明一下上述彙編代碼的關鍵地方。
Class1被實例化,並賦值給了指針c1,這裏c1的值是指針的offset。
mov eax,dword ptr [c1]
是把 c1 指向的地址的32位內容移動到eax寄存器,這是實例化的對象Class1的offset,即Class1對象的地址。
因爲我們之前分析過,虛函數表是在類的開頭,所以此時eax寄存器中實際上時存儲的虛函數表的地址。
mov edx,dword ptr [eax] 取到虛函數表中指向的函數的offset,
mov eax,dword ptr [edx] 這是取第一個函數的地址,
mov eax,dword ptr [edx+4] 取第二個函數的地址,
mov eax,dword ptr [edx+8] 取第三個函數的地址,

所以正如上面鏈接提到的博客所說,在編譯階段就把虛函數表中的函數地址做了替換,所以通過偏移來取得真正的函數就不會出錯。

構造函數的執行順序是從父類到基類,從上往下,析構函數是從基類到父類,所以一般把析構函數聲明爲虛函數,這樣無論基類的指針是賦值給了誰,都可以按照從下往上的順序釋放資源。


函數調用過程

這裏我們說vc的stdcall。
函數調用過程堆棧

推薦看《彙編語言 基於x86處理器》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章