WDM与NDIS中的同步机制

来源:互联网 发布:cloud9 ide java 编辑:程序博客网 时间:2024/04/28 20:35

WDM与NDIS中的同步机制

Windows提供了几种系统服务用于实现同步。NDIS对其中的几个机制做了包装来提高代码的可移植性。深入理解这些同步机制对于开发人员编写抢占式代码流程是不可或缺的。下面就详细解释这些机制的使用方式和适用场景。

概述

同步的本质是为了在多任务环境中保证数据的有效性。在原始的单任务系统中,如DOS,绝不可能有多个任务同时运行。早期的Windows版本,如Windows 3,使用的也是所谓协作式多工(cooperative multitasking)。在这种场景下,代码的运行流程不会被强制打断。

DOS环境下中断处理是唯一的例外,这个场景我们暂不讨论。

在抢占式多工(preemptive multitasking)的场景下就完全不同。代码在运行过程中随时会被抢断。这个抢断会在CPU指令级别发生而不是在C或者C++这样的高级语言的单个语句中。考虑下面最简单的C语句。

// int a;a++;

编译后非常有可能产生下面的汇编

mov     eax, dword ptr [ebp-4]add     eax, 1mov     dword ptr [epb-4], eax

假设执行到第二条指令后当前线程被抢断,CPU转而去执行其他任务,在任务返回后接着执行最后一条语句。由于抢占过程并不会保证所有的寄存器内容不被改变,eax寄存器有可能存放任意数值。等执行完第三条指令后,变量a中保存的数据就完全不符合开发人员的预期。这种场景被称之为race condition。

Windows提供了不同的机制来防止出现这样的问题。在深入这些同步机制的细节之前,开发人员有必要理解Windows上的一个概念————调度器。

调度器 (Dispatcher)

学习过操作系统概念的开发人员都有进程(process)和线程(thread)的概念。程序(program)和进程概念上的差别这里不在讨论。这里只详细讲述线程这个概念。

根据操作系统的定义,线程是cpu可调度的单元。所有代码必然在某个线程中被执行。如果没有明确建立新的线程,在进程建立时,操作系统会为进程建立一个主线程(main thread)。在Windows中,这个概念依然完全适用。每个用户程序在开始运行时,系统就会为它建立一个主线程。应用程序中的线程,实际上是内核线程的一个影子。Windows内核也会建立一些线程,这些线程与应用程序的线程之间没有任何关联,被称之为内核线程(kernel thread)。内核态的代码就执行在这些内核线程之上————用户编写的内核态代码,当然也可以建立自己的内核线程。而调度器的主要工作,就是选择某一个线程作让cpu来执行。

线程包含一些状态来定义调度器对他们的处理方式。

  • initialized 初始化完成,该状态存在于线程刚刚建立的时候。线程一旦建立完成,要么进入ready状态,要么进入waiting状态;
  • ready 入队状态,允许调度器把该线程放入cpu运行队列中;
  • standby 等待状态,已经被放入cpu的运行队列中,等待cpu来执行
  • running 运行状态,已经被cpu选中并执行
  • waiting 等待状态,等待一个dispatcher object称为激活状态
  • terminated 结束状态,线程已经结束并且很快就会被销毁

调度器以时间片(time slide)的方式来运行线程。当时间片到期或者线程主动放弃执行转而等待某个事件,调度器就会重新选择其他线程。有一些参数会影响到调度器对线程的选择,最主要的就是线程的优先级。关于优先级只有一个定义————高优先级的线程会被优先调度。

在静态优先级的场景下,如果高优先级的线程一直存在,cpu就会反复调度该线程,而其他线程就完全没有机会执行。这种情况被称之为线程饥渴(thread starvation)。所以Windows使用动态优先级的方式,线程的优先级会随时间的推移变得越来越高,直到被调度器选中。

能够影响到调度器对线程选择的对象,在Windows中被称之为内核调度对象(kernel dispatcher objects),或者简称调度对象(dispatcher objects)。Windows系统提供的调度对象包括:

  • 计时器(timer objects)
  • 事件(event objects)
  • 信号量(semaphore objects)
  • 互斥(mutex objects)
  • 线程对象(thread objects)

