首页  编辑  

在Win32中管理虚拟内存

Tags: /超级猛料/OS.操作系统/   Date Created:

Win32 中管理虚拟内存

Randy Kath

Microsoft Developer Network 技术小组

创建于: 1993 1 20

单击此处以打开或复制 ProcessWalker 示例程序中的文件。该 Win32 示例程序要求 Microsoft Windows NT 的环境。

摘要

Microsoft Windows NT 操作系统中,假如您对每组函数的功能,以及它们每个函数的作用,没有足够的认识,那么在 Win32 应用程序中,决定使用哪个函数,或者哪组函数来管理内存将是困难的。为了简化这个问题,本篇技术文章主要是围绕 Win32 虚拟内存管理函数的:它包括哪些函数是可用的、如何使用它们,以及使用它们会对操作系统产生什么影响。

本文将讨论如下的主题:

保留、提交,和释放虚拟内存

在虚拟内存页上改变保护

锁定虚拟内存页

查询一个进程的虚拟内存

Microsoft Developer Network CD 中有一个叫作 ProcessWalker 的示例程序,它将会在本篇技术文章中出现。该示例程序对于探索一个进程的内存地址空间是很有用的。它还使用了虚拟内存函数来实现一个相互链接的列表结构。

 

概述

本文是三篇相关技术文章中的其中一篇,这三篇文章分别是棗"在 Win32 中管理虚拟内存"、"在 Win32 中管理内存 - 映射文件",以及快要完成的"在 Win32 中管理堆( heap )内存"棗它们解释了如何在 Win32 编程接口的应用程序中管理内存。在每篇文章的概述部分

,要指明 Win32 编程模型中基本的内存部件,并且指出如果您对特殊领域的较有兴趣,那么应该参考哪一篇文章。

Microsoft Windows 操作系统的第一个版本介绍了基于一个单个的全局堆( global heap )和多个专有的局部堆( local heaps )来管理动态内存的方法,所有应用程序和系统共存该全局堆( global heap ),而每一个应用程序具有其单独的局部堆( local heaps )。同时还提供了全局和局部的内存管理函数,为这种新的内存管理系统提供了扩展的特性。最近, Microsoft C 的运行时 (CRT) 库被修改以包含如下功能,即使用如 malloc free 这样的纯粹的 CRT 函数来管理 Windows 中的堆。所以,现在开发者应该作出选择了要么学习作为 Windows 3.1 版本的一部分来提供的新的应用程序编程接口 (API) ,要么坚持使用可移植的、典型的、并且为人所熟悉的 CRT 函数在为 Windows 3.1 所编写的应用程序中管理内存。

随着 Win32 API 内容的不断增加,选择机会也随之增加。 Win32 提供了三个附加的函数组来管理应用程序的内存:内存 - 映射( memory-mapped )文件函数、堆内存( heap memory )函数,以及虚拟内存函数。这些新的函数并不替代在 Windows 3.1 中现存的内存管理函

数;相反它们提供了新的特性,使得开发者在为他们的 Win32 应用程序编写内存管理部分时,日子会轻松得多。

1. Win32 API 为应用程序编程的多样性提供了不同级别的内存管理。

总之,如图 1 所看到的,在 Win32 中有六组内存管理函数,所有这些函数都被设计成单独使用。所以,您应该使用哪种函数呢?要回答这一问题主要依靠以下两件事:您希望的内存管理类型是什么,以及与之相关联的函数在操作系统中是如何实现的。换句话说,您是否是正在建立一个大的数据库应用程序,因而希望操作一个大的内存结构的子集合,或者您正计划一些简单的动态内存结构,例如链接列表或二进制树( binary trees )。在这两种情况下,您都需要搞清楚哪些函数提供的功能最适合您的意向,并确切地了解在使用每个函数时要占用多少资源。

1 Win32 中的内存管理函数组进行了分类,并且分别指出在本系列的三篇技术文章中,每一篇所描述的相关的组的行为。在每篇技术文章中,通过描述作为对使用这些函数的响应的系统行为,重点强调了这些函数对系统所产生的影响。

1. Win32 中可用的内存管理函数

内存设置 受影响的系统资源 相关的技术文章

虚拟内存函数 一个进程的虚拟地址空间

系统页文件

系统内存

硬盘空间

