C++ 虚函数之一:对象内存布局

考虑这样三个类:
虚函数
Base1、Base2和Derived各有一个数据成员。Base1有两个虚函数A()和B(),Base2有两个虚函数C()和D(),Dervied继承了Base1和Base2,并重新实现了A()和C():

 class Base1
 {
 public:
     Base1() { memset(&Base1Data, 0x11, sizeof(Base1Data)); }
     virtual void A() {};
     virtual void B() {};
     uint64_t Base1Data;
 };

 class Base2
 {
 public:
     Base2() { memset(&Base2Data, 0x22, sizeof(Base2Data)); }
     virtual void C() {};
     virtual void D() {};
     uint64_t Base2Data;
 };

 class Derived : public Base1, public Base2
 {
 public:
     Derived() { memset(&DerivedData, 0x33, sizeof(DerivedData)); }
     virtual void A() {};
     virtual void C() {};
     uint64_t DerivedData;
 };

那么,对于下面的代码,虚函数调用具体是怎样的过程呢?

Base1 *x = new Derived;
x->A();
x->B();

Base2 *y = new Derived;
y->C();
y->D();

Derived *z = new Derived;
z->A();
z->B();
z->C();
z->D();

下面我以GCC 7.3.0生成的二进制代码来分析一下上面的C++代码。

对象内存布局

首先关注一下Dervied对象的内存布局,利用gdb,可以很方便的达到我们的目的:

(gdb) p sizeof(Derived) #查看对象大小
$7 = 40
(gdb) x/5xg z #直接打印出z对象数据
0x555555768ed0: 0x0000555555755ce0  0x1111111111111111
0x555555768ee0: 0x0000555555755d08  0x2222222222222222
0x555555768ef0: 0x3333333333333333

上面输出的0xXXXXXXXXXXXXXXXX很明显分别是三个数据成员,那么另外两个0x0000555555755ce0和0x0000555555755d08又是什么呢?这两个明显是内存地址值,我们直接看看这些地址存了什么:

(gdb) x 0x0000555555755ce0
0x555555755ce0 <_ZTV7Derived+16>:   0x0000555555554b7e
(gdb) x 0x0000555555755d08
0x555555755d08 <_ZTV7Derived+56>:   0x0000555555554b95

gdb提示这两个地址分别位于_ZTV7Derived+16和_ZTV7Derived+56处,那么_ZTV7Derived是什么呢?这明显是个mangle后的C++对象名或函数名,利用c++filt对其demangle:

$ c++filt _ZTV7Derived
vtable for Derived

正是Derived的虚函数表,也就是说Derived对象中的两个地址值都指向Derived的虚函数表起始地址后的一个位置。

下面我们换种方式输出z对象的数据:

(gdb) p/x *z
#为了直观,我调整了输出内容的格式
$8 = {
    <Base1> = {
        _vptr.Base1 = 0x555555755ce0 <vtable for Derived+16>,  
        Base1Data = 0x1111111111111111
    }, 
    <Base2> = { 
        _vptr.Base2 = 0x555555755d08 <vtable for Derived+56>, 
        Base2Data = 0x2222222222222222
    },  
    DerivedData = 0x3333333333333333
}

可以非常直观的看出来对象z的内存布局,前16个字节属于基类Base1,中间16个字节属于基类Base2,最后8个自己属于Derived。前16个字节和中间16个字节分别都可以看作是一个独立的完整对象,我们可以看一下下面的结果:

(gdb) p/x *x
$19 = {
    _vptr.Base1 = 0x555555755ce0 <vtable for Derived+16>, 
    Base1Data = 0x1111111111111111
}
(gdb) p/x *y
$20 = {
    _vptr.Base2 = 0x555555755d08 <vtable for Derived+56>, 
    Base2Data = 0x2222222222222222
}
(gdb) p/x *(Derived *)x
#输出同z,省略
(gdb) p/x *(Derived *)y
#输出同z,省略

这时我们注意到一个有趣的事情,y指向的是一个Derived对象的中间部分:

(gdb) p y
$41 = (Base2 *) 0x555555768eb0
(gdb) x/3xg 0x555555768eb0 #
0x555555768eb0: 0x0000555555755d08  0x2222222222222222
0x555555768ec0: 0x3333333333333333
(gdb) x/5xg 0x555555768eb0-16 #查看y-16到y+24的数据
0x555555768ea0: 0x0000555555755ce0  0x1111111111111111
0x555555768eb0: 0x0000555555755d08  0x2222222222222222
0x555555768ec0: 0x3333333333333333

虽然y的值为0x555555768eb0,但实际上y指向的Derived对象起始地址是0x555555768ea0比y小16个字节,这个特性会影响类型转换操作,考虑下面的代码,执行4种类型的类型转换操作:

Base2 *m = new Derived;
printf("%p %p %p %p %p\n", m,
        dynamic_cast<Derived *>(m),
        static_cast<Derived *>(m),
        reinterpret_cast<Derived *>(m),
        (Derived *)m);

会输出什么呢?

0x55ceedd2fe80 0x55ceedd2fe70 0x55ceedd2fe70 0x55ceedd2fe80 0x55ceedd2fe70

没错,某些方式类型转换前后,指针值发生了变化,指针被调整到指向了实际对象起始地址,事实上只有reinterpret_cast没有帮我们调整这个值。这是不是意味着dynamic_cast、static_cast和C-style cast做了相同的事情呢?当然不是,上面这个例子static_cast和C-style cast只是凑巧没把事情搞砸。考虑下面的代码:

Base2 *n = new Base2;
printf("%p %p %p %p %p\n", n,
        dynamic_cast<Derived *>(n),
        static_cast<Derived *>(n),
        reinterpret_cast<Derived *>(n),
        (Derived *)n);

这回,*n是一个真正的Base2,会输出什么呢?

0x55ceedd302b0 (nil) 0x55ceedd302a0 0x55ceedd302b0 0x55ceedd302a0

n根本不是一个Derived指针,static_cast和C-style cast还是画蛇添足的把指针向前调整了16个字节,调整到了一个当前对象外的地址。如果这个地址恰好是另一个有效对象的内部,然后你又做了一些写操作的话,后果就很难说了,可能什么事情也没有,也可能会出现一个需要你花几天时间调试的bug。
这一篇就先写到这里,剩下的内容接下来更新。

猜你喜欢

转载自blog.csdn.net/imred/article/details/80646057