要想知道c++类对象的内存布局, 可以有多种方式,比如:
1)输出成员变量的偏移, 通过offsetof宏来得到
2)通过调试器查看, 比如常用的VS
1.没有数据成员的对象
class A{ }; int main() { A arr[] = { A(),A(),A() }; cout << sizeof(A) << endl; //输出1 int n = sizeof(arr) / sizeof(A); //为了防止分母为0,发生错误 cout << n << endl; return 0; }
1)最开始的c++编译器对没有成员变量的类对象执行sizeof是返回0的,但是为了防止上述错误发生,编译器会强行(隐式)地插入一个字节,至于这个字节到底存着什么信息,不知道
2)同时这也可以让这类的对象有一个明确的地址
2.只有数据成员的对象
class Base1 { public: int base1_1; int base1_2; };
对象大小及偏移:
可知对象布局:
可以看到, 成员变量是按照定义的顺序来保存的, 最先声明的在最上边, 然后依次保存,类对象的大小就是所有成员变量大小之和.
3.没有虚函数有普通函数的对象
class Base1 { public: int base1_1; int base1_2; void foo(){} };
结果如下:
结论:
- 此处和前面的结果是一样的
- 非虚成员函数可以被看作是类作用域下的全局函数,不存在实例分配的空间里,由该类所有实例所共享,它们的地址编译期就已确定,实例调用成员函数时,编译器向成员函数传入this指针完成和实例的匹配
- 类的静态成员函数也是类作用域下的全局函数,不存在实例分配的空间里,但它属于整个类,不属于类的实例,它不需要实例化也能调用,而非虚成员函数必须实例化才能调用
class A { public: static void fun1() { cout << 777 << endl; } void fun2() { cout << 888 << endl; } }; int main() { A::fun1(); //类的静态成员函数不需要实例化也能调用 A::fun2(); //编译错误:类的成员函数必须实例化才能调用 A a; a.fun2(); //输出:888 return 0; }
4.拥有仅一个虚函数的类对象
class Base1 { public: int base1_1; int base1_2; virtual void base1_fun1() {} }; Base1 b1;
结果如下:
咦? 多了4个字节? 且 base1_1 和 base1_2 的偏移都各自向后多了4个字节! 说明类对象的最前面被多加了4个字节的"东东". 我们通过VS2013来瞧瞧类Base1的变量b1的内存布局情况:(由于没有写构造函数, 所以变量的数据没有初始化, Debug模式下, 未初始化的变量值为0xCCCCCCCC, 即:-858983460)
base1_1前面多了一个变量 __vfptr(这就是虚函数表指针vfptr), 其类型为void**, 这说明它是一个指向void*的指针,再看高亮部分,说明它是一个指向指针数组的指针. 再看看[0]元素, 其类型为void*, 其值为 ConsoleApplication2.exe!Base1::base1_fun1(void), 这是什么意思呢? 如果对WinDbg比较熟悉, 那么应该知道这是一种惯用表示手法, 它就是指 Base1::base1_fun1() 函数的地址.
所以,该类的对象大小为12个字节, 大小及偏移信息如下:
现在的类对象布局如下:
结论:
- 含有虚函数的类对象里会有一个指针,叫虚函数表指针vfptr,指向虚函数表,虚函数表是一个指针数组,其中的每一个元素都是一个函数指针,指向对应的虚函数
- 注意到__vfptr前面的const修饰.,它修饰的是那个虚函数表,而不是__vfptr
5.拥有多个虚函数的类对象
class Base1 { public: int base1_1; int base1_2; virtual void base1_fun1() {} virtual void base1_fun2() {} }; Base1 b1;
大小以及偏移信息如下:
多了一个虚函数, 类对象大小却依然是12个字节! 再来看看VS形象的表现:
现在__vfptr所指向的指针数组中出现了第2个元素, 其值为Base1类的第2个虚函数base1_fun2()的函数地址. 通过上面两张图表, 我们可以得到如下结论:
- 更加肯定前面我们所描述的: __vfptr只是一个指针, 它指向一个函数指针数组(即: 虚函数表)
- 增加一个虚函数, 只是简单地向该类对应的虚函数表中增加一项而已, 并不会影响到类对象的大小以及布局情况
不妨, 我们再定义一个类的变量b2, 现在再来看看__vfptr的指向:
通过Watch 1窗口我们看到:
- b1和b2是类的两个变量, 理所当然, 它们的地址是不同的(见 &b1 和 &b2)
- 虽然b1和b2是类的两个变量, 但是: 它们的__vfptr的指向却是同一个虚函数表
由此我们可以总结出:
同一个类的不同对象共用同一份虚函数表, 它们都通过一个所谓的虚函数表指针__vfptr(定义为void**类型)指向该虚函数表.
是时候该展示一下类对象的内存布局情况了:
结论:
- 虚函数表是编译器在编译时期创建好的, 只存在一份,同一个类的不同对象共用同这份虚函数表,虚函数表保存在.rodata只读数据段
- 定义类对象时, 编译器自动将类对象的__vfptr指向这个虚函数表
6.单继承且本身不存在虚函数的派生类的内存布局
class Base1 { public: int base1_1; int base1_2; virtual void base1_fun1() {} virtual void base1_fun2() {} }; class Derive1 : public Base1 { public: int derive1_1; int derive1_2; }; Derive1 d1;
来看看现在的内存布局:
基类在上边, 继承类的成员在下边依次定义! 展开来看看:
现在类的布局情况应该是下面这样:
7.覆盖的基类虚函数的单继承派生类的内存布局
class Base1 { public: int base1_1; int base1_2; virtual void base1_fun1() {} virtual void base1_fun2() {} }; class Derive1 : public Base1 { public: int derive1_1; int derive1_2; // 覆盖基类虚函数 virtual void base1_fun1() {} };
Derivel d1;
现在的布局:
注意高亮的那一行: 原本是Base1::base1_fun1(), 但由于继承类重写了基类Base1的此方法, 所以现在变成了Derive1::base1_fun1(), 这里发生静态绑定
8.定义了基类没有的虚函数的单继承的派生类对象布局
class Base1 { public: int base1_1; int base1_2; virtual void base1_fun1() {} virtual void base1_fun2() {} }; class Derive1 : public Base1 { public: int derive1_1; int derive1_2; virtual void derive1_fun1() {} }; Derive1 d1;
现在的布局:
表面上看来几乎和第5种情况完全一样, 现在继承类明明定义了自身的虚函数, 但不见了, 那么, 来看看类对象的大小, 以及成员偏移情况吧:
也没有变化, 现在我们只能从汇编入手了, 来看看调用derive1_fun1()时的代码:
Derive1 d1; Derive1* pd1 = &d1; pd1->derive1_fun1();
1 ; pd1->derive1_fun1(); 2 00825466 mov eax,dword ptr [pd1] 3 00825469 mov edx,dword ptr [eax] 4 0082546B mov esi,esp 5 0082546D mov ecx,dword ptr [pd1] 6 00825470 mov eax,dword ptr [edx+8] 7 00825473 call eax
汇编代码解释:
第2行: 由于pd1是指向d1的指针, 所以执行此句后 eax 就是d1的地址
第3行: 又因为Base1::__vfptr是Base1的第1个成员, 同时也是Derive1的第1个成员, 那么: &__vfptr == &d1, clear? 所以当执行完 mov edx, dword ptr[eax] 后, edx就得到了__vfptr的值, 也就是虚函数表的地址.
第5行: 由于是__thiscall调用, 所以把this保存到ecx中.
第6行: 一定要注意到那个 edx+8, 由于edx是虚函数表的地址, 那么 edx+8将是虚函数表的第3个元素, 也就是__vftable[2]!!!
第7行: 调用虚函数.
结论:
- 继承类Derive1的虚函数表被加在基类的虚函数后面
- 由于Base1只知道自己的两个虚函数索引[0][1], 所以就算在后面加上了[2],Base1根本不知情, 不会对它造成任何影响.
最新的类对象布局表示:
9.多继承且存在虚函数覆盖同时又存在自身定义的虚函数的类对象布局
class Base1 { public: int base1_1; int base1_2; virtual void base1_fun1() {} virtual void base1_fun2() {} }; class Base2 { public: int base2_1; int base2_2; virtual void base2_fun1() {} virtual void base2_fun2() {} }; // 多继承 class Derive1 : public Base1, public Base2 { public: int derive1_1; int derive1_2; // 基类虚函数覆盖 virtual void base1_fun1() {} virtual void base2_fun2() {} // 自身定义的虚函数 virtual void derive1_fun1() {} virtual void derive1_fun2() {} };
Derive1 d1;
对象大小及偏移信息:
VS的布局:
结论:
- 按照基类的声明顺序, 基类的成员依次分布在继承中.
- 注意被高亮的那两行, 已经发生了虚函数覆盖!
继承反汇编:
Derive1 d1; Derive1* pd1 = &d1; pd1->derive1_fun2();
1 ; pd1->derive1_fun2(); 2 00995306 mov eax,dword ptr [pd1] 3 00995309 mov edx,dword ptr [eax] 4 0099530B mov esi,esp 5 0099530D mov ecx,dword ptr [pd1] 6 00995310 mov eax,dword ptr [edx+0Ch] 7 00995313 call eax
汇编解释:
第2行: 取d1的地址
第3行: 取Base1::__vfptr的值!!
第6行: 0x0C, 也就是第4个元素(下标为[3])
结论:
- 派生类的虚函数表依然是保存到第1个拥有虚函数表的那个基类的虚函数表里
看看现在的类对象布局图:
10.如果第1个直接基类没有虚函数
class Base1 { public: int base1_1; int base1_2; }; class Base2 { public: int base2_1; int base2_2; virtual void base2_fun1() {} virtual void base2_fun2() {} }; // 多继承 class Derive1 : public Base1, public Base2 { public: int derive1_1; int derive1_2; // 自身定义的虚函数 virtual void derive1_fun1() {} virtual void derive1_fun2() {} };
VS的布局:
现在的大小及偏移情况:
注意: sizeof(Base1) == 8, sizeof(Base1) == 12
继承返汇编:
Derive1 d1; Derive1* pd1 = &d1; pd1->derive1_fun2();
1 ; pd1->derive1_fun2(); 2 012E4BA6 mov eax,dword ptr [pd1] 3 012E4BA9 mov edx,dword ptr [eax] 4 012E4BAB mov esi,esp 5 012E4BAD mov ecx,dword ptr [pd1] 6 012E4BB0 mov eax,dword ptr [edx+0Ch] 7 012E4BB3 call eax
这段汇编代码和前面一个完全一样, 观察基类成员变量偏移:
可以发现,base2_1、base2_2在base1_1、base1_2前面
结论:
- 哪个基类有虚函数表, 在派生类对象的内存布局中,这个基类就放在前面
现在类的布局情况:
11.如果两个基类都没有虚函数表
class Base1 { public: int base1_1; int base1_2; }; class Base2 { public: int base2_1; int base2_2; }; // 多继承 class Derive1 : public Base1, public Base2 { public: int derive1_1; int derive1_2; // 自身定义的虚函数 virtual void derive1_fun1() {} virtual void derive1_fun2() {} };
VS的基本布局:
可以看到, 现在__vfptr已经独立出来了, 不再属于Base1和Base2!
看看偏移情况:
对象布局:
结论:
- 虚函数表指针始终在最前面
参考资料:
12.类的成员函数调用
1)非虚成员函数可以被看作是类作用域下的全局函数,不存在实例分配的空间里,由该类所有实例所共享,它们的地址编译期就已确定,实例调用成员函数时,编译器向成员函数传入this指针完成和实例的匹配
2)如果是虚函数,则类对象通过其虚表指针找到虚函数表,并进行后续的调用。 同一个类,共用同一份虚函数表.
3)基类指针(或引用)指向派生类对象(或引用)时调用虚函数:含有虚函数的基类对象和派生类对象都有vfptr指针,每个类的vfptr指针指向相应的虚函数表。基类指针(或引用)绑定到派生类对象时,可以使用派生类对象中的基类元素,这里包括在位于最前端的vfptr(这也是为什么vfptr总在最前端的原因),此时调用虚函数时,通过这个最前端的vfptr指针找到虚函数表,在虚函数表中找到相应虚函数,然后进行调用,这就是动态绑定
13.虚函数表里保存的可能并非虚函数的地址,但是肯定跟虚函数有一点关联,最后都能通过虚函数表里的表项成功的调用虚函数
参考资料:
https://www.cnblogs.com/cswuyg/archive/2010/08/20/1804716.html