内核同步对象(下)

来源:互联网 发布:淘宝外宣怎么宣传 编辑:程序博客网 时间:2024/06/04 19:18

内核互斥对象

互斥(mutex)就是互相排斥(mutual exclusion)的简写。内核互斥对象为多个竞争线程串行化访问共享资源提供了一种方法(不一定是最好的方法)。如果互斥对象不被某线程所拥有,则它是信号态,反之则是非信号态。当线程为了获得互斥对象的控制权而调用KeWaitXxx例程时,内核同时也做了一些工作以帮助避免可能的死锁。同样,互斥对象也需要与KeWaitForSingleObject类似的附加动作。内核可以确保线程不被换出,并且阻止所有APC的提交,内核专用APC(如IoCompleteRequest用以完成I/O请求的APC)除外。

通常我们应该使用executive部件输出的快速互斥对象而不是内核互斥对象。这两者的主要不同是,内核互斥可以被递归获取,而executive快速互斥则不能。即内核互斥的所有者可以调用KeWaitXxx并指定所拥有的互斥对象从而使等待立即被满足。如果一个线程真的这样做,它必须也要以同样的次数释放该互斥对象,否则该互斥对象不被认为是空闲的。

如果你需要长时间串行化访问一个对象,你应该首先考虑使用互斥(而不是依赖提升的IRQL和自旋锁)。利用互斥对象控制资源的访问,可以使其它线程分布到多处理器平台上的其它CPU中运行,还允许导致页故障的代码仍能锁定资源而不被其它线程访问。表4-4列出了互斥对象的服务函数。

表4-4. 互斥对象服务函数

服务函数描述KeInitializeMutex初始化互斥对象KeReadStateMutex取互斥对象的当前状态KeReleaseMutex设置互斥对象为信号态

为了创建一个互斥对象,你需要为KMUTEX对象保留一块非分页内存,然后象下面这样初始化:

ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL);KeInitializeMutex(mutex, level);

mutex是KMUTEX对象的地址,level参数最初是用于辅助避免多互斥对象带来的死锁。但现在,内核忽略level参数。

互斥对象的初始状态为信号态,即未被任何线程拥有。KeWaitXxx调用将使调用者接管互斥对象的控制并使其进入非信号态。

利用下面函数可以获取互斥对象的当前状态:

ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);LONG signalled = KeReadStateMutex(mutex);

返回值0表示互斥对象已被占用,非0表示未被占用。

下面函数可以使所有者放弃其占有的互斥对象并使其进入信号态:

ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL);LONG wassignalled = KeReleaseMutex(mutex, wait);

wait参数与KeSetEvent函数中的含义相同。该函数返回值总是0,表示该互斥对象曾被占用过,如果不是这种情况(所有者释放的不是它自己的对象),KeReleaseMutex将产生bug check。

出于完整性的考虑,我想提一下KeWaitForMutexObject函数,它是DDK中的宏(见WDM.H)。其定义如下:

#define KeWaitForMutexObject KeWaitForSingleObject
内核定时器

 

内核还提供了一种定时器对象,该对象可以在指定的绝对时间或间隔时间后自动从非信号态变为信号态。它还可以周期性地进入信号态。我们可以用它来安排一个定期执行的DPC回调函数。表4-5列出了用于定时器对象的服务函数。

表4-5. 内核定时器对象的服务函数

服务函数描述KeCancelTimer取消一个活动的定时器KeInitializeTimer初始化一次性的通知定时器KeInitializeTimerEx初始化一次性的或重复通知的或同步的定时器KeReadStateTimer获取定时器的当前状态KeSetTimer为通知定时器设定时间KeSetTimerEx为定时器设定时间和其它属性通知定时器用起来象事件

 

在这一段中,我们将创建一个通知定时器对象并等到它达到预定时间。首先,我们在非分页内存中分配一个KTIMER对象。然后,我们在低于或等于DISPATCH_LEVEL级上初始化这个定时器对象:

PKTIMER timer;      //  someone gives you thisASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);KeInitializeTimer(timer);

在此,定时器处于非信号状态,它还没有开始倒计时,在这样的定时器上等待的线程永远得不到唤醒。为了启动定时器倒计时,我们调用KeSetTimer函数:

ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);LARGE_INTEGER duetime;BOOLEAN wascounting = KeSetTimer(timer, duetime, NULL);

duetime是一个64位的时间值,单位为100纳秒。如果该值为正,则表示一个从1601年1月1日算起的绝对时间。如果该值为负,则它是相对于当前时间的一段时间间隔。

返回值如果为TRUE,则表明定时器已经启动。(在这种情况下,如果我们再调用KeSetTimer函数,则定时器放弃原来的时间重新开始倒计时)

下面语句读取定时器的当前状态:

ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);BOOLEAN counting = KeReadStateTimer(timer);

KeInitializeTimer和KeSetTimer实际上是旧的服务函数,它们已经被新函数取代。我们可以用下面调用初始化定时器:

ASSERT(KeGetCurrentIqrl() <= DISPATCH_LEVEL);KeInitializeTimerEx(timer, NotificationTimer);

定时器设置函数也有扩展版本,KeSetTimerEx

ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);LARGE_INTEGER duetime;BOOLEAN wascounting = KeSetTimerEx(timer, duetime, 0, NULL);

我将在本章后面解释该函数扩展版本的新参数。

即使定时器开始倒计时,它仍处于非信号态,直到到达指定的时间。在那个时刻,该定时器对象自动变为信号态,所有等待的线程都被释放。

 

 

 

同步定时器

 

 

与事件对象类似,定时器对象也有两种形式:通知方式和同步方式。通知定时器允许有任意数量的等待线程。同步定时器正相反,它只允许有一个等待线程。一旦有线程在这种定时器上等待,定时器就自动进入非信号态。为了创建同步定时器,你必须使用扩展形式的初始化函数:

ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);KeInitializeTimerEx(timer, SynchronizationTimer);

SynchronizationTimer是枚举类型TIMER_TYPE的一个枚举值。另一个枚举值是NotificationTimer

如果你在同步定时器上使用DPC例程,可以把排队DPC看成是定时器到期时发生的额外事情。即定时器到期时,系统把定时器置成信号态,并把DPC对象插入DPC队列。定时器进入信号态将使阻塞的线程得以释放。

 

 

周期性定时器

 

 

到现在为止,我们讨论过的定时器仅能定时一次。通过使用定时器的扩展设置函数,你可以请求一个周期性的超时:

ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);LARGE_INTEGER duetime;BOOLEAN wascounting = KeSetTimerEx(timer, duetime, period, dpc);

这里,period是周期超时值,单位为毫秒(ms),dpc是一个可选的指向KDPC对象的指针。这种定时器在第一次倒计时时使用duetime时间,到期后再使用period值重复倒计时。为了准确地完成周期定时,应该把duetime时间指定为与周期间隔参数一样的相对时间。指定为0的duetime参数将使定时器立即完成第一次倒计时,然后开始周期性倒计时。由于不用重复等待超时通知,所以周期性定时器常常与DPC对象联用。

取消一个周期性定时器

在定时器对象超出定义范围之外前,一定要调用KeCancelTimer取消任何已创建的周期性定时器。如果这个周期性定时器带有一个DPC,则还需要在取消该定时器之后调用KeRemoveQueueDpc。甚至即使你做了这两件事,还可能出现一个无法解决的问题。如果你在DriverUnload例程中取消这种定时器,可能会出现一种罕见的情形:你的驱动程序已被卸载,但那个DPC例程的实例却仍运行在另一个CPU上。这个问题只有等待未来版本的操作系统来解决。你可以尽早地取消这种定时器以便减少该问题出现的可能性,比如在IRP_MN_REMOVE_DEVICE的处理程序中。

一个例子

内核定时器的一个用处是为定期检测设备活动的系统线程提供循环定时。今天很少有设备需要循检服务,但你可能遇到例外情况。我将在第九章中讨论这个主题。随书光盘中有一个例子(POLLING)演示了这个概念。这个例子的部分代码以固定的间隔时间循检设备。这个循环可以被设置的kill事件所打破,所以程序使用了KeWaitForMultipleObjects函数。实际的代码要比下面的例子更复杂一些,下面代码片段主要侧重于定时器的使用:

VOID PollingThreadRoutine(PDEVICE_EXTENSION pdx){  NTSTATUS status;  KTIMER timer;  KeInitializeTimerEx(&timer, SynchronizationTimer);   <--1  PVOID pollevents[] = {      <--2    (PVOID) &pdx->evKill,    (PVOID) &timer,  };  ASSERT(arraysize(pollevents) <= THREAD_WAIT_OBJECTS);    LARGE_INTEGER duetime = {0};  #define POLLING_INTERVAL 500  KeSetTimerEx(&timer, duetime, POLLING_INTERVAL, NULL);  <--3  while (TRUE)  {    status = KeWaitForMultipleObjects(arraysize(pollevents),  <--4          pollevents,          WaitAny,          Executive,          KernelMode,          FALSE,          NULL,          NULL);    if (status == STATUS_WAIT_0)      break;    if (<device needs attention>)     <--5      <do something>;  }  KeCancelTimer(&timer);  PsTerminateSystemThread(STATUS_SUCCESS);}

  1. 在此,我们把一个内核定时器初始化成同步方式。它只能用于一个线程,本线程。
  2. 我们需要为KeWaitForMultipleObjects函数提供一个同步对象指针数组。第一个数组元素是kill事件对象,驱动程序的其它部分可能在系统线程需要退出时设置这个对象,以终止循环。第二个数组元素就是定时器对象。
  3. KeSetTimerEx语句启动周期定时器。由于duetime参数是0,所以定时器立即进入信号态。然后每隔500毫秒触发一次。
  4. 在循检循环内,我们等待定时器到期或kill事件发生。如果等待由于kill事件而结束,我们退出循环,并做一些清理工作,最后终止这个系统线程。如果等待是由定时器到期而结束,我们就前进到下一步处理。
  5. 在这里,设备驱动程序可以做一些与硬件有关的操作。

 

 

定时函数

 

 

除了使用内核定时器对象外,你还可以使用另外两个定时函数,它们也许更适合你。第一函数是KeDelayExecutionThread,你可以在PASSIVE_LEVEL级上调用该函数并给出一个时间间隔。该函数省去了使用定时器时的麻烦操作,如创建,初始化,设置,等待操作。

ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL);LARGE_INTEGER duetime;NSTATUS status = KeDelayExecutionThread(WaitMode, Alertable, &duetime);

在这里,WaitModeAlertable,和函数返回代码与KeWaitXxx中的对应部分有相同的含义。duetime也是内核定时器中使用的同一种时间表达类型。

如果你需要延迟一段非常短的时间(少于50毫秒),可以调用KeStallExecutionProcessor,在任何IRQL级上:

KeStallExecutionProcessor(nMicroSeconds);

这个延迟的目的是允许硬件在程序继续执行前有时间为下一次操作做准备。实际的延迟时间可能大大超过你请求的时间,因为KeStallExecutionProcessor可以被其它运行在更高IRQL级上的活动抢先,但不能被同一IRQL级上的活动抢先。

 

 

内核线程同步

 

 

操作系统的进程结构部件(Process Structure)提供了一些例程,WDM驱动程序可以使用这些例程创建和控制内核线程,这些例程可以帮助驱动程序周期性循检设备,我将在第九章中讨论这些例程。出于完整性考虑,我在这里先提一下。如果在KeWaitXxx调用中指定一个内核线程对象,那么你的线程将被阻塞直到那个内核线程结束运行。那个内核线程通过调用PsTerminateSystemThread函数终止自身。

为了等待某内核线程结束,你首先应获得一个KTHREAD对象(不透明对象)的指针,在内部,该对象用于代表内核线程,但这里还有一点问题,当你运行在某线程的上下文中时,你可以容易地获取当前线程的KTHREAD指针:

ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);PKTHREAD thread = KeGetCurrentThread();

不幸的是,当你调用PsCreateSystemThread创建新内核线程时,你仅能获取该线程的不透明句柄。为了获得KTHREAD对象指针,你必须使用对象管理器服务函数:

HANDLE hthread;PKTHREAD thread;PsCreateSystemThread(&hthread, ...);ObReferenceObjectByHandle(hthread,     THREAD_ALL_ACCESS,     NULL,     KernelMode,     (PVOID*) &thread,     NULL);ZwClose(hthread);

