作为一个程序员,内存分配是免不了的事情。像JAVA、Python等,底层支持内存管理,可以定时进行垃圾回收,防止内存泄露,但像C/C++,自己分配内存,还需要自己回收,否则就会造成内存泄露。今天我们不谈内存泄露相关问题,而是来谈谈内存分配的一些问题。
一、理解堆
我们在进行程序开发的时候,经常会说到堆栈这个词,说得多了,有时候就会忽略它其实是指两种不同的数据结构,一个是堆,一个是栈。
先说栈,程序每开启一个线程,就会自动创建一个栈,用于分配局部变量、存储函数调用参数和返回地址。对于C/C++语言来说,编译器在编译阶段就会生成合适的代码来从栈上分配和释放空间,不需要程序员进行干预。但是栈空间容量一般是比较少的,Windows程序默认栈大小是1M(当然在编译的时候可以调整栈空间大小),所以不适合分配特别大的内存区(记得曾经在写程序的时候,递归嵌套太深,导致栈溢出,不过这些问题也好发现,修改也比较容易) 。其次函数返回,栈空间就被回收了,所有栈上的变量生命期随着函数的返回而结束了。
如果要分配大的内存空间,那么就可以使用堆来管理数据。在Windows内部,有一个堆管理器,堆管理器从内存中分配一块较大的内存进行管理,并将大块的内存分割成不同的小块来满足应该程序的需要,所以我们的C++程序new一块内存,并不是直接从内存中获得的,而是从堆管理器中拿到的。在NTDLL.dll中实现了一个通用的堆管理器,并且Windows SDK也公开了一组API来访问堆管理器,比如HeapAlloc、HeapFree等等。
在NTDLL.dll中,有很多函数是用来管理堆,下表仅列出一部分
ntdll!RtlAllocateHeap | 从堆上分配内存 |
ntdll!RtlCreateHeap | 创建堆 |
ntdll!RtlDestoryHeap | 销毁堆 |
ntdll!RtlFreeHeap | 释放堆块 |
ntdll!RtlSizeHeap | 获取堆块大小 |
有些函数,在后面谈到分配函数的时候,会提及,所以先列在这儿。
现在我们来看看,怎么去查找和分析系统中的堆
int main()
{
return 0;
}
上面的程序很简单,看起来没有任何操作,但实际上系统还是创建了一个堆,我们通过WinDbg来查看。
将我们的程序通过WinDbg运行起来并加载上调试符号,然后使用!heap -h
0:000> !heap -h
Index Address Name Debugging options enabled
1: 25ee47f0000
Segment at 0000025ee47f0000 to 0000025ee48ef000 (00011000 bytes committed)
2: 25ee46b0000
Segment at 0000025ee46b0000 to 0000025ee46c0000 (00001000 bytes committed)
可以看到,系统已分配了两个堆,第一个堆起始位置是0x0000025ee47f0000,已提交大小为0x11000 bytes;第二个堆起始位置是0x0000025ee46b0000,已提交大小为0x1000 bytes。Windows各版本系统有所不同,我这里使用的是Windows 11呈现出来的结果
如果要查看堆的详细信息,可以使用!heap 地址 -v来查看
0:000> !heap 0000025ee47f0000 -v
Index Address Name Debugging options enabled
1: 25ee47f0000
Segment at 0000025ee47f0000 to 0000025ee48ef000 (00011000 bytes committed)
Flags: 00000002
ForceFlags: 00000000
Granularity: 16 bytes
Segment Reserve: 00100000
Segment Commit: 00002000
DeCommit Block Thres: 00000400
DeCommit Total Thres: 00001000
Total Free Size: 00000120
Max. Allocation Size: 00007ffffffdefff
Lock Variable at: 0000025ee47f02c0
Next TagIndex: 0000
Maximum TagIndex: 0000
Tag Entries: 00000000
PsuedoTag Entries: 00000000
Virtual Alloc List: 25ee47f0110
Uncommitted ranges: 25ee47f00f0
FreeList[ 00 ] at 0000025ee47f0150: 0000025ee47ff8d0 . 0000025ee47fa300 (4 blocks)
里面的结构不详谈,要展开来说的话,有太多东西了。有兴趣的话,可以去找找相关文章,或者以后有时间,我们来慢慢聊。
二、分配和释放堆块
在C/C++程序中,我们一般使用malloc/new来分配内存,使用free/delete来释放内存。在应用层方面,new 和 malloc确实有很多不同,这里我们不再详谈。那么一步步进入到分配的底层,是什么样子的呢?我们来看看
int main()
{
char* c = (char*)malloc(1000);
char* b = new char[1000];
free(c);
delete[] b;
return 0;
}
首先,我们来看看malloc的调用过程
00007ff7`75a41000 4883ec58 sub rsp,58h
00007ff7`75a41004 b9e8030000 mov ecx,3E8h
00007ff7`75a41009 ff15b1100000 call qword ptr [Test!_imp_malloc (00007ff7`75a420c0)]
从上面的汇编代码可以看到,malloc走到了_imp_malloc里面去了,我们进一步去看看
00007ff8`2db00060 e90b000000 jmp ucrtbase!_malloc_base (00007ff8`2db00070)
00007ff8`2db00065 cc int 3
00007ff8`2db00066 cc int 3
00007ff8`2db00067 cc int 3
00007ff8`2db00068 71a8 jno ucrtbase!_realloc_base+0x22 (00007ff8`2db00012)
00007ff8`2db0006a 53 push rbx
00007ff8`2db0006b 14d3 adc al,0D3h
00007ff8`2db0006d 8f ???
00007ff8`2db0006e f4 hlt
00007ff8`2db0006f a7 cmps dword ptr [rsi],dword ptr [rdi]
ucrtbase!_malloc_base:
00007ff8`2db00070 4883ec28 sub rsp,28h
00007ff8`2db00074 4883f9e0 cmp rcx,0FFFFFFFFFFFFFFE0h
00007ff8`2db00078 0f872cb00400 ja ucrtbase!_malloc_base+0x4b03a (00007ff8`2db4b0aa)
00007ff8`2db0007e 48895c2430 mov qword ptr [rsp+30h],rbx
00007ff8`2db00083 4885c9 test rcx,rcx
00007ff8`2db00086 bb01000000 mov ebx,1
00007ff8`2db0008b 48897c2420 mov qword ptr [rsp+20h],rdi
00007ff8`2db00090 480f45d9 cmovne rbx,rcx
00007ff8`2db00094 488b0d15070f00 mov rcx,qword ptr [ucrtbase!_acrt_heap (00007ff8`2dbf07b0)]
00007ff8`2db0009b 4c8bc3 mov r8,rbx
00007ff8`2db0009e 33d2 xor edx,edx
00007ff8`2db000a0 ff1542870b00 call qword ptr [ucrtbase!_imp_HeapAlloc (00007ff8`2dbb87e8)]
看最后一行,调用到了ucrtbase!_imp_HeapAlloc
我们执行到call那,再进去看一看
ntdll!RtlAllocateHeap:
00007ff8`30268e70 48895c2408 mov qword ptr [rsp+8],rbx ss:00000068`f0effb70=0000000000000000
00007ff8`30268e75 4889742410 mov qword ptr [rsp+10h],rsi
00007ff8`30268e7a 57 push rdi
00007ff8`30268e7b 4883ec30 sub rsp,30h
00007ff8`30268e7f 498bf8 mov rdi,r8
00007ff8`30268e82 8bf2 mov esi,edx
00007ff8`30268e84 488bd9 mov rbx,rcx
00007ff8`30268e87 4885c9 test rcx,rcx
00007ff8`30268e8a 0f841c460900 je ntdll!RtlAllocateHeap+0x9463c
看,最终调到了ntdll!RtlAllocateHeap。这就是我们前面提到的从堆上分配内存的底层函数。
我们再来看看new的执行过程
0007ff7`75a41014 b9e8030000 mov ecx,3E8h
00007ff7`75a41019 e89a000000 call Test!operator new[] (00007ff7`75a410b8)
首先是调用的operator new[],进去看看
Test!operator new:
00007ff7`75a414b8 4053 push rbx
00007ff7`75a414ba 4883ec20 sub rsp,20h
00007ff7`75a414be 488bd9 mov rbx,rcx
00007ff7`75a414c1 eb0f jmp Test!operator new+0x1a (00007ff7`75a414d2)
00007ff7`75a414c3 488bcb mov rcx,rbx
00007ff7`75a414c6 e8480a0000 call Test!callnewh (00007ff7`75a41f13)
00007ff7`75a414cb 85c0 test eax,eax
00007ff7`75a414cd 7413 je Test!operator new+0x2a (00007ff7`75a414e2)
00007ff7`75a414cf 488bcb mov rcx,rbx
00007ff7`75a414d2 e8c4090000 call Test!malloc (00007ff7`75a41e9b)
00007ff7`75a414d7 4885c0 test rax,rax
看倒数第二行,走到了malloc这个函数里面去了,说明new分配内存还是使用的malloc,只是分配内存后再实现与malloc不一样的东西。
接下来的过程应该也猜到了,最后肯定也是调用的ntdll!RtlAllocateHeap
ucrtbase!malloc:
00007ff8`2db00060 e90b000000 jmp ucrtbase!_malloc_base (00007ff8`2db00070)
00007ff8`2db00065 cc int 3
00007ff8`2db00066 cc int 3
00007ff8`2db00067 cc int 3
00007ff8`2db00068 71a8 jno ucrtbase!_realloc_base+0x22 (00007ff8`2db00012)
00007ff8`2db0006a 53 push rbx
00007ff8`2db0006b 14d3 adc al,0D3h
00007ff8`2db0006d 8f ???
00007ff8`2db0006e f4 hlt
00007ff8`2db0006f a7 cmps dword ptr [rsi],dword ptr [rdi]
ucrtbase!_malloc_base:
00007ff8`2db00070 4883ec28 sub rsp,28h
00007ff8`2db00074 4883f9e0 cmp rcx,0FFFFFFFFFFFFFFE0h
00007ff8`2db00078 0f872cb00400 ja ucrtbase!_malloc_base+0x4b03a (00007ff8`2db4b0aa)
00007ff8`2db0007e 48895c2430 mov qword ptr [rsp+30h],rbx
00007ff8`2db00083 4885c9 test rcx,rcx
00007ff8`2db00086 bb01000000 mov ebx,1
00007ff8`2db0008b 48897c2420 mov qword ptr [rsp+20h],rdi
00007ff8`2db00090 480f45d9 cmovne rbx,rcx
00007ff8`2db00094 488b0d15070f00 mov rcx,qword ptr [ucrtbase!_acrt_heap (00007ff8`2dbf07b0)]
00007ff8`2db0009b 4c8bc3 mov r8,rbx
00007ff8`2db0009e 33d2 xor edx,edx
00007ff8`2db000a0 ff1542870b00 call qword ptr [ucrtbase!_imp_HeapAlloc (00007ff8`2dbb87e8)]
过程也与malloc一模一样了。
所以,不管上层是怎么实现,什么语法,到了底层,肯定是殊途同归。这也很好理解,都是实现同样的功能,底层都有一套东西了,为啥还要另起锅灶,从新开始呢。
最后,再来简单看看free的过程
ucrtbase!free:
00007ff8`2db02150 c744241000000000 mov dword ptr [rsp+10h],0
00007ff8`2db02158 8b442410 mov eax,dword ptr [rsp+10h]
00007ff8`2db0215c e90f000000 jmp ucrtbase!_free_base (00007ff8`2db02170)
00007ff8`2db02161 cc int 3
00007ff8`2db02162 cc int 3
00007ff8`2db02163 cc int 3
00007ff8`2db02164 cc int 3
00007ff8`2db02165 cc int 3
00007ff8`2db02166 cc int 3
00007ff8`2db02167 cc int 3
00007ff8`2db02168 7190 jno ucrtbase!__stdio_common_vswprintf+0x1ea (00007ff8`2db020fa)
00007ff8`2db0216a 5b pop rbx
00007ff8`2db0216b 12e7 adc ah,bh
00007ff8`2db0216d 9e sahf
00007ff8`2db0216e 70ce jo ucrtbase!__stdio_common_vswprintf+0x22e (00007ff8`2db0213e)
ucrtbase!_free_base:
00007ff8`2db02170 4883ec28 sub rsp,28h
00007ff8`2db02174 4885c9 test rcx,rcx
00007ff8`2db02177 741a je ucrtbase!_free_base+0x23 (00007ff8`2db02193)
00007ff8`2db02179 4c8bc1 mov r8,rcx
00007ff8`2db0217c 33d2 xor edx,edx
00007ff8`2db0217e 488b0d2be60e00 mov rcx,qword ptr [ucrtbase!_acrt_heap (00007ff8`2dbf07b0)]
00007ff8`2db02185 ff1535660b00 call qword ptr [ucrtbase!_imp_HeapFree (00007ff8`2dbb87c0)]
最终调用的是ntdll!RtlFreeHeap。delete过程也是如此,就不列出来了。
其实不管Windows还是Linux,都有一套堆管理API,上层使用malloc/new,底层肯定都是归到同一个分配函数里面去,但是我没有Linux的内核符号,所以跟踪不进去,也无法向大家展示过程。
这里我使用的是Release版本的程序,为什么呢?因为Debug版在分配函数的时候,会加入很多检查函数,影响调试,所以,还是用Release干净一点。后续有时间,可以谈谈Debug中的检查函数都做了些什么。