C++ 虚函数表原理及对象内存结构分析

By dwj1210 at

本篇文章主要讲什么是虚函数、什么是虚函数表,以及在 C++ 中一个对象的内存布局是什么样的。

C++ 是一门面向对象的语言,跟 OC 一样有三大特性:继承、封装、多态。 C++ 中的多态是通过虚函数重写的方式来实现的。

从开发角度了解什么是虚函数

虚函数就是父类中由 virtual 修饰的函数。

普通类:

源代码:

#include<iostream>
using namespace std;
class Base {
    
    public:
        Base():base_int(1),base_char('d'){};
        void test(){ cout<<"Base Print()"<<endl; }
    protected:
        int base_int;
        char base_char;                                   
};
int main() {
    Base b;
    return 0;
}

一个 Base 类,有两个成员变量分别是 base_int 和 base_char,在初始化的时候分别赋值。

调试过程:

dwj@dwj-virtual-machine:~/C++Demo$ gdb ./demo1
Reading symbols from ./demo1...done.
(gdb) b 14
Breakpoint 1 at 0x400749: file demo1.cpp, line 14.
(gdb) run
Starting program: /home/dwj/C++Demo/demo1 

Breakpoint 1, main () at demo1.cpp:14
14        return 0;
(gdb) p b
$1 = {base_int = 1, base_char = 100 'd'}

通过 p b 命令可以查看到对象内存结构中有两个成员变量,并且已经被赋值。

存在虚函数的类:

源代码:

#include<iostream>
using namespace std;
class Base {
    
    public:
        Base():base_int(1),base_char('d'){};
        virtual void test(){ cout<<"Base Print()"<<endl; }
    protected:
        int base_int;
        char base_char;                             
};
int main() {
    Base b;
    return 0;
}

一个 Base 类,有两个成员变量分别是 base_int 和 base_char,还有一个用 virtual 修饰的虚函数 test()。

调试过程:

dwj@dwj-virtual-machine:~/C++Demo$ gdb ./demo2 
Reading symbols from ./demo2...done.
(gdb) b 14
Breakpoint 1 at 0x400959: file demo2.cpp, line 14.
(gdb) run
Starting program: /home/dwj/C++Demo/demo2 

Breakpoint 1, main () at demo2.cpp:14
14        return 0;
(gdb) p b
$1 = {_vptr.Base = 0x400ad8 <vtable for Base+16>, base_int = 1, base_char = 100 'd'}
(gdb) info vtbl b
vtable for 'Base' @ 0x400ad8 (subobject @ 0x7fffffffdde0):
[0]: 0x4009fe <Base::test()>

通过 p b 命令可以查看到对象内存结构中有两个成员变量,并且已经被赋值,除了这两个成员变量外,前面还有一个 Base 的虚函数指针,使用 info vtbl b 命令可以看到这个虚函数指针指向的内存空间是一个数组,数组中有一个元素,这个元素就是对象的 test() 虚函数指针。

存在虚函数的类并且子类没有重写虚函数

源代码:

#include<iostream>
using namespace std;
class Base {
    
    public:
        Base():base_int(1),base_char('d'){};
        virtual void test(){ cout<<"Base Print()"<<endl; }
    protected:
        int base_int;
        char base_char;                             
};
class sub_Base: public Base{
    private:
    int sub_Base_int;
};
int main() {
    Base b;
    sub_Base sub_b;
    return 0;
}

一个 Base 类,有两个成员变量分别是 base_int 和 base_char,还有一个用 virtual 修饰的虚函数 test()。 一个 sub_Base 类继承于 Base 类,有一个自己的成员变量 sub_Base_int。

调试过程:

dwj@dwj-virtual-machine:~/C++Demo$ gdb ./demo3
Reading symbols from ./demo3...done.
(gdb) b 19
Breakpoint 1 at 0x4009b5: file demo3.cpp, line 19.
(gdb) run
Starting program: /home/dwj/C++Demo/demo3 

Breakpoint 1, main () at demo3.cpp:19
19        return 0;
(gdb) p b
$1 = {_vptr.Base = 0x400b70 <vtable for Base+16>, base_int = 1, base_char = 100 'd'}
(gdb) p sub_b
$2 = {<Base> = {_vptr.Base = 0x400b58 <vtable for sub_Base+16>, base_int = 1, base_char = 100 'd'}, sub_Base_int = -8480}
(gdb) info vtbl b
vtable for 'Base' @ 0x400b70 (subobject @ 0x7fffffffddd0):
[0]: 0x400a4e <Base::test()>
(gdb) info vtbl sub_b
vtable for 'sub_Base' @ 0x400b58 (subobject @ 0x7fffffffdde0):
[0]: 0x400a4e <Base::test()>

