第17章 内存映射文件
对于应用程序这种类型的文件,自然也要有被打开和关闭这些操作,只不过有两种方式值得争议:是直接打开文件读写它最后关闭、还是提供一种缓存的做法在文件不同部位操作呢? Windows的方案是一种两全其美的方法,叫内存映射文件
。
先保留一个地址空间的区域,并将物理存储器提交给该区域,这和虚拟内存
一样。只不过内存映射文件没有系统的页文件中转,而是直接来自物理存储器。一旦文件被映射,就可以访问它,就像它已经加载到内存中一样。
内存映射文件大致有三种用法:
- 加载
exe
和dll
可以大大的节省页文件空间和启动应用程序启动运行所需要的时间。
17.1 内存映射的可执行文件和DLL文件
首先当线程调用CreateProcess
时,系统会这么办:
- 找出函数的参数中设定的
exe
文件,找不到就返回FALSE。 - 创建一个新的内核对象。
- 为新的进程创建一个私有的地址空间。
- 保留一个足够大的地址空间区域存放
exe
和DLL文件
文件。然后加载到基地址0x400000(x86的情况,也可以使用链接选项/BASE设置)。
exe
文件被映射到进程的地址空间后,系统还要访问exe
文件的关于需要导入的DLL文件,使用LoadLibrary
函数加载这些DLL
。- 系统又要保存足够大的区域存放
DLL文件
,基地址默认是0x10000000
。由于系统提供的标准DLL文件
都有不同的基地址,这样它们加载就不会重叠。 - 如果由于某种原因
DLL文件
无法加载到默认的基地址,系统就会找另一个地址加载。但这是很不好的情况,首先这个时候如果系统没有再定位信息,DLL
就无法加载,再则系统要在DLL
中执行某些重定位操作,这需要更多的存储器,也增加了加载DLL文件
的时间。 - 虽然支持已经保留区域物理存储器是在磁盘的
DLL文件
也不是系统的页文件,但是如果DLL文件
无法加载到默认的基地址,系统就必须做重定位操作,DLL文件
中的某些物理存储器也已经映射到页文件中。
- 系统又要保存足够大的区域存放
如果还是由于某些原因调用CreateProcess
仍然失败,返回FALSE,那么系统会显示一个信息框,并释放进程的地址空间和进程对象。可以使用GetLastError
函数获取失败信息。
如果都没错,exe
的启动代码就可以被执行,系统开始管理所有的分页、缓存和高速缓冲的处理,例如缺页异常,这是掩盖在程序内部的,可以重复执行。
17.1.1 可执行文件或DLL的多个实例不能共享静态数据
其实就是说一个写时拷贝(copy-on-wriite)这个机制。至于起因,就是因为旧旧进程A创建新的进程B被后,根据上面的描述,新的内存映射文件也被创建。而如果A修改这个内存映射文件中的全局变量,B中相应的全局变量也会被修改。
所以就需要写时拷贝的机制来防止这种情况了。这样一旦应用程序尝试写入它的内存映射文件,系统就会注意到这种情况,就会分配一个新的内存块用来支持写入操作,当然这个内存块是位于被写入的进程的内存映像中的。 如此就可以防止当前进程的全局变量被其他进程篡改了。
17.1.2 在可执行文件或DLL的多个实例之间共享静态数据
但是多个实例共享数据仍然是很常见的需求。所以还是有一种方法,允许可执行文件中的变量成为共享的状态。这个办法就是说明变量是被链接到一个可以被读写的节里。原理如下:
每个可执行文件都由很多节组成,代码存在text节,数据则分成已初始化(.data)和未初始化(.bss)两种。
而每一个节都有其相关的属性:
属性 含义 READ 该节中的字节可以读取 WRITE 该节中的字节可以写入 EXECUTE 该节中的字节可以执行 SHARED 该节中的字节可以被多个实例共享(此属性可以有效地关闭copy-on-write机制) 使用
Visual Studio
的DumpBin
程序,带上/Headers
开关,可以查看可执行程序的各个节的内容:
节名 作用 .bss 未经初始化的数据 .CRT C运行时只读数据 .data 已经初始化的数据 .debug 调试信息 .didata 延迟输入文件名表 .edata 输出文件名表 .idata 输入文件名表 .rdata 运行时只读信息 .reloc 重定位表信息 .rsrc 资源 .text exe或者dll文件的代码 .tls 线程的本地存储 .xdata 异常处理表 // 创建一个名称为 Shared 的节,包含一个LONG值 #pragma data_seg("Shared") // 节的内容的起始标记 LONG g_lInstanceCount = 0; // 已经初始化 int b; // 未经初始化 #pragma data_seg() // 节的内容的结束标记
这样当编译器对这个代码进行编译时,就会创建这个节,并将其后的全局变量放入了这个节的声明中。代码中的第5行告诉编译器停止,将变量放入这个新节中。
Visual C++编译器提供了一个Allocate说明符,有同样的效用:__declspce(allocate("Shared")) int c = 0; // 已初始化 __declspce(allocate("Shared")) int d; // 未初始化 int e = 0; // 已初始化 int f; // 未初始化
有了上面的操作,还差一步能让这些变量共享。链接程序也需要知道,哪些节中的变量是需要共享的。通过链接命令行的
/SECTION
开关才能做到:/SECTION:name,attributes // 实例 /SECTION:Shared,RWS // R代表Read,W代表Write,S代表Shared #pragma comment(linker, "/SECTION:Shared,RWS")
虽然共享节可以有这个优势,但是也是有风险的。有两个原因:第一,共享内存破坏了系统的安全;第二,共享变量一个程序的错误可能影响到另一个程序的运行。黑客只需要监视到这个举措,写一段很短的程序,加载到你的产品的DLL中,就能监控共享数据。这样一旦用户输入口令,就有机会被他们截获。
17.1.3 AppInst示例程序
清单17-1列出的AppInst示例显示了应用程序如何能知道每次有多少个应用程序的实例在运行。
一旦这个应用程序的新实例开始运行,新旧两个实例的对话框都会发生变化。
17.2 内存映射数据文件
有了内存映射文件,对大量的数据进行操作是非常方便的。书中举了一个例子:一个应用程序把文件中的所有字节按原来的顺序倒序。里面提出了四种思路:
- 一个文件,一个缓存
- 就分配足够大的内存块来存放整个文件,然后对内存块进行倒叙,再回写到文件中去。
- 这样如果文件很大,比如超过2G,32的系统是不允许应用程序提交那么大的物理内存块的。
- 还有如果倒叙操作的执行期间,发生了中断,那么文件的内容就会被破坏。当然可以保存一个拷贝,但这又需要更多的磁盘空间。
- 两个文件,一个缓存
- 除了旧文件,再多创建一个长度为0的新文件。然后分配一个固定大小(例如8K)的缓存,读取旧文件尾部8K大小的内容,倒叙,再写入新文件。这些操作反复进行到旧文件的开头(当然如果如果文件的大小不是8K的倍数的话,需要特殊的操作),就关闭两个文件,删掉旧文件。
- 这样做确实节省了内容,不过有两个问题:
- 这个方式需要循环处理,处理的速度就慢了很多。
- 可能会消耗掉大量的硬盘空间。如果旧文件是400MB,那么全部操作结束前程序需要占用800MB的空间。
- 一个文件,两个缓存
- 思路和上面类似,如果有两个缓存固定大小(例如8K),就可以不用新文件了。先把文件头部和尾部的数据分别写入S和E两个缓存中,然后缓存中的数据作倒叙操作,再把S里的内容写到文件的结束,E中的内容写到文件的开头。循环这个过程直到文件内容S和E相同,说明倒叙完毕,可以关闭文件、释放缓存了。
- 这个方式和上面的做法相比,节省了很可观的磁盘空间,又没有比较离谱的内存开销。
- 当然和上面也一样有遇到中断,这个方法会破坏原始的数据文件。
- 一个文件,没有缓存
- 通过内存映射文件直接修改文件:
- 打开文件,然后告诉系统将本进程的地址空间中的一段区域保留并倒序,再把文件的内容写入这段区域中。这样就完成了文件逆序的操作。
- 如果出现电源故障之类的问题使得进程中断,数据就被破坏了。
- 通过内存映射文件直接修改文件:
17.3 使用内存映射文件
使用内存映射文件有以下的步骤:
- 创建或打开一个文件内核对象,用于标识磁盘上的目标文件;
- 创建一个文件映射内核对象,告诉系统文件的大小和访问方式;
- 让系统将文件映射对象的全部或一部分映射到你的进程空间中;
完成对内存映射文件的使用后,使用下列的步骤释放相关资源:
- 告诉系统从你的进程的地址空间中撤销文件映射对象的映像;
- 关闭内存映射文件内核对象;
- 关闭文件内核对象;
17.3.1 步骤1:创建或打开文件内核对象
首先创建或者打开一个文件内核对象。调用CreateFile
函数;
17.3.2 步骤2:创建或打开文件映射内核对象
做了上面的步骤,系统已经知道了文件的物理存储的位置,现在需要告诉系统你需要多少物理存储器,这里就调用函数CreateFileMapping
了。
HANDLE CreateFileMapping(
HANDLE hFile,
PSECURITY_ATTRIBUTES psa,
DWORD fdwProtect,
DWORD dwMaximumSizeHigh,
DWORD dwMaximumSizeLow,
PCTSTR pszName);
hFile
标识映射到进程内存空间的文件的句柄。获得这个句柄的时候 。
psa
指向PSECURITY_ATTRIBUTE结构的指针,通常是NULL。
fdwProtect
当系统将hFile指向的存储映射到进程的地址空间时,指定的保护属性。
dwMaximumSizeHigh
32位下始终是0,只是方便64位扩展。
dwMaximumSizeLow
代表文件的最大字节数。
pszName
以0结尾的字符串,用于给文件映射对象赋予一个名字,便于和其他进程共享。可是内存映射数据文件通常并不需要被共享,所以它通常是NULL。
最后,如果CreateFileMapping失败了,返回的是NULL,不是INVALID_HANDLE_VALUE(-1)。
17.3.3 步骤3:将文件数据映射到进程的地址空间
接下来调用MapViewOfFile
函数。因为现在只有一个文件映射对象,还是需要让系统为文件的数据保留一段空间,那么映射到这个区域的文件数据就可以提交。
PVOID MapViewOfFile(
HANDLE hFileMappingObject,
DWORD dwDesireAccess,
DWORD dwFileOffetHigh,
DOWRD dwFileOffetLow,
SIZE_T dwNumberOfBytesToMap);
hFileMappingObject标识文件映射对象的句柄,产生于CreateFileMapping函数或OpenFileMapping函数,dwDesireAccess用于标识如何访问该数据,可以是下面表中的任意一个。
值 | 含义 |
---|---|
FILE_MAP_WRITE | 可以读取和写入数据,CreateFileMapping函数必须同时传递PAGE_READWRITE标志以调用 |
FILE_MAP_READ | 可以读取数据,CreateFileMapping函数可以通过PAGE_READONLY、PAGE_READWRITE或者PAGE_WRITECOPY |
FILE_MAP_ALL_ACCESS | 和FILE_MAP_WRITE相同 |
FILE_MAP_COPY | 可以读取和写入数据。如果写入数据,可以创建一个页面的私有拷贝,CreateFileMapping函数中必须用了PAGE_READONLY、PAGE_READWRITE或者PAGE_WRITECOPY其中一个保护属性 |
如此这般的保护属性或许已经让人不胜其烦,不过也同时显示出内存的保护有多重要。
剩余的3个参数与保留地址空间及物理存储器映射到这个空间相关。当你将一个文件映射到你的进程的地址空间中时,其实不必一次性把所有文件映射进去,而是只映射一部分。这一部分文件被称为“视图”,这也是函数叫做MapViewOfFIle的原因。
接下来就是所要映射的文件内容了。系统必须知道数据文件中的哪一个字节应该作为视图的第一个字节来映射,可以用参数dwFileOffetHigh和dwFileOffetLow来指定。由于Windows支持的文件最大可以到16EB,因此必须用一个64位的值来描述它。这个64位值的高位用dwFileOffetHigh参数描述高32位、dwFileOffetLow参数描述低32位。当然,这两个值也必须是一个页面的大小的整数倍。页面的大小后面会提到。
有了数据的起始,还需要数据的大小,知道有多少字节要映射到地址空间。描述这个数据的大小的就是参数dwNumberOfBytesToMap。如果这个值是0,那么文件中所有的内容都会被映射到地址空间中。
如果MapViewOfFile函数被调用时,参数设置了FILE_MAP_COPY,页文件就要参与到进来了。由于页文件其实也是内存(或者说是RAM和文件映像视图),只是因为不常用而被交换出来的。设置了参数FILE_MAP_COPY之后,只要有进程中的任何线程往文件映像视图中写入数据,那么系统就会取出页文件中的一个页面,将原始数据拷贝到这个页面中,再映射到到进程的地址空间。至此,那个线程就要访问本地的拷贝,不能读取或修改原始数据了。当系统制作原始页面的拷贝时,系统吧页面的保护属性从PAGE_WRITECOPY改为PAGE_READWRITE。
17.3.4 步骤4:从进程的地址空间中撤销文件数据的映像
当你不再需要已经映射到地址空间的文件数据的时候,可以通过下面这个函数将它释放:
BOOL UnmapViewOfFile(PVOID pvBaseAddress);
该函数唯一的参数就是MapViewOfFile的返回值。记得必须调用这个函数,不然这个映射在你的进程结束之前所保留的区域是不会被释放的,毕竟MapViewOfFile永远会找一片新的区域,而旧的将不会被释放。
如果需要确保你对数据的修改被写入磁盘,可以强制系统将修改过的数据(的部分)写入磁盘映像中,方法是调用下面这个函数:
BOOL FlushViewOfFile(
PVOID pvAddress,
SIZE_T dwNumberOfByteToFlush);
两个参数分别代表了需要写入的数据的起始位置和字节数。
对于有工作站的情形,FlushViewOfFile也许就只是将视图写入服务器的高速缓存而不是磁盘了。若要保证数据被写入磁盘,你需要将FILE_FLAG_WRITE_THROUGH标志传递给CreateFile。这样仅当文件的所有数据都已经存放在服务器时,FlushViewOf'File才返回。
记住UnmapViewOfFile函数的一个很重要的特性。如果原先使用FILE_MAP_COPY标志来映射视图,那么你对文件的数据做的任何修改,实际上是对存放在系统的页文件中的文件数据的拷贝作的修改。此时如果调用UnmapViewOfFile,磁盘上是不会有更新的,只会释放页文件中的文件,从而导致数据丢失。为了保存修改后的数据,必须采用其他的措施。
17.3.5 步骤4和6:关闭文件映射对象和文件对象
内核对象作为一种系统资源总是要被关闭的。如果忘记关闭,就会发生内存泄漏的问题。当然当你的进程终止时系统会自动关闭你忘记关闭任何对象,不过”正确“的事总是要被做的,资源泄漏可不是小事。
17.3.6 文件倒序示例应用程序
FileRev应用程序在清单17-2中列出。
17.4 使用内存映射文件来处理大文件
上面介绍了如何将一个16EB的文件映射到一个较小的地址空间中,当然这一点是不能做到的,你只能映射一个包含一小部分文件数据的文件视图。首先映射一个文件的开头的视图,完成对它的访问,就可以取消它的映像。然后映射一个从文件中的一个更深的位移开始的新视图。重复这个操作,直至访问了整个文件。这样的方式使得大型内存映射文件的处理并不方便。
17.5 内存映射文件与数据视图的相关性
系统允许你映射一个文件的相同数据的多个视图。这样,系统就会保证映射的视图的相关性。例如一个文本文件有多个相同位置的映射视图的话,其中一个视图一旦被改变,那么其他的视图也会更新这个变化。这是因为数据是放在单个RAM页面上。
然而没有理由使得其他进程无法用CreateFile函数打开自己已经映射的同一个文件,即使是调用ReadFile和WriteFile也是可以的。在读写文件的时候的缓冲区就需要使用者自己提供了,不能直接使用映射文件使用的内存缓冲区。
这样又会产生资源争夺的问题,就是多个线程可以同时修改这个文件的数据了。为了避免这种情况,调用CreateFile函数时dwShareMode参数的值就要设置成0。这样就可以告诉系统,当前的进程需要单独访问这个文件,其他文件不能打开它。
只读文件就没有这样的问题了。
17.6 设定内存映射文件的基地址
还有一个函数MapViewOfFileEx,是用来确定一个文件被映射到某个特定的地址的:
PVOID MapViewOfFileEx(
HANDLE hFileMappingObject,
DWORD dwDesiredAccess,
DWORD dwFileOffsetHigh,
DWORD dwFileOffsetLow,
SIZE_T dwNumberOfBytesToMap,
PVOID pvBaseAddress);
不同的只是最后一个参数pvBaseAddress。这个值当然也必须是64KB的倍数。
这个值pvBaseAddress如果是NULL,那么MapViewOfFileEx和MapViewOfFile就并没有不一样的地方。
17.7 实现内存映射文件的具体方法
比较了Win98和Win2k之间使用内存映射文件的区别。
17.8 使用内存映射文件在进程之间共享数据
Windows中提供了各种机制用于应用程序间共享数据,这些机制包括RPC、COM、OLE、DDE、窗口消息(WM_COPYDATA)、剪贴板、管道、套接字等。不过最常用的还是内存映射文件,特别是对性能和开销都有要求的时候。
多个进程只需要映射同一个文件就可以达到共享的效果,这意味着它们共享物理存储器的同一个页面。其中一个进程改变了文件的数据,其他线程都会得到这个变更。只是有一点需要注意,就是这些进程需要同样的名字来表示这个文件映射对象。