[Happy Coding] C++中的多态遇上数组

探讨C++中多态性与动态数组结合时可能遇到的问题,特别是在不同位宽平台上的表现差异。通过具体示例代码,分析了GCC编译器下程序的行为,并深入汇编层面解释了潜在错误的原因。

在C++中,当多态遇上数组会发生什么事情,比如下面的代码:

class A {
    public:
        A() {
                m_data = 10;
        }
    virtual void print() {

        printf("%d\n", m_data);
    }

    int m_data;

};

class B : public A {
    public:
        B() {
                m_data1 = 11;
    }
    virtual void print() {
        printf("%d\n", m_data1);
    }
    int m_data1;
};

int main()
{
   A* p = new B[2];
   p[1].print();
   delete [] p;
   return 0;

}
问题:

1. 用GCC来编译上面程序,能够通过吗?

2. 如果编译通过了,程序能够正常运行吗?

回答:

1. GCC能够正确编译出可执行文件;

2. 但是程序运行时会不会core dump,得看是32bit平台,还是64bit平台。32bit平台会crash,64bit不会。

简单分析

在32bit平台上,sizeof(A)=8, sizeof(B) = 12. p[1] 好比于p+1操作,故而p[1]将会(char*)p + 8地址位置,而不是(char*)p+12,第二个B所在的位置。而(char*)p+8指向的位置是第一个B的数据内部,那里并没有vptr。

在64bit平台上,sizeof(A)=16 (sizeof(vptr)=8, sizeof(int)=4, with 4 padding bytes), sizeof(B)=16. p[1]所指的位置正好是第二个B的头指针。因此能够正常操作那里的vptr。程序运行不会有问题。(但不建议这样的代码)


复杂分析

这个分析将从汇编代码的角度来阐述(平台:intel-i386):

直接贴出main函数的汇编代码,接下来将会分析各条指令的含义:

00000000 <main>:
   0:   55                      push   %ebp
   1:   89 e5                   mov    %esp,%ebp
   3:   83 ec 18                sub    $0x18,%esp
   6:   83 e4 f0                and    $0xfffffff0,%esp
   9:   b8 00 00 00 00          mov    $0x0,%eax
   e:   83 c0 0f                add    $0xf,%eax
  11:   83 c0 0f                add    $0xf,%eax
  14:   c1 e8 04                shr    $0x4,%eax
  17:   c1 e0 04                shl    $0x4,%eax
  1a:   29 c4                   sub    %eax,%esp
  1c:   83 ec 0c                sub    $0xc,%esp
  1f:   6a 18                   push   $0x18
  21:   e8 fc ff ff ff          call   22 <main+0x22>
                        22: R_386_PC32  _Znaj
  26:   83 c4 10                add    $0x10,%esp
  29:   89 45 f8                mov    %eax,0xfffffff8(%ebp)
  2c:   8b 45 f8                mov    0xfffffff8(%ebp),%eax
  2f:   89 45 f4                mov    %eax,0xfffffff4(%ebp)
  32:   c7 45 f0 01 00 00 00    movl   $0x1,0xfffffff0(%ebp)
  39:   83 7d f0 ff             cmpl   $0xffffffff,0xfffffff0(%ebp)
  3d:   75 02                   jne    41 <main+0x41>
  3f:   eb 17                   jmp    58 <main+0x58>
  41:   83 ec 0c                sub    $0xc,%esp
  44:   ff 75 f4                pushl  0xfffffff4(%ebp)
  47:   e8 fc ff ff ff          call   48 <main+0x48>
                        48: R_386_PC32  _ZN1BC1Ev
  4c:   83 c4 10                add    $0x10,%esp
  4f:   83 45 f4 0c             addl   $0xc,0xfffffff4(%ebp)
  53:   ff 4d f0                decl   0xfffffff0(%ebp)
  56:   eb e1                   jmp    39 <main+0x39>
  58:   8b 45 f8                mov    0xfffffff8(%ebp),%eax
  5b:   89 45 fc                mov    %eax,0xfffffffc(%ebp)
  5e:   83 ec 0c                sub    $0xc,%esp
  61:   8b 45 fc                mov    0xfffffffc(%ebp),%eax
  64:   83 c0 08                add    $0x8,%eax
  67:   8b 10                   mov    (%eax),%edx
  69:   8b 45 fc                mov    0xfffffffc(%ebp),%eax
  6c:   83 c0 08                add    $0x8,%eax
  6f:   50                      push   %eax
  70:   8b 02                   mov    (%edx),%eax
  72:   ff d0                   call   *%eax
  74:   83 c4 10                add    $0x10,%esp
  77:   83 7d fc 00             cmpl   $0x0,0xfffffffc(%ebp)
  7b:   74 0e                   je     8b <main+0x8b>
  7d:   83 ec 0c                sub    $0xc,%esp
  80:   ff 75 fc                pushl  0xfffffffc(%ebp)
  83:   e8 fc ff ff ff          call   84 <main+0x84>
                        84: R_386_PC32  _ZdaPv
  88:   83 c4 10                add    $0x10,%esp
  8b:   b8 00 00 00 00          mov    $0x0,%eax
  90:   c9                      leave
  91:   c3                      ret