有些上面的概念,你就可以明白为什么KeWaitForSingleObject函数可以等待这些对象。实际上KeWaitForSingleObject就是工作在dispatcher objects之上的。

调度器的工作模型简单而有效。很多操作系统就是按照上面的逻辑来建立内核线程的调度功能。Windows则额外提供了另一个概念————IRQL。IRQL结合另外一个底层同步机制自旋锁,共同构建了所有同步方式的基础。

IRQL

IRQL(interrupt request level)是Windows内核对Intel CPU执行级别的一种体现————但并不是一一对应的那种。Intel CPU把执行级别分成4种,分别是RING0~RING3,每个指令也有各自对执行级别的需求。大多数指令能够执行在所有级别上,但有些只能在某些级别执行。Windows只用了RING0和RING3。RING0对应着用户程序,RING3对应着Windows内核。IRQL的概念只适用于Windows内核。

IRQL一共分成32级,大多数只对内核本身有意义。开发人员要记住的只有下面3种。

  • PASSIVE_LEVEL
  • APC_LEVEL
  • DISPATCH_LEVEL

DISPATCH_LEVEL之上的级别,统称为DIRQL(device IRQL)。

在实践中,开发人员只要记住下面的准则就可以编写出完全符合Windows内核要求的代码。

  1. 低优先级的IRQL会被高优先级的IRQL抢断;
  2. 所有执行在DISPATCH_LEVEL或更高级别IRQL上的代码,不能产生缺页中断;
  3. 所有执行在DISPATCH_LEVEL或更高级别IRQL上的代码,不能等待(除了busy-wait);
  4. DIRQL上只能完成最小的工作来确定中断是否产生于当前硬件;
  5. 只有在代码提高IRQL的情况下,才可以降低IRQL到原来的级别;

下面我们来详细解释上面这些准则产生的原因。

在Windows内核执行过程中,任何低IRQL级别的代码会被高IRQL级别的代码中断。等CPU完成了高IRQL级别的工作,系统会切换低IRQL。代码流程会在被打断的位置上开始执行。与线程的切换相同,这个抢断同样发生在CPU指令级别而不是在C或C++语句级别。

Windows系统的调度器就运行在DISPATCH_LEVEL的最低级别上————这并不是说DISPATCH_LEVEL内部有子级别,而是调度器会在CPU切换回更低IRQL之前执行。Windows系统的调度器除了完成线程调度这个功能外,同时也会完成缺页内存分配的工作。所以准则2准则3就是显而易见的。如果运行在DISPATCH_LEVEL上的代码产生了缺页中断,系统永远也无法完成缺页内存的分配。如果在DISPATCH_LEVEL级别上做了等待,由于系统无法切换到其他线程,所以等待永远不会完成。

Windows上的很多驱动需要操作硬件。每个这样的驱动都遵循准则4来处理硬件中断。在中断服务程序(ISR,Interrupt Service Routine)中快速检测中断是否由本硬件产生,如果是的话,就调度一个DPC(Deferred Procedure Call)来完成实际的中断处理。例如PCI接口的以太网卡,在收包中断中确认是当前硬件产生了中断,然后调度DPC来完成实际接收网络数据的工作。在共享中断的系统中(现在intel和ARM的CPU都是如此)上面的处理流程可以显著提高整个系统的性能。

DPC(Deferred Procedure Call)的细节讨论参考我们这个系列的其他文档。

通过系统提供的API,驱动代码可以提高或降低运行级别。

VOID KeRaiseIrql(    IN KIRQL  NewIrql,    OUT PKIRQL  OldIrql    );VOID KeLowerIrql(    IN KIRQL  NewIrql    );

把当前线程的执行级别提高到DISPATCH_LEVEL后,可以防止当前线程被调度器切换到其他DISPATCH_LEVEL级别的代码上。

准则5定义只有下面的代码流程才是合法的。

// raise IRQL to dispatch levelKIRQL OldIrql;KeRaiseIrql(DISPATCH_LEVEL, &OldIrql);// do jobs in dispatch level// lower IRQL to original oneKeLowerIrql(OldIrql);

而下面的代码流程是非法的,会造成无法预期的效果。

