窥视C++细节-虚函数实现原理

环境

在运行测试代码时,使用了如下环境:

linux使用的是ubuntu 18,在ubuntu上使用的是g++,版本如下:

root@learner:~# g++ --version
g++ (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0

https://godbolt.org/,这个网站可以方便的将C++代码编译成汇编,同时使用颜色标识某条C++语句对应的汇编代码。

在使用之前需要可以根据自己的习惯配置一下。
在这里插入图片描述

使用的gcc版本是10.2

-m32:将C++代码编译成32位的汇编

-O0:不要进行编译优化

Intel asm syntax:编译成intel格式的汇编,默认是AT&T格式的汇编。

Demangle identifiers:反命名倾轧,在汇编代码中使用C++中函数的名字,这样更便于阅读。

类对象内有指向虚函数表的指针

class A
{
public:
    virtual void f(){}
    virtual void g(){}
    virtual void h(){}
private:
    int i;
};

int main()
{
    A a;

    cout<<"sizeof(A) = "<<sizeof(A)<<endl;
    cout<<"sizeof(a) = "<<sizeof(a)<<endl;

    return 0;
}
sizeof(A) = 8
sizeof(a) = 8

有虚函数的类,其生成的类对象内部有一个指向虚函数表的指针。

在这里插入图片描述

虚函数表

带有虚函数的类,所有的类对象共用同一个虚函数表,即虚函数表只有一个。但每个类对象都有自己的指向虚函数表的指针。

父类和子类都有自己专属的虚函数表,子类不会使用父类的虚函数表,父类也不会使用子类的虚函数表。

创建时机及位置

虚函数表是在编译时创建的,且创建完毕后就不能再修改了。更确切的说,在构建程序(预处理、编译、汇编、链接)的处理过程中,虚函数表是在编译时就创建了。

虚函数表在静态可执行文件的.data.rel.ro.local段内,当可执行文件载入内存后,虚函数表位于.text段。

#include <iostream>

using namespace std;

class A
{
public:
    virtual void f(){}
    virtual void g(){}
    virtual void h(){}
private:
    int i;
};

int main()
{
    A a;

    return 0;
}

将这段程序编译成汇编程序:g++ -S aa.cpp -o aa.s,在aa.s文件中,找到虚函数表如下图:

在这里插入图片描述
在这里插入图片描述

内容

在这里插入图片描述

类B继承自类A,但是类A和类B都有属于自己的的虚函数表。

类B对类A中的h()虚函数进行了覆写,所以在类B的虚函数表中,使用类B的B::h()函数替换了类A中的A::h()函数。

class A
{
public:
    virtual void f(){}
    virtual void g(){}
    virtual void h(){}
private:
    int i;
};

class B : public A
{
public: 
    virtual void h(){}
};

int main()
{
    A a;
    A *pa = new A;

    B b;
    B *pb = new B;

    return 0;
}

父类对象、子类对象、虚函数表指针、虚函数表、虚函数之间的关系如下图:

在这里插入图片描述

使用虚函数表指针调用虚函数

class A
{
public:
    virtual void f(){
        cout<<"A::f()"<<endl;
    }

    virtual void g(){
        cout<<"A::g()"<<endl;
    }

    virtual void h(){
        cout<<"A::h()"<<endl;
    }
private:
    int i;
};

typedef void (*PF)(void);

int main()
{
    A a;
    cout<<"对象a的起始地址  :"<<&a<<endl;   //A*
    cout<<"vtable的起始地址 :"<<(int**)*(int*)(&a)<<endl;

    cout<<"vtable中第0个函数的地址:"<<*((int**)*(int*)(&a)+0)<<endl;
    cout<<"vtable中第1个函数的地址:"<<*((int**)*(int*)(&a)+1)<<endl;
    cout<<"vtable中第2个函数的地址:"<<*((int**)*(int*)(&a)+2)<<endl;

    PF pf;

    pf = (PF)(*((int**)*(int*)(&a)+0));
    pf();    //调用f()

    pf = (PF)(*((int**)*(int*)(&a)+1));
    pf();    //调用g()

    pf = (PF)(*((int**)*(int*)(&a)+2));
    pf();    //调用h()

    return 0;
}
root@learner:~/vscode/tmp# g++ aa.cpp -m32      #编译成32位程序
root@learner:~/vscode/tmp# ./a.out 
对象a的起始地址  :0xffe94c84
vtable的起始地址 :0x56648ea4
vtable中第0个函数的地址:0x56646a58
vtable中第1个函数的地址:0x56646a9e
vtable中第2个函数的地址:0x56646ae4
A::f()
A::g()
A::h()

通过对象a,获取到指向虚函数表的指针,再通过虚函数表获取虚函数的地址,然后调用虚函数。

因为虚函数中没有操纵类内部的数据成员,只是打印信息,所以不会出问题,若是访问了类数据成员,一般情况下都会崩溃,因为编译器在调用虚函数的时候,会隐式的传入this指针,而此处我们调用虚函数时,并没有传入this指针。

#include <iostream>

using namespace std;

class A
{
public:
    virtual void f(){
        i = 100;     //对变量i赋值
    }

private:
    int i = 0;
};

typedef void (*PF)(void);

int main()
{
    A a;

    PF pf;

    pf = (PF)(*((int**)*(int*)(&a)+0));   
    pf();

    return 0;
}
root@learner:~/vscode/tmp# ./a.out 
Segmentation fault

现在我们在虚函数中对成员变量赋值了,所以崩溃了。

下面调试看一下什么原因导致的崩溃:

root@learner:~/vscode/tmp# g++ aa.cpp -m32 -g
root@learner:~/vscode/tmp# gdb a.out 

(gdb) b 24    #在24行打上断点
Breakpoint 1 at 0x6e4: file aa.cpp, line 24.
(gdb) r       #开始调试
Starting program: /root/vscode/tmp/a.out 

Breakpoint 1, main () at aa.cpp:24
24	    pf = (PF)(*((int**)*(int*)(&a)+0));   
(gdb) p &a    #打印对象a地址
$1 = (A *) 0xffffd5d4  
(gdb) c       #继续调试
Continuing.
              #执行到第9行时,收到一个段错误信号。
Program received signal SIGSEGV, Segmentation fault.
0x565557a0 in A::f (this=0x56555790 <A::f()>) at aa.cpp:9
9	        i = 100;

我们可以看到a对象的地址是0xffffd5d4,而A::f()函数内部的this指针是0x56555790。我们在访问虚函数时,A::func()实际访问的内存是0x56555790内存,但这段内存并不是我们拥有的合法内存,是一份非法的内存。对这段内存赋值,是非法的。

从崩溃的提示信息A::f (this=0x56555790 <A::f()>)上,我们也可以知道,编译在调用A::f()时,应该把对象a的地址隐式的传入,这样在A::f()函数内才能正确的使用this指针。

我们眼中的A::func()是这样的:

void A::f()
{
	i = 100;
}

而编译器眼中的A::func()是这样的:

void A::f(A * const this)
{
	this->i = 100;
}

我们眼中的调用函数是这样的:

A a;
a.func();

而编译器眼中是这样的:

A a;
func(&a);

为类对象初始化虚函数表指针

有虚函数的类,编译器会在构造器中安插一些代码,以用于初始化类对象的虚函数指针。

在这里插入图片描述

虽然我们没有为类A、类B提供默认构造器,但是为了初始化类对象的虚函数表指针,编译器帮助提供了默认构造器,同时安插了代码用于初始化类对象的虚函数表指针。

定义对象b的时候,调用类B的构造器B(){},在类B的构造器中,先调用了类A的构造器。在类A的构造器中,将类A的虚函数表指针赋值给传入的类B对象。当类A的构造器调用结束后,此时类B的虚函数表指针指向的是类A的虚函数表。类A构造器执行结束后,回到类B的构造器继续执行,接下来类B将其虚函数表指针赋给类B对象,将之前指向类A虚函数表的指针给覆盖了,之后类B对象将一直指向类B的虚函数表。

A::A() [base object constructor]:
        push    ebp         #保存B::B()函数的ebp
        mov     ebp, esp    #生成A::A()函数的ebp
        mov     edx, OFFSET FLAT:vtable for A+8 #获取类A虚函数表的入口地址
        mov     eax, DWORD PTR [ebp+8]  #获取类A虚函数表地址,并保存到eax中。
        
        #B::B()调用A::A()时的参数依然在栈中,也就是对象bde地址。
        #但此时eax中保存的就是对象b的地址,去内存中取对象b的地址,
        #不如直接使用eax中的b对象地址
        mov     DWORD PTR [eax], edx #将类A虚函数表地址填入b对象的前4个字节。
        nop
        pop     ebp
        ret
B::B() [base object constructor]:
        push    ebp          #保存main函数的ebp
        mov     ebp, esp     #生成B::B()函数的ebp
        sub     esp, 8       #分配8字节的栈空间
        
        #[ebp+8]是调用B::B()传入的参数,该参数是b对象的地址。
        mov     eax, DWORD PTR [ebp+8]  #将对象b的地址保存到eax
        sub     esp, 12      #分配8字节的栈空间
        push    eax          #将对象b的地址压栈,作为调用A::A()的参数
        call    A::A() [base object constructor]   #将eip压栈,调用A::A()。
        add     esp, 16      #释放栈中的16字节空间
        mov     edx, OFFSET FLAT:vtable for B+8  #获取类B虚函数表的入口地址
        mov     eax, DWORD PTR [ebp+8]  #获取类B虚函数表地址,并保存到eax中。
        
        #将类B虚函数表地址填入b对象的前4个字节,
        #之前指向类A虚函数表的指针被覆盖了。
        mov     DWORD PTR [eax], edx
        nop
        leave
        ret
main:
        lea     ecx, [esp+4]
        and     esp, -16
        push    DWORD PTR [ecx-4]
        push    ebp
        mov     ebp, esp
        push    ecx
        sub     esp, 20
        sub     esp, 12
        lea     eax, [ebp-16]    #获取b对象的地址
        push    eax              #将b对象的地址压栈,作为调用类B构造器的参数。
        call    B::B() [complete object constructor]  #将eip压栈,调用B::B()
        add     esp, 16
        mov     eax, 0
        mov     ecx, DWORD PTR [ebp-4]
        leave
        lea     esp, [ecx-4]
        ret

虚函数调用过程

在这里插入图片描述

在调用虚函数的时候,需要先通过虚函数表找到虚函数的入口地址,得到虚函数的地址后,再调用虚函数。

        #[ebp-12]中保存着类B对象的地址,也就是pb指向的类B对象。    
        mov     eax, DWORD PTR [ebp-12]   #将类B对象的地址保存到eax中
        mov     eax, DWORD PTR [eax]      #取出类B对象前4个字节的内容到eax
        add     eax, 4  #此时eax指向类B虚函数表,加4使其指向g()函数指针。
        mov     eax, DWORD PTR [eax]      #将g()函数的地址,保存到eax中。
        sub     esp, 12
        push    DWORD PTR [ebp-12]        #将pb压栈,作为调用g()的参数
        call    eax          #调用g()
        add     esp, 16

动多态形成的实现机制

在这里插入图片描述

        #[ebp-12]表示A* p变量,此时p指向的是类A对象。
        mov     eax, DWORD PTR [ebp-12]    #将p保存到eax
        
        #获取类对象指向虚函数表的指针,此时p指向的是类A对象,
        #所以此时eax指向的是类A的虚函数表
        mov     eax, DWORD PTR [eax]      
        #从类A虚函数表中取出第一个虚函数地址,也就是A::f()函数的地址。
        mov     eax, DWORD PTR [eax]    
        sub     esp, 12
        push    DWORD PTR [ebp-12]    #将p压栈,作为调用A::f()的参数。
        call    eax                   #调用A::f()
        add     esp, 16
        sub     esp, 12
        push    8
        call    operator new(unsigned int)
        add     esp, 16
        mov     ebx, eax
        sub     esp, 12
        push    ebx
        call    B::B() [complete object constructor]
        add     esp, 16
        mov     DWORD PTR [ebp-12], ebx
        
        #[ebp-12]表示A* p变量,此时p指向的是类B对象。
        mov     eax, DWORD PTR [ebp-12]    #将p保存到eax
        
        #获取类对象指向虚函数表的指针,此时p指向的是类B对象,
        #所以此时eax指向的是类B的虚函数表
        mov     eax, DWORD PTR [eax]  
        #从类B虚函数表中取出第一个虚函数地址,也就是B::f()函数的地址。
        mov     eax, DWORD PTR [eax]
        sub     esp, 12
        push    DWORD PTR [ebp-12]    #将p压栈,作为调用B::f()的参数。
        call    eax                   #调用A::f()
        add     esp, 16

通过p->f()调用虚函数f(),若p指向对象类A,则调用类A中的f()函数;若p指向对象类B,则调用类B中的f()函数。

这也就所谓的动多态,因为p->f()在编译后,没有运行之前,是不知道到底哪个f()函数会被调用,需要在运行时,等到通过p->f()调用时,由p指向的类对象类型决定。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值