【超深入解析】C++类的结构及其构造过程(虚函数表指针、虚基表指针的位置及其构造时机)

说到类的结构,最值得研究的便是虚函数表和虚基表。关于这两者的作用,已经有许多文章做出了详细的描述,但是对于它们的在类中的位置,以及它们是何时被安装到类当中的,这些问题却似乎没有多少人做出解释。本篇文章将采用一个菱形继承的案例来探讨虚函数表指针、虚基表指针被安装到类的什么位置,以及它们是何时被安装到类当中的。

本篇文章使用的IDE为visual studio 2022,启用debug选项,适用于64位处理器,编译器是VS自带的MSVC,因此本篇文章只保证所得结论与MSVC编译器所编译的代码相符,不保证与其他编译器相符。但其他编译器与MSVC同为C++编译器,相信它们的逻辑是大致相同的

C++代码如下

        类的结构如下

class Parent
{
	int a;
	int b;
public:
	Parent();
	virtual void test01();
};

class Left :virtual public Parent
{
public:
	Left();
	virtual void test02();
};

class Right :virtual public Parent
{
public:
	Right();
	virtual void test03();
};

class Son :public Left, public Right
{
public:
	Son();
	virtual void test04();
	virtual void test01();
	virtual void test02();
	virtual void test03();
};

 显然,类的结构是一个典型的菱形继承,每个类中都有一个虚函数,son类有一个单独的虚函数并覆写类所有父类函数。更加直观的类结构示意图如下:

        类中函数的实现如下

本文只是为了研究类的结构,因此函数全为空实现

Parent::Parent()
{
}

void Parent::test01()
{
}

Left::Left()
{
}

void Left::test02()
{
}

Right::Right()
{
}

void Right::test03()
{
}

Son::Son()
{
}

void Son::test04()
{
}

void Son::test01()
{
}

void Son::test02()
{
}

void Son::test03()
{
}

        测试代码如下

int main()
{
	Son* son = new Son();
	Left* l = son;
	Right* r = son;
	Parent* p = son;
	p = l;
	p = r;

	son->test01();
	son->test02();
	son->test03();
	son->test04();

	l->test01();
	l->test02();

	r->test01();
	r->test03();

	p->test01();

	delete son;
}

构造过程

在构造函数处加入断点,并打开反编译窗口

可以看到,当调用构造函数并使用new关键字时,首先调用operator new函数开辟一块内存,返回值(即指向Son类的this指针)装在rax寄存器中,编译器将其保存在栈上,随后00007FF738EF647C处检查是否成功开辟内存,即返回值是否为nullptr。对于本案例,显然能够成功开辟(即接下来的je语句不发生跳转),所以不必管这里的判断,继续往下看即可。接下来执行mov edx,1语句,我们先不管这条语句,等之后再解释,先往下看。显然,接下来就是将this指针从栈上拿出来,放入rcx寄存器中,随后就调用了构造函数。(这里需要解释的是,对于__thiscall函数调用约定,编译器通常会把this指针放入rcx当中,而不是栈或其他寄存器中)

进入构造函数

开头的edx如先前所述,先不管

上文说到,此时,this指针装到rcx寄存器中,而在00007FF738EF1B04处执行了一条mov         qword ptr [rsp+8],rcx指令,从而将rcx保存在了栈上。

接下来是调用__CheckForDebuggerJustMyCode函数的过程,这显然是为了debug而产生的代码,所以不必管他。

接下来便是安装虚基表的过程,可以看到,代码在把虚基表安装在了this指针向后偏移8字节的位置和向后偏移18h(转换为10进制为24)的位置。这显然是为Left类和Right类安装的虚基表,那么Son类呢?可以知道的是指向Left类与Son类的指针是重合的,所以它们两个可以共用一个虚基表指针。以下为Son类的结构:

上图能够说明Left就在Son的头部,所以指向两者的指针是重合的

安装完虚基表后,就调用了父类构造函数,这里先调用Parent构造函数,还是与刚才一样,要往rcx中放this指针。这里令rcx=this+28h(10进制为40),这说明parent类放在偏移量为40处。同理下面调用Left和Right构造函数的代码说明Left的偏移为0,right的偏移为10H(10进制的16),这也说明了上图的正确性。这里在每个构造函数前都多执行了一句:xor  edx,edx(将edx置为0),就像之前所说的,先不管edx,之后再探讨。

调用完构造函数后,就是安装虚函数表的过程,将虚函数表指针安装在偏移量为0,10h,28h的位置,这说明虚表位于类的头部。同样,son与left共用虚表位。因此得出了如下类的结构示意图:

至于为什么会空出来8字节,我也不清楚,如果有知道原因的小伙伴,欢迎在下方留言

类的构造顺序如下图:

edx的作用

聪明的小伙伴们可能看出来了,如果先安装虚基表,再调用父类构造函数,那么子类安装的虚基表就会被父类的构造函数所覆盖,那么这样必然引发一系列问题。所以一定有什么机制阻止父类构造覆写虚基表。

答案就在edx上。观察细致的小伙伴们就会发现,在调用son构造函数之前,edx被置1,son构造函数中,在调用父类构造函数之前,edx被置0。说明edx指示了是否该写入虚基表。

再来分析一下son类的构造函数:

该构造函数首先将edx的值存储在栈上,在第二个红框处又拿出来比较,如果值为0,则跳过虚基表的安装过程以及虚继承的父类的构造函数的调用过程。显然edx存储了一个bool值,指示构造函数是否该覆写虚基表指针位和调用虚继承的父类构造函数。

证明:00007FF67ED91B00处的rsp+10h与00007FF67ED91B23处的rbp+0F8h相等

设刚进入函数时rsp中的值为x,那么此时RSP+10H=X+10H

两个push语句使得rsp-=10H,即rsp=x-10H,

sub  rsp,0F8h  使得rsp-=F8H,即rsp=x-10h-f8h=x-108h

lea   rbp,[rsp+20h]  使得rbp=rsp+20h=x-108h+20h=x-e8h

最终rbp+0f8h=x-e8h+f8h=x+10h

故两者相等

同样,在Left和Right类中也有这样的检测机制:

如果不希望他们覆写虚基表,只需将edx置0即可。son中的构造函数就将edx置0了之后才调用Left和Right构造。

总结

最终的构造过程如下:

类的结构规律如下:

  • 虚函数表优先放在头部
  • 虚基表其次
  • 虚继承的父类放在尾部
  • 如果有多个非虚继承的父类,第一个父类放在头部,第二个其次,以此类推
  • 如果有多个非虚继承的父类,子类将与第一个父类共用一个虚表

最后本文还留了一点坑,本来我还想描述一下子类指针变为父类指针的过程、通过虚基表找到父类的过程以及子类覆写父类函数会生成的额外代码。但奈何文章篇幅已经太长,可能我会在我的下一篇文章中弥补这篇文章的缺陷。但总体来看,本文已经大致将类的结构以及构造过程讲述完整了,希望大家能够喜欢

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值