"在 Win32 中管理虚拟内存"

内存 - 映射文件函数 一个进程的虚拟地址空间

系统页文件

标准文件 I/O

系统内存

硬盘空间

"在 Win32 中管理内存 - 映射文件"

堆( Heap )内存函数 一个进程的虚拟地址空间

系统内存

进程堆资源结构

"在 Win32 中管理堆内存"

全局堆内存函数 一个进程的堆资源结构 "在 Win32 中管理堆内存"

局部堆内存函数 一个进程的堆资源结构 "在 Win32 中管理堆内存"

C 运行时参考库 一个进程的堆资源结构 "在 Win32 中管理堆内存"

每篇技术文章都是围绕 Win32 函数的用途问题而展开讨论的。要想对 Windows NT 操作系统如何管理系统内存有更详细的了解,请参阅 Microsoft Developer Network CD 上的" The Virtual-Memory Manager in Windows NT "(技术文章, Win32 Windows NT 的文章)。

 

Windows NT 内存系统概述

Windows NT 使用一个以页为基础的虚拟内存系统,该系统使用 32 位线性地址。在内部,系统管理被称为页的 4096 字节段中的所有内存。每页的物理内存都被备份 对于临时的内存页使用页文件 (pagefile) ,而对于只读的内存页,则使用磁盘文件。在同一时刻,最多可以有 16 个不同的页文件。代码、资源和其它只读数据都是通过它们创建的文件直接备份。

Windows NT 为系统中的每一个应用程序(进程)提供一个独立的、 2 GB 的用户地址空间。对于应用程序来说,好象是有 2 GB 的可用内存,而不用考虑实际可用的物理内存的量。如果某个应用程序要求的内存比可用的内存更多时, Windows NT 是这样满足这种要求

的,它从这个和 / 或其他的进程把非关键内存分页 (paging) 到一个页文件,并且释放这些物理内存页。结果,在 Windows NT 中,全局堆不再存在。相反,每一个进程都有其自己的 32 位地址空间,在其中,该进程的所有内存被分配 , 包括代码、资源、数据、 DLL (动

态链接库),和动态内存。实际上,系统仍然要受到可用的硬件资源的限制,但是实现了与系统中应用程序无关的、对于可用资源的管理。

 

Win32 中的虚拟内存

Windows NT 在内存和地址空间之间作出了区分。每个进程分配到 2 GB 的用户地址空间,而不管对于该进程的实际可用物理内存有多少。而且,所有进程都使用相同范围的线性 32 位地址,范围从 0000000016-7FFFFFFF16 ,而不考虑可用内存的地址。 Windows NT 负责

在适当的时间把内存页映射 (paging) 到磁盘以及从磁盘页映射回内存,使得每个进程都确保能够寻址到它所需要的内存。尽管有可能出现两个进程试图同时访问同一虚拟地址上的内存,但是,实际上 Windows NT 虚拟内存管理程序是在不同的物理位置描述这两个内存的位置。而且这两个地址都不见得与原始的虚拟地址一致。这就是虚拟内存。

因为虚拟内存的存在,一个应用程序能够管理它自己的地址空间,而不必考虑在系统中对于其它进程的影响。在 Windows NT 中的内存管理程序负责查看在任何给定的时间里,所有的应用程序是否有足够的物理内存进行有效的操作。与在 Windows 3.1 版本或更早的版本中不同, Windows NT 操作系统下的应用程序不必考虑和其它应用程序共享系统内存这个问题。并且,即使在应用程序自己的地址空间内,它们仍能够与其它的应用程序共享内存。

区分内存和地址空间的一个好处是,为应用程序提供了将非常大的文件加载到内存的能力。不必将一个大的文件读进内存中,W indows NT 为应用程序保留该文件所需的地址范围提供了支持。然后,在需要的时候,该文件部分就可以被浏览了(物理性地读进内存)。通过虚拟内存的支持,对于大段的动态内存的分配同样可以做到这一点。

Windows 的早期版本中,在一个应用程序能够操作内存中的地址之前,该应用程序必须首先分配内存。在 Windows NT 中,每一个进程的地址空间已经分配好了,是否有内存与该段地址空间中的地址相关联是另外的问题。 Win32 虚拟内存管理函数为分别管理进程的

地址和内存提供了低级别的支持。

Win32 虚拟内存函数的全体是:

VirtualAlloc VirtualFree

VirtualLock VirtualUnlock

VirtualQuery VirtualQueryEx

VirtualProtect VirtualProtectEx

对于每个函数,如有与之相对应的,则它们共同组成一组。分配内存请使用 VirtualAlloc ,一旦已经分配,则必须使用 VirtualFree 来释放。类似地,对于被使用 VirtualLock 锁定的页,当不再需要时,则必须用 VirtualUnlock 来解除锁定。 VirtualQuery VirtualProtect 没有与之相对应的函数,但它们俩都有完整功能 (complementary) 的函数(在函数名字上的 Ex 扩展来指示)。这样,就允许它们在除调用进程之外的其他进程中使用,但是,此时调用进程需要有适当的特权才可以这样。这些函数将在下面的适当的上下文中解释。

 

自由的、保留的和已提交的虚拟内存

在任意给定的时间,进程中每个地址都可以被当作是自由的、保留的或已提交的。进程开始时,所有地址的都是自由的,意味着它们都是自由空间并且可以被提交到内存,或者为将来使用而保留起来。在任何自由的地址能够被使用前,它必须首先被分配为保留的或已提交的。试图访问一个保留的或已提交的地址都将产生一个访问冲突异常 (access violation exception)

一个进程中的所有 2 GB 的地址要么为了使用而是自由的、要么为了将来的使用而是保留的、要么已提交到特定的内存(在使用的)。图 2 描述了一个假设的进程,它包含自由的、保留的和已提交的地址。

2. 一个进程的 2 GB 的虚存地址空间被分配为自由的、保留的和已提交的内存区位置。

 

保留的地址

当在一个进程中保留地址时,没有物理内存页被提交,并且,也许更为重要的是,在页文件中没有为备份该内存而保留空间。而且,保留一个地址范围将不会保证将来会有可用的物理内存来提交给这些地址。实际上,它只是保存了一个指定的自由地址地址,一

直到需要使用它时,而阻止了其它分配对该段地址的请求。如果没有这种类型的保护,那么例程操作 (routine operations) ,例如加载一个 DLL 或者资源,可能会占有指定的地址,并且危害以后对它的使用。

保留地址是一个快速的操作,完全与被保留的地址范围的大小没有关系。不论保留 1 GB 的地址范围,还是保留 4K 的地址范围,该函数的速度都非常快。这并不令人惊奇,因为在此操作期间,没有资源被分配。该函数只是进入进程的虚拟地址描述符 (VAD) 树。有关

VAD 的详细信息,请参阅 Developer Network CD 的" The Virtual-Memory Manager in Windows NT "(技术文章, Win32 Windows NT 文章)

要想保留一段地址范围,需要参照下列代码来调用 VirtualAlloc 函数:

/* 保留 10 MB 的地址空间 */

lpBase = VirtualAlloc (NULL, 10485760, MEM_RESERVE, PAGE_NOACCESS);

如这里所示,第一个参数, lpAddress ,使用的是 NULL 值,指导该函数在某一个最方便的位置保留地址范围。另外,也可能一个指定的地址已经被传递,为要保留的范围指示一个准确的初始地址。无论两种方法中的哪一种,该函数的返回值都指示出被保留的地址范围的开始位置,除非该函数无法完成请求。要是这样, VirtualAlloc 函数的返回值将是一个错误状态值。

第二个参数指示函数应该分配的地址范围。该值的大小可以是从一页到 2 GB 的任意值,但是 VirtualAlloc 实际上被限制为一个较小的范围。能够被保留的最小值为 64K ,而能够被保留的最大值为该进程中最大的连续自由地址空间。请求的保留地址,结果是得到 64K 的地址范围。反之,请求 2 GB 的范围将会失败,因为在任何给定的时间里,有那么多的可用地址空间是不可能的。(请记住,加载一个应用程序的动作也要使用初始 2 GB 地址空间中的一部分。)

注意 Windows NT 在每一个进程的地址空间中生成一个保护设施 (safeguard) 。每一个进程的顶端 65,536 字节和低端 65,536 字节都被系统永久地保留。这些地址空间部分被保留为陷阱迷失指针( trap stray pointers )棗试图在 0000000016-0000FFFF16 7FFF000016-7FFFFFFF16 范围内寻址内存的指针。并不是巧合,在这个范围内,只需忽略这些地址中的低四位(最右边的两个字节)就可很容易地检测到该指针。从根本来讲,如果高四位是 000016 7FFF16 ,那么这个指针是无效的;所有其它的值都表示有效的地址。