// in dispatch level alreadyKIRQL OldIrql;KeLowerIrql(PASSIVE_LEVEL);// wait somethingKeRaiseIrql(DISPATCH_LEVEL, &OldIrql);

NDIS也提供了2个宏来管理IRQL。

#define NDIS_RAISE_IRQL_TO_DISPATCH(_pIrql_)     KeRaiseIrql(DISPATCH_LEVEL, _pIrql_)#define NDIS_LOWER_IRQL(_OldIrql_, _CurIrql_)                       \    {                                                               \        if (_OldIrql_ != _CurIrql_) KeLowerIrql(_OldIrql_);         \    }

自旋锁 (spin lock)

在多CPU环境中,自旋锁(spin lock)是所有同步机制中最底层的部分。任何可能被多个CPU同时修改的内存都必须使用自旋锁进行保护。对于自旋锁的操作非常简单,同时也暴露出操作系统内部对于这些底层API实现的细节。

自旋锁的初始化

NDIS提供的自旋锁被定义成NDIS_SPIN_LOCK。

typedef struct _NDIS_SPIN_LOCK{    KSPIN_LOCK  SpinLock;    KIRQL       OldIrql;} NDIS_SPIN_LOCK, * PNDIS_SPIN_LOCK;

可以看到该结构是对WDM中KSPIN_LOCK进行了简单的包装。KIRQL类型的OldIrql用于保存自旋锁在获取前CPU的执行级别。

NDIS提供了一个API来初始化NDIS_SPIN_LOCK结构。

VOIDNdisAllocateSpinLock(    IN PNDIS_SPIN_LOCK  SpinLock);

因为SpinLock变量会被DISPATCH_LEVEL级别的代码存取到,所以该变量必须存放在非分页内存上防止出现缺页中断。通常该变量会放在设备操作上下文(device context)中。

为了对称性,NDIS同时提供了一个API用于释放自旋锁。

VOIDNdisFreeSpinLock(    IN PNDIS_SPIN_LOCK  SpinLock);

实际上该函数并不会做任何工作而被定义成空。

// in ndis.h#define NdisFreeSpinLock(_SpinLock)

自旋锁的获取与释放

NDIS使用了2个很简单的API来获取自旋锁。

VOIDNdisAcquireSpinLock(    IN PNDIS_SPIN_LOCK  SpinLock    );VOIDNdisDprAcquireSpinLock(    IN PNDIS_SPIN_LOCK  SpinLock    );

NdisAcquireSpinLock可以在DISPATCH_LEVEL或更低级别运行。执行该函数后,当前线程的级别提高到DISPATCH_LEVEL,原来的执行级别被存放到NDIS_SPIN_LOCK的OldIrql中。其他尝试获取该自旋锁的线程会进入busy-wait状态。NdisDprAcquireSpinLock则是对NdisAcquireSpinLock的一个优化,必须在DISPATCH_LEVEL级别上执行,获取自旋锁的时候。

由于自旋锁使用busy-wait的方式来同步,所以使用自旋锁保护的代码流程要尽可能短。完成必要的功能后,需要立即使用下面两个函数之一来释放自旋锁。

VOIDNdisReleaseSpinLock(    IN PNDIS_SPIN_LOCK  SpinLock    );VOIDNdisDprReleaseSpinLock(    IN PNDIS_SPIN_LOCK  SpinLock    );

在释放自旋锁后,当前线程会切换到获取自旋锁之前的运行级别。所以上面的API需要对称使用。用NdisAcquireSpinLock获取的自旋锁,一定要用NdisReleaseSpinLock来释放,反之亦然。

Windows 95及其几个变种,如Windows 98和WinMe,只能安装在单CPU的环境中。所以系统使用一个简化的方式来实现自旋锁。NdisAcquireSpinLock仅仅是把当前线程的执行级别提高到DISPATCH_LEVEL。

// pseudo code for Win95 NdisAcquireSpinLock implementationvoid NdisAcquireSpinLock(PNDIS_SPIN_LOCK SpinLock) {    KeRaiseIrql(DISPATCH_LEVEL, &SpinLock->OldIrql);}

在单CPU的环境中,上面的实现就完全符合了自旋锁的语义。1)提高当前线程的执行级别到DISPATCH_LEVEL;2)其他cpu无法获取该自旋锁————根本就没有其他cpu!

自然释放自旋锁就是降低IRQL到原来的级别。

// pseudo code for win95 NdisReleaseSpinLock implementationvoid NdisReleaseSpinLock(PNDIS_SPIN_LOCK SpinLock) {    KeLowerIrql(SpinLock->OldIrql);}

使用自旋锁注意事项

死锁(dead-lock)是自旋锁使用过程中最容易发生的问题。下面的代码必然会引发一个死锁。

// global spin lock, initialized in other placeNDIS_SPIN_LOCK MyLock;void MyFunction() {    NdisAcquireSpinLock(&MyLock);    // do something    NdisReleaseSpinLock(&MyLock);}NdisAcquireSpin(&MyLock);MyFunction();   // <-- dead-lock here!

上面代码中的问题很容易发现并解决,但是有些发生死锁的场景就不太容易被察觉。

// global spin lock variables initialized in other placeNDIS_SPIN_LOCK  MyLock1;NDIS_SPIN_LOCK  MyLock2;void MyFunction1() {    NdisAcquireSpinLock(&MyLock1);                                      // <-- place_1    NdisAcquireSpinLock(&MyLock2);    // do something    NdisReleaseSpinLock(&MyLock2);    NdisReleaseSpinLock(&MyLock1);}void MyFunction2() {    NdisAcquireSpinLock(&MyLock2);                                    // <-- place_2    NdisAcquireSpinLock(&MyLock1);    // do something    NdisReleaseSpinLock(&MyLock1);    NdisReleaseSpinLock(&MyLock2);}

如果MyFunction1和MyFunction2会在不同的cpu上同时运行,那上面的实现就有概率发生死锁。假设cpu1执行MyFunction1到place_1处,而cpu2执行到MyFunction2的place_2处。这时MyLock1和MyLock2分别被cpu1和cpu2获取着,两个cpu都无法获取另外一个自旋锁。

使用下面的自旋锁使用规则就可以有效避免死锁的发生。

  • 多个自旋锁必须以相同的顺序获取,以相反的顺序释放
  • 单个函数尽可能满足自旋锁的状态不变,即在函数内部完成一次自旋锁的获取和释放
  • 驱动返回执行流程到系统内时,释放所有获取的自旋锁

原子操作

使用自旋锁,能够很容易实现对内存的原子操作。例如,下面的代码就很好地解决了对一个LONG变量的原子操作。即使在多CPU环境中,该变量也是安全的。

// initialized elsewhereLONG count;NDIS_SPIN_LOCK MyLock;void IncrementCount() {    NdisAcquireSpinLock(&MyLock);    ++count;    NdisReleaseSpinLock(&MyLock);}void DecrementCount() {    NdisAcquireSpinLock(&MyLock);    --count;    NdisReleaseSpinLock(&MyLock);}

对于复杂变量的操作进行自旋锁的保护还情有可原,如果对于一个简单的LONG类型变量进行操作也要做这样的保护,那代码就显得过度冗余。为此,Windows内核提供了方便API来操作LONG类型,NDIS对这些API进行了包装。

LONG InterlockedIncrement(    IN PLONG  Addend    );LONG InterlockedDecrement(    IN PLONG  Addend    );

NDIS包装的形态。

LONG NdisInterlockedIncrement(    IN PLONG  Addend    );LONG NdisInterlockedDecrement(    IN PLONG  Addend    );

InterlockedIncrement和NdisInterlockedDecrement输入一个LONG类型变量的地址,返回已经被加1的值。InterlockedDecrement和NdisInterlockedDecrement有完全相同的函数原型,返回被减1的值。

利用NDIS的这组API,可以很容易的防止下面代码中存在的问题。

// initialized elsewhereLONG count;void AddOne() {    ++count;}

根据我们前面的知识,如果AddOne函数会在多个CPU上同时执行,count变量就有可能和用户的预期不同。用下面的版本就可以解决这个潜在的问题。

void AddOne() {    NdisInterlockedIncrement(&count);}

除了这两个最基本的形态,NDIS还为一个特殊的类型定义了原子操作版本。LARGE_INTEGER被广泛使用在NDIS驱动中,通常作为统计信息存在,例如记录一个驱动从某片网卡上收到了多少字节的数据包。这时,用下面的版本可以很容易进行该统计信息的管理。

VOIDNdisInterlockedAddLargeStatistic(    IN PLARGE_INTEGER  Addend,    IN ULONG  Increment    );

如果你有Linux内核编码的经验,你会惊讶于这些函数的语义和Linux内核中的atomic_t类型操作函数非常一致。例如,InterlockedIncrement就完全等价于Linux内核的atomic_inc_return。

int atomic_inc_return(atomic_t *v);

最简单的调度对象——事件(Event Objects)

使用方法

只要接触过Windows多线程操作,一定会熟悉事件(event)这一概念。用户模式下所有对事件的操作完全一一对应到内核态对事件对象的操作。下面是Windows内核对事件对象的操作函数。

VOID KeInitializeEvent(    IN PRKEVENT  Event,    IN EVENT_TYPE  Type,    IN BOOLEAN  State    );LONG KeSetEvent(    IN PRKEVENT  Event,    IN KPRIORITY  Increment,    IN BOOLEAN  Wait    );VOID KeClearEvent(    IN PRKEVENT  Event    );LONG KeResetEvent(    IN PRKEVENT  Event    );

KeInitializeEvent用于初始化一个事件对象,同时可指明其类型和初始状态。事件对象类型分成NotificationEvent和SynchronizationEvent,前者在win32环境中被称为manual-reset,后者当然就是auto reset,其差别在于事件对象被激活时是否允许多个等待中的线程可以被调度。

KeSetEvent用于把事件对象变成激活状态。依赖于事件对象类型,等待中的一个或多个线程变成可调度状态。如果事件对象是SynchronizationEvent, 同时被激活时没有线程等待该事件对象时,事件对象保持激活状态,直到某个线程等待他。该线程会立刻称为可调度状态同时事件对象变成非激活状态。

KeClearEvent和KeResetEvent都是把事件对象变成非激活状态。他们的唯一差别是KeResetEvent同时会返回之前的状态是激活还是非激活。

等待,等待,等待是美德

所有调度对象统一由一个函数族来实现等待功能。

NTSTATUS KeWaitForSingleObject(    IN PVOID  Object,    IN KWAIT_REASON  WaitReason,    IN KPROCESSOR_MODE  WaitMode,    IN BOOLEAN  Alertable,    IN PLARGE_INTEGER  Timeout OPTIONAL    );NTSTATUS KeWaitForMultipleObjects(    IN ULONG  Count,    IN PVOID  Object[],    IN WAIT_TYPE  WaitType,    IN KWAIT_REASON  WaitReason,    IN KPROCESSOR_MODE  WaitMode,    IN BOOLEAN  Alertable,    IN PLARGE_INTEGER  Timeout OPTIONAL,    IN PKWAIT_BLOCK  WaitBlockArray OPTIONAL    );

刚刚接触Windows内核开发的工程师会被上面函数的参数数量吓到。其实大多数参数都很简单,要么有其固定用法,要么完全是用户模式下API的功能精化。

WaitReadon和WaitMode,在绝大多数情况下是Executive和KernelMode。Alertable用于指明等待的时候是否允许调度APC。Timeout参数则是等待的时间(以100微妙位单位)

APC(Asynchronous Procedure Call)的细节讨论参考我们这个系列的其他文档。

Timeout参数可以指明是绝对时间或相对时间。在大于等于0的情况下指明是绝对时间,起始于1601年1月1日0时;在小于0的情况下是相对于当前时间的差距。内核态的代码流程在绝大多数情况下使用的都是相对时间。

KeWaitForMultipleObjects包含了一些额外的复杂性。为了性能的考虑,当需要等待的调度对象的数量大于等于THREAD_WAIT_OBJECTS(在wdm.h中被定义成3)时,WaitBlockArray参数需要指向一块内存,其大小是sizeof(KWAIT_BLOCK) * Count。这一点不如他在用户模式下的兄弟函数。WaitForMultipleObjects对最大可等待对象的数量限制是必须小于等于MAXIMUM_WAIT_OBJECTS,该值被定义成64。

