Windows编程之进程的创建

本文原创,最早发表于公司内部博客, 禁止转载

前言

最初想写一篇文章整理下过去一些读书笔记, 结果发现写文章过程中, 触及不少知识盲区, 很多地方似懂非懂, 于是又查询了很多资料。本文主要是围绕着进程的装载及运行展开。

一. PE文件格式简介

1.1. 基本概念

PE是“Portable Executable File Format”的缩写, PE格式是目前Windows平台上的主流可执行文件格式。

从某种意义上讲,可执行文件格式是操作系统本身执行机制的反应,虽然研究可执行文件格式不是程序员首要任务,但是在这一过程中能够学到大量知识,有助于程序员深刻了解操作系统,掌握可执行文件数据结构及运行机理,也是软件安全的必修课。

在Windows API中描述PE格式的地方再winnt.h头文件中,其中有一节叫做“Image Format”,该节给除了DOS MZ格式和Windwos 3.1的NE格式文件头,之后就是PE文件的内容。在这个头文件中几乎可以找到所有关于PE文件的数据结构定义、枚举类型、常量定义。可以肯定,在别的地方也能找到相关文档(如MSDN),但Winnt.h是PE文件定义的最终决定者。

EXE文件和DLL文件的区别完全是语义上的。实际上,他们使用完全相同的PE格式,唯一的区别就是用一个字段表示出这个文件是EXE还是DLL。另外,64位的Windows只是对PE格式进行了一些简单的修饰,新格式叫做PE32+,并没有新的数据结构加入,只是简单地将32位字段扩展成64位。在VC++中,Windows的头文件配置使其并没有明显的区别。

下图展示了PE文件的大致布局(注意从下往上是文件头到文件尾, 有些带COFF字段,COFF是PE文件的前身)
在这里插入图片描述

PE文件使用的是一个平面地址空间,所有代码和数据合并在一起,组成了一个很大的结构。文件的内容被分割为不同的区块(Section,又称区块、段、节等)区块中包含代码或者数据,各个区块按照页边界对齐。区块没有大小限制,是一个连续结构,每个区块都有它自己在内存中的一套属性,如这个块是否包含代码、是否制度或可读/写等。

PE文件在装载时被直接映射到进程的虚拟地址空间中运行,它是进程虚拟地址空间的映像。所以PE可执行文件很多时候被叫做映像文件(Image File), 我们会发现在与PE相关的数据结构大多以IMAGE开头。

1.2. MS-DOS头

为了兼容DOS系统,PE文件在设计之初就背负着历史的累赘。每个PE文件都是以一个DOS程序开始的,有了它,一旦程序在DOS下执行,DOS就能识别出这是一个有效的执行体,然后运行紧随的MZ header的DOS stub(DOS块)。DOS stub是一个有效的EXE,在不支持PE文件格式的操作系统中,它将简单地显示一个错误提示,类似字符串"This program cannot be run in MS-DOS mode"。用户可以使用/stub链接器选项指定不同的stub。由于DOS基本已灭绝,大多数情况下它由汇编器/编译器自动生成。我们通常把DOS MZ头(MZ为DOS下一种可执行文件可是)与DOS stub合成为DOS头文件头。

DOS MZ Header其实就跟DOS下“MZ”可执行文件头一致, 可以在WinNT.h中查看其结构:IMAGE_DOS_HEADER。
在这里插入图片描述

其中有两个字段比较重要,分别为e_magic和e_lfanew。e_magic在开头,其包含"MZ"这两个字母的ASCII码,是MS-DOS创建者之一Mark Zbikowski名字缩写。e_lfanew大小为4字节,它是真正的PE文件头的相对偏移(RVA),指出真正的PE头的文件偏移位置。

1.3. PE文件头

“PE Header”是PE相关结构NT映像头(IMAGE_NT_HEADERS)的简称。如上图,32位的PE文件头(NT头)结构包括PE文件头和PE可选头。IMAGE_FILE_HEADER是PE文件头,其定义了PE文件的一些基本信息和属性,这些属性会在PE加载器加载时用到,如果加载器发现PE文件头中定义的一些属性不满足当前的运行环境,将会终止加载该PE。IMAGE_OPTIONAL_HEADER32是PE可选头,虽然其叫可选头,但是其很重要,并不可少。其结构较复杂,包含导入表、导出表等重要数据。

