• Windows核心编程 第十七章 -内存映射文件(上)


    1 7章 内存映射文件

        对文件进行操作几乎是所有应用程序都必须进行的,并且这常常是人们争论的一个问题。应用程序究竟是应该打开文件,读取文件并关闭文件,还是打开文件,然后使用一种缓冲算法,从文件的各个不同部分进行读取和写入呢?M i c r o s o f t提供了一种两全其美的方法,那就是内存映射文件。

        与虚拟内存一样,内存映射文件可以用来保留一个地址空间的区域,并将物理存储器提交给该区域。它们之间的差别是,物理存储器来自一个已经位于磁盘上的文件,而不是系统的页文件。一旦该文件被映射,就可以访问它,就像整个文件已经加载内存一样。

    内存映射文件可以用于3个不同的目的:

        • 系统使用内存映射文件,以便加载和执行 . e x eD L L文件。这可以大大节省页文件空间和应用程序启动运行所需的时间。

        • 可以使用内存映射文件来访问磁盘上的数据文件。这使你可以不必对文件执行 I / O操作,并且可以不必对文件内容进行缓存。

        • 可以使用内存映射文件,使同一台计算机上运行的多个进程能够相互之间共享数据。

        Wi n d o w s确实提供了其他一些方法,以便在进程之间进行数据通信,但是这些方法都是使用内存映射文件来实现的,这使得内存映射文件成为单个计算机上的多个进程互相进

    行通信的最有效的方法。本章将要介绍内存映射文件的各种使用方法。

    17.1 内存映射的可执行文件和D L L文件

        当线程调用C r e a t e P r o c e s s时,系统将执行下列操作步骤:

        1) 系统找出在调用C r e a t e P r o c e s s时设定的. e x e文件。如果找不到这个. e x e文件,进程将无法创建,C r e a t e P r o c e s s将返回FA L S E

        2) 系统创建一个新进程内核对象。

        3) 系统为这个新进程创建一个私有地址空间。

        4) 系统保留一个足够大的地址空间区域,用于存放该 . e x e文件。该区域需要的位置在 . ex e文件本身中设定。按照默认设置,. e x e文件的基地址是0 x 0 0 4 0 0 0 0 0(这个地址可能不同于在6 4Windows 2000上运行的6 4位应用程序的地址),但是,可以在创建应用程序的 . e x e文件时重载这个地址,方法是在链接应用程序时使用链接程序的 / B A S E选项。

        5) 系统注意到支持已保留区域的物理存储器是在磁盘上的 . e x e文件中,而不是在系统的页文件中。当. e x e文件被映射到进程的地址空间中之后,系统将访问 . e x e文件的一个部分,该部分列出了包含 . e x e文件中的代码要调用的函数的 D L L文件。然后,系统为每个 D L L文件调用L o a d L i b r a r y函数,如果任何一个D L L需要更多的D L L,那么系统将调用L o a d L i b r a r y函数,以便加载这些D L L。每当调用L o a d L i b r a r y来加载一个D L L时,系统将执行下列操作步骤,它们均类似上面的第4和第5个步骤:

        1) 系统保留一个足够大的地址空间区域,用于存放该 D L L文件。该区域需要的位置在

    D L L文件本身中设定。按照默认设置, M i c r o s o f tVisual C++建立的 D L L文件基地址是0 x 1 0 0 0 0 0 0 0(这个地址可能不同于在 6 4Windows 2000上运行的6 4D L L的地址)但是,你可以在创建D L L文件时重载这个地址,方法是使用链接程序的 / B A S E选项。Wi n d o w s提供的所有标准系统D L L都拥有不同的基地址,这样,如果加载到单个地址空间,它们就不会重叠。

        2) 如果系统无法在该D L L的首选基地址上保留一个区域,其原因可能是该区域已经被另一个D L L. e x e占用,也可能是因为该区域不够大,此时系统将设法寻找另一个地址空间的区域来保留该D L L。如果一个D L L无法加载到它的首选基地址,这将是非常不利的,原因有二。首先,如果系统没有再定位信息,它就无法加载该D L L(可以在D L L创建时,使用链接程序的/ F I X E D开关,从D L L中删除再定位信息,这能够使D L L变得比较小,但是这也意味着该D L L必须加载到它的首选地址中,否则它就根本无法加载)。第二,系统必须在 D L L中执行某些再定位操作。在Windows 98中,系统可以在页面被转入R A M时执行再定位操作。在Windows 2000中,这些再定位操作需要由系统的页文件提供更多的存储器,它们也增加了加载D L L所需要的时间量。

        3) 系统会注意到支持已保留区域的物理存储器位于磁盘上的 D L L文件中,而不是在系统的页文件中。如果由D L L无法加载到它的首选基地址,Windows 2000必须执行再定位操作,那么系统也将注意到D L L的某些物理存储器已经被映射到页文件中。

    如果由于某个原因系统无法映射 . e x e和所有必要的D L L文件,那么系统就会向用户显示一个消息框,并且释放进程的地址空间和进程对象。 C r e a t e P r o c e s s函数将向调用者返回FA L S E,调用者可以调用G e t L a s t E r r o r函数,以便更好地了解为什么无法创建该进程。

    当所有的. e x eD L L文件都被映射到进程的地址空间之后,系统就可以开始执行 . e x e文件的启动代码。当. e x e文件被映射后,系统将负责所有的分页、缓冲和高速缓存的处理。例如,如果. e x e文件中的代码使它跳到一个尚未加载到内存的指令地址,那么就会出现一个错误。系统能够发现这个错误,并且自动将这页代码从该文件的映像加载到一个 R A M页面。然后,系统将这个R A M页面映射到进程的地址空间中的相应位置,并且让线程继续运行,就像这页代码已经加载了一样。当然,这一切是应用程序看不见的。当进程中的线程每次试图访问尚未加载到R A M的代码或数据时,该进程就会重复执行。

    17.1.1 可执行文件或D L L的多个实例不能共享静态数据

        当为正在运行的应用程序创建新进程时,系统将打开用于标识可执行文件映像的文件映射对象的另一个内存映射视图,并创建一个新进程对象和(为主线程创建)一个新线程对象。系统还要将新的进程I D和线程I D赋予这些对象。通过使用内存映射文件,同一个应用程序的多个正在运行的实例就能够共享R A M中的相同代码和数据。

        这里有一个小问题需要注意。进程使用的是一个平面地址空间。当编译和链接你的程序时,所有的代码和数据都被合并在一起,组成一个很大的结构。数据与代码被分开,但仅限于跟在. e x e文件中的代码后面的数据而已 。图1 7 - 1简单说明了应用程序的代码和数据究竟是如何加载到虚拟内存中,然后又被映射到应用程序的地址空间中的。

        作为一个例子,假设应用程序的第二个实例正在运行。系统只是将包含文件的代码和数据的虚拟内存页面映射到第二个应用程序的地址空间,如图1 7 - 2所示。

    如果应用程序的一个实例改变了驻留在数据页面中的某些全局变量,那么该应用程序的所有实例的内存内容都将改变。这种类型的改变可能带来灾难性的后果,因此是决不允许的。

    系统运用内存管理系统的c o p y - o n - w r i t e(写入时拷贝)特性来防止进行这种改变。每当应用程序尝试将数据写入它的内存映射文件时,系统就会抓住这种尝试,为包含应用程序尝试写入数据的内存页面分配一个新内存块,再拷贝该页面的内容,并允许该应用程序将数据写入这个新分配的内存块。结果,同一个应用程序的所有其他实例的运行都不会受到影响。图 1 7 - 3显示了当应用程序的第一个实例尝试改变数据页面2时出现的情况。


        系统分配一个新的虚拟内存页面,并且将数据页面2的内容拷贝到新页面中。第一个实例的地址空间发生了变更,这样,新数据页面就被映射到与原始地址页面相同位置上的地址空间中。这时系统就可以让进程修改全局变量,而不必担心改变同一个应用程序的另一个实例的数据。

    当应用程序被调试时,将会发生类似的事件。比如说,你正在运行一个应用程序的多个实例,并且只想调试其中的一个实例。你访问调试程序,在一行源代码中设置一个断点。调试程序修改了你的代码,将你的一个汇编语言指令改为能使调试程序自行激活的指令。因此你再次遇到了同样的问题。当调试程序修改代码时,它将导致应用程序的所有实例在修改后的汇编语言指令运行时激活该调试程序。为了解决这个问题,系统再次使用 c o p y - o n - w r i t e内存。当系统发现调试程序试图修改代码时,它就分配一个新内存块,将包含该指令的页面拷贝到新的内存页面中,并且允许调试程序修改页面拷贝中的代码。

        Windows 98 当一个进程被加载时,系统要查看文件映像的所有页面。系统立即为通常用c o p y - o n - w r i t e属性保护的那些页面提交页文件中的存储器。这些页面只是被提交而已,它们并不被访问。当文件映像中的页面被访问时,系统就加载相应的页面。如果该页面从来没有被修改,它就可以从内存中删除,并在必要时重新加载。但是,如果文件的页面被修改了,系统就将修改过的页面转到页文件中以前被提交的页面之一。

        Windows 2000Windows 98之间的行为特性的唯一差别,是在你加载一个模块的

    两个拷贝并且可写入的数据尚未被修改的时候显示出来的。在这种情况下,在Windows 2000下运行的进程能够共享数据,而在Windows 98下,每个进程都可以得到它自己的数据拷贝。如果只加载模块的一个拷贝,或者可写入的数据已经被修改(这是通常的情况),那么Windows 2000Windows 98的行为特性是完全相同的。

    17.1.2 在可执行文件或D L L的多个实例之间共享静态数据

        全局数据和静态数据不能被同一个 . e x eD L L文件的多个映像共享,这是个安全的默认设置。但是,在某些情况下,让一个 . e x e文件的多个映像共享一个变量的实例是非常有用和方便的。例如,Wi n d o w s没有提供任何简便的方法来确定用户是否在运行应用程序的多个实例。但是,如果能够让所有实例共享单个全局变量,那么这个全局变量就能够反映正在运行的实例的数量。当用户启动应用程序的一个实例时,新实例的线程能够简单地查看全局变量的值(它已经被另一个实例更新);如果这个数量大于 1,那么第二个实例就能够通知用户,该应用程序只有一个实例可以运行,而第二个实例将终止运行。

        本节将介绍一种方法,它允许你共享 . e x eD L L文件的所有实例的变量。不过在介绍这个方法之前,首先让我们介绍一些背景知识。

    每个. e x eD L L文件的映像都由许多节组成。按照规定,每个标准节的名字均以圆点开头。例如,当编译你的程序时,编译器会将所有代码放入一个名叫 . t e x t的节中。该编译器还将所有未经初始化的数据放入一个. b s s节,而已经初始化的所有数据则放入. d a t a节中。

    每一节都拥有与其相关的一组属性,这些属性如表1 7 - 1所示。


        表1 7 - 2显示了比较常见的一些节的名字,并且说明了每一节的作用。除了编译器和链接程序创建的标准节外,也可以在使用下面的命令进行编译时创建自己的节:

        当编译器对这个代码进行编译时,它创建一个新节,称为 S h a r e d,并将它在编译指示后面看到的所有已经初始化(i n i t i a l i z e d)的数据变量放入这个新节中。在上面这个例子中,变量放入S h a r e d节中。该变量后面的#pragma dataseg()一行告诉编译器停止将已经初始化的变量放入S h a r e d节,并且开始将它们放回到默认数据节中。需要记住的是,编译器只将已经初始化的变量放入新节中。例如,如果我从前面的代码段中删除初始化变量(如下面的代码所示),那么编译器将把该变量放入S h a r e d节以外的节中。


    Microsoft Visual C++编译器提供了一个A l l o c a t e说明符,使你可以将未经初始化的数据放入你希望的任何节中。请看下面的代码:

     

        上面的注释清楚地指明了指定的变量将被放入哪一节。若要使 A l l o c a t e声明的规则正确地起作用,那么首先必须创建节。如果删除前面这个代码中的第一行 #pragma data_seg,上面的代码将不进行编译。

        之所以将变量放入它们自己的节中,最常见的原因也许是要在 . e x eD L L文件的多个映像之间共享这些变量。按照默认设置,. e x eD L L文件的每个映像都有它自己的一组变量。然而,可以将你想在该模块的所有映像之间共享的任何变量组合到它自己的节中去。当给变量分组时,系统并不为. e x eD L L文件的每个映像创建新实例。

        仅仅告诉编译器将某些变量放入它们自己的节中,是不足以实现对这些变量的共享的。还必须告诉链接程序,某个节中的变量是需要加以共享的。若要进行这项操作,可以使用链接程序的命令行上的/ S E C T I O N开关:

    /SECTION:name,attributes

        在冒号的后面,放入你想要改变其属性的节的名字。在我们的例子中,我们想要改变

    S h a r e d节的属性。因此应该创建下面的链接程序开关:

    /SECTION:Shared,RWS

    在逗号的后面,我们设定了需要的属性。用 R代表 R E A DW代表 W E I T EE代表E X E C U T ES代表S H A R E D。上面的开关用于指明位于S h a r e d节中的数据是可以读取、写入和共享的数据。如果想要改变多个节的属性,必须多次设定 / S E C T I O N开关,也就是为你要改变属性的每个节设定一个/ S E C T I O N开关。

    也可以使用下面的句法将链接程序开关嵌入你的源代码中:

    #pragma comment(linker,”/SECTION:Shared,RWS”)

        这一行代码告诉编译器将上面的字符串嵌入名字为“ . d r e c t v e”的节。当链接程序将所有的. o b j模块组合在一起时,链接程序就要查看每个 . o b j模块的“. d r e c t v e”节,并且规定所有的字符串均作为命令行参数传递给该链接程序。我一直使用这种方法,因为它非常方便。如果将源代码文件移植到一个新项目中,不必记住在Visual C++Project Settings(项目设置)对话框中设置链接程序开关。

        虽然可以创建共享节,但是,由于两个原因, M i c r o s o f t并不鼓励你使用共享节。第一,用这种方法共享内存有可能破坏系统的安全。第二,共享变量意味着一个应用程序中的错误可能影响另一个应用程序的运行,因为它没有办法防止某个应用程序将数据随机写入一个数据块。

        假设你编写了两个应用程序,每个应用程序都要求用户输入一个口令。然而你又决定给应用程序添加一些特性,使用户操作起来更加方便些:如果在第二个应用程序启动运行时,用户正在运行其中的一个应用程序,那么第二个应用程序就可以查看共享内存的内容,以便获得用户的口令。这样,如果程序中的某一个已经被使用,那么用户就不必重新输入他的口令。

        这听起来没有什么问题。毕竟没有别的应用程序而只有你自己的应用程序加载了 D L L,并且知道到什么地方去查找包含在共享节中的口令。但是,黑客正在窥视着你的行动,如果他们想要得到你的口令,只需要编写一段很短的程序,加载到你的公司的 D L L文件中,然后监控共享内存块。当用户输入口令时,黑客的程序就能知道该用户的口令。

        黑客精心编制的程序也可能试图反复猜测用户的口令并将它们写入共享内存。一旦该程序猜测到正确的口令,它就能够将各种命令发送给两个应用程序中的一个。如果有一种办法只为某些应用程序赋予访问权,以便加载一个特定的 D L L,那么这个问题也许是可以解决的。但是目前还不行,因为任何程序都能够调用L o a d L i b r a r y函数来显式加载D L L

    17.1.3 AppInst示例应用程序

        书上是写了一个测试程序,不用书上的了,自己也简单的写了个测试程序,通过内存映射来实现共享。根据程序运行的内存映射原理,自己创建一个节,改变其属性达到共享数据的功能。

    17.2 内存映射数据文件

        操作系统使得内存能够将一个数据文件映射到进程的地址空间中。因此,对大量的数据进行操作是非常方便的。

    为了理解用这种方法来使用内存映射文件的功能,让我们看一看如何用 4种方法来实现一个程序,以便将文件中的所有字节的顺序进行倒序。

    17.2.1 方法1:一个文件,一个缓存

        第一种方法也是理论上最简单的方法,它需要分配足够大的内存块来存放整个文件。该文件被打开,它的内容被读入内存块,然后该文件被关闭。文件内容进入内存后,我们就可以对所有字节的顺序进行倒序,方法是将第一个字节倒腾为最后一个字节,第二个字节倒腾为倒数第二个字节,依次类推。这个倒腾操作将一直进行下去直到文件的中间位置。当所有的字节都已经倒腾之后,就可以重新打开该文件,并用内存块的内容来改写它的内容。

        这种方法实现起来非常容易,但是它有两个缺点。首先,必须分配一个与文件大小相同的内存块。如果文件比较小,那么这没有什么问题。但是如果文件非常大,比如说有 2 G B大,那该怎么办呢?一个3 2位的系统不允许应用程序提交那么大的物理内存块。因此大文件需要使用不同的方法。


        第二,如果进程在运行过程的中间被中断,也就是说当倒序后的字节被重新写入该文件时进程被中断,那么文件的内容就会遭到破坏。防止出现这种情况的最简单的方法是在对它的内容进行倒序之前先制作一个原始文件的拷贝。如果整个进程运行成功,那么可以删除该文件的拷贝。这种方法需要更多的磁盘空间。

    17.2.2 方法2:两个文件,一个缓存

        在第二种方法中,你打开现有的文件,并且在磁盘上创建一个长度为 0的新文件。然后分配一个比较小的内部缓存,比如说 8 KB。你找到离原始文件结尾还有 8 KB的位置,将这最后的8 KB读入缓存,将字节倒序,再将缓存中的内容写入新创建的文件。这个寻找、读入、倒序和写入的操作过程要反复进行,直到到达原始文件的开头。如果文件的长度不是 8 KB的倍数,那么必须进行某些特殊的处理。当原始文件完全处理完毕之后,将原始文件和新文件关闭,并删除原始文件。

        这种方法实现起来比第一种方法要复杂一些。它对内存的使用效率要高得多,因为它只需要分配一个8 KB的缓存块,但是它存在两个大问题。首先,它的处理速度比第一种方法要慢,原因是在每个循环操作过程中,在执行读入操作之前,必须对原始文件进行寻找操作。第二,这种方法可能要使用大量的硬盘空间。如果原始文件是 400 MB,那么随着进程的不断运行,新文件就会增大为400 MB。在原始文件被删除之前,两个文件总共需要占用 800 MB的磁盘空间。这比应该需要的空间大400 MB。由于存在这个缺点,因此引来了下一个方法。

    17.2.3 方法3:一个文件,两个缓存

        如果使用这个方法,那么我们假设程序初始化时分配了两个独立的 8 KB缓存。程序将文件的第一个8 KB读入一个缓存,再将文件的第二个8 KB 读入另一个缓存。然后进程将两个缓存的内容进行倒序,并将第一个缓存的内容写回文件的结尾处,将第二个缓存的内容写回同一个文件的开始处。每个迭代操作不断进行(以8 KB为单位,从文件的开始和结尾处移动文件块)。如果文件的长度不是16 KB的倍数,并且有两个8 KB的文件块相重叠,那么就需要进行一些特殊的处理。这种特殊处理比上一种方法中的特殊处理更加复杂,不过这难不倒经验丰富的编程员。

        与前面的两种方法相比,这种方法在节省硬盘空间方面有它的优点。由于所有内容都是从同一个文件读取并写入同一个文件,因此不需要增加额外的磁盘空间,至于内存的使用,这种方法也不错,它只需要使用16 KB的内存。当然,这种方法也许是最难实现的方法。与第一种方法一样,如果进程被中断,本方法会导致数据文件被破坏。

    下面让我们来看一看如何使用内存映射文件来完成这个过程。

    17.2.4 方法4:一个文件,零缓存

        当使用内存映射文件对文件内容进行倒序时,你打开该文件,然后告诉系统将虚拟地址空间的一个区域进行倒序。你告诉系统将文件的第一个字节映射到该保留区域的第一个字节。然后可以访问该虚拟内存的区域,就像它包含了这个文件一样。实际上,如果在文件的结尾处有一个单个0字节,那么只需要调用C运行期函数_ s t r r e v,就可以对文件中的数据进行倒序操作。

        这种方法的最大优点是,系统能够为你管理所有的文件缓存操作。不必分配任何内存,或者将文件数据加载到内存,也不必将数据重新写入该文件,或者释放任何内存块。但是,内存映射文件仍然可能出现因为电源故障之类的进程中断而造成数据被破坏的问题。

  • 相关阅读:
    struts的ognl.NoConversionPossible错误
    hibernate many-to-one
    向网页中插入百度地图
    hibernate多对一单向配置
    PHP+MySQL按时间段查询记录代码
    iis无法启动的解决办法-卸掉KB939373补丁
    跳转回上一页代码
    QQ在线客服代码
    SSH(Struts2 + Hibernate + Spring)嵌入 KindEditor(KE)
    php从数据库选取记录形成列表(首页调用)
  • 原文地址:https://www.cnblogs.com/csnd/p/12062133.html
Copyright © 2020-2023  润新知