[笔记]深入解析Windows操作系统《三》系统机制

文章目录


前言

Windows 操作系统提供了一些基本的机制供内核模式的组件 (比如执行体、内核和设备驱动程序) 使用。

本章将介绍下面的系统机制,同时说明如何使用这些机制:

  • 陷阱分发, 包括中断、延迟的过程调用 (DPC) 、异步过程调用 (APC) 、异常分发以及系统服务分发。
  • 执行体对象管理器。
  • 同步,包括自旋锁、内核分发器对象、等待是如何实现的,以及一些专门针对用户模式的同步原语 (它们不同于传统的同步对象,可避免切换至内核模式)。
  • 系统辅助线程。
  • 其他的机制, 比如 Windows 全局标志。
  • 高级的本地过程调用 (AL PC) 。
  • 内核事件跟踪。
  • Wow 64。
  • 用户模式调试。
  • 映像加载器。
  • 超级管理器 (Hyper-V) 。
  • 内核事务管理器 (KTM) 。
  • 内核补丁保护 (KPP) 。
  • 代码完整性。

第三章 系统机制

3.1 陷阱分发

中断异常是导致处理器转向正常控制流之外代码的两种操作系统条件。硬件或者软件都可以检测到这两种条件

术语陷阱 (trap) 指的是这样一种机制, 当异常或者中断发生时,处理器捕捉到一个执行线程, 并且将控制权转移到操作系统中某一固定地址处。在 Windows 中,处理器会将控制权转给陷阱处理器 (trap handler) 。

所谓陷阱处理器, 是指与某个特定的中断或异常相关联的函数。

图 3.1 显示了一些能激活陷阱处理器的条件。
在这里插入图片描述
内核按照下面的方法来区分中断和异常。

中断是一个异步事件 (可以在任何时候发生),并且与处理器当前正在执行的任务毫无关系。中断主要是由 1/O 设备、处理器时钟或者定时器产生的,并且可以被启用 (打开) 或者禁用 (关闭)。

相反地,异常是一个同步条件,它往往是一个特殊指令执行的结果。(中止 (abort)——比如机器检查,是一种典型的不与指令执行有关联的处理器异常。) 在同样的条件下用同样的数据第二次运行程序可以重现原来的异常。

异常的例子有:

  • 内存访问违例、
  • 特定的调试器指令,
  • 以及除零错误。

内核把系统服务调用也看作异常 (不过,从技术上讲,它们是系统陷阱)。

无论是硬件还是软件都能够产生异常和中断。 例如,总线错误异常是由于硬件问题引起的,而除零异常则是软件错误的结果。同样, 1/O 设备可以产生中断,内核本身也可以发出软中断 (比如 APC 或者 DPC,本章后面会讲到这些机制)。

当硬件异常或者中断产生时,处理器将在被中断的线程的内核栈中记录下足够多的机器状态信息,因而可以回到控制流中的该点处继续执行,就好像什么也没有发生过一样。如果该线程在用户模式下执行,那么 Windows 就切换到该线程的内核模式栈。然后, Windows 在被中断线程的内核栈上创建一个陷阱帧 (trap frame),并且把线程的执行状态保存到陷阱帧里。

陷阱帧是一个线程的完整执行环境的一个子集,在内核调试器中输入 dt nt!_ktrap_frame 就可以看到陷阱帧的定义(第 5 章“进程和线程”介绍了线程环境)。内核在处理软中断时,或者将软中断当作硬中断处理的一部分,或者当线程调用与软中断相关的内核函数时以同步方式进行处理。

在大多数情况下,内核安装了前端陷阱处理函数,在内核将控制权转交给与特定陷阱相关的处理函数之前或者之后,由这些前端陷阱处理函数来执行一些常规的陷阱处理任务。 例如,如果陷阱条件是一个设备中断,则内核的硬件中断陷阱处理器将控制权转交给一个由设备驱动程序提供给该中断设备的中断服务例程(ISR,interrupt service routine)。如果陷阱条件是因为调用一个系统服务而引发的,那么,通用的系统服务陷阱处理器将控制权转交给执行体中指定的系统服务函数。内核也会为它不希望看到的或者根本不处理的陷阱安装陷阱处理器。这些陷阱处理器一般的做法是执行系统函数 KeBugCheckEx,当内核检测到可能导致数据破坏的有问题行为或者不正确行为时,该函数会停止计算机(有关错误检查的更多信息,参见本书下册第 14 章“崩溃转储分析”)。

下面的章节将更加详细地介绍中断、异常和系统服务分发。


3.2 对象管理器

第2章“系统架构”中提到,Windows实现了一个对象模型,为执行体中实现的各种内部服务提供一致的、安全的访问机制。

本节将介绍Windows的对象管理器(object manager),即执行体内部负责创建、删除、保护和跟踪对象的组件。

对象管理器将那些本来有可能散落在操作系统各处的资源控制操作集中在一起,其设计意图是满足稍后列出的一系列目标。


对象管理器的设计目标如下:

  • 提供一种公共的、统一的机制来使用系统资源。
  • 将对象保护隔离到操作系统中的一个地方,从而确保统一的、一致的对象访问策略。提供一种机制来管理进程对对象的使用,从而可以对系统资源的使用加以限制。
  • 建立一套对象命名方案,它可以很方便地融合已有的对象,比如设备、文件、文件系统中的目录,或者其他独立的对象集合。
  • 支持各种操作系统环境的需求,比如一个进程能够从它的父进程继承资源(Windows和UNIX应用子系统都需要这样的能力)、创建大小写敏感的文件名称的能力(UNIX应用子系统需要这种能力)。
  • 建立统一的规则来维护对象的保持力(也就是说,保持一个对象总是可用的,直至所有的进程都用完了这个对象为止)。
  • 提供为特定的会话隔离对象的能力,以便在名字空间中允许局部(local)全局(global)对象并存。

在内部,Windows有三种类型的对象:

  • 执行体对象、
  • 内核对象
  • GDI/User对象。

所谓执行体对象,是指由执行体的各个组件(比如进程管理器、内存管理器、IO子系统,等等)所实现的对象
内核对象是指由Windows内核实现的一组更为基本的对象

这些对象对于用户模式代码不可见,它们只是在执行体内部被创建和使用。

内核对象提供了最为基本的能力,比如同步等,执行体对象正是建立在它们之上。

因此,许多执行体对象包含(封装)了一个或者多个内核对象,如图3.18所示。

有关内核对象数据结构的细节,以及如何利用内核对象来实现同步的详细情况,本章后面会介绍。
在本节的余下内容中,我们将集中介绍对象管理器是如何工作的,以及执行体对象、句柄和句柄表的数据结构。

这里,我们也将粗略地描述一下对象是如何参与到Windows的安全访问检查机制中的,在第6章中我们将全面地讨论这一话题。
在这里插入图片描述

执行体对象

每个Windows环境子系统总是把操作系统的不同面貌呈现给它的应用程序。执行体对象和对象服务是环境子系统用于构建其自有版本的对象和其他资源的基础。

执行体对象 往往或者由环境子系统代表用户应用程序而创建,或者由操作系统的各种组件作为其常规操作的一部分而创建。例如,为了创建一个文件,Windows应用程序调用Windows的CreateFileW函数,该函数是在Windows子系统DLL Kernelbase.dlI中实现的。在经过一些验证和初始化工作以后,CreateFileW又依次调用原生的Windows服务NtCreateFile来创建执行体文件对象。

环境子系统提供给其应用程序的对象集合可能比执行体提供的要大–些,也可能小-一些。Windows子系统使用执行体对象来导出自己的对象集合,其中许多对象直接对应于执行体对象。例如,Windows的互斥体和信号量直接建立在执行体对象之上,而这些执行体对象又建立在对应的内核对象之上。另外,Windows子系统提供了命名管道(named pipe)邮件槽(mailslot),以及资源( resource),它们都建立在执行体文件对象基础之上。

有些子系统,比如UNIX应用子系统,甚至根本不提供对象方式的支持。UNIX应用子系统使用执行体对象和服务作为基础,来向其应用程序表达UNIX风格的进程、管道和其他资源。

表3.8 列出了执行体提供的基本对象,并简要地描述了它们所代表的含义。在本书后面专门讲述有关执行体组件的章节中,可以找到更多有关执行体对象的细节(如果执行体对象被直接导出至Windows子系统,那么可以在Windows API参考文档中找到有关的细节。) 也可以用提升的权限来运行Winobj,并转换到ObjectType目录,然后就可以看到完整的对象类型列表。

注:
执行体总共实现了42种对象类型。这些对象中有许多仅被用于其定义所在的执行体组件,无法通过Windows API直接访问。这样的对象例子包括驱动程序(Driver)、 设备(Device)和事件对(EventPair)。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

注:
由于Windows NT最初设计时要求支持OS/2操作系统,因此,互斥体必须要跟OS/2互斥体对象已有的设计保持兼容,OS/2的设计要求一个线程能够舍弃互斥体对象,使它不可再被访问。由于这种行为对于互斥体对象来说被认为是非同寻常的,因此,Windows创建了另一个内核对象,即突变体(mutant)。 最终,对OS/2的支持被放弃了,该对象被Windows 32子系统用于互斥体(mutex),但是在内部它仍然被称为突变体。

如图3.19所示,每个对象都有-一个对象头和一个对象体。对象管理器控制了对象头,而执行体组件则控制了由它们所创建的对象类型的对象体。

每个对象头中也有一个索引,指向一个被称为类型对象(typeobject)的特殊对象,该对象包含的信息对于它的每个实例是公共的。

另外,还可以有多达五个可选的子头:

  • 名称信息头、
  • 配额信息头、
  • 进程信息头、
  • 句柄信息头
  • 创建者信息头。
    在这里插入图片描述

对象头和对象体

对象管理器使用对象头中保存的数据来管理这些对象,而无须涉及其类型。
表3.9简要地描述了对象头的域,表3.10描述了可选的对象子头中的域。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
对象头中包含的信息适用于任何类型的对象,除此之外,子头中包含的可选信息仅涉及对象的某些特定的方面。

注意,这些结构的位置是从对象头的开始处加上-一个并不固定的偏移量,此偏移值取决于与主对象头相关联的子头的数量(如上所述,除了创建者信息)。对于每一个出现在对象头部的子头,InfoMask域相应地做更新,以反映出该子头存在于对象头部。对象管理器在检查指定的子头时,先检查InfoMask中对应的位是否已被设置,然后利用余下的位,从ObpInfoMaskToOffset表中选择出正确的偏移,这样就可以找到距离对象头开始处的偏移量。

对于所有子头各种可能的组合情形,都存在相应的偏移量,但是,因为子头(如果存在)总是按照固定不变的顺序来分配,所以,对于一个给定的头,它可能出现的位置的数量,与优先于它而出现的最多子头数量相同。例如,因为名称信息子头总是最先分配的,所以,它只有-一个可能的偏移量。另一方面,句柄信息子头(第三优先分配)有三个可能的位置,因为它可能在配额子头之后分配,也可能不在配额子头之后;而这也可能在名称信息之后分配。

表3.10 描述了所有可选对象子头和它们的位置。对于创建者信息的情形,对象头标志中有一个值指明了该子头是否存在。(关于这些标志的信息,参见表3.12。)这些子头中的每一个都是可选的,只有在特定的条件下才会出现,可能在系统引导期间,也可能在对象创建时。表3.11描述 了这些条件。

最后,许多属性和/或标志决定了对象在创建时或某些特定操作过程中的行为。任何一个新对象在被创建时,对象管理器都会在-一个称为对象属性(objectattribute)的结构中接收到这些标志。此对象属性结构定义了对象名称、将来被插入处的根对象目录、对象的安全描述符,以及对象属性标志(object attribute flag)。

表3.12列出了各种可以与一个对象关联起来的标志。
在这里插入图片描述

注:
当通过Windows 子系统中的API函数(比如CreateEvent或CreateFile)来创建对象时,调用者并不指定任何对象属性一子 系统DLL将在背后完成这些事情。由于这个原因,所有通过Win32创建的有名称的对象都将进入BaseNamedObjects目录中(无论是全局实例,还是每个会话的实例),因为这是Kernelbase.dll在对象属性结构中指定的根对象目录。有关BaseNamedObjects及其如何与会话名字空间关联起来的更多信息,参见本章后面的介绍。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
除对象头外,每个对象还有一个对象体,其格式和内容都取决于其对象类型:同一类型的所有对象共享同样的对象体格式。执行体组件通过创建一一个对象类型,并为其提供必要的服务,便可以控制所有该类型的对象体的数据。因为对象头有-一个静态的、确定的大小,所以,对象管理器可以很容易地为-一个对象找到它的对象头,做法很简单,从对象指针上减去头的大小。正如前面所解释的那样,要想访问对象的子头,对象管理器只需从对象头的指针再减去另一个确定的值即可。

由于对象头和子头结构已被标准化,因此对象管理器可以提供少量通用的服务对一个对象头中保存的属性进行操作,而且这些服务可以作用在任何类型的对象上(不过,有些通用服务对于某些特定的对象并没有意义)。

表3.13中 列出了这些通用的服务,Windows子 系统允许其中的某些服务直接被Windows应用程序使用。虽然所有的对象类型都支持这些通用的对象服务,但是,每个对象都有其自己的创建、打开和查询服务。例如,I/O系统为其文件对象实现了一个创建文件的服务,而进程管理器则为其进程对象实现了一个创建进程的服务。尽管实现一个通用的创建对象的服务也是有可能的,但是这样的例程将会相当复杂,因为,举例而言,初始化–个文件对象与初始化一个进程对象,所需要的参数集合会有极大的差异。而且,每次线程调用对象服务来确定句柄所指对象的类型,再调用正确版本的服务时,对象管理器都将招致额外的处理开销。

在这里插入图片描述
对象头中包含的数据对于所有的对象都是公共的,但是每个对象实例可以取不同的值。

类型对象

例如,每个对象有一个唯一名称, 也可以有唯一-的安全描述符。然而,对象也可以包含一些对于某种特定类型的所有对象皆为常数的数据。例如,当打开一个指向某种类型的对象的句柄时,可以从一-组特定于该对象类型的访问权限中进行选择。执行体为线程对象提供了终止和挂起(及其他)访问操作,为文件对象提供了读、写、添加和删除(及其他)访问操作。与对象类型相关的属性的另-一个例子是同步,稍后会介绍。

为了节省内存,对象管理器只在创建新的对象类型时,才存储这些静态的、特定于对象类型的属性。它使用自己的一个对象(一个类型对象)来记录这些数据。

如图3.20所示,如果对象跟踪调试标志(参见本章后面的“Windows全局标志”一节所述)被设置,则类型对象也会将同一类型(在图中是进程类型)的所有对象链接起来,从而使对象管理器可以在必要的时候找到这些对象,并且对它们进行枚举。这- .功能利用了前面讨论过的创建者信息子头。
在这里插入图片描述


在用户模式下不能操纵类型对象,因为对象管理器并没有提供任何有关类型对象的服务。
然而,类型对象定义的某些属性通过特定的原生服务或者WindowsAPI例程是可见的。表3.14
列出了在类型初始化结构中保存的信息。

在这里插入图片描述
在这里插入图片描述
同步是一个对于Windows应用程序可见的属性,它指的是,一一个线程通过等待某个对象从一.种状态改变成另–种状态,从而达到同步其执行过程的能力。–个线程可以通过执行体的作业、进程、线程、文件、事件、信号量、互斥体和定时器对象来进行同步。其他的执行体对象不支持同步。

一个对象支持同步的能力,基于以下三种可能性:

  • 该执行体对象是一个分发器对象的包装对象,它包含-一个分发器头;分发器头是一个内核结构,本章后面“低IRQL的同步”一节中将进一步介绍。
  • 对象类型的创建者请求-一个默认对象,而对象管理器提供默认对象。
  • 执行体对象有一个内嵌的分发器对象,比如在对象体内部某处的一个事件;该对象的所有者在注册此对象类型时向对象管理器提供了此内嵌对象的偏移量(参见表3.14中的描述)。

对象方法

表3.14 中最后一个属性,即方法,是由一组内部例程构成的,这些例程类似于C++的构造函数和析构函数一-也就是说, 当一个对象被创建或者销毁时自动被调用的例程。对象管理器扩展了这种思想,它也可以在其他一些场合下调用对象的方法,比如当有人打开或关闭一个指向某个对象的句柄,或者企图改变对象上的保护属性时。有些对象类型指定了这些方法,
而其他的对象类型则没有,这取决于对象类型将来如何使用。

执行体组件创建新的对象类型时,可以向对象管理器注册一-个或者多个方法。因此,对象管理器会在此种类型的对象的生命期过程中,在某些明确定义的点上调用这些方法,通常是在一个对象被创建、删除或者以某种方式被修改的时候调用这些方法。对象管理器支持的方法如表3.15所示。

之所以设计这些对象方法,是为了针对这样的事实:如你所见,某些特定的对象操作是通用的(比如关闭、复制、安全等)。要将这些通用的操作完全一般化将要求对象管理器的设计者必须预知所有的对象类型。然而,内核将创建对象类型的例程导出到了内核模块之外,从而允许外部的内核组件可以创建其自己的对象类型。尽管这种功能并没有以文档的形式开放给驱动程序开发人员,但实际上Win32k.sys内部使用了这种功能来定义WindowStation和Desktop对象。通过对象方法的扩展机制,Win32k.sys为诸 如创建和查询操作定义了专门的例程。

在这里插入图片描述
在这里插入图片描述
这一规则的一个例外是安全(security) 例程,该例程除非另行指明,否则默认指向SeDefaultObjectMethod。此默认例程并不需要知道对象的内部结构,因为它只处理对象的安全描述符,而我们已经看到过,指向安全描述符的指针被存储在对所有对象都适用的对象头部,而不是对象体中。然而,如果-一个对象要求使用它自己额外的安全检查,那么,它可以定义一个专门的安全例程。使用统–的安全方法的另一个理由是避免复杂性,因为绝大多数对象依赖于安全引用监视器来管理它们的安全性。

对象管理器创建指向对象的句柄时,就会调用Open方法;当–个对象被创建或者打开时,该方法就会被调用。WindowStation和Desktop对象定义了Open方法。例如,WindowStation对象类型要求有一个Open方法,这样,Win32k.sys能够与进程共享同一块内存(作为桌面内存池)。

Close方法的一个用法例子是在I/O系统中。I/O管理器为文件对象类型注册一个Close方法,对象管理器每次关闭对象句柄时就会调用Close方法。此Close方法将查看正在关闭该文件句柄的进程是否拥有任何用于该文件并且尚未完成的锁,如果拥有未完成的锁,则去除这些锁。检查文件锁并不是对象管理器所能完成或者应该做的事情。

对象管理器在从内存中删除一个临时对象以前,会调用Delete方法,如果该方法已经被注册了的话。例如,内存管理器为内存区对象类型注册一个Delete方法, 它会释放该内存区所使用的物理页面。它在删除-一个内存区对象以前,也会验证内存管理器为该内存区所分配的任何内部数据结构都已被删除。同样,对象管理器不可能自己做这项工作,因为它对于内存管理器的内部工作一一无所知。针对其他类型的对象的Delete方法也完成类似的功能。

Parse方法(类似地,也包括Query name方法)允许对象管理器把查找-一个对象的控制权交给一个从属的对象管理器,如果它发现-一个对象存在于对象管理器名字空间之外的话。对象管理器在查找一个对象名称时,如果在搜索路径上碰到一个关联了Parse方法的对象,就会暂时挂起该搜索过程。对象管理器调用此Parse方法,将当前正在查找的对象名称的剩余部分传递给它。除了对象管理器的名字空间以外,在Windows中还有两个名字空间:注册表名字空间和文件系统名字空间。注册表名字空间是配置管理器实现的,而文件系统名字空间则是I/O管理器在文件系统驱动程序的帮助下实现的。(关于配置管理器的更多信息,参见第4章“管理机制”;关于I/O管理器和文件系统驱动程序的更多细节,参见本书下册第8章。) 例如,当一个进程打开一个名为\Device\HarddiskVolume 1\docs\resume.doc 的文件的句柄时,对象管理器会遍历它的名称树,直到到达了名为HarddiskVolumel的设备对象。它看到该对象

有一个关联的Parse方法,于是调用此方法,并且将当前正在搜索的对象名称的剩余部分(在这个例子中,是字符串docs\resume .doc)传递给它。设备对象的Parse方法是一-个I/O例程,因为I/O管理器定义了设备对象类型,并且为它注册了一个Parse方法。 I/O管 理器的Parse例程接受此名称字符串,并且将它传递给适当的文件系统,该文件系统会找到磁盘上的文件,并且
打开此文件。Security方法也是I/O系统使用的方法,它类似于Parse方法。只要- - 个线程试图查询或者改变那些用于保护一个文件的安全信息,该方法就会被调用。这些安全信息对于文件和其他对象是不同的,因为安全信息存储在文件本身,而不是内存中。因此,必须要调用I/O系统才能找到安全信息,并且将它们读出来或者进行修改。最后,Okaytoclose则是针对系统所使用的句柄,针对恶意或不正确关闭句柄的一-个额外保护层。例如,每个进程有-一个或多个指向Desktop对象的句柄,它的线程所创建的窗口都在这个(或这些)桌面对象上可见。在标准的安全模型下,这些线程有可能关闭其指向桌面的句柄,因为该进程对它自己的对象有完全的控制权。在这种情形下,线程可以做到不与任何一个桌面关联一-这违反 了Windows的窗口模型。Win32k.sys为Desktop 和WindowStation对象注册了一个Okay to close例程,以防止发生这种情况。

对象句柄和进程句柄表

当一个进程根据名称来创建或者打开-一个对象时,它接收到一个句柄,代表了对此对象的访问。通过句柄来访问一个对象,要比直接使用名称来访问对象快得多,因为对象管理器可以跳过名称查找过程,而直接找到目标对象。进程也可以在其创建时刻,通过继承句柄的方式来获得对象的句柄(要求创建者在CreateProcess调用中指定了继承句柄的标志,并且句柄已被标记为可继承的,它可以是在该句柄被创建的时候进行标记,也可以事后通过Windows的SetHandleInformation函数来设定),或者从另一个进程接收-一个 复制的句柄(参见Windows的DuplicateHandle函数)。

所有的用户模式进程在其线程使用一个对象以前,必须先拥有一个指向该对象的句柄。使用句柄来维护系统资源并不是一个新的想法。例如,C和Pascal(以及老式的类似于Delphi这样的程序设计语言)运行库会把已打开文件的句柄返回给应用程序。句柄被用作指向系统资源的间接指针;这一层间接性使得应用程序不用直接与系统数据结构打交道。对象句柄还提供了额外的一些好处。 第一,除了所指的内容不同以外,文件句柄、事件句柄和进程句柄没有区别。这种相似性使得可以用一个统一的接口来引用对象,而无须关心它们的类型。第二,对象管理器有独占的权限来创建句柄,以及找到一个句柄所指的对象。这意味着,对象管理器可以仔细地审查每一个可能会影响对象的用户模式动作,看一看调用者的安全轮廓是否允许在该对象上执行所请求的操作。

注:
执行体组件 和设备驱动程序可以直接访问对象,因为它们运行在内核模式下,因此可以访问系统内存中的对象结构。然而,它们必须显式地声明自己要使用某-一个对象, 其做法是增加该对象的引用计数,这样做的结果是,该对象在使用过程中不会被释放掉(更多的细节,参见本章稍后的“对象保持力”一节)。然而,要想真正成功地使用一一个对象,设备驱动程序需要知道对象的内部结构定义,而这并非大多数对象所能提供的。相反地,设备驱动程序应该使用恰当的内核API来修改或读取对象的信息。例如,虽然设备驱动程序可以获得指向进程对象(EPROCESS)的指针,但该数据结构并不透明,驱动程序应该使用Ps形式的APl。对于其他的对象,类型本身是不透明的(比如大多数执行体对象包装了一个分发器对象,譬如事件或互斥体)。对于这些对象,驱动程序必须使用用户模式应用程序最终同样会使用的系统调用( 比如ZwCreateEvent),只不过应用程序使用句柄而非对象指针。


保留对象( Reserve Objects )

因为对象代表了从事件到文件,到进程间消息的任何事物,所以,让应用程序和内核代码有能力来创建对象,这本质上对于任何部分的Windows代码都是一项常规的、期望的运行时行为。如果一个对象的内存分配失败,那么这通常是各种异常现象 从功能缺失(进程不能打开文件)到数据丢失或崩溃(进程不能分配同步对象)的原因所在。更糟的是,在特定的情形下,指示对象创建失败的错误报告本身可能也要求分配新的对象。Windows实现了两个特别的保留对象来处理这样的情形:用户APC保留对象和I/O完成包保留对象。注意,保留对象机制本身是完全可扩展的,将来的Windows版本可能会增加其他的保留对象类型- -从更广阔
的视角来看,保留对象是一种机制,将来可允许任何内核模式数据结构被包装成对象(有关联的句柄、名称和安全性)。
本章前面的APC部分中讲到,APC被用于诸如挂起、终止和VO完成等操作,也被用于想要提供异步回调的用户模式应用程序之间的通信。当用户模式应用程序请求用户APC被定向到另一个线程时,它使用KernelBase.dll中的QueueUserApe API函数,该函数又调用NtQueueUserApcThread系统调用。在内核中,此系统调用试图在换页池中分配-一块内存, 以存放与APC相关联的KAPC控制对象结构。在低内存情形下,这一操作失败, 妨碍了APC被交付;根据此APC的可能用途,这可能又会招致数据丢失或者功能缺失。为了防止出现这种情况,用户模式应用程序可以在启动的时候,使用NtallocateReserveObject系统调用,请求内核预先分配此KAPC结构。然后,应用程序使用另一个系统调用NtQueueUserApcThreadEx,它包含-一个 额外的参数,用于保存指向此保留对象的句柄。这次,内核不再申请一个新的结构,而是试图获取保留对象(将它的InUse位设置为true),并使用该对象,直到此KAPC对象不再需要为止:到那个时候,此保留对象被释放,又归还给系统。目前,为了防止第三方开发人员错误地管理系统资源,保留对象API仅仅在内部通过系统调用的形式,供操作系统组件使用。例如,RPC库使用保留的APC对象来保证,即使在低内存情形下,异步回调仍然能够返回。当应用程序需要保证I0完成端口的消息或者包总能被交付时,类似的情形也会发生。通
常这些完成包是通过KermelBase.dIl中的PostQueuedCompletionStatus API来发送的,该函数又会调用NtSetloCompletion API。与用户APC的情形类似,内核必须要分配一个I/O管理器的数据结构来容纳此完成包的信息,如果这一内存申请失败,则该包无法被创建出来。利用保留对象机制,应用程序可以在启动的时候使用NtallocateReserveObject API,让内核预先分配一个I/O完成包,然后使用NtSetloCompletionEx系统调用,向它提供-一个指向此保留对象的句柄,从而可以确保成功的执行路径。如同用户APC保留对象一样,这- .功能仅被保留用于系统组件,当前被RPC库和Windows Peer-To-Peer BranchCache服务(有关网络的更多信息,参见第7章“网络”)用来保证异步I/O操作的完成。

对象安全性

