内存管理基础
Win32 中的内存管理是分层次的,系统提供了几组层次不同的函数来管理内存,它们是标准内存管理函数、堆管理函数、虚拟内存管理函数和内存映射文件函数。
所有的这些函数都是为了让用户能在比较高的层次上方便地管理内存,以便将程序和底层的内存分页机制隔离开来。
图说内存:
虚拟内存管理函数
Windows 使用一个以页为基础的虚拟内存系统,与分页有关的概念已经在该系列教程刚开始几个讲中有所介绍,虽然那时候大家还懵懵懂懂。
Windows 充分利用了 80x86 处理器保护模式下的线性寻址机制和分页机制,这些机制是 Win32 内存管理的基础。
Win32 提供了一组虚拟内存管理函数来管理虚拟内存,主要用于保留/提交/释放虚拟内存,在虚拟内存页上改变保护方式,锁定虚拟内存页以及查询一个进程的虚拟内存等操作,这是一组位于底层的函数。
堆管理函数
堆管理函数相对比较高级一点,堆的主要功能就是有效地管理内存和进程的地址空间。
DOS 操作系统下的 C 语言中就已经有了”堆”的概念,这时的”堆”是程序初始化时向操作系统申请并预留的大块内存,程序通过 C 函数在这块空间中申请和释放内存。
在Win32中,进程可以使用的整个地址空间就是一个堆。
标准内存管理函数
并且”堆”的概念又被引伸了一步:Win32中分两种堆
一种是进程的”默认堆”,默认堆只有一个,指的就是可以使用的整个地址空间;
另一种是”动态堆”,也称为”私有堆”,私有堆类似于 DOS 下 C 语言中使用的那种堆,一个进程可以随意建立多个私有堆,也可以随意将它们释放。
私有堆全部位于默认堆中,从概念上看,它和默认堆并没有什么不同,使用堆管理函数可以对所有的私有堆和默认堆进行操作。
标准内存管理函数总是在默认堆中分配和释放内存,这组函数就是常规意义上的内存管理函数。
内存映射文件函数
内存映射文件函数相对比较独立,它是为了文件操作的方便性而设立的。
当对文件进行操作的时候,一般先打开文件,申请一块内存用做缓冲区,再将文件数据循环读入并处理,当文件长度大于缓冲区长度则需要多次读入,每次读入后处理缓冲区边界位置的数据往往是个麻烦问题。
还记得我们曾经介绍过 Windows 可以使用磁盘文件当做虚拟内存,内存映射文件函数使用同样的办法将一个文件直接映射到进程的地址空间中,这样可以通过内存指针用读写内存的办法直接存取文件内容。
不同内存管理函数的操作对象
对比这些函数,可以发现它们涉及的系统资源是各不相同的:
获取内存的当前状态
为什么需要此操作?!
因为我们之前介绍过,一个进程可以寻址的地址空间是 4GB,但用户可以直接管理的地址空间是多大呢?
实际上,高端的 2GB 是供操作系统内核使用的,其中安排了操作系统的代码和数据,可供应用程序使用的地址空间是低端的 2GB,这 2GB 除去应用程序与用户 DLL 等的代码和静态数据段以后,余下来的才是内存管理函数可以使用的地址空间,应用程序和用户 DLL 的大小一般只有几兆字节到上百兆字节,所以可以认为能自由使用的地址空间基本上是 2GB。
既然用户可以使用的地址空间大概为 2GB,鱼油们千万不要天真的认为就可以申请 2GB 的内存了。
因为这 2GB 仅是可以使用的”地址”空间,而不是可以使用的”内存”空间(请务必谨慎区分),可分配内存的大小还受制于物理内存和磁盘交换文件的大小。
因为物理内存和磁盘交换文件是供整个系统和所有用户程序使用的,所有系统内核、当前执行的所有用户程序的代码、数据以及分配的内存总量并不能超过物理内存和磁盘交换文件的总和。
当设计一个可能需要申请大量内存的程序时,如何预先得知系统的配置情况呢?
对此可以使用 GlobalMemoryStatus 函数:
invoke GlobalMemoryStatus, lpBuffer
lpBuffer 指向一个 MEMORYSTATUS 结构,结构的定义如下:
MEMORYSTATUS STRUCT
dwLength DWORD ? ;本结构的长度
dwMemoryLoad DWORD ? ;已用内存的百分比
dwTotalPhys DWORD ? ;物理内存总量
dwAvailPhys DWORD ? ;可用物理内存
dwTotalPageFile DWORD ? ;交换文件总的大小
dwAvailPageFile DWORD ? ;交换文件中空闲部分大小
dwTotalVirtual DWORD ? ;用户可用的地址空间
dwAvailVirtual DWORD ? ;当前空闲的地址空间
MEMORYSTATUS ENDS
在调用之前需要首先将 dwLength 字段设置为 MEMORYSTATUS 结构的长度。
当调用 GlobalMemoryStatus 函数后,函数会在结构中填充对应的数值。
注意:dwTotalPageFile 字段返回的是交换文件的最大值,并不是当前实际建立的交换文件的大小,一般当前的交换文件大小会小于这个数值,但这个数值的大小也不是确定的,如果需要的话,系统会增加它的大小直到不再有空余的磁盘空间放置交换文件为止。
我们来分析一个不完美的栗子:MemInfo
关于 GlobalMemoryStatus, MSDN 上有这样一段话:
On computers with more than 4 GB of memory, the MEMORYSTATUS structure can return incorrect information. Windows 2000 reports a value of -1 to indicate an overflow. Earlier versions of Windows NT report a value that is the real amount of memory, modulo 4 GB. If your application is at risk for this behavior, use the GlobalMemoryStatusEx function instead of the GlobalMemoryStatus function.
先把视频暂停片刻。。。
自己试着改一下。。。。
小甲鱼接着会讲解支持 4GB 以上的解决方案,但希望大家先自己根据提示进行探索!
支持 4GB 以上资源获取解析:MemInfo2
标准内存管理函数
标准内存管理函数的功能是在进程的默认堆中申请和释放内存块,它由下面一些函数组成:
GlobalAlloc,GlobalFree 和 GlobalReAlloc 分别用来申请、释放和修改内存大小。
GlobalLock 和 GlobalUnlock 用来进行锁定操作。
GlobalDiscard,GlobalFlags,GlobalHandle 和 GlobalSize 等用来丢弃内存或获取已分配内存的一些信息。
友情提示:
在 Win16 中,内存管理函数有"全局"或"本地"之分,它们的区别在于返回的指针是远指针还是近指针,全局内存管理函数名是以"Global"开头的,而"本地"内存管理函数名是以"Local"开头的。
在 Win32 中,指针并没有远近之分,只有一种32位的指针,但为了保持向下兼容,这些函数名仍然沿用了下来。
这两组函数在 Win32 中是完全相同的,读者可以自由使用名字以 Global 或 Local 为前缀的函数。
用标准内存管理函数可以分配的内存有两种:固定地址的内存块和可移动的内存块,而可移动的内存块又可以进一步定义为可丢弃的,让我们逐步来讨论它们的不同。
固定的内存块
常规意义上的内存就是固定的内存块,因为申请到内存后,这块内存的线性地址是固定不变的。要申请一块固定的内存,可以使用函数:
invoke GlobalAlloc,GMEM_FIXED or \ GMEM_ZEROINIT,dwBytes
固定的内存块
解释一下:
第一个参数是标志,GMEM_FIXED 表示申请的是固定的内存块,GMEM_ZEROINIT 表示需要将内存块中的所有字节预先初始化为0,也可以简单地使用 GPTR 标志,它就相当于是GMEM_FIXED or GMEM_ZEROINIT。
第2个参数 dwBytes 指出了需要申请的是以字节为单位的内存大小。如果内存申请失败,eax 中返回 NULL,否则返回值是一个指向内存块起始地址的指针,用户需要保存这个指针,在使用内存或者释放内存的时候还要用到它。
如果要释放一个先前申请的固定内存块,可以使用 GlobalFree 函数:
invoke GlobalFree,lpMemory
如果释放成功,函数返回 NULL,否则函数返回的值就是输入的 lpMemory。
另外程序在不再使用内存块的时候应该使用这个函数将内存释放,不过就算程序在退出的时候忘记了释放内存,Windows 也会自动将它们释放。
在实际使用中往往需要改变一个内存块的大小,这时候就要用到 GlobalReAlloc 函数。
该函数可以缩小或扩大一块已经申请到的内存:
invoke GlobalReAlloc,lpMemory,dwBytes,uFlags
.if eax
mov lpNewMemory,eax
.endif
lpMemory 是先前申请的内存块指针,dwBytes 是新的大小,如果这个数值比原来申请的时候要小,也就是需要缩小内存块,那么 uFlags 标志参数可以是 NULL。
如果缩小内存块的操作不成功,那么函数的返回值为0,否则是新的缩小了的内存块指针,当然,这个指针和原来的指针肯定是一样的。
但是需要扩大一个内存块的时候,情况就稍微有些复杂了。
我们来考虑这样一种情况:
首先申请两个 1000h 大小的固定内存块,得到两个指针,可以发现第二块几乎紧接第一块内存的。(请自行尝试)
一般情况下如果第一块内存的地址是 X,那么第二块内存的地址几乎就是 X + 1000h,如果需要将第一个内存块扩大到 2000h 字节,那么只能在别的地方开辟一个 2000h 大小的内存块,因为原来位置后面的 1000h 已经被第二块内存占用了,这就意味着新的指针可能和原来的不一样。
可以在 GlobalReAlloc 函数中通过指定不同的 uFlags 来规定是否允许 Windows 在必要的时候移动内存块。
当 uFlags 中有 GMEM_MOVEABLE 选项的时候,如果需要移动内存块,Windows 会在别的地方开辟一块新的内存,并把原来内存块中的内容自动复制到新的内存块中,这时函数的返回值是新的指针,原来的指针作废。
如果不指定 GMEM_MOVEABLE 选项,那么只有当内存块后面扩展所需的空间没有被使用时,函数才会执行成功,否则,函数失败并返回 NULL,这时原来的指针继续有效。
为了保证内存块扩大成功,建议总是使用下面的语句来扩大和缩小内存:
invoke GlobalReAlloc,lpMemory,dwBytes,\ GMEM_ZEROINIT or GMEM_MOVEABLE
.if eax
mov lpMemory,eax
.endif
指定 GMEM_ZEROINIT 选项可以使内存块扩大的部分自动被初始化为0,然后程序判断返回值。
如果改变大小成功的话,则用新的指针替换原来的指针,其他和原来指针有关的值也不要忘了同时更新。
接下来,我们来讨论:可移动的内存块
可移动的内存块
可移动的内存块在不使用的时候允许 Windows 改变它的线性地址,为什么要使用可移动的内存块呢?
惟一的理由是防止内存的碎片化,当进程长时间频繁地申请和释放不同大小的内存块后,申请的大量小块内存可能零散地分布在整个地址空间。
尽管空闲的内存总数不小,但久而久之却没有剩下连续的大块空闲地址,最终导致无法再申请大块的内存。
解决内存碎片化的办法很简单,因为碎片之间有大量的内存是空闲的,只要允许 Windows 移动小块的在用内存,就可以将碎片合并成大块的空闲内存。
但是在用内存被移动后,程序中对应的指针也要随着改变,不然就会造成访问地址错误。
另外,在使用内存的过程中,内存需要有个锁定的过程,不然用到一半就被 Windows 移动了,结果依然是错误的。
只有程序将内存解锁,Windows 才可以自由移动它们,这就引申出可移动内存块的概念和操作的基本方法。
要申请一个可移动的内存块,使用的函数还是GlobalAlloc,但需要使用不同的参数:
invoke GlobalAlloc,GMEM_MOVEABLE or \ GMEM_ZEROINIT,dwBytes
.if eax
mov hMemory,eax
.endif
GMEM_MOVEABLE 标志指定了分配的内存是可移动的,GMEM_ZEROINIT 同样表示将申请到的内存块的内容初始化为0(也可以用 GHND 标志,它就相当于 GMEM _MOVEABLE or GMEM_ZEROINIT)。
如果内存申请失败,eax 中返回 NULL,成功的话返回值是一个句柄而不是内存指针,用户需要保存这个句柄,在锁定或释放内存的时候还要用到它。
一个进程可以申请的可移动内存的块数最大不能超过 65536 个,申请固定内存块时则没有数量限制。
要使用可移动内存之前,需要把它锁定,这相当于告诉 Windows 现在程序要使用这块内存了,不能将它移动,锁定内存使用 GlobalLock 函数:
invoke GlobalLock, hMemory
.if eax
mov lpMemory, eax
.endif
函数的入口参数是 GlobalAlloc 返回的内存句柄,如果锁定成功,函数返回一个指针,如果锁定失败,则函数返回NULL。
当程序暂时不需要操作这块内存的时候,应该将它解锁,否则和使用固定的内存块就没有区别了,解锁使用 GlobalUnlock 函数:
invoke GlobalUnlock, hMemory
函数的参数同样是 GlobalAlloc 返回的句柄,解锁成功的话函数返回非0值。
有个问题:在多线程的程序中,两个地方同时锁定内存,但当一个地方还在使用的情况下另一个地方却调用 GlobalUnlock 将内存解锁了怎么办?
其实不用担心这个问题,Windows 为每个可移动的内存句柄维护一个锁定计数,每次锁定内存的时候计数加 1,解锁的时候计数减 1,只有当计数为 0 的时候内存才真正被解锁。
所以只要程序中的 GlobalLock 函数和 GlobalUnlock 函数是配对的,就不用担心这个问题。
要释放一个可移动的内存块,同样使用GlobalFree 函数:
invoke GlobalFree, hMemory
但使用的参数是 GlobalAlloc 返回的内存句柄,如果释放成功,函数返回 NULL。不管内存当前是否处在锁定状态,都可以被成功释放。
调整可移动内存块的大小,同样使用 GlobalReAlloc 函数:
invoke GlobalReAlloc, hMemory, dwBytes, \ GMEM_ZEROINIT or GMEM_MOVEABLE
如果调整成功,返回值就是输入的 hMemory,失败的话返回值是 NULL。即使内存块在锁定状态,函数仍然可以调用成功,但这时候内存块可能已经被移动了位置,原来用 GlobalLock 函数获取的指针可能已经失效了,所以调整可移动内存块的大小最好还是先将内存解锁,等调整完毕以后再锁定使用。
由于使用可移动的内存块多了一个锁定的动作,速度自然要比使用固定的内存块要慢一点,但固定内存块又存在碎片问题,程序中使用哪种方法有个取舍的问题。
如果程序要频繁地分配和释放不定长的内存块,内存的碎片化现象就比较严重,特别是当程序长时间运行时,这种情况下使用可移动内存块比较好。
如果程序只进行少量的内存操作,或者虽然频繁分配和释放内存,但使用的内存块长度都是一样的,则使用固定内存块可以节省时间。
可丢弃的内存块
分配可移动内存块的时候需要使用 GMEM_MOVEABLE 标志,如果同时配合使用 GMEM_DI SCARDABLE 标志的话,这样生成的内存块是可丢弃的内存块。
表示当Windows急需内存使用的时候,可以将它从物理内存中丢弃,可丢弃的内存块首先必须是可移动的内存块。
函数调用如下:
invoke GlobalAlloc, GHND or GMEM_DISCARDABLE, dwBytes
.if eax
mov hMemory, eax
.endif
当用GlobalLock锁定内存的时候如果返回NULL指针,表示内存已经被Windows丢弃了。
当然其中的数据也丢失了,程序需要重新生成数据。
另外需要注意的是当内存块被丢弃的时候,内存句柄还是有效的,如果程序还要使用这个句柄,那么可以对它使用GlobalReAlloc函数来重新分配内存。
当可丢弃内存块的锁定计数为0时,程序也可以使用GlobalDiscard函数主动将它丢弃,这和Windows将它丢弃的效果是一样的:
invoke GlobalDiscard, hMemory
使用内存函数时有两个地方需要特别注意
第一个需要注意的地方是:
NULL指针的检测——GlobalAlloc函数和GlobalLock函数都可以返回内存指针,在使用指针前一定要检测它的有效性。
如果使用了函数执行失败而返回的NULL指针来访问数据,会导致程序越权访问不该访问的地方,从而被Windows毫不留情地终止掉。
这就是例子代码中总是有个if语句来判断eax是否为NULL的原因。
第二个需要注意的地方是:
注意访问越界问题,越界操作也会引起越权访问,千万不要到超出内存块长度的地方去访问。
例如,使用lstrcpy之类的函数处理字符串之前,先用lstrlen检测字符串长度是一个好习惯。
补充:lstrcpy函数事实上是很多溢出漏洞的根源,该函数作用是复制一个字符串到缓冲区。
LPTSTR lstrcpy(LPTSTR lpString1, LPCTSTR lpString2);
一些不安全因素
在微软产品的安全漏洞中,有很大一部分是由于不正确的使用C动态库(C Runtime Library) 的函数,特别是有关字符串处理的函数导致的。
证据请看下表:
在这里小甲鱼列出其中的一部分特别尤其不安分的因素,以便大家日后编程对那些”有威胁性”的API函数有所警惕,要么使用安全函数替代,要么自行进行必要的检查等。
有关完整的危险API的禁用列表,可以参见:http://msdn.microsoft.com/en-us/library/bb288454.aspx
StrSafe函数和SafeCTR函数
下边通过一些栗子给大家介绍一下这两种函数如何替代不安全的API函数,有兴趣的朋友可以听听哈~
使用StrSafe
使用SafeCRT
另外纯C语言也有好多函数编程的时候需要你多留点心眼儿:C语言中不安全的函数以及解决方案