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处理器》

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