当你打开一个文件的时候,你必须要指定你的目的是读或是写。如果你在打开文件时指定了读访问,但又试图执行写操作,那么你就会得到一个错误。同样地,在执行体内部,当一个进程创建一个对象或者打开一个指向已有对象的句柄时,该进程必须要指定-.组期望的访问权限( desired acess rights),也就是说,它打算怎样操作该对象。它既可以请求一组适用于所有对象的标准访问权限(比如读、写和执行),也可以指定一组随 着对象类型而有所不同的特殊访问权限。例如,一个进程可以请求对一一个文件对象进行删除或者追加访问。类似地,它也可以要求能够挂起或者终止一个线程对象。当一个进程打开-一个对象句柄时,对象管理器调用安全引用监视器(securityreferencemonitor),这是安全系统的内核模式部分,而且,对象管理器将该进程期望的一-组访问权限传送给它。安全引用监视器检查该对象的安全描述符是否允许该进程所请求的访问类型。如果允许的话,引用监视器返回- -组准许的访问权限( granted access rights),允许该进程得到这些权限,同时,对象管理器将这些权限存放在它所创建的对象句柄中。至于安全系统是如何确定谁可以访问哪些对象的,请参见第6章。之后,无论何时当该进程的线程通过-一个系统服务调用,要使用此句柄时,对象管理器可以根据该线程调用的对象服务所隐含的用法,快速地检查这–组存储于句柄中的准许的访问权限。例如,如果调用者请求读访问一个内存区对象,但之后却调用一个服务对它进行写操作,则该服务就会失败。


Windows也支持一些API的Ex (扩展)版本,例如CreateEventEx、CreateMutexEx、CreateSemaphoreEx等,它们加入了额外的参数来指定访问掩码。这使得应用程序有可能正确地使用自主访问控制列表(DACL)来保护它们的对象,同时又不破坏它们的“使用这些创建对象API来打开对象句柄”的能力。你可能会奇怪,为什么一个客户应用程序不是简单地使用OpenEvent (因为该函数已经支持- -个期望的访问权限参数了) ?使用打开对象API会导致在处理打开调用失败情形时发生竞争条件一-- 也就是说, 客户应用程序试图打开事件对象,而该对象尚未被创建起来。在大多数这种应用程序中,在打开API的后面,在失败情形下,会跟着一个创建API调用。不幸的是,并没有可靠的办法来保证此创建操作是原子的(atomic),换句话说,无法保证创建操作只发生- -次。实际上,多个线程或进程并发地执行此创建API是有可能的,也就是说,它们在同一时刻试图创建该事件对象。这一竞争条件, 以及为了处理该竞争条件而招致的额外复杂性,使得利用打开对象API并不是解决问题的正确方案,这也正是应该改而使用扩展(Ex)API的原因。

对象保持力 (Object Retention)

对象有两种类型:暂时的和永久的。大多数对象是暂时的,也就是说,它们只有在使用过程中才保留着,当不再需要的时候就会被释放掉。永久对象会一直保留着, 直到被显式地释放掉为止。因为大多数对象是暂时的,所以本小节接下来部分介绍一下对象管理器是如何实现对象保持力(object retention)的,即只有当暂时对象还在被使用的时候,才会保留它们,而等到用完以后就会将它们删除。因为所有的用户模式进程在访问一个对象以前首先要打开一个指向该对象的句柄,所以,对象管理器可以很容易地跟踪有多少个进程正在使用一个对象,甚至于哪些进程正在使用它。跟踪这些句柄只是实现对象保持力的一部分。 对象管理器通过两个阶段来实现对象保持力。第- -个阶段称为名称保持力(name retention),它是受一个对象已有打开句柄的数目控制的。每次当一- 个进程打开-一个对 象的句柄时,对象管理器就会将该对象的头部的已打开句柄计数器增加1。当这些进程用完了该对象,并且关闭了指向它的句柄时,对象管理器就会相应地递减已打开句柄计数器。当该计数器减到0的时候,对象管理器从它的全局名字空间中删除该对象的名称。这样删除以后,可以防止再有进程打开指向该对象的句柄。

实现对象保持力的第二阶段是,当对象不再有用的时候,停止保留对象本身(也就是说,删除它们)。因为操作系统代码通常通过指针而不是句柄来访问对象,所以,对象管理器必须要记录下它已经给操作系统进程分配了多少个对象指针。每次当它提供–个指向该对象的指针时,它就会递增-一个专 用于该对象的引用计数(reference count);当内核模式组件用完了该指针时,它们会调用对象管理器以递减该对象的引用计数。当系统递增句柄计数的时候,它也会递增该引用计数,同样地,当句柄计数递减的时候,它也会递减引用计数,因为对于这种必须跟踪的对象来说,句柄也是它的引用。

图3.23显示了两个使用中的事件对象。进程A打开了第-一个事件,进程B同时打开了两个对象。而且,第一个事件同时还被某一个内 核模式的结构引用了:因此,其引用计数为3。所以,即使进程A和B都关闭了指向第一个事件对象的句柄,第-一个事件对象仍然会存在,因为它的引用计数为1。然而,当进程B关闭了指向第二个事件对象的句柄时,该对象将被释放掉。所以,即使当一个对象的已打开句柄计数器到达了0,该对象的引用计数可能仍然是一个正数,表明了操作系统仍然在使用该对象。最终,当引用计数减到0的时候,对象管理器就会将它从内存中删除。这一删除操作必须要遵从一定的规则,而且在特定的情形下还需要调用方的配合。例如,因为对象既可以位于换页内存池,也可以位于非换页内存池(取决于其对象类型中的设置),所以,如果在-一个Dispatch级别或更高IRQL上发生了解引用操作,并且此解引用操作导致引用计数减到0,那么,系统若试图立即释放换页内存池对象的内存,则可能会崩溃。(前面曾经提到过,这样的内存访问操作是非法的,因为其页面错误永远也不会被处理。)在这种情形下,对象管理器将执行一个延迟的删除操作( deferred delete operation),它把此操作放到一个在被动级别(IRQL为0),上运行的辅助线程的队列中。在本章后面,我们将会进一步讲述系统辅助线程。

另一种要求延迟删除的情形发生在处理KTM ( 内核事务管理器,Kernel Transaction Manager)对象的时候。在有些情况下,特定的驱动程序可能拥有一一个与这种对象相关联的锁,若试图删除该对象,则系统将会试图获取其关联的锁。然而,驱动程序可能永远也不会有机会释放此锁,从而导致死锁。在处理KTM对象的时候,驱动程序开发人员必须使用ObDereferenceObjetDeferDelete来强制使用延迟的删除,无论当时的IRQL级别是什么。最后,I/O管理器也会使用这–机制作为一种优化,从而有些特定的I/O可以更快地完成,而不用等待对象管理器删除相应的对象。

在这里插入图片描述
由于对象保持力这样的工作方式,一个应用程序只需简单地保持-一个已打开的句柄指向某个对象,就可以确保该对象和它的名称仍然在内存中。如果程序员编写的应用程序包含了两个或者多个相互协作的进程,则他们无须担心-一个进程正在使用一个对象时另-一个进程会删除该对象;而且,如果操作系统正在使用一个对象,则关闭一个应用程序指向该对象的句柄也不会导致该对象被删除。例如,-一个进程可能创建了第二个进程,让它在后台执行一个程序,然后前者立即关闭了指向第二个进程的句柄。因为操作系统需要第二个进程来运行该程序,所以它维护了一个指向该进程对象的引用。只有当后台程序完成了它的执行任务时,
对象管理器才会递减第二个进程的引用计数,然后将它删除。因为对象泄漏对于系统是很危险的,它泄漏了内核内存池,最终招致全系统范围内的内存缺失,而且,对象泄漏也可能会以各种微妙的形式打破应用程序,所以,Windows包含了多种调试机制,使得可以监视、分析和调试各种与句柄和对象有关的问题。而且,Windows的调试工具箱提供了两个扩展模块来接合这些机制,并提供了友好的图形分析。表3.16描述了这些调试机制。

在这里插入图片描述
当试图要理解每个句柄在一-个应用程序或系统环境中是如何被使用的时候,启用句柄跟踪数据库( handle-tracing database)是非常有用的。!htrace是一个调试器扩展,它可以显示出一个指定的句柄在被打开时候捕获到的栈痕迹。当你发现–个句柄泄漏的时候,通过栈痕迹,你可以定位到创建该句柄的代码上,然后可以分析在哪里漏了一个诸如CloseHandle这样的函数调用。

对象引用跟踪(object-reference-tracing) !obtrace扩 展监视的内容更多,它可以显示每个新句柄被创建时的栈痕迹,以及每次-一个句柄被内核引用和解引用(以及打开、复制或继承)时候的栈痕迹。通过分析这些模式,从系统层次上来看-一个对象被误用,就可以很容易调试。而且,这些引用的栈痕迹提供了–种方法来理解系统在处理某些特定对象时的行为。例如,在
跟踪过程中,可以显示出系统中所有已经登记了回调通知的驱动程序(比如进程监视器)中的引用,并且可以帮助检测到非正常的或错误的第三方驱动程序,它们可能在内核模式下引用了句柄,但从来没有解除这些引用。注当针对特定的对 象类型启用对象引用跟踪时,你可以获得它的内存池标记的名称,做法是,在使用dt命令时检查一下OBJECT. TYPE结构的key成员。系统中的每一种对象类型都有一个全局变量来引用此结构,例如,PsProcessType.另一-种做法是, 你也可以使用!object命令,它会显示指向此结构的指针。

与前两种机制不同,对象引用标记(object-reference tagging)不是一种调试功能,并非通过全局标志或者调试器来启用,而是–组API,由设备驱动程序开发人员用于引用对象,或者解除对象引用,这样的API包括ObReferenceObjectWithTag和ObDeferenceObjectWithTag.与内存池标记( pool tagging,更多的信息,参考本书下册第10章)特性类似,这些API允许开发人员
提供–个4字符的标记来标识出每一对引用/解引用。当使用前面刚刚描述的!obtrace扩展时,每一个引用或解引用操作的标记也会显示出来,这样可以避免仅仅使用调用栈这一种机制来标识句柄泄漏或引用不足(under-reference) 发生的问题,特别是,如果- -个给定的调用被驱动程序执行了数千次之多的情况下。

资源记账

资源记账(resource accounting),如同对象保持力一样, 与对象句柄用法的关系非常密切。一个正的已打开句柄计数值表明了某个进程正在使用该资源。它也表明,必定某个进程承担了该对象所占用的内存消耗。当一个对象的句柄计数和引用计数减到0的时候,原先使用该对象的那个进程应该不用再承担这些内存消耗了。

许多操作系统使用一个配额系统(quota system)来限制进程对系统资源的访问。然而,在进程上强加的配额类型有时候是多样而又复杂的,并且,跟踪配额的代码散布在整个操作系统中。例如,在有些操作系统中,I/O组件可能会记录和限制每个进程所能打开的文件数量,而内存组件可能会强迫限制一个进程的线程所能分配的内存数量。进程组件可能会限制一个用户所能创建的新进程的数量不得超过某个最大值,或者限制一个进程内部的线程数量不得超过某个最大值。操作系统分别在不同的地方跟踪和强加这些限制值。

与此相反,Windows的对象管理器提供了-一个中心设施来实现资源记账。每个对象头都包含了一个称为配额花费(quota charges)的属性,其中记录了当一个进程的线程打开一个指向该对象的句柄时,对象管理器从该进程在换页池和/或非换页池中分配得到的配额中该减去多少。

Windows中的每个进程都指向一个配额数据结构,其中记录了该进程在非换页池、换页池和页面文件中使用量的限制值和当前值。这些配额默认为0 (表示无限制),但通过修改注册表值可以指定它们(你需要在HKLM\System\CurrentControlSet\Control\SessionManagerMemory Management 下面增加或编辑NonPagedPoolQuota、PagedPoolQuota 和 PagingFileQuota)。 请注意,一个交互会话中所有的进程共享同样的配额块(没有任何文档化的方法可以创建具有自己特有配额块的进程)。

3.3 同步

在操作系统的发展过程中,互斥的概念是非常重要的。它指的是,保证任何时候只有一个线程可以访问某一特定的资源。 当一个资源不允许共享访问,或者共享访问将导致不可预测的后果时,互斥是必需的。 例如,如果两个线程同时拷贝一个文件到打印机端口上,则它们的输出有可能被混杂在一起。类似地,如果一个线程在读某个内存位置时另一个线程正在往里写数据,则第一个线程有可能读取到不可预测的数据。一般地, 可写的资源在没有限制的情况下是不能被共享的,而不会被修改的资源则可以被共享。

图3.24演示了当两个运行于不同处理器上的线程同时向一个循环队列写数据时可能发生的情形。

因为在第一个线程尚未更新队列的尾指针以前,第二个线程就获得了尾指针的值,所以第二个线程将数据插入到第一个线程刚刚使用过的位置上,从而改写了该位置上的数据,同时留下了一个空的队列元素。

虽然此图演示的是在一个多处理器系统上可能发生的情形,但同样的错误也可能在单处理器上发生:如果在第一个线程更新队列尾指针以前操作系统执行了一个环境切换,将控制权交给了第二个线程。
在这里插入图片描述
如果一段代码区访问了一个不可共享的资源,则这样的代码区 称为临界区(critical section)

为了确保代码正确无误,同一时刻只允许一个线程在临界区内执行。当一个线程在写一个文件、更新一个数据库,或者修改-一个共 享变量的时候,其他的线程不允许访问同样的资源。图3.24中显示的伪代码是一个临界区,它在毫无互斥的情况下,不正确地访问了一个共享的数据结构。

尽管互斥问题对于所有的操作系统都很重要,但是对于一个像Windows这样的紧耦合的、对称多处理(SMP)操作系统显得尤为重要(和复杂),在这样的系统中,同样的系统代码同时运行在多个处理器上,它们共享了存储在全局内存中的特定数据结构。在Windows中,内核负责提供各种机制,供系统代码用来避免两个线程同时修改同样的数据结构。内核提供的互斥原语使得它自己和执行体的其他部分可以利用这些原语来同步它们对于全局数据结构的访问。

因为在DPC/Dispatch级别的IRQL上,调度器已经对其数据结构的访问进行了同步,所以,当IRQL在DPC/Dispatch或者更高的级别( 称为提升的或者高IRQL级别)上时,内核和执行体不能依赖于那些可能会导致页面错误或者重新调度操作的同步机制,来对各种数据结构的访问进行同步。在下面的小节中,你将会看到,当IRQL高的时候,内核和执行体如何使用互斥机制来保护它们的全局数据结构;而当IRQL低的时候(低于DPC/Dispatch级别),内核和执行体又用到了哪些互斥和同步机制。

高IRQL的同步

在内核执行的各个阶段中,内核必须要保证,在临界区内部同一时刻只有-一个处理器在执行。内核临界区是指修改某个全局数据结构的代码段,比如修改内核的分发器数据库或者它的DPC队列。除非内核能保证所有的线程都按照互斥的方式来访问这些数据结构,否则操作系统不可能正确地工作。

DPC是什么?
86架构设计在上是基于中断思想的,因而从DOS到Win32,操作系统中大量使用中断的概念来表达异步操作的行为。但与DOS下独占的情况不同,Win32下需要由系统对多任务进行调度,因此中断响应代码必须尽可能地简单,并且尽快的将控制权交还给系统。虽然这样一来系统调度的响应速度和实现过程方便了,但还是有很多功能需要在中断响应中完成。为此,Win32核心提供了DPC(Deferred Procedure Call)和APC(Asynchronous Procedure Call)两个IRQL特殊的软件中断级别,用于实现延迟和异步的过程调用。从IRQL分层来说,DPC和APC是介于较高级别的设备中断和最低级别的Passive中断之间,由操作系统用于完成特殊方法调用的中断级别。与处理硬件操作的设备中断和更高级别的时钟、处理器中断不同,这两级中断纯粹是为了实现功能调用异步性而设计实现的,因此操作系统本身也对它们具有很强的依赖型。DPC在功能上可以理解为ISR(Interrupt Service Routine)的一部分。只是因为ISR为了尽量简单和返回控制权给操作系统,而将一部分功能剥离出来放入相应DPC中,延迟调用。因为DPC的IRQL仅在APC和Passive中断之上,所以系统可以从容地处理完高级别的中断后,再在DPC一级慢慢处理积累起来的相对并不那么紧急功能。

DPC队列
指的是DPC对象的队列

值得关注的最大区域是中断。例如,当中断发生的时候,内核可能正在更新一个全局数据结构,而该中断的处理例程可能也要修改此数据结构。简单的单处理器操作系统有时候采用一种简便的办法来避免发生这样的情形,即每次当它们要访问全局数据的时候就禁止所有的中断,不过,Windows的内 核采用了一种更为复杂的方案。在使用-一个全局资源以前,内核临时屏蔽掉那些在中断处理例程中也用到了该资源的中断。它的做法是,将处理器的IRQL提升到任何有可能访问该全局数据的中断源所用到的最高IRQI级别。

例如,一个位于DPC/Dispatch级别的中断会触发分发器运行,而分发器用到了分发器数据库。因此,在内核中凡是用到了分发器数据库的代码部分都将IRQL提升到DPC/Dispatch级别上,在使用分发器数据库之前屏蔽掉DPC/Dispatch级别的中断。这种策略对于单处理器系统是非常合适的,但是对于-一个多处理器的系统还是不够的。将一个处理器上的IRQL提升起来,并不会阻止在另-一个处理器.上发生中断。内核也需要保证在跨越几个处理器的情况下实现互斥访问。

互锁操作

同步机制的最简单形式,莫过于直接依赖硬件.上对于多处理器安全操作整数值的支持,以及对于比较操作的支持。这包括诸如InterlockedIncrement、 InterlockedDecrement、InterlockedExchange和InterlockedCompareExchange等函数。例如,InterlockedDecrement函 数在减操作过程中,利用x86的lock指令前缀(比如lock xadd)来锁住多处理器总线,因此,如果另一个处理器也要修改这一被减内存单元,它就不可能在减操作读取原始值和写入结果值的过程中间修改此内存单元。内核和驱动程序用到了这种最基本的同步形式。在今天的Microsoft编译器套件中,这些函数被称为固有的( intrinsic),因为它们的代码在编译阶段以内联的方式直接被生成,而无需通过一一个函数调用。(很可能是,将参数压到栈中,再调用函数,将参数拷贝到寄存器中,然后将参数出栈,再返回调用者,这一系列过程比这些函数实际要做的工作昂贵得多。)

自旋锁

内核用来实现多处理器互斥的机制称为自旋锁(spinlock)。自旋锁是一个与某个全局数据结构相关联的锁原语,比如图3.25中与自旋锁关联的数据结构是DPC队列。在进入如图中所示的临界区以前,内核必须先获得与被保护的DPC队列相关联的自旋锁。如果自旋锁并非空闲的话,则内核一直尝 试着获取该锁,直到成功为止。自旋锁之所以得名“(自)旋转”,是基于这样的事实:内核(因此也即处理器)一直等待,“旋转”,直到获得锁。
在这里插入图片描述
自旋锁如同它们所保护的数据结构-样,也驻留在非换页内存中,且被映射到系统地址空间中。获取和释放-一个自旋锁的代码是用汇编语言来编写的,一方面是为了速度, 另一方面也是要充分发掘底层处理器体系结构所提供的锁机制。在许多体系结构上,自旋锁是通过硬件支持的test-and-set操作来实现的,即在一条原子指令内测试–个锁变量的值并且获得该锁。
在单条指令内测试和获取-一个锁可以避免第二个线程在“第一-个线程测试锁变量的时间点”与“它获得锁的时间点”之间抓取到该锁。而且,前面提到过的lock指令也可以被用在test-and-set操作.上,结果就是组合的lockbts汇编操作,它也锁住了多处理器总线;若不然的话,有可能多个处理器同时原子性地执行该操作(若没有lock,则该操作只能保证在当前处理器上是原子的)。

在Windows中,所有的内核模式自旋锁都有一个与之关联的IRQL,并且它总是在DPC/Dispatch或者更高的级别上。因此,当一个线程试图获得一一个 自旋锁的时候,该处理器上凡是在该自旋锁的IRQL或者更低级别上的所有其他活动都要停止下来。因为线程分发动作也工作在DPC/Dispatch级别上,所以,如果-一个线程持有 -一个自旋锁,则它永远也不会被抢占,因为此级别的IRQL屏蔽了线程分发机制。如果一段代码正在执行-一个受自旋锁保护的临界区,则这种屏蔽能力使得该段代码可以继续执行,从而它可以更快地释放该自旋锁。内核在使用自旋锁的时候非常小心,尽可能地使它在持有一个自旋锁的过程中执行最少数量的指令。任何一个试图要获取自旋锁的处理器本质上处于忙状态,它会无限等待下去,消耗电源(忙等会导致100%的CPU使用率),但又不执行任何实际的工作。

在x86和x64处理器上,一条特殊的pause汇编指令可以被插入到忙等循环中。该指令向处理器提供了一个“线索”,指明它正在处理的循环指令是自旋锁(或类似性质的结构)的获取循环部分。
这条指令提供了三方面的好处:

  • 它可以极大地降低电源消耗,其做法是,总是微微地延迟其核的处理,而不是持续地循环。
  • 在超线程核上,它可以让CPU意识到,正在自旋的逻辑核所要做的“工作”其实没那么重要,因而把更多的CPU时间分配给第二个逻辑核。
  • 因为忙等循环会导致有大量的总线读请求来自于这一正在等待的线程(这些读请求可能会以乱序方式产生),CPU一旦检测到一个写请求(即,当拥有锁的线程释放该锁时),则试图纠正这些内存乱序的情形。因此,一旦自旋锁被释放,CPU对于正在等待的内存读操作重新进行排序,以确保顺序的正确性。这一重排序过程将导致极大的系统性能损失,利用pause指 令可以避免此过程。

内核提供了一组内核函数,包括KeAcquireSpinlock和KeReleaseSpinlock, 从而使得执行体的其他部分也可以使用自旋锁。例如,设备驱动程序为了保证设备寄存器和其他的全局数据结构在同一时刻只能被该设备驱动程序的某一部分(并且只能由一一个处理器)访问,就可以请求自旋锁。自旋锁不是给用户程序使用的一用户程序应该使用下一小节讲述的同步对象。

设备驱动程序也需要考虑来自其关联的中断中对其数据结构的访问,以保护这些数据结构。因为自旋锁API往往只是将IRQL升高至DPC/Dispatch级别,这对于防止来自中断的访问还是不够的。由于这一原因, 内核也导出了KeAcquireInterruptSpinLockKeReleaseInterruptSpinLockAPI,它们携带一个KINTERRUPT对象为参数( 关于KINTERRUPT对象,本章前文已经讨论过)。系统将会在中断对象中,检查与中断相关联的DIRQL,并且将IRQL升高至恰当的级别,以确保对数据结构的访问能够正确地与ISR共享。设备也可以使用KeSynchronizeExecution API,将整个函数与ISR进行同步,而不是仅仅一个临界区。在所有这些情形下,由中断自旋锁保护的代码必须极其快速地执行一-任何延迟都会招致超乎寻常的中断时延,并且带来显著的负面性能影响。

对于使用内核自旋锁的代码,自旋锁也带来了一些限制。因为自旋锁总是有一个DPC/Dispatch或更高级别的IRQL,所以,正如上文所述的,如果一段代码正在持有一个自旋锁,若它企图让调度器执行一个分发操作,或者它引发了一个页面错误,则会导致系统崩溃。

排队的自旋锁

为了提高自旋锁的伸缩性,有一种特殊的自旋锁类型称为排队的自旋锁(queuedspinlock),它们被用于某些非标准自旋锁的场合下。

排队的自旋锁的工作方式如下:

当一个处理器想要获得一个当前已被其他处理器持有的排队的自旋锁时,它把自己的标识符放在与该自旋锁关联的一一个队列中。如果当前正持有该自旋锁的处理器释放了该锁,则它将该锁移交给队列中标识的第一一个处理器。同时,如果一个处理器正在等待一个忙着的自旋锁,则它并不是检查该自旋锁本身的状态,而是检查一个针对每个处理器的标志;在队列中位于该处理器之前的处理器会设置这一标志,以表明该轮到这个正在等待的处理器了。排队的自旋锁的结果是,处理器在这些针对每个处理器的标志上旋转,而不是在全局自旋锁上旋转。这有两种效果。

  • 第一是,多处理器的总线不会因为处理器之间的同步而招致繁重的流量。
  • 第二是,对于一组正在等待获取某个自旋锁的处理器,排队的自旋锁强加了先进先出(FIFO) 的获取顺序,而不是随机选择一个处理器。

FIFO顺序意味着在一组访问同样锁的处理器之间有了更加一致的性能表现。Windows定义了许多全局排队的自旋锁,它在每个处理器的“处理器区域控制块(PRCB)”所包含的一个数组中保存了指向这些全局排队的自旋锁的指针。

只需在调用KeAcquireQueuedSpinlock的时候将一个全局自旋锁的指针在PRCB数组中的索引传递进去,就可以获得对应的全局自旋锁。全局自旋锁的数量随着操作系统的每个发行版本而不断增加,WDK头文件Wdm.h中公开了这些全局自旋锁的索引定义表。然而,要注意,在一个设备驱动程序中获取这些排队的自旋锁是不被支持的,也是应该极力要避免的操作。这些锁是保留给内核自己内部使用的。


栈内排队自旋锁

设备驱动程序可以通过KeAcquireInStackQueuedSpinLockKeReleaselnStackQueuedSpinLock这两个函数来使用动态分配的排队自旋锁。

有几个组件一一包括缓存管理器 、执行体内存池管理器和NTFS——充分使用了这些类型的锁,而并非使用全局的排队自旋锁。

KeAcquireInStackQueuedSpinlock带一个指针参数指向一个自旋锁数据结构,以及一个自旋锁队列句柄。此自旋锁句柄实际上是一个数据结构,内核将有关该锁的状态信息保存在此数据结构中,状态信息包括该锁的所有权和-一个处理器队列(该队列中的每个处理器都可能在等待该锁)。出于这个原因,句柄不应该是一个全局变量,它往往是一个栈变量,这样可保证对于调用者线程的局部性,这种类型自旋锁以及相应API名称中的InStack部分正因此而得来。

执行体的互锁操作

内核提供许多简单的、建立在自旋锁基础之上的同步函数,来支持-些更加高级的操作,比如在单向链表和双向链表中插入和删除元素。这样的例子有,ExInterlockedPopEntryList和ExInterlockedPushEntryList支持单向链表,ExInterlockedInsertHeadList 和ExInterlockedRemoveHeadList支持双向链表。所有这些函数都要求带–个标准的自旋锁作为参数,内核和设备驱动程序中到处使用了这些函数。

这些函数并不是依赖标准的API来获取和释放自旋锁参数,相反地,它们把代码放在内联区域,并且使用一种不同的时序方案。以Ke打 头的自旋锁API首先测试并设置(test-and-set)锁位,以确定该锁是否已被释放,然后原子性地执行一个带lock的test-and-set操作,来实际执行获取操作,与此不同的是,执行体的这些互锁例程将禁止该处理器上的中断,并立即试图执行一个原子性的test-and-set。如果这次尝试失败了,则再允许中断,并继续执行标准的忙等算法,直到test-and-set操 作返回0一-在这种情况 下整个函数又重新执行。由于这些微妙的差异,用于执行体互锁函数的自旋锁不能再用于前面讨论过的标准内核API。自然地,非互锁的链表操作不能与互锁的操作混合在一起。

注 某些特定的执行体互锁操作在可能的情况下实际上只是简单地忽略自旋锁。
例如,ExInterlockedIncrementLong和ExInterlockedCompareExchange AP|实际上与标准的互锁函数和固有函数使用同样的lock前缀。这些函数在老的系统.上(或非x86系统上)是有用的,因为在这些系统上lock操作不适合或根本不能用。由于这个原因,这些调用现在已经不再被鼓励使用,而应该使用固有函数。

低IRQL的同步

在多处理器环境中,内核之外的执行体软件也需要对全局数据结构的访问进行同步。例如,内存管理器只有一一个页帧数据库,所以在访问该页帧数据库时将它当做-一个 全局数据结构,而且,设备驱动程序也需要确保它们在访问设备的时候是独占的。执行体通过调用内核函数,可以创建-一个自旋锁,获取该锁,以及释放该锁。

