[Happy Coding] C++中的多態遇上數組

在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個字節,獲取真正的分配空間首地址






發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章