1.4. 区块

在PE文件头与原始数据之间存在一个区块表(section table)。区块表是一个IMAGE_SECTION_HEADER 结构数组,这个结构包含区块的信息,比如位置、长度、属性等,区块的数目是由NT头的文件头(IMAGE_FILE_HEADER)里的 NumberOfSections 给出。

区块中数据逻辑通常是关联的。PE文件一般至少有两个区块,一个是代码块(.text),一个是数据块(.data)。还有一些其它区块,此处不介绍了。链接器会将一些相似、具有一致性的区块合并成单一区块。例如,所有的代码指令都会放在.text段中,全局变量、静态变量都放在.data段中,早期PE文件中的.bss段(未初始化数据段)现在也被合并到.data中去了。

在PE文件头中,FileAlignment定义了磁盘上区块对齐值,这个值通常为0200h。每个区块都是从该值的整数倍开始,不足的地方以00h填充。PE文件头中的SectionAlignment定义了内存映像(虚拟地址空间)中所映射区块的对齐值,这一大小在x86下为4k(1000h),x64下为8k(2000h)。磁盘上区块与内存映像中区块对齐大小并不一致,显然PE在装载的时候区块地址会发生地址偏移。

关于PE文件,此处只是简单介绍,还有一些其它概念,如导入表、导出表、基址重定位、地址偏移等相关的东西,想要深入可以参考《加密与解密》这本书。

二. R0和R3

现代操作系统一般分为应用层和内核层,应用层通过系统调用进入内核层,由系统底层完成相应的功能,这个时候内核执行处在该进程的上下文空间中。一般而言,内核指的是系统内核本身,以及以内核方式加载的驱动。

操作系统使用特权级别来隔离内核层和应用层。系统内核层,又叫Ring 0(简称R0),与此对应的应用层叫Ring 3(简称R3, R1和R2两个级别并没有用到)。R0拥有操作系统最高的执行权限, R3拥有最低的执行权限。一般来说,运行在R0级上的代码将自己降低至R3级是允许的,但是反过来R3级别代码将自己提升至R0级别无法轻易进行,这也是系统设置特权级别的目的所在。用户的程序(常见的桌面客户端程序)就是运行在R3级的,它们享有的权限最低,该类应用程序无法破坏操作系统。如果想控制系统,必须取得R0特权级,大多数驱动动程序都是工作在R0特权级上。用户进程发生严重错误只会导致当前进程意外退出,不会影响其它进程和操作系统,而如果是驱动程序发严重错误,通常会导致操作系统的崩溃,windows电脑上通常表现为蓝屏。

应用层程序通过系统调用进入内核,由系统底层完成相应功能。系统调用是运行在内核态的,而应用程序都是运行在用户态。用户态程序如何运行内核态的代码呢?操作系统一般通过特定中断(Interrupt)来从用户态切换到内核态,从而进行系统调用。实际上windows对硬件的操作,如物理内存的读写、文件的读写等最终都只能在R0上执行,R3上用户进程只能间接的通过系统调用切换到RO执行这些操作。我们本文所说的进程都是只R3上的用户进程。

三. 进程虚拟地址空间

3.1. 进程隔离

对操作系统而言,隔离性应该是设计的一个基本原则。进程从逻辑上来看是可以独占计算机资源的,进程之间是可以相互独立,互不干扰。操作系统的多任务功能使得CPU能够在多个进程之间很好的共享,从进程的角度看好像是它独占了CPU而不用考虑与其它进程分享CPU。操作系统I/O抽象模型也很好地实现了I/O设备的共享和抽象,那么对于内存而言,它的分配该如何设计呢?

很显然,物理内存都是有限的,假设让进程直接去访问物理内存,这是一个很糟糕的设计。这样会导致三个问题:1)地址空间不隔离,所有程序能直接访问物理内存,用户进程中内存数据很容易被其它进程篡改;2)内存使用效率低,没有有效的内存管理机制,将整个程序装入内存执行,很容易出现内存不够用情况;3)程序运行地址不确定,程序每次需要装入运行,我们需从内存中分配一块足够大的区域,这个空闲区域位置不确定,这对编程会造成一定的麻烦。