然而,自旋锁只是部分地满足了执行体对于同步机制的需求。因为在一般情况下, 等待一个自旋锁意味着要使一一个 处理器停止下来,所以,自旋锁只能被用于以下一-些严格受限的场合:

  • 对于受保护的资源,必须快速访问,并且与其他的代码没有复杂的交互关系;
  • 临界区代码的内存页不能被换出去,这些代码不能引用那些可被换页的数据,不能调用外部过程(包括系统服务),也不能产生中断或者异常。

这些约束条件并不是在所有的情况下都能满足的。而且,除了互斥以外,执行体还需要完
成其他类型的同步操作,并且它必须为用户模式提供同步机制。

以下列出一些当自旋锁不适合时可以使用的其他同步机制:

  • 内核分 发器对象( Kernel Dispatcher Objects);
  • 快速互斥体和守护互斥体 (Fast Mutexes and Guarded Mutexes);
  • 推锁 ( Pushlocks);
  • 执行体 资源( Executive Resources)。

此外,在低IRQL上执行的用户模式代码必须具备它自己的锁原语。Windows支持 各种专门用于用户模式的同步语义:

  • 条件变量 (CondVars);
  • Slim读者 -写者锁(SRWs); .
  • 一次运行初始化(InitOnce);
  • 临界区 (critical sections)。

我们在后面将会讨论用户模式语义以及它们的底层内核模式支持;现在我们将注意力集中在内核模式对象上。表3.18可以作为一张参考表,它比较了这些同步机制的能力,以及它们与内核模式APC的交互关系。

在这里插入图片描述

内核分发器对象

内核以内核对象的形式,向执行体提供了额外的同步机制,这些内核对象合起来统称为分发器对象( dispatcher object)

那些对于Windows API可见的同步对象,正是从这些内核分发器对象中获得它们的同步能力。每个对WindowsAPI可见的且支持同步的对象都封装了至少一个内核分发器对象。通过WailForSingleObject和WaitForMultipleObjects函数,执行体的同步语义对于Windows程序员是可见的,Windows子系统通过调用类似的、由对象管理器提供的系统服务来实现这两个等待函数。Windows应用程序中的线程可以通过各种对象进行同步,包括Windows进程、线程、事件、信号量、互斥体、可等待的定时器、I/O完成端口、ALPC端口、注册表键,或者文件对象

事实.上,内核暴露的几乎所有对象都可以被用来进行等待。这其中有些对象正是分发器对象,而其他有些则是内含一个分发器对象的更大型对象(比如端口、键或文件)。表3.19显示了正宗的分发器对象,所以,任何其他的Windows API允许等待的对象可能内部包含了这其中某一个分发器对象。

值得提及的另外一种执行 体同步对象称为执行体资源( executive resource)。 执行体资源既提供了独占访问的能力(像- 一个互斥体),也提供了共享读访问的能力(多方共享了对一个.数据结构的只读访问能力)。然而,它们仅仅可用于内核模式的代码,因此通过Windows API是无法访问的。本小节余下的部分介绍了等待分发器对象的实现细节。

等待分发器对象

一个线程可以与一一个分发器对象进行同步,做法是等待该对象的句柄。这样做使得内核
将该线程置于等待状态。在任何给定的时刻,一个同步对象总是处于两种状态之.- -:有信号状态( signaled state),或者无信号状态(nonsignaledstate)。一个线程在它的等待条件被满足以前,不能恢复执行。

如果该线程正在等待一个分发器对象的句柄,而且,该分发器对象经历了一次状态改变(从无信号状态改变成有信号状态,例如,当-一个线程设置了一个事件对象时),那么,该线程等待的条件就会满足。- 一个线程为了与一个对象同步,调用对象管理器提供的几个等待系统服务之一,同时传递给它一- 个对象句柄,该句柄指向它所要同步的对象。该线程可以等待一个或者几个对象,还可以指定:如果在特定长度的时间段以内等待过程还没有结束的话,则取消等待。无论何时当内核将一个对象设置成有信号状态时,内核的某一个信号例程就会进行检查,看是否有任何线程正在等待该对象(而且并不同时还在等待其他对象变成有信号状态)。

如果存在这样的线程,则内核将这些线程中的一一个或者多个从它们的等待状态中释放出来,从而它们可以继续执行。

以下设置事件的例子演示了同步与线程分发是如何相互影响的:

  • 一个用户模式的线程等待一个事件对象的句柄;
  • 内核将该线程的调度状态改变成等待状态,然后将该线程加入到正在等待该事件的线程列表中:
  • 另一个线程设置了该事件;
  • 内核沿着该事件的等待线程列表向前搜索。如果一 个线程的等待条件满足的话(参看下面的注释),内核将该线程从等待状态中解放出来。如果它是一个可变优先级的线程,则内核可能也要提升它的执行优先级。(关于线程调度的更多细节,请参考第5章。)

注 有些线程可 能在等待多个对象,所以它们会继续等待,除非它们指定了WaitAny方式的等待。对于WaitAny这种等待方式,只要有一一个对象(不是全部)变成有信号状态,则正在等待它的线程就可以被唤醒。.

如何让对象有信号

对于不同的对象,有信号状态的定义也有所不同。一个线程对象在它的生命周期中处于无信号状态,当线程终止的时候,它被内核设置为有信号状态。类似地,当一个进程的最后一个线程终止的时候,内核将该进程对象设置为有信号状态。与此不同的是,定时器对象,就像一个闹钟一样,在特定的时候被设置为“响铃”。当它的时间到期时,内核将定时器对象设置为有信号状态。

在选择同步机制时,程序必须考虑到那些“控制各种同步对象的行为”的规则。当一个对象被设置为有信号状态时一个线程的等待是否结束,随着该线程所等待的对象的类型而有所不同,如表3.19所示。

在这里插入图片描述
在这里插入图片描述
当一个对象被设置成有信号状态时,那些正在等待该对象的线程一般会立即从它们的等待状态中解除出来。–些可导致这种状态改变的内核分发器对象和系统事件如图3.26所示。
例如,通知类型的事件对象( 在Windows API中称为手工重置的事件)被用来宣布某一件事情发生了。当该事件对象被设置为有信号状态时,所有正在等待该事件的线程都被解除。

一个例外是,对于任何同时在等待多个对象的线程,这一条并不成立;这样的线程可能要继续等待,直到其他的对象也变成有信号状态。

与事件对象不同的是,互斥体对象有与之关联的所属权(除非它是在DPC中被获得的)。所属权的用途是,获得对一个资源的互斥访问,即同一时刻只有一个线程可以持有该互斥体。当互斥体对象变成空闲的时候,内核将它设置成有信号状态,然后选择一个正在等待的线程来执行,同时也会继承任何已经适用的优先级提升。(有关优先级提升的更多信息,参考第5章。)内核选中的线程获得该互斥体对象,所有其他的线程继续等待。

互斥体对象也可以被遗弃:这发生在当拥有该互斥体对象的线程终止的时候。当一个线程终止时,内核会枚举该线程拥有的所有互斥体,并且将它们设置为遗弃状态。从信号逻辑的角度,遗弃状态可以被看成是有信号状态,即互斥体的所属权可以被转移到一个正在等待的线程。

这里简短的讨论并不是要列举出使用这些执行体对象的所有理由和应用场合,而只是列出它们的基本功能和同步行为。关于如何在Windows程序中使用这些对象的更多信息,请参见关于同步对象的Windows参考文档,或者Jeffrey Richter和Christophe Nasarre合著的Windows via C/C++书。
在这里插入图片描述

数据结构

有三个数据结构对于搞清楚谁正在等待、它们如何等待的、它们正在等待什么,以及整个
等待操作处于什么状态,是至关重要的。

这三个数据结构就是

  • 分发器头
  • 等待块
  • 等待状态寄存器。

前两个结构被公开定义在WDK包含文件Wdm.h中:后一个文件没有被文档化。

分发器头是一个很紧凑的结构,因为它需要在一个固定大小的结构中维护大量的信息。(参见下文的“实验:查看等待队列”部分,可以看到分发器头数据结构的定义。)主要的窍门之一是,在结构中同样的内存位置(偏移)处定义互斥的标志。通过Type域,内核知道这些域中的哪些域是真正适用的。例如,一个互斥体可能已被遗弃,但是一-个定时器可能是绝对或相对的。类似地,一个定时器可以被插入到定时器列表中,但DebugActive域仅对进程才 有意义。另一方面,分发器头确实也包含了对于所有分发器对象都通用的信息:对象类型、信号状态,以及正在等待该对象的线程列表。

等待块结构代表了一个线程正在等待-一个对象。处于等待状态的每个线程都有一个等待块列表,这些等待块代表了该线程正在等待的对象。每个分发器对象都有一个等待块列表,这些等待块代表了哪些线程正在等待该对象。由于分发器对象维护了这一-列表,所以,当一个分发器对象有信号时,内核可以很快地确定谁正在等待该对象。最后,因为在每个CPU上运行的平衡集管理器(关于平衡集管理器的更多信息,参见第5章)需要分析每个线程已经等待的时间(为了决定是否要换出内核栈),所以,每个PRCB都有一个等待线程的列表。

等待块结构中有一个指针指向正在被等待的对象,另一个指针指向正在等待该对象的线程,还有一个指针指向下一个等待块(如果该线程正在等待多个对象的话)。它也记录了等待的类型(等待任-对象,或者,等待所有对象),以及在WaitForuMlipleObjets调用中,调用者线程传递的句柄数组中当前项所在的位置(如果该线程只等待一个对象,则位置为0)。等待类型在等待满足过程中非常重要,因为它决定了该线程在当前对象有信号之后是否所有的等待块都应该被处理:对于“wait any (等待任一对象)", 分发器并不关心其他对象的状态,因为该线程等待的对象中至少有一个(即当前对象)已经有信号了。另一方面,对于“waitall(等待所有对象)”,只有当所有其他的对象也处于有信号状态的情况下,分发器才可以唤醒该线程,这就要求遍历所有的等待块和关联的对象。

等待块也包含一 个易变的等待块状态,它定义了这一等待块在它当前参与的事务型等待操作中的当前状态。表3.20解释了各种不同的状态、它们的含义,以及它们在等待逻辑代码中的影响。

因为在等待操作尚在进行过程中,线程的总体状态(或者在开始等待时要求等待的任何一个对象的总体状态)可以改变(因为并没有阻止另外的线程在其他的逻辑处理器上给这些对象发送信号,或者警醒该线程,甚至向它发送一-个APC), 所以,内核分发器需要为每个正在等待的线程记录下两个额外的数据:该线程当前的细粒度等待状态,以及任何可能修改此等待操作结果的可能状态变化。

在这里插入图片描述
当一个线程被指示要求等待一个给定的对象(比如由于WaitForSingleObject调用)时,它首先开始此等待操作,试图进入in-progress等待状态( WaitInProgress)。如果此刻没有尚未完成的警醒操作(根据该等待是否可警醒,以及该等待的当前处理器模式,这决定了警醒操作是否可以抢占该等待),则这一-操作成功。如果有一个警醒操作,则该等待根本就不会进入,调用者会接收到恰当的状态代码;否则的话,该线程现在进入WaitInProcess状态,在这个点上主线程的状态被设置为Waiting,等待理由和等待时间也记录下来,有任何指定的超时也被注册到系统中。

一旦该等待在进行中了,该线程可以根据需要初始化一 些等待块( 并且在进行过程中将它们标记为WaitBlockActive),然后将这次等待中涉及的所有对象锁住。因为每个对象有它自己的锁,所以,很重要的一点是,当多个处理器可能在分析-一个由许多对象构成的等待链(由WaitForMulitpleObjects调用而导致)的时候,内核能够维护-一个-致的锁顺序方案。内核使用一项称为地址排序 (address ordering)的技术来做到这一点: 因为每个对象有一个特有的静态内核模式地址,所以,这些对象可以按照单调递增的地址顺序进行排序,这样可以保证这些锁总是被调用者按照同样的顺序获取和释放。这意味着,调用者提供的对象数组将被复制,并据此而排列顺序。

下一个步骤是检查该等待是否可以立即被满足,比如当一个线程 被告知要等待一一个已经被释放了的互斥体时,或者等待一个已经处于有信号状态的事件时。在这样的情况下,该等待可立即被满足,这将导致从等待链解除相关联的等待块(然而,在这种情况下,尚未有等待块被插入),并执行一个等待退出(继续进行任何在等待状态寄存器中标记的尚未完成的调度器操作)。如果这一快捷路径失败,内核接下来试图检查该等待所指定的超时( 如果有的话)是否已经到期。若确实已到期,则该等待未被“满足”,而仅仅是“超时”,这将导致略微更快地进入退出代码的处理过程,尽管结果是一样的。

如果这些快捷处理都未见效,那么,等待块被插入到线程的等待列表中,现在该线程试图提交其等待。(同时,对象锁已经被释放了,从而允许其他的进程修改“现在该线程应该已经正在等待的任何一个对象”的状态。)假定在非竞争的情形下,即其他的处理器对这个线程或者它所等待的对象毫无兴趣,那么,只要等待状态寄存器没有标记任何尚未完成的改变,该等待就切换到已提交的状态。此提交操作把这一-等待线程链接到PRCB列表中,若有必要的话激活一个额外的等待队列线程,并且插入与等待超时有关的定时器(若有的话)。因为到这个时候,可能已经过去了大量的指令周期,所以,此时又有可能该超时已经到期。在这种情况下,插入定时器可能会导致立即向线程发送信号,因而在该定时器上此等待被满足,从而此等待以超时结束。否则,在更为一般的情形下,现在CPU被切换环境,到下一个已经准备好要执行的线程。(关于线程调度的更多信息,参见第5章。)

在多处理器机器上高度竞争的代码路径上,极有可能正在试图提交其等待的线程在此等待进行过程中已经经历了一次变化。一种可能的情形是,它正在等待的某一个对象刚刚变成了有信号状态。正如前面所提及的,这使得它所关联的等待块进入到WaitBlockBypassStart状态,该线程的等待状态寄存器现在显示了WaitAborted等待状态。另一种可能的情形是,有一个警醒或者APC被发给此等待线程,而该线程并没有设置WaitAborted状态,但置上了等待状态寄存器中对应的那一一位。 因为APC可以打断等待(取决于APC的类型、等待模式,以及是否可警醒),该APC被交付,等待被终止(abort)。其他有一些操作可以修改等待状态寄存器但不会产生一个完全的终止指令,包括修改线程的优先级或亲和性,当线程由于未能提交等待而退出等待的时候(如同前面描述的情形一样)此修改操作将会被处理。

图3.27显示了分发器对象与等待块以及线程、PRCB之间的关系。在这个例子中,CPU0有两个等待线程(已提交):线程1正在等待对象B,线程2正在等待对象A和B。如果对象A变成有信号状态的话,内核将会看到:因为线程2也在等待另一个对象,所以线程2不可能马上准备执行。另一方面,如果对象B变成有信号状态,则内核可以立即让线程1准备执行,因为它并没有在等待任何其他对象。(或者,如果线程1也在等待其他的对象,但是它的等待类型是WaitAny,那么内核仍然可以唤醒它。)

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

带键的事件( keyed event)

一种 被称为带键的事件(keyed event)的同步对象值得特别- -提,因为它在用户模式互斥同步语义中有特别的地位。实现带键的事件的最初意图是,帮助进程在使用临界区同步对象时发生的低内存情形,这里临界区是一-种用户模式同步对象,稍后我们将会进-一步讨论。带键的事件并没有被文档化,它使得-一个线程可以指定-一个等待 “键”,当同- -进程中的另一个线程用同样的键使该事件有信号时,等待的线程被唤醒。

如果发生竞争的话,EnterCriticalSection动态地分配- 一个事件对象,因而,想要获取临界区对象的线程等待目前正拥有该临界区对象的线程在LeaveCriticalSection中给它信号。不幸的是,这引入了一个新的问题。若没有带键的事件,系统有可能耗光了内存,从而获取临界区的操作失败,因为系统不能分配所请求的事件对象。这种低内存条件本身也有可能由于应用程序试图获取临界区对象而引发,所以,系统在这种情况下可能会死锁。低内存并不是导致获取临界区操作失败的唯一-情形:另一种可能性较小的情形是句柄被用光。如果-一个进程已经达到了16兆个句柄的极限,则为事件对象分配新句柄就会失败。

由于低内存条件而引起的失败往往是负责获取临界区对象的代码中的一一个异常。不幸的是,结果得到的是一个被损坏的临界区,这使得这种情形很难调试,而且该对象对于重新获取也毫无用处。试图执行LeaveCriticalSection将 导致试图分配另-一个事件对象,又进-一步产生异常,并破坏数据结构。分配一个全局的标准事件对象并不能修复这一-问题,因为标准的事件原语只能被用于单个对象。一个进程中的每个临界区仍然要求它自己的事件对象,所以,同样的问题还会再现。

带键的事件使得多个临界区(等待者)使用同一个全局的带键事件句柄(每个进程-一个)。这使得临界区函数即使在内存暂时很低的情况下也可以正确地工作。当一个线程用信号通知一-个带键的事件,或者在带键的事件上执行等待时,它使用一个称为键(key)的唯一标识符, 这标明了带键事件的一一个实例(将带键的事件与- -个临界区关联起来)。当拥有带键事件的线程释放了该带键事件对象(使它有信号)时,只有一个正在等待该键的线程被唤醒(与同步事件对象而非通知事件对象有同样的行为)。此外,只有当前进
程中的等待者才会被唤醒,所以,这里的键是跨越进程被隔离的,也意味着实际上整个系统只有一一个带键的事件对象。当临界区使用带键的事件时,EnterCriticalSection将 临界区的地址设置为键,再执行等待。

当EnterCriticalSetion调 用NtWaitForKeyedEvent以在带键的事件上执行等待时,它现在只需指定一个NULL句柄参数作为带键的事件,告诉内核它不能创建一一个带键的事件。 内核理解这一行为,它使用一个名为ExpCritSecOutOfMemoryEvent的全局带键事件。这样做主要的好处是,进程不必再为一个命名的带键事件浪费一个句柄,因为内核会跟踪该对象和它的引用。

然而,带键的事件不仅仅是低内存条件的退路对象。当多个等待者在同样的键上等待,都需要被唤醒时,该键实际上会被通知多次,这要求该对象要维护所有的等待者,以便能够为每一一个等待者执行一-次“唤醒”操作(前面提到了,使一个带键的事件变成有信号状态,等同于使一个同步事件变成有信号状态)。然而,- -个线程在通知一一个带键的事件时并无其他线程在等待者列表中。在这种情形下,设置信号状态的线程实际上在等待事件本身。如果没有这样的应变措施,那么,有可能发生这样的情形:用户模式代码看到带键事件是无信号状态,然后试图等待该事件,在此期间,- 一个线程设置该对象的信号状态。用户模式代码的等待有可能在设置线程设置了信号状态之后才到达,这样会导致一次错位的信号匹配,所以等待线程将会死锁。在这种情形下,强制设置线程进行等待,因而只有当有人正在检查带键事件的状态(即等待)的时候,设置线程才真正设置带键事件的信号状态。

注:当带键的事件的等待代码本身需要执行一次等 待时,它用到了内核模式线程对象(ETHREAD)中内置的一个称为KeyedWaitSemaphore的信号量。(该信号量实际上与ALPC的等待信号量共享同-一个位置。)关于线程对象的更多信息,请参考第5章。

然而,在临界区的实现中,带键的事件并不能替代标准的事件对象。最初在Windows XP开发过程中,其理由是,带键的事件在繁重使用的情形下并不能提供良好的伸缩性能。前面提到了,所有讲述的算法都是针对在紧急的、低内存的情形下使用的,此时性能和伸缩性都是不重要的。若替代标准的事件对象,则带键事件尚未实现和处理的一些压力问题也随之而来。主要的性能瓶颈是,带键事件用一个双链表来维护等待者列表。这种链表有很差的遍历速度,即循环链表- -遍所需要的时间。在这种情况下,此时间长度取决于等待者线程的数量。
因为带键的事件对象是全局的,所以,有可能有几十个线程位于等待者列表中,这导致每次一个键被设置或者等待,都需要很长的遍历时间。


尽管链表的头被记录在 带键的事件对象中,不过,这些线程实际上是通过内核模式线程对象
(ETHREAD)中的KeyedWaitChain域(实际上该域与线程的退出时间共享,退出时间域的
类型为L ARGE INTEGER,与双链表的大小相同)链接起来的。关于线程对象的更多信息,请
参考第5章。

Windows改进了带键的事件的性能,它使用一个散列表,而不再使用链表来维护等待者线
程。这一优化使得Windows引入了三种新的轻量级的用户模式同步原语(稍后将会讨论),它
们全都依赖于带键的事件。然而,临界区仍然使用事件对象,主要是为了应用程序兼容性以
及调试的目的,因为事件对象和它的内部机理已经广为人知,并且有很好的文档,而带键的
事件则是不透明的,没有被暴露到Win32 API中。

快速互斥体( fast mutex )和守护互斥体( guarded mutex )

快速互斥体,也称为执行体互斥体,通常比互斥体对象提供了更好的性能,原因是,尽管它们也是建立在分发器事件对象基础之.上的,但只有当快速互斥体有竞争的时候它们才通过分发器对象执行等待,而标准的互斥体总是试图通过分发器来执行获取操作。这使得快速互斥体在一个多处理器环境中具有特别好的性能。快速互斥体广泛应用于设备驱动程序中。

然而,快速互斥体仅适用于当普通的内核模式APC(本章前面已经介绍过)的交付能够被禁止的时候。执行体定义了两个函数来获得快速互斥体: ExAcquireFastMutex 和ExAcquireFastMutexUnsafe.前- -个函数将处理器的IRQL提升到APC级别上,从而阻止所有的APC被交付;后一个函数期望在被调用的时候普通的内核模式APC交付是禁止的,这可以通过提升IRQL至APC级别来做到。ExTryAcquireFastMutex 完成的功能与前-一个函数类似,但如果快速互斥体已经被持有的话,它并不真正执行等待,而是返回FALSE。快速互斥体的另一个局限性是,它们不能被递归获取,而互斥体对象则可以。守护互斥体本质上与快速互斥体是相同的(不过,它在内部使用了不同的同步对象:KGATE)。通过KeAcquireGuardedMutex和KeAcquireGuardedMutexUnSafe函数可以获得守护互斥体,但是,它们并非通过提升IRQL至APC级别来禁止APC,相反,它们通过调用KeEnterGuardedRegion来禁止所有内核模式APC的交付。与快速互斥体类似,也存在一个KeTryAcquireGuardedMutex方法。回忆一下, 守护的区域与临界的区域不同,守护的区域禁止特殊的和普通的内核模式APC,因此,守护互斥体不必提升IRQL。

三个实现上的不同使得守护互斥体比快速互斥体更快:

  • 由于不需要提升IRQL,所以内核可以避免与总线上的每个处理器的本地APIC进行通话,而这恰好是在负担繁重的SMP系统上的关键操作。在单处理器系统上,由于延迟的IRQL计算,这不是一个问题,但是,降低IRQL可能仍然要求访问PIC。
  • 门对象的原语是事件对象的一个优化版本。获取和释放- 一个门对象的代码已经被大力地进行了优化,它没有同步和通知两个版本,并且门对象是一种可让线程等待的互斥对象。门对象甚至有它们自己的分发器锁,而不必获取整个分发器数据库。
  • 在没有竞争的情况下,守护互斥体的获取和释放操作在单个位上,通过一一个原子的
    位测试和重置( bit test-and-reset)操作进行工作,而不像快速互斥体那样通过更加复
    杂的整数操作。

注:快速互斥体的代码也是经过优化的,几乎考虑到了所有这些优化 它使用了同样的原子锁
操作,而且,事件对象实际上是一一个门对象(不过,如果在内核调试器中转储其类型的话,
你将会看到一一个事件对象结构,这实际上只是一个兼容性幌子)。然而,快速互斥体仍然提升
IRQL,而并非使用守护区域。

因为负责禁止特殊内核APC交付(和守护区域功能)的标志直至Windows Server 2003以后才被加入进来,所以,大多数驱动程序并没有充分利用守护互斥体。这样做(指使用守护互斥体)将会引起与以前版本Windows的兼容性问题,要求重新编译–个仅仅使用快速互斥体的驱动程序。然而,在Windows内 部,内核已经将所有使用快速互斥体的地方换成了守护互斥体, .由于两者有相同的语义,所以很容易相互交换。

与守护互斥体相关的另一个问题是内核函数KeAreApesDisabled。在Windows Server 2003以前,该函数指明了普通的APC是否已被禁止,它检查该代码是否运行在一个临界区内部。在Windows Server 2003中,该函数发生了变化,它指明了该代码是否运行在一个临界的或守护的区域内部;其功能也有所改变,如果特殊内核APC也被禁止的话,它也返回TRUE。

由于当特殊内核APC被禁止的时候,有-些特定的操作驱动程序不应该执行,因此,调用KeGetCurrentIrq|来查看一下当前IRQL是否在APC级别是非常有意义的,这是特殊内核APC可能已经被禁止的唯一做法。 然而,因为内存管理器使用了守护互斥体,所以这一检查将会失败,因为守护互斥体并不提升IRQL。因此,驱动程序应该为此目的调用KeAreAllApcsDisabled.该函数检查特殊内核APC是否已经被禁止,以及/或者IRQL是否在APC级别一-这 是可以同时检测守护互斥体和快速互斥体的行之有效的方法。

执行体资源

执行体资源是一种支持共享和独占访问的同步机制;如同快速互斥体一样,在获取执行体资源以前,它们要求普通的内核模式APC交付已被禁止。它们也建立在分发器对象之上,不过,只有当出现竞争的时候才会用到分发器对象。执行体资源也被应用于整个系统之中,特别是在文件系统的驱动程序中,因为这样的驱动程序倾向于有长时间的等待周期,在此期间I/O应该在某种程度上仍然是允许的( 比如读操作)。.

如果一个线程正在等待获得对一个资源的共享访问权,则它等待-一个与该资源相关联的信号量;如果一个线程正在等待获得对一个资源的独占访问权,则它等待一个事件。具有无限计数值的信号量被用于共享的等待者,因为当一个独占持有者通过给信号量发信号来释放一个资源时,这些共享等待者全部可被唤醒,并且被赋予对该资源的访问权。当一个线程在等待独占访问一个资源,而该资源当前正被其他线程拥有的时候,该线程等待-一个同步事件对象,因为当该事件有信号时,只有一个等待者将被唤醒。在前面关于同步事件的章节中,曾经提到过,有些事件的解除等待操作实际上会导致优先级提升:当使用执行体资源时,这种情形就会发生,这正是为什么它们像互斥体对象一样也要跟踪所属权的原因之一-。(关于执行体资源的优先级提升的更多信息,参见第5章。)

鉴于共享和独占访问所提供的灵活性,有以下的-一些函数可被用于获取资源:
ExAcquireResourceSharedLite、ExAcquireResourceExclusiveLite、 ExAcquireSharedStarveExclusive和ExAcquireSharedWaitForExclusive。WDK中有文档介绍这些函数。


推锁

推锁是另一种建立在门对象基础之上的优化同步机制,如同守护互斥体-样,只有当在一个推锁上存在竞争的时候,它们才会等待-一个门对 象。相比守护互斥体,它们提供的好处是,它们可以按照共享的或者独占的模式来获得。然而,它们的主要优势在于它们的大小:

资源对象是56字节,但推锁是-一个指针的大小。不幸的是,在WDK中推锁并没有被文档化,因此,它们仅被保留给操作系统使用(不过,API函数已被导出,所以内部驱动程序用到了推锁)。有两种类型的推锁:普通的推锁和可感知缓存的推锁。普通的推锁只要求一个指针大小的存储空间( 在32位系统上是4字节,在64位系统上是8字节)。当一个线程获取一个普通的推锁时,如果它当前尚未被占有,则推锁代码将它标记为已被占有。如果该推锁已被独占方式占有,或者该线程希望以独占方式获取该推锁但它却被一组线程以共享方式占有着, 则该线程在自己的栈上分配一个等待块,并且初始化该等待块中的-一个门对象,然后将该等待块加入到与推锁相关联的等待列表中。当- 一个线程释放-一个推锁的时候,如果有等待者的话,则唤醒一个等待者,其做法是,向该等待者的等待块中的门对象发出信号。

因为推锁只是指针的大小,所以,它实际上包含了多个位来描述它的状态。随着一个推锁从竞争状态到非竞争状态的变化,这些位的含义也有所不同。在初始状态下,推锁包含下面的结构:

  • 1个锁位,如果推锁被获取,则置为1;
  • 1个等待位,如果推锁是竞争的,并且有线程正在等待该推锁,则等待位置1; ;
  • 1个唤醒位,如果推锁正在被授予-一个线程,等待者列表需要被优化调整,则唤醒位
    置1;
  • 1个多方共享位,如果推锁已被共享,当前已被多个线程获取,则多方共享位置1;
  • 28个(在32位Windows上)或60个(在64位Windows上)共享计数位,包含了已经获
    取该推锁的线程的个数。

正如前面所讨论,当一个线程要以互斥模式获取一- 个推锁,而该推锁已经被多个读访问线程或一个写访问线程获取时,内核将会分配-一个推锁等待块。推锁值的结构本身也会发生变化。共享计数现在变成了指向该等待块的指针。因为此等待块是在栈上分配的,并且头文件中包含了一个特殊的对齐编译指示符强制等待块必须是16字节对齐的,所以,任何一个推锁等待块结构的最低4位总是0。因此,这些位对于指针引用的用途可被忽略,相反地,上面显示的4位可以跟指针值结合在一起。因为这一-对 齐做法去除了共享计数位,所以,共享计数值现在被保存到等待块中了。

可感知缓存的推锁在普通(基本)推锁的基础上加入了层次,它为系统中的每个处理器分配一个推锁,然后将这些推锁与自己关联起来。当一个线程希望以共享访问模式获取-一个可感知缓存的推锁时,它只是简单地以共享模式获取对应于当前处理器的那个推锁;如果该线程希望以独占访问模式获取-一个可感知缓存的推锁,则它以独占模式获取每–个处理器的推锁。

除了更小的内存印迹,推锁超越执行体资源的一一个大的优势是,在非竞争的情形下,推锁不要求过度的计数和整数操作来执行获取或释放动作。由于推锁与指针–样大小,所以内核可以使用原子的CPU指令来执行这些任务(用到了lock cmpxchg,该指令可以以原子方式来比较并交换新锁和老锁)。如果原子的比较和交换操作失败,则该锁中包含的并非是调用者期望的值(调用者往往期望该锁当前未被使用,或者已被共享模式获取),然后,再调用一个更加复杂的竞争版本。为了进一步挖掘性能潜力,内核将推锁的功能暴露成内联函数,这意味着在非竞争的情形下根本不需要函数调用—直接在每个函数中插入汇编代码。这会略微地增加代码的尺寸,但避免了函数调用带来的延迟。最后,推锁使用了几方面的算法技巧来避免锁封护(lockconvoy,这是指当多个相同优先级的线程都在等待-一个锁,而实际只有极少的的
工作得以完成的一种情形),它们都是自优化的:在一个推锁上等待的线程的列表将会定期地重新组织,以便当推锁被释放时可以提供更为公平的行为。

推锁的使用范围包括对象管理器和内存管理器。在对象管理器中,它们可保护全局的对象管理器数据结构和对象安全描述符;而在内存管理器中,通过可感知缓存的推锁来保护AWE;(Address Windowing Extension)数据结构。

利用驱动程序检验器( Driver Verifier )来检测死锁

死锁是一个同步问题,它源于两个线程或者处理器分别持有另一个想要的资源,并且谁也不会让出自己所属的资源。这种情况可能会导致系统或者进程停止。在本书下册第8章和第9章中将介绍的驱动程序检验器有–个选项可以检查与自旋锁、快速互斥体和互斥体有关的死锁问题。有关何时启用驱动程序检验器来帮助解决系统停止问题的信息,请参见本书下册第14章。

临界区

Windows在内核提供的同步原语的基础上,向用户模式应用程序提供了多种同步原语,临界区(eriticalsetion)正是其中主要的同步原语之–。临界区和稍后我们将要看到的其他用户模式原语与内核中的同步原语相比,一个主要的优势是,当没有锁竞争的时候,它们可以节省下进出内核模式的来回开销( 往往占99%的时间甚至更多)。然而,在竞争情形下,它们仍然要调用内核,因为只有系统代码才能执行复杂的唤醒和分发逻辑,而这对于这些同步对象是必不可少的。

临界区之所以能够保持在用户模式下,是因为它利用了-个局部的位来提供主要的互斥锁逻辑,非常类似于自旋锁。如果该位为0,则该临界区可以被获取,于是所有者将该位设置为1。此操作并不要求调用内核,而是使用了前面讨论过的互锁CPU操作。释放临界区的过程也类似,利用一个互锁操作将该位从1变成0。另一-方面,你可能已经猜到了,当该位已经被置为1,而另一个调用者试图获取该临界区的时候,它必须调用内核,以便将该线程置为等待状态。最后,因为临界区不是内核对象,它们有一些特 定的限制。最主要的限制是,你不可能获得一个指向临界区的内核句柄;同样地,没有安全性,没有名称,对象管理器的其他功能也无法适用于临界区对象。两个进程不可能使用同样的临界区来协调它们的操作,复制和继承特性也不适用于临界区对象。

用户模式资源

用户模式资源也提供了比内核同步原语更为精细的锁机制。–个资源可以被共享或互斥模式获取,从而使它可以作为多个读者(multiple-reader, 共享)单个写者(single-writer) 锁,用于像数据库这样的数据结构。当一个资源被共享模式获取,而其他的线程试图也以共享模式获取该资源时,无需进入到内核,因为这些线程都不需要等待。只有当-一个线程试图以互斥模式获取该资源的时候,或者该资源已经被-一个互斥的所有者锁住的时候,才需要进入内核。

为了使用前面我们已经看到过的内核中的分发和同步机制,资源实际上使用了已有的内核原语。一个资源数据结构(RT_ RESOURCE)实际,上包含了一个内核互斥体和一个内核信号量对象。当该资源被多个线程以互斥方式获取的时候,内核互斥体将会起作用,因为它只允许一个所有者。当该资源被多个线程以共享模式获取的时候,信号量对象就会起作用,因为它允许多个所有者参与计数。这一层细节对于程序员往往是隐藏的,程序员永远也不需要直接使用这些内部对象。

最初实现资源的用途是为了支持SAM (Security Account Manager,安全账户管理器,在第6章中讨论),但并没有通过Windows API暴露给标准的应用程序。稍后要介绍的Slim读写锁(SRW Lock)是在Windows Vista中实现的,并且通过-一个文档化的API暴露 了一个类似的锁原语,不过,有些系统组件仍然使用资源机制。

条件变量

条件变量可以同步一组正在等待某个结果进行条件测试的线程,是Windows提供的一个原生实现。尽管利用其他的用户模式同步方法也有可能实现这样的操作,但是,没有一种原子性的机制可以既检查条件测试的结果,也开始等待该结果上的变化。这要求在实现这种功能的代码片断上使用额外的同步手段。

为了初始化一一个条件变量,用户模式线程调用IitializeConditionVariable来建立起初始的状态。当它想要激发-一个在该变量上的等待动作时,它可以调用SleepConditionVariableCS,该函数使用一个临界区(该线程必须已经初始化此临界区对象了)来等待该变量上的变化。而设置线程在修改了该变量以后,必须使用WakeConditionVariable或者WakeAllConditionVariable(没有自动的检测机制)。此函数调用将释放-一个线程或所有线程的临界区等待,取决于哪个函数被调用。

在引入条件变量以前,常用的做法是,使用通知事件或同步事件(曾经提到过,在WindowsAPI中它们分别被称为自动重置或手工重置, auto-reset或manual-reset)来通知一一个变量的变化,比如一个辅助队列的状态的变化。为了等待一个变化, 要求首先获取一一个临界区, 然后释放该临界区,接着在-一个事件上等待。在等待之后,必须要重新获取该临界区。在这- - 系列的获取和释放过程中,该线程可能有环境切换,如果有线程调用PulseEvent的话则会引发问题(个类似于带键的事件已经解决的问题,即在没有等待者的情况下强制等待信号线程)。利用条件变量,临界区的获取动作可以SleepConditionVariableCS被调用时由应用程序来维护,并且只有当实际工作完成以后,临界区才被释放。这使得编写工作队列的代码(以及类似的实现)更加简单,并且具有可预测性。

在内部,条件变量可以被看作内核模式下已有的推锁算法的一个移植,加上了SleepConditionVariableCS API内部获取和释放临界区的额外复杂性。条件变量也是指针大小(就像推锁),避免使用线程分发器(分发器要求一次环转换,进入到内核模式下,这使得条件变量的优势更为显著),在等待操作过程中自动地优化等待列表,并且保护锁封护(lock convoy)的发生。此外,条件变量充分使用了带键的事件,而不是开发人员他们自己使用的普通事件对象,这使得即使在竞争的情形下也有更优化的性能表现。

Slim读写锁

虽然条件变量是-一种同步机制,但它们并非基本的锁对象。我们已经看到了,它们仍然依赖于临界区锁,其获取和释放操作用到了标准的分发器事件对象,所以仍然要进入到内核模式,并且调用者仍然要初始化大的临界区对象。如果说条件变量与推锁具有足够多的相似之处,那么slim读写锁( SRW Locks, Slim Reader Writer Locks)与推锁几乎是等同的。它们也是指针大小,使用原子操作来实现获取和释放,重新安排等待者列表,保护避免锁封护,可以支持共享模式或互斥模式的获取操作。然而,与推锁还是有一些差异, 包括SRW锁不能被“升级”,或者说不能从共享锁转变成互斥锁,反之也不行。而且,它们不能被递归地获取。最后,SRW锁专用于用户模式代码,而推锁专用于内核模式代码,两者不能共享,或者从一层暴露.给另一层。

在应用程序代码中,SRW锁不仅可以完全地替代临界区,而且还提供了多个读者-单个写者的功能。SRW锁必须首先通过InitializeSRWLock进行初始化,之后可以通过适当的API函数,以共享模式或互斥模式进行获取或释放: AcquireSRWLockExclusive 、ReleaseSRWLockExclusive、AcquireSRWLockShared和ReleaseSRWLockShared.


与大多 数其他的Windows API不同,如果SRW锁不能被获取的话,这些SRW锁函数并不返回一个值一相反地, 它们会产生异常。显然地,若一个获取操作失败了,如果调用代码假定该获取操作成功,则这样的代码会终止,而不会继续执行并且潜在地破坏用户数据。

Windows的SRW锁并不偏向于读者或写者,这意味着,在两种情况下的性能应该是一样的。这也使得它们更适合于替换临界区,因为临界区是仅对于写者的同步机制,或者说互斥的同步机制;相对于资源机制,它们提供了进一步的优化。 如果SRW锁针对读者而优化的话,那么,若它们作为仅用于互斥的锁的话,就会性能很差,但实际情形并非如此。因此,前面讲述的条件变量机制也允许使用SRW锁,来代替临界区,做法是改用SleepConditionVariableSRW API。最后,SRW锁也使用带键的事件,来代替标准的事件对象,所以,结合条件变量和SRW 锁可以在极少进入内核模式的情况下,获得可伸缩的、指针大小的同步机制一一而 在竞争的情形下,已经做了优化,以期使用更少的时间和内存来唤醒等待者和设置状态(因为使用了带键的事件)。

一次运行初始化 ( Run Once Initialization )

让一段负责执行某种初始化任务的代码以原子方式来执行,这种能力是多线程程序设计中的一个典型问题。这样的初始化任务包括申请内存、初始化特定的变量,或者根据需要而创建对象,等等。在一段可以被多个线程并发调用的代码(–个很好的例子是DIIMain例程,它负责初始化DLL)中,有几种方法可以确保初始化任务被正确地、唯一地以原子方式执行。

在这种情形下,Windows实现了一次初始化(init once),或者一次性初始化( one-time initialization,在内部也称为run once initialization,即一次运行初始化)。这一机制既允许一段特定的代码被同步执行(意味着其他线程必须等待初始化完成),也允许被异步执行(意味着其他线程可以试图执行它们自己的初始化进行竞争)。我们先介绍同步机制,然后再看一看异.步执行背后的逻辑。在同步情况下,开发人员通常这样编写代码:在- -个专门的函数中双重检查(double-checking)了一个全局变量以后再执行一段功能代码。此例程需要的任何信息可以通过一- 次初始化例程所接受的parameter变量来传递。任何输出信息则通过context变量来返回( 初始化状态本身被作为-一个布尔值返回)。为了确保执行正确,开发人员所需要做的工作是,在利用InitOnceInitialize API来初始化- -个INIT_ ONCE对象以后,调用InitOnceExecuteOnce,并将
paramenter、context和一 次运行的函数指针传递给它。系统将会处理余下的一切。

对于那些想要使用异步模型的应用程序,其线程调用InitOnceBeinInitialize,接收一个布尔类型的pending status和前面描述的context。如果pending status是FALSE,那么初始化已经发生了,该线程使用context的值作为结果。( 也有可能函数本身返回FALSE,意味着初始化失败了。)然而,如果pending status在返回时为TRUE,那么,该线程现在应该是在竞争第一个创建对象。随后的代码将执行任何初始化任务所需要的事情,比如创建对象或申请内存。当这些工作完成时,该线程调用InitOnceComplete,将当前的执行结果作为context传给它,并接收一个布尔类型的status。如果status是TRUE, 则该线程赢得了竞争,它所创建的对象或者申请的内存应该是全局对象。现在该线程可以保存该对象,或者将该对象返回给调用者,取决于具体的用法。

在一个更加复杂的情形下,当status是FALSE时, 这意味着该线程在竞争中输掉了。现在该线程必须取消(undo) 所有它做过的工作,比如删除对象,或者释放内存,然后再次调用InitOnceBeginInitialize。然而,这一次它不再像前面那样请求发动一次竞争,而是使用INIT_ ONCE CHECK_ _ONLY标志,表明它知道已经输掉了,因而请求赢者的context (例如,赢者所创建或分配的对象或内存)。这次返回另一个status,它可能是TRUE,表明context是有效的,可以被使用或返回给调用者;也可能是FALSE,表明初始化失败了,没有线程能够真正执行初始化工作(比如,可能在低内存条件的情形下)。

在同步和异步两种情形下,一次运行初始化机制与条件变量机制和SRW锁机制非常相似。

一次运行(init once)结构也是指针大小,对于非竞争的情形,使用了SRW获取/释放代码的内联汇编版本;而当竞争发生时(发生在同步模式下使用该机制的时候),使用了带键的事件,其他的线程必须等待初始化。在异步的情形下,锁是以共享模式来使用的,所以多个线程可以同时执行初始化。

3.4 系统辅助线程

在系统初始化的过程中,Windows在System进程中创建了几个线程,这些线程称为系统辅助线程它们的用途只是代表其他的线程来完成一些工作

在许多情况下,在DPC/Dispatch级别上执行的线程需要执行一些只有在更低IRQL级别上才能执行的函数。例如,一个DPC例程在任意线程环境中以DPC/Dispatch级别IRQL在执行(因为DPC的执行可以篡夺系统中的任何线程),它可能需要访问换页内存池,或者等待一个分发器对象以便与一个应用程序线程保持同步。因为DPC例程不能降低IRQL,所以,它必须要将这样的处理过程传递给一个在低于DPC/Dispatch级别的IRQL上执行的线程。

有些设备驱动程序和执行体组件创建了它们自己的线程,由这些线程专门在被动级别上处理一些工作;然而,绝大多数设备驱动程序和执行体组件使用系统辅助线程,从而可以避免在系统中因这些额外线程而招致的不必要的调度和内存开销。执行体组件通过调用执行体函数ExQueueWorkltem或IoQueueWorkltem,可以请求一个系统辅助线程的服务;设备驱动程序只能使用后一个函数(因为这会将工作项目与一个Device对象关联起来,可以允许更好的记录能力,以及处理“当工作项目尚在激活时而驱动程序却要卸载”的情形)。这两个函数把一个工作项目(work item)放在一个队列分发器对象上,系统辅助线程在这个对象上寻找工作来做(有关队列分发器对象的更多细节信息,请参见本书下册第8章中的“IO完成端口”一节)。

loQueueWorkItemEx、loSizeofWorkItem、lolnitializeWorkItem和IoUninitializeWorkIltem这些API函数的工作方式类似,但它们将工作项目与一个驱动程序的Driver对象或其中某一个Device对象建立关联。

工作项目包括一个例程指针以及一个参数,当系统辅助线程处理该工作项目时它会把此参数传递给该例程。该例程是由请求被动级别执行模式的设备驱动程序或者执行体组件实现的。例如,如果一个DPC例程必须等待一个分发器对象,那么它可以初始化一个工作项目,让它指向该驱动程序内部的一个专门等待此分发器对象的例程,可能还指向一个该对象的指针。在某个阶段上,系统辅助线程将该工作项目从它的队列中删除,并执行此驱动程序的例程。

在驱动程序的例程完成以后,系统辅助线程检查一下,看是否还有其他的工作项目要处理。如果没有其他的工作项目了,则该系统辅助线程被阻塞,直到有新的工作项目被放到其队列中。当系统辅助线程处理一个工作项目时,其DPC例程可能已经完成执行了,也可能尚未完成。

系统辅助线程有以下三种类型:

  • 延迟型辅助线程,它们在优先级12上执行,处理一些被认为并非时间紧急的工作项
    目,而且当它们在等待工作项目的时候允许它们的栈页面被换出到页面文件中。对象管理器使用一个延迟型工作项目来执行延迟的对象删除操作,当内核对象被安排了要释放以后该工作项目负责删除这些内核对象。
  • 紧急型辅助线程,它们在优先级13上执行,处理一些时间紧急的工作项目,在Windows Server系统上,它们的栈始终位于物理内存中。
  • 一个超紧急型辅助线程,它在优先级15上执行,其栈总是在内存中。进程管理器( process manager)使用这一超紧急型工作项目来执行线程“回收”功能,即释放已被终止的线程。

通过执行体的ExpWorkerInitialization函数(在系统引导过程的早期被调用)来创建的延迟型和紧急型辅助线程的数目取决于系统中内存的数量,以及该系统是否为服务器系统。表3.22显示了在默认系统配置上创建的线程初始数目。你可以通过注册表HKLMISYSTEM\CurrentControlSet\ControllSession Manager\Executive键下面的 AdditionalDelayedWorkerThreadsAdditionalCriticalWorkerThreads值,来指定ExpInitializeWorker创建至多16个额外的延迟型辅助线程,以及至多16个额外的紧急型辅助线程。

在这里插入图片描述

(此处有图)

执行体试图在系统执行过程中,让紧急型辅助线程的数目符合工作负载的变化。每隔一秒钟,执行体函数ExpWorkerThreadBalanceManager确定是否应该创建一个新的紧急型辅助线程。

由ExpWorkerThreadBalanceManager创建的紧急型辅助线程称为动态的辅助线程,在创建这样的辅助线程以前下面的条件必须全部满足:

  • 在紧急工作队列中存在工作项目。
  • 不活动的紧急型辅助线程(指因为等待工作项目而被阻塞的线程,或者在执行一个
    工作例程时被阻塞在分发器对象上的线程)的数目必须少于系统中处理器的数目。口动态辅助线程的数目少于16个。

动态辅助线程在10分钟不活动之后就会退出。因此,当工作负载需要时,执行体可以创建至多16个动态辅助线程。


3.5 Windows全局标志

Windows有一组全局的标志,保存在一个名为NtGlobalFlag的系统范围的全局变量中,通过它可以打开操作系统内部的调试、跟踪和验证支持。 系统变量NtGlobalFlag是在系统引导时候根据注册表HKL\MISYSTEMI\CurrentControlSet\ControllSession Manager键中的GlobalFlag值来初始化的。

该注册表值的默认值是0,所以很有可能在你的系统上,你没有使用任何全局标志。而且,每个映像也有一组全局标志,它们也能打开内部的跟踪和验证代码(但是,这些标志的位布局完全不同于系统范围的全局标志)。

幸运的是,调试工具箱包含了一个名为Gflags.exe的工具,它使得你可以查看并改变系统全局标志(既可以是注册表中的标志值,也可以是当前正在运行的系统中的标志值)以及映像全局标志

Gflags既有命令行界面,也有GUI界面。为了看清楚命令行标志,你可以输入gflags /P。如果你运行该工具时不加任何开关,则显示出如图3.28所示的对话框。
在这里插入图片描述
你可以在“System Registry”页面上配置一个变量在注册表中的设置,在“Kernel Flags”页面上配置一个变量在系统内存中的当前值。

“Image File”页面要求你填写一个可执行映像的文件名称。该选项被用于改变一组仅适用于单个映像(而并非整个系统)的全局标志。请注意,图3.29中的标志不同于图3.28中显示的操作系统标志。

在这里插入图片描述


3.6 高级本地过程调用

所有的现代操作系统都需要一种机制来安全地在用户模式下在一个或多个进程之间传输数据,或者允许内核中的服务与用户模式下的客户之间传输数据。

典型情况下,为了移植性的原因,可以使用诸如邮件槽(mailslot)、文件、命名管道和套接字(socket)这样的UNIX机制,而对于图形应用程序,开发人员往往使用窗口消息。

Windows实现了一种称为高级本地过程调用(advanced local procedure call)ALPC的内部IPC机制这是一种高速的、可伸缩的、安全的消息传递设施,可用于传递任意大小的消息。

尽管ALPC是一种内部机制,因而第三方开发人员无法使用,但是它本身被广泛应用于Windows的各个部分:

  • 使用了远过程调用(RPC,一个已文档化的API)的Windows应用程序,如果它们指定了基于ncalrpc的本地RPC,则会间接地使用ALPC。ncalrpc是一种RPC的形式,用于在同一个系统上的进程之间进行通信。网络栈使用的内核模式RPC也使用了ALPC。
  • 无论何时,当Windows进程和/或线程启动的时候,或者在Windows子系统操作(比如有的控制台I/O)时,也通过ALPC与子系统进程(CSRSS)进行通信。所有的子系统通过ALPC与会话管理器(SMSS)进行通信。
  • Winlogon使用ALPC与本地安全认证服务器进程LSASS进行通信;
  • 安全引用监视器(一个执行体组件,在第6章中介绍)使用ALPC与LSASS进程进行通信。
  • 用户模式电源管理器电源监视器通过ALPC与内核模式电源管理器进行通信,比如当LCD亮度发生改变的时候。
  • Windows错误报告机制使用ALPC来接收崩溃进程的环境信息。
  • 用户模式驱动程序框架(User-Mode Driver Framework,UMDF)允许用户模式驱动程序使用ALPC进行通信。

注: ALPC替代了最初Windows NT内核设计中引入的老式IPC机制(称为LPC),所以,即使在今
天,在特定的变量、域和函数中仍然用“LPC”来引用。记住,为了兼容性的原因,LPC现在是在ALPC上模拟的,它本身已经从内核中移除了(以前的系统调用仍然存在,它们内部包装了ALPC调用)。

连接模型

ALPC通常被用于在一个服务器进程和该服务器的一个或者多个客户进程之间进行通信。既可以在两个或多个用户模式进程之间建立起一个ALPC连接,也可以在一个内核模式组件和一个或多个用户模式进程之间建立起ALPC连接。ALPC导出了一个称为端口对象( port object)的执行体对象,来维护在通信过程中所需要的状态信息。尽管只有一个对象,但实际上可以代表几种ALPC端口。

  • 服务器连接端口,这是一个命名的端口,也是服务器连接请求点。客户通过连接到该端口上,就可以连接至服务器。
  • 服务器通信端口,这是一个未命名的端口,服务器利用该端口与一个特定的客户进行通信。针对每个活动的客户,服务器都有一个这样的端口。
  • 客户通信端口,这是一个未命名的端口,客户线程利用该端口与一个特定的服务器进行通信。
  • 未连接的通信端口,这是一个未命名的端口,客户利用该端口与自身进行本地通信。

ALPC遵从的连接和通信模型多少会让人想起BSD套接字编程模型。服务器首先创建一个服务器连接端口(NtAlpcCreatePort),而客户试图连接到该服务器(NtAlpcConnectPort)。如果服务器正处于监听的状态,它就会接收到一个连接请求消息,于是可以选择接受该请求(NtAlpcAcceptPort)。在这么做的过程中,客户和服务器通信端口都被创建起来,每一个端点进程都接收到一个句柄,指向它的通信端口。然后通过该句柄来发送消息(NtAlpcSendWaitReceiveMessage),通常是在专门的线程中发送的,所以,服务器可以继续在原来的连接端口上监听连接请求(除非该服务器只被设计用于一个客户)。
服务器也具备能力可以拒绝此连接请求,或者出于安全的原因,或者由于协议或版本的问题。因为客户可以在连接请求中发送一段自定义的负荷数据,所以,很多服务常常利用这一点来确保只有正确的客户,或者只有一个客户在与服务器通话。如果发现了任何异常行为或情形,服务器可以拒绝该连接请求,甚至可以有选择地返回一段负荷数据,其中包含了为什么此客户被拒绝的信息(这使得客户可以采取正确的行动,或者出于调试的目的)。

一旦连接已建立起来,有一个连接信息结构(实际上是一个blob,稍后将会讲述)保存了所有不同端口之间的连接关系,如图3.30所示。
在这里插入图片描述

消息模型

通过ALPC,客户和使用阻塞消息的线程,每一方依次执行一个循环,来调用NtAlpcSendWaitReplyPort系统调用;在该系统调用中,一方发送一个请求,并等待应答,另一方则正好相反。然而,因为ALPC支持异步消息,所以任何一方都可以不阻塞,而是执行其他的运行时任务,以后再来检查消息(稍后将会讲述这样一些方法)。ALPC支持以下三种在所发送的消息中交换负荷的方法:

一个消息可以通过标准的双缓冲机制被发送至另一个进程。

在这种机制中,内核维护了该消息的一份拷贝(从源进程拷贝该消息),然后切换到目标进程,再从内核的缓冲区中拷贝消息数据。由于兼容性的原因,若使用了老式的LPC,则只有不超过256字节的消息可以用这种方式来发送;而ALPC有能力为不超过64KB的消息分配一个扩展的缓冲区。

可以把消息存放在一个ALPC内存区对象中,客户和服务器进程都映射该内存区对象的视图。(关于内存区映射的更多信息,参见本书下册第10章。)

消息可以存放在一个消息区(message zone)中。消息区是一个内存描述符列表(MDL),它代表了包含消息数据的物理页面,可以被映射到内核的地址空间中。

这种发送异步消息能力的一个重要额外效应是,消息可以被取消—一例如,当一个请求花了太长时间,或者用户指示她想要取消ALPC所实现的操作时。ALPC通过NtAlpcCancelMessage系统调用来支持这一行为。

