Amiya · 2023年08月14日 · 北京市

剖析虚幻渲染体系(18)- 操作系统(2)

18.9 内存

内存管理包含的内容有:逻辑和物理地址映射、内存分配、内部和外部碎片和压缩、分页,以及虚拟内存:请求分页、页面替换策略。本章将详细阐述内存相关的概念、技术、原理和机制。

18.9.1 内存基础

今天的现代Intel/AMD处理器在内存方面开始非常有限,最初的8086/8088处理器仅支持1 MB内存(物理内存,因为当时没有其他内存),对内存的每次访问都是段地址和偏移量的组合,在处理器内部使用16位值,但内存访问需要20位(1 MB)。段寄存器的值(16位)乘以16(0x10),然后添加偏移量以达到1MB范围内的地址。这种工作模式现在被称为实模式(Real Mode),并且仍然是当今Intel/AMD处理器的唤醒模式。

随着80386处理器的引入,虚拟内存诞生了,因为它基本上是今天使用的,包括通过只使用偏移量线性访问内存的能力(段寄存器刚刚设置为零),使得内存访问更加方便。虚拟内存意味着每个内存访问都需要转换到物理地址所在的位置,此模式称为保护模式(Protected Mode)。在保护模式下,无法直接访问物理内存,只能通过从虚拟地址到物理地址的映射,此映射必须由操作系统的内存管理器预先准备,因为CPU需要此映射。

在64位系统上,保护模式称为长模式(Long Mode),但本质上是相同的机制,扩展到64位地址。

虚拟地址和物理地址之间的映射,以及操作系统级内存块的管理,都是在称为页面的块中执行的。页面是必要的,因为不可能管理每个字节——管理结构将比该字节大得多。下表列出了Windows支持的所有架构的页面大小。

image.png

小(正常)页面是默认页面,页面通常表示小页面或正常页面,在所有架构中都是4KB。如果提到不同的页面大小,通常伴随一个前缀:大或巨大。不同的页面大小和页面帧数量,对应的页面错误比率如下:

image.png

18.9.2 内存机制

内存管理与管理主内存有关,内存由字节或单词数组组成,每个字节或单词都有自己的地址。CPU根据值程序计数器从内存中提取指令,这些指令可能会导致从特定内存地址进行额外加载和存储。内存单元只看到内存地址流,它不知道它们是如何生成的。程序必须被放入内存并放置在进程中才能运行。输入队列是磁盘上等待进入内存执行的进程的集合。用户程序在运行之前要经过几个步骤。

内存管理功能:跟踪每个内存位置的状态,确定分配策略——内存分配技术和取消分配技术。

内存管理:逻辑和物理地址映射、内存分配、内部和外部碎片和压缩、分页;虚拟内存:请求分页、页面替换策略。

18.9.2.1 地址绑定

程序作为二进制可执行文件存储在辅助存储磁盘上。当程序要执行时,它们被放入主内存并放在一个进程中,磁盘上等待进入主内存的进程集合形成输入队列,将要执行的进程之一从队列中取出并放入主内存中。在执行期间,它从主内存中获取指令和数据,进程终止后,它返回内存空间。在执行过程中,该进程将经历不同的步骤,在每个步骤中,地址以不同的方式表示。在源程序中,地址是符号,编译器将符号地址转换为可重新定位的地址,加载程序将把这个可重新定位的地址转换为绝对地址。

image.png

指令和数据的绑定可以在进程中的任何步骤完成:

  • 编译时期:如果我们知道进程是否驻留在内存中,那么就可以生成绝对代码。如果静态地址更改,则需要从头重新编译代码。
  • 加载时期:如果编译器不知道进程是否驻留在内存中,那么它会生成可重新定位的代码。在这种情况下,绑定被延迟到加载时间。
  • 执行时期:如果进程在执行期间从一个内存段移动到另一个,则绑定将延迟到运行时。为此需要使用了特殊硬件,大多数通用操作系统都使用这种方法。

18.9.2.2 逻辑和物理地址

CPU生成的地址称为逻辑地址或虚拟地址。内存单元看到的地址,即加载到内存寄存器的地址,称为物理地址。编译时和加载时地址绑定方法生成一些逻辑地址和物理地址,执行时地址绑定生成不同的逻辑地址和物理地址。

程序生成的逻辑地址空间集是逻辑地址空间,与这些逻辑地址对应的物理地址集是物理地址空间。在运行时,虚拟地址到物理地址的映射是由称为内存管理单元(MMU)的硬件设备完成的。

基址寄存器也称为重定位寄存器,重新定位寄存器的值将添加到用户进程在发送到内存时生成的每个地址。用户程序永远看不到真实的物理地址。下图显示了动态关系,动态关系意味着从虚拟地址空间到物理地址空间的映射,通常在运行时通过一些硬件协助执行。

image.png

使用重新定位寄存器的动态重新定位上图显示了动态重新定位,意味着从虚拟地址空间映射到物理地址空间,并由硬件在运行时执行。重新定位由硬件执行,用户无法看到动态重新定位,因此可以将部分执行的进程从内存的一个区域移动到另一个区域,而不会产生影响。具体的例子:

  • 如果基数为14000,则用户尝试寻址位置0时会动态重新定位到位置14000,对位置346的访问被映射到位置14346。
  • 用户程序永远看不到实际的物理地址。
  • 该程序可以创建指向位置346的指针,将其存储在内存中,对其进行操作,并将其与其他地址进行比较,所有这些地址都是数字346。
  • 只有当它用作内存地址(可能在间接加载或存储中)时,它才会相对于基址寄存器进行重新定位。
  • 用户程序处理逻辑地址。
  • 内存映射硬件将逻辑地址转换为物理地址。
  • 我们现在有两种不同类型的地址:逻辑地址(范围从0到最大)和物理地址(对于基值R,范围从R+0到R+max)。
  • 用户程序只生成逻辑地址,并认为进程在0到最大位置运行。然而,在使用这些逻辑地址之前,必须将其映射到物理地址。
  • 逻辑地址空间绑定到单独的物理地址空间的概念是正确的内存管理的核心。

动态加载:对于要执行的进程,应该将其加载到物理内存中,进程的大小仅限于物理内存的大小,动态加载用于获得更好的内存利用率。在动态加载中,除非调用例程或过程,否则不会加载它。每当调用例程时,调用例程首先检查被调用例程是否已加载。如果没有加载,它会导致加载程序将所需的程序加载到内存中,并更新程序地址表,以指示更改和控制传递给新调用的例程。

优势:提供更好的内存利用率,从不加载未使用的例程,不需要特殊的操作系统支持。当在频繁发生的情况下需要处理大量代码时,此方法非常有用。

交换是一种暂时从系统内存中删除非活动程序的技术。进程可以暂时从内存中交换到后备存储,然后再回到内存中继续执行。此过程称为交换。例如:

image.png
示例:

  • 在具有循环CPU调度的多编程环境中,只要时间段到期,刚完成的进程就会被换出,新进程会换入内存以执行。
  • 交换的一种变体是基于优先级的调度。当低优先级正在执行时,如果高优先级进程到达,则低优先级将被调出,并允许执行高优先级。此过程也称为推出(Roll out)和推入(Roll in)。
  • 通常,被调出的进程将被调回先前占用的相同内存空间,取决于地址绑定。
  • 如果绑定是在加载时完成的,那么进程将移动到相同的内存位置。如果绑定是在运行时完成的,那么进程将移动到不同的内存位置。因为物理地址是在运行时计算的。
  • 交换需要备份存储,并且它应该足够大,能够容纳所有内存映像的副本。系统维护一个就绪队列,该队列由内存映像位于后备存储或内存中的所有进程组成,这些进程已准备好运行。

交换的决定因素:要交换进程,它应该完全空闲;进程可能正在等待I/O操作;如果I/O异步访问I/O缓冲区的用户内存,则无法交换进程。逻辑地址和物理地址对比如下表:

image.png

连续内存分配:内存分配的最简单方法之一是将内存划分为几个固定分区,主内存通常分为两个分区:常驻操作系统,通常用中断向量保存在低内存中;用户进程,保存在高内存中。每个分区正好包含一个进程,多编程的程度取决于分区的数量。

内存映射和保护:内存保护意味着保护操作系统不受用户进程的影响,保护进程不受其他进程的影响。通过使用带有限制寄存器的重新定位寄存器提供内存保护,重新定位寄存器包含最小物理地址的值,限制寄存器包含逻辑地址的范围。(重新定位=100040,限制=74600)。

image.png
逻辑地址必须小于限制寄存器,MMU通过在重新定位寄存器中添加值来动态映射逻辑地址。当CPU调度程序选择要执行的进程时,作为上下文切换的一部分,调度程序加载具有正确值的重新定位和限制寄存器。由于CPU生成的每个地址都会根据这些寄存器进行检查,因此我们可以保护操作系统和其他用户的程序和数据不被修改。

18.9.2.3 内存分配

多分区分配:

孔洞(hole)是可用内存块,不同大小的孔洞分散在内存中。当进程到达时,会从一个足够容纳它的洞中分配内存。操作系统维护的信息有分配的分区、自由隔板(孔)。一组大小不同的孔,在任何给定的时间分散在内存中。

当一个进程到达并需要内存时,系统会搜索这个集合以查找一个足够大的洞来容纳这个进程。如果孔洞太大,则将其分为两部分:一部分分配给到达进程,另一个返回到孔洞组。

当进程终止时,它会释放内存块,然后将其放回孔洞集。如果新孔洞与其他孔洞相邻,则这些相邻的孔洞将合并为一个较大的孔洞。此过程是一般动态存储分配问题的一个特殊实例,即如何满足空闲孔洞列表中大小为n的请求。

image.png

这个问题有很多解决方案。搜索孔洞集以确定最佳分配的孔,首个匹配、最佳匹配和最差匹配策略是用于从可用孔集中选择自由孔的最常用策略。选择自由孔洞的三种策略说明:

  • 首个匹配(First fit):分配第一个足够大的孔洞。该算法从一开始就扫描内存,并选择第一个足够大的可用块来保存进程。
  • 最佳配合(Best fit):选择尺寸最接近要求的孔洞,分配最小的孔洞,即足够大的孔洞来容纳进程。
  • 最差匹配(Worst fit):将最大的孔洞分配给进程请求,搜索整个列表中最大的孔洞。

First-fit和best-fit是最流行的动态内存分配算法,First-fit通常更快,最佳匹配搜索整个列表以查找最小的孔,即足够大的孔。最差匹配会降低最小孔洞的生成速率。所有这些算法都存在碎片化问题。

18.9.2.4 内存碎片和整理

当进程被加载并从内存中删除时,空闲内存空间被分割成小块。一段时间后,我们从内存中删除的进程无法分配,因为内存中有小块内存,内存块仍然未使用。这个问题称为碎片化。

或者,当内存在系统中按顺序/连续分配时,会出现碎片,不同的文件以块的形式存储在内存中,当频繁修改或删除这些文件时,中间会留下间隙/可用空间,称为碎片。