KeWaitForSingleObject和KeWaitForMultipleObjects都会把当前线程状态变成等待。直到得到等待的结果或者超时,线程再次变成可调度状态。

NDIS对事件对象API的包装

NDIS对事件对象做了非常简单的封装,所有函数名称和功能都是望文知义。唯一需要注意的是NdisWaitEvent函数的MsToWait参数,当该参数是0值时,表明是永远等待,这一点和用户模式下INFINITE声明的功能相同————但值是不同的。

VOIDNdisInitializeEvent(    IN PNDIS_EVENT  Event    );VOIDNdisResetEvent(    IN PNDIS_EVENT  Event    );VOIDNdisSetEvent(    IN PNDIS_EVENT  Event    );BOOLEANNdisWaitEvent(    IN PNDIS_EVENT  Event,    IN UINT  MsToWait    ); 

其他调度对象(Dispatcher Objects)

信号量(Semaphore)和互斥对象(Mutex)

信号量和互斥对象的概念非常类似,他们都是闸门(gate)。当资源有效的时候,允许一定数量的线程通过其闸门继续运行,当资源无效的时候,线程会等待直到资源有效或者超时。信号量和互斥对象唯一的差别是信号量允许用户决定资源有效的数量,而互斥对象的资源数量最高是1。实际上,当你使用如下的代码初始化一个信号量的时候,其作用等同于初始化一个互斥对象。

KeInitializeSemaphore(&aSemaphore, 1, 1);

超时器对象(timer)在某种层面上是一个带延迟的事件对象。在超时没有到的时候,超时器对象处于非激活状态;当超时到的时候,超时器对象就是激活状态。超时器对象从非激活状态转变到激活状态时,依然有事件对象的差别。NotificationTimer对应于NotificationEvent,称为激活状态后会一直保持,任何等待该时间的线程会立刻变成可调度状态;SynchronizationTimer则对应于SynchronizationEvent,具体含义参考事件对象中对于两者的说明。

超时器对象可以关联一个DPC,在达到指定时间后系统会自动调度该DPC。关于DPC的讨论参考后面的章节。

线程对象也是可以等待的一个对象。线程对象一直处于非激活状态直到该线程结束。一旦线程结束,线程对象变为激活状态。这样一个线程就可以等待另外一个线程结束。

在Windows内核中,通过PsCreateSystemThread建立出来的内核线程,其返回的句柄并不是线程对象,不能放到等待函数中。通过ObReferenceObjectByHandle函数,可以把线程句柄转换成线程对象,然后进行等待。等待完成后,通过ObDereferenceObject释放线程对象。

NDIS中对几个调度对象的包装

除了事件对象,NDIS只包装了其他调度对象中的计时器对象。下面是NDIS 5.1中对计时器对象的操作函数。具体参数细节请参考MSDN中相关文档。

VOID NdisMInitializeTimer(    IN OUT PNDIS_MINIPORT_TIMER  Timer,    IN NDIS_HANDLE  MiniportAdapterHandle,    IN PNDIS_TIMER_FUNCTION  TimerFunction,    IN PVOID  FunctionContext    );VOID NdisMCancelTimer(    IN PNDIS_MINIPORT_TIMER  Timer,    OUT PBOOLEAN  TimerCancelled    );VOID NdisMSetPeriodicTimer(    IN PNDIS_MINIPORT_TIMER Timer,    IN UINT  MillisecondsPeriod    );VOID NdisMSetTimer(    IN PNDIS_MINIPORT_TIMER  Timer,    IN UINT  MillisecondsToDelay    );

知识点回顾

知识点简要说明线程状态决定调度器对线程的处理调度对象能够影响到当前线程状态的对象IRQL线程执行的级别,分成PASSIVE_LEVEL, APC_LEVEL和DISPATCH_LEVEL自旋锁(spin lock)同步多cpu对内存的存取原子操作InterlockedXXX函数族事件对象(event object)最简单的调度器对象等待KeWaitXXX函数族
0 0
原创粉丝点击