一个ALPC消息可以位于ALPC端口对象所实现的四种不同队列之一:

  • 主队列(main queue),消息已经被发送,客户正在处理该消息。
  • 待处理队列(pending queue),消息已经被发送,调用者正在等待应答,但是应答尚未被发出。
  • 大消息队列(large message queue),消息已经被发送,但是调用者的缓冲区太小因而不能接收该消息。调用者获得另一次机会来申请一个更大的缓冲区,并再次请求该消息的负荷数据。
  • 已取消的队列( canceled queue),原本发送给该端口对象的消息,但是此后已被取消。
  • 注意,还有第五个队列,称为等待队列( wait queue),它并没有把消息链接起来,相反,它把所有正在等待某个消息的线程链接起来了。

异步操作

ALPC的同步模型与早期NT设计中最初的LPC架构紧密关联,也类似于其他的阻塞类型的IPC机制,比如Mach端口。虽然阻塞的IPC算法设计起来非常简单,但是这样的算法包含各种死锁的可能性,而解决这些死锁场景需要引入复杂的代码,这些代码要求支持一种更加灵活的异步(非阻塞)模型。同样地,ALPC最初设计的目的是为了支持异步的操作,这是可伸缩的RPC和其他用途的一个需求,例如在用户模式驱动程序中支持尚未完成的IO( pending I/O)。ALPC的一个基本特性是,阻塞的调用可以有一个超时参数。这一特性在以前的LPC中是不存在的。它使得以前遗留的应用程序避免某些特定的死锁情形。

然而,ALPC专门为异步消息做了优化,针对异步通知提供了三种不同的模型。第一种模型并不真正通知客户或服务器,而只是简单地拷贝了有效的数据负荷。在这种模型下,由实现者来选择可靠的同步方法。例如,客户和服务器可以共享一个通知事件对象,或者,客户可以主动查询数据是否到达。这种模型使用的数据结构是ALPC完成列表(ALPC completion list,注意,不要与Windows lO完成端口混淆)。ALPC完成列表是一个非常高效、非阻塞的数据结构,它允许在客户之间以原子方式传递数据,其内部机理将在后面的“性能”小节中进一步讲述。

第二种通知模型是一种等待模型,用到了Windows完成端口机制(在ALPC完成列表基础之上)。这使得一个线程可以一次获取多个有效负荷、可以控制并发请求的最大数量,以及充分利用原生的完成端口功能。用户模式线程池(将在本章后面讲述)的具体实现提供了内部的API,进程通过这些API可以在与辅助线程同样的设施内部管理ALPC消息(辅助线程也是用这种模型来实现的)。Windows中的RPC系统当使用本地RPC(通过ncalrpc)的时候,也利用了这一内核支持来提供高效的消息投递能力
最后,因为驱动程序也可以使用异步ALPC,但是通常并不在这样的高层上支持完成端口,因此,ALPC也提供了一种机制,通过使用执行体的回调对象来提供一种更加基础、基于内核的通知。驱动程序可以利用NtAlpcSetInformation来注册其回调环境,之后,当接收到一个消息时,它就会被调用到。例如,在内核中针对用户模式提供的电源管理器接口使用了这种机制来实现笔记本电脑的异步LCD背光操作。

视图、区域和内存区

服务器和客户不再相互之间发送消息缓冲区,而是选择一种更加高效的数据传递机制,该机制也正好位于Windows内存管理器的核心,即内存区对象( section object)。(更多的信息,参见本书下册第10章。)
这允许一块内存被分配成共享的,客户和服务器对这一内存有一个一致的、等同的视图。

在这种情况下,在这一内存中能容纳多少数据,就可以传输多少数据;数据只要被拷贝到这一地址范围中,另一方立即就可以使用这些数据了。不幸的是,像传统LPC提供的共享内存通信也有一些缺点,尤其是当考虑到安全性的时候。

其中一个缺点是,因为客户和服务器必须都能访问这一共享内存,所以,非特权客户可以利用这一点来破坏服务器的共享内存,甚至构建出可执行的负荷数据来发掘潜在的软件漏洞。而且,因为客户知道服务器的数据的位置,所以,它可以利用这一信息来绕过ASLR保护措施。(更多信息,参见本书下册第8章。)

ALPC在内存区对象提供的安全性基础之上又提供了它自己的安全性。利用ALPC,必须通过正确的NtAlpcCreatePortSection API来创建一个特定的ALPC内存区对象,该API将建立起对端口的正确引用,以及允许自动的内存区垃圾回收。(也存在一个手工API用于删除)。随着ALPC内存区对象的所有者开始使用这一内存区,就会逐渐分配出相应的内存块(chunk),称为ALPC区域(ALPC region),它们代表了在该内存区内部已经被使用的地址范围,并且也加上了对该消息的一个额外引用。最后,在共享内存的范围内,所有客户获得该内存的视图,这些视图代表了在它们的地址空间内部的本地映射。

ALPC区域也支持一组安全选项。首先,既可以通过安全模式,也可以通过非安全模式来映射区域。在安全模式下,只有两个视图被允许映射到一个区域。这种模式通常被用于当服务器想要与单个客户进程私有地共享数据时的情形下。而且,对于共享内存中给定的地址范围,在给定的端口环境中只能打开一个区域。最后,ALPC区域也可以被标记为写-访问(write-access)保护,这就使得只有一个进程环境(即服务器)可以对该视图进行写访问(利用MmSecureVirtualMemoryAgainstWrites)。与此同时,其他的客户将只能进行读访问。这些设置可以缓解许多发生在共享内存攻击上的特权提升( privilege-escalation)攻击,它们也使得ALPC比传统的IPC机制更有恢复能力。

属性

ALPC比简单的消息传递提供了更多的功能:它也允许在每个消息上加上特定的与环境有关的信息,可以让内核跟踪此信息的有效性、生命期和具体实现。ALPC的用户也可以指定它们自己的环境信息。无论是系统管理的信息,还是用户管理的信息,ALPC都把这些数据称为属性( attribute)。内核管理的属性有三种:

  • 安全属性,其中包含一些关键信息,允许客户模仿以及高级的ALPC安全功能(稍后
    进一步介绍)。
  • 数据视图属性,负责管理与一个ALPC内存区的区域相关联的不同视图。
  • 句柄属性,其中包含了与该消息关联的那些句柄的信息(更多细节,在稍后的“安全性”小节中介绍)。

通常,这些属性是在最初当消息被发送的时候由服务器或客户传递进来,然后被转换成内核自己的ALPC内部表示。如果ALPC用户要求传回这一数据,那么传回的数据会被安全地送回来。ALPC通过实现这种模型,并且将它与自己的内部句柄表结合起来,从而保证关键的数据在客户和服务器之间是不透明的,同时仍然保持在内核模式下使用真实的指针。

最后,ALPC还支持第四个属性,称为环境属性(context attribute)。这一属性支持传统的、LPC风格的、用户特定的环境指针,此环境指针可以与给定的消息关联起来;在有些场景下,自定义的数据有必要与“客户/服务器”对相关联,这种情况仍然可以支持。

为了正确地定义属性,有各种各样的API可以供内部的ALPC消费者使用,比如AlpcInitializeMessageAttribute和AlpcGetMessageAttribute

Blob、句柄和资源

虽然ALPC库通过对象管理器只暴露了一个对象类型(port),但是,它内部必须管理很多数据结构,以便可以执行它的机制所要求的各项任务。例如,ALPC需要分配和跟踪与每个端口相关联的消息,以及消息属性;它必须跟踪它们生命周期的整个过程。ALPC并没有使用对象管理器的例程来管理数据,而是实现了它自己的一种轻量的对象,称为blob

与对象类似,blob可以自动被分配和垃圾回收,可以被跟踪引用,以及通过同步机制来锁定。而且,blob可以有客户定义的分配和还原回调函数,这使得它们的所有者可以控制额外的信息,比如可以用于跟踪每个blob的使用情况。最后,ALPC也用到了执行体的句柄表的实现(可以使用对象和PID/TID),有一个专门ALPC的句柄表,使得ALPC可以为blob生成私有的句柄,而不是使用指针。

在ALPC模型中,例如,消息是blob,它们的构造函数生成一个消息ID,消息ID本身是一个指向ALPC句柄表的句柄。其他的ALPC blob包括以下:

  • 连接blob,它保存了客户和服务器的通信端口,以及服务器连接端口和ALPC句柄表。
  • 安全blob,它保存了必要的安全数据,以便允许模仿一个客户。它也存储了安全属性。
  • 内存区、区域和视图blob,它们描述了ALPC的共享内存模型。最终由视图blob负责
    存储数据视图属性。
  • 保留blob,它实现了对ALPC保留对象的支持。(参见本章前面的“保留对象”章节。)
  • 句柄数据blob,它包含了支持ALPC句柄属性而需要的信息。

因为blob是从可换页的内存中分配的,所以,它们必须要小心地维护好,以便在适当的时候被删除掉。对于特定种类的blob,这是很容易做到的。例如,当一个ALPC消息被释放的时候,用于包含该消息的blob也相应地被删除掉。然而,有些特定的blob可能代表了附着于某个ALPC消息的诸多属性,因而内核必须要正确地管理它们的生命周期。例如,因为一个消息可以有多个视图附着于它(当许多客户访问同一个共享内存的时候),所以,这些视图必须要通过引用它们的消息来进行跟踪。ALPC利用资源的概念来实现这一功能。每个消息都关联了一个资源列表,任何时候当一个消息关联的blob(不是通过一个简单的指针)被分配时,该blob也会被作为一个资源加入到该消息的资源列表中。依次地,ALPC库提供了查找、刷新和删除这些关联资源的功能。安全blob、保留blob和视图blob都是以资源的形式来存储的。

安全性

ALPC实现了几种安全机制,它有完全的安全边界,能够在一般的IPC解析错误的情况下缓解各种攻击。在最基础的层面上,ALPC端口对象是由同样的对象管理器接口来管理的,能够管理对象安全性、阻止非特权的应用通过ACL来获得指向服务器端口的句柄。在此之上,ALPC提供了一个基于SID的信任模型,继承自最初的LPC设计。该模型使客户可以不仅仅通过端口名称来验证它们正在连接的服务器。通过一个受保护的端口,客户进程将它期望的在端点另一侧的服务器进程的SID提交给内核,内核验证该客户是否真正连接着所期望的服务器,从而可缓解当一个非可信的服务器创建一个端口来欺骗服务器时所发生的名字空间蹲守攻击( namespace squatting attacks)。

ALPC也允许客户和服务器都自动地唯一标识出负责每条消息的线程和进程。通过NtAlpcImpersonateClientThread API,ALPC也支持完整的Windows模仿模型。还有其他的API,可以让ALPC服务器有能力查询所有连接的客户相关联的SID,以及查询客户的安全令牌的LUID(本地唯一标识符)(关于安全令牌,将在第6章中进一步讲述)。

性能

ALPC使用几种策略来改进性能,主要通过支持完成列表(前面已经粗略地介绍过)来做到。在内核层次上,完成列表本质上是一个用户MDL:它已经被探查〈 probe)过,并且被锁定,然后映射到一个地址上。(有关内存描述符列表——Memory Descriptor List的更多信息,参见本书下册第10章。〉因为它与MDL关联(MDL记录了物理页面),所以,当一个客户向服务器发送消息的时候,负荷数据的拷贝可以直接在物理内存层次上进行,而不用像其他IPC机制中常见的那样,请求内核对消息进行双缓冲区处理。
完成列表本身的实现是一个完成项的64位队列,用户模式和内核模式的消费者都可以使用一个互锁的比较-交换操作,从队列中插入和删除项目。更进一步,为了简化内存分配,一旦一个MDL已经被初始化,就利用一个位图来标识出哪些内存还可以使用,以此来留住那些仍然在队列中的新消息。位图算法也使用处理器上原生的锁指令来提供原子的分配和还原操作来操纵完成列表所使用的物理内存的区域。
另一个ALPC性能优化是采用了消息区(message zone)。消息区只是一个预分配的内核缓冲区(也是由MDL来支撑的),消息可以存储在该内核缓冲区中直到服务器或者客户来获取它。消息区将一个系统地址与该消息关联起来,从而使得该消息在任何进程地址空间中都是可见的。更为重要的是,在异步操作的情况下,它并不要求很复杂地建立起延迟的负荷数据,因为无论何时当消息的消费者最终来获取消息数据的时候,该消息区仍将是有效的。完成列表和消息区都可以通过NtAlpcSetInformation建立起来。
最后一个值得提及的优化是,不再是一发送消息内核马上就拷贝数据,而是内核先为将来延迟的拷贝准备好负荷数据,它只抓取必要的信息,此时没有任何拷贝动作。只有当接收者请求该消息的时候才拷贝消息数据。显然,如果一个消息区或者共享内存正在被使用,那么这种方法没有任何优势,但是,在异步的、内核缓冲区消息传递的情况下,这可以用来优化取消的情形和高流量的情形。

调试和跟踪

在检查版本(checked build))的内核中,ALPC消息可以被日志记录下来。所有的ALPC属性、blob、消息区,以及分发事务都可以被单独记录下来;WinDbg中有一个未文档化的!alpc命令,可以将这些日志转储出来。在零售版本的系统上,IT管理员和故障解决人员可以启用ALPCETW(Event Tracing for Windows)记录器来监视ALPC消息。ETW事件并不包含负荷数据,但它们包含了建立连接、断开连接,以及发送/接收、等待/解除阻塞等信息。最后,即使在零售系统上,通过特定的!alpc命令可以获取有关ALPC端口和消息的信息。


3.7 内核事件跟踪

Windows内核和几个核心设备驱动程序的各个组件内置了一些功能来记录下其操作的痕迹数据,以便用于系统诊断。它们依赖于内核中一个公共的基础设施,由它向用户模式的ETW(Event Tracing for Windows,Windows事件跟踪)设施提供痕迹数据。

使用ETW的应用程序必然是以下三类中的某一类或同属于多类:

  • 控制器 控制器启动或者停止记录会话,也管理缓冲区池。控制器的例子有:
    可靠性和性能监视器(参见本节后面的“实验:用内核记录器跟踪TCP/IP的活动”部分)和Windows性能工具箱中的XPerf(参见本章前面的“实验:监视中断和DPC活动”)。
  • 提供者 提供者为它所能产生的事件类定义GUID(全局唯一标识符),并且将它们注册到ETW中。提供者接受来自控制器的命令,以便启动或者停止它所负责的事件类的痕迹跟踪。
  • 消费者 消费者针对它想要读取的痕迹数据,选择一个或者多个跟踪会话。它们可以实时地接收缓冲区中的事件,也可以接收日志文件中的事件。

Windows包含了许多用户模式的提供者,从针对活动目录、服务控制管理器的提供者,到针对资源管理器(Explorer)的提供者,应有尽有。ETW也定义了一个名为“NTKernel Logger”的记录会话(也称为内核记录器),专门用于内核和核心驱动程序。NTKernel Logger的提供者是由Ntoskrnl.exe中的ETW代码和一些核心的驱动程序合起来实现的。

当一个用户模式下的控制器启动内核记录器时,ETW库(在\WindowslSystem32\Ntdll.dll中实现)调用NtTraceControl系统函数,告诉内核中的ETW代码,该控制器想要开始跟踪哪些事件类。如果当前的配置是文件记录(相对于内存记录,即输出到一个缓冲区中),则内核在创建日志文件的系统进程中创建一个系统线程。当内核接收到来自于已启用的痕迹数据源的事件时,它将这些事件记录到一个缓冲区中。如果文件记录线程已被启动的话,则它每隔一秒钟被唤醒一次,以便将缓冲区中的内容转储到日志文件中。

内核记录器生成的痕迹记录有一个标准的ETW痕迹事件头,其中记录了时间戳、进程、线程ID,以及关于该记录所对应的那一类事件的信息。事件类可能提供了与它们的事件有关的额外数据。例如,磁盘事件类痕迹记录指明了操作类型(读或者写)、该操作所在的磁盘号,以及该操作的扇区偏移和长度。
可被内核记录器启用的痕迹类,以及产生每一事件类的组件包括:

  • 磁盘I/O磁盘类驱动程序。
  • 文件I/O文件系统驱动程序。
  • 文件I/O完成文件系统驱动程序。
    硬件配置即插即用管理器(有关即插即用管理器的信息,参见本书下册第9章)。
  • 映像加载/卸载内核中的系统映像加载器。
    页面错误内存管理器(有关页面错误的更多信息,参见本书下册第10章)。
  • 硬页面错误内存管理器。
  • 进程创建/删除进程管理器(Process manager,有关进程管理器的更多信息,参见
    第5章)。
  • 线程创建/删除进程管理器(Process manager)。
  • 注册表活动配置管理器(有关配置管理器的更多信息,参见第4章中“注册表”一
    节)。
  • 网络TCP/IP TCP/IP驱动程序。
  • 进程计数器进程管理器(Process manager)。
  • 环境切换( context switch)内核分发器(kernel dispatcher)。口延迟的过程调用(DPC)内核分发器。
    中断内核分发器。
  • 系统调用内核分发器。
    基于采样的性能剖析内核分发器和HAL。驱动程序延迟IO管理器。
  • 分裂的I/O (Split lO)1/O管理器。口电源事件电源管理器。
    ALPC高级本地过程调用。
  • 调度器和同步内核调度器(有关线程调度的更多信息,参见第5章)。

在Windows SDK中可以找到有关ETW和内核记录器的更多信息,其中包括一些控制器和消费者的例子代码。


3.8 WOW64

Wow64(64位Windows上的Win32仿真)是指允许在64位Windows上执行32位x86应用程序的软件。它的实现方式是一组用户模式DLL,外加一些来自内核的支持,此内核支持是为了创建32位版本的数据结构,比如进程环境块(PEB)和线程环境块(TEB),这些数据结构正常情况下只有64位版本。

通过Get/SetThreadContext来改变Wow64环境也是由内核实现的。下面是负责Wow64的用户模式DLL:
Wow64.dll:管理进程和线程的创建、钩住异常分发和Ntoskrnl.exe导出的基本系统调用。它也实现了文件系统重定向,以及注册表重定向。

Wow64Cpu.dll:为每个正在Wow64内部运行的线程,管理它们的32位CPU环境;针对从32位到64位或者从64位到32位的CPU模式切换,提供了与处理器体系结构相关的支持。
Wow64Win.dll:截取了Win32k.sys导出的GUI系统调用。

IA64系统上的IA32Exec.bin和Wowia32x.dll:包含IA-32软件仿真器和它的接口库。因为Itanium处理器不能以原生方式高效地执行x86的32位指令(性能差于30%),所以有必要通过这两个额外的组件来实现软件仿真(通过二进制翻译)。

这些DLL之间的关系如图3.31所示。

Wow64进程地址空间布局结构

Wow64进程可以在2GB虚拟空间中运行,也可以在4GB虚拟空间中运行。如果映像文件的头部设置了大地址空间感知标志,则内存管理器将4GB边界之上至用户模式边界末尾之间保留为用户模式地址空间。如果映像文件没有被标记为大地址空间感知的,则内存管理器将保留2GB之上的用户模式地址空间(有关大地址空间支持的更多信息,请参见本书下册第10章中“x86用户地址空间的布局结构”一节)。

系统调用

Wow64钩住了所有从32位代码转变至原生64位系统的代码路径,也钩住了64位原生系统需要调用至32位用户模式代码的所有代码路径。在进程创建的过程中,进程管理器(processmanager)将原生的64位Ntdl.dll和针对Wow64进程的32位Ntdll.dIl映射到进程地址空间中。当加载器的初始化过程被调用时,它调用Wow64.dll内部的Wow64初始化代码。然后Wow64建立起32位Ntdll所要求的启动环境,将CPU模式切换到32位下,并开始执行32位加载器。从这个点开始,执行过程继续进行,就如同该进程运行在原生的32位系统上一样。

Ntdll.dll、User32.dll和Gdi32.dil的特殊32位版本位于\WindowslSyswow64文件夹下(也有一些特定的执行跨进程通信的其他DLL,比如Rpcrt4.dll)。它们调用到Wow64中,而不是发出原生的32位系统调用指令。Wow64转变到原生的64位模式下,捕获到与系统调用有关的参数(将32位指针转换为64位指针),并且发出对应的原生64位系统调用。当原生的系统调用返回时,Wow64把任何输出参数,如果有必要的话,在返回至32位模式之前从64位转换成32位格式。

异常分发

Wow64通过Ntdll的KiUserExceptionDispatcher钩住了异常分发过程。无论何时当64位内核将要给一个Wow64进程分发一个异常时,Wow64会捕获住原生的异常以及用户模式下的环境记录(context record),然后准备一个32位异常和环境记录,并且按照原生32位内核所做的那样将它分发出去。

用户APC分发

Wow64通过Ntdll的KiUserApcDispatcher也钩住了用户模式APC的递交过程。无论何时当64位内核将要给一个Wow64进程分发一个用户模式APC时,Wow64把32位APC地址映射到一个更高的64位地址空间范围中。然后,64位Ntdll捕获住原生的APC以及用户模式下的环境记录,将它映射回32位地址。然后它准备一个32位用户模式APC和环境记录,并且按照原生32位内核所做的那样将它分发出去。

控制台支持

因为控制台支持是由Csrss.exe在用户模式下实现的,它只是单个原生二进制可执行文件,所以,32位应用程序在64位Windows上不能执行控制台IO。类似于专门有一个特殊的rpcrt4.dil用来将32位RPC适配成64位RPC,Wow64的32位Kernel.dll包含有专门的代码来调用到Wow中,以便在与Csrss和Conhost.exe交互过程中对参数进行适配。

用户回调

Wow64截取了所有从内核到用户模式的回调。Wow64将这样的调用也按照系统调用来对待;然而,数据转换则是按相反的顺序来完成的:输入参数从64位转换为32位,而输出参数则是在该次回调返回时从32位转换至64位。

文件系统重定向

为了维护应用程序的兼容性,以及降低从Win32到64位Windows的应用程序移植代价,系统目录名称仍然保持不变。因此,\WindowslSystem32文件夹包含了原生的64位映像文件。因为Wow64钩住了所有的系统调用,所以,它会解释所有与路径相关的API,将\Windows\System32文件夹的路径名替换为\Windows.Syswow64。Wow64也将\Windows\LastGood重定向到\Windows\LastGoodlsyswow64,将\Windows\Regedit.exe重定向到\Windowsisyswow64\Regedit.exe。

通过使用系统环境变量,%PROGRAMFILES%环境变量对于32位应用程序被设置为\Program Files (x86),而对于64位应用程序被设置为\Program Files文件夹。CommonProgramFiles和CommonProgramFiles(x86)环境变量也存在,它们总是指向32位的位置,而ProgramW6432和CommonProgramWP6432则无条件地指向64位位置。

注因为有些特定的32位应用程序可能真的需要知晓或者能够处理64位映像文件,所以,有一个
虚拟的目录,\Windows\Sysnative,使得任何从32位应用程序发出的针对此目录的I/O,都免于被文件重定向。这个目录实际上并不存在,它只是一个允许访问到真正的System32目录的虚拟路径而已,即使运行在Wow64下的应用程序也不例外。

lWindows\System32中有一些子目录,出于兼容性的原因,这些子目录没有被重定向,所以32位应用程序在访问这些目录时实际上是在访问真正的目录。这些目录包括:

  • %windir%lsystem32\drivers\etc
  • %windir%lsystem32\spool
  • %windir%lsystem32\catroot和%windir%lsystem32lcatroot2%windir%lsystem32logfiles
  • %windir%\system32ldriverstore

最后,Wow64提供了一种机制来控制Wow64中内置的文件系统重定向功能,这种机制是以每个线程为基础的,通过Wow64DisableWow64FsRedirection和 Wow64RevertWow64FsRedirection函数来实施控制。该机制对于延迟加载的DLL、通过公共文件对话框来打开文件,甚至国际化方面,都存在一些问题——因为一旦重定向被关闭,系统要么在内部加载过程中不再使用重定向,要么有些特定的只有64位的文件便无法再找到。使用c:1windowsisysnative路径或者前面介绍的其他的一致路径通常是一种更为安全的方法,可以供开发人员使用。

注册表的重定向

应用程序和组件程序将它们的配置数据保存在注册表中。组件程序在安装过程中,当它们被注册的时候,通常将配置数据写到注册表中。如果同样的组件既安装和注册了一个32位二进制文件,又安装和注册了一个64位二进制文件,那么,最后被注册的那个组件将会覆盖掉以前组件的注册,因为它们写到注册表中同样的位置上。

为了以透明的方式解决这个问题,并且无须对32位组件进行任何代码修改,注册表被分成两个部分:原生的和Wow64的。在默认情况下,32位组件访问32位视图,64位组件访问64位视图。这为32位和64位组件提供了一个安全的执行环境,并且将32位应用程序的状态与64位应用程序(如果存在的话)的状态隔离开来。

为了实现这一点,Wow64截取了所有要打开注册表键的系统调用,并且重新解释这些注册表键的路径,将它们指向注册表的Wow64视图。Wow64在以下这些点上分裂注册表:

  • HKLM\SOFTWARE
  • HKEY_CLASSES_ROOT
    然而,请注意,许多子键实际上在32位和64位应用之间是共享的——也就是说,并非整个储巢被分裂了。

在以上每一个键的下面,Wow64创建了一个称为Wow6432Node的键。在该键下面保存的是32位配置信息。注册表的所有其他部分对于32位应用程序和64位应用程序都是共享的(比如HKLMISYSTEM)。

还有一个额外的帮助,如果一个32位应用程序向注册表中写入一个以数据“%ProgramFiles%”或“%commonprogramfiles%”为开头的REG_SZ或者REG_EXPAND_SZ值,那么Wow64将实际的值修改为“%ProgramFiles(x86)%“或”%commonprogramfiles(x86)%”,以便符合前面介绍的文件系统重定向和布局结构。32位应用程序必须正确地写这些字符串(包括大小写)——任何其他的数据都被忽略,按普通的方式写入。最后,任何包含“system32”的键被替换为“syswow64”(针对所有的大小写),也不管标志和大小写是否敏感,除非使用了KEY_WOW64_64KEY,以及该键位于“反射键”列表中(可在MSDN上查询到)。

如果应用程序需要显式地指定一个注册表键位于某个特定的视图中,那么,在RegOpenKeyEx、RegCreateKeyEx、RegOpenKeyTransacted、RegCreateKeyTransacted和RegDeleteKeyEx函数中使用下述标志可以做到这一点:

KEY_WOW64_64KEY——从一个32位或者64位应用程序中显式地打开一个64位键,
并且禁止前面介绍的REG_SZ或REG_EXPAND_SZ截取转换处理。
KEY_WOW64_32KEY ——从一个32位或者64位应用程序中显式地打开一个32位键。

I/O控制请求

除了普通的读和写操作以外,应用程序可以利用Windows的DeviceloControlAPI,与某些设备驱动程序通过设备IO控制函数进行通信。应用程序可能会在调用时指定一个输入和/或输出缓冲区。如果该缓冲区中包含了与指针相关的数据,并且发送该控制请求的进程是一个Wow64进程,那么,输入和/或输出结构的视图在32位应用程序和64位驱动程序之间是不相同的,因为对于32位应用程序来说,指针是4字节,而对于64位应用程序来说,指针是8字节。在这种情况下,内核驱动程序最好能够转换这些与指针相关的结构。驱动程序可以调用loIs32bitProcess函数来检测一个IO请求是否是从一个Wow64进程发出的。更多的细节可以参考MSDN中的“Supporting 32-Bit IO in Your 64-Bit Driver”。

16位安装器应用程序

Wow64不支持运行16位应用程序。然而,由于许多应用安装器是16位程序,所以,Wow64包含一些特殊的代码,使得对于某些特别知名的16位安装器的引用能够工作。这样的安装器包括:
Microsoft ACME Setup 版本: 1.2、2.6、3.0和3.1。InstallShield版本5.x(这里x是任何一个小版本号)。
无论何时当通过CreateProcess()API创建一个16位进程时,首先Ntvdm64.dll被加载进来,然后控制权被传递给它,以检查该16位可执行文件是否是所支持的安装器中的某一个。如果是的话,则发出另一个CreateProcess调用,以便使用同样的命令行参数来激发该安装器的一个32位版本。