除了和前一个 demo 调试一致的结果外,我们还发现 sub_Base 对象也有一个虚函数表指针,并且虚函数表指针指向内存空间的函数指针和父类 Base 中的虚函数表中的函数指针地址完全一致。

存在虚函数的类并且子类重写虚函数

源代码:

#include<iostream>
using namespace std;
class Base {
    
    public:
        Base():base_int(1),base_char('d'){};
        virtual void test(){ cout<<"Base Print()"<<endl; }
    protected:
        int base_int;
        char base_char;                             
};
class sub_Base: public Base{
    private:
        int sub_Base_int;
    public:
        void test() { cout<<"sub_Base print()"<<endl; }
};
int main() {
    Base b;
    sub_Base sub_b;
    return 0;
}

一个 Base 类,有两个成员变量分别是 base_int 和 base_char,还有一个用 virtual 修饰的虚函数 test()。 一个 sub_Base 类继承于 Base 类,有一个自己的成员变量 sub_Base_int,并且重写了父类的虚函数实现。

调试过程:

dwj@dwj-virtual-machine:~/C++Demo$ gdb ./demo4
Reading symbols from ./demo4...done.
(gdb) b 21
Breakpoint 1 at 0x4009b5: file demo4.cpp, line 21.
(gdb) run
Starting program: /home/dwj/C++Demo/demo4 

Breakpoint 1, main () at demo4.cpp:21
21        return 0;
(gdb) p b
$1 = {_vptr.Base = 0x400ba0 <vtable for Base+16>, base_int = 1, base_char = 100 'd'}
(gdb) p sub_b
$2 = {<Base> = {_vptr.Base = 0x400b88 <vtable for sub_Base+16>, base_int = 1, base_char = 100 'd'}, sub_Base_int = -8480}
(gdb) info vtbl b
vtable for 'Base' @ 0x400ba0 (subobject @ 0x7fffffffddd0):
[0]: 0x400a4e <Base::test()>
(gdb) info vtbl sub_b
vtable for 'sub_Base' @ 0x400b88 (subobject @ 0x7fffffffdde0):
[0]: 0x400a7a <sub_Base::test()>

在这个调试过程中我们发现 sub_Base 对象的虚函数表指针指向内存空间里的函数指针和父类 Base 中的虚函数表中的函数指针地址不再一致。

结论:

C++ 类如果定义了用 virtual 修饰的虚函数,那么他的对象内存中就会存在一个 8 字节(64位)的虚函数表指针,这个指针指向一块连续的内存空间,也就是虚函数表,虚函数表是一个数组,数组中存放了类定义的虚函数指针。 如果子类继承了父类,并且父类中存在虚函数表,那么子类也会继承父类的虚函数表。当子类没有重写父类的虚函数时,子类虚函数表中保存的是父类的函数指针,当子类重写了父类的虚函数时,子类函数指针就会覆盖虚函数表中父类的函数指针。

从逆向看 C++ 对象内存结构

我们还是写一个 C++ demo,Base 类有两个成员变量,两个虚函数。来看看这个对象在内存中的结构:

#include <iostream>

using namespace std;
class Base {
    
    public:
    Base():base_int(10),base_char('D'){};
        virtual void testa(int arg){ cout<<"Base Print()"<< arg <<endl; }
        virtual void testb(int arg){ cout<<"Base Print()"<< arg <<endl; }
    protected:
        int base_int;
        char base_char;
};
int main(int argc, const char * argv[]) {
    Base b;
    b.testa(20);
    return 0;
}

断点到 b.testa(20) 这行,以下为调试过程:

(lldb) p &b
(Base *) $0 = 0x00007ffeefbff540
(lldb) x 0x00007ffeefbff540
0x7ffeefbff540: 38 20 00 00 01 00 00 00 0a 00 00 00 44 00 00 00  8 ..........D...
0x7ffeefbff550: 88 f5 bf ef fe 7f 00 00 01 00 00 00 00 00 00 00  ................
(lldb) x 0x0000000100002038
0x100002038: 50 11 00 00 01 00 00 00 d0 11 00 00 01 00 00 00  P...............
0x100002048: 60 36 99 92 ff 7f 00 00 4c 1f 00 00 01 00 00 00  `6......L.......
(lldb) b 0x0000000100001150
Breakpoint 6: where = C++Test`Base::testa(int) at main.cpp:18, address = 0x0000000100001150
(lldb) b 0x00000001000011d0
Breakpoint 7: where = C++Test`Base::testb(int) at main.cpp:19, address = 0x00000001000011d0
(lldb) po 0x0a
10

(lldb) po 0x44
68

(lldb) p char(68)
(char) $2 = 'D'
(lldb)

从内存结构中查看虚函数表指针: img

从内存结构中查看成员变量: img

从虚函数指针内存看对象的虚函数指针数组: img

对比 IDA 中反编译结果,可以证实我们拿到了 Base 类两个虚函数指针: img

img