ObReferenceObjectByHandle函数把你提供的句柄转换成一个指向下层内核对象的指针。一旦有了这个指针,你就可以调用ZwClose关闭那个句柄。在某些地方,你还需要调用ObDereferenceObject函数释放对该线程对象的引用。

ObDereferenceObject(thread);
线程警惕和APC

在内部,Windows NT内核有时使用线程警惕(thread alert)来唤醒线程。这种方法使用APC(异步过程调用)来唤醒线程去执行某些特殊例程。用于生成警惕和APC的支持例程没有输出给WDM驱动程序开发者使用。但是,由于DDK文档和头文件中有大量地方引用了这个概念,所以我想在这里谈一下。

当某人通过调用KeWaitXxx例程阻塞一个线程时,需要指定一个布尔参数,该参数表明等待是否是警惕的(alertable)。一个警惕的等待可以提前完成,即不用满足任何等待条件或超时,仅由于线程警惕。线程警惕起源于用户模式的native API函数NtAlertThread。如果因为警惕等待提前终止,则内核返回特殊的状态值STATUS_ALERTED。

APC机制使操作系统能在特定线程上下文中执行一个函数。APC的异步含义是,系统可以有效地中断目标线程以执行一个外部例程。APC的动作有点类似于硬件中断使处理器从任何当前代码突然跳到ISR的情形,它是不可预见的。

APC来自三种地方:用户模式、内核模式,和特殊内核模式。用户模式代码通过调用Win32 API函数QueueUserAPC请求一个用户模式APC。内核模式代码通过调用一个未公开的函数请求一个APC,而且该函数在DDK头文件中没有原型。某些逆向工程师可能已经知道该例程的名称以及如何调用它,但该函数的确是仅用于内部,所以我不在这里讨论它。系统把APC排入一个特殊线程直到和适的执行条件出现。和适的执行条件要取决于APC的类型,如下:

  • 特殊的内核APC被尽可能快地执行,既只要APC_LEVEL级上有可调度的活动。在很多情况下,特殊的内核APC甚至能唤醒阻塞的线程。
  • 普通的内核APC仅在所有特殊APC都被执行完,并且目标线程仍在运行,同时该线程中也没有其它内核模式APC正执行时才执行。
  • 用户模式APC在所有内核模式APC执行完后才执行,并且仅在目标线程有警惕属性时才执行。

如果系统唤醒线程去提交一个APC,则使该线程阻塞的等待原语函数将返回特殊状态值STATUS_KERNEL_APC或STATUS_USER_APC。

 

 

APC与I/O请求

 

 

内核使用APC概念有多种目的。由于本书仅讨论驱动程序的编写,所以我仅解释APC与执行I/O操作之间的关系。在某些场合,当用户模式程序在一个句柄上执行同步的ReadFile操作时,Win32子系统就调用一个名为NtReadFile(尽管未公开,但已经被广泛了解)的内核模式例程。该函数创建并提交一个IRP到适当的设备驱动程序,而驱动程序通常返回STATUS_PENDING以指出操作未完成。NtReadFile然后向ReadFile也返回这个状态代码,于是ReadFile调用NtWaitForSingleObject函数,这将使应用程序在那个用户模式句柄指向的文件对象上等待。NtWaitForSingleObject接着调用KeWaitForSingleObject以执行一个非警惕的用户模式的等待,在文件对象内部的一个事件对象上等待。

当设备驱动程序最后完成了读操作时,它调用IoCompleteRequest函数,该函数接下来排队一个特殊的内核模式APC。该APC例程然后调用KeSetEvent函数使文件对象进入信号态,因此应用程序被释放并得以继续执行。有时,I/O请求被完成后还需要执行一些其它任务,如缓冲区复制,而这些操作又必须发生在请求线程的地址上下文中,因此会需要其它种类的APC。如果请求线程不处于警惕性的等待状态,则需要内核模式APC。如果在提交APC时线程并不适合运行,则需要特殊的APC。实际上,APC例程就是用于唤醒线程的机制。