打印

32位打印机驱动程序不能被用在64位Windows中。打印驱动程序必须要被移植为原生的64位版本。然而,由于打印机驱动程序运行在所请求进程的用户模式地址空间中,并且在64位Windows上只支持原生的64位打印机驱动程序,所以,需要一种特殊的机制来支持32位进程中的打印任务。这是这样做到的:将所有的打印函数重定向到Splwow64.exe中,这里Splwow64.exe是Wow64RPC打印服务器。由于Splwow64是一个64位进程,所以它可以加载64位打印机驱动程序。

—些限制

Wow64不支持16位应用程序的执行(而在32位版本的Windows上它们是支持的),也不支持加载32位内核模式的设备驱动程序(它们必须被移植为原生的64位版本)。Wow64进程只能加载32位DLL,不能加载原生的64位DLL。类似地,原生的64位进程不能加载32位DLL。唯一的例外是,在跨越体系结构差异时,能够加载仅包含资源或数据的DLL,这是允许的,因为这些DLL只包含数据,而并非代码。
除了上述限制以外,由于页面大小的差异,在IA64系统上的Wow并不支持ReadFileScatter、WriteFileGather、GetWriteWatch、AVX寄存器、XSAVE,以及AWE函数。而且,通过DirectX得到的硬件加速也是不可用的(针对Wow64进程提供了软件仿真)。

3.9 用户模式调试

对用户模式调试的支持被分在三个不同的模块中。

第一个模块位于内核可执行程序内部,其前缀为Dbgk,代表了调试框架(debugging framework)的意思。

它提供了必要的内部函数.用于注册和监听调试事件、管理调试对象,以及对信息进行打包以供用户模式部分使用。

直接与Dbgk打交道的用户模式组件位于原生的系统库,Ntdl.dll中,在一组以前缀DbgUi打头的API函数中。

这些API负责将底层的调试对象实现(这是不可见的)包装起来,允许所有的子系统应用程序使用调试功能,它们可以在这一DbgUi实现上再包装它们自己的API。

最后,用户模式调试的第三个组件属于子系统DLL。这是指被暴露出来的、文档化的API(对于Windows子系统,位于KernelBase.dll中),每个子系统都会支持这样的API以便可以调试其他的应用程序。

内核支持

内核通过一种前面提到过的对象,调试对象(debug object),来支持用户模式调试。

它提供了一系列系统调用,这些系统调用绝大多数直接映射到Windows调试API上,通常首先通过DbgUi层来进行访问。调试对象本身是一个简单的结构体,由一系列标志(决定了对象的状态)、一个事件(用于通知等待者已经有了调试器事件)、一个调试事件双链表(这些调试事件正在等待被处理),以及一个用于锁住该对象的快速互斥体构成。这是内核为了能够成功地接收和发送调试事件而需要的所有信息,每个被调试的进程在它的结构中有一个调试端口(debug port)成员指向此调试对象。

一旦一个进程有一个关联的调试端口,那么,表3.23中描述的事件可以导致在事件列表中插入一个调试事件。
在这里插入图片描述
在这里插入图片描述
除了上表中提到的原因以外,还有一些超越于这些常规条件下当一个调试器对象被第一次与一个进程关联起来时的特殊触发情形。当调试器被附载(attach)到一个进程时,就会手工发送第一个创建进程( create process)和创建线程(create thread)的消息,这是针对进程本身,以及它的主线程;接着,为进程中的所有其他线程发送创建线程消息。最后,针对被调试的可执行程序(Ntdll.dll)发送加载dll事件,再为被调试进程中的所有当前DLL发送加载dll事件。

一旦一个调试器对象已经关联上一个进程,则该进程中的所有线程都被挂起。在这时候,调试器有责任开始请求发送这些调试事件。调试器通过在调试对象上执行一个wait动作,请求这些调试事件被送回到用户模式。此调用对调试事件链表进行循环。当每个请求被从链表中移除时,该请求的内容将从dbgk内部结构转换为上一层可以理解的原生结构。我们将会看到,这一结构与Win32的结构并不相同,因此,还需要另外一层转换。即使当调试器处理完了所有待处理的调试消息以后,内核不会自动地重新启动这一进程。调试器有责任调用ContinueDebugEvent函数来恢复该进程的执行。
除了一些跟多线程有关的复杂处理事项以外,这一框架的基本模型是非常简单的,只不过是生产者(provider),即内核中产生上面表格中所列的调试事件的代码,加上消费者(consumer),即,在这些事件上等待并且做出响应的调试器。

原生支持

虽然用户模式调试的基本协议非常简单,但是,Windows应用程序并非直接使用用户模式调试。相反地,用户模式调试被包装为Ntdl.dll中的DbgUi函数族。这一抽象是必要的,这使得原生应用程序以及不同的子系统可以使用这些例程(因为Ntdll.dll中的代码没有依赖性)。这一组件提供的函数绝大多数与Windows API函数和有关的系统调用非常类似。

在其代码内部也提供了请求“创建一个与当前线程相关联的调试对象”的功能。被创建出来的调试对象的句柄永远不会被暴露出去。相反地,它被保存在正在执行此关联操作的调试器线程的TEB(线程环境块,thread environment block)中。(有关TEB的更多信息,请参考第5章。)此值被保存在

DbgSsReserved[1]中。
当一个调试器附载到一个进程时,它希望该进程可以被侵入(break into),也就是说,一个int 3(断点)操作应该已经发生了,这是由注入在该进程中的一个线程产生的。如若不然,调试器应该永远不会真正控制该进程,它只不过可以看到调试事件发生而已。Ntdl.dl负责创建此线程并将其注入到目标进程中。
最后,Ntdll.dll也提供了API来把调试事件的原生数据结构转换为Windows API可以理解的数据结构。


Windows子系统的支持

使得诸如Microsoft Visual Studio或WinDbg之类的调试器可以调试用户模式应用程序的最后一个组件是在Kernel32.dll中。它提供了文档化的Windows API。在这里列举出这些函数名称并非重要,这部分调试设施的一个重要的管理任务是:管理复制的文件和线程句柄。

回忆一下,每次当一个加载dll事件被送出的时候,内核就会复制一个指向该映像文件的句柄,并放在事件结构中,这就如同在创建进程的事件过程中处理指向进程可执行映像文件的句柄一样。在每一个等待调用过程中,Kernel32.dl检查是否有事件导致在内核中新的进程和/或线程句柄被复制(两个创建事件)。如果是的话,则分配一个数据结构,其中存放进程ID、线程ID,以及与该事件相关联的线程和/或进程句柄。此数据结构被链接到TEB的第一个DbgSsReserved数组索引中,上一小节曾经提到过,调试对象的句柄也被存放在这里。同样地,Kernel32.dll也会检查退出事件。当它检测到这样的事件时,它会在数据结构中“标记”相应的句柄。

一旦调试器用完了这些句柄,并且执行了继续调用,Kernel32.dIl将解析这些数据结构,检查那些已经退出的线程的句柄,并且为调试器关闭这些句柄。否则的话,这些线程和进程将永远不会退出,因为只要调试器在运行,就总会有打开的句柄指向这些线程或进程。

3.10 映像加载器

当系统中一个进程被启动时,内核创建一个进程对象来代表该进程(有关进程的更多信息,请参考第5章),并执行各种与内核有关的初始化任务。然而,这些任务并不会导致应用程序被执行起来,而仅仅做了一些准备上下文和执行环境的工作。事实上,不像驱动程序是内核模式的代码,应用程序是在用户模式下执行的,所以,实际的初始化工作绝大部分是在内核之外完成的。这些工作是由映像加载器(image loader)来完成的,在内部用Ldr来表示。

映像加载器驻留在用户模式系统DLLNtdll.dll中,不在内核库中。因此,它的行为表现就像标准的、位于一个DLL中的代码一样,而且,在内存访问和安全权限方面也受同样的限制。使这部分代码变得特殊的是,它可以确保总是出现在任何正在运行的进程中(Ntdll.dll总是被加载到进程中),而且它是新的应用程序中最先在用户模式下运行的代码。(当系统建立起初始的上下文环境后,程序计数器或者指令指针被设置为Ntdll.dll中的一个初始化函数。更多的信息请参考第5章。)
因为加载器总是在实际的应用程序代码之前运行,所以,它对于用户和开发人员通常是不可见的。而且,尽管加载器的初始化任务被隐藏起来了,但是,一个程序在运行过程中,往往确实需要跟加载器的接口打交道,例如,当加载或卸载一个DLL,或者查询一个DLL的基地址的时候。加载器负责的一些主要任务如下所列:

  • 为应用程序初始化其用户模式状态,比如创建初始的堆、建立起线程局部存储(TLS,thread local storage)和纤程局部存储(FLS,fiber local storage)槽。

  • 解析应用程序的导入表(IAT),查找所有它要求的DLL(然后递归地为每个DLL解析IAT),接着,解析DLL的导出表,确保导入的函数确实存在(特殊的前转项(forwarderentry)也可以将一个导出表项重定向到另一个DLL中)。

  • 在运行时候或者根据需要加载或卸载DLL,并且维护一个包含所有已被加载的模块
    的列表(模块数据库)。

  • 使得可以支持运行时刻打补丁(称为热补丁,hotpatching),本章后面会进一步解释。口处理清单文件(manifest file)。

  • 读取任何铺垫形式的应用程序兼容性数据库,如果有必要的话,加载此铺垫(Shim)
    引擎DLL。

启用对API集和API重定向的支持,这是MinWin重构工程的一个核心部分。口启用基于SwitchBranch机制的运行时刻动态兼容性缓解方案(mitigation)。

正如你所看到的,绝大多数这些任务对于一个应用程序真正运行其代码都是至关重要的;否则,从调用外部函数,到使用堆内存,一切都马上宕掉。在进程已被创建起来以后,加载器将调用一个特殊的原生API,因而可以基于栈中的一个环境帧(context frame)继续执行。此环境帧是由内核建立起来的,包含了应用程序的实际入口点。因此,由于加载器并不使用标准的调用或跳转指令进入到正在运行的应用程序中,所以,你在一个线程的栈痕迹中,永远不会看到加载器的初始化函数出现在调用树中。


进程初始化早期工作

因为加载器是在Ntdll.dll中,这是一个不附属于任何子系统的原生DLL,所以,所有进程都遵从同样的加载器行为(有一些细微的差别)。在第5章,我们将会详细地看一下在内核模式下一个进程创建过程中的步骤,以及Windows函数CreateProcess完成的一些工作。然而,现在我们来讨论发生在用户模式下的工作,这些工作独立于任何一个子系统,并且从第一条用户模式指令执行就开始了。当一个进程启动时,加载器执行以下步骤:

  1. 构建起应用程序的映像路径名称,并且确定该应用程序的Image File Execution
    Options键,以及DEP和SHE有效性链接器设置。
  2. 检查映像可执行文件的头,看它是否为一个.NET应用(取决于是否出现了与.NET相
    关的映像目录)。
  3. 初始化进程的国家语言支持(NLS,National Language Support)表(国际化支持)。
  4. 如果该映射是32位的,但是在64位Windows上运行,则初始化Wow64引擎。
  5. 把映像可执行文件头中指定的配置选项都加载上。这些选项能够控制此可执行文件
    的行为,开发人员在编译该应用程序时可以定义这些选项。
  6. 如果在可执行文件头中指定了亲和性掩码( affinity mask),则设置此亲和性掩码。
  7. 初始化FLS和TLS。
  8. 为当前进程初始化堆管理器,并创建第一个进程堆。
  9. 为进程分配一个SxS(Side-by-Side Assembly,并行程序集)/Fusion激活环境,这使得系
    统可以使用正确的DLL版本文件,而不再默认指向那个与操作系统一起发行的DLL(更多的信息请参考第5章)。
  10. 打开\KnownDlls对象目录,构建起已知DLL的路径。对于Wow64进程,使用
    lKnownDlls32。
  11. 确定进程的当前目录和默认加载路径(当加载映像文件和打开文件的时候会用到)。
  12. 为应用程序可执行文件和Ntdll.dlI建立起相应的加载器数据表项,然后将表项插入到
    模块数据库中。

到这时候,映像加载器已经做好了准备,可以开始解析属于该应用程序的可执行文件的导入表,以及加载任何在应用程序编译过程中动态链接的DLL了。因为每一个被导入的DLL也可以有它自己的导入表,所以,这个过程会递归地进行,直到所有的DLL都被满足,所有被导入的函数都已经找到。随着每一个DLL被加载进来,加载器会记录下它的状态信息,并构建起模块数据库。

dll名称解析

名称解析是指这样一个过程:当调用者没有指定或者不能指定一个唯一文件标识的情况下,系统把一个PE格式二进制文件的名称转换成一个物理文件。因为各个目录(应用目录、系统目录等等)的位置无法在链接的时候以硬编码的方式确定,所以,这也包括所有二进制依赖性的解析,以及当调用者没有指定一个完整路径的情况下LoadLibrary操作的解析过程。

当解析二进制依赖性的时候,基本的Windows应用程序模型是按照搜索路径来查找文件,这里的搜索路径是一个位置列表,其中每个位置被顺序搜索以发现一个匹配的基本名称;不过各种系统组件为了扩展默认的应用程序模型,会覆盖掉这一路径搜索机制。搜索路径的概念是从命令行时代遗留下来的产物,那时候一个应用程序的当前目录是一个有意义的概念;对于现代的GUI应用程序,这多少有点不合时宜了。

然而,由于当前目录在这一路径顺序中的特殊位置,通过在应用程序的当前目录下放置一些相同基本文件名的恶意二进制文件,使得系统二进制文件的加载操作可以被改变。为了防止与这一行为相关联的安全风险,在路径搜索计算上新加入一个称为安全DLL搜索模式的特性,并且从Windows XPSP2开始,这一特性对所有的进程都默认启用。在安全搜索模式下,当前路径被移到三个系统目录的后面,从而导致下面的路径顺序:

  1. 应用程序被激发时的目录;
  2. 原生的Windows系统目录(例如,C:\Windows\System32);
  3. 16位Windows系统目录(例如,C:\Windows\System);
  4. Windows目录(例如,C:\Windows)
  5. 应用程序激发时刻的当前目录;
  6. 任何在%PATH%环境变量中指定的目录。

对于每个后续的DLL加载操作,DLL搜索路径都要重新计算。用于计算搜索路径的算法与计算默认搜索路径所使用的算法相同,但是应用程序可以通过SetEnvironmentVariable API来编辑%PATH%变量,从而改变特定的路径元素;也可以使用SetCurrentDirectory API来改变当前目录,或者使用SetDIIDirectory API来为当前进程指定一个DLL目录。当指定了DLL目录的时候,该目录代替了搜索路径中的当前目录,并且对于该进程,加载器将会忽略安全DLL搜索模式的设置。

调用者也可以在调用LoadLibraryEx API时提供LOAD_WITH_ALTERED_SEARCH_PATH标志,以便针对特定的加载操作修改DLL搜索路径。当提供了这一标志,并且提供给此API的DLL名称是一个全路径字符串时,在计算该操作的搜索路径的时候将使用包含该DLL文件的路径来代替应用程序目录。

DLL名称重定向

在将一个DLL名称字符串解析成一个文件以前,加载器试图使用DLL名称重定向规则。这些重定向规则被用于扩展或者改变DLL名字空间的某些部分,以进一步扩展Windows应用程序模型。这里的DLL名字空间通常对应于Win32文件系统名字空间。对于应用程序,这些规则是:

  • MinWin API集重定向API集机制的设计目标是,允许Windows开发组以一种对应用
    程序透明的方式来改变一个要导出特定系统API的二进制文件。
  • .LOCAL重定向,.LOCAL重定向机制允许应用程序将某个特定DLL基本名称的所有加载操作(不管是否指定了一个全路径),都重定向到该应用程序目录中DLL文件的一份本地副本上,做法有两种:用相同基本名称再加上.local,为该DLL创建一个副本文件(例如,MyLibrary.dll.local);或者,在应用程序目录下创建一个名为.local的

文件夹,再把本地DLL的一份副本放在该文件夹中(例如,C:\ProgramFilesMyApp.LOCALMyLibrary.dll)。通过.LOCAL机制来重定向的文件,其处理过程与通过SxS来重定向的DLL一样(参见下一条)。只有当可执行文件没有一个关联的清单文件(无论是内嵌的还是外部的)的时候,加载器才为DLL使用.LOCAL重定向。
Fusion (SxS)重定向 Fusion(也称为并行〔程序集),side-by-side,或SxS)是对Windows应用程序模型的扩展,允许二进制组件嵌入二进制资源(称为清单, manifest>来表达更为详细的二进制依赖性信息(通常是版本信息)。当Windows公共控件包( comctl32.dll)被分裂成多个可以相互并存的不同版本以后,Fusion机制率先被使用,因而应用程序可以加载正确版本的二进制文件。此后,其他的二进制文件也采用同样的方式进行版本管理。到了Visual Studio 2005,用Microsoft链接器编出来的应用程序将使用Fusion来定位到正确版本的C运行时库。

Fusion运行时工具利用Windows资源加载器,从一个二进制文件的资源区,读入内嵌的依赖性信息,然后把依赖性信息封装成称为激活环境( activation context)的查找结构。系统分别在引导时刻和进程启动时刻创建起系统级的默认激活环境,和进程级的默认激活环境;而且,每个线程有一个关联的激活环境栈,在栈顶的激活环境结构被认为是活动的(active)的。每个线程的激活环境栈既可以被显式地管理(通过ActivatcActCtx和DeactivateActCtx API),也可以在特定的点上由系统隐式地管理(比如当一个内嵌有依赖性信息的二进制文件的DLL主例程被调用的时候)。当一个Fusion DLL名称重定向查找发生的时候,系统在该线程的激活环境栈的头部处的激活环境中搜索重定向信息,接着再搜索进程激活环境和系统激活环境;如果找到了重定向信息,则当前的加载操作使用激活环境中指定的文件标识。

已知DLL重定向已知DLL是指这样一种系统机制:将特定的DLL基本名称映射到系统目录中的文件上,从而阻止这些DLL被不同位置处其他版本的文件替换掉。

在DLL路径搜索算法中,一个边界情形是,在64位和Wow64应用程序上执行的DLL版本检查。如果找到了一个基本名称匹配的DLL,但是随后发现该DLL编译的机器体系结构不正确—一例如,在32位应用程序中的64位映像文件,那么,加载器会忽略此错误,并恢复路径搜索操作,从找到此不正确文件所使用的那个元素之后的下一个元素继续开始搜索。这么设计的目的是,让应用程序可以在全局的%PATH%环境变量中同时指定64位和32位路径项。


加载器维护了一份包含所有已被一个进程加载的模块(DLL以及基本的可执行文件)的列表。此信息被保存在一个称为进程环境块(PEB,Process Environment Block)的结构中(此PEB结构针对每个进程,关于PEB的完整描述,参见第5章),实际上是在一个由Ldr标识的子结构中,称为PEB_LDR_DATA。在此结构中,加载器维护了三个双向链表,它们包含了同样的信息,但顺序不尽相同(或按照加载顺序,或按照内存位置的顺序,或按照初始化顺序)。这些链表包含的结构体称为加载器数据表项(loader data table entry,LDR_DATA_TABLE_ENTRY),其中存储了关于每个模块的信息。表3.24列出了加载器在一个表项中维护的各种信息。

在这里插入图片描述
在这里插入图片描述
查看一个进程的加载器数据库的一种做法是,使用WinDbg来检查PEB的格式化输出。下面的实验展示了如何做到这一点,以及你自己如何查看LDR_DATA_TABLE_ENTRY结构。


虽然本节内容介绍了Ntdll.dll中的用户模式加载器,注意,内核为驱动程序和相关联的DLL也使用了它自己的加载器,也有类似的加载器表项结构。同样地,内核模式加载器有它自己的一个表项数据库,通过全局数据变量PsActiveModuleList可以直接访问此数据库。为了转储出内核的已加载模块数据库,你可以如前面实验中所指示的那样,使用一个类似的!list命令,只需把命令末尾的指针用“nt!PsActiveModuleList”来替代。

以原始的格式来查看这一链表,可以让你对加载器的内部机理有额外的认识,比如flags域包含的状态信息是!peb命令不会显示给你的。表3.25显示了这些标志的含义。因为内核模式和用户模式加载器都使用这一结构,所以,有些标志仅适用于内核模式驱动程序,而其他标志仅适用于用户模式应用程序(比如.NET状态)。

在这里插入图片描述

导入信息解析

我们已经解释了加载器如何记录下一个进程中所有已被加载的模块,现在我们可以继续来分析加载器执行的进程启动初始化任务。在此过程中,加载器完成以下事项:

  1. 将进程可执行映像的导入表中引用到的每个DLL加载到进程中。
  2. 通过检查模块数据库,可以检查每个DLL是否已被加载到进程中。如果在链表中未
    找到一个DLL,则加载器打开该DLL,并将它映射到内存中。
  3. 在映射操作过程中,加载器首先依照该有的查找该DLL的途径,来检查各个路径,并
    且也检查该DLL是否为一个“已知DLL(known DLL)”,这里“已知DLL”意味着系统已经在启动时加载过了,同时提供了一个全局的内存映射文件可以访问此DLL。在特定的情况下有可能偏离这一标准查找算法,既可以是使用了.local文件的结果,也可以通过一个清单文件。.local文件将强迫加载器使用本地路径中的DLL,而清单文件可能会指定一个重定向的DLL,以确保使用某个特定的版本。
  4. 当DLL已经被找到在磁盘上的某个地方,并且被映射以后,加载器检查内核是否已
    经将它加载到别处了——这被称为重定位(relocation)。如果加载器检测到重定位,
    那么,它将解析DLL中的重定位信息,并执行必要的操作。如果DLL中不存在重定位信息,那么,此DLL的加载将以失败告终。
  5. 然后,加载器为该DLL创建一个加载器数据表项,并且将它插入到数据库中。
  6. 当DLL已经被映射以后,此过程针对该DLL重复进行,以便解析它的导入表和所有它
    的依赖库。
  7. 当每个DLL被加载以后,加载器解析IAT,查找那些被导入的函数。通常这是通过名
    称匹配来完成的,但是也可以通过序号(一个索引号)来完成。对于每个名称,加载器解析导入DLL的导出表,试图找到一个匹配。如果没有找到名称匹配,则此操作终止。
  8. 一个映像文件的导入表也可以是已经绑定好的(bound)。这意味着,在链接时刻,开
    发人员已经分配了静态的地址,指向外部DLL中的导入函数。这使得不必为每个名称进行查找操作,但是这样做的前提是,应用程序将要使用的DLL总是位于同样的地址处。因为Windows使用地址空间随机化技术(有关地址空间加载随机化的更多信息,Address Space Load Randomization,简称ASLR,请参见本书下册第10章),这对于系统应用程序和系统库通常并不能工作。
  9. 一个导入DLL的导出表可以使用转发项(forwarder entry),这意味着实际的函数是在
    另一个DLL中实现的。实质上,这必须也被看作一个导入项或依赖项,因而,在解析了导出表以后,转发项所引用的每个DLL也要被加载,这样又回到第1步。

当所有的导入DLL(以及它们自己的依赖库或导入库)已经被加载以后,所有必需的导入函数已经被查找过并且找到了,所有的转发项也被加载和处理了,导入解析的步骤就完成了:应用程序和它的各个DLL在编译时刻定义的所有依赖性现在已经满足了。在执行过程中,延迟的依赖性(称为延迟加载),以及运行时刻操作(比如调用LoadLibrary)可以调用到加载器的功能,本质上也是重复同样的任务。然而,请注意,如果这些步骤是在进程启动过程中进行的,那么任何失败将会导致一个应用程序启动错误。例如,如果试图要运行的应用程序要求一个在当前操作系统版本中并不存在的函数,那么,这将会导致一个类似于图3.32那样的消息出现。

当必要的依赖库已经被加载进来以后,还必须要执行一些初始化任务,以便完满地完成应用程序的启动过程。在这个阶段,加载器将完成以下事项:

  1. 检查应用程序是否为一个.NET应用,如果是的话,将该应用的执行重定向到.NET运
    行时库的入口点,假定该映像文件已经被框架检验过了。
  2. 检查应用程序自身是否要求重定位,并处理它的重定位项。如果应用程序不能被重
    定位,或者没有重定位信息,则加载过程失败。
  3. 检查应用程序是否使用了TLS,并且在应用程序可执行文件中查找它需要分配和配
    置的TLS项。
  4. 如果这是一个Windows应用程序,那么,在加载了Kernel32.dll以后,对Windows子系
    统的线程初始化垫片代码进行定位,并启用Authz/AppLocker机制。(关于软件限制策略的更多信息,参见第6章。)如果未找到Kernel32.dll,则推断为该系统正在MinWin中运行,只加载了KernelBase.dll。
  5. 现在加载任何静态的导入项。
  6. 至此,如果使用了一个诸如WinDbg这样的调试器,则初始的调试器断点将被碰上。
    这也正是在前面的实验中你需要输入“g”才能继续执行的地方。
  7. 如果系统是一个多处理器系统,则确保该应用程序能够正确地运行。
  8. 建立起默认的数据执行保护(DEP)选项,包括异常链验证,也称为“软件”DEP。
    (有关DEP的更多信息,参见本书下册第10章。)
  9. 检查该应用程序是否要求任何的应用兼容工作,若必要的话加载Shim引擎。
  10. 检测一下该应用程序是否被SecuROM、SafeDisc和其他的可能存在DEP问题的封装或
    保护工具所保护(在这些情况下,重新配置DEP设置)。
  11. 对所有已加载的模块运行它们的初始化函数。
  12. 如果该模块为了应用兼容性而已经加了铺垫,则运行Shim引擎的初始化后期回调函
    数。
  13. 运行Windows应用程序PEB中注册的子系统DLL的初始化后处理例程,例如,它会做
    一些与终端服务相关的检查。

运行各个初始化函数是加载器的工作中最后一个主要步骤。这一步骤将调用每个DLL的DIIMain例程(允许每个DLL有机会执行它自己的初始化工作,这甚至也包括在运行时刻加载新的DLL),并处理每个DLL的TLS初始化函数。这是导致应用程序加载失败的最后可能的步骤之一。如果所有已被加载的DLL在完成了它们的DIIMain例程之后并没有返回一个成功的返回代码,那么,加载器将会终止应用程序启动过程。作为一个非常靠后的步骤,加载器将调用实际应用程序的TLS初始化函数。

SwitchBack

随着Windows每个新的版本修复了已有API函数中的诸如竞争条件和不正确参数验证检查之类的错误(bug),每一次改变都会引入应用兼容性风险,无论此风险有多小。Windows采用了一种称为SwitchBack的技术(是在加载器中实现的),使得软件开发人员可以在其可执行文件关联的清单文件中嵌入一个专门针对目标Windows版本的GUID。例如,如果开发人员想要利用在Windows 7中针对某个API新加入的增强,她可以在她的清单文件中包含Windows 7的GUID;而如果开发人员有一个遗留的应用程序,它依赖于Windows Vista的特殊行为,那么,她可以在清单文件中加上Windows Vista GUID。SwitchBack解析这些信息,并且将它们与SwitchBack兼容DLL中内嵌的信息(在映像文件的sb_data区段)关联起来,以决定该模块应该调用这一受影响API的哪个版本。因为SwitchBack是在已加载模块的层次上进行工作,所以,它使得一个进程同时有遗留版本的DLL和当前版本的DLL,都调用同样的API,但可以观察到不同的结果。
Windows当前定义了两个GUID,分别代表了Windows Vista或Windows 7的兼容性设置:

  • {e2011457-1546-43c5-a5fe-008deee3d3f0} for Windows Vista
  • {35138b9a-5d96-4fbd-8e2d-a2440225f93a} for Windows 7
    这些GUID必须出现在应用程序清单文件中兼容性属性项的SupportedOS ID之下。(如果应用程序清单文件没有包含任何一个GUID,则选择Windows Vista作为默认的兼容性模式。)在Windows 7环境下运行将会影响下面的组件:
  • RPC组件使用Windows线程池,而不是私有的实现
  • 在主缓冲区上不能获取DirectDraw Lock
  • 如果没有剪裁窗口,不允许对桌面进行位图复制( blitting)操作
  • GetOverlappedResult中的一个竞争条件被修复了

无论何时当一个Windows API被作了修改有可能打破兼容性的时候,该函数的入口代码调用SbSwitchProcedure来调用SwitchBack的代码逻辑。它传递一个指向SwitchBack模块表(SwitchBack Module Table)的指针,此模块表包含了该模块中所使用的SwitchBack机制的信息。该表也包含一个指向数组的指针,数组中每一项有SwitchBack点。该表包含了每个分叉点的一个描述,用符号名称来标识分叉点;也包含一个完整的描述,连同一个关联的方案标记。通常情况下,在一个模块中有两个分叉点,一个针对Windows Vista的行为,另一个针对Windows 7的行为。对于每个分叉点,给出所要求的SwitchBack环境—一此环境决定了在运行时刻两个(或多个)分叉中的哪一个被取用。最后,这些描述符中的每一个都包含了一个函数指针,指向每个分叉应该执行的实际代码。如果应用程序正在Windows 7 GUID环境下运行,这将是其SwitchBack环境的一部分;SbSelectProcedure API在解析了模块表之后,将执行一个匹配操作。它找到针对此环境的模块项描述符,继续调用该描述符中所包含的函数指针。
SwitchBack使用ETW来跟踪到底选择了哪个SwitchBack环境和分叉点,把这些数据送到Windows AIT (Application Impact Telemetry)日志记录器中。Microsoft可以周期性地收集这些数据,以确定每个兼容性项被用到了什么程度,标识出哪些应用程序正在使用这一项(在日志中提供了一个完整的栈痕迹),以及通知第三方软件厂商。
正如前面所提及,应用程序的兼容性级别保存在它的清单文件中。在加载时刻,加载器解析清单文件,创建一个环境数据结构,并且将其缓存在进程环境块的pContextData成员中。(有关PEB的更多信息,请参考第5章。)此环境数据包含了该进程执行时所处的相关联的兼容性GUID,也决定了在那些采用SwitchBack的APr被调用时将会执行哪个版本的分叉点。

API集

虽然可以利用SwitchBack为特定的应用兼容性情形使用API重定向,但还有一个更为普遍适用的重定向机制可以让Windows中的所有应用程序使用,称为API集。它的目的是,允许更细粒度地将Windows的API分类到子DLL中,而不是使用很庞大的多用途DLL,它们几乎散布了数千个API,但这些API又并非对今天或将来的所有Windows系统都需要。这项技术的主要开发目的,是为了支持Windows体系结构的最底层部分的重构工作,从而将它与上面的层次分离开;该技术可以方便地将Kernel32.dll和Advapi32.dll分解成多个虚拟的DLL文件。
例如,下面的图形显示了Windows的一个核心库,Kernel32.dll,从许多个以AP-MS-WIN开头的其他DLL中导入其功能。这些DLL中的每一个都包含了Kernel32正常情况下提供的API的一个小子集,但是它们合起来构成了Kernel32.dll暴露的完整API界面。譬如,CORE-STRING库仅仅提供了基本的Windows字符串函数。

将大量的函数分解到不同的文件中,可以达到两个目的:第一,这么做可以让未来的应用程序只需链接那些真正提供了它们所需功能的API库;第二,如果Microsoft要创建一个特殊的Windows版本,例如,无需支持本地化(即,不面向用户,仅支持English的嵌入式系统),那么,它有可能只要简单地移除掉子DLL,并修改API集的表配置数据。这可以导致得到一个更小的Kernel32库,任何原本不要求本地化功能的应用程序仍然可以正常运行。

基于这项技术,一个称为“MinWin”的“基本”Windows系统也建立起来(在源代码层次上,可以联编出来),它只包含一个最小的服务集合,包括内核、核心驱动程序(包括文件系统,基本系统进程,如CSRSS和服务控制管理器,以及少量的Windows服务)。WindowsEmbedded连同它的Platform Builder,提供了一项看起来很类似的技术,因为系统的联编器(builder)可以移除掉所选中的“Windows组件”,比如外壳程序或者网络栈。然而,从Windows中移除这些组件,会留下悬挂的依赖性(dangling dependency)——代码路径若被调用到的话就会失败,因为它们依赖于那些已被移除的组件。然而,MinWin的依赖性则是完全自包含的。
在这里插入图片描述

当进程管理器初始化的时候,它调用PspInitializeApiSetMap函数,该函数负责创建一个内存区对象(使用标准的内存区对象)用于存放API集重定向表,该表存储在%SystemRoot%System32\ApiSetSchema.dll中。此DLL没有包含可执行的代码,但是有一个称为.apiset的内存区,其中包含了API集的映射数据:将虚拟的API集DLL映射到实现了这些API的逻辑DLL上。无论何时当一个新的进程启动时,进程管理器把这个内存区对象映射到新进程的地址空间中,并且把进程的PEB中的ApiSetMap域设置为指向该内存区对象被映射后的基地址处。
依次地,每当加载一个新的以“API-”名称开头的导入库(无论动态或静态)的时候,加载器的LdrpApplyFileNameRedirection函数也会检查API集重定向数据。该函数通常负责前面提到过的.local和SxS/Fusion清单重定向。API集的表数据是按照库来组织的,每一项描述了函数可以在哪个逻辑DLL中找到,该DLL是否已加载。虽然表数据是二进制格式,但是你可以通过Sysinternals的Strings工具将它的字符串转储出来,可以看到当前定义了哪些DLL:

3.11 超级监督者Hyper-V

在软件工业界,虚拟化(virtualization)是一项被系统管理员、开发人员和测试人员采用的关键技术,它是指在同一台物理机器上同时运行多个操作系统的能力。

虚拟化软件在执行时所在的操作系统称为宿主操作系统(host),而在虚拟化软件内部运行的其他操作系统被称为访客操作系统(guest)

这一模型的使用场景,涵盖了各种可能:允许在不同平台上测试一个应用程序;完全让虚拟服务器真正地在同一台机器上运行,并通过一个中心点来管理这些虚拟服务器。

直到最近,所有的虚拟化都是通过软件本身来完成的,有时候在硬件层次的虚拟化技术的帮助下完成(称为基于宿主的虚拟化, host-based virtualization)。借助于硬件虚拟化的出现,CPU可以完成绝大多数因为跟踪指令和虚拟访问内存而需要的各种通知。

这些通知,连同因为同时运行多个访客操作系统而需要的各种配置步骤,必须要由一部分与CPU的虚拟化支持相兼容的底层基础设施来处理。并非要依赖于宿主操作系统内部运行的一些独立软件来执行这些任务,相反地,通过一个严格使用了硬件虚拟化支持的薄薄底层系统软件也可以完成这些任务,这一系统软件被称为超级监督者(hypervisor)

图3.33显示了这两种系统的简单体系结构图。
在这里插入图片描述
通过Hype-V,Windows Server计算机可以安装超级监督者虚拟化支持而成为一个服务器角色(只要一个支持Hyper-V的版本已经得到授权)。因为超级监督者是操作系统的一部分,所以,管理其内部的访客操作系统,以及与这些操作系统的交互,都是通过标准的管理机制,比如WMI和服务(关于这些话题的更多信息请参见第4章),被完全集成在操作系统中。

最后,除了超级监督者允许一台Windows服务器宿主可以运行和管理其他的访客操作系统以外,Windows的客户和服务器版本都有一些相关的启发式增强〈 enlightenments),这是指内核中的一些特殊优化,也可能是设备驱动程序检测到代码正运行在超级监督者之下的访客操作系统中从而执行某些特定的不同任务,或者充分考虑了这一环境的因素使执行更为有效。后面我们将会看一些这样的改进,现在我们先来看一看Windows虚拟化栈的基本体系结构,如图3.34所示。

