基本概念【摘录】
每个进程都被赋予它自己的虚拟地址空间。对于32位进程来说,这个地址空间是4GB,因为32位指针可以拥有从0x000000000至0xFFFFFFFF之间的任何一个值。这使得一个指针能够拥有4 294 967 296个值中的一个值,它覆盖了一个进程的4GB虚拟空间的范围。这是相当大的一个范围。由于每个进程可以接收它自己的私有的地址空间,因此当进程中的一个线程正在运行时,该线程可以访问只属于它的进程的内存。属于所有其他进程的内存则隐藏着,并且不能被正在运行的线程访问。
注意在Windows 2000中,属于操作系统本身的内存也是隐藏的,正在运行的线程无法访问。这意味着线程常常不能访问操作系统的数据。Windows 98中,属于操作系统的内存是不隐藏的,正在运行的线程可以访问。因此,正在运行的线程常常可以访问操作系统的数据,也可以破坏操作系统(从而有可能导致操作系统崩溃)。在Windows 98中,一个进程的线程不可能访问属于另一个进程的内存。
前面说过,每个进程有它自己的私有地址空间。进程A可能有一个存放在它的地址空间中的数据结构,地址是0x12345678,而进程B则有一个xx不同的数据结构存放在它的地址空间中,地址是0x12345678。当进程A中运行的线程访问地址为0x12345678的内存时,这些线程访问的是进程A的数据结构。当进程B中运行的线程访问地址为0x12345678的内存时,这些线程访问的是进程B的数据结构。进程A中运行的线程不能访问进程B的地址空间中的数据结构。反之亦然。
记住,这是个虚拟地址空间,不是物理地址空间。该地址空间只是内存地址的一个范围。在你能够成功地访问数据而不会出现违规访问之前,必须赋予物理存储器,或者将物理存储器映射到各个部分的地址空间。
每个进程的虚拟地址空间都要划分成各个分区。地址空间的分区是根据操作系统的基本实现方法来进行的。不同的Windows内核,其分区也略有不同。
当进程被创建并被赋予它的地址空间时,该可用地址空间的主体是空闲的,即未分配的。若要使用该地址空间的各个部分,必须通过调用VirtualAlloc函数来分配它里边的各个区域。对一个地址空间的区域进行分配的操作称为保留(reserving)。
每当你保留地址空间的一个区域时,系统要确保该区域从一个分配粒度的边界开始。对于不同的CPU平台来说,分配粒度是各不相同的。但是,目前(x86、32位Alpha、64位Alpha和IA-64)都使用64KB这个相同的分配粒度。当你保留地址空间的一个区域时,系统还要确保该区域的大小是系统的页面大小的倍数。页面是系统在管理内存时使用的一个内存单位。与分配粒度一样,不同的CPU,其页面大小也是不同的。x86使用的页面大小是4KB,而Alpha(当既能运行32位Windows 2000也能运行6 4位Windows 2000时)使用的页面大小则是8 KB。
在较老的操作系统中,物理存储器被视为计算机拥有的RAM的容量。换句话说,如果计算机拥有16MB的RAM,那么加载和运行的应用程序最多可以使用16MB的R A M。今天的操作系统能够使得磁盘空间看上去就像内存一样。磁盘上的文件通常称为页文件,它包含了可供所有进程使用的虚拟内存。
当然,若要使虚拟内存能够运行,需要得到CPU本身的大量帮助。当一个线程试图访问一个字节的内存时, CPU必须知道这个字节是在RAM中还是在磁盘上。
从应用程序的角度来看,页文件透明地增加了应用程序能够使用的RAM(即内存)的数量。如果计算机拥有64MB的RAM,同时在硬盘上有一个100 MB的页文件,那么运行的应用程序就认为计算机总共拥有164MB的RAM。当然,实际上并不拥有164MB的RAM。相反,操作系统与CPU相协调,共同将R A M的各个部分保存到页文件中,当运行的应用程序需要时,再将页文件的各个部分重新加载到R A M。
由于页文件增加了应用程序可以使用的RAM的容量,因此页文件的使用是视情况而定的。如果没有页文件,那么系统就认为只有较少的RAM可供应用程序使用。但是,我们鼓励用户使用页文件,这样他们就能够运行更多的应用程序,并且这些应用程序能够对更大的数据集进行操作。{zh0}将物理存储器视为存储在磁盘驱动器(通常是硬盘驱动器)上的页文件中的数据。
这样,当一个应用程序通过调用VirtualAlloc函数,将物理存储器提交给地址空间的一个区域时,地址空间实际上是从硬盘上的一个文件中进行分配的。系统的页文件的大小是确定有多少物理存储器可供应用程序使用时应该考虑的最重要的因素, RAM的容量则影响非常小。
现在,当你的进程中的一个线程试图访问进程的地址空间中的一个数据块时,将会发生两种情况之一:
在{dy}种情况中,线程试图访问的数据是在RAM中。在这种情况下, CPU将数据的虚拟内存地址映射到内存的物理地址中,然后执行需要的访问。
在第二种情况中,线程试图访问的数据不在RAM中,而是存放在页文件中的某个地方。这时,试图访问就称为页面失效, CPU将把试图进行的访问通知操作系统。这时操作系统就寻找RAM中的一个内存空页。如果找不到空页,系统必须释放一个空页。如果一个页面尚未被修改,系统就可以释放该页面。但是,如果系统需要释放一个已经修改的页面,那么它必须首先将该页面从RAM拷贝到页交换文件中,然后系统进入该页文件,找出需要访问的数据块,并将数据加载到空闲的内存页面。然后,操作系统更新它的用于指明数据的虚拟内存地址现在已经映射到RAM中的相应的物理存储器地址中的表。这时CPU重新运行生成初始页面失效的指令,但是这次CPU能够将虚拟内存地址映射到一个物理R A M地址,并访问该数据块。
保护属性
已经分配的物理存储器的各个页面可以被赋予不同的保护属性。x86和Alpha CPU不支持“执行”保护属性,不过操作系统软件却支持这个属性。这些CPU将读访问视为执行访问。这意味着如果将PAGE_EXECUTE保护属性赋予内存,那么该内存也将拥有读优先权。当然,不应该依赖这个行为特性,因为在其他CPU上的Windows实现代码很可能将“执行”保护视为“仅为执行”保护。
PAGE_NOACCESS 如果试图在该页面上读取、写入或执行代码,就会引发访问违规
PAGE_READONLY 如果试图在该页面上写入或执行代码,就会引发访问违规
PAGE_READWRITE 如果试图在该页面上执行代码,就会引发访问违规
PAGE_EXECUTE 如果试图在该页面上对内存进行读取或写入操作,就会引发访问违规
PAGE_EXECUTE_READ 如果试图在该页面上对内存进行写入操作,就会引发访问违规
PAGE_EXECUTE_READWRITE 对于该页面不管执行什么操作,都不会引发访问违规
PAGE_WRITECOPY 如果试图在该页面上执行代码,就会引发访问违规。如果试图在该页面上写入内存,就会导致系统将它自己的私有页面(受页文件的支持)拷贝赋予该进程
PAGE_EXECUTE_WRITECOPY 对于该地址空间的区域,不管执行什么操作,都不会引发访问违规。如果试图在该页面上的内存中进行写入操作,就会将它自己的私有页面(受页文件的支持)拷贝赋予该进程
几个API函数
通过调用VirtualAlloc函数,可以在进程的地址空间中保留一个区域:
PVOID VirtualAlloc(
PVOID pvAddress,
SIZE_T dwsize,
DWORD fdwAllocationtype,
DWORD fdwProtect);
{dy}个参数pvAddress包含一个内存地址,用于设定想让系统将地址空间保留在什么地方。在大多数情况下,你为该参数传递NULL。它告诉VirtualAlloc,保存着一个空闲地址区域的记录的系统应该将区域保留在它认为合适的任何地方。系统可以从进程的地址空间的任何位置来保留一个区域,因为不能保证系统可以从地址空间的底部向上或者从上面向底部来分配各个区域。可以使用MEM_TOP_DOWN标志来说明该分配方式。
对大多数程序员来说,能够选择一个特定的内存地址,并在该地址保留一个区域,这是个非同寻常的想法。当你在过去分配内存时,操作系统只是寻找一个其大小足以满足需要的内存块,并分配该内存块,然后返回它的地址。但是,由于每个进程有它自己的地址空间,因此可以设定一个基本内存地址,在这个地址上让操作系统保留地址空间区域。例如,你想将一个从50MB开始的区域保留在进程的地址空间中。这时可以传递52 428 800(50×1024×1024)作为pvAddress参数。如果该内存地址有一个足够大的空闲区域满足你的要求,那么系统就保留这个区域并返回。如果在特定的地址上不存在空闲区域,或者如果空闲区域不够大,那么系统就不能满足你的要求,VirtualAlloc函数返回NULL。注意,为pvAddress参数传递的任何地址必须始终位于进程的用户方式分区中,否则对VirtualAlloc函数的调用就会失败,导致它返回NULL.
如果VirtualAlloc函数能够满足你的要求,那么它就返回一个值,指明保留区域的基地址。如果传递一个特定的地址作为pvAddress参数,那么该返回值与传递给VirtualAlloc的值相同,并被圆整为(如果需要的话)64KB边界值。
第二个参数是dwSize,用于设定想保留的区域的大小(以字节为计量单位)。由于系统保留的区域始终必须是CPU页面大小的倍数,因此,如果试图保留一个跨越62KB的区域,结果就会在使用4 KB、8 KB或16 KB页面的计算机上产生一个跨越6 4 K B的区域。
第三个参数是fdwAllocationType,它能够告诉系统你想保留一个区域还是提交物理存储器.若要保留一个地址空间区域,必须传递MEM_RESERV E标识符作为参数的值。
如果保留的区域预计在很长时间内不会被释放,那么可以在尽可能高的内存地址上保留该区域。这样,该区域就不会从进程地址空间的中间位置上进行保留。因为在这个位置上它可能导致区域分成碎片。如果想让系统在{zg}内存地址上保留一个区域,必须为pvAddress参数和fdwAllocationType参数传递N U L L,还必须逐位使用OR将MEM_TOP_DOWN标志和MEM_RESERVE标志连接起来。
注意在Windows 98下,MEM_TOP_DOWN标志将被忽略。
{zh1}一个参数是fdwProtect,用于指明应该赋予该地址空间区域的保护属性。与该区域相关联的保护属性对映射到该区域的已提交内存没有影响。无论赋予区域的保护属性是什么,如果没有提交任何物理存储器,那么访问该范围中的内存地址的任何企图都将导致该线程引发一个访问违规。当保留一个区域时,应该为该区域赋予一个已提交内存最常用的保护属性。例如,如果打算提交的物理存储器的保护属性是PAGE_ READWRITE(这是最常用的保护属性),那么应该用PAGE_READWRITE保护属性来保留该区域。当区域的保护属性与已提交内存的保护属性相
匹配时,系统保存的内部记录的运行效率{zg}。
可以使用下列保护属性中的任何一个: PAGE_NOACCESS、PAGE_READWRITE、PAGE_READONLY、PAGE_EXECUTE、PAGE_EXECUTE_READ或PAGE_EXE CUTE_READWRITE。但是,既不能设定PAGE_WRITECOPY属性,也不能设定PAGE_EXECUTE_WRITECOPY属性。如果设定了这些属性,函数将不保留该区域,并且返回NULL。另外,当保留地址空间区域时,不能使用保护属性标志PAGE_GUARD,PAGE_NOCACHE或PAGE_WRITECOMBINE,这些标志只能用于已提交的内存。
在保留区域中的提交存储器
当保留一个区域后,必须将物理存储器提交给该区域,然后才能访问该区域中包含的内存地址。系统从它的页文件中将已提交的物理存储器分配给一个区域。物理存储器总是按页面边界和页面大小的块来提交的。
若要提交物理存储器,必须再次调用VirtualAlloc函数。不过这次为fdwAllocationType参数传递的是MEM_COMMIT标志,而不是MEM_RESERV E标志。传递的页面保护属性通常与调用VirtualAlloc来保留区域时使用的保护属性相同(大多数情况下是PAGE_READWRITE),不过也可以设定一个不同的保护属性。
在已保留的区域中,你必须告诉VirtualAlloc函数,你想将物理存储器提交到何处,以及要提交多少物理存储器。为了做到这一点,可以在pvAddress参数中设定你需要的内存地址,并在dwSize参数中设定物理存储器的数量(以字节为计量单位)。注意,不必立即将物理存储器提交给整个区域。
下面让我们来看一个如何提交物理存储器。比如说,你的应用程序是在x86 CPU上运行的,该应用程序保留了一个从地址5 242 880开始的512 KB的区域。你想让应用程序将物理存储器提交给已保留区域的6 KB部分,从2 KB的地方开始,直到已保留区域的地址空间。为此,可以调用带有MEM_COMMIT标志的VirtualAlloc函数
有时你可能想要在保留区域的同时,将物理存储器提交给它。只需要一次调用VirtualAlloc函数就能进行这样的操作,如下所示:
VirtualAlloc(NIL,SizeOf(ArrayType),
MEM_RESERVE or MEM_COMMIT, PAGE_READWRITE);
当系统处理这个函数调用时,它首先要搜索你的进程的地址空间,找出未保留的地址空间中一个地址连续的区域,它必须足够大,原因是已将pvAddress参数设定为NULL。如果为pvAddress设定了内存地址,系统就要查看在该内存地址上是否存在足够大的未保留地址空间。如果系统找不到足够大的未保留地址空间,VirtualAlloc将返回NULL,如果能够保留一个合适的区域,系统就将物理存储器提交给整个区域。无论是该区域还是提交的内存,都将被赋予PAGE_READWRITE保护属性。
{zh1}需要说明的是,VirtualAlloc将返回保留区域和提交区域的虚拟地址,然后该虚拟地址被保存在pvMem变量中。如果系统无法找到足够大的地址空间,或者不能提交该物理存储器,VirtualAlloc将返回NULL。当用这种方式来保留一个区域和提交物理存储器时,将特定的地址作为pvAddress参数传递给VirtualAlloc当然是可能的。否则就必须用OR将MEM_TOP_DOWN标志与fdwAllocationType参数连接起来,并为pvAddress参数传递NULL,让系统在进程的地址空间的顶部选定一个适当的区域。
回收虚拟内存和释放地址空间区域
若要回收映射到一个区域的物理存储器,或者释放这个地址空间区域,可调用
BOOL VirtualFree(
LPVOID lpAddress, // address of region of committed pages
DWORD dwSize, // size of region
DWORD dwFreeType // type of free operation
);
当你的进程不再访问区域中的物理存储器时,就可以释放整个保留的区域和所有提交给该区域的物理存储
器,方法是一次调用VirtualFree函数。就这个函数的调用来说, lpAddress参数必须是该区域的基地址。此地址与该区域被保留时VirtualFree函数返回的地址相同。系统知道在特定内存地址上的该区域的大小,因此可以为dwSize参数传递0。实际上,必须为dwSize参数传递0,否则对VirtualFree的调用就会失败。对于第三个参数fdwFreeType,必须传递MEM_RELEASE,以告诉系统将所有映射的物理存储器提交给该区域并释放该区域。当释放一个区域时,必须释放该区域保留的所有地址空间。例如不能保留一个128 KB的区域,然后决定只释放它的64 KB。必须释放所有的128 KB。
当想要从一个区域回收某些物理存储器,但是却不释放该区域时,也可以调用VirtualFree函数,若要回收某些物理存储器,必须在VirtualFree函数的pvAddress参数中传递用于标识要回收的{dy}个页面的内存地址,还必须在dwSize参数中设定要释放的字节数,并在dwFreeType 参数中传递MEM_DECOMMIT标志。
与提交物理存储器的情况一样,回收时也必须按照页面的分配粒度来进行。这就是说,设定页面中间的一个内存地址就可以回收整个页面。当然,如果pvAddress + dwSize的值位于一个页面的中间,那么包含该地址的整个页面将被回收。因此位于pvAddress 至pvAddress +dwSize范围内的所有页面均被回收。如果dwSize是0,lpAddress是已分配区域的基地址,那么VirtualFree将回收全部范围内的已分配页面。当物理存储器的页面已经回收之后,已释放的物理存储器就可以供系统中的所有其他进程使用,如果试图访问未回收的内存,将会造成访问违规。
改变内存页面的保护属性
可以调用
BOOL VirtualProtect(
LPVOID lpAddress, // address of region of committed pages
DWORD dwSize, // size of the region
DWORD flNewProtect, // desired access protection
PDWORD lpflOldProtect // address of variable to get old protection
);
这里的lpAddress参数指向内存的基地址(它必须位于进程的用户方式分区中),dwSize参数用于指明你想要改变保护属性的字节数,而flNewProtect参数则代表PAGE_*保护属性标志中的任何一个标志,但PAGE_WRITECOPY和PAGE_EXECUTE_WRITECOPY这两个标志除外。{zh1}一个参数lpflOldProtect用来接收原先的保护属性,尽管许多应用程序并不需要该信息,但是必须为该参数传递一个有效地址,否则该函数的运行将会失败。当然,保护属性是与内存的整个页面相关联的,而不是赋予内存的各个字节的。
确定地址空间的状态
用函数称
DWORD VirtualQuery(
LPCVOID lpAddress, // address of region
PMEMORY_BASIC_INFORMATION lpBuffer, // address of information buffer
DWORD dwLength // size of buffer
);
lpAddress参数必须包含你想要查询其信息的虚拟内存地址。lpBuffer参数是你必须分配的PMEMORY_BASIC_INFORMATION结构的地址,{zh1}一个参数用与制定结构的大小。关于MEMORY_BASIC_INFORMATION 结构,请参考MSDN帮助或其他资料。
说明,上述介绍的函数都是针对本进程而言的,如果要操作其他进程的内存信息,可以使用*****Ex结构的函数,例如要查询其他进程的地址空间状态,可以使用VirtualQueryEx函数,用法几乎一样,就是多了一个要查询进程的句柄。
DELPHI中的内存分配
如果你使用DELPHI开发了一些程序,你经常用来分配内存的函数我想大概是,new,GetMem(),AllocMem()等,他们是怎么实现的呢?观察DELPHI的System.pas以及GetMem.inc源码,你会得到一些启发,注,在Getmem.inc中,Borlnd实现了自己的缺省内存管理器,DELPHI的内存管理器还包括ShareMem.pas以及相应的内存管理模块borlndmm.dll,看到这个名字,如果你用DELPHI做过DLL一定很熟悉吧。至于DELPHI内存管理器的具体实现,这不是我今天要描述的重点,但有了上面那些API函数以及WINDOWS分配内存的概念,无论是你写一个内存管理器还是看现有的代码,都已经算是入门了,至于最终如何实现,那需要的将不再是简单的编码知识,而是一种思路。
一个例子:
程序界面:
程序的演示使用:
上面的程序是我写的一个用来演示WINDOWS虚拟内存概念的例子。
1、首先我们可以用他来了解一下windows进程地址空间如何分区的情况。启动程序后,在【从进程虚拟内存开始地址显示××个页面信息】的EDIT中输入20,然后点击【显示】按钮,在我的机器上(根据CPU以及WINDOWS内核的不同,在你机器上会有不同),你会发现第17个页面是红色(程序中代表已经提交的页面),而前16个是黄色,代表FREE的区域,从程序右边的显示信息你还可以看到他们的具体情况,计算以下,前16个,刚好是64K,和本文最开始的那个【进程地址空间分区图】对比以下,你会有更多的了解。
注意:在测试中,我输入了显示20个页面的信息,你当然可以用其他的数字,但因为在做这个示例程序时,过于匆忙,我只画了20个标尺信息,在我演示的信息中,20已经足够反映一些事情。另外,因为WIN32虚拟地址空间是4G,所以如果你将我程序中用来显示进程空间划分情况的代码稍加改动,就可以做到对整个4G的历遍,而我的程序中用来循环历遍地址空间信息的代码中用的是longint类型,他不足以支持4G。程序执行情况如上图所示。
2、对windows中保留、提交、释放等概念加深了解。运行程序,点击【申请内存-保留】按钮,程序将在自己的进程空间中保留20个页面大小的空间,然后提交1个页面、4、5页面等,请仔细观察下面的图表信息以及右方的列表信息。然后,你还可以进行下面【高级】信息框中的一些操作,自己定义要从那里提交多大的空间,例如,你首先点击【申请内存-保留】按钮,然后在起始地址偏移中输入1,大小中输入1,你可以看到,虽然你只要求1个BYTE的空间,但依然会为你分配一个页面。
3、对页面保护属性加深了解。通常而言我们在编写代码的时候,经常会出现“×××××内存访问错误”信息,在这个示例程序中,你可以做一些这方面的测试。在【尝试对{dy}个页面进行写操作】按钮中,我对分配空间进行了写操作。我定义了一个这样的结构,
TTestMemAlloc=record
Data : array[0..4093] of Char;
注意4093这个信息,在我的机器上,一个页面大小是4096 Byte,这里定义4093代表一个页面就足够了。
var
arrayTemp : ^TTestMemAlloc;
begin
arrayTemp := Arrayptr;//将分配空间的地址传递给指向TTestMemAlloc类型的指针
arrayTemp^.Data := 'wuiasdfasdfasdfasdfasf';//此时的读写实际上是对Arrayptr的读写
showmessage(pchar(Arrayptr));//如果写入成功,从Arrayptr中读取信息
例如,你首先通过【申请内存-保留】按钮保留一块区域,然后提交页面1,因为对页面1,我代码中设置的保护属性是PAGE_READONLY,所以在此时,你尝试进行写操作是会失败的。你可以通过【修改保护属性】按钮来将页面1的保护属性修改为PAGE_READWRITE,再尝试写操作,OK! 你还可以换一个方式来测试,将代码中的Data : array[0..4093] of Char;改为4099,总之,是大于一个页面,此时,你依然只对页面1进行内存提交,并赋值可以读写的属性,你会发现,当你尝试进行写入操作时,依然会失败,原因很简单,TTestMemAlloc结构的大小已经大于一个页面,而第二个页面,我们并没有提交。