VirtualAlloc 函数中的最后两个参数, dwAllocationType dwProtect 被用来决定如何分配地址以及与它们相关联的保护。地址可被分配为 MEM_COMMIT 或者 MEM_RESERVE 类型。 PAGE_READONLY PAGE_READWRITE PAGE_NOACCESS 是三种可以被应用到虚拟内存的保护。无论何值被传递到该函数,被保留的地址总是 PAGE_NOACCESS ,这是系统强制的默认值。已提交的页可以是只读的、也可以可读写的,或者是不能访问的。

已提交的内存

要使用保留的地址,内存首先必须被提交给该地址。提交内存到地址与保留内存相类似棗调用 VirtualAlloc ,并且在调用时设置 dwAllocation 参数等于 MEM_COMMIT 。在这一时刻,资源被提交到地址上。每一次,内存可以按一页的大小被提交。能够被提交的最大内存值仅仅取决于连续的自由或者保留地址的最大范围(但两者不可组合在一起),无须考虑系统的可用物理内存的大小。

当内存被提交时,内存物理页被分配,并且该段空间被保留在在一个页文件中。也就是说,已提交的内存页总是以物理内存页或者在已经被分页的磁盘上的页文件的形式存在。当提交一个大块内存时,在初始阶段,其部分或者全部内存没有驻留在物理内存中也

是有可能的。某些内存页一开始驻留在页文件中,直到它被访问。在系统中,一旦内存页已提交,虚拟内存管理器象对待所有其它的内存页一样对待它们。

Windows NT 虚拟内存系统中,使用了页表( page tables )来访问物理内存页。每个页表本身也是一个内存页,象已提交的页一样。偶而,当提交内存时,同时还必须对页表分配附加的页。所以,提交一页内存的请求可能需要为页表分配一页,为请求的页分配

一页,并且在页文件中需要两页空间来备份这些页中的每一页。因此, VirtualAlloc 完成一个内存提交请求所需要的时间变化很大,它取决于系统的状态以及请求的空间大小。

下面的示例演示了如何将上例中被保留地址的指定页提交到一个内存页中。

/* 为第 3 页地址提交内存。 */

lpPage3 = VirtualAlloc (lpBase + (2 * 4096),4096,MEM_COMMIT,PAGE_READWRITE);

请注意,对于 lpAddress 没有指定为 NULL ,而是指定了一个特定的地址来准确地指示被保留地址的哪一页会变成提交给内存的页。而且,初始时该内存页被赋予 PAGE_READWRITE 保护,而不是象在前面示例中的 PAGE_NOACCESS 。该函数的返回地址是第一页已提交地址的虚拟地址。

 

释放虚拟内存

一旦地址被以保留的或者已提交的形式分配, VirtualFree 是唯一可以释放它们的方法,那就是,将它们返回到自由的地址。 VirtualFree 还可以用来对已提交的页解除提交,同时,返回这些地址到保留状态。当解除地址的提交时,所有与该地址相关的物理内存和页文件空间都被释放。下面的示例演示如何对在前一个示例中已提交的内存页解除提交。

/* 对第 3 页地址解除提交内存。 */

VirtualFree (lpBase + (2 * 4096),4096,MEM_DECOMMIT,PAGE_NOACCESS);

只有已提交的地址才能被解除提交。当您需要对一个大范围的地址解除提交时,牢记这一点是非常重要的。例如,假设您有一定范围的地址,在其中多个地址的子集已被提交,而其它部分被保留。要想使全部范围的地址保留,唯一方法就是一个一个地对每个被提交的地址子集解除提交。如果试图对整个范围的地址解除提交将会失败,因为保留的地址无法解除提交。

与之相反,在一次操作中相同范围的地址都能被释放。因为在地址被释放时,一个地址的状态并不重要。下面的示例演示释放在第一个示例中被保留的 10 MB 范围的地址。

/* 释放整个 10 MB 范围的地址。 */

VirtualFree (lpBase,10485760,MEM_RELEASE,PAGE_NOACCESS);

改变虚拟内存页的保护