有人说过一句名言:计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。操作系统为了保持进程的隔离,它增加了中间层,使用了一种间接取地址的方法。这个想法是这样的,把程序中取到的地址看作是一种虚拟地址(Virtual Address),然后通过某些映射方法,可以将虚拟地址转化为实际物理地址。这样,只要我们能够妥善地控制这个虚拟地址到物理地址的映射过程, 就可以保证任意一个程序所能访问的物理内存区域跟另外一个程序互不重叠,这就达到了地址空间隔离的效果,也就是进程的隔离。

3.2. 虚拟地址空间介绍

一个进程最关键的特城就是拥有独立的虚拟地址空间,这使得其有别于其它进程。所以,在一个进程创建之初,首要做的就是创建一个独立的虚拟地址空间。虚拟地址空间,顾名思义,这个地址空间是虚拟的,它不是实际存在的?实际上,创建虚拟地址空间其实是创建虚拟地址到物理地址的映射函数所需要的相应的数据结构

CPU的位数决定了地址空间的最大理论上限,在32位硬件平台上决定虚拟地址空间范围是0到2^32 - 1, 即使0x00000000到0XFFFFFFFF,也是我们常说的4GB虚拟地址空间;64位硬件平台上它的虚拟地址空间大小达到2^64字节,即0x0到0xFFFFFFFFFFFFFFFF,总共17 179 869 184GB,其寻址能力目前看来几乎无限了。在64位的Windows系统上, 仍然可以运行32-bit的应用程序, 这是因为Windows提供一个syswow64的子系统用来兼容32位程序。在C:\Windows中可以看到SysWOW64目录,其中存存放32位Windows系统文件。我们在下文中以32位地址空间为主,64位与其类似。

对于32位进程,其用有4GB虚拟地址空间,需要强调的是,这个是虚拟地址空间,不是物理内存地址。系统将4GB虚拟地址空间进行了划分,默认情况下,操作系统占用了2GB(内核空间,kernel space),剩下的2GB是留给进程使用的(用户空间,user mode space)。内核空间供供内核代码、设备驱动程序代码、设备I/O高速缓存、非页面内存池的分配和进程页面表等使用。

2GB的地址空间对有些进程来说太小了,Windows允许将用户空间扩大为3GB,并且将内核空间减少到1GB。使用“/LARGEADDRESSAWARE”链接选项可以控制程序是否能使用大于2GB的虚拟地址空间。当操作系统在创建进程的虚拟地址空间时,需要检查一个IMAGE_FILE_LARGE_ADDRESS_AWARE标志,这个标志实际最终保存在PE的IMAGE_FILE_HEADER结构中的Characteristics字段中,如果包含此标志,用户模式地址空间为3GB,反之为2GB。对于DLL系统则忽略该标志。(备注:在早期的Windows Server 2003和Windows XP上,仍需要使用在Windwos系统盘根目录下的Boot.ini,加上“/3G”参数。 参考于最新MSDN)

3.3. 虚拟地址空间分区

对于一个用户模式地址空间为2/3GB的进程来说,原则上它可以使用2/3GB虚拟地址空间,但实际上并不能,其中有一部分预留给了其它用途。进程的虚拟地址空间会被划分成各个分区,其大概布局如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wErvjyLH-1619575454104)(http://km.oa.com/files/photos/pictures/202011/1604761905_25_w819_h483.png)]

1)64K空指针赋值分区,保留该分区的目的是为了帮助应用程序员捕获对空指针的赋值。如malloc分配内存失败,就会返回NULL。如果进程中的线程试图访问该分区内的内存地址,就会引发访问违规。这也是c++中通常将指针初始化为NULL(0)的原因(c++11以后可以用nullptr)。
2)用户模式分区,在Windows中,所有的exe和动态链接库都载入到这一区域。系统还可以在这个分区中映射该进程可以访问的所有内存映射文件。进程无法访问其他进程的这一分区,因此一个应用程序破坏另一个应用程序的可能性就非常小了,从而使整个系统更加稳定。用户模式其大概布局如下:
在这里插入图片描述
3)64KB禁止进入分区,这个分区禁止进入,任何试图访问这个内存分区的操作都是违规的。
4)内核模式分区,这是操作系统代码的驻扎区域。线程调度,内存管理,文件系统支持,网络支持,所有设备驱动的代码被加载到这个区域。该分区的代码和数据被保护起来了,如果应用程序试图读写这一分区的内存地址,会引用访问违规。另外,这一分区内的代码为系统中所有进程共享。

