本文背景:
在编程中,很多Windows或C++的内存函数不知道有什么区别,更别谈有效使用;根本的原因是,没有清楚的理解操作系统的内存管理机制,本文企图通过简单的总结描述,结合实例来阐明这个机制。
本文目的:
对Windows内存管理机制了解清楚,有效的利用C++内存函数管理和使用内存。
本文内容:
本文一共有六节,由于篇幅较多,故按节发表。其他章节请看本人博客的Windows内存管理及C++内存分配实例(一)(二)(三)(五)和(六)。
4. 内存管理机制--内存映射文件 (Map)
和虚拟内存一样,内存映射文件可以用来保留一个进程地址区域;但是,与虚拟内存不同,它提交的不是物理内存或是虚拟页文件,而是硬盘上的文件。
· 使用场合
它有三个主要用途:
系统加载EXE和DLL文件
操作系统就是用它来加载exe和dll文件建立进程,运行exe。这样可以节省页文件和启动时间。
访问大数据文件
如果文件太大,比如超过了进程用户区2G,用fopen是不能对文件进行操作的。这时,可用内存映射文件。对于大数据文件可以不必对文件执行I/O操作,不必对所有文件内容进行缓存。
进程共享机制
内存映射文件是多个进程共享数据的一种较高性能的有效方式,它也是操作系统进程通信机制的底层实现方法。RPC、COM、OLE、DDE、窗口消息、剪贴板、管道、Socket等都是使用内存映射文件实现的。
· 系统加载EXE和DLL文件
ü EXE文件格式
每个EXE和DLL文件由许多节(Section)组成,每个节都有保护属性:READ,WRITE,EXECUTE和SHARED(可以被多个进程共享,关闭页面的COPY-ON-WRITE属性)。
以下是常见的节和作用:
节名 | 作用 |
.text | .exe和.dll文件的代码 |
.data | 已经初始化的数据 |
.bss | 未初始化的数据 |
.reloc | 重定位表(装载进程的进程地址空间) |
.rdata | 运行期只读数据 |
.CRT | C运行期只读数据 |
.debug | 调试信息 |
.xdata | 异常处理表 |
.tls | 线程的本地化存储 |
.idata | 输入文件名表 |
.edata | 输出文件名表 |
.rsrc | 资源表 |
.didata | 延迟输入文件名表 |
ü 加载过程
1. 系统根据exe文件名建立进程内核对象、页目和页表,也就是建立了进程的虚拟空间。
2. 读取exe文件的大小,在默认基地址0x0040 0000上保留适当大小的区域。可以在链接程序时用/BASE 选项更改基地址(在VC工程属性/链接器/高级上设置)。提交时,操作系统会管理页目和页表,将硬盘上的文件映射到进程空间中,页表中保存的地址是exe文件的页偏移。
3. 读取exe文件的.idata节,此节列出exe所用到的所有dll文件。然后和
exe文件一样,将dll文件映射到进程空间中。如果无法映射到基地址,系统会重新定位。
4. 映射成功后,系统会把第一页代码加载到内存,然后更新页目和页
表。将第一条指令的地址交给线程指令指针。当系统执行时,发现代码没有在内存中,会将exe文件中的代码加载到内存中。
ü 第二次加载时(运行多个进程实例)
1. 建立进程、映射进程空间都跟前面一样,只是当系统发现这个exe已
经建立了内存映射文件对象时,它就直接映射到进程空间了;只是当
系统分配物理页面时,根据节的保护属性赋予页面保护属性,对于代码
节赋予READ属性,全局变量节赋予COPY-ON-WRITE属性。
2. 不同的实例共享代码节和其他的节,当实例需要改变页面内容时,会
拷贝页面内容到新页面,更新页目和页表。
3. 对于不同进程实例需要共享的变量,exe文件有一
个默认的节, 给这个节赋予SHARED属性。
4. 你也可以创建自己的SHARED节
#pragma data_seg(“节名”)
Long instCount;
#pragma data_seg()
然后,你需要在链接程序时告诉编译器节的默认属性。
/SECTION: 节名,RWS
或者,在程序里用以下表达式:
#pragma comment(linker,“/SECTION:节名,RWS”)
这样的话编译器会创建.drective节来保存上述命令,然后链接时会用它改变节属性。
注意,共享变量有可能有安全隐患,因为它可以读到其他进程的数据。
C++程序:多个进程共享变量举例
*.cpp开始处:
#pragma data_seg(".share")
long shareCount=0;
#pragma data_seg()
#pragma comment(linker,"/SECTION:.share,RWS")
ShareCount++;
注意,同一个exe文件产生的进程会共享shareCount,必须是处于同一个位置上的exe。
· 访问大数据文件
ü 创建文件内核对象
使用CreateFile(文件名,访问属性,共享模式,…) API可以创建。
其中,访问属性有:
0 不能读写 (用它可以访问文件属性)
GENERIC_READ
GENERIC_WRITE
GENERIC_READ|GENERIC_WRITE;
共享模式:
0 独享文件,其他应用程序无法打开
FILE_SHARE_WRITE
FILE_SHARE_READ|FILE_SHARE_WRITE
这个属性依赖于访问属性,必须和访问属性不冲突。
当创建失败时,返回INVALID_HANDLE_VALUE。
C++程序如下:
试图打开一个1G的文件:
MEMORYSTATUS memStatus;
GlobalMemoryStatus(&memStatus);
HANDLE hn=CreateFile(L"D://1G.rmvb",GENERIC_READ|GENERIC_WRITE,
FILE_SHARE_READ|FILE_SHARE_WRITE,NULL,OPEN_EXISTING,FILE_ATTRIBUTE_NORMAL,NULL);
if(hn==INVALID_HANDLE_VALUE)
cout<<"打开文件失败!"< FILE *p&#61;fopen("D://1G.rmvb","rb"); if(p&#61;&#61;NULL) cout<<"用fopen不能打开大文件!"< MEMORYSTATUS memStatus2; GlobalMemoryStatus(&memStatus2); cout<<"打开文件后的空间&#xff1a;"< cout<<"减少物理内存&#61;"< cout<<"减少可用页文件&#61;"< cout<<"减少可用进程空间&#61;" < 结果如下&#xff1a; 可见&#xff0c;系统需要一些内存来管理内核对象&#xff0c;每一次运行的结果都不一样&#xff0c;但差别不会太大。 用c语言的fopen不能打开这么大的文件。理论上&#xff0c;32位系统能支持232字节&#xff0c;但是&#xff0c;进程空间只有2G&#xff0c;它只能表示那么大的空间。 ü 创建文件映射内核对象 API如下&#xff1a; HANDLE CreateFileMapping(Handle 文件&#xff0c;PSECURITY_ATTRIBUTES 安全属性&#xff0c;DWORD 保护属性&#xff0c;DWORD 文件大小高32位&#xff0c;DWORD 文件大小低32位&#xff0c;PCTSTR 映射名称) “文件”是上面创建的句柄&#xff1b; “安全属性”是内核对象需要的&#xff0c;NULL表示使用系统默认的安全属性&#xff1b;“保护属性”是当将存储器提交给进程空间时&#xff0c;需要的页面属性&#xff1a;PAGE_READONLY, PAGE_READWRITE和PAGE_WRITECOPY。这个属性不能和文件对象的访问属性冲突。除了这三个外&#xff0c;还有两个属性可以和它们连接使用(|)。当更新文件内容时&#xff0c;不提供缓存&#xff0c;直接写入文件&#xff0c;可用SEC_NOCACHE&#xff1b;当文件是可执行文件时&#xff0c;系统会根据节赋予不同的页面属性&#xff0c;可用SEC_IMAGE。另外&#xff0c;SEC_RESERVE和SEC_COMMIT用于稀疏提交的文件映射&#xff0c;详细介绍请参考下文。 “文件大小高32位”和“文件大小低32位”联合起来告诉系统&#xff0c;这个映射所能支持的文件大小&#xff08;操作系统支持264B文件大小&#xff09;&#xff1b;当这个值大于实际的文件大小时&#xff0c;系统会扩大文件到这个值&#xff0c;因为系统需要保证进程空间能完全被映射。值为0默认为文件的大小&#xff0c;这时候如果文件大小为0&#xff0c;创建失败。 “映射名称”是给用户标识此内核对象&#xff0c;供各进程共享&#xff0c;如果为NULL&#xff0c;则不能共享。 对象创建失败时返回NULL。 创建成功后&#xff0c;系统仍未为文件保留进程空间。 C&#43;&#43;程序&#xff1a; MEMORYSTATUS memStatus2; GlobalMemoryStatus(&memStatus2); HANDLE hmap&#61;CreateFileMapping(hn,NULL,PAGE_READWRITE,0,0,L"Yeming-Map"); if(hmap&#61;&#61;NULL) cout<<"建立内存映射对象失败!"< MEMORYSTATUS memStatus3; GlobalMemoryStatus(&memStatus3); cout<<"建立内存映射文件后的空间&#xff1a;"< cout<<"减少物理内存&#61;"< cout<<"减少可用页文件&#61;"< cout<<"减少可用进程空间&#61;" < 结果如下&#xff1a; 默认内存映射的大小是1G文件。没有损失内存和进程空间。它所做的是建立内核对象&#xff0c;收集一些属性。 ü 文件映射内核对象映射到进程空间 API如下&#xff1a; PVOID MAPViewOfFile(HANDLE 映射对象&#xff0c;DWORD访问属性&#xff0c;DWORD 偏移量高32位&#xff0c;DWORD 偏移量低32位&#xff0c;SIZE_T 字节数) “映射对象”是前面建立的对象&#xff1b; “访问属性”可以是下面的值&#xff1a;FILE_MAP_WRITE(读和写)、FILE_MAP_READ、FILE_MAP_ALL_ACCESS(读和写)、FILE_MAP_COPY。当使用FILE_MAP_COPY时&#xff0c;系统分配虚拟页文件&#xff0c;当有写操作时&#xff0c;系统会拷贝数据到这些页面&#xff0c;并赋予PAGE_READWRITE属性。 可以看到&#xff0c;每一步都需要设置这类属性&#xff0c;是为了可以多点控制&#xff0c;试想&#xff0c;如果在这一步想有多种不同的属性操作文件的不同部分&#xff0c;就比较有用。 “偏移高32位”和“偏移低32位”联合起来标识映射的开始字节&#xff08;地址是分配粒度的倍数&#xff09;&#xff1b; “字节数”指映射的字节数&#xff0c;默认0为到文件尾。 当你需要指定映射到哪里时&#xff0c;你可以使用&#xff1a; PVOID MAPViewOfFile(HANDLE 映射对象&#xff0c;DWORD访问属性&#xff0c;DWORD 偏移量高32位&#xff0c;DWORD 偏移量低32位&#xff0c;SIZE_T 字节数&#xff0c;PVOID 基地址) “基地址”是映射到进程空间的首地址&#xff0c;必须是分配粒度的倍数。 C&#43;&#43;程序&#xff1a; MEMORYSTATUS memStatus3; GlobalMemoryStatus(&memStatus3); LPVOID pMAP&#61;MapViewOfFile(hmap,FILE_MAP_WRITE,0,0,0); cout<<"映射内存映射文件后的空间&#xff1a;"< if(pMAP&#61;&#61;NULL) cout<<"映射进程空间失败!"< else printf("首地址&#61;%x/n",pMAP); MEMORYSTATUS memStatus4; GlobalMemoryStatus(&memStatus4); cout<<"减少物理内存&#61;"< cout<<"减少可用页文件&#61;"< cout<<"减少可用进程空间&#61;" < 结果如下&#xff1a; 进程空间减少了1G&#xff0c;系统同时会开辟一些内存来做文件缓存。 ü 使用文件 1. 对于大文件&#xff0c;可以用多次映射的方法达到访问的目的。有点像AWE技术。 2. Windows只保证同一文件映射内核对象的多次映射的数据一致性&#xff0c;比如&#xff0c;当有两次映射同一对象到二个进程空间时&#xff0c;一个进程空间的数据改变后&#xff0c;另一个进程空间的数据也会跟着改变&#xff1b;不保证不同映射内核对象的多次映射的一致性。所以&#xff0c;使用文件映射时&#xff0c;最好在CreateFile时将共享模型设置为0独享&#xff0c;当然&#xff0c;对于只读文件没这个必要。 C&#43;&#43;程序&#xff1a;使用1G的文件 MEMORYSTATUS memStatus4; GlobalMemoryStatus(&memStatus4); cout<<"读取1G文件前&#xff1a;"< cout<<"可用物理内存&#61;"< cout<<"可用页文件&#61;"< cout<<"可用进程空间&#61;"< int* pInt&#61;(int*)pMAP; cout<<"更改前&#61;"< for(int i&#61;0;i<1000001536/4-1;i&#43;&#43;) pInt[i]&#43;&#43;; pInt[1000001536/4-1]&#61;10; pInt[100]&#61;90; pInt[101]&#61;100; cout<<"读取1G文件后&#xff1a;"< MEMORYSTATUS memStatus5; GlobalMemoryStatus(&memStatus5); cout<<"可用物理内存&#61;"< cout<<"可用页文件&#61;"< cout<<"可用进程空间&#61;"< 结果如下&#xff1a; 程序将1G文件的各个整型数据加1&#xff0c;从上图看出内存损失了600多兆&#xff0c;但有时候损失不过十几兆&#xff0c;可能跟系统当时的状态有关。 不管怎样&#xff0c;这样你完全看不到I/O操作&#xff0c;就像访问普通数据结构一样方便。 ü 保存文件修改 为了提高速度&#xff0c;更改文件时可能只更改到了系统缓存&#xff0c;这时&#xff0c;需要强制保存更改到硬盘&#xff0c;特别是撤销映射前。 BOOL FlushViewOfFile(PVOID 进程空间地址&#xff0c;SIZE_T 字节数) “进程空间地址”指的是需要更改的第一个字节地址&#xff0c;系统会变成页面的地址&#xff1b; “字节数”&#xff0c;系统会变成页面大小的倍数。 写入磁盘后&#xff0c;函数返回&#xff0c;对于网络硬盘&#xff0c;如果希望写入网络硬盘后才返回的话&#xff0c;需要将FILE_FLAG_WRITE_THROUGH参数传给CreateFile。 当使用FILE_MAP_COPY建立映射时&#xff0c;由于对数据的更改只是对虚拟页文件的修改而不是硬盘文件的修改&#xff0c;当撤销映射时&#xff0c;会丢失所做的修改。如果要保存&#xff0c;怎么办&#xff1f; 你可以用FILE_MAP_WRITE建立另外一个映射&#xff0c;它映射到进程的另外一段空间&#xff1b;扫描第一个映射的PAGE_READWRITE页面(因为属性被更改)&#xff0c;如果页面改变&#xff0c;用MoveMemory或其他拷贝函数将页面内容拷贝到第二次映射的空间里&#xff0c;然后再调用FlushViewOfFile。当然&#xff0c;你要记录哪个页面被更改。 ü 撤销映射 用以下API可以撤销映射&#xff1a; BOOL UnmapViewOfFile(PVOID pvBaseAddress) 这个地址必须与MapViewOfFile返回值相同。 ü 关闭内核对象 在不需要内核对象时&#xff0c;尽早将其释放&#xff0c;防止内存泄露。由于它们是内核对象&#xff0c;调用CloseHandle(HANDLE)就可以了。 在CreateFileMapping后马上关闭文件句柄&#xff1b; 在MapViewOfFile后马上关闭内存映射句柄&#xff1b; 最后再撤销映射。 · 进程共享机制 ü 基于硬盘文件的内存映射 如果进程需要共享文件&#xff0c;只要按照前面的方式建立内存映射对象&#xff0c;然后按照名字来共享&#xff0c;那么进程就可以映射这个对象到自己的进程空间中。 C&#43;&#43;程序如下&#xff1a; HANDLE mapYeming&#61;OpenFileMapping(FILE_MAP_WRITE,true,L"Yeming-Map"); if(mapYeming&#61;&#61;NULL) cout<<"找不到内存映射对象:Yeming-Map!"< MEMORYSTATUS memStatus3; GlobalMemoryStatus(&memStatus3); LPVOID pMAP&#61;MapViewOfFile(mapYeming,FILE_MAP_WRITE,0,0,100000000); cout<<"建立内存映射文件后的空间&#xff1a;"< if(pMAP&#61;&#61;NULL) cout<<"映射进程空间失败!"< else printf("首地址&#61;%x/n",pMAP); MEMORYSTATUS memStatus4; GlobalMemoryStatus(&memStatus4); cout<<"减少物理内存&#61;"< cout<<"减少可用页文件&#61;"< cout<<"减少可用进程空间&#61;"< int* pInt&#61;(int*)pMAP; cout< 结果如下&#xff1a; 在2.exe中打开之前1.exe创建的内存映射对象(当然&#xff0c;1.exe得处于运行状态)&#xff0c;然后映射进自己的进程空间&#xff0c;当1.exe改变文件的值时&#xff0c;2.exe的文件对应值也跟着改变&#xff0c;Windows保证同一个内存映射对象映射出来的数据是一致的。可以看见&#xff0c;1.exe将值从90改为91&#xff0c;2.exe也跟着改变&#xff0c;因为它们有共同的缓冲页。 ü 基于页文件的内存映射 如果只想共享内存数据时&#xff0c;没有必要创建硬盘文件&#xff0c;再建立映射。可以直 接建立映射对象&#xff1a; 只要传给CreateFileMapping一个文件句柄INVALID_HANDLE_VALUE就行了。所以&#xff0c;CreateFile时&#xff0c;一定要检查返回值&#xff0c;否则会建立一个基于页文件的内存映射对象。接下来就是映射到进程空间了&#xff0c;这时&#xff0c;系统会分配页文件给它。 C&#43;&#43;程序如下&#xff1a; HANDLE hPageMap&#61;CreateFileMapping(INVALID_HANDLE_VALUE,NULL,PAGE_READWRITE,0, 100000000,L"Yeming-Map-Page"); if(hPageMap&#61;&#61;NULL) cout<<"建立基于页文件的内存映射对象失败!"< MEMORYSTATUS memStatus6; GlobalMemoryStatus(&memStatus6); cout<<"建立基于页文件的内存映射文件后的空间&#xff1a;"< cout<<"减少物理内存&#61;"< cout<<"减少可用页文件&#61;"< cout<<"减少可用进程空间&#61;"< LPVOID pPageMAP&#61;MapViewOfFile(hPageMap,FILE_MAP_WRITE,0,0,0); 结果如下&#xff1a; 可见&#xff0c;和基于数据文件的内存映射不同&#xff0c;现在刚建立内核对象时就分配了所要的100M内存。好处是&#xff0c;别的进程可以通过这个内核对象共享这段内存&#xff0c;只要它也做了映射。 ü 稀疏内存映射文件 在虚拟内存一节中&#xff0c;提到了电子表格程序。虚拟内存解决了表示很少单元格有数据但必须分配所有内存的内存浪费问题&#xff1b;但是&#xff0c;如果想在多个进程之间共享这个电子表格结构呢&#xff1f; 如果用基于页文件的内存映射&#xff0c;需要先分配页文件&#xff0c;还是浪费了空间&#xff0c;没有了虚拟内存的优点。 Windows提供了稀疏提交的内存映射机制。 当使用CreateFileMapping时&#xff0c;保护属性用SEC_RESERVE时&#xff0c;其不提交物理存储器&#xff0c;使用SEC_COMMIT时&#xff0c;其马上提交物理存储器。注意&#xff0c;只有文件句柄为INVALID_HANDLE_VALUE时&#xff0c;才能使用这两个参数。 按照通常的方法映射时&#xff0c;系统只保留进程地址空间&#xff0c;不会提交物理存储器。 当需要提交物理内存时才提交&#xff0c;利用通常的VirtualAlloc函数就可以提交。 当释放内存时&#xff0c;不能调用VirtualFree函数&#xff0c;只能调用UnmapViewOfFile来撤销映射&#xff0c;从而释放内存。 C&#43;&#43;程序如下&#xff1a; HANDLE hVirtualMap&#61;CreateFileMapping(INVALID_HANDLE_VALUE,NULL,PAGE_READWRITE|SEC_RESERVE,0,100000000,L"Yeming-Map-Virtual"); if(hPageMap&#61;&#61;NULL) cout<<"建立基于页文件的稀疏内存映射对象失败!"< MEMORYSTATUS memStatus8; GlobalMemoryStatus(&memStatus8); cout<<"建立基于页文件的稀疏内存映射文件后的空间&#xff1a;"< cout<<"减少物理内存&#61;"< cout<<"减少可用页文件&#61;"< cout<<"减少可用进程空间&#61;"< LPVOID pVirtualMAP&#61;MapViewOfFile(hVirtualMap,FILE_MAP_WRITE,0,0,0); cout<<"内存映射进程后的空间&#xff1a;"< if(pVirtualMAP&#61;&#61;NULL) cout<<"映射进程空间失败!"< else printf("首地址&#61;%x/n",pVirtualMAP); MEMORYSTATUS memStatus9; GlobalMemoryStatus(&memStatus9); cout<<"减少物理内存&#61;"< cout<<"减少可用页文件&#61;"< cout<<"减少可用进程空间&#61;"< 结果如下&#xff1a; 用了SEC_RESERVE后&#xff0c;只是建立了一个内存映射对象&#xff0c;和普通的一样&#xff1b;不同的是&#xff0c;它映射完后&#xff0c;得到了一个虚拟进程空间。现在&#xff0c;这个空间没有分配任何的物理存储器给它&#xff0c;你可以用VirtualAlloc 提交存储器给它&#xff0c;详细请参考上一篇<虚拟内存&#xff08;VM&#xff09;>。 注意&#xff0c;你不可以用VirtualFree来释放了,只能用UnmapViewOfFile来。 C&#43;&#43;程序如下&#xff1a; LPVOID pP&#61;VirtualAlloc(pVirtualMAP,100*1000*1000,MEM_COMMIT,PAGE_READWRITE); MEMORYSTATUS memStatus10; GlobalMemoryStatus(&memStatus10); cout<<"减少物理内存&#61;"< cout<<"减少可用页文件&#61;"< cout<<"减少可用进程空间&#61;"< bool result&#61;VirtualFree(pP,100000000,MEM_DECOMMIT); if(!result) cout<<"释放失败!"< result&#61;VirtualFree(pP,100000000,MEM_RELEASE); if(!result) cout<<"释放失败!"< CloseHandle(hVirtualMap); MEMORYSTATUS memStatus11; GlobalMemoryStatus(&memStatus11); cout<<"增加物理内存&#61;"< cout<<"增加可用页文件&#61;"< cout<<"增加可用进程空间&#61;"< result&#61;UnmapViewOfFile(pVirtualMAP); if(!result) cout<<"撤销映射失败!"< MEMORYSTATUS memStatus12; GlobalMemoryStatus(&memStatus12); cout<<"增加物理内存&#61;"< cout<<"增加可用页文件&#61;"< cout<<"增加可用进程空间&#61;" < 结果如下&#xff1a; 可以看见&#xff0c;用VirtualFree是不能够释放这个稀疏映射的&#xff1b;最后用UnmapViewOfFile得以释放进程空间和物理内存。