在这里插入图片描述

分区

Windows超级监督者背后的一个关键架构组件是分区(partition)的概念。一个分区本质上代表了一个操作系统安装实例,既可以指传统上称为宿主的操作系统(host),也可以指访客操作系统( guest)。

在Windows超级监督者的模型下,这两个术语不再使用了,相反地,我们分别将它们称为父分区(parent partition)子分区(child partition)

因此,最起码,一个Hyper-V系统有一个父分区(推荐的做法是包含一个Windows Server Core的安装实例),以及虚拟化栈和相关联的组件。虽然这是推荐的安装形式,因为它使得补丁数量最小,并且降低了安全表面,但是,为了增加服务器的可用能力,完全的安装实例也是支持的。运行在虚拟化环境内部的每个操作系统代表了一个子分区,它可以包含一些特定的、额外用于优化硬件访问或管理操作系统的工具。

父分区

Windows超级监督者背后的主要设计目标之一是,让它尽可能地小和模块化,就像微内核那样,而不是提供一个完全的单个模块。这意味着,绝大多数虚拟化工作实际上是由一个单独的虚拟化栈来完成的,而且也没有所谓的超级监督者驱动程序。替而代之的是,超级监督者使用已有的Windows驱动程序体系架构,与实际的Windows设备驱动程序打交道。这种体系结构导致了有多个组件来提供和管理相应的功能,这些组件合起来称为超级监督者栈( hypervisor stack)。

逻辑上讲,提供超级监督者是父分区的责任,当然还有整个超级监督者栈。因为这些都是Microsoft的组件,所以,很自然地,只有Windows机器才可以成为父分区。一个父分区应该几乎没什么资源消耗在它自己身上,因为它的角色是运行其他的操作系统。父分区提供的主要组件如图3.35所示。

在这里插入图片描述

父分区操作系统

父分区的Windows安装实例(通常是一个最小痕迹的服务器安装实例,称为WindowsServer Core,以使资源使用量最小化)负责提供超级监督者和该系统上的硬件设备驱动程序(超级监督者将需要访问这些硬件),以及运行超级监督者栈。它也是所有子分区的管理点。

虚拟机管理器服务和辅助进程

虚拟机管理服务(%SystemRoot%lSystem32\Vmms.exe)负责向超级监督者提供WMI( Windows Management Instrumentation)接口,这使得可以通过MMC (Microsoft Management Console)插件来管理子分区。它也负责为那些需要与超级监督者或子分区进行通信的应用程序传送请求。它控制各种设置,比如哪些设备对于子分区是可见的,如何为每个子分区分配内存和处理器,等等。

另一方面,虚拟机辅助进程(VMWP,virtual machine worker process)执行各种通常由单体超级监督者来完成的虚拟化工作(类似于软件虚拟化方案中的工作)。这意味着这样一些事情:为一个给定的子分区管理它的状态机(以便支持诸如快照和状态迁移之类的特性),响应各种来自于超级监督者的通知,为那些需要暴露给子分区的特定设备实现仿真,以及与VM服务和配置组件进行协作。
在一个子分区要执行大量I/O或特权操作的系统上,你可以想象,绝大多数CPU使用量在父分区上可以看得到:你可以通过名称Vmwp.exe找到这些CPU使用量(每个子分区有一个Vmwp.exe)。此辅助进程也包含了负责远程管理虚拟化栈的组件,以及一个RDP组件(允许使用远程桌面客户连接到任何子分区,以及远程查看其用户界面并进行交互)。

虚拟化服务提供者

虚拟化服务提供者(VSP,Virtualization Service Provider)负责实现那些对于子分区可见的设备的高速仿真(关于VSP仿真的设备与用户模式进程仿真的设备之间的确切差别,将在后面解释),与VM服务和进程不同的是,VSP也可以作为驱动程序,运行在内核模式下。有关VSP的更多细节,将在后面描述虚拟化栈的设备体系结构的章节中进一步讲述。

VM基础设施驱动程序和超级监督者API库

因为超级监督者无法被用户模式应用程序(比如负责管理任务的VM服务)直接访问,所以,虚拟化栈必须实际地跟一个内核模式的驱动程序打交道,由该驱动程序负责将请求转发给超级监督者。

这正是VM基础设施驱动程序(VID,VM infrastructure driver)的职责。VID也提供了对特定的低内存(low-memory)内存设备的支持,比如MMIO和ROM仿真。

一个位于内核模式下的库提供了与超级监督者之间的实际接口(称为超级调用, hypercall)。消息也可以来自于子分区(它们执行其自己的超级调用),因为整个系统只有一个超级监督者,并且它可以监听来自于任何一个分区的消息。你可以在Winhv.sys设备驱动程序中找到这一功能。

超级监督者

在体系结构的底部是超级监督者自身,它在系统引导时候向处理器注册其自身,它提供的服务可供整个虚拟化栈使用(通过超级调用接口)。这一早期的初始化是由hvboot.sys驱动程序来完成的,该驱动程序被配置成在系统引导过程的早期启动起来。因为Intel和AMD处理器在硬件辅助虚拟化的实现方面有细微的差异,所以,实际上有两个不同的超级监督者——在引导时刻利用CPUID指令询问处理器,以便选择正确的超级监督者。在Intel系统上, Hvix64.exe二进制文件被加载到系统中;而在AMD系统上,真正被使用的是Hvax64.exe映像。

子分区

正如前面所讨论的,子分区是任何一个与父分区并行运行的操作系统的实例。(因为你可以保存或暂停任何子分区的状态,所以,它可以不必正在运行,但是会有一个专门针对它的辅助进程)。父分区对APIC、IO端口和物理内存有完全的访问权,与此不同的是,子分区出于安全和管理的原因,仅限于访问它们自己的地址空间的视图(称为访客虚拟地址空间,GVA,Guest Virtual Address Space,是由超级监督者管理的),它们不能直接访问硬件。即使对于超级监督者访问,它也主要被限定在通知和状态变化。例如,一个子分区不能控制其他的分区(也不能创建新的分区)。

相比父分区,子分区有少得多的虚拟化组件,因为它们并不负责运行虚拟化栈——只是负责与虚拟化栈进行通信。而且,这些组件也可以认为是可选的,因为它们只是增强了环境的性能,而并非具有关键性的作用。图3.36显示了在一个典型的Windows子分区中出现的组件。
在这里插入图片描述·

虚拟化服务客户

虚拟化服务客户(VSC,Virtualization service client)是VSP在子分区中的对等概念。如同VSP一样,VSC被用于设备仿真,后面还会讨论设备仿真这一话题。

启发式增强(Enlightenment)

启发式增强是Windows虚拟化所采用的一种关键的性能优化手段。它们是对标准的Windows内核代码的直接修改,通过这些修改可以检测到当前操作系统正运行在一个子分区中,从而可以以不同的方式进行工作。通常,这些优化是与硬件高度相关的,它们会导致一次超级调用,以便通知超级监督者。一个例子是,通知超级监督者有一个长的忙等自旋循环。在这种情况下,超级监督者可以让有些状态保持在过时状态,而不是在每一个循环指令都保持最新的状态。进入和退出一个中断状态也可以与超级监督者进行协调,同样地,访问APIC也可以这样,这样的增强可以避免总是捕捉实际的访问然后再对其进行虚拟化。
另一个例子与内存管理有关,特别是TLB刷新和改变地址空间。(有关这些概念的更多信息,请参考本书下册第9章)。通常,操作系统将执行一条CPU指令来刷新这一信息,这会影响整个处理器。然而,因为一个子分区可以与多个其他的子分区共享一个CPU,所以,这样的操作也会为其他这些操作系统刷新这一信息,从而导致可以感知得到的性能退化。如果Windows正运行在一个超级监督者之下,那么,它可以发出一个超级调用,让超级监督者只刷新属于该子分区的特定信息。

硬件仿真和支持

作为一个虚拟化方案,必须也要提供对设备的优化访问。不幸的是,绝大多数设备并没有被设计成可以接受多个来自不同操作系统的请求。超级监督者通过尽可能地提供同一层次的同步机制,以及当实际访问硬件不允许的时候仿真特定设备的做法,来切入这一问题。除了设备以外,内存和处理器也必须被虚拟化。表3.26描述了超级监督者必须要管理的三种硬件。
超级监督者并非将实际的硬件暴露给子分区,而是向其暴露虚拟的设备(称为VDev)。VDev被包装成一些运行在VM辅助进程内的COM组件,它们是位于设备背后的、可以中心化管理的对象。(通常,VDev会暴露一个WMI接口。)Windows虚拟化栈提供了两种虚拟设备的支持:仿真设备(emulated device)和合成设备(synthetic device,也称为enlightened 1/O)。前一种设备为子分区操作系统期望找到的各种设备提供了支持,而后一种设备则要求访客操作系统提供相应的特殊支持。此外,合成设备则通过降低CPU额外负载而获得显著的性能优势。

在这里插入图片描述

仿真设备

仿真设备的工作方式是,向子分区展示一组IO端口、内存范围和中断,并且超级监督者可以控制和监视这些端口、内存范围和中断。当检测到对这些资源的访问时,VM辅助进程最终会通过虚拟化栈得到通知(如前面图3.34所示)。然后该进程会仿真什么样的动作在设备上发生,并完成相应的请求,再通过超级监督者往回走,然后到达子分区。仅从这一拓扑视图就可以看出,即使不考虑硬件设备的软件仿真往往很慢这一因素,其间也必有性能损失。
之所以需要仿真设备,源于这样的事实:超级监督者需要支持那些无法感知超级监督者存在的操作系统,以及即便是Windows自身,它的早期安装步骤也需要得到支持。在引导过程中,安装程序不能简单地将子分区需要的所有组件(比如VSC)都加载进来以便使用合成设备,所以,Windows的安装过程总是使用仿真的设备(这也是为什么安装过程显得非常慢,但一旦安装之后,操作系统的运行将会接近于原生系统的速度)。仿真的设备也用于那些并不要求高速仿真的硬件,以及有可能软件仿真更快速的硬件。这包含诸如COM(串行)端口、并行端口或主板本身之类的硬件项目。

注:Hyper-v提供了对Intel i440BX主板、S3 Trio视频卡,以及Intel 21140 NIC的仿真。

合成的设备

尽管对于10Mb的网络连接、低分辨率的VGA显示设备,以及16位的声卡来说,仿真设备已经可以工作得很好了,但是在如今的使用场景下,子分区通常要支持的操作系统和硬件往往要求更高的处理能力,比如支持1000Mb的GbE连接、全彩色高分辨率3D画质,以及高速访问存储设备。

为了在一定的可接受的CPU使用率和虚拟化吞吐量的程度上支持这种虚拟化的硬件访问,虚拟化栈使用了多种组件来优化设备的IO,以达到它们的最佳功能(类似于内核的启发式增强,kernel enlightenment)。这其中包括三个组件,它们都属于对用户来说称为集成组件(IC,integration component)的组件。它们是:

  • 虚拟化服务提供者
  • 虚拟化服务客户/消费者
  • VMBus

图3.37显示的框图展示了一个启发式增强的,或合成的存储IO是如何被虚拟化栈处理的。
在这里插入图片描述
如图3.37所示,VSP运行在父分区中,它们在父分区中与一个特定的设备关联起来,设备的启发式增强(enlightening)由相应的VSP负责。(在提到合成的设备[synthetic device]的时候,我们将启发式增强[enlightening]当作一个术语来使用,而不使用仿真[emulating])。VSC驻留在子分区中,也跟一个特定的设备关联起来。然后,请注意,术语提供者(provider)可以指散布在设备栈中的多种组件。

例如,一个VSP可以是下面列举的组件之一:

  • 用户模式服务
  • 用户模式COM组件内核模式驱动程序

在所有这三种情形下,VSP都可以与VM辅助进程中的实际虚拟设备关联起来。而另一方面,VSC则总是被设计成位于设备栈最底层的驱动程序(关于设备栈的更多信息,请参考本书下册第8章),它们截取一个设备的I/O,将这些I/O重定向到一个更优化的路径上。按这种模型执行的主要优化是,避免实际的硬件访问,改而使用VMBus。在这种模型下,超级监督者是不知道这一IO的,VSP将I/O直接定向到父分区的内核存储栈,从而避免了与用户模式的来回通信。其他的VSP可能直接在设备上进行工作,它们与实际的硬件打交道,绕过了父分区上可能已经加载的任何驱动程序。另一种选择是用户模式VSP,当处理低带宽设备的时候,用户模式VSP是有意义的。

正如前面所述,VMBus是用于优化设备访问的总线传输器的名称,其优化的做法是,利用超级监督者的服务来实现一个通信协议。VMBus是一个在父分区和子分区中都存在的总线型驱动程序,负责对子分区中的合成设备进行即插即用列举。它同时也包含了一个优化的跨分区消息传递协议,该消息传递协议采用了一个对于数据大小较为恰当的传输器方法。在这些传输器方法中,其中有一个方法提供了一个在每个分区之间共享的环形缓冲区——本质上,这是一块内存区域,在一端加载特定数量的数据,在另一端卸载相应的数据。在此过程中不需要分配或释放内存,因为该缓冲区是被连续复用的,只是简单地“旋转”而已。最终,该缓冲区可能会填满了各种请求,这意味着新的IO将会覆盖掉老的I/O。在这种不常见的情形下,VMBus只是简单地将新的请求延迟到老的I/O请求完成为止。另一个消息传输器方法是,直接将子内存映射到父地址空间中,以便于大数据量的传输。

虚拟处理器

因为超级监督者不允许直接访问硬件(或内存),稍后我们将会看到,子分区并不会真正看到机器上的实际处理器,但是它们会有一个虚拟化的CPU视图。在根机器上,管理员和操作系统负责处理逻辑处理器(logical processor),这是指线程可直接跑于其上的实际处理器(比如,一个双4核的机器有8个逻辑处理器),同时他们负责将这些逻辑处理器分配给各个子分区。例如,一个子分区可以被安排到逻辑处理器1、2、3和4上运行,而第二个子分区可能被安排到处理器5、6、7和8上运行。通过使用虚拟处理器(VP,virtual processor),这些操作都是有可能的。

因为处理器可以被多个子分区共享,所以,超级监督者包含它自己的调度器,负责在每个处理器上分配各个分区的负载。更进一步,超级监督者也维护了每个虚拟处理器的寄存器状态,当同样的逻辑处理器要被另一个子分区使用的时候,它负责执行一次正确的“处理器切换”。父分区有能力访问所有这些环境信息,并且必要时修改环境信息,这是虚拟化栈中的一个关键部分,它必须响应特定的指令,并执行相应的动作。

超级监督者也直接负责虚拟化处理器的APIC,以及提供一个更简单的、功能少一些的虚拟APIC,其中支持在绝大多数APIC上都可以找到的定时器功能(然而,速率慢一些)。因为并不是所有的操作系统都支持APIC,所以,超级监督者也允许通过一个超级调用来注入中断,这就使得虚拟化栈可以仿真一个标准的i8059 PIC。

最后,因为Windows支持处理器的动态加入,所以,管理员有可能在运行时刻给一个子分区添加新的处理器,因而在负载很重的情况下,可以增强一个访客操作系统的响应能力。

内存虚拟化

必须从子分区中抽象出来的最后一部分硬件是内存,这不仅是为了访客操作系统的正常表现行为,也是为了安全性和稳定性。不正确地管理子分区对内存的访问,有可能导致隐私暴露和数据破坏,以及可能的恶意攻击(比如,通过“脱离”子分区和攻击父分区的做法,再进一步攻击其他的子分区)。除了这一层因素以外,也需要管理访客操作系统所看到的物理地址空间的视图。几乎所有的操作系统都期望内存是从地址0开始的,并且在某种程度上是连续的,所以,即使系统中有足够多的内存,简单地把物理内存块分配给每个子分区并不能工作。

为了解决这个问题,超级监督者实现了一种称为访客物理地址空间(GPA空间,guestphysical address space)的地址空间。GPA从地址0开始,这满足了子分区内部的操作系统的需要。然而,由于第二个问题(缺少连续内存)的原因,GPA并不是简单地映射到一大块物理内存上。相反地,GPA可以指向机器的物理内存(这称为系统物理地址空间,system physicaladdress space,或SPA空间)中的任何位置,因此,从一个地址类型到另一个地址类型,必须要存在一个转译系统。此转译系统是由超级监督者来维护的,几乎等同于在x86和x64处理器上虚拟内存被映射至物理内存的做法。(关于内存管理器和地址转译的更多信息,请参见本书下册第10章。)

对于子分区内的实际虚拟地址(称为访客虚拟地址空间,guest virtual address space,或GVA空间),它们仍然是由操作系统来管理,无需任何行为上的变化。操作系统相信的是,在它的页表中的物理地址是实际的SPA。图3.38概略地显示了每一层映射。
在这里插入图片描述
这意味着,当一个访客操作系统引导起来,创建了页表将虚拟内存映射为物理内存的时候,超级监督者截取了SPA,并维护了它自己的页表拷贝。从概念上讲,无论何时当访客操作系统有一段代码要访问一个虚拟地址的时候,超级监督者首先执行初始的页表转译,从访客虚拟地址转译为GPA,然后将GPA映射到相应的SPA。而实际上,这一操作已经利用影子页表( shadow page table,SPT)而进行了优化,这些影子页表是由超级监督者维护的,用于直接从GVA到SPA进行转译,在适当的时候被加载进来,因而访客可以直接访问SPA。

二级地址转译和带标记的TLB

