Windows核心编程:内存映射文件

作者:shenzi

链接:http://blog.csdn.net/shenzi

Windows核心编程:内存映射文件
    与 虚拟内存相似,内存映射文件允许开发人员预定一块地址空间区域并给区域调拨物理存储器。不同之处在于内存映射文件的物理存储器来自磁盘上已有的文件,而不 是来自系统的页交换文件。一旦把文件映射到地址空间,我们就可以对它进行访问,就好像整个文件都已经在被载入内存一样。
     内存映射主要用于以下三种情况:
  • 系统使用内存映射文件来载入并运行.exe和动态链接库(DLL)。这大量节省了页交换文件的空间以及应用程序启动的时间。
  • 开发人员可以使用内存映射文件来访问磁盘上的数据文件。这使得我们可以避免直接对文件进行I/O操作和对文件内容进行缓存。
  • 通过使用内存映射文件,我们可以在同一台机器的不同进程间共享数据。Windows的确提供了其它一些方法来在进程间传送数据,但这些方法都是通过内存映射文件来实现的。因此,如果在同一台机器的不同进程之间共享数据,内存映射文件时最高效的方法。    
1.映射到内存的可执行文件和DLL
    当一个线程在调用CreateProcess的时候,系统会执行以下步骤:
  • 系统会先确定CreateProcess所指定的可执行文件所在的位置。如果无法找到该.exe文件,那么系统将不会创建进程,这时CreateProcess会返回FALSE.
  • 系统创建一个新的进程内核对象。
  • 系统为新进程创建一个私有地址空间。
  • 系统预定一块足够大的地址空间来容纳.exe。待预定的地址空间区域的具体位置已经在.exe文件中指定。 默认情况下,.exe文件的基地址是0x00400000。但是,只需在构建应用程序的.exe文件时使用/BASE连接器开关,我们就可以给自己的应用程序指定一个不同的地址。
  • 系统会对地址空间区域进行标注,表明该区域的后备物理存储器来自磁盘上的.exe文件,而并非来自系统的页交换文件。   

    当 系统把.exe文件映射到进程的地址空间之后,会访问.exe文件中一个段,这个段列出了一些DLL文件,它们包含该.exe文件调用到的函数。然后系统 会调用LoadLibrary来载入每个DLL,如果哪个DLL需要用到其它DLL,那么系统同样会调用其它DLL,那么系统同样会调用 LoadLibrary来载入相应的DLL。系统每次调用LoadLibrary来载入DLL的时候,执行的操作与刚才列出的后两步相似。

  • 系统会预定一块足够大的地址空间区域来容纳DLL文件。待预定的的地址空间区域的具体位置已经在DLL文件中指定。所有与Windows一起发布的系统DLL都有不同的基地址,这样即使把它们载入到同一个地址空间,也不会发生重叠。
  • 如 果系统无法在DLL文件指定的基地址处预定区域,这可能是因为该区域已经被另一个DLL或.exe占用,也可能是区域不够大,这时系统会尝试在另一个地址 来为DLL预定地址空间区域。如果DLL不包含重定位信息(当使用连接器的/FIXED开关来构建DLL),这意味着DLL必须被载入到指定的基地址,否 则无法被载入。如果对DLL执行重定位,重定位不仅需要占用页交换文件中额外的存储空间,而且会增加载入DLL所需的时间。
  • 系统会对地址空间区域进行标注,表明该区域的后备物理存储器来自磁盘上的DLL文件,而并非来自页交换文件。如果由于Windows不能将DLL载入到指定的基地址而必须重定位的话,那么系统还会另外进行标注,表明DLL中有一部分物理存储器映射到了页交换文件。   

    把所有的.exe文件和DLL文件都映射到进程的地址空间之后,系统会开始执行.exe文件的启动代码。当完成对.exe文件的映射后,系统会负责所有的换页(paging)、缓存(buffering)、以及高速缓存(caching)操作。
同一个可执行文件或DLL的多个实例不会共享静态数据
    如 果一个应用程序已经在运行,那么当我们为这个应用程序创建一个新的进程时,系统只不过是打开另一个内存映射试图(memory-mapped view),创建一个新的进程对象,并为主线程创建一个新的线程对象。这个新打开的内存映射视图隶属一个文件映射对象(file-mapping object),后者用来标识可执行文件的映像。系统同时给进程对象和线程对象分别制定新的进程ID和线程ID。通过使用内存映射文件,同一个应用程序的 多个实例可以共享内存中的代码和数据。
    下图是一个简单的视图,它描述了如何把应用程序中的代码和数据载入到虚拟内存,并将它们映射到地址空间中。