上面代码中用到的symbol用c++filt解析之后如下:

bash-3.00$ c++filt _Znaj _ZN1BC1Ev _ZN1AC2Ev _ZN1A5printEv _ZN1B5printEv _ZdaPv
operator new[](unsigned int)
B::B()
A::A()
A::print()
B::print()
operator delete[](void*)

汇编代码中相关常量的具体数值如下:

0xfffffff8 = -0x18 = -8
0xfffffff4 = -0x0c = -12
0xfffffff0 = -0x10 = -16
0xffffffff = -1
0xfffffffc = -4


分析如下:

1. 程序首先会调用operator new[](unsigned int) (_Znaj: line 22),在堆空间中构造2个B对象,在调用之前会调用push   $0x18指令来传入24 (2*12)个字节的参数。这是函数operator new[]要求的。

2. line29中%eax存的是源代码中p指针的值,代表2个B对象堆空间的首地址。它将会被保存到0xfffffff8(%ebp) (=%ebp-8)中,然后拷贝到0xfffffff4(%ebp) (=%ebp-12)的内存作为临时变量,调用B::B() (_ZN1BC1Ev: line 48)。因为在call 48 <main+0x48>之前总是调用pushl  0xfffffff4(%ebp) (=%ebp-12)。注意到line39的指令是一个比较指令,它会将-1 与 0xfffffff0(%ebp) (=%ebp-16)内存变量(=+1)做比较,从而循环2次来构造B对象,其间line4f的指令addl   $0xc,0xfffffff4(%ebp) 将那个内存地址往后推到下一个B对象地址头。

1和2,一起就完成了语句A* p = new B[2]; 1就是函数operator new [](unsigned int)调用,2就是placement new语句(A* p = new (%eax) A).


3. 先看p[1]如何被计算出来:

  58:   8b 45 f8                mov    0xfffffff8(%ebp),%eax
  5b:   89 45 fc                mov    %eax,0xfffffffc(%ebp)
  5e:   83 ec 0c                sub    $0xc,%esp
  61:   8b 45 fc                mov    0xfffffffc(%ebp),%eax
  64:   83 c0 08                add    $0x8,%eax
  67:   8b 10                   mov    (%eax),%edx
我们知道之前有指令将%eax的值保存到了%ebp-8内存空间中,现在line58将它取出来放到eax中。接下来将这个值备份到%ebp-4内存空间中。注意到line64,常数8被增加到了eax中,这里的8就是sizeof(A),而之前的%eax的值就是2个B堆空间的首地址,所以这条指令的意思就是将首地址增加8个字节,与我们之前分析的(char*)p + 8刚好对应上,但是增加8个字节并不能定位到第二个B对象地址。[问题就是出现在这里]

再来看看p[1].print是如何被调用的。

  69:   8b 45 fc                mov    0xfffffffc(%ebp),%eax
  6c:   83 c0 08                add    $0x8,%eax
  6f:   50                      push   %eax
  70:   8b 02                   mov    (%edx),%eax
  72:   ff d0                   call   *%eax
仍然是获取到堆空间的首地址,然后增加8个字节,存放到%eax中。由于print函数是虚函数,所以这个调用必然是在运行时决定的。line6f的push %eax指令是将偏移后的地址(期待是this指针)作为第一个参数压入堆栈。注意到C++对象的虚函数表指针被GCC放在对象内存空间的起始位置,而且B只有一个虚函数,故print函数的地址位于虚函数表的第一个,因而line70指令是将%edx(=%eax:B对象起始位置上的虚函数表指针)指向的虚函数表,也即print函数的地址存入%eax。line71指令中*是代表运行时调用%eax指向的函数体,从而实现多态调用。由于前面增加8个字节来获取第一个B对象地址的操作不正确,故调用print函数会失败。


4. line74到line88是delete [] p;的汇编代码。0xfffffffc(%ebp) (%ebp-4) 存储着指针p。首先判断p是否等于0,然后将其作为参数push到栈上,接着调用operator delete[](void*)函数。


进一步:

Q: 如果class A定义了virtual ~A();虚析构函数,delete [] p;调用会crash程序吗?

A: 在32bit平台上,会crash,原因类似。虚析构函数也是通过虚函数表指针来定位的。我们知道delete [] p;操作会先调用class A的虚析构函数2次,然后再调用operator delete [](void*)来回收堆空间。那么在用p+1来定位到第二个A*指针时,那么并没有一个虚函数表指针,因此无法定位到虚析构函数。

以下是包含virtual ~A();之后的main函数汇编代码片段:

Disassembly of section .text:

00000000 <main>:
   0:   55                      push   %ebp
   1:   89 e5                   mov    %esp,%ebp
   3:   53                      push   %ebx
   4:   83 ec 34                sub    $0x34,%esp
   7:   83 e4 f0                and    $0xfffffff0,%esp
   a:   b8 00 00 00 00          mov    $0x0,%eax
   f:   83 c0 0f                add    $0xf,%eax
  12:   83 c0 0f                add    $0xf,%eax
  15:   c1 e8 04                shr    $0x4,%eax
  18:   c1 e0 04                shl    $0x4,%eax
  1b:   29 c4                   sub    %eax,%esp
  1d:   83 ec 0c                sub    $0xc,%esp
  20:   6a 1c                   push   $0x1c
  22:   e8 fc ff ff ff          call   23 <main+0x23>
                        23: R_386_PC32  _Znaj
  27:   83 c4 10                add    $0x10,%esp
  2a:   89 45 f4                mov    %eax,0xfffffff4(%ebp)
  2d:   8b 45 f4                mov    0xfffffff4(%ebp),%eax
  30:   83 c0 04                add    $0x4,%eax
  33:   89 45 f0                mov    %eax,0xfffffff0(%ebp)
  36:   8b 55 f0                mov    0xfffffff0(%ebp),%edx
  39:   c7 42 fc 02 00 00 00    movl   $0x2,0xfffffffc(%edx)
  40:   8b 45 f0                mov    0xfffffff0(%ebp),%eax
  43:   89 45 ec                mov    %eax,0xffffffec(%ebp)
  46:   8b 55 ec                mov    0xffffffec(%ebp),%edx
  49:   89 55 e8                mov    %edx,0xffffffe8(%ebp)
  4c:   c7 45 e4 01 00 00 00    movl   $0x1,0xffffffe4(%ebp)
  53:   83 7d e4 ff             cmpl   $0xffffffff,0xffffffe4(%ebp)
  57:   75 05                   jne    5e <main+0x5e>
  59:   e9 89 00 00 00          jmp    e7 <main+0xe7>		; 循环2次之后,直接跳转到e7
  5e:   83 ec 0c                sub    $0xc,%esp
  61:   ff 75 e8                pushl  0xffffffe8(%ebp)
  64:   e8 fc ff ff ff          call   65 <main+0x65>
                        65: R_386_PC32  _ZN1BC1Ev
  69:   83 c4 10                add    $0x10,%esp
  6c:   83 45 e8 0c             addl   $0xc,0xffffffe8(%ebp)
  70:   ff 4d e4                decl   0xffffffe4(%ebp)
  73:   eb de                   jmp    53 <main+0x53>
  
  ; 从这里到e7前的代码都不会执行到
  
  75:   89 45 dc                mov    %eax,0xffffffdc(%ebp)
  78:   8b 45 dc                mov    0xffffffdc(%ebp),%eax
  7b:   89 45 e0                mov    %eax,0xffffffe0(%ebp)
  7e:   83 7d ec 00             cmpl   $0x0,0xffffffec(%ebp)
  82:   74 3e                   je     c2 <main+0xc2>
  84:   b8 01 00 00 00          mov    $0x1,%eax
  89:   2b 45 e4                sub    0xffffffe4(%ebp),%eax
  8c:   89 45 d8                mov    %eax,0xffffffd8(%ebp)
  8f:   8b 45 d8                mov    0xffffffd8(%ebp),%eax
  92:   d1 e0                   shl    %eax
  94:   03 45 d8                add    0xffffffd8(%ebp),%eax
  97:   c1 e0 02                shl    $0x2,%eax
  9a:   8b 55 ec                mov    0xffffffec(%ebp),%edx
  9d:   01 c2                   add    %eax,%edx
  9f:   89 55 d8                mov    %edx,0xffffffd8(%ebp)
  a2:   8b 45 d8                mov    0xffffffd8(%ebp),%eax
  a5:   39 45 ec                cmp    %eax,0xffffffec(%ebp)
  a8:   74 18                   je     c2 <main+0xc2>
  aa:   83 6d d8 0c             subl   $0xc,0xffffffd8(%ebp)
  ae:   83 ec 0c                sub    $0xc,%esp
  b1:   8b 55 d8                mov    0xffffffd8(%ebp),%edx
  b4:   8b 02                   mov    (%edx),%eax
  b6:   ff 75 d8                pushl  0xffffffd8(%ebp)
  b9:   8b 00                   mov    (%eax),%eax
  bb:   ff d0                   call   *%eax
  bd:   83 c4 10                add    $0x10,%esp
  c0:   eb e0                   jmp    a2 <main+0xa2>
  c2:   8b 45 e0                mov    0xffffffe0(%ebp),%eax
  c5:   89 45 dc                mov    %eax,0xffffffdc(%ebp)
  c8:   8b 5d dc                mov    0xffffffdc(%ebp),%ebx
  cb:   83 ec 0c                sub    $0xc,%esp
  ce:   ff 75 f4                pushl  0xfffffff4(%ebp)
  d1:   e8 fc ff ff ff          call   d2 <main+0xd2>
                        d2: R_386_PC32  _ZdaPv
  d6:   83 c4 10                add    $0x10,%esp
  d9:   89 5d dc                mov    %ebx,0xffffffdc(%ebp)
  dc:   83 ec 0c                sub    $0xc,%esp
  df:   ff 75 dc                pushl  0xffffffdc(%ebp)
  e2:   e8 fc ff ff ff          call   e3 <main+0xe3>
                        e3: R_386_PC32  _Unwind_Resume
						