因为从GVA到GPA再到SPA比较昂贵(因为必须由软件来完成),所以,CPU制造商已经做了工作来缓解这部分低效的处理过程,其做法是,让处理器自身能感知到虚拟机的地址转译需求―—换句话说,一个高级的处理器可以理解其内存访问发生在一个访客虚拟机中,并且不依赖于超级监督者的帮助就可以执行GVA-SPA地址查找。这一查找技术被称为二级地址转译(SLAT, Second-Level Address Translation),因为它既包括从目标到宿主的转译(第二级),也包括从宿主VA到宿主PA的转译(第一级)。然而,出于市场的意图,Intel将这一支持称为VT扩展/嵌套的页表(NPT,Nested Page 'Table)技术,而AMD将其称为AMD-V快速虚拟化索引(AMD-V Rapid Virtualization Indexing,RVD

Hyper-V栈的最新版本充分利用了处理器的这一支持技术,大大降低了其代码的复杂程度,并且使得子分区中因处理页面错误而需要的环境切换数量降低到最小。而且,SLAT也使得Hyper-V能够扔掉它的影子页表和相关的映射关系,这本身也额外地降低了内存开销。这些变化增加了Hyper-V在这些系统上的伸缩能力,显著地增加了单个宿主(Hyper-V服务器)可以服务(或者可以同时运行的)的虚拟机的最大数量。根据Microsoft所做的测试,SLAT的支持使得所支持的会话最大数量提高了16至25倍。更进一步,处理器的额外开销从10%降低到2%,每个虚拟机消耗的宿主物理RAM少于1兆字节。

而且,Intel和AMD都引入了一种通常只在RISC处理器(比如ARM、MIPS或PPC)上才有的功能,即处理器能够区分与TLB(地址转译快查缓冲区)中每个缓存的虚拟-物理转译项相关联的进程。在像x86和x64这样的CISC处理器上,TLB被内置成一个全系统范围的资源―一每次当操作系统切换当前正在执行的进程时,TLB必须被刷新,以便让所有可能属于前一个执行进程的缓存项无效。反之,如果处理器可以被告知当前进程已经改变了,那么,TLB可以避免一次刷新,处理器只需简单地不用这些与当前进程没有对应关系的缓存项即可。新的缓存项将会创建起来,最终会覆盖其他进程的老项。这种类型的智能TLB被称为带标记的TLB( tagged TLB),因为每个缓存项都有一个属于特定进程的标识符作为标记。

对于Hyper-V系统,刷新TLB尤其糟糕,因为不同的进程可能实际上对应于一个完全不同的VM。换句话说,每次当超级监督者和操作系统调度另一个VM来执行时,宿主的TLB必须被刷新,把前一个VM已经执行过的所有缓存起来的转译项都刷掉了,从而显著减慢内存访问,导致明显的延迟。当在一个实现了带标记的TLB的处理器上运行时,Hyper-V可以简单地通知处理器,有一个新的进程/VM正在运行,其他VM的缓存项不应该使用了。有RVI支持的AMD处理器通过一个地址空间标识符(ADIS,Address Space ldentifier)来支持带标记的TLB,而最新的Intel Nehalem-EX处理器使用虚拟处理器标识符(VPID,Virtual Processor Ildentifier)来实现带标记的TLB。

动态内存

一个称为动态内存的特性使得系统管理员可以让一个虚拟机的物理内存分配根据活动的虚拟机的内存需求来动态地变化,其做法如同Windows内存管理器根据各个进程的内存需求来调整每个进程被分配到的物理内存。这种能力意味着,管理员不必为了优化性能而精确地测量虚拟机的内存需求,并且系统的物理内存可以被真正需要内存的虚拟机更加有效地使用。
动态内存的体系结构包括几个组件,如图3.39所示。
在这里插入图片描述
此体系结构的基本组件如下所述:

  • 动态内存平衡器,是在虚拟机管理服务中实现的。此平衡器负责为子分区分配物理
    内存。
  • 动态内存VSP (DM VSP),它运行在已经启用了动态内存的子分区的VMWP中。
  • 动态内存VSC (DM VSC,%SystemRoot%lSystem32\Drivers\Dmvsc.sys),被安装为一个启发式增强驱动程序,在子分区中运行。

要想配置VM的动态内存,管理员可以在VM的内存设置中选择Dynamic,如图3.40所示。
在这里插入图片描述
相关的设置包括,当启动时候分配给该VM的内存数量、最大可为它分配的数量(MaximumRAM)、如果操作系统的内存需求增加了它立即可以使用的VM内存占到多少百分比,最后,这个VM相对于其他VM的权重。超级监督者除了要权衡那些启用了动态内存的虚拟机之间的物理内存分布以外,也使用该权重作为系统引导时候这些虚拟机的启动顺序的一个指导。最后,可用内存百分比是指,在VM内部VM操作系统尚未分配给进程、设备驱动程序或其自身的内存的一个参考,这些内存可立即分配而不会招致页面错误。关于可用内存,本书下册第10章有更为详细的描述。

当一个在内存配置中已经启用了动态内存的子分区中的DM VSC启动的时候,它首先进行检查,看操作系统是否支持动态内存的能力。它执行这一检查的做法很简单,只是调用内存管理器中热加入(hot-add)的内存函数,指定一块已经分配给该虚拟机的子物理内存块。如果内存管理器支持热加入(hot-add),它返回一个错误,指明此地址范围已经在使用了;如果它不支持,则会报告该函数不支持。如果支持动态内存,则DM VSC通过VMBus建立起与DMVSP之间的连接。因为系统在引导过程中内存使用情况波动很大,所以,在所有自启动的Windows服务已经完成初始化以后,VSC才开始报告内存统计情况,一秒钟一次,指示出虚拟机中的当前系统内存提交级别(system commit level)。有关系统内存提交的更多信息,参见本书下册第10章。
父分区中的DM VSP根据VM的内存报告,利用下面的公式,为它对应的VM计算一个内存压力值:

内存压力=已提交的内存/物理内存

物理内存指的是当前分配给此VM分区的内存总数。它也维护了一个正在运行的指数平均压力,代表了此前20秒的压力报告,并且只有当当前压力偏离平均值至少一个标准偏差的时候才调整平均压力。

一个称为平衡器的组件在VMMS服务中执行。它每秒钟一次分析由DM VSP报告的内存压力,并考虑到VM策略配置,来决定是否应该要重新分配内存,以及重新分配多少内存。如果一个称为NUMA散布(NUMA spanning)的全局Hyper-V设置被启用的话,平衡器使用两个平衡引擎:一个引擎是全局平衡器,它负责将新的VM分配到NUMA节点上。它在分配的时候根据内存使用量和VM压力来做到这一点。每个NUMA节点有它自己的本地平衡器,管理所有分配给它的VM之间的节点内存分配情况。如果NUMA散布选项没启用,则全局平衡器仅仅只是调用系统的本地平衡器,没有其他的功能角色。
将VM分配到NUMA节点,带来的好处是,VM将保证有尽可能最快的内存访问。然而,也需要做出妥协,在特定情况下,虽然未分配的内存是足够的,但是没有一个节点有足够的内存来满足所请求的内存数量,那么,可能无法启动一个VM,或者为它增加内存。

一个本地平衡器增加或降低全局的目标内存压力,以便使用所有在它管理下的可用内存,或者直至达到最小的压力级别(表明所有的VM都有充足的内存)才使用它。然后,平衡器在VM上进行循环,确定从每个VM中增加或移除多少内存可以达到目标压力。在计算过程中,平衡器为宿主系统保留一个最小的内存数量。宿主系统保留的内存数量是这样来计算的,大约400MB为一个基数,再每1GB RAM增加30MB。可能会影响保留内存数量的因素有:系统是否使用SLAT或软件换页,多媒体重定向是否已启用。每隔5分钟,平衡器也要从那些有了足够多内存以至于其压力值基本为0的VM中移除内存。

注意,如果子分区的操作系统正在运行32位版本的Windows,那么,动态内存引擎将不会为该分区分配超过4GB内存。
一旦已经计算出要从VM中增加或移除的内存数量,它就请每个WP执行所期望的操作。如果此操作是移除内存,那么WP通过VMBus向子DM VSC发送信号,告诉它要移除内存的数量,DM VSC通过MmAllocatePagesForMdlEx函数从系统申请物理内存,以此来膨胀它的内存使用量。它获得所分配的GPA,并送回给WP,WP又把这些GPA传递给Hyper-V内存管理器。Hyper-V内存管理器然后又把GPA转换成SPA,并且将这些内存加入到它的空闲内存池中。

如果这是一个内存增加操作,那么WP首先问Hyper-V内存管理器,该VM是否有已经赋予了它但当前已被VSC的膨胀操作(VSC’s balloon)而分配掉的任何物理内存。如果有的话,则WP获取应该被解除膨胀(unballooned)数量的GPA,请VSC释放这些页面,使它们又可用于VM的操作系统。如果因解除膨胀而释放的内存数量少于平衡器想要交给此VM的物理内存数量,那么,它通过Windows对热增加(hot-add)内存的支持,请Hyper-V内存管理器从它的空闲内存池中把剩余数量的内存交给此子分区,并且把它增加的GPA报告给WP,WP又会依次转发给子分区的DM VSC.

中断

我们已经讨论了超级监督者对硬件、处理器和内存访问进行虚拟化的各种方法,有时候这些过程需要转交给一个VM辅助进程,但是,我们尚未讨论到使这些虚拟化赖以实现的一种机制―—中断。中断是可配置的“钩子(hook)”,父分区可以安装和配置来响应中断。这可以包括以下的一些项目:

  • IO中断,用于设备仿真。
  • MSR中断,用于APIC仿真和性能剖析。
  • 访问GPA,有助于设备仿真、监视和性能剖析(而且,这样的中断可以针对一个特定的内存访问进行微调,比如读、写或执行)。
  • 异常中断,比如页面错误,有助于维护机器的状态和内存仿真(比如,维护写时复制(copy-on-write))。

一旦超级监督者检测到一个已经注册了相应中断的事件,它通过虚拟化栈发送一个中断消息,将虚拟处理器(VP)置于一种挂起的状态。然后,虚拟化栈(往往是辅助进程)必须处理该事件,并且恢复VP(通常一个寄存器状态已被修改,反映出为处理该中断而执行了相应的工作。)

热迁移

为了支持诸如有计划的硬件升级和跨服务器的资源负载平衡之类的场景,Hyper-V也支持在一个Windows Failover Cluster的节点之间以最小的宕机时间来迁移虚拟机。热迁移效率的关键之处在于,当虚拟机继续在源节点上运行的时候有大量的虚拟机内存要从源节点传输到目标节点上;只有当内存传输完成以后,该虚拟机才挂起,并且在目标节点上恢复运行。当最终的虚拟机状态迁移的时候,这一小窗口通常小于默认的TCP超时值,因而可以为那些使用虚拟机服务的客户保留住它们的已打开连接,使得整个迁移过程从它们的角度来看是透明的。图3.41显示了热迁移过程。

在这里插入图片描述
热迁移过程按照几个步骤来进行,如图3.41所示:

  1. 设置迁移虚拟机宿主(源)节点的VMMS打开一个TCP连接,连接至目标主机。它
    将虚拟机的配置信息传输至目标节点,包括诸如处理器个数、RAM数量之类的虚拟硬件规格参数。目标节点上的VMMS构造一个符合此配置但已停住的虚拟机。源节点的VMMS通知虚拟机的辅助进程,此热迁移已经可以进行了,并且将TCP连接交给它。同样地,目标VMMS把它的TCP连接端点交给目标辅助进程。

  2. 内存传输内存传输阶段包括以下几个子阶段:
    (1) 源VMWP创建一个位图,其中每一位代表了虚拟机的访客物理内存中的每一个
    页面。它设置好每一位,以反映出该页面是脏的,即该页面的当前内容尚未被发送至目标节点。
    (2) 源VMWP向超级监督者注册一个内存变化通知回调,该回调函数会为虚拟机中
    每一个发生变化的页面在位图中设置相应的位。
    (3)源VMWP继续处理,按照16-KB内存块来遍历脏页面位图,为内存块中的页面清
    除掉在脏页面位图中的脏位,通过超级监督者来读取每一个脏页面的内容,再将这些内容发送至目标节点。目标VMWP调用超级监督者,将内存的内容插入到目标虚拟机的访客物理内存中。
    (4)当遍历完成了脏页面位图以后,源VMWP检查一下看是否在迭代过程中有页面被弄脏了。如果没有,则转移到迁移过程中的下一个阶段,但如果有页面被弄脏,那么,它重复上述迭代过程。如果已经迭代了5次,说明虚拟机当前弄脏内存的速度比辅助进程发送内存变化信息的速度还要快,因此它也继续向前,转到迁移过程的下一个阶段。

  3. 状态传输源VMWP挂起虚拟机,对脏页面位图作最后一遍迭代,把上次遍历以来的
    所有弄脏的页面都发送过去。因为在传输过程中虚拟机被挂起了,所以,不会再有其他页面被弄脏。然后,源辅助进程发送虚拟机的状态,包括虚拟处理器的寄存器内容。最后,它通知VMMS,迁移过程已经完成,等待确认,然后向目标发送一个消息,把虚拟机的所有权传递过去。作为最后的迁移步骤,目标辅助进程将虚拟机转移到运行状态。

  4. 热迁移的另一个方面是,虚拟机中的文件的所有权转移,包括它的VHD。传统的Windows Clustering是一个“什么也不共享(shared-nothing)”的模型,集群存储系统的每一个LUN一次只归属于一个节点。LUN所属节点对LUN和其中存储的文件有专属的访问权。这种模型带来了管理上的复杂性,因为每个虚拟机必须被存储在一个独立的LUN上,因此也一定在一个单独的卷上,这就使得,若一个集群中宿纳了许多虚拟机,则这些卷就要暴露出来。对于热迁移,这也带来了一个更加严重的挑战,
    因为LUN所有权的迁移是一个很昂贵的操作,包括:源节点将所有修改的文件数据刷新到LUN上,源节点把在LUN上格式化的卷都要解除挂载(unmounting),把所有权从源节点转移到目标节点,再在目标节点上挂载相应的卷。根据LUN上卷的数量以及要被写回的脏数据的数量的不同,这整个序列可以要花几十秒钟,从而使得热迁移无法满足“感觉上近乎瞬间完成迁移”的目标。

  5. 为了解决传统集群模型的限制,使得热迁移真正成为可能,热迁移采用了一种称为
    集群共享卷(CSV,Clustered Shared Volume)的存储特性。通过CSV,一个节点拥有LUN上卷的名字空间,而其他的节点可以独占拥有单独的文件。这种独占所有权使得存放虚拟机的节点可以直接访问VHD文件的磁盘存储,从而绕过了为与其他节点所属LUN打交道而要求的网络文件系统访问。只有当一个节点想要创建或删除文件、改变文件的大小(比如,扩展一个动态的或差分式VHD的大小)、或者改变其他文件元数据(比如时间戳)的时候,它才需要通过SMB2协议向所属节点发送一个请求(如果它不是文件的所有者的话)。

  6. CSV的混合共享模型使得LUN所有权在热迁移过程中保持不变,仅仅使要迁移的虚
    拟机文件的所属权发生了变化,这样就避免了解除挂载和重新挂载的操作。而且,只有虚拟机文件的脏数据必须要在迁移之前写入,这一动作通常可以与内存迁移同时进行。图3.42显示了在一次热迁移过程中存储所有权的变化。本书下册第12章“文件系统”的“文件系统过滤型驱动程序”一节讲述了CSV的实现。
    在这里插入图片描述

3.12 内核事务管理器

软件开发的一项琐碎任务是处理各种错误条件。在有些情况下尤是如此,譬如在执行一个高层的操作过程中,应用程序已经完成了一个或多个导致文件系统或注册表发生变化的子任务。例如,一个应用程序的软件更新服务可能要进行多次注册表更新动作,再替换该应用程序的一个可执行文件,然后当它试图更新第二个可执行文件的时候被拒绝访问。如果该软件更新服务不想让这个应用程序留在最后导致的不一致状态,那么,它必须跟踪所有已经做过的改变,并且准备好撤销这些改变。测试错误恢复代码是很难的,因而常常被跳过去,所以,在恢复代码中的错误可能使得其功能根本不起作用。

应用程序可以通过使用一种称为内核事务管理器(KMT,Kernel Transaction Manager)的内核机制,以非常小的编码工作,获得自动的错误-恢复功能。内核事务管理器提供了执行此种事务所需要的设施,允许诸如用户模式下的分布式事务协调器(DTC,distributed transactioncoordinator)之类的服务充分利用这些设施。开发人员通过使用适当的API也可以利用这些服务。
KMT除了解决了如上面提到的这种大规模问题,还做了更多的事情。即使在单用户的家庭计算机上,安装一个服务补丁,或者执行一次系统恢复,都涉及文件和注册表键的大量操作。在这样的操作过程中拔掉老的Windows计算机的电源,再要成功引导系统的机会变得很渺茫。即使NT文件系统(NTFS)总是有一个日志文件,使得该文件系统总是可以保证原子操作(有关NTFS的更多信息,请参见本书下册第12章),这也只是意味着,在此过程中无论哪个文件正在被写入,它要么被全部写入,要么完全被删除―一它并不保证整个更新操作或者恢复操作。类似地,注册表在过去几年间已经有了大量的改进来处理数据被破坏的情形(有关注册表的更多信息,请参见第4章),但是这些修补也只适用于键/值这个层次。

作为事务支持的核心,KTM允许事务性的资源管理器,比如NTFS和注册表,可以为一个应用程序执行的一组修改操作协调它们的更新动作。NTFS使用一个扩展模块来支持事务,称为TxF。注册表使用一个类似的扩展,称为TxR。这些内核模式资源管理器与KTM一起工作,来协调事务的状态,就如同用户模式资源管理器使用DTC来协调跨越多个用户模式资源管理器的事务的状态。第三方也可以使用KTM来实现他们自己的资源管理器。

TxF和TxR都定义了一组新的文件系统API和注册表API,这些API与现有的API类似,只不过它们包含了一个事务参数。如果一个应用程序想要在一个事务内创建一个文件,它首先要用KTM来创建该事务,然后把结果得到的事务句柄传递给新的文件创建API。虽然后面我们还会看一看KTM的注册表和NTFS实现,但是,它们并非唯一可能的用途。实际上,KTM提供了四个系统对象,通过这四个系统对象可支持各种不同的操作。表3.27列出了这些对象。
在这里插入图片描述

3.13 热补丁支持

重新引导一台机器以便把最新的补丁都应用上,这对于服务器来说可能会意味着一段显著的宕机时间,这也正是为什么Windows要支持运行时刻的补丁方法,称为热补丁(hot patch,或简化为hotpatch)。与之相反的是冷补丁(cold patch),它要求一次重新引导。热补丁并非只是简单地允许文件可在执行过程中被覆盖;相反地,它包括了一系列可能会被请求执行的复杂操作(这些操作也可能会组合起来)。表3.28列出了这些操作。
在这里插入图片描述
尽管热补丁利用了内核的内部机制,但是,它们的实际实现与冷补丁并没有什么不同。补丁本身是通过Windows的更新机制(Windows Update)来提交的,通常是一个可执行文件,其中包含了一个称为Update.exe的程序,此Update.exe程序完成该补丁的提取工作,也执行相应的更新过程。然而,对于热补丁,还存在一个额外的热补丁文件,其扩展名为.hp。此文件包含一个特殊的PE头,称为.HOT1。该PE头包含了一个数据结构,描述了该文件中出现的各种补丁描述符(patch descriptor)。每一个补丁描述符指明了在原始文件中需要被补丁的偏移位置、一个验证机制(它可以包含一份老数据的简单比较、一个校验和,或者一个散列值),以及待补丁的新数据。内核将解析这些描述符,并且执行恰当的修改动作。若是被保护的进程( protected process,有关进程的更多信息,参见第5章),或者其他经过数字签名的二进制映像,在这些情况下,热补丁也必须被经过数字签名,以避免针对敏感文件或进程执行“伪造的”补丁。

注: 因为热补丁文件也包含了原始的数据,所以,热补丁机制也可以被用于在运行时刻卸载(uninstall)一个补丁。

在编译时刻对热补丁的支持,是通过在每个函数的开始处加入7个额外的字节来做到的。其中5个字节被看作前一个函数的结束部分,2个字节被看作函数前缀(function prolog)部分,也就是函数的开始。这里是一个在编译时候支持热补丁信息的函数的例子:
在这里插入图片描述
注意,5个nop指令并不做任何实际的事情,而NtCreateFile函数开始处的mov edi, edi也并无实质性的意义―—起码,没有发生实际的状态改变操作。因为有这7个字节可以使用,NtCreateFile前缀部分可以被变换成一个短跳转(short jump),跳至那5个指令构成的缓冲区,而这5个指令的缓冲区又被转换成一个近跳转(near jump)指令,跳至补丁后的例程。下面是被热补丁之后的NtCreateFile:
在这里插入图片描述
这种方法使得每个函数只增加2个字节,通过这两个字节跳转到前一个函数的对齐填充部分,这些填充字节极有可能本就在函数末尾存在的。

下面是热补丁功能的一些限制:

  • 像安全软件之类的第三方应用程序可能会阻止这样的补丁;补丁也可能与第三方应
    用程序的操作不兼容。
  • 补丁要修改一个文件的导出表或导入表。
  • 补丁要改变数据结构,修正无限循环,或者包含内联的汇编代码。

3.14 内核补丁保护

有些32位设备驱动程序以一些未被正式支持的方式来修改Windows的行为。

例如,它们通过修改系统调用表来截取系统调用,或者给内存中的内核映像打上补丁,以便为某些特定的内部函数添加功能。Microsoft在发布了针对x64的64位Windows之后不久,同时在一个丰富的第三方生态系统建立起来以前,看到了一个很好的机会来保持64位Windows的稳定性。为了防止这些类型的改变,x64 Windows实现了内核补丁保护(KPP,Kernel Patch Protection),也称作PatchGuard。KPP在系统中的任务就如同其名称所指明的—一它试图阻止那些常用于修补系统或钩住系统的技术。

表3.29列出了哪些组件或数据结构被保护了,以及保护的目的。
在这里插入图片描述

注: 由于有些特定的Intel 64位处理器在实现上略微不同于x64体系结构的功能集,所以,内核
需要执行运行时刻的代码补丁,以解决缺失prefetch指令的问题。即使在这些处理器上,KPP也可以阻止内核补丁,它的做法是,将这些特定的补丁免除检查。而且,因为超级监督者(Hyper-V)的启发式增强(有关超级监督者的更多信息,参见本章前面部分),内核中的某些特定函数是在引导时刻被修补的,比如交换环境(swap context)的例程。这些补丁也通过显式检查的方式得以允许通过,确保它们对于超级监督者启发式增强的内核版本是已知的补丁。

当KPP检测到以上提到的数据结构中的任何变化(以及某些其他的内部一致性检查中的变化)时,它会让系统崩溃,代码为0x109——CRITICAL_STRUCTURE_CORRUPTION。

对于用到了KPP阻止的技术的第三方开发人员来说,下面一些支持的技术可供使用:

  • 文件系统小过滤驱动程序(有关这一话题的更多信息,参见本书下册第8章),以钩住所有的文件操作,包括加载映像文件和DLL。截取了这一操作之后,可以动态地清除恶意代码,或者阻止读取已知的坏可执行文件。
  • 注册表过滤通知(有关这些通知的更多信息,参见第4章),以钩住所有的注册表操作。安全软件可以阻止对注册表关键部分的修改操作,以及根据注册表的访问模式或已知的坏注册表键来启发式地判定恶意软件。
  • 进程通知(有关这些通知的更多信息,参见第5章)。安全软件可以监视系统中所有进程和线程的执行和终止,以及DLL的加载和卸载。利用新引入的专门针对防病毒或其他安全厂商的增强通知,它们还可以有能力阻止进程的启动。
  • 对象管理器过滤(在本章前面的对象管理器章节中解释)。安全软件可以移除某些已
    经被授予进程和/或线程的特定访问权限,以防护它们自己的工具被执行某些特定的操作。

3.15 代码完整性

代码完整性(code integrity)是Windows中一项认证可执行映像(比如应用程序、DLL或驱动程序)的完整性和来源的机制,其做法是,检验映像文件的资源部分所包含的一个数字证书。这一机制与系统策略联合起来进行工作,系统策略定义了应如何强制要求签名。其中一个策略是内核模式代码签名(KMCS,Kernel Mode Code Signing)策略,它要求内核模式代码必须经过一个有效的Authenticode证书进行签名,并且此证书必须是少数几个公认的代码签名机构,比如VeriSign或Thawte,作为其根证书。

为了解决向后兼容性的问题,KMCS策略只在64位机器上才完全被强制要求,因为这些驱动程序最近已经被重新编译过了,目的就是为了能在此Windows体系结构上运行。这也进一步隐含着,一个公司或个体仍然要维护其驱动程序,并且要能够对驱动程序进行签名。然而,在32位机器上,许多老的设备随带的是过时的驱动程序,这些驱动程序甚至可能来自于一些已不复存在的公司,所以,对这些驱动程序进行签名有时候是不现实的。图3.43显示了在64位Windows机器上试图加载一个未经签名的驱动程序时显示的警告信息。

注:Windows也有第二个驱动程序签名策略,这也是即插即用管理器的一部分。该策略仅被应用在即插即用驱动程序上;与内核模式代码签名策略不同的是,该策略可以被配置成允许未经签名的即插即用驱动程序(不过在64位系统上不行,因为在64位系统上KMCS策略是优先的)。有关即插即用管理器的更多信息,请参见本书下册第8章。

在这里插入图片描述
注意,即使在32位机器上,当加载一个未经签名的驱动程序时,代码完整性机制也会在Code Integrity事件日志中写入一个事件。

注“受保护的媒体路径(Protected Media Path)”应用程序也可以向内核询问其完整性状态
(integrity state),这包括涉及“是否有未经签名的32位驱动程序被加载到系统中”这样的信息。在这种情形下,它们可以禁止受保护的、高清晰度的媒体播放,作为保护加密媒体流的安全性和可靠性的一种方法。

然而,代码完整性机制并不止于驱动程序加载时刻。更强的措施也存在,对于可执行代码页,它可以认证每个页面的映像内容。这要求在对驱动程序二进制文件进行签名时使用一个特殊的标志,从而产生一个目录,其中包含了该驱动程序所在的每一个可执行页面的密码学散列值。(页面是CPU上的保护单位;更多的信息,请参见本书下册第10章。)这种方法也使得可以检测到对一个已有驱动程序的修改:可能在运行时刻碰巧由其他驱动程序进行修改,或者通过一个页面文件进行修改,或者通过休眠文件攻击而实施的修改(在磁盘上修改内存的内容,然后重新加载到内存中)。针对每个页面产生散列值,也是新的过滤模型的一个要求,也是“受保护的媒体路径(Protected Media Path)”组件的要求。

实验

实验:将系统调用号映射到函数和参数

实验:查看系统服务的行为

通过观察“System”对象中的“System Calls/Sec”性能计数器,可以监视系统服务的行为。
运行性能监视器,然后在“监视工具”下单击“性能监视器”,再单击“添加”按钮在图表中加入一个计数器。

选择“System”对象,再选择“System Calls/sec”计数器,然后单击“添加” 按钮就可以将该计数器加入到图表中。

实验:考察对象管理器

实验:查看对象头和类型对象

实验:查看已打开的句柄

实验:查看对象安全性

实验:查看全局排队自旋锁

实验:查看等待队列

实验:列出已获得的执行体资源

实验:列出系统辅助线程

实验:查看和设置NtGlobalFlag

你可以使用!gflag内核调试器命令来查看和设置NtGlobalFlag内核变量的状态。!gflag命令列出所有已被启用的标志。你可以使用!gflag -?来获得所有已支持的全局标志的完整列表。

实验:查看子系统ALPC端口对象

实验:转储一个连接端口

实验:用内核记录器跟踪TCP/IP的活动

实验:查看调试器对象

实验:观察映像加载器

实验:观察DLL加载搜索顺序

实验:转储出已经加载模块数据库

实验:利用LiveKd从父分区中检查子分区

实验:观察动态内存

实验:列出事务管理器

本章总结

在本章中,我们看到了Windows执行体赖以建立起来的关键的基本系统机制。在下一章中,我们将会看到三个重要的、与Windows的管理基础设施有关的机制:注册表、服务,以及WMI( Windows Management Instrumentation,Windows管理规范)。

  • 2
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
Windows操作系统是由微软公司开发的一种广泛使用的操作系统。它被广泛应用于个人计算机、笔记本电脑和服务器等设备中。 Windows操作系统具有强大的图形化用户界面,用户可以通过可视化的操作来执行各种任务。它包括桌面、任务栏、开始菜单等组件,用户可以方便地访问其所需的程序和文件。 Windows操作系统支持多任务处理,用户可以同时运行多个应用程序,并且可以在不影响其他程序的情况下切换任务。它还支持多用户登录,允许多个用户同时使用同一台计算机。 此外,Windows操作系统还具有丰富的硬件和软件兼容性。它可以与各种不同的硬件设备和软件应用程序进行良好的兼容,以满足用户的各种需求。 Windows操作系统还提供了大量的内置工具和功能,如文件管理器、网络连接、安全设置等。用户可以使用这些工具来管理和操作他们的计算机系统,提高工作效率。 同时,Windows操作系统还提供了强大的安全性能。它具有防火墙、反病毒程序和安全更新等功能,可以有效地保护用户的计算机免受恶意软件和网络攻击的威胁。 综上所述,Windows操作系统是一种功能强大、易于使用和广泛应用的操作系统。它具有图形用户界面、多任务处理、硬件和软件兼容性、丰富的内置工具和强大的安全性能等特点。通过深入了解Windows操作系统,用户可以更好地利用其功能和特点,提高工作和生活效率。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

二进制怪兽

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值