Image from book

    假设应用程序的第二个实例现在开始运行。这时系统只不过是把包含应用程序代码和数据的虚拟内存页面映射到第二个实例的地址空间中,如下图所示:
    
     如果一个应用程序的一个实例修改了数据页面中的一些全局变量,那么有用程序所有实例的内容都会被修改。由于这种类型的修改可能会导致灾难性的结构,因此必须避免。
     系统通过内存管理器的写时保护(copy-on-write)特性来防止这种情况的发生。任何时候当应用程序试图写入内存映射文件的时候,系统会首先截获 此类尝试,接着为应用程序试图写入的内存页面分配一块新的内存,然后复制页面内容,最后让应用程序写入到刚分配的内存块。下图描述了当应用程序的第一个实 例试图修改数据页面2中的一个全局变量时,会产生怎样的结果:

    系 统先分配了一页新的虚拟内存(New page),然后把数据页面2中的内容复制到新压面中。系统会更新第一个实例的地址空间,这样新的数据页面就会和原始数据页面一样,映射到进程地址空间中 的同一位置。现在系统不仅可以让进程修改全局变量的值,而且也不用担心会修改到同一个应用程序的其它实例的数据了。
    说 明:当系统创建一个进程时,会检查文件映像的所有页面。对那些通常需要用写时复制属性进行保护的页面,系统会立即从页交换文件中调拨存储器。但系统只是调 拨这些页面,而不会实际载入页面的内容。当程序访问到文件映像中的一个页面时,系统会载入相应的页面。如果该页从未修改过,那么可以舍弃其中的内容并在需 要时重新载入。但如果文件映像的该页面被修改过,那么系统必须把修改过的页面调换到页交换文件中。
在同一个可执行文件或DLL的多个实例间共享静态数据
    默认情况下,同一个.exe文件或DLL的多个实例之间不会共享全局或静态数据,这样的设计是最保险的。但是,有些情况下在同一个.exe文件或DLL的多个实例之间共享同一个变量不仅有用,而且方便。
     每个.exe文件或DLL文件映像由许多段组成。按照惯例,每个标准的段名称都以点号开始。

表1:可执行文件常用段
段 命

 

目    的

 

.bss

未经初始化的数据

.CRT

只读的C运行时数据

.data

已初始化的数据

.debug

调试信息

.didata

延迟导入的名字表(Delay imported names table)

.edata

导出的名字表(Exported names table)

.idata

导入的名字表(Imported names table)

.rdata

只读的运行时数据(Read-only run-time data)

.reloc

重定位表信息(Relocation table information)

.rsrc

资源(Resources)

.text

.exe文件或DLL的代码(.exe's or DLL's code)

.textbss

当启用增量链接(Incremental Linking)选项时,由C++编译器生成

.tls

线程本地存储(Thread-local storage)

.xdata

异常处理表(Exception-handling table)

    每个段都有与之相关联的属性,如表2所示:

    表2:段的属性
属  性

 

含   义

 

READ

可以从该段读取数据

WRITE

可以从该段写入数据

EXECUTE

可以执行该段的内容

SHARED

该段的内容为多个实例所共享 (这个属性事实上关闭了写时复制机制)

    除了使用编译器和连接器所创建的标准段之外,我们还可以在编译的时候使用下面的编译器指示符来创建自己的段:
     #pragma data_seg("sectionname")

    举个例子,我们可以用下面的代码来创建一个名为“Shared”的段,它只包含一个LONG变量:
    
#pragma data_seg("Shared")
    LONG g_lInstanceCount;
    #pragma data_seg()

    需要谨记的极其重要的一点是,编译器只会将已初始化的变量保存在这个段中。
    Visual C++编译器也提供了一个allocate说明符,它允许我们将未经初始化的数据放到任何我们想要放的段中。
    下面的例子显示用法:
    // Create Shared section & have compiler place initialized data in it.
    #pragma data_seg("Shared")
    // Initialized, in Shared section
    int a = 0;
    // Uninitialized, not in Shared section
    int b;
    // Have compiler stop placing initialized data in Shared section.
    #pragma data_seg()
    // Initialized, in Shared section
    __declspec(allocate("Shared")) int c = 0;
    // Uninitialized, in Shared section
    __declspec(allocate("Shared")) int d;
    // Initialized, not in Shared section
    int e = 0;
    // Uninitialized, not in Shared section
    int f;

2.映射到内存的数据文件
    Windows操作系统使我们能够把数据文件映射到进程的地址空间中,这样以来,对大型数据流进行操控就非常容易。
    颠倒文件内容的例子四种方法的比较。。。
     内存映射文件方法:
     使用内存映射文件来颠倒文件内容时,我们先打开文件并向系统预定一块虚拟地址空间区域。接着让系统把文件的第一个字节映射到该区域的第一个字节。然后就可 以访问这块虚拟内存区域,就好像它实际上包含了文件一样。事实上,如果颠倒得是一个文本文件,而且文件末尾的字节为0,则可以把这个文件当作内存中的一个 字符串来处理,在这种情况下,直接调用C运行库函数_tcsrev就能颠倒文件中的数据。
     这种方法的最大优点在于让系统为我们处理所有与文件缓存有关的操作。我们不必再分配分配任何内存,把文件中的数据载入内存,把数据写回文件、以及释放内存块。但如果操作过程被中断,仍然可能导致数据被破坏。
3.使用内存映射文件
    要使用内存映射文件,需要执行下面三个步骤:
   (1)创建或打开一个文件内核对象,该对象标识了我们想要用作内存映射文件的那个磁盘文件。
    (2)创建一个文件映射内核对象(file-mapping kernel object)来告诉系统文件的大小以及我们打算如何访问文件。
    (3)告诉系统把文件映射对象的部分或全部映射到进程的地址空间中。
     用完内存映射文件之后,必须执行下面三个步骤来做清理工作:
    (1)告诉系统从进程地址空间中取消对文件映射内核对象的映射。
    (2)关闭文件映射内核对象。
    (3)关闭文件内核对象。
第1步:创建或打开文件内核对象
    我们总是通过调用CreateFile函数来创建或打开一个文件内核对象:
   
HANDLE CreateFile(
    PCSTR pszFileName,
    DWORD dwDesiredAccess,
    DWORD dwShareMode,
    PSECURITY_ATTRIBUTES psa,
    DWORD dwCreationDisposition,
    DWORD dwFlagsAndAttributes,
    HANDLE hTemplateFile);

    在创建或打开一个作为内存映射文件来使用的文件时,应该根据我们打算如何访问文件来选择一个或一组最合适的标志。对内存映射文件来说,必须以只读方式或读/写方式来打开文件,因此我们可以为 dwDesiredAccess 相应地使用GERERIC_READ或GENERIC_READ | GENERIC_WRIITE。
第2步:创建文件映射内核对象
    调用CreateFile是为了告诉操作系统文件映射的物理存储器所在的位置。传入的路径是文件在磁盘上所在的位置,文件映射对象的物理存储器来自该文件。 现在我们必须告诉系统文件映射对象需要多大的物理存储器。为了达到这一目的,必须调用CreateFileMapping:
    
HANDLE CreateFileMapping(
    HANDLE hFile,
    PSECURITY_ATTRIBUTES psa,
    DWORD fdwProtect,
    DWORD dwMaximumSizeHigh,
    DWORD
dwMaximumSizeHigh ,
    PCTSTR pszName);

    hFile 是需要映射到进程地址空间的文件的句柄。该句柄时前面调用CreateFile的时候返回的。
   
psa 是一个指向SECURITY_ATTRIBUTES结构的指针,一般来说传NULL就可以了。
   
fdwProtect 指定保护属性。
   
dwMaximumSizeHigh dwMaximumSizeHigh 告诉系统内存映射文件的最大大小,以字节为单位。如果想要用当前的文件大小创建一个文件映射对象,那么只要穿0给这两个参数就可以了。
第3步:将文件的数据映射到进程的地址空间
    在创建了文件映射对象之后,还需要为文件的数据预定一块地址空间区域并将文件的数据作为物理存储器调拨给区域。 这可以通过调用MapViewOfFile来实现:
   
PVOID MapViewOfFile(
    HANDLE hFileMappingObject,
    DWORD dwDesiredAccess,
    DWORD dwFileOffsetHigh,
    DWORD dwFileOffsetLow,
    SIZE_T dwNumberOfBytesToMap);

    hFileMappingObject 是文件映射对象的句柄,它是之前调用CreateFileMapping或OpenFileFMapping函数时返回的。
     dwDesiredAccess 表示想要如何访问数据。

保护属性

含  义

 

FILE_MAP_WRITE

可以读取和写入文件。在调用 CreateFileMapping 时必须传 PAGE_READWRITE 保护属性。

FILE_MAP_READ

可以读取文件。在调用 CreateFileMapping 时可以传 PAGE_READONLY PAGE_ READWRITE 保护属性。

FILE_MAP_ALL_ACCESS

等同于 FILE_MAP_WRITE | FILE_MAP_READ | FILE_MAP_ COPY .

FILE_MAP_COPY

可以读取和写入文件。 写入操作会导致系统为该页面创建一份副本。在调用CreateFileMapping 时必须传 PAGE_WRITECOPY 保护属性。

FILE_MAP_EXECUTE

可以将文件中的数据作为代码来执行。在调用 CreateFileMapping 时可以传PAGE_EXECUTE_READWRITE PAGE_EXECUTE_READ 保护属性。

    剩下的三个参数与预定区域和给区域调拨物理存储器有关。当我们把一个文件映射到进程的地址空间中的时候,不必一下子映射整个文件。可以每次只把文件的一小部分映射到地址空间中。文件中被映射到进程地址空间中的部分被称为试图 (view)。
    把文件的一个视图映射到进程地址空间中时,必须告诉系统两件事情。第一,必须告诉系统应该把数据文件中的哪个字节映射到试图中的第一个字节。这是通过参数 dwFileOffsetHigh dwFileOffsetLow 来指定的。第二,必须告诉系统要把数据文件中的多少映射到地址空间中去。参数 dwNumberOfBytesToMap 用来指定大小。如果指定的大小为0,系统会试图把文件中从偏移量开始到文件末尾的所有部分都映射到试图中。
第4步:从进程的地址空间撤销对文件数据的映射
    不再需要把文件的数据映射到进程的地址空间中时,可以调用下面的函数来释放内存区域:
   BOOL UnmapViewOfFile(PVOID pvBaseAddress);
    这个函数唯一的参数 pvBaseAddress 用来指定区域的基地址,它必须和MapViewOfFile的返回值相同。确定调用UnmapViewOfFile,如果不这样做,在进程终止之前,区域将得不到释放。
     出于速度上的考虑,系统会对文件数据的页面进行缓存处理,这样在处理文件映射试图的时候就不需要随时更新磁盘上的文件。如果需要确保所做的修改已经被写入 到磁盘中,那么可以调用FlushViewOfFile,这个函数用来强制系统把部分或全部修改过的数据写回到磁盘中:
    
BOOL FlushViewOfFile(
    PVOID pvAddress,
    SIZE_T dwNumberOfBytesToFlush);

    第一个参数是内存映射文件的视图中第一个字节的地址。函数会把传入的地址向下取整到页面大小的整数倍。第二个参数表示想要刷新的字节数。系统会把这个数值向上取整,使总的字节数称为页面大小的整数倍。
    UnmapViewOfFile 有个特征需要牢记。如果视图最初是用FILE_MAP_COPY标志映射的,那么对文件数据的任何修改实际上是对保存在页交换文件中的数据副本的修改。如 果在这种情况下调用UnmapViewOfFile,函数不需要对磁盘文件进行任何更新,但它会释放页交换文件中的页面,从而导致数据丢失。如果希望保留 修改过的数据,必须自己进行额外的操作。
第5步和第六步:关闭文件映射对象和文件对象
    不用说,我们必须关闭自己打开的任何内核对象,不然会在进程继续运行的过程中引起资源泄漏。
     HANDLE hFile = CreateFile(...);
    HANDLE hFileMapping = CreateFileMapping(hFile, ...);
    PVOID pvFile = MapViewOfFile(hFileMapping, ...);

    // Use the memory-mapped file.

    UnmapViewOfFile(pvFile);
    CloseHandle(hFileMapping);
    CloseHandle(hFile);

4.用内存映射文件来处理大文件
    如何将一个16EB的文件映射到一个较小的地址空间中?
    一开始,我们只映射文件开头的部分到视图中,完成对文件的第一个视图的访问后,我们可以撤销对文件这一部分的远射,然后把文件的另一部分映射到视图中。我们一直重复这个过程,直到完成对整个文件的访问。
5.内存映射文件和一致性
    系 统允许我们把同一个文件中的数据映射到多个视图中。只要我们映射的是同一个文件映射对象,那么系统会确保各视图中的数据是一致的。这是因为即使该页面被多 次映射到进程的虚拟地址空间中,系统也还是在同一个内存页面中保存被映射的数据。如果多个进程把同一个数据文件映射到多个视图中,那么数据也仍然会是一致 的,这是因为数据文件中的每个页面在内存中只有一份——但这些内存页面会被映射到多个进程的地址空间中。
     说明:Windows允许我们以同一个数据文件为后备存储器来创建多个文件映射对象。Windows并不保证这些不同的文件映射对象的各个视图是一致的。系统只保证在同一文件映射对象的多个视图间保持一致。
6.给内存映射文件制定基地址

    在调用VirtualAlloc的时候,我们可以建议系统在指定的基地址预定地址空间。同样,也可以用MapViewOfFileEx函数来代替MapViewOfFile函数,这样就能建议系统把文件映射到指定的地址:
    
PVOID MapViewOfFileEx(
    HANDLE hFileMappingObject,
    DWORD dwDesiredAccess,
    DWORD dwFileOffsetHigh,
    DWORD dwFileOffsetLow,
    SIZE_T dwNumberOfBytesToMap,
    PVOID pvBaseAddress);

    除了最后一个参数 pvBaseAddress ,这个函数的所有参数和返回值都与MapViewOfFile函数完全相同。我们可以用这个参数来给要映射的文件制定一个目标地址。同VirtualAlloc函数一样,指定的目标地址必须是分配粒度的整数倍。
    使用内存映射文件跨进程共享数据的时候,MapViewOfFileEx非常有用。给共享的数据文件在不同的进程指定相同的基地址。
7.内存映射文件的实现细节

    在进程能够从自己的地址空间中访问内存映射文件的数据之前,Windows要求进程先调用MapViewOfFile。不同进程调用MapViewOfFile时返回的内存地址,很可能是不同的。
8.用内存映射文件在进程间共享数据
    Windows提供了多种机制,使得应用程序之间能够快速、方便地共享数据和信息。在Windows中,在同一台机器上共享数据的最底层的机制就是内存映射文件。
    让 我们来看一个例子:启动应用程序。当一个应用程序启动时,系统会先调用CreateFile来打开磁盘上的.exe文件。接着系统会调用 CreateFileMapping来创建文件映射对象。最后系统会以新创建的进程的名义调用MapViewOfFileEx(并传入SEC_IMAGE 标志),这样就把.exe文件映射到了进程的地址空间中。值所以调用MapViewOfFileEx而不是MapViewOfFile,是为了把文件映射 到指定的基地址,这个基地址保存在.exe的PE文件头中。系统然后创建进程的主线程,在映射得到的视图中取得可执行代码的第一个字节的地址,把该地址放 到线程的指令指针中,最后让CPU开始执行其中的代码。
     如 果用户启动同一个应用程序的第二个实例,那么系统会发现该.exe文件已经有一个文件映射对象,因此就不会再创建一个新的文件对象或文件映射对象。取而代 之的是,系统会再次映射.exe文件的一个视图,但这次是在新创建的进程的地址空间中。至此,系统已经把同一个文件同时映射到了两个地址空间中。显然,由 于物理内存中包含.exe文件可执行代码的那些页面为两个进程所共享,因此内存的使用率更高。
9.以页交换文件为后备存储器的内存映射文件
    Microsoft加入了相应的支持,让系统能够创建以页交换文件为后备存储器的内存映射文件,这样就不需要用磁盘上专门的文件来作为后备存储器了。这种方法和为磁盘文件创建内存映射文件的方法几乎完全相同,甚至更简单。 一方面,由于不必创建或打开一个专门的磁盘文件,因此不需要调用CreateFile。我们只需要像原来那样调用CreateFileMapping,并将INVALID_HANDLE_VALUE作为hFile参数传入。这告诉系统我们创建的文件映射对象的物理存储器不是磁盘上的文件,而是希望系统从页交换文件中调拨物理存储器。 所需分配的存储器大小由CreateFileMapping的dwMaximumSizeHigh和dwMaximumSizeLow参数决定。

    






你可能感兴趣的:(Windows核心编程:内存映射文件)