内核模式例程也能调用NtReadFile函数。但驱动程序应该调用ZwReadFile函数替代,它使用与用户模式程序一样的系统服务接口到达NtReadFile(注意,NtReadFile函数未公开给设备驱动程序使用)。如果你遵守DDK的限定调用ZwReadFile函数,那么你向NtReadFile的调用与用户模式中的调用几乎没有什么不同,仅有两处不同。第一,ZwReadFile函数更小,并且任何等待都将在内核中完成。另一个不同之处是,如果你调用了ZwCreateFile函数并指定了同步操作,则I/O管理器将自动等待你的读操作直到完成。这个等待可以是警惕的也可以不是,取决于你在ZwCreateFile调用中指定的实际选项。

 

 

如何指定Alertable和WaitMode参数

 

 

现在你已经有足够的背景资料了解等待原语中的AlertableWaitMode参数。作为一个通用规则,你绝不要写同步响应用户模式请求的代码,仅能为确定的I/O控制请求这样做。一般说来,最好挂起长耗时的操作(从派遣例程中返回STATUS_PENDING代码)而以异步方式完成。再有,你不要一上来就调用等待原语。线程阻塞仅适合设备驱动程序中的某几个地方使用。下面几段介绍了这几个地方。

内核线程 有时,当你的设备需要周期性循检时,你需要创建自己的内核模式线程。

处理PnP请求 我将在第六章中讨论如何处理PnP管理器发送给你的I/O请求。有几个PnP请求需要你在驱动程序这边同步处理。换句话说,你把这些请求传递到低级驱动程序并等待它们完成。你将调用KeWaitForSingleObject函数并在内核模式中等待,这是由于PnP管理器是在内核模式线程的上下文中调用你的驱动程序。另外,如果你需要执行作为处理PnP请求一部分的辅助请求时,例如,与USB设备通信,你应在内核模式中等待。

处理其它I/O请求 当你正在处理其它种类的I/O请求时,并且你知道正运行在一个非任意线程上下文中时,那么你在行动前必须仔细考虑,如果你确信那个线程可以被阻塞,你应该在调用者所处于的处理器模式中等待。在多数情况下,你可以利用IRP中的RequestorMode域。此外,你还可以调用ExGetPreviousMode来确定前一个处理器模式。如果你在用户模式中等待,并允许用户程序调用QueueUserAPC提前终止等待,你应该执行一个警惕性等待。

我最后要提到的情况是,在用户模式中等待并要允许用户模式APC打断,你应使用警惕性等待。

底线是:使用非警惕性等待,除非你知道不这样做的原因。

在这一小节中,我们想让定时器去触发一个DPC例程。使用这种方法,不论你的线程有什么优先级都会响应超时事件。(因为线程只能在PASSIVE_LEVEL级上等待,而定时器到时间后,获取CPU控制权的线程是随机的。然而,DPC例程执行在提升的IRQL级上,它可以有效地抢先所有线程)

我们用同样的方法初始化定时器对象。另外我们还再初始化一个KDPC对象,该对象应该在非分页内存中分配。如下面代码:

PKDPC dpc;  //  points to KDPC you've allocatedASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL);KeInitializeTimer(timer);KeInitializeDpc(dpc, DpcRoutine, context);

用KeInitializeTimer或KeInitializeTimerEx初始化定时器对象。DpcRoutine是一个DPC(推迟过程调用)例程的地址,这个例程必须存在于非分页内存中。context参数是一个任意的32位值(类型为PVOID),它将作为参数传递给DPC例程。dpc参数是一个指向KDPC对象的指针(该对象必须在非分页内存中。例如,在你的设备扩展中)。

当开始启动定时器的倒计时,我们把DPC对象指定为KeSetTimer或KeSetTimerEx函数的一个参数:

ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);LARGE_INTEGER duetime;BOOLEAN wascounting = KeSetTimer(timer, duetime, dpc);

这个KeSetTimer调用与上一段中的调用的不同之处是,我们在最后一个参数中指定了一个DPC对象地址。当定时器时间到时,系统将把该DPC排入队列,并且只要条件允许就立即执行它。在最差的情况下,它也与在PASSIVE_LEVEL级上唤醒线程一样快。DPC函数的定义如下:

VOID DpcRoutine(PKDPC dpc, PVOID context, PVOID junk1, PVOID junk2){  ...}

即使你为KeSetTimer或KeSetTimerEx提供了DPC参数,你仍可以调用KeWaitXxx函数使自己在PASSIVE_LEVEL级上等待。在单CPU的系统上,DPC将在等待完成前执行,因为它执行在更高的IRQL上。