3.4. 分页

虚拟地址空间被分成以“页面”为单位,因为MMU(内存管理单元)是以页面为单位将虚拟地址转译成物理地址的。在X86下页面大小是4KB,在X64下页面大小是8KB,对于物理内存,也采用同样的分页方法。对于默认页面大小,32-bit程序运行在32-bit Windows上是4kb,64-bit程序运行在64-bit Windows上是8k。那么问题来了,32-bit进程运行在64-bit Windows上,页大小是4k还是8K? 答案仍然是4K。64位系统下,32位进程运行在WOW64子系统下,其模拟了32位进程的运行环境。分页方法的核心思想在于,当执行到虚拟地址空间的第x页时,就为第x页分配一个物理内存y,然后再将这个物理内存页添加到一个映射表中,这个映射表就相当于一个y=f(x)的函数,应用程序通过这个映射关系就能访问到x页对应的物理内存。

前面说过,创建虚拟地址空间实际是创建虚拟地址到物理地址的映射所需的数据结构,这种数据结构就是页表。可以将页表看成一个线性数组,线性(虚拟)地址到物理地址的映射可以简单理解成数组查找。x86下通常采用二级页表结构,第一级表称为页目录(page directory),第二级表称为页表(page table),二级页表也是遵循按需分配的原则,减少运行时内存占用量。页目录中每个PDE(page directory entry)记录对应页表信息。页表中每个PTE(page table entry)记录这个页对应的物理存储信息,x86下PTE占4个字节。根据PTE就可以计算出虚拟页对应的物理地址了,PTE中一些BIT也标示了当前页的一些属性。MMU就是通过页表来查询虚拟地址和物理地址的映射关系。另外,x86下支持PAE(Physical Address Extension),PAE和非PAE下使用PTE计算物理地址算法不一样。x86和x64下页表会有所不同,关于页表相关知识,网上有很多资料,此处就不多讲。

3.5. VirtualAlloc、HeapAlloc、malloc、new

当进程空间被创建并被赋予地址空间时,该可用地址空间主题是空闲的。若要使用地址空间的各部分需调用VirtualAlloc函数来分配其中的区域。对一个地址空间的区域进行分配的操作称为预定(reserve)。每当预定地址空间的一个区域时,系统要确保该区域从一个分配粒度的边界开始。目前所有CPU平台分配粒度都是64k,也就是说分配区域起始地址是64KB的整数倍。在Windows中可以使用GetSystemInfo获取页面大小和分配粒度大小。

在虚拟地址空间中预定(reserve)一块空间后,我们仍不能完全使用这块地址,若要使用已经reserve的地址区域,必须为其分配物理存储,这个过程叫做提交(commit)。commit同样也是通过VirtualAlloc来实现,函数的第三个参数flAllocationType,可以取MEM_RESERVE(保留)、MEM_RELEASE(释放)、和MEM_COMMIT(提交)等,当为MEM_COMMIT时,表示为当前地址空间调拨物理内存。此时第二个参数dwSize可以设置为reserve时大小一致,也可以小于reserve时大小,但需是页大小的整数倍(若不是,系统会帮你调成整数倍)。VirtualAlloc分配内存时,其第四个参数flProtect指定当前内存的页属性。程序不再需要访问所预定的地址空间区域时,使用VirtualFree释放该区域。

在C/C++语言中,堆上内存的分配和释放分别使用malloc/new,free/delete。实际上这两个分配内存函数最终都调用了HeapAlloc。HeapAlloc能够分配指定大小的内存,它最终是通过调用VirtualAlloc实现。HeapAlloc会在需要的时候调用VirtualAlloc。其基本思想是:VirtualAlloc会分配一块比较大的内存,HeapAlloc每次申请堆内存时,会从分配出来的虚拟内存块上指定一块给用户,当某一次HeapAlloc申请内存时发现虚拟地址空间没有足够的空闲内存,这时就会调用VirtualAlloc。HeapAlloc所分配内存使用HeapFree释放。

四. 进程的创建和运行

4.1. 进程的创建

