Windows 操作系统提供了一些基本的机制供内核模式的组件 (比如执行体、内核和设备驱动程序) 使用。
本章将介绍下面的系统机制,同时说明如何使用这些机制:
中断
和异常
是导致处理器转向正常控制流之外代码的两种操作系统条件。硬件或者软件都可以检测到这两种条件
。
术语陷阱 (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 章“崩溃转储分析”)。
下面的章节将更加详细地介绍中断、异常和系统服务分发。
通过观察“System”对象中的“System Calls/Sec”性能计数器,可以监视系统服务的行为。
运行性能监视器,然后在“监视工具”下单击“性能监视器”,再单击“添加”按钮在图表中加入一个计数器。
第2章“系统架构”中提到,Windows实现了一个对象模型,为执行体中实现的各种内部服务提供一致的、安全的访问机制。
本节将介绍Windows的对象管理器(object manager),即执行体内部负责创建、删除、保护和跟踪对象的组件。
对象管理器将那些本来有可能散落在操作系统各处的资源控制操作集中在一起,其设计意图是满足稍后列出的一系列目标。
对象管理器的设计目标如下:
局部(local)
和全局(global)
对象并存。在内部,Windows有三种类型的对象:
所谓执行体对象,是指由执行体的各个组件(比如进程管理器、内存管理器、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)。
注:
当通过Windows 子系统中的API函数(比如CreateEvent或CreateFile)来创建对象时,调用者并不指定任何对象属性一子 系统DLL将在背后完成这些事情。由于这个原因,所有通过Win32创建的有名称的对象都将进入BaseNamedObjects目录中(无论是全局实例,还是每个会话的实例),因为这是Kernelbase.dll在对象属性结构中指定的根对象目录。有关BaseNamedObjects及其如何与会话名字空间关联起来的更多信息,参见本章后面的介绍。
除对象头外,每个对象还有一个对象体,其格式和内容都取决于其对象类型:同一类型的所有对象共享同样的对象体格式。执行体组件通过创建一一个对象类型,并为其提供必要的服务,便可以控制所有该类型的对象体的数据。因为对象头有-一个静态的、确定的大小,所以,对象管理器可以很容易地为-一个对象找到它的对象头,做法很简单,从对象指针上减去头的大小。正如前面所解释的那样,要想访问对象的子头,对象管理器只需从对象头的指针再减去另一个确定的值即可。
由于对象头和子头结构已被标准化,因此对象管理器可以提供少量通用的服务对一个对象头中保存的属性进行操作,而且这些服务可以作用在任何类型的对象上(不过,有些通用服务对于某些特定的对象并没有意义)。
表3.13中 列出了这些通用的服务,Windows子 系统允许其中的某些服务直接被Windows应用程序使用。虽然所有的对象类型都支持这些通用的对象服务,但是,每个对象都有其自己的创建、打开和查询服务。例如,I/O系统为其文件对象实现了一个创建文件的服务,而进程管理器则为其进程对象实现了一个创建进程的服务。尽管实现一个通用的创建对象的服务也是有可能的,但是这样的例程将会相当复杂,因为,举例而言,初始化–个文件对象与初始化一个进程对象,所需要的参数集合会有极大的差异。而且,每次线程调用对象服务来确定句柄所指对象的类型,再调用正确版本的服务时,对象管理器都将招致额外的处理开销。
对象头中包含的数据对于所有的对象都是公共的,但是每个对象实例可以取不同的值。
例如,每个对象有一个唯一名称, 也可以有唯一-的安全描述符。然而,对象也可以包含一些对于某种特定类型的所有对象皆为常数的数据。例如,当打开一个指向某种类型的对象的句柄时,可以从一-组特定于该对象类型的访问权限中进行选择。执行体为线程对象提供了终止和挂起(及其他)访问操作,为文件对象提供了读、写、添加和删除(及其他)访问操作。与对象类型相关的属性的另-一个例子是同步,稍后会介绍。
为了节省内存,对象管理器只在创建新的对象类型时,才存储这些静态的、特定于对象类型的属性。它使用自己的一个对象(一个类型对象)来记录这些数据。
如图3.20所示,如果对象跟踪调试标志(参见本章后面的“Windows全局标志”一节所述)被设置,则类型对象也会将同一类型(在图中是进程类型)的所有对象链接起来,从而使对象管理器可以在必要的时候找到这些对象,并且对它们进行枚举。这- .功能利用了前面讨论过的创建者信息子头。
在用户模式下不能操纵类型对象,因为对象管理器并没有提供任何有关类型对象的服务。
然而,类型对象定义的某些属性通过特定的原生服务或者WindowsAPI例程是可见的。表3.14
列出了在类型初始化结构中保存的信息。
同步是一个对于Windows应用程序可见的属性,它指的是,一一个线程通过等待某个对象从一.种状态改变成另–种状态,从而达到同步其执行过程的能力。–个线程可以通过执行体的作业、进程、线程、文件、事件、信号量、互斥体和定时器对象来进行同步。其他的执行体对象不支持同步。
一个对象支持同步的能力,基于以下三种可能性:
表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),只不过应用程序使用句柄而非对象指针。
因为对象代表了从事件到文件,到进程间消息的任何事物,所以,让应用程序和内核代码有能力来创建对象,这本质上对于任何部分的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)的,即只有当暂时对象还在被使用的时候,才会保留它们,而等到用完以后就会将它们删除。因为所有的用户模式进程在访问一个对象以前首先要打开一个指向该对象的句柄,所以,对象管理器可以很容易地跟踪有多少个进程正在使用一个对象,甚至于哪些进程正在使用它。跟踪这些句柄只是实现对象保持力的一部分。 对象管理器通过两个阶段来实现对象保持力。第- -个阶段称为名称保持力(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.24演示了当两个运行于不同处理器上的线程同时向一个循环队列写数据时可能发生的情形。
因为在第一个线程尚未更新队列的尾指针以前,第二个线程就获得了尾指针的值,所以第二个线程将数据插入到第一个线程刚刚使用过的位置上,从而改写了该位置上的数据,同时留下了一个空的队列元素。
虽然此图演示的是在一个多处理器系统上可能发生的情形,但同样的错误也可能在单处理器上发生:如果在第一个线程更新队列尾指针以前操作系统执行了一个环境切换,将控制权交给了第二个线程。
如果一段代码区访问了一个不可共享的资源,则这样的代码区 称为临界区(critical section)
。
为了确保代码正确无误,同一时刻只允许一个线程在临界区内执行。当一个线程在写一个文件、更新一个数据库,或者修改-一个共 享变量的时候,其他的线程不允许访问同样的资源。图3.24中显示的伪代码是一个临界区,它在毫无互斥的情况下,不正确地访问了一个共享的数据结构。
尽管互斥问题对于所有的操作系统都很重要,但是对于一个像Windows这样的紧耦合的、对称多处理(SMP)操作系统显得尤为重要(和复杂),在这样的系统中,同样的系统代码同时运行在多个处理器上,它们共享了存储在全局内存中的特定数据结构。在Windows中,内核负责提供各种机制,供系统代码用来避免两个线程同时修改同样的数据结构。内核提供的互斥原语使得它自己和执行体的其他部分可以利用这些原语来同步它们对于全局数据结构的访问。
因为在DPC/Dispatch
级别的IRQL上,调度器已经对其数据结构的访问进行了同步,所以,当IRQL在DPC/Dispatch或者更高的级别( 称为提升的或者高IRQL级别)上时,内核和执行体不能依赖于那些可能会导致页面错误或者重新调度操作的同步机制,来对各种数据结构的访问进行同步。在下面的小节中,你将会看到,当IRQL高的时候,内核和执行体如何使用互斥机制来保护它们的全局数据结构;而当IRQL低的时候(低于DPC/Dispatch级别),内核和执行体又用到了哪些互斥和同步机制。
在内核执行的各个阶段中,内核必须要保证,在临界区内部同一时刻只有-一个处理器在执行。内核临界区是指修改某个全局数据结构的代码段,比如修改内核的分发器数据库或者它的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汇编指令可以被插入到忙等循环中。该指令向处理器提供了一个“线索”,指明它正在处理的循环指令是自旋锁(或类似性质的结构)的获取循环部分。
这条指令提供了三方面的好处:
内核提供了一组内核函数,包括KeAcquireSpinlock和KeReleaseSpinlock, 从而使得执行体的其他部分也可以使用自旋锁。例如,设备驱动程序为了保证设备寄存器和其他的全局数据结构在同一时刻只能被该设备驱动程序的某一部分(并且只能由一一个处理器)访问,就可以请求自旋锁。自旋锁不是给用户程序使用的一用户程序应该使用下一小节讲述的同步对象。
设备驱动程序也需要考虑来自其关联的中断中对其数据结构的访问,以保护这些数据结构。因为自旋锁API往往只是将IRQL升高至DPC/Dispatch级别,这对于防止来自中断的访问还是不够的。由于这一原因, 内核也导出了KeAcquireInterruptSpinLock
和KeReleaseInterruptSpinLock
API,它们携带一个KINTERRUPT对象为参数( 关于KINTERRUPT对象,本章前文已经讨论过)。系统将会在中断对象中,检查与中断相关联的DIRQL,并且将IRQL升高至恰当的级别,以确保对数据结构的访问能够正确地与ISR共享。设备也可以使用KeSynchronizeExecution API,将整个函数与ISR进行同步,而不是仅仅一个临界区。在所有这些情形下,由中断自旋锁保护的代码必须极其快速地执行一-任何延迟都会招致超乎寻常的中断时延,并且带来显著的负面性能影响。
对于使用内核自旋锁的代码,自旋锁也带来了一些限制。因为自旋锁总是有一个DPC/Dispatch或更高级别的IRQL,所以,正如上文所述的,如果一段代码正在持有一个自旋锁,若它企图让调度器执行一个分发操作,或者它引发了一个页面错误,则会导致系统崩溃。
为了提高自旋锁的伸缩性,有一种特殊的自旋锁类型称为排队的自旋锁(queuedspinlock)
,它们被用于某些非标准自旋锁的场合下。
排队的自旋锁的工作方式如下:
当一个处理器想要获得一个当前已被其他处理器持有的排队的自旋锁时,它把自己的标识符放在与该自旋锁关联的一一个队列中。如果当前正持有该自旋锁的处理器释放了该锁,则它将该锁移交给队列中标识的第一一个处理器。同时,如果一个处理器正在等待一个忙着的自旋锁,则它并不是检查该自旋锁本身的状态,而是检查一个针对每个处理器的标志;在队列中位于该处理器之前的处理器会设置这一标志,以表明该轮到这个正在等待的处理器了。排队的自旋锁的结果是,处理器在这些针对每个处理器的标志上旋转,而不是在全局自旋锁上旋转。这有两种效果。
FIFO顺序意味着在一组访问同样锁的处理器之间有了更加一致的性能表现。Windows定义了许多全局排队的自旋锁,它在每个处理器的“处理器区域控制块(PRCB)”所包含的一个数组中保存了指向这些全局排队的自旋锁的指针。
只需在调用KeAcquireQueuedSpinlock的时候将一个全局自旋锁的指针在PRCB数组中的索引传递进去,就可以获得对应的全局自旋锁。全局自旋锁的数量随着操作系统的每个发行版本而不断增加,WDK头文件Wdm.h中公开了这些全局自旋锁的索引定义表。然而,要注意,在一个设备驱动程序中获取这些排队的自旋锁是不被支持的,也是应该极力要避免的操作。这些锁是保留给内核自己内部使用的。
设备驱动程序可以通过KeAcquireInStackQueuedSpinLock
和KeReleaselnStackQueuedSpinLock
这两个函数来使用动态分配的排队自旋锁。
有几个组件一一包括缓存管理器 、执行体内存池管理器和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上执行的用户模式代码必须具备它自己的锁原语。Windows支持 各种专门用于用户模式的同步语义:
我们在后面将会讨论用户模式语义以及它们的底层内核模式支持;现在我们将注意力集中在内核模式对象上。表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)。一个线程在它的等待条件被满足以前,不能恢复执行。
如果该线程正在等待一个分发器对象的句柄,而且,该分发器对象经历了一次状态改变(从无信号状态改变成有信号状态,例如,当-一个线程设置了一个事件对象时),那么,该线程等待的条件就会满足。- 一个线程为了与一个对象同步,调用对象管理器提供的几个等待系统服务之一,同时传递给它一- 个对象句柄,该句柄指向它所要同步的对象。该线程可以等待一个或者几个对象,还可以指定:如果在特定长度的时间段以内等待过程还没有结束的话,则取消等待。无论何时当内核将一个对象设置成有信号状态时,内核的某一个信号例程就会进行检查,看是否有任何线程正在等待该对象(而且并不同时还在等待其他对象变成有信号状态)。
如果存在这样的线程,则内核将这些线程中的一一个或者多个从它们的等待状态中释放出来,从而它们可以继续执行。
以下设置事件的例子演示了同步与线程分发是如何相互影响的:
注 有些线程可 能在等待多个对象,所以它们会继续等待,除非它们指定了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)的同步对象值得特别- -提,因为它在用户模式互斥同步语义中有特别的地位。实现带键的事件的最初意图是,帮助进程在使用临界区同步对象时发生的低内存情形,这里临界区是一-种用户模式同步对象,稍后我们将会进-一步讨论。带键的事件并没有被文档化,它使得-一个线程可以指定-一个等待 “键”,当同- -进程中的另一个线程用同样的键使该事件有信号时,等待的线程被唤醒。
如果发生竞争的话,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中。
快速互斥体,也称为执行体互斥体,通常比互斥体对象提供了更好的性能,原因是,尽管它们也是建立在分发器事件对象基础之.上的,但只有当快速互斥体有竞争的时候它们才通过分发器对象执行等待,而标准的互斥体总是试图通过分发器来执行获取操作。这使得快速互斥体在一个多处理器环境中具有特别好的性能。快速互斥体广泛应用于设备驱动程序中。
然而,快速互斥体仅适用于当普通的内核模式APC(本章前面已经介绍过)的交付能够被禁止的时候。执行体定义了两个函数来获得快速互斥体: ExAcquireFastMutex 和ExAcquireFastMutexUnsafe.前- -个函数将处理器的IRQL提升到APC级别上,从而阻止所有的APC被交付;后一个函数期望在被调用的时候普通的内核模式APC交付是禁止的,这可以通过提升IRQL至APC级别来做到。ExTryAcquireFastMutex 完成的功能与前-一个函数类似,但如果快速互斥体已经被持有的话,它并不真正执行等待,而是返回FALSE。快速互斥体的另一个局限性是,它们不能被递归获取,而互斥体对象则可以。守护互斥体本质上与快速互斥体是相同的(不过,它在内部使用了不同的同步对象:KGATE)。通过KeAcquireGuardedMutex和KeAcquireGuardedMutexUnSafe函数可以获得守护互斥体,但是,它们并非通过提升IRQL至APC级别来禁止APC,相反,它们通过调用KeEnterGuardedRegion来禁止所有内核模式APC的交付。与快速互斥体类似,也存在一个KeTryAcquireGuardedMutex方法。回忆一下, 守护的区域与临界的区域不同,守护的区域禁止特殊的和普通的内核模式APC,因此,守护互斥体不必提升IRQL。
三个实现上的不同使得守护互斥体比快速互斥体更快:
注:快速互斥体的代码也是经过优化的,几乎考虑到了所有这些优化 它使用了同样的原子锁
操作,而且,事件对象实际上是一一个门对象(不过,如果在内核调试器中转储其类型的话,
你将会看到一一个事件对象结构,这实际上只是一个兼容性幌子)。然而,快速互斥体仍然提升
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字节)。当一个线程获取一个普通的推锁时,如果它当前尚未被占有,则推锁代码将它标记为已被占有。如果该推锁已被独占方式占有,或者该线程希望以独占方式获取该推锁但它却被一组线程以共享方式占有着, 则该线程在自己的栈上分配一个等待块,并且初始化该等待块中的-一个门对象,然后将该等待块加入到与推锁相关联的等待列表中。当- 一个线程释放-一个推锁的时候,如果有等待者的话,则唤醒一个等待者,其做法是,向该等待者的等待块中的门对象发出信号。
因为推锁只是指针的大小,所以,它实际上包含了多个位来描述它的状态。随着一个推锁从竞争状态到非竞争状态的变化,这些位的含义也有所不同。在初始状态下,推锁包含下面的结构:
正如前面所讨论,当一个线程要以互斥模式获取一- 个推锁,而该推锁已经被多个读访问线程或一个写访问线程获取时,内核将会分配-一个推锁等待块。推锁值的结构本身也会发生变化。共享计数现在变成了指向该等待块的指针。因为此等待块是在栈上分配的,并且头文件中包含了一个特殊的对齐编译指示符强制等待块必须是16字节对齐的,所以,任何一个推锁等待块结构的最低4位总是0。因此,这些位对于指针引用的用途可被忽略,相反地,上面显示的4位可以跟指针值结合在一起。因为这一-对 齐做法去除了共享计数位,所以,共享计数值现在被保存到等待块中了。
可感知缓存的推锁在普通(基本)推锁的基础上加入了层次,它为系统中的每个处理器分配一个推锁,然后将这些推锁与自己关联起来。当一个线程希望以共享访问模式获取-一个可感知缓存的推锁时,它只是简单地以共享模式获取对应于当前处理器的那个推锁;如果该线程希望以独占访问模式获取-一个可感知缓存的推锁,则它以独占模式获取每–个处理器的推锁。
除了更小的内存印迹,推锁超越执行体资源的一一个大的优势是,在非竞争的情形下,推锁不要求过度的计数和整数操作来执行获取或释放动作。由于推锁与指针–样大小,所以内核可以使用原子的CPU指令来执行这些任务(用到了lock cmpxchg,该指令可以以原子方式来比较并交换新锁和老锁)。如果原子的比较和交换操作失败,则该锁中包含的并非是调用者期望的值(调用者往往期望该锁当前未被使用,或者已被共享模式获取),然后,再调用一个更加复杂的竞争版本。为了进一步挖掘性能潜力,内核将推锁的功能暴露成内联函数,这意味着在非竞争的情形下根本不需要函数调用—直接在每个函数中插入汇编代码。这会略微地增加代码的尺寸,但避免了函数调用带来的延迟。最后,推锁使用了几方面的算法技巧来避免锁封护(lockconvoy,这是指当多个相同优先级的线程都在等待-一个锁,而实际只有极少的的
工作得以完成的一种情形),它们都是自优化的:在一个推锁上等待的线程的列表将会定期地重新组织,以便当推锁被释放时可以提供更为公平的行为。
推锁的使用范围包括对象管理器和内存管理器。在对象管理器中,它们可保护全局的对象管理器数据结构和对象安全描述符;而在内存管理器中,通过可感知缓存的推锁来保护AWE;(Address Windowing Extension)数据结构。
死锁是一个同步问题,它源于两个线程或者处理器分别持有另一个想要的资源,并且谁也不会让出自己所属的资源。这种情况可能会导致系统或者进程停止。在本书下册第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读写锁( 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 锁可以在极少进入内核模式的情况下,获得可伸缩的、指针大小的同步机制一一而 在竞争的情形下,已经做了优化,以期使用更少的时间和内存来唤醒等待者和设置状态(因为使用了带键的事件)。
让一段负责执行某种初始化任务的代码以原子方式来执行,这种能力是多线程程序设计中的一个典型问题。这样的初始化任务包括申请内存、初始化特定的变量,或者根据需要而创建对象,等等。在一段可以被多个线程并发调用的代码(–个很好的例子是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获取/释放代码的内联汇编版本;而当竞争发生时(发生在同步模式下使用该机制的时候),使用了带键的事件,其他的线程必须等待初始化。在异步的情形下,锁是以共享模式来使用的,所以多个线程可以同时执行初始化。
在系统初始化的过程中,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例程可能已经完成执行了,也可能尚未完成。
系统辅助线程有以下三种类型:
通过执行体的ExpWorkerInitialization函数(在系统引导过程的早期被调用)来创建的延迟型和紧急型辅助线程的数目取决于系统中内存的数量,以及该系统是否为服务器系统。表3.22显示了在默认系统配置上创建的线程初始数目。你可以通过注册表HKLMISYSTEM\CurrentControlSet\ControllSession Manager\Executive
键下面的 AdditionalDelayedWorkerThreads
和AdditionalCriticalWorkerThreads
值,来指定ExpInitializeWorker
创建至多16个额外的延迟型辅助线程,以及至多16个额外的紧急型辅助线程。
(此处有图)
执行体试图在系统执行过程中,让紧急型辅助线程的数目符合工作负载的变化。每隔一秒钟,执行体函数ExpWorkerThreadBalanceManager
确定是否应该创建一个新的紧急型辅助线程。
由ExpWorkerThreadBalanceManager创建的紧急型辅助线程称为动态的辅助线程
,在创建这样的辅助线程以前下面的条件必须全部满足:
动态辅助线程在10分钟不活动之后就会退出。因此,当工作负载需要时,执行体可以创建至多16个动态辅助线程。
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中显示的操作系统标志。
你可以使用!gflag内核调试器命令来查看和设置NtGlobalFlag内核变量的状态。!gflag命令列出所有已被启用的标志。你可以使用!gflag -?来获得所有已支持的全局标志的完整列表。
所有的现代操作系统都需要一种机制来安全地在用户模式下在一个或多个进程之间传输数据,或者允许内核中的服务与用户模式下的客户之间传输数据。
典型情况下,为了移植性的原因,可以使用诸如邮件槽(mailslot)、文件、命名管道和套接字(socket)这样的UNIX机制,而对于图形应用程序,开发人员往往使用窗口消息。
Windows实现了一种称为高级本地过程调用(advanced local procedure call)
或ALPC的内部IPC机制
,这是一种高速的、可伸缩的、安全的消息传递设施,可用于传递任意大小的消息。
尽管ALPC是一种内部机制,因而第三方开发人员无法使用,但是它本身被广泛应用于Windows的各个部分:
远过程调用(RPC,一个已文档化的API)的Windows应用程序
,如果它们指定了基于ncalrpc的本地RPC,则会间接地使用ALPC。ncalrpc是一种RPC的形式,用于在同一个系统上的进程之间进行通信。网络栈使用的内核模式RPC也使用了ALPC。子系统通过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还支持第四个属性,称为环境属性(context attribute)。这一属性支持传统的、LPC风格的、用户特定的环境指针,此环境指针可以与给定的消息关联起来;在有些场景下,自定义的数据有必要与“客户/服务器”对相关联,这种情况仍然可以支持。
为了正确地定义属性,有各种各样的API可以供内部的ALPC消费者使用,比如AlpcInitializeMessageAttribute和AlpcGetMessageAttribute
。
虽然ALPC库通过对象管理器只暴露了一个对象类型(port),但是,它内部必须管理很多数据结构,以便可以执行它的机制所要求的各项任务。例如,ALPC需要分配和跟踪与每个端口相关联的消息,以及消息属性;它必须跟踪它们生命周期的整个过程。ALPC并没有使用对象管理器的例程来管理数据,而是实现了它自己的一种轻量的对象,称为blob
。
与对象类似,blob可以自动被分配和垃圾回收,可以被跟踪引用,以及通过同步机制来锁定。而且,blob可以有客户定义的分配和还原回调函数,这使得它们的所有者可以控制额外的信息,比如可以用于跟踪每个blob的使用情况。最后,ALPC也用到了执行体的句柄表的实现(可以使用对象和PID/TID),有一个专门ALPC的句柄表,使得ALPC可以为blob生成私有的句柄,而不是使用指针。
在ALPC模型中,例如,消息是blob,它们的构造函数生成一个消息ID,消息ID本身是一个指向ALPC句柄表的句柄。其他的ALPC blob包括以下:
因为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端口和消息的信息。
Windows内核和几个核心设备驱动程序的各个组件内置了一些功能来记录下其操作的痕迹数据,以便用于系统诊断。它们依赖于内核中一个公共的基础设施,由它向用户模式的ETW(Event Tracing for Windows,Windows事件跟踪)设施提供痕迹数据。
使用ETW的应用程序必然是以下三类中的某一类或同属于多类:
控制器
控制器启动或者停止记录会话,也管理缓冲区池。控制器的例子有:提供者
提供者为它所能产生的事件类定义GUID(全局唯一标识符),并且将它们注册到ETW中。提供者接受来自控制器的命令,以便启动或者停止它所负责的事件类的痕迹跟踪。消费者
消费者针对它想要读取的痕迹数据,选择一个或者多个跟踪会话。它们可以实时地接收缓冲区中的事件,也可以接收日志文件中的事件。Windows包含了许多用户模式的提供者,从针对活动目录、服务控制管理器的提供者,到针对资源管理器(Explorer)的提供者,应有尽有。ETW也定义了一个名为“NTKernel Logger”的记录会话(也称为内核记录器),专门用于内核和核心驱动程序。NTKernel Logger的提供者是由Ntoskrnl.exe中的ETW代码和一些核心的驱动程序合起来实现的。
当一个用户模式下的控制器启动内核记录器时,ETW库(在\WindowslSystem32\Ntdll.dll中实现)调用NtTraceControl系统函数,告诉内核中的ETW代码,该控制器想要开始跟踪哪些事件类。如果当前的配置是文件记录(相对于内存记录,即输出到一个缓冲区中),则内核在创建日志文件的系统进程中创建一个系统线程。当内核接收到来自于已启用的痕迹数据源的事件时,它将这些事件记录到一个缓冲区中。如果文件记录线程已被启动的话,则它每隔一秒钟被唤醒一次,以便将缓冲区中的内容转储到日志文件中。
内核记录器生成的痕迹记录有一个标准的ETW痕迹事件头,其中记录了时间戳、进程、线程ID,以及关于该记录所对应的那一类事件的信息。事件类可能提供了与它们的事件有关的额外数据。例如,磁盘事件类痕迹记录指明了操作类型(读或者写)、该操作所在的磁盘号,以及该操作的扇区偏移和长度。
可被内核记录器启用的痕迹类,以及产生每一事件类的组件包括:
在Windows SDK中可以找到有关ETW和内核记录器的更多信息,其中包括一些控制器和消费者的例子代码。
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进程可以在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位内核所做的那样将它分发出去。
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位应用程序在访问这些目录时实际上是在访问真正的目录。这些目录包括:
最后,Wow64提供了一种机制来控制Wow64中内置的文件系统重定向功能,这种机制是以每个线程为基础的,通过Wow64DisableWow64FsRedirection和 Wow64RevertWow64FsRedirection函数来实施控制。该机制对于延迟加载的DLL、通过公共文件对话框来打开文件,甚至国际化方面,都存在一些问题——因为一旦重定向被关闭,系统要么在内部加载过程中不再使用重定向,要么有些特定的只有64位的文件便无法再找到。使用c:1windowsisysnative路径或者前面介绍的其他的一致路径通常是一种更为安全的方法,可以供开发人员使用。
应用程序和组件程序将它们的配置数据保存在注册表中。组件程序在安装过程中,当它们被注册的时候,通常将配置数据写到注册表中。如果同样的组件既安装和注册了一个32位二进制文件,又安装和注册了一个64位二进制文件,那么,最后被注册的那个组件将会覆盖掉以前组件的注册,因为它们写到注册表中同样的位置上。
为了以透明的方式解决这个问题,并且无须对32位组件进行任何代码修改,注册表被分成两个部分:原生的和Wow64的。在默认情况下,32位组件访问32位视图,64位组件访问64位视图。这为32位和64位组件提供了一个安全的执行环境,并且将32位应用程序的状态与64位应用程序(如果存在的话)的状态隔离开来。
为了实现这一点,Wow64截取了所有要打开注册表键的系统调用,并且重新解释这些注册表键的路径,将它们指向注册表的Wow64视图。Wow64在以下这些点上分裂注册表:
在以上每一个键的下面,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位键。
除了普通的读和写操作以外,应用程序可以利用Windows的DeviceloControlAPI,与某些设备驱动程序通过设备IO控制函数进行通信。应用程序可能会在调用时指定一个输入和/或输出缓冲区。如果该缓冲区中包含了与指针相关的数据,并且发送该控制请求的进程是一个Wow64进程,那么,输入和/或输出结构的视图在32位应用程序和64位驱动程序之间是不相同的,因为对于32位应用程序来说,指针是4字节,而对于64位应用程序来说,指针是8字节。在这种情况下,内核驱动程序最好能够转换这些与指针相关的结构。驱动程序可以调用loIs32bitProcess函数来检测一个IO请求是否是从一个Wow64进程发出的。更多的细节可以参考MSDN中的“Supporting 32-Bit IO in Your 64-Bit Driver”。
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进程提供了软件仿真)。
对用户模式调试的支持被分在三个不同的模块中。
第一个模块位于内核可执行程序内部,其前缀为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可以理解的数据结构。
使得诸如Microsoft Visual Studio或WinDbg之类的调试器可以调试用户模式应用程序的最后一个组件是在Kernel32.dll中。它提供了文档化的Windows API。在这里列举出这些函数名称并非重要,这部分调试设施的一个重要的管理任务是:管理复制的文件和线程句柄。
回忆一下,每次当一个加载dll事件被送出的时候,内核就会复制一个指向该映像文件的句柄,并放在事件结构中,这就如同在创建进程的事件过程中处理指向进程可执行映像文件的句柄一样。在每一个等待调用过程中,Kernel32.dl检查是否有事件导致在内核中新的进程和/或线程句柄被复制(两个创建事件)。如果是的话,则分配一个数据结构,其中存放进程ID、线程ID,以及与该事件相关联的线程和/或进程句柄。此数据结构被链接到TEB的第一个DbgSsReserved数组索引中,上一小节曾经提到过,调试对象的句柄也被存放在这里。同样地,Kernel32.dll也会检查退出事件。当它检测到这样的事件时,它会在数据结构中“标记”相应的句柄。
一旦调试器用完了这些句柄,并且执行了继续调用,Kernel32.dIl将解析这些数据结构,检查那些已经退出的线程的句柄,并且为调试器关闭这些句柄。否则的话,这些线程和进程将永远不会退出,因为只要调试器在运行,就总会有打开的句柄指向这些线程或进程。
当系统中一个进程被启动时,内核创建一个进程对象来代表该进程(有关进程的更多信息,请参考第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完成的一些工作。然而,现在我们来讨论发生在用户模式下的工作,这些工作独立于任何一个子系统,并且从第一条用户模式指令执行就开始了。当一个进程启动时,加载器执行以下步骤:
到这时候,映像加载器已经做好了准备,可以开始解析属于该应用程序的可执行文件的导入表,以及加载任何在应用程序编译过程中动态链接的DLL了。因为每一个被导入的DLL也可以有它自己的导入表,所以,这个过程会递归地进行,直到所有的DLL都被满足,所有被导入的函数都已经找到。随着每一个DLL被加载进来,加载器会记录下它的状态信息,并构建起模块数据库。
名称解析是指这样一个过程:当调用者没有指定或者不能指定一个唯一文件标识的情况下,系统把一个PE格式二进制文件的名称转换成一个物理文件。因为各个目录(应用目录、系统目录等等)的位置无法在链接的时候以硬编码的方式确定,所以,这也包括所有二进制依赖性的解析,以及当调用者没有指定一个完整路径的情况下LoadLibrary操作的解析过程。
当解析二进制依赖性的时候,基本的Windows应用程序模型是按照搜索路径来查找文件,这里的搜索路径是一个位置列表,其中每个位置被顺序搜索以发现一个匹配的基本名称;不过各种系统组件为了扩展默认的应用程序模型,会覆盖掉这一路径搜索机制。搜索路径的概念是从命令行时代遗留下来的产物,那时候一个应用程序的当前目录是一个有意义的概念;对于现代的GUI应用程序,这多少有点不合时宜了。
然而,由于当前目录在这一路径顺序中的特殊位置,通过在应用程序的当前目录下放置一些相同基本文件名的恶意二进制文件,使得系统二进制文件的加载操作可以被改变。为了防止与这一行为相关联的安全风险,在路径搜索计算上新加入一个称为安全DLL搜索模式的特性,并且从Windows XPSP2开始,这一特性对所有的进程都默认启用。在安全搜索模式下,当前路径被移到三个系统目录的后面,从而导致下面的路径顺序:
对于每个后续的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名字空间的某些部分,以进一步扩展Windows应用程序模型。这里的DLL名字空间通常对应于Win32文件系统名字空间。对于应用程序,这些规则是:
.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状态)。
我们已经解释了加载器如何记录下一个进程中所有已被加载的模块,现在我们可以继续来分析加载器执行的进程启动初始化任务。在此过程中,加载器完成以下事项:
当所有的导入DLL(以及它们自己的依赖库或导入库)已经被加载以后,所有必需的导入函数已经被查找过并且找到了,所有的转发项也被加载和处理了,导入解析的步骤就完成了:应用程序和它的各个DLL在编译时刻定义的所有依赖性现在已经满足了。在执行过程中,延迟的依赖性(称为延迟加载),以及运行时刻操作(比如调用LoadLibrary)可以调用到加载器的功能,本质上也是重复同样的任务。然而,请注意,如果这些步骤是在进程启动过程中进行的,那么任何失败将会导致一个应用程序启动错误。例如,如果试图要运行的应用程序要求一个在当前操作系统版本中并不存在的函数,那么,这将会导致一个类似于图3.32那样的消息出现。
当必要的依赖库已经被加载进来以后,还必须要执行一些初始化任务,以便完满地完成应用程序的启动过程。在这个阶段,加载器将完成以下事项:
运行各个初始化函数是加载器的工作中最后一个主要步骤。这一步骤将调用每个DLL的DIIMain例程(允许每个DLL有机会执行它自己的初始化工作,这甚至也包括在运行时刻加载新的DLL),并处理每个DLL的TLS初始化函数。这是导致应用程序加载失败的最后可能的步骤之一。如果所有已被加载的DLL在完成了它们的DIIMain例程之后并没有返回一个成功的返回代码,那么,加载器将会终止应用程序启动过程。作为一个非常靠后的步骤,加载器将调用实际应用程序的TLS初始化函数。
随着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的兼容性设置:
无论何时当一个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被调用时将会执行哪个版本的分叉点。
虽然可以利用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: