17.1 使用内存映射文件--《Windows核心编程》

Windows 提供了以下三种机制来对内存进行操控
虚拟内存:最适合用来管理大量对象数组或者大型数据结构
内存映射文件:最适合用来管理大型数据流(通常是文件),以及在同一机器上运行的多个进程之间的共享数据。
堆:最适合用来管理大量的小型对象。

内存映射文件定义

内存映射文件允许开发人员预订一块地址空间,并给区域调拨物理存储器,这个物理存储器来自磁盘上已有的文件,而非系统的页交换文件。

内存映射文件主要用于以下三种情况:

  • 系统使用内存映射文件,以便加载和执行.exe和DLL文件。这可以大大节省页文件空间和应用程序启动运行所需的时间。
  • 可以使用内存映射文件来访问磁盘上的数据文件。这使你可以不必对文件执行I/O操作,并且可以不必对文件内容进行缓存。
  • 可以使用内存映射文件,使同一台计算机上运行的多个进程能够相互之间共享数据。Windows确实提供了其他一些方法,以便在进程之间进行数据 通信,但是这些方法都是使用内存映射文件来实现的,这使得内存映射文件成为单个计算机上的多个进程互相进行通信的最有效的方法。

 

映射到内存的可执行文件和DLL

一个线程在调用 CreateProcess 的时候,会执行以下步骤:

1) 系统找出在调用 CreateProcess 时设定的.exe文件。如果找不到 这个.exe文件,进程将无法创建,CreateProcess 将返回FALSE。
2) 系统创建一个新进程内核对象。
3) 系统为这个新进程创建一个私有地址空间。
4) 系统保留一个足够大的地址空间区域,用于存放该.exe文件。
5) 系统会对地址空间区域进行标注,表明该区域的后备物理存储器来自磁盘上的exe文件,而非系统的页交换文件。

当.exe文件被映射到进程的地址空间中之后,系统将访问.exe文件的一个部分,该部分列出了包含.exe文件中的代码要调用的函数的DLL文件。 然后,系统为每个DLL文件调用LoadLibrary函数,如果任何一个DLL需要更多的DLL,那么系统将调用LoadLibrary函数,以便加载这些DLL。每当调用LoadLibrary来加载一个DLL时,系统将执行的操作均类似上面的第4和第5个步骤。

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

 

同一个可执行文件或者DLL的多个实例不会共享静态数据

如果应用程序一个实例修改了数据页面的一些全局变量,系统会通过内存管理系统的写时复制来防止所有实例的内存被修改。

 

  


当系统创建一个进程时,会检查文件映像的所有页面。对那些通常需要用写时复制属性进行保护的页面,系统会立即从页交换文件中调拨存储器。但系统只是调拨这些页面,而不会实际载入页面的内容。当程序访问到文件映像中的一个页面时,系统会载入相应的页面。如果该页从未修改过,那么可以舍弃其中的内容并在需要时重新载入。但如果文件映像的该页面被修改过,那么系统必须把修改过的页面调换到页交换文件中。

 

在同一个可执行文件或者DLL的多个实例间共享静态数据

.exe 或者 DLL 文件映像有许多段组成:

  

段属性:

  

可以使用如下的代码创建一个命名段。并将已初始化好的变量保存在该段内。

#pragma data_seg("name")可以创建一个名为 name 的段,并将 #pragma 指示符之间的所有带有初始值的变量放到这个新段中。
#pragma data_seg() 告诉编译器后面的变量不用再放到 name 段中了,而是重新放回到默认的数据段中。

Microsoft Visual C++ 编译器提供了 allocate 声明符,可以允许将未初始化的数据放到任何想要放的段中。这里我测试了.data段,vs 提示要先使用 #pragma data_seg("") 创建.data段,我估计这个声明符只能放到我们创建的段中。书上也指出,为了让 allocate 正常工作,必须先创建对应的段。

// 只会将已初始化好的变量保存在该段内
#pragma data_seg("Shared")
LONG v1 = 0;		
#pragma data_seg()

// 也可以使用 C++ 编译器提供的 allocate 声明符将未初始化的数据放入到指定创建的段中
__declspec(allocate("Shared")) int v2;	


要在同一个.exe 或者 DLL 的多个实例间共享数据,可以将变量放到一个单独的段中,设置段具有 SHARE 属性。过程如下:

自定义一个节
把变量放到自定义节中(注意变量要初始化
告诉链接器,某个节中的变量是需要加以共享的

#pragma comment(linker, "/SECTION:...,RWS")

修改段的属性,请注意S属性,表明该节中的字节可以被多个实例共享(本属性能够有效地关闭copy-on-write机制)

#include "stdio.h"
#include "stdlib.h"
#include <iostream>
#include <tchar.h>
using namespace std;

#pragma comment(linker, "/SECTION:Shared,RWS") // 告诉链接程序,某个节中的变量是需要加以共享的(RWS:可读可写共享)

#pragma data_seg("Shared") // 创建一个称为“Shared”的节
int v1 = 0;
int v2;
#pragma data_seg()

// 也可以用allocate把某变量加入到自定义节中
__declspec(allocate("Shared")) int v3 = 0;

int _tmain(int argc, _TCHAR* argv[])
{
cout << "v1: " << ++v1 << " 有初始化,有加到自定义,且有共享属性的节中" << endl;
cout << "v2: " << ++v2 << " 没有初始化,没有加到自定义,且有共享属性的节中" << endl;
cout << "v3: " << ++v3 << " 利用__declspec(allocate(\"...\")) 把变量加到自定义,且有共享属性的节中" << endl;

system("pause");
return 0;
}

结果:对于具有共享属性的段中的变量 v1 和 v3 来说,多开程序的时候,是共享的。

  


请注意,Microsoft 并不鼓励使用共享段。一是因为可能不安全,二是因为一个应用程序中的错误可能会影响到另一个应用程序。


使用内存映射文件


要使用内存映射文件,需要执行下面三个步骤。
(1)创建或打开一个文件内核对象,该对象标识了我们想要用作内存映射文件的那个磁盘文件。
(2)创建一个文件映射内核对象(file-mapping kernel object)来告诉系统文件的大小以及我们打算如何访问文件。
(3)告诉系统把文件映射对象的部分或全部映射到进程的地址空间中。

用完内存映射文件之后,必须执行下面三个步骤来做清理工作。
(1)告诉系统从进程地址空间中取消对文件映射内核对象的映射。
(2)关闭文件映射内核对象。
(3)关闭文件内核对象。

一、创建或打开文件内核对象

我们总是通过调用CreateFile函数来创建或打开一个文件内核对象:

HANDLE CreateFile(
LPCTSTR lpFileName, // 想创建的或打开的文件的名称(既可以包含路径,也可以不包含路径)
DWORD dwDesiredAccess, // 如何访问文件内容;0(获得文件属性)、GENERIC_READ、GENERIC_WRITE、GENERIC_READ|GENERIC_WRITE
DWORD dwShareMode, // 如何共享这个文件
LPSECURITY_ATTRIBUTES lpSecurityAttributes, // 安全属性
DWORD dwCreationDispostion ,
DWORD dwFlagsAndAttributes,
HANDLE hTemplateFile);


关于第二参数:
0 表示该对象的设备查询访问权限。应用程序可以在不访问设备的情况下查询设备属性。
GENERIC_READ 该对象的读访问权限。可以从文件中读取数据并移动文件指针。结合GENERIC_WRITE进行读写访问。
GENERIC_WRITE 对象的写访问权限。数据可以写入文件,文件指针可以移动。结合GENERIC_READ进行读写访问。

关于第三参数:

0 想要独占对文件的访问,使其他进程无法打开同一文件。
FILE_SHARE_READ 其他任何试图通过 GENERIC_WRITE 来打开文件的操作都会失败。
FILE_SHARE_WRITE 其他任何试图通过 GENERIC_READ 来打开文件的操作都会失败。
FILE_SHARE_WRITE | FILE_SHARE_READ 其他任何试图打开文件的操作都会成功。


如果CreateFile成功地创建或打开了指定的文件,它会返回一个文件内核对象的句柄。否则,它返回INVALID_HANDLE_VALUE。


二、创建文件映射的内核对象

调用CreateFile是为了告诉操作系统文件映射的物理存储器所在的位置。传入的路径是文件在磁盘(也可以是网络或光盘)上所在的位置,文件映射对象的物理存储器来自该文件。现在我们必须告诉系统文件映射对象需要多大的物理存储器。为了达到这一目的,必须调CreateFileMapping:

这个函数为指定的文件创建一个命名或未命名的文件映射对象。

HANDLE CreateFileMapping(
HANDLE hFile, // 需要映射到进程空间的文件的句柄,
// 如果是通过文件映射对象进行进程间通信,这个参数可以传INVALID_HANDLE_VALUE
LPSECURITY_ATTRIBUTES lpFileMappingAttributes,
// 指向SECURITY_ATTRIBUTES结构体,它用于文件映射内核对象,一般传NULL
DWORD flProtect, // 映射的页面要指定的保护属性
DWORD dwMaximumSizeHigh, // 描述内存映射文件最大的大小:由SizeHigh(高32位)和SizeLow(低32位,如下)组成
DWORD dwMaximumSizeLow, // 描述内存映射文件最大的大小:由SizeHigh(高32位,如上)和SizeLow(低32位)组成
LPCTSTR lpName ); // 文件映射对象的名称

创建一个内存映射文件相当于先预定一块地址空间,然后给区域调拨物理存储器,内存映射文件的物理存储器来自于磁盘上的文件,而不是从系统的页交换文件中分配的。创建一个文件映射对象的时候,系统不会预订一块地址空间区域并把文件映射到该区域中


关于第三参数:

  

以下五种属性也可以与上面的参数按位或起来使用。

  • SEC_NOCACHE:告诉系统不要对内存映射的页面进行缓存。如果把数据写入文件写入文件,那么与通常的情况相比,系统会更频繁的更新磁盘上的文件。这个标志和 PAGE_NOCACHE 属性相似,主要是给驱动开发使用。
  • SEC_IMAGE:告诉系统要映射的文件是一个 PE 文件映像。当系统把文件映射到进程的地址空间的时候,系统会检查文件内容并决定给各个页面指定在保护属性。例如 PE 文件的代码段一般是 PAGE_EXECUTE_READ 属性来映射,数据段是 PAGE_READWRITE 来映射。SEC_IMAGE 相当于告诉系统要映射文件的映像并给页面设置相应的保护属性。
  • SEC_RESERVE 和 SEC_COMMIT:两个互斥的属性,也不适用于内存映射文件的情况。在17节"稀疏调拨的内存映射文件"中会涉及。
  • SEC_LARGE_PAGES:告诉系统要为内存映射文件使用大页面内存。当用于 PE 映像文件或者内存映射文件的时候,此属性才有效。
    注:使用大页面内存需满足以下条件
    (1)调用 CreateFileMapping 时必须指定 SEC_COMMIT 属性来调拨内存。
    (2)映射的大小必须大于 GetLargePageMinmum 函数返回值。
    (3)必须用 PAGE_READWRITE 保护属性定义映射。
    (4)用户必须具有并齐永内存中锁定页面的用户权限,否则 CreateFileMapping 会失败。


关于第四五参数:

CreateFileMapping 函数的主要目的是为了确保有足够的物理存储器可供文件映射对象使用。这两个参数告诉系统内存映射文件的最大大小,以字节为单位。由于 Windows支持的最大文件大小可以用64位整数表示,因此这里必须使用两个32位值,其中参数 dwMaximumSizeHigh 表示高32位,而参数dwMaximumSizeLow 则表示低32位。对小于4GB的文件来说, dwMaximumSizeHigh 始终为0。

 

三、将文件的数据映射到进程的地址空间

在创建了文件映射对象之后,还需要为文件的数据预订一块地址空间区域并将文件的数据作为物理存储器调拨给区域。这可以通过调用MapViewOfFile来实现。这个函数返回这块区域的地址。

这个函数将一个文件的视图映射到调用进程的地址空间。
LPVOID MapViewOfFile(
HANDLE hFileMappingObject, // 文件映射对象的句柄,调用Create/OpenFileMapping返回的
DWORD dwDesiredAccess, // 如何访问数据
DWORD dwFileOffsetHigh, // 关于将文件中哪个字节映射到视图中的第一个字节
DWORD dwFileOffsetLow, // 关于将文件中哪个字节映射到视图中的第一个字节
DWORD dwNumberOfBytesToMap ); // 把数据文件中的多少映射到视图中

关于第二参数:

  

关于第三四参数:

把文件的一个视图映射到进程的地址空间中时,必须告诉系统两件事情。第一,我们必须告诉系统应该把数据文件中的哪个字节映射到视图中的第一个字节。这是通过参数dwFileOffsetHigh和dwFileOfsetLow来指定的。由于Windows支持的文件大小最大可以到16 EB,因此编移量也必须用64位值来指定,其中高32位的部分由dwFileOffsetHigh表示,而低32位的部分则由dwFileOffsetLow表示。注意,文件的偏移量必须是系统分配粒度的整数倍。(到目前为止,在所有版本的Windows中,分配粒度全部都是64 KB)

 

四、从进程的地址空间撤销对文件数据的映射

不再需要把文件的数据映射到进程的地址空间中时,可以调用下面的函数来释放内存区域:

  BOOL UnmapviewofFile (PVOID pvBaseAddress);

这个函数唯一的参数pvBaseAddress用来指定区域的基地址,它必须和MapViewOfFile的返回值相同。确定调用UnmapViewOfFile,如果不这样做,在进程终止之前,区域将得不,到释放。在调用MapViewOFile的时候,系统总是会在进程的地址空间中预订一块新的区域,它不会释放之前预订的任何区域。

请注意!如果视图最初使用 FIEL_MAP_COPY 标志映射的,那么对文件数据的任何修改实际上是对保存在页交换文件中的文件数据副本的修改。这种情况下调用 UnmapviewofFile,函数不会对磁盘文件更新,但会释放页交换文件中的副本,从而导致数据丢失。

 

如果需要确保所做的修改已经被写入到磁盘中,那么可以调用 FlushViewOfFile ,这个函数用来强制系统把部分或全部修改过的数据写回到磁盘中。

BOOL FlushViewOfFile(
LPCVOID lpBaseAddress, // 内存映射文件的视图中第一个字节的地址,函数会将传入的地址向下(小)取整到页面粒度大小(64K)整数倍。
DWORD dwNumberOfBytesToFlush ); // 想刷新的字节数,向上(大)取整到页面大小(4K)整数倍。

对于存储器是在网络上的内存映射文件来说, FlushViewOfFile 能够保证文件的数据已经从工作站写入存储器。但是 FlushViewOfFile 不能保证正在共享文件的服务器已经将数据写入远程磁盘

 

五、关闭文件映射对象和文件对象

调用CloseHandle关闭两个句柄

 

测试程序:

#include"stdio.h"
#include<stdio.h>
#include<Windows.h>

DWORD MappingFileRead(LPSTR lpcFile)
{
HANDLE hFile;
HANDLE hMapFile;
DWORD dwFileMapSize;
LPVOID lpAddr;

// 1.得到文件句柄
hFile = CreateFileA(lpcFile,
GENERIC_READ | GENERIC_WRITE,
0,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL
);
if (hFile == INVALID_HANDLE_VALUE)
{
printf("CreateFile 失败:%d \n", GetLastError());
return 0;
}

// 2.创建FileMapping对象
hMapFile = CreateFileMapping(hFile,
NULL,
PAGE_READWRITE,
0,
0,
NULL
);
if (hMapFile == NULL)
{
printf("CreateFileMapping 失败:%d \n ", GetLastError());
CloseHandle(hFile);
return 0;
}
// 3.映射到虚拟内存
lpAddr = MapViewOfFile(hMapFile,
FILE_MAP_COPY,// FILE_MAP_ALL_ACCESS FILE_MAP_COPY
0, 0, 0);

// 4.读取文件
printf("%s",lpAddr);

// 6.关闭资源
UnmapViewOfFile(lpAddr);
CloseHandle(hMapFile);
CloseHandle(hFile);
}




DWORD MappingFileWrite(LPSTR lpcFile)
{
HANDLE hFile;
HANDLE hMapFile;
DWORD dwFileMapSize;
LPVOID lpAddr;

// 1.得到文件句柄
hFile = CreateFileA(lpcFile,
GENERIC_READ | GENERIC_WRITE,
0,
NULL,
OPEN_EXISTING,// 打开现有文件
FILE_ATTRIBUTE_NORMAL,
NULL
);
if (hFile == INVALID_HANDLE_VALUE)
{
printf("CreateFile 失败:%d \n", GetLastError());
return 0;
}

// 2.创建FileMapping对象
hMapFile = CreateFileMapping(hFile,
NULL,
PAGE_READWRITE,
0,
0,
NULL
);
if (hMapFile == NULL)
{
printf("CreateFileMapping 失败:%d \n ", GetLastError());
CloseHandle(hFile);
return 0;
}
// 3.映射到虚拟内存
lpAddr = MapViewOfFile(hMapFile,
FILE_MAP_READ|FILE_MAP_WRITE,// FILE_MAP_ALL_ACCESS FILE_MAP_COPY
0, 0, 0);

// 设置文件尾,使文件强制变大
LARGE_INTEGER liDistanceToMove;
liDistanceToMove.QuadPart = 0x200;
SetFilePointerEx(hFile, liDistanceToMove, NULL, FILE_BEGIN);
SetEndOfFile(hFile);

// 5.写文件
char WriteIn[] = "写入测试"; // 写入位置超出文件尾长度会失败,因此上面设置文件尾,改变文件大小。
memcpy((LPVOID)((INT64)lpAddr+0x120), WriteIn, strlen(WriteIn) + 1);
printf("写入: %s\n", WriteIn);

// 强制更新缓存
FlushViewOfFile(((PDWORD)lpAddr), 4);

// 6.关闭资源
UnmapViewOfFile(lpAddr);
CloseHandle(hMapFile);
CloseHandle(hFile);
}


int main(int argc, char* argv[])

{
MappingFileRead((LPSTR)"D:\\编程学习课\\书\\test.txt"); // 读文件测试

MappingFileWrite((LPSTR)"D:\\编程学习课\\书\\test.txt"); // 写文件测试

return 0;
}

 

posted @ 2022-11-21 11:51  人类观察者  阅读(294)  评论(0编辑  收藏  举报