内存碎片可以有两种类型:

  • 内部碎片。由于加载的数据块小于分区,因此部分内部存在浪费的空间。这种情况下,可用空间太小,无法容纳任何文件。跟踪可用空间的开销对于操作系统来说太大了,这种便是内部碎片。示例:如果有一个50kb的块,并且进程请求40kb,如果该块被分配给进程,则剩余10kb内存。
  • 外部碎片。当存在足够的内存空间来满足请求,但不连续时即存在,即存储被分割成大量的小孔洞。外部碎片可能是小问题,也可能是大问题。如图所示,如果我们想将一个等于释放空间总和的文件放入内存,则不能,因为空间不是连续的。因此,尽管有足够的内存来保存文件,但由于内存分散在不同的位置,我们无法容纳它。这就是外部碎片。

image.png
image.png

克服外部碎片的一个解决方案是压缩(compaction)。目标是将所有空闲内存移动到一起,形成一个大的块。压缩并非总是可行的,如果重新定位是静态的,并且是在装载时完成的,则不可能进行压实。如果重新定位是动态的,并且在执行时完成,则可以进行压缩。

image.png

外部碎片问题的另一个可能的解决方案是允许进程的逻辑地址空间是非连续的,从而允许在物理内存可用时为进程分配物理内存。

固定和动态分区方案都有缺点。固定分区方案限制了活动进程的数量,如果可用分区大小和进程大小不匹配,则可能会低效率地使用空间。动态分区方案维护起来更复杂,并且包括压缩的开销。一个有趣的折衷方案是伙伴系统(Buddy System)。

在请求分配大小为k(即$2^{i-1}<k\le2^i$)的情况下,使用以下递归算法查找大小为$2^i$的孔:

void get_hole(int i) 
{
    if(i==(U+1)) <failure>;
    if(<i_list_empty>)
    {
        get_hole(i+1);
        <split hole into buddies>;
        <put buddies on i_list>;
    }
    <take first hole on i_list>;
}

下图给出了使用1M字节初始块的示例。第一个请求A是100K字节,需要128K块,最初的区块分为两个512K伙伴,第一个被分成两个256K的伙伴,其中第一个被分成两个128K伙伴,其中一个分配给A。下一个请求B需要256K块,这样的块已经可用并已分配。该过程继续,根据需要进行拆分和合并。请注意,当释放E时。两个128K好友合并成256K区块,其立即与其伙伴合并。

image.png
下图显示了释放B请求后立即的好友分配的二叉树表示,叶节点表示内存的当前分区。如果两个伙伴是叶节点,则必须至少分配一个,否则它们将合并为一个更大的块。

image.png
伙伴系统的树表达。

image.png
将进程分配给自由帧。

18.9.2.5 分页

大多数虚拟内存系统都使用一种称为分页的技术。在任何计算机上,程序都引用一组内存地址,当程序执行如下指令时:

MOV REG, 1000

这样做是为了将内存地址1000的内容复制到REG(或反之,具体取决于计算机)。地址可以使用索引、基址寄存器、段寄存器和其他方式生成。

image.png
MMU的位置和功能。这里显示的MMU是CPU芯片的一部分,因为它现在很常见。然而,从逻辑上讲,它可能是一个单独的芯片,而且是多年前的事了。

image.png
MMU的内部操作。

image.png
硬件支持的重分配。

这些程序生成的地址称为虚拟地址,构成虚拟地址空间。在没有虚拟内存的计算机上,虚拟地址直接放在内存总线上,并导致读取或写入具有相同地址的物理内存字。当使用虚拟内存时,虚拟地址不会直接到达内存总线。相反,它们进入MMU(内存管理单元),将虚拟地址映射到物理内存地址,如上图所示。

下图显示了该映射工作原理的一个非常简单的示例。在这个示例中,我们有一台计算机可以生成16位地址,从0到64K− 1,这些是虚拟地址。然而,这台计算机只有32 KB的物理内存。因此,尽管可以编写64KB的程序,但它们不能全部加载到内存中并运行。然而,一个程序核心映像的完整副本(最大为64KB)必须存在于磁盘上,这样才能根据需要放入各个部分。

虚拟地址空间由称为页面的固定大小单元组成。物理内存中的相应单元称为页帧(page frame)。页面和页帧通常大小相同。在本例中,它们是4KB,但实际系统中使用的页面大小从512字节到1GB不等。通过64KB的虚拟地址空间和32KB的物理内存,我们得到了16个虚拟页面和8个页帧。RAM和磁盘之间的传输始终是整页的,许多处理器支持多种页面大小,可以根据操作系统的需要进行混合和匹配。例如,x86-64体系结构支持4-KB、2-MB和1-GB页面,因此我们可以为用户应用程序使用4-KB页面,为内核使用单个1-GB页面。

image.png
虚拟地址和物理内存地址之间的关系由页表给出。每个页面以4096的倍数开始,以4095个地址结尾,因此4K–8K实际上意味着4096–8191,8K到12K意味着8192–12287。

分页是一种允许进程的物理地址空间不连续的内存管理方案。分页支持由硬件处理,用于避免外部碎片。分页可以避免将不同大小的内存块安装到后备存储上的相当大的问题。当主存储器中的某些代码或日期需要交换时,必须在后备存储器中找到空间。物理内存分为固定大小的块,称为帧(f)。逻辑内存被分成大小相同的块,称为页(p)。当一个进程要执行时,它的页面会从后备存储器加载到可用的帧中。块存储也被划分为与内存帧大小相同的固定大小的块。

image.png

例如,如果操作系统决定退出第1页帧,它将在物理地址4096加载虚拟第8页,并对MMU映射进行两次更改。首先,它将虚拟页1的条目标记为未映射,以捕获将来对4096和8191之间虚拟地址的任何访问。然后,它将用1替换虚拟页8条目中的叉,以便在重新执行捕获的指令时,它将把虚拟地址32780映射到物理地址4108(4096+12)。

现在让我们看看MMU内部,看看它是如何工作的,以及为什么我们选择使用2的幂次方的页面大小。在下图中,我们看到了一个虚拟地址8196(二进制00100000000000100)的示例,它使用MMU映射进行映射。传入的16位虚拟地址被拆分为4位页码和12位偏移量。用4位表示页码,我们可以有16页,用12位表示偏移量,我们可以寻址一页中的所有4096字节。

image.png
16个4-KB页面的MMU内部操作。

image.png
典型的页表条目。

页码用作页表的索引,生成对应于该虚拟页的页框架的编号。如果当前/缺失位为0,则会导致操作系统陷阱。如果位为1,则页表中找到的页帧编号将与12位偏移量一起复制到输出寄存器的高位3位,12位偏移是从传入虚拟地址原样复制的。它们一起构成了一个15位的物理地址。然后将输出寄存器作为物理内存地址放在内存总线上。

image.png
两级层级页表。

image.png
两级分页系统的地址转换。

CPU生成的逻辑地址分为两部分:页码(p)和页面偏移量(d)。页码(p)用作页表的索引,页表包含物理内存中每个页的基址。此基址与页偏移量相结合,以定义物理内存,即发送到内存单元。下图显示了分页硬件:

image.png

页面大小由硬件定义。2的幂的大小,每页在512字节和10Mb之间变化。如果逻辑地址空间的大小为2m地址单元,页面大小为2n,则高位m-n表示页码,低位n表示页面偏移量。

image.png
image.png
反向页表结构。

示例:结合下图,要显示如何将中的逻辑内存映射到物理内存,请考虑4字节的页面大小和32字节(8页)的物理内存。

  • 逻辑地址0是第0页,偏移量为0,第0页在第5帧(frame)中。因此,逻辑地址0映射到物理地址是$[(5*4)+0]=20$。
  • 逻辑地址3是第0页,偏移量3映射到物理地址是$[(5*4) + 3]=23$。
  • 逻辑地址4是第1页,偏移量为0和第1页映射到帧6。因此,逻辑地址4映射到物理地址是$[(6*4)+0]=24$。
  • 逻辑地址13是第3页,偏移量1和第3页映射到帧2。因此,逻辑地址13映射到物理地址是$[(2*4)+1]=9$。

image.png
image.png
转换后备缓冲区的使用。

当一个进程到达要执行的系统时,将检查其大小(以页表示)。进程的每一页都需要一个帧。因此,如果进程需要n个页面,那么内存中必须至少有n个帧可用。如果n个帧可用,则将它们分配给此到达进程。进程的第一页被加载到一个分配的帧中,帧编号被放入该进程的页表中。下一页被加载到另一个帧中,其帧编号被放入页面表中,依此类推。

image.png

由于操作系统正在管理物理内存,因此它必须知道物理内存的分配细节,即分配了哪些帧,哪些帧可用,总共有多少帧,等等。这些信息通常保存在称为帧表的数据结构中。帧表为每个物理页帧都有一个条目,指示后者是空闲的还是已分配的,如果已分配,则指示哪个进程或进程的哪个页面。

分页替换算法有:

  • 最优页面替换算法(The Optimal Page Replacement Algorithm)
  • 最近未使用的页面替换算法(The Not Recently Used Page Replacement Algorithm)
  • 先进先出页面替换算法(The First-In, First-Out Page Replacement Algorithm)
  • 第二次机会页面替换算法(The Second-Chance Page Replacement Algorithm)
  • 时钟页面替换算法(The Clock Page Replacement Algorithm)
  • 最近最少使用(LRU)的页面替换算法(The Least Recently Used Page Replacement Algorithm)
  • 软件模拟LRU(Simulating LRU in Software)
  • 工作集页面替换算法(The Working Set Page Replacement Algorithm)
  • WSClock页面替换算法(The WSClock Page Replacement Algorithm)

页面替换算法总结如下表所示:

image.png

最佳算法将逐出将来引用最远的页面。不幸的是,无法确定这是哪个页面,因此在实践中无法使用此算法。然而,它可以作为衡量其他算法的基准。

NRU算法根据R和M位的状态将页面分为四类。从编号最低的类中随机选择一页。此算法很容易实现,但很粗糙,有更好的存在。

FIFO通过将页面保存在链接列表中来跟踪页面加载到内存中的顺序。然后删除最旧的页面就变得很简单,但该页面可能仍在使用中,因此FIFO是一个错误的选择。

第二次机会是修改FIFO,在删除页面之前检查页面是否正在使用。如果是,则该页面是空闲的,此修改大大提高了性能。时钟只是第二次机会的不同实现,具有相同的性能属性,但执行算法所需的时间稍短。

LRU是一种很好的算法,但如果没有特殊的硬件就无法实现。如果此硬件不可用,则无法使用。NFU是近似LRU的粗略尝试,不是很好。然而,老化是LRU的一个更好的近似值,可以有效实施,是一个很好的选择。

最后两个算法使用工作集。工作集算法提供了合理的性能,但实现起来有些昂贵。WSClock是一种变体,它不仅提供了良好的性能,而且实现效率也很高。

总之,最好的两种算法是老化和WSClock。它们分别基于LRU和工作集,两者都具有良好的分页性能,并且可以有效地实现。还有其他一些好的算法,但这两个算法在实践中可能是最重要的。

18.9.2.6 地址转换

页面地址称为逻辑地址,由页码和偏移量表示:

Logical Address = Page number + page offset

帧地址称为物理地址,由帧编号和偏移量表示:

Physical Address = Frame number + page offset

称为页映射表的数据结构用于跟踪进程的页与物理内存中的帧之间的关系。

image.png

当系统为任何页面分配一个帧时,它会将该逻辑地址转换为物理地址,并在页面表中创建条目,以便在整个程序执行过程中使用。

