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