Win32 提供 VirtualProtect 函数,作为对已提交内存改变页保护的一个方法。例如,一个应用程序可以按 PAGE_READWRITE 来提交一个页的地址,并且立即将数据填写到该页中。然后,该页的保护将被改变为 PAGE_READONLY ,这样可以有效地保护数据不被该进程中的任何线程重写。下面的示例使用 VirtualProtect 函数使一个不能被访问的页可用。

/* 将页保护改变成可读 / 写。 */

VirtualProtect (lpStack + 4096,4096,PAGE_READWRITE,lpdwOldProt);

可以将下面的环境看作是使用该函数的环境。一个用于缓冲数据的应用程序接收到一组大小变化的数据流,根据特定的硬件配置和其它的软件应用程序对 CPU 时间的竞争,数据流可能某时 (at times) 超出进程的能力。为了防止这种现象发生,应用程序设计了一个内存系统,可以在开始时为一个缓冲提交一些内存页。然后,应用程序则使用 PAGE_NOACCESS 保护来保护内存的顶端页,使得任何想要访问该内存的请求都会产生一个异常。应用程序也在该代码的外层代码中使用一个异常处理程序来处理访问冲突。

当一个访问冲突发生时,应用程序能够确定缓冲区是否已经到了其极限。该应用程序通过将页保护改变为 PAGE_READWRITE 来响应,允许该缓冲区接收任何附加的数据,并且继续不间断的执行。同时,应用程序加载另一个线程来减缓数据流,直到该缓冲区恢复到一个理想的操作范围。当情况恢复到正常,顶端的页又返回到 PAGE_NOACCESS ,并且附加的线程也结束了。这种情况描述了在 Win32 中,如何将页保护和异常处理程序结合使用来提供独一无二的内存管理机会。

 

锁定虚拟内存页

Windows NT 中的进程有一个被称为工作组( working set )的最小页,是为了进程能够顺利地运行,在运行时在内存中必须被提供。 Windows NT 在启动时为一个进程分配了默认数量的页数,并且逐渐地调整该数,使得系统中所有激活的进程的性能达到一种平衡

的最优。当一个进程正在运行时(实际上是,是一个进程的线程正在运行时), Windows NT 在"努力工作"以确保该进程的工作组页总是驻留在物理内存中。

Windows NT 中的进程被授权使用 VirtualLock VirtualUnlock 函数来巧妙地影响该系统的行为。从根本上讲,一个进程可以建立特定的页将它锁定到工作组中。然而,这并不给进程的工作组以自由的范围。它不能影响组成它的工作组的页数,(系统为每一个进程按规范来调整工作组),并且它无法控制何时工作组在内存中以及何时不在内存中。每次一个进程的工作组锁住的页数码最多不超过 32 。如果一个应用程序将被提交的内存页锁定在工作组中,这样做可能利大于弊,因为这样做可能会迫使该进程中的其他关键页被替代。如果那样的话,页可能会页映射到磁盘,在被访问的任何时候都会发生页故障。于是,该进程将花费许多的 CPU 时间却只是将关键页映射到内存和映射出内存。

请牢记,在 Win32 中锁定一页内存并不意味着该页内存将不能被页映射到磁盘。相反,它意味着当进程正在运行时,被锁定的内存页将是在一段物理内存中。不仅仅是有可能,而是非常可能,当一个进程处于理想情况时,该进程的整个页的工作组将被页映射到磁盘。当该进程的工作情况变差时,则工作组页将立即被页映射回内存中,包括 VirtualLocked 页。

下面的例子在进程运行时,锁定一段地址到内存中。

/* 锁定关键地址 (critical addresses) 到内存中。 */

VirtualLock (lpCriticalData, 1024);

请注意,在本例中被锁定到内存的地址范围小于一页。并不是必需要整段范围都在内存的单个页中。最后的结果 (net result) 是包含该地址中的数据的整页的内存被锁定到内存,而不仅仅是指出的地址的数据被锁定到内存中。如果该数据跨越了页的边界,则两页都将被锁定。

查询一个进程的虚拟内存

