C++对象模型:g++的实现(一)

刚看完了《深度探索C++对象模型》第三章,这里做一下总结,也写一下我自己在g++ 7.5.0上的验证。
本文中所有的源文件都可以在这里拿到(百度网盘链接)。
注意,这里所说的“对象”是指在C++中使用classstruct关键字创建的类的实例。

创新互联建站-专业网站定制、快速模板网站建设、高性价比河南网站开发、企业建站全套包干低至880元,成熟完善的模板库,直接使用。一站式河南网站制作公司更省心,省钱,快速模板网站建设找我们,业务覆盖河南地区。费用合理售后完善,10余年实体公司更值得信赖。

1. 无继承情况下的C++对象内存布局

首先当然是从最基础的情况来讲,在没有继承的情况下的C++对象内存布局是什么样的?这又分为两种:无虚函数和有虚函数。

1.1 无虚函数

C++类内成员变量分为两类:static成员变量和非static成员变量。static成员变量不在类的实例的内部,在整个内存中只有一份,只需要使用类名即可访问;而非static成员变量在类的实例内部,需要为其分配空间。
在这种情况下C++的对象和C的结构体是一样的,毕竟要实现和C的兼容,主要就是结构体/类内成员变量的对齐。
其一般规则总结如下:

  1. 所有成员按照在类内的声明顺序在内存中排列;
// test00.cpp
#include 

int main();

class Test00 {
    friend int main();
public:
    int i1;
private:
    int i2;
public:
    int i3;
};

#define showOffset(ClassName, memberName) (reinterpret_cast( &(static_cast(nullptr)->memberName)))

int main() {
    std::cout << showOffset(Test00, i1) << std::endl;
    std::cout << showOffset(Test00, i2) << std::endl;
    std::cout << showOffset(Test00, i3) << std::endl;
}

// Output:
//  0
//  4
//  8
  1. 任一非static成员变量的偏移(offset)要是其大小的倍数;
// test01.cpp
#include 
struct Test01 {
    char c;
    int i; // 如果紧凑排列,则i的偏移为1,但i的size为4,偏移要是4的倍数,因此i的偏移为4
};

#define showOffset(ClassName, memberName) (reinterpret_cast( &(static_cast(nullptr)->memberName)))

int main() {
    std::cout << showOffset(struct Test01, i) << std::endl;
}

// Output:
//  4
  1. 结构体整体的size需要为最大非static成员变量size的倍数;
// test02.cpp
#include 

// 如果紧凑排,Test02_1的size应为9,
// 但要与int(size为4)对其,所以其size为12
struct Test02_1 {
    char c1;  // Offset: 0
    int i;    // Offset: 4
    char c2;  // Offset: 8
};

// Test02_2成员和Test02_1相同,但顺序不同,
// 受规则2和3影响,其size为8
struct Test02_2 {
    char c1;  // Offset: 0
    char c2;  // Offset: 1
    int i;    // Offset: 4
};

int main() {
    std::cout << "sizeof Test02_1: " << sizeof(Test02_1) << std::endl;
    std::cout << "sizeof Test02_2: " << sizeof(Test02_2) << std::endl;
}

// Output:
//  sizeof Test02_1: 12
//  sizeof Test02_2: 8
  1. 空对象的size为1,为了保证每个对象都有唯一的内存位置(memory location)
// test03.cpp
#include 

struct Test03 {}; // Empty class

int main() {
    Test03 a, b;
    std::cout << "sizeof Test03: " << sizeof(Test03) << std::endl;
    if (&a == &b)
        std::cerr << " Error! &a == &b, at " << static_cast(&a) <<  std::endl;
    else
        std::cout << "a and b has different address, &a = " << static_cast(&a) << " and &b = " << static_cast(&b) << std::endl;
}

// Output:
//  sizeof Test03: 1
//  a and b has different address, &a = 0x7fffe62e8486 and &b = 0x7fffe62e8487
  1. 当类(class)/结构体(struct) A 作为一个类B的内部成员变量时,其对齐要求为类A内部最大的对齐要求;
// test04.cpp
#include 

// 规则2中的类,size为8,对齐要求为4
struct Test01{
    char c;
    int i;
};

struct Test04 {
    char c;   // Offset: 1, size 1
    Test01 t; // Offset: 4, size 8
};

#define showOffset(ClassName, memberName) (reinterpret_cast( &(static_cast(nullptr)->memberName)))

int main() {
    std::cout << "Offset of t in struct Test04: " << showOffset(Test04, t) << std::endl;
    std::cout << "sizeof Test04: " << sizeof(Test04) << std::endl; 
}

// Output:
//  Offset of t in struct Test04: 4
//  sizeof Test04: 12
  1. 空的类(empty class)A作为作为一个类B的成员变量时,类A占用一个字节的空间,对其要求也为1;
// test05.cpp
#include 

// 规则4中的类,空类,size = 1
struct Test03 {};

struct Test05 {
    char c;     // Offset: 0, size: 1
    Test03 t;   // Offset: 1, size: 1
};

#define showOffset(ClassName, memberName) (reinterpret_cast( &(static_cast(nullptr)->memberName)))

int main() {
    std::cout << "Offset of t in struct Test04: " << showOffset(Test05, t) << std::endl;
    std::cout << "sizeof Test05: " << sizeof(Test05) << std::endl; 
}

