作为驱动程序开发者我们需要了解的是:执行在某线程上下文中的代码在任何时刻都可能被系统夺去控制权。另外,只有在多处理器的计算机上才能真正实现多线程的并发执行。一般,我们需要做两个最差的假定:
- 操作系统可以在任何时间抢先任何例程并停留任何长的时间,所以我们不能保证自己的任务不被干扰或延迟。
- 即使我们能防止被抢先,但其它CPU上执行的代码也会干扰我们代码的执行,甚至一个程序的代码可以在两个不同线程的上下文中并发执行。
Windows NT为解决一般的同步问题提供了两种方法,一个是中断请求优先级(IRQL)方案,另一个是在关键代码段周围声明和释放自旋锁。IRQL可以避免在单CPU上的破坏性抢先,而自旋锁可以防止多CPU间的干扰。
中断的同步
编写执行在多IRQL的可重入的代码需要注意同步问题。这个部分将介绍这个问题的解决。
问题的产生
如果执行在两个不同的IRQLs的代码尝试同时访问同一个数据结构,这个数据结构可能被破坏。图5.1说明了这个问题。
请考虑如下问题:
1. 假设一段低IRQL的代码决定去修改foo数据结构中的几个数。如设置foo.x为1。
2. 这时一个中断产生,一段高优先级的代码得到处理器的控制权,这段代码将设置foo.x为10 , foo.y为20。
3. 当这段高优先级的代码离开,控制权又回到了那段低IRQL的代码,它设置foo.y为2。
4. 这时foo数据结构产生了矛盾,x为10而y为2,不是我们想得到的结果。
图5.1中断的同步问题
当然,当多个线程使用同一个的共享数据也会产生相似的结果。当一个代码尝试增加这个数据,这个改变的数据将占用处理器的寄存器一段时间。如果这个线程在传送这个寄存器的数据回到变量之前被中断,同样的问题产生。
下面介绍一些解决这个问题的方法:
中断阻塞
低的IRQL的例程可以通过防止被中断来避免同步的问题,它可以暂时的提高CPU的IRQL,完成操作之后使IRQL回到原来的水平。这个技术称作中断阻塞。表5.4列出了驱动程序更改CPU IRQL内核函数。
函数名 |
意义 |
KeRaiseIrql |
更改CPU IRQL到指定的值 |
KeLowerIrql |
降低CPU IRQL值 |
KeGetCurrentIrql |
返回这个调用的CPU IRQL值 |
表5.4 更改CPU IRQL内核函数
阻塞中断的规则
使用中断阻塞必须遵循的规则如下:
1. 接触的每一个共享数据结构数据的代码必须使用同一个IRQL,除非IRQL在选择的级别上否则不能访问共享的数据结构。
2. 如果低的IRQL的代码提升到允许的IRQL,它必须尽可能快的恢复原来的IRQL。如果不遵循这个规则,根据不同的阻塞级别,其它的硬件中断可能被阻塞很长时间。
3. 如果一个驱动程序代码的IRQL被提升了,一定不能将它的IRQL降到原来的IRQL以下。违反这个规则将破坏整个系统的优先权机制。
使用DPC同步
DPC是另外一个避免数据冲突的方法。如果所有的内核模式组件仅仅使用DPC例程去访问共享的数据,就不会产生冲突的问题,因为DPC例程总是串行的执行。使用DPC来进行同步的主要原因是它们运行较低的IRQL。
DPC例程的另一个优点是内核模式的DPC派遣器自动的处理多处理器的同步问题。
多处理器同步
在多处理器系统中,改变一个处理器的IRQL并不影响其它处理器的IRQL,因此IRQL的提高仅仅保护本地数据。为了在多处理器的环境中保护数据,WIN2000使用了叫做自旋锁的同步对象。
自旋锁如何工作
自旋锁是一个与一定数据结构相关联的互斥对象。当内核模式的代码想要去访问被保护的数据结构,必须先请求相关联的自旋锁的拥有权,因为同一时刻只有一个处理器可以得到自旋锁的拥有权,所以这个数据是安全的。任何处理器请求一个已经被拥有的自旋锁的操作将被悬挂直到自旋锁变的可用。图5.2说明了这个过程。
自旋锁总是在特定的IRQL下工作。这样有在本地处理器阻塞危险中断和防止同步问题的作用。当一个处理器等待自旋锁的时候,所有相同或者更低IRQL的代码在这个处理器上被阻塞,提高IRQL水平和请求自旋锁是非常重要的。
使用自旋锁
内核提供两种重要的自旋锁,它们使用的IRQL不同:
1. 中断自旋锁。同步和提供访问驱动程序的被多个设备共享的数据结构的例程,它在与设备相关联的DIRQL上获得。
2. Executive自旋锁。它保护操作系统的数据结构,它们相关联的IRQL是DISPATCH_LEVEL。
当一个设备使用中断自旋锁的时候,操作是相当简单的。函数KeSynchronizeExecution将在介绍中断I/O的时候讨论。
图5.2 自旋锁的工作过程
使用Executive自旋锁的过程相当复杂,必须按照以下的步骤来使用自旋锁:
1. 决定什幺数据必须被保护和需要多少自旋锁。较多的自旋锁允许程序更大程度的同步执行,但是同时获得多于一个的自旋锁可能引起死锁。
2. 为每一个自旋锁准备一个KSPIN_LOCK数据结构。自旋锁必须存储在非分页池。通常,自旋锁在设备或者控制器的Extension中声明。
3. 通过KeInitializeSpinLock函数初始化自旋锁一次,这个函数可以在任何IRQL上调用。大多数情况下在DriverEntry例程中调用。
4. 在访问任何自旋锁保护的数据之前调用KeAcquireSpinLock,这个函数提高IRQL到DISPATCH_LEVEL,获得自旋锁后回到之前的IRQL。它必须被低于或者等于DISPATCH_LEVEL IRQL的代码调用。如果代码的IRQL已经在DISPATCH_LEVEL,这时调用KeAcquireSpinLockFromDpcLevel会更加有效。
5. 当访问资源完成后,使用KeReleaseSpinLock函数释放自旋锁。调用这个函数的代码的IRQL是DISPATCH_LEVEL, 它将把IRQL恢复到原来的值。如果原来的值是DISPATCH_LEVEL,这时调用KeReleaseSpinLockFromDpcLevel会更加有效一些,它释放自旋锁而不改变IRQL
一些驱动程序支持例程(像互锁列表和队列)使用Executive自旋锁来保护数据。在这种情况下,只需要初始化自旋锁就可以了,管理互锁对象的例程自己请求和释放自旋锁。
使用自旋锁的规则
使用自旋锁不是十分困难,但是必须遵循以下几点:
1. 尽快释放自旋锁,因为当一个处理器拥有它的时候,其它的处理器的执行可能被阻塞。官方的DDK推荐不要拥有一个自旋锁超过25ms。
2. 当拥有自旋锁的时候,不要引起任何软件或者硬件的异常。这样会引起系统崩溃。
3. 当拥有自旋锁的时候,不要访问任何分页的代码或者数据。这样可能导致缺页故障。
4. 当拥有自旋锁的时候,不要尝试请求处理器已经拥有的自旋锁。这将导致死锁,因为处理器等待它自己释放自旋锁。
5. 避免设计同一个时刻需要多个自旋锁的程序。除非特别小心,否则会死锁。如果必须使用这种情况,确定请求它们必须要一个固定的顺序,释放它们的顺序则于这个顺序相反。