我们来看下创建一个进程到底需要做哪些事情。Windows使用CreateProcess来创建一个进程,调用此函数会执行以下步骤。
(1)系统会先确定CrateProcess所指定的可执行文件所在位置。如果无法找到该.exe文件,那么系统不会创建进程,CreateProcess返回FALSE。
(2)系统创建一个新的进程内核对象。
(3)系统为新进程创建一个虚拟地址空间。
(4)系统首先会先读取PE文件中DOS头、PE头、区块表(section table)相关信息。
(5)系统使用区块表中相关信息,并预定足够大的空间,系统将PE文件中的所有区块映射到虚拟地址空间中相应的位置。需要注意的是PE文件的真正指令和数据还没有被装入物理内存中,只是建立了虚拟地址空间跟PE区块之间的映射。
(6)系统会对地址空间区域进行标注,表明该区域备用的物理存储器来自磁盘上的.exe文件,而并非系统的页交换文件(page file,也称页文件)。这一点很重要。
(7)装载PE文件所依赖的DLL文件。当系统把exe文件映射到进程地址空间之后,会访问.exe中的一个区块,这个区块列出了一些DLL文件,它们包含了该exe文件调用到的函数。然后调用LoadLibrary载入每个DLL,如果哪个DLL需要用到其它DLL,那么系统同样会调用LoadLibrary来载入相应的DLL。系统每次调用LoadLibarary载入一个DLL时,执行的操作与上面第4、5、6步类似。系统会预定足够大的空间来映射DLL文件。待预定的地址空间区域位置已经在DLL中指定。如果系统无法在DLL文件指定的基地址处预定区域,这可能是该区域已经被另一个DLL或者exe占用,也有可能因为区域不够大。目标地址不可用时,如果DLL包含rebasing(重定位)信息,系统会对DLL执行重定位操作,若DLL不包含重定位信息,则DLL无法被载入。重定位不仅需要占用页交换文件中的额外存储空间,而且会增加载入DLL所需的时间。同样,系统会对DLL载入的地址空间区域进行标注,表明该区域备用物理存储器来自磁盘上的DLL文件,而并非系统的页交换文件,如果发生重定位,那么系统还会另外进行标注,表明DLL中有一部分物理存储器被映射到了页交换文件。
(8)把所有的exe和DLL文件都映射到进程的地址空间之后,系统会开始执行exe中的启动代码。

此处补充一下,我们多次提到所谓映射,PE在装载过程中,是按需把虚拟地址空间映射到磁盘空间(PE文件位置,也是backing stroe),而这个映射实际上只是一些数据结构的建立,PE中代码和数据等并没有被载入物理内存,这节省了进程启动运行所需时间。实际上系统通过memory-mapped file(内存映射文件)来建立这种映射。例如,当一个dll已经被映射到A进程的虚拟地址空间时,B进程再加载它的时候,只需获取到这个dll的memory-mapped file object建立dll文件到B进程虚拟地址空间的映射,建立映射只需要读取PE头和段(块)表少量信息,速度很快。B进程加载dll完成,首次调用到dll中某个函数,假设该函数代码所在页已经在A进程中被加载到物理内存,那么此时B进程只需要将对应的虚拟页和已经存在的物理页建立建立映射(更新页表),然后即可继续调用该函数,并不需要重新从磁盘载入。

4.2. 内存映射文件

创建一个内存映射文件(下文简简称MMF)Windows中API为:

HANDLE CreateFileMappingA(
  HANDLE                hFile,
  LPSECURITY_ATTRIBUTES lpFileMappingAttributes,
  DWORD                 flProtect,
  DWORD                 dwMaximumSizeHigh,
  DWORD                 dwMaximumSizeLow,
  LPCSTR                lpName
);

注意到CreateFileMappingA第一个参数hFile,这是一个被映射的文件的句柄,如果为0xFFFFFFFF则使用系统页交换文件。通常使用MMF来多进程通信时,会将文件设置为页交换文件。关于MMF使用,此处就不多讲,详情参考MSDN。下面简单讲下MMF的原理。

