解析VC6.0中为类对象申请内存的过程(三) | ||||||
| ||||||
再来看一下用malloc申请内存的汇编代码,以加深理解两者的区别。下面的malloc申请内存的汇编代码:
00401D2D mov esi,esp 00401D2F push 8 00401D31 call dword ptr [__imp__malloc (004176c8)] 00401D37 add esp,4 00401D3A cmp esi,esp 00401D3C call _chkesp (004021ca) 00401D41 mov dword ptr [ebp-8],eax 00401D44 mov esi,esp 00401D46 mov eax,dword ptr [ebp-8] 00401D49 push eax 00401D4A call dword ptr [__imp__free (004176cc)] 00401D50 add esp,4 00401D53 cmp esi,esp 00401D55 call _chkesp (004021ca) 可以看到这里除对堆栈做了两次校验外没有看到任何有关构造和析构函数的调用。从以上分析可以看出两者的区别就在于:用new申请类对象时编译器加入对类构造函数的调用而malloc则没有,这是因为malloc是C语言中的函数,在C语言中没有类的概念,实现时也不会为其加入对构造函数的调用,如果单步调试到00401D7E call operator new (004021c4)时,按F11进入它的内部就能看到它的内部仍然是把调用转给了malloc函数,free 和delete区别也类似是这样的。
3 new操作的过程 本节来详细看一下申请一个类对象时的构造过程和申请一个类对象数组时的构造过程以及为基本数据类型申请数组时的一些情况,并对比三者的不同;试验时用的类还是上面的CMemoryItem类。 3.1 new单个类对象的过程
3.1.1 类中有虚函数 通过前面叙述了解new一个类对象过程但并未提及它的构造过程,这里接着上面的内容解析一下单个类的构造过程。看下面的示例代码: 代码1: CMemoryItem* pItem = new CMemoryItem; delete pItem; 在调用类的构造函数时,代码段是这样的: 00401D96 mov ecx,dword ptr [ebp-28h] 00401D99 call @ILT+15(CMemoryItem::CMemoryItem) (00401014) 00401D9E mov dword ptr [ebp-38h],eax 在dword ptr [ebp-28h]中存放着CMemoryItem类对象申请的内存起始地址,现在跟踪一下类的构造函数来看看内部的执行流程,看现在的汇编代码:
00401DF0 push ebp 00401DF1 mov ebp,esp 00401DF3 push 0FFh 00401DF5 push offset __ehhandler$??0CMemoryItem@@QAE@XZ (004037ec) 00401DFA mov eax,fs:[00000000] 00401E00 push eax 00401E01 mov dword ptr fs:[0],esp 00401E08 sub esp,44h 00401E0B push ebx 00401E0C push esi 00401E0D push edi 00401E0E push ecx 00401E0F lea edi,[ebp-50h] 00401E12 mov ecx,11h 00401E17 mov eax,0CCCCCCCCh 00401E1C rep stos dword ptr [edi] 00401E1E pop ecx 00401E1F mov dword ptr [ebp-10h],ecx 00401E22 mov ecx,dword ptr [ebp-10h] 00401E25 add ecx,4 00401E28 call CString::CString (00402164) 00401E2D mov dword ptr [ebp-4],0 00401E34 mov eax,dword ptr [ebp-10h] 00401E37 mov dword ptr [eax],offset CMemoryItem::`vftable' (0041501c) 00401E3D mov ecx,dword ptr [g_Number (00416900)] 00401E43 add ecx,1 00401E46 mov dword ptr [g_Number (00416900)],ecx 00401E4C mov edx,dword ptr [g_Number (00416900)] 00401E52 push edx 00401E53 push offset string "Construct CMemoryItem Nember[%d]"... (004152c0) 00401E58 mov eax,dword ptr [ebp-10h] 00401E5B add eax,4 00401E5E push eax 00401E5F call CString::Format (0040119a) 00401E64 add esp,0Ch 00401E67 mov ecx,dword ptr [ebp-10h] 00401E6A add ecx,4 00401E6D call CString::operator char const * (0040214c) 00401E72 mov esi,esp 00401E74 push eax 00401E75 call dword ptr [__imp__OutputDebugStringA@4 (004173e0)] 00401E7B cmp esi,esp 00401E7D call _chkesp (004021ca) 00401E82 mov dword ptr [ebp-4],0FFFFFFFFh 00401E89 mov eax,dword ptr [ebp-10h] 00401E8C mov ecx,dword ptr [ebp-0Ch] 00401E8F mov dword ptr fs:[0],ecx 00401E96 pop edi 00401E97 pop esi 00401E98 pop ebx 00401E99 add esp,50h 00401E9C cmp ebp,esp 00401E9E call _chkesp (004021ca) 00401EA3 mov esp,ebp 00401EA5 pop ebp 00401EA6 ret 因为代码较多,在此只选择其中关键部分的代码来讨论这里把构造函数的整代码分成3部分函数初始化代码、类初始化代码、函数体代码。 (1)函数初始化代码 首先来简单了解一下00401DF0到00401E1C的这段代码,主要是一些保存当前状态的操作,大家可以看到最后一个入栈的寄存器ECX,那也就是说代码一直执行00401E1C处 ECX都是栈顶第一个元素。下面代码接着是00401E1E位置的代码,它把栈顶的一个元素弹出并保存到了ECX寄存器中,这个寄存器中当前值是什么呢?可能大家都一直这个疑问,返回再去看看构造函数调用前的那一句汇编代码: 00401D96 mov ecx,dword ptr [ebp-28h] 这样大家应该明白了,ECX寄存器中存放的就是CMemoryItem对象的起始地址,这时它被保存入dword ptr [ebp-10h] 内存区。 (2)类初始化代码 这个阶段从00401E1E一直到00401E37,在这段汇编代码中间没有看到有关在类的构造函数中写的操作。它首先把对象的起始地址存入了dword ptr [ebp-10h],然后将对象指针偏移4个字节,因为这个地方存放了类的成员变量m_info;由于它是CString类的对象所以这里调用了它的默认构造函数来构造这个成员变量。接着再看00401E34和00401E37这两句汇编代码;先把对象的起始地址放到EAX寄存器中,然后把offset CMemoryItem::`vftable' (0041501c)放入对象前4个字节中,这里大家肯定有一个疑问那就是这个偏移地址到底是什么?再返回去看CMemoryItem类的声明,可以看到它的构造函数被声明成了一个虚函数。一些编译器支持虚函数的方法就是为这个类建立一个虚函数表,可见这个偏移就是这个虚函数表了。
| ||||||
00402123 push 4 00402125 mov eax,dword ptr [ebp-4] 00402128 push eax 00402129 call `eh vector destructor iterator' (004028f0) 0040212E mov ecx,dword ptr [ebp+8] 00402131 and ecx,1 00402134 test ecx,ecx 00402136 je CMemoryItem::`vector deleting destructor'+57h (00402147) 00402138 mov edx,dword ptr [ebp-4] 0040213B sub edx,4 0040213E push edx 0040213F call operator delete (00402786) 00402144 add esp,4 00402147 mov eax,dword ptr [ebp-4] 0040214A sub eax,4 0040214D jmp CMemoryItem::`vector deleting destructor'+80h (00402170) 0040214F mov ecx,dword ptr [ebp-4] 00402152 call @ILT+160(CMemoryItem::~CMemoryItem) (004010a5) 00402157 mov eax,dword ptr [ebp+8] 0040215A and eax,1 0040215D test eax,eax 0040215F je CMemoryItem::`vector deleting destructor'+7Dh (0040216d) 00402161 mov ecx,dword ptr [ebp-4] 00402164 push ecx 00402165 call operator delete (00402786) 0040216A add esp,4 0040216D mov eax,dword ptr [ebp-4] 00402170 pop edi 00402171 pop esi 00402172 pop ebx 00402173 add esp,44h 00402176 cmp ebp,esp 00402178 call _chkesp (004027d4) 0040217D mov esp,ebp 0040217F pop ebp 00402180 ret 4 在这个调用里循环为了执行每一个数组成员的析构函数,最后释放整块内存返回。而且大家可以从00402161这句看出,系统把pItem指向的地址向前移了4字节,上节的讨论中知道,为对象申请数组时会多申请4字节并把前4字节存放入数组的元素个数,那么释放这个数组也得从它的开始位置释放,所以要向前移动4字节。这是一个正常的情况,那么出错的情况是怎么发生的呢?接下来看一下代码2的汇编实现: | ||||||
00401EA8 mov ecx,dword ptr [ebp-20h] 00401EAB call @ILT+170(CMemoryItem::`scalar deleting destructor') (004010af) 00401EB0 mov dword ptr [ebp-2Ch],eax 第一句不变,第二句变化了那相关流程会是什么样呢?进入它的内部去跟踪一下。看下面跟踪的汇编代码:
004021B0 push ebp 004021B1 mov ebp,esp 004021B3 sub esp,44h 004021B6 push ebx 004021B7 push esi 004021B8 push edi 004021B9 push ecx 004021BA lea edi,[ebp-44h] 004021BD mov ecx,11h 004021C2 mov eax,0CCCCCCCCh 004021C7 rep stos dword ptr [edi] 004021C9 pop ecx 004021CA mov dword ptr [ebp-4],ecx 004021CD mov ecx,dword ptr [ebp-4] 004021D0 call @ILT+160(CMemoryItem::~CMemoryItem) (004010a5) 004021D5 mov eax,dword ptr [ebp+8] 004021D8 and eax,1 004021DB test eax,eax 004021DD je CMemoryItem::`scalar deleting destructor'+3Bh (004021eb) 004021DF mov ecx,dword ptr [ebp-4] 004021E2 push ecx 004021E3 call operator delete (00402786) 004021E8 add esp,4 004021EB mov eax,dword ptr [ebp-4] 004021EE pop edi 004021EF pop esi 004021F0 pop ebx 004021F1 add esp,44h 004021F4 cmp ebp,esp 004021F6 call _chkesp (004027d4) 004021FB mov esp,ebp 004021FD pop ebp 004021FE ret 4 顺着看下来一直到004021C9,这句过后可得出ECX里存放的是pItem指向的地址。紧接着一直到004021D0,它是对类析构函数的调用。再向下看程序只做了一些检查就直接调用了释放内存的调用,这也能解释为什么能看到那一句输出。再想想上面一起讨论了new的过程,pItem指向的地址向前4字节才是new所得内存的开始,但看004021E2这句它直接把pItem指向的地址传给了释放内存的调用,这当参数当然不是一个有效的内存块的起始地址了。如果深入跟踪的话可以了解到在debug版本在堆内存的管理中,为每一块申请的内存建立了一个链表项并存放在一个链表中,释放时就去链表里找。而传入的那个地址显然不是一个已申请内存块的正确地址,所以系统会报错。 在上面实验的类中把CMemoryItem析构函数的virtual修饰符去掉,现在再把它加做上面的实验看一下情况会有变化吗?在析构函数中加不加virtual修饰符真的在释放时没有差别吗?那么再看一下单个对象的释放。 4.2 解释类对象时的跟踪 同样先把CMemoryItem类的析构函数的virtual修饰符去掉,然后来看两段代码片段的具体执行情况。 代码1: CMemoryItem* pItem = new CMemoryItem; delete pItem; 执行正常,再看下面的代码。 代码2: CMemoryItem* pItem = new CMemoryItem; delete[] pItem; 弹出了上面贴出的那个出错框。大家可能觉得这很正常,那来看看一段不正常的。现在再把类析构函数的virtual修饰符加在上面的实验看一下情况。代码1没错(肯定的),再执行代码2也没错。是不是有些奇怪了,为了了解其中原因就看它的汇编代码。下面列出代码: 00401E89 mov edx,dword ptr [ebp-20h] 00401E8C mov eax,dword ptr [edx] 00401E8E mov ecx,dword ptr [ebp-20h] 00401E91 call dword ptr [eax] EDX 和ECX存放是的pItem指向的地址,因为这个类的析构函数加了virtual修饰符,那么这个类的前四个字节就是析构函数的指针。00401E91这句也就调用了类的析构函数,这自然不会错。那么为什么类的析构函数不加virtual修饰符时就会出错呢?下面也列出一段汇编代码大家便可明白。 00401E87 mov ecx,dword ptr [ebp-20h] 00401E8A call @ILT+170(CMemoryItem::`vector deleting destructor') (004010af) 类的析构函数不加virtual修饰符时, delete[] pItem;系统把它当成一个对象数组去释放自然会出错。这不是说只要为类的析构函数加了virtual修饰符就可以不分数组还是单个对象都用delete[]释放了。因为不同编译器或相同编译器的不同版会有不同的编译方法,只是对VC6.0编译后的程序做了剖析。 5 结语 这里讨论了VC6.0中new和deltte对象的过程,希望能给大家的编程带来一些帮助。 |