; 开始执行delete [] p;
  e7:   8b 45 f0                mov    0xfffffff0(%ebp),%eax	; %ebp-16保存着p指针
  ea:   89 45 f8                mov    %eax,0xfffffff8(%ebp)
  ed:   83 7d f8 00             cmpl   $0x0,0xfffffff8(%ebp)	; 判断指针是否=0
  f1:   74 45                   je     138 <main+0x138>
  f3:   8b 45 f8                mov    0xfffffff8(%ebp),%eax
  f6:   83 e8 04                sub    $0x4,%eax        		; 将指针往前推4个字节(-4之后才是分配堆空间的真正起始地址)
  f9:   8b 00                   mov    (%eax),%eax				; 取出内容
  fb:   c1 e0 03                shl    $0x3,%eax				; * 8之后,应该是整个分配空间的大小
  fe:   8b 55 f8                mov    0xfffffff8(%ebp),%edx	
 101:   01 c2                   add    %eax,%edx				; 这样可以获取分配空间末尾的指针,放入%ebx
 103:   89 55 d4                mov    %edx,0xffffffd4(%ebp)
 106:   8b 45 d4                mov    0xffffffd4(%ebp),%eax
 109:   39 45 f8                cmp    %eax,0xfffffff8(%ebp)	; 判断首地址和末尾地址是否相等
 10c:   74 18                   je     126 <main+0x126>			; =?,跳转到126
 10e:   83 6d d4 08             subl   $0x8,0xffffffd4(%ebp)	; 否则,末尾地址减去8 (这个末尾地址-8,应该是从第二个对象开始析构)
 112:   83 ec 0c                sub    $0xc,%esp				
 115:   8b 55 d4                mov    0xffffffd4(%ebp),%edx	; 将减8之后的末尾地址存入%edx
 118:   8b 02                   mov    (%edx),%eax				; 取出那里的内容到%eax
 11a:   ff 75 d4                pushl  0xffffffd4(%ebp)			; 将减8之后的末尾地址,压入栈顶
 11d:   8b 00                   mov    (%eax),%eax				; %eax存着末尾地址-8指向的内容,应该是虚函数表指针。(%eax)是继续拿出第一个虚函数的地址
 11f:   ff d0                   call   *%eax					; 调用虚函数
 121:   83 c4 10                add    $0x10,%esp
 124:   eb e0                   jmp    106 <main+0x106>			; 跳转到106,继续执行第二个B的虚构函数
 126:   83 ec 0c                sub    $0xc,%esp
 129:   8b 45 f8                mov    0xfffffff8(%ebp),%eax	; 拿出p指针
 12c:   83 e8 04                sub    $0x4,%eax				; 往前4个字节,获取真正的分配空间首地址
 12f:   50                      push   %eax						; 压入栈顶,作为operator delete[](void*)的参数
 130:   e8 fc ff ff ff          call   131 <main+0x131>			; 
                        131: R_386_PC32 _ZdaPv		
 135:   83 c4 10                add    $0x10,%esp
 138:   b8 00 00 00 00          mov    $0x0,%eax
 13d:   8b 5d fc                mov    0xfffffffc(%ebp),%ebx
 140:   c9                      leave
 141:   c3                      ret

可以看到,delete [] p;的汇编语句是从第二个B对象开始析构:首先获取分配堆空间的末尾地址,然后减去sizeof(A)=8,对应以下语句:

10e:   83 6d d4 08             subl   $0x8,0xffffffd4(%ebp)	; 否则,末尾地址减去8 (这个末尾地址-8,应该是从第二个对象开始析构)

之后尝试着调用虚析构函数,失败了。

从delete []p;的汇编代码,我们可以知道一点GLIBC的实现细节:A* p其实并不是分配堆空间的首地址,p-4(32bits平台)/p-8(64bits平台)才是首地址指针,*(p-4)的值乘以8之后(fb: c1 e0 03 shl $0x3,%eax),就可以得到p之后的堆空间字节大小。如下图所示:


从下面的代码可知, p-4也是传入operator delete[]()函数的参数。

129:   8b 45 f8                mov    0xfffffff8(%ebp),%eax	; 拿出p指针
 12c:   83 e8 04                sub    $0x4,%eax				; 往前4个字节,获取真正的分配空间首地址






评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值