我们知道在使用MMF时会将文件映射到虚拟地址空间一段区域,当程序通过指针操作这段内存,操作系统则会自动将对应脏页面(dirty page回写到磁盘文件上)。这个过程并没有调用到系统CreateFile、ReadFile等系统函数就完成了文件读写。我们稍微细化MMF对文件读写过程:(1)当程序读写MMF在虚拟地址映射的某个地址时,发现该地址所在虚拟页没有对应物理页,此时会报一个page fault(下文还会讲);(2)页错误会引起缺页中断,进程切换到内核态对该中断进行处理,判断无非法操作后,会将磁盘上文件对应也内容拷贝到内存,然后建立虚拟页到物理页的映射;(3)程序切换到用户模式从开始中断的地方继续运行,此时就可以正常对这个地址读写了;(4)如果对当前地址数据进行了修改,那么这个页会被标记为脏页(dirty page),这个标记位存储在页表项(PTE)中,系统会自动将dity page数据写回磁盘文件中,不过这个写过程并不实时。MMF对文件操作依赖于系统的缺页中断机制,对比标准的文件IO,MMF操作文件会很多(可去了解文件IO过程)。

4.3. 工作集(working set)

进程启动时内存中是没有任何物理页的。当CPU试图执行第一条指令,会出现页错误(page fault),操作系统会处理并载入包含第一条指令的物理页面。很快,全局变量和堆栈等其它页面错误也会出现。过一段时间后,进程拥有可供它运行所需的大部分页面,并且在较少页错误的情况下运行。这种策略叫做demand paging,物理内存页只会在需要的时候加载。当内存不足时,物理内存会被交换到磁盘上页文件中;当发生页错误时,如果当前页backing store在磁盘页文件中,那么该页将被重新加载。如果页面在磁盘和内存中频繁交换,进程会发生抖动(thrashing),显然抖动时频繁的IO严重影响程序性能。

实际上大部分程序对虚拟地址空间的引用并不是均匀的,大多数频繁引用都集中在一小部分页面。我们把cpu在某段时间t内所引用的最近k个页面看成一个集合,会发现这个页集合随时间变化很缓慢,这也是局部性原理的一种体现。工作集原理实际上也是基于此。工作集(working set),也叫驻留集,它指的是已驻留于物理内存中的虚拟页面集合。working set只包含可分页(pageable)的内存分配,不包含非分页的内存分配,如AWE(Address Windowing Extensions)以及大页面分配(large page allocations)。AWE允许应用程序快速操作大于4GB的物理内存,它解决了使用32bit指针寻址大块内存的问题。large page support让应用程序(特别是数据库或者服务器)能够建立大页面内存区域,其页大小通常是2M或者更大。当物理内存较为充足时,相对于4kb页面,large page减少页表的级数,也就减少了查找页表的内存访问次数,并大量减少了缺页中断的数量,显著提高频繁访问内存时候的性能。AWE和large page support所分配内存并不在Working set中。

working set大小是以页为单位,它跟页错误息息相关。当程序访问的页面不再working set中的时候,会发生页错误。反过来,我们可以认为working set是不引起页错误异常就能够访问的内存页集合。工作集会在需要的时候对页面进行换入还出。现代操作系统中,系统会把工作集页面放入cpu的cache中,这极大提高程序性能。

4.3. 页错误

进程刚创建完成后,可执行文件的真正指令和数据都没有被装入到内存中。操作系统只是通过可执行文件头部信息建立起可执行文件和虚拟地址空间的映射关系而已。例如,当进程运行时,CPU需要访问某个虚拟地址,它会交给CPU的MMU单元进行内存寻址,找到实际的物理内存。但是,当所引用页面不在当前工作集中,即在物理内存中并没有对应页帧时候,CPU无法获取数据进行运算。此时CPU会报告一个缺页错误(Page Fault),进程发生中断,这个中断叫做缺页中断。进程会从用户态切换到内核态来处理Page Fault。缺页错误通常可以分为两种:硬缺页错误(hard page fault)和软缺页错误(soft page fault)。Page Fault Handler会判断缺页的类型,进而处理缺页错误。当中断处理完成后,控制权会还给进程,进程从刚才页错误位置再次开始执行。

hard page fault的处理通常需要从backing store中读取页内容。有两种情况:
a)无法找到虚拟页对应的物理页,物理页被交换到页交换文件(page file),此时这个backing store是磁盘上page file。页表PTE项中有标志位标示当前虚拟页是有物理页,;
b)另一种情况是从memory-mapped file读取对应页内容到物理内存。Windows使用MMF的方式将PE文件映射到进程的虚拟地址空间, 在装载完成后会将映像文件当成虚拟地址空间的backing store(但此后却不一定,比如发生copy-on-write时)。

soft page fault的处理不需要访问backing store。处理soft page fault不需要去访问backing store,其通常通常发生于这几种情况:
a)所需物理页位于其它进程的working set中,因此它已经驻留在内存中。Windows允许多个进程共享同一块物理内存。
b)该页(通常是shared page)正在转换(transition page),它已从使用该页的所有进程的工作集中删除,并且没有被重新利用。
c)进程第一次引用分配(如malloc的内存)的虚拟页会触发soft page fault,也叫demand-zero page fault。当程序在虚拟地址空间预定一块空间,并不会马上为其分配相应的物理页内存,只有程序运行时用到了才会去寻找虚拟地址对应的物理内存页帧,找不到才进行分配,这是一种懒惰(延时)分配机制。内存的懒惰分配(lazy loading)大大提高内存的利用率。
d)所需物理页已经被内存管理器预读(Prefetch)到了物理内存。Windows系统有一个Prefetch支持,其目的是加快进程的启动速度。在程序启动时发生缺页中断(即使不是硬缺页错误),The Windows Prefetcher会记录程序访问的文件及位置,并将其保存在c:\windows\prefetch中的.PF文件中。当下次启动程序时,系统会先查询这个PF文件,然后它立即发出这个文件中指定的所有文件I/O操作,待其完成再继续程序运行。实际上由于空间局部性原理,缺页中断引发磁盘I/O时,一般不会只取所需要的那一页数据,而是把相邻的数据都读取到物理内存。所以Prefetcher会把一些相邻或者接近的I/O操作合并成一次大的I/O操作,这减少了磁盘IO次数,提高了程序的启动速度。

处理硬缺页错误时,需要进行磁盘IO操作(实际上使用MMF),如果频繁的出现出硬缺页错误,会发生上文所说抖动。

4.4. 写时复制

Windows许两个或两个以上的进程共享同一块物理内存。因此,如果有10个记事本程序正在运行,所有的进程会共享物理内存中的相同的代码页和数据页,对多个进程引用同一个dll也是如此。这种机制提高了系统性能,但另一方面,这也要求所有的应用程序实例只能读取其中数据或是执行其中的代码,如果一个程序修改了了某个数据页,那么等于是修改了其他实例正在使用的存储页,最终将导致混乱。Windows会通过内存管理系统的写时复制(copy-on-write)特性来防止这种事情发生。此处可以参考核心编程13.6.1和17.1.1章节。

上文提到的VirtualAlloc分配内存时,它有一个参数flProtect,这个参数定义了所分配页的保护属性, 其取值如下:
PAGE_NOACCESS 试图读取页面、写入页面或执行页面中的代码将引发访问违规
PAGE_READONLY 试图写入页面或执行页面中的代码将引发访问违规
PAGE_READWRITE 试图执行页面中的代码将引发访问违规
PAGE_EXECUTE 试图读取页面或写入页面将引发访问违规
PAGE_EXECUTE_READ 试图写入页面将引发访问违规
PAGE_EXECUTE_READWRITE 对页面执行任何操作都不会引发访问违规
PAGE_WRITECOPY 试图执行页面中的代码将引发访问违规。试图写入页面将使系统为进程单独创建一份该页面的私有副本(以页交换文件为后备存储器)
PAGE_EXECUTE_WRITECOPY 对页面执行任何操作都不会引发访问违规。试图写入页面将使系统为进程单独创建一份该页面的私有副本(以页交换文件为后备存储器)

除最后两个属性PAGE_WRITECOPY和PAGE_EXECUTE_WRITECOPY之外,其余的都不言自明。这两个保护属性表明当前页需要copy-on-wirite,其存在的目的是为了节省内存和页交换文件的使用。通常,包含代码的页面被标记为PAGE_EXECUTE_READ,而包含数据的页面被标记为PAGE_READWRITE。

copy-on-write是典型的lazy evaluation(内存管理器经常使用到这种技术)例子。Lazy-evaluation算法避免了一些消耗性能的操作,除非这些操作绝对需要的时候。当线程对一个具有read/write属性的页进行写操作的时候(如修改一个数据页的全局变量),系统会截获这种尝试并进行以下操作:
1)系统在内存(ram)中找到一个闲置页面,该闲置页面后备存储(backing store)来自于page file)。当系统创建进程的时候,会检查文件映像中所有页面,对那些用写时复制属性进行保护的页面(通常是PE中数据(data)段),系统会立即从页交换文件中为其调拨存储器(把页交换文件看作虚拟内存-物理内存扩展,此处可理解成在虚拟内存上为其预分配空间,此时并不会载入内容,只有发生写时复制的时候,这些page file中页面才可能发生写入)。
2)系统把线程想要修改的页面内容复制到第1步中找到的闲置页面。系统会给该闲置页面指定PAGE_READWRITE或者PAGE_EXECUTE_WRITECOPY属性,系统不会对原始页面的保护属性和页面数据做任何修改。
3)系统将更新进程的页表,这样一来,原来的虚拟地址现在对应了了ram中的一个新页面了,这也是对应的backing store也变成了磁盘上page file。此时对这个全局数据进行修改,也不会影响其它实例的数据了。

Windows中将ram中已经修改过的页面叫做dirty page,在页表PTE中有一个标志位表示物理页是否被修改过。程序运行时,ram和页交换文件中不应该存在两份相同的页面。Windows的内存管理组件中有一个modified page writer,它会在需要的时候(典型如内存压力下需要一个新页,但并不止于此)将dirty pages写到页交换文件中去。

4.5. 页文件

操作系统能让磁盘空间看起来像内存一样。磁盘上的文件一般被称为页交换文件(paging file或者page file),其中包含虚拟内存,可供任何进程使用。页文件存储了进程一些被修改过的页面,这些页面仍然在被程序使用,只过不因为被取消映射或者内存压力不得不被写入磁盘。

从应用程序的角度来说,页交换文件以一种透明的方式增大了应用程序可用内存(或存储器)的总量。如果一台机器装备了1 GB的内存,硬盘上还有1GB的页交换文件,那么应用程序会认为可用内存的总量为2GB。实际上是操作系统与CPU分工协作,把内存中的一部分保存到页交换文件中,并在应用程序需要的时候再将页交换文件中的对应部分载入内存。页交换文件相当于扩展了物理内存,这样系统可以运行更多的应用程序。

当应用程序调用VirtualAlloc函数来把物理存储器调拨(commit)给地址空间区域时,该空间实际上是从硬盘上的页交换文件分配得到的。《Windows核心编程》中这句话一度让我怀疑其翻译出错,事实上它只是过于简略,没有道出其中细节。上文讲过VirtualAlloc分配内存时,使用MEM_RESERVE预定一块地址空间地址,使用MEM_COMMIT时调拨物理内存。实际上commit的时候物理内存并没被分配,这也不符合操作系统的lazy策略。commit只是告诉系统这个地址可用,当内存第一次被写入时才会真正为其分配物理内存。根据《Windows Internals 7th》,其较详细过程如下
1)reserve只是在虚拟地址空间预定一块没映射过的连续空间,这个操作消耗极少系统资源。
2)commit操作时,在页表中创建虚拟地址块对应的一些PTE项,并将这些PTE项都标示为Invalid。对于核心编程中所说“该空间实际上是从硬盘上的页交换文件分配得到的”,我认为其表述并不准确。我理解的是:在commit时为了保证有足够的内存可以使用到(无论是ram还是page file),系统只是确定page file中是否有足够的大小可用来交换。
3)当程序第一次访问步骤2中所commit过的虚拟地址指针时,MMU发现该指针所在页的PTE(页表项)是Invalid,此时会产生demand-zero page faults,这是一个soft page fault,系统发生中断并处理:从空闲页列表(free list)获取一个页并将其填充为0;如果空闲页列表为空,则从备用列表中(standby list)中取出一个页填0。中断处理完后返回进程用户态,重新从中断开始的地方执行,此时就可以正常对这个地址进行写入。

下面为流程图来自与核心编程,简略描述了页文件使用:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VFf9rpFA-1619575454106)(http://km.oa.com/files/photos/pictures/202012/1607256831_64_w1070_h1114.png)]

后记

写文章既是分享也是学习的过程。写此文过程中,越是深入,发现的问题也越多,文章写得比较吃力,加上这几个月没做Windows开发,所以断断续续花了较长时间,但总算有始有终。限于篇幅,文中有些东西只是提及,想要进一步深入可以参考结尾资料。若发现文中表达有误或者不准确地方,烦请指出。

主要参考资料:
《Windows核心编程》
《Microsoft Windows Internals》
《程序员的自我修养》,
《加密解密》
《Windows内核原理与实现 》
《MSDN文档》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值