request-free-img

深入理解 C++ new[] 与 delete[] 的底层原理:为什么直接 delete p1 会产生未定义行为?

大家看这几行简单的C++代码,p1,p2都是指向包含MyClass 类型数组的指针。但在删除时,为什么要使用delete数组p2,而直接delete p1(delete p1指针)就会产生未定义行为呢?

今天这个视频,我们就来深入探讨这个问题,彻底弄明白其中的底层原理。

一、MyClass 类定义与内存占用

开始之前,咱们看一下我们的MyClass类,它非常简单,只有一个Int类型的变量value然后包含一个构造函数和一个析构函数,我们采用32位的方式编译,因此这个类会占用4个字节的内存空间。

二、从内存分配的汇编代码开始分析

好,想要弄明白这两种delete的底层原理,还是得看汇编代码,但是你不要担心,跟着我一步步地走,你也能看懂这些晦涩的汇编代码。

我们从分配数组内存这里开始看:

00F52A3E push 10h

这一行它将(0x10)压栈,0X10换成十进制就是16,它会作为operator new[]的参数,表示请求的内存大小,我们知道每个MyClass对象占用4个字节的长度,3个数组对象应该是12个字节,可这里为什么是16个字节呢?因为这里是一个包含3个MyClass对象的数组, 所以还需要4个字节来表示数组的长度(4字节数组长度 + 3*4字节对象)。

00F52A40 call operator new[] (0F511D6h)

然后就调用operator new[]函数分配内存。 它返回的指针存入eax寄存器中。

00F52A45 add esp,4

这一行是清理栈。

00F52A48 mov dword ptr [ebp-0F8h],eax

然后这行是将分配的内存指针(eax)存入局部变量中(ebp-0F8h)。

00CD2A4E mov dword ptr [ebp-4],0

这一行常见的是SEH异常处理标志。

00F52A4E mov dword ptr [ebp-4],0
00F52A55 cmp dword ptr [ebp-0F8h],0

这两行是检查new分配的内存是否为空,如果空就跳转分配失败的处理。

00F52A62 mov eax,dword ptr [ebp-0F8h]

接着再将new分配的内存指针加载到eax寄存器。

00F52A68 mov dword ptr [eax],3

然后在分配的内存的开头写入数组 cookie,它表示数组元素有3个。

这里需要说一下,在 C++ 中,使用new[]分配数组时,MS C++ 的运行时库(CRT)会分配额外的内存,包括调试模式下的堆头(Heap Header),里边会包括一些调试信息,用于检测溢出或损坏,堆头在 call operator new[] 时由 CRT 分配,用户无法直接看到CRT分配的堆头,它由 CRT 内部管理。然后是数组 cookie,通常是一个 4 字节(或更多字节)的隐藏值,用来存储数组元素数量。位于堆头之后。然后才是实际数组数据。

所以这一行汇编代码的意思是在数组cookie中写入3,表示数组有3个元素。

接下来这些汇编代码是调用MyClass类的构造函数,分别将1,2,3参数传给MyClass的构造函数,没有什么特别的,这一部份我们就不一一细讲了。

三、指针 p1 的初始化过程

00F52AE3 add ecx,4

我们知道ecx寄存器是指向operator new所分配的内存首地址,而前4个字节是用于记录数组cookie。所以这里跳过4个字节,就是跳过前边的数组cookie,使ecx指向数组的第一个元素。

00F52AE6 mov dword ptr [ebp-13Ch],ecx

然后把数组的第一个元素指针保存到ebp-13Ch。

00F52B11 mov dword ptr [p1],eax

最后这里将数组的第一个元素指针存入p1变量中。

到这里, 我们知道使用new所申请到的内存布局大至是这样的:最前边是CRT所管理的堆头,然后是数组 cookie,这里有4个字节,然后才是用户可见指针,也就是数组的第一个元素。

四、直接使用 delete p1 为什么会导致未定义行为?

咱们就来看会产生未定义行为的delete p1这行:

00F52B14 mov eax,dword ptr [p1]

首先这行是加载所分配的内存地址p1到eax寄存器。

00F52B17 mov dword ptr [ebp-104h],eax

然后这行再把指针保存到临时变量ebp-104h中。

00F52B1D cmp dword ptr [ebp-104h],0
00F52B24 je __$EncStackInitStart+131h (0F52B3Bh)

这两检查一下指针是否为0。

00F52B26 push 1

这一行将1压栈,这个是常见的删除析构函数的汇编代码,1表示deleting destructor的参数,表示需要释放内存。

00F52B28 mov ecx,dword ptr [ebp-104h]

然后这行是加载this指针到ecx。