// Output:
//  Offset of t in struct Test04: 1
//  sizeof Test05: 2

1.2 有虚函数

C++使用虚函数来实现多态,非虚函数不展现多态性,当调用非虚函数时,只要调用一个写死的地址即可,无论是使用对象调用还是使用指针/引用调用;而当使用指针/引用调用虚函数需要视其绑定到的实际对象来调用对应的虚函数,以展现多态性(用对象调用虚函数不展现多态性)。
而C++实现虚函数用到的便是虚表。所谓虚表,就是保存该类所有虚函数地址的一张表,一个类的某个确定的虚函数在虚表的确定位置,而类实例中有一个虚表指针指向该虚表,当出现类继承并覆写(override)了该虚函数时,只需要将虚表指针指向另一张虚表,该虚表中对应位置的函数指针换为新的函数即可。另外,一个类的所有对象共享同一张虚表,因此不会带来大的内存消耗。该虚表由编译器生成。
这里只是对于虚函数和虚表进行了简单的描述,详细可查询网络资源,这里不再赘述。
就像上面所说,相比于没有虚函数的类,由虚函数的类的实例只是多了一个指向虚表的指针,其放在类的开头或者结尾(g++将其放在类的开头),大小和对其要求视平台而定,在x86-64平台上,虚表指针大小和对其要求为8字节。

// test06.cpp
class Point {
public:
    Point(int x)
        :m_x(x)
    {}

    virtual
    int getX()
    { return m_x; }

private:
    int m_x;
};


int main() {
    Point p(1);
    int x = p.getX();
}


使用gdb观察,可以看到Point类实例p的size为16,包括size为8的虚表指针和size为4的int类型的成员变量m_x,同时,由于虚表指针的对其要求为8,所以Point的size必须是8的倍数,所以其size为16。
同时查看p的内存布局,可以看到虚表指针被放置于类实例的头部,占用8个字节,后面紧跟4个字节的int类型的成员变量m_i,最后填充了4个字节以使类Point的size为8的倍数。

我们在查看一下虚表指针指向的内存,我这里使用的是64位系统和程序,所以函数指针是8位大小,虚表指针指向的虚表的第一个表项是地址0x0b2,同时查看反汇编,因为我们使用对象来调用虚函数,不展现多态性,这里直接call了Point::getX()的地址,可以看到其地址为0x0b2,正好是前面虚表的第一个表项。
还有就是Point类型对应的typeinfo对象的地址,在《深度探索C++对象模型》中提到其位于虚表的第一个表项,但前面我们看到虚表第一个表项存放的是虚函数,那typeinfo的地址放在哪里呢?我们来找一下。

// file test07.cpp
#include 
  2 class Point {
  3 public:
  4     Point(int x)
  5         :m_x(x)
  6     {}
  7
  8     virtual
  9     int getX()
 10     { return m_x; }
 11
 12 private:
 13     int m_x;
 14 };
 15
 16 int main() {
 17     Point p(1);
 18     auto& ti = typeid(p);
 19     int x = p.getX();
 20 }


可以看到反汇编中保存了0x8200da8这一地址到栈上,再结合我们的源码,很可能gdb所提示的<_ZTI5Point>这一对象就是Point类的typeinfo对象,我们使用工具c++filt来看_ZTI5Point这个被修饰过的符号是什么含义,不出所料,正是Point类对应的typeinfo对象。

liuyun@DESKTOP-Q5AT31V:/tmp/test/cppObjectModel/chap03/blog$ c++filt _ZTI5Point
typeinfo for Point

既然Point对应的typeinfo对象的地址为0x8200da8,我们查看虚表附近的地址,发现虚表指针指向的地址的前面的一个QWORD的内容正好是typeinfo的地址,那是不是虚表指针指向的并不是虚表的开头,而是第一个虚函数所在的地址,而在虚表中,第一个虚函数这一表项前面便是该类对应的typeinfo的地址?

在查阅资料的时候,《C++虚函数之二:虚函数表与虚函数调用》这篇博客提到g++支持-fdump-class-hierarchy这一编译选项,可以生成一个名为{source_file_name}.002t.class的文件,文件中详细记录了各个类的信息,包括其虚表信息。
正如我们所想,如果我们使用vptr指代虚表指针,那么vptr[0]就是第一个虚函数的地址,vptr[-1]则是该类对应的typeinfo的地址,而在最前面,g++还填充了一个空的表项。

最后还有一个问题,再没有虚函数的时候,编译器为了让每一个对象都有自己独一无二的地址,会在对象中插入一个字节占位,而在有虚函数的时候类中会有一个原生的虚表指针vptr,从而至少占8字节大小(x86-64上),那么是否就不需要再插入一个字节了呢?事实正如我们所想,Test08类的size为8而不是16。

// test08.cpp
#include 

class Test08 {
public:
virtual
int getNumber() { return s_i++; }

private:
    static int s_i;
};

int Test08::s_i = 0;

int main() {
    std::cout << "sizeof Test08: " << sizeof(Test08) << std::endl;
    Test08 t;
    int i = t.getNumber();
}
// Output:
//  sizeof Test08: 8

这一篇博客就先写到这里,下一篇再谈谈在继承体系下g++是如何实现C++对象的内存布局的。


本文名称:C++对象模型:g++的实现(一)
链接URL:http://myzitong.com/article/dsoieip.html