给定一个进程的地址空间为 2 GB ,如果没有查询地址信息的能力,那么想要管理地址的全部范围将是困难的。因为地址本身代表独立的内存,对于它们,这些内存有可能被提交,或者不被提交,对它们进行查询,仅仅是读取保存它们状态的数据结构一件事罢了。在 Windows NT 中,该结构是以前提到过的虚拟地址描述符树。 Win32 VirtualQuery VirtualQueryEx 函数中提供了"遍历 VAD 结构"的能力。同样, Ex 后缀指出哪个函数可从一个进程中被调用来查询另一个进程棗假如调用进程有足够的安全特权来执行该函数。

下面的示例是从 ProcessWalker 示例程序中分离出来的:

/* 查询子进程中的下一个内存区。 */

VirtualQueryEx (hChildProcess,lpMem,lpList,sizeof (MEMORY_BASIC_INFORMATION));

ProcessWalker 应用程序的主函数是遍历一个进程的地址空间,标识它的每一个唯一的地址区,并且显示有关每个区的特定状态信息。它是通过每次枚举从进程底部到上部的每个区域来完成的。 LpMem 被用来指出每个区域的位置。开始时它被设为 0 ,从每个新区域

的查询返回后,它通过查询的区域大小而递增。该过程一直重复下去,直到 lpMem 达到系统最高的保留区域。

LpList 是一个指向 MEMORY_BASIC_INFORMATION 结构的指针,该结构被 VirtualQueryEx 函数所填充。当该函数返回时,该结构代表被查询区域的有关信息。该结构包含下列成员:

typedef struct _MEMORY_BASIC_INFORMATION { /* mbi */PVOID BaseAddress; /* 区域的基本地址 */

PVOID AllocationBase; /* 分配基本地址 */

DWORD AllocationProtect; /* 初始访问保护 */

DWORD RegionSize; /* 区域的字节大小 */

DWORD State; /* 已提交的、保留的、自由的 */

DWORD Protect; /* 当前访问保护 */

DWORD Type; /* 页类型 */

} MEMORY_BASIC_INFORMATION;

VirtualQuery 函数将为任何连接的地址区域返回这种状态信息。函数确定区域的底层边界和区域的大小,以及该区域中的地址的确切状态。它用于确定该区域的地址可以是该区域中的任何地址。因此,如果您想确定在任何给定的时间里有多少堆栈空间已经被提交,请按照这些步骤进行:

为问题中的线程取得线程环境。 调用 VirtualQuery 函数,将线程环境信息中的堆栈指针地址作为函数中 lpMem 参数来提供。 该查询返回被提交内存的大小,并且分别以 RegionSize BaseAddres 的形式返回在 MEMORY_BASIC_INFORMATION 结构中基本堆栈的地址。

内存区域,由 VirtualQueryRegions 来定义,是一个连续的地址范围,其保护、类型和基本分配是相同的。类型和保护值在本篇技术文章的前面曾做过描述。基本分配是 lpAddress 参数值,当整个内存区域通过 VirtualAlloc 函数第一次被分配时该参数被使用。在 EMORY_BASIC_INFORMATION 结构中,它作为 AllocationBase 字段被表示出来。

当自由的地址成为保留的或已提交的情况时,那时它们的基本分配被确定。从任何角度来说,内存区域都不是静态的。一旦在保留地址区域中的一个单页被提交,则该区域被分成一个或多个保留区域和一个被提交区域。当页内存改变状态时,这种情况就会继续下去。类似地,当多个 PAGE_READWRITE 的已提交页中的一个被改变成 PAGE_READONLY 保护时,该区域也被分成多个较小的区域。

 

总结

Win32 中的虚拟内存管理函数提供在 Windows NT 中直接管理虚拟内存的能力。每个进程的 2 GB 用户地址空间被分成为保留的、已提交的或者自由的虚拟地址内存区。一个区被定义为一个连续的地址范围,其保护、类型和每个地址的基本分配是相同的,在每个区中是一个或多个地址页,也可携带保护和页锁定标志状态位。

虚拟内存管理函数为应用程序提供了转换虚拟地址空间页状态的能力。一个应用程序可以把内存类型从已提交的改变为保留的,或把保护模式从 PAGE_READWRITE 改变为 PAGE_READONLY ,从而防止对某段地址空间的访问。一个应用程序可以锁定一页内存到工

作组,使得一个进程最少地分页关键内存页。虚拟内存函数被认为是低级函数,意味着它们的速度相对较快,但是缺乏很多高级函数所具有的功能。