00F52B2E call MyClass::`scalar deleting destructor' (0F51262h)

接着这行调用标量删除析构函数scalar deleting destructor(单对象删除),this参数保存ecx寄存器中,参数1表示释放。

这里插几句:在 C++ 中,标量删除析构函数(scalar deleting destructor)和向量删除析构函数(vector deleting destructor)是编译器为类类型生成的两类特殊函数,标量删除析构函数用于在 delete 时使用 和 向量删除析构函数用于在delete[] 操作中使用。它们是编译器在编译代码时生成的,只能通过汇编代码看到。

标量删除析构函数内部细节

好,我们继续跟入到这个标量删除析构函数中去:

00F527A0 push ebp
00F527A1 mov ebp,esp
...(栈帧设置代码)

前边这些代码是为了保存调用者的ebp,以及设置新的栈帧,同时保存一些寄存器的值。

00F527BC pop ecx
00F527BD mov dword ptr [this],ecx

接着恢复this指针。

00F527C0 mov ecx,dword ptr [this]
00F527C3 call MyClass::~MyClass (0F51339h)

接着调用MyClass类的析构函数~MyClass()析构单个对象。

00F527C8 mov eax,dword ptr [ebp+8]
00F527CB and eax,1
00F527CE je __$EncStackInitStart+31h (0F527DEh)

接着这三行,就是判断前边我们传入的第2个参数是不是为1,如果为1就需要删除对象。因为前面的汇编代码中,我们传入的是1,所以这里是需要删除对象的。

00F527D0 push 4

然后这里压入4,作为operator delete的大小(这里只删除MyClass单个对象,也就是4个字节,但实际数组是16字节,这里预示着将会存在问题)。

00F527D2 mov eax,dword ptr [this]
00F527D5 push eax
00F527D6 call operator delete (0F510A0h)

紧接着调用operator delete释放内存。因为咱们申请的实际内存是16字节,但只释放4字节,这会导致堆损坏或调试器中断。所以这里会触发int 3中断。

五、使用 delete[] p2 的正确处理过程

对于p2的内存申请代码和p1是完全一样的。我们就不再一一讲解。咱们直接来到delete数组p2的汇编代码:

delete[] p2;

前边也是加载p2指针到寄存器,并判断它是不是为空,这几行代码咱们跳过。

00F52C7C push 3

这里压入3(这里的3表示delete[],上边我们直接调用delete时是传的1表示释放内存),这个3它作为vector deleting destructor的参数。 要注意这里的3并不是指数组大小。

00F52C84 call MyClass::`vector deleting destructor' (0F51479h)

向量删除析构函数内部细节

接着这行是调用向量删除析构函数,我们跟进去:

前边这一堆是栈帧处理以及寄存器的保护。

00F526D7 push offset MyClass::~MyClass (0F51339h)

这里压入析构函数地址作为参数,供迭代器析构类时调用。

00F526DC mov eax,dword ptr [this]
00F526DF mov ecx,dword ptr [eax-4]

然后这里它会读p2-4,我们知道p2指向数组首地址,这里-4是为了获得数组首地址前边的数组COOKIE,数组cookie中记录了数组的大小,这里就是为了获取这个数组元素数(3),存入 ecx。

00F526E2 push ecx
00F526E3 push 4
00F526E5 mov edx,dword ptr [this]
00F526E8 push edx
00F526E9 call `eh vector destructor iterator' (0F5136Bh)

所以我们可以知道在调用eh vector destructor iterator时,传入的参数从左到右分别是:p2, 4, 3, MyClass::~MyClass。然后CRT 辅助函数eh vector destructor iterator,它会循环调用 ~MyClass() 三次,从 p2 开始,每次偏移 4 字节。这里的eh表示支持异常处理(eh = exception handling)。

00F526F6 mov eax,dword ptr [this]
00F526F9 mov ecx,dword ptr [eax-4]
00F526FC lea edx,[ecx*4+4]

然后这里用于计算分配的内存大小,ecx中记录了数组大小,每个类占用4个字节,3*4=12再+4 就是16个字节的内存大小。

00432707 sub eax,4
0043270A push eax
00F5270B call operator delete[] (0F514ABh)

然后再调用 operator delete释放内存。

六、核心区别与重要结论

发现了吗,在向量删除析构函数中和标量删除析构函数中,重要的区别就是:在向量删除析构函数中,首先循环3次分别调用了类的析构函数,同时会通过数组cookie来计算出所分配内存的大小,再调用operator delete将所分配的完整 内存释放掉。

所以,如果你采用new[]分配的内存只能用 delete[] 释放,因为 delete[] 会读取 数组cookie 析构所有对象同时也能正确的释放所分配的内存。

一定要始终匹配使用 new/delete 和 new[]/delete[]。

关于delete使用现在你明白了吗?


更多问题探讨,请关注公众号:程序员角