当一个进程被执行时,它对应的页面被加载到任何可用的内存帧中。假设有一个8Kb的程序,但在给定的时间点,内存只能容纳5Kb,那么分页概念就会出现。当计算机的RAM用完时,操作系统(OS)会将空闲或不需要的内存页移到辅助内存中,以便为其他进程释放RAM,并在程序需要时将其恢复。

在程序的整个执行过程中,操作系统会不断从主内存中删除空闲页面,并将其写入辅助内存,然后在程序需要时将其恢复。

分页的优缺点:

  • 分页可以减少外部碎片,但仍会受到内部碎片的影响。
  • 分页实现简单,被认为是一种高效的内存管理技术。
  • 由于页面和帧的大小相等,交换变得非常容易。
  • 页表需要额外的内存空间,因此对于RAM较小的系统可能不太好。

18.9.3 进程地址空间

每个进程都有自己的线性、虚拟和私有地址空间,地址空间开始于地址零,结束于某个最大值,基于操作系统位(32或64)和进程位。下图展示了不同进程在私有内存地址空间下指向的实际物理地址,物理地址包含RAM和磁盘。

image.png

进程可以直接访问自己地址空间中的内存,意味着一个进程不能仅仅通过操纵指针来意外或恶意地读取或写入另一个进程的地址空间。可以访问另一个进程的内存,但这需要调用一个函数(ReadProcessMemory或WriteProcessMemority),该函数对目标进程具有足够强的句柄。

进程的地址空间称为虚拟,指的是地址空间只是一个潜在内存映射的空间。每个进程开始时都会非常适度地使用其虚拟地址空间——可执行文件与ntdll.Dll一起映射。然后,加载器(NtDll的一部分)在进程地址空间内分配一些基本结构,例如默认进程堆、进程环境块(PEB)、进程中第一个线程的线程环境块(TEB)。大部分地址空间为空。

image.png
进程的寻址需求。

18.9.3.1 页面状态

虚拟内存中的每个页面可以处于三种状态之一:空闲(free)、提交(committed)和保留(reserved)。

空闲页面:未映射,因此尝试访问该页会导致访问冲突异常。进程的大部分地址空间开始时是空闲的。
提交页面:与空闲页面相反——是一个映射页面,映射到RAM或文件,访问该页面应成功。如果页面在RAM中,CPU直接访问数据并继续。如果页面不在RAM中(至少CPU查询的表告诉它),CPU会引发一个称为页面错误的异常,由内存管理器捕获。如果页面确实驻留在磁盘上,内存管理器会将其带回RAM,修复转换表以指向RAM中的新地址,并指示CPU重试。从调用线程的角度来看,最终结果是访问成功。如果确实涉及I/O,访问速度会变慢,但调用线程不需要知道这一点,也不需要对它做任何特殊的操作——它是透明的。
提交内存通常称为分配内存。调用C/C++内存分配函数,如malloc、calloc、operator new等,总是提交内存。

保留页面:介于空闲和提交之间,类似于空闲页面,因为访问该页面会导致访问冲突——那里没有任何内容。保留页可能稍后提交,保留页范围确保正常内存分配不会使用该范围,因为它是为其他目的保留的,例如管理线程堆栈的。由于线程的堆栈可以增长,并且在虚拟内存中必须是连续的,因此保留了一个页面范围,以便进程中发生的其他分配不使用保留的地址范围。
下表总结了页面的三种状态及特点:

image.png

18.9.3.2 地址空间布局

下表总结了不同系统的地址空间大小。

image.png

在32位系统上,存在两种变体,结合上表,如下图所示。

image.png

32位意味着4GB,但为什么进程只能获得2GB?因为高2GB是系统空间(也称为内核空间),是操作系统内核本身所在的位置,包括所有内核设备驱动程序,以及它们在代码和数据方面消耗的内存。

64位系统提供了几个优点,第一个优点是大大增加了地址空间。64位的理论极限是2到64次方,或16EB。大多数现代处理器只支持48位虚拟和物理地址,意味着可以获得的最大地址范围是2到48次方或256TB。也就是说,64位系统的每个进程可以有128TB的地址空间范围,而其他128TB用于系统空间。

image.png

18.9.4 Windows内存统计

开发人员通常希望了解他们的进程在内存使用方面的情况。

image.png
下表是Windows任务管理(上图)可以看到的内存使用信息:

image.png
关于内存压缩
内存压缩是在Windows 10中添加的,作为一种通过压缩当前不需要的内存来节省内存的方法,特别适用于UWP进程,因为它们不消耗CPU,因此这些进程使用的任何私有物理内存都可以被放弃。相反,内存被压缩,仍然为其他进程留下空闲页。当进程唤醒时,内存将快速解压缩并准备使用,避免了对页面文件的I/O。
在Windows 10的前两个版本中,压缩内存存储在系统进程的用户模式地址空间中,但过于明显且碍眼,所以从Windows 10版本1607开始,一个特殊的进程,内存压缩(一个最小化进程),是保持压缩内存的进程。此外,任务管理器完全没有显示此进程,但其他工具(如Process Explorer)正常显示此进程。
上图中的内存组成条以宽泛的笔划表示物理页面如何在内部管理,“正在使用”部分是当前被视为流程和系统工作集的一部分的页面,备用页是将其备份存储在磁盘上的内存,但与所属进程的关系仍然保留。如果流程现在触及其中一个页面,它们将立即返回其工作集(变为“正在使用中”)。如果这些页面立即被抛出到“空闲”页面堆中,则需要I/O将页面返回RAM。

修改部分表示内容尚未写入备份存储(通常为页面文件)的页面,因此不能丢弃这些页面。如果修改的页面数量过多,或者待机和空闲页面计数过小,则修改的页面将写入其备份文件,并将移动到待机状态。

所有这些转换和管理都旨在减少I/O,这些物理页面列表管理的更精确视图可在Process Explorer的系统信息视图中的内存选项卡中获得,如下图所示。

image.png

上图的分页列表部分详细说明了执行人员内存管理用于管理物理页面的各种列表。零页面是只包含零的页面,与包含垃圾的空闲页面相比,这些页面占大多数。一个特殊的执行线程称为零页线程,它以优先级0(唯一具有此优先级的线程)运行,是将空闲页归零的线程。零页之所以重要,是为了满足安全要求,即分配的内存永远不能包含属于另一个进程的数据,即使该进程不再存在。上上图中内存组成中的空闲部分包括空闲页和零页的组合。

上图另一个有趣的部分是,根据优先级,没有单一的备用页面列表,但有八个,称为内存优先级,可以在Process Explorer中逐个线程地查看,尽管这也是一个进程属性,默认情况下由每个线程继承。

当由于进程或系统需要物理内存,需要将待机列表中的页移动为空闲页时,使用内存优先级。问题是,哪些页面应该首先释放?一种简单的方法是使用FIFO队列,其中从进程工作集中删除的第一个页面是第一个空闲的页面。然而此法过于简单,假设一个进程在后台工作很多,例如反恶意软件或备份应用程序。这些进程显然使用内存,但它们不像用户直接使用的应用程序那么重要。因此,如果需要物理内存,它们的备用页应该是第一个使用的,即使它们是最近使用的。这就是内存优先级的作用。

默认内存优先级为5。进程和线程的后台模式,其CPU优先级降低到4,内存优先级降低到1,使得该进程使用的备用页更有可能在内存优先级较高的进程之前重用。如果想在不进入后台模式的情况下更改内存优先级,可以使用以下接口:

BOOL SetProcessInformation(HANDLE hProcess, PROCESS_INFORMATION_CLASS ProcessInformationClass, LPVOID ProcessInformation, DWORD ProcessInformationSize);
BOOL SetThreadInformation(HANDLE hThread, THREAD_INFORMATION_CLASS ThreadInformationClass, LPVOID ThreadInformation, DWORD ThreadInformationSize);

18.9.4.1 Windows进程内存统计

任务管理器中与进程相关的内存计数器有些混乱,“详细信息”选项卡中显示的默认内存计数器:内存(专用工作集)或内存(活动专用工作集)。让我们分析这些术语:

  • 工作集(Working Set):进程使用的物理内存。
  • 专用(Private):进程专用内存(非共享)。
  • 活动(Active):不包括后台的UWP进程。

这些计数器的问题在于工作集部分,表示当前在RAM中的专用内存,然而是一个不稳定的计数器,可能会根据流程活动而上下浮动。如果试图确定进程提交(分配)了多少内存,或者进程泄漏了内存,那么这些不是要查看的计数器。

这些计数器仅显示私有内存这一事实通常是一件好事,因为共享内存(如DLL代码使用的内存)是常量,因此任何人都无法对此做任何事情。私有内存是由进程控制的内存。

那么,正确的计数器是什么呢?是提交大小。为了使事情更加混乱,Process Explorer和性能监视器将此计数器称为私有字节。下图显示了任务管理器,它将提交大小和活动私有工作集并排排列,并按提交大小排序。

image.png

提交大小也与私有内存有关,所以它与私有工作集处于同等地位,区别在于不在工作集中的内存。如果两个计数器都接近,则表示进程相当活跃,并使用其大部分内存,或者Windows的可用内存不低,因此内存管理器无法快速从工作集中删除页面。

在某些情况下,两个计数器之间的差异可能非常大。在上图中,流程代码(PID 34316)的大部分提交内存不是其工作集的一部分。这就是为什么查看专用工作集计数器会产生误导,看起来这个进程消耗了大约97MB的内存,但实际上它消耗了大约368MB的内存。诚然,目前在RAM中,它仅使用97MB,但提交的内存确实消耗了页表(用于映射提交的内存),并且该内存根据系统的提交限制计数。

使用任务管理器中的提交大小列确定进程的内存消耗,不包括共享内存,但并不重要(在大多数情况下)。在Process Explorer中,提交大小的等效值是私有字节。任务管理器和Process Explorer都包含更多与内存相关的列(Procss Explorer包含多个任务管理器)。特别是一个列没有类似的,即虚拟大小列,如下图所示。

image.png

“虚拟大小”列统计所有不处于空闲状态(即已提交和保留)的页面,本质上是进程消耗的地址空间量。对于潜在地址空间为128 TB的64位进程,无关紧要,对于32位进程,可能是个问题。即使提交的内存不太高,但拥有大的保留内存区域会限制新分配的可用地址空间,可能会导致分配失败,即使整个系统可能有足够的空闲内存。

前面描述的计数器不包括保留内存,是有原因的。保留内存成本非常低,因为从CPU的角度来看,它与空闲内存是一样的——不需要页表来描述保留内存。事实上,从Windows 8.1开始,保留内存的成本甚至更低。

上图虚拟大小栏中的一些数字似乎有些令人担忧,一些进程的虚拟大小似乎约为2TB,私有字节列显示的数字要小得多,意味着虚拟大小描述的大部分内存大小都是保留的。一些进程拥有如此巨大的保留块的真正原因是因为Windows 10的安全特性,称为控制流保护(Control Flow Guard,CFG)。可以在Process Explorer中添加CFG列,将看到支持CFG的进程与巨大的2 TB保留区域之间的紧密关联。

18.9.5 进程内存映射

进程的地址空间必须包含进程在内存方面使用的所有内容:可执行代码和全局数据、DLL代码和全局信息、线程堆栈、堆,以及进程提交和/或保留的任何其他内存。下图显示了进程虚拟地址空间的典型示例。

image.png

一个典型的进程加载几十个DLL并可能使用许多线程,.NET等框架有自己的DLL和堆,但所有这些看似不同的东西都是由相同的“东西”组成的。

要查看进程的实际内存映射,可以使用系统内部中的VMMap工具。启动VMMap时,会立即显示一个进程选择对话框,可以在其中选择感兴趣的流程(下图)。但是,VMMap仍然限于用户模式访问,无法打开受保护的进程。

image.png

一旦选择了一个进程,VMMap的主视图将由三个不同的水平部分填充(下图显示了Explorer.exe的实例)。

image.png
顶部显示三个计数器:

  • 提交内存-进程中的总提交内存(包括专用页和共享页)
  • 专用字节-专用提交内存
  • 工作集-总工作集(专用页和共享页使用的物理内存)

每个计数器都有一个排序直方图,显示该计数器中包含的内存区域的类型,区域类型如第二部分所示。下表总结了VMMap显示的区域类型。

image.png

如果需要获取进程内存使用情况的摘要视图,PSAPI函数GetProcessMemoryInfo可以提供帮助:

  • BOOL GetProcessMemoryInfo(HANDLE Process, PPROCESS_MEMORY_COUNTERS ppsmemCounters, DWORD cb);

该函数接受进程句柄,该进程句柄必须具有PROCESS_VM_READ访问掩码,且带有PROCESS_QUERY_LIMITED_INFORMATION或PROCESS_QUERY_INFORMATION。当前进程句柄(GetCurrentProcess)是一个自然的候选,因为它具有完全访问掩码。

18.9.6 页面保护

进程虚拟地址空间中的每个提交页都有保护标志,可以使用VirtualAlloc或VirtualProtect函数设置。下表显示了页面保护属性,可以从中为提交的页面指定一个属性,任何违反页面保护的访问都会导致访问违反异常。

image.png

除上述值外,还可以添加一些保护常数,如下表所示。

image.png

18.9.7 共享内存

通常进程具有不混合的单独地址空间,然而在进程之间共享内存有时是有益的。典型的例子是DLL,所有用户模式进程都需要NtDll,最需要的是Kernel32.dll,KernelBase.dll、AdvApi32.dll和许多其他。如果每个进程在物理内存中都有自己的DLL副本,将很快耗尽。事实上,拥有DLL的首要动机之一是共享(至少是代码)的能力。按照惯例,代码是只读的,因此可以安全地共享,来自EXE文件的可执行代码也是如此。如果多个进程基于同一图像文件执行,则没有理由不共享。下图的内核32.dll在两个进程之间共享。

image.png

上图共享DLL的所有进程中DLL的虚拟地址相同是必要的,因为并非所有代码都是可重定位的。如果我们在全局范围内声明一个变量,如下所示:

int x;
void main() 
{
    x++;
    //...
}

如果我们运行这个可执行文件的两个实例——第二个实例中的x值是多少?答案是1。x是进程的全局数据,而不是系统的全局数据,与DLL的工作原理相同。如果DLL声明了一个全局变量,则它仅对加载DLL的每个进程是全局的。

在大多数情况下,这正是我们想要的,通过使用名为写时复制(PAGE_WRITECOPY)的页面保护来实现的。其思想是,所有使用相同变量的进程(在可执行文件或这些进程使用的DLL中声明)将该变量所在的页面映射到相同的物理页面(上图)。如果进程更改了该变量的值(下图的进程A),则会引发异常,导致内存管理器创建页面的副本,并将其作为私有页面移交给调用进程,从而删除写时的副本保护(下图中的第3页)。

image.png

将任何全局数据复制到每个使用它的进程会更简单,但会浪费物理内存。如果数据未更改,则无需进行复制。在某些情况下,需要在进程之间共享数据。一个相对简单的机制是使用全局变量,但是指定页面应该由普通的PAGE_READWRITE而不是PAGE_WRITECOPY保护。可以通过在可执行文件或DLL中构建新的数据段,并指定其所需的属性来实现。下面代码显示了如何实现这一点:

#pragma data_seg("shared")
int x = 0;
#pragma data_seg()

#pragma comment(linker, "/section:shared,RWS")

data_seg的pragma(编译指示)在PE中创建一个新段,它的名称可以是任何名称(最多8个字符),为了清晰起见,在上面的代码中称为“shared”。然后,应该共享的所有变量都放在该节中,并且必须显式初始化它们,否则它们将不会存储在该节。从技术上讲,如果有几个变量,则只需要明确初始化第一个变量。不过,最好将它们全部初始化。第二个#pragma是指向链接器的指令,用于创建具有属性RWS(读、写、共享)的节。那个小“S”是关键,映像映射后,它将不具有PAGE_WRITECOPY保护,因此在使用相同PE的所有进程之间共享。

18.9.8 页面文件

处理器只能访问物理内存(RAM)中的代码和数据。如果启动了某些可执行文件,Windows会将可执行文件的代码和数据(以及NTdll.dll)映射到进程的地址空间,然后,进程的第一个线程开始执行,会导致它执行的代码(首先在NtDll.dll中,然后是可执行文件)映射到物理内存并从磁盘加载,以便CPU可以执行它。 假设进程的线程都处于等待状态,可能进程有一个用户界面,用户最小化了应用程序的窗口,有一段时间没有使用应用程序。Windows可以将可执行文件使用的RAM重新用于其他需要它的进程。现在假设用户恢复应用程序的窗口——Windows现在必须将应用程序的代码带回RAM。从哪里读取代码?可执行文件本身。

意味着可执行文件和DLL是它们自己的备份。事实上,Windows为可执行文件和DLL创建了一个内存映射文件(这也解释了为什么不能删除这些文件,因为这些文件至少有一个打开的句柄)。

数据呢?如果长时间没有访问某些数据(或者Windows的可用内存不足),内存管理器可以将数据写入磁盘——页面文件(Page File)。页面文件用作专用提交内存的备份,不需要使用页面文件——没有页面文件,Windows也可以正常运行,但减少了一次可以提交的内存量。

此外,Windows最多支持16页文件,它们必须位于不同的磁盘分区中,并命名为pagefile.sys,位于根分区(默认情况下隐藏文件)。如果一个分区太满,或者另一个分区是单独的物理磁盘,那么拥有多个页面文件可能会有好处,会增加I/O吞吐量。

任务管理器中显示的提交限制本质上是RAM的数量加上所有页面文件的当前大小。每个页面文件可以具有初始大小和最大大小。当系统达到其提交限制时,页面文件将增加到其配置的最大值,因此提交限制现在会增加(由于更多I/O,性能可能会降低)。如果提交的内存低于原始提交限制,则页面文件大小将减少回其初始大小。

可以通过进入系统属性,然后选择高级系统设置,然后在性能部分选择设置,然后选择“高级”选项卡,最后在虚拟内存部分选择“更改…”来配置页面文件的大小,结果显示如下图所示的对话框。在单击最后一个按钮之前,请注意,页面文件的当前大小显示在按钮附近。

image.png

18.9.9 虚拟内存

传统的虚拟内存包含了虚拟地址、页表入口、段表入口、以及它们的组合等概念和方式:

image.png

18.9.9.1 虚拟内存概述

每个进程都有自己的虚拟、私有、线性地址空间,此地址空间开始时为空(或接近空,因为可执行映像和NtDll.Dll通常是第一个映射的)。一旦主(第一个)线程开始执行,可能会分配内存、加载更多DLL等。该地址空间是私有的,意味着其它进程无法直接访问它。地址空间范围从零开始(技术上不能分配第一个64KB的地址),一直到最大值,取决于进程“位”(32或64位)、操作系统“位”和链接器标志,如下所示:

  • 对于32位Windows系统上的32位进程,进程地址空间大小默认为2GB。
  • 对于32位Windows系统上使用“增加用户地址空间”设置的32位进程,进程地址空间大小可以高达3 GB(取决于具体设置)。要获取扩展地址空间范围,创建进程的可执行文件必须在其头中标记有LargeAddressWare链接器标志。如果不是,它仍将限制在2GB。
  • 对于64位进程(自然是在64位Windows系统上),地址空间大小为8TB(Windows 8及更早版本)或128 TB(Windows 7.1及更高版本)。
  • 对于64位Windows系统上的32位进程,如果可执行映像与LargeAddressWare标志链接,则地址空间大小为4 GB。否则,大小保持在2GB。

内存本身称为虚拟的,意味着地址范围和它在物理内存(RAM)中的确切位置之间存在间接关系。进程中的缓冲区可以映射到物理内存,也可以临时驻留在文件(如页面文件)中。术语“虚拟”是指从执行角度来看,不需要知道要访问的内存是否在RAM中;如果内存确实映射到RAM,CPU将直接访问数据。否则,CPU将引发页面错误异常,导致内存管理器的页面错误处理程序从适当的文件中提取数据,将其复制到RAM,在映射缓冲区的页面表条目中进行所需的更改,并指示CPU重试。

虚拟内存是一种允许执行可能无法在主内存中编译的进程的技术,它将用户逻辑内存与物理内存分开,这种分离允许在只有小物理内存可用时为程序提供超大内存。虚拟内存使编程任务变得更容易,因为程序员不再需要计算可用或不可用的物理内存量,允许不同进程通过页面共享共享文件和内存,通常由请求分页实现。

image.png
进程的虚拟地址空间是指进程如何存储在内存中的逻辑(或虚拟)视图。通常,此视图表示进程开始于某个逻辑地址,例如地址0,并且存在于连续内存中。

18.9.9.2 分页

请求分页系统类似于具有交换功能的分页系统,当我们想执行一个进程时,我们把它交换到内存中。交换程序操作整个进程,其中分页器(Pager)涉及进程的各个页面,请求分页的概念是使用分页器而不是交换程序。当一个进程要换入时,分页器会猜测在再次换出该进程之前将使用哪些页面,而不是在整个过程中交换,分页器只将那些必要的页面放入内存。分页内存到连续磁盘空间的传输如下图所示。

image.png

因此,它避免了读取无法使用的内存页,从而减少了交换时间和所需的物理内存量。在这种技术中,我们需要一些硬件支持来区分内存中的页面和磁盘上的页面。为此,硬件使用了有效位和无效位——当该位设置为有效时,表示关联页面在内存中;如果该位设置为无效,则表示页面无效或有效,但当前不在磁盘中。

image.png

如果进程从未尝试访问页面,则将页面标记为无效将无效。因此,当进程执行并访问驻留在内存中的页面时,执行将正常进行。访问标记为无效的页面会导致页面错误捕捉器(trap),是操作系统无法将所需页面放入内存的结果。

结合下图,如果进程引用的页面不在物理内存中:

1、检查此进程的内部表(页表),以确定引用是否有效。

2、如果引用无效,则终止进程;如果引用有效但尚未引入,则必须从主内存引入。

3、现在在内存中找到一个自由帧。

4、然后,将所需的页面读入新分配的帧。

5、当磁盘读取完成时,修改内部表以指示页面现在在内存中。

6、重新启动被非法地址捕捉器中断的指令。现在,该进程可以像访问内存中的页面一样访问该页面。

image.png
image.png

对于请求分页,需要与分页和交换相同的硬件。

  • 页表:能够通过有效无效位将条目标记为无效。
  • 辅助内存:保存主内存中不存在的页面,是一个高速磁盘。

请求分页会对计算机系统的性能产生重大影响。设P(0<=P<=1)为页面错误的概率,有效访问时间是(1-P) ma + P page fault,其中:P=页面故障、ma=内存访问时间。说明有效访问时间与页面故障率成正比。在按需分页中,保持页面错误率低十分重要。

页面错误会导致以下顺序的行为发生:

1、操作系统陷阱。

2、保存用户注册和进程状态。

3、确定中断是页面错误。

4、检查页面引用是否合法,并确定页面在磁盘上的位置。

5、从磁盘向空闲帧发出读取。

6、如果等待,请将CPU分配给其他用户。

7、从磁盘中断。

8、保存其他用户的寄存器和进程状态。

9、确定中断来自磁盘。

10、更正页面表和其他表,以显示所需页面现在在内存中。

11、等待CPU再次分配给该进程。

12、恢复用户寄存器进程状态和新页表,然后恢复中断的指令。

页面替换策略:

  • 每个进程都分配了保存进程页面(数据)的帧(内存)。
  • 帧根据需要填充页面——称为请求分页。
  • 通过修改页面错误服务例程来替换页面,可以防止内存过度分配。
  • 页面替换算法的工作是确定哪个页面受到损坏,从而为新页面腾出空间。
  • 页面替换完成逻辑内存和物理内存的分离。

结合下图,页面替换的描述如下:

  • 请求分页通过不加载从未使用过的页面来共享I/O,还允许在某个时间运行更多进程,从而提高了多道程序设计的程度。
  • 页面替换策略处理内存中的页面被必须引入的新页面替换的解决方案。当用户进程执行时,发生页面错误。
  • 硬件陷阱指向操作系统,操作系统检查内部表,以确定是页面错误,而不是非法的内存访问。
  • 操作系统确定派生页在磁盘上的位置,会发现空闲帧列表中没有空闲帧。
  • 当所有帧都在主存中时,需要带一个新的页面来满足页面错误,替换策略与选择当前内存中要替换的页面有关。
  • 要删除的第i、e页应该是将来最不可能引用的第i、e页。

image.png

  • 当用户进程执行时,发生页面错误。操作系统确定所需页面在磁盘上的位置,但发现空闲帧列表中没有空闲帧,即所有内存都在使用中,如上图所示。此时,操作系统有几个选项:可以终止进程,也可以通过释放其所有帧并减少多道程序设计的程度来交换进程。

结合下图,页面替换算法的过程如下:

1、查找磁盘上派生页的位置。

2、查找自由帧。如果有空闲帧,就使用它。 否则,使用替换算法来选择候选页面。将候选页面写入磁盘,相应地更改页面和帧表。

3、将所需页面读入自由帧,更改页面和帧表。

4、重新启动用户进程。

image.png

候选页面(Victim Page)是物理内存不足时支持的页面。如果没有空闲帧,则读取两个页面转换(输出和一个输入),将看到有效访问时间。每个页面或帧可能有一个与硬件相关联的脏(修改)位,每当写入页面中的任何单词或字节时,硬件都会设置页面的修改位,表示页面已被修改。当选择要替换的页面时,检查其修改位,如果设置了位,那么页面会因为从磁盘读取而被修改。如果未设置位,则页面自读入内存后未被修改。因此,如果页的副本没有被修改,可以避免将内存页写入磁盘,如果它已经存在的话。但有些页面无法修改。

要实现请求页面,必须解决两个主要问题:

  • 必须开发帧分配算法和页面替换算法。
  • 如果内存中有多个处理器,必须决定要分配多少帧,并且需要替换页面。

18.9.9.3 页面替换

页面替换算法决定当需要分配内存页时,将交换哪个内存页到页。有3种页面替换算法:

页面错误:内存中不存在CPU要求的页面。
页面命中:内存中存在CPU要求的页面。

1、FIFO(先进先出)算法

FIFO是最简单的页面替换算法,将每个页面放入内存的时间关联起来。当要替换页面时,将选择最旧的页面,把队列放在队列的最前面,当一个页面进入内存时,我们将它插入队列的尾部。示例:考虑以下引用字符串,其帧最初为空。

image.png

前三个引用(7,0,1)会出现页面错误,并被放入空帧中。下一个参考页面2取代了第7页,因为第7页是最先引入的。由于0是下一个引用,并且0已经在内存中,因此e没有页面错误。下一个引用3会导致页面0被替换,因此下一个对0的引用会导致页面错误,这将一直持续到字符串结束。总共有15个页面错误。

分析:参考页数 = 20,页面错误数 = 15,页面命中次数 = 5,页面命中率 = 页面命中数/页面引用总数 = (5 / 20)x100 = 25% ,页面错误率 = 页面错误数 / 页面引用总数 = (15 / 20)x100= 75%。

Belady’的Anamoly问题:对于某些页面替换算法,页面错误可能会随着分配的帧数的增加而增加。FIFO替换算法可能会面临此问题。

最佳算法:最优页面替换算法主要是解决Belady的Anamoly问题。理想情况下,我们希望选择一个页面错误率最低的算法,这种算法存在,并被称为最优算法。其过程:替换最长时间(或根本不会)不会使用的页面,即替换参考字符串中向前距离最大的页面。示例:考虑以下引用字符串,其帧最初为空。

image.png
前三个引用会导致填充三个空帧的错误,第2页的参考文献取代了第7页,因为只有参考文献18才会使用第7页。第0页将在第5页使用,第1页将在14页使用。只有9个页面错误,最佳替换比有15个错误的FIFO好得多。该算法很难实现,因为它需要知道将来的引用字符串。

分析:参考页数=20,页面错误数=9,页面命中数=11,页面命中率=页面命中数/页面引用总数 = (11 / 20)x100 = 55%,页面错误率=页面错误数/页面引用总数= (9 / 20)x100=45%。

2、LRU(最近最少使用)算法

如果最优算法不可行,则可以近似最优算法。带OPTS和FIFO的主要区别是:FIFO算法使用页面内置的时间,OPT使用页面使用的时间。LRU算法将替换最长时间未使用的页面,将其页面与上次使用该页面的时间相关联。这种策略是向后而不是向前看的最佳页面替换算法。示例:考虑以下引用字符串,其帧最初为空。

image.png

前5个错误类似于最佳更换。当引用第4页时,LRU会看到三个帧中的第2页,即最近最少使用的帧。最近使用的页面是第0页,刚好在使用第3页之前。LRU策略通常用作页面替换算法,被认为是不错的选择。

分析:参考页数=20,页面错误数=12,页面命中数=8,页面命中率=页面命中数/页面引用总数= (8 / 20)x100=40%,页面错误率=页面错误数/页面引用总数= (12 / 20)x100=60%。

为了支持超大地址的内存空间,可以使用多级页表,下图a是具有两个页表字段的32位地址,b是两级页表:

image.png

4种常见的页面替换算法的行为对比图:

image.png

不同算法在固定分配、局部页面替换算法的比较:

image.png

18.9.9.4 分段

前面讨论的虚拟内存是一维的,因为虚拟地址从0到某个最大地址,一个接一个。对于许多问题,拥有两个或多个独立的虚拟地址空间可能比只有一个要好得多。例如,编译器有许多在编译过程中构建的表,可能包括:

  • 为打印列表保存的源文本(在批处理系统上)。
  • 符号表,包含变量的名称和属性。
  • 包含所有使用的整数和浮点常量的表。
  • 解析树,包含程序的语法分析。
  • 编译器内用于过程调用的堆栈。

随着编译的进行,前四个表中的每个表都在不断增长。最后一个在编译过程中以不可预知的方式增长和收缩。在一维内存中,这些五表必须分配连续的虚拟地址空间块,如下图所示。

image.png

考虑一下,如果一个程序的变量数量比平时大得多,但其他所有变量的数量都正常,会发生什么。为符号表分配的地址空间块可能已满,但其他表中可能有很多空间。所需要的是一种方法,让程序员不必管理扩展和收缩表,就像虚拟内存消除了将程序组织成覆盖层的担忧一样。

一个简单而通用的解决方案是为机器提供许多完全独立的地址空间,这些地址空间称为段(Segment)。每个段由一个线性地址序列组成,从0开始,一直到某个最大值。每个段的长度可以是从0到允许的最大地址之间的任意值。不同的段可能(通常)有不同的长度。此外,段长度可能在执行期间发生变化。每当有东西被推到堆栈上时,堆栈段的长度可能会增加,而当有东西从堆栈中弹出时,则会减少。

因为每个段构成一个单独的地址空间,所以不同的段可以独立增长或收缩,而不会相互影响。如果某个段中的堆栈需要更多的地址空间来增长,它的地址空间中没有其他东西可以插入。当然,一个段可能会填满,但段通常非常大,因此这种情况很少发生。要在这个分段或二维内存中指定地址,程序必须提供一个由两部分组成的地址、段号和段内的地址。下图说明了用于前面讨论的编译器表的分段内存,这里显示了五个独立的段。

image.png

分段内存允许每个表独立地进行增长或收缩。

段是一个逻辑实体,程序员知道并将其用作逻辑实体。一个段可能包含一个过程、一个数组、一个堆栈或一组标量变量,但通常它不包含不同类型的混合。

分段内存除了简化对增长或收缩的数据结构的处理之外,还有其他优点。如果每个过程占用一个单独的段,以地址0作为起始地址,那么单独编译的过程的链接将大大简化。在编译并连接了构成程序的所有过程之后,对段n中的过程的过程调用将使用由两部分组成的地址(n, 0)来寻址字0(入口点)。

如果随后修改并重新编译了段n中的过程,则不需要更改其他过程(因为没有修改起始地址),即使新版本比旧版本大。在一维内存中,过程被紧紧地挤在一起,彼此之间没有地址空间。因此,更改一个过程的大小可能会影响段中所有其他(不相关)过程的起始地址。反过来,需要修改调用任何移动过程的所有过程,以便合并它们的新起始地址。如果一个程序包含数百个过程,那么此过程可能代价高昂。

分段也有助于在多个进程之间共享程序或数据。一个常见的例子是共享库。运行高级窗口系统的现代工作站通常在几乎每个程序中都有超大的图形库。在分段系统中,图形库可以放在一个段中,由多个进程共享,这样就不需要在每个进程的地址空间中都有图形库。虽然在纯分页系统中也可以有共享库,但它更为复杂。实际上,这些系统是通过模拟分割来实现的。

由于每个段形成程序员所知道的逻辑实体,例如过程或数组,因此不同的段可以有不同的保护类型。过程段可以指定为仅执行,禁止尝试读取或存储过程段。浮点数组可以指定为读/写,但不能执行,跳转到该数组的尝试将被捕获,这种保护有助于捕捉错误。下表比较了分页和分段。

image.png

分段的实现与分页在本质上有所不同:页面大小固定,而分段则不是。下图(a)显示了最初包含五个段的物理内存的示例。现在考虑一下,如果段1被逐出,而较小的段7被放回原处,会发生什么情况。我们得出(b)的存储器配置。段7和段2之间是一个未使用的区域,即一个孔。然后段4替换为段5,如(c)所示,段3替换为段6,如(d)所示。在系统运行一段时间后,内存将被划分为多个块,一些包含段,一些包含孔。这种现象称为棋盘格或外部碎片,会在洞中浪费内存。可以通过压实处理,如(e)所示。

image.png
(a)-(d)棋盘的形成。(e) 通过压实移除棋盘格。

image.png
动态分区的效果。

下面阐述Intel x86的分页分段技术。直到x86-64,x86的虚拟内存系统在许多方面都与MULTICS相似,包括分段和分页。MULTICS有256K个独立的段,每个段最多64K个36位字,而x86有16K个独立段,每个独立段最多可容纳10亿个32位字。虽然段数较少,但较大的段大小更为重要,因为很少程序需要1000个以上的段,但许多程序需要较大的段。从x86-64开始,分段被认为是过时的,不再受支持,除非是在传统模式下。尽管在x86-64的原生模式中仍然可以使用一些旧的分割机制的残留物,主要是为了兼容性,但它们不再扮演相同的角色,也不再提供真正的分段。

18.9.9.5 虚拟内存转换

虚拟地址转换为物理地址的转换本身是自动的,因此当CPU看到如下指令时:

mov eax, [100000H]

它知道地址0x100000是虚拟的而不是物理的(因为CPU配置为在保护模式/长模式下运行)。CPU现在必须查看内存管理器预先准备的表,这些表描述了页面在RAM中的位置(如果有的话)。如果它不在RAM中(由CPU在转换表中检查的有效位中的零标记),则会引发页面错误异常,由内存管理器适当处理。地址转换涉及的基本组件如下图所示。

image.png

CPU被提供虚拟地址作为输入,并且应该输出(和使用)物理地址。由于所有工作都是按照页面工作的,所以地址的低12位(页面内的偏移量)永远不会被转换,并按原样传递到最终地址。

CPU需要上下文进行转换。每个进程都有一个初始结构,它总是驻留在RAM中。对于32位系统,它称为页面目录指针表,对于64位系统,则称为页面映射级别4(Intel术语)。从这个初始结构开始,使用其他结构,包括页面目录和页面表,页表条目是指向物理页地址的条目(如果设置了有效位)。当页面移动到页面文件时,内存管理器将相应的页面表条目标记为无效,以便CPU下次遇到该页面时,将引发页面错误异常。

最后,转换查询缓冲区(Translation Lookaside Buffer,TLB)是最近转换的页面的缓存,因此访问这些页面不需要为了转换目的而通过多层结构。该缓存相对较小,从实用角度来看非常重要。在邻近的时间使用相同范围的内存地址对利用TLB缓存非常有用。

image.png
分页和转换后备缓冲器(TLB)的操作。

18.9.10 Windows内存

Windows提供了多组API来处理内存,下图显示了可用集及其依赖关系。

image.png
最底层是虚拟API最接近内存管理器,有几个含义:

  • 是最强大的API,提供了虚拟内存可以完成的所有功能。
  • 始终以页面单位和页面边界为单位工作。
  • 高层级的API使用它。

相关虚拟API如下:

LPVOID VirtualAlloc(LPVOID lpAddress, SIZE_T dwSize, DWORD flAllocationType, DWORD flProtect);
LPVOID VirtualAllocEx(HANDLE hProcess, LPVOID lpAddress, SIZE_T dwSize, DWORD flAllocationType, DWORD flProtect);
PVOID VirtualAllocFromApp(PVOID BaseAddress, SIZE_T Size, ULONG AllocationType, ULONG Protection);

BOOL VirtualFree(LPVOID lpAddress, SIZE_T dwSize, DWORD dwFreeType);
BOOL VirtualFreeEx(HANDLE hProcess, LPVOID lpAddress, SIZE_T dwSize, DWORD dwFreeType);

18.9.10.1 工作集

工作集(Working Set)表示在不发生页面错误的情况下可访问的内存。当然,一个进程希望其所有提交的内存都在其工作集中,内存管理器必须平衡一个进程和所有其他进程的需求,长时间未访问的内存可能会从进程的工作集中删除,并不意味着它会被自动丢弃——内存管理器有精心设计的算法,可以将曾经是进程工作集一部分的物理页面保留在RAM中的时间超过可能需要的时间,因此,如果有问题的进程决定访问该内存,它可能会立即发现错误进入工作集(称为软页面故障)。

通过GetProcessMemoryInfo,可以获得进程的当前和峰值工作集:

BOOL GetProcessMemoryInfo(HANDLE Process, PPROCESS_MEMORY_COUNTERS ppsmemCounters, DWORD cb);

进程具有最小和最大工作集。默认情况下,这些限制是软的,因此如果内存充足,进程可以消耗比其最大工作集更多的RAM,如果内存不足,则可以使用比其最小工作集更少的RAM。使用GetProcessWorkingSetSize查询这些限制:

BOOL GetProcessWorkingSetSize(HANDLE hProcess, PSIZE_T lpMinimumWorkingSetSize, PSIZE_T lpMaximumWorkingSetSize);

其它工作集相关的API:

BOOL SetProcessWorkingSetSize(HANDLE hProcess, SIZE_T dwMinimumWorkingSetSize, SIZE_T dwMaximumWorkingSetSize);
BOOL WINAPI EmptyWorkingSet(HANDLE hProcess);
BOOL SetProcessWorkingSetSizeEx(HANDLE hProcess, SIZE_T dwMinimumWorkingSetSize, SIZE_T dwMaximumWorkingSetSize, DWORD Flags);
BOOL GetProcessWorkingSetSizeEx(HANDLE hProcess, PSIZE_T lpMinimumWorkingSetSize, PSIZE_T lpMaximumWorkingSetSize, PDWORD Flags);

18.9.10.2 堆

VirtualAlloc函数集非常强大,因为它们非常接近内存管理器。然而,也有一个缺点。这些函数只在页面块中工作:如果分配10个字节,则返回一个页面。如果再分配10个字节,则会得到不同的页面,对于管理在应用程序中非常常见的小型分配来说太浪费了。这正是堆起作用的地方。

堆管理器是一个在虚拟API之上分层的组件,它知道如何有效地管理小型分配。在此上下文中,堆是由堆管理器管理的内存块,每个进程都从单个堆开始,称为默认进程堆。使用GetProcessHeap获得该堆的句柄:

HANDLE GetProcessHeap();

可以创建更多堆,有了堆,使用HeapAlloc分配(提交)内存:

LPVOID HeapAlloc(HANDLE hHeap, DWORD dwFlags, SIZE_T dwBytes);

其它堆相关的API:

BOOL HeapFree(HANDLE hHeap, DWORD dwFlags, LPVOID lpMem);
HANDLE HeapCreate(DWORD flOptions, SIZE_T dwInitialSize, SIZE_T dwMaximumSize);
BOOL HeapDestroy(HANDLE hHeap);

C/C++内存管理函数(如malloc、calloc、free、C++的new和delete操作符等)的实现取决于编译器提供的库。C/C++运行时使用堆函数来管理它们的分配。以下是malloc的实现,为了清晰起见,删除了一些宏和指令(在malloc.cpp中):

extern "C" void* __cdecl malloc(size_t const size) 
{
#ifdef _DEBUG
    return _malloc_dbg(size, _NORMAL_BLOCK, nullptr, 0);
#else
    return _malloc_base(size);
#endif
}
malloc有两个实现——一个用于调试构建,另一个用于发布构建,以下是发布构建的摘录(在文件malloc_base.cpp中):

extern "C" __declspec(noinline) void* __cdecl _malloc_base(size_t const size) 
{
    // Ensure that the requested size is not too large:
    _VALIDATE_RETURN_NOEXC(_HEAP_MAXREQ >= size, ENOMEM, nullptr);
    // Ensure we request an allocation of at least one byte:
    size_t const actual_size = size == 0 ? 1 : size;
    for (;;) 
    {
        void* const block = HeapAlloc(__acrt_heap, 0, actual_size);
        if (block)
            return block;

        //...code omitted...
}

extern "C" bool __cdecl __acrt_initialize_heap() 
{
    __acrt_heap = GetProcessHeap();
    if (__acrt_heap == nullptr)
        return false;

    return true;
}

实际上,以上API只是VirtualAlloc的冰山一角,Windows还提供了其它诸多功能的API。

18.9.10.3 非统一内存体系架构(NUMA)

非统一内存体系架构(NUMA)系统涉及一组节点,每个节点持有一组处理器和内存。下图显示了此类系统的拓扑示例。

image.png

上图显示了具有两个NUMA节点的系统示例,每个节点拥有一个具有4个内核和8个逻辑处理器的套接字。NUMA系统仍然是对称的,因为任何CPU都可以运行任何代码并访问任何节点中的任何内存。然而,从本地节点访问内存要比访问另一个节点中的内存快得多。

Windows知道NUMA系统的拓扑结构。之前讨论的线程调度,调度程序充分利用了这些信息,并尝试在CPU上调度线程,其中线程堆栈位于该节点的物理内存中。NUMA系统通常用于服务器机器,其中通常存在多个套接字。

18.9.10.4 内存映射文件

文件映射对象在Windows中无处不在。加载图像文件(EXE或DLL)时,将使用内存映射文件将其映射到内存。通过这种映射,通过标准指针访问内存,间接访问底层文件。当代码需要在映像内执行时,初始访问会导致页面错误异常,内存管理器在修复用于映射此内存的适当页面表之前,通过从文件读取数据并将其放入物理内存来处理该异常,此时调用线程可以访问代码/数据。这些对应用程序来说是透明的。

一些代码需要在文件中搜索一些数据,而搜索需要在文件内来回跳转。对于I/O API,充其量是不方便的,涉及对ReadFile(预先分配了缓冲区)和SetFilePointer(Ex)的多次调用。另一方面,如果文件的“指针”可用,那么移动和执行文件操作就容易得多:无需分配缓冲区,无需读取文件调用,任何文件指针更改只需转换为指针算术。所有其他常见内存函数,如memcpy、memset等,在内存映射文件中也同样有效。

涉及内存映射文件的常见API有:

HANDLE CreateFileMapping(HANDLE hFile, LPSECURITY_ATTRIBUTES lpFileMappingAttributes, DWORD flProtect, DWORD dwMaximumSizeHigh, DWORD dwMaximumSizeLow, LPCTSTR lpName);
LPVOID MapViewOfFile(HANDLE hFileMappingObject, DWORD dwDesiredAccess, DWORD dwFileOffsetHigh, DWORD dwFileOffsetLow, SIZE_T dwNumberOfBytesToMap);
BOOL UnmapViewOfFile(_In_ LPCVOID lpBaseAddress);

18.9.10.5 共享内存

进程是相互隔离的,因此每个都有自己的地址空间、自己的句柄表等。大多数时候,正是我们想要的。然而,在某些情况下,数据需要在进程之间以某种方式共享。Windows为进程间通信(IPC)提供了许多机制,包括组件对象模型(COM)、Windows消息、套接字、管道、邮件槽、远程过程调用(RPC)、剪贴板、动态数据交换(DDE)等。每种方法都有其优点和缺点,但上述所有方法的共同主题是内存必须从一个进程复制到另一个进程。

内存映射文件是IPC的另一种机制,是所有机制中最快的,因为没有复制(事实上,其他一些IPC机制在同一台机器上的进程之间通信时使用内存映射文件)。一个进程将数据写入共享内存,所有其他具有同一文件映射对象句柄的进程都可以立即看到内存,因为每个进程都将同一内存映射到自己的地址空间,所以不会进行复制。

共享内存基于访问同一文件映射对象的多个进程,对象可以通过三种方式中的任何一种共享,最简单的方法是使用文件映射对象的名称。共享内存本身可以由特定文件(CreateFileMapping的有效文件句柄)备份,在这种情况下,即使在文件映射对象被销毁后,数据仍然可用,或者由分页文件备份,在该情况下,一旦文件映射对象销毁,数据将被丢弃。这两个选项的工作方式基本相同。

文件映射对象在数据一致性方面提供了若干保证:

  • 同一数据/文件的多个视图(即使来自多个进程)保证同步,因为不同视图映射到同一物理内存。唯一例外是在网络上映射远程文件时,在这种情况下,来自不同机器的视图可能不会一直同步,但来自同一台机器的视图将继续同步。
  • 映射同一文件的多文件映射对象不能保证同步。通常,使用两个或多个文件映射对象映射同一文件不是良好的做法。最好以独占访问方式打开文件,这样就不可能对文件进行其他访问(至少在打算写入的情况下)。
  • 如果文件由文件映射对象映射,同时为正常I/O(读文件、写文件等)打开,则I/O操作的更改通常不会立即反映在映射到文件中相同位置的视图中。应避免这种情况。

18.10 动态链接库

18.10.1 DLL概述

动态链接库(DLL)是Windows NT的基本组成部分。DLL存在背后的主要动机是,它们可以在进程之间轻松共享,因此DLL的单个副本位于RAM中,所有需要它的进程都可以共享DLL的代码。在早期,RAM比现在小得多,使得内存节省非常重要。即使在今天,内存节省也非常重要,因为一个典型的进程使用了几十个DLL。

DLL是可移植可执行(PE)文件,可以包含以下一个或多个:代码、数据和资源。每个用户模式进程都使用子系统dll,如kernel32.dll、user32.dll、gdi32.dll和advapi32.dll,实现文档化的Windows API。当然,Ntdll.Dll在每个用户模式进程中都是必需的,包括原生应用程序。

DLL是可以包含函数、全局变量和资源(如菜单、位图和图标)的库。某些函数(和类型)可以通过DLL导出,以便加载DLL的其他DLL或可执行文件可以直接使用它们。DLL可以在进程启动时隐式加载到进程中,也可以在应用程序调用LoadLibrary或LoadLibraryEx函数时显式加载。

18.10.2 显式加载

显式链接到DLL可以更好地控制何时加载和卸载DLL。此外,如果DLL加载失败,进程不会崩溃,因此应用程序可以处理错误并继续。显式DLL链接的一个常见用途是加载语言相关资源。例如,应用程序可能尝试加载带有当前系统区域设置中资源的DLL,如果未找到,则可以加载默认资源DLL,该DLL始终作为应用程序安装的一部分提供。对于显式链接,不使用导入库,因此加载程序不会尝试加载DLL(可能不存在)。这也意味着不能使用#include来获取导出的符号声明,因为链接器将因“未解决的外部”错误而失败。我们如何使用这样的DLL?

第一步是在运行时加载它,通常在需要的地方加载。这是LoadLibrary的工作:

HMODULE LoadLibrary(LPCTSTR lpLibFileName);

LoadLibrary只接受文件名或完整路径。如果只指定了文件名,则搜索DLL的顺序与隐式加载DLL的顺序相同。如果指定了完整路径,则只尝试加载该文件。在实际搜索开始之前,加载器检查是否有一个具有相同名称的模块已加载到进程地址空间中。如果是,则不执行搜索,并返回现有DLL的句柄。例如,如果SimpleDell.Dll已加载(无论从哪个路径),并调用LoadLibrary加载名为SimpleDll.Dll的文件(在任何路径或不带路径的情况下),不会加载其他Dll。

成功加载DLL后,可以使用GetProcAddress从DLL访问导出的函数:

  • FARPROC GetProcAddress(HMODULE hModule, LPCSTR lpProcName);

函数返回DLL中导出符号的地址。第二个参数是符号的名称,注意,名称必须是ASCII。返回值是一个通用的FARPROC,其中“远”和“近”表示不同的东西。如果符号不存在(或未导出,这是相同的),则GetProcAddress返回NULL。举个例子:

// dll的其中一个函数声明
__declspec(dllexport) bool IsPrime(int n);

// 加载dll,导出上面的函数地址,并调用。
auto hPrimesLib = ::LoadLibrary(L"SimpleDll.dll");
if (hPrimesLib) 
{
    // DLL found
    using PIsPrime = bool (*)(int);
    auto IsPrime = (PIsPrime)::GetProcAddress(hPrimesLib, "IsPrime");
    if (IsPrime) 
    {
        bool test = IsPrime(17);
        printf("%d\n", (int)test);
    }
}

这段代码看起来相对简单——DLL已加载。不幸的是,对GetProcAddress的调用失败,GetLastError返回127(找不到指定的进程)。显然,GetProcAddress无法定位导出的函数,即使它已导出。为什么?

原因与函数的名称有关,如果我们使用Dumpbin查探关于SimpleDll.Dll的信息:

0 000111F9 ?IsPrime@@YA_NH@Z = @ILT+500(?IsPrime@@YA_NH@Z)

原因找到了——链接器“弄乱”了要使用的函数的名称:IsPrime@@YA_NH@Z。原因与IsPrime在C++中不够唯一有关。iPrime函数可以是A类、B类以及全局函数,也可能是某个命名空间C的一部分。此外,由于C++函数重载,同一作用域中可能有多个名为IsPrime的函数。因此,链接器为函数提供了一个奇怪的名称,其中包含这些独特的属性。我们可以尝试在前面的代码示例中替换这个损坏的名称,如下所示:

auto IsPrime = (PIsPrime)::GetProcAddress(hPrimesLib, "?IsPrime@@YA_NH@Z");

你会发现它是有效的!然而,这非常无趣且不太实用,我们必须用一种方法来查找损坏的名称,以使其正确。通常的做法是将所有导出的函数转换为C风格的函数。因为C不支持函数重载或类,所以链接器不必进行复杂的修改。下面是一种将函数导出为C的方法:

extern "C" __declspec(dllexport) bool IsPrime(int n);
如果编译C文件,以上将是默认值。

通过此更改,可以简化获取指向IsPrime函数的指针:

auto IsPrime = (PIsPrime)::GetProcAddress(hPrimesLib, "IsPrime");

但是,这种将函数转换为C风格的方案不能用于类中的成员函数——正是使用GetProcAddress访问C++函数不实际的原因,也是大多数用于LoadLibrary/GetProcAddress的DLL仅公开C风格函数的原因。

如果DLL不再需要,可以使用以下API释放之:

BOOL FreeLibrary(HMODULE hLibModule);

系统为每个加载的DLL维护每个进程计数器。如果对同一DLL多次调用LoadLibrary,则需要相同数量的FreeLibrary调用才能从进程地址空间真正卸载DLL。如果需要加载DLL的句柄,则可以使用GetModuleHandle检索它:

HMODULE GetModuleHandle(LPCTSTR lpModuleName);

18.10.3 调用约定

调用约定(Calling Convention)表明函数参数如何传递给函数,以及如果在堆栈上传递,谁负责清理参数。对于x64,只有一个调用约定。对于x86,有几个,最常见的是标准调用约定stdcall和C调用约定cdecl。stdcall和cdecl都使用堆栈传递参数,从右向左推送。它们之间的主要区别在于,对于stdcall,被调用方(函数体本身)负责清理堆栈,而对于cdecl,调用方负责清理堆栈。

stdcall的优点是更小,因为堆栈清理代码只显示一个(作为函数体的一部分)。使用cdecl,对函数的每次调用都必须跟随一条指令,以清除堆栈中的参数。cdecl函数的优点是它们可以接受可变数量的参数(在C/C++中由…指定),因为只有调用方知道传入了多少参数。

Visual C++中用户模式项目中使用的默认调用约定是cdecl,通过在返回类型和函数名之间放置适当的关键字来指定调用约定。为此,Microsoft编译器重新识别__cdecl和__stdcall关键字,使用的关键字也必须在实现中指定,以下是将IsPrime设置为使用stdcall的示例:

extern "C" __declspec(dllexport) bool __stdcall IsPrime(int n);

这也意味着,在定义函数指针以与GetProcAddress一起使用时,还必须指定正确的调用约定,否则我们将得到运行时错误或堆栈损坏:

using PIsPrime = bool (__stdcall *)(int);
// or
typedef bool(__stdcall* PIsPrime)(int);

__stdcall是大多数Windows API使用的调用约定,通常是使用WINAPI、APIENTRY、PASCAL、CALLBACK之一的宏,它们的含义完全相同。

18.10.4 DllMain函数
DLL可以有一个入口点(但不是必须,也可以没有),传统上称为DllMain,必须具有以下原型:

BOOL WINAPI DllMain(HINSTANCE hInsdDll, DWROD reason, PVOID reserved);

hInstance参数是DLL加载到进程中的虚拟地址,如果显式加载DLL,则它与从LoadLibrary返回的值相同。reason参数指示调用DllMain的原因,其值如下表所述:

image.png

18.10.5 Dll注入

在某些情况下,需要将DLL注入另一个进程。注入DLL是指以某种方式强制另一个进程加载特定的DLL,允许DLL在目标进程的上下文中执行代码。这种能力有很多用途,但它们本质上都归结为某种形式的定制或目标进程内操作的拦截。以下是一些具体的例子:

  • 反恶意软件解决方案和其他应用程序可能希望在目标进程中挂接API函数。
  • 通过子类化窗口或控件自定义窗口的能力,允许对UI进行行为更改。
  • 作为目标进程的一部分,可以无限访问该进程中的任何内容。有些是好的,比如监控应用程序行为以定位错误的DLL,但有些是坏的。

18.10.5.1 远程线程注入

通过在加载所需DLL的目标进程中创建线程来注入DLL可能是最广为人知和最直接的技术。其思想是在目标进程中创建一个线程,该线程使用要注入的DLL路径调用LoadLibrary函数。将代码执行到目标进程中的示例代码如下:

int main(int argc, const char* argv[]) 
{
    // 检查命令行参数
    if (argc < 3) 
    {
        printf("Usage: injector <pid> <dllpath>\n");
        return 0;
    }

    // 注入器需要目标进程ID和要注入的DLL, 故而打开目标进程的句柄
    HANDLE hProcess = ::OpenProcess(PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_CREATE_THREAD, FALSE, atoi(argv[1]));
    if (!hProcess)
        return Error("Failed to open process");

    // 准备要加载的DLL路径, 路径字符串本身必须放在目标进程中,因为是执行LoadLibrary的地方. 可以使用VirtualAllocEx函数.
    void* buffer = ::VirtualAllocEx(hProcess, nullptr, 1 << 12, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
    if (!buffer)
        return Error("Failed to allocate buffer in target process");

    // 使用WriteProcessMemory将DLL路径复制到分配的缓冲区
    if (!::WriteProcessMemory(hProcess, buffer, argv[2], ::strlen(argv[2]) + 1, nullptr))
        return Error("Failed to write to target process");

    // 创建远程线程
    DWORD tid;
    HANDLE hThread = ::CreateRemoteThread(hProcess, nullptr, 0,
        (LPTHREAD_START_ROUTINE)::GetProcAddress(::GetModuleHandle(L"kernel32"), "LoadLibraryA"),
        buffer, 0, &tid);
    if (!hThread)
        return Error("Failed to create remote thread");

    // 等待远程线程退出
    printf("Thread %u created successfully!\n", tid);
    if (WAIT_OBJECT_0 == ::WaitForSingleObject(hThread, 5000))
        printf("Thread exited.\n");
    else
        printf("Thread still hanging around...\n");

    // 释放和清理
    ::VirtualFreeEx(hProcess, buffer, 0, MEM_RELEASE);
    ::CloseHandle(hThread);
    ::CloseHandle(hProcess);
}

以上代码中,必须指定DLL的完整路径,因为加载规则是从目标进程的角度,而不是调用方的角度。注入的DLL的DllMain显示了一个简单的消息框:

BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, PVOID lpReserved) 
{
    switch (reason) 
    {
        case DLL_PROCESS_ATTACH:
            wchar_t text[128];
            ::StringCchPrintf(text, _countof(text), L"Injected into process %u", ::GetCurrentProcessId());
            ::MessageBox(nullptr, text, L"Injected.Dll", MB_OK);
        break;
    }
    return TRUE;
}

18.10.5.2 Windows挂钩

Windows挂钩指的是一组与用户界面相关的挂钩,可通过SetWindowsHookEx等API使用:

HHOOK SetWindowsHookEx(int idHook, HOOKPROC lpfn, HINSTANCE hmod, DWORD dwThreadId);

lpfn提供的钩子函数具有以下原型:

typedef LRESULT (CALLBACK* HOOKPROC)(int code, WPARAM wParam, LPARAM lParam);

示例代码:

int main() 
{
    DWORD tid = FindMainNotepadThread();
    if (tid == 0)
        return Error("Failed to locate Notepad");

    auto hDll = ::LoadLibrary(L"HookDll");
    if (!hDll)
        return Error("Failed to locate Dll\n");

    using PSetNotify = void (WINAPI*)(DWORD, HHOOK);
    auto setNotify = (PSetNotify)::GetProcAddress(hDll, "SetNotificationThread");
    if (!setNotify)
        return Error("Failed to locate SetNotificationThread function in DLL");

    auto hookFunc = (HOOKPROC)::GetProcAddress(hDll, "HookFunction");
    if (!hookFunc)
        return Error("Failed to locate HookFunction function in DLL");

    // 设置挂钩。
    auto hHook = ::SetWindowsHookEx(WH_GETMESSAGE, hookFunc, hDll, tid);
    if (!hHook)
        return Error("Failed to install hook");

    (...)
}

在DLL(或EXE)中共享变量的技术。注入应用程序在其自身进程的上下文中调用SetNotificationThread,但函数将信息写入共享变量,因此这些变量可用于使用相同DLL的任何进程:

image.png

18.10.5.3 API挂钩

API挂钩是指拦截Windows API(或更一般地说,任何外部函数)的行为,以便可以检查其参数并可能改变其行为,是一种非常强大的技术,首先是反恶意软件解决方案所采用的技术,通常将自己的DLL注入每个进程(或大多数进程),并挂接他们关心的某些函数,如VirtualAllocEx和CreateRemoteThread,将它们重定向到DLL提供的备用实现。在该实现中,他们可以检查参数并在向调用方返回错误代码或将调用转发到原始函数之前执行任何需要的操作。

  • IAT挂钩

导入地址表(Import Address Table,IAT)挂钩可能是函数挂钩的最简单方法,设置相对简单,不需要任何特定于平台的代码。每个PE映像都有一个导入表,其中列出了它所依赖的DLL以及它从DLL中使用的函数。使用dumpbin或图形工具检查PE文件来查看这些导入,以下是notepad.exe的模块概览:

D:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Tools\MSVC\14.29.30133\bin\Hostx64\arm>dumpbin /imports c:\Windows\System32\notepad.exe

Microsoft (R) COFF/PE Dumper Version 14.29.30133.0
Copyright (C) Microsoft Corporation.  All rights reserved.

Dump of file c:\Windows\System32\notepad.exe

File Type: EXECUTABLE IMAGE

  Section contains the following imports:

    KERNEL32.dll
             1400268B8 Import Address Table
             14002D3D8 Import Name Table
                     0 time date stamp
                     0 Index of first forwarder reference

                         2B8 GetProcAddress
                          DC CreateMutexExW
                           1 AcquireSRWLockShared
                         114 DeleteCriticalSection
                         221 GetCurrentProcessId
                         2BE GetProcessHeap
                         281 GetModuleHandleW
                         10A DebugBreak
                         387 IsDebuggerPresent
                         342 GlobalFree
(...)

     GDI32.dll
             140026800 Import Address Table
             14002D320 Import Name Table
                     0 time date stamp
                     0 Index of first forwarder reference

                          34 CreateDCW
                         39F StartPage
                         39D StartDocW
                         366 SetAbortProc
                         180 DeleteDC
                         18E EndDoc
(...)

    USER32.dll
             140026B50 Import Address Table
             14002D670 Import Name Table
                     0 time date stamp
                     0 Index of first forwarder reference

                         2AF PostMessageW
                         28C MessageBoxW
                         177 GetMenu
                          43 CheckMenuItem
                         1C2 GetSubMenu
                          E9 EnableMenuItem
                         38D ShowWindow
                         142 GetDC
                         2FC ReleaseDC
(...)

  Summary
        3000 .data
        1000 .didat
        2000 .pdata
        A000 .rdata
        1000 .reloc
        1000 .rsrc
       25000 .text

调用这些导入函数的方式是通过导入地址表,该表包含加载程序(NtDll.Dll)在运行时映射这些函数后这些函数的最终地址。这些地址事先不知道,因为DLL可能不会在其首选地址加载。

IAT挂钩利用了所有调用都是间接调用的事实,在运行时只替换表中的函数地址以指向替代函数,同时保存原始地址,以便在需要时调用实现。这种挂钩可以在当前进程上完成,也可以在另一个进程的上下文中与DLL注入相结合。

必须在所有进程模块中搜索要挂钩的函数,因为每个模块都有自己的IAT。例如,记事本可以调用函数CreateFileW.exe模块本身,但当调用“打开文件”对话框时,ComCtl32.dll也可以调用它。如果只对记事本的调用感兴趣,那么它的IAT是唯一需要连接的。否则,必须搜索所有加载的模块,并且必须替换CreateFileW的IAT条目。

下面的示例代码从User32.Dll中挂接GetSysColor API,并在应用程序中更改一些颜色,而无需接触应用程序的UI代码:

void HookFunctions() 
{
    auto hUser32 = ::GetModuleHandle(L"user32");
    // save original functions
    GetSysColorOrg = (decltype(GetSysColorOrg))::GetProcAddress(hUser32, "GetSysColor");

    // IAT辅助函数使得挂钩使用变得很简单
    auto count = IATHelper::HookAllModules("user32.dll", GetSysColorOrg, GetSysColorHooked);
    ATLTRACE(L"Hooked %d calls to GetSysColor\n");
}

18.10.5.4 “迂回”式挂钩

挂接函数的另一种常见方法是执行以下步骤:

  • 找到原始函数的地址并保存。
  • 用JMP汇编指令替换代码的前几个字节,保存旧代码。
  • JMP指令调用挂钩函数。
  • 如果要调用原始代码,使用第一步中保存的地址。
  • 取消挂钩时,恢复修改的字节。

该方案比IAT挂钩更强大,因为无论是否通过IAT调用,实际函数代码都会被修改。但这种方法有两个缺点:

替换的代码是特定于平台的。x86、x64、ARM和ARM64的代码不同,因此更难正确使用。
上述步骤必须自动完成。进程中可能有其他线程在程序集字节被替换时调用挂钩函数,可能会导致程序崩溃。
实现这种挂钩很困难,需要复杂的CPU指令和调用约定知识,更不用说上面的同步问题了。有几个开源和免费的库提供此功能,其中一个叫做“Detours”(迂回),来自微软,但也有其他如MinHook和EasyHook。如果需要这种挂钩,优先考虑使用现有的库。以下是Detours挂钩使用示例:

#include <detours.h>
bool HookFunctions() 
{
    DetourTransactionBegin();
    DetourUpdateThread(GetCurrentThread());
    DetourAttach((PVOID*)&GetWindowTextOrg, GetWindowTextHooked);
    DetourAttach((PVOID*)&GetWindowTextLengthOrg, GetWindowTextLengthHooked);
    auto error = DetourTransactionCommit();
    return error == ERROR_SUCCESS;
}

Detours与事务的概念一起工作,事务是一组提交以原子方式执行的操作。我们需要保存原始函数,可以在挂接之前使用GetProcAddress完成,也可以使用指针定义完成:

decltype(::GetWindowTextW)* GetWindowTextOrg = ::GetWindowTextW;
decltype(::GetWindowTextLengthW)* GetWindowTextLengthOrg = ::GetWindowTextLengthW;

18.10.6 DLL基地址

每个DLL都有一个首选加载(基)地址,即PE头的一部分,甚至可以使用Visual Studio中的项目属性来指定它(下图)。

image.png

默认情况下没有任何内容,使得VisualStudio使用一些默认值。对于32位DLL,这些值为0x10000000;对于64位DLL,它们为0x180000000。可以通过dumping从PE标头信息来验证:

dumpbin /headers HookDll_x64.dll
...
    OPTIONAL HEADER VALUES
        20B magic # (PE32+)
...
        112FD entry point (00000001800112FD) @ILT+760(_DllMainCRTStartup)
            1000 base of code
        180000000 image base (0000000180000000 to 0000000180025FFF)
...

dumpbin /headers HookDll_x86.dll
...
OPTIONAL HEADER VALUES
    10B magic # (PE32)
...
    111B8 entry point (100111B8) @ILT+435(__DllMainCRTStartup@12)
        1000 base of code
        1000 base of data
    10000000 image base (10000000 to 1001FFFF)
...

18.10.7 延迟加载DLL

前面研究了链接到DLL的两种主要方式:使用LIB文件的隐式链接(最简单、最方便)和动态链接(显式加载DLL并查找要使用的函数)。事实证明,还有第三种方法,在静态链接和动态链接之间有一种“中间地带”——延迟加载DLL(Delay-Load DLL)。

通过延迟加载,有两个好处:静态链接的便利性和仅在需要时动态加载DLL。要使用延迟加载DLL,需要对使用这些DLL的模块进行一些更改,无论是可执行DLL还是其他DLL。应延迟加载的DLL将添加到输入选项卡中的链接器选项中(下图)。

image.png

如果要支持动态卸载延迟加载DLL,在“高级链接器”选项卡中添加该选项(“卸载延迟加载的DLL”)。剩下的就是链接DLL的导入库(LIB)文件,并使用导出的功能,就像使用隐式链接的DLL一样。以下是延迟加载DLL示例:

#include "..\SimpleDll\Simple.h"
#include <delayimp.h>

bool IsLoaded() 
{
    auto hModule = ::GetModuleHandle(L"simpledll");
    printf("SimpleDll loaded: %s\n", hModule ? "Yes" : "No");
    return hModule != nullptr;
}

int main() 
{
    IsLoaded();

    bool prime = IsPrime(17);
    IsLoaded();

    printf("17 is prime? %s\n", prime ? "Yes" : "No");

    // 卸载dll
    __FUnloadDelayLoadedDLL2("SimpleDll.dll");

    IsLoaded();
    prime = IsPrime(1234567);
    IsLoaded();

    return 0;
}

输出结果:

SimpleDll loaded: No
SimpleDll loaded: Yes
17 is prime? Yes
SimpleDll loaded: No
SimpleDll loaded: Yes
作者:向往
文章来源:https://zhuanlan.zhihu.com/p/579031521
推荐阅读
关注数
1670
文章数
216
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息