Cover

 [下一页]

cover
Copyright© 1999 by Walter Oney


LOC Page

 [上一页] [下一页]

PUBLISHED BY

微软出版社

微软公司的子公司

One Microsoft Way Redmond, Washington 98052-6399

版权 沃尔特 oney 1999 所有版权保留.

没有出版者的书面许可这本书的内容的任何部份不可以复制或传播不论何种形式的或无论如何。

Library of Congress Cataloging-in-Publication Data Oney, Walter.
Programming the Microsoft Windows Driver Model p. cm. Includes index.
ISBN 0-7356-0588-2
1. Microsoft Windows NT device drivers (Computer programs)
2. Computer programming. I. Title QA76.76.D49O54 1999 005.7'126--dc21 99-33878 CIP

印刷和装订于美国。

1 2 3 4 5 6 7 8 9 QMQM 4 3 2 1 0 9

发行在加拿大by企鹅书业加拿大股份有限公司
此书的CIP目录分类档案生效在英国图书馆。
Microsoft Press books are available through booksellers and distributors worldwide. For further information about international editions, contact your local Microsoft Corporation office or contact Microsoft Press International directly at fax (425) 936-7329. Visit our Web site at mspress.microsoft.com. Intel is a registered trademark of Intel Corporation. Microsoft, Microsoft Press, MSDN, Visual C++, Visual Studio, Win32, Windows, and Windows NT are either registered trademarks or trademarks of Microsoft Corporation in the United States and/or other countries. Other product and company names mentioned herein may be the trademarks of their respective owners. The example companies, organizations, products, people, and events depicted herein are fictitious. No association with any real company, organization, product, person, or event is intended or should be inferred.

采集编辑: Ben Ryan

项目编辑: Devon Musgrave

技术编辑: Robert Lyon


Dedication

献给

我的父母亲，他们给了我生命并教会我做一切。


Chapter 1 -- COM Plus What?

 [上一页 [下一页]

前言

Windows Driver Model(WDM)的根源可追溯到几年前一种叫做Windows for Workgroups 3.10的操作系统。那时候我们努力地支持无数不同的SCSI控制器，我长期地注意WindowsNT开发组创建的小端口驱动程序类型。不久就认识到重新构造必要的映象加载器(image loader)和小端口驱动程序需要的执行环境比把这些小端口驱动程序重写成某些VXD形式的驱动程序并调试完毕所花费的努力要少得多。

不幸的是,Windows from Workgroups 3.10已经停止发行带有SCSI小端口支持的版本,主要是由于象ASPI(高级SCSI编程接口)这样的外围问题。然而，跨Windows和windows NT操作系统共享同样的驱动程序执行映象的基础是适当的并且可以在win95中见到，它(win95)可以与NT共享SCSI和NDIS小端口驱动程序二进制代码。

共享驱动程序模式的潜在意义是重大的。驱动程序开发人员感兴趣的是支持双平台，共享驱动模式能降低开发和调试的一半开消。对微软来说，共享模式意味着更容易地从win9x迁移到Windows2000或这个平台的未来版本。对最终用户来说，大量不同种类的稳定驱动程序可以在这个家族中的所有成员之间通用。

下一个(逻辑)步骤是

The next logical step, then, was to create a driver model with the ability to share general purpose drivers across both platforms. But what form should it take? Three requirements were immediately obvious: it must be multiprocessor-capable, it must be processor-independent, and it must support Plug and Play (PnP). Fortunately, the Windows NT 4.0 driver model met the first two requirements, and it seemed clear that the next major release of Windows NT would support PnP as well. As a result, WDM can be considered a proper subset of what is now the Windows NT driver model.

The potential benefits of a shared driver model can be realized today for many classes of devices, and choosing the WDM driver model will continue to pay dividends in the future. For example, a correctly written WDM driver requires only a recompile before functioning in an NT 64-bit environment prototype.

WDM will continue to evolve as new platforms and device classes are supported. Future versions of Windows 9x and Windows 2000 will contain upwardly revised WDM execution environments. Fortunately, WDM is designed to be "backward compatible," meaning that WDM drivers written according to the Windows 2000 DDK and designed to work for the intended environment will continue to work in a subsequent WDM environments.

There is a lot to WDM, and in this book Walter does an excellent job in offering an in-depth tour of every aspect as well as the philosophy of the Windows Driver Model.

Forrest Foltz
微软公司Windows开发体系结构设计者


感谢

致谢

我感谢所有帮助我完成此书的人们. Devon Musgrave, Robert Lyon, 和其余为把此书从原始的winword手稿转换成在你手中的精美作品而服务的微软出版社工作人员。我清楚为了这个项目采编Ben Ryan花费无数小时并飞行数千英里寻找好的作者和有用的新书,祝他下次好运。 微软的Sandy Spinrad,在百忙之中还有力地协助查找技术资料，作硬件测试，更新版本,和我所依赖的其它许多资料。许多windows98 和windows2000基本开发组成员审阅了这些材料，值得个别提起，他们要求匿名，但至少我知道他们是谁。我们研究会的学者和网上团体在大大小小的各个方面给予帮助,提出深刻的问题和共享难得的洞察力。 最后,我要感谢我的妻子 Marty, 她总是在我工作最困难时在我身边。

Walter Oney
http://www.oneysoft.com

驱动开发网的全体翻译人员也要感谢网上为我们提供工具和协助翻译的人们。znsoft要感谢他的女友每天打电话问候他。


第一章：导言

以某种观点来看，Windows 2000或Windows 98都是由一个操作系统核心和多个驱动程序组成，这些驱动程序与系统中的硬件相对应。本书的内容全部都是关于驱动程序及其相关的技术。

  • 操作系统概述
  • Windows 2000驱动程序
  • 例子代码
  • 本书的结构
  • 其它资源
  • 注意事项


操作系统概述

WDM模型为存在于Windows 98和Windows 2000操作系统中的设备驱动程序提供了一个参考框架。尽管对于最终用户来说这两个操作系统非常相似，但它们的内部工作却有很大不同。在这一节，我将对这两个操作系统做一个简要描述。

Windows 2000概述

图1-1是以我的视点所看到的Windows 2000操作系统，该图着重了驱动程序开发者所关心的特征。软件要么执行在用户模式中，要么执行在内核模式中。当用户模式程序需要读取设备数据时，它就调用Win32 API函数，如ReadFile。Win32子系统模块(如KERNEL32.DLL)通过调用平台相关的系统服务接口实现该API，而平台相关的系统服务将调用内核模式支持例程。在ReadFile调用中，调用首先到达系统DLL(NTDLL.DLL)中的一个入口点，NtReadFile函数。然后这个用户模式的NtReadFile函数接着调用系统服务接口，最后由系统服务接口调用内核模式中的服务例程，该例程同样名为NtReadFile。

图1-1. Windows 2000系统结构

我们经常说NtReadFile是I/O管理器的一部分。“I/O管理器(I/O Manager)”这个术语多少有些误导，系统中并不存在名为“I/O管理器”的单独执行模块。但当我们讨论围绕在驱动程序周围的操作系统服务“云”时，我们需要使用一个名字来代表，而“I/O管理器”就是我们通常使用的名字。

系统中还有许多与NtReadFile相似的服务例程，它们同样运行在内核模式中，为应用程序请求提供服务，并以某种方式与设备交互。它们首先检查传递给它们的参数以保护系统安全或防止用户模式程序非法存取数据，然后创建一个称为“I/O请求包(IRP)”的数据结构，并把这个数据结构送到某个驱动程序的入口点。在刚才的ReadFile调用中，NtReadFile将创建一个主功能代码为IRP_MJ_READ(DDK头文件中的一个常量)的IRP。实际的处理细节可能会有不同，但对于NtReadFile例程，可能的结果是，用户模式调用者得到一个返回值，表明该IRP代表的操作还没有完成。用户模式程序也许会继续其它工作然后等待操作完成，或者立即进入等待状态。不论哪种方式，设备驱动程序对该IRP的处理都与应用程序无关。

执行IRP的设备驱动程序最后可能会访问硬件。对于PIO方式的设备，一个IRP_MJ_READ操作将导致直接读取设备的端口(或者是设备实现的内存寄存器)。尽管运行在内核模式中的驱动程序可以直接与其硬件会话，但它们通常都使用硬件抽象层(HAL)访问硬件。读操作最后会调用READ_PORT_UCHAR从某个I/O口读取单字节数据。HAL例程执行的操作是平台相关的。在Intel x86计算机上，HAL使用IN指令访问设备端口，在Alpha计算机上，HAL使用内存提取指令访问设备实现的内存寄存器。

驱动程序完成一个I/O操作后，通过调用一个特殊的内核模式服务例程来完成该IRP。完成操作是处理IRP的最后动作，它使等待的应用程序恢复运行。

Windows 98概述

图1-2显示了Windows 98的基本结构。其操作系统内核称为虚拟机管理器(VMM)，因为它的主要工作就是创建“虚拟”机器，这些虚拟机器共享同一个物理机器。Windows 3.0引入虚拟设备驱动程序(VxD)的原始目的就是为了虚化设备，以帮助VMM实现每个虚拟机器都拥有全部硬件的假象。VMM架构也被引入Windows 98，并能处理新硬件和32位应用程序。

图1-2. Windows 98系统结构

Windows 98不能象Windows 2000那样整洁地处理I/O操作。在处理磁盘操作、通讯口操作、键盘操作，等等方面与Windows 2000有很大不同。Windows 98以两种完全不同的方式为32位应用程序和16位应用程序提供服务。见图1-3。

图1-3. Windows 98中的I/O请求

图1-3的左侧显示了32位应用程序的I/O请求处理过程。应用程序调用的Win32API(例如ReadFile)是系统DLL(如KERNEL32.DLL)中的服务例程，但应用程序仅能用ReadFile读磁盘文件、通讯口，和有WDM驱动程序的设备。对于其它种设备，应用程序必须使用基于DeviceIoControl的特殊方式。并且Windows 98的系统DLL含有与Windows 2000不同的代码。ReadFile的用户模式部分(如参数检验，Windows 2000在内核中实现)使用某个专用机制到达内核模式驱动程序。磁盘文件操作使用一种机制，串行口操作使用另一种机制，而WDM设备也有自己专用的机制进入内核。所有这些机制都利用软件中断30h来实现用户模式到内核模式的转换，但它们之间又完全不同。

图1-3的中间显示了16位Windows应用程序的I/O请求处理过程，右侧是MS-DOS应用程序的I/O请求处理过程。在这两种形式中，用户模式应用程序直接或间接地调用了用户模式的驱动程序，原理上，这些用户模式驱动程序可以直接操作机器硬件而不用其它系统部件支持。例如，Win16程序通过调用名为COMM.DRV的16位DLL间接地执行串行口I/O。(到Windows 95为止，COMM.DRV仍是一个单独的驱动程序，它挂在IRQ3和IRQ4上，直接向串行口芯片发出IN和OUT指令) 虚拟通信(指虚拟机器之间的沟通)设备(VCD)驱动程序通过截获I/O端口操作来保证两个虚拟机不同时访问相同的端口。如果以一种神秘的方式思考这个过程，你可以这样认为，用户模式驱动程序使用了一个基于I/O截获操作的“API”，象VCD这样的“虚拟化”驱动程序就是通过冒充硬件操作来实现假API服务的。

Windows 2000的所有内核模式I/O操作都使用一个公用的数据结构(IRP)。而Windows 98没有达到这样高度统一，其串行口驱动程序要遵从由VCOMM.VXD规定的port驱动程序函数调用规范，而磁盘驱动程序则遵从IOS.VXD实现的包驱动层次架构。其它设备类驱动程序也有其它的实现方式。

如果要把WDM引入Windows 98，就必须使Windows 98内部架构与Windows 2000非常类似。Windows 98包含了NTKERN.VXD(VMM32.VXD)系统模块，该模块含有大量Windows NT内核支持函数的Windows实现。NTKERN.VXD使用与Windows 2000相同的方式创建IRP并发送IRP到WDM驱动程序。实际上，WDM驱动程序几乎区别不出这两个环境的不同。


Windows 2000驱动程序

Windows 2000系统可以使用多种驱动程序，图1-4显示了其中几种。

图1-4. Windows 2000中的设备驱动程序种类

  • 虚拟设备驱动程序(VDD)是一个用户模式部件，它可以使DOS应用程序访问x86平台上的硬件。VDD通过屏蔽I/O权限掩码来捕获端口存取操作，它基本上是模拟硬件操作，这对于那些直接对裸机硬件编程的应用程序特别有用。尽管这种驱动程序在Windows 98和Windows 2000中共享一个名称并且有相同的功能，但实际上它们完全不同。我们用VDD缩写代表这种驱动程序，用VxD缩写代表Windows 98中的虚拟设备驱动程序以示区别。
  • 内核模式驱动程序的分类包含许多子类。PnP驱动程序就是一种遵循Windows 2000即插即用协议的内核模式驱动程序。准确地说，本书涉及的所有内容都是面向PnP驱动程序的。
  • WDM驱动程序是一种PnP驱动程序，它同时还遵循电源管理协议，并能在Windows 98和Windows 2000间实现源代码级兼容。WDM驱动程序还细分为类驱动程序(class driver)和迷你驱动程序(minidriver)，类驱动程序管理属于已定义类的设备，迷你驱动程序向类驱动程序提供厂商专有的支持。
  • 显示驱动程序是用于显示和打印设备的内核模式驱动程序。
  • 文件系统驱动程序在本地硬盘或网络上实现标准PC文件系统模型(包括多层次目录结构和命名文件概念)。
  • 遗留设备驱动程序也是一种内核模式驱动程序，它直接控制一个硬件设备而不用其它驱动程序帮助。这种驱动程序主要包括Windows NT早期版本的驱动程序，它们可以不做修改地运行在Windows 2000中。

内核模式驱动程序的属性

内核模式驱动程序有许多共有的属性，下面我将分别讨论这些属性。(注意，在这本书中，缩写“DDK”仅指Windows 2000 DDK，如果要讨论其它DDK，我将给出具体的名字)

  • 可移植性

内核模式驱动程序的源代码应该可以移植于所有Window NT平台。WDM驱动程序在其定义中就规定了其源代码可以在Windows 98和Windows 2000之间相互移植。为了实现这种可移植性，驱动程序应该全部用C写，并且只使用ANSI C标准规定的语言元素。应避免使用编译器厂商专有的语言特征，并避免使用没有被操作系统内核输出的运行时间库函数(参见第三章)。如果不能避免驱动程序中的平台依赖，至少应该用条件编译指令隔离这些代码。如果严格遵循这些设计方针，那么仅需要重新编译连接源代码，生成的驱动程序就可以运行在任何新的Windows NT平台上。

在大多数情况下，WDM驱动程序的二进制映像可以兼容Windows 98和Windows 2000(32位版本)。如果仅使用WDM.H中声明的内核模式支持函数，那么可以很容易地实现驱动程序的源代码级兼容。但操作系统之间的差异会在某些地方影响驱动程序的移植性，我将在本书的多个地方讨论这个问题。

  • 可配置性

内核模式驱动程序应避免对设备特征或某些系统设置作绝对假设，这些系统设置会随着平台的改变而改变。例如，在x86平台上，标准串行口使用一个专用的IRQ和8个I/O端口，这些数值持续20年从未改变。把这些值直接写到驱动程序中将使驱动程序失去可配置性。在第八章，我将讨论两个用户可控制的电源管理特征——空闲检测和系统唤醒；一个总使用特定空闲超时常量的驱动程序或者只利用针对自己设备唤醒特征的驱动程序将不能实现用户可控制。这样的驱动程序将是不可配置的。

为了实现可配置性，首先应该在代码中避免直接引用硬件，即使是在平台相关的条件编译块中也是这样。应该使用HAL工具或调用低级总线驱动程序，或者实现一个标准的或定制的控制接口，并通过控制面板程序与用户交互。另外，还应该支持Windows管理仪器(WMI)控件，这种控件允许用户和管理员在分布式企业环境中配置硬件特征(见第十章)。最后，应该使用注册表作为配置信息的数据库，这可以使配置信息在系统重新启动后仍然存在。

  • 可抢先性和可中断性

Windows 2000和Windows 98都是多任务操作系统，可以为任意多个线程分配CPU时间。在大部分时间中，驱动程序例程执行在可以被其它线程(在同一个CPU上)抢先的环境中。线程抢先取决于线程的优先级，系统使用系统时钟为线程分配CPU时间片。

Windows 2000还使用了一个中断优先级的概念，即IRQL。我将在第四章中详细讨论IRQL，在这里我先简单介绍一下。你可以认为CPU中有一个IRQL寄存器，它记录着CPU当前的执行级别。有三个IRQL值对设备驱动程序有重要意义：PASSIVE_LEVEL(值为0)、DISPATCH_LEVEL(值为2)，以及所谓的设备中断请求级DIRQL(大于2的值，设备的中断服务例程在该级上执行)。在大部分时间中，CPU执行在PASSIVE_LEVEL级上，所有的用户模式代码也运行在PASSIVE_LEVEL级上，并且驱动程序的许多活动也都发生在PASSIVE_LEVEL级上。当CPU运行在PASSIVE_LEVEL级时，当前运行的线程可以被任何优先级大于它的线程抢先。然而，一旦CPU的IRQL大于PASSIVE_LEVEL级，线程抢先将不再发生，此时CPU执行在使CPU越过PASSIVE_LEVEL级的任意线程上下文中。

你可以把高于PASSIVE_LEVEL级的IRQL看成是针对中断的优先级。这是一个与支配线程抢先机制不同的优先级方案，正如我前面说的，在PASSIVE_LEVEL级上没有线程抢先发生。但是，运行在任何IRQL级上的活动都可以被更高IRQL级上的活动中断。所以驱动程序必须假定在任何时刻都可能失去控制权，而此时系统可能需要执行更基本的任务。

  • 多处理器安全

Windows 2000可以运行在多处理器计算机上。Windows 2000使用对称多处理器模型，即所有的处理器都是相同的，系统任务和用户模式程序可以执行在任何一个处理器上，并且所有处理器都平等地访问内存。多处理器的存在给设备驱动程序带来了一个困难的同步问题，因为执行在多个CPU上的代码可能同时访问共享数据或共享硬件资源。Windows 2000提供了一个同步对象，自旋锁(spin lock)，驱动程序可以使用它来解决多处理器的同步问题。(见第四章)

  • 基于对象

Windows 2000内核是基于对象的，即驱动程序和内核例程使用的许多数据结构都有公共的特征，这些特征集中由对象管理器管理。这些特征包括名称、参考计数、安全属性，等等。在内部，内核中包含了许多执行公共对象管理的方法例程，例如打开和关闭对象或析取对象名。

驱动程序使用内核部件输出的服务例程来维护对象或对象中的域。某些内核对象，例如内核中断对象，是不透明的，DDK头文件中没有其数据成员的声明。其它内核对象，如设备对象或驱动程序对象则是部分不透明的，DDK头文件中声明了其结构的全部成员，但DDK文档中仅描述了可访问的成员并警告驱动程序开发者不要直接访问或修改其它成员。对于驱动程序必须间接访问的不透明域，可以用支持例程访问。部分不透明的对象类似于C++的类，有任何人都能访问的公共成员，还有必须通过方法函数才能访问的私有成员和保护成员。

  • 包驱动

I/O管理器和设备驱动程序使用I/O请求包来管理I/O操作的具体细节。首先，某个内核模式部件创建一个IRP，该IRP可以是让设备执行一个操作、向驱动程序发送一个命令，或者向驱动程序询问某些信息的请求。然后I/O管理器把这个IRP发送到驱动程序输出的例程上。一般，每个驱动程序例程仅执行IRP指定的一部分工作然后返回I/O管理器。最后，某个驱动程序例程完成该IRP，之后I/O管理器删除该IRP并向原始请求者报告结束状态。

  • 异步

Windows 2000允许应用程序和驱动程序在起动一个I/O操作后继续执行，而此时I/O操作仍在进行。所以，需要长时间运行的I/O操作应该以异步方式执行。即当驱动程序接收到一个IRP后，它首先初始化用于管理该I/O操作的任何状态信息，然后安排IRP的执行，而该IRP将在以后的某个时刻完成，最后返回调用者。由调用者决定是否等待该IRP的完成。

作为一个多任务操作系统，Windows 2000根据线程的适合性和优先级来调度它们在有效处理器上的执行。通常，驱动程序在某些不可预测线程的上下文中应该使用异步方式处理I/O请求。我们使用术语任意线程上下文(arbitrary thread context)来描述驱动程序并不知道(或并不关心)处理器当前执行在哪一个线程上的上下文。驱动程序应避免阻塞任意线程，这使得驱动程序有了这样的架构：通过执行离散的操作并返回来响应硬件事件。

WDM驱动程序模型

在WDM驱动程序模型中，每个硬件设备至少有两个驱动程序。其中一个驱动程序我们称为功能(function)驱动程序，通常它就是你认为的那个硬件设备驱动程序。它了解使硬件工作的所有细节，负责初始化I/O操作，有责任处理I/O操作完成时所带来的中断事件，有责任为用户提供一种设备适合的控制方式。

另一个驱动程序我们称为总线(bus)驱动程序。它负责管理硬件与计算机的连接。例如，PCI总线驱动程序检测插入到PCI槽上的设备并确定设备的资源使用情况，它还能控制设备所在PCI槽的电流开关。

有些设备有两个以上的驱动程序。我们使用术语过滤器驱动程序(filter driver)来描述它们。某些过滤器驱动程序仅仅是在功能驱动程序执行I/O操作时进行监视。多数情况是：硬件或软件厂商利用过滤器驱动程序修改现有功能驱动程序的行为。上层过滤器驱动程序在功能驱动程序之前看到IRP，它们有机会为用户提供额外的特征，而功能驱动程序根本不知道。有时，一个上层驱动程序可以修正功能驱动程序或硬件存在的毛病或缺陷。低层过滤器驱动程序在功能驱动程序要向总线驱动程序发送IRP时看到IRP。在某些情况下，例如当USB设备插入USB总线时，低层过滤器驱动程序可以修改功能驱动程序要执行的总线操作流。

WDM功能驱动程序通常由两个分离的执行文件组成。一个文件是类驱动程序，它了解如何处理操作系统使用的WDM协议(有些协议相当复杂)，以及如何管理整个设备类的基本特征。USB照相机类驱动程序就是一个例子。另一个文件称为迷你驱动程序(minidriver)，它包含类驱动程序用于管理设备实例的厂商专有特征例程。类驱动程序和迷你驱动程序合在一起才成为一个完整的功能驱动程序。

可以把一个完整的驱动程序看作是一个容器，它包含许多例程，当操作系统遇到一个IRP时，它就调用这个容器中的例程来执行该IRP的各种操作。图5-1表现了这个概念。有些例程，例如DriverEntry和AddDevice，还有与几种IRP对应的派遣函数将出现在每一个这样的容器中。需要对IRP排队的驱动程序一般都有一个StartIo例程。执行DMA传输的驱动程序应有一个AdapterControl例程。大部分能生成硬件中断的设备，其驱动程序都有一个中断服务例程(ISR)和一个推迟过程调用(DPC)例程。驱动程序一般都有几个支持不同类型IRP的派遣函数，其中三个派遣函数是必须的。所以，WDM驱动程序开发者的一个任务就是为这个容器选择所需要的例程。

图1-5. WDM驱动程序“容器”中的内容


例子代码

本书的随书光盘中包含许多驱动程序例子和测试程序。编写例子程序的目的是为了配合文中讨论的问题或技术。但是这些例子仅仅是一些“玩具”而已，你不能仅改变几行代码后就发行它们。第七章和第十一章有一些可以驱动真正硬件的驱动程序例子，这些硬件就是PCI芯片厂商和USB芯片厂商提供的开发板，除此之外，所有驱动程序都是针对假想的硬件。

几乎每个驱动程序例子都带有一个简单的用户模式测试程序，你可以用它来检查例子驱动程序的操作。这些测试程序十分小，仅包含几行涉及被测试驱动程序特殊功能的代码。

随书光盘

光盘含有每个例子的完整源代码和执行文件。此外还有几个你可能用得着的工具程序。可以在WDMBOOK.HTM文件中看到例子程序的索引和工具程序的使用说明。

光盘中的安装程序可以把全部例子安装到硬盘上，当然也可以直接在光盘上使用这些例子。安装程序不会向你的系统目录安装任何内核模式部件。安装程序将询问你是否可以向AUTOEXEC.BAT文件中加入某些环境变量。Build程序可能需要这些环境变量。安装程序还向注册表中加入了必要的表项以定义一个SAMPLE设备类，所有例子驱动程序都属于这个SAMPLE设备类。

如果你的计算机上既有Windows 2000又有Windows 98，我建议你在其中一个操作系统上做一次完全安装，在另一个操作系统上做一个简便安装。(注意，Windows 2000不再使用AUTOEXEC.BAT文件初始化环境变量)

每个例子都有一个阐述如何创建和测试该例子的HTML文件说明，我建议你在安装一个例子驱动程序前先阅读这个说明，因为某些例子有特别的安装要求。一旦安装了某个例子驱动程序，你将在设备管理器中发现一个额外的属性页(如图1-6)。

图1-6. 某个例子驱动程序的定制属性页

关于创建例子驱动程序

有一个原因可以解释为什么我的例子程序看起来都像从一个模子里印出来的：实际上，它就是用特殊的模子印出来的，由于要写许多例子驱动程序，所以我决定写一个应用程序向导。Microsoft Visual C++6.0的向导功能完全可以用于创建WDM驱动程序工程，我决定使用它。这个向导就是WDMWIZ.AWX，你可以在随书光盘中找到它。我在附录C中给出了它的用法。你可以用它构造驱动程序框架。但这个向导不是一个产品级软件，其用途仅是帮助你学习驱动程序编程。另外，由于这个向导功能有限，有时你需要手动更改驱动程序工程的某些设置。更详细的信息请参考WDMBOOK.HTM文件。

安装了Windows 2000 DDK后，开始菜单上将出现checked build environment和free build environment两个菜单项。每个环境都有与驱动程序创建方法相适应的环境变量。BUILD.EXE工具将根据不同的环境和工程描述文件SOURCES创建不同的驱动程序。我为每个例子驱动程序工程都提供了一个SOURCES文件。

我个人更喜欢用Microsoft Visual Studio环境管理驱动程序工程。过去我曾提倡使用BUILD.EXE工具，这是因为我担心Microsoft有一天会改变某些重要的编译连接选项，那样的话，任何基于IDE的方法都将被破坏。事实上，这种事情已经在Windows 2000的beta版中发生了。(有人决定修改沿用了十年的库文件结构，为此我就必须修改某些工程设置) 我认为使用IDE带来的高生产效率要比冒险在将来做某些修改更值得。

GENERIC.SYS

WDM驱动程序中包含大量处理即插即用和电源管理的代码。这些代码很长，另人厌烦，并且容易出错。所以我在所有依赖这些代码的驱动程序中都调用了GENERIC.SYS(内核模式DLL)来代替这些代码。WDMWIZ.AWX可以创建使用GENERIC.SYS的工程。附录B详细列出了GENERIC.SYS的输出函数。


本书的结构

在教授了几年驱动程序编程讲座之后，我渐渐发现人们通常以两种不同的方法学习新事物。一些人喜欢先学会理论然后再学习实际操作。而另一些人喜欢先学习实际操作然后再学习理论。我把前者称为演绎方式，把后者称为归纳方式。我本人更喜欢归纳方式，所以我把这本书也组织成与归纳学习法相适合的结构。

我的目标是阐述如何写设备驱动程序。具体地说，我想为你提供一个写实际驱动程序所必须的最小的背景知识，然后再过渡到更专门的主题上。这个“最小背景知识”包含的内容很广泛，它占了本书的前六章。但是一旦你跨过了第七章，其后的章节虽然重要但对于写一个能工作的驱动程序来说并不是必须的。

  • 第二章，“WDM驱动程序的基本结构”解释了Windows 2000用于管理I/O设备的基本数据结构，以及驱动程序与这些数据结构相联系的基本方式。我将讨论驱动程序对象和设备对象。还将讨论两个基本例程：DriverEntry和AddDevice，每个WDM驱动程序“容器”都包含这两个例程。
  • 第三章，“基本编程技术”描述了一些最重要的服务函数，你可以调用这些函数来完成普通的编程工作。我还将讨论错误处理、内存管理，和其它一些编程技术。
  • 第四章，“同步”讨论驱动程序怎样在多任务和多处理器的环境中同步访问共享数据，你将详细地学习IRQL，学习用操作系统提供的各种同步原语解决同步问题。
  • 第五章，“I/O请求包”是本书的真正主题。我将解释I/O请求包从哪来，讨论IRP处理的“标准模型”。另外，我还将讨论一个更复杂的题目：IRP取消，它涉及到同步问题。
  • 第六章，“即插即用”仅涉及到一种类型的I/O请求包，即IRP_MJ_PNP。PnP管理器通过发送这种IRP来报告设备的配置，并在设备存在期间向驱动程序通知重要事件。多数设计良好的PnP驱动程序不能使用“标准模型”来处理IRP。所以，我描述了一个名为DEVQUEUE的对象，你可以在PnP事件发生时使用它来正确地入队(queue)和出队(dequeue)IRP。
  • 第七章，“读写数据”我们将在这一章里讨论执行I/O操作的驱动程序代码。我将讲述怎样从PnP管理器那里获得配置信息，怎样用这些信息初始化驱动程序，以使它能处理数据读写IRP。我还给出了两个简单的驱动程序例子：一个用于PIO设备，一个用于总线主控的DMA设备。
  • 第八章，“电源管理”描述了驱动程序如何参与电源管理。电源管理颇为复杂。不幸的是，你必须参与系统的电源管理协议，否则整个系统将不能正常工作。
  • 第九章，“专门问题”包含了对过滤器驱动程序、错误登记、I/O控制操作，和系统线程的讨论。
  • 第十章，“Windows管理仪器”涉及一个企业范围的计算机管理方案，你的驱动程序能够并且也应该参与这个方案。我将解释如何为监视程序提供统计数据和性能数据，怎样响应标准的WMI控制，怎样向控制程序报告重要事件的发生。
  • 第十一章，“USB总线”描述怎样写USB设备驱动程序。
  • 第十二章，“安装设备驱动程序”告诉你怎样把驱动程序安装到用户系统中，你将学习INF文件的基本写法，还可以学到关于系统注册表的一些有趣并且有用的东西。
  • 附录A，“Windows 98的不兼容处理”介绍一个基于VxD的方案，该方案允许你在Windows 98和Windows 2000平台上使用相同的驱动程序映像。由于Windows 2000出现在Windows 98之后，它输出了Windows 98没有输出或没有实现的服务例程，我将介绍一个短小的VxD程序，它可以解决这个问题。
  • 附录B，“使用GENERIC.SYS”描述了GENERIC.SYS的公共接口。大部分驱动程序例子使用了这个库。
  • 附录C，“使用WDMWIZ.AWX”描述了如何使用这个应用程序向导来创建一个驱动程序。我再提一下，WDMWIZ.AWX并不是一个商品化的工具包。

关于书中的错误

我尽可能使本书不出现错误。但是想一想，当写一本含有复杂技术和许多新概念的书时，你不可能保证百分之百的正确。另外，几个月后当Windows 2000从beta版变为正式版时，WDM不可避免地会有些改变。为此，我将在http://www.oneysoft.com/上发布一个勘误表。


其它资源

本书并不是关于驱动程序编程的唯一资源。它仅着重描述了我认为重要的特征；但你有可能需要一些我没有涉及到的信息，或者你有与我不同的学习方法。我没有讲述超出驱动程序编制需求之外的操作系统知识。如果你是一个演绎式学者，或者你仅仅想知道一些理论背景，可以参考下面列出的资源。

驱动程序开发书籍

  • Art Baker，《The Windows NT Device Driver Book: A Guide for Programmers》，(Prentice Hall, 1997)。
  • Chris Cant，《Writing Windows WDM Device Drivers》，(R&D Press, 1999)。
  • Edward N. Dekker和Joseph M. Newcomer，《Developing Windows NT Device Drivers: A Programmer's Handbook》，(Addison-Wesley, 1999)。
  • Rajeev Nagar，《Windows NT File System Internals: A Developer's Guide》，(O'Reilly & Associates, 1997)。
  • Peter G. Viscarola和W. Anthony Mason，《Windows NT Device Driver Development》，(Macmillan, 1998)。

其它参考书籍

  • David A. Solomon，《Inside Windows NT, Second Edition》，(Microsoft Press, 1998)。

杂志

  • Microsoft Systems Journal偶尔会有一些关于驱动程序开发的文章。

新闻组

  • comp.os.ms-windows.programmer.nt.kernel-mode新闻组提供了一个讨论内核模式编程问题的论坛。

讲座

我办了一个WDM编程讲座，关于讲座的信息和时间表请访问http://www.oneysoft.com/。这个技术专题的讲座也有其他人在办，我们以此为生。我相信你能谅解我没有为你明确指出我的竞争者的地址链接。


注意事项

出于解释说明的目的，书中出现的代码没有做任何错误检测。我遵循这样的原则：尽可能一步一步地解释复杂的题目，而不是把你淹没在太多的代码中。

随书光盘中的例子驱动程序全部做了错误检测并且有产品级驱动程序所需要的一切内容。当你要向自己的代码中加入某些东西时，应该先查阅光盘。


第二章：WDM驱动程序的基本结构

在第一章中，我介绍了Windows 2000和Windows 98操作系统的基本架构。我引入这样一个概念：设备驱动程序是一个包含了许多操作系统可调用例程的容器，这些例程可以使硬件设备执行相应的动作。本章将描述驱动程序容器应包含的基本内容、驱动程序的层次结构，还将讲述DriverEntry和AddDevice函数，这两个函数存在于每个WDM驱动程序中。

  • 设备和驱动程序的层次结构
  • DriverEntry例程
  • AddDevice例程
  • Windows 98兼容问题


设备和驱动程序的层次结构

WDM模型使用了如图2-1的层次结构。图中左边是一个设备对象堆栈。设备对象是系统为帮助软件管理硬件而创建的数据结构。一个物理硬件可以有多个这样的数据结构。处于堆栈最底层的设备对象称为物理设备对象(physical device object)，或简称为PDO。在设备对象堆栈的中间某处有一个对象称为功能设备对象(functional device object)，或简称FDO。在FDO的上面和下面还会有一些过滤器设备对象(filter device object)。位于FDO上面的过滤器设备对象称为上层过滤器，位于FDO下面(但仍在PDO之上)的过滤器设备对象称为下层过滤器。

图2-1. WDM中设备对象和驱动程序的层次结构

关于过滤器设备对象(Filter Device Object)的缩写

我们这个行业大量使用缩写词，我觉得这有些怪，为什么术语过滤器设备对象(filter device object)没有官方缩写词，FDO已经用于功能设备对象。以前，Microsoft曾用FiDO表示过滤器设备对象。这个缩写的缺点是不能表明是上层过滤器还是下层过滤器。另外，这个缩写有些不适合严肃的技术论述。例如，在我的讲座上，有的学生很快指出，在堆栈上面的FiDO是一个top-dog。

我将在本书中使用FiDO来代表过滤器设备对象。我想驱动程序编程(至少是这本书)将要go to the dogs。

操作系统的PnP管理器按照设备驱动程序的要求构造了设备对象堆栈，在本书中，我们用通用术语“总线(bus)”来描述与设备进行电气连接的硬件。这是一个广义的定义，它不仅包括PCI总线，还包括SCSI卡、并行口、串行口、USB集线器(hub)，等等。实际上，它可以是任何能插入多个设备的硬件设备。总线驱动程序的一个任务就是枚举总线上的设备，并为每个设备创建一个PDO。一旦总线驱动程序检查到新硬件存在，PnP管理器就创建一个PDO，之后便开始描绘如图2-1所示的结构。

创建完PDO后，PnP管理器参照注册表中的信息查找与这个PDO相关的过滤器和功能驱动程序，它们出现在图的中部。系统安装程序负责添加这些注册表项，而驱动程序包中控制硬件安装的INF文件负责添加其它表项。这些表项定义了过滤器和功能驱动程序在堆栈中的次序。PnP管理器先装入最底层的过滤器驱动程序并调用其AddDevice函数。该函数创建一个FiDO，这样就在过滤器驱动程序和FiDO和之间建立了水平连接。然后，AddDevice把PDO连接到FiDO上，这就是设备对象之间连线的由来。PnP管理器继续向上执行，装入并调用每个低层过滤器、功能驱动程序、每个高层过滤器，直到完成整个堆栈。

层次结构可以使I/O请求过程更加明了，见图2-1的右侧。每个影响到设备的操作都使用I/O请求包。通常IRP先被送到设备堆栈的最上层驱动程序，然后逐渐过滤到下面的驱动程序。每一层驱动程序都可以决定如何处理IRP。有时，驱动程序不做任何事，仅仅是向下层传递该IRP。有时，驱动程序直接处理完该IRP，不再向下传递。还有时，驱动程序既处理了IRP，又把IRP传递下去。这取决于设备以及IRP所携带的内容。

在单个硬件的驱动程序堆栈中，不同位置的驱动程序扮演了不同的角色。功能驱动程序管理FDO所代表的设备。总线驱动程序管理计算机与PDO所代表设备的连接。过滤器驱动程序用于监视和修改IRP流。由于设备对象与驱动程序软件之间关系紧密，有时使我用FDO驱动程序来代表功能驱动程序，用PDO驱动程序来代表总线驱动程序。

听我讲座的一个学生曾看到过与图2-1类似的图，他把图中描述的层次结构误认为是C++中的类继承。设计设备驱动程序架构的一个好方法是定义基类，程序员可以从它派生出更多的专用类。以这种方式，你可以用一组抽象类来管理不同种类的PDO，还可以用它们派生出FDO驱动程序。系统把IRP发送到虚拟函数，其中一些IRP被PDO驱动程序中的基类处理，另一些被FDO驱动程序中的派生类处理。但是，WDM并不是以这种方式工作，PDO驱动程序执行的工作与FDO驱动程序完全不同。FDO通过下传IRP把某些工作委托给PDO驱动程序去做，这种关系更象链条上的环节，而不象类间的继承关系。

系统怎样装入驱动程序

介绍完WDM驱动程序层次结构后，我们可以更深入一步，但这里有一个明显的“鸡生蛋”问题需要解决。我曾说过总线驱动程序创建了PDO，然后PnP管理器根据该PDO的注册表项装入它的驱动程序。那么总线驱动程序从哪来？下面我将解释这个问题。在装入和配置驱动程序过程中，注册表扮演了一个关键角色。所以我将解释与这个过程相关的注册表键，以及它的内容是什么。

递归枚举

首先，PnP管理器有一个内建的驱动程序，它与一个实际不存在的根总线相对应。根总线概念性地把计算机与所有那些不能用电子方式声明自己存在的设备连接起来，这包括主硬件总线(如PCI)。根总线驱动程序从注册表中获取有关计算机的信息。而这些关于计算机本身的注册表信息是由Windows 2000系统安装程序初始化的。安装程序通过运行一个精心制作的硬件检测程序以及向用户提出一些适当的问题来获取这些信息。所以，根总线驱动程序有足够的信息为主总线创建PDO。

然后，主总线的功能驱动程序用电子方式枚举自己的硬件。例如，PCI总线提供了一种访问每个插入设备的硬件配置空间的方法，配置空间保存了设备的资源需求和描述信息。当总线驱动程序枚举硬件时，它的动作就象一个普通的功能驱动程序。检测完一个硬件后，它改变角色，变成总线驱动程序并为查到的硬件创建PDO。然后PnP管理器装入该PDO的驱动程序。有时，设备的功能驱动程序还要枚举其它硬件，如果是这样，整个过程将递归重复。结果将出现如图2-2的树形结构，总线设备堆栈将分叉成其它设备堆栈，每个设备堆栈代表着插入到该总线上的一个硬件设备：

图2-2. 设备递归枚举过程

注册表的角色

有三种注册表键负责配置。它们是硬件(hardware)键、类(class)键、服务(service)键。必须明确一点，这些名字(指hardware、class、service)并不是某个专用子键的名称：它们是这三种键的一般称谓，其具体的路径名要取决于它们所属的设备。概括地讲，硬件键包含单个设备的信息，类键涉及所有相同类型设备的共同信息，服务键包含驱动程序信息。有时人们用“实例(instance)键”和“软件(software)键”来代表硬件键和服务键。这个命名差异是由于Windows 95/98与Windows 2000是由两个不同的项目组开发所造成的。

硬件(或实例)键 设备的硬件键出现在注册表local machine分支的\System\CurrentControlSet\Enum子键上。通常你不能查看到该键的内部信息，系统只允许拥有系统帐号的用户访问该键。即只有内核模式程序和运行在系统帐号下的用户模式服务可以读写Enum键和其子键，但是即使是管理员也不应该直接修改这些键的内容。要想查看Enum键的内容，可以在Administrator特权级帐户下使用REGEDIT32.EXE工具查看。图2-3显示了Enum键的内部情况：

图2-3. 注册表中的硬件键

如何命名注册表键

注册表顶级键的命名可能使第一次看到它的人感到迷惑。当你用用户模式的Win32API函数访问注册表时，可以用HKEY_CLASSES_ROOT、HKEY_CURRENT_USER、HKEY_LOCAL_MACHINE，等等的预定义句柄常量代表顶级注册表键。注册表编辑器REGEDIT.EXE也使用这些预定义常量，如图2-3。你还可以用缩写HKCR、HKCU、HKLM代替它们。即HKCR是HKLM\Software\Classes的别名，HKCU是HKEY_USER的某个子键的别名，这两个别名所代表的具体键值要取决于被处理的具体情况。

在内核模式中，你应使用另一种基于内核命名空间的命名方案。在这种命名方案中，顶级键命名为\Registry\User和\Registry\Machine。Machine分支就是用户模式中的HKLM分支，在这里你可以找到与设备驱动程序相关的所有信息。除非另有所指，否则你应该假定本文中所引用的注册表键都在\Registry\Machine中。

Enum键下的第一级子键与系统中的各种总线枚举器相对应。\Enum\USB子键中包含了所有以前用过和现在存在的USB设备的描述。在USB42例子中，我将阐述怎样把设备的硬件ID(vendor 0574, product 102A)转换成键名(Vid_0574&Pid_102A)，以及如何使有该ID的设备实例被表示为下一层的子键7&2。7&2就是该设备的硬件(或实例)键名。

硬件键中的某些键值提供的描述性信息可以被象设备管理器这样的用户模式部件所使用。图2-4显示了设备管理器给出的USB42设备属性。参见文字框“从用户模式中访问设备键”解释设备管理器如何收集这些信息，虽然它自身并不能跨过Enum键的正常安全保护。

图2-4. 设备管理器显示的设备属性页

从用户模式中访问设备键

应用程序经常需要访问注册表中关于硬件设备的信息。为了使这成为可能而同时又不暴露重要的Enum键，Microsoft提供了一组SetupDiXxx函数。

假设你的驱动程序使用IoRegisterDeviceInterface函数寄存了一个设备接口，并且你有一个该接口的符号连接名(通过枚举该接口GUID的所有实例或者从WM_DEVICECHANGE消息的参数中获得这个名字)。为了从硬件键中获得Manufacturer名字，你可以使用下面代码：

#include <setupapi.h>
...
LPTSTR lpszDeviceName;
HDEVINFO info = SetupDiCreateDeviceInfoList(NULL, NULL);
SP_DEVICE_INTERFACE_DATA ifdata = {sizeof(SP_DEVICE_INTERFACE_DATA)};
SetupDiOpenDeviceInterface(info, lpszDeviceName, 0, &ifdata);
SP_DEVINFO_DATA did = {sizeof(SP_DEVINFO_DATA)};
SetupDiGetDeviceInterfaceDetail(info, &ifdata, NULL, 0, NULL, &did);
TCHAR buffer[256];
SetupDiGetDeviceRegistryProperty(info,
                 &did,
                 SPDRP_MFG,
                 NULL,
                 (PBYTE) mfgname,
                 sizeof(mfgname),
                 NULL);
SetupDiDestroyDeviceInfoList(info);

lpszDeviceName是一个象“USB\Vid_0547&Pid_102A\7&2”一样的串。

硬件键还包含几个标识设备所属类和驱动程序的值。ClassGUID是设备类GUID(全局唯一标识符)的ASCII形式；在效果上，它是一个指向该设备类键的指针。Service指向服务键。可选值(USB42中没有)LowerFilters和UpperFilters分别标识低层过滤器和高层过滤器的服务名。

最后，硬件键还可以有名为Security、Exclusive、DeviceType、和DeviceCharacteristics的超越值，这四种值强制驱动程序创建有某种属性的设备对象。我将在后面讲述如何创建设备对象时再讨论这些超越值的重要性。

硬件键中的大部分值是在安装过程中自动填入的，或者在安装开始后的某个时刻，系统识别出了新硬件(或者经由硬件安装向导)并由系统自动填入的。有些值是由于INF(硬件安装文件)中曾指明要放入这里的。我将在第十二章讨论INF文件。

类键 所有设备类的类键都出现在HKLM\System\CurrentControlSet\Control\Class键中。它们的键名是由Microsoft赋予的GUID值。图2-5显示了SAMPLE设备的类键，USB42例子和本书中所有其它例子设备都属于该类。

图2-5. 注册表中的类键

Sample类并不是特别有代表性，因为它缺少某些可选值，如：

  • LowerFilters和UpperFilters，为该类所有设备指定过滤器驱动程序。
  • Security、Exclusive、DeviceType，和DeviceCharacteristics，如果类键的一个Properties子键中出现这些值，则指定值将超越该类所有设备对象的某些默认参数设置。但这些超越值要比硬件键中的超越值优先级低。系统管理员能通过管理控制台设置这些超越值。

每个设备还可以在所属类键下拥有自己的子键。该键的键名就是设备硬件键中的Driver值。图2-6显示了这种子键，这个子键的作用是把所有这些注册表项与安装设备使用的INF文件相关联。

图2-6. 存在于类键中的设备专用子键

服务(或软件)键 对设备驱动程序重要的最后一个键是服务键。它指出驱动程序执行文件的位置，以及控制驱动程序装入的一些参数。服务键位于HKLM\System\CurrentControlSet\Services键中。图2-7是USB42的服务键。

图2-7. 注册表中的一个服务键

服务键的内容在平台SDK的“Service Install”中有详尽描述，因此我不想再重述。在USB42的例子中，这些值有如下意义：

  • ImagePath 指出驱动程序执行文件为USB42.SYS，路径为%SystemRoot%\system32\drivers，注意这个路径名起始于系统根目录。
  • Type(1) 指出该表项描述一个内核模式驱动程序。
  • Start(3) 指出系统应动态装入这个驱动程序。(该值与CreateService中的SERVICE_DEMAND_START常量对应，用于内核模式驱动程序时它代表不必明确调用StartService函数或发出NET START命令来启动驱动程序)
  • ErrorControl(1) 指出如果装入该驱动程序失败，系统应登记该错误并显示一个消息框。

驱动程序装入顺序

当PnP管理器遇到一个新设备时，它打开设备的硬件键和类键，然后按如下顺序装入驱动程序：

  1. 硬件键中指定的任何低层过滤器驱动程序。由于LowerFilter值的类型是REG_MULTI_SZ，所以它可以指定多个驱动程序，其装入顺序由它们在串中的位置决定。
  2. 类键中指定的任何低层过滤器驱动程序。同样，它也可以指定多个驱动程序，装入顺序同1。
  3. 硬件键中Service值指定的驱动程序。
  4. 硬件键中指定的任何高层过滤器驱动程序，同样，它也可以指定多个驱动程序，装入顺序同1。
  5. 类键中指定的任何高层过滤器驱动程序，同样，它也可以指定多个驱动程序，装入顺序同1。

当我说系统“装入”一个驱动程序时，是指系统把驱动程序的映像映射到虚拟内存中，并重定位内存参考，最后调用驱动程序的主入口点。主入口点通常命名为DriverEntry。我将在本章后面讲述DriverEntry函数。如果驱动程序已经在内存中，则装入过程仅仅是增加驱动程序映像的参考计数。

你可能注意到了，属于类和设备实例的上下层过滤器驱动程序在装入过程中并没有出现象你想象的嵌套方式。在我知道这个事实前，我猜想设备级的过滤器驱动程序应比类级过滤器驱动程序更靠近功能驱动程序。但是，正如我后面讲到的，驱动程序的装入顺序并不是很重要。系统以PnP管理器装入驱动程序的顺序调用驱动程序的AddDevice函数，而这个顺序与设备对象在设备堆栈中出现的顺序完全一致。

设备对象之间如何关联

图2-2显示了一个由多个设备堆栈组成的树形结构，但这并不表示IRP必须从上一层的PDO流向下一层的FiDO。实际上，一个堆栈的PDO驱动程序就是其下一层堆栈的FDO驱动程序，见图2-2中的阴影块。当驱动程序以PDO角色接收到一个IRP时，它对该IRP执行一些操作但不发出这个或其它IRP到设备(以FDO角色)。相反，当总线驱动程序以FDO角色接收到一个IRP时，它可能需要向设备发送某些IRP(以PDO角色)。

举一些例子可以澄清FiDO、FDO、PDO之间的关系。第一个例子是PCI总线(PCI总线本身是通过PCI-to-PCI桥芯片附着到主总线上)上的一个直接到设备的读操作。为了使问题更简单，我们假设该设备仅有一个FiDO，如图2-8，该操作被系统转换成一个主功能码为IRP_MJ_READ的请求(如何转换我们以后再讨论)。这个请求首先流到上层FiDO，然后再传递给该设备的功能驱动程序(即图中标为FDOdev的设备对象)。最后功能驱动程序直接调用硬件抽象层执行读操作，所以图中其它驱动程序看不到该IRP。

图2-8. 设备(位于二级总线)读请求处理流程

我们对第一个例子稍做修改，如图2-9。这次我们的读请求将发往一个USB设备(USB设备插入USB hub，USB hub再插入主机控制器)，因此，USB设备堆栈、hub设备堆栈、主机控制器设备堆栈将加入设备树。IRP_MJ_READ请求先穿过FiDO到达功能驱动程序，然后功能驱动程序生成一个或多个不同种类的IRP并发送到自己的PDO。USB设备的PDO驱动程序就是USBHUB.SYS，该驱动程序把这些IRP送到主机控制器堆栈的最上层驱动程序，跳过中间的USB集线器堆栈(含有两个驱动程序)。

图2-9. USB设备的读请求处理流程

第三个例子与第一个类似，不同的是这里的IRP是一个通知IRP，涉及到一个PCI总线上的磁盘驱动器是否被用做系统分页文件库。该通知以主功能码为IRP_MJ_PNP副功能码为IRP_MN_DEVICE_USAGE_NOTIFICATION的IRP形式出现。在这里，FiDO驱动程序把请求传递到FDOdev驱动程序，FDOdev驱动程序得到通知再把请求传递到PDOdev驱动程序。这个通知暗示驱动程序该如何处理PnP或电源管理请求，所以PDOdev驱动程序向二级总线堆栈(它的FDObus就是现在设备堆栈中的PDOdev)发出相同的通知，如图2-10。(注意并不是所有的总线驱动程序都这样做，但PCI总线是这样的)

图2-10. 一个设备用途通知的处理流程

检查设备堆

最好能形象地表现一下设备对象和驱动程序的层次结构，所以我写了一个辅助工具DevView。把名为USB42的设备插入USB hub，然后运行DevView，屏幕上将出现图2-11，图2-12所示的对话框。

这个设备仅使用两个设备对象。PDO由USBHUB.SYS管理，FDO由USB42.SYS(USB42设备的驱动程序)管理。在图2-11中你可以看到关于PDO的一些信息。对照注册表中与USB42相关的键，现在你应该知道这些信息是从哪来的吧。

用DevView查看你自己的系统，可以更深刻地了解你的计算机。

图2-11. DevView显示USB42的PDO信息

图2-12. DevView显示的USB42的FDO信息

驱动程序对象

I/O管理器使用驱动程序对象来代表每个设备驱动程序，见图2-13。就象我们将要讨论的许多数据结构一样，驱动程序对象是部分不透明的。这意味着虽然DDK头中公开了整个结构，但我们仅能直接访问或修改结构中的某些域。在图中，我把驱动程序对象的不透明域用灰背景表示。这些不透明域类似于C++类中的私有成员或保护成员，而透明域类似于公共成员。

图2-13. DRIVER_OBJECT数据结构

DDK头文件中声明了包括驱动程序对象在内的所有内核模式数据结构，其声明遵循如下形式(摘自WDM.H)：

typedef struct _DRIVER_OBJECT {
  CSHORT Type;
  CSHORT Size;
  ...
} DRIVER_OBJECT, *PDRIVER_OBJECT;

这就是类型名为DRIVER_OBJECT的结构的声明形式，它同时还声明了一个PDRIVER_OBJECT指针类型和结构标签_DRIVER_OBJECT。这种结构声明方式在DDK中大量使用。DDK头文件还声明了一组类型名(如CSHORT)，这些类型名用于描述内核模式中的原子数据类型。表2-1列出了一些这样的类型。例如，CSHORT代表用作基数的有符号短整型数。基数(cardinal number)用于表示数值，序数(ordinal number)用于表示计数器。

表2-1. 内核模式驱动程序可以使用的公用类型名

类型名 | 描述
PVOID, PVOID64 | 通用指针(默认长度和64位长度)
NTAPI | 用于声明服务函数，强制使用__stdcall调用约定，用于x86平台上
VOID | 等价于“void”
CHAR, PCHAR | 8位字符，指向8位字符的指针(有无符号取决于编译器默认设置)
UCHAR, PUCHAR | 无符号8位字符，无符号8位字符指针
SCHAR, PSCHAR | 有符号8位字符，有符号8位字符指针
SHORT, PSHORT | 有符号16位整数，有符号16位整数指针
USHORT, PUSHORT | 无符号16位整数，无符号16位整数指针
LONG, PLONG | 有符号32位整数，有符号32位整数指针
ULONG, PULONG | 无符号32位整数，无符号32位整数指针
WCHAR, PWSTR | Unicode字符，Unicode字符串
PCWSTR | 常量Unicode串指针
NTSTATUS | 状态代码(类型为有符号长整型)
LARGE_INTEGER | 有符号64位整数
ULARGE_INTEGER | 无符号64位整数
PSZ, PCSZ | ASCIIZ(单字节)串指针，常量ASCIIZ(单字节)串指针
BOOLEAN, PBOOLEAN | TRUE或FALSE (等价于UCHAR)
关于64位类型

DDK头中包含的类型名可以使相同的驱动程序源代码相对容易地在Intel 32位或64位平台上编译。例如，不假设长整型与指针有相同大小，我们可以声明LONG_PTR或ULONG_PTR变量。这样的变量既可以是一个长整型也可以是一个指针。这些32/64类型定义可以在DDK头文件BASETSD.H中找到。

现在我们简要地讨论一下驱动程序对象中的透明域。

DeviceObject(PDEVICE_OBJECT) 指向一个设备对象链表，每个设备对象代表一个设备。I/O管理器把多个设备对象连接起来并维护这个域。非WDM驱动程序的DriverUnload函数利用这个域来遍历设备对象列表，以便删除其中的设备对象。WDM驱动程序没有必要使用这个域。

DriverExtension(PDRIVER_EXTENSION) 指向一个不大的子结构，其中只有AddDevice(PDRIVER_ADD_DEVICE)成员可以直接访问，见图2-14。AddDevice是一个指针，它指向驱动程序中创建设备对象的函数；该函数内容较多，我将在本章的后面讨论它。

图2-14. DRIVER_EXTENSION数据结构

HardwareDatabase(PUNICODE_STRING)指向一个串，该串为设备的硬件数据库键名。内容与“\Registry\Machine\Hardware\Description\System”类似，这个注册表键保存着该设备的资源分配信息。WDM驱动程序没有必要访问该键下的信息，因为PnP管理器自动执行资源分配。该名字以Unicode方式存储。(实际上，所有内核模式串都使用Unicode编码)，我将在下一章讨论UNICODE_STRING结构的格式和用法。

FastIoDispatch(PFAST_IO_DISPATCH)指向一个函数指针表，这些函数是由文件系统和网络驱动程序输出的。它们的使用已经超出了本书的范围。如果你想学习更多的关于文件系统驱动程序的知识，请参考Rajeev Nagar的《Windows NT File System Internals: A Developer's Guide (O'Reilly & Associates, 1997)》。

DriverStartIo(PDRIVER_STARTIO)指向驱动程序中处理串行I/O请求的函数，I/O管理器自动为驱动程序串行化多个I/O请求。我将在第五章详细讨论该函数和请求排队。

DriverUnload (PDRIVER_UNLOAD)指向驱动程序中的清除函数。我将在DriverEntry函数后面讨论该函数，实际上，WDM驱动程序根本就没有什么重要的清除工作要做。

MajorFunction (array of PDRIVER_DISPATCH)是一个函数指针表，指向存在于驱动程序中的二十多种IRP处理函数。这个表同样是一个大话题，因为它定义了I/O请求如何进入驱动程序。

设备对象

图2-15给出了设备对象的格式，阴影处代表不透明域。WDM驱动程序可以调用IoCreateDevice函数创建设备对象，但设备对象的管理则由I/O管理器负责。

DriverObject(PDRIVER_OBJECT)指向与该设备对象相关的驱动程序对象，通常就是调用IoCreateDevice函数创建该设备对象的驱动程序对象。过滤器驱动程序有时需要用这个指针来寻找被过滤设备的驱动程序对象，然后查看其MajorFunction表项。

NextDevice(PDEVICE_OBJECT)指向属于同一个驱动程序的下一个设备对象。是这个域把多个设备对象连接起来，起始点就是驱动程序对象中的DeviceObject成员。WDM驱动程序没有必要使用这个域。

图2-15. DEVICE_OBJECT结构

CurrentIrp(PIRP)指向最近发往驱动程序StartIo函数的I/O请求包。在第五章中，当讨论取消例程时，我还要更详细地讨论这个域。

Flags(ULONG)包含一组标志位。表2-2列出了其中可访问的标志位。

表2-2. DEVICE_OBJECT结构中的Flags标志

标志 | 描述
DO_BUFFERED_IO | 读写操作使用缓冲方式(系统复制缓冲区)访问用户模式数据
DO_EXCLUSIVE | 一次只允许一个线程打开设备句柄
DO_DIRECT_IO | 读写操作使用直接方式(内存描述符表)访问用户模式数据
DO_DEVICE_INITIALIZING | 设备对象正在初始化
DO_POWER_PAGABLE | 必须在PASSIVE_LEVEL级上处理IRP_MJ_PNP请求
DO_POWER_INRUSH | 设备上电期间需要大电流

Characteristics(ULONG)是另一组标志位，描述设备的可选特征，见表2-3。I/O管理器基于IoCreateDevice的一个参数初始化这些标志。过滤器驱动程序沿设备堆栈向上方向传播这些标志。

表2-3. DEVICE_OBJECT结构中的Characteristics标志

标志 | 描述
FILE_REMOVABLE_MEDIA | 可更换媒介设备
FILE_READ_ONLY_DEVICE | 只读设备
FILE_FLOPPY_DISKETTE | 软盘驱动器设备
FILE_WRITE_ONCE_MEDIA | 只写一次设备
FILE_REMOTE_DEVICE | 通过网络连接访问的设备
FILE_DEVICE_IS_MOUNTED | 物理媒介已在设备中
FILE_DEVICE_SECURE_OPEN | 在打开操作中检查设备对象的安全属性

DeviceExtension(PVOID)指向一个由用户定义的数据结构，该结构可用于保存每个设备实例的信息。I/O管理器为该结构分配空间，但该结构的名字和内容完全由用户决定。一个常见的做法是把该结构命名为DEVICE_EXTENSION。使用给定的设备对象指针fdo可访问这个用户结构，代码如下：

PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;

也许是巧合，在内存中，设备扩展(DeviceExtension)的位置紧接在设备对象后面。但依赖这种巧合，放弃使用指针访问设备扩展却是个完全错误的想法。

DeviceType(DEVICE_TYPE)是一个枚举常量，描述设备类型。I/O管理器基于IoCreateDevice的一个参数初始化这个成员。过滤器驱动程序有可能需要探测该值。在本书写作时，该成员大约有50多种可能值，见表2-4。

表2-4. 设备类型代码和默认的安全属性

设备类型 | 默认安全属性
FILE_DEVICE_BEEP | Public Open Unrestricted
FILE_DEVICE_CD_ROM | Modified Public Default Unrestricted
FILE_DEVICE_CD_ROM_FILE_SYSTEM | Public Default Unrestricted
FILE_DEVICE_CONTROLLER | Public Open Unrestricted
FILE_DEVICE_DATALINK | Public Open Unrestricted
FILE_DEVICE_DFS | Public Open Unrestricted
FILE_DEVICE_DISK | Modified Public Default Unrestricted
FILE_DEVICE_DISK_FILE_SYSTEM | Public Default Unrestricted
FILE_DEVICE_FILE_SYSTEM | Public Default Unrestricted
FILE_DEVICE_INPORT_PORT | Public Open Unrestricted
FILE_DEVICE_KEYBOARD | Public Open Unrestricted
FILE_DEVICE_MAILSLOT | Public Open Unrestricted
FILE_DEVICE_MIDI_IN | Public Open Unrestricted
FILE_DEVICE_MIDI_OUT | Public Open Unrestricted
FILE_DEVICE_MOUSE | Public Open Unrestricted
FILE_DEVICE_MULTI_UNC_PROVIDER | Public Open Unrestricted
FILE_DEVICE_NAMED_PIPE | Public Open Unrestricted
FILE_DEVICE_NETWORK | Modified Public Default Unrestricted
FILE_DEVICE_NETWORK_BROWSER | Public Open Unrestricted
FILE_DEVICE_NETWORK_FILE_SYSTEM | Modified Public Default Unrestricted
FILE_DEVICE_NULL | Public Open Unrestricted
FILE_DEVICE_PARALLEL_PORT | Public Open Unrestricted
FILE_DEVICE_PHYSICAL_NETCARD | Public Open Unrestricted
FILE_DEVICE_PRINTER | Public Open Unrestricted
FILE_DEVICE_SCANNER | Public Open Unrestricted
FILE_DEVICE_SERIAL_MOUSE_PORT | Public Open Unrestricted
FILE_DEVICE_SERIAL_PORT | Public Open Unrestricted
FILE_DEVICE_SCREEN | Public Open Unrestricted
FILE_DEVICE_SOUND | Public Open Unrestricted
FILE_DEVICE_STREAMS | Public Open Unrestricted
FILE_DEVICE_TAPE | Public Open Unrestricted
FILE_DEVICE_TAPE_FILE_SYSTEM | Public Default Unrestricted
FILE_DEVICE_TRANSPORT | Public Open Unrestricted
FILE_DEVICE_UNKNOWN | Public Open Unrestricted
FILE_DEVICE_VIDEO | Public Open Unrestricted
FILE_DEVICE_VIRTUAL_DISK | Modified Public Default Unrestricted
FILE_DEVICE_WAVE_IN | Public Open Unrestricted
FILE_DEVICE_WAVE_OUT | Public Open Unrestricted
FILE_DEVICE_8042_PORT | Public Open Unrestricted
FILE_DEVICE_NETWORK_REDIRECTOR | Public Open Unrestricted
FILE_DEVICE_BATTERY | Public Open Unrestricted
FILE_DEVICE_BUS_EXTENDER | Public Open Unrestricted
FILE_DEVICE_MODEM | Public Open Unrestricted
FILE_DEVICE_VDM | Public Open Unrestricted
FILE_DEVICE_MASS_STORAGE | Modified Public Default Unrestricted
FILE_DEVICE_SMB | Public Open Unrestricted
FILE_DEVICE_KS | Public Open Unrestricted
FILE_DEVICE_CHANGER | Public Open Unrestricted
FILE_DEVICE_SMARTCARD | Public Open Unrestricted
FILE_DEVICE_ACPI | Public Open Unrestricted
FILE_DEVICE_DVD | Public Open Unrestricted
FILE_DEVICE_FULLSCREEN_VIDEO | Public Open Unrestricted
FILE_DEVICE_DFS_FILE_SYSTEM | Public Open Unrestricted
FILE_DEVICE_DFS_VOLUME | Public Open Unrestricted
FILE_DEVICE_SERENUM | Public Open Unrestricted
FILE_DEVICE_TERMSRV | Public Open Unrestricted
FILE_DEVICE_KSEC | Public Open Unrestricted

StackSize(CCHAR)统计从该设备对象开始向下直到PDO之间的设备对象个数。该域的目的是告诉其它代码，如果把该设备对象的驱动程序作为其IRP的第一发送对象，那么应在这个IRP中创建多少个堆栈单元(stack location)。WDM驱动程序通常不需要修改该值，因为创建设备堆栈的支持函数会自动完成这个任务。

如何建立设备堆栈

在DEVICE_OBJECT的讨论中，我指出有一个NextDevice域把所有属于特定驱动程序的设备对象水平地连接在一起，但我没有描述把多个设备对象垂直地连接成一个设备堆栈的方法，即从上层FiDO到FDO，再到下层FiDO，最后到PDO。不透明域AttachedDevice就是用于这个目的。从PDO开始，每个设备对象都有指向上一层设备对象的指针。由于没有已公开的向下方向的指针，所以驱动程序必须自己记住下层设备对象是谁。(实际上，IoAttachDeviceToDeviceStack确实在一个结构中建立了向下方向的指针，但DDK中没有完全声明该结构。不要试图去查出这个结构，它随时都有可能被修改)

AttachedDevice域故意没有公开，因为要正确地使用该域需要与删除设备对象的代码同步。但我们可以调用IoGetAttachedDeviceReference函数，该函数在给定的堆栈中寻找最上层设备对象并增加参考计数，这样可以防止该设备对象被过早地从内存中删除。如果你要自己下到PDO，可以向设备发送一个IRP_MJ_PNP请求，其副功能码为IRP_MN_QUERY_DEVICE_RELATIONS，Type参数为TargetDeviceRelation。PDO驱动程序将返回PDO的地址。但是，我猜想这个IRP是保留给系统使用的，所以你最好不要使用这个IRP。我们完全可以在第一次建立设备对象时记住PDO的地址。

同样，为了知道你下层是什么设备对象，需要在第一次把设备对象加入堆栈中时保存对象的指针。另外为了派遣IRP，堆栈中的每个驱动程序都有可能用自己的方式实现向下指针。注意，一旦设备堆栈建立好就不要再改变它。


DriverEntry例程

在上一节，我讲到PnP管理器先装入硬件需要的驱动程序，然后再调用驱动程序中的AddDevice函数。一个驱动程序可以被多个类似的硬件使用，但驱动程序的某些全局初始化操作只能在第一次被装入时执行一次。而DriverEntry例程就是用于这个目的。

DriverEntry是内核模式驱动程序主入口点常用的名字。I/O管理器按下面方式调用该例程：

extern "C" NTSTATUS DriverEntry(IN PDRIVER_OBJECT DriverObject, IN PUNICODE_STRING RegistryPath)
{
  ...
}
注意
标准Build脚本将驱动程序入口点定为DriverEntry，你最好遵守这个假设，否则必须修改Build脚本。

在我讲述DriverEntry代码之前，我想谈一下函数原型本身。也许我们都不知道(除非你仔细看过build脚本中的编译器选项)，内核模式函数和驱动程序中的函数在为x86平台编译时都使用__stdcall调用约定。这虽然对编程没有任何影响，但应该记住这一点，它有利于你调试程序。我有时使用extern "C"编译指令，这是因为有时候我们需要在C代码中使用了C++语法，C++语法允许在程序的任何地方声明变量而不仅仅是在左大括号后面。这个预编译指令将禁止编译器生成C++形式的外部函数名修饰，这样连接器就能找到该函数。使用这个指令编译后，驱动程序入口函数的外部名将为_DriverEntry@8。

关于函数原型还有一点要提的是“IN”关键字。IN、OUT、INOUT在DDK中都被定义成空串，它们的功能就象程序注释，当你看到一个IN参数时，应该认定该参数是纯粹用于输入目的。OUT参数的内容无意义，它仅用于函数的输出信息，INOUT用于既可以输入又可以输出的参数。DDK头文件并不真正使用这些关键字。例如DriverEntry例程，它的DriverObject指针是IN参数，即你不能改变这个指针本身，但你完全可以改变它指向的对象。

关于函数原型最后一点要注意的是返回值类型，DriverEntry函数返回一个NTSTATUS值。NTSTATUS实际就是一个长整型，但你应该使用NTSTATUS定义该函数的返回值而不是LONG，这样代码的可读性会更好。大部分内核模式支持例程都返回NTSTATUS状态代码，你可以在DDK头文件NTSTATUS.H中找到NTSTATUS的代码列表。

DriverEntry概述

DriverEntry的第一个参数是一个指针，指向一个刚被初始化的驱动程序对象，该对象就代表你的驱动程序。WDM驱动程序的DriverEntry例程应完成对这个对象的初始化并返回。非WDM驱动程序需要做大量额外的工作，它们必须探测自己的硬件，为硬件创建设备对象(用于代表硬件)，配置并初始化硬件使其正常工作。而对于WDM驱动程序，颇麻烦的硬件探测和配置工作由PnP管理器自动完成，我将在第六章讨论PnP。如果你想知道非WDM驱动程序是如何初始化自身的，参见Art Baker的《The Windows NT Device Driver Book (Prentice Hall, 1997)》、Viscarola和Mason的《Windows NT Device Driver Development (Macmillan, 1998)》。

DriverEntry的第二个参数是设备服务键的键名。这个串不是长期存在的(函数返回后可能消失)，如果以后想使用该串就必须先把它复制到安全的地方。

对于WDM驱动程序的DriverEntry例程，其主要工作是把各种函数指针填入驱动程序对象。这些指针为操作系统指明了驱动程序容器中各种子例程的位置。它们包括下面这些指针成员(驱动程序对象中)：

  • DriverUnload 指向驱动程序的清除例程。I/O管理器会在卸载驱动程序前调用该例程。通常，WDM驱动程序的DriverEntry例程一般不分配任何资源，所以DriverUnload例程也没有什么清除工作要做。
  • DriverExtension->AddDevice 指向驱动程序的AddDevice函数。PnP管理器将为每个硬件实例调用一次AddDevice例程。由于AddDevice例程对WDM驱动程序特别重要，所以我将在本章下一节单独讲述它。
  • DriverStartIo 如果驱动程序使用标准的IRP排队方式，应该设置该成员，使其指向驱动程序的StartIo例程。如果你不理解什么是“标准”排队方式，不要着急，到第五章你就会完全明白，许多驱动程序都使用这种方法。
  • MajorFunction 是一个指针数组，I/O管理器把每个数组元素都初始化成指向一个哑派遣函数，这个哑派遣函数仅返回失败。驱动程序可能仅需要处理几种类型的IRP，所以至少应该设置与那几种IRP类型相对应的指针元素，使它们指向相应的派遣函数。我将在第五章详细讨论IRP和派遣函数。现在，你仅需要知道至少有三种IRP必须处理。

下面是一个近乎完整的DriverEntry例程：

extern "C"
NTSTATUS
DriverEntry(IN PDRIVER_OBJECT DriverObject, IN PUNICODE_STRING RegistryPath)
{
  DriverObject->DriverUnload = DriverUnload;							<--1
  DriverObject->DriverExtension->AddDevice = AddDevice;
  DriverObject->DriverStartIo = StartIo;
  DriverObject->MajorFunction[IRP_MJ_PNP] = DispatchPnp;					<--2
  DriverObject->MajorFunction[IRP_MJ_POWER] = DispatchPower;
  DriverObject->MajorFunction[IRP_MJ_SYSTEM_CONTROL] = DispatchWmi;
  ...												<--3
  servkey.Buffer = (PWSTR) ExAllocatePool(PagedPool, RegistryPath->Length + sizeof(WCHAR));	<--4
  if (!servkey.Buffer)
    return STATUS_INSUFFICIENT_RESOURCES;
  servkey.MaximumLength = RegistryPath->Length + sizeof(WCHAR);
  RtlCopyUnicodeString(&servkey, RegistryPath);
  return STATUS_SUCCESS;									<--5
}
  1. 前三条语句为驱动程序的其它入口点设置了函数指针。在这里，我用了能表达其功能的名字命名了这些函数：DriverUnload、AddDevice、StartIo。
  2. 每个WDM驱动程序必须能处理PNP、POWER、SYSTEM_CONTROL这三种请求；应该在这里为这些请求指定派遣函数。在早期的Windows 2000 DDK中，IRP_MJ_SYSTEM_CONTROL曾被称作IRP_MJ_WMI，所以我把系统控制派遣函数命名为DispatchWmi。
  3. 在省略号处，你可以插入设置其它MajorFunction指针的代码。
  4. 如果驱动程序需要访问设备的服务键，可以在这里备份RegistryPath串。例如，如果驱动程序要作为WMI生产者(见第十章)，则需要备份这个串。这里我假设已经在某处声明了一个类型为UNICODE_STRING的全局变量servkey。
  5. 返回STATUS_SUCCESS指出函数成功。如果函数失败，应该返回NTSTATUS.H中的一个错误代码，或者返回用户定义的错误代码。STATUS_SUCCESS的值为0。

DriverUnload例程

在WDM驱动程序中，DriverUnload例程的作用就是释放DriverEntry例程在全局初始化过程中申请的任何资源，但它几乎没什么可做。如果你在DriverEntry中备份了RegistryPath串，应该在这里释放备份所占用的内存：

VOID DriverUnload(PDRIVER_OBJECT DriverObject)
{
  RtlFreeUnicodeString(&servkey);
}

如果DriverEntry例程返回一个失败状态代码，系统将不再调用DriverUnload例程。所以，不能让DriverEntry例程出错后产生任何副作用，必须在它返回错误代码前消除副作用。

驱动程序再初始化例程

I/O管理器提供了一个服务函数：IoRegisterDriverReinitialization。它可以为非WDM驱动程序解决一个奇特的问题。这里我想解释一下这个问题，从中你可以知道为什么WDM驱动程序不用担心问题。非WDM驱动程序需要在DriverEntry例程中枚举它的硬件，并在其硬件的所有可能实例被识别前装入内存并初始化。例如，鼠标和键盘设备就是这样。假定DriverEntry枚举了所有鼠标或键盘硬件并为它们创建了设备对象，但如果DriverEntry例程运行的太快，那么这些驱动程序将不能正常工作。因此，它们必须使用IoRegisterDriverReinitialization函数寄存一个例程，之后I/O管理器在某个驱动程序检测到新硬件存在时再回调这个寄存例程。最后“再初始化例程”运行，同时也把自身寄存为下一次回调的函数。

WDM驱动程序不需要寄存再初始化例程，因为它们不需要用自己的代码去检测硬件。PnP管理器自动把新硬件匹配到正确的WDM驱动程序上，并调用该驱动程序的AddDevice例程，再由AddDevice例程做所有必要的初始化工作。


AddDevice例程

在前一节中，我讲述了当WDM驱动程序被第一次装入时如何初始化。通常，一个驱动程序可以被多个设备利用。WDM驱动程序有一个特殊的AddDevice函数，PnP管理器为每个设备实例调用该函数。该函数的原型如下：

NTSTATUS AddDevice(PDRIVER_OBJECT DriverObject, PDEVICE_OBJECT pdo)
{
}

DriverObject参数指向一个驱动程序对象，就是你在DriverEntry例程中初始化的那个驱动程序对象。pdo参数指向设备堆栈底部的物理设备对象。

对于功能驱动程序，其AddDevice函数的基本职责是创建一个设备对象并把它连接到以pdo为底的设备堆栈中。相关步骤如下：

  1. 调用IoCreateDevice创建设备对象，并建立一个私有的设备扩展对象。
  2. 寄存一个或多个设备接口，以便应用程序能知道设备的存在。另外，还可以给出设备名并创建符号连接。
  3. 初始化设备扩展和设备对象的Flag成员。
  4. 调用IoAttachDeviceToDeviceStack函数把新设备对象放到堆栈上。

下面我将详细解释这些步骤。

创建设备对象

调用IoCreateDevice函数创建设备对象，例如：

PDEVICE_OBJECT fdo;
NTSTATUS status = IoCreateDevice(DriverObject,
                 sizeof(DEVICE_EXTENSION),
                 NULL,
                 FILE_DEVICE_UNKNOWN,
                 FILE_DEVICE_SECURE_OPEN,
                 FALSE,
                 &fdo);

第一个参数(DriverObject) 就是AddDevice的第一个参数。该参数用于在驱动程序和新设备对象之间建立连接，这样I/O管理器就可以向设备发送指定的IRP。

第二个参数是设备扩展结构的大小。正如我在本章前面讲到的，I/O管理器自动分配这个内存，并把设备对象中的DeviceExtension指针指向这块内存。

第三个参数在本例中为NULL。它可以是命名该设备对象的UNICODE_STRING串的地址。决定是否命名设备对象以及以什么名字命名还需要仔细考虑，我将在本节后面深入讨论这个问题。

第四个参数(FILE_DEVICE_UNKNOWN) 是表2-4中列出的设备类型。这个值可以被设备硬件键或类键中的超越值所替代，如果这两个键都含有该参数的超越值，那么硬件键中的超越值具有更高的优先权。对于属于某个已存在类的设备，必须在这些地方指定正确的值，因为驱动程序与外围系统的交互需要依靠这个值。另外，设备对象的默认安全设置也依靠这个设备类型值。

第五个参数(FILE_DEVICE_SECURE_OPEN) 为设备对象提供Characteristics标志(见表2-3)。这些标志主要关系到块存储设备(如软盘、CDROM、Jaz等等)。未公开标志位FILE_AUTOGENERATED_DEVICE_NAME仅用于内部使用，并不是DDK文档忘记提到该标志。这个参数同样也能被硬件键或类键中的对应值超越，如果两个值都存在，那么硬件键中的超越值具有更高的优先权。

第六个参数(FALSE) 指出设备是否是排斥的。通常，对于排斥设备，I/O管理器仅允许打开该设备的一个句柄。这个值同样也能被注册表中硬件键和类键中的值超越，如果两个超越值都存在，硬件键中的超越值具有更高的优先权。

注意
排斥属性仅关系到打开请求的目标是命名设备对象。如果你遵守Microsoft推荐的WDM驱动程序设计方针，没有为设备对象命名，那么打开请求将直接指向PDO。PDO通常不能被标记为排斥，因为总线驱动程序没有办法知道设备是否需要排斥特征。把PDO标为排斥的唯一的机会在注册表中，即设备硬件键或类键的Properties子键含有Exclusive超越值。为了完全避免依赖排斥属性，你应该利用IRP_MJ_CREAT例程弹出任何有违规行为的打开请求。

第七个参数(&fdo) 是存放设备对象指针的地址，IoCreateDevice函数使用该变量保存刚创建设备对象的地址。

如果IoCreateDevice由于某种原因失败，则它返回一个错误代码，不改变fdo中的值。如果IoCreateDevice函数返回成功代码，那么它同时也设置了fdo指针。然后我们进行到下一步，初始化设备扩展，做与创建新设备对象相关的其它工作，如果在这之后又发现了错误，那么在返回前应先释放刚创建的设备对象并返回状态码。见下面例子代码：

NTSTATUS status = IoCreateDevice(...);
if (!NT_SUCCESS(status))
  return status;
...
if (<some other error discovered>)
{
  IoDeleteDevice(fdo);
  return status;
}

NTSTATUS状态代码和NT_SUCCESS宏的解释见下一章。

为设备命名

Windows NT使用对象管理器集中管理大量的内部数据结构，包括我们讨论过的驱动程序对象和设备对象。David Solomon在《Inside Windows NT, Second Edition (Microsoft Press, 1998)》的第三章“System Mechanisms”中给出了关于Windows NT对象管理器和命名空间的一个比较完整的阐述。对象都有名称，对象管理器用一个层次化的命名空间来管理这些名称。图2-16是DevView显示的顶层对象名。图中以文件夹形式显示的对象是目录对象，它可以包含子目录或常规对象，其它图标则代表正常对象。(从这一点上看，DevView与平台SDK中的WINOBJ工具相类似，但WINOBJ不能给出设备对象和驱动程序的相关信息)

图2-16. 用DevView观察命名空间

通常设备对象都把自己的名字放到\Device目录中。在Windows 2000中，设备的名称有两个用途。第一个用途，设备命名后，其它内核模式部件可以通过调用IoGetDeviceObjectPointer函数找到该设备，找到设备对象后，就可以向该设备的驱动程序发送IRP。

另一个用途，允许应用程序打开命名设备的句柄，这样它们就可以向驱动程序发送IRP。应用程序可以使用标准的CreateFile API打开命名设备句柄，然后用ReadFile、WriteFile，和DeviceIoControl向驱动程序发出请求。应用程序打开设备句柄时使用\\.\路径前缀而不是标准的UNC(统一命名约定)名称，如C:\MYFILE.CPP或\\FRED\C-Drive\HISFILE.CPP。在内部，I/O管理器在执行名称搜索前自动把\\.\转换成\??\。为了把\??目录中的名字与名字在其它目录(例如，在\Device目录)中的对象相连接，对象管理器实现了一种称为符号连接(symbolic link)的对象。

符号连接

符号连接有点象桌面上的快捷方式，符号连接在Windows NT中的主要用途是把处于列表前面的DOS形式的名称连接到设备上。图2-17显示了\??目录的部分内容，这里就有一些符号名，例如，“C:”和其它一些用DOS命名方案命名的驱动器名称，它们被连接到\Device目录中，而这些设备对象的真正名称就放在\Device目录中。符号连接可以使对象管理器在分析一个名称时能跳到命名空间的某个地方。例如，如果我用CreateFile打开名称为“C:\MYFILE.CPP”的对象，对象管理器将以下面过程打开该文件：

  1. 内核模式代码最开始看到的名称是\??\C:\MYFILE.CPP。对象管理器在根目录中查找“??”。
  2. 找到\??目录后，对象管理器在其中查找“C:”。它发现找到的对象是一个符号连接，所以它就用这个符号连接组成一个新的内核模式路径名：\Device\HarddiskVolume1\MYFILE.CPP，然后析取它。
  3. 使用新路径名后，对象管理器重新在根目录中查找“Device”。
  4. 找到\Device目录后，对象管理器在其中查找“HarddiskVolume1”，最后它找到一个以该名字命名的设备。

图2-17. \??目录和部分符号连接

现在，对象管理器要创建一个IRP，然后把它发到HarddiskVolume1设备的驱动程序。该IRP最终将使某个文件系统驱动程序或其它驱动程序定位并打开一个磁盘文件。描述文件系统驱动程序的工作过程已经超出了本书的范围。如果我们使用设备名COM1，那么最终收到该IRP的将是\Device\Serial0的驱动程序。

用户模式程序可以调用DefineDosDevice创建一个符号连接，如下例：

BOOL okay = DefineDosDevice(DDD_RAW_TARGET_PATH, "barf", "\\Device\\SECTEST_0");

图2-17中显示了上面调用的结果。

如果你需要在WDM驱动程序中创建一个符号连接，可以调用IoCreateSymbolicLink函数：

IoCreateSymbolicLink(linkname, targname);

linkname是要创建的符号连接名，targname是要连接的名字。顺便说一下，对象管理器并不关心targname是否是已存在对象的名字，如果连接到一个未定义的符号名，那么访问该符号连接将简单地收到一个错误。如果你想允许用户模式程序能超越这个连接而转到其它地方，应使用IoCreateUnprotectedSymbolicLink函数替代上面函数。

ARC名字

在ARC(Advanced RISC Computing)架构的计算机中，Windows 2000需要依赖一个称为ARC命名的概念。你可以在BOOT.INI文件中看到使用中的ARC名字，BOOT.INI文件位于引导驱动器的根目录。下面是该文件的一个例子：

[boot loader]
timeout=30
default=c:\
[operating systems]
C:\="Microsoft Windows 98"
scsi(0)disk(1)rdisk(0)partition(1)\BETA2F="Win2k Beta-2 (Free Build)"  /fastdetect /noguiboot
scsi(0)disk(1)rdisk(0)partition(1)\WINNT ="Win2K Beta-3 (Free Build)"  /fastdetect /noguiboot

在Intel平台上，象scsi(0)disk(1)rdisk(0)partition(1)的ARC名其实是符号连接，它们存在于内核的\ArcName目录中。

除了硬盘之外的块存储设备，它们的驱动程序在初始化时需要调用IoAssignArcName建立一个这样的符号连接。由于系统引导的需要，I/O管理器自动为硬盘设备建立ARC名。

应该命名设备对象吗？

决定为设备对象命名之前，你应该多想一想。如果命名了设备对象，那么任何内核模式程序都可以打开该设备的句柄。另外，任何内核模式或用户模式程序都能创建连接到该设备的符号连接，并可以使用这个符号连接打开设备的句柄。你可能允许也可能不允许这种事情发生。

是否命名设备对象的主要考虑是安全问题。当有人打开一个命名对象的句柄时，对象管理器将检查他是否有权这样做。当IoCreateDevice为你创建设备对象时，它也为设备对象设置了一个默认安全描述符(基于第四个参数中的设备类型)。下面是三个基本分类，I/O管理器基于这些分类来选择安全描述符。(参考表2-4中的第二列)

  • 大部分文件系统设备对象(磁盘、CD-ROM、文件、磁带)将得到“public default unrestricted”ACL(访问控制表)。该表对系统(SYSTEM)和管理员(administrator)之外的所有账户给予了SYNCHRONIZE、READ_CONTROL、FILE_READ_ATTRIBUTES、FILE_TRAVERSE访问权限。顺便说一下，文件系统设备对象就是作为CreateFile函数的目标而存在，CreateFile函数将打开一个由文件系统管理的文件。
  • 磁盘设备和网络文件系统对象将得到与文件系统对象相同的ACL，但做了一些修改。例如，任何人对命名软磁盘设备对象都有全部访问权，管理员有足够的权限运行ScanDisk。(用户模式的网络支持DLL需要更大的权限来访问其对应文件系统驱动程序的设备对象，这就是网络文件系统需要与其它文件系统区别对待的原因)
  • 所有其它的设备对象将得到“public open unrestricted”ACL，它允许任何有设备句柄的人不受限制地使用该设备。

可以看出，如果非磁盘设备的驱动程序在调用IoCreateDevice时给出设备对象名，那么任何人都可以读写这个设备，因为默认安全设置几乎允许用户有全部的访问权限，而且在创建符号连接时根本不进行安全检查。安全检查仅发生在对设备的打开操作上，基于命名对象的安全描述符。这对于在同一堆栈中的有更严格安全限制的其它设备对象也是这样。

DevView可以显示设备对象的安全属性。你可以通过测试一个文件系统、一个磁盘设备、或者任何其它随机存取设备了解到我刚描述过的默认操作规则。

PDO也得到一个默认安全描述符，但这个安全描述符可能被存储在硬件键或类键的Properties子键中的安全描述符超越(当两者都存在时，硬件键中的超越值有更高的优先权)。即使没有指定安全描述符超越，如果硬件键或类键的Properties子键中有设备类型或特征的超越值，那么I/O管理器也会基于新类型为对象构造一个新的默认安全描述符。但I/O管理器不会超越PDO上面的任何其它设备对象的安全设置。因此，由于超越的影响，你不应该命名你的设备对象。但不要失望，应用程序仍可以使用注册的接口(interface)访问你的设备。

关于安全问题的最后一点：当对象管理器析取对象名时，对于名字的中间部分仅需要具有FILE_TRAVERSE访问权，它仅在最终对象名上执行全部的安全检查。所以，假设某个设备对象可以通过\Device\SECTEST_0名或符号连接\??\SecurityTest_0名到达，那么，如果设备对象的安全描述符设置为拒绝写，则试图以写方式打开\\.\SecurityTest_0的用户模式应用程序将被阻塞。但如果应用程序试图打开名为\\.\SecurityTest_0\ExtraStuff的对象，那么打开请求(IRP_MJ_CREATE形式)将被允许，而此时用户对\\.\SecurityTest_0\仅有FILE_TRAVERSE权限。I/O管理器希望设备驱动程序自己去处理额外名称部件的安全检查。

为了避免涉及到我刚描述过的安全问题，你可以在调用IoCreateDevice时指定设备特征参数为FILE_DEVICE_SECURE_OPEN。该标志将使Windows 2000在额外名称部件存在的情况下仍检查调用者是否有权限打开设备句柄。

设备名称

如果你决定命名设备对象，通常应该把对象名放在名称空间的\Device分支中。为了命名设备对象，首先应该创建一个UNICODE_STRING结构来存放对象名，然后把该串作为调用IoCreateDevice的参数：

UNICODE_STRING devname;
RtlInitUnicodeString(&devname, L"\\Device\\Simple0");
IoCreateDevice(DriverObject, sizeof(DEVICE_EXTENSION), &devname, ...);

我将在下一章中讨论RtlInitUnicodeString的用法。

通常，驱动程序用设备类型串后加上一个以0开始的实例号作为设备对象名(如上面的Simple0)。一般，你不希望象我上面做的那样使用带有硬编码性质的名称。你希望用串操作函数动态地合成一个名字：

UNICODE_STRING devname;
static LONG lastindex = -1;
LONG devindex = InterlockedIncrement(&lastindex);
WCHAR name[32];
_snwprintf(name, arraysize(name), L"\\Device\\SIMPLE%2.2d", devindex);
RtlInitUnicodeString(&devname, name);
IoCreateDevice(...);

我将在后两章中解释上面代码中出现的服务函数。如上面代码所示，从私有设备类型得出的实例号应该是一个静态变量。

设备命名的注意事项

如果仅仅想在开发过程中为应用程序打开设备句柄提供一个快速方法，你应该在\??目录中为设备赋予一个名字。然而，对于一个产品级的驱动程序来说，最好把设备对象名放到\Device目录中。

\??目录以前叫做\DosDevices。实际上，\DosDevice仍可以使用，但它本身是\??目录的符号连接。这种改变将使经常查找的用户模式目录名能位于字母排序的目录列表前面。如果你要在命名中使用\??，应该先参考本章“Windows 98兼容问题”节中的注意事项。

注意，上面提到的把设备对象名放到\??目录中可能不适用于Windows 2000的Terminal Server版本。由于设备对象不能复制到控制台事务的外边，而符号连接可以，因此你应该在\Device目录中保存设备命名，而在\DosDevices目录中放一个符号连接。

在以前版本的Windows NT中，某些种类设备(特别是磁盘、磁带、串行口，和并行口)的驱动程序通过调用IoGetConfigurationInformation来获得一个全局表的指针，该表包含这些类中的设备计数。驱动程序应使用当前计数值来合成设备名称，例如Harddisk0、Tape1，等等，并同时增加该计数器的值。然而，WDM驱动程序并不需要使用这个服务函数以及它返回的计数器表，为这些类中的设备构造名称现在是Microsoft专有的类驱动程序的责任，如DISK.SYS。

设备接口

用旧的命名方法命名设备对象，并创建一个应用程序能够使用的符号连接，存在着两个主要问题。命名设备对象所带来的潜在安全问题我们已经讨论过。此外，访问设备的应用程序需要先知道设备采用的命名方案。如果你的硬件仅由你的应用程序访问，那么不会有什么问题。但是，如果有其它公司想为你的硬件写应用程序，并且有许多硬件公司想制作相似的设备，那么设计一个合适的命名方案是困难的。最后，许多命名方案将依赖于程序员所说的自然语言，这不是一个好的选择。

为了解决这些问题，WDM引入了一个新的设备命名方案，该方案是语言中立的、易于扩展的、可用于许多硬件和软件厂商，并且易于文档化。该方案依靠一个设备接口(device interface)的概念，它基本上是软件如何访问硬件的一个说明。一个设备接口被一个128位的GUID唯一标识。你可以用平台SDK中的UUIDGEN工具或者GUIDGEN工具生成GUID，这两个工具输出同一种数，但格式不同。这个想法就象某些工业组织联合起来共同制定某种硬件的标准访问方法一样。在标准制作过程中，产生了一些GUID，这些GUID将永远关联到某些接口上。

关于GUID

GUID用于标识软件接口，它与COM(部件对象模型)中用于标识COM接口的标识符相同，它还用于OSF(开放软件基金)的DCE(分布式计算环境)中，标识RPC(远程过程调用)目标。如果你想了解GUID如何生成以及为什么能在统计意义上唯一，请参考Kraig Brockschmidt的《Inside OLE, Second Edition (Microsoft Press, 1995)》第66页，原始算法规范由OSF制定，相关部分见http://www.opengroup.org/onlinepubs/9629399/apdxa.htm。

为了在设备驱动程序中使用GUID，首先需要使用UUIDGEN或者GUIDGEN生成GUID，然后把结果放到头文件中。GUIDGEN更易于使用，它允许选择GUID的输出格式，并把结果送到剪贴板。图2-18显示了GUIDGEN的运行窗口。你可以把它的输出粘贴到头文件中：

// {CAF53C68-A94C-11D2-BB4A-00C04FA330A6}
DEFINE_GUID(<<name>>,
0xCAF53C68, 0xA94C, 0x11D2, 0xBB, 0x4A, 0x00, 0xC0, 0x4F, 0xA3, 0x30, 0xA6);

然后，用有意义的名字换掉<<name>>，如GUID_SIMPLE，并把这个定义包含到驱动程序或应用程序中。

图2-18. 使用GUIDGEN生成GUID

我想接口类似于蛋白质合成器，它能制作活细胞的细胞膜。访问特定种类设备的应用程序有自己的蛋白质合成器，它就象一把钥匙，可以插入到所有有匹配合成器的设备驱动程序中。如图2-19。

图2-19. 用设备接口匹配应用程序和设备

注册设备接口 调用IoRegisterDeviceInterface函数，功能驱动程序的AddDevice函数可以注册一个或多个设备接口：

#include <initguid.h>							<--1
#include "guids.h"							<--2
...
NTSTATUS AddDevice(...)
{
  ...
  IoRegisterDeviceInterface(pdo, &GUID_SIMPLE, NULL, &pdx->ifname);	<--3
  ...
}
  1. 我们包含了GUIDS.H头文件，那里定义了DEFINE_GUID宏。DEFINE_GUID通常声明一个外部变量。在驱动程序的某些地方，我们不得不为将要引用的每个GUID保留初始化的存储空间。系统头文件INITGUID.H利用某些预编译指令使DEFINE_GUID宏在已经定义的情况下仍能保留该存储空间。
  2. 我使用单独的头文件来保存我要引用的GUID定义。这是一个好的想法，因为用户模式的代码也需要包含这些定义，但它们不需要那些仅与内核模式驱动程序有关的声明。
  3. IoRegisterDeviceInterface的第一个参数必须是设备PDO的地址。第二个参数指出与接口关联的GUID，第三个参数指出额外的接口细分类名。只有Microsoft的代码才使用名称细分类方案。第四个参数是一个UNICODE_STRING串的地址，该串用于接收设备对象的符号连接名。

IoRegisterDeviceInterface的返回值是一个Unicode串，这样在不知道驱动程序编码的情况下，应用程序能用该串确定并打开设备句柄。顺便说一下，这个名字比较丑陋；后面例子是我在Windows 98中为Sample设备生成的名字：\DosDevices\0000000000000007#{CAF53C68-A94C-11d2-BB4A-00C04FA330A6}。

注册过程实际就是先创建一个符号连接名，然后再把它存入注册表。之后，当响应PnP请求IRP_MN_START_DEVICE时，驱动程序将调用IoSetDeviceInterfaceState函数“使能”该接口：

IoSetDeviceInterfaceState(&pdx->ifname, TRUE);

在响应这个调用过程中，I/O管理器将创建一个指向设备PDO的符号连接对象。以后，驱动程序会执行一个功能相反的调用禁止该接口(用FALSE做参数调用IoSetDeviceInterfaceState)。最后，I/O管理器删除符号连接对象，但它保留了注册表项，即这个名字将总与设备的这个实例关联；但符号连接对象与硬件一同到来或消失。

因为接口名最终指向PDO，所以PDO的安全描述符将最终控制设备的访问权限。这样比较好，因为只有管理员才可以通过控制台控制PDO的安全属性。

枚举设备接口 内核模式代码和用户模式代码都能定位含有支持它们感兴趣接口的设备。下面我将解释如何在用户模式中枚举所有含有特定接口的设备。枚举代码写起来十分冗长，最后我不得不写一个C++类来实现。你可以在DEVICELIST.CPP和DEVICELIST.H文件中找到这些代码，这些文件是第八章“电源管理”中WDMIDLE例子的一部分。它们声明并实现了一个CDeviceList类，该类包含一个CDeviceListEntry对象数组。这两个类声明如下：

class CDeviceListEntry
{
public:
  CDeviceListEntry(LPCTSTR linkname, LPCTSTR friendlyname);
  CDeviceListEntry(){}
  CString m_linkname;
  CString m_friendlyname;
};

class CDeviceList
{
public:
  CDeviceList(const GUID& guid);
  ~CDeviceList();
  GUID m_guid;
  CArray<CDeviceListEntry, CDeviceListEntry&> m_list;
  int Initialize();
};

该类使用了CString类和CArray模板，它们都是MFC的一部分。这两个类的构造函数仅简单地把它们的参数复制到数据成员中：

CDeviceList::CDeviceList(const GUID& guid)
{
  m_guid = guid;
}

CDeviceListEntry::CDeviceListEntry(LPCTSTR linkname, LPCTSTR friendlyname)
{
  m_linkname = linkname;
  m_friendlyname = friendlyname;
}

所有实际的工作都发生在CDeviceList::Initialize函数中。其执行过程大致是这样：先枚举所有接口GUID与构造函数得到的GUID相同的设备，然后确定一个“友好”名，我们希望向最终用户显示这个名字。最后返回找到的设备号。下面是这个函数的代码：

int CDeviceList::Initialize()
{
  HDEVINFO info = SetupDiGetClassDevs(&m_guid, NULL, NULL, DIGCF_PRESENT | DIGCF_INTERFACEDEVICE);	<--1
  if (info == INVALID_HANDLE_VALUE)
    return 0;
  SP_INTERFACE_DEVICE_DATA ifdata;
  ifdata.cbSize = sizeof(ifdata);
  DWORD devindex;
  for (devindex = 0; SetupDiEnumDeviceInterfaces(info, NULL, &m_guid, devindex, &ifdata); ++devindex)	<--2
  {
    DWORD needed;
    SetupDiGetDeviceInterfaceDetail(info, &ifdata, NULL, 0, &needed, NULL);				<--3

    PSP_INTERFACE_DEVICE_DETAIL_DATA detail = (PSP_INTERFACE_DEVICE_DETAIL_DATA) malloc(needed);
    detail->cbSize = sizeof(SP_INTERFACE_DEVICE_DETAIL_DATA);
    SP_DEVINFO_DATA did = {sizeof(SP_DEVINFO_DATA)};
    SetupDiGetDeviceInterfaceDetail(info, &ifdata, detail, needed, NULL, &did));

    TCHAR fname[256];											<--4
    if (!SetupDiGetDeviceRegistryProperty(info,
                      &did,
                      SPDRP_FRIENDLYNAME,
                      NULL,
                      (PBYTE) fname,
                      sizeof(fname),
                      NULL)
      && !SetupDiGetDeviceRegistryProperty(info,
                       &did,
                       SPDRP_DEVICEDESC,
                       NULL,
                       (PBYTE) fname,
                       sizeof(fname),
                       NULL)
    )
      _tcsncpy(fname, detail->DevicePath, 256);

    CDeviceListEntry e(detail->DevicePath, fname);							<--5
    free((PVOID) detail);

    m_list.Add(e);
  }

  SetupDiDestroyDeviceInfoList(info);
  return m_list.GetSize();
}
  1. 该语句打开一个枚举句柄，我们用它寻找寄存了指定GUID接口的所有设备。
  2. 循环调用SetupDiEnumDeviceInterfaces以寻找每个匹配的设备。
  3. 有两项信息是我们需要的，接口的“细节”信息和设备实例信息。这个“细节”信息就是设备的符号名。因为它的长度可变，所以我们两次调用了SetupDiGetDeviceInterfaceDetail。第一次调用确定了长度，第二次调用获得了名字。
  4. 通过询问注册表中的FriendlyName键或DeviceDesc键，我们获得了设备的“友好”名称。
  5. 我们用设备符号名同时作为连接名和友好名创建了类CDeviceListEntry的一个临时实例e。
友好名

你可能会疑惑，注册表怎么会有设备的FriendlyName名。安装设备驱动程序的INF文件中有一个指定设备参数的HW段，这些参数将被添加到注册表中。通常我们可以在这里为设备提供一个FriendlyName名。

其它全局性的设备初始化操作

在AddDevice中还需要加入其它一些步骤来初始化设备对象，下面我将按顺序描述这些步骤。

初始化设备扩展

设备扩展的内容和管理全部由用户决定。该结构中的数据成员应直接反映硬件的专有细节以及对设备的编程方式。大多数驱动程序都会在这里放入一些数据项，下面代码声明了一个设备扩展结构：

typedef struct _DEVICE_EXTENSION {				<--1
  PDEVICE_OBJECT DeviceObject;					<--2
  PDEVICE_OBJECT LowerDeviceObject;				<--3
  PDEVICE_OBJECT Pdo;						<--4
  UNICODE_STRING ifname;					<--5
  IO_REMOVE_LOCK RemoveLock;					<--6
  DEVSTATE devstate;						<--7
  DEVSTATE prevstate;
  DEVICE_POWER_STATE devpower;
  SYSTEM_POWER_STATE syspower;
  DEVICE_CAPABILITIES devcaps;					<--8
  ...
} DEVICE_EXTENSION, *PDEVICE_EXTENSION;
  1. 我模仿DDK中官方的结构声明模式声明了这个结构。
  2. 我们可以用设备对象中的DeviceExtension指针定位自己的设备扩展。同样，我们有时也需要在给定设备扩展时能定位设备对象。因为某些函数的逻辑参数就是设备扩展本身(这里有设备每个实例的全部信息)。所以，我认为这里应该有一个DeviceObject指针。
  3. 我在一些地方曾提到过，在调用IoAttachDeviceToDeviceStack函数时，应该把紧接着你下面的设备对象的地址保存起来。LowerDeviceObject成员用于保存这个地址。
  4. 有一些服务例程需要PDO的地址，而不是堆栈中某个高层设备对象的地址。由于定位PDO非常困难，所以最好的办法是在AddDevice执行时在设备扩展中保存一个PDO地址。
  5. 无论你用什么方法(符号连接或设备接口)命名你的设备，都希望能容易地获得这个名字。所以，这里我用一个Unicode串成员ifname来保存设备接口名。如果你使用一个符号连接名而不是设备接口，应该使用一个有相关含义的成员名，例如“linkname”。
  6. 当你调用IoDeleteDevice删除这个设备对象时，需要使用一个自旋锁来解决同步安全问题，我将在第六章中讨论同步问题。因此，需要在设备扩展中分配一个IO_REMOVE_LOCK对象。AddDevice有责任初始化这个对象。
  7. 你可能需要一个成员来记录设备当前的PnP状态和电源状态。DEVSTATE和POWERSTATE是枚举类型变量，我假设事先已经在头文件中声明了这些变量类型。我将在后面章节中讨论这些状态变量的用途。
  8. 电源管理的另一个部分涉及电源能力设置的恢复，设备扩展中的devcaps结构用于保存这些设置。

下面是AddDevice中的初始化语句(着重设备扩展部分的初始化)：

NTSTATUS AddDevice(...)
{
  PDEVICE_OBJECT fdo;
  IoCreateDevice(..., sizeof(DEVICE_EXTENSION), ..., &fdo);
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  pdx->DeviceObject = fdo;
  pdx->Pdo = pdo;
  IoInitializeRemoveLock(&pdx->RemoveLock, ...);
  pdx->devstate = STOPPED;
  pdx->devpower = PowerDeviceD0;
  pdx->syspower = PowerSystemWorking;
  IoRegisterDeviceInterface(..., &pdx->ifname);
  pdx->LowerDeviceObject = IoAttachDeviceToDeviceStack(...);
}

初始化默认的DPC对象

许多设备使用中断来报告操作完成。我将在第七章“读写数据”中讨论中断处理，其中对中断服务例程(ISR)能做什么做了严格的限定。特别是ISR不能调用用于报告IRP完成的例程(IoCompleteRequest)。利用DPC(推迟过程调用)可以绕过这个限制。你的设备对象中应包含一个辅助DPC对象，它可以调度你的DPC例程，该对象应该在设备对象创建后不久被初始化。

NTSTATUS AddDevice(...)
{
  IoCreateDevice(...);
  IoInitializeDpcRequest(fdo, DpcForIsr);
}

设置缓冲区对齐掩码

执行DMA传输的设备直接使用内存中的数据缓冲区工作。HAL要求DMA传输中使用的缓冲区必须按某个特定界限对齐，而且设备也可能有更严格的对齐需求。设备对象中的AlignmentRequirement域表达了这个约束，它是一个位掩码，等于要求的地址边界减一。下面语句可以把任何地址圈入这个界限：

PVOID address = ...;
SIZE_T ar = fdo->AlignmentRequirement;
address = (PVOID) ((SIZE_T) address & ~ar);

还可以把任意地址圈入下一个对齐边界：

PVOID address = ...;
SIZE_T ar = fdo->AlignmentRequirement;
address = (PVOID) (((SIZE_T) address + ar) & ~ar);

在这两段代码中，我使用了SIZE_T把指针类型(它可以是32位也可以是64位，这取决于编译的目标平台)转化成一个整型，该整型与原指针有同样的跨度范围。

IoCreateDevice把新设备对象中的AlignmentRequirement域设置成HAL要求的值。例如，Intel的x86芯片没有对齐需求，所以AlignmentRequirement的默认值为0。如果设备需要更严格的缓冲区对齐(例如设备有总线主控的DMA能力，要求对齐数据缓冲区)，应该修改这个默认值，如下：

if (MYDEVICE_ALIGNMENT - 1 > fdo->AlignmentRequirement)
  fdo->AlignmentRequirement = MYDEVICE_ALIGNMENT - 1;

我假设你在驱动程序某处已定义了一个名为MYDEVICE_ALIGNMENT的常量，它是2的幂，代表设备的数据缓冲区对齐需求。

其它对象

设备可能还有其它一些需要在AddDevice中初始化的对象。这些对象可能包括各种同步对象，各种队列头(queue anchors)，聚集/分散列表缓冲区，等等。事实上，在本书的其它地方讨论这些对象的初始化更合适。

初始化设备标志

设备对象中有两个标志位需要在AddDevice中初始化，并且它们在以后也不会改变，它们是DO_BUFFERED_IO和DO_DIRECT_IO标志。你只能设置并使用其中一个标志，它将决定你以何种方式处理来自用户模式的内存缓冲区。(我将在第七章中讨论这两种缓冲模式的不同，以及你如何选择) 由于任何在后面装入的上层过滤器驱动程序将复制你的标志设置，所以在AddDevice中做这个选择十分重要。如果你在过滤器驱动程序装入后改变了设置，它们可能会不知道。

设备对象中有两个标志位属于电源管理范畴。与前两个缓冲区标志不同，这两个标志在任何时间都可以被改变。我将在第八章中详细讨论它们，但这里我先介绍一下。DO_POWER_PAGABLE意味着电源管理器将在PASSIVE_LEVEL级上向你发送IRP_MJ_POWER请求。DO_POWER_INRUSH意味着你的设备在上电时将汲取大量电流，因此，电源管理器将确保没有其它INRUSH设备同时上电。

设置初始电源状态

大部分设备一开始就进入全供电状态。如果你知道你的设备的初始电源状态，应该告诉电源管理器：

POWER_STATE state;
state.DeviceState = PowerDeviceD0;
PoSetPowerState(fdo, DevicePowerState, state);

电源管理的细节请见第八章。

建立设备堆

每个过滤器驱动程序和功能驱动程序都有责任把设备对象放到设备堆栈上，从PDO开始一直向上。你可以调用IoAttachDeviceToDeviceStack完成你那部分工作：

NTSTATUS AddDevice(..., PDEVICE_OBJECT pdo)
{
  PDEVICE_OBJECT fdo;
  IoCreateDevice(..., &fdo);
  pdx->LowerDeviceObject = IoAttachDeviceToDeviceStack(fdo, pdo);
}

IoAttachDeviceToDeviceStack的第一个参数是新创建的设备对象的地址。第二个参数是PDO地址。AddDevice的第二个参数也是这个地址。返回值是紧接着你下面的任何设备对象的地址，它可以是PDO，也可以是其它低级过滤器设备对象。如果该函数失败则返回一个NULL指针，因此你的AddDevice函数也是失败的，应返回STATUS_DEVICE_REMOVED。

清除DO_DEVICE_INITIALIZING标志

在AddDevice中最后一件需要做的事是清除设备对象中的DO_DEVICE_INITIALIZING标志：

fdo->Flags &= ~DO_DEVICE_INITIALIZING;

当这个标志设置时，I/O管理器将拒绝任何打开该设备句柄的请求或向该设备对象上附着其它设备对象的请求。在驱动程序完成初始化后，必须清除这个标志。在以前版本的Windows NT中，大部分驱动程序在DriverEntry中创建所有需要的设备对象。当DriverEntry返回时，I/O管理器自动遍历设备对象列表并清除该标志。但在WDM驱动程序中，设备对象在DriverEntry返回后才创建，所以I/O管理器不会自动清除这个标志，驱动程序必须自己清除它。


Windows 98兼容问题

Windows 98在创建某些外围设备对象和装载驱动程序上与Windows 2000有一些细节上的不同。本节将解释能影响到驱动程序开发的某些差异。

DriverEntry调用上的不同

如我以前提到的，DriverEntry例程有一个UNICODE_STRING参数，该参数指定驱动程序的服务键。在Windows 2000中，该串以全注册表路径形式出现“\Registry\Machine\System\CurrentControlSet\Services\xxx”(xxx是服务表项的名称) 。在Windows 98中，该串出现的形式是“System\CurrentControlSet\Services\<classname >\<instance#>”(<classname>是设备所属类的名称，<instance-#>是实例号，例如0000)。用ZwOpenKey函数打开该键可以避免这个环境差异。

注册表组织的不同

Windows 98与Windows 2000在设备注册表项的组织上有些差异。下面这些简短解释说明了这个问题，如果你阅读了第十二章资料后再回头看这些解释会更加明了。

  • Windows 98的硬件键位于HKLM\Enum下并且没有任何保护(因为Windows 98没有安全系统)。它没有Service值；但有一个Driver值，提供服务键名的另外两个部分。由于Windows 98注册表没有MULTI_SZ类型，所以LowerFilters和UpperFilters值将以二进制形式出现，并且该值使用8位字符来命名驱动程序映像文件(.SYS扩展名)。
  • Windows 98的类键在HKLM\System\CurrentControlSet\Services\Class下。
  • Windows 98的服务键是类键的一个子键。服务键的表项中包含一个DevLoader值和一个NTMPDriver值，前者指向NTKERN.VXD，后者命名驱动程序映像(.SYS扩展名)，这个驱动程序必须存在于%SystemRoot%\System32\Drivers目录中。

\??目录

Windows 98不能识别\??目录名。所以，你需要把符号连接名放到\DosDevices目录中。该目录在Windows NT中也可以使用，因为它就是\??目录的符号连接。

未实现的设备类型

原始版本的Windows 98不支持创建块存储设备对象，这些设备类型包括FILE_DEVICE_DISK、FILE_DEVICE_TAPE、FILE_DEVICE_CD_ROM，和FILE_DEVICE_VIRTUAL_DISK。即使你调用了IoCreateDevice函数，并且它也返回了STATUS_SUCCESS状态码，但并没有实际的设备对象出现，而且在最后一个参数给出的PDEVICE_OBJECT地址也没有被填写。

问题的根源是Windows 98的磁盘驱动程序必须使用为Windows 95制定的I/O管理架构。但是，为什么IoCreateDevice以沉默方式返回失败则有些另人迷惑。


第三章：基本编程技术

写一个WDM驱动程序就象在做一次软件工程方面的练习。无论硬件有什么需求，你都必须把各种编程元素组合起来以形成一个完整的程序。在上一章中，我描述了WDM驱动程序的基本结构，并详细阐述了两个基本例程：DriverEntry和AddDevice。在这一章中，我将集中到更基本的主题上，如错误处理、内存和数据结构管理、注册表和文件存取，以及其它一些主题。另外，我还用一些辅助驱动程序调试的简短讨论来充实这章内容。

  • 内核模式编程环境
  • 错误处理
  • 内存管理
  • 字符串操作
  • 其它编程技术
  • Windows 98兼容问题


内核模式编程环境

图3-1显示了Windows NT操作系统的某些组成部分。每个部分都输出一些服务函数，这些函数以两个特别的字母组合开头：

  • I/O管理器(Io前缀) 包含许多驱动程序可以使用的服务函数，对这些函数的描述遍及本书。
  • 进程结构模块(Ps前缀) 创建并管理内核模式线程。普通的WDM驱动程序应使用一个独立的线程来循检无中断生成能力的设备。
  • 内存管理器(Mm前缀) 控制页表，页表定义了虚拟内存到物理内存之间的映射。
  • executive (Ex前缀) 提供堆管理和同步服务。本章将讨论堆管理函数。下一章讨论同步服务。
  • 对象管理器(Ob前缀) 集中控制Windows NT中的各种数据对象。WDM驱动程序仅需要对象管理器维护对象的参考计数，以防止对象被意外删除。
  • 安全参考监视器(Se前缀) 使文件系统驱动程序执行安全检测。I/O请求到达WDM驱动程序前已经做完了安全检测，所以本书不讨论这些函数。
  • 运行时间库部件(Rtl前缀) 包含工具例程，例如列表和串管理例程，内核模式驱动程序可以用这些例程来替代常规的ANSI标准例程。大部分例程可以从其名字上直接看出它的功能。
  • Win32子系统存在于用户模式中，所以用户模式中的应用程序可以容易地调用其例程。为了方便，Windows NT在内核模式中实现了一些有Zw前缀名的函数，这些函数可以使驱动程序调用Win32子系统例程。Windows 2000 DDK中仅暴露一少部分这样的函数给驱动程序使用，包括访问文件和注册表的函数。我将在本章讨论这些函数。
  • Windows NT内核(Ke前缀) 所有多线程和多处理器的低级同步活动都发生在内核中，我将在下一章中讨论KeXxx函数。
  • 在操作系统的最底层是硬件抽象层(HAL，Hal前缀)。操作系统把所有关于计算机硬件如何连接的信息都存放在HAL中。HAL了解如何在特定平台上实现中断操作，如何实现自旋锁，如何寻址I/O或内存映射设备，等等。 WDM驱动程序不直接与硬件对话，它通过调用HAL中的函数来达到目的。所以WDM驱动程序能够实现平台无关和总线无关。

图3-1. 内核模式支持例程概观

使用标准运行时间库函数

在历史上，Windows NT的设计者认为，不应该在驱动程序中使用C编译器厂商提供的运行时间库。部分原因是由于Windows NT是在ANSI标准出台前设计的，每个C编译器厂商都有自己的实现方法和品质标准。另一个原因是因为标准运行时间库中的例程有时需要依赖用户模式中的应用程序来初始化，并且有些例程并不是以多线程或多处理器安全的方式实现的。

直到现在，官方认为内核模式驱动程序仅应调用DDK中公开的函数。例如，你不能在驱动程序中调用wcscmp函数，而应该调用RtlCompareUnicodeString。然而，这里有一个公开的秘密，用于创建驱动程序的标准输入库(ntoskrnl.lib)定义了许多函数，而这些函数却是在诸如string.h、stdio.h、stdlib.h，和ctypes.h的头文件中声明的，这些头文件都是应用程序经常使用的头文件。所以，为什么我们不能使用它们？实际上，倘若你了解所有的内部细节，你完全可以调用它们。但你不能总这样做，例如，你不能总用memcpy替代RtlCopyBytes，因为这两者稍有不同。(RtlCopyByte可以保证一个字节一个字节地复制数据而不是以较大的块，大块复制数据在某些RISC平台上会出现麻烦)

注意侧效

驱动程序中使用的许多支持“函数”其实是DDK头文件中定义的宏。我们都知道应该避免在宏的参数中使用带有边效的表达式，原因很明显，宏可以多次使用其参数，见下面代码：

int a = 2, b = 42, c;
c = min(a++, b);

a的值是什么？(c的值又是什么？) 让我们看看这个似是而非的min宏：

#define min(x,y) (((x)<(y)) ? (x) : (y))

如果你用a++代替x，你将看到a最后等于4，因为表达式a++执行了两次。而“函数”min将返回3而不是2，因为函数的返回值是在第二次计算a++之前提取的a值。

通常，你不能知道DDK什么时候使用宏，什么时候使用真正的外部函数。有时候，一个特殊的服务函数在某些平台上是宏而在其它平台上却是外部函数。此外，Microsoft也可能在将来改变想法。所以，当你写WDM驱动程序时应坚守下面原则：

决不在内核模式服务函数的参数中使用带有侧效的表达式。


错误处理

人总会犯错误，错误恢复是软件工程的一部分。程序中总会发生异常情况，其中一些源自程序中的Bug，或者在我们的代码中或者在调用我们代码的用户模式应用程序中。另一些涉及到系统装载或硬件的瞬间状态。无论什么原因，代码必须能对不寻常的情况作出恰当的反应。在这一节中，我将描述三种错误处理形式：状态代码、结构化异常处理，和bug check。一般，内核模式支持例程通过返回状态代码来报告意外错误。对于正常情况，它们将返回布尔值或者数值而不是正式的状态代码。结构化异常处理为异常事件发生后的清除工作提供了一个标准化方法，它可以避免因为异常事件而导致系统崩溃，异常事件是指诸如被零除或参考无效指针等的意外错误。Bug check实际上就是致命错误的内部名称，对于这种错误，唯一的解决办法就是重启动系统。

状态代码

内核模式支持例程(以及你的代码)通过向其调用者返回一个状态代码来表明调用是否成功。NTSTATUS是一个由多个子域组成的32位整数，如图3-2。高两位(Severity)指出状态的严重性——成功、信息、警告、错误。客户位(Customer)是一个标志，完成的IRP将携带一个表明完成状态的状态代码，如果这个状态代码中的Customer标志被设置，那么这个状态代码将被不修改地传回应用程序(应用程序通过调用GetLastError函数获得)。通常，状态代码在返给应用程序前要翻译成Win32错误代码(Win32错误代码可以在KBase Q113996文章中查到)。facility代码指出该状态是由哪个系统部件导致的，一般用于减少开发组之间的代码关联。剩下的16位代码指出实际的状态。

图3-2. NTSTATUS代码的格式

我们应该总是检测例程的返回状态。为了不让大量的错误处理代码干扰例子代码所表达的实际意图，我经常省略代码片段中错误检测部分，但你在实际练习中不要效仿我。

如果状态码高位为0，那么不管其它位是否设置，该状态代码仍旧代表成功。所以，绝对不要用状态代码与0比较来判断操作是否成功，应该使用NT_SUCCESS宏：

NTSTATUS status = SomeFunction(...);
if(!NT_SUCCESS(status))
{
  <handle error>
}

不仅要检测调用例程的返回状态，还要向调用你的例程返回状态代码。在上一章中，我讲述了两个驱动程序例程，DriverEntry和AddDevice，它们都定义了NTSTATUS返回代码。所以，如果这些例程成功，则应返回STATUS_SUCCESS。如果在某个地方出错，应返回一个适当的错误状态代码，有时函数返回的状态代码就是出错函数返给你的状态代码。

例如，下面是AddDevice函数的一些初始化代码，包括完整的错误检测代码：

NTSTATUS AddDevice(PDRIVER_OBJECT DriverObject, PDEVICE_OBJECT pdo)
{
  NTSTATUS status;
  PDEVICE_OBJECT fdo;
  status = IoCreateDevice(DriverObject,
                          sizeof(DEVICE_EXTENSION),
                          NULL,
                          FILE_DEVICE_UNKNOWN,
                          0,
                          FALSE,
                          &fdo);
  if (!NT_SUCCESS(status))										<--1
  {
    KdPrint(("IoCreateDevice failed - %X\n", status));							<--2
    return status;
  }
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  pdx->DeviceObject = fdo;
  pdx->Pdo = pdo;
  pdx->state = STOPPED;
  IoInitializeRemoveLock(&pdx->RemoveLock, 0, 0, 255);							<--3
  status = IoRegisterDeviceInterface(pdo, &GUID_SIMPLE, NULL, &pdx->ifname);
  if (!NT_SUCCESS(status))										<--4
  {
    KdPrint(("IoRegisterDeviceInterface failed - %X\n", status));
    IoDeleteDevice(fdo);
    return status;
  }
    ...
}
  1. 如果IoCreateDevice失败，我们就把这个状态代码返回给上层调用者。注意代码中NT_SUCCESS宏的使用。
  2. 打印出任何错误状态信息是一个好的习惯，尤其是在调试驱动程序时。我将在本章后面讨论如何使用KdPrint。
  3. IoInitializeRemoveLock是一个VOID函数，这意味着它不会失败。所以没有必要检测它的状态代码，该函数在第六章讨论。
  4. 如果IoRegisterDeviceInterface失败，我们就需要在返回前做些清除工作；即我们必须删除刚创建的设备对象。

并不是所有被调用例程导致的错误都要处理，有些错误是可以忽略的。例如，在第八章电源管理中，我将提到带有IRP_MN_POWER_SEQUENCE子类型的电源管理请求，使用它可以避免上电过程中不必要的状态恢复过程。这个请求不仅对你是可选的，而且总线驱动程序在实现该请求上也是可选的。所以如果该请求执行失败，你不用做任何处理，继续其它工作。同样，你也可以忽略IoAllocateErrorLogEntry产生的错误，因为不能向错误登记表添加一条记录根本不是什么严重错误。

结构化异常处理

Windows NT提供了一种处理异常情况的方法，它可以帮助我们避免潜在的系统崩溃。结构化异常处理与编译器的代码生成器紧密集成，它允许你在自己的代码段周围加上保护语句，如果被保护代码段中的任何语句出现异常，系统将自动调用异常处理程序。结构化异常处理还便于你提供清除语句，不管控制以何种方式离开被保护代码段，清除代码都会被执行。

许多读者并不熟悉结构化异常方法，所以我在这里先解释一些基本概念。使用这个方法可以写出更好更稳固的代码。在许多情况下，WDM驱动程序例程接收到的参数都是经过其它代码严格检验的，一般不会成为导致异常的原因。但我们仍要遵循这样基本原则：对用户模式虚拟内存直接引用的代码段应该用结构化异常帧保护起来。这样的引用通常发生在调用MmProbeAndLockPages、ProbeForRead，和ProbeForWrite函数时。

注意
结构化异常机制可以使内核模式代码在访问一个非法的用户模式地址后避免系统崩溃。但它不能捕捉其它处理器异常，例如被零除或试图访问非法的内核模式地址。从这一点上看，这种机制在内核模式中不象在用户模式中那样具有通用性。

内核模式程序通过在内存堆栈上建立异常帧来实现结构化异常，这个堆栈就是程序用于参数传递、子程序调用，和分配自动变量所使用的内存堆栈，我不将详细描述这个机制的内部过程，因为该机制在不同的Windows NT平台上会不同。然而，这个机制与在用户模式程序中使用的结构化异常机制相同，你可以在这两个地方找到对这个机制的详细描述，Matt Pietrek的文章“A Crash Course on the Depths of Win32 Structured Exception Handling”Microsoft Systems Journal (January 1997)。Jeff Richter在《Programming Applications for Microsoft Windows, Fourth Edition (Microsoft Press, 1999)》中的专题讨论。

当异常发生时，操作系统通过扫描堆栈异常帧来寻找相应的异常处理程序。图3-3描绘了这个逻辑流程。每个异常帧都指定一个过滤函数，系统调用这个过滤函数来回答这样的问题：“你能处理这个异常吗？”，当系统找到对应的异常处理程序时，它就回卷堆栈异常帧以恢复处理程序所需要的上下文。回卷过程包括调用同一组过滤函数，并指定一个有这样含义的参数：“我们正在回卷；如果你回答：是，那么立即接管控制”，如果没有代码处理这个异常，那么这里将是一个默认处理例程，该例程使系统崩溃。

图3-3. 结构化异常处理逻辑

当使用Microsoft编译器时，你可以使用C/C++的Microsoft扩展，它隐藏了使用某些操作系统原语的复杂性。例如，用__try语句指定被保护代码段，用__finally语句指定终止处理程序，用__except语句指定异常处理程序。

注意
最好总使用带有双下划线的关键字，如__try、__finally、和__except。在C编译单元中，DDK头文件WARNING.H也把try、finally、和except宏定义成这些双下划线的关键字。DDK例子程序使用这些宏而不是直接使用带双下划线的关键字。有一点需要注意：在C++编译单元中，try语句必须与catch语句成对出现，这是一个完全不同的异常机制，是C++语言的一部分。C++异常机制不能用于驱动程序中，除非你自己从运行时间库中复制出某些基础结构。Microsoft不推荐那样做，因为这将增加驱动程序的内存消耗并增大执行文件的大小。

Try-Finally块

从try-finally块开始解释结构化异常处理最为容易，用它你可以写出象下面这样的清除代码：

__try
{
  <guarded body>
}
__finally
{
  <termination handler>
}

在这段伪代码中，被保护体<guarded body>是一系列语句和子例程。通常，这些语句会有副作用，如果没有副作用，就没有必要使用一个try-finally块，因为没有东西需要清除。终止处理程序<termination handler>包含一些恢复语句，用于部分或全部恢复被保护体产生的副作用。

语法上，try-finally按下面方式工作。首先，计算机执行被保护体<guarded body>。由于某种原因控制离开被保护体，计算机执行终止处理程序。如图3-4。

图3-4. try-finally中的控制流程

这里有一个简单的例子：

LONG counter = 0;
__try
{
  ++counter;
}
__finally
{
  --counter;
}
KdPrint(("%d\n", counter));

首先，被保护体执行并把counter变量的值从0增加到1。当控制穿过被保护体右括号后，终止处理程序执行，又把counter减到0。打印出的值将为0。

下面是一个稍复杂的修改：

VOID RandomFunction(PLONG pcounter)
{
  __try
  {
    ++*pcounter;
    return;
  }
  __finally
  {
    --*pcounter;
  }
}

该函数的结果是：pcounter指向的整型值不变，不管控制以何种原因离开被保护体，包括通过return语句或goto语句，终止处理程序都将执行。开始，被保护体增加计数器值并执行一个return语句，接着清除代码执行并减计数器值，之后该子程序才真正返回。

下面例子可以加深你对try-finally语句的理解:

static LONG counter = 0;
__try
{
  ++counter;
  BadActor();
}
__finally
{
  --counter;
}

这里我们调用了BadActor函数，我假定该函数将导致某种异常，这将触发堆栈回卷。作为回卷“执行和异常堆栈”过程的一部分，操作系统将调用我们的恢复代码并把counter恢复到以前的值。然后操作系统继续回卷堆栈，所以不论我们在__finally块后有什么代码都得不到执行。

Try-Except块

结构化异常处理的另一种使用方式是try-except块：

__try
{
  <guarded body>
}
__except(<filter expression>)
{
  <exception handler>
}

try-except块中的被保护代码可能会导致异常。你可能调用了象MmProbeAndLockPages这类的内核模式服务函数，这些函数使用来自用户模式的指针，而这些指针并没有做过明确的有效性检测。也许是因为其它原因。但不管什么原因，如果程序在通过被保护代码段时没有发生任何错误，那么控制将转到异常处理代码后面继续执行，你可以认为这是正常情况。如果在你的代码中或任何你调用的子例程中发生了异常，操作系统将回卷堆栈，并对__except语句中的过滤表达式求值。结果将是下面三个值中的一个：

  • EXCEPTION_EXECUTE_HANDLER 数值上等于1，告诉操作系统把控制转移到你的异常处理代码。如果控制走到处理程序的右大括号之外(如执行了return语句或goto语句)，那么控制将转到紧接着异常处理代码的后面继续执行。(我看过了平台SDK中关于异常控制返回点的文档，但那不正确)
  • EXCEPTION_CONTINUE_SEARCH 数值上等于0，告诉操作系统你不能处理该异常。系统将继续扫描堆栈以寻找其它处理程序。如果没有找到为该异常提供的处理程序，系统立即崩溃。
  • EXCEPTION_CONTINUE_EXECUTION 数值上等于-1，告诉操作系统返回到异常发生的地方。关于这个值我稍后再谈。

图3-5显示了try-except块中可能出现的控制路径。

图3-5. try-except块中的控制流程

例如，下面代码演示了如何防止接收非法指针。(见光盘中的SEHTEST例子)

PVOID p = (PVOID) 1;
__try
{
  KdPrint(("About to generate exception\n"));
  ProbeForWrite(p, 4, 4);
  KdPrint(("You shouldn't see this message\n"));
}
__except(EXCEPTION_EXECUTE_HANDLER)
{
  KdPrint(("Exception was caught\n"));
}
KdPrint(("Program kept control after exception\n"));

ProbeForWrite测试一个数据区域是否有效。在这个例子中，它将导致一个异常，因为我们提供的指针参数没有以4字节边界对齐。然后，异常处理程序得到控制。最后，异常处理完成后控制将转到异常处理程序后面的代码。

在上面的例子中，如果你返回EXCEPTION_CONTINUE_SEARCH，操作系统将继续回卷堆栈以寻找适合的异常处理程序。这时，异常处理程序和跟在它后面的代码都得不到控制，此时或者系统崩溃或者由更高级的处理程序接管控制。

不能在内核模式中返回EXCEPTION_CONTINUE_EXECUTION，因为你没有办法改变导致异常的情况，所以就不能实现重试。

注意，你不能用结构化异常捕获算术异常、页故障，和非法指针引用等等。你必须保证你的代码不产生这样的异常。

异常过滤表达式

你也许会惊奇，仅对一个能产生三种值的表达式求值，如何能执行麻烦的错误检测和修正。你可以用C/C++的逗号操作符把多个表达式串联起来：

__except(expr-1, ... EXCEPTION_CONTINUE_SEARCH){}
原始异常处理与Microsoft语法

语句__try、__except、__finally是C语言的Microsoft扩展，它简化了原始异常处理机制(系统底层提供)的使用。在图3-3的流程中，显示了对过滤函数的两次调用。一次为了定位异常处理程序，另一次为了回卷堆栈。运行时间库包含了操作系统调用的实际过滤函数。当使用__try、__except、__finally语句时，实际上你正与运行时间库中的某些函数对话。__except子句中的过滤表达式在每次异常发生时只求值一次。

逗号操作符总是放弃它左边的表达式而对右边表达式求值。所以，最后一个表达式的值就是整个表达式的求值结果。

你可以使用C/C++条件操作符来执行更复杂的表达式计算：

__except(<some-expr> ? EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH)

如果some_expr表达式的值为TRUE，则执行你自己的处理程序。否则，通知操作系统继续寻找堆栈中的其它异常处理程序。

当然，你也可以写一个返回EXCEPTION_Xxx值的子程序：

LONG EvaluateException()
{
  if (<some-expr>)
    return EXCEPTION_EXECUTE_HANDLER;
  else
    return EXCEPTION_CONTINUE_SEARCH;
}

...
__except(EvaluateException())
...

如果你需要获得更多的关于异常的信息，有两个函数可以在__except的求值表达式中调用，它们可以提供本次异常的相关信息。实际上，这两个函数是在Microsoft编译器的内部实现的，所以仅能用于特定时刻：

  • GetExceptionCode() 返回当前异常的数值代码。该值是一个NTSTATUS值。该函数仅在__except表达式和其后的异常处理代码中有效。
  • GetExceptionInformation() 返回EXCEPTION_POINTERS结构的地址，该结构包含异常的所有详细信息，在哪发生、发生时寄存器的内容，等等。该函数仅在__except表达式中有效。
注意
C/C++语言的名称范围规则同样适用于try-except和try-finally块中的名称。你可以在__try后面的复合语句内部声明变量，但这些变量名在过滤表达式、异常句柄，或终止句柄中都是不可见的。而平台SDK或MSDN中关于这点的叙述是不正确的。有价值的是：所有在被保护代码段中声明的局部变量，其堆栈帧在过滤表达式求值时期仍然存在，所以如果你有一个指向被保护代码段内部声明变量的指针(假定该指针已在外部声明)，那么你仍能在过滤表达式中安全地引用这些变量。

由于这两个函数在使用上的限制，你可以以调用某过滤函数的形式使用它们，象下面这样：

LONG EvaluateException(NTSTATUS status, PEXCEPTION_POINTERS xp)
{
  ...
}
...
__except(EvaluateException(GetExceptionCode(), GetExceptionInformation()))
...

生成异常

程序中的bug可以导致异常并使系统调用异常处理机制。应用程序开发者应该熟悉Win32 API中的RaiseException函数，它可以生成任意异常。在WDM驱动程序中，你可以调用表3-1列出的例程。由于下面规则，我不能给你举一个使用这些函数的例子：

仅当你知道存在一个异常处理代码并知道你真正在做什么时，才可以在非任意线程上下文下生成一个异常。

表3-1. 用于生成异常的服务函数

服务函数 | 描述
ExRaiseStatus | 用指定状态代码触发异常
ExRaiseAccessViolation | 触发STATUS_ACCESS_VIOLATION异常
ExRaiseDatatypeMisalignment | 触发STATUS_DATATYPE_MISALIGNMENT异常

特别地，不要通过触发异常来告诉你的调用者你在普通执行状态中的信息，你完全可以返回状态代码。应该尽量避免使用异常，因为堆栈回卷机制非常消耗资源。

一些真实环境中的例子

尽管建立异常帧然后撕去异常帧会非常消耗资源，但在特殊情况下，驱动程序必须使用结构化异常语法。并且，在一些时间不是特别重要的场合中，如果想要得到更好的程序也可以使用结构化异常机制。

有一个地方你必须使用结构化异常处理机制，那就是当调用MmProbeAndLockPages函数锁定被MDL(内存描述符表)使用的内存页时，必须建立一个异常处理例程。对于WDM驱动程序，这个问题不经常出现，因为通常你使用的MDL都已经被其它程序探测并锁定(probe-and-lock)过。但是，由于你可以定义使用METHOD_NEITHER缓冲方法的I/O控制(IOCTL)操作，所以你必须按下面方式写代码：

PMDL mdl = MmCreateMdl(...);
__try
{
  MmProbeAndLockPages(mdl, ...);
}
__except(EXCEPTION_EXECUTE_HANDLER)
{
  NTSTATUS status = GetExceptionCode();
  ExFreePool((PVOID) mdl);
  return CompleteRequest(Irp, status, 0);
}

CompleteRequest是用于实现I/O请求完成机制的辅助函数。在第五章我将详细解释I/O请求和完成I/O请求。ExFreePool是一个内核模式服务例程，它释放由MmCreateMdl等函数创建的内存块。

另一个真实环境中的例子，考虑一下我在本章前面提到的关于AddDevice函数的错误处理代码。当你进入这个函数后，所有累积的副作用在发现一个错误后都必须消除。结构化异常处理可以使这个函数更具维护性。下面代码略去了一些不相关代码而着重错误处理：

NTSTATUS AddDevice(...)
{
  NTSTATUS status = STATUS_UNSUCCESSFUL;
  PDEVICE_OBJECT fdo;
  PDEVICE_EXTENSION pdx;
  status = IoCreateDevice(..., &fdo);
  if (!NT_SUCCESS(status))
    return status;
  __try
  {
    pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
    ...
    IoInitializeRemoveLock(&pdx->RemoveLock, ...);
    status = IoRegisterDeviceInterface(..., &pdx->ifname);
    if (!NT_SUCCESS(status))
      return status;
    ...
  }
  __finally
  {
    if (!NT_SUCCESS(status))
    {
      ...
      if (pdx->ifname.Buffer)
        RtlFreeUnicodeString(&pdx->ifname);
      IoDeleteDevice(fdo);
    }
  }
  return status;
}

这里的关键思想是：一旦我们发现某个服务函数执行失败，我们仅仅执行一个return status语句。return status语句触发了终止处理程序的执行，这个处理程序消除了所有累积的副作用。为了利用这种技术，必须先做两件事：第一，由于终止处理程序总是被执行，即便被保护代码无错误发生时也是这样，所以必须知道何时消除副作用何时不用。在这里我们通过测试status变量。如果状态为成功，我们不必做任何清除工作，否则，我们必须彻底清除由于异常给程序带来的副作用。要做的第二件事是了解需要清除哪一个副作用。我们把所有可能产生副作用的变量都初始化为NULL。如果在寄存一个设备接口时失败，那么pdx->ifname中将不会存在一个串，从而也不需要释放操作，如此等等。

在上面情况中使用try-finally块的一个最大好处是易于代码修改。你可以在IoCreateDevice调用和IoRegisterDeviceInterface调用间加入任何语句，这些语句在成功执行后也可能对函数造成副作用。正确的清除操作就是在终止处理程序中加入与产生副作用相反的补偿语句。相反，如果不用结构化异常块语句，那么你必须在每次状态代码测试后都加入明确的清除代码，这更容易导致错误，你可能从这样的地方退出函数，因此不得不记住每个加入了清除语句的代码位置。

__Leave语句

Microsoft在其C/C++语言中加入了__leave语句，它解决了文中AddDevice例程出现的效率问题。如果在__try块中发出一个普通的return语句，将触发昂贵的回卷机制。然而，__leave语句可以直接把控制传递到终止处理程序，最后到达终止处理程序后面的语句。由于它不造成任何回卷动作，所以要比return语句快得多。在这个例子中，我们总是执行终止处理程序并返回一个状态代码。因为我们在代码执行成功或失败后都要执行相同的语句：return status，所以可以用__leave代替return。

如果要分配一块内存，我们只需向AddDevice直接插入一些语句即可(粗体部分)：

NTSTATUS AddDevice(...)
{
  NTSTATUS status = STATUS_UNSUCCESSFUL;
  PDEVICE_OBJECT fdo;
  PDEVICE_EXTENSION pdx;
  status = IoCreateDevice(..., &fdo);
  if (!NT_SUCCESS(status))
    return status;
  __try
  {
    pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
    ...
    pdx->DeviceDescriptor = (PUSB_DEVICE_DESCRIPTOR)
      ExAllocatePool(NonPagedPool, sizeof(USB_DEVICE_DESCRIPTOR));
    if (!pdx->DeviceDescriptor)
      return STATUS_INSUFFICIENT_RESOURCES;
    IoInitializeRemoveLock(&pdx->RemoveLock, ...);
    status = IoRegisterDeviceInterface(..., &pdx->ifname);
    if (!NT_SUCCESS(status))
      return status;
    ...
  }
  __finally
  {
    if (!NT_SUCCESS(status))
    {
      ...
      if (pdx->ifname.Buffer)
        RtlFreeUnicodeString(&pdx->ifname);
      if (pdx->DeviceDescriptor)
        ExFreePool((PVOID) pdx->DeviceDescriptor);
      IoDeleteDevice(fdo);
    }
  }
  return status;
}

如果不使用结构化异常，那么在剩下的程序中，你必须在每个返回错误的代码后面都加入一个ExFreePool调用。

Bug Checks

Bug check是系统检测到的错误，一旦发现这种错误，系统立即以一种可控制的方式关闭。许多内核模式部件运行时都进行一致性检测，如果某个系统部件发现一个不可恢复的错误，将生成一个bug check。如果可能，所有内核模式部件都先登记遇到的错误，然后继续运行，而不是调用KeBugCheckEx，除非这种错误将使系统本身变得不可靠。程序可以在任何IRQL上调用KeBugCheckEx。如果程序发现一个不可恢复的错误，并且该程序继续运行将会破坏系统，那么该程序就调用KeBugCheckEx函数，这个函数将使系统以一种可控制的方式关闭。

当内核模式中出现不可恢复错误时，会出现一个称为死亡蓝屏(BSOD blue screen of death)的画面，驱动程序开发者应该十分熟悉它。图3-6就是一个例子(出自手写，因为在这种情况下根本不可能运行屏幕截取软件)。在内部，这种错误被称为bug check，它的主要特征是，系统尽可能以正常的方式关闭并弹出一个死亡蓝屏。一旦死亡蓝屏出现，则表明系统已经死掉必须重启动。

图3-6. “死亡蓝屏”

可以按下面方式调用KeBugCheckEx：

KeBugCheckEx(bugcode, info1, info2, info3, info4);

bugcode是一个数值，指出出错的原因，info1、info2等是整型参数，将出现在死亡蓝屏中以帮助程序员了解错误细节。该函数从不返回(!)。

我不将解释死亡蓝屏中的信息。你可以在Art Baker的《The Windows NT Device Driver Book (Prentice Hall, 1997)》17.3段中找到更多的信息。Microsoft自己的bugcheck代码在DDK头文件bugcodes.h中列出；对该代码的更完整解释以及各种参数的含义可以在KBase文章Q103059 “Descriptions of Bug Codes for Windows NT”中找到。

如果需要，你也可以创建自己的bugcheck代码。Microsoft定义的值是从1(APC_INDEX_MISMATCH)到0xDE(POOL_CORRUPTION_IN_FILE_AREA)之间的整数。为了创建你自己的bugcheck代码，你需要定义一个整型常量(类似STATUS_SEVERITY_SUCCESS的状态代码)，并指出customer标志或非0的facility代码。例如：

#define MY_BUGCHECK_CODE 0x002A0001
...
KeBugCheckEx(MY_BUGCHECK_CODE, 0, 0, 0, 0);

使用非0的facility代码(例子中为42)或customer标志(例子中为0)是为了与Microsoft使用的代码区分开。

现在，我已经告诉你如何生成自己的BSOD，那么我们什么时候使用它呢？回答是决不，或者仅在驱动程序的内部调试中使用。我们不可能写出这样的驱动程序，它发现了一个错误并且只有通过关闭系统才能解决。更好的做法是记录这个错误(使用错误登记工具，将在第九章中描述)并返回一个状态码。


内存管理

这一节我们讨论内存管理。Windows 2000采用多种方式分割虚拟地址空间。一种方式是基于安全性和完整性，有两种地址：用户模式地址和内核模式地址。另一种方式基于处理器的分页能力，有两种内存：分页内存和非分页内存。全部用户模式地址和某些内核模式地址使用分页内存，内存管理器可以在分页内存页帧和磁盘扇区间交换内容，而另一些内核模式地址总是引用物理内存中固定的页帧。由于Windows 2000允许驱动程序的某些部分存在于分页内存中，所以我将阐述如何在编译时和运行时控制驱动程序的分页属性。

Windows 2000提供了多种内存管理方法。我将描述其中的两个基本服务函数，ExAllocatePool和ExFreePool，你可以用它们从堆上分配和释放任意大小的内存块。我还将描述把内存块组织成结构链表的原语。最后，我将描述lookaside(后援式)链表的概念，它可以使你高效率地分配和释放相同大小的内存块。

用户模式地址空间与内核模式地址空间

Windows NT和Windows 98都是运行在支持虚拟地址空间的计算机上，虚拟地址空间或者映射到一段真实的物理内存，或者映射到交换文件中的页帧。为了简化问题，可以认为虚拟地址空间由两部分组成：内核模式部分和用户模式部分。见图3-7。

图3-7. 地址空间中用户模式部分和内核模式部分

每个用户模式进程都有自己的地址上下文，它把用户模式的虚拟地址映射成一组唯一的物理页帧。这意味着，当Windows NT调度器把控制从一个进程的当前线程切换到另一个进程的某个线程时，与进程相对应的虚拟地址空间也被更换。线程切换的一个步骤就是改变处理器当前使用的页表，以便它能引用新线程的进程上下文。

注意
如果你熟悉Alpha，那么你可能不认同我的叙述。Alpha没有页表概念，但Alpha中有一个称为转换缓冲区(translation buffers)的部件，该部件把虚拟页地址映射到物理页地址。对我来说，这与PC没有什么不同，这就象说：《奥德修》是由另一个叫荷马的人写的，而不是由历史学家以前认为的那个荷马写的。

一般情况下，WDM驱动程序不太可能执行在I/O请求发起者的线程上下文中。我们之所以说“运行在任意线程上下文”是因为我们不知道当前用户模式地址上下文到底属于哪个进程。我们不能简单地使用属于用户模式中的虚拟地址，因为我们没有办法知道它指向的是哪块物理内存。由于这种不确定性，在编写驱动程序时我们要遵守下面原则：

决不(或几乎从不)直接引用用户模式的内存地址

即，不使用用户模式应用程序提供的任何地址。我将在以后章节中讨论用户模式数据缓冲区的访问技术。现在，我们所要知道的就是，无论何时我们需要访问计算机内存，都要使用内核模式的虚拟地址。

一页有多大？

在虚拟内存系统中，操作系统以固定大小的页帧组织物理内存和交换文件。在WDM驱动程序中，常量PAGE_SIZE指出页的大小。在某些Windows NT计算机中，一页有4096字节；在另一些计算机中，一页有8192字节。有一个相关常量PAGE_SHIFT，你可以从下面语句中看出它的值：

PAGE_SIZE == 1 << PAGE_SHIFT

下面预处理宏可以简化页大小的使用：

  • ROUND_TO_PAGES 把指定值舍入为下一个页边界。例如，在4KB页的计算机上，ROUND_TO_PAGES(1)的结果为4096，ROUND_TO_PAGES(4097)的结果为8192。
  • BYTES_TO_PAGES 得出给定的字节量需要多少页来保存。例如，BYTES_TO_PAGES(42)在所有平台上都等于1，而BYTES_TO_PAGES(5000)在4KB页的平台上为2，在8KB页的平台上为1。
  • BYTE_OFFSET 返回虚拟地址的字节偏移部分。例如，在4KB页的计算机上，BYTE_OFFSET(0x12345678)的结果为0x678。
  • PAGE_ALIGN 把虚拟地址舍向上一个页边界。例如，在4KB页的计算机上，PAGE_ALIGN(0x12345678)的结果为0x12345000。
  • ADDRESS_AND_SIZE_TO_SPAN_PAGES 返回从指定虚拟地址开始的指定字节数所跨过的页数。例如，在4KB的计算机上， ADDRESS_AND_SIZE_TO_SPAN_PAGES(0x12345FFF, 2)的结果为2，因为这两个字节跨过了页边界。

分页和非分页内存

虚拟内存系统的特征就是能使软件有一个比物理内存大得多的虚拟内存空间。为了做到这一点，内存管理器需要在物理内存和磁盘文件间交换页帧。但操作系统的某些部分是不能被分页的，这些内存用来支持内存管理器本身。最明显的例子就是，用于处理页故障的代码和数据结构必须常驻内存。

Windows NT把内核模式地址空间分成分页内存池和非分页内存池。(用户模式地址空间总是分页的) 必须驻留的代码和数据放在非分页池；不必常驻的代码和数据放在分页池中。Windows NT为决定代码和数据是否需要驻留非分页池提供了一个简单规则。我将在下一章详细说明这个规则，但在这里先稍提一下：

执行在高于或等于DISPATCH_LEVEL级的代码不可以引发页故障。

在驱动程序的checked版中，你可以使用PAGED_CODE预处理宏(在wdm.h中声明)来帮助发现有违背这个规则的代码。例如：

NTSTATUS DispatchPower(PDEVICE_OBJECT fdo, PIRP Irp)
{
  PAGED_CODE()
  ...
}

PAGED_CODE包含条件编译语句。在checked-build方式中，如果当前IRQL太高，它就打印出一行信息并生成一个断言失败。在free-build方式中，它不做任何事。如果测试驱动程序时包含DispatchPower代码的页正好在内存中，那么你不会发现已经在一个提升的IRQL上调用了DispatchPower函数。即使这样，PAGED_CODE仍能查出问题。如果该页碰巧不在内存中，系统将产生一个bug check。

驱动程序检验器

Windows 2000的驱动程序检验特征可以帮助你调试驱动程序，包括程序分段，内存堆的使用，等等。这个特征在本书出版时仍在修改，所以我只能做一些简单讨论。PAGED_CODE宏只能在驱动程序的checked版中检测问题，并且只能发现调用它的那点上出现的问题。而驱动程序检验器可以诊断函数任何地方出现的问题，不论是free版本还是checked版本的驱动程序。

编译时控制分页能力

有时，驱动程序的某些部分必须驻留内存而另一些可以被分页，这就需要一种能控制代码和数据是否分页的方法。通过指导编译器的段分配可以实现这个目的。在运行时，装入器通过检查驱动程序中的段名把段放到你指定的内存池中。此外在运行时调用内存管理器的例程也能实现这个目的。

注意
Win32执行文件，包括内核模式驱动程序，在内部都是由一个或多个段组合而成。段可以包含代码或数据，通常还会有诸如可读性、可写性、共享性、执行性，等等附加属性。段是指定分页能力的最小单元。当装载一个驱动程序映像时，操作系统把以“page”或“.eda(.edata)”为段名开头的段放到分页池中，除非HKLM\System\CurrentControlSet\Control\Session Manager\Memory Management中的DisablePagingExecutive值被设置(在这种情况下，驱动程序占用的内存不被分页)。在Windows 2000中运行Soft-ICE需要用这种方式禁止内核分页。但这使得把驱动程序代码或数据误放到分页池中所造成的错误特别难以查找。如果你使用这种调试器，我推荐你最好使用PAGED_CODE宏和驱动程序检查器。

使编译器把代码放到特定段的传统方法是使用alloc_text编译指示。但不是每种编译器都支持这个编译指示，判断DDK中是否定义了ALLOC_PRAGMA可以帮助决定能否使用alloc_text编译指示。这个编译指示可以把驱动程序的单独例程放到特定段中：

#ifdef ALLOC_PRAGMA
  #pragma alloc_text(PAGE, AddDevice)
  #pragma alloc_text(PAGE, DispatchPnp)
  ...
#endif

上面语句把AddDevice和DispatchPnp函数的代码放到分页池中。

Microsoft的C/C++编译器在alloc_text的使用上加了两个讨厌的限制：

  • 该编译指示必须跟在函数声明后面而不能在前面。你可以把驱动程序中的所有函数集中到一个头文件中，并在包含该头文件的源文件中，在#include语句的后面使用alloc_text。
  • 该编译指示仅能用于有C连接形式的函数。即，它不能用于类成员函数或C++源文件中未用extern "C"声明的函数。

控制数据变量的布置需要使用另外一个编译指示：

#ifdef ALLOC_DATA_PRAGMA
  #pragma data_seg("PAGE")
#endif

data_seg编译指示使所有在其后声明的静态数据变量进入分页池。这个编译指示与alloc_text完全不同。一个分页段可以从#pragma data_seg("PAGE")出现的地方开始到#pragma data_seg()出现的地方结束。而Alloc_text仅应用于单个函数。

在把数据放到分页段前应该仔细考虑，因为这有可能把事情搞得更坏。最小的页单位大小为PAGE_SIZE长。仅把一点字节放到分页段可能很蠢，因为整个页的内存都会被占用。另外，一个脏页(从磁盘读取后被改变过)在其物理页帧能被重用之前需要写回硬盘。

关于段布置

我发现用code_seg编译指示安排代码段更方便，它与data_seg的用法一样，但仅用于代码。你可以象下面这样通知Microsoft编译器把函数放到分页池：

#pragma code_seg("PAGE")
NTSTATUS AddDevice(...){...}
NTSTATUS DispatchPnp(...){...}

AddDevice和DispatchPnp函数将进入分页池。为了检测编译器是否是Microsoft的编译器，可以测试预定义宏_MSC_VER是否存在。

加入下面语句，可以恢复到默认的代码段设置安排：

#pragma code_seg()

类似的，加入下面语句，可以使数据放入通常使用的非分页数据段：

#pragma data_seg()

顺便提一下，如果某些代码在驱动程序完成初始化后不再需要，可以直接把它插入到INIT段。例如：

#pragma alloc_text(INIT, DriverEntry)

该语句强制DriverEntry函数进入INIT段。当函数返回后，系统将释放掉它占用的内存。这个小的节省并不十分重要，因为WDM的DriverEntry函数没有多少工作要做。以前的Windows NT驱动程序有一个大的DriverEntry函数，用于创建设备对象，定位资源，配置设备，等等。所以对于老式驱动程序，这个特征可以节省一些内存。

使用Microsoft Visual C++中的DUMPBIN工具可以查看驱动程序中有多少分页部分。你的销售人员甚至会吹嘘你们的驱动程序要比竞争对手少使用多少非分页内存。

运行时控制分页能力

表3-2列出了一些服务函数，你可以在运行时使用它们调整驱动程序的分页布局。这些函数的功能是释放被不再需要的代码和数据所占用的物理内存。在第八章中，我将讲述如何向电源管理器寄存你的设备，这样，在一段不活动时期后设备可以自动掉电。掉电期间是释放锁定内存页的最佳时期。

表3-2. 动态锁定和解锁驱动程序占用内存页的例程

服务函数 | 描述
MmLockPagableCodeSection | 锁定含有给定地址的代码段
MmLockPagableDataSection | 锁定含有给定地址的数据段
MmLockPagableSectionByHandle | 用MmLockPagableCodeSection返回的句柄锁定代码段(仅用于Windows 2000)
MmPageEntireDriver | 解锁所有属于某驱动程序的页
MmResetDriverPaging | 恢复整个驱动程序的编译时分页属性
MmUnlockPagableImageSection | 为一个锁定代码段或数据段解锁

下面我要描述一种用这些函数实现代码分页的方法，其它方法可参考DDK文档。首先，把驱动程序的某些例程放到单独命名的代码段，例如：

#pragma alloc_text(PAGEIDLE, DispatchRead)
#pragma alloc_text(PAGEIDLE, DispatchWrite)
...

即，定义一个以“PAGE”开头加任意四个字母做后缀为段名的段。然后用alloc_text编译指示把一些例程放到该段。你可以有任意多个专用分页段，但那会带来许多麻烦的维护问题。

在初始化期间(即，在DriverEntry)，按下面方式锁定分页段：

PVOID hPageIdleSection;
NTSTATUS DriverEntry(...)
{
  hPageIdleSection = MmLockPagableCodeSection((PVOID) DispatchRead);
}

调用MmLockPagableCodeSection时，你可以指定任何一个要锁定段中的地址。在DriverEntry中调用该函数的真正目的是为了获得其返回的句柄，该句柄被保存到全局变量hPageIdleSection中。在后面，当这个段不再需要存在于内存中时会用到该句柄：

MmUnlockPagableImageSection(hPageIdleSection);

该调用将解锁包含有PAGEIDLE段的页并允许它们按需要进出内存。之后，如果你需要它出现在内存中时，可以调用：

MmLockPagableSectionByHandle(hPageIdleSection);

该调用完成后，PAGEIDLE段将再次进入非分页内存(可以是与上一次不同的物理内存)。注意该函数仅在windows 2000中有效，并且必须使用ntddk.h文件代替wdm.h文件。对于其它系统，只能使用MmLockPagableCodeSection函数。

把数据对象放入分页段与上面类似：

PVOID hPageDataSection;

#pragma data_seg("PAGE")
ULONG ulSomething;
#pragma data_seg()

hPageDataSection = MmLockPagableDataSection((PVOID) &ulSomething);

MmUnlockPagableImageSection(hPageDataSection);

MmLockPagableSectionByHandle(hPageDataSection);

这些内存管理器服务函数后面的主要思想是：你一开始先锁定包含一个或多个页的段，并获得其句柄，而后面的调用将用到该句柄。调用MmUnlockPagableImageSection并传递同样的句柄你还可以解锁该段占用的页，再次调用MmLockPagableSectionByHandle又可以锁定该段。

如果你确信全部驱动程序都不必驻留，可以调用MmPageEntireDriver把驱动程序的所有段都变为分页式。相反，调用MmResetDriverPaging将把整个驱动程序恢复到编译时的分页属性布局。调用这些函数仅需要一个存在于驱动程序中的某个地址。例如：

MmPageEntireDriver((PVOID) DriverEntry);
...
MmResetDriverPaging((PVOID) DriverEntry);

如果设备使用中断，那么你在练习使用这些内存管理器例程时需要格外小心。有时会发生假中断，这将导致系统调用不存在的ISR，这种随机系统崩溃的原因特别难于发现。DDK推荐的规则是：不要把ISR和与其相关的DPC代码所占用的内存置成分页式。

堆分配符

内核模式中的基本堆分配函数是ExAllocatePool。调用方式如下：

PVOID p = ExAllocatePool(type, nbytes);

type参数是表3-3中列出的POOL_TYPE枚举常量，nbytes是要分配的字节数。返回值是一个内核模式虚拟地址指针，指向已分配的内存块。如果内存不足，则返回一个NULL指针。如果指定的内存池类型为“must succeed”类型，即NonPagedPoolMustSucceed或NonPagedPoolCacheAlignedMustS，那么内存不足将导致一个代码为MUST_SUCCEED_POOL_EMPTY的bug check。

注意
驱动程序不应该分配“must succeed”类型内存。驱动程序不应使系统在低内存状态下崩溃。另外，整个系统中仅存在有限的“must succeed”内存。实际上，Microsoft希望他们从来就没有公布过“must succeed”内存类型。

表3-3. ExAllocatePool的内存池类型参数

内存池类型 | 描述
NonPagedPool | 从非分页内存池中分配内存
PagedPool | 从分页内存池中分配内存
NonPagedPoolMustSucceed | 从非分页内存池中分配内存，如果不能分配则产生bugcheck
NonPagedPoolCacheAligned | 从非分页内存池中分配内存，并确保内存与CPU cache对齐
NonPagedPoolCacheAlignedMustS | 与NonPagedPoolCacheAligned类似，但如果不能分配则产生bugcheck
PagedPoolCacheAligned | 从分页内存池中分配内存，并确保内存与CPU cache对齐

调用ExAllocatePool时的最基本原则是被分配内存块是否可以交换出内存。这取决于驱动程序的哪一部分需要访问这块内存。如果在大于或等于DISPATCH_LEVEL级上使用该内存块，那么必须从非分页池中分配内存。如果你总是在低于DISPATCH_LEVEL级上使用内存块，那么既可以从非分页池中分配内存也可以从分页池中分配内存。

你获得的内存块至少是按8字节边界对齐的。如果把某结构的实例放到分配的内存中，那么编译器赋予结构成员的4或8字节偏移在新内存中也将是4或8字节偏移。但在某些RISC平台上，结构成员可能以双字和四字对齐。出于性能上的考虑，希望内存块能适合处理器cache行的最少可能数，使用Xxx CacheAligned类型代码可以达到这个要求。如果请求的内存多于一页，那么内存块将从页的边界开始。

释放内存块

调用ExFreePool可以释放由ExAllocatePool分配的内存块：

ExFreePool((PVOID) p);

你确实需要记录分配的内存以便在该内存不再需要时释放它，因为没有人为你做这些事。例如，在AddDevice函数中，有一个IoRegisterDeviceInterface调用，该函数存在副作用：它分配了一块内存以保存接口名。你有责任在以后释放该内存。

不用说，访问从内核模式内存池中分配来的内存必须格外小心。因为驱动程序代码可能执行在处理器的最高特权模式下，在这里，系统对内存数据没有任何保护。

ExAllocatePoolWithTag

调用ExAllocatePool是从内核模式堆中分配内存的标准方式。另一个函数ExAllocatePoolWithTag，与ExAllocatePool稍有不同，它提供了一个有用的额外特征。当使用ExAllocatePoolWithTag时，系统在你要求的内存外又额外地多分配了4个字节的标签。这个标签占用了开始的4个字节，位于返回指针所指向地址的前面。调试时，如果你查看分配的内存块会看到这个标签，它帮助你识别有问题的内存块。例如：

PVOID p = ExAllocatePoolWithTag(PagedPool, 42, 'KNUJ');

在这里，我使用了一个32位整数常量作为标签值。在小结尾的计算机如x86上，组成这个标签的4个字节的顺序与正常拼写相反。

WDM.H中声明的内存分配函数受一个预处理宏POOL_TAGGING控制。WDM.H(NTDDK.H中也是)中无条件地定义了POOL_TAGGING，结果，无标签的函数实际上是宏，它真正执行的是有标签函数并加入标签‘ mdW’(指明为WDM的内存块)。如果在未来版本的DDK中没有定义POOL_TAGGING，那么带标签函数将成为无标签函数的宏。Microsoft现在还没打算改变POOL_TAGGING的设置。

由于POOL_TAGGING宏的存在，当你在程序中调用ExAllocatePool时，最终被调用的将是ExAllocatePoolWithTag。如果你关闭了该宏，自己去调用ExAllocatePool，但ExAllocatePool内部仍旧调用ExAllocatePoolWithTag并带一个‘enoN’(即None)的标签。因此你无法避免产生内存标签。所以你应该明确地调用ExAllocatePoolWithTag并加上一个你认为有意义的标签。实际上，Microsoft强烈鼓励你这样做。

ExAllocatePool的其它形式

尽管ExAllocatePoolWithTag函数是分配堆内存时应该使用的函数，但在某些特殊场合你也可以使用该函数的另外两种形式：

  • ExAllocatePoolWithQuota 分配一块内存并充入当前线程的调度配额中，该函数仅用于顶层驱动程序，如文件系统驱动程序或其它运行在非任意线程上下文中的驱动程序。
  • ExAllocatePoolWithQuotaTag 同上，但加入一个标签。

链表

Windows NT广泛使用链表，用链表把一些相似的数据结构组织起来。在这章中，我将讨论管理双链表和单链表的一些基本服务函数。另一些服务函数可以使多线程和多处理器共享链表；我将在下一章解释这些函数。

通常，不管你是用单链表还是用双链表组织数据，都需要把一个子结构(用于连接链表的连接元素)——或者是LIST_ENTRY，或者是SINGLE_LIST_ENTRY——嵌入到你自己的数据结构中。另外你还需要在某处保存一个链表头，它与连接元素有相同的结构。下面是一个例子：

typedef struct _TWOWAY
{
  ...
  LIST_ENTRY linkfield;
  ...
} TWOWAY, *PTWOWAY;

LIST_ENTRY DoubleHead;

typedef struct _ONEWAY
{
  ...
  SINGLE_LIST_ENTRY linkfield;
  ...
} ONEWAY, *PONEWAY;

SINGLE_LIST_ENTRY SingleHead;

当调用任何一个链表管理函数时，应该总是使用连接域或链表头——决不直接使用包含它的数据结构本身。所以，假设你得到了一个TWOWAY结构的指针(pdElement)，为了把这个结构加入到链表中，应该象下面这样引用嵌入的连接域：

InsertTailList(&DoubleHead, &pdElement->linkfield);

类似地，当你要从链表中提取一个元素时，真正使用的地址是嵌入连接域的地址。为了得到外层数据结构的地址，可以使用CONTAINING_RECORD宏。(见图3-8)

图3-8. CONTAINING_RECORD宏

所以，如果要丢弃一个单链表中的所有元素，应该象下面这样：

PSINGLE_LIST_ENTRY psLink = PopEntryList(&SingleHead);
while (psLink)
{
  PONEWAY psElement = (PONEWAY) CONTAINING_RECORD(psLink, ONEWAY, linkfield);
  ...
  ExFreePool(psElement);
  psLink = PopEntryList(&SingleHead);
}

在开始循环前和每次循环中间，我们都调用PopEntryList来获得当前链表的第一个元素。PopEntryList返回ONEWAY结构中内嵌连接域的地址，如果链表为空，则返回NULL。注意，不要不加选择地使用CONTAINING_RECORD宏，应该先检查PopEntryList返回的连接域地址是否为NULL。

双链表

双链表以两个方向把元素连接起来并形成一个环形拓扑。见图3-9。即从任何一个元素开始，以两个相反的方向前进，最终都能回到原来的地方。双链表的关键特征是可以在链表的任何地方添加或删除元素。

图3-9. 双链表的拓扑结构

表3-4列出了管理双链表的服务函数。

表3-4. 双链表服务函数

服务函数或宏 | 描述
InitializeListHead | 初始化链表头中的LIST_ENTRY结构
InsertHeadList | 在起始处插入一个元素
InsertTailList | 在结尾处插入一个元素
IsListEmpty | 判断链表是否为空
RemoveEntryList | 删除元素
RemoveHeadList | 删除第一个元素
RemoveTailList | 删除最后一个元素

下面例子程序演示了这些函数的用法：

typedef struct _TWOWAY {
  ...
  LIST_ENTRY linkfield;
  ...
} TWOWAY, *PTWOWAY;

LIST_ENTRY DoubleHead;
InitializeListHead(&DoubleHead);						<--1
ASSERT(IsListEmpty(&DoubleHead));

PTWOWAY pdElement = (PTWOWAY) ExAllocatePool(PagedPool, sizeof(TWOWAY));
InsertTailList(&DoubleHead, &pdElement->linkfield);				<--2
...
if (!IsListEmpty(&DoubleHead))							<--3
{
  PLIST_ENTRY pdLink = RemoveHeadList(&DoubleHead);				<--4
  pdElement = CONTAINING_RECORD(pdLink, TWOWAY, linkfield);
  ...
  ExFreePool(pdElement);
}
  1. InitializeListHead 初始化一个LIST_ENTRY结构并使其指向自身。这种情况代表链表为空。
  2. InsertTailList 把一个元素添加到链表尾部。注意，要用嵌入连接域的地址而不是TWOWAY结构的地址。调用InsertHeadList可以把新元素插入到链表头部。实际上，如果给出链表中某个TWOWAY结构的内嵌连接域地址，你可以把新元素插入该结构的前面或后面。
  3. 空的双链表仅有一个指向自身的链表头。而IsListEmpty就是做这样的检测。注意，RemoveXxxList的返回值永远都不会为NULL！
  4. RemoveHeadList 断开该元素与链表的连接，并返回该元素内嵌连接域的地址。RemoveTailList与其相反。

我们应该了解RemoveHeadList和RemoveTailList的实现细节，这样可以避免一些错误。例如，观察下面语句：

if (<some-expr>)
  pdLink = RemoveHeadList(&DoubleHead);

很明显，上面语句的意图是有条件地从链表中删除第一个元素。但是，当调试这段代码时，你会发现第一个链表元素总是神秘地消失，pdLink会在if表达式为TRUE时被更改，但是RemoveHeadList看起来好象在if表达式为FALSE时也被调用。

天哪！怎么回事？实际上RemoveHeadList是一个宏，在预编译后被扩展成多个语句。下面是编译器真正看到的程序：

if (<some-expr>)
  pdLink = (&DoubleHead)->Flink;
{{
  PLIST_ENTRY _EX_Blink;
  PLIST_ENTRY _EX_Flink;
  _EX_Flink = ((&DoubleHead)->Flink)->Flink;
  _EX_Blink = ((&DoubleHead)->Flink)->Blink;
  _EX_Blink->Flink = _EX_Flink;
  _EX_Flink->Blink = _EX_Blink;
}}

啊！现在链表元素神秘消失的原因终于变得明了了。if语句的TRUE分支仅有pdLink = (&DoubleHead)->Flink语句，它把一个指针放到第一个元素中。而删除链表元素的语句块却跑到了if语句外面，所以这些语句总是被执行。RemoveHeadList和RemoveTailList都被翻译成一个表达式加上一个复合语句的形式，因此你不能在单表达式或单语句的地方使用它们。:-(

其它链表操作函数(宏)没有这个问题。

单链表

单链表以一个方向连接元素，如图3-10。Windows NT用单链表实现下推堆栈，表3-5列出了下推堆栈的服务函数。与双链表相同，这些“函数”在wdm.h中以宏实现。PushEntryList和PopEntryList也生成多条语句，所以你只能把它们用在等号右边。

表3-5. 单链表服务函数

服务函数或宏 | 描述
PushEntryList | 向链表顶加入元素
PopEntryList | 删除最上面的元素

图3-10. 单链表的拓扑结构

下面代码演示了单链表的使用方法：

typedef struct _ONEWAY {
  ...
  SINGLE_LIST_ENTRY linkfield;
} ONEWAY, *PONEWAY;

SINGLE_LIST_ENTRY SingleHead;
SingleHead.Next = NULL;								<--1

PONEWAY psElement = (PONEWAY) ExAllocatePool(PagedPool, sizeof(ONEWAY));
PushEntryList(&SingleHead, &psElement->linkfield);				<--2

SINGLE_LIST_ENTRY psLink = PopEntryList(&SingleHead);				<--3
if (psLink)
{
  psElement = CONTAINING_RECORD(psLink, ONEWAY, linkfield);
  ...
  ExFreePool(psElement);
}
  1. 与双链表中的表头初始化不同，单链表仅把表头的Next域置为NULL。同样，测试单链表是否为空时也仅是测试Next域本身。
  2. PushEntryList 把一个元素放到链表头部，链表头部是唯一可以直接访问的链表部分。同样应该仅使用内嵌连接域的地址而不是使用ONEWAY结构的地址。
  3. PopEntryList 断开第一个元素与链表的连接并返回该元素中的连接域地址。不同于双链表，单链表用NULL来代表空链表。单链表没有与双链表IsListEmpty函数对应的函数。

Lookaside(后援式)链表

即使用最好的算法，堆管理器仍不时地需要一些处理器时间来处理随机出现的内存空洞。图3-11显示了这种情况，当内存块B返回堆时，块A和C已经空闲，之后，堆管理器可以把块A、B、C组合成一个更大的单块内存。而这个内存块可以满足比单个A、B、C都大的内存申请。

图3-11. 接合邻近的空闲内存块

如果你总是使用固定大小的内存块，你可以制定一个更有效的方案来管理堆。例如，你可以预先分配一个大内存块，然后再把这块内存细分成多块固定大小的小块内存。然后再设计一个方案来确定哪一块内存是空闲的，哪一块是使用的，就象图3-12那样。向这样的堆返回内存块仅仅是把被返回块置成空闲，而不必接合临近空闲块，因为你从不提供随机大小的内存块。

仅仅分配一个大内存块然后再细分成多个小内存块并不是实现固定大小堆的最佳办法。通常，我们难于猜测应预先分配多少内存。如果太大就会浪费内存，如果太小，你的算法会因内存不足而失败，或者频繁地向随机堆管理器申请更多内存。为此，Microsoft创建了lookaside链表对象，以及一组相应的算法来克服这些缺点。

图3-12. 仅接受固定大小内存请求的堆

图3-13显示了lookaside链表的概念。假设有一个可以在水池中直上直下平衡的玻璃杯子。这个杯子就代表lookaside链表对象。当初始化该对象时，你告诉系统需要多大的内存块(杯中的水滴)。在早期版本的Windows NT中，你还要指出杯子的容量，但现在的操作系统可以自动适应。为了满足一个内存分配请求，系统首先尝试着从链表中取出(删除)一块内存(从杯子中取出一滴水)。如果连一块内存也没有，系统就从外面内存池中取。相反，释放内存时，系统首先尝试着放到链表上(向杯子中加入一滴水)。但是，如果链表满了，那么这个内存块就返回到外界的内存池中(杯子中的水溢出到水池)。

图3-13. Lookaside链表

系统根据实际的使用量周期性调整lookaside链表的深度。算法的细节并不重要，而且还会变化。基本上，在当前发行的操作系统中，对于那些最近不常使用的lookaside链表，系统将降低其深度。但这个深度从不低于4，这也是新链表的初始深度。

表3-6列出了8个lookaside链表服务函数。实际上它们分成两组，每组4个函数，一组管理利用分页内存的lookaside链表(ExXxxPagedLookasideList)，另一组管理利用非分页内存的lookaside链表(ExXxxPagedLookasideList)。首先要做的是为一个PAGED_LOOKASIDE_LIST或NPAGED_LOOKASIDE_LIST对象保留非分页内存。这两个对象十分相似，但分页类型的链表使用FAST_MUTEX来同步，而非分页类型的链表要使用自旋锁(下一章将讨论这些同步对象)。PAGED_LOOKASIDE_LIST对象所指向的内存块将出现在分页池中，但它自身应出现在非分页内存中，因为系统需要在提升的IRQL级上访问它。

表3-6. lookaside链表的服务函数

服务函数 | 描述
ExInitializeNPagedLookasideList ExInitializePagedLookasideList | 初始化lookaside链表
ExAllocateFromNPagedLookasideList ExAllocateFromPagedLookasideList | 分配一个固定大小的内存块
ExFreeToNPagedLookasideList ExFreeToPagedLookasideList | 将一个内存块释放回lookaside链表
ExDeleteNPagedLookasideList ExDeletePagedLookasideList | 删除lookaside链表

为lookaside链表对象保留完存储后，应调用相应的初始化函数：

PPAGED_LOOKASIDE_LIST pagedlist;
PNPAGED_LOOKASIDE_LIST nonpagedlist;

ExInitializePagedLookasideList(pagedlist, Allocate, Free, 0, blocksize, tag, 0);
ExInitializeNPagedLookasideList(nonpagedlist, Allocate, Free, 0, blocksize, tag, 0);

(这两个函数仅在函数名和第一个参数的拼写上不相同)

这两个函数的第一个参数都指向[N]PAGED_LOOKASIDE_LIST对象，我们已经为该对象保留了存储。Allocate和Free指向由你写的可以在随机堆中分配和释放内存的例程。如果把它们指定为NULL，系统将使用ExAllocatePoolWithTag和ExFreePool来分配和释放内存。blocksize参数是该链表能分配的内存块大小，tag是一个32位的标签值，将放到每个这样块的前面。两个0值的地方用于与以前版本的Windows NT兼容，现在的系统可以自己决定这些值；它们分别是用于控制分配类型的标志和lookaside链表深度。

从链表上分配一个内存块，调用AllocateFrom函数：

PVOID p = ExAllocateFromPagedLookasideList(pagedlist);
PVOID q = ExAllocateFromNPagedLookasideList(nonpagedlist);

向链表返回一个内存块，调用FreeTo函数：

ExFreeToPagedLookasideList(pagedlist, p);
ExFreeToNPagedLookasideList(nonpagedlist, q);

删除链表，调用Delete函数：

ExDeletePagedLookasidelist(pagedlist);
ExDeleteNPagedLookasideList(nonpagedlist);

经常犯的错误是忘记删除lookaside链表。应该在lookaside链表的有效范围内删除它。例如，如果在AddDevice函数中创建了lookaside链表，应该把链表对象放到设备对象中并在调用IoDeleteDevice前删除它。如果在DriverEntry函数中创建了lookaside链表，应该把链表对象放到全局变量中，并在DriverUnload例程返回前删除它(在DriverUnload例程的尾部)。


字符串操作

WDM驱动程序可以使用下面4种格式的字符串：

  • Unicode串，由UNICODE_STRING结构描述，包含16位字符。Unicode有足够的代码空间编码地球上所有语言的字型。参见http://www.indigo.ie/egt/standards/csur/klingon.html。
  • ANSI串，由ANSI_STRING结构描述，包含8位字符。另一种OEM_STRING串与其相似，也是用8位字符描述串。两者的不同是，OEM串中的字符字型由当前代码页决定，而ANSI串的字符字型不依赖任何代码页。WDM驱动程序不必处理OEM串，因为它们只能来自用户模式，在驱动程序看到这些串之前，它们已经被某些内核模式部件转换成Unicode串。
  • 空结尾的字符串。你可以用普通的C语法表达串常量，例如，"Hello, world!"，该串使用了类型为CHAR的8位字符，这些字符被假定属于ANSI字符集。串常量中的字符来自你创建源程序所用的编辑器。如果你的编辑器需要依赖当前代码页才能显示编辑窗口中的字符字型，那么应该注意，这些字符在Windows ANSI字符集中可能会有不同的含义。
  • 空结尾的宽字符(WCHAR类型)串，用C语法也可以表达宽串常量，例如，L"Goodbye, cruel world!"，该串看起来象Unicode串常量，但是，最后从文本编辑器出来的串实际仅使用了Windows ANSI字符集中ASCII和Latin1区(0020-007F和00A0-00FF)中的字符。

UNICODE_STRING和ANSI_STRING有相同的数据结构，如图3-14。Buffer域指向包含串数据的缓冲区。MaximumLength给出了缓冲区的长度，Length提供串的当前长度，它不考虑任何可能存在的空结束符。length域的单位是字节，对于UNICODE_STRING结构也是这样。

图3-14. UNICODE_STRING结构和ANSI_STRING结构

表3-7列出了用于处理Unicode和ANSI字符串的服务函数。我还对应列出了一些标准C运行时间库函数，这些函数在内核模式中也存在，它们可以处理常规的C样式串。虽然它们从来没有在DDK文档中公开过，但标准DDK头中有这些函数的声明，而且连接库中也包含了它们，所以没有理由不使用它们。

表3-7. 串处理函数

操作 | ANSI串函数 | Unicode串函数
Length | strlen | wcslen
Concatenate | strcat, strncat | wcscat, wcsncat, RtlAppendUnicodeStringToString, RtlAppendUnicodeToString
Copy | strcpy, strncpy, RtlCopyString | wcscpy, wcsncpy, RtlCopyUnicodeString
Reverse | _strrev | _wcsrev
Compare | strcmp, strncmp, _stricmp, _strnicmp, RtlCompareString, RtlEqualString | wcscmp, wcsncmp, _wcsicmp, _wcsnicmp, RtlCompareUnicodeString, RtlEqualUnicodeString, RtlPrefixUnicodeString
Initialize | _strset, _strnset, RtlInitAnsiString, RtlInitString | _wcsnset, RtlInitUnicodeString
Search | strchr, strrchr, strspn, strstr | wcschr, wcsrchr, wcsspn, wcsstr
Upper/lowercase | _strlwr, _strupr, RtlUpperString | _wcslwr, _wcsupr, RtlUpcaseUnicodeString
Character | isdigit, islower, isprint, isspace, isupper, isxdigit, tolower, toupper, RtlUpperChar | towlower, towupper, RtlUpcaseUnicodeChar
Format | sprintf, vsprintf, _snprintf, _vsnprintf | swprintf, _snwprintf
String conversion | atoi, atol, _itoa | _itow, RtlIntegerToUnicodeString, RtlUnicodeStringToInteger
Type conversion | RtlAnsiStringToUnicodeSize, RtlAnsiStringToUnicodeString | RtlUnicodeStringToAnsiString
Memory release | RtlFreeAnsiString | RtlFreeUnicodeString

系统DLL还输出了许多RtlXxx函数，但我仅列出了在DDK头文件中定义了原型的函数，在驱动程序中我们仅应使用这些函数。

分配和释放串缓冲区

我不将详细描述这些串处理函数，DDK中的描述已经十分详细。你可以根据你的串使用经验使用它们。但这里有一个问题我要提一下。

你可能经常把UNICODE_STRING或ANSI_STRING类型的串定义成自动变量，或者定义成设备扩展的一部分。而串的数据缓冲区通常是动态分配的内存。但有时候你希望使用串常量，记录谁拥有某个串缓冲区可能是一个问题。考虑下面函数片段：

UNICODE_STRING foo;
if (bArriving)
  RtlInitUnicodeString(&foo, L"Hello, world!");
else
{
  ANSI_STRING bar;
  RtlInitAnsiString(&bar, "Goodbye, cruel world!");
  RtlAnsiStringToUnicodeString(&foo, &bar, TRUE);
}
...
RtlFreeUnicodeString(&foo); 				//  <--don't do this!

在第一种情况下，我们为驱动程序中的宽字符串常量初始化foo.Length、foo.MaximumLength，和foo.Buffer。在另一种情况下，我们要求系统(使用第三个参数为TRUE的RtlAnsiStringToUnicodeString调用)为转化ANSI串分配内存。在第一种情况下，调用RtlFreeUnicodeString就是一个错误，因为它将无条件地释放常量串“Hello, world!”占用的内存，由于foo串的Buffer成员指向一个常量串，不是动态分配的内存，所以根本不能释放。在另一种情况下，由于我们指定了TRUE参数，系统为表达转换结果动态地分配了Unicode串缓冲区，所以我们必须调用RtlFreeUnicodeString释放该缓冲区，以避免内存泄漏。

Blob数据(大块数据)

我从数据库技术中借来术语“blob”来描述一块无结构定义的字节组合。表3-8列出了处理这种数据的函数(包括一些来自标准运行时间库中的函数)，你可以在内核模式中调用它们。我假设你能从它们的名字上看出它们的用法。然而，我需要指出一些不明显的事实：

  • 内存的“copy”和“move”操作之间的区别在于可否容忍源和目的相重叠。move操作不管源和目的是否重叠。而copy操作在源和目的有任何重叠时不工作。
  • “byte”操作和“memory”操作的区别是操作的间隔尺寸。byte操作保证按字节为单位执行。而memory操作可以在内部使用更大的块，所有这些块的和等于指定的字节数。这个区别会根据平台的不同而改变，在32位Intel计算机上，byte操作实际上是对应memory操作的宏。但在Alpha平台上，RtlCopyBytes与RtlCopyMemory是完全不同的函数。

表3-8. 处理blob数据的服务函数

服务函数或宏 | 描述
memchr | 在blob中寻找一个字节
memcpy, RtlCopyBytes, RtlCopyMemory | 复制字节，不允许重叠
memmove, RtlMoveMemory | 复制字节，允许重叠
memset, RtlFillBytes, RtlFillMemory | 用给定的值填充blob
memcmp, RtlCompareMemory, RtlEqualMemory | 比较两个blob
memset, RtlZeroBytes, RtlZeroMemory | blob清零


其它编程技术

在本章的剩余部分我将讨论一些杂项编程技术，你的驱动程序或许会用到这些技术。我将从如何访问注册表开始，注册表保存着许多涉及代码控制和硬件配置的信息。然后讲述如何访问磁盘文件和其它命名设备，如何在WDM驱动程序中执行浮点运算。最后，我还要讲述一些驱动程序调试技术。

访问注册表

Windows NT和Windows 98把配置信息和其它重要信息都记录到注册表(registry)中。WDM驱动程序可以使用表3-9列出的函数访问注册表。如果你在用户模式编程中曾涉及过注册表访问，你可能会猜出如何在驱动程序中使用这些函数。然而这些内核模式中的支持函数确有些不同，我想有必要描述一下它们的用法。

表3-9. 注册表访问函数

服务函数 | 描述
IoOpenDeviceRegistryKey | 打开PDO专用键
IoOpenDeviceInterfaceRegistryKey | 打开与注册设备接口相连的键
RtlDeleteRegistryValue | 删除一个注册表值
RtlQueryRegistryValues | 从注册表中读取多个值
RtlWriteRegistryValue | 向注册表写一个值
ZwClose | 关闭注册表键句柄
ZwCreateKey | 创建一个注册表键
ZwDeleteKey | 删除一个注册表键
ZwEnumerateKey | 枚举子键
ZwEnumerateValueKey | 枚举某注册表键中的值
ZwFlushKey | 把注册表更改提交到磁盘
ZwOpenKey | 打开一个注册表键
ZwQueryKey | 取关于某注册表键的信息
ZwQueryValueKey | 取某个注册表键中的值
ZwSetValueKey | 置某个注册表键中的值

在这一节，我将描述ZwXxx函数族和RtlDeleteRegistryValue函数，这些函数基本上可以满足大部分WDM驱动程序的需要。

打开注册表键

在读注册表的某个值之前，你需要先打开包含该值的键。用ZwOpenKey函数可以打开一个已存在的键。ZwCreateKey函数既可以打开已存在键又可以创建新键。这两个函数都需要事先用键名以及某些其它信息初始化一个OBJECT_ATTRIBUTES结构。OBJECT_ATTRIBUTES结构的声明如下：

typedef struct _OBJECT_ATTRIBUTES {
  ULONG Length;
  HANDLE RootDirectory;
  PUNICODE_STRING ObjectName;
  ULONG Attributes;
  PVOID SecurityDescriptor;
  PVOID SecurityQualityOfService;
} OBJECT_ATTRIBUTES;

除了手工初始化这个结构外，你还可以调用InitializeObjectAttributes宏来初始化它。

例如，假设我们想要打开驱动程序的服务键。它的键名我们可以从I/O管理器传递给DriverEntry函数的一个参数中得到。所以，我们的代码如下：

NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
  ...
  OBJECT_ATTRIBUTES oa;
  InitializeObjectAttributes(&oa, RegistryPath, 0, NULL, NULL);			<--1
  HANDLE hkey;
  status = ZwOpenKey(&hkey, KEY_READ, &oa);					<--2
  if (NT_SUCCESS(status))
  {
    ...
    ZwClose(hkey);								<--3
  }
  ...
}
  1. 我们用I/O管理器提供的注册表路径名和为NULL的安全描述符来初始化OBJECT_ATTRIBUTES结构对象oa。ZwOpenKey总是忽略安全描述符，安全描述符仅在创建键时才被真正使用。
  2. ZwOpenKey以读方式打开该键，并把返回句柄保存到hkey变量中。
  3. ZwClose是关闭内核模式对象句柄的通用例程。在这里，我们用它关闭注册表键句柄。

尽管我们经常把注册表说成数据库，但它并不具备真正数据库的所有特征。例如，它不允许提交或回卷数据更改。另外，在打开键操作中指定的访问权限是用于安全检测而不是为了防止共享冲突。即两个不同的进程能以写方式打开同一个键。然而，系统仍能自动保护与破坏性写操作同时发生的读操作，也能保证使用中的键不被意外删除。

其它打开注册表键的方法

除了ZwOpenKey函数，Windows 2000又提供了两个打开注册表键的函数。

IoOpenDeviceRegistryKey打开某设备对象专用的注册表键：

HANDLE hkey;
status = IoOpenDeviceRegistryKey(pdo, flag, access, &hkey);

pdo是物理设备对象的地址，flag指出你要打开哪一个专用键(见表3-10)，access是访问掩码，如KEY_READ。

表3-10. IoOpenDeviceRegistryKey函数使用的注册表键代码

Flag值 | 被选择的注册表键
PLUGPLAY_REGKEY_DEVICE | Enum键中的硬件(或实例)子键
PLUGPLAY_REGKEY_DRIVER | 服务(或软件)键

IoOpenDeviceInterfaceRegistryKey打开与已注册设备接口关联的键：

HANDLE hkey;
status = IoOpenDeviceInterfaceRegistryKey(linkname, access, &hkey);

linkname是已注册接口的符号连接名，access是访问掩码，如KEY_READ。

接口键是HKLM\System\CurrentControlSet\Control\DeviceClasses下的一个子键，可以常驻注册表。我们可以在这里放入需要与用户模式程序共享的参数信息，用户模式程序可以调用SetupDiOpenDeviceInterfaceRegKey函数访问该键。

在第十二章中，我将描述如何用安装脚本向硬件和接口键插入值，以及应用程序如何访问这些值。

获取和设置注册表值

通常，打开一个注册表键是为了从注册表中提取某个值。使用ZwQueryValueKey函数可以达到这个目的。例如，为了从驱动程序的服务键中提取ImagePath值，你可以参考下面代码：

UNICODE_STRING valname;
RtlInitUnicodeString(&valname, L"ImagePath");
size = 0;
status = ZwQueryValueKey(hkey, &valname, KeyValuePartialInformation, NULL, 0, &size);
if (status == STATUS_OBJECT_NAME_NOT_FOUND || size == 0)
  <handle error>;
PKEY_VALUE_PARTIAL_INFORMATION vpip = (PKEY_VALUE_PARTIAL_INFORMATION) ExAllocatePool(PagedPool, size);
if (!vpip)
  <handle error>;
status = ZwQueryValueKey(hkey, &valname, KeyValuePartialInformation, vpip, size, &size);
if (!NT_SUCCESS(status))
  <handle error>;
<do something with vpip->Data>
ExFreePool(vpip);

这里，我们两次调用了ZwQueryValueKey函数。第一次调用是为了获取KEY_VALUE_PARTIAL_INFORMATION结构的长度，然后为其分配空间。第二次调用接收其内容。最开始我认为第一次调用ZwQueryValueKey将返回STATUS_BUFFER_TOO_SMALL错误码，因为我传给它一个空缓冲区指针NULL，实际上该函数并没有这样做。最重要的错误代码是STATUS_OBJECT_NAME_NOT_FOUND，它指出注册表中不存在该值。如果这里还有其它错误使ZwQueryValueKey函数不工作，第二次调用时会被发现。

这个所谓的“partial”信息结构包含该注册表值的数据和数据类型描述：

typedef struct _KEY_VALUE_PARTIAL_INFORMATION {
    ULONG   TitleIndex;
    ULONG   Type;
    ULONG   DataLength;
    UCHAR   Data[1];
} KEY_VALUE_PARTIAL_INFORMATION, *PKEY_VALUE_PARTIAL_INFORMATION;

Type是表3-11中列出的一个注册表数据类型。(可能还有其它数据类型，但设备驱动程序一般不会用到) DataLength是数据的长度，Data是数据本身。TitleIndex与驱动程序无关。下面事实可以帮助你了解各种数据类型：

  • REG_DWORD是一个32位无符号整型，其格式根据平台而变(大结尾或小结尾)。
  • REG_SZ是一个空结尾的Unicode字符串。这个NULL结束符被计入DataLength中。
  • 扩展REG_EXPAND_SZ值需要使用环境变量替换%-escapes，你应该使用RtlQueryRegistryValues函数获取这种类型的数据。但访问环境变量的内部例程没有对驱动程序公开。
  • RtlQueryRegistryValues也可以用于获取REG_MULTI_SZ类型值，如果含有多个串，该函数将调用你指定的回调函数来处理。
注意
RtlQueryRegistryValues是一个复杂的函数，我没有为它写例子，DDK中有几个例子驱动程序用到了该函数。

表3-11. WDM驱动程序使用的注册表值类型

数据类型常量 | 描述
REG_BINARY | 可变长二进制数据
REG_DWORD | 无符号长整型，格式依平台而变
REG_DWORD_BIG_ENDIAN | 无符号长整型，大结尾格式
REG_EXPAND_SZ | 空结尾的Unicode串，包含%-escapes环境变量
REG_MULTI_SZ | 一个或多个空结尾的Unicode串，最后有一个额外的null
REG_SZ | 空结尾的Unicode串

为了设置注册表值，你必须对其父键拥有KEY_SET_VALUE访问权。我前面用过的KEY_READ并没有这样的权力。你可以使用KEY_WRITE或KEY_ALL_ACCESS，尽管它们提供的权力有点超出你的需求。然后调用ZwSetValueKey，例如：

RtlInitUnicodeString(&valname, L"TheAnswer");
ULONG value = 42;
ZwSetValueKey(hkey, &valname, 0, REG_DWORD, &value, sizeof(value));

删除子键或键值

为了删除已打开键中的键值，可以使用RtlDeleteRegistryValue函数：

RtlDeleteRegistryValue(RTL_REGISTRY_HANDLE, (PCWSTR) hkey, L"TheAnswer");

RtlDeleteRegistryValue是一个通用的服务函数，它的第一个参数可以指向注册表中的几个特殊位置。当使用RTL_REGISTRY_HANDLE时，表示你要删除已打开键中的键值。第二参数指出键。第三个参数是一个空结尾的Unicode串，它是被删除键值的名称。在这里，你不必为描述该串创建一个UNICODE_STRING结构。

如果你对某注册表键有DELETE权限(用KEY_ALL_ACCESS打开)，可以调用ZwDeleteKey删除该键：

ZwDeleteKey(hkey);

被删除键要等到其所有句柄都关闭后才真正消失，所有后来访问或打开该键的请求都将失败，并返回STATUS_KEY_DELETED错误代码。键删除后，仍需要调用ZwClose函数关闭其句柄。(DDK文档中关于ZwDeleteKey函数的内容指出那个句柄将变为无效，但实际不是这样，你仍要调用ZwClose关闭它)

枚举子键或键值

枚举键中的元素(子键和键值)是一个较复杂的操作。为此，需要先调用ZwQueryKey以获得该键子键和键值的一些信息，如个数、最长名的长度，等等。ZwQueryKey有一个参数可以让你指出需要该键的哪种类型的信息。有三种类型：基本信息、节点信息，和全部信息。为了准备枚举操作，你最好指出需要全部信息：

typedef struct _KEY_FULL_INFORMATION {
    LARGE_INTEGER LastWriteTime;
    ULONG   TitleIndex;
    ULONG   ClassOffset;
    ULONG   ClassLength;
    ULONG   SubKeys;
    ULONG   MaxNameLen;
    ULONG   MaxClassLen;
    ULONG   Values;
    ULONG   MaxValueNameLen;
    ULONG   MaxValueDataLen;
    WCHAR   Class[1];
} KEY_FULL_INFORMATION, *PKEY_FULL_INFORMATION;

该结构实际上是可变长的，因为Class[0]只是类名的第一个字符。通常，第一次调用获得要分配缓冲区的大小，第二次调用获得实际的数据，如下：

ULONG size;
ZwQueryKey(hkey, KeyFullInformation, NULL, 0, &size);
PKEY_FULL_INFORMATION fip = (PKEY_FULL_INFORMATION) ExAllocatePool(PagedPool, size);
ZwQueryKey(hkey, 0, KeyFullInformation, bip, size, &size);

用subkeys值做计数器，循环调用ZwEnumerateKey：

for (ULONG i = 0; i < fip->SubKeys; ++i)
{
  ZwEnumerateKey(hkey, i, KeyBasicInformation, NULL, 0, &size);
  PKEY_BASIC_INFORMATION bip = (PKEY_BASIC_INFORMATION) ExAllocatePool(PagedPool, size);
  ZwEnumerateKey(hkey, i, KeyBasicInformation, bip, size, &size);
  <do something with bip->Name>
  ExFreePool(bip);
}

每个子键的关键信息就是它的名字，存在于KEY_BASIC_INFORMATION结构中：

typedef struct _KEY_BASIC_INFORMATION {
    LARGE_INTEGER LastWriteTime;
    ULONG   Type;
    ULONG   NameLength;
    WCHAR   Name[1];
} KEY_BASIC_INFORMATION, *PKEY_BASIC_INFORMATION;

这个名字并不是空结尾的字符串，因此你必须使用NameLength成员确定其长度，不要忘了长度的单位是字节。这个名字并不是完整的注册表路径，它就是这个子键的名称。这样更好，因为只要有子键名和其父键的句柄我们就可以打开这个子键了。

为了枚举键中的键值，可使用下面方法：

ULONG maxlen = fip->MaxValueNameLen + sizeof(KEY_VALUE_BASIC_INFORMATION);
PKEY_VALUE_BASIC_INFORMATION vip = (PKEY_VALUE_BASIC_INFORMATION) ExAllocatePool(PagedPool, maxlen);
for (ULONG i = 0; i < fip->Values; ++i)
{
  ZwEnumerateValueKey(hkey, i, KeyValueBasicInformation, vip, maxlen, &size);
  <do something with vip->Name>
}
ExFreePool(vip);

基于已获得的KEY_FULL_INFORMATION结构中的MaxValueNameLen成员，你可以为最大可能的KEY_VALUE_BASIC_INFORMATION结构分配空间。在循环中，你可以处理该键值的名称，这个名称存在于KEY_VALUE_BASIC_INFORMATION结构中：

typedef struct _KEY_VALUE_BASIC_INFORMATION {
    ULONG   TitleIndex;
    ULONG   Type;
    ULONG   NameLength;
    WCHAR   Name[1];
} KEY_VALUE_BASIC_INFORMATION, *PKEY_VALUE_BASIC_INFORMATION;

再一次，我们有了键值名和其父键的句柄，因此可以直接获取该键值。

访问文件

有时，WDM驱动程序需要读写常规的磁盘文件。你可能需要向硬件下载一大段微代码，或者创建自己的全面的硬件操作记录。有一组ZwXxx函数可以做这些事情。

访问磁盘文件的第一步是调用ZwCreateFile打开一个文件句柄。DDK中详细地描述了该函数。我在这里仅描述该函数的两个方面，即读/写一个已知名的文件。

打开已存在文件然后读

下面例子打开一个已存在的文件然后读该文件：

NTSTATUS status;
OBJECT_ATTRIBUTES oa;
IO_STATUS_BLOCK iostatus;
HANDLE hfile;              //  the output from this process
PUNICODE_STRING pathname;  //  you've been given this

InitializeObjectAttributes(&oa, pathname, OBJ_CASE_INSENSITIVE, NULL, NULL);
status = ZwCreateFile(&hfile,
              GENERIC_READ,
              &oa,
              &iostatus,
              NULL,
              0,
              FILE_SHARE_READ,
              FILE_OPEN,
              FILE_SYNCHRONOUS_IO_NONALERT,
              NULL,
              0);

创建或重写文件

为了创建一个新文件，或打开一个已存在文件并截为0长度，用下面方式调用ZwCreateFile函数：

status = ZwCreateFile(&hfile,
              GENERIC_WRITE,
              &oa,
              &iostatus,
              NULL,
              FILE_ATTRIBUTE_NORMAL,
              0,
              FILE_OVERWRITE_IF,
              FILE_SYNCHRONOUS_IO_NONALERT,
              NULL,
              0);

在上面这些例子中，我们建立了一个OBJECT_ATTRIBUTES结构，其目的是用它指向被打开文件的完整路径。我们指定了OBJ_CASE_INSENSITIVE属性，因为Win32文件系统模型对路径名的大小写不区分。然后我们调用ZwCreateFile打开文件句柄。

  • 第一个参数(&hfile)，是HANDLE类型变量的地址，ZwCreateFile将向该地址返回它创建的句柄。
  • 第二个参数(GENERIC_READ或GENERIC_WRITE)，指定文件访问方式，读或写。
  • 第三个参数(&oa)，是OBJECT_ATTRIBUTES结构的地址，该结构包含要打开的文件名。
  • 第四个参数(&iostatus)，指向一个IO_STATUS_BLOCK结构，该结构接收ZwCreateFile操作的结果状态。
  • 第五个参数(NULL)，是一个指针，指向一个64位整数，该数指定文件的初始分配大小。该参数仅关系到创建或重写文件操作，如果忽略它(如我在这里所做的)，那么文件长度将从0开始，并随着写入而增长。
  • 第六个参数(0或FILE_ATTRIBUTE_NORMAL)，指定新创建文件的属性。
  • 第七个参数(FILE_SHARE_READ或0)，指定文件的共享方式。如果仅为读数据而打开文件，则可以与其它线程同时读取该文件。如果为写数据而打开文件，你可能不希望其它线程访问该文件。
  • 第八个参数(FILE_OPEN或FILE_OVERWRITE_IF)，表明当指定文件存在或不存在时应如何处理。在只读情况中，我指定FILE_OPEN，因为我希望打开一个已存在的文件，当文件不存在时返回错误。在只写情况中，我指定FILE_OVERWRITE_IF，因为我希望改写任何已存在的文件或创建一个新文件。
  • 第九个参数(FILE_SYNCHRONOUS_IO_NONALERT)，指定控制打开操作和句柄使用的附加标志位。在这里，我指明要做同步I/O操作(即我希望读写函数在I/O操作完成后才返回，此时调用者处于等待状态)。
  • 第十个参数(NULL)，一个指针，指向可选的扩展属性区。
  • 第十一个参数(0)，扩展属性区的长度。

我们希望ZwCreateFile返回STATUS_SUCCESS状态并设置句柄变量。然后我们用ZwReadFile和ZwWriteFile函数执行读写操作，最后调用ZwClose函数关闭文件句柄。

ZwClose(hfile);

你可以执行同步或异步读写操作，这取决于你在ZwCreateFile中指定的标志。我在上面例子中指定了同步操作，读写函数在完成后才返回。如下例：

PVOID buffer;
ULONG bufsize;
status = ZwReadFile(hfile, NULL, NULL, NULL, &iostatus, buffer, bufsize, NULL, NULL);

            -or-

status = ZwWriteFile(hfile, NULL, NULL, NULL, &iostatus, buffer, bufsize, NULL, NULL);

这些调用与用户模式中的非重叠ReadFile或WriteFile调用类似。当这些函数返回时，你可能对iostatus.Information域感兴趣，该域携带了操作所传输的字节数量。

如果你想把整个文件读入内存缓冲区，应该先调用ZwQueryInformationFile获得文件的总长度：

FILE_STANDARD_INFORMATION si;
ZwQueryInformationFile(hfile, &iostatus, &si, sizeof(si), FileStandardInformation);
ULONG length = si.EndOfFile.LowPart;
文件操作的时间选择

当驱动程序为响应IRP_MN_START_DEVICE请求而初始化设备时，可能需要读磁盘文件。由于设备初始化可能出现在系统初始化各种设备的不同阶段，所以使用普通的路径名如\??\C:\dir\file.ext有时不能访问到文件。为了安全起见，你应该把数据文件放到系统根目录下的某个目录中，如\SystemRoot\dir\file.ext，而名称空间中的SystemRoot分支总是可访问的，因为操作系统在启动时也需要读磁盘文件。

浮点运算

有时候整数运算并不能满足要求，我们需要浮点运算。在Intel处理器上，浮点协处理器还可以执行MMX指令。在历史上，驱动程序在执行浮点运算上有两个问题。对于没有浮点协处理器的计算机，操作系统将用软件仿真一个，但是仿真的浮点协处理器会消耗很大的CPU处理能力，并且需要一个处理器异常来捕捉浮点指令。在内核模式中处理异常，尤其是在提升的IRQL级上，是困难的。另外，在有浮点协处理器的计算机上，由于CPU结构上的原因，当线程上下文切换时，需要一个耗时的操作来保存和恢复浮点协处理器的状态。所以，通常的做法是禁止在内核模式驱动程序中使用浮点运算。

Windows 2000和Windows 98提供了一种方法来绕过这个问题。首先，有一个运行在低于或等于DISPATCH_LEVEL级上系统线程，该线程可以自由地使用浮点协处理器。另外，运行在低于或等于DISPATCH_LEVEL级上的任意线程背景的驱动程序可以调用两个系统函数，用这两个函数调用可以把浮点运算程序括起来：

ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);
KFLOATING_SAVE FloatSave;
NTSTATUS status = KeSaveFloatingPointState(&FloatSave);
if (NT_SUCCESS(status))
{
  ...
  KeRestoreFloatingPointState(&FloatSave);
}

这两个调用必须成对出现，它们为当前CPU保存和恢复浮点协处理器状态，即浮点协处理器的状态可以维持到任意多个指令周期。这个状态信息包括寄存器、控制字，等等。而在某些其它种类的CPU上，这两个调用不做任何事，因为这种CPU在线程切换时可以自动保存和恢复浮点协处理器的状态。出于这种原因，Microsoft建议，除非必要，应避免在内核模式驱动程序中使用浮点运算。

调用KeSaveFloatingPointState时发生了什么，不同的CPU结构会有不同的答案。例如，在Intel处理器上，该函数通过执行FSAVE指令把整个浮点处理器状态保存起来。它既可以把状态信息保存到当前线程的上下文块中，也可以保存到一块动态分配的内存区中。它还用一个不透明的FloatSave区记录关于被保存状态的中间信息，这可以使KeRestoreFloatingPointState正确地恢复浮点协处理器的状态。

如果系统没有浮点协处理器，KeSaveFloatingPointState将失败并返回STATUS_ILLEGAL_FLOAT_CONTEXT代码。(顺便提一下，多CPU计算机的每个CPU必须同时拥有或同时没有协处理器) 因此你的驱动程序必须以其它方式实现浮点计算，或者干脆拒绝把驱动程序装入无浮点处理器的计算机上(由于DriverEntry的失败)。

调试技巧

我的驱动程序总是有bug，你也许也象我这样不幸。当创建驱动程序时，你或者选择“checked”创建环境，或者选择“free”创建环境。在checked创建环境中，预处理符号DBG等于1，而在free创建环境中它等于0。因此，我们可以在驱动程序中提供一些调试代码，而这些代码仅在checked创建中才会产生作用：

#if DBG
  <extra debugging code>
#endif

一个最有用的调试技术就是让程序不时地打印出一些信息。DbgPrint就是一个用于这种目的的内核模式服务例程，你可以用它在任何调试器的输出窗口中显示格式化的信息。另一种查看DbgPrint输出的方法是使用DbgView工具，它可以从http://www.sysinternals.com/下载。除了在代码中直接调用DbgPrint外，你还可以使用KdPrint宏，它在DBG为真时才调用DbgPrint，如果DBG为假，它不生成任何代码：

KdPrint(("KeReadProgrammersMind failed with code %X\n", status));

必须为KdPrint使用两组括号，因为它就是这样定义的。第一个参数是带有%-escapes的串，在输出时，%-escapes出现的地方将被后面的值代替。第二个，第三个，等等的参数用于替代后来出现的多个%-escapes值。这个宏调用DbgPrint，而DbgPrint内部使用标准运行时间库例程_vsnprintf来格式化串。所以，你可以象在应用程序中使用_vsnprintf函数那样使用%-escape代码。

另外一个有用的调试技术需要依赖ASSERT宏：

ASSERT(1 + 1 == 2);

在checked版本的驱动程序中，ASSERT生成对布尔表达式求值的代码。如果表达式为假，ASSERT将使调试器中的程序停止执行，这样就可以看到出了什么问题。如果表达式为真，程序将正常执行。

如果你使用Soft-ICE调试器，那么DDK提供的ASSERT将不会向通常那样有用：第一，它需要调用RtlAssert，而该函数在free版本的操作系统中不做任何事。(你可以在checked版本的操作系统中测试你的驱动程序，但最好在free版本的操作系统中调试你的驱动程序) 第二，如果它确实生成一个调试异常，也是在RtlAssert内部生成的而不是在你的代码中，这使你难于查看局部变量。为了克服这些问题，你可以用下面代码来替代DDK中的ASSERT宏：

#if DBG && defined(_X86_)
#undef ASSERT
#define ASSERT(e) if(!(e)){DbgPrint("Assertion failure in "\
  __FILE__ ", line %d: " #e "\n", __LINE__);\
  _asm int 1;\
  }
#endif

记住一定要在这个ASSERT宏中发出Soft-ICE命令i1here on，以使调试器能停止程序执行。象这样替换ASSERT也有一个不利之处，如果在无调试器运行的情况下发生ASSERT失败，那么即使运行在free版本的操作系统上也会出现bug check。


Windows 98兼容问题

用于访问磁盘文件的ZwXxx例程不能工作在零售版本的Windows 98上，原因有两个，一个是Windows 98的结构问题，另一个看起来象通常的bug。

第一个问题，由于Windows 98必须按顺序初始化各种虚拟设备驱动程序(VxD)。而配置管理器(CONFIGMG.VXD)是在可安装文件系统管理器(IFSMGR.VXD)之前初始化的。在系统启动期间，WDM驱动程序是在配置管理器(CONFIGMG)的初始化阶段收到IRP_MN_START_DEVICE请求的，而此时可安装文件系统管理器(IFSMGR)还没有初始化。所以不可能用ZwCreateFile函数或本章讨论的其它函数执行文件I/O操作。另外，也没有办法把WDM驱动程序的IRP_MN_START_DEVICE处理推迟到文件系统初始化之后。如果你没有象Soft-ICE那样的调试器，那么这个问题的征兆就是在初始化CONFIGMG时出现一个Windows保护错误的蓝屏。

第二个问题更差劲，ZwReadFile、ZwWriteFile，和ZwQueryInformationFile函数需要对其参数进行有效性检测。如果你提供了一个内核模式内存中的IO_STATUS_BLOCK(只能这样，没有别的办法)，这些函数将探测一个不存在的虚拟地址，结果就发生了页故障，而结构化异常机制将捕捉到这个异常并返给你STATUS_ACCESS_VIOLATION状态代码，尽管你的做法没有任何毛病。这个问题在1998年7月的零售版Windows 98上也没有得到解决。

随书光盘上有一个FILEIO例子，它演示了一种绕过这些Windows 98问题的方法。执行文件操作时，FILEIO在运行时决定是调用ZwXxx函数还是调用VxD服务函数。


第四章：同步

Windows 2000可以运行在对称多处理器平台上。我并不想在这里严格描述Windows NT的多任务处理能力；如果你想了解这些内容，可参考David Solomon的《Inside Windows NT, Second Edition (Microsoft Press, 1998)》。作为驱动程序开发者我们需要了解的是：执行在某线程上下文中的代码在任何时刻都可能被系统夺去控制权。另外，只有在多处理器的计算机上才能真正实现多线程的并发执行。一般，我们需要做两个最差的假定：

  • 操作系统可以在任何时间抢先任何例程并停留任何长的时间，所以我们不能保证自己的任务不被干扰或延迟。
  • 即使我们能防止被抢先，但其它CPU上执行的代码也会干扰我们代码的执行，甚至一个程序的代码可以在两个不同线程的上下文中并发执行。

Windows NT为解决一般的同步问题提供了两种方法，一个是中断请求优先级(IRQL)方案，另一个是在关键代码段周围声明和释放自旋锁。IRQL可以避免在单CPU上的破坏性抢先，而自旋锁可以防止多CPU间的干扰。

  • 一个原始的同步问题
  • 中断请求级
  • 自旋锁
  • 内核同步对象
  • 其它内核模式同步原语


一个原始的同步问题

举一个常见的例子，假设你的驱动程序有一个静态整型变量，该变量用于统计当前未完成的I/O请求数：

static LONG lActiveRequests;

再假设你接收到一个请求后就把该变量增1，完成一个请求后就把该变量减1：

NTSTATUS DispatchPnp(PDEVICE_OBJECT fdo, PIRP Irp)
{
  ++lActiveRequests;
  ... // process PNP request
  --lActiveRequests;
}

我想你已经认识到了，象这样的计数器不应该是静态变量：它应该是设备扩展的一个成员，这样每个设备对象就可以有自己的计数器。我们暂且假定你的驱动程序仅管理一个设备。为了使这个例子更有意义，最后假定你的驱动程序中有这样一个函数，该函数在设备对象被删除时调用。你应该一直推迟删除操作，直到所有等待的请求都被完成，因此你应该加入计数器测试代码：

NTSTATUS HandleRemoveDevice(PDEVICE_OBJECT fdo, PIRP Irp)
{
  if (lActiveRequests)
    <wait for all requests to complete>
  IoDeleteDevice(fdo);
}

这个例子描述了一个真实的问题，我将在第六章中讨论PnP请求时再解决这个问题。I/O管理器能在驱动程序处理请求时删除我们的设备对象，所以我们需要某种计数器来保护设备对象。我将在第六章用IoAcquireRemoveLock函数和其它一些相关函数来解决这个问题。

有一个可怕的同步问题潜伏在上面代码片段中，但只有当你看到增1和减1操作的内部代码后才能看出这个问题。在x86处理器上，编译器为这两个操作生成下面汇编指令：

; ++lActiveRequests;
  mov eax, lActiveRequests
  add eax, 1
  mov lActiveRequests, eax

...

; --lActiveRequests;
  mov eax, lActiveRequests
  sub eax, 1
  mov lActiveRequests, eax

为了查出这个同步问题，让我们先考虑单CPU的情况。假定有两个线程在大致相同的时间内都要进入DispatchPnp函数。我们知道它们并不是真正地并发执行，因为我们仅有一个CPU。再假定有一个线程刚好执行到函数的尾部并且就在另一个线程要抢先它之前把lActiveRequests的当前内容读入了EAX寄存器。假设那一时刻lActiveRequests的值等于2。作为线程切换的一部分，操作系统把EAX寄存器的内容(值为2)保存到该线程的上下文中。

现在，假设另一个线程正执行到DispatchPnp函数的头部，它把lActiveRequests的值从2增为3(因为上个线程并没有修改该变量)。如果此时这个线程又被第一个线程抢先，操作系统将恢复第一个线程的上下文，这包括EAX寄存器中的值2。然后，第一个线程继续执行，它把EAX的值减1，最后把结果存入lActiveRequests。此时lActiveRequests的值为1，但这是不正确的。这造成的结果是，我们可能过早地删除我们的设备对象，因为确实丢失了一个I/O请求记录。

在x86计算机上可以很容易地解决这个特殊问题，我们仅需要把load/add/store和load/subtract/store指令序列替换为原子指令：

; ++lActiveRequests;
  inc lActiveRequests
  ...
; --lActiveRequests;
  dec lActiveRequests

在Intel x86 CPU上，INC和DEC指令不能被中断，所以绝不会出现线程在更新计数器时被抢先的情况。虽然如此，但这两个指令在多处理器环境中仍然是不安全的，因为这两个指令都是由几条微代码(CISC的特点)实现的。我们可以使用x86处理器中的LOCK指令前缀避免这个多CPU问题：

; ++lActiveRequests;
  lock inc lActiveRequests
  ...
; --lActiveRequests;
  lock dec lActiveRequests

LOCK指令前缀可以使当前执行多微码指令的CPU锁定总线，从而保证数据访问的完整性。

不幸的是，并不是所有的同步问题都可以这样容易地解决。我举的这个例子主要是为了阐述同步问题的来源：如果一个线程正在改变某状态变量时被另一个线程抢先；那么两个有矛盾的改变操作就可能被同时执行。在本章的剩下内容中，我将讲述如何使用IRQL优先级方案来避免抢先；如何使用自旋锁来防止并发执行。


中断请求级

Windows NT为每个硬件中断和少数软件事件赋予了一个优先级，即中断请求级(interrupt request level - IRQL)。IRQL为单CPU上的活动提供了同步方法，它基于下面规则：

一旦某CPU执行在高于PASSIVE_LEVEL的IRQL上时，该CPU上的活动仅能被拥有更高IRQL的活动抢先。

图4-1显示了x86平台上的IRQL值范围。(通常，这个IRQL数值要取决于你所面对的平台) 用户模式程序执行在PASSIVE_LEVEL上，可以被任何执行在高于该IRQL上的活动抢先。许多设备驱动程序例程也执行在PASSIVE_LEVEL上。第二章中讨论的DriverEntry和AddDevice例程就属于这类，大部分IRP派遣例程也属于这类。

某些公共驱动程序例程执行在DISPATCH_LEVEL上，而DISPATCH_LEVEL级要比PASSIVE_LEVEL级高。这些公共例程包括StartIo例程，DPC(推迟过程调用)例程，和其它一些例程。这些例程的共同特点是，它们都需要访问设备对象和设备扩展中的某些域，它们都不受派遣例程的干扰或互相干扰。当任何一个这样的例程运行时，上面陈述的规则可以保证它们不被任何驱动程序的派遣例程抢先，因为派遣例程本身执行在更低级的IRQL上。另外，它们也不会被同类例程抢先，因为那些例程运行的IRQL与它们自己的相同。只有拥有更高IRQL的活动才能抢先它们。

注意
派遣例程(Dispatch routine)和DISPATCH_LEVEL级名称类似。之所以称做派遣例程是因为I/O管理器向这些函数派遣I/O请求。而存在派遣级(DISPATCH_LEVEL)这个名称是因为内核线程派遣器运行在这个IRQL上，它决定下一次该执行哪个线程。(现在，线程调度程序通常运行在SYNCH_LEVEL级上)

图4-1. 中断请求级

在DISPATCH_LEVEL级和PROFILE_LEVEL级之间是各种硬件中断级。通常，每个有中断能力的设备都有一个IRQL，它定义了该设备的中断优先级别。WDM驱动程序只有在收到一个副功能码为IRP_MN_START_DEVICE的IRP_MJ_PNP请求后，才能确定其设备的IRQL。设备的配置信息作为参数传递给该请求，而设备的IRQL就包含在这个配置信息中。我们通常把设备的中断级称为设备IRQL，或DIRQL。

其它IRQL级的含义有时需要依靠具体的CPU结构。这些IRQL通常仅被Windows NT内核内部使用，因此它们的含义与设备驱动程序的编写不是特别密切相关。例如，我将要在本章后面详细讨论的APC_LEVEL，当系统在该级上为某线程调度APC(异步过程调用)例程时不会被同一CPU上的其它线程所干扰。在HIGH_LEVEL级上系统可以执行一些特殊操作，如系统休眠前的内存快照、处理bug check、处理假中断，等等。

IRQL的变化

为了演示IRQL的重要性，参见图4-2，该图显示了发生在单CPU上的一系列事件。在时间序列的开始处，CPU执行在PASSIVE_LEVEL级上。在t1时刻，一个中断到达，它的服务例程执行在DIRQL1上，该级是在DISPATCH_LEVEL和PROFILE_LEVEL之间的某个DIRQL。在t2时刻，另一个中断到达，它的服务例程执行在DIRQL2上，比DIRQL1低一级。我们讨论过抢先规则，所以CPU将继续服务于第一个中断。当第一个中断服务例程在t3时刻完成时，该中断服务程序可能会请求一个DPC。而DPC例程是执行在DISPATCH_LEVEL上。所以当前存在的未执行的最高优先级的活动就是第二个中断的服务例程，所以系统接着执行第二个中断的服务例程。这个例程在t4时刻结束，假设这之后再没有其它中断发生，CPU将降到DISPATCH_LEVEL级上执行第一个中断的DPC例程。当DPC例程在t5时刻完成后，IRQL又落回到原来的PASSIVE_LEVEL级。

图4-2. 变化中的中断优先级

基本同步规则

遵循下面规则，你可以利用IRQL的同步效果：

所有对共享数据的访问都应该在同一(提升的)IRQL上进行。

换句话说，不论何时何地，如果你的代码访问的数据对象被其它代码共享，那么你应该使你的代码执行在高于PASSIVE_LEVEL的级上。一旦越过PASSIVE_LEVEL级，操作系统将不允许同IRQL的活动相互抢先，从而防止了潜在的冲突。然而这个规则不足以保护多处理器机器上的数据，在多处理器机器中你还需要另外的防护措施——自旋锁(spin lock)。如果你仅关心单CPU上的操作，那么使用IRQL就可以解决所有同步问题。但事实上，所有WDM驱动程序都必须设计成能够运行在多处理器的系统上。

IRQL与线程优先级

线程优先级是与IRQL非常不同的概念。线程优先级控制着线程调度器的调度动作，决定何时抢先运行线程以及下一次运行什么线程。然而，当IRQL级高于或等于DISPATCH_LEVEL级时线程切换停止，无论当前活动的是什么线程都将保持活动状态直到IRQL降到DISPATCH_LEVEL级之下。而此时的“优先级”仅指IRQL本身，由它控制到底哪个活动该执行，而不是该切换到哪个线程的上下文。

IRQL和分页

执行在提升的IRQL级上的一个后果是，系统将不能处理页故障(系统在APC级处理页故障)。这意味着：

执行在高于或等于DISPATCH_LEVEL级上的代码绝对不能造成页故障。

这也意味着执行在高于或等于DISPATCH_LEVEL级上的代码必须存在于非分页内存中。此外，所有这些代码要访问的数据也必须存在于非分页内存中。最后，随着IRQL的提升，你能使用的内核模式支持例程将会越来越少。

DDK文档中明确指出支持例程的IRQL限定。例如，KeWaitForSingleObject例程有两个限定：

  • 调用者必须运行在低于或等于DISPATCH_LEVEL级上。
  • 如果调用中指定了非0的超时，那么调用者必须严格地运行在低于DISPATCH_LEVEL的IRQL上。

上面这两行想要说明的是：如果KeWaitForSingleObject真的被阻塞了指定长的时间(你指定的非0超时)，那么你必定运行在低于DISPATCH_LEVEL的IRQL上，因为只有在这样的IRQL上线程阻塞才是允许的。如果你所做的一切就是为了检测事件是否进入信号态，则可以执行在DISPATCH_LEVEL级上。但你不能在ISR或其它运行在高于DISPATCH_LEVEL级上的例程中调用KeWaitForSingleObject例程。

IRQL的隐含控制

在大部分时间里，系统都是在正确的IRQL上调用驱动程序中的例程。虽然我们还没有详细地讨论过这些例程，但我希望举一个例子来表达这句话的含义。你首先遇到的I/O请求就是I/O管理器调用你的某个派遣例程来处理一个IRP。这个调用发生在PASSIVE_LEVEL级上，因为你需要阻塞调用者线程，还需要调用其它支持例程。当然，你不能在更高的IRQL级上阻塞一个线程，而PASSIVE_LEVEL也是唯一能让你无限制地调用任何支持例程的IRQL级。

如果你的派遣例程通过调用IoStartPacket来排队IRP，那么你第一个遇到的请求将发生在I/O管理器调用你的StartIo例程时。这个调用发生在DISPATCH_LEVEL级，因为系统需要在没有其它例程(这些例程能在队列中插入或删除IRP)干扰的情况下访问I/O队列。回想一下前面提到的规则：所有对共享数据的访问都应该在同一(提升的)IRQL级上进行。因为每个能访问IRP队列的例程都执行在DISPATCH_LEVEL级上，所以任何例程在操作队列期间都不可能被打断(仅指在单CPU系统)。

之后，设备可能生成一个中断，而该中断的服务例程(ISR)将在DIRQL级上被调用。设备上的某些寄存器也许不能被安全地共享。但是，如果你仅在DIRQL上访问那些寄存器，可以保证在单CPU计算机上没人能妨碍你的ISR执行。如果驱动程序的其它代码需要访问这些关键的硬件寄存器，你应该让这些代码仅执行在DIRQL级上。KeSynchronizeExecution服务函数可以帮助你强制执行这个规则，我将在第七章的“与中断处理连接”段中讨论这个函数。

再往后，你应该安排一个DPC调用。DPC例程执行在DISPATCH_LEVEL级上，它们需要访问你的IRP队列，并取出队列中的下一个请求，然后把这个请求发送给StartIo例程。你可以调用IoStartNextPacket服务函数从队列中提取下一个请求，但必须在DISPATCH_LEVEL级上调用。该函数在返回前将调用你的StartIo例程。注意，这里的IRQL吻合得相当巧妙：队列访问，调用IoStartNextPacket，和调用StartIo都需要发生在DISPATCH_LEVEL级上，并且系统也是在这个IRQL级上调用DPC例程的。

尽管明确地控制IRQL也是可能的，但几乎没有理由这样做，因为你需要的IRQL和系统调用你时使用的IRQL总是相应的。所以不必不时地提高IRQL，例程希望的IRQL和系统使用的IRQL几乎总是正确对应的。

IRQL的明确控制

如果必要，你还可以在当前处理器上临时提升IRQL，然后再降回到原来的IRQL，使用KeRaiseIrql和KeLowerIrql函数。下面代码运行在PASSIVE_LEVEL级上：

KIRQL oldirql;								<--1
ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);				<--2
KeRaiseIrql(DISPATCH_LEVEL, &oldirql);					<--3
...
KeLowerIrql(oldirql);							<--4
  1. KIRQL定义了用于保存IRQL值的数据类型。我们需要一个变量来保存当前IRQL。
  2. 这个ASSERT断定了调用KeRaiseIrql的必要条件：新IRQL必须大于或等于当前IRQL。如果这个关系不成立，KeRaiseIrql将导致bug check。(即用死亡蓝屏报告一个致命错误)
  3. KeRaiseIrql把当前的IRQL提升到第一个参数指定的IRQL级上。它同时还把当前的IRQL值保存到第二个参数指定的变量中。在这个例子中，我们把IRQL提升到DISPATCH_LEVEL级，并把原来的IRQL级保存到oldirql变量中。
  4. 执行完任何需要在提升的IRQL上执行的代码后，我们调用KeLowerIrql把IRQL降低到调用KeRaiseIrql时的级别。

DDK文档中提到，你必须用与你最近的KeRaiseIrql调用所返回的值调用KeLowerIrql。这在大的方面是对的，因为你提升了IRQL就必须再降低它。然而，由于你调用的代码或者调用你的代码所做的各种假设会使后面的决定变得不正确。所以，文档中的这句话从严格意义上讲是不正确的。应用到KeLowerIrql函数的唯一的规则就是新IRQL必须低于或等于当前IRQL。

当系统调用你的驱动程序例程时，你降低了IRQL(系统调用你的例程时使用的IRQL，或你的例程希望执行的IRQL)，这是一个错误，而且是严重错误，尽管你在例程返回前又提升了IRQL。这种打破同步的结果是，某些活动可以抢先你的例程，并能访问你的调用者认为不能被共享的数据对象。

有一个函数专用于把IRQL提升到DISPATCH_LEVEL级：

KIRQL oldirql = KeRaiseIrqlToDpcLevel();
...
KeLowerIrql(oldirql)

注意：该函数仅在NTDDK.H中声明，WDM.H中并没有声明该函数，因此WDM驱动程序不应该使用该函数。


自旋锁

IRQL概念仅能解决单CPU上的同步问题，在多处理器平台上，它不能保证你的代码不被运行在其它处理器上的代码所干扰。一个称为自旋锁(spin lock)的原始对象可以解决这个问题。为了获得一个自旋锁，在某CPU上运行的代码需先执行一个原子操作，该操作测试并设置(test-and-set)某个内存变量，由于它是原子操作，所以在该操作完成之前其它CPU不可能访问这个内存变量。如果测试结果表明锁已经空闲，则程序获得这个自旋锁并继续执行。如果测试结果表明锁仍被占用，程序将在一个小的循环内重复这个“测试并设置(test-and-set)”操作，即开始“自旋”。最后，锁的所有者通过重置该变量释放这个自旋锁，于是，某个等待的test-and-set操作向其调用者报告锁已释放。

关于自旋锁有两个明显的事实。第一，如果一个已经拥有某个自旋锁的CPU想第二次获得这个自旋锁，则该CPU将死锁(deadlock)。自旋锁没有与其关联的“使用计数器”或“所有者标识”；锁或者被占用或者空闲。如果你在锁被占用时获取它，你将等待到该锁被释放。如果碰巧你的CPU已经拥有了该锁，那么用于释放锁的代码将得不到运行，因为你使CPU永远处于“测试并设置”某个内存变量的自旋状态。

关于自旋锁的另一个事实是，CPU在等待自旋锁时不做任何有用的工作，仅仅是等待。所以，为了避免影响性能，你应该在拥有自旋锁时做尽量少的操作，因为此时某个CPU可能正在等待这个自旋锁。

关于自旋锁还存在着一个不太明显但很重要的事实：你仅能在低于或等于DISPATCH_LEVEL级上请求自旋锁，在你拥有自旋锁期间，内核将把你的代码提升到DISPATCH_LEVEL级上运行。在内部，内核能在高于DISPATCH_LEVEL的级上获取自旋锁，但你和我都做不到这一点。

使用自旋锁

为了明确地使用一个自旋锁，首先要在非分页内存中为一个KSPIN_LOCK对象分配存储。然后调用KeInitializeSpinLock初始化这个对象。接着，当代码运行在低于或等于DISPATCH_LEVEL级上时获取这个锁，并执行需要保护的代码，最后释放自旋锁。例如，假设你的设备扩展中有一个名为QLock的自旋锁，你用它来保护你专用IRP队列的访问。你应该在AddDevice函数中初始化这个锁：

typedef struct _DEVICE_EXTENSION {
  ...
  KSPIN_LOCK QLock;
} DEVICE_EXTENSION, *PDEVICE_EXTENSION;

...

NTSTATUS AddDevice(...)
{
  ...
  PDEVICE_EXTENSION pdx = ...;
  KeInitializeSpinLock(&pdx->QLock);
  ...
}

在驱动程序的其它地方，假定就在某种IRP的派遣函数中，你在某些必须的队列操作代码周围获取了(并很快释放)该自旋锁。注意这个函数必须存在于非分页内存中，因为在某个时期它会执行在提升的IRQL上。

NTSTATUS DispatchSomething(...)
{
  KIRQL oldirql;
  PDEVICE_EXTENSION pdx = ...;
  KeAcquireSpinLock(&pdx->QLock, &oldirql);				<--1
  ...
  KeReleaseSpinLock(&pdx->QLock, oldirql);				<--2
}
  1. 当KeAcquireSpinLock获取自旋锁时，它也把IRQL提升到DISPATCH_LEVEL级上。
  2. 当KeReleaseSpinLock释放自旋锁时，它也把IRQL降低到原来的IRQL级上。

如果你知道代码已经处在DISPATCH_LEVEL级上，你可以调用两个专用函数来获取自旋锁。这个技术适合于DPC、StartIo，和其它执行在DISPATCH_LEVEL级上的驱动程序例程：

KeAcquireSpinLockAtDpcLevel(&pdx->QLock);
...
KeReleaseSpinLockFromDpcLevel(&pdx->QLock);


内核同步对象

Windows NT提供了五种内核同步对象(Kernel Dispatcher Object)，你可以用它们控制非任意线程(普通线程)的流程。表4-1列出了这些内核同步对象的类型及它们的用途。在任何时刻，任何对象都处于两种状态中的一种：信号态或非信号态。有时，当代码运行在某个线程的上下文中时，它可以阻塞这个线程的执行，调用KeWaitForSingleObject或KeWaitForMultipleObjects函数可以使代码(以及背景线程)在一个或多个同步对象上等待，等待它们进入信号态。内核为初始化和控制这些对象的状态提供了例程。

表4-1. 内核同步对象

对象 | 数据类型 | 描述
Event(事件) | KEVENT | 阻塞一个线程直到其它线程检测到某事件发生
Semaphore(信号灯) | KSEMAPHORE | 与事件对象相似，但可以满足任意数量的等待
Mutex(互斥) | KMUTEX | 执行到关键代码段时，禁止其它线程执行该代码段
Timer(定时器) | KTIMER | 推迟线程执行一段时期
Thread(线程) | KTHREAD | 阻塞一个线程直到另一个线程结束

在下几段中，我将描述如何使用内核同步对象。我将从何时可以调用等待原语阻塞线程开始讲起，然后讨论用于每种对象的支持例程。最后讨论与线程警惕(thread alert)和提交APC(异步过程调用)相关的概念。

何时阻塞和怎样阻塞一个线程

为了理解WDM驱动程序何时以及如何利用内核同步对象阻塞一个线程，你必须先对线程有一些基本了解。通常，如果在线程执行时发生了软件或硬件中断，那么在内核处理中断期间，该线程仍然是“当前”线程。而内核模式代码执行时所在的上下文环境就是指这个“当前”线程的上下文。为了响应各种中断，Windows NT调度器可能会切换线程，这样，另一个线程将成为新的“当前”线程。

术语“任意线程上下文(arbitrary thread context)”和“非任意线程上下文(nonarbitrary thread context)”用于精确描述驱动程序例程执行时所处于的上下文种类。如果我们知道程序正处于初始化I/O请求线程的上下文中，则该上下文不是任意上下文。然而，在大部分时间里，WDM驱动程序无法知道这个事实，因为控制哪个线程应该激活的机会通常都是在中断发生时。当应用程序发出I/O请求时，将产生一个从用户模式到内核模式的转换，而创建并发送该IRP的I/O管理器例程将继续运行在非任意线程的上下文中。我们用术语“最高级驱动程序”来描述第一个收到该IRP的驱动程序。

通常，只有给定设备的最高级驱动程序才能确切地知道它执行在一个非任意线程的上下文中。这是因为驱动程序派遣例程通常把请求放入队列后立即返回调用者。之后通过回调函数，请求被提出队列并下传到低级驱动程序。一旦派遣例程挂起某个请求，所有对该请求的后期处理必须发生在任意线程上下文中。

解释完线程上下文后，我们可以陈诉出关于线程阻塞的简单规则：

当我们处理某个请求时，仅能阻塞产生该请求的线程。

通常，仅有设备的最高级驱动程序才能应用这个规则。但有一个重要的例外，IRP_MN_START_DEVICE请求，所有驱动程序都以同步方式处理这个请求。即驱动程序不排队或挂起该类请求。当你收到这种请求时，你可以直接从堆栈中找到请求的发起者。正如我在第六章中讲到的，处理这种请求时你必须阻塞那个线程。

下面规则表明在提升的IRQL级上不可能发生线程切换：

执行在高于或等于DISPATCH_LEVEL级上的代码不能阻塞线程。

这个规则表明你只能在DriverEntry函数、AddDevice函数，或驱动程序的派遣函数中阻塞当前线程。因为这些函数都执行在PASSIVE_LEVEL级上。没有必要在DriverEntry或AddDevice函数中阻塞当前线程，因为这些函数的工作仅仅是初始化一些数据结构。

在单同步对象上等待

你可以按下面方法调用KeWaitForSingleObject函数：

ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);
LARGE_INTEGER timeout;
NTSTATUS status = KeWaitForSingleObject(object, WaitReason, WaitMode, Alertable, &timeout);

ASSERT语句指出必须在低于或等于DISPATCH_LEVEL级上调用该例程。

在这个调用中，object指向你要等待的对象。注意该参数的类型是PVOID，它应该指向一个表4-1中列出的同步对象。该对象必须在非分页内存中，例如，在设备扩展中或其它从非分页内存池中分配的数据区。在大部分情况下，执行堆栈可以被认为是非分页的。

WaitReason是一个纯粹建议性的值，它是KWAIT_REASON枚举类型。实际上，除非你指定了WrQueue参数，否则任何内核代码都不关心此值。线程阻塞的原因被保存到一个不透明的数据结构中，如果你了解这个数据结构，那么在调试某种死锁时，你也许会从这个原因代码中获得一些线索。通常，驱动程序应把该参数指定为Executive，代表无原因。

WaitMode是MODE枚举类型，该枚举类型仅有两个值：KernelMode和UserMode。

Alertable是一个布尔类型的值。它不同于WaitReason，这个参数以另一种方式影响系统行为，它决定等待是否可以提前终止以提交一个APC。如果等待发生在用户模式中，那么内存管理器就可以把线程的内核模式堆栈换出。如果驱动程序以自动变量(在堆栈中)形式创建事件对象，并且某个线程又在提升的IRQL级上调用了KeSetEvent，而此时该事件对象刚好又被换出内存，结果将产生一个bug check。所以我们应该总把alertable参数指定为FALSE，即在内核模式中等待。

最后一个参数&timeout是一个64位超时值的地址，单位为100纳秒。正数的超时表示一个从1601年1月1日起的绝对时间。调用KeQuerySystemTime函数可以获得当前系统时间。负数代表相对于当前时间的时间间隔。如果你指定了绝对超时，那么系统时钟的改变也将影响到你的超时时间。如果系统时间越过你指定的绝对时间，那么永远都不会超时。相反，如果你指定相对超时，那么你经过的超时时间将不受系统时钟改变的影响。

为什么是1601年1月1日

许多年以前，当我第一次学习Win32 API时，我曾迷惑为什么选择1601年1月1日作为Windows NT的时间起点。在我写了一组时间转换函数后我明白了这个问题的原因。每个人都知道可被4整除的年份是闰年。许多人也知道世纪年(如1900年)应例外，虽然这些年份都能被4整除但它们不是闰年。少数人还知道能被400整除的年份(如1600和2000)是例外中的例外，它们也是闰年。而1601年1月1日正好是一个400年周期的开始。如果把它作为时间信息的起点，那么把NT时间信息转换为常规日期表达(或相反)就不用做任何跳跃操作。

指定0超时将使KeWaitForSingleObject函数立即返回，返回的状态代码指出对象是否处于信号态。如果你的代码执行在DISPATCH_LEVEL级上，则必须指定0超时，因为在这个IRQL上不允许阻塞。每个内核同步对象都提供一组KeReadStateXxx服务函数，使用这些函数可以直接获得对象的状态。然而，取对象状态与0超时等待不完全等价：当KeWaitForSingleObject发现等待被满足后，它执行特殊对象要求的附加动作。相比之下，取对象状态不执行任何附加动作，即使对象已经处于信号态。

超时参数也可以指定为NULL指针，这代表无限期等待。

该函数的返回值指出几种可能的结果。STATUS_SUCCESS结果是你所希望的，表示等待被满足。即在你调用KeWaitForSingleObject时，对象或者已经进入信号态，或者后来进入信号态。如果等待以第二种情况满足，则有必要在同步对象上执行附加动作。当然，这个附加动作还要参考对象的类型，我将在后面讨论具体对象类型时再解释这一点。(例如，一个同步类型的事件在你的等待满足后需要重置该事件)

返回值STATUS_TIMEOUT指出在指定的超时期限内对象未进入信号态。如果指定0超时，则函数将立即返回。返回代码为STATUS_TIMEOUT，代表对象处于非信号态，返回代码为STATUS_SUCCESS，代表对象处于信号态。如果指定NULL超时，则不可能有返回值。

其它两个返回值STATUS_ALERTED和STATUS_USER_APC表示等待提前终止，对象未进入信号态。原因是线程接收到一个警惕(alert)或一个用户模式的APC。

在多同步对象上等待

KeWaitForMultipleObjects函数用于同时等待一个或多个同步对象。该函数调用方式如下：

ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);
LARGE_INTEGER timeout;
NTSTATUS status = KeWaitForMultipleObjects(count,
                       objects,
                       WaitType,
                       WaitReason,
                       WaitMode,
                       Alertable,
                       &timeout,
                       waitblocks);

在这里，objects指向一个指针数组，每个数组元素指向一个同步对象，count是数组中指针的个数。count必须小于或等于MAXIMUM_WAIT_OBJECTS值(当前为64)。这个数组和它所指向的所有对象都必须在非分页内存中。WaitType是枚举类型，其值可以为WaitAll或WaitAny，它指出你是等到所有对象都进入信号态，还是只要有一个对象进入信号态就可以。

waitblocks参数指向一个KWAIT_BLOCK结构数组，内核用这个结构数组管理等待操作。你不需要初始化这些结构，内核仅需要知道这个结构数组在哪里，内核用它来记录每个对象在等待中的状态。如果你仅需要等待小数量的对象(不超过THREAD_WAIT_OBJECTS，该值当前为3)，你可以把该参数指定为NULL。如果该参数为NULL，KeWaitForMultipleObjects将使用线程对象中预分配的等待块数组。如果你等待的对象数超过THREAD_WAIT_OBJECTS，你必须提供一块长度至少为count * sizeof(KWAIT_BLOCK)的非分页内存。

其余参数与KeWaitForSingleObject中的对应参数作用相同，而且大部分返回码也有相同的含义。

如果你指定了WaitAll，则返回值STATUS_SUCCESS表示等待的所有对象都进入了信号态。如果你指定了WaitAny，则返回值在数值上等于进入信号态的对象在objects数组中的索引。如果碰巧有多个对象进入了信号态，则该值仅代表其中的一个，可能是第一个也可能是其它。你可以认为该值等于STATUS_WAIT_0加上数组索引。你可以先用NT_SUCCESS测试返回码，然后再从其中提取数组索引：

NTSTATUS status = KeWaitForMultipleObjects(...);
if (NT_SUCCESS(status))
{
  ULONG iSignalled = (ULONG) status - (ULONG) STATUS_WAIT_0;
  ...
}

如果KeWaitForMultipleObjects返回成功代码，它也将执行等待被满足的那个对象的附加动作。如果多个对象同时进入信号态而你指定的WaitType参数为WaitAny，那么该函数仅执行返回值指定对象的附加动作。

内核事件

表4-2列出了用于处理内核事件的服务函数。为了初始化一个事件对象，我们首先应该为其分配非分页存储，然后调用KeInitializeEvent：

ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL);
KeInitializeEvent(event, EventType, initialstate);

event是事件对象的地址。EventType是一个枚举值，可以为NotificationEvent或SynchronizationEvent。通知事件(notification event)有这样的特性，当它进入信号态后，它将一直处于信号态直到你明确地把它重置为非信号态。此外，当通知事件进入信号态后，所有在该事件上等待的线程都被释放。这与用户模式中的手动重置事件相似。而对于同步事件(synchronization event)，只要有一个线程被释放，该事件就被重置为非信号态。这又与用户模式中的自动重置事件相同。而KeWaitXxx函数在同步事件对象上执行的附加动作就是把它重置为非信号态。最后的参数initialstate是布尔量，为TRUE表示事件的初始状态为信号态，为FALSE表示事件的初始状态为非信号态。

表4-2. 用于内核事件对象的服务函数

服务函数 | 描述
KeClearEvent | 把事件设置为非信号态，不报告以前的状态
KeInitializeEvent | 初始化事件对象
KeReadStateEvent | 取事件的当前状态
KeResetEvent | 把事件设置为非信号态，返回以前的状态
KeSetEvent | 把事件设置为信号态，返回以前的状态
注意
在这些关于同步原语的段中，我还要再谈论一下DDK文档中对IRQL的使用限定。在当前发行的Windows 2000中，DDK有时比OS实际要求的有更多的限制。例如，KeClearEvent可以在任何IRQL上调用，但DDK却要求调用者必须在低于或等于DISPATCH_LEVEL级上调用。KeInitializeEvent也可以在任何IRQL上调用，但DDK要求仅在PASSIVE_LEVEL级上调用该函数。然而，你应该尊重DDK中的描述，也许某一天Microsoft会利用文档中的这些限制。

调用KeSetEvent函数可以把事件置为信号态：

ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);
LONG wassignalled = KeSetEvent(event, boost, wait);

在上面代码中，ASSERT语句强制你必须在低于或等于DISPATCH_LEVEL级上调用该函数。event参数指向一个事件对象，boost值用于提升等待线程的优先级。wait参数的解释见文字框“KeSetEvent的第三个参数”，WDM驱动程序几乎从不把wait参数指定为TRUE。如果该事件已经处于信号态，则该函数返回非0值。如果该事件处于非信号态，则该函数返回0。

多任务调度器需要人为地提升等待I/O操作或同步对象的线程的优先级，以避免饿死长时间等待的线程。这是因为被阻塞的线程往往是放弃自己的时间片并且不再要求获得CPU，但只要这些线程获得了比其它线程更高的优先级，或者其它同一优先级的线程用完了自己的时间片，它们就可以恢复执行。注意，正处于自己时间片中的线程不能被阻塞。

用于提升阻塞线程优先级的boost值不太好选择。一个较好的笨方法是指定IO_NO_INCREMENT值，当然，如果你有更好的值，可以不用这个值。如果事件唤醒的是一个处理时间敏感数据流的线程(如声卡驱动程序)，那么应该使用适合那种设备的boost值(如IO_SOUND_INCREMENT)。重要的是，不要为一个愚蠢的理由去提高等待者的优先级。例如，如果你要同步处理一个IRP_MJ_PNP请求，那么在你要停下来等待低级驱动程序处理完该IRP时，你的完成例程应调用KeSetEvent。由于PnP请求对于处理器没有特殊要求并且也不经常发生，所以即使是声卡驱动程序也也应该把boost参数指定为IO_NO_INCREMENT。

KeSetEvent的第三个参数

wait参数的目的是允许在内部快速地把控制从一个线程传递到另一个线程。除了设备驱动程序之外，大部分系统部件都可以创建双事件对象。例如，客户线程和服务器线程使用双事件对象来界定它们的通信。当服务器线程需要唤醒对应的客户线程时，它首先调用KeSetEvent函数，并指定wait参数为TRUE，然后立即调用KeWaitXxx函数使自己进入睡眠状态。由于这两个操作都以原子方式完成，所以在控制交接时没有其它线程被唤醒。

DDK总是稍稍地描述一些内部细节，但我发现有些描述另人迷惑。我将以另一种方式解释这些内部细节，看过这些细节后你就会明白为什么我们总指定这个参数为FALSE。在内部，内核使用一个“同步数据库锁(dispatcher database lock)”来保护线程的阻塞、唤醒，和调度操作。KeSetEvent函数需要获取这个锁，KeWaitXxx函数也是这样。如果你把这个参数指定为TRUE，则KeSetEvent函数将设置一个标志以便KeWaitXxx函数知道你使用了TRUE参数，然后它返回，并且不释放这个锁。当你后来(应该立即调用，因为你此时正运行在一个比任何硬件设备都高的IRQL上，并且你占有着一个被极其频繁争夺的自旋锁)调用KeWaitXxx函数时，它不必再获取这个锁。产生的效果就是你唤醒了等待的线程并同时把自己置入睡眠状态，而不给其它线程任何运行的机会。

你应该明白，以wait参数为TRUE调用KeSetEvent的函数必须存在于非分页内存中，因为它在某段时间内执行在提升的IRQL上。很难想象一个普通设备驱动程序会需要使用这种机制，因为驱动程序决不会比内核更了解线程的调度。底线是：对该参数总使用FALSE。实际上，Microsoft暴露该参数给我们的原因仍不十分清楚。

调用KeReadStateEvent函数(在任何IRQL上)可以测试事件的当前状态：

LONG signalled = KeReadStateEvent(event);

返回值不为0代表事件处于信号态，为0代表事件处于非信号态。

注意
Windows 98不支持KeReadStateEvent函数，但支持上面描述的其它KeReadStateXxx函数。为了获得事件的状态，我们必须使用Windows 98的其它同步原语。

调用KeResetEvent函数(在低于或等于DISPATCH_LEVEL级)可以立即获得事件对象的当前状态，但该函数会把事件对象重置为非信号状态。

ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);
LONG signalled = KeResetEvent(event);

如果你对事件的上一个状态不感兴趣，可以调用KeClearEvent函数，象下面这样：

ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);
KeClearEvent(event);

KeClearEvent函数执行得更快，因为它在读取事件的当前状态后不设置事件为非信号态。

内核信号灯

内核模式信号灯是一个有同步语义的整数计数器。信号灯计数器为正值时代表信号态，为0时代表非信号态。计数器不能为负值。释放信号灯将使信号灯计数器增1，在一个信号灯上等待将使该信号灯计数器减1。如果计数器值被减为0，则信号灯进入非信号态，之后其它调用KeWaitXxx函数的线程将被阻塞。注意如果等待线程的个数超过了计数器的值，那么并不是所有等待的线程都可以恢复运行。

内核提供了三个服务函数来控制信号灯对象的状态。(见表4-3) 信号灯对象应该在PASSIVE_LEVEL级上初始化：

ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL);
KeInitializeSemaphore(semaphore, count, limit);

在这个调用中，semaphore参数指向一个在非分页内存中的KSEMAPHORE对象。count是信号灯计数器的初始值，limit是计数器能达到的最大值，它必须与信号灯计数器的初始值相同。

表4-3. 内核信号灯对象服务函数

服务函数 | 描述
KeInitializeSemaphore | 初始化信号灯对象
KeReadStateSemaphore | 取信号灯当前状态
KeReleaseSemaphore | 设置信号灯对象为信号态

如果你创建信号灯时指定limit参数为1，则该对象与仅有一个线程的互斥对象类似。但内核互斥对象有一些信号灯没有的特征，这些特征用于防止死锁。所以，没有必要创建limit为1的信号灯。

如果你以一个大于1的limit值创建信号灯， 则该信号灯允许多个线程同时访问某些资源。在队列理论中我们会发现同样的原理，单队列可以被多个服务程序使用。多个服务程序使用一个队列要比每个服务程序都有各自的队列更合理。这两种形式的平均等待时间是相同的，但前者的等待次数更少。使用信号灯，你可以把一组软件或硬件服务程序按照队列原理组织起来。

信号灯的所有者可以调用KeReleaseSemaphore函数释放信号灯：

ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);
LONG wassignalled = KeReleaseSemaphore(semaphore, boost, delta, wait);

这里出现了一个delta参数，它必须为正数，该函数把delta值加到semaphore指向的信号灯计数器上，这将把信号灯带入信号态，并使等待线程释放。通常，该参数应该指定为1，代表有一个所有者释放了它的权利。boost和wait参数与在KeSetEvent函数中的作用相同。返回值为0代表信号灯的前一个状态是非信号态，非0代表信号灯的前一个状态为信号态。

KeReleaseSemaphore不允许你把计数器的值增加到超过limit指定的值。如果你这样做，该函数根本就不调整计数器的值，它将产生一个代码为STATUS_SEMAPHORE_LIMIT_EXCEEDED的异常。除非系统中存在捕获该异常的处理程序，否则将导致一个bug check。

下面调用读取信号灯的当前状态：

ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);
LONG signalled = KeReadStateSemaphore(semaphore);

非0返回值表示信号灯处于信号态，0返回值代表信号灯为非信号态。不要把该返回值假定为计数器的当前值。

内核互斥对象

互斥(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 this
ASSERT(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);

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

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

通知定时器与DPC例程

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

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

PKDPC dpc;  //  points to KDPC you've allocated
ASSERT(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上。

同步定时器

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

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);

在这里，WaitMode、Alertable，和函数返回代码与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参数

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

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

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

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

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

底线是：使用非警惕性等待，除非你知道不这样做的原因。
下面是MFC中的同步对象及其用途，仅供参考

类CSemaphore的对象代表一个“信号灯”：一种同步对象，允许一个或多个进程中的有限数量的线程访问某个资源。一个CSemaphore对象维护着当前正访问某个资源的进程的个数。信号灯通常用于控制仅能支持有限数量用户的共享资源的访问。CSemaphore对象计数器的当前值表示还可以允许多少个用户使用它保护的共享资源。当这个数到达0时，所有试图访问被保护资源的操作都被放入一个系统队列等待，直到计数数值上升到0以上或等待超时。

类CEvent的对象代表一个“事件”：一种同步对象，用于一个线程通知另一个线程某事件发生。事件通常用于线程想知道何时执行其任务。例如，复制数据到某文件的线程需要被通知数据何时准备好。用CEvent对象可以通知复制线程数据已经有效，这样线程就可以尽快地执行其任务。CEvent对象有两种类型：手动和自动。手动CEvent对象将停留在SetEvent或ResetEvent设置的状态，除非你再次设置它。自动CEvent对象在一个线程释放后自动回到非信号态(无效状态)。

类CMutex的对象代表一个“互斥”：一种同步对象，可以使一个线程排斥性地访问某个资源。互斥可以用于一次仅允许一个线程访问的资源。例如，向链表添加一个接点的过程就是一次只允许一个线程执行。通过使用CMutex对象控制链表，一次只能有一个线程获得链表的访问权。

类CCriticalSection的对象代表一个“关键段”：一种同步对象，代表一次只允许一个线程访问的资源或代码片段。例如，向链表添加一个节点。使用CCriticalSection对象控制的链表一次只允许一个线程访问该链表。如果着重于执行速度并且被保护资源不跨进程使用，也可以用关键段代替互斥。


其它内核模式同步原语

其它内核模式同步要素

Windows 2000内核为同步线程执行和保护共享对象访问提供了一些额外的方法。在这一节中，我们将讨论快速互斥(fast mutex)对象，通过对无竞争情况的优化处理，它可以提供比普通内核互斥对象更快的执行性能。我还将描述一种名称中含有“互锁(Interlocked)”术语的支持函数。这些函数都执行某种公用操作，例如增加或减少一个整数的值，从链表中插入或删除一个表项，这些操作都是以原子方式执行，从而可以避免多任务或多处理器的干扰。

快速互斥对象

参照内核互斥，表4-6列出了快速互斥的优点和缺点。有利的一面，快速互斥在没有实际竞争的情况下可以快速获取和释放。不利的一面，你不能递归获取一个快速互斥对象。即如果你拥有快速互斥对象你就不能发出APC，这意味着你将处于APC_LEVEL或更高的IRQL，在这一级上，线程优先级将失效，但你的代码将不受干扰地执行，除非有硬件中断发生。

表4-6. 内核互斥和快速互斥的比较

内核互斥 | 快速互斥
可以被单线程递归获取(系统为其维护一个请求计数器) | 不能被递归获取
速度慢 | 速度快
所有者只能收到“特殊的”内核APC | 所有者不能收到任何APC
所有者不能被换出内存 | 不自动提升被阻塞线程的优先级(如果运行在大于或等于APC_LEVEL级)，除非你使用XxxUnsafe函数并且执行在PASSIVE_LEVEL级上
可以是多对象等待的一部分 | 不能作为KeWaitForMultipleObjects的参数使用

表4-7 列出了与快速互斥相关的服务函数

表4-7. 快速互斥服务函数

服务函数 | 描述
ExAcquireFastMutex | 获取快速互斥，如果必要则等待
ExAcquireFastMutexUnsafe | 获取快速互斥，如果必要则等待，调用者必须先停止接收APC
ExInitializeFastMutex | 初始化快速互斥对象
ExReleaseFastMutex | 释放快速互斥
ExReleaseFastMutexUnsafe | 释放快速互斥，不解除APC提交禁止
ExTryToAcquireFastMutex | 获取快速互斥，如果可能，立即获取不等待

为了创建一个快速互斥，你必须先在非分页内存中分配一个FAST_MUTEX数据结构。然后调用ExInitializeFastMutex函数初始化该快速互斥对象。实际上，在WDM.H中该函数是一个宏：

ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);
ExInitializeFastMutex(FastMutex);

FastMutex是FAST_MUTEX对象的地址。快速互斥开始于无主状态。为了获取快速互斥对象，调用下面函数：

ASSERT(KeGetCurrentIrql() < DISPATCH_LEVEL);
ExAcquireFastMutex(FastMutex);

或

ASSERT(KeGetCurrentIrql() < DISPATCH_LEVEL);
ExAcquireFastMutexUnsafe(FastMutex);

第一种函数等待互斥变成有效状态，然后再把所有权赋给调用线程，最后把处理器当前的IRQL提升到APC_LEVEL。IRQL提升的结果是阻止所有APC的提交。第二种函数不改变IRQL。在使用这个“不安全”的函数获取快速互斥前你需要考虑潜在的死锁可能。必须避免运行在同一线程上下文下的APC例程获取同一个互斥或任何其它不能被递归锁定的对象。否则你将冒随时死锁那个线程的风险。

如果你不想在互斥没立即有效的情况下等待，使用“尝试获取”函数：

ASSERT(KeGetCurrentIrql() < DISPATCH_LEVEL);
BOOLEAN acquired = ExTryToAcquireFastMutex(FastMutex);

如果返回值为TRUE，则你已经拥有了该互斥。如果为FALSE，表明该互斥已经被别人占有，你不能获取。

为了释放一个快速互斥并允许其它线程请求它，调用适当的释放函数：

ASSERT(KeGetCurrentIrql() < DISPATCH_LEVEL);
ExReleaseFastMutex(FastMutex);

或

ASSERT(KeGetCurrentIrql() < DISPATCH_LEVEL);
ExReleaseFastMutexUnsafe(FastMutex);

快速互斥之所以快速是因为互斥的获取和释放步骤都为没有竞争的情况做了优化。获取互斥的关键步骤是自动减和测试一个整数计数器，该计数器指出有多少线程占有或等待该互斥。如果测试表明没有其它线程占有该互斥，则没有额外的工作需要做。如果测试表明有其它线程拥有该互斥，则当前线程将阻塞在一个同步事件上，该同步事件是FAST_MUTEX对象的一部分。释放互斥时必须自动增并测试计数器。如果测试表明当前没有等待线程，则没有额外的工作要做。如果还有线程在等待，则互斥所有者需调用KeSetEvent函数释放一个等待线程。

互锁运算

在WDM驱动程序能调用的函数中，有一些函数可以以线程安全和多处理器安全的方式执行算术运算。见表4-8。这些例程有两种形式，第一种形式以Interlocked为名字开头，它们可以执行原子操作，其它线程或CPU不能干扰它们的执行。另一种形式以ExInterlocked为名字开头，它们使用自旋锁。

表4-8. 互锁运算服务函数

服务函数 | 描述
InterlockedCompareExchange | 比较并有条件地交换两个值
InterlockedDecrement | 整数减1
InterlockedExchange | 交换两个值
InterlockedExchangeAdd | 加两个值并返回和
InterlockedIncrement | 整数加1
ExInterlockedAddLargeInteger | 向64位整数加
ExInterlockedAddLargeStatistic | 向ULONG加
ExInterlockedAddUlong | 向ULONG加并返回原始值
ExInterlockedCompareExchange64 | 交换两个64位值

InterlockedXxx函数可以在任意IRQL上调用；由于该函数不需要自旋锁，所以它们还可以在PASSIVE_LEVEL级上处理分页数据。尽管ExInterlockedXxx函数也可以在任意IRQL上调用，但它们需要在大于或等于DISPATCH_LEVEL级上操作目标数据，所以它们的参数需要在非分页内存中。使用ExInterlockedXxx的唯一原因是，如果你有一个数据变量，且需要增减该变量的值，并且有时还需要用其它指令序列直接访问该变量。你可以在对该变量的多条访问代码周围明确声明自旋锁，然后仅用ExInterlockedXxx函数执行简单的增减操作。

InterlockedXxx函数

InterlockedIncrement向内存中的长整型变量加1，并返回加1后的值：

LONG result = InterlockedIncrement(pLong);

pLong是类型为LONG的变量的地址，概念上，该函数的操作等价于C语句：return ++*pLong，但它与简单的C语句的不同地方是提供了线程安全和多处理器安全。InterlockedIncrement可以保证整数变量被成功地增1，即使其它CPU上的线程或同一CPU上的其它线程同时尝试改变这个整数的值。就操作本身来说，它不能保证所返回的值仍是该变量当前的值，甚至即使仅仅过了一个机器指令周期，因为一旦这个增1原子操作完成，其它线程或CPU就可能立即修改这个变量。

InterlockedDecrement除了执行减1操作外，其它方面同上。

LONG result = InterlockedDecrement(pLong);

InterlockedCompareExchange函数可以这样调用：

LONG target;
LONG result = InterlockedCompareExchange(&target, newval, oldval);

target是一个类型为LONG的整数，既可以用于函数的输入也可以用于函数的输出，oldval是你对target变量的猜测值，如果这个猜测正确，则newval被装入target。该函数的内部操作与下面C代码类似，但它是以原子方式执行整个操作，即它是线程安全和多处理器安全的：

LONG CompareExchange(PLONG ptarget, LONG newval, LONG oldval)
{
  LONG value = *ptarget;
  if (value == oldval)
    *ptarget = newval;
  return value;
}

换句话说，该函数总是返回target变量的历史值给你。此外，如果这个历史值等于oldval，那么它把target的值设置为newval。该函数用原子操作实现比较和交换，而交换仅在历史值猜测正确的情况下才发生。

你还可以调用InterlockedCompareExchangePointer函数来执行类似的比较和交换操作，但该函数使用指针参数。该函数或者定义为编译器内部的内联函数，或者是一个真实的函数，取决于你编译时平台的指针宽度，以及编译器生成内联代码的能力。下面例子中使用了这个指针版本的比较交换函数，它把一个结构加到一个单链表的头部，而不用使用自旋锁或提升IRQL：

typedef struct _SOMESTRUCTURE {
  struct _SOMESTRUCTURE* next;
  ...
} SOMESTRUCTURE, *PSOMESTRUCTURE;
...
void InsertElement(PSOMESTRUCTURE p, PSOMESTRUCTURE* anchor)
{
  PSOMESTRUCTURE next, first;
  do
  {
    p->next = first = *anchor;
    next = InterlockedCompareExchangePointer(anchor, p, first);
  }
  while (next != first);
}

每一次循环中，我们都假设新元素将连接到链表的当前头部，即变量first中的地址。然后我们调用InterlockedCompareExchangePointer函数来查看anchor是否仍指向first，即使在过了几纳秒之后。如果是这样，InterlockedCompareExchangePointer将设置anchor，使其指向新元素p。并且如果InterlockedCompareExchangePointer的返回值也与我们的假设一致，则循环终止。如果由于某种原因，anchor不再指向那个first元素(可能被其它并发线程或CPU修改过)，我们将发现这个事实并重复循环。

最后一个函数是InterlockedExchange，它使用原子操作替换整数变量的值并返回该变量的历史值：

LONG value;
LONG oldval = InterlockedExchange(&value, newval);

正如你猜到的，还有一个InterlockedExchangePointer函数，它交换指针值(64位或32位，取决于具体平台)。

ExInterlockedXxx函数

每一个ExInterlockedXxx函数都需要在调用前创建并初始化一个自旋锁。注意，这些函数的操作数必须存在于非分页内存中，因为这些函数在提升的IRQL上操作数据。

ExInterlockedAddLargeInteger加两个64位整数并返回被加数的历史值：

LARGE_INTEGER value, increment;
KSPIN_LOCK spinlock;
LARGE_INTEGER prev = ExInterlockedAddLargeInteger(&value, increment, &spinlock);

value是被加数。increment是加数。spinlock是一个已经初始化过的自旋锁。返回值是被加数的历史值。该函数的操作过程与下面代码类似，但除了自旋锁的保护：

__int64 AddLargeInteger(__int64* pvalue, __int64 increment)
{
  __int64 prev = *pvalue;
  *pvalue += increment;
  return prev;
}

注意，并不是所有编译器都支持__int64整型类型，并且不是所有计算机都能用原子指令方式执行64位加操作。

ExInterlockedAddUlong与ExInterlockedAddLargeInteger类似，但它的操作数是32位无符号整数：

ULONG value, increment;
KSPIN_LOCK spinlock;
ULONG prev = ExInterlockedAddUlong(&value, increment, &spinlock);

该函数同样返回被加数的加前值。

ExInterlockedAddLargeStatistic与ExInterlockedAddUlong类似，但它把32位值加到64位值上。该函数在本书出版时还没有在DDK中公开，所以我在这里仅给出它的原型：

VOID ExInterlockedAddLargeStatistic(PLARGE_INTEGER Addend, ULONG Increment);

该函数要比ExInterlockedAddUlong函数快，因为它不需要返回被加数的加前值。因此，它也不需要使用自旋锁来同步。该函数的操作也是原子性的，但仅限于调用同一函数的其它调用者。换句话说，如果你在一个CPU上调用ExInterlockedAddLargeStatistic函数，而同时另一个CPU上的代码正访问Addend变量，那么你将得到不一致的结果。我将用该函数在Intel x86上的执行代码(并不是实际的源代码)来解释这个原因：

mov eax, Addend
mov ecx, Increment
lock add [eax], ecx
lock adc [eax+4], 0

这个代码在低32位没有进位的情况下可以正常工作，但如果存在着进位，那么在ADD和ADC指令之间其它CPU可能进入，如果那个CPU调用的ExInterlockedCompareExchange64函数复制了这个时刻的64位变量值，那么它得到值将是不正确的。即使每个加法指令前都有lock前缀保护其操作的原子性(多CPU之间)，但多个这样的指令组成的代码块将无法保持原子性。

链表的互锁访问

Windows NT的executive部件提供了三组特殊的链表访问函数，它们可以提供线程安全的和多处理器安全的链表访问。这些函数支持双链表、单链表，和一种称为S链表(S-List)的特殊单链表。我在前面章中已经讨论过单链表和双链表的非互锁访问。在这里，我将解释这些链表的互锁访问。

如果你需要一个FIFO队列，你应该使用双链表。如果你需要一个线程安全的和多处理器安全的下推栈，你应该使用S链表。为了以线程安全和多处理器安全的方式使用这些链表，你必须为它们分配并初始化一个自旋锁。但S链表并没有真正使用自旋锁。S链表中存在顺序号，内核利用它可以实现比较-交换操作的原子性。

用于互锁访问各种链表对象的函数都十分相似，所以我将以函数的功能来组织这些段。我将解释如何初始化这三种链表，如何向这三种链表中插入元素，如何从这三种链表中删除元素。

初始化

你可以象下面这样初始化这些链表：

LIST_ENTRY DoubleHead;
SINGLE_LIST_ENTRY SingleHead;
SLIST_HEADER SListHead;

InitializeListHead(&DoubleHead);

SingleHead.Next = NULL;

ExInitializeSListHead(&SListHead);

不要忘记为每种链表分配并初始化一个自旋锁。另外，链表头和所有链表元素的存储都必须来自非分页内存，因为支持例程需要在提升的IRQL上访问这些链表。注意，在链表头的初始化过程中不需要使用自旋锁，因为此时不存在竞争。

插入元素

双链表可以在头部或尾部插入元素，但单链表和S链表仅能在头部插入元素：

PLIST_ENTRY pdElement, pdPrevHead, pdPrevTail;
PSINGLE_LIST_ENTRY psElement, psPrevHead;
PKSPIN_LOCK spinlock;

pdPrevHead = ExInterlockedInsertHeadList(&DoubleHead, pdElement, spinlock);
pdPrevTail = ExInterlockedInsertTailList(&DoubleHead, pdElement, spinlock);

psPrevHead = ExInterlockedPushEntryList(&SingleHead, psElement, spinlock);

psPrevHead = ExInterlockedPushEntrySList(&SListHead, psElement, spinlock);

返回值是插入前链表头(或尾)的地址。注意，被插入的链表元素地址是一个链表表项结构的地址，这个地址通常要嵌入到更大的应用结构中，调用CONTAINING_RECORD宏可以获得外围应用结构的地址。

删除元素

你可以从这些链表的头部删除元素：

pdElement = ExInterlockedRemoveHeadList(&DoubleHead, spinlock);

psElement = ExInterlockedPopEntryList(&SingleHead, spinlock);

psElement = ExInterlockedPopEntrySList(&SListHead, spinlock);

如果链表为空则函数的返回值为NULL。你应该先测试返回值是否为NULL，然后再用CONTAINING_RECORD宏取外围应用结构的指针。

IRQL的限制

你只能在低于或等于DISPATCH_LEVEL级上调用S链表函数。只要所有对链表的引用都使用ExInterlockedXxx函数，那么访问双链表和单链表的ExInterlockedXxx函数可以在任何IRQL上调用。这些函数没有IRQL限制的原因是因为它们在执行时都禁止了中断，这就等于把IRQL提升到最高可能的级别。一旦中断被禁止，这些函数就获取你指定的自旋锁。因为此时在同一CPU上没有其它代码能获得控制，并且其它CPU上的代码也不能获取那个自旋锁，所以你的链表是安全的。

注意
DDK文档中关于这条规则的陈述过于严格，它认为所有调用者必须运行在低于或等于你的中断对象DIRQL之下的某个IRQL上。实际上，并不需要所有调用者都在同一IRQL上，同样也不必限制IRQL必须小于或等于DIRQL。

最好在代码的一个部分使用ExInterlockedXxx互锁函数访问单链表或双链表(不包括S链表)，在另一部分使用非互锁函数(InsertHeadList等等)。在使用一个非互锁原语前，应该提前获取调用使用的自旋锁。另外，应该限制低于或等于DISPATCH_LEVEL级的代码访问链表。例如：

// Access list using noninterlocked calls:

VOID Function1()
{
  ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);
  KIRQL oldirql;
  KeAcquireSpinLock(spinlock, &oldirql);
  InsertHeadList(...);
  RemoveTailList(...);
  ...
  KeReleaseSpinLock(spinlock, oldirql);
}

// Access list using interlocked calls:

VOID Function2()
{
  ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);
  ExInterlockedInsertTailList(..., spinlock);
}

第一个函数必须运行在低于或等于DISPATCH_LEVEL上，因为这里需要调用KeAcquireSpinLock函数。第二个函数的IRQL限定原因是这样的：假定Function1在准备访问链表阶段获取了自旋锁，而获取自旋锁时需要把IRQL暂时提升到DISPATCH_LEVEL级，现在再假定在同一CPU上有一个中断发生在更高级的IRQL上，然后Function2获得了控制，而它又调用了一个ExInterlockedXxx函数，而此时内核正要获取同一个自旋锁，因此CPU将死锁。导致这个问题的原因是允许用同一个自旋锁的代码运行在两个不同的IRQL上：Function1在DISPATCH_LEVEL级上，而Function2在HIGH_LEVEL级上。

共享数据的非互锁访问

如果你要提取一个对齐的数据，那么调用任何一个InterlockedXxx函数就可以正确地做到。支持NT的CPU必然保证你能获得一个首尾一致的值，即使互锁操作发生在数据被提取前后的短暂时间内。然而，如果数据没有对齐，当前的互锁访问也会禁止其它的互锁访问，不至于造成并发访问而取到不一致的值。想象一下，如果有一个整数，其大小跨过了物理内存中的缓冲边界，此时，CPU A想提取这个整数，而CPU B在同一时间要在这个值上执行一个互锁加1操作。那么即将发生的一系列事情可能是：(a) CPU A提取了含有该值高位部分的缓冲线，(b) CPU B执行了一个互锁增1操作并向该值高位部分产生了一个进位，(c) CPU A接着提取了包含该值低位部分的缓冲线。确保这个值不跨过一个缓冲界限可以避免这个问题，但最容易的解决办法是确保该值按其数据类型的自然边界对齐，如ULONG类型按4字节对齐。


第五章：I/O请求包

操作系统使用一种称为I/O请求包(IRP)的数据结构与内核模式驱动程序通信。在这一章中，我们将讨论这个重要的数据结构，包括它的创建、发送、处理，以及最后的销毁。在本章的最后，我将讨论相对复杂的IRP取消操作。本章的内容相对深奥一些，因为我事先没有谈到IRP的任何相关概念。因此，你完全可以先跳过这一章直接读后面的章节，然后再回过头来阅读本章。

  • 数据结构
  • IRP处理的“标准模型”
  • 完成I/O请求
  • 向下级传递请求
  • 取消I/O请求
  • 管理自己的IRP
  • 松散的结尾


数据结构

有两个数据结构对I/O请求的处理至关重要：I/O请求包(IRP)本身和IO_STACK_LOCATION结构。下面我将详细描述这两个结构。

IRP结构

图5-1显示了IRP的数据结构，阴影部分代表不透明域。下面是该结构中重要域的简要描述。

MdlAddress(PMDL)域指向一个内存描述符表(MDL)，该表描述了一个与该请求关联的用户模式缓冲区。如果顶级设备对象的Flags域为DO_DIRECT_IO，则I/O管理器为IRP_MJ_READ或IRP_MJ_WRITE请求创建这个MDL。如果一个IRP_MJ_DEVICE_CONTROL请求的控制代码指定METHOD_IN_DIRECT或METHOD_OUT_DIRECT操作方式，则I/O管理器为该请求使用的输出缓冲区创建一个MDL。MDL本身用于描述用户模式虚拟缓冲区，但它同时也含有该缓冲区锁定内存页的物理地址。为了访问用户模式缓冲区，驱动程序必须做一点额外工作。

图5-1. I/O请求包数据结构

Flags(ULONG)域包含一些对驱动程序只读的标志。但这些标志与WDM驱动程序无关。

AssociatedIrp(union)域是一个三指针联合。其中，与WDM驱动程序相关的指针是AssociatedIrp.SystemBuffer。 SystemBuffer指针指向一个数据缓冲区，该缓冲区位于内核模式的非分页内存中。对于IRP_MJ_READ和IRP_MJ_WRITE操作，如果顶级设备指定DO_BUFFERED_IO标志，则I/O管理器就创建这个数据缓冲区。对于IRP_MJ_DEVICE_CONTROL操作，如果I/O控制功能代码指出需要缓冲区(见第九章)，则I/O管理器就创建这个数据缓冲区。I/O管理器把用户模式程序发送给驱动程序的数据复制到这个缓冲区，这也是创建IRP过程的一部分。这些数据可以是与WriteFile调用有关的数据，或者是DeviceIoControl调用中所谓的输入数据。对于读请求，设备驱动程序把读出的数据填到这个缓冲区，然后I/O管理器再把缓冲区的内容复制到用户模式缓冲区。对于指定了METHOD_BUFFERED的I/O控制操作，驱动程序把所谓的输出数据放到这个缓冲区，然后I/O管理器再把数据复制到用户模式的输出缓冲区。

IoStatus(IO_STATUS_BLOCK)是一个仅包含两个域的结构，驱动程序在最终完成请求时设置这个结构。IoStatus.Status域将收到一个NTSTATUS代码，而IoStatus.Information的类型为ULONG_PTR，它将收到一个信息值，该信息值的确切含义要取决于具体的IRP类型和请求完成的状态。Information域的一个公认用法是用于保存数据传输操作，如IRP_MJ_READ，的流量总计。某些PnP请求把这个域作为指向另外一个结构的指针，这个结构通常包含查询请求的结果。

RequestorMode将等于一个枚举常量UserMode或KernelMode，指定原始I/O请求的来源。驱动程序有时需要查看这个值来决定是否要信任某些参数。

PendingReturned(BOOLEAN)如果为TRUE，则表明处理该IRP的最低级派遣例程返回了STATUS_PENDING。完成例程通过参考该域来避免自己与派遣例程间的潜在竞争。

Cancel(BOOLEAN)如果为TRUE，则表明IoCancelIrp已被调用，该函数用于取消这个请求。如果为FALSE，则表明没有调用IoCancelIrp函数。取消IRP是一个相对复杂的主题，我将在本章的最后详细描述它。

CancelIrql(KIRQL)是一个IRQL值，表明那个专用的取消自旋锁是在这个IRQL上获取的。当你在取消例程中释放自旋锁时应参考这个域。

CancelRoutine(PDRIVER_CANCEL)是驱动程序取消例程的地址。你应该使用IoSetCancelRoutine函数设置这个域而不是直接修改该域。

UserBuffer(PVOID) 对于METHOD_NEITHER方式的IRP_MJ_DEVICE_CONTROL请求，该域包含输出缓冲区的用户模式虚拟地址。该域还用于保存读写请求缓冲区的用户模式虚拟地址，但指定了DO_BUFFERED_IO或DO_DIRECT_IO标志的驱动程序，其读写例程通常不需要访问这个域。当处理一个METHOD_NEITHER控制操作时，驱动程序能用这个地址创建自己的MDL。

Tail.Overlay是Tail联合中的一种结构，它含有几个对WDM驱动程序有潜在用途的成员。图5-2是Tail联合的组成图。在这个图中，以水平方向从左到右是这个联合的三个可选成员，在垂直方向是每个结构的成员描述。Tail.Overlay.DeviceQueueEntry(KDEVICE_QUEUE_ENTRY)和Tail.Overlay.DriverContext(PVOID[4])是Tail.Overlayare内一个未命名联合的两个可选成员(只能出现一个)。I/O管理器把DeviceQueueEntry作为设备标准请求队列中的连接域。当IRP还没有进入某个队列时，如果你拥有这个IRP你可以使用这个域，你可以任意使用DriverContext中的四个指针。Tail.Overlay.ListEntry(LIST_ENTRY)仅能作为你自己实现的私有队列的连接域。

图5-2. IRP中Tail联合的组成图

CurrentLocation (CHAR)和Tail.Overlay.CurrentStackLocation(PIO_STACK_LOCATION)没有公开为驱动程序使用，因为你完全可以使用象IoGetCurrentIrpStackLocation这样的函数获取这些信息。但意识到CurrentLocation就是当前I/O堆栈单元的索引以及CurrentStackLocation就是指向它的指针，会对驱动程序调试有一些帮助。

I/O堆栈

任何内核模式程序在创建一个IRP时，同时还创建了一个与之关联的IO_STACK_LOCATION结构数组：数组中的每个堆栈单元都对应一个将处理该IRP的驱动程序，另外还有一个堆栈单元供IRP的创建者使用(见图5-3)。堆栈单元中包含该IRP的类型代码和参数信息以及完成函数的地址。图5-4显示了堆栈单元的结构。

图5-3. 驱动程序和I/O堆栈之间的平行关系

注意
我将在本章稍后处讨论IRP的创建机制。将帮助你了解DEVICE_OBJECT的StackSize域，该域指出IRP应为其目标设备驱动程序保留多少堆栈单元。

图5-4. I/O堆栈单元数据结构

MajorFunction(UCHAR)是该IRP的主功能码。这个代码应该为类似IRP_MJ_READ一样的值，并与驱动程序对象中MajorFunction表的某个派遣函数指针相对应。如果该代码存在于某个特殊驱动程序的I/O堆栈单元中，它有可能一开始是，例如IRP_MJ_READ，而后被驱动程序转换成其它代码，并沿着驱动程序堆栈发送到低层驱动程序。我将在第十一章(USB总线)中举一个这样的例子，USB驱动程序把标准的读或写请求转换成内部控制操作，以便向USB总线驱动程序提交请求。

MinorFunction(UCHAR)是该IRP的副功能码。它进一步指出该IRP属于哪个主功能类。例如，IRP_MJ_PNP请求就有约一打的副功能码，如IRP_MN_START_DEVICE、IRP_MN_REMOVE_DEVICE，等等。

Parameters(union)是几个子结构的联合，每个请求类型都有自己专用的参数，而每个子结构就是一种参数。这些子结构包括Create(IRP_MJ_CREATE请求)、Read(IRP_MJ_READ请求)、StartDevice(IRP_MJ_PNP的IRP_MN_START_DEVICE子类型)，等等。

DeviceObject(PDEVICE_OBJECT)是与该堆栈单元对应的设备对象的地址。该域由IoCallDriver函数负责填写。

FileObject(PFILE_OBJECT)是内核文件对象的地址，IRP的目标就是这个文件对象。驱动程序通常在处理清除请求(IRP_MJ_CLEANUP)时使用FileObject指针，以区分队列中与该文件对象无关的IRP。

CompletionRoutine(PIO_COMPLETION_ROUTINE)是一个I/O完成例程的地址，该地址是由与这个堆栈单元对应的驱动程序的更上一层驱动程序设置的。你绝对不要直接设置这个域，应该调用IoSetCompletionRoutine函数，该函数知道如何参考下一层驱动程序的堆栈单元。设备堆栈的最低一级驱动程序并不需要完成例程，因为它们必须直接完成请求。然而，请求的发起者有时确实需要一个完成例程，但通常没有自己的堆栈单元。这就是为什么每一级驱动程序都使用下一级驱动程序的堆栈单元保存自己完成例程指针的原因。

Context(PVOID)是一个任意的与上下文相关的值，将作为参数传递给完成例程。你绝对不要直接设置该域；它由IoSetCompletionRoutine函数自动设置，其值来自该函数的某个参数。


IRP处理的“标准模型”

粒子物理学里有关于宇宙的“标准模型”，WDM也是这样。图5-5显示了一个典型的IRP在各个处理阶段的所有权流程。并不是每种IRP都经过这些步骤，由于设备类型和IRP种类的不同某些步骤会改变或根本不存在。尽管这个过程可能有各种变化形式，但这个图为我们将要展开的讨论提供了一个很好的起点。

图5-5. IRP处理的“标准模型”

比你想象的更复杂...

当你第一次遇到IRP处理标准模型这个概念时，你也许认为这是个比较复杂的概念。但不幸的是，这个概念还不能满足所有问题，比如热拔插设备、动态资源再分配，和电源管理等等。在后面的章节中，我将描述处理这些额外问题的其它IRP排队和取消方法。标准模型仅作为你阅读时清晰的参考模型！

抛开这些特殊问题，许多设备仍能利用这个标准模型。如果你的设备在系统运行时不能被删除或重配置，并且在低电源状态下拒绝I/O请求，那么你可以使用这个标准模型。

创建IRP

IRP开始于某个实体调用I/O管理器函数创建它。在上图中，我使用术语“I/O管理器”来描述这个实体，尽管系统中确实有一个单独的系统部件用于创建IRP。事实上，更精确地说，应该是某个实体创建了IRP，并不是操作系统的某个例程创建了IRP。例如，你的驱动程序有时会创建IRP，而此时出现在图中第一个方框中的实体就应该是你的驱动程序。

可以使用下面任何一种函数创建IRP：

  • IoBuildAsynchronousFsdRequest 创建异步IRP(不需要等待其完成)。该函数和下一个函数仅适用于创建某些类型的IRP。
  • IoBuildSynchronousFsdRequest 创建同步IRP(需要等待其完成)。
  • IoBuildDeviceIoControlRequest 创建一个同步IRP_MJ_DEVICE_CONTROL或IRP_MJ_INTERNAL_DEVICE_CONTROL请求。
  • IoAllocateIrp 创建上面三个函数不支持的其它种类的IRP。

前两个函数中的Fsd表明这些函数专用于文件系统驱动程序(FSD)。虽然FSD是这两个函数的主要使用者，但其它驱动程序也可以调用这些函数。DDK还公开了一个IoMakeAssociatedIrp函数，该函数用于创建某些IRP的从属IRP。WDM驱动程序不应该使用这个函数。

决定该调用哪一个函数，和决定对IRP执行什么额外的初始化是更复杂的问题，我将在本章的结尾再回到这个问题上。

发往派遣例程

创建完IRP后，你可以调用IoGetNextIrpStackLocation函数获得该IRP第一个堆栈单元的指针。然后初始化这个堆栈单元。在初始化过程的最后，你需要填充MajorFunction代码。堆栈单元初始化完成后，就可以调用IoCallDriver函数把IRP发送到设备驱动程序：

PDEVICE_OBJECT DeviceObject; 		//something gives you this
PIO_STACK_LOCATION stack = IoGetNextIrpStackLocation(Irp);
stack->MajorFunction = IRP_MJ_Xxx;
<other initialization of "stack">
NTSTATUS status = IoCallDriver(DeviceObject, Irp);

IoCallDriver函数的第一个参数是你在某处获得的设备对象的地址。我将在本章的结尾处描述获得设备对象指针的两个常用方法。在这里，我们先假设你已经有了这个指针。

IRP中的第一个堆栈单元指针被初始化成指向该堆栈单元之前的堆栈单元，因为I/O堆栈实际上是IO_STACK_LOCATION结构数组，你可以认为这个指针被初始化为指向一个不存在的“-1”元素，因此当我们要初始化第一个堆栈单元时我们实际需要的是“下一个”堆栈单元。IoCallDriver将沿着这个堆栈指针找到第0个表项，并提取我们放在那里的主功能代码，在上例中为IRP_MJ_Xxx。然后IoCallDriver函数将利用DriverObject指针找到设备对象中的MajorFunction表。IoCallDriver将使用主功能代码索引这个表，最后调用找到的地址(派遣函数)。

你可以把IoCallDriver函数想象为下面代码：

NTSTATUS IoCallDriver(PDEVICE_OBJECT device, PIRP Irp)
{
  IoSetNextIrpStackLocation(Irp);
  PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
  stack->DeviceObject = device;
  ULONG fcn = stack->MajorFunction;
  PDRIVER_OBJECT driver = device->DriverObject;
  return (*driver->MajorFunction[fcn])(device, Irp);
}

派遣例程的职责

IRP派遣例程的原型看起来像下面这样：

NTSTATUS DispatchXxx(PDEVICE_OBJECT device, PIRP Irp)
{
  PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);				<--1
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) device->DeviceExtension;			<--2
  ...
  return STATUS_Xxx;											<--3
}
  1. 你通常需要访问当前堆栈单元以确定参数或副功能码。
  2. 你可能还需要访问你创建的设备扩展。
  3. 你将向IoCallDriver函数返回某个NTSTATUS代码，而IoCallDriver函数将把这个状态码返回给它的调用者。

在本书中，我使用DispatchXxx(如DispatchRead、DispatchPnp，等等)来代表例子驱动程序中的派遣例程。其它人可能会使用另外的约定，但Microsoft推荐用这样的方法，例如，如果你的驱动程序名为RANDOM.SYS，那么你应该命名IRP_MJ_READ派遣函数为RandomDispatchRead。这个方法使驱动程序调试跟踪起来更容易，但它同时也需要你输入更多的文字。由于这些名称在驱动程序的名空间之外是不可见的，所以由你自己决定是使用Microsoft推荐的命名方案，还是使用你认为更有意义的命名方法。

在上面派遣函数原型中省略号的地方，是派遣函数必须做出决定的地方，有三种选择：

  • 派遣函数立即完成该IRP。
  • 把该IRP传递到处于同一堆栈的下层驱动程序。
  • 排队该IRP以便由这个驱动程序中的其它例程来处理。

我将在本章详细讨论这三种选择，但在这里我仅讨论排队的可能性，因为这个过程就是IRP处理标准模型所描述的。你知道，当有大量读写请求进入设备时，通常需要把这些请求放入一个队列中，以便使硬件访问串行化。

每个设备对象都自带一个请求队列对象，下面是使用这个队列的标准方法：

NTSTATUS DispatchXxx(...)
{
  ...
  IoMarkIrpPending(Irp);								<--1
  IoStartPacket(device, Irp, NULL, NULL);						<--2
  return STATUS_PENDING;								<--3
}
  1. 无论何时，当你的派遣例程返回STATUS_PENDING状态代码时，你应该先调用这个IoMarkIrpPending函数，以帮助I/O管理器避免内部竞争。我们必须在放弃IRP所有权之前做这一点。
  2. 如果设备正忙，IoStartPacket就把请求放到队列中。如果设备空闲，IoStartPacket将把设备置成忙并调用StartIo例程。IoStartPacket的第三个参数是用于排序队列的键(ULONG)的地址，例如磁盘驱动程序将在这里指定一个柱面地址以提供顺序搜索的排队。如过你在这里指定一个NULL，则该请求被加到队列的尾部。最后一个参数是取消例程的地址。我将在本章的后面讨论取消例程，这种例程比较复杂。
  3. 返回STATUS_PENDING以通知调用者我们没有完成这个IRP。

注意，一旦我们调用了IoStartPacket函数，就不要再碰IRP。因为在该函数返回之前，IRP可能已经被完成并且其占用的内存可能被释放，而我们拥有的该IRP的指针也许是无效的。

StartIo例程

每处理一个IRP，I/O管理器就调用一次StartIo例程：

VOID StartIo(PDEVICE_OBJECT device, PIRP Irp)
{
  PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) device->DeviceExtension;
  ...
}

StartIo例程在DISPATCH_LEVEL级上获得控制，这意味着该函数不能生成任何页故障。另外，设备对象的CurrentIrp域和Irp参数都指向I/O管理器送来的IRP。

StartIo的工作是就着手处理IRP。如何做要完全取决于你的设备。通常你需要访问硬件寄存器，但可能有其它例程，如你的中断服务例程，或者是驱动程序中的其它例程也需要访问这些寄存器。实际上，有时着手一个新操作的最容易的方式是在设备扩展中保存某些状态信息，然后伪造一个中断。由于这些方法的执行都需要在一个自旋锁的保护之下，而这个自旋锁与保护你的ISR所使用的是同一个自旋锁，所以正确的方法是调用KeSynchronizeExecution函数。例如：

VOID StartIo(...)
{
  ...
  KeSynchronizeExecution(pdx->InterruptObject, TransferFirst, (PVOID) pdx);
}

BOOLEAN TransferFirst(PVOID context)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) context;
  ...
  return TRUE;
}

这里的TransferFirst例程是同步关键段(SynchCritSection)的一个例子，之所以这样做是因为StartIo需要与ISR同步。我将在第七章中详细讨论同步关键段(SynchCritSection)的概念。

一旦StartIo使设备忙于处理新请求，它就立即返回。当设备完成传输并发出中断时你将看到下一个请求。

中断服务例程

当设备完成数据传输后，它将以硬件中断形式发出通知。在第七章中，我将讲述如何用IoConnectInterrupt函数“钩住”一个中断，该函数的一个参数就是ISR的地址。因此当中断发生时，硬件抽象层(HAL)就调用你的ISR。ISR运行在DIRQL上，并由ISR专用的自旋锁保护。ISR的函数原型如下：

BOOLEAN OnInterrupt(PKINTERRUPT InterruptObject, PVOID context)
{
  ...
}

ISR的第一个参数是中断对象的地址，中断对象由IoConnectInterrupt函数创建，但是你不太可能用到这个参数。第二个参数是在调用IoConnectInterrupt时你指定的任意上下文值；它可能是设备对象或设备扩展的地址，完全由你决定。

我将在第七章中详细讨论ISR的职责。为了继续标准模型的讨论，我要告诉你一点，一个ISR最可能做的事就是调度DPC例程(推迟过程调用)。而DPC的目的就是让你做某些事情，如调用IoCompleteRequest，而该调用不可能运行在ISR运行的DIRQL级上。所以，你的ISR中将有下面一行语句(device是指向设备对象的指针)：

IoRequestDpc(device, device->CurrentIrp, NULL);

那么下一次你将在DPC例程中看到这个IRP，这个DPC例程是你在AddDevice函数中用IoInitializeDpcRequest寄存的。DPC例程的传统名称为DpcForIsr，因为它是由ISR请求的。

DPC例程

DpcForIsr例程在DISPATCH_LEVEL级上获得控制。通常，它的工作就是完成IRP(导致最近的中断发生)。但一般情况下，它通过调用IoCompleteRequest函数把剩余的工作交给完成例程来做。

VOID DpcForIsr(PKDPC Dpc, PDEVICE_OBJECT device, PIRP Irp, PDEVICE_EXTENSION pdx)
{
  ...
  IoStartNextPacket(device, FALSE);						<--1
  IoCompleteRequest(Irp, boost);						<--2
}
  1. IoStartNextPacket 取出设备队列中的下一个IRP并发送到StartIo。FALSE参数指出该IRP不能以通常方式取消。
  2. IoCompleteRequest 完成第一个参数指定的IRP。第二参数是等待线程的优先级提高值。注意在调用IoCompleteRequest之前你还要填充IRP中的IoStatus块。

调用IoCompleteRequest例程是处理I/O请求的标准结束方式。在这个调用之后，I/O管理器(或者是任何在开始处创建该IRP的实体)将再次拥有该IRP。最后该IRP被这个实体销毁并解除等侍线程的阻塞状态。

定制队列

有些设备的操作需要多个请求队列。一个常见的例子就是串行口，它可以同时地并且分开地处理输入输出请求流。IoStartPacket和IoStartNextPacket函数(以及其它含有键排序功能的等价函数)都使用设备对象自带的队列。创建与标准队列有相同工作方式的附加队列要相对容易一些。

为了使我们更容易讨论问题，让我们假设你需要一个单独的队列来管理IRP_MJ_SPECIAL(并不存在这个主功能码，使用它是为了使问题更具体一些)请求。你将写两个与StartIo和DpcForIsr例程功能类似的，但专用于处理这些假想IRP的辅助例程：

  • 与StartIo类似的函数 --- 我们称它为StartIoSpecial --- 它启动下一个IRP_MJ_SPECIAL请求。
  • 与DPC类似的函数 --- 我们称它为DpcSpecial --- 它处理IRP_MJ_SPECIAL请求的完成。

你还需要在你的设备扩展中创建一个KDEVICE_QUEUE对象，并在AddDevice例程中初始化这个队列对象：

NTSTATUS AddDevice(...)
{
  ...
  KeInitializeDeviceQueue(&pdx->dqSpecial);
  ...
}

dqSpecial就是KDEVICE_OBJECT对象的名字，用于排队IRP_MJ_SPECIAL请求。设备队列对象是一种三态对象(见图5-6)。这三种状态反映了设备队列例程是如何操作设备队列的：

  • idle状态是指设备不忙于处理任何请求并且队列为空。KeInsertDeviceQueue或KeInsertByKeyDeviceQueue函数把队列标记为busy-empty状态并返回FALSE。你不应该在队列为idle状态时调用KeRemoveDeviceQueue或KeRemoveByKeyDeviceQueue函数。
  • busy-empty状态是指设备忙但队列中没有IRP。KeInsertDeviceQueue和KeInsertByKeyDeviceQueue函数向队列尾加入新IRP，使队列进入busy-not empty状态，并返回TRUE。KeRemoveDeviceQueue或KeRemoveByKeyDeviceQueue函数返回NULL并使队列进入idle状态。
  • busy-not empty状态是指设备忙且队列中至少存有一个IRP。KeInsertDeviceQueue和KeInsertByKeyDeviceQueue函数向队列尾加入新IRP并返回TRUE，但队列状态不变。KeRemoveDeviceQueue或KeRemoveByKeyDeviceQueue函数提取队列的第一个IRP并返回其地址，此时，如果队列为空，这些函数将把队列置成busy-empty状态。

图5-6. KDEVICE_QUEUE队列的三种状态

在下面代码中，我们在派遣例程和DPC例程中使用了这些支持例程和我们专用的设备队列：

NTSTATUS DispatchSpecial(PDEVICE_OBJECT fdo, PIRP Irp)
{
  IoMarkIrpPending(Irp);								<--1
  KIRQL oldirql;
  KeRaiseIrql(DISPATCH_LEVEL, &oldirql);						<--2
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  if (!KeInsertDeviceQueue(&pdx->dqSpecial, &Irp->Tail.Overlay.DeviceQueueEntry))	<--3
    StartIoSpecial(fdo, Irp);
  KeLowerIrql(oldirql);
  return STATUS_PENDING;
}

VOID DpcSpecial(...)
{
  ...
  PKDEVICE_QUEUE_ENTRY qep = KeRemoveDeviceQueue(&pdx->dqSpecial);			<--4
  if (qep)
    StartIoSpecial(fdo, CONTAINING_RECORD(qep, IRP, Tail.Overlay.DeviceQueueEntry));
  ...
}
  1. 作为一个“规矩”的派遣例程，我们把该IRP标记为pending，因为我们要让它进入队列，然后派遣例程返回STATUS_PENDING状态。
  2. KeInsertDeviceQueue函数和我们自己的StartIoSpecial例程希望在DISPATCH_LEVEL级上被调用。所以我们明确地提升了IRQL，之后很快我们又调用KeLowerIrql函数把IRQL降低到原来的级别(可能是PASSIVE_LEVEL)。
  3. KeInsertDeviceQueue函数把IRP加入到专用队列中，如果返回值为TRUE，表明IRP已被加入队列中，所以我们不用再做任何关于IRP的事。如果设备正空闲，那么返回值应该为FALSE并且IRP也用不着排入队列，我们直接调用StartIoSpecial例程。
  4. DPC中的这个KeRemoveDeviceQueue调用将产生两种结果。如果队列当前为空，则返回值为NULL并且我们也不用启动新请求。否则，返回值将为某IRP内嵌连接域的地址。我们可以使用CONTAINING_RECORD宏来获得外围的真正的IRP地址。然后我们把这个地址传递给StartIoSpecial例程。注意，这个DPC例程已经运行在DISPATCH_LEVEL级上，所以我们不需要在删除队列表项或调用StartIo之前调整IRQL。

我以前描述的StartPacket和StartNextPacket函数使用一个名为DeviceQueue的KDEVICE_QUEUE对象。该对象是设备对象中的一个不透明域，其工作原理与管理私有设备队列相同。


完成I/O请求

每个IRP都渴望被完成。在标准模型中，你至少有两种完成IRP的环境。DpcForIsr通常用于完成导致最近中断的IRP。派遣函数也可以在下面这两种情况下完成IRP：

  • 如果请求是错误的(可以以容易的检测方式查明，例如要求打印机倒纸请求或卸载键盘请求)，则派遣例程应以失败方式完成该请求并返回适当的出错代码。
  • 如果请求要求得到的仅是派遣函数可以容易确定的信息(例如一个询问驱动程序版本号的控制请求)，则派遣例程应立即给出回答并完成请求，返回成功状态码。

完成机制

完成一个IRP必须先填充IoStatus块的Status和Information成员，然后调用IoCompleteRequest例程。Status值就是NTSTATUS.H中定义的状态代码。表5-1简要地列出了常用的状态代码。而Information值要取决于你完成的是何种类型的IRP以及是成功还是失败。通常情况下，如果IRP完成失败(即，完成的结果是某种错误状态)，你应把Information域置0。如果你成功地完成了一个数据传输IRP，通常应该把Information域设置成传输的字节量。

表5-1. 一些常用的NTSTATUS代码

状态代码 | 描述
STATUS_SUCCESS | 正常完成
STATUS_UNSUCCESSFUL | 请求失败，没有描述失败原因的代码
STATUS_NOT_IMPLEMENTED | 一个没有实现的功能
STATUS_INVALID_HANDLE | 提供给该操作的句柄无效
STATUS_INVALID_PARAMETER | 参数错误
STATUS_INVALID_DEVICE_REQUEST | 该请求对这个设备无效
STATUS_END_OF_FILE | 到达文件尾
STATUS_DELETE_PENDING | 设备正处于被从系统中删除过程中
STATUS_INSUFFICIENT_RESOURCES | 没有足够的系统资源(通常是内存)来执行该操作
注意
在设置IRP的IoStatus.Information域时，我们应该首先参考DDK文档中的正确设置。例如，在某些IRP_MJ_PNP中，这个域用于指向一个数据结构，而该数据结构由PnP管理器负责释放。如果你在处理这些IRP_MJ_PNP请求失败后把Information域置成0，将造成资源丢失。

通常你常做的工作就是完成某个请求，所以我建议你编制一个辅助函数：

NTSTATUS CompleteRequest(PIRP Irp, NTSTATUS status, ULONG_PTR Information)
{
  Irp->IoStatus.Status = status;
  Irp->IoStatus.Information = Information;
  IoCompleteRequest(Irp, IO_NO_INCREMENT);
  return status;
}

该函数将返回其第二个参数给出的状态值。该函数适用于需要完成一个请求并立即返回状态码的场合。例如：

NTSTATUS DispatchControl(PDEVICE_OBJECT device, PIRP Irp)
{
  PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
  ULONG code = stack->Parameters.DeviceIoControl.IoControlCode;
  if (code == IOCTL_TOASTER_BOGUS)
    return CompleteRequest(Irp, STATUS_INVALID_DEVICE_REQUEST, 0);
  ...
}

你也许注意到了，CompleteRequest函数的Information参数类型为ULONG_PTR。即该参数既可以是一个ULONG也可以是一个指针。

当你调用IoCompleteRequest时，应该为等待线程提供一个优先级推进值，该值将用于提高等待该请求完成的线程的优先级。一般说来，你需要根据设备类型来选择这个推进值，表5-2列出了一些设备的建议值。优先级的调整提高了那些需要频繁等待I/O操作完成的线程的吞吐量。对于那些直接响应用户的事件，如键盘或鼠标操作，应该有一个比较大的优先级推进，以提高交互任务的表现。因此，你要仔细地选择推进值。不要绝对地在声卡驱动程序完成的每个操作后都使用IO_SOUND_INCREMENT值。例如，没有必要在获取驱动程序版本控制请求后提高线程的优先级。

表5-2. IoCompleteRequest的优先级推进值

推进值常量 | 优先级推进值
IO_NO_INCREMENT | 0
IO_CD_ROM_INCREMENT | 1
IO_DISK_INCREMENT | 1
IO_KEYBOARD_INCREMENT | 6
IO_MAILSLOT_INCREMENT | 2
IO_MOUSE_INCREMENT | 6
IO_NAMED_PIPE_INCREMENT | 2
IO_NETWORK_INCREMENT | 2
IO_PARALLEL_INCREMENT | 1
IO_SERIAL_INCREMENT | 2
IO_SOUND_INCREMENT | 8
IO_VIDEO_INCREMENT | 1

顺便说一下，不要以专用状态代码STATUS_PENDING来完成一个IRP。派遣例程经常要使用STATUS_PENDING代码作为返回值，但你决不能在IoStatus.Status中设置这个值。所以，在checked版本的IoCompleteRequest函数中有一个ASSERT语句用于检查该函数的最终返回值是否为STATUS_PENDING。另一个常犯的错误是在返回值中使用“-1”，该值作为NTSTATUS代码没有任何意义，所以IoCompleteRequest函数中也有检查这种错误的ASSERT语句。

使用完成例程

通常，你需要知道发往低级驱动程序的I/O请求的结果。为了了解请求的结果，你需要安装一个完成例程，调用IoSetCompletionRoutine函数：

IoSetCompletionRoutine(Irp,
               CompletionRoutine,
               context,
               InvokeOnSuccess,
               InvokeOnError,
               InvokeOnCancel);

Irp就是你要了解其完成的请求。CompletionRoutine是被调用的完成例程的地址，context是任何一个指针长度的值，将作为完成例程的参数。InvokeOnXxx参数是布尔值，它们指出在三种不同的环境中是否需要调用完成例程：

  • InvokeOnSuccess 你希望完成例程在IRP以成功状态(返回的状态代码通过了NT_SUCCESS测试)完成时被调用。
  • InvokeOnError 你希望完成例程在IRP以失败状态(返回的状态代码未通过了NT_SUCCESS测试)完成时被调用。
  • InvokeOnCancel 如果驱动程序在完成IRP前调用了IoCancelIrp例程，你希望在此时调用完成例程。IoCancelIrp将在IRP中设置取消标志，该标志也是调用完成例程的条件。一个被取消的IRP最终将以STATUS_CANCELLED(该状态代码不能通过NT_SUCCESS测试)或任何其它状态完成。如果IRP以失败方式完成，并且你也指定了InvokeOnError参数，那么是InvokeOnError本身导致了完成例程的调用。相反，如果IRP以成功方式完成，并且你也指定了InvokeOnSuccess参数，那么是InvokeOnSuccess本身导致了完成例程的调用。在这两种情况中，InvokeOnCancel参数将是多余的。如果你省去InvokeOnSuccess和InvokeOnError中的任何一个参数或两个都省去，并且IRP也被设置了取消标志，那么InvokeOnCancel参数将导致完成例程的调用。

这三个标志中至少有一个设置为TRUE。注意，IoSetCompletionRoutine是一个宏，所以你应避免使用有副作用的参数。这三个标志参数和一个函数指针参数在宏中被引用了两次。

IoSetCompletionRoutine将把完成例程地址和上下文参数安装到下一个IO_STACK_LOCATION中，即下一层驱动程序将在那个堆栈单元中找到这些参数。因此，最底层的驱动程序不应该安装一个完成例程。

一个完成例程看起来应该像这样：

NTSTATUS CompletionRoutine(PDEVICE_OBJECT device, PIRP Irp, PVOID context)
{
  if (Irp->PendingReturned)
    IoMarkIrpPending(Irp);
  ...
  return <some status code>;
}

该函数将收到一个设备对象指针和一个IRP指针，还收到一个任意上下文值，该值在IoSetCompletionRoutine调用中指出。完成例程通常在DISPATCH_LEVEL级和任意线程上下文中被调用，但有时也在PASSIVE_LEVEL或APC_LEVEL级被调用。为了适应大多数情况(DISPATCH_LEVEL)，完成例程应存在于非分页内存中，并且仅使用可在DISPATCH_LEVEL级上调用的服务例程。然而，为了适应在低级IRQL上调用该例程的可能情况，完成例程不应调用像KeAcquireSpinLockAtDpcLevel这样的函数，因为这些函数假定开始执行于DISPATCH_LEVEL级上。

注意
完成例程使用的设备对象指针参数就是I/O堆栈单元中DeviceObject域中的指针。通常IoCallDriver设置该值。有时，在创建IRP时还同时创建一个额外的堆栈单元，以便能向完成例程传递参数而不用创建一个额外的上下文结构。如果此时创建者不去设置这个额外的DeviceObject域，那么完成例程得到的设备对象指针将为NULL。

完成例程如何获得调用

IoCompleteRequest函数负责调用每个驱动程序安装在各自堆栈单元中的完成例程。这个调用过程见流程图5-7，开始，底层驱动程序的某段代码调用IoCompleteRequest例程以通知IRP处理结束。然后，IoCompleteRequest参考当前的堆栈单元以查明其上层驱动程序是否安装了完成例程。如果没有，它就把堆栈指针前进到上一层堆栈单元并重复测试，直到找到某个完成例程或者到达堆栈顶部。最后IoCompleteRequest函数执行其它操作(如释放IRP占用的内存)。

当IoCompleteRequest函数发现含有完成例程指针的堆栈单元时，它就调用这个完成例程并检查其返回代码。如果返回代码是除了STATUS_MORE_PROCESSING_REQUIRED以外的其它值，它就把堆栈指针移动到上一层并重复前面的工作。如果返回代码是STATUS_MORE_PROCESSING_REQUIRED，IoCompleteRequest将停止前进并返回到调用者，而此时的IRP将处于一个中间状态。因此，如果完成例程在堆栈单元回卷过程中停止，那么其驱动程序有责任处理这个处于中间状态的IRP。

在完成例程内部，一个IoGetCurrentIrpStackLocation调用将获得上一层堆栈单元的指针。上层堆栈单元的完成例程不应该依赖任何下层堆栈单元中的内容。为了加强这个规则，IoCompleteRequest在调用完成例程前清除了下一个堆栈单元中的大部分内容。

图5-7. IoCompleteRequest函数的执行过程

完成例程为什么要调用IoMarkIrpPending

你可能已经注意到了上面完成例程框架代码的前两行：

if (Irp->PendingReturned)
  IoMarkIrpPending(Irp);

所有不返回STATUS_MORE_PROCESSING_REQUIRED状态的完成例程都需要这两行代码。如果你想知道为什么，读下面这些段。然而，你应该明白编写驱动程序不应该依靠有关I/O管理器是如何处理未决IRP的信息，这个处理过程在未来的Windows版本中可能会改变。

何时调用IoMarkIrpPending

上文中陈述的规则“如果Irp->PendingReturned为TRUE，那么任何不返回STATUS_MORE_PROCESSING_REQUIRED的完成例程都应该调用IoMarkIrpPending”，这几乎完全是对的，但仍有例外。如果驱动程序分配了IRP，安装了完成例程，然后在未改变堆栈指针的情况下调用IoCallDriver，那么完成例程就不应该包含这两行代码，因为没有堆栈单元与你的驱动程序关联。(这种情况与完成例程的设备对象参数为NULL的情形类似。驱动程序通常做的是分配一个带有额外堆栈单元的IRP，在第一个单元中设置DeviceObject指针，在调用IoSetCompletionRoutine和IoCallDriver前用IoSetNextIrpStackLocation函数跳过那个额外堆栈单元。如果你这样做，那么在完成例程中调用IoMarkIrpPending将不会出现问题，并且完成例程也能得到了一个有效的设备对象)

注意
下面解释是复杂的！

为了使系统吞吐量最大化，I/O管理器希望驱动程序推迟其耗时IRP的完成。驱动程序通过调用IoMarkIrpPending函数并在派遣例程中返回STATUS_PENDING来表示完成操作被推迟。I/O管理器的原始调用者通常希望在继续执行之前等待操作完成，所以I/O管理器在处理推迟完成时有下面类似的逻辑(不代表真正的Microsoft源代码)：

Irp->UserEvent = pEvent; 			//  don't do this yourself
status = IoCallDriver(...);
if (status == STATUS_PENDING)
  KeWaitForSingleObject(pEvent, ...);

换句话说，如果IoCallDriver返回STATUS_PENDING，则该段代码将在一个内核事件上等待。IoCompleteRequest有责任在IRP最后完被成时设置这个事件。该事件(UserEvent)的地址在IRP的一个不透明域中，所以IoCompleteRequest能够找到它。但实际的内容比这要多。

为了使问题更简单，假设请求仅涉及一个驱动程序。该驱动程序的派遣函数仅做两件事情：调用IoMarkIrpPending，返回STATUS_PENDING，而STATUS_PENDING实际上就是IoCallDriver返回的状态代码，此外，某段代码将要在一个事件上等待。IoCompleteRequest调用发生在任意线程上下文中，因此该函数将调度一个特殊的内核APC，这个APC执行在原始线程(现在正被阻塞)的上下文中。APC例程将设置那个事件，并释放任何正等待操作完成的对象。有一些原因我们现在不需要深入，例如为什么用APC来做这个工作而不是用一个简单的KeSetEvent调用。

但是，排队一个APC是相对昂贵的。设想一下，不是直接返回STATUS_PENDING，而是派遣例程自己调用IoCompleteRequest并返回某个其它状态。在这种情况下，IoCompleteRequest的调用者将与IoCallDriver的调用者处于同一个线程上下文中。因此就没有必要排队一个APC。另外，甚至没有必要调用KeSetEvent，因为如果I/O管理器没有得到派遣例程返回的STATUS_PENDING，它就不用等待某个事件。如果IoCompleteRequest恰好知道发生的这种情况，它将优化这个处理以避免调用APC，能这样做吗？这就是IoMarkIrpPending的来处。

IoMarkIrpPending是什么，它是WDM.H中的一个宏，这你可以自己去看，它在当前的堆栈单元中设置了一个名为SL_PENDING_RETURNED的标志。IoCompleteRequest将把IRP的PendingReturned标志设置为它在顶级堆栈单元中找到的任何值。然后，它查看这个标志以确定派遣例程是否已返回或将返回STATUS_PENDING。如果你做的正确，那么派遣例程在IoCompleteRequest做这个检查之前返回或在之后返回都无关紧要。在这种情况下的“正确做法”就是指你在做任何使IRP完成的操作之前都调用IoMarkIrpPending。

所以，无论如何，IoCompleteRequest都将查看PendingReturned标志。如果该标志设置，并且如果IRP是那种可以以异步方式完成的IRP，那么IoCompleteRequest将简单地返回其调用者并不排队APC。它假定自己运行在IRP发起者的线程上下文中，并且派遣例程很快会返回一个非未决状态的代码给请求发起者。请求发起者也不用等待那个事件，因为没有代码使那个事件进入信号态。到目前为止一切顺利。

现在，让我们把其它驱动程序加入到假想图中。顶级驱动程序不了解下面发生了什么，它只简单地把请求传递到下面，就象下面代码：

IoCopyCurrentIrpStackLocationToNext(Irp);
IoSetCompletionRoutine(Irp, ...);
return IoCallDriver(...);

换句话说，顶级驱动程序安装了一个完成例程并调用IoCallDriver，然后返回从IoCallDriver得到的任何值。这个过程被重复几次，经过中间的驱动程序，当IRP到达能处理它的那个驱动程序级时，派遣例程就调用IoMarkIrpPending并返回STATUS_PENDING。然后该STATUS_PENDING值按原路返回到顶级驱动程序，最后回到IRP的发起者。而发起者将立即在那个事件上等待，直到某个代码使那个事件变为信号态。

但要注意，调用IoMarkIrpPending的驱动程序仅在它自己的堆栈单元中设置了SL_PENDING_RETURNED标志。上面的驱动程序实际上仅返回STATUS_PENDING状态代码，它们没有调用IoMarkIrpPending，因为它们不知道底层驱动程序到底发生了什么。这就是完成例程中那两行代码的来处。当IoCompleteRequest沿着I/O堆栈向上走时，它在每一层都停下来并把每层中的SL_PENDING_RETURNED标志设置为PendingReturned标志。如果某一层没有完成例程它就前进到上一层。这样，SL_PENDING_RETURNED标志就被自下向上传播到堆栈的顶层，并且如果任何驱动程序曾调用过IoMarkIrpPending，则IRP的PendingReturned标志最终为TRUE。

然而，IoCompleteRequest不能自动传播SL_PENDING_RETURNED。完成例程必须自己测试IRP的PendingReturned标志并调用IoMarkIrpPending来作到这个。如果每个完成例程都做了这个工作，那么SL_PENDING_RETURNED将顺利地从下而上传播到顶层驱动程序，就象IoCompleteRequest自己做了所有工作。

现在，我已经解释完这些复杂的细节，如果派遣例程要明确返回STATUS_PENDING，那么返回前它必须调用IoMarkIrpPending，并且在某些情况下，完成例程也应该这样做。如果完成例程打破了这个链条，那么线程将在事件上空等待，而且这个事件注定永远也不会被置成信号态。如果没有发现PendingReturned标志，那么IoCompleteRequest在处理完成过程时就象在同一个上下文中，因此它也不排队使事件改变状态的APC。这与派遣例程忽略了IoMarkIrpPending调用而直接返回STATUS_PENDING的结果一样。

另一方面，调用IoMarkIrpPending然后同步完成IRP是正确的，尽管效率会低一点。这样做的结果是IoCompleteRequest将排队APC，这个APC将改变事件的状态，但没有任何线程在这个事件上等待(其目的是使在调用KeSetEvent前保证这个事件存在)。这会降低一些效率，但不会有什么害处。

另外，不要在完成例程中尝试避开IoMarkIrpPending调用，就象下面代码：

status = IoCallDriver(...);
if (status == STATUS_PENDING)
  IoMarkIrpPending(...);     //  DON'T DO THIS!

原因是如果你调用了IoCallDriver并给出了IRP指针，那么该函数返回后这个IRP指针可能是无效的。能完成IRP的接收者可能会调用IoFreeIrp，而该函数将使IRP指针无效。


向下级传递请求

WDM使用分层设备对象结构的目的就是使IRP能方便地从一层驱动程序传递到下一层驱动程序。回到第二章“WDM驱动程序的基本结构”，我讲述了AddDevice例程如何创建设备对象堆栈，其中的一条语句如下：

pdx->LowerDeviceObject = IoAttachDeviceToDeviceStack(fdo, pdo);

fdo是设备对象地址，pdo是处于设备堆栈底部的物理设备对象地址。IoAttachDeviceToDeviceStack函数返给你下一层设备对象的地址。当你决定把从上层收到的IRP发送到自己的下层时，那么这个设备对象就是调用IoCallDriver时所指定的设备对象。

向下传递IRP时，你有责任初始化一个IO_STACK_LOCATION结构，下一层驱动程序将使用该结构获取它的参数。一种方法是执行物理拷贝，象这样：

...
IoCopyCurrentIrpStackLocationToNext(Irp);
status = IoCallDriver(pdx->LowerDeviceObject, Irp);
...

IoCopyCurrentIrpStackLocationToNext是WDM.H中的一个宏，它把当前堆栈单元的所有域，除了一个属于I/O完成例程的域，都复制到下一个堆栈单元。在以前版本的Windows NT中，内核模式驱动程序开发者有时会复制整个堆栈单元，这会导致调用者的完成例程被调用两次。(回想一下，你的完成例程指针进入了下一层驱动程序的堆栈单元) 如果你想深入了解这个问题，参见《The NT Insider (May 1997, vol. 4, no. 3)》中的“Secrets of the Universe Revealed! How NT Handles I/O Completion”。IoCopyCurrentIrpStackLocationToNext是WDM新引入的宏，利用它你可以避免这个问题。

如果你的驱动程序不用关心IRP传递到下层驱动程序之后的事情，你可以利用一个捷径来避免复制堆栈单元。在这种情形下，我们不需要安装完成例程——我刚说过驱动程序不关心该IRP以后的情况。图5-8显示了在这种情况中先后出现的事件。

图5-8. 向下传递IRP并忽略其结束状态

没有必要花费处理器时间去把你的堆栈单元内容复制到下一个堆栈单元，因为那个堆栈单元已经含有下一层驱动程序要得到的参数，以及自己上一层驱动程序可能给出的任何完成例程指针。因此，你可以使用下面捷径方法：

NTSTATUS ForwardAndForget(PDEVICE_OBJECT fdo, PIRP Irp)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  IoSkipCurrentIrpStackLocation(Irp);
  return IoCallDriver(pdx->LowerDeviceObject, Irp);
}

这个捷径存在于IoSkipCurrentIrpStackLocation函数(它实际上是一个宏，名字有些另人误解)中。这个宏的作用就是使堆栈指针少前进一步，而IoCallDriver函数会使堆栈指针向前一步，中和的结果就是堆栈指针不变。当下一个驱动程序的派遣例程调用IoGetCurrentIrpStackLocation时，它将收到与我们正使用的完全相同的IO_STACK_LOCATION指针，因此，它所处理的将是同一个请求(相同的主副功能代码)以及相同的参数。

你也许会注意到，IO_STACK_LOCATION数组的最后一项在这里没有使用。实际上，如果我们下层的驱动程序也使用这个办法，也许会有更多的堆栈单元不被使用。虽然这不会产生任何问题，但确实有一些不必要的堆栈单元被分配了。也许完成处理中的堆栈单元回卷操作会更快一些。在回卷堆栈单元时，IoCompleteRequest不使用任何绝对索引或指针。当它获得控制时，它仅从当前堆栈单元开始然后向上调用各个完成例程。所有被安装的完成例程都得到了调用。

流程图可以帮助你了解IoSkipCurrentIrpStackLocation函数的工作过程。图5-9显示了这样一种情形：某设备堆栈有三个驱动程序，你的驱动程序(功能设备对象[FDO])和其它两个驱动程序(一个上层过滤器设备对象[FiDO]，一个PDO)。在图(a)中，你将看到执行复制堆栈单元的IoCopyCurrentIrpStackLocationToNext函数，堆栈单元、各个参数，和完成例程之间的关系。在图(b)中，你将看到还是这样的关系，但使用的是IoSkipCurrentIrpStackLocation函数，第三个和最后一个堆栈单元被跳过，但没有人会对这个事实感到迷惑。

图5-9. 复制I/O堆栈单元与跳过I/O堆栈单元


取消I/O请求

就象人们在现实生活中碰到的问题一样，程序有时会取消它们原来请求的IRP。我并不是在讲某些简单的变化。应用程序可能发出某些需要长时间才能完成的请求，然后这个应用程序结束执行，而这个IRP仍然是未完成的。这种情况在WDM模型中尤为常见，例如当新硬件插入系统时，驱动程序必须停止执行以等待配置管理器重新分配硬件资源，设备电源关闭时也是这样。

为了在内核模式中取消一个请求，IRP的创建者需调用IoCancelIrp函数。如果某线程终止时，它发出的请求仍然未完成，则操作系统自动为每个IRP调用IoCancelIrp。用户模式应用程序调用CancelIo函数可以取消给定线程发出的所有未完成的异步操作。IoCancelIrp仅仅是简单地设置IRP的Cancel标志位然后调用IRP的取消例程。即：它并不知道你在是否修改过IRP指针，也不知道你是否正在处理这个IRP，所以它必须依靠一个你提供的取消例程来做大部分IRP取消工作。

IoCancelIrp调用的作用更多的是一个建议而不是命令。如果每个确实要取消的IRP都能以STATUS_CANCELLED状态被完成，则很好。但是如果IRP可以很快被完成，那么驱动程序完全可以直接完成该IRP。你应该提供一种方法来放弃那些长时间在派遣例程和StartIo例程的队列中等待的IRP。多长时间算长由你自己判断；我的建议是在取消例程这边尝试，因为这比较容易去做并可以使你的驱动程序更匹配操作系统。

注意
关于如何在驱动程序中加入取消逻辑的解释非常复杂。你应该跳过细节论述而直接阅读代码例子，不用过多理会它们是如何工作的。

要是没有多任务就...

与取消IRP相关的是一个复杂的同步问题。在我描述和解决这个问题之前，我想描述一下在没有多任务和多处理器情况下的取消操作。在那个理想环境中，仅有部分I/O管理器，你的StartIo例程，和一个你提供的取消例程，如下：

  • 当你调用IoStartPacket时，你指定了保存在IRP中的取消例程的地址。当你调用IoStartNextPacket(在你的DPC例程中)时，你为一个布尔参数指定了TRUE值，指定这个值表示你要使用标准取消机制。在IoStartPacket或IoStartNextPacket调用你的StartIo例程之前，它把设备对象中的CurrentIrp域设置为指向你要发送的IRP。如果队列中没有请求，则IoStartNextPacket把CurrentIrp域设置为NULL。
  • 你的StartIo例程要做的第一件事就是把IRP中的取消例程指针设置为NULL。
  • IoCancelIrp无条件地设置IRP中的Cancel标志。然后它查看该IRP是否指定了一个取消例程。在你调用IoStartPacket函数和你的StartIo例程获得控制之间，IRP中的取消例程指针将为非空值。在这种情况下，IoCancelIrp调用了你的取消例程。最后，你从队列中取出该IRP，并以STATUS_CANCELLED状态完成了该IRP。然而，在StartIo开始处理该IRP之后，取消例程指针将为NULL，并且 IoCancelIrp将不做任何事。

同步化取消操作

不幸的是，我们写的代码要求运行在多处理器和多任务环境下，所以有时有些事情会提前出现。在我刚描述的逻辑中将会出现至少三种竞争情况。图5-10显示了这些竞争情况，下面我将阐述这些竞争情况：

  • 假设IoCancelIrp刚设置了Cancel标志，而此时(在另一个CPU上运行的)IoStartNextPacket把这个IRP从队列中提出并发送到StartIo例程。 因为IoCancelIrp将很快把同一个IRP送到你的取消例程，所以StartIo例程不能用这个IRP做任何事。
  • 这两个参与者(你的取消例程和IoStartNextPacket函数)或多或少可能会同时尝试从请求队列中删除同一个IRP。这明显是不行的。
  • StartIo有可能先通过了Cancel标志测试阶段，而该标志你现在正要设置，由于第一种竞争，IoCancelIrp还可能在StartIo能废除取消例程指针之前偷偷测试那个指针。现在，你已经有一个完成这个请求的完成例程，但同时，另一个例程(可能是你的DPC例程)也要去完成该请求。天哪！

图5-10. IRP取消中的竞争

防止这些竞争的标准方法是使用一个在系统范围内有效的自旋锁，称为取消自旋锁(cancel spin lock)。要取消某IRP的线程在IoCancelIrp函数中请求取消自旋锁，在驱动程序的取消例程中释放取消自旋锁。启动某IRP的线程需要获取和释放取消自旋锁两次：一次在调用StartIo前，一次在StartIo函数中。实际的代码应该象下面这样：

VOID StartIo(PDEVICE_OBJECT fdo, PIRP Irp)
{
  KIRQL oldirql;
  IoAcquireCancelSpinLock(&oldirql);
  if (Irp != fdo->CurrentIrp || Irp->Cancel)
  {
    IoReleaseCancelSpinLock(oldirql);
    return;
  }
  else
  {
    IoSetCancelRoutine(Irp, NULL);
    IoReleaseCancelSpinLock(oldirql);
  }
  ...
}

VOID OnCancel(PDEVICE_OBJECT fdo, PIRP Irp)
{
  if (fdo->CurrentIrp == Irp)
  {
    KIRQL oldirql = Irp->CancelIrql;
    IoReleaseCancelSpinLock(DISPATCH_LEVEL);
    IoStartNextPacket(fdo, TRUE);
    KeLowerIrql(oldirql);
  }
  else
  {
    KeRemoveEntryDeviceQueue(&fdo->DeviceQueue, &Irp->Tail.Overlay.DeviceQueueEntry);
    IoReleaseCancelSpinLock(Irp->CancelIrql);
  }
  CompleteRequest(Irp, STATUS_CANCELLED, 0);
}
避免使用全局取消自旋锁

Microsoft认为全局取消自旋锁是多处理器系统中的一个瓶颈，驱动程序处理每个IRP时都需要多次获取和释放这个自旋锁，而CPU在等待自旋锁时什么也不能干。现在，Windows 2000中的IoSetCancelRoutine函数以原子交换操作实现，并且IoCancelIrp函数的操作也遵循一个精确的顺序，因此我们完全可以避免使用全局取消自旋锁。Ervin Peretz的文章"The Windows Driver Model Simplifies Management of Device Driver I/O Requests" (Microsoft Systems Journal, January 1999)，讲述了如何实现不用取消自旋锁的取消操作。

虽然依靠全局取消自旋锁是一个坏的方法，但有时你不能避免使用它。比如，用标准模型处理IRP。

在内部，调用你的代码的系统例程象下面代码(注意：这不是真正的Windows 2000源代码)：

VOID IoStartPacket(PDEVICE_OBJECT device, PIRP Irp, PULONG key, PDRIVER_CANCEL cancel)
{
  KIRQL oldirql;
  IoAcquireCancelSpinLock(&oldirql);
  IoSetCancelRoutine(Irp, cancel);
  device->CurrentIrp = Irp;
  IoReleaseCancelSpinLock(oldirql);
  device->DriverObject->DriverStartIo(device, Irp);
}

VOID IoStartNextPacket(PDEVICE_OBJECT device, BOOLEAN cancancel)
{
  KIRQL oldirql;
  if (cancancel)
    IoAcquireCancelSpinLock(&oldirql);
  PKDEVICE_QUEUE_ENTRY p = KeRemoveDeviceQueue(&device->DeviceQueue));
  PIRP Irp = CONTAINING_RECORD(p, IRP, Tail.Overlay.DeviceQueueEntry);
  device->CurrentIrp = Irp;
  if (cancancel)
    IoReleaseCancelSpinLock(oldirql);
  device->DriverObject->DriverStartIo(device, Irp);
}

BOOLEAN IoCancelIrp(PIRP Irp)
{
  IoAcquireCancelSpinLock(&Irp->CancelIrql);
  Irp->Cancel = TRUE;
  PDRIVER_CANCEL cancel = IoSetCancelRoutine(Irp, NULL);
  if (cancel)
  {
    (*cancel)(device, Irp);
    return TRUE;
  }
  IoReleaseCancelSpinLock(&Irp->CancelIrql);
  return FALSE;
}

很明显，上面只是实际例程的框架代码，真正的系统例程不能这么简单。例如，IoStartNextPacket将测试KeRemoveDeviceQueue的返回值。另外，我省略了IoStartNextPacketByKey例程，它是IoStartNextPacket例程的姊妹函数，基于一个排序键来选择请求。

为了说明这个代码的工作过程，我们需要考虑三种情况。图5-11可以帮助你理解下面的讨论。我们假设上面代码运行在多处理器平台上，CPU A中的代码要取消一个IRP，而CPU B中的代码却要启动这个IRP。因为同时仅有两个活动与这个IRP相关，所以我们不用考虑系统中有超过两个CPU的情况。

Figure 5-11. 利用取消自旋锁保护取消操作

情况1：CPU A先获得自旋锁

假定CPU A先通过了标记1并获取了自旋锁。它首先设置该IRP的Cancel标志，然后测试IRP是否有一个取消例程(测试IRP的CancelRoutine)。回答应该是Yes，因为使CancelRoutine指针无效的代码还没有运行，正在标记2处等待着自旋锁。接着CPU A调用取消例程，出队该IRP，最后释放自旋锁。此时，在标记2处等待的CPU B获取了自旋锁，它先从队列中取出一个IRP，这个IRP与CPU A处理的IRP不是同一个，它只是队列中的下一个IRP。因此，CPU A将以STATUS_CANCELLED状态完成该IRP，而CPU B将前进并初始化下一个排队的请求。

情况2：就在CPU A刚要获取自旋锁前CPU B获得了自旋锁

现在假定在CPU A刚要获取自旋锁之前，CPU B在标记2处获取了自旋锁。CPU B首先从队列中提取一个IRP并设置设备对象的CurrentIrp指向该IRP。然后它释放自旋锁并调用StartIo。这时，CPU A在标记1处抓住了自旋锁，因此，CPU B将不能前进到标记3处。CPU A设置Cancel标志并调用取消例程。取消例程发现该IRP是当前IRP，所以它释放了自旋锁。这样，CPU B就可以自由通过StartIo中的标记3处。它将发现该IRP的Cancel标志已被设置，所以它释放了自旋锁并返回。在这一时刻，设备是空闲的。CPU A继续执行取消例程，它将调用IoStartNextPacket例程，然后完成取消请求。

拥有取消自旋锁时不调用IoStartNextPacket例程这一点非常重要，因为该例程也要获取这个锁。如果我们在拥有取消自旋锁时调用了这个函数，则我们所在的CPU将死锁，自旋锁不能被递归获取。

StartIo中的代码还避免了另一个竞争情况。你可能想知道为什么StartIo在测试Cancel标志前测试了CurrentIrp域。假设CPU A在CPU B快要到达标记3前马上就要完成该IRP。此时CPU A上的某些代码可能调用IoFreeIrp释放该IRP的存储。所以CPU B使用的Irp指针可能是无效的，引用这样的指针将是不安全的。

再看一眼前面的IoStartNextPacket代码，它在取消自旋锁的保护下改变了设备对象的CurrentIrp指针。取消例程在完成IRP之前调用了IoStartNextPacket例程。因此，必然会发生下面两种情况：一种情况是CPU B的StartIo在CPU A的IoStartNextPacket之前获得自旋锁，这时的IRP指针是安全的并且Cancel标志将是设置的，另一种情况是CPU B的StartIo在CPU A的IoStartNextPacket之后获得自旋锁，而这时的Irp变量将不等于CurrentIrp，IoStartNextPacket改变了它，所以CPU B不能引用这个指针。

从前面两段论述中我们可以得出这样的推论，如果你不想在取消例程中调用IoStartNextPacket(或者IoStartNextPacketByKey)例程，你必须确保在拥有取消自旋锁时设置CurrentIrp为NULL。

情况3：CPU B获得自旋锁两次

第三个情况是，在StartIo中，CPU B在标记3处获得了自旋锁，而CPU A停在标记1处。StartIo例程在释放自旋锁前先把CancelRoutine指针置空，CPU A获得了自旋锁后立即设置了IRP中的Cancel标志，但它将不能调用取消例程，因为那个指针现在为空。与此同时，CPU B将处理该IRP直至完成，尽管这时IRP的Cancel标志已被设置，但如果IRP可以很快完成，这样做是可以的。

清除相关的IRP

与取消IRP紧密关联的主题是带有IRP_MJ_CLEANUP主功能码的I/O请求。为了解释这个请求的处理，你需要一些背景资料。

当应用程序或其它驱动程序要访问你的设备时，它们首先打开该设备的一个句柄。应用程序可以调用CreateFile函数做到这一点；驱动程序则调用ZwCreateFile函数。在内部，这些函数先创建一个内核文件对象，然后把这个文件对象附加到IRP_MJ_CREATE请求中，最后把IRP_MJ_CREATE请求发往你的驱动程序。当这个句柄完成使命后，应用程序或其它驱动程序就调用CloseHandle或ZwClose函数关闭该句柄。在内部，这些函数向你的驱动程序发送IRP_MJ_CLOSE请求。然而，就在发送IRP_MJ_CLOSE请求前，I/O管理器先向你发送了IRP_MJ_CLEANUP请求，这样你就可以有机会取消所有未处理的IRP，这些IRP属于这个文件对象但仍停留在你的队列中。从驱动程序的角度来看，这些请求都有一个共同点，即在任何时刻它们的堆栈单元都指向同一个文件对象。

图5-12显示了当你收到IRP_MJ_CLEANUP请求时，你应做的动作。

图5-12. 驱动程序响应IRP_MJ_CLEANUP请求

如果你使用标准模型，你的派遣函数应该象下面这样：

NTSTATUS DispatchCleanup(PDEVICE_OBJECT fdo, PIRP Irp)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
  PFILE_OBJECT fop = stack->FileObject;							<--1
  LIST_ENTRY cancellist;								<--2
  InitializeListHead(&cancellist);

  KIRQL oldirql;									<--3
  IoAcquireCancelSpinLock(&oldirql);
  KeAcquireSpinLockAtDpcLevel(&fdo->DeviceQueue.Lock);

  PLIST_ENTRY first = &fdo->DeviceQueue.DeviceListHead;
  PLIST_ENTRY next;

  for (next = first->Flink; next != first; )						<--4
  {
    PIRP QueuedIrp = CONTAINING_RECORD(next,
      IRP, Tail.Overlay.ListEntry);
    PIO_STACK_LOCATION QueuedIrpStack =
      IoGetCurrentIrpStackLocation(QueuedIrp);

    PLIST_ENTRY current = next;
    next = next->Flink;

    if (QueuedIrpStack->FileObject != fop)						<--5
      continue;

    IoSetCancelRoutine(QueuedIrp, NULL);
    RemoveEntryList(current);
    InsertTailList(&cancellist, current);
  }

  KeReleaseSpinLockFromDpcLevel(&fdo->DeviceQueue.Lock);				<--6
  IoReleaseCancelSpinLock(oldirql);

  while (!IsListEmpty(&cancellist))							<--7
  {
    next = RemoveHeadList(&cancellist);
    PIRP CancelIrp = CONTAINING_RECORD(next, IRP, Tail.Overlay.ListEntry);
    CompleteRequest(CancelIrp, STATUS_CANCELLED, 0);
  }

  return CompleteRequest(Irp, STATUS_SUCCESS, 0);					<--8
}
  1. 我们要在队列中寻找与IRP_MJ_CLEANUP同属于一个文件对象的IRP。堆栈单元中含有该文件对象的指针。
  2. 我们的策略是在两个自旋锁的保护下，把要取消的IRP从主设备队列中拉出来。由于可能有多个IRP要取消，所以创建一个临时的链表可以方便地把它们组织起来，因此我们在这里初始化一个链表头。
  3. 为了从队列中提取IRP，我们需要两个自旋锁。我们获取全局取消自旋锁以避免IoCancelIrp的干扰。我们还需要一个保护设备队列的自旋锁，它可以防止与ExInterlockedXxxList函数同操作一个队列时所带来的干扰。
  4. 利用这个循环我们可以检查设备队列上的每个IRP。我们知道，由于我们拥有保护队列的自旋锁，所以其它代码不能向队列添加或删除IRP。因此我们使用常规链表原语(非互锁)访问队列链表。
  5. 当发现某个IRP属于指定文件对象时，我们就把它从设备队列中删除并添加到我们临时创建的cancellist链表中。我们同时还把这个IRP中的取消例程指针置空。注意，我们应该测试排队IRP堆栈单元中的文件对象指针，但查看排队IRP中的不透明域Tail.Overlay.OriginalFileObject是错误的。在IRP完成时，I/O管理器利用该域指出何时该废除文件对象指针。所以它有时可能是NULL，即使那时，该IRP仍属于那个文件对象。而对于堆栈单元，如果IRP正确创建，那么它总是含有正确的文件对象指针。
  6. 我们在循环结束处释放两个自旋锁。
  7. 这个循环取消了我们在上一个循环中找到的IRP。因此，我们不再需要自旋锁。
  8. 最后这个IoCompleteRequest调用属于IRP_MJ_CLEANUP请求本身，它应该是成功的。
文件对象

通常，在设备堆栈中仅有一个驱动程序(实际上是功能驱动程序)实现了全部三种请求：IRP_MJ_CREATE、IRP_MJ_CLOSE、IRP_MJ_CLEANUP。I/O管理器先创建一个文件对象(一个常规内核对象)，然后把该文件对象连接到I/O堆栈中，带有这个文件对象的I/O堆栈将被发往到这三种请求的派遣例程。任何发往设备的IRP都应该带有该文件对象的指针，并且这个指针也应该存在于I/O堆栈中。从某种意义上说，处理这三种IRP的驱动程序就是该文件对象的拥有者，因此，只有这个驱动程序才有资格使用该文件对象中的FsContext和FsContext2域。所以你的DispatchCreate例程可以把某些信息放到其中一个上下文域中，而其它派遣例程都可以使用这些信息，最后由DispatchClose例程清除这些信息。

上面代码的真正要点是第一个循环，在这个循环中我们把要取消的IRP从设备队列中删除。拥有设备队列的自旋锁可以保证队列操作本身的完整性。我们还需要获得全局取消自旋锁。如果没有这个自旋锁，其它代码将在我们删除某IRP时调用IoCancelIrp例程处理这个IRP，而IoCancelIrp将调用取消例程，但取消例程在从队列中提取IRP时会被阻塞。(参考“同步化取消”段中的取消例程例子) 一旦我们释放了队列锁，取消例程将提取队列中的IRP并完成它。这两步都是错误的，因为派遣例程正在做与这两步完全相同的事情。解决的办法是阻止IoCancelIrp在拥有全局取消自旋锁的情况下继续前进，即，使IRP变为不可取消的。

你可能已经注意到了，我们先获取了全局取消自旋锁，然后再获取设备队列锁。以相反顺序获取这两个锁可能会导致死锁：因为取消例程和I/O管理器中的例程(如IoStartPacket)是先获取全局锁，然后再调用KeXxxDeviceQueue例程，而这些例程都需要获取队列锁。因此，可能出现的情况是，我们获取了队列锁然后在获取全局锁时阻塞，而其它例程获取了全局锁并等待队列锁的释放，这就形成了死锁。

在以前的文字框“避免使用全局取消自旋锁”中，我曾提到全局取消自旋锁可能造成严重的系统瓶颈，这里还有一个事实使这个瓶颈问题变得更严重，即IRP_MJ_CLEANUP例程需要占用这个自旋锁足够长的时间以检查整个IRP队列。假设没有人试图执行意外的活动(如取消一个IRP)，那么每个驱动程序在每次调用IoStartPacket、IoStartNextPacket、StartIo、和DispatchCleanup例程时都会请求这个锁，此外，当系统变得更慢时，队列中的IRP会更多，而清除派遣例程将需要更多的时间检查队列，从而增加了全局取消自旋锁的竞争，这又降低了系统性能。

由于存在着性能瓶颈，所以，如果可能我们应该避免使用全局取消自旋锁。要做到这一点，我们需要管理自己的IRP队列。详细内容请见下一章。


管理自己的IRP

现在，我已经解释完IRP处理的所有底层结构，我们可以返回到创建IRP的主题上。我曾提到过有四种不同的服务函数可以用来创建IRP。但我不得不推迟到现在才讨论如何选择它们。下面事实供你在选择时参考：

  • IoBuildAsynchronousFsdRequest和IoBuildSynchronousFsdRequest函数仅能用于创建主功能码在表5-3中列出的IRP。
  • IoBuildDeviceIoControlRequest仅能用于创建主功能码为IRP_MJ_DEVICE_CONTROL或IRP_MJ_INTERNAL_DEVICE_CONTROL的IRP。
  • 当调用IoCompleteRequest时，有些代码可能会释放IRP占用的内存。
  • 你应该事先做好安排以便该IRP能被IoCancelIrp调用取消。

表5-3. 适用于IoBuildXxxFsdRequest的IRP类型。

主功能码
IRP_MJ_READ
IRP_MJ_WRITE
IRP_MJ_FLUSH_BUFFERS
IRP_MJ_SHUTDOWN
IRP_MJ_PNP
IRP_MJ_POWER

使用IoBuildSynchronousFsdRequest

IoBuildSynchronousFsdRequest函数的调用格式如下：

PIRP Irp = IoBuildSynchronousFsdRequest(MajorFunction,
                    DeviceObject,
                    Buffer,
                    Length,
                    StartingOffset,
                    Event,
                    IoStatusBlock);

MajorFunction(ULONG)是新IRP的主功能码(见表5-3)。DeviceObject(PDEVICE_OBJECT)是该IRP最初要发送到的设备对象的地址。对于读写请求，你必须提供Buffer(PVOID)、Length(ULONG)、StartingOffset(PLARGE_INTEGER)参数。Buffer是一个内核模式数据缓冲区的地址，Length是读写操作的字节长度，StartingOffset是读写操作在目标文件中的定位。对于该函数创建的其它请求，这些参数将被忽略。(这就是为什么该函数在WDM.H中的原型将这些参数定为“可选的”，但对于读写请求它们则是必需的) I/O管理器假定你给出的缓冲区地址在当前进程上下文中是有效的。

Event(PKEVENT)是一个事件对象的地址，IoCompleteRequest应该在操作完成时设置这个事件，IoStatusBlock(PIO_STATUS_BLOCK)是一个状态块的地址，该状态块用于保存IRP结束状态和信息。在操作完成前，事件对象和状态块必须一直存在于内存中。

如果创建的是读写IRP，那么在提交该IRP前你不需要做任何事。如果创建的是其它类型的IRP，你需要用附加的参数信息完成第一个堆栈单元；MajorFunction已经被设置，不能设置未公开的Tail.Overlay.OriginalFileObject域，这样做将使文件对象在IRP完成时被错误地废除。同样也不可能设置RequestorMode，它已经被初始化成KernelMode。(我提到这两点是因为我回想起曾读过该函数的一个公开讨论，该讨论认为应该做这两件事，但我认为没有必要) 现在，你可以提交该IRP并等待其完成：

PIRP Irp = IoBuildSynchronousFsdRequest(...);
NTSTATUS status = IoCallDriver(DeviceObject, Irp);
if (status == STATUS_PENDING)
  KeWaitForSingleObject(Event, Executive, KernelMode, FALSE, NULL);

IRP完成后，你可以通过察看你的I/O状态块来了解该IRP的结束状态和相关信息。

很明显，在等待操作完成前你应该运行在PASSIVE_LEVEL级上和非任意线程上下文中。

清除

我曾说过，你必须事先计划好IRP占用内存的释放以及IRP的取消。第一个问题很容易解决，当使用IoBuildSynchronousFsdRequest创建IRP时，I/O管理器自动释放IRP占用的内存。如果是需要系统缓冲区或内存描述符的读写请求，I/O管理器也能自动清除。由于该函数的这些便利性，我们经常调用它。

尽管清除同步IRP比较容易，但取消它并不那么容易。继续往下读...

取消同步IRP

系统中仅有两个实体可以取消IRP。一个是I/O管理器中称为thread rundown的代码，当线程结束时仍有未处理的IRP，就执行这段代码。另一个实体就是产生该IRP的第一个驱动程序。我们应该尽量小心地避免模糊的，低概率的问题。为了说明问题，假设你要强制执行一个5秒钟长的I/O操作。当过了5秒钟后操作仍未完成，你希望取消该操作。下面是完成这个功能的假想代码：

SomeFunction()
{
  KEVENT event;
  IoInitializeEvent(&event, ...);
  PIRP Irp = IoBuildSynchronousFsdRequest(...);
  NTSTATUS status = IoCallDriver(DeviceObject, Irp);
  if (status == STATUS_PENDING)
  {
    LARGE_INTEGER timeout;
    timeout.QuadPart = -5 * 10000000;
    if (KeWaitForSingleObject(&event, Executive, KernelMode, FALSE, &timeout) == STATUS_TIMEOUT)
    {
      IoCancelIrp(Irp);  //  don't do this!
      KeWaitForSingleObject(&event, Executive, KernelMode, FALSE, NULL);
    }
  }
}

对KeWaitForSingleObject的第二次调用确保了事件对象在I/O管理器用完它之前不超出有效范围。任何拥有该IRP的人都必须迅速完成它，所以任何发生在此处的过度延迟都将是一个程序错误。(说起来容易，哼?)

上面代码的错误确实很小。设想一下，如果某人要调用IoCompleteRequest完成该IRP，时间正好与我们要调用IoCancelIrp取消该IRP相同。例如，在5秒超时后的短暂操作。IoCompleteRequest初始化一个IRP处理过程，在处理过程的结尾它调用了IoFreeIrp例程。如果IoFreeIrp调用碰巧发生在IoCancelIrp破坏该IRP之前，那么当IoCancelIrp修改IRP的CancelIrql、Cancel、CancelRoutine域的同时也破坏了正常的内存数据。

我刚描述的情节确实不太可能发生。但正如James Thurber说过的“一次足够了”。这种错误几乎不可能被发现，所以你应该尽可能地预防它。在当前版本的Windows 98和Windows 2000中，一个公认的技术是依赖这样一个事实，IoFreeIrp调用都发生在产生该IRP的线程的APC中。你应该先确保自己在那个线程中，然后提升IRQL到APC_LEVEL，检测该IRP是否已被完成，如果没有则调用IoCancelIrp。在当前的系统中，这样做可以有效地阻塞APC及有问题的IoFreeIrp调用。DDK中的USBCAMD例子就是这样做的。在Compuware Numega的Web站点上可以找到关于这项技术的讨论和注释。

不能认为未来版本的Windows也使用APC来执行IRP的清除。因此，你不能把提升IRQL到APC_LEVEL作为避免IoCancelIrp和IoFreeIrp竞争的方法。我的意思是，未来版本的操作系统很可能会改变，因此，这种技术将不足以避免竞争发生。所以，你需要另外一种方法来解决这个问题。

解决这个问题的关键是使IoFreeIrp调用发生在任何可能的IoCancelIrp调用之后。为了做到这一点，我们使用一个返回STATUS_MORE_PROCESSING_REQUIRED状态的完成例程，见下面代码：

SomeFunction()
{
  KEVENT event;
  IoInitializeEvent(&event, ...);
  PIRP Irp = IoBuildSynchronousFsdRequest(...);
  IoSetCompletionRoutine(Irp, OnComplete, (PVOID) &event, TRUE, TRUE, TRUE);
  NTSTATUS status = IoCallDriver(...);
  if (status == STATUS_PENDING)
  {
    LARGE_INTEGER timeout;
    timeout.QuadPart = -5 * 10000000;
    if (KeWaitForSingleObject(&event, Executive, KernelMode, FALSE, &timeout) == STATUS_TIMEOUT)
    {
      IoCancelIrp(Irp);  //  okay in this context
      KeWaitForSingleObject(&event, Executive, KernelMode, FALSE, NULL);
    }
  }
  KeClearEvent(&event);
  IoCompleteRequest(Irp, IO_NO_INCREMENT);
  KeWaitForSingleObject(&event, Executive, KernelMode, FALSE, NULL);
}

NTSTATUS OnComplete(PDEVICE_OBJECT junk, PIRP Irp, PVOID pev)
{
  KeSetEvent((PKEVENT) pev, IO_NO_INCREMENT, FALSE);
  return STATUS_MORE_PROCESSING_REQUIRED;
}

粗体部分的代码用于防止竞争。假设IoCallDriver返回STATUS_PENDING。在通常情况下，操作将正常完成，某些低级驱动程序将调用IoCompleteRequest。因此，我们的完成例程将获得控制，它把事件对象设置成信号态，而此时我们的主程序(SomeFunction)正在该事件上等待。因为该完成例程返回STATUS_MORE_PROCESSING_REQUIRED，所以IoCompleteRquest将停在该IRP上。在SomeFunction程序中我们又获得了控制，并且我们的等待是以正常方式结束的。由于IRP还没有被清除，我们需要再一次调用IoCompleteRequest例程以触发正常的清除机制。我们仍旧不能让事件对象太快地超出函数范围，所以又执行了一次等待。

现在我们假设要取消这个IRP，由于我们的完成例程返回STATUS_MORE_PROCESSING_REQUIRED，这阻止了清除机制的执行，所以我们不用担心IRP会被IoFreeIrp释放。不管IoCancelIrp怎么处置我们的IRP，该IRP只有在IoCancelIrp安全返回后，在第二次调用IoCompleteRequest时才被释放。

使用IoAllocateIrp

如果你想更深入一些，你可以使用IoAllocateIrp函数创建任何类型的IRP：

PIRP Irp = IoAllocateIrp(StackSize, ChargeQuota);

StackSize(CCHAR)是与IRP一同分配的I/O堆栈单元的数目，ChargeQuota(BOOLEAN)指出该内存分配是否应充入进程限额。通常，你可以从该IRP对应的设备对象中获得StackSize参数，ChargeQuota参数指定为FALSE，例如：

PDEVICE_OBJECT DeviceObject;
PIRP Irp = IoAllocateIrp(DeviceObject->StackSize, FALSE);

当你使用IoAllocateIrp时，必须为它创建的IRP安装一个完成例程，并且该完成例程必须返回STATUS_MORE_PROCESSING_REQUIRED。另外，你还要负责释放该IRP以及任何相关的对象。如果你不打算取消该IRP，你的完成例程应该象这样：

NTSTATUS OnComplete(PDEVICE_OBJECT DeviceObject, PIRP Irp, PVOID Context)
{
  IoFreeIrp(Irp);
  return STATUS_MORE_PROCESSING_REQUIRED;
}

如果IRP的发起线程提前结束，则IoAllocateIrp创建的IRP不能被自动取消。


松散的结尾

我将以描述以前未曾提到的，而你又需要知道的一些其它事情来结束本章。包括两种IRP创建方法，以及如何定位IoCallDriver函数使用的设备对象。

使用IoBuildDeviceIoControlRequest

我将在第九章“如何执行I/O控制操作”中讨论IoBuildDeviceIoControlRequest函数。在清除和取消方面，它所创建的IRP与IoBuildSynchronousFsdRequest创建的IRP有相同的行为。

使用IoBuildAsynchronousFsdRequest

IoBuildAsynchronousFsdRequest是用于创建表5-3中列出的IRP的另一个例程。该函数的原型如下：

PIRP IoBuildAsynchronousFsdRequest(ULONG MajorFunction,
                   PDEVICE_OBJECT DeviceObject,
                   PVOID Buffer,
                   ULONG Length,
                   PLARGE_INTEGER StartingOffset,
                   PIO_STATUS_BLOCK IoStatusBlock);

这个原型与IoBuildSynchronousFsdRequest不同，它没有Event参数，并且IoStatusBlock指针可以为NULL。你仍需要为该函数创建的IRP安装一个完成例程，这个完成例程的工作就是调用IoFreeIrp并返回STATUS_MORE_PROCESSING_REQUIRED。

我想知道这两个函数在创建IRP上有什么不同之处，因此我稍微深入了一些。这两个函数的代码基本上是相同的。实际上，IoBuildAsynchronousFsdRequest是IoBuildSynchronousFsdRequest的子函数。IoBuildSynchronousFsdRequest仅有的额外操作就是保存事件指针到IRP中(I/O管理器需要找到这个事件对象并把它置成信号态)，并把该IRP放到当前线程的IRP队列中。这可以使IRP在线程死亡时能被取消。

你可能在这样两种情况下需要调用IoBuildAsynchronousFsdRequest：第一种情况，当你发现自己执行在任意线程上下文中并需要创建一个IRP时，IoBuildAsynchronousFsdRequest函数就是理想选择，因为当前线程(任意线程)的结束过程不能取消这个新创建的IRP。第二种情况，当你运行在APC_LEVEL级的非任意线程上下文时，你需要同步执行一个IRP。IoBuildSynchronousFsdRequest不能满足这个要求，因为这种IRQL将阻塞设置事件的APC。所以你应该调用IoBuildAsynchronousFsdRequest并在某个事件上等待，而这个事件将由你的完成例程去置成信号态。第二种情况不经常出现在设备驱动程序中。

通常情况下，与IoBuildAsynchronousFsdRequest配合使用的完成例程不仅仅是调用IoFreeIrp。实际上，你需要实现I/O管理器用于清除已完成IRP的内部例程(IopCompleteRequest)。你不能依赖I/O管理器来清除IoBuildAsynchronousFsdRequest创建的IRP。因为在当前版本的Windows 98和Windows 2000中清除操作需要一个APC，并且在任意线程下执行APC是错误的，所以I/O管理器不能为你做清除工作，你必须自己做全部的清除工作。

如果IRP的目标设备对象设置了DO_DIRECT_IO标志，IoBuildAsynchronousFsdRequest将创建一个MDL，这个MDL必须由你自己释放，如下面代码：

NTSTATUS CompletionRoutine(...)
{
  PMDL mdl;
  while ((mdl = Irp->MdlAddress))
  {
    Irp->MdlAddress = mdl->Next;
    IoFreeMdl(mdl);
  }
  ...
  IoFreeIrp(Irp);
  return STATUS_MORE_PROCESSING_REQUIRED;
}

如果IRP的目标设备对象设置了DO_BUFFERED_IO标志，IoBuildAsynchronousFsdRequest将分配一个系统缓冲区，但这个缓冲区需要你来释放。如果你正在做一个输入操作，那么在释放这个缓冲区之前你还需要把输入数据从系统缓冲区复制到你自己真正的输入缓冲区。并且要保证这个真正的缓冲区在非分页内存中，因为完成例程需要在DISPATCH_LEVEL级上运行。你还需要确保你得到的是缓冲区的内核地址，因为完成例程运行在任意线程上下文中。如果这些限制还不足以使你在使用IoBuildAsynchronousFsdRequest(用于DO_BUFFERED_IO设备)时感到泄气，那么想一想你还必须在完成例程中检测未公开标志位IRP_BUFFERED_IO、IRP_INPUT_OPERATION、IRP_DEALLOCATE_BUFFER。我不将给出关于这个函数的代码，因为我曾保证过不在本书中使用未公开的内部技术。

我的建议是，仅在你知道IRP的目标设备不使用DO_BUFFERED_IO标志时，才使用IoBuildAsynchronousFsdRequest。

设备对象指针从哪来？

IoCallDriver函数需要一个PDEVICE_OBJECT作为它的第一个参数。你也许想知道我是从哪得到设备对象的指针的。

一个最明显的获得设备对象指针的方式是调用IoAttachDeviceToDeviceStack函数，这也是每个WDM驱动程序的AddDevice函数应做的一步。在本书的所有例子驱动程序中，你都将看到下面一行代码：

pdx->LowerDeviceObject = IoAttachDeviceToDeviceStack(fdo, pdo);

任何时候我们需要沿着设备堆栈向下传送一个IRP时，我们就使用这个设备对象指针。

另一个常用的定位设备对象的方法是使用对象名称：

PUNICODE_STRING DeviceName;   //  something gives you this
PDEVICE_OBJECT DeviceObject;  //  an output from this process
PFILE_OBJECT FileObject;      //  another output
NTSTATUS status = IoGetDeviceObjectPointer(DeviceName, <access mask>, &FileObject, &DeviceObject);

通过指定设备对象名称和与其相关的文件对象指针你可以得到这个设备对象的指针。文件对象就是文件句柄指向的对象。最后，你还需要解除文件对象参考，如下：

ObDereferenceObject(FileObject); //  DeviceObject now poison!

解除文件对象参考后，你还要释放设备对象的隐含引用。如果你要继续使用该设备对象，应该先做设备对象引用：

ObReferenceObject(DeviceObject);
ObDereferenceObject(FileObject); //  DeviceObject still okay

然而，你不应该机械地把前两行代码加入到你的驱动程序中。实际上，当你向地址由IoGetDeviceObjectPointer获得的设备对象发送IRP时，你应该带着文件对象：

PIRP Irp = IoBuildXxxRequest(...);
PIO_STACK_LOCATION stack = IoGetNextIrpStackLocation(Irp);
stack->FileObject = FileObject;
IoCallDriver(DeviceObject, Irp);

这里是这个额外语句的解释。在内部，IoGetDeviceObjectPointer打开设备对象的一个常规句柄，驱动程序应创建与这个文件对象相关联的某些辅助数据结构，处理IRP_MJ_CREATE之后的IRP也许会用到这些结构。处理IRP_MJ_CLOSE时将销毁这些结构。因此，你需要在发往驱动程序的每个IRP的第一个堆栈单元中设置FileObject指针。

你不必总在新IRP中设置文件对象指针，如果你在IRP_MJ_CREATE的对应例程中获得了文件对象，那么你下面的驱动程序就没有必要查看该文件对象。而在前面描述的情况中，文件对象的所有者是你用IoGetDeviceObjectPointer函数获得设备对象的驱动程序，在这种情况下，你必须设置文件对象指针。

不要使用IoAttachDevice

这里没有讨论IoAttachDevice函数，该函数用于以前版本的NT中，通过给出设备名来定位设备对象。该函数有一个可怕的bug，在该函数返回前它向附着的设备发送一个IRP_MJ_CLOSE请求。不了解IoAttachDevice内部实现的附着驱动程序将不能向目标驱动程序传递请求，从而导致一个孤立句柄。此外，如果目标设备碰巧有DO_EXCLUSIVE属性(如Windows 2000中的串行口驱动程序)，则该目标设备的句柄将不能再次打开。


第六章：即插即用

即插即用(Plug and Play -- PnP)管理器使用主功能码为IRP_MJ_PNP的IRP与设备驱动程序交换信息和请求。这种类型的请求是新引入到Windows 2000和WDM中的，在以前版本的Windows NT中，大部分检测和配置设备的工作由设备驱动程序自己做。而WDM驱动程序可以让PnP管理器做这个工作。为了与PnP管理器协同工作，驱动程序开发者需要了解一些相关的IRP。

在WDM中，PnP请求扮演了两个角色。在第一个角色中，这些请求指示驱动程序何时以及如何配置或取消其硬件或自身的设置。表6-1列出了PnP请求可以指定的二十多个副功能码。其中有9个副功能码仅能由总线驱动程序处理，以星号标出；过滤器驱动程序或功能驱动程序仅下传这些IRP。剩下的副功能码中，有三个对过滤器驱动程序或功能驱动程序特别重要。PnP管理器使用IRP_MN_START_DEVICE来通知功能驱动程序其硬件被赋予了什么I/O资源，以及指导功能驱动程序做任何必要的硬件或软件设置以便设备能正常工作。IRP_MN_STOP_DEVICE告诉功能驱动程序关闭设备。IRP_MN_REMOVE_DEVICE告诉功能驱动程序关闭设备并释放与之关联的设备对象。我将在本章和下一章详细讨论这三种副功能码；以及其它未用星号标出的，过滤器驱动程序或功能驱动程序会处理的副功能码。

  • IRP_MJ_PNP派遣函数
  • 启动和停止设备
  • 管理PnP状态转换
  • 其它配置功能
  • Windows 98兼容问题

表6-1. IRP_MJ_PNP的副功能码 (*指出仅由总线驱动程序处理)

IRP副功能码 | 描述
IRP_MN_START_DEVICE | 配置并初始化设备
IRP_MN_QUERY_REMOVE_DEVICE | 设备可以被安全地删除吗？
IRP_MN_REMOVE_DEVICE | 关闭并删除设备
IRP_MN_CANCEL_REMOVE_DEVICE | 忽略以前的QUERY_REMOVE
IRP_MN_STOP_DEVICE | 关闭设备
IRP_MN_QUERY_STOP_DEVICE | 设备可以被安全地关闭吗？
IRP_MN_CANCEL_STOP_DEVICE | 忽略以前的QUERY_STOP
IRP_MN_QUERY_DEVICE_RELATIONS | 给出与指定特征相关的设备列表
IRP_MN_QUERY_INTERFACE | 获得直接调用函数地址
IRP_MN_QUERY_CAPABILITIES | 取设备能力
IRP_MN_QUERY_RESOURCES* | 取引导配置
IRP_MN_QUERY_RESOURCE_REQUIREMENTS* | 取I/O资源需求
IRP_MN_QUERY_DEVICE_TEXT* | 获得描述信息或位置串
IRP_MN_FILTER_RESOURCE_REQUIREMENTS | 修改I/O资源需求列表
IRP_MN_READ_CONFIG* | 读配置空间
IRP_MN_WRITE_CONFIG* | 写配置空间
IRP_MN_EJECT* | 弹出设备
IRP_MN_SET_LOCK* | 设备弹出锁定/解除
IRP_MN_QUERY_ID* | 取设备硬件ID
IRP_MN_QUERY_PNP_DEVICE_STATE | 取设备状态
IRP_MN_QUERY_BUS_INFORMATION* | 取父总线类型
IRP_MN_DEVICE_USAGE_NOTIFICATION | 通知分页、dump、睡眠文件被创建或删除
IRP_MN_SURPRISE_REMOVAL | 通知设备已经被删除

PnP请求的第二个角色是指导驱动程序完成一系列状态转换，如图6-1所示。WORKING和STOPPED是设备的两个基本状态。当你创建设备对象后，设备就立即进入STOPPED状态。WORKING状态指出设备是全部可操作的。此外，还有两个中间状态，PENDINGSTOP和PENDINGREMOVE，它们出现在WORKING状态前。SURPRISEREMOVED发生在物理硬件突然被移去的情况下。

图6-1. 设备状态图

在上一章描述的IRP处理标准模型中，我指出即插即用对IRP排队和取消有额外需求。在这章我将描述DEVQUEUE对象，它可以满足这些需求，并帮助你管理状态转换。


IRP_MJ_PNP派遣函数

在第五章我解释了向驱动程序堆栈下层传递IRP的两种情况：一种情况你关心IRP的结果，因此你需要一个完成例程，另一种情况你不关心IRP的结果，因此也不需要安装一个完成例程。有许多PnP请求属于第二类，你收到这样的IRP，然后把它们向下传递，不关心该IRP以后的情况。因此，我建议你写一个辅助函数，该函数以“发射后不管”的方式下传IRP，代码见下面。

NTSTATUS DefaultPnpHandler(PDEVICE_OBJECT fdo, PIRP Irp)
{
  IoSkipCurrentIrpStackLocation(Irp);
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  return IoCallDriver(pdx->LowerDeviceObject, Irp);
}

一个简化版的IRP_MJ_PNP派遣函数看起来象这样：

NTSTATUS DispatchPnp(PDEVICE_OBJECT fdo, PIRP Irp)
{
  PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp); 			<--1
  ULONG fcn = stack->MinorFunction; 							<--2

  static NTSTATUS (*fcntab[])(PDEVICE_OBJECT, PIRP) = 					<--3
  {
    HandleStartDevice, // IRP_MN_START_DEVICE
    HandleQueryRemove, // IRP_MN_QUERY_REMOVE_DEVICE
    <etc.>,
  };

  if (fcn >= arraysize(fcntab)) 							<--4
    return DefaultPnpHandler(fdo, Irp);
  return (*fcntab[fcn])(fdo, Irp); 							<--5
}
  1. IRP的所有参数，包括全部重要的副功能码，都在堆栈单元中。因此，我们通过调用IoGetCurrentIrpStackLocation函数获得一个指向它的指针。
  2. 我们期望IRP的副功能码是表6-1中列出的一个。
  3. 处理这二十多个副功能码的一种方法就是为每一个我们将要处理的功能码写一个子派遣函数，并定义一个指向这些子派遣函数的指针表。表中的许多表项将是DefaultPnpHandler。而象HandleStartDevice的子派遣函数将使用设备指针和IRP作为参数，返回NTSTATUS状态码。
  4. 如果收到一个不认识的副功能码，可能是DDK新定义的代码，正确的做法是调用默认处理程序把不认识的功能码沿设备堆栈向下传递。顺便说一下，arraysize是一个宏，它返回数组中的元素个数。其定义为#define arraysize(p) (sizeof(p)/sizeof((p)[0]))。
  5. 该语句索引指针表中的子派遣函数并调用它。
使用函数指针表

用派遣函数指针表来对应副功能码需要冒一些风险。未来版本的操作系统也许会改变某些代码的含义。尽管在发行版本的操作系统中可以不用担心这一点，但在系统的beta版测试期间，有些代码可能会不确定。我喜欢使用指针表来代表子派遣函数，因为用分离的函数来代表副功能码看起来更符合软件工程。举例来说，如果我要为此设计一个C++类库，我愿意定义一个基类，把每个副功能码用虚拟函数来代表。

大部分程序员可能在DispatchPnp例程中放一个switch语句。通过简单的重编译你可以适应副功能码的任何重赋值。但重编译有时也不能解决问题，代码名字的改变往往表示代码功能的改变。实际上，在Windows 98和Windows 2000的beta测试中曾发生过一两次这样的事情。另外，一个有优化功能的编译器可能会使用一个转跳表来产生稍微快速的switch语句代码，而不是调用子派遣函数。

我认为选择switch语句还是一个函数指针表只是个人偏好，以我看来代码可读性和模块性要比执行效率更重要。为了避免beta测试中副功能代码的不确定性，你可以在代码中放置适当的ASSERT语句。例如，在HandleStartDevice函数中加入start->MinorFunction == IRP_MN_START_DEVICE的断言。如果你用新的beta版DDK重编译你的驱动程序，你将捕获到代码的任何数值和名称上的更改。


启动和停止设备

在Windows 2000和Windows 98中，通过使用总线驱动程序，PnP管理器能够自动检测硬件和分配I/O资源。大部分现代设备都有即插即用特征，可以允许系统软件自动检测并提取它们的I/O资源需求。但早期的遗留设备不能用电子方式向操作系统标识自身和表达资源需求，不过注册表可以包含检测和资源赋予操作所需要的信息。

注意
我发现给出术语“I/O资源”的抽象定义十分困难，所以我将以描述具体实例来解释这个术语。WDM包含四种标准I/O资源类型：I/O端口、内存寄存器、DMA通道、中断请求。

当PnP管理器检测到硬件时，它首先参考注册表以了解有哪些过滤器驱动程序将管理该硬件。正如我在第二章中讨论的，如果必要(某些驱动程序可能因为其它硬件的需要已经被系统装入)它将装入这些驱动程序，并调用它们的AddDevice函数。最后AddDevice函数创建设备对象并连入设备堆栈。此后，PnP管理器将为所有设备驱动程序分配I/O资源。

开始，PnP管理器为每个设备创建一个资源需求列表并允许驱动程序过滤这个列表。在此我将忽略过滤步骤，因为并不是每个驱动程序都需要经过这个步骤。给出需求列表，则PnP管理器在分配资源时能协调当前系统中所有硬件的潜在资源冲突。例如，图6-2显示了PnP管理器如何裁决两个有重叠中断请求号设备的资源需求。

图6-2. 裁决I/O资源冲突

一旦资源分配确定，PnP管理器通过向每个设备发送一个带IRP_MN_START_DEVICE副功能码的PnP请求来通知设备。通常过滤器驱动程序对这个IRP不感兴趣，所以它们使用DefaultPnpHandler方式把请求向下传。而功能驱动程序正好相反，它需要在这个IRP上做大量工作，包括分配并配置额外的软件资源以及为设备操作做准备。这个工作需要在PASSIVE_LEVEL级上进行，并在低层驱动程序处理完该IRP后完成。

前进和等待IRP

为了在下传IRP_MN_START_DEVICE请求后再获得控制，派遣例程需要等待一个内核事件，该事件最终由低层驱动程序对IRP的完成操作来通知。在第四章中，我曾警告过你不要阻塞任意线程。但PnP请求是在系统线程(可以被阻塞)上下文中发送给你的，所以警告是不必要的。因为前进和等待IRP在其它上下文中也是一个有潜在用途的功能，所以我建议你写一个辅助函数来执行这个机制：

NTSTATUS ForwardAndWait(PDEVICE_OBJECT fdo, PIRP Irp)
{
  KEVENT event;										<--1
  KeInitializeEvent(&event, NotificationEvent, FALSE);
  IoCopyCurrentIrpStackLocationToNext(Irp);						<--2
  IoSetCompletionRoutine(Irp,								<--3
             (PIO_COMPLETION_ROUTINE) OnRequestComplete,
             (PVOID) &event,
             TRUE,
             TRUE,
             TRUE);
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  IoCallDriver(pdx->LowerDeviceObject, Irp);						<--4
  KeWaitForSingleObject(&event, Executive, KernelMode, FALSE, NULL);			<--5
  return Irp->IoStatus.Status;								<--6
}
  1. 我们创建一个内核事件对象。KeInitializeEvent必须在PASSIVE_LEVEL级上被调用。幸运的是，PnP请求总是在PASSIVE_LEVEL上发送，所以正好符合这种需求。事件对象本身必须占用非分页内存。另外，在大多数情况下，你也可以认为执行堆栈也是非分页的。
  2. 由于我们要安装一个完成例程，所以必须向下一层驱动程序复制堆栈参数。
  3. 指定一个完成例程以便我们能知道下层驱动程序何时完成该IRP。我们应该等待完成操作发生，所以必须确保我们的完成例程被调用。这就是为什么我把三个标志参数都指定为TRUE，它们指出我们希望在IRP正常完成、遇到错误、被取消这三种情况下都调用OnRequestComplete。完成例程的上下文参数是event对象的地址。
  4. IoCallDriver调用下一层驱动程序，可以是一个低层过滤器驱动程序或是PDO驱动程序本身。PDO驱动程序将执行某些处理，或者是立即完成该请求，或者返回STATUS_PENDING。
  5. 不管IoCallDriver返回什么，我们都将调用KeWaitForSingleObject在我们以前建立的内核事件上永远等待。当下层驱动程序完成该IRP并把事件置成信号态时，我们的完成例程将再次获得控制。
  6. 这里，我们捕获IRP的最终状态并返回给我们的调用者。

一旦我们调用了IoCallDriver，我们就放弃了IRP的控制权，直到某些运行在任意线程上下文中的代码调用IoCompleteRequest通知该IRP完成，IoCompleteRequest将调用我们的完成例程。图6-3显示了这个过程中的时间顺序。完成例程特别简单：

NTSTATUS OnRequestComplete(PDEVICE_OBJECT fdo, PIRP Irp, PKEVENT pev)
{
  KeSetEvent(pev, 0, FALSE);								<--1
  return STATUS_MORE_PROCESSING_REQUIRED;						<--2
}
  1. 我们把阻塞ForwardAndWait的事件置成信号态。
  2. 通过返回STATUS_MORE_PROCESSING_REQUIRED，我们停止了I/O堆栈的回卷处理。此时，上层过滤器驱动程序安装的任何完成例程都得不到调用，并且I/O管理器将停止在该IRP上的工作。这种情形就象根本没有调用过IoCompleteRequest一样，当然，某些已经调用过的低级完成例程除外。在这一时刻，该IRP将处于一个中间状态，但我们的ForwardAndWait例程将再次获得该IRP的所有权。

图6-3. ForwardAndWait的时间序列

关于ForwardAndWait

当我在描述ForwardAndWait如何与OnRequestComplete协同工作时，我掩盖了两个细微之处。第一，线程的内核堆栈有时也可能被换出物理内存，这仅发生在线程被阻塞于用户模式中。David Solomon的《Inside Windows NT, Second Edition (Microsoft Press, 1998)》中第194页给出了这种可能性的状态图。ForwardAndWait中所有处理事件对象的调用都要求event对象驻留内存。因为我们指定一个内核模式等待，所以我们的堆栈不能被换出内存，因此KeSetEvent也会在内存中找到event对象。

第二，你可能已经注意到了，在完成例程开始处没有了样板代码if(Irp->PendingReturned) IoMarkIrpPending(Irp)。如果完成例程返回STATUS_MORE_PROCESSING_REQUIRED，你就不需要那个样板代码。但大部分标准完成例程需要这个样板代码。这也是为什么所有DDK例子都包含这个样板代码，尽管这段代码有时并不严格需要。

提取资源分配信息

在前一段中，我解释了如何使用ForwardAndWait辅助函数发送IRP_MN_START_DEVICE请求并等待其完成。另外，你还可以从子派遣例程中调用ForwardAndWait，这个子派遣函数就是前面提到的DispatchPnp派遣函数的一个分支，其框架如下：

NTSTATUS HandleStartDevice(PDEVICE_OBJECT fdo, PIRP Irp)
{
  Irp->IoStatus.Status = STATUS_SUCCESS;						<--1
  NTSTATUS status = ForwardAndWait(fdo, Irp);
  if (!NT_SUCCESS(status))								<--2
    return CompleteRequest(Irp, status, Irp->IoStatus.Information);
  PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);				<--3
  status = StartDevice(fdo, <additional args>);						<--4
  return CompleteRequest(Irp, status, Irp->IoStatus.Information);
}
  1. 总线驱动程序利用IoStatus.Status中的设置来判断上层驱动程序是否已经处理了该IRP。对于IRP_MJ_PNP的其它几种副功能码，总线驱动程序也做类似的判断。因此，我们需要把IRP的Status域初始化成STATUS_SUCCESS，然后再传递下去。
  2. ForwardAndWait返回一个状态码。如果这个状态码指出低层驱动程序的某种失败，我们就把这个失败代码返回给我们的调用者。因为我们的完成例程返回STATUS_MORE_PROCESSING_REQUIRED，它在IoCompleteRequest内部停止了完成处理。因此，我们必须再一次完成该请求。
  3. 我们需要的配置信息隐藏在堆栈参数中，我将在后面继续讨论。
  4. StartDevice是你写的用于提取和处理配置信息的辅助函数。在我的例子驱动程序中，我把它放到源文件READWRITE.CPP中。设备对象后面的参数我稍后再解释。

你也许会猜到，IRP_MN_START_DEVICE处理程序的工作涉及到设备从STOPPED状态转换到WORKING状态。现在我还不能解释这些，我需要先解释PnP请求关于状态转换、IRP排队、IRP取消的其它分支。所以，我将在PnP请求的配置方面上停留一会。

I/O堆栈单元的Parameters联合有一个名为StartDevice的子结构，该结构包含的配置信息，见表6-2，将被传递到StartDevice辅助函数。

表6-2. IO_STACK_LOCATION.Parameters.StartDevice子结构中的域

域名 | 描述
AllocatedResources | 包含原始的资源分配信息
AllocatedResourcesTranslated | 包含转换后的资源分配信息

AllocatedResources和AllocatedResourcesTranslated都是同一种类数据结构的实例，这种数据结构就是CM_RESOURCE_LIST。如果你看到该结构在WDM.H中的声明，你可能认为它是一个十分复杂的数据结构。然而，当用于启动设备的IRP时，仅有一个表项是有用的，即CM_PARTIAL_RESOURCE_LIST，它描述了设备被赋予的所有I/O资源。

可以使用下面语句访问这两个列表：

PCM_PARTIAL_RESOURCE_LIST raw, translated;
raw = &stack->Parameters.StartDevice.AllocatedResources->List[0].PartialResourceList;
translated = &stack->Parameters.StartDevice.AllocatedResourcesTranslated->List[0].PartialResourceList;

raw和translated资源列表是StartDevice辅助函数的实际参数：

status = StartDevice(fdo, raw, translated);

因为I/O总线与CPU在寻址物理硬件的方式上不同，所以存在着两种资源列表。raw资源包含总线相关的数值，而translated资源包含系统相关的数值。在WDM出现之前，内核模式驱动程序从注册表、PCI配置空间、或其它地方获取raw资源值，并通过调用诸如HalTranslateBusAddress或HalGetInterruptVector函数转换这些数值。参见Art Baker的《The Windows NT Device Driver Book: A Guide for Programmers (Prentice Hall, 1997)》第122-162页。现在，接收和转换工作全部由PnP管理器来完成，WDM驱动程序需要做的仅是访问设备启动IRP的Parameters结构。

StartDevice函数中对资源信息的实际操作是下一章的主题。

IRP_MN_STOP_DEVICE

设备停止请求通知你关闭设备，然后PnP管理器重新分配I/O资源。在硬件级，关闭设备将包括暂停或停止当前活动并阻止后来的中断。在软件级，关闭设备将涉及释放设备启动时配置的I/O资源。基于派遣例程/子派遣例程架构，你应该有一个如下面这样的子派遣函数：

NTSTATUS HandleStopDevice(PDEVICE_OBJECT fdo, PIRP Irp)
{
   <complicated stuff>									<--1
   StopDevice(fdo, oktouch);								<--2
   Irp->IoStatus.Status = STATUS_SUCCESS;
   return DefaultPnpHandler(fdo, Irp);							<--3
}
  1. 在这里，你需要插入一些代码来处理IRP的排队和取消。在本章后面的“当设备停止时”段中我将给出这里的代码。
  2. 对照设备启动阶段，在那时我们先向下传递请求，然后做设备相关的工作。但在这里，我们应该先做设备相关的工作，然后再向下传递请求。这里的思想是，当低层驱动程序看到这个请求时我们的硬件已经停止了活动。我写了一个名为StopDevice的辅助函数来做设备关闭工作，该函数的第二个参数指出它可否在必要时接触硬件。参考文字框“停止设备时接触硬件”中的解释来设置这个参数。
  3. 我们总是向下传递PnP请求。在这里，我们不需要关心低层驱动程序如何处理该IRP，所以我们简单地使用DefaultPnpHandler向下传递该请求。

上面例子中对StopDevice辅助函数的调用是与StartDevice中配置步骤功能相反的重要代码。我将在下一章解释StartDevice函数，关于这个函数有一个重要的事实，即该函数可以被多次单独调用。对于PnP请求的处理例程，知道是否已经调用过StopDevice函数并不总是容易的，但使用StopDevice检测该函数的重复调用还是比较容易的。

停止设备时接触硬件

在HandleStopDevice框架代码中，我用了一个oktouch变量，但没有描述它如何初始化。在本书介绍的驱动程序编写方案中，StopDevice函数将得到一个BOOLEAN参数，该参数指出能否对硬件执行安全的I/O操作。这个参数背后的想法是，你可能需要向设备发送自定义的指令，但在某些情况下例外。例如，你可以向PCMCIA调制解调器发出挂断命令，尽管用户这时已经从计算机上拔出了调制解调器卡。

没有确切的方法知道你的硬件是否物理地连接到计算机上，除非你真正访问它。然而Microsoft推荐，如果你成功地处理了START_DEVICE请求，那么在你处理STOP_DEVICE请求和其它某些PnP请求时你可以访问你的硬件。当我在后面讨论如何跟踪PnP状态改变时，如果我们确信设备当前处于工作状态，我们应设置oktouch参数为TRUE，反之为FALSE。

IRP_MN_REMOVE_DEVICE

回想一下，PnP管理器通过调用驱动程序中的AddDevice函数来通知你已经找到你要管理的硬件实例，并给你一个创建设备对象的机会。当设备将要被系统删除时，PnP管理器向你发送副功能码为IRP_MN_REMOVE_DEVICE的PnP请求(不是调用函数做补充操作)。为了响应这个请求，你应该做与IRP_MN_STOP_DEVICE相同事情，关闭你的设备，然后删除设备对象：

NTSTATUS HandleRemoveDevice(PDEVICE_OBJECT fdo, PIRP Irp)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  <complicated stuff>
  DeregisterAllInterfaces(pdx);
  StopDevice(fdo, oktouch);
  Irp->IoStatus.Status = STATUS_SUCCESS;
  NTSTATUS status = DefaultPnpHandler(fdo, Irp);
  RemoveDevice(fdo);
  return status;
}

这段代码看起来与HandleStopDevice非常相似，除了两条附加的语句。DeregisterAllInterfaces将禁止所有(可能在AddDevice中寄存的，在StartDevice中允许的)设备接口，并释放它们的符号连接名所占用的内存。RemoveDevice将撤消你在AddDevice中所做的所有工作。

VOID RemoveDevice(PDEVICE_OBJECT fdo)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  IoDetachDevice(pdx->LowerDeviceObject);						<--1
  IoDeleteDevice(fdo);									<--2
}
  1. IoDetachDevice调用与AddDevice中的IoAttachDeviceToDeviceStack调用正好相反。
  2. IoDeleteDevice调用与AddDevice中的IoCreateDevice调用正好相反。一旦该函数返回，设备对象将不复再存在。如果你的驱动程序没有其它设备需要管理，那么很快你的驱动程序也将被从内存中卸载。

当低层驱动程序仍在处理IRP_MN_REMOVE_DEVICE请求时，调用IoDeleteDevice函数会不会出现麻烦？这样做是无害的，因为对象管理器为你的设备对象维护一个引用计数器，这可以防止设备对象在有任何活动指针指向它时被意外删除。

注意，你不能在删除设备请求后再希望获得停止设备请求。删除设备请求潜在地包含了停止设备操作，所以在这个请求中你应该做两份工作。

IRP_MN_SURPRISE_REMOVAL

有时用户可能不经过任何用户接口交互操作突然地拆卸设备。如果系统检测到这种突然的删除，它就向驱动程序发送副功能码为IRP_MN_SURPRISE_REMOVAL的PnP请求，后面还会跟着一个IRP_MN_REMOVE_DEVICE请求。除非你以前在处理IRP_MN_QUERY_CAPABILITIES时曾设置SurpriseRemovalOK标志(见第八章)，否则系统将显示一个对话框，通知用户这样做是危险的。

为了响应突然删除请求，设备驱动程序应该禁止所有已寄存的接口。这将给应用程序一个机会关闭设备的句柄，但应用程序必须事先关注这种通知，我将在后面的“PnP通知”中再讨论这些，然后驱动程序应释放I/O资源并向下传递请求：

NTSTATUS HandleSurpriseRemoval(PDEVICE_OBJECT fdo, PIRP Irp)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  <complicated stuff>
  EnableAllInterfaces(pdx, FALSE);
  StopDevice(fdo, oktouch);
  Irp->IoStatus.Status = STATUS_SUCCESS;
  return DefaultPnpHandler(fdo, Irp);
}
IRP_MN_SURPRISE_REMOVAL来自何处？

简单且直接地从计算机上拆卸设备并不能产生突然删除PnP通知。有些总线能够察觉设备消失。例如，拔掉一个USB设备将产生一个电信号，这个电信号能被总线驱动程序注意到。然而，对于大多数其它总线类型，没有任何信号能用于通知总线驱动程序。因此，PnP管理器需要依靠其它方法来判断设备是否消失。

功能驱动程序可以通知其管理的设备的消失(如果它知道)，通过调用IoInvalidateDeviceState函数，然后从紧接着的IRP_MN_QUERY_PNP_DEVICE_STATE中返回PNP_DEVICE_FAILED、PNP_DEVICE_REMOVED、PNP_DEVICE_DISABLED中的任一个值。举例来说，如果你的ISR在读通常结果为1和0混合的状态端口时突然得到了全部为1的值，你的驱动程序就应该通知设备消失。更普通的情况是，总线驱动程序调用IoInvalidateDeviceRelations函数触发一个再枚举操作时报告某个设备枚举失败。另外，如果系统处于休眠或在其它低电源状态时用户拆卸了设备，那么驱动程序在接收到IRP_MN_SURPRISE_REMOVAL请求前先收到了一系列电源管理IRP。

这些事实意味着什么，实际地说，就是你的驱动程序应该能应付由设备突然消失所造成错误。


管理PnP状态转换

正如本章开头提到的，WDM需要跟踪设备的状态转换。状态跟踪还涉及到如何排队和取消IRP。而取消操作需要用到一个全局取消自旋锁，但该锁在多处理器机器上会造成性能瓶颈。IRP处理的标准模型不能解决所有这些问题。因此，在这一节中，我将介绍一种新的对象类型，DEVQUEUE，你可以在PnP请求处理中使用这种对象，它可以代替标准模型中的StartPacket和StartNextPacket例程。DEVQUEUE是我自己的创造，使用它的例子驱动程序有PNPPOWER和CANCEL。关于IRP取消的其它讨论见Ervin Peretz的文章“The Windows Driver Model Simplifies Management of Device Driver I/O Requests” (Microsoft Systems Journal, January 1999)。我描述的一部分IRP取消逻辑还来自Peretz和其它Microsoft雇员的工作。

我以前描述的KDEVICE_QUEUE队列对象有三种状态：idle、busy-empty、busy-not empty。用于维护KDEVICE_QUEUE的支持例程假定设备当前不忙，你所做的仅是启动设备可执行的新请求。而这种行为正是我们管理PnP状态时所要克服的。图6-4显示了DEVQUEUE的状态。

图6-4. DEVQUEUE对象的状态

在READY状态中，队列的操作更象一个KDEVICE_QUEUE，它接收并发送请求到你的StartIo例程。在STALLED状态中，队列停止向StartIo运送IRP，即使设备处于空闲状态。在REJECTING状态，队列不接受新的IRP。图6-5显示了穿过队列的IRP流。

图6-5. DEVQUEUE中的IRP流

使用DEVQUEUE来排队和取消IRP

你可以为驱动程序要管理的每个请求队列定义一个DEVQUEUE对象。例如，如果你的设备用一个单独的队列来管理读写请求，你应该定义一个DEVQUEUE：

typedef struct _DEVICE_EXTENSION {
  ...
  DEVQUEUE dqReadWrite;
  ...
} DEVICE_EXTENSION, *PDEVICE_EXTENSION;

表6-3列出了DEVQUEUE的支持函数

表6-3. DEVQUEUE的服务函数

支持函数 | 描述
AbortRequests | 放弃当前及将来的请求
AllowRequests | 允许请求，与AbortRequests相反
AreRequestsBeingAborted | 检查新请求是否被取消
CancelRequest | 普通的取消例程
CheckBusyAndStall | 用一个原子操作检测空闲和停止
CleanupRequests | 取消给定文件对象的所有请求，用于处理IRP_MJ_CLEANUP
GetCurrentIrp | 取当前正被处理的请求
InitializeQueue | 初始化DEVQUEUE对象
RestartRequests | 重启动一个停止的队列
StallRequests | 停止队列
StartNextPacket | 出队并启动下一个请求
StartPacket | 启动或排队一个新请求
WaitForCurrentIrp | 等待当前IRP完成

现在，我将讨论用于替代标准IRP处理模型中StartPacket和StartNextPacket函数的支持例程。对于每个队列，都应该提供一个独立的StartIo例程。DriverEntry例程将不在驱动程序对象的DriverStartIo指针域存储任何东西。相反，应该在AddDevice例程中初始化你的队列对象：

NTSTATUS AddDevice(...)
{
  ...
  PDEVICE_EXTENSION pdx = ...;
  InitializeQueue(&pdx->dqReadWrite, StartIo);
  ...
}

对于使用DEVQUEUE队列的IRP，其派遣函数应该使用下面模式：

NTSTATUS DispatchWrite(PDEVICE_OBJECT fdo, PIRP Irp)
{
  <some power management stuff you haven't heard about yet>
  IoMarkIrpPending(Irp);
  StartPacket(&pdx->dqReadWrite, fdo, Irp, OnCancel);
  return STATUS_PENDING;
}

即不调用IoStartPacket，而调用队列的StartPacket函数，其参数为队列对象地址、设备对象、IRP、取消例程。在派遣例程的开始处，你还要花一点代码来处理电源恢复问题，这将在第八章中讨论。

下面是用于DEVQUEUE的新StartIo例程：

VOID StartIo(PDEVICE_OBJECT fdo, PIRP Irp)
{
  <some PnP stuff you haven't heard about yet>
  // start request on device
}

StartIo不必担心IRP的取消。在这里使用的取消例程与标准取消例程不同，它只是简单地把所有工作都委托给DEVQUEUE来处理：

VOID OnCancel(PDEVICE_OBJECT fdo, PIRP Irp)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  CancelRequest(&pdx->dqReadWrite, Irp);
}

CancelRequest将释放全局取消自旋锁，然后以线程安全和多处理器安全的方式取消IRP。

在请求完成时使用的DPC例程也与标准模型中的DPC例程稍有不同，见下面代码：

VOID DpcForIsr(PKDPC Dpc, PDEVICE_OBJECT device, PIRP junk, PVOID context)
{
  PIRP Irp = GetCurrentIrp(&pdx->dqReadWrite);
  ...
  StartNextPacket(&pdx->dqReadWrite, device);
  <some PnP stuff you haven't heard about yet>
  CompleteRequest(Irp, ...);
}

就象IoStartNextPacket函数，StartNextPacket函数也从队列中删除下一个IRP并把它发送到你的(这种队列专用)StartIo例程。它也返回IRP地址，如果返回地址为NULL则代表该IRP因为某种原因被取消或放弃，所以试图完成这样的IRP是不正确的。你可以调用GetCurrentIrp来获得正在完成中的IRP的地址，不要使用来自DPC例程第三个参数的IRP指针，为此我命名该参数为junk以强化这一点。

DEVQUEUE也简化了IRP_MJ_CLEANUP的处理。实际的代码很短小：

NTSTATUS DispatchCleanup(PDEVICE_OBJECT fdo, PIRP Irp)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
  CleanupRequests(&pdx->dqReadWrite, stack->FileObject, STATUS_CANCELLED);
  return CompleteRequest(Irp, STATUS_SUCCESS, 0);
}

用DEVQUEUE排队PnP请求

使用DEVQUEUE代替KDEVICE_QUEUE的关键原因是DEVQUEUE能简化PnP状态转换的管理。在我所有的例子驱动程序中，设备扩展都含有一个状态变量state。我还定义了一个枚举变量DEVSTATE，其值对应PnP状态。当你在AddDevice中初始化设备对象时，你应为每个设备队列调用InitializeQueue函数并指定设备为STOPPED状态：

NTSTATUS AddDevice(...)
{
  ...
  PDEVICE_EXTENSION pdx = ...;
  InitializeQueue(&pdx->dqRead, StartIoReadWrite);
  pdx->state = STOPPED;
  ...
}

AddDevice返回后，系统发出各种IRP_MJ_PNP请求，这些请求将指导设备进入各种PnP状态。

启动设备

刚初始化的DEVQUEUE将处于STALLED状态，StartPacket调用会把一个请求排入队列，不管设备是否处于空闲状态。你应该使队列保持STALLED状态直到你成功地处理了IRP_MN_START_DEVICE，如下面代码：

NTSTATUS HandleStartDevice(...)
{
  status = StartDevice(...);
  if (NT_SUCCESS(status))
  {
    pdx->state = WORKING;
    RestartRequests(&pdx->dqReadWrite, fdo);
  }
}

你先把设备的当前状态记录为WORKING，然后为每个队列调用RestartRequests函数，该函数将释放在AddDevice运行后和你收到IRP_MN_START_DEVICE前所到达的所有IRP。

可以停止设备吗？

PnP管理器在停止你的设备前总是先询问，得到允许后才向你发送IRP_MN_STOP_DEVICE请求。询问以IRP_MN_QUERY_STOP_DEVICE请求的形式出现，你可以回答成功或失败。询问的基本含义是，“如果系统在几纳秒后向你发送IRP_MN_STOP_DEVICE，你能立即停止设备吗？”你可以以两种稍微不同的方式处理这个询问请求。第一种方式适合于可以迅速完成或者能容易地中途结束的IRP：

NTSTATUS HandleQueryStop(PDEVICE_OBJECT fdo, PIRP Irp)
{
  Irp->IoStatus.Status = STATUS_SUCCESS;
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  if (pdx->state != WORKING)									<--1
    return DefaultPnpHandler(fdo, Irp);
  if (!OkayToStop(pdx))										<--2
    return CompleteRequest(Irp, STATUS_UNSUCCESSFUL, 0);
  StallRequests(&pdx->dqReadWrite);								<--3
  WaitForCurrentIrp(&pdx->dqReadWrite);
  pdx->state = PENDINGSTOP;									<--4
  return DefaultPnpHandler(fdo, Irp);
}
  1. 该语句用于处理引导设备这种特殊情况：如果你还没有完成设备初始化，PnP管理器将向你发送QUERY_STOP。你希望忽略这样的查询，这等于对PnP管理器说“是”。
  2. 在此，你执行某种调查函数，看是否可以回复到STOPPED状态。我们马上就要讨论这个问题。
  3. StallRequests把DEVQUEUE设置成STALLED状态，以便任何新的IRP能进入队列。WaitForCurrentIrp等待当前的请求完成。使设备停止有两个步骤，这两个步骤执行后我们就可以知道设备是否真的停止或仍在活动。
  4. 在此，我们没有理由犹豫，所以我们把设备的状态记录为PENDINGSTOP，然后把请求下传，这样其它驱动程序就可以有机会接受或拒绝这个查询。

另一种处理QUERY_STOP的方式适合于需要长时间才能完成并且不能被中途停止的IRP，例如磁带机的备份操作就不能被中途打断。在这种情况下，你可以使用DEVQUEUE的CheckBusyAndStall函数。如果你的设备忙，该函数返回TRUE，因此，你将以STATUS_UNSUCCESSFUL回答QUERY_STOP查询。如果设备空闲，该函数返回FALSE，在这种情况下，你还需要停止队列。(检测设备状态和停止队列操作需要一个自旋锁的保护，这就是为什么我要先写这个函数)

失败一个设备停止查询可以有多种原因。例如，用于分页机制的磁盘设备不能被停止。保存睡眠文件或dump文件的设备也不能被停止。(这些特征可以从IRP_MN_DEVICE_USAGE_NOTIFICATION请求中得到，我将在后面的“其它配置功能”中讨论这个请求) 此外还有其它原因。

即使你成功地回答了查询，但下层驱动程序可能会失败这个查询。即使所有驱动程序都成功地回答了查询，PnP管理器也可能决定不关闭你的设备。在这种情况下，你将收到另一个副功能码为IRP_MN_CANCEL_STOP_DEVICE的PnP请求，它通知设备不将被关闭。之后你应该清除在查询中设置的任何state值：

NTSTATUS HandleCancelStop(PDEVICE_OBJECT fdo, PIRP Irp)
{
  Irp->IoStatus.Status = STATUS_SUCCESS;
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  if (pdx->state != PENDINGSTOP)
    return DefaultPnpHandler(fdo, Irp);
  NTSTATUS status = ForwardAndWait(fdo, Irp);
  if (NT_SUCCESS(status))
  {
    pdx->state = WORKING;
    RestartRequests(&pdx->dqReadWrite, fdo);
  }
  return CompleteRequest(Irp, status, Irp->IoStatus.Information);
}

我们首先查看是否有一个未处理的停止操作。某些高级驱动程序可以否决一个查询，所以我们将看不到这样的查询，因此我们仍处于WORKING状态。如果我们没有处于PENDINGSTOP状态，我们就简单地下传该IRP。否则，我们发送CANCEL_STOP请求来同步低级驱动程序。即我们用自己的ForwardAndWait辅助函数来下传该IRP并等待其完成。我们等待低级驱动程序是因为我们要继续处理该IRP，并且低级驱动程序在我们向它发送IRP前也许会有工作要做。如果低级驱动程序成功地处理了IRP_MN_CANCEL_STOP_DEVICE，我们就改变state变量为WORKING状态，并且调用RestartRequests函数重新启动队列。

当设备停止时

如果所有设备驱动程序都成功地回答了查询并且PnP管理器也决定要关闭你的设备，你将收到一个IRP_MN_STOP_DEVICE请求。你的子派遣函数应该象这样：

NTSTATUS HandleStopDevice(PDEVICE_OBJECT fdo, PIRP Irp)
{
  Irp->IoStatus.Status = STATUS_SUCCESS;
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  if (pdx->state != PENDINGSTOP);							<--1
  {
    <complicated stuff>
  }
  StopDevice(fdo, pdx->state == WORKING);						<--2
  pdx->state = STOPPED;									<--3
  return DefaultPnpHandler(fdo, Irp);							<--4
}
  1. 我们希望系统在发送STOP请求前发送一个QUERY_STOP，因此我们的设备能够提前进入PENDINGSTOP状态并且让设备的所有队列都停止。然而，Windows 98中有一个bug，当我们需要REMOVE请求时，有时会得到STOP，而这个STOP之前却没有QUERY_STOP请求。因此，你必须采取一些动作以避免驱动程序拒绝任何新IRP，当收到一个REMOVE请求时，你不要真的删除你的设备对象或其它与真正REMOVE相关的动作。
  2. StopDevice是一个辅助函数，我们已经在取消设备配置时讨论过。
  3. 现在我们进入STOPPED状态。这与AddDevice刚执行完的情形几乎相同。即所有的队列都停止，设备没有I/O资源。仅有的不同是我们注册的接口仍然是允许的，这意味着应用程序还没有接到设备删除通知并且它们手中的设备句柄还是打开的。在这种情况下，应用程序仍然能打开新的设备句柄。但并不要紧，因为停止状态不会持续多长时间。
  4. 正如我们以前讨论过的，处理IRP_MN_STOP_DEVICE的最后一件事就是向下层驱动程序传递该请求。

可以删除设备吗？

与PnP管理器在停止设备前向你询问一样，它在删除设备前也向你询问，即IRP_MN_QUERY_REMOVE_DEVICE请求。你可以回答成功或失败。与停止查询相似，如果PnP管理器中途改变想法，它就发送IRP_MN_CANCEL_REMOVE_DEVICE请求。

NTSTATUS HandleQueryRemove(PDEVICE_OBJECT fdo, PIRP Irp)
{
  Irp->IoStatus.Status = STATUS_SUCCESS;
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  if (OkayToRemove(fdo))								<--1
  {
    StallRequests(&pdx->dqReadWrite);							<--2
    WaitForCurrentIrp(&pdx->dqReadWrite);
    pdx->prevstate = pdx->state;							<--3
    pdx->state = PENDINGREMOVE;
    return DefaultPnpHandler(fdo, Irp);
  }
  return CompleteRequest(Irp, STATUS_UNSUCCESSFUL, 0);
}

NTSTATUS HandleCancelRemove(PDEVICE_OBJECT fdo, PIRP Irp)
{
  Irp->IoStatus.Status = STATUS_SUCCESS;
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  if (pdx->state != PENDINGREMOVE)							<--4
    return DefaultPnpHandler(fdo, Irp);
  NTSTATUS status = ForwardAndWait(fdo, Irp);
  if (NT_SUCCESS(status))
  {
    pdx->state = pdx->prevstate;							<--5
    if (pdx->state == WORKING)
      RestartRequests(&pdx->dqReadWrite, fdo);
  }
  return CompleteRequest(Irp, status, Irp->IoStatus.Information);
}
  1. OkayToRemove辅助函数回答这样的问题，“这个设备可以删除吗？”通常，这个回答将包含某些专用设备的成分，如设备是否存储分页文件或休眠文件，等等。
  2. 就象IRP_MN_QUERY_STOP_DEVICE一样，你还需要停止队列，并且如果需要，你还要等待一小段时间，直到当前请求完成。
  3. 如果你仔细观察图6-1，你会注意到当设备处于WORKING或STOPPED状态时驱动程序有可能收到一个QUERY_REMOVE。正确的做法是返回到原来的状态。所以我在设备扩展中放了一个prestate变量，它记录查询前的设备状态。
  4. 如果我们上面或下面的驱动程序否决了QUERY_REMOVE，则我们将收到CANCEL_REMOVE请求。如果我们根本就没有看到该查询，我们将仍旧处于WORKING状态并且不需要对该IRP做任何事。否则，我们就必须在处理前把该IRP发送到低级驱动程序，因为我们希望低级驱动程序在我们处理删除请求前做好准备。
  5. 在这里，如果我们成功地回答了QUERY_REMOVE，我们就恢复做过的步骤，回复到以前的状态。如果以前的状态是WORKING，则还要启动在处理查询时被停止的队列。

同步删除

有时候I/O管理器发出的PnP请求会与其它I/O请求(如包含读写的请求)同时出现。这完全有可能，例如当你处理其它IRP时收到了IRP_MN_REMOVE_DEVICE请求。你必须自己避免这种麻烦产生，标准的做法是使用一个IO_REMOVE_LOCK对象和几个相关的内核模式支持例程。

防止设备被过早地删除的基本想法是在每一次开始处理请求时都获取删除锁，处理完成后释放删除锁。在你删除你的设备对象前，应确保删除锁未被使用。否则，你将等到这个锁的所有引用都被释放。图6-6显示了这个过程。

图6-6. 操作IO_REMOVE_LOCK

为了实现这个处理机制，你应该在设备扩展中定义一个锁变量：

struct DEVICE_EXTENSION {
  ...
  IO_REMOVE_LOCK RemoveLock;
  ...
};

还应该在AddDevice中初始化这个锁对象：

NTSTATUS AddDevice(PDRIVER_OBJECT DriverObject, PDEVICE_OBJECT pdo)
{
  ...
  IoInitializeRemoveLock(&pdx->RemoveLock, 0, 0, 0);
  ...
}

IoInitializeRemoveLock的最后三个参数分别代表标签值、锁的最大生存期、锁计数器的最大值，它们在free版本的操作系统中不被使用。第三个和第四个参数为0代表你不希望执行对应的错误检测(生存期和嵌套级)，即使在checked版本的操作系统中。

无论何时，当你收到一个I/O请求时(除了IRP_MJ_CREATE)，你就调用IoAcquireRemoveLock。如果删除设备的操作正在进行，则IoAcquireRemoveLock返回STATUS_DELETE_PENDING。否则，该函数将获得删除锁并返回STATUS_SUCCESS。一旦你完成一个I/O操作，就调用IoReleaseRemoveLock，该函数将释放删除锁以及目前未处理的删除操作。下面是一个纯粹假设的派遣函数，该函数正完成手头的IRP：

NTSTATUS DispatchSomething(PDEVICE_OBJECT fdo, PIRP Irp)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  NTSTATUS status = IoAcquireRemoveLock(&pdx->RemoveLock, Irp);
  if (!NT_SUCCESS(status))
    return CompleteRequest(Irp, status, 0);
  ...
  IoReleaseRemoveLock(&pdx->RemoveLock, Irp);
  return CompleteRequest(Irp, <some code>, <info value>);
}

IoAcquireRemoveLock和IoReleaseRemoveLock的第二个参数仅是一个标签值，checked版本的操作系统用它来匹配请求或释放调用。

获取和释放删除锁的调用与PnP派遣函数和删除设备子派遣函数中的附加逻辑正好吻合。首先，DispatchPnp必须遵守锁定设备和解锁设备的规则，所以它将包含下面代码，这些代码以前没有在“IRP_MJ_PNP派遣函数”中出现过：

NTSTATUS DispatchPnp(PDEVICE_OBJECT fdo, PIRP Irp)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  NTSTATUS status = IoAcquireRemoveLock(&pdx->RemoveLock, Irp);
  if (!NT_SUCCESS(status))
    return CompleteRequest(Irp, status, 0);
  ...
  status = (*fcntab[fcn](fdo, Irp);
  if (fcn != IRP_MN_REMOVE_DEVICE)
    IoReleaseRemoveLock(&pdx->RemoveLock, Irp);
  return status;
}

换句话说，DispatchPnp先锁定设备，然后调用子派遣函数，最后解锁设备。而关于IRP_MN_REMOVE_DEVICE的子派遣函数也含有你没见过的专用逻辑：

NTSTATUS HandleRemoveDevice(PDEVICE_OBJECT fdo, PIRP Irp)
{
  Irp->IoStatus.Status = STATUS_SUCCESS;
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  AbortRequests(&pdx->dqReadWrite, STATUS_DELETE_PENDING);				<--1
  DeregisterAllInterfaces(pdx);
  StopDevice(fdo, pdx->state == WORKING);
  pdx->state = REMOVED;
  NTSTATUS status = DefaultPnpHandler(pdx->LowerDeviceObject, Irp);			<--2
  IoReleaseRemoveLockAndWait(&pdx->RemoveLock, Irp);					<--3
  RemoveDevice(fdo);
  return status;
}
  1. Windows 98不发送SURPRISE_REMOVAL请求，所以这个删除IRP就是第一个指出设备已经消失的IRP。调用StopDevice允许你释放设备占用的所有I/O资源。调用AbortRequests将使你完成任何排队的IRP，并且从现在开始将不再接受任何新的IRP。
  2. 我们已经做完了自己的工作，现在把这个请求传递到低层驱动程序。
  3. PnP派遣例程占有着删除锁。我们现在调用专用的IoReleaseRemoveLockAndWait函数来释放锁引用并等待所有对该锁的引用都被释放。一旦IoReleaseRemoveLockAndWait函数返回，任何后来的IoAcquireRemoveLock调用都将得到STATUS_DELETE_PENDING返回状态，指出设备正在删除。
注意
有时当某个IRP完成时，IRP_MN_REMOVE_DEVICE处理程序会被阻塞。这在Windows 98和Windows 2000中确实允许，因为它们的设计允许这种可能性，IRP_MN_REMOVE_DEVICE是在一个系统线程的上下文中发送的，所以允许阻塞。某些WDM功能甚至存在于OEM版本的Windows 95中，但你不能在那里阻塞设备删除请求。因此，如果你的设备需要运行在Windows 95中，你需要避免这种阻塞。

这就是防止设备在使用时被删除的锁定和解锁机制。为了运用这个机制，你还需要知道何时调用IoAcquireRemoveLock和IoReleaseRemoveLock函数。基本上，可以快速完成请求的IRP派遣函数应该获取和释放这种锁。

然而，需要排队IRP的派遣例程不应该获取删除锁。对于一个在队列中的IRP，你应该在StartIo函数中获取删除锁，并在DPC例程中释放删除锁。因此，我们扩展了StartIo和DpcForIsr的框架例程：

VOID StartIo(PDEVICE_OBJECT fdo, PIRP Irp)
{
  PDEVICE_EXTENSION pdx =(PDEVICE_EXTENSION) fdo->DeviceExtension;
  NTSTATUS status = IoAcquireRemoveLock(&pdx->RemoveLock, Irp);				<--1
  if (!NT_SUCCESS(status))
  {
    CompleteRequest(Irp, status, 0);							<--2
    return;
  }

  // start request on device
}

VOID DpcForIsr(PKDPC Dpc, PDEVICE_OBJECT device, PIRP junk, PVOID context)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  PIRP Irp = GetCurrentIrp(&pdx->dqReadWrite);
  ...
  StartNextPacket(&pdx->dqReadWrite, device);
  IoReleaseRemoveLock(&pdx->RemoveLock, Irp);						<--3
  CompleteRequest(Irp, ...);
}
  1. 我们在这里获取了删除锁而不是在派遣例程中。我们不希望以在队列中存放一个IRP的方式来防止PnP管理器关闭我们的设备。另外，我们最好不让取消例程关心这个删除锁。
  2. IoAcquireRemoveLock只有在删除操作未决时才失败。该函数的返回值可以为STATUS_SUCCESS或STATUS_DELETE_PENDING。在失败的情况中，不要调用StartNextPacket函数，因为在设备将要消失时我们不能启动一个新操作。如果我们调用StartNextPacket函数，将递归调用StartIo函数，StartIo函数将再次获取删除锁并失败，而这又导致调用StartNextPacket，它又会去调用StartIo，最后由于堆栈溢出而导致BSOD。
  3. IoReleaseRemoveLock调用与StartIo中的IoAcquireRemoveLock调用相对。

在IRP_MJ_CREATE派遣例程中没有必要获取删除锁。第六章最后给出的兼容性讨论解释了这个问题，在Windows 98中处理IRP_MJ_CREATE请求时不应该获取删除锁，否则将导致死锁。在Windows 2000中你也不需要获取删除锁，尽管这不会带来什么坏处，因为当设备句柄打开时，PnP管理器不会向你发送IRP_MN_REMOVE_DEVICE。Service Pack-2包含了修订后的例子驱动程序和一个修订后的驱动程序向导。

如果用户使用设备管理器删除设备，而某应用程序正拥有该设备的打开句柄，那么操作系统将拒绝删除该设备并通知用户。如果设备被用户从计算机上物理地摘除并且没有使用设备管理器，那么一个良好的应用程序应该注意WM_DEVICECHANGE消息，该消息通知应用程序设备已经被卸载，应用程序接着应该关闭设备句柄，驱动程序应该延迟执行IRP_MN_REMOVE_DEVICE请求，直到句柄被真正关闭，其实这也是删除锁逻辑允许你做的。

IO_REMOVE_LOCK的兼容问题

IO_REMOVE_LOCK对象和与之相关的服务函数并不是WDM的一部分。但WDM.H中却包含它们的声明，并且WDM.LIB也包含删除锁函数的输入定义。但Windows 98并没有实际输出这些函数。因此，引用这些函数的驱动程序将不能在Windows 98中运行。这非常不幸，因为每个WDM驱动程序都需要互锁的设备删除操作。

DDK中的例子程序使用两种方式应付这个不兼容问题。有些例子使用自创建的机制来代替IO_REMOVE_LOCK。另一些例子提供了名称类似于XxxAcquireRemoveLock的服务函数。

我的例子驱动程序使用与第二种方式类似的方法。通过使用#define语句，我用自己声明的IO_REMOVE_LOCK对象和支持函数替换了官方的对象和函数。这样，我的例子代码将调用IoAcquireRemoveLock，等等。在使用GENERIC.SYS的例子中，预处理器中的欺骗代码实际上把这些调用导向GENERIC.SYS中的GenericAcquireRemoveLock等函数。在没有使用GENERIC.SYS的例子中，预处理器中的欺骗代码把这些调用导向REMOVELOCK.CPP文件中的AcquireRemoveLock等函数。

在Windows 2000中你可以调用标准的删除锁函数。为了使例子程序能够运行在Windows 98中，我写了一个实现删除锁函数的小驱动程序，它是一个VxD(虚拟设备驱动程序)，在运行例子驱动程序前你需要先安装这个VxD。(见附录A) 但我并不认为这是解释WDM编程的好方法。

DEVQUEUE如何工作

不同于本书中的其它例子，我将在这里给出DEVQUEUE对象的全部实现代码。

初始化DEVQUEUE

在DEVQUEUE.H头文件中，DEVQUEUE对象有如下声明：

typedef struct _DEVQUEUE {
  LIST_ENTRY head;
  KSPIN_LOCK lock;
  PDRIVER_START StartIo;
  LONG stallcount;
  PIRP CurrentIrp;
  KEVENT evStop;
  NTSTATUS abortstatus;
} DEVQUEUE, *PDEVQUEUE;

InitializeQueue按下面方式初始化这种对象：

VOID NTAPI InitializeQueue(PDEVQUEUE pdq, PDRIVER_STARTIO StartIo)
{
  InitializeListHead(&pdq->head);							<--1
  KeInitializeSpinLock(&pdq->lock);							<--2
  pdq->StartIo = StartIo;								<--3
  pdq->stallcount = 1;									<--4
  pdq->CurrentIrp = NULL;								<--5
  KeInitializeEvent(&pdq->evStop, NotificationEvent, FALSE);				<--6
  pdq->abortstatus = (NTSTATUS) 0;							<--7
}
  1. 我们使用一个普通(非互锁)的双链表来排队IRP。因为我们用自己的自旋锁保护链表访问，所以不必使用互锁链表。
  2. 该自旋锁既保护队列的访问，也保护DEVQUEUE结构中的其它域。它还代替了全局取消自旋锁，既保护了全部的取消处理，也提高了系统性能。
  3. 每个队列都有自己的StartIo函数。
  4. 停止计数器指出提交到停止的StartIo的IRP的次数。初始化这个计数器为1代表IRP_MN_START_DEVICE处理程序必须调用RestartRequests释放一个IRP。
  5. CurrentIrp域记录着最近发往StartIo例程的IRP。初始化这个域为NULL指出设备开始是空闲的。
  6. 必要时，我们使用这个事件来阻塞WaitForCurrentIrp。我们将在StartNextPacket函数中设置这个事件，该函数在当前IRP完成时总被调用。
  7. 有两种情况我们必须拒绝到来的IRP。第一种情况是在我们真正提交设备删除请求后，当我们必须启动一个状态为STATUS_DELETE_PENDING的新IRP时。第二种情况是在低电源期间，参照我们管理的设备类型，我们应该把新IRP标上STATUS_DEVICE_POWERED_OFF失败代码。abortstatus域记录着我们拒绝的IRP所使用的状态代码。

停止队列

停止IRP队列包含两个DEVQUEUE函数：

VOID NTAPI StallRequests(PDEVQUEUE pdq)
{
  InterlockedIncrement(&pdq->stallcount);						<--1
}

BOOLEAN NTAPI CheckBusyAndStall(PDEVQUEUE pdq)
{
  KIRQL oldirql;
  KeAcquireSpinLock(&pdq->lock, &oldirql);						<--2
  BOOLEAN busy = pdq->CurrentIrp != NULL;						<--3
  if (!busy)										<--4
    InterlockedIncrement(&pdq->stallcount);
  KeReleaseSpinLock(&pdq->lock, oldirql);
  return busy;
}
  1. 为了停止请求，我们仅需要设置停止计数器为一个非零的值。没有必要使用一个自旋锁来保护这个增1操作，因为任何与我们竞争的设备也必须使用互锁的增1或减1函数来更改这个值。
  2. 因为CheckBusyAndStall函数需要以原子方式操作，所以我们应该先获取队列的自旋锁。
  3. CurrentIrp为非NULL则代表设备正忙于处理队列中的某个请求。
  4. 如果设备当前空闲，则该语句启动停止的队列，这能防止设备后来变为忙。

排队IRP

当某派遣函数调用StartPacket时，一个IRP就被加入到队列中：

VOID NTAPI StartPacket(PDEVQUEUE pdq, PDEVICE_OBJECT fdo, PIRP Irp, PDRIVER_CANCEL cancel)
{                  					// StartPacket
  KIRQL oldirql;										<--1
  KeAcquireSpinLock(&pdq->lock, &oldirql);
  NTSTATUS abortstatus = pdq->abortstatus;							<--2
  if (abortstatus)
  {                					// aborting all requests now
    KeReleaseSpinLock(&pdq->lock, oldirql);
    Irp->IoStatus.Status = abortstatus;
    IoCompleteRequest(Irp, IO_NO_INCREMENT);
  }                					// aborting all requests now
  else if (pdq->CurrentIrp || pdq->stallcount)							<--3
  {                					// queue this irp
    IoSetCancelRoutine(Irp, cancel);								<--4
    if (Irp->Cancel && IoSetCancelRoutine(Irp, NULL))
    {              					// IRP has already been cancelled
      KeReleaseSpinLock(&pdq->lock, oldirql);
      Irp->IoStatus.Status = STATUS_CANCELLED;
      IoCompleteRequest(Irp, IO_NO_INCREMENT);
    }              					// IRP has already been cancelled
    else
    {              					// queue IRP
      InsertTailList(&pdq->head, &Irp->Tail.Overlay.ListEntry);					<--5
      KeReleaseSpinLock(&pdq->lock, oldirql);
    }              					// queue IRP
  }                					// queue this irp
  else												<--6
  {                					// start this irp
    pdq->CurrentIrp = Irp;
    KeReleaseSpinLock(&pdq->lock, DISPATCH_LEVEL);
    (*pdq->StartIo)(fdo, Irp);
    KeLowerIrql(oldirql);
  }                					// start this irp
}                  					// StartPacket
  1. 获取自旋锁可以使我们访问DEVQUEUE中的域不受其它支持例程的干扰，主要是StartNextPacket，该函数试图访问同一个队列。
  2. 如我以前描述的，我们有时需要拒绝到来的IRP。如果abortstatus为非零，我们就完成该IRP。我们的调用者将得到STATUS_PENDING，所以需要由我们自己来做完成操作。
  3. 如果设备当前正忙，或者驱动程序的其它例程停止了设备队列，我们需要排队该IRP以便以后处理。
  4. 我们可能与IoCancelIrp的一个实例竞争，该函数试图取消这个IRP。我们先用IoSetCancelRoutine例程在该IRP中安装我们自己的取消例程，该例程执行一个互锁的数据交换。然后我们测试Cancel标志。如果Cancel标志被设置，我们的取消例程可能被调用，也可能没被调用，这取决于我们的代码与IoCancelIrp执行的确切顺序。如果我们的取消例程被调用，则第二次调用IoSetCancelRoutine将返回NULL；这样我们就能把该IRP加入队列，并利用取消例程立即提取该IRP并完成它。如果我们的取消例程没有被调用，我们将在这里完成该IRP。
  5. 这里我们真正地排队该IRP。IRP的Tail.Overlay.ListEntry域被设计成这样使用。
  6. 最后一种情况是，当队列处于READY状态并且设备当前不忙。我们设置DEVQUEUE中的CurrentIrp指针，释放自旋锁，然后在DISPATCH_LEVEL级上调用StartIo例程。

拥有自旋锁的程序可以修改CurrentIrp，因此我们确信在测试CurrentIrp时不会得到含糊的结果。另一方面，停止计数器在StallRequests中被增1却没有自旋锁的保护。很明显，问题发生的唯一机会就是当计数器被从0增到1时，由于我们并不关心计数器有什么非零值，因此我们的例程可能与此同时执行。假设有一个StallRequests调用要把计数器的值从0增到1，如果我们阻止增1操作而发现计数器的值为0，我们将前进并启动下一个请求。因为StallRequests的调用者愿意使设备处于忙状态(如果调用者不愿意，它应使用CheckBusyAndStall函数)，所以这样做可以。如果我们发现计数器已增到1，我们就排队该IRP，这也符合StallRequests调用者的意图。

出队IRP

出队大部分IRP的函数是StartNextPacket，该函数在一个DPC例程中被调用：

PIRP NTAPI StartNextPacket(PDEVQUEUE pdq, PDEVICE_OBJECT fdo)
{
  KIRQL oldirql;										<--1
  KeAcquireSpinLock(&pdq->lock, &oldirql));
  PIRP CurrentIrp = (PIRP) InterlockedExchangePointer (&pdq->CurrentIrp, NULL);			<--2
  if (CurrentIrp)										<--3
    KeSetEvent(&pdq->evStop, 0, FALSE);
  while (!pdq->stallcount && !pdq->abortstatus && !IsListEmpty(&pdq->head))			<--4
  {
    PLIST_ENTRY next = RemoveHeadList(&pdq->head);						<--5
    PIRP Irp = CONTAINING_RECORD(next, IRP, Tail.Overlay.ListEntry);
    if (!IoSetCancelRoutine(Irp, NULL))								<--6
    {
      InitializeListHead(&Irp->Tail.Overlay.ListEntry);
      continue;
    }
    pdq->CurrentIrp = Irp;
    KeReleaseSpinLockFromDpcLevel(&pdq->lock);							<--7
    (*pdq->StartIo)(fdo, Irp);
    KeLowerIrql(oldirql);
    return CurrentIrp;
  }
  KeReleaseSpinLock(&pdq->lock, oldirql);
  return CurrentIrp;
}
  1. 我们首先获取该队列的自旋锁，以便我们能不受干扰地访问队列对象的内部结构。
  2. 我们将把当前IRP的地址作为返回值，并且还设置CurrentIrp指针为NULL。由于使用了自旋锁，我们不必使用原子操作来提取并置空CurentIrp的值。
  3. 某些例程可能在WaitForCurrentIrp中等待当前请求的完成。调用KeSetEvent将满足那些等待。
  4. 这一系列检测决定我们是否能出队一个请求。队列不是停止的，也不处于REJECTING状态，队列至少应包含一个请求，这样我们调用RemoveHeadList才有意义。
  5. 这行代码删除队列中最旧的一个IRP。
  6. 置空IRP中的取消例程指针将阻止IoCancelIrp企图取消该IRP。如果试图取消该IRP的IoCancelIrp函数此时正在另一个CPU上运行，我们应该从IoSetCancelRoutine函数得到NULL返回值。当CancelRequest又获得控制时，它将需要获取队列的自旋锁以便继续进一步处理。在这一点上，它将盲目地从当前队列中删除该IRP。在该IRP自己的连接域上调用InitializeListHead将使CancelRequest在做进一步处理时更安全。
  7. 在这里我们把刚出队的IRP送到StartIo进行处理。

RestartRequests函数平衡StallRequests调用或CheckBusyAndStall调用。它把队列的第一个IRP送到StartIo例程，幸好，我们可以利用StartNextPacket：

VOID NTAPI RestartRequests(PDEVQUEUE pdq, PDEVICE_OBJECT fdo)
{
  if (InterlockedDecrement(&pdq->stallcount) > 0)
    return;
  StartNextPacket(pdq, fdo);
}

取消IRP

StartPacket寄存了由其调用者提供的取消例程，该例程然后又把工作委托给队列的CancelRequest函数：

VOID NTAPI CancelRequest(PDEVQUEUE pdq, PIRP Irp)
{
  KIRQL oldirql = Irp->CancelIrql;
  IoReleaseCancelSpinLock(DISPATCH_LEVEL);
  KeAcquireSpinLockAtDpcLevel(&pdq->lock);
  RemoveEntryList(&Irp->Tail.Overlay.ListEntry);
  KeReleaseSpinLock(&pdq->lock, oldirql);
  Irp->IoStatus.Status = STATUS_CANCELLED;
  IoCompleteRequest(Irp, IO_NO_INCREMENT);
}

我们在拥有全局取消自旋锁的情况下被调用，而我们立刻又释放了这个自旋锁。这之后，所有的保护工作由队列的自旋锁来代替。当IoCancelIrp调用IoAcquireCancelSpinLock时，它在IRP的CancelIrql域保存了以前的中断请求级，我们最后需要回复到那个IRQL，因此，我们把这个值保存到oldirql变量中。

注意
IoCancelIrp的调用者有责任确保该IRP没有被完成。

IRP_MJ_CLEANUP也能取消IRP，该请求在我们收到IRP_MJ_CLOSE请求前收到。DEVQUEUE的CleanupRequests函数几乎与标准模型中的DispatchCleanup例程完全相同。两者仅有的不同之处是CleanupRequests函数需要获取队列的自旋锁：

VOID NTAPI CleanupRequests(PDEVQUEUE pdq, PFILE_OBJECT fop, NTSTATUS status)
{
  LIST_ENTRY cancellist;								<--1
  InitializeListhead(&cancellist);
  KIRQL oldirql;
  KeAcquireSpinLock(&pdq->lock, &oldirql);
  PLIST_ENTRY first = &pdq->head;
  PLIST_ENTRY next;
  for (next = first->Flink; next != first; )						<--2
  {
    PIRP Irp = CONTAINING_RECORD(next, IRP, Tail.Overlay.ListEntry);
    PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);			<--3
    PLIST_ENTRY current = next;
    next = next->Flink;									<--4
    if (fop && stack->FileObject != fop)
      continue;
    if (!IoSetCancelRoutine(Irp, NULL))							<--5
      continue;
    RemoveEntryList(current);								<--6
    InsertTailList(&cancellist, next);
  }
  KeReleaseSpinLock(&pdq->lock, oldirql);						<--7
  while (!IsListEmpty(&cancellist))
  {
    next = RemoveHeadList(&cancellist);
    PIRP Irp = CONTAINING_RECORD(next, IRP, Tail.Overlay.ListEntry);
    Irp->IoStatus.Status = status;
    IoCompleteRequest(Irp, IO_NO_INCREMENT);
  }
}
  1. 我们的策略是，在队列自旋锁的保护下把需要删除的IRP移到我们私有的队列中。因此，第一步就是初始化这个私有队列并获取自旋锁。
  2. 该循环遍历整个队列，直到返回队列头。注意，for语句的第三个子句没有循环自增步骤。
  3. 如果我们为处理IRP_MJ_CLEANUP请求而被调用，则fop参数就是将要被关闭的文件对象的地址。我们要排除同属于这个文件对象的IRP，因此我们需要先找到该IRP的堆栈单元。
  4. 如果我们决定从队列中删除这个IRP，我们就不能方便地找到主队列中的下一个IRP，因此我们在这里使用这样的语句，这里也执行了循环的自增步骤。
  5. 这个尤其明智的语句是由Jamie Hanrahan添加的。我们需要知道是否有人试图取消我们当前正在寻找的IRP。它们仅能到达CancelRequest试图获取自旋锁的地方。然而在到达那里之前，它们必须执行IoCancelIrp中置空取消例程指针的语句。如果我们用IoSetCancelRoutine函数发现那个指针为空，我们就能确信有人真的要取消这个IRP。因此我们就在这个循环中简单地跳过该IRP，稍后我们再允许取消例程完成该IRP。
  6. 在这里我们把IRP从主队列中取出并放入我们自己的队列。
  7. 一旦完成IRP的移动，我们就可以释放自旋锁。然后我们取消找到的所有IRP。

CleanupRequests可以在驱动程序的任何地方被调用。例如，从我以前提到的IRP_MN_REMOVE_DEVICE处理程序中调用，它给出一个NULL文件对象指针(指出清除所有IRP)和一个为STATUS_DELETE_PENDING的状态代码。

等待当前的IRP

IRP_MN_STOP_DEVICE的处理程序需要等待当前IRP的完成，调用WaitForCurrentIrp：

VOID NTAPI WaitForCurrentIrp(PDEVQUEUE pdq)
{
  KeClearEvent(&pdq->evStop);								<--1
  ASSERT(pdq->stallcount != 0);								<--2
  KIRQL oldirql;									<--3
  KeAcquireSpinLock(&pdq->lock, &oldirql);
  BOOLEAN mustwait = pdq->CurrentIrp != NULL;
  KeReleaseSpinLock(&pdq->lock, oldirql);
  if (mustwait)
    KeWaitForSingleObject(&pdq->evStop, Executive, KernelMode, FALSE, NULL);
}
  1. 每次调用StartNextPacket函数，它都使evStop事件进入信号态。为了不使我们的等待被错误地满足，我们先清除该事件。
  2. 不先停止队列就调用该函数是无意义的。此外，StartNextPacket将启动下一个IRP，因此，设备将再次进入忙状态。
  3. 如果设备当前忙，我们将在evStop事件上等待，直到某段代码调用了StartNextPacket使该事件进入信号态。我们需要用自旋锁保护对CurrentIrp的探测操作，通常，测试一个指针是否为NULL并不是一个原子事件。如果指针现在为NULL，以后它就不能被改变，因为我们假定队列是停止的。

放弃请求

设备的突然删除要求我们立即停止所有试图触及硬件的未决IRP。另外，我们还要能拒绝所有后来的IRP。AbortRequests函数可以完成这个任务：

VOID NTAPI AbortRequests(PDEVQUEUE pdq, NTSTATUS status)
{
  pdq->abortstatus = status;
  CleanupRequests(pdq, NULL, status);
}

abortstatus的设置将使队列进入REJECTING状态，这样所有后来的IRP都将被拒绝，其返回的状态值由我们的调用者提供。在这里，以NULL文件对象指针调用CleanupRequests函数将清空整个队列。

我们不应该碰任何当前在硬件上活动的IRP。不使用HAL访问硬件的驱动程序，例如USB驱动程序，将依靠hub和主控制器驱动程序来废弃当前IRP。而使用HAL的驱动程序可能会挂起系统，或至少使IRP进入不稳定状态，因为不存在的硬件不会生成中断使IRP完成。为了处理这种情况，你应该调用AreRequestsBeingAborted：

NTSTATUS AreRequestsBeingAborted(PDEVQUEUE pdq)
{
  return pdq->abortstatus;
}

在这个例程中使用队列自旋锁是愚蠢的。假定我们要以线程安全和多处理器安全的方式捕获abortstatus的瞬间值，那么一旦我们释放该自旋锁，这个值就将变成无用值。

注意
如果你的设备可以以这种方式删除，即简单地挂起未决的请求，你应该有一个看门狗定时器(Watchdog Timer)在运行，它可以在一段时间后杀死这些IRP。看门狗定时器见第九章。

有时我们需要恢复上一个AbortRequest调用的作用。AllowRequests可以做到这一点：

VOID NTAPI AllowRequests(PDEVQUEUE pdq)
{
  pdq->abortstatus = (NTSTATUS) 0;
}


其它配置功能

到现在，我已经讲完了关于写一个硬件设备驱动程序所需的全部重要概念。为了结束本章，我将讨论两个不太重要的副功能码IRP_MN_FILTER_RESOURCE_REQUIREMENTS和IRP_MN_DEVICE_USAGE_NOTIFICATION，在实际的驱动程序中你有时需要处理这两种请求。然后，我将讲述如何写一个小的总线驱动程序以支持非标准控制器或多功能设备。最后，我还将提到关于如何寄存PnP事件的接收通知，这些事件可以影响你自己以外的其它设备。

注意
其它PnP请求我没有讨论，因为DDK参考手册已经给出详细的解释，没有必要重述。例如，向其它驱动程序输出一个直接调用接口可能有潜在的用途，但在一般情况下没有必要这样做。因此，我不将为IRP_MN_QUERY_INTERFACE提供例子和解释。我将在第八章“电源管理”中再讨论IRP_MN_QUERY_CAPABILITIES，因为那里比较适合。

过滤资源需求

有时PnP管理器会错误地报告驱动程序的资源需求。这可能由于硬件或固件上存在着bug，也可能因为某个遗留设备的INF文件中出现错误，或者是其它原因。系统以IRP_MN_FILTER_RESOURCE_REQUIREMENTS请求的形式提供了一个切入点，它在PnP管理器将要分配资源前 给你提供一个机会来检测或修改资源列表。

当你收到一个过滤请求时，其堆栈单元的Parameters联合的FilterResourceRequirements子结构指向一个IO_RESOURCE_REQUIREMENTS_LIST数据结构，该结构列出了设备的资源需求。另外，如果你上层面的任何驱动程序处理了该IRP并修改了其中的资源需求，则该IRP的IoStatus.Information域将指向第二个IO_RESOURCE_REQUIREMENTS_LIST，你应该使用这个数据结构。策略是这样的：如果你想向当前的资源列表中加入资源，应该在派遣函数中做。你先把该IRP同步地传向堆栈下层，即，使用ForwardAndWait方法启动一个设备请求。当你最后有获得控制时，就可以修改列表中的任何资源描述。

下面是一个演示过滤处理机制的简单函数：

NTSTATUS HandleFilterResources(PDEVICE_OBJECT fdo, PIRP Irp)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
  PIO_RESOURCE_REQUIREMENTS_LIST original = 								<--1
    stack->Parameters.FilterResourceRequirements.IoResourceRequirementList;
  PIO_RESOURCE_REQUIREMENTS_LIST filtered = 								<--2
    (PIO_RESOURCE_REQUIREMENTS_LIST) Irp->IoStatus.Information;
  PIO_RESOURCE_REQUIREMENTS_LIST source = filtered ? filtered : original;				<--3
  if (source->AlternativeLists != 1)									<--4
    return DefaultPnpHandler(fdo, Irp);
  ULONG sizelist = source->ListSize;									<--5
  PIO_RESOURCE_REQUIREMENTS_LIST newlist = (PIO_RESOURCE_REQUIREMENTS_LIST)
    ExAllocatePool(PagedPool, sizelist + sizeof(IO_RESOURCE_DESCRIPTOR));
  if (!newlist)
    return DefaultPnpHandler(fdo, Irp);
  RtlCopyMemory(newlist, source, sizelist);
  newlist->ListSize += sizeof(IO_RESOURCE_DESCRIPTOR);
  PIO_RESOURCE_DESCRIPTOR resource = &newlist->List[0].Descriptors[newlist->List[0].Count++];		<--6
  RtlZeroMemory(resource, sizeof(IO_RESOURCE_DESCRIPTOR));
  resource->Type = CmResourceTypeDevicePrivate;
  resource->ShareDisposition = CmResourceShareDeviceExclusive;
  resource->u.DevicePrivate.Data[0] = 42;
  Irp->IoStatus.Information = (ULONG_PTR) newlist;							<--7
  if (filtered && filtered != original)
    ExFreePool(filtered);
  NTSTATUS status = ForwardAndWait(fdo, Irp);								<--8
  if (NT_SUCCESS(status))
  {
    // stuff
  }
  Irp->IoStatus.Status = status;									<--9
  IoCompleteRequest(Irp, IO_NO_INCREMENT);
  return status;
}
  1. 该请求的参数包含一个I/O资源需求列表。这些信息得自设备的配置空间、注册表、或总线驱动程序能发现的任何地方。
  2. 高级驱动程序也许已经过滤了这些资源，并加入了另一个列表。如果是这样，它们就设置IoStatus.Information域，使其指向扩展后的需求列表。
  3. 如果这里没有过滤后的列表，我们就扩展原始的列表。如果有过滤后的列表，我们就扩展过滤后的列表。
  4. 理论上，将存在几个可选的需求列表，但处理这些列表超过了这个简单例子的能力。
  5. 在向下传递请求前，我们需要加入资源。首先我们分配一个新的需求列表并把旧需求复制到新表。
  6. 我们小心地保留已存在的资源描述顺序，然后加入自己的资源描述。在这个例子中，我们加入一个我们驱动程序私有的资源。
  7. 我们在IRP的IoStatus.Information域中保存扩展后的需求列表的地址，该域将被低级驱动程序和PnP系统查看。如果我们扩展的是已过滤的列表，我们需要释放被旧表占用的内存。
  8. 我们使用ForwardAndWait辅助函数下传该请求。在该IRP向上返回时我们不修改其任何资源描述，所以我们在此应调用DefaultPnpHandler，并同时传递下层的返回状态。
  9. 当完成该IRP时，不论我们指定成功或失败，都必须注意不要修改I/O状态块中的Information域：该域可能存放一个指向某驱动程序资源需求列表的指针，而这个列表也可能是我们添加的。PnP管理器将释放额外表结构所占用的内存。

设备用途通知

磁盘驱动程序(以及磁盘控制器驱动程序)有时需要了解外来的关于它们如何被操作系统使用的信息，IRP_MN_DEVICE_USAGE_NOTIFICATION请求提供了获取这些信息的手段。在IRP堆栈单元的Parameters.UsageNotification子结构中包含了这样两个参数，见表6-4。InPath值(Boolean)指出设备是否处于支持用途信息的设备路径上，Type值指出几种可能的特殊用法。

表6-4. I/O堆栈单元中Parameters.UsageNotification子结构中的域

参数 | 描述
InPath | 如果设备处于Type指定的路径中，则为TRUE，否则为FALSE
Type | 用法类型

在通知请求的子派遣例程中，你应该用一个switch语句来区分各种通知。在大多数情况下，你将把该IRP下传。下面是这个子派遣例程的框架代码：

NTSTATUS HandleUsageNotification(PDEVICE_OBJECT fdo, PIRP Irp)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
  DEVICE_USAGE_NOTIFICATION_TYPE type = stack->Parameters.UsageNotification.Type;
  BOOLEAN inpath = stack->Parameters.UsageNotification.InPath;
  switch (type)
  {
  case DeviceUsageTypeHibernation:
    ...
    Irp->IoStatus.Status = STATUS_SUCCESS;
    break;
  case DeviceUsageTypeDumpFile:
    ...
    Irp->IoStatus.Status = STATUS_SUCCESS;
    break;
  case DeviceUsageTypePaging:
    ...
    Irp->IoStatus.Status = STATUS_SUCCESS;
    break;
  default:
    break;
  }
  return DefaultPnpHandler(fdo, Irp);
}

仅当你明确地认为该通知是送往总线驱动程序的信号时才设置其Status域为STATUS_SUCCESS。如果你没有设置STATUS_SUCCESS，则总线驱动程序将假设你并不知道，因此也不处理该通知。

你应该知道你的设备不能支持某种用途。例如，假设你的磁盘设备不能用来存储休眠文件，但如果该IRP指定InPath值，你应该使该IRP失败：

...
  case DeviceUsageTypeHibernation:
    if (inpath)
      return CompleteRequest(Irp, STATUS_UNSUCCESSFUL, 0);

下面我将简要地描述一下每种使用类型。

DeviceUsageTypePaging

如果该通知的InPath为TRUE则指出将有一个内存交换文件在这个设备上打开。如果为FALSE则指出有一个内存交换文件已被关闭。通常，你应该为被通知的内存交换文件维护一个计数器。当交换文件仍在活动时，你应该使STOP或REMOVE的查询失败。另外，当你收到第一个分页通知时，应该确保你的READ、WRITE、DEVICE_CONTROL、PNP、POWER派遣例程锁定在内存中。你还应该清除设备对象中的DO_POWER_PAGABLE标志以强制电源管理器在DISPATCH_LEVEL级上向你发送电源管理IRP。为了安全，我还建议你置空任何已经寄存的空闲通知。(检测空闲状态的讨论见第八章)

注意
在第八章“电源管理”中我将讨论如何在设备对象中设置DO_POWER_PAGABLE标志。当你拥有某个设备对象时，如果该设备对象设置了这个标志，你应该确保决不清除这个标志。你仅应该在完成例程中，在低级驱动程序已清除其自己的标志后，再清除这个标志。总之，你需要一个完成例程，因为如果IRP在低级驱动程序中失败，你需要恢复在派遣例程中所做的任何操作。

DeviceUsageTypeDumpFile

如果该通知的InPath为TRUE则指出设备已被选定用于保存系统崩溃时的dump文件。如果为FALSE则取消这个选定。应该用一个计数器来保存TRUE通知个数减去FALSE通知个数的差值。当计数器不为0时：

  • 应确保你的电源管理代码决不使设备超过D0，或进入全部打开状态。
  • 应避免寄存设备的空闲检测，并置空任何未处理的寄存操作。
  • 应确保你的驱动程序在处理停止和删除查询请求时返回失败状态。

DeviceUsageTypeHibernation

如果该通知的InPath为TRUE则指出设备被选定用于保存休眠文件。如果为FALSE则取消这个选定。应该用一个计数器来保存TRUE通知个数减去FALSE通知个数的差值。你对指定了PowerSystemHibernate的系统电源管理IRP的响应应与通常不同，因为你的设备将用于瞬间记录休眠文件。关于磁盘驱动程序的这方面细节已经超出本书的范围。

控制器和多功能设备

有两类设备不能整洁地匹配到PnP框架中。它们是控制器设备：它管理一组子设备；或多功能设备：它在一个卡上提供多种功能。对这两种设备的管理需要加入对使用独立I/O资源的多设备对象的创建过程。

在Windows 2000中支持PCI、PCMCIA、USB设备十分容易，因为它们遵从各自的多功能设备标准。PCI总线自动识别PCI多功能卡。对于PCMCIA多功能设备，你可以按照DDK中的详细指导把MF.SYS做为你的多功能卡的功能驱动程序；而MF.SYS然后将枚举你卡上的功能设备，并使PnP管理器装入单独的功能驱动程序。通常USB hub驱动程序也为单配置设备上的每个接口装入一个独立的功能驱动程序。

除了USB设备，原始版本的Windows 98缺乏象Windows 2000那样的对多功能设备的支持。在Windows 98中，为了处理控制器设备或多功能设备，或者处理非标准设备，你需要凭借更英勇的手段。你需要为你的主设备提供一个功能驱动程序，还要分别为每个连接到主设备上的子设备提供一个功能驱动程序。主设备的功能驱动程序就象一个小型的总线驱动程序，它枚举每个子设备并为PnP请求和电源管理请求提供默认处理。而写一个完整的总线驱动程序将是一个巨大的工作，我并不打算在这里描述其过程。但我要描述一下枚举子设备的基本机制。你可以利用这些信息写控制器设备或多功能设备的驱动程序，不过这样的驱动程序并不不符合Microsoft提供的标准模型。

整体结构

在第二章中，图2-2显示了当一个父设备，如总线驱动程序，含有子设备时的拓扑结构。控制器和多功能设备使用与此类似的拓扑结构。父设备插入到某个标准总线，该标准总线的驱动程序检测到这个父设备，然后PnP管理器就象配置任何普通设备一样配置这个父设备，当它启动该父设备后，它又向这个设备发送了一个带有IRP_MN_QUERY_DEVICE_RELATIONS副功能码的PnP请求，以了解该父设备的总线关系。实际上，这个查询发生于所有设备，因为PnP管理器并不知道设备是否含有子设备。

为了响应总线关系请求，父设备的功能驱动程序定位或创建附加的设备对象，每个这样的对象都将成为子设备的PDO。然后，PnP管理器继续装入子设备对应的功能驱动程序和过滤器驱动程序，因此最后你就看到了象图2-2这样的拓扑结构。

父设备的驱动程序不得不扮演两个角色。它是控制器或多功能设备的FDO，它还是子设备的PDO。在FDO角色中，它以功能驱动程序的方式处理PnP和电源管理请求。在PDO角色中，它的行为就象最后处理PnP和电源管理请求的驱动程序。

创建子设备对象

在这条路的某个地方，我们可能要处理IRP_MN_START_DEVICE请求，而父设备驱动程序，以其FDO角色，需要为其子设备创建PDO，并需要记录下这些PDO以备以后使用。在这个早期阶段只有一个复杂地方：FDO和所有PDO都属于同一个驱动程序对象，这意味着任何指向这些设备对象的IRP都将进入唯一一组派遣例程。驱动程序需要区别处理FDO和PDO的PnP与电源管理请求。因此，你需要为派遣例程提供一个方便的方式来区别FDO和某个子设备的PDO。我处理这个问题的方法是定义了两个有公共开头的设备扩展结构，如下：

// The FDO extension:

typedef struct _DEVICE_EXTENSION {
  ULONG flags;
  ...
} DEVICE_EXTENSION, *PDEVICE_EXTENSION;

// The PDO extension:

typedef struct _PDO_EXTENSION {
  ULONG flags;
  ...
} PDO_EXTENSION, *PPDO_EXTENSION;

// The common part:

typedef struct _COMMON_EXTENSION {
  ULONG flags;
} COMMON_EXTENSION, *PCOMMON_EXTENSION;

#define ISPDO 0x00000001

IRP_MJ_PNP的派遣例程如下：

NTSTATUS DispatchPnp(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
  PCOMMON_EXTENSION pcx = (PCOMMON_EXTENSION) DeviceObject->DeviceExtension;
  if (pcx->flags & ISPDO)
    return DispatchPnpPdo(DeviceObject, Irp);
  else
    return DispatchPnpFdo(DeviceObject, Irp);
}

在随书光盘上有一个例子驱动程序，MULFUNC，它是一个十分简陋的多功能设备：它仅有两个子设备，我称它们为A和B。MULFUNC在响应IRP_MN_START_DEVICE请求时创建了A和B的PDO，下面是它执行的代码(真正的代码含有更多的错误检测)：

NTSTATUS StartDevice(PDEVICE_OBJECT fdo, ...)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  CreateChild(pdx, CHILDTYPEA, &pdx->ChildA);							<--1
  CreateChild(pdx, CHILDTYPEB, &pdx->ChildB);
  return STATUS_SUCCESS;
}

NTSTATUS CreateChild(PDEVICE_EXTENSION pdx, ULONG flags, PDEVICE_OBJECT* ppdo)
{
  PDEVICE_OBJECT child;
  IoCreateDevice(pdx->DriverObject,								<--2
         sizeof(PDO_EXTENSION),
         NULL,
         FILE_DEVICE_UNKNOWN,
         FILE_AUTOGENERATED_DEVICE_NAME,
         FALSE,
         &child);
  PPDO_EXTENSION px = (PPDO_EXTENSION) child->DeviceExtension;
  px->flags = ISPDO | flags;
  px->DeviceObject = child;
  px->Fdo = pdx->DeviceObject;
  child->Flags &= ~DO_DEVICE_INITIALIZING;
  *ppdo = child;
  return STATUS_SUCCESS;
}
  1. CHILDTYPEA和CHILDTYPEB是flags成员的附加标志位。如果你正写一个真正的总线驱动程序，你不应在这里创建子设备的PDO，你应该为响应IRP_MN_QUERY_DEVICE_RELATIONS请求而枚举你的实际硬件，然后创建PDO。
  2. 我们在这里创建一个命名的设备对象，但我们要求系统自动生成名字，我们已在DeviceCharacteristics的参数中指定了FILE_AUTOGENERATED_DEVICE_NAME标志。

创建过程的最后结果是两个指向设备对象(ChildA和ChildB)的指针，这两个指针存在于父设备FDO的设备扩展中。

设备向PnP管理器告知自己含有子设备

PnP管理器通过向每个设备发送类型码为BusRelations的IRP_MN_QUERY_DEVICE_RELATIONS请求来了解其是否含有子设备。以FDO角色，父设备使用如下代码响应这个请求：

NTSTATUS HandleQueryRelations(PDEVICE_OBJECT fdo, PIRP Irp)
{
  PDEVICE_EXTENSION pdx = ...;
  PIO_STACK_LOCATION stack = ...;
  if (stack->Parameters.QueryDeviceRelations.Type != BusRelations)				<--1
    return DefaultPnpHandler(fdo, Irp);
  PDEVICE_RELATIONS newrel = (PDEVICE_RELATIONS)						<--2
    ExAllocatePool(PagedPool, sizeof(DEVICE_RELATIONS) + sizeof(PDEVICE_OBJECT));
  newrel->Count = 2;
  newrel->Objects[0] = pdx->ChildA;
  newrel->Objects[1] = pdx->ChildB;
  ObReferenceObject(pdx->ChildA);								<--3
  ObReferenceObject(pdx->ChildB);
  Irp->IoStatus.Information = (ULONG_PTR) newrel;						<--4
  Irp->IoStatus.Status = STATUS_SUCCESS;
  return DefaultPnpHandler(fdo, Irp);
}
  1. 除了我们关心的总线关系之外，该IRP还能涉及多种类型的关系。我们简单地把该查询请求送到总线驱动程序去处理。
  2. 这里，我们分配了能包含两个设备对象指针的结构。DEVICE_RELATIONS结构的最后是一个一维数组，所以我们仅需加上一个附加指针的大小。
  3. 我们调用ObReferenceObject来增加与每个设备对象关联的参考计数，这些参考计数存在于DEVICE_RELATIONS数组中。PnP管理器会在适当的时间解除对象的参考。
  4. 我们需要把该请求下传到真正的总线驱动程序，该总线驱动程序或某个低级过滤器了解我们不知道的额外事实。该IRP使用一个不寻常的协议来执行下传操作和完成操作。如果你真正处理了该IRP，你应该象这样设置IoStatus；否则，不要碰它。注意这里的Information域用于保存DEVICE_RELATIONS结构的指针。在其它情况下，Information域总是保存一个数值。

在前面的代码片段中，我省略了一个因素。上层过滤器也许已经在IRP的IoStatus.Information域中安装了一个设备对象列表。我们必须扩展这个表，加入我们自己的设备对象指针。

在设备启动时PnP管理器自动发送一个总线关系查询请求。你可以通过调用下面服务函数强制发出该请求：

IoInvalidateDeviceRelations(pdx->Pdo, BusRelations);

你应该仅在检测到你某个子设备到来或离开时才做这个调用。

以PDO角色处理PnP请求

戴上PDO驱动程序的帽子，则父设备驱动程序必须以与功能驱动程序十分不同的方式处理PnP请求。表6-5列出这些请求。

表6-5. PDO驱动程序要处理的PnP请求

PnP请求 | 处理方式
IRP_MN_START_DEVICE | 成功
IRP_MN_QUERY_REMOVE_DEVICE | 成功
IRP_MN_REMOVE_DEVICE | 成功
IRP_MN_CANCEL_REMOVE_DEVICE | 成功
IRP_MN_STOP_DEVICE | 成功
IRP_MN_QUERY_STOP_DEVICE | 成功
IRP_MN_CANCEL_STOP_DEVICE | 成功
IRP_MN_QUERY_DEVICE_RELATIONS | 专门处理
IRP_MN_QUERY_INTERFACE | 忽略
IRP_MN_QUERY_CAPABILITIES | 委派
IRP_MN_QUERY_RESOURCES | 成功
IRP_MN_QUERY_RESOURCE_REQUIREMENTS | 成功
IRP_MN_QUERY_DEVICE_TEXT | 成功
IRP_MN_FILTER_RESOURCE_REQUIREMENTS | 成功
IRP_MN_READ_CONFIG | 委派
IRP_MN_WRITE_CONFIG | 委派
IRP_MN_EJECT | 委派
IRP_MN_SET_LOCK | 委派
IRP_MN_QUERY_ID | 专门处理
IRP_MN_QUERY_PNP_DEVICE_STATE | 委派
IRP_MN_QUERY_BUS_INFORMATION | 委派
IRP_MN_DEVICE_USAGE_NOTIFICATION | 委派
IRP_MN_SURPRISE_REMOVAL | 成功
Any other | 忽略

通常，父设备驱动程序仅简单地使大多数PnP请求成功，并不做特别的处理：

NTSTATUS SucceedRequest(PDEVICE_OBJECT pdo, PIRP Irp)
{
  Irp->IoStatus.Status = STATUS_SUCCESS;
  IoCompleteRequest(Irp, IO_NO_INCREMENT);
  return STATUS_SUCCESS;
}

在上面这个短例程中有一点值得注意，即它不需要修改IRP的IoStatus.Information域。PnP管理器总在发送IPR前以某种方式初始化该域，在某些情况下，该域可以被过滤器驱动程序或功能驱动程序修改为指向某个数据结构。PDO驱动程序不应该修改这个域。

父设备驱动程序可以忽略某些IRP。忽略一个IRP与使该IRP失败并返回错误代码类似，除了驱动程序不改变IRP的status域。

NTSTATUS IgnoreRequest(PDEVICE_OBJECT pdo, PIRP Irp)
{
  NTSTATUS status = Irp->IoStatus.Status;
  IoCompleteRequest(Irp, IO_NO_INCREMENT);
  return status;
}

一个缩小的总线驱动程序，就象我们正讨论的这个，它可以把某些PnP请求委派给父设备FDO下面的真正总线驱动程序去完成。在这种情况中的委派并不是仅仅调用IoCallDriver那样简单，因为此时我们是以PDO驱动程序的角色接收IRP，但I/O堆栈通常都被用光了，因此我们必须创建一个转发(repeater)IRP，然后再把它发送到我们以FDO驱动程序角色出现的驱动程序堆栈中：

NTSTATUS RepeatRequest(PDEVICE_OBJECT pdo, PIRP Irp)
{
  PPDO_EXTENSION pdx = (PPDO_EXTENSION) pdo->DeviceExtension;
  PDEVICE_OBJECT fdo = pdx->Fdo;
  PDEVICE_EXTENSION pfx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);

  PDEVICE_OBJECT tdo = IoGetAttachedDeviceReference(fdo);					<--1
  PIRP subirp = IoAllocateIrp(tdo->StackSize + 1, FALSE);

  PIO_STACK_LOCATION substack = IoGetNextIrpStackLocation(subirp);				<--2
  substack->DeviceObject = tdo;
  substack->Parameters.Others.Argument1 = (PVOID) Irp;

  IoSetNextIrpStackLocation(subirp);								<--3
  substack = IoGetNextIrpStackLocation(subirp);
  RtlCopyMemory(substack, stack, FIELD_OFFSET(IO_STACK_LOCATION, CompletionRoutine));
  substack->Control = 0;
  BOOLEAN needsvote = <I'll explain later>;							<--4
  IoSetCompletionRoutine(subirp, OnRepeaterComplete, (PVOID) needsvote, TRUE, TRUE, TRUE);
  subirp->IoStatus.Status = STATUS_NOT_SUPPORTED;						<--5
  IoMarkIrpPending(Irp);
  IoCallDriver(tdo, subirp);
  return STATUS_PENDING
}

NTSTATUS OnRepeaterComplete(PDEVICE_OBJECT tdo, PIRP subirp, PVOID needsvote)
{
  ObDereferenceObject(tdo);									<--6
  PIO_STACK_LOCATION substack = IoGetCurrentIrpStackLocation(subirp);
  PIRP Irp = (PIRP) substack->Parameters.Others.Argument1;					<--7
  if (subirp->IoStatus.Status == STATUS_NOT_SUPPORTED)						<--8
  {
    if (needsvote)
      Irp->IoStatus.Status = STATUS_UNSUCCESSFUL;
  }
  else
    Irp->IoStatus = subirp->IoStatus;
  IoFreeIrp(subirp);										<--9
  IoCompleteRequest(Irp, IO_NO_INCREMENT);							<--10
  return STATUS_MORE_PROCESSING_REQUIRED;							<--11
}
  1. 我们将把这个转发IRP发送到我们FDO所属堆栈的最顶层过滤器驱动程序。该服务例程返回顶级设备对象地址，同时它又增加了该对象参考计数。
  2. 分配IRP时，我们创建了一个额外的堆栈单元，在这个堆栈单元中，你可以为将要安装的完成例程记录某些上下文信息。在这个额外堆栈单元中放置的DeviceObject指针将成为完成例程的第一个参数。
  3. 这里，我们首先初始化一个实际的堆栈单元，它就是FDO堆栈最顶层驱动程序将要接收的堆栈单元。然后安装我们的完成例程。这里我们不能使用标准的IoCopyCurrentIrpStackLocationToNext宏来复制堆栈单元，因为我们正处理两个不同的I/O堆栈。
  4. 应该事先计划好如何处理父设备堆栈不真正处理该转发IRP的情况。我们后面的处理需要取决于这个被复制IRP的副功能码。我们所做的就是计算一个布尔值needsvote，并把该值作为上下文参数传递给我们的完成例程。
  5. 你应该把新PnP请求的status域初始化为特殊值STATUS_NOT_SUPPORTED，否则驱动程序校验器会产生bug check。
  6. 该语句释放我们对FDO堆栈中顶级设备对象的引用。
  7. 我们在这里保存原始IRP的地址。
  8. 这些语句为原始IRP设置了完成状态。
  9. 我们分配了转发IRP，因此有责任删除它。
  10. 我们现在可以完成这个原始的IRP，因为FDO驱动程序堆栈已经收到该IRP的复制品。
  11. 我们必须返回STATUS_MORE_PROCESSING_REQUIRED，因为该IRP已经被删除，其完成操作在转发IRP中处理。

上面代码处理了一个较复杂的问题。PnP管理器初始化PnP请求，使其包含STATUS_NOT_SUPPORTED状态。因此通过测试这些IRP的结束状态就可以知道该IRP是否被驱动程序处理过。如果IRP以STATUS_NOT_SUPPORT完成，则PnP管理器就知道该IRP没有被任何驱动程序处理。如果IRP以其它状态完成，则PnP管理器可以了解到该IRP或者被某些驱动程序置成失败或者被成功地处理，并不是简单地被忽略。

象MULFUNC这样的驱动程序在创建PnP请求时也必须把IoStatus.Status设置为STATUS_NOT_SUPPORTED。正如我所提到的，驱动程序校验器将检验这一点。但这个初始化会导致这样的问题：子堆栈(子设备PDO之上)中的某个设备在向我们以PDO角色出现的驱动程序下传某个IRP时可能会改变IoStatus.Status。我们将创建一个转发IRP，并预置IoStatus.Status为STATUS_NOT_SUPPORTED，然后下传这个IRP到父堆栈(即我们以FDO驱动程序角色出现的堆栈)。如果转发IRP也以STATUS_NOT_SUPPORTED状态完成，那么原始IRP将以什么状态完成呢？它不应该是STATUS_NOT_SUPPORTED，因为这将意味着任何子堆栈驱动程序都没有处理该IRP(有一个驱动程序处理了，但却改变了主IRP的状态)。这就是使用needsvote标志的原因。

对于某些需要转发的IRP，我们不用关心父驱动程序是否真正处理该IRP。也就是说，父驱动程序不需要“表决”该IRP。如果你仔细观察OnRepeaterComplete函数，你将看到我们并没有在这种情况下改变主IRP的结束状态。对于另一些要转发的IRP，如果父堆栈驱动程序忽略该IRP，我们并不能给出一个真正答案。对于这些IRP，父驱动程序必须“表决”，而我们将使主IRP失败并置上STATUS_UNSUCCESSFUL状态。为了查明哪一个IRP属于“需要表决”类，哪一个IRP不属于，你可以查看MULFUNC例子中的RepeatRequest函数(在PlugPlayPdo.cpp中)。

如果有一个父驱动程序真正处理了该转发IRP，我们就复制整个IoStatus域，包括Status和Information值，到主IRP中。Information域可能包含某个查询的回答，并且这个复制步骤也是我们把回答上传的过程。

在RepeatRequest中我还做了另一件事，我把该IRP标记为未决并返回STATUS_PENDING。大部分PnP请求都是以同步方式完成，所以IoCallDriver调用很可能立即开始该IRP的完成操作。这就是我为什么把该IRP标记为未决并使I/O管理器在完成主IRP时不必调度APC。如果我们不从派遣函数中返回STATUS_PENDING状态，回想一下，RepeatRequest是作为IRP_MJ_PNP派遣例程的子例程运行的，我们必须返回与完成该IRP使用的值完全相同的值。只有我们的完成例程知道是哪一个值，通过检测STATUS_NOT_SUPPORTED和needsvote标志。

处理设备删除

PnP管理器了解了父FDO和其子PDO之间的父子关系。因此，当用户删除父设备时，PnP管理器将自动删除其所有子设备。尽管有些奇怪，通常当父设备驱动程序收到IRP_MN_REMOVE_DEVICE请求时，它不应该删除子设备PDO。PnP管理器希望在底层硬件移去之前子设备PDO一直存在。因此多功能设备的驱动程序直到被通知删除父设备FDO时才删除子设备PDO。当总线驱动程序在某个枚举过程中不能成功地报告某个设备时，它将收到IRP_MN_REMOVE_DEVICE，这时它才可以删除这个子设备PDO。

MULFUNC在处理设备删除事件时删除了子设备的PDO。

对于控制器类型的设备(相对于非标准多功能设备)，你的控制器驱动程序需要某些附加代码来枚举设备。我忽略了这些代码，因为我的例子设备的子设备在主设备出现时总是存在。另外在枚举操作前不要忘记恢复控制器的电源。

处理IRP_MN_QUERY_ID请求

父设备驱动程序要处理的最重要的PnP请求是IRP_MN_QUERY_ID。PnP管理器以多种形式发出这个请求，它们用于获得设备标识符，而这些标识符能定位子设备的INF文件。响应这些请求你只需要返回一个包含设备标识符的MULTI_SZ值(在IoStatus.Information中)。MULFUNC设备有两个子设备(全是假的)，标识符为*WCO0604和*WCO0605，即第六章的第四个和第五个设备。下面是它处理该查询请求的程序：

NTSTATUS HandleQueryId(PDEVICE_OBJECT pdo, PIRP Irp)
{
  PPDO_EXTENSION pdx = (PPDO_EXTENSION) pdo->DeviceExtension;
  PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
  PWCHAR idstring;
  switch (stack->Parameters.QueryId.IdType)
  {
  case BusQueryInstanceID:
    idstring = L"0000";									<--1
    break;
  case BusQueryDeviceID:
    if (pdx->flags & CHILDTYPEA)							<--2
      idstring = LDRIVERNAME L"\\*WCO0604";
    else
      idstring = LDRIVERNAME L"\\*WCO0605";
    break;
  case BusQueryHardwareIDs:
    if (pdx->flags & CHILDTYPEA)							<--3
      idstring = L"*WCO0604";
    else
      idstring = L"*WCO0605";
    break;
  default:
    return CompleteRequest(Irp, STATUS_NOT_SUPPORTED, 0);
  }
  ULONG nchars = wcslen(idstring);
  ULONG size = (nchars + 2) * sizeof(WCHAR);
  PWCHAR id = (PWCHAR) ExAllocatePool(PagedPool, size);
  wcscpy(id, idstring);
  id[nchars + 1] = 0;
  return CompleteRequest(Irp, STATUS_SUCCESS, (ULONG_PTR) id);
}
  1. 实例标识符是一个字符串，它唯一标识总线上一个特定类型的设备。如果使用“0000”常量将限制计算机上只出现父类型的一个实例设备。
  2. 设备标识符是一个形式为“enumerator\type”的字符串，它基本上提供了硬件注册表键的两个部分。例如，ChildA设备的硬件键将是\Enum\Mulfunc\*WCO0604\0000。
  3. 硬件标识符是唯一标识设备类型的串。在这里，我们制作了两个假EISA标识符*WCO0604和*WCO0605。
注意
如果你要以我在这介绍的方式构造一个设备标识符，你应该用自己的设备名代替MULFUNC。应该强调一下，你不应该象我的例子程序那样把名字硬编码到常量中，这里的代码使用的LDRIVERNAME常量定义在MULFUNC工程的DRIVER.H文件中。

Windows 98的PnP管理器可以容忍你提供一个与硬件标识符相同的设备标识符，但Windows 2000的PnP管理器不允许这样做。把枚举器名组合到设备标识符中非常困难。通过调用IoGetDeviceProperty来获得PDO的枚举器名将导致一个bug check，因为PnP管理器最后将得到一个NULL串指针。使用父设备枚举器名将导致奇怪的结果，PnP管理器在你把父设备删除后又把子设备还给你！

处理IRP_MN_QUERY_DEVICE_RELATIONS请求

最后要考虑的PnP请求是IRP_MN_QUERY_DEVICE_RELATIONS。回想一下，FDO驱动程序是以提供一组子PDO列表来回答这个请求的。然而，戴上PDO的帽子，父设备驱动程序仅需要提供PDO的地址来回答一个称为目标设备关系的请求。

NTSTATUS HandleQueryRelations(PDEVICE_OBJECT pdo, PIRP Irp)
{
  PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
  NTSTATUS status = Irp->IoStatus.Status;
  if (stack->Parameters.QueryDeviceRelations.Type == TargetDeviceRelation)
  {
    PDEVICE_RELATIONS newrel = (PDEVICE_RELATIONS) ExAllocatePool(PagedPool, sizeof(DEVICE_RELATIONS));
    newrel->Count = 1;
    newrel->Objects[0] = pdo;
    ObReferenceObject(pdo);
    status = STATUS_SUCCESS;
    Irp->IoStatus.Information = (ULONG_PTR) newrel;
  }
  Irp->IoStatus.Status = status;
  IoCompleteRequest(Irp, IO_NO_INCREMENT);
  return
}

处理子设备资源

如果你的设备是控制器类型，那么插入该控制器的子设备可能会要求自己的I/O资源。如果你有一个自动的方式获得该设备的资源需求，你可以在响应IRP_MN_QUERY_RESOURCE_REQUIREMENTS请求时返回该设备的需求列表。如果没有自动方式来获得这个资源需求，则子设备的INF文件应有一个LogConfig段能确定这些信息。

如果你需要应付多功能设备，碰巧父设备又替所有子设备获取了所有必须的I/O资源。如果子设备有单独的WDM驱动程序，你必须设计一个方法按子设备分开这些资源，并让每个子设备知道哪个资源属于它。这并不简单。PnP管理器通常在IRP_MN_START_DEVICE请求中告诉子设备其资源的分配情况(详细讨论见下一章)。这里没有一个普遍的方法强制PnP管理器使用你的资源而不是它分配的资源。注意，响应需求查询或过滤请求并没有什么帮助，因为那些请求处理的资源需求可以被PnP管理器更新。

Microsoft的MF.SYS驱动程序通过使用系统资源仲裁器的某些内部接口来处理资源细分，这些内部接口对作为第三方开发者的我们是不能访问的。有两个不同的方式可以细分资源：一个用于Windows 2000，一个用于Windows 98。因为我们不能象MF.SYS那样做，所以我们需要寻找另外一种方式来细分父设备的资源。我并没有尝试去实现这两种方法，但我希望听到有某个读者实现了这两个想法。

如果你能控制所有子设备的功能驱动程序，那么你的父设备驱动程序可以输出一个直接调用的接口。子设备驱动程序通过向父设备驱动程序发送IRP_MN_QUERY_INTERFACE请求来获得一个指向父设备接口描述符的指针。这些子设备应该在设备启动和停止时调用父设备驱动程序中的函数来获得和释放父设备拥有的资源。

如果你不能修改子设备的功能驱动程序，你可以在每个子设备的FDO上安装一个微小的上层过滤器来解决这个资源细分问题。这个过滤器的唯一目的就是向每个IRP_MN_START_DEVICE请求插入一组细分配后的资源。过滤器可以经由一个直接调用接口与父设备驱动程序通信。

PnP通知

Windows 2000和Windows 98提供了一种方法来通知用户模式和内核模式部件刚发生的PnP事件。Windows 95有一个WM_DEVICECHANGE消息，用户模式可以通过处理该消息来监视，或控制系统中的硬件和电源配置的改变。新操作系统中的WM_DEVICECHANGE消息还允许用户模式程序容易地检测到某驱动程序允许或禁止寄存的设备接口。内核模式驱动程序也可以注册类似的通知。

注意
参见平台SDK中关于WM_DEVICECHANGE、RegisterDeviceNotification、UnregisterDeviceNotification的文档。我将给出关于使用这个消息和相关API的例子，但我不能解释它们的所有可能的用法。在后面的某些解释中我将假设你熟悉Microsoft的MFC编程。

WM_DEVICECHANGE扩充

带有窗口的应用程序可以预定与特定接口GUID相关的WM_DEVICECHANGE消息。下面是一个例子：

int CAutoLaunch::OnCreate(LPCREATESTRUCT csp)
{
  DEV_BROADCAST_DEVICEINTERFACE filter = {0};
  filter.dbcc_size = sizeof(filter);
  filter.dbcc_devicetype = DBT_DEVTYP_DEVICEINTERFACE;
  filter.dbcc_classguid = GUID_AUTOLAUNCH_NOTIFY;
  HDEVNOTIFY hNotification = RegisterDeviceNotification(m_hWnd, (PVOID) &filter, DEVICE_NOTIFY_WINDOW_HANDLE);
  ...
}

这里的关键语句是调用RegisterDeviceNotification函数，它要求PnP管理器在任何人允许或禁止GUID_AUTOLAUNCH_NOTIFY接口时向我们的窗口发送一个WM_DEVICECHANGE消息。所以，如果一个设备驱动程序在其AddDevice函数中用这个接口GUID调用IoRegisterDeviceInterface，那么当那个驱动程序调用IoSetDeviceInterfaceState允许或禁止该寄存的接口时，我们将得到通知。

注意
平台SDK告诉你调用UnregisterDeviceNotification来反寄存用RegisterDeviceNotification得到的通知句柄。这对于Windows 2000确实可以，但不能用于Windows 98。虽然Windows 98支持调用RegisterDeviceNotification作为预定属于某设备接口WM_DEVICECHANGE消息的一种方式，但UnregisterDeviceNotification好象不太可靠。在我的测试中，调用这个函数将导致随机的系统崩溃。而我取消UnregisterDeviceNotification调用后系统好象就不出现异常。

WM_DEVICECHANGE消息的处理程序应该象这样：

BOOL CAutoLaunch::OnDeviceChange(UINT evtype, DWORD dwData)
{
  _DEV_BROADCAST_HEADER* dbhdr = (_DEV_BROADCAST_HEADER*) dwData;
  if (!dbhdr || dbhdr->dbcd_devicetype != DBT_DEVTYP_DEVICEINTERFACE)
    return TRUE;
  PDEV_BROADCAST_DEVICEINTERFACE p = (PDEV_BROADCAST_DEVICEINTERFACE) dbhdr;
  CString devname = p->dbcc_name;
  if (evtype == DBT_DEVICEARRIVAL)
    <handle arrival>
  else if (evtype == DBT_DEVICEREMOVECOMPLETE)
    <handle removal>
  return TRUE;
}

这个程序忽略了所有不属于设备接口的消息。devname变量是将要到来或离去的设备的符号连接名。(这个名字与调用SetupDiGetDeviceInterfaceDetail获得的以及传递给CreateFile的是同一个名字) 第十二章详细介绍了如何用各种SetupDiXxx API来了解新设备的相关信息。

何时关闭设备句柄

PnP管理器不允许你在某应用程序还持有设备句柄时删除设备对象。为了删除设备，驱动程序必须促使应用程序关闭设备句柄。对在上面段中谈到的设备接口通知改变消息做些修改就可以解决这个问题。

如果某应用程序拥有你的设备的句柄，它应该调用RegisterDeviceNotification来寄存句柄通知。

DEV_BROADCAST_HANDLE filter = {0};
filter.dbch_size = sizeof(filter);
filter.dbch_devicetype = DBT_DEVTYP_HANDLE;
filter.dbch_handle = m_hDevice; 			//  the device handle
HDEVNOTIFY hNotify = RegisterDeviceNotification(m_hWnd, &filter, DEVICE_NOTIFY_WINDOW_HANDLE);

现在，应用程序应注意wParam(事件代码)等于DBT_DEVICEQUERYREMOVE，devicetype为DBT_DEVTYP_HANDLE的WM_DEVICECHANGE消息。该消息意味着接口将要被禁止，因此你应该关闭你拥有的设备句柄。你还应该在该消息的处理程序中无条件地返回TRUE。

注意
根据平台SDK文档，你在响应DBT_DEVICEQUERYREMOVE消息时应返回BROADCAST_QUERY_DENY，这个特殊的返回值表示你不希望设备被删除或禁止。我在各种版本的Windows 98和Windows 2000中测试了这个返回值但却得到了不一致的结果。所以我建议你的应用程序应总使这个查询成功。
PNPEVENT例子

例子驱动程序PNPEVENT(更恰当地说是TEST程序)显示了如何使用WM_DEVICECHANGE消息检测一个已寄存接口的到来和离开，以及你何时必须关闭设备句柄以允许设备的禁止和删除。你可以在安装PNPEVENT“设备”之前或之后运行TEST程序看到这些。

在TEST程序对话框中，你将看到一个Send Event按钮。点击这个按钮将使驱动程序产生一个定制PnP事件。稍后我再讨论定制事件。取关于一个定制事件的用户模式通知不会成功，所以除非你运行PNPMON的测试程序，否则当你点击这个按钮时什么也不会发生。

Windows 2000的服务通知

Windows 2000的服务程序也能预定PnP通知。服务程序应该先调用RegisterServiceCtrlHandlerEx寄存一个扩展的控制处理函数，然后它才能寄存关于设备接口改变的服务控制通知。例如，观察下面代码(见AUTOLANCH例子)：

DEV_BROADCAST_DEVICEINTERFACE filter = {0};
filter.dbcc_size = sizeof(filter);
filter.dbcc_devicetype = DBT_DEVTYPE_DEVICEINTERFACE;
filter.dbcc_classguid = GUID_AUTOLAUNCH_NOTIFY;
m_hNotification = RegisterDeviceNotification(m_hService, (PVOID) &filter, DEVICE_NOTIFY_SERVICE_HANDLE);

这里，m_hService是由服务管理器提供的服务句柄，DEVICE_NOTIFY_SERVICE_HANDLE指出你寄存的是服务控制通知而不是窗口消息。在收到SERVICE_CONTROL_STOP命令后，你应该反寄存这个通知句柄：

UnregisterDeviceNotification(m_hNotification);

当包含某接口GUID的PnP事件发生时，系统调用你的扩展服务控制处理函数：

DWORD __stdcall HandlerEx(DWORD ctlcode, DWORD evtype, PVOID evdata, PVOID context)
{
}

ctlcode将等于SERVICE_CONTROL_DEVICEEVENT，evtype将等于DBT_DEVICEARRIVAL或其它DBT_Xxx代码，evdata将为DEV_BROADCAST_DEVICEINTERFACE结构的地址，context将为你在调用RegisterServiceCtrlHandlerEx时指定的任意上下文值。

内核模式通知

WDM驱动程序可以使用IoRegisterPlugPlayNotification函数来申请接口和句柄通知。下面是从PNPMON例子驱动程序中摘录的语句，PNPMON程序通过一个I/O控制(IOCTL)操作来为接口的到来和离开寄存通知。

status = IoRegisterPlugPlayNotification(EventCategoryDeviceInterfaceChange,
                    PNPNOTIFY_DEVICE_INTERFACE_INCLUDE_EXISTING_INTERFACES,
                    &p->guid,
                    pdx->DriverObject,
                    (PDRIVER_NOTIFICATION_CALLBACK_ROUTINE) OnPnpNotify,
                    reg,
                    &reg->InterfaceNotificationEntry);

第一个参数指出我们希望在指定接口GUID被某人允许或禁止时能收到通知。第二个参数是一个标志，指出我们希望在接口GUID的所有实例都被允许时得到回调。第三个参数是接口GUID，它来自应用程序的IOCTL。第四个参数是驱动程序对象的地址。PnP管理器向这个对象增加引用，所以当有未关闭的通知句柄时驱动程序对象不会被卸载。第五个参数是通知回调函数的地址。第六个参数是这个回调函数的上下文参数。在这里，我指定了一个结构(reg)的地址，该结构包含了与这个注册调用相关的信息。第七个参数给出了PnP管理器用于记录通知句柄的地址。最后我们将用这个句柄调用IoUnregisterPlugPlayNotification函数。

关闭注册句柄需要调用IoUnregisterPlugPlayNotification函数，因为IoRegisterPlugPlayNotification向你的驱动程序对象增加了一个引用，所以在DriverUnload函数中调用这个例程不会得到什么好处。而DriverUnload只有当引用落回0时才被调用，所以如果DriverUnload本身中有反寄存调用将得不到调用。这个问题比较容易解决，你只需要在合适的时间调用反寄存函数，例如你可以在处理一个接口时做反寄存操作。

为要允许的接口给定一个符号连接名，你也可以用设备的连接名来请求通知。例如：

PUNICODE_STRING SymbolicLinkName;		//  input to this process
PDEVICE_OBJECT DeviceObject;			//  an output
PFILE_OBJECT FileObject;			//  another output
IoGetDeviceObjectPointer(&SymbolicLinkName, 0, &FileObject, &DeviceObject);
IoRegisterPlugPlayNotification(EventCategoryTargetDeviceChange,
                   0,
                   FileObject,
                   pdx->DriverObject,
                   (PDRIVER_NOTIFICATION_CALLBACK_ROUTINE) OnPnpNotify,
                   reg,
                   &reg->HandleNotificationEntry);

你不能把这段代码放到你的PnP事件处理程序中。在内部，IoGetDeviceObjectPointer对命名设备对象执行一个打开操作。如果目标设备正要执行某种PnP操作，则发生死锁。你应该改为调用IoQueueWorkItem来调度工作项。第九章将详细介绍工作项(work item)。PNPMON例子驱动程序显示了如何在这种特殊情况下使用工作项。

这些注册调用的通知将以调用你指定的回调函数的形式出现：

NTSTATUS OnPnpNotify(PPLUGPLAY_NOTIFICATION_HEADER hdr, PVOID Context)
{
  ...
  return STATUS_SUCCESS;
}

PLUGPLAY_NOTIFICATION_HEADER结构是PnP管理器用于通知的几种不同结构的公共头结构：

typedef struct _PLUGPLAY_NOTIFICATION_HEADER {
  USHORT Version;
  USHORT Size;
  GUID Event;
} PLUGPLAY_NOTIFICATION_HEADER, *PPLUGPLAY_NOTIFICATION_HEADER;

Event(GUID) 指出报告给你的是何种事件。见表6-6。DDK头文件WDMGUID.H包含了这些GUID的定义。

表6-6. PnP通知GUID

GUID名 | 通知的目的
GUID_HWPROFILE_QUERY_CHANGE | 可以改变到新硬件profile上吗？
GUID_HWPROFILE_CHANGE_CANCELLED | 上一个改变要求已被取消
GUID_HWPROFILE_CHANGE_COMPLETE | 上一个改变要求已被完成
GUID_DEVICE_INTERFACE_ARRIVAL | 设备接口刚被允许
GUID_DEVICE_INTERFACE_REMOVAL | 设备接口刚被禁止
GUID_TARGET_DEVICE_QUERY_REMOVE | 设备对象可以删除吗？
GUID_TARGET_DEVICE_REMOVE_CANCELLED | 上一个删除要求已被取消
GUID_TARGET_DEVICE_REMOVE_COMPLETE | 上一个删除要求已被完成

如果你收到DEVICE_INTERFACE通知，你可以把回调函数的hdr参数强制转换成指向下面结构的指针：

typedef struct _DEVICE_INTERFACE_CHANGE_NOTIFICATION {
  USHORT Version;
  USHORT Size;
  GUID Event;
  GUID InterfaceClassGuid;
  PUNICODE_STRING SymbolicLinkName;
} DEVICE_INTERFACE_CHANGE_NOTIFICATION, *PDEVICE_INTERFACE_CHANGE_NOTIFICATION;

在这个接口改变通知结构中，InterfaceClassGuid是接口GUID，SymbolicLinkName是刚被允许或禁止的接口实例名。

如果你收到任何一种TARGET_DEVICE通知，你可以把hdr参数强制转换成指向下面结构的指针：

typedef struct _TARGET_DEVICE_REMOVAL_NOTIFICATION {
  USHORT Version;
  USHORT Size;
  GUID Event;
  PFILE_OBJECT FileObject;
} TARGET_DEVICE_REMOVAL_NOTIFICATION, *PTARGET_DEVICE_REMOVAL_NOTIFICATION;

FileObject是你用于请求通知的文件对象。

最后，如果你收到任何一种HWPROFILE_CHANGE通知，hdr将是下面结构的指针：

typedef struct _HWPROFILE_CHANGE_NOTIFICATION {
  USHORT Version;
  USHORT Size;
  GUID Event;
} HWPROFILE_CHANGE_NOTIFICATION, *PHWPROFILE_CHANGE_NOTIFICATION;

这里并没有包含比头结构本身更多的信息，它仅仅是头结构的另一个不同的定义名。

使用这些通知的一种方式是为整个设备接口类实现一个过滤器驱动程序。(实现过滤器驱动程序有一个标准方法，或者为一个单独驱动程序或者为一类设备，这基于注册表中的设置。我将在第九章中讨论这个主题。这里，我正谈到的是过滤所有寄存了特定接口的设备，所以没有其它机制可以选择) 在驱动程序的DriverEntry例程中，你可以为一个或多个接口GUID寄存PnP通知。当你收到到达通知时，你使用IoGetDeviceObjectPointer例程打开一个设备对象，然后寄存与设备关联的目标设备通知。你还从IoGetDeviceObjectPointer得到一个设备对象指针，并且你可以调用IoCallDriver向该设备发送IRP。你应该时刻监视GUID_TARGET_DEVICE_QUERY_REMOVE通知，因为你必须在设备删除前解除文件对象的引用。

PNPMON例子

PNPMON例子显示了如何在内核模式中寄存和处理PnP通知。为了让你能看到真正在计算机上运行的例子，我设计了PNPMON，它把通知简单地传递回用户模式应用程序(TEST程序)。这比较蠢，因为用户模式应用程序可以通过调用RegisterDeviceNotification获得这些通知。

PNPMON不同于本书中的其它驱动程序例子。它可以作为一个用户模式应用程序的辅助程序而被动态地加载。其它驱动程序则用于管理真正的或假想的硬件。用户模式应用程序使用服务管理器API来装入PNPMON，而PNPMON将在DriverEntry例程中创建一个设备对象，以便应用程序能使用DeviceIoControl做只有在内核模式中才能做的事。当应用程序退出时，它关闭其句柄并调用服务管理器终止驱动程序。

PNPMON还包含一个Windows 98的VxD驱动程序，该VxD可以被test应用程序动态装入。在Windows 98中使用未公开的函数也可以动态装入一个WDM驱动程序(_NtKernLoadDriver，如果你敢)，但用这种方法装入的驱动程序没有办法卸载。你不需要凭借未公开的函数，因为VxD可以直接调用大部分WDM的支持例程，使用Windows 98DDK中的WDMVXD输入库。所以你应该在你的VxD工程中把WDM.H包含语句放到VxD头文件前边，并在连接器的输入列表中加入WDMVXD.CLB。因此PNPMON.VXD仅仅把自己当做一个WDM驱动程序来寄存PnP通知，并与PNPMON.SYS相同，都支持同一个IOCTL接口。

定制通知

最后，我将解释WDM驱动程序如何生成定制PnP通知。为了发出定制PnP事件，先创建一个定制通知结构的实例，然后调用IoReportTargetDeviceChange或IoReportTargetDeviceChangeAsynchronous函数。异步特性将使调用者立即返回，同步特性将使调用者等待一段时间，以我的经验，这段时间将持续到通知被发出后。通知结构有如下声明：

typedef struct _TARGET_DEVICE_CUSTOM_NOTIFICATION {
  USHORT Version;
  USHORT Size;
  GUID Event;
  PFILE_OBJECT FileObject;
  LONG NameBufferOffset;
  UCHAR CustomDataBuffer[1];
} TARGET_DEVICE_CUSTOM_NOTIFICATION, *PTARGET_DEVICE_CUSTOM_NOTIFICATION;

Event是你为这个通知定义的定制GUID。FileObject将为NULL，PnP管理器将把通知送到打开某文件对象的驱动程序，而这个文件对象的PDO与IoReportXxx调用中指定的PDO相同。CustomDataBuffer包含你选择的任意二进制数据。如果你没有任何串数据则NameBufferOffset为-1，否则它将是那个二进制数据的长度。从Size中减去CustomDataBuffer的域偏移可以得到整个有效数据的大小。

下面解释了为什么你在test对话框中按下Send Event按钮会生成PNPEVENT定制通知：

struct _RANDOM_NOTIFICATION
  : public _TARGET_DEVICE_CUSTOM_NOTIFICATION {
  WCHAR text[14];
};
...
_RANDOM_NOTIFICATION notify;
notify.Version = 1;
notify.Size = sizeof(notify);
notify.Event = GUID_PNPEVENT_EVENT;
notify.FileObject = NULL;
notify.NameBufferOffset = FIELD_OFFSET(RANDOM_NOTIFICATION, text)
  - FIELD_OFFSET(RANDOM_NOTIFICATION, CustomDataBuffer);
*(PULONG)(notify.CustomDataBuffer) = 42;
wcscpy(notify.text, L"Hello, world!");
IoReportTargetDeviceChangeAsynchronous(pdx->Pdo, &notify, NULL, NULL);

即，PNPEVENT生成了一个定制通知，该通知的数据部分包含了数值42和一个串“Hello, world！”。

顺便提一下，我推荐你使用异步报告API，因为它可以立即返回，为此你必须用NTDDK.H替换WDM.H，但连接时必须包含WDM.LIB和NTOSKRNL.LIB库。

如果通知回调例程得到的通知结构中Event域含有非标准GUID，可以认为它是某人的定制通知GUID，因此在你处理其CustomDataBuffer之前需要了解这个GUID的含义。

用户模式应用程序也可以接收定制事件通知，但我现在还不能使其工作。


Windows 98兼容问题

Windows 98从不发出IRP_MN_SURPRISE_REMOVAL请求。因此，WDM驱动程序需要把非正常的IRP_MN_REMOVE_DEVICE请求当做意外删除设备。本章介绍的代码例子考虑到了这一点，如果遇到这样的IRP它将调用AbortRequests和StopDevice。

Windows 98在调用IoReportTargetDeviceChange函数时会失败并返回STATUS_NOT_IMPLEMENTED。并且它根本就没有输出IoReportTargetDeviceChangeAsynchronous函数；因此调用该函数的驱动程序不能装到Windows 98中。参考附录A，查看如何解决这个问题以及其它不支持函数的问题。

Windows 98的结构不允许以内核模式阻塞方式来等待用户模式程序完成任务。这个事实使我难于把我的USB例子驱动程序(USBINT)连接起来。该例子驱动程序的test程序打开一个句柄并发出一个异步DeviceIoControl调用。如果你现在拔掉设备，你想会发生什么：驱动程序将收到一个IRP_MN_SURPRISE_REMOVAL，而它将取消未处理的DeviceIoControl。test程序然后关闭其句柄。这时，我们再回到驱动程序这边看看，REMOVE_DEVICE处理程序将被阻塞在IoReleaseRemoveLockAndWait调用上。当IRP_MJ_CLOSE到来时，驱动程序将释放最后获得的自旋锁并允许设备删除处理。这个过程在Windos 2000上进行得很好，但在Windows 98中会挂起，因为test程序没有运行的机会，因此也就不会关闭其句柄。(Windows 98没有SURPRISE_REMOVAL，但我们得到的REMOVE_DEVICE可以起到同样的作用) 利用QUERY_REMOVE的代码将不会挂起系统。所以，如果你的设备可以被用户热拔插，那么在Windows 98中，如果你有一个打开的句柄，就不要再获取删除自旋锁。


第七章：读写数据

经过以前各章讲述的基础内容，必然引出了本章的内容，即如何在设备上读写数据。我将讨论几个重要的服务函数，这些函数能对插入传统总线(例如PCI)上的设备进行重要操作。由于许多设备使用硬件中断向系统软件通知有关I/O完成或异常事件，因此我还将讨论如何处理中断。中断处理通常需要调度一个DPC，所以我还将描述DPC机制。最后，我将讲述如何在设备和内存之间安排DMA传输。

  • 配置设备
  • 寻址数据缓冲区
  • 端口与寄存器
  • 响应中断
  • 直接内存存取(DMA)


配置设备

在上一章中，我们讨论了PnP管理器发送给你的各种IRP_MJ_PNP。PnP管理器为你的设备分配I/O资源，而IRP_MN_START_DEVICE是把这个I/O资源分配信息传递给你的运载工具。我向你展示了如何获得两组原始的和经过转换的资源描述信息，以及如何调用StartDevice辅助函数，该函数有如下原型：

NTSTATUS
StartDevice(PDEVICE_OBJECT fdo, PCM_PARTIAL_RESOURCE_LIST raw, PCM_PARTIAL_RESOURCE_LIST translated)
{
  ...
}

现在我将解释如何使用这些资源列表。简单地说，你首先从转换后的资源表中提取描述信息，然后用这些描述信息创建一个内核对象，最后，使用这个内核对象访问你的硬件。

CM_PARTIAL_RESOURCE_LIST结构包含一个计数器和一个CM_PARTIAL_RESOURCE_DESCRIPTOR结构的数组，数组元素的描述见图7-1。数组中每个资源描述符都有一个Type成员，它指出所描述的资源类型，另外还有一些附加成员，它们描述某些资源的细节信息。不要对该数组中的信息感到惊讶：如果你的设备使用一个IRQ和一组I/O端口，你的数组将包含两个资源描述符。一个描述符描述IRQ，另一个描述符描述I/O端口范围。不幸的是，你不能提前知道这些描述符在数组中出现的位置。因此，你的StartDevice函数必须用循环先把资源值提取到一组局部变量中，然后再处理这些资源信息。

图7-1. 局部资源表结构

你的StartDevice函数基本上应该象下面这样：

NTSTATUS
StartDevice(PDEVICE_OBJECT fdo, PCM_PARTIAL_RESOURCE_LIST raw, PCM_PARTIAL_RESOURCE_LIST translated)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  PCM_PARTIAL_RESOURCE_DESCRIPTOR resource = translated->PartialDescriptors;			<--1
  ULONG nres = translated->Count;								<--2
  <local variable declarations>									<--3
  for (ULONG i = 0; i < nres; ++i, ++resource)
  {
    switch (resource->Type)									<--4
    {
    case CmResourceTypePort:
      <save port info in local variables>
      break;
    case CmResourceTypeInterrupt:
      <save interrupt info in local variables>
      break;
    case CmResourceTypeMemory:
      <save memory info in local variables>
      break;
    case CmResourceTypeDma:
      <save DMA info in local variables>
      break;
    }
  }
  <use local variables to configure driver & hardware>						<--5
  IoSetDeviceInterfaceState(&pdx->ifname, TRUE);						<--6
}
  1. 我用这个resource指针来指向可变长数组中的当前资源描述符。在循环的结尾，该指针将指向最后有效描述符后面的地址。
  2. 资源表中的Count成员指出数组PartialDescriptors中有多少个描述符。
  3. 你应该为每个希望接收的I/O资源声明一个适当的局部变量。我将后面的标准I/O资源处理中详细讨论这些细节。
  4. 循环中使用switch语句把资源描述保存到适当的局部变量中。在本文中，我安装了一个仅需要一个I/O端口范围和一个中断的设备，这个设备希望得到CmResourceTypePort和CmResourceTypeInterrupt类型的资源。此外，我还将详细讨论另外两个标准资源类型，CmResourceTypeMemory和CmResourceTypeDma。
  5. 一旦跳出循环，在各个case语句中初始化的局部变量将包含你需要的资源信息。
  6. 如果你在AddDevice期间注册了一个设备接口，这时就应该使能这个接口，以便应用程序能找到设备并打开设备句柄。

如果你在某个资源类型上有多个资源，那么你需要创造一种方法分开资源描述符。为了给出一个具体的例子(本例纯属虚构)，假设某设备使用一个4KB的内存范围来实现控制，用另外的16KB内存范围作为数据捕获缓冲区。那么你希望PnP管理器发给你两个CmResourceTypeMemory资源。控制内存是4KB长的内存块，而数据内存是16KB长的内存块。如果该设备的资源有可以区别的特征，如这个例子中两个资源的需求长度，你就可以知道哪个资源是哪个。

当遇到同类型多资源的情况时，不要假设资源描述符会以在设备配置空间表中出现的顺序出现，也不要假设同一个总线驱动程序在不同的平台或不同的操作系统版本间总是按同一个顺序构造资源描述符。

我会在本章的适当地方解释如何处理这四种标准I/O资源类型。表7-1列出了处理每种资源类型的关键步骤。

表7-1. I/O资源处理步骤概述

资源类型 | 处理概述
Port | 可能映射端口范围；应在设备扩展中保存端口范围基址
Memory | 映射内存范围；应在设备扩展中保存内存范围基址
Dma | 调用IoGetDmaAdapter函数创建适配器对象
Interrupt | 调用IoConnectInterrupt函数创建中断对象，中断对象指向ISR(中断服务例程)


寻址数据缓冲区

当应用程序发起一个读或写操作时，通过给出一个用户模式虚拟地址和长度，应用程序向I/O管理器提供了一个数据缓冲区。正如我在第三章中提到的，内核模式驱动程序几乎从不使用用户模式虚拟地址访问内存，因为你不能把线程上下文确定下来。Windows 2000为驱动程序访问用户模式数据缓冲区提供了三种方法：

  • 在buffered方式中，I/O管理器先创建一个与用户模式数据缓冲区大小相等的系统缓冲区。而你的驱动程序将使用这个系统缓冲区工作。I/O管理器负责在系统缓冲区和用户模式缓冲区之间复制数据。
  • 在direct方式中，I/O管理器锁定了包含用户模式缓冲区的物理内存页，并创建一个称为MDL(内存描述符表)的辅助数据结构来描述锁定页。因此你的驱动程序将使用MDL工作。
  • 在neither方式中，I/O管理器仅简单地把用户模式的虚拟地址传递给你。而使用用户模式地址的驱动程序应十分小心。

图7-2显示了前两种方法。后一种方法实际上并不是方法，因为系统没有为你获取数据而给予任何帮助。

图7-2. 访问用户模式数据缓冲区

指定缓冲方式

为了指定设备读写的缓冲方式，你应该在AddDevice函数中，在创建设备对象后，立即设置其中的标志位：

NTSTATUS AddDevice(...)
{
  PDEVICE_OBJECT fdo;
  IoCreateDevice(..., &fdo);
  fdo->Flags |= DO_BUFFERED_IO;
           <or>
  fdo->Flags |= DO_DIRECT_IO;
           <or>
  fdo->Flags |= 0; 	// i.e., neither direct nor buffered
}

这之后你不能该变缓冲方式的设置，因为过滤器驱动程序将复制这个标志设置，并且，如果你改变了设置，过滤器驱动程序没有办法知道这个改变。

Buffered方式

当I/O管理器创建IRP_MJ_READ或IRP_MJ_WRITE请求时，它探测设备的缓冲标志以决定如何描述新IRP中的数据缓冲区。如果DO_BUFFERED_IO标志设置，I/O管理器将分配与用户缓冲区大小相同的非分页内存。它把缓冲区的地址和长度保存到两个十分不同的地方，见下面代码片段中用粗体字表示的语句。你可以假定I/O管理器执行下面代码(注意这并不是Windows NT的源代码)：

PVOID uva;             //  user-mode virtual buffer address
ULONG length;          //  length of user-mode buffer

PVOID sva = ExAllocatePoolWithQuota(NonPagedPoolCacheAligned, length);
if (writing)
  RtlCopyMemory(sva, uva, length);

Irp->AssociatedIrp.SystemBuffer = sva;

PIO_STACK_LOCATION stack = IoGetNextIrpStackLocation(Irp);
if (reading)
  stack->Parameters.Read.Length = length;
else
  stack->Parameters.Write.Length = length;

<code to send and await IRP>

if (reading)
  RtlCopyMemory(uva, sva, length);

ExFreePool(sva);

可以看出，系统缓冲区地址被放在IRP的AssociatedIrp.SystemBuffer域中，而数据的长度被放到stack->Parameters联合中。在这个过程中还包含作为驱动程序开发者不必了解的额外细节。例如，读操作之后的数据复制工作实际发生一个APC期间，在原始线程的上下文中，由一个与构造该IRP完全不同的子例程执行。I/O管理器把用户模式虚拟地址(uva变量)保存到IRP的UserBuffer域中，这样一来复制操作就可以找到这个地址。但你不要使代码依赖这些事实，因为它们有可能会改变。IRP最终完成后，I/O管理器将释放系统缓冲区所占用的内存。

Direct方式

如果你在设备对象中指定DO_DIRECT_IO方式，I/O管理器将创建一个MDL用来描述包含该用户模式数据缓冲区的锁定内存页。MDL结构的声明如下：

typedef struct _MDL {
  struct _MDL *Next;
  CSHORT Size;
  CSHORT MdlFlags;
  struct _EPROCESS *Process;
  PVOID MappedSystemVa;
  PVOID StartVa;
  ULONG ByteCount;
  ULONG ByteOffset;
} MDL, *PMDL;

图7-3显示了MDL扮演的角色。StartVa成员给出了用户缓冲区的虚拟地址，这个地址仅在拥有数据缓冲区的用户模式进程上下文中才有效。ByteOffset是缓冲区起始位置在一个页帧中的偏移值，ByteCount是缓冲区的字节长度。Pages数组没有被正式地声明为MDL结构的一部分，在内存中它跟在MDL的后面，包含用户模式虚拟地址映射为物理页帧的个数。

图7-3. 内存描述符表(MDL)结构

顺便说一下，我们不可以直接访问MDL的任何成员。应该使用宏或访问函数，见表7-2。

表7-2. 用于访问MDL的宏和访问函数

宏或函数 | 描述
IoAllocateMdl | 创建MDL
IoBuildPartialMdl | 创建一个已存在MDL的子MDL
IoFreeMdl | 销毁MDL
MmBuildMdlForNonPagedPool | 修改MDL以描述内核模式中一个非分页内存区域
MmGetMdlByteCount | 取缓冲区字节大小
MmGetMdlByteOffset | 取缓冲区在第一个内存页中的偏移
MmGetMdlVirtualAddress | 取虚拟地址
MmGetSystemAddressForMdl | 创建映射到同一内存位置的内核模式虚拟地址
MmGetSystemAddressForMdlSafe | 与MmGetSystemAddressForMdl相同，但Windows 2000首选
MmInitializeMdl | (再)初始化MDL以描述一个给定的虚拟缓冲区
MmPrepareMdlForReuse | 再初始化MDL
MmProbeAndLockPages | 地址有效性校验后锁定内存页
MmSizeOfMdl | 取为描述一个给定的虚拟缓冲区的MDL所占用的内存大小
MmUnlockPages | 为该MDL解锁内存页

对于I/O管理器执行的Direct方式的读写操作，其过程可以想象为下面代码：

KPROCESSOR_MODE mode;   //  either KernelMode or UserMode
PMDL mdl = IoAllocateMdl(uva, length, FALSE, TRUE, Irp);
MmProbeAndLockPages(mdl, mode, reading ? IoWriteAccess : IoReadAccess);

<code to send and await IRP>

MmUnlockPages(mdl);
ExFreePool(mdl);

I/O管理器首先创建一个描述用户缓冲区的MDL。IoAllocateMdl的第三个参数(FALSE)指出这是一个主数据缓冲区。第四个参数(TRUE)指出内存管理器应把该内存充入进程配额。最后一个参数(Irp)指定该MDL应附着的IRP。在内部，IoAllocateMdl把Irp->MdlAddress设置为新创建MDL的地址，以后你将用到这个成员，并且I/O管理器最后也使用该成员来清除MDL。

这段代码的关键地方是调用MmProbeAndLockPages(以粗体字显示)。该函数校验那个数据缓冲区是否有效，是否可以按适当模式访问。如果我们向设备写数据，我们必须能读缓冲区。如果我们从设备读数据，我们必须能写缓冲区。另外，该函数锁定了包含数据缓冲区的物理内存页，并在MDL的后面填写了页号数组。在效果上，一个锁定的内存页将成为非分页内存池的一部分，直到所有对该页内存加锁的调用者都对其解了锁。

在Direct方式的读写操作中，对MDL你最可能做的事是把它作为参数传递给其它函数。例如，DMA传输的MapTransfer步骤需要一个MDL。另外，在内部，USB读写操作总使用MDL。所以你应该把读写操作设置为DO_DIRECT_IO方式，并把结果MDL传递给USB总线驱动程序。

顺便提一下，I/O管理器确实在stack->Parameters联合中保存了读写请求的长度，但驱动程序应该直接从MDL中获得请求数据的长度。

ULONG length = MmGetMdlByteCount(mdl);

Neither方式

如果你在设备对象中同时忽略了DO_DIRECT_IO和DO_BUFFERED_IO标志设置，你将得到默认的neither方式。对于这种方式，I/O管理器将简单地把用户模式虚拟地址和字节计数(以粗体显示的代码)交给你，其余的工作由你去做。

Irp->UserBuffer = uva;
PIO_STACK_LOCATION stack = IoGetNextIrpStackLocation(Irp);
if (reading)
  stack->Parameters.Read.Length = length;
else
  stack->Parameters.Write.Length = length;

<code to send and await IRP>


端口与寄存器

图7-4显示了Windows 2000驱动程序访问硬件设备的几种模式。通常，CPU的内存地址空间和I/O地址空间是分离的。为了访问“内存映射”设备，CPU用load或store操作直接对一个虚拟地址进行内存引用，然后CPU利用一组页表把虚拟地址转换成物理地址。为了访问“I/O映射”设备，CPU必须使用特殊的机制，如使用x86处理器上的IN和OUT指令。

图7-4. 访问端口与寄存器

设备使用总线专有的方式解码内存和I/O地址。在PCI总线中，主PCI桥把CPU物理地址和I/O地址映射到总线地址空间，而设备可以直接访问总线地址空间。对于两种地址空间都支持的CPU，设备配置空间中的标志位决定了主PCI桥是把设备寄存器映射成内存地址还是映射成I/O地址。

正如我刚提到的，某些CPU有分离的内存地址空间和I/O地址空间。例如，Intel架构的CPU同时支持这两种地址空间。其它CPU，例如Alpha，仅有一个内存地址空间。如果你的设备是I/O映射的，PnP管理器将赋予你端口资源。如果你的设备是内存映射的，它将赋予你内存资源。

为了避免为兼容各种平台而使用大量条件编译代码，Windows NT的设计者发明了硬件抽象层(HAL)，这个概念我曾在本书的多个地方提到过。HAL提供了用于访问端口和内存资源的函数，见表7-3。正如这个表所指出的，你可以从或向一个PORT/REGISTER资源READ/WRITE一个或一组UCHAR/USHORT/ULONG值。有24个函数用于设备访问，但WDM驱动程序不直接依靠HAL做任何事，你可以把这24个函数看成是HAL的全部公共接口。

表7-3. 用于访问端口和内存寄存器的HAL函数

存取宽度 | 端口访问函数 | 内存访问函数
8位 | READ_PORT_UCHAR WRITE_PORT_UCHAR | READ_REGISTER_UCHAR WRITE_REGISTER_UCHAR
16位 | READ_PORT_USHORT WRITE_PORT_USHORT | READ_REGISTER_USHORT WRITE_REGISTER_USHORT
32位 | READ_PORT_ULONG WRITE_PORT_ULONG | READ_REGISTER_ULONG WRITE_REGISTER_ULONG
8位字节串 | READ_PORT_BUFFER_UCHAR WRITE_PORT_BUFFER_UCHAR | READ_REGISTER_BUFFER_UCHAR WRITE_REGISTER_BUFFER_UCHAR
16位字串 | READ_PORT_BUFFER_USHORT WRITE_PORT_BUFFER_USHORT | READ_REGISTER_BUFFER_USHORT WRITE_REGISTER_BUFFER_USHORT
32位双字串 | READ_PORT_BUFFER_ULONG WRITE_PORT_BUFFER_ULONG | READ_REGISTER_BUFFER_ULONG WRITE_REGISTER_BUFFER_ULONG

很明显，这些访问函数的内部实现与平台高度相关。例如，Intel x86版本的READ_PORT_CHAR函数用IN指令从指定的I/O端口读一个字节。而在Windows 98中，有些地方甚至把驱动程序对这个函数的CALL指令直接替换成IN指令。该函数的Alpha版本则执行一个内存提取。READ_REGISTER_UCHAR函数的Intel x86版本将执行一个内存提取；而该函数的Alpha版本则是一个直接内存引用的宏。该函数的缓冲区版本，READ_REGISTER_BUFFER_UCHAR，在x86环境中需要做一些额外工作，以确保在操作完成时所有CPU的高速缓存都被正确刷新。

使用HAL的关键原因是你可以不必担心平台的不同，也不用担心Windows 2000的多任务和多处理器环境对设备访问的特殊要求。因此，你的工作将十分简单：使用PORT调用访问端口资源，使用REGISTER调用访问内存资源。

端口资源

I/O映射设备把硬件寄存器暴露给某些种类的CPU(包括Intel x86)，软件可以使用I/O地址空间寻址这些硬件寄存器。而其它种类的CPU没有分离的I/O地址空间，因此它们使用常规的内存引用来寻址这些寄存器。幸运的是，你完全可以不用了解这些复杂的寻址过程。如果你的设备需要一个端口资源，那么你将在转换后的描述符中找到一个CmResourceTypePort类型的描述符，你需要保存这个描述符的三个方面的信息。

typedef struct _DEVICE_EXTENSION {
  ...
  PUCHAR portbase;
  ULONG nports;
  BOOLEAN mappedport;
  ...
} DEVICE_EXTENSION, *PDEVICE_EXTENSION;

PHYSICAL_ADDRESS portbase;    // base address of range
...
for (ULONG i = 0; i < nres; ++i, ++resource)
{
  switch (resource->Type)
  {
  case CmResourceTypePort:
    portbase = resource->u.Port.Start;						<--1
    pdx->nports = resource->u.Port.Length;
    pdx->mappedport = (resource->Flags & CM_RESOURCE_PORT_IO) == 0;		<--2
    break;
  ...
  }
...
if (pdx->mappedport)
{
  pdx->portbase = (PUCHAR) MmMapIoSpace(portbase, pdx->nports, MmNonCached);	<--3
  if (!pdx->portbase)
    return STATUS_NO_MEMORY;
}
else
  pdx->portbase = (PUCHAR) portbase.QuadPart;					<--4
  1. 资源描述符含有一个名为u的联合，u联合含有每个标准资源类型的子结构。u.Port含有关于端口资源的信息。u.Port.Start是I/O端口范围的起始地址，u.Port.Length是在该范围内的端口数量。起始地址是一个64位的PHYSICAL_ADDRESS值。
  2. 端口资源的描述符有一个Flags成员，如果CPU支持分离的I/O地址空间，而这个给定端口又属于这个空间，那么这个标志将有CM_RESOURCE_PORT_IO设置。
  3. 如果没有设置CM_RESOURCE_PORT_IO标志，这可能是在一个Alpha平台或其它RISC平台上，你必须调用MmMapIoSpace函数来获得能访问该端口的内核模式虚拟地址。访问将真正使用内存引用，但在驱动程序中你仍要调用HAL中的PORT风格的例程(如READ_PORT_UCHAR)。
  4. 如果设置了CM_RESOURCE_PORT_IO标志，这将在一个x86平台上，因此你不必映射端口地址。所以，在你的驱动程序中你应该使用PORT风格的HAL例程访问设备端口。HAL例程要求一个PUCHAR类型的端口地址参数，因此我们需要把基地址强制转换成这个类型。QuadPart引用将使你得到一个与编译平台相适应的32位或64位指针。

不管端口地址是否需要经过MmMapIoSpace函数映射，你总应该调用处理I/O端口资源的HAL例程：READ_PORT_UCHAR或WRITE_PORT_UCHAR，等等。在一个需要映射端口地址的CPU上，HAL将生成内存引用。在不需要映射的CPU上，HAL将生成I/O引用；在x86处理器上，这意味着使用IN和OUT指令族。

如果你需要映射你的端口资源，那么你的StopDevice函数必须执行一个小的清除任务：

VOID StopDevice(...)
{
  ...
  if (pdx->portbase && pdx->mappedport)
    MmUnmapIoSpace(pdx->portbase, pdx->nports);
  pdx->portbase = NULL;
  ...
}

内存资源

内存映射设备暴露了软件可以使用load和store指令访问的寄存器。你从PnP管理器得到的转换后的资源值是一个物理地址，你需要保留该物理地址对应的虚拟地址。以后，你将使用处理内存寄存器的HAL例程，如READ_REGISTER_UCHAR和WRITE_REGISTER_UCHAR，等等。你的提取和配置代码应该象下面这样：

typedef struct _DEVICE_EXTENSION {
  ...
  PUCHAR membase;
  ULONG nbytes;
  ...
} DEVICE_EXTENSION, *PDEVICE_EXTENSION;

PHYSICAL_ADDRESS membase;     // base address of range
...
for (ULONG i = 0; i < nres; ++i, ++resource)
{
  switch (resource->Type)
  {
  case CmResourceTypeMemory:
  membase = resource->u.Memory.Start;						<--1
    pdx->nbytes = resource->u.Memory.Length;
    break;
  ...
  }
...
pdx->membase = (PUCHAR) MmMapIoSpace(membase, pdx->nbytes, MmNonCached);	<--2
if (!pdx->membase)
  return STATUS_NO_MEMORY;
  1. 在资源描述符中，u.Memory含有内存资源信息。u.Memory.Start是一个内存范围的起始地址，u.Memory.Length是该范围的字节长度。起始地址是一个64位的PHYSICAL_ADDRESS值。u.Port和u.Memory子结构完全相同，这是故意的，并非偶然，如果你需要你可以依靠这个事实。
  2. 你必须调用MmMapIoSpace函数获得一个内核模式虚拟地址，这样内存范围才能被访问。

你的StopDevice函数可以无条件地解除内存资源映射：

VOID StopDevice(...)
{
  ...
  if (pdx->membase)
    MmUnmapIoSpace(pdx->membase, pdx->nbytes);
  pdx->membase = NULL;
  ...
}


响应中断

许多设备使用异步方式的中断来通知处理器其I/O操作的完成。在这节中，我们将讨论如何为中断处理配置驱动程序，以及如何响应中断。

配置中断

你应该在StartDevice函数中配置中断资源，使用从CmResourceTypeInterrupt描述符中提取的参数来调用IoConnectInterrupt函数。调用IoConnectInterrupt时，你的驱动程序和设备必须完全准备好，因为中断可能发生在函数返回前，所以通常应该在接近配置过程的结尾处做这个调用。某些设备的硬件特征可以允许驱动程序禁止其硬件中断。如果你的设备有这种特征，你应该在调用IoConnectInterrupt前禁止设备中断，之后再允许设备中断。一个中断的提取和配置代码应该象下面这样：

typedef struct _DEVICE_EXTENSION {
  ...
  PKINTERRUPT InterruptObject;
  ...
} DEVICE_EXTENSION, *PDEVICE_EXTENSION;

ULONG vector;             // interrupt vector
KIRQL irql;               // interrupt level
KINTERRUPT_MODE mode;     // latching mode
KAFFINITY affinity;       // processor affinity
BOOLEAN irqshare;         // shared interrupt?
...
for (ULONG i = 0; i < nres; ++i, ++resource)
{
  switch (resource->Type)
  {
  case CmResourceTypeInterrupt:
    irql = (KIRQL) resource->u.Interrupt.Level;								<--1
    vector = resource->u.Interrupt.Vector;								<--2
    affinity = resource->u.Interrupt.Affinity;								<--3
    mode = (resource->Flags == CM_RESOURCE_INTERRUPT_LATCHED) ? Latched : LevelSensitive;		<--4
    irqshare = resource->ShareDisposition == CmResourceShareShared;					<--5
    break;
  ...
}
...
status = IoConnectInterrupt(&pdx->InterruptObject,
                (PKSERVICE_ROUTINE) OnInterrupt,
                (PVOID) pdx,
                NULL,
                vector,
                irql,
                irql,
                mode,
                irqshare,
                affinity,
                FALSE);
  1. Level参数指定这个中断的IRQL。
  2. Vector参数指定这个中断的硬件中断矢量。因为我们仅充当PnP管理器和IoConnectInterrupt之间的连接桥梁，所以不用关心这个数值。这个数的含义只有HAL能了解。
  3. Affinity是一个位掩码，它指出该中断应由哪个CPU来处理。
  4. 我们需要告诉IoConnectInterrupt我们的中断是边缘触发方式还是水平触发方式。如果资源标志Flags为CM_RESOURCE_INTERRUPT_LATCHED，则我们的中断是边缘触发方式，否则是水平触发方式。
  5. 这个语句查明中断是否是共享的。

在这段代码的最后是IoConnectInterrupt调用，在这里我们使用了从中断资源描述符中获得的各个值。第一个参数(&pdx->InterruptObject)指出在哪里存放连接操作的结果，即一个指向内核中断对象的指针，该对象描述你的中断。第二个参数(OnInterrupt)指向你的中断服务例程(ISR)；稍后我将讨论ISR。第三个参数(pdx)是每次当你的设备中断时作为参数传递给ISR的一个上下文值。在后面的“选择适当的上下文参数”段中，我将详细讨论这个上下文参数。

第五和第六个参数(vector和irql)为被连接的中断指定中断矢量号和中断请求级。第八个参数(mode)应该为Latched或LevelSensitive，它指出中断是边缘触发方式还是水平触发方式。第九个参数如果为TRUE，则表明这个中断可以被其它设备共享，反之为FALSE。第十个参数(affinity)是该中断的处理器亲合掩码。第十一个和最后一个参数指出当中断发生时是否需要操作系统保存浮点上下文。因为在x86平台上的ISR中不允许执行浮点运算，所以一个可移植的驱动程序应该总把该标志设为FALSE。

有两个IoConnectInterrupt的参数我还没有描述。当设备使用多个中断时，这两个参数就变得特别重要。在这种情况下，你需要为这些中断创建自旋锁，并调用KeInitializeSpinLock函数初始化这个自旋锁。在连接中断前，你还需要计算出这些中断中的最大IRQL。在每次调用IoConnectInterrupt时，你应该在第四个参数中(在例子中为NULL)指定这个自旋锁的地址，以及在第七个参数(在例子中为irql)中指定最大IRQL。第七个参数指定的IRQL用于同步多个中断，所以你应该使用这些中断中最大的IRQL，这样你一次就只需要响应一个中断。

然而，如果设备只使用一个中断，你就不必指定自旋锁(因为I/O管理器自动为你分配一个)，并且中断的同步级别将与中断的IRQL相同。

中断要连接到CPU时需要满足两个条件，一、affinity中指定的CPU，二、当前使用的。换句话说，决定由哪个CPU接受中断是在IoConnectInterrupt中作出的。

处理中断

当设备生成中断时，HAL将基于你指定的CPU亲合掩码选择一个CPU来服务这个中断。然后它把选定CPU的IRQL提升到合适的同步级并请求一个与中断对象关联的自旋锁。最后调用ISR，ISR的框架结构见下面代码：

BOOLEAN OnInterrupt(PKINTERRUPT InterruptObject, PVOID Context)
{
  if (<device not interrupting>)
    return FALSE;
  <handle interrupt>
  return TRUE;
}

Windows NT的中断处理机制假定多个设备可以共享一个硬件中断。因此，ISR的首要工作就是找出哪一个设备发生了中断。如果没有，你应该立刻返回FALSE，以便HAL能把中断送往其它设备驱动程序。如果有，你应该先在设备级清除该中断然后返回TRUE。HAL是否调用其它驱动程序的ISR，主要取决于设备中断是边缘触发方式还是水平触发方式，以及具体的平台。

ISR的主要工作就是清除中断，执行了这个主要任务之后就可以向HAL返回TRUE，表明你已经服务了该设备中断。

ISR中的编程限制

ISR执行在高于DISPATCH_LEVEL的IRQL上。因此ISR中使用的所有代码和数据必须存在于非分页内存中，此外，ISR能调用的内核模式函数十分有限。

因为ISR执行在提升的IRQL上，所以它冻结了其CPU上所有低于或等于该IRQL的其它活动。为了提高系统性能，你的ISR应该尽可能快地执行。基本上，只做服务硬件所需的最小量的工作，然后立即返回。如果有额外的工作需要做(例如完成一个IRP)，应该交给DPC来完成。

尽管ISR的代码应该尽可能地短，但你不要把这个想法运用到极端。例如，如果某设备的中断是表明它已经为接收下一个字节而准备就绪，你就应该在ISR中直接发出这个字节。为了传输一个字节而调用DPC是愚蠢的。

不要发疯似地在ISR中计算pi到上千位。你应该在ISR和DPC间找到正确的负载平衡。

选择一个合适的上下文参数

在IoConnectInterrupt调用中，第三个参数是一个任意的上下文值，这个值最后将成为ISR的第二个参数。你应该选择好这个参数以使ISR能尽可能快地执行；设备对象的地址或设备扩展的地址将是一个好的选择。设备扩展中保存了许多有用的数据，如设备的端口基地址，你可能使用这个端口地址来测试设备是否真的发出了中断。例如，假定你的设备硬件使用I/O映射模式，其基地址是设备的状态端口，而状态值的低位指出设备当前是否发出中断。如果你采纳我的建议，你的ISR的前几行应该象这样：

BOOLEAN OnInterrupt(PKINTERRUPT InterruptObject, PDEVICE_EXTENSION pdx)
{
  UCHAR devstatus = READ_PORT_UCHAR(pdx->portbase);
  if (!(devstatus & 1))
    return FALSE;
  <etc.>
}

该函数的全优化代码仅需要几条指令来读状态端口以及测试低位。

如果你把设备扩展作为上下文参数，在调用IoConnectInterrupt函数时应做一个强制类型转换：

IoConnectInterrupt(..., (PKSERVICE_ROUTINE) OnInterrupt, ...);

如果你忽略了强制类型转换，编译器将生成一个特别含糊的错误消息，因为OnInterrupt例程(一个PDEVICE_EXTENSION)的第二个参数不匹配IoConnectInterrupt函数的函数指针参数，它需要一个PVOID类型。

ISR的同步操作

作为一个通用规则，ISR可以与驱动程序的其它部分共享数据和硬件资源。任何时候你听到“共享”这个词，就应该立即想到同步问题。例如，一个标准的UART设备有一个数据端口，驱动程序就用这个端口来读写数据。你可能希望串口驱动程序的ISR能随时访问这个端口。改变波特率必须设置一个控制标志，因此应该先在这个数据端口上执行两个单字节的写操作，然后清除控制标志。如果UART在改变波特率的途中被中断，你可能看到两种意外情况，或者是中断传输的数据字节被放到波特率分配寄存器中，或者是应该传递给分配寄存器的控制数据被当成普通数据传输。

系统在一个相对高的IRQL(DIRQL)上用一个自旋锁来保护ISR的执行。为了简化获取该自旋锁以及提升IRQL到与中断相同的级，系统提供了下面服务函数：

BOOLEAN result = KeSynchronizeExecution(InterruptObject, SynchRoutine, Context);

InterruptObject(PKINTERRUPT)是一个指向中断对象的指针，这个中断对象描述了我们要同步的中断。SynchRoutine(PKSYNCHRONIZE_ROUTINE)是存在于驱动程序中的回调函数的地址。Context(PVOID)是作为参数传递到SynchRoutine的任意上下文参数。我们使用通用术语“同步关键段例程(synch critical section routine)”来描述KeSynchronizeExecution应用的子例程。同步关键段例程有如下原型：

BOOLEAN SynchRoutine(PVOID Context);

即该例程接受一个参数并返回一个布尔类型的结果。当该例程获得控制时，当前CPU将运行在IoConnectInterrupt调用中指定的同步IRQL上，并且拥有与中断关联的自旋锁。因此，设备中断将被临时阻塞，SynchRoutine就可以自由地访问与ISR共享的数据和硬件资源。

KeSynchronizeExecution将返回SynchRoutine返回的任何值。由于布尔类型声明为无符号字符类型，因此你只能有一个字节用来从SynchRoutine返回信息。

DPC

完整的中断服务通常需要执行这样一些操作，这些操作不适合在ISR中执行，或者考虑到执行在提升IRQL上的ISR中会对系统性能造成影响。为了解决这个问题，Windows NT的设计者提供了推迟过程调用(DPC)机制。DPC是一个通用机制，但通常都用在中断处理中。在最普通的情况下，ISR决定当前请求的完成并请求一个DPC。之后，内核在DISPATCH_LEVEL级上调用这个DPC例程。因此DPC中的代码要比ISR中的代码有更少的限制。特别是，DPC例程可以调用象IoCompleteRequest或IoStartNextPacket这样的例程，在一个I/O操作的结尾处调用这些例程在逻辑上是必要的。

每个设备对象中都含有一个DPC对象。即，DEVICE_OBJECT中有一个内置的DPC对象Dpc。创建设备对象后你需要立刻初始化这个内置DPC对象。

NTSTATUS AddDevice(...)
{
  PDEVICE_OBJECT fdo;
  IoCreateDevice(..., &fdo);
  IoInitializeDpcRequest(fdo, DpcForIsr);
  ...
}

IoInitializeDpcRequest是WDM.H中的一个宏，它初始化设备对象中的内置DPC对象。第二个参数是DPC例程的地址，很快，我们将讨论这个例程。

如果你在适当的地方初始化一个DPC对象，你就可以在ISR中用下面函数(宏)请求一个DPC：

BOOLEAN OnInterrupt(...)
{
  ...
  IoRequestDpc(pdx->DeviceObject, NULL, (PVOID) pdx);
  ...
}

这个IoRequestDpc函数将把设备对象中的DPC对象放到一个系统范围的队列，见图7-5。

图7-5. 处理DPC请求

两个为NULL的参数是上下文值，在这里没有什么实际用处。之后，当DISPATCH_LEVEL级上没有其它活动时，内核从队列上删除你的DPC对象，然后调用你的DPC例程，DPC例程有如下原型：

VOID DpcForIsr(PKDPC Dpc, PDEVICE_OBJECT fdo, PIRP junk, PVOID Context)
{
}

DPC例程所做的工作大部分要取决于设备的工作方式。一个可能的任务是完成当前的IRP以及发出队列中的下一个IRP。如果你使用标准模型来排队IRP，则代码如下：

VOID DpcForIsr(...)
{
  PIRP Irp = fdo->CurrentIrp;
  IoStartNextPacket(fdo, TRUE);
  IoCompleteRequest(Irp, <boost value>);
}

IoStartNextPacket的TRUE参数指出下一个IRP可以被取消，即IoStartPacket的原始调用中指定了取消例程，这将使IoStartNextPacket函数在访问设备对象和CurrentIrp时需申请全局取消自旋锁的保护。

在这个代码片段中，我们依赖这样一个事实，I/O管理器设置设备对象的CurrentIrp域为指向其发往StartIo例程的IRP的指针。我们要完成的IRP就是当我们启动DPC例程时CurrentIrp指向的IRP。通常我们在调用IoCompleteRequest前先调用IoStartNextPacket函数，这样在我们开始完成有可能是长耗时的IRP处理前，可以使设备忙于处理新请求。

如果你使用上一章讲述的DEVQUEUE对象来排队IRP，则代码将类似标准模型：

VOID DpcForIsr(...)
{
  PDEVICE_EXTENSION pdx = ...;
  PIRP Irp = GetCurrentIrp(&pdx->dqRead);
  StartNextPacket(&pdx->dqRead, fdo);
  IoCompleteRequest(Irp, <boost value>);
}

DPC调度

到现在我必须揭示关于DPC的两个重要细节和一个相对次要的细节。第一个重要的细节暗含在这样的事实内，你有一个DPC对象，你用IoRequestDpc函数把它放到队列中。如果在DPC例程真正运行前，你的设备又生成一个中断，并且如果你的ISR为此又请求了一个DPC，那么内核将简单地忽略第二个DPC请求。换句话说，不管ISR连续请求了多少个DPC，你的DPC对象仅在队列上一次，而且内核也仅调用你的回调例程一次。在这个调用中，你的DPC例程需要完成所有发生在上一个DPC之后的所有中断的相关工作。

一旦DPC派遣器提取(出队)了你的DPC对象，某些代码可能再次把它排入队列，甚至在这个DPC例程正执行时。如果该对象在同一CPU上被排队两次，那么不会产生任何问题。关于DPC处理的第二个重要细节是必须参考CPU的中断亲合性。通常，内核把DPC对象排队到与请求该DPC对象的同一个CPU上来处理，例如，刚服务了那个中断并调用了IoRequestDpc例程的CPU。一旦DPC派遣器取出了该DPC对象并在某个CPU上调用了它的回调例程，那么在理论上你的设备可以中断另一个CPU，而这个CPU最终也要请求那个DPC，结果两个不同的CPU将同时执行一个DPC。DPC例程的并发执行是否会产生问题，很明显，主要取决于你代码的具体处理细节。

对于DPC并发地运行在多个CPU上的情况，有几种方法可以避免这个潜在的问题。第一种方法并不是最好的，即调用KeSetTargetProcessorDpc函数把DPC指定为特定的CPU才能运行。同样，理论上你可以在第一次把中断连接到CPU时限定其CPU亲合性；如果你从不在ISR之外的地方排队DPC，那么该DPC也不会在任何其它的CPU上执行。然而，你指定DPC或中断的CPU亲合性的真正原因是提高性能，即，使DPC或ISR例程访问的数据和代码能存在于高速缓存中。

你还可以使用自旋锁或其它同步原语来防止多个DPC例程实例间的相互干扰。但在这里使用自旋锁要小心：通常你应该用ISR调整假定的DPC的多个实例，而使用普通的自旋锁，ISR的运行IRQL又太高，互锁链表(用ExInterlockedInsertHeadList族函数来维护)可以帮助你，因为(只要你不获取用于保护该链表的自旋锁)你可以在任何IRQL上使用这种链表。互锁形式的位OR和AND操作也可以帮助你，通过控制要完成的DPC例程的屏蔽位(例如用屏蔽位指定最近的中断情况)；必要时可以用InterlockedCompareExchange函数来辅助这些函数。

最简单的办法是确保设备不在你请求DPC和DPC例程完成其工作之间发生中断。(“咳，硬件设计者，不要产生不必要的中断！”)

第三个DPC细节，就是我认为比前两个次要的细节，关系到DPC的“重要性”属性。调用KeSetImportanceDpc函数，你可以为DPC指定三种重要性级别：

  • MediumImportance 是默认值，它指出该DPC应该排在所有当前排队的DPC之后。如果该DPC被排到其它处理器的队列中，则那个处理器不必立即中断去执行该DPC。如果它被排入当前处理器的队列，则只要可能，内核将立即请求一个DPC中断并开始服务该DPC。
  • HighImportance 将使DPC被排到队列头部。如果有两个高度重要的DPC几乎同时请求，则后排入队列的DPC先得到服务。
  • LowImportance 使DPC排到队列末尾。此外，内核不必立即中断相关处理器去执行这种DPC。

DPC重要级的效果是产生影响，没有必要去控制DPC发生的速度。即使一个有低重要性的DPC也会触发一个处理器的DPC中断，只要该处理器到达排队DPC的下限。如果设备能在DPC例程运行前再次产生中断，则可以降低该DPC的重要性，但这会增加你执行多工作项(work item)的可能性。如果你的DPC指定了与请求该DPC之外的CPU的亲合，则为DPC选择高重要性将增加ISR与DPC例程同时运行的可能性。这些可能性都不是确定的，改变或不改变DPC的重要性并不能消除这些可能性。

定制DPC对象

除了设备对象内置的DPC对象Dpc之外，你还可以创建其它DPC对象。在你的设备扩展或其它非分页的区域中为KDPC对象保留存储，然后初始化该对象：

typedef struct _DEVICE_EXTENSION {
  ...
  KDPC CustomDpc;
  ...
};

KeInitializeDpc(&pdx->CustomDpc, (PKDEFERRED_ROUTINE) DpcRoutine, fdo);

在KeInitializeDpc调用中，第二个参数是存在于非分页内存中的DPC例程的地址，第三个参数是一个任意上下文参数，将作为DPC例程的第二个参数发往DPC例程。

为了请求定制DPC例程的推迟调用，调用KeInsertQueueDpc函数：

BOOLEAN inserted = KeInsertQueueDpc(&pdx->CustomDpc, arg1, arg2);

这里，arg1和arg2是任意上下文指针，将被传递到定制DPC例程。如果DPC对象已进入某处理器的队列则该函数返回FALSE，否则返回TRUE。

同样，调用KeRemoveQueueDpc函数可以从处理器队列中删除一个DPC对象。

一个中断驱动设备的例子

我写了一个例子驱动程序，PCI42，来演示如何写典型的中断驱动(非DMA)设备上的各种驱动程序例程。处理这种设备的方法通常称为PIO(programmed I/O)方法，因为每传输一个单位的数据都需要程序来干预。

PCI42是为S5933 PCI芯片组写的一个哑驱动程序。S5933就象PCI总线与其上设备之间的媒介。S5933非常灵活。实际上，你可以编程其上的非易失RAM以便为你的设备初始化PCI配置空间。但PCI42使用S5933出厂时的默认状态。

为了简化问题，WDM驱动程序与S5933上的设备之间的通信或者采用DMA方式，或者通过一组收发数据的信箱寄存器。PCI42将使用信箱寄存器，以一次一个字节的方式传输数据。

S5933的开发包(S5933DK1)包括两个开发板和一个ISA接口卡，该卡经过一个带状电缆连接到S5933开发板。ISA卡允许你从另一侧直接访问S5933，以便提供额外的软件仿真功能。PCI42例子的一个组成部分就是那个ISA卡的驱动程序(S5933DK1.SYS)，它输出一个供测试程序使用的接口。

硬件工程师可能会嘲笑PCI42管理设备的简单方式。但使用这个简单例子你可以看到I/O处理操作的每一步。

初始化PCI42

PCI42中的StartDevice函数将初始化一个端口资源和一个中断资源。端口资源描述了I/O空间中的一组32位寄存器(共16个)，中断资源描述了主机赋予设备的INTA#中断。在StartDevice的最后，我们有下面设备专用代码：

NTSTATUS StartDevice(...)
{
  ...
  ResetDevice(pdx);
  status = IoConnectInterrupt(...);
  KeSynchronizeExecution(pdx->InterruptObject, (PKSYNCHRONIZE_ROUTINE) SetupDevice, pdx);
  return STATUS_SUCCESS;
}

我们调用了一个辅助例程(ResetDevice)来重置硬件。ResetDevice的一个任务是尽可能地阻止设备生成任何中断。然后我们调用IoConnectInterrupt函数把设备中断连接到我们的ISR。在IoConnectInterrupt返回前，设备可能生成中断，所以驱动程序和硬件必须提前做好准备。中断连接后，我们调用另一个名为SetupDevice的辅助函数来编程设备，使其正常工作。因为这一步可能使用与ISR相同的硬件寄存器，所以我们必须做好与ISR的同步，我们不希望存在任何向设备发送不一致指令的可能性。SetupDevice是StartDevice函数中的最后一步，这与我在第二章“WDM驱动程序的基本结构”中讲的正好相反，原因是PCI42没有寄存任何设备接口，因此也不必在这里有允许接口的代码。

ResetDevice是高度设备相关的例程，如下面代码：

VOID ResetDevice(PDEVICE_EXTENSION pdx)
{
  PAGED_CODE();

  WRITE_PORT_ULONG((PULONG) (pdx->portbase + MCSR), MCSR_RESET);					<--1

  LARGE_INTEGER timeout;
  timeout.QuadPart = -10 * 10000; // i.e., 10 milliseconds

  KeDelayExecutionThread(KernelMode, FALSE, &timeout);							<--2
  WRITE_PORT_ULONG((PULONG) (pdx->portbase + MCSR), 0);

  WRITE_PORT_ULONG((PULONG) (pdx->portbase + INTCSR), INTCSR_INTERRUPT_MASK);				<--3
}
  1. S5933有一个主控/状态寄存器(MCSR)，它控制着总线主控的DMA传输和其它动作。其中有4个位能重置设备的不同特征。我定义了MCSR_RESET常量来作为这四个标志位的掩码。所有这些常量都可以在PCI42工程的S5933.H文件中找到。
  2. 前三个重置标志属于S5933的内部特征，如果重置会立即产生效果。第四个标志设置为1，就是要求S5933上的功能设备进入重置状态。如果想取消重置状态，你必须明确地把这个标志重置为0。通常，需要给硬件一点时间来认识重置信号。KeDelayExecutionThread可以使线程进入睡眠状态10毫秒。根据硬件的实际需求你可以增加或减少这个常量值，但要记住这个值不要小于系统时钟的单位值。因为我们将要阻塞自己的线程，所以我们必须运行在PASSIVE_LEVEL级上的一个非任意线程上下文中。当前的情况是适合的，因为我们的最终调用者是PnP管理器，是它向我们发送了IRP_MN_START_DEVICE请求，并希望我们阻塞所在的系统线程。
  3. 重置设备的最后一步是清除任何未决的中断。S5933的中断控制/状态寄存器(INTCSR)中有六个中断标志。向这六个位置写入1将清除所有未决的中断。(如果我们写回的位掩码中的中断标志位置上有为0的位，则对应的中断状态不受影响。这种标志位被称为读/写清除或R/WC标志) INTCSR中的其它位用于使能各种中断。在这些地方写入0位将禁止相应的中断。

我们的SetupDevice函数十分简单：

VOID SetupDevice(PDEVICE_EXTENSION pdx)
{
  WRITE_PORT_ULONG((PULONG) (pdx->portbase + INTCSR),
           INTCSR_IMBI_ENABLE
            | (INTCSR_MB1 << INTCSR_IMBI_REG_SELECT_SHIFT)
            | (INTCSR_BYTE0 << INTCSR_IMBI_BYTE_SELECT_SHIFT)
          );
}

该函数重编程了INTCSR，使其内部的信箱寄存器1的字节0在发生改变时能产生中断。我们还指定了该芯片的其它中断条件，包括清空一个对外信箱寄存器的特定字节，一个读DMA传输的完成操作和一个写DMA传输的完成操作。

启动一个读操作

PCI42的StartIo例程使用下面编程模式：

VOID StartIo(IN PDEVICE_OBJECT fdo, IN PIRP Irp)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
  NTSTATUS status = IoAcquireRemoveLock(&pdx->RemoveLock, Irp);
  if (!NT_SUCCESS(status))
  {
    CompleteRequest(Irp, status, 0);
    return;
  }

  if (!stack->Parameters.Read.Length)
  {
    StartNextPacket(&pdx->dqReadWrite, fdo);
    CompleteRequest(Irp, STATUS_SUCCESS, 0);
    return;
  }

  pdx->buffer = (PUCHAR) Irp->AssociatedIrp.SystemBuffer;						<--1
  pdx->nbytes = stack->Parameters.Read.Length;
  pdx->numxfer = 0;

  KeSynchronizeExecution(pdx->InterruptObject, (PKSYNCHRONIZE_ROUTINE) TransferFirst, pdx);		<--2
}
  1. 在这里，我们把描述输入操作过程的参数保存到设备扩展中。PCI42使用DO_BUFFERED_IO方式，该方式不算典型但可以简化例子驱动程序。
  2. 由于我们的中断已经连接完，因此设备可以随时发出中断。ISR将在中断发生时传输数据字节，但我们希望ISR不必为具体使用哪个数据缓冲区或要读的字节数所烦恼。为了抑制ISR的热心，我们把一个标志放到设备扩展中，该标志名为activerequest，平常为FALSE。现在正是把该标志设为TRUE的时候。通常，当处理共享资源时，我们设置该标志的代码需要与ISR中测试该标志的代码同步，因此我们需要调用SynchCritSection例程。这种情况也发生在一个数据字节已准备好时，这样第一个中断将不再发生。TransferFirst是一个辅助例程，它检测这种可能性并读取第一个字节。插入设备有办法知道我们清空了信箱寄存器，因此它会在预期的时间发送下一个字节。下面就是TransferFirst例程：
VOID TransferFirst(PDEVICE_EXTENSION pdx)
{
  pdx->activerequest = TRUE;
  ULONG mbef = READ_PORT_ULONG((PULONG) (pdx->portbase + MBEF));
  if (!(mbef & MBEF_IN1_0))
    return;

  *pdx->buffer = READ_PORT_UCHAR(pdx->portbase + IMB1);
  ++pdx->buffer;
  ++pdx->numxfer;
  if (—pdx->nbytes == 0)
  {
    pdx->activerequest = FALSE;
    IoRequestDpc(pdx->DeviceObject, NULL, NULL);
  }
}

S5933有一个信箱空/满寄存器(MBEF)，其中的位指出每个信箱寄存器的每个字节的当前状态。这里，我们检测用于输入(内部信箱寄存器1，字节地址0)的寄存器字节是否未被读过。如果是，我们就读出它。这也许会达到该次传输要求量。我们事先准备好的处理例程(DpcForIsr)知道该怎么做，所以如果第一个字节满足了该请求，我们就请求一个DPC。(想一想，因为我们作为一个SynchCritSection例程被调用，所以我们正执行在DIRQL级上，并在一个中断自旋锁的保护下，因此我们不能立刻完成该IRP)

处理中断

在PCI42的正常操作中，S5933在信箱1到达一个新数据字节时发生中断。然后下面的ISR获得控制：

BOOLEAN OnInterrupt(PKINTERRUPT InterruptObject, PDEVICE_EXTENSION pdx)
{
  ULONG intcsr = READ_PORT_ULONG((PULONG) (pdx->portbase + INTCSR));		<--1
  if (!(intcsr & INTCSR_INTERRUPT_PENDING))
    return FALSE;

  BOOLEAN dpc = FALSE;

  PIRP Irp = GetCurrentIrp(&pdx->dqReadWrite);					<--2

  NTSTATUS status;

  if (pdx->busy)
  {
    if (Irp->Cancel)
      status = STATUS_CANCELLED;
    else
      status = AreRequestsBeingAborted(&pdx->dqReadWrite);
    if (!NT_SUCCESS(status))
      dpc = TRUE, pdx->nbytes = 0;
  }

  if (!Irp || AreRequestsBeingAborted(&pdx->dqReadWrite) || Irp->Cancel)	<--3
  {
    pdx->nbytes = 0;
    dpc = Irp != NULL;
  }

  while (intcsr & INTCSR_INTERRUPT_PENDING)					<--4
  {
    if (intcsr & INTCSR_IMBI)							<--5
    {
      if (pdx->nbytes && pdx->activerequest)
      {
        *pdx->buffer = READ_PORT_UCHAR(pdx->portbase + IMB1);
        ++pdx->buffer;
        ++pdx->numxfer;
        if (!—pdx->nbytes)
          dpc = TRUE;
      }
    }

    WRITE_PORT_ULONG((PULONG) (pdx->portbase + INTCSR), intcsr);		<--6

    intcsr = READ_PORT_ULONG((PULONG) (pdx->portbase + INTCSR));		<--7
  }

  if (dpc)									<--8
  {
    pdx->activerequest = FALSE;
    IoRequestDpc(pdx->DeviceObject, NULL, NULL);
  }

  return TRUE;
}
  1. 第一个任务是检查设备现在是否要中断。读S5933的INTCSR并测试其中一个位(INTCSR_INTERRUPT_PENDING)。如果该位被清除，我们立即返回。在这里，我选择设备扩展指针作为该例程(当调用IoConnectInterrupt时)的上下文参数，原因很明显：我们需要立即访问这个结构以获得端口基地址。
  2. 如果我们使用DEVQUEUE形式的队列，我们依靠队列对象来跟踪当前的IRP。这个中断也许是我们不需要的中断，因为我们现在没有服务任何IRP。在这种情况下，我们仍需要清除中断，但不做其它事情。
  3. 这时也可能发生PnP或电源管理事件，这将使派遣例程拒绝任何新的IRP。DEVQUEUE的AreRequestsBeingAborted函数能告诉我们这个事实，所以我们能立即放弃当前的请求。对于以一个字节一个字节处理的设备，放弃一个活动的请求是合理的。同样，检测一个需要长时间完成的IRP是否被取消也是一个好想法。如果设备仅在一个长的数据传输后才发生中断，你也可以不在ISR中做这个测试。
  4. 现在我们进入一个循环，该循环在设备当前的所有中断都被清除后结束。在循环的结尾，我们重读了INTCSR以确定是否还有中断产生。如果有，我们将重复这个循环。不应该在这里过多地占用CPU时间，我们应该避免重叠中断，因为中断服务自身是相对昂贵的。
  5. 如果S5933发生了中断，我们就从信箱寄存器中读入一个字节到当前IRP的I/O缓冲区中。如果你要在读操作后立即查看一下MBEF寄存器，你应该查看内部信箱寄存器1地址0对应的位，这个位将被对它的读操作清除。注意，我们不必通过测试MBEF来确定数据字节是否真正被改变，因为我们把设备编程为仅在该字节被改变时才发生中断。
  6. 把INTCSR写入以前的内容将产生清除六个R/WC中断位的效果，同时不改变那些只读位，并保留了所有读写控制位的原始设置。
  7. 这里，我们读出INTCSR以确定是否有另外的中断发生。如果有，我们将重复这个循环。
  8. 当我们执行过前面的代码后，如果完成当前IRP需要一个DPC，我们就设置dpc变量为TRUE。

PCI42的DPC例程如下：

VOID DpcForIsr(PKDPC Dpc, PDEVICE_OBJECT fdo, PIRP junk, PVOID Context)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  NTSTATUS status = STATUS_SUCCESS;
  PIRP Irp = GetCurrentIrp(&pdx->dqReadWrite);
  ULONG info = pdx->numxfer;
  StartNextPacket(&pdx->dqReadWrite, fdo);
  CompleteRequest(Irp, status, info);
}

测试PCI42

如果你要测试PCI42的操作，需要做几件事情。第一，安装S5933DK1开发板卡，包括ISA卡。使用添加硬件向导把S5933DK1.SYS驱动程序和PCI42.SYS驱动程序装入系统。(我发现Windows 98开始把这个开发板识别为一个不工作的声卡，我不得不用设备管理器先删除这个声卡，然后再安装PCI42驱动程序。Windows 2000正常地识别了这个开发板)

然后，同时运行ADDONSIM和TEST程序，它们在随书光盘的PCI42目录中。ADDONSIM通过ISA接口向信箱寄存器写入一个数据字节。TEST从PCI42中读取这个数据字节。

书中的例子代码与真正代码的不同之处

这里的DpcForisr、TransferFirst、OnInterrupt例程与随书光盘中的代码在细节上有些不同：

  1. 在实际的例子中，activerequest变量的名字为busy，但含义相同。
  2. 实际例子中的DpcForIsr例程仅简单地完成IRP，并不设置IRP中的IoStatus。TransferFirst和OnInterrupt例程将在请求DPC前填充这些值。这种安排的目的是使ISR能在遇到错误时能更容易地提前结束该IRP。
  3. 实际例子中的OnInterrupt例程有更完善的意外情况检测。在标记2和标记3之间的代码实际应该是这样：
PIRP Irp = GetCurrentIrp(&pdx->dqReadWrite);
NTSTATUS status;
if (pdx->busy)
{
  if (Irp->Cancel)
    status = STATUS_CANCELLED;
  else
    status = AreRequestsBeingAborted(&pdx->dqReadWrite);
  if (!NT_SUCCESS(status))
    dpc = TRUE, pdx->nbytes = 0;
}

这个逻辑在IRP已被取消，或放弃所有IRP的PnP/电源管理事件发生时，使ISR能停止该IRP。


直接内存存取(DMA)

Windows 2000的DMA传输基于如图7-6这样的抽象模型。在这个模型中，计算机被认为有这样一组“映射寄存器”，它们在CPU的物理地址和总线地址之间做相互转换。每个映射寄存器保存着一个物理页帧的地址。硬件使用一个“逻辑的”或总线专有的地址来读写内存。对软件来说映射寄存器扮演了与页表项相同的角色。

图7-6. DMA传输中的抽象计算机模型

某些CPU，如Alpha，含有真正的硬件映射寄存器，因此在初始化DMA传输时需要有一个步骤来保存某些映射寄存器。其它CPU，如Intel的x86，没有物理的映射寄存器，但写驱动程序时我们应该假定存在这些寄存器。在这样的计算机中，MapTransfer函数应保留使用属于系统的物理内存缓冲区，这样，DMA操作将使用保留的缓冲区。显然，在DMA传输前后必须有代码用来在DMA缓冲区和内存间复制数据。例如，在某些情况下，当应付一个有分散/聚集能力的总线主控设备时，MapTransfer阶段应该做无映射寄存器机器不能完成的全部工作。

Windows 2000内核使用一个称为适配器对象的数据结构来描述设备上的DMA特征，并用它来控制访问潜在的共享资源，如系统DMA通道和映射寄存器。你可以在StartDevice函数中调用IoGetDmaAdapter获得适配器对象的指针。适配器对象中有一个指针，指向一个名为DmaOperations的结构，该结构包含了所有你需要的其它函数，见表7-4。这些函数代替了以前版本Windows NT中的全局函数(如IoAllocateAdapter、IoMapTransfer，等等)。事实上，现在的全局函数都是调用DmaOperations函数的宏。

表7-4. DMA辅助例程中的DmaOperations函数指针

DmaOperations函数指针 | 描述
PutDmaAdapter | 销毁适配器对象
AllocateCommonBuffer | 分配公用缓冲区
FreeCommonBuffer | 释放公用缓冲区
AllocateAdapterChannel | 保留适配器和映射寄存器
FlushAdapterBuffers | 传输后刷新中间数据缓冲区
FreeAdapterChannel | 释放适配器和映射寄存器
FreeMapRegisters | 仅释放映射寄存器
MapTransfer | 安排传输的一个阶段
GetDmaAlignment | 取适配器的地址对齐要求
ReadDmaCounter | 取余量计数
GetScatterGatherList | 保留适配器并构造分散/聚集列表
PutScatterGatherList | 释放分散/聚集列表

传输策略

执行一个DMA传输需要考虑下面几个因素：

  • 如果设备有总线主控能力，那么它就有访问主存的必要电子部件，因此我们只需要告诉它几个基本事实，如从哪开始，需要传输多少单位的数据，是输入操作还是输出操作，等等。你可以向硬件设计者咨询以得到细节部分，否则你只能参考硬件级的说明文档。
  • 一个有分散/聚集能力的设备可以在自身与不连续的物理内存区之间传输大块数据。设备的分散/聚集能力对软件十分有利，它可以避免对具有连续页帧的大数据块的需求。页可以被简单地锁定在所在的物理内存，然后把内存地址告诉设备。
  • 如果设备不是总线主控设备，那么你需要使用计算机主板上的系统DMA控制器。这种形式的DMA传输有时被称为副DMA。与ISA总线连接的系统DMA控制器对所能访问的物理内存和一次传输的数据量会有些限制。EISA总线的DMA控制器去掉了这些限制。在Windows 2000中，你不必知道硬件具体插入到哪种类型的总线，因为系统自动参考这些不同的限定。
  • 通常，DMA操作将包括编程硬件映射寄存器或操作前后的数据复制。如果设备需要连续地读写数据，你不会希望在每次I/O请求中都做这两步，这将大大地降低处理速度，在某些情况下是不能接受的。因此，你应该分配一个公用缓冲区，设备和驱动程序可以在任何时间同时访问这个缓冲区。

尽管这四种因素的相互影响会产生许多种不同的结果，但你执行的步骤中有许多共同的特征。图7-7显示了一次传输过程。在StartIo例程中，你通过请求适配器对象的所有权而启动了DMA传输。所有权仅在你与其它设备共享一个系统DMA通道时才有意义，但Windows 2000的DMA模型要求你必须执行这一步。当I/O管理器同意给你适配器对象的所有权后，它就分配一些映射寄存器为你临时使用，并回调你提供的AdapterControl例程。在你的AdapterControl例程中，你执行一个“传输映射”步骤来安排传输的第一阶段(也许是仅有的)。如果映射寄存器不够，则需要多个传输阶段；你的设备必须能处理发生在两个阶段间的任何延迟。

图7-7. DMA传输期间的所有权流程

一旦AdapterControl例程初始化完第一阶段的映射寄存器，就可以通知设备开始操作。传输完成后设备将产生一个中断，于是系统调度一个DPC。如果必要，DPC例程将初始化下一阶段的传输，否则它将完成这个请求。

在这个过程的某个时刻，你将释放映射寄存器和适配器对象。这两个事件发生的时间会因为上面提到的几种因素而改变。

执行DMA传输

现在让我们详细了解被称为“基于包(packet-based)”的DMA传输，在这种方式中，你将使用IRP携带的数据缓冲区来传输一个固定量的数据。为了简单，让我们假设面对当前最普通的情况：一个基于PCI的总线主控设备并且没有分散/聚集能力。

开始，当你创建设备对象时，你通常指定使用Direct缓冲方式，即设置DO_DIRECT_IO标志。这里你也应该使用这种方法，因为你调用的MapTransfer函数需要一个内存描述符列表作为参数，尽管这种选择会对缓冲区对齐造成一点小麻烦。除非应用程序在CreateFile调用中使用FILE_FLAG_NO_BUFFERING标志，I/O管理器从不强制设备对象的Alignment-Requirement值对齐到用户模式的数据缓冲区上。(除非在checked版本的系统中，否则绝不要对内核模式的调用者做这种强制要求) 但是，如果你的设备或HAL确实需要DMA缓冲区开始于某个特殊的边界，你应该最后把一小部分用户数据复制到正确对齐的内部缓冲区中以迎合这个对齐需求，或者干脆拒绝任何缓冲区未对齐请求。

在StartDevice函数中，可以使用下面代码创建一个适配器对象：

INTERFACE_TYPE bustype;
ULONG junk;
IoGetDeviceProperty(pdx->Pdo,
            DevicePropertyLegacyBusType,
            sizeof(bustype),
            &bustype,
            &junk);

DEVICE_DESCRIPTION dd;
RtlZeroMemory(&dd, sizeof(dd));
dd.Version = DEVICE_DESCRIPTION_VERSION;
dd.Master = TRUE;
dd.InterfaceType = bustype;
dd.MaximumLength = MAXTRANSFER;
dd.Dma32BitAddresses = TRUE;

pdx->AdapterObject = IoGetDmaAdapter(pdx->Pdo, &dd, &pdx->nMapRegisters);

这段代码的最后一条语句特别重要。IoGetDmaAdapter函数将与总线驱动程序或HAL沟通，以创建一个适配器对象，然后返回地址。第一个参数(pdx->Pdo)指出设备的PDO。第二个参数指向一个DEVICE_DESCRIPTION结构，初始化该结构使其描述设备的DMA特征。最后一个参数指示系统应该在哪存放映射寄存器的最大数量。注意，我在设备扩展中保留了两个域(AdapterObject和nMapRegisters)，它们将用于接收该函数的这两个输出。

在StopDevice函数中，使用这个调用销毁适配器对象：

VOID StopDevice(...)
{
  ...
  if (pdx->AdapterObject)
    (*pdx->AdapterObject->DmaOperations->PutDmaAdapter)(pdx->AdapterObject);
  pdx->AdapterObject = NULL;
  ...
}

如果设备是总线主控方式，那么你不能获得DMA资源。即你的资源提取循环语句中不需要CmResourceTypeDma类型的case子句。PnP管理器不赋予你任何DMA资源，因为硬件本身包含有执行DMA传输所必须的所有电路逻辑，所以系统没必要赋予你DMA资源。

以前版本的Windows NT依赖一个名为HalGetAdapter的函数来获取DMA适配器对象。为了兼容，该函数现在仍存在，但新的WDM驱动程序应该调用IoGetDmaAdapter函数替代。这两个函数的不同之处是，IoGetDmaAdapter函数先发出一个即插即用请求IRP_MN_QUERY_INTERFACE来确定PDO是否支持直接调用接口GUID_BUS_INTERFACE_STANDARD。如果支持，IoGetDmaAdapter将使用这个接口来分配适配器对象。如果不支持，它就简单地调用HalGetAdapter函数。

表7-5列出了DEVICE_DESCRIPTION结构中的所有域，你需要把这个结构传递给IoGetDmaAdapter函数。与总线主控设备相关的域就是前面StartDevice代码中使用的域。HAL也许需要或不需要了解设备是否认识32位或64位地址，(例如，Intel x86的HAL仅在你分配公共缓冲区时才使用这个标志) 但为了保持可移植性，你应该指出这个能力。通过清零整个结构，我们设置了ScatterGather为FALSE。由于我们不使用系统DMA通道，所以创建适配器对象的例程将不检查DmaChannel、DmaPort、DmaWidth、DemandMode、AutoInitialize、IgnoreCount、DmaSpeed域。

表7-5. IoGetDmaAdapter函数使用的DEVICE_DESCRIPTION结构

域名 | 描述 | 与设备的关系
Version | 结构的版本号，初始化为DEVICE_DESCRIPTION_VERSION | All
Master | 总线主控设备，设置基于实际设备 | All
ScatterGather | 设备支持分散/聚集表，设置基于实际设备 | All
DemandMode | 使用系统DMA控制器的需求模式，设置基于实际设备 | Slave
AutoInitialize | 使用系统DMA控制器的自动初始化模式，设置基于实际设备 | Slave
Dma32BitAddresses | 能够使用32位物理地址 | 公共缓冲区
IgnoreCount | 控制器不维护一个精确的传输计数，设置基于实际设备 | Slave
Reserved1 | 保留，必须为FALSE |
Dma64BitAddresses | 能够使用64位物理地址 | 公共缓冲区
DoNotUse2 | 保留，必须为0 |
DmaChannel | DMA通道号，用资源描述符中的通道属性信息初始化 | Slave
InterfaceType | 总线类型，DevicePropertyLegacyBusType来自IoGetDeviceProperty调用的结果 | All
DmaWidth | 传输的宽度，Width8Bits、Width16Bits、Width32Bits | Slave
DmaSpeed | 传输速度，Compatible、TypeA、TypeB、TypeC、TypeF | Slave
MaximumLength | 单个传输的最大长度，设置基于实际设备(近似为PAGE_SIZE的整数倍) | All
DmaPort | Microchannel类型总线端口号，用端口资源描述符属性信息初始化 | Slave

为了初始化一个I/O操作，StartIo例程必须首先调用适配器对象的AllocateAdapterChannel例程以保留适配器对象。该函数的一个参数是AdapterControl例程的地址，I/O管理器将在保留操作完成后调用这个地址。下面代码例子演示了AllocateAdapterChannel函数的准备和调用过程：

typedef struct _DEVICE_EXTENSION {
  ...
  PADAPTER_OBJECT AdapterObject;	// device's adapter object				<--1
  ULONG nMapRegisters;			// max # map registers
  ULONG nMapRegistersAllocated;		// # allocated for this xfer
  ULONG numxfer;			// # bytes transferred so far
  ULONG xfer;				// # bytes to transfer during this stage
  ULONG nbytes;				// # bytes remaining to transfer
  PVOID vaddr;				// virtual address for current stage
  PVOID regbase;			// map register base for this stage
  ...
} DEVICE_EXTENSION, *PDEVICE_EXTENSION;

VOID StartIo(PDEVICE_OBJECT fdo, PIRP Irp)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  NTSTATUS status = IoAcquireRemoveLock(&pdx->RemoveLock, Irp);					<--2
  if (!NT_SUCCESS(status))
  {
    CompleteRequest(Irp, status, 0);
    return;
  }

  PMDL mdl = Irp->MdlAddress;									<--3
  pdx->numxfer = 0;
  pdx->xfer = pdx->nbytes = MmGetMdlByteCount(mdl);
  pdx->vaddr = MmGetMdlVirtualAddress(mdl);

  ULONG nregs = ADDRESS_AND_SIZE_TO_SPAN_PAGES(pdx->vaddr, pdx->nbytes);			<--4
  if (nregs > pdx->nMapRegisters)
  {
    nregs = pdx->nMapRegisters;
    pdx->xfer = nregs * PAGE_SIZE - MmGetMdlByteOffset(mdl);
  }
  pdx->nMapRegistersAllocated = nregs;

  status = (*pdx->AdapterObject->DmaOperations->AllocateAdapterChannel)				<--5
        (pdx->AdapterObject,
         fdo,
         nregs,
         (PDRIVER_CONTROL) AdapterControl,
         pdx);
  if (!NT_SUCCESS(status))
  {
    IoReleaseRemoveLock(&pdx->RemoveLock, Irp);
    CompleteRequest(Irp, status, 0);
    StartNextPacket(&pdx->dqReadWrite, fdo);
  }
}
  1. 你的设备扩展需要几个与DMA传输相关的域。注释信息指出了这些域的用途。
  2. 此时正是请求“删除自旋锁”的时候，这个自旋锁将阻止在I/O操作未决期间出现PnP删除事件。平衡IoReleaseRemoveLock函数的调用发生在最终完成请求的DPC例程中。
  3. 这几条语句初始化设备扩展中的几个域，为传输的第一阶段做准备。
  4. 这里，我们计算在这次传输中要求系统为我们保留多少映射寄存器。我们从计算整个传输需求的寄存器个数开始。ADDRESS_AND_SIZE_TO_SPAN_PAGES宏会考虑到缓冲区也许会跨过页界限。然而，这个数值最后可能超过IoGetDmaAdapter调用允许的最大值。在这种情况下，我们需要执行多步传输。因此我们将回到第一阶段以便能使用允许数量的映射寄存器。我们还需要记住分配了多少个映射寄存器(在设备扩展的nMapRegistersAllocated域)以便以后我们能正确地释放它们。
  5. 在这个AllocateAdapterChannel调用中，我们指定了适配器对象的地址、我们自己设备对象的地址，映射寄存器个数的计算结果，以及AdapterControl例程的地址。最后一个参数(pdx)是AdapterControl例程的上下文参数。

通常，几个设备可以共享一个适配器对象。但在实际环境中，共享仅发生在需要依赖系统DMA控制器时；总线主控设备拥有自己专用的适配器对象。但是，因为你不必了解系统在创建适配器对象时是如何做决定的，所以你不要做关于这一点的任何假设。当你调用AllocateAdapterChannel时，适配器对象也许会忙，因此你的请求将被放到一个队列中，直到这个适配器对象恢复到有效状态。同样，计算机上的所有DMA设备共享一组映射寄存器。进一步的延迟能跟着发生，直到请求数量的寄存器都变为有效。这两个延迟都发生在AllocateAdapterChannel中，该函数会在适配器对象和所有你请求的映射寄存器都变为有效时才调用你的AdapterControl例程。

尽管PCI总线主控设备拥有自己的适配器对象，但如果设备没有分散/聚集能力，它仍需要使用映射寄存器。而在Alpha这样的CPU上存在着映射寄存器，AllocateAdapterChannel将把这些寄存器保留给你使用。但像Intel这样的CPU不存在映射寄存器，AllocateAdapterChannel将使用软件模拟，例如物理内存中的一段连续区域。

AllocateAdapterChannel把什么排入队列？

为了等待适配器对象或必要数量的映射寄存器，AllocateAdapterChannel把你的设备对象放入了队列。有些设备结构可以允许你同时执行多个DMA传输。因为你一次只能把一个设备对象放入适配器对象的队列中(这样不会弄坏系统)，你需要创建多个哑设备对象来利用这种多DMA传输的能力。

正如我们讨论过的，AllocateAdapterChannel最终将调用你的AdapterControl例程(在DISPATCH_LEVEL，就象你的StartIo例程)。你有两个任务需要完成。第一，你应该调用适配器对象的MapTransfer例程以便为I/O操作的第一阶段准备映射寄存器和其它系统资源。如果是总线主控设备，MapTransfer将返回一个代表传输第一阶段开始点的逻辑地址。这个逻辑地址可能会与CPU的物理内存地址相同，但也可能会不同。你所需要知道的全部就是该地址是编程设备硬件的正确地址。MapTransfer会修正你请求的传输长度以适合其使用的映射寄存器，所以你应该把保存当前阶段传输长度的变量地址作为参数提供。

第二个任务是执行与“通知设备这个物理地址”操作中涉及的任何设备相关的操作步骤，然后开始操作设备硬件：

IO_ALLOCATION_ACTION
AdapterControl(PDEVICE_OBJECT fdo, PIRP junk, PVOID regbase, PDEVICE_EXTENSION pdx)
{
  PIRP Irp = GetCurrentIrp(&pdx->dqReadWrite);								<--1
  PMDL mdl = Irp->MdlAddress;
  PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
  BOOLEAN isread = stack->MajorFunction == IRP_MJ_READ;							<--2
  pdx->regbase = regbase;										<--3
  KeFlushIoBuffers(mdl, isread, TRUE);									<--4
  PHYSICAL_ADDRESS address = (*pdx->AdapterObject->DmaOperations->MapTransfer)				<--5
                (pdx->AdapterObject,
                 mdl,
                 regbase,
                 pdx->vaddr,
                 &pdx->xfer,
                 !isread);
  ...													<--6
  return DeallocateObjectKeepRegisters;									<--7
}
  1. AdapterControl的第二个参数名为junk，是设备对象的CurrentIrp域中存放的任何值。如果你使用DEVQUEUE来排队IRP，你需要询问DEVQUEUE对象当前是什么IRP。如果你使用标准模型，即，使用IoStartPacket和IoStartNextPacket来管理队列，那么junk将是正确的IRP。在那种情况下，我就把这个参数命名为Irp。
  2. 使用DMA的输入和输出操作之间很相似，所以为了方便通常把处理这两个操作的代码放到一个单独的子程序中。这行代码检查IRP的主功能码以确定是读操作还是写操作。
  3. 这个函数的regbase参数是一个不透明的句柄，标识映射寄存器组，这些寄存器为这个操作而保留。你以后会需要这个值，因此应该把它保存到设备扩展中。
  4. KeFlushIoBuffers确保所有处理器高速缓冲中的内容为你正使用的内存缓冲区中的内容。第三个参数(TRUE)指出你正为准备一个DMA操作而刷新高速缓冲。有些CPU结构需要这个步骤，但通常情况下，DMA传输直接面向内存不必涉及高速缓冲。
  5. MapTransfer例程为传输的一个阶段编程DMA硬件，然后返回传输起始的物理地址。注意你应该为该函数的第二个参数提供一个MDL的地址。因为在这里你需要一个MDL，在第一次创建设备对象时你还可以选择DO_DIRECT_IO缓冲区模式，这样I/O管理器将为你自动创建MDL。你还需要传递映射寄存器基地址(regbase)。你要指出MDL的哪部分卷入这个阶段的操作，即提供一个虚拟地址(pdx->vaddr)和一个字节计数(pdx->xfer)。MapTransfer将使用这个虚拟地址参数计算缓冲区中的一个偏移，利用这个偏移地址它能确定包含你的数据的物理页号。
  6. 在这里你可以以设备专用的方式编程你的硬件。例如，你也许使用某个WRITE_Xxx HAL例程向卡上的寄存器发送物理地址和字节计数值，然后你可能选通某个命令寄存器开始传输数据。
  7. 我们返回常量DeallocateObjectKeepRegisters以指出我们使用完适配器对象但仍在使用映射寄存器。在这个特别的例子中(PCI总线主控)，对适配器对象没有任何争夺，所以释放适配器对象不会有事。在其它总线主控形式中，我们可能与其它设备共享一个DMA控制器。释放适配器对象将允许其它设备使用我们用过的映射寄存器组进行传输。

中断通常都发生在传输启动后的很短一段时间后，并且中断服务例程通常都请求一个DPC来处理传输第一阶段的完成。你的DPC例程可以是这样：

VOID DpcForIsr(PKDPC Dpc, PDEVICE_OBJECT fdo, PIRP junk, PVOID Context)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  PIRP Irp = GetCurrentIrp(&pdx->dqReadWrite);								<--1
  PMDL mdl = Irp->MdlAddress;
  BOOLEAN isread = IoGetCurrentIrpStackLocation(Irp)->MajorFunction == IRP_MJ_READ;
  (*pdx->AdapterObject->DmaOperations->FlushAdapterBuffers)(pdx->AdapterObject,				<--2
                                mdl,
                                pdx->regbase,
                                pdx->vaddr,
                                pdx->xfer,
                                !isread);
  pdx->nbytes -= pdx->xfer;										<--3
  pdx->numxfer += pdx->xfer;
  NTSTATUS status = STATUS_SUCCESS;
  ...													<--4
  if (pdx->nbytes && NT_SUCCESS(status))
  {
    pdx->vaddr = (PVOID) ((PUCHAR) pdx->vaddr + pdx->xfer);						<--5
    pdx->xfer = pdx->nbytes;
    ULONG nregs = ADDRESS_AND_SIZE_TO_SPAN_PAGES(pdx->vaddr, pdx->nbytes);				<--6
    if (nregs > pdx->nMapRegistersAllocated)
    {
      nregs = pdx->nMapRegistersAllocated;
      pdx->xfer = nregs * PAGE_SIZE;
    }
    PHYSICAL_ADDRESS address = (*pdx->AdapterObject->DmaOperations->MapTransfer)(pdx->AdapterObject,
                                         mdl,
                                         pdx->regbase,
                                         pdx->vaddr,
                                         pdx->xfer,
                                         !isread);
    ...
  }
  else
  {
    ULONG numxfer = pdx->numxfer;
    (*pdx->AdapterObject->DmaOperations->FreeMapRegisters)(pdx->AdapterObject,				<--7
                               pdx->regbase,
                               pdx->nMapRegistersAllocated);
    IoReleaseRemoveLock(&pdx->RemoveLock, Irp);								<--8
    StartNextPacket(&pdx->dqReadWrite, fdo);
    CompleteRequest(Irp, status, numxfer);
  }
}
  1. 当使用DEVQUEUE排队IRP时，你可以依靠这种队列对象来跟踪当前IRP。
  2. FlushAdapterBuffers用于应付使用系统拥有的中间缓冲区进行传输的情形。如果你做完的输入操作跨过了一个页边界，则输入数据现在停留在中间缓冲区中，需要复制到用户模式缓冲区。
  3. 这里，我们在刚完成的传输阶段后更新了剩余的和累积的数据计数。
  4. 在这点上，确定完成的当前传输阶段是成功还是返回错误。例如，读一个状态端口或检查由中断例程执行的类似操作所返回的结果。在这个例子中，我把status变量设置为STATUS_SUCCESS，期望你如果发现错误就改变这个变量。
  5. 如果传输还没完成，你需要编程下一个传输阶段。这个过程的第一步是计算用户模式缓冲区下一部分的虚拟地址。记住，这个计算只是用一个数，实际上我们并不用这个虚拟地址访问内存。访问内存是一个坏的想法，因为我们当前执行在一个任意线程上下文中。
  6. 接下来的几个语句与在第一阶段中StartIo和AdapterControl执行的语句几乎完全相同。最后的结果将是一个逻辑地址，可以被编程到你的设备中。它对应的物理地址可能会也可能不会被CPU理解。一个小的不利是我们被限制在只能使用由AdapterControl例程分配的那些映射寄存器；StartIo把这些寄存器的个数保存在设备扩展的nMapRegistersAllocated域中。
  7. 如果整个传输现在完成，我们需要释放使用过的映射寄存器。
  8. 该DPC例程的剩下几条语句用于处理IRP的完成机制。在这里我们释放了在StartIo中获取的删除自旋锁。

使用分散/聚集表的传输

如果硬件支持分散/聚集能力，那么系统可以更容易地处理设备的DMA传输。分散/聚集能力允许设备传输所涉及的页在物理内存中不连续。

这种设备的StartDevice例程在创建适配器对象上使用与前面讨论的同样的方式，除了ScatterGather标志应该设为TRUE。

传统方法(即在以前版本Windows NT中使用的方法)在安排一个涉及分散/聚集功能的DMA传输时，几乎等同于前面段“执行DMA传输”中的基于包的例子。新方法的唯一不同之处是它需要为传输的每个阶段多次调用MapTransfer。每次调用后你都会得到分散/聚集列表中的一个包含物理地址和长度的数组元素。当循环完成时，你可以使用设备专用的方法把分散/聚集列表发送到设备，然后开始传输。

我需要做一些假设以便你能恰当地构造分散/聚集列表。首先，我假设你已经定义了一个MAXSG常量，它指出设备能处理的最大的分散/聚集列表元素个数。为了使事情更简单，我还要假设你能使用WDM.H中的SCATTER_GATHER_LIST结构构造这个列表：

typedef struct _SCATTER_GATHER_ELEMENT {
  PHYSICAL_ADDRESS Address;
  ULONG Length;
  ULONG_PTR Reserved;
} SCATTER_GATHER_ELEMENT, *PSCATTER_GATHER_ELEMENT;

typedef struct _SCATTER_GATHER_LIST {
  ULONG NumberOfElements;
  ULONG_PTR Reserved;
  SCATTER_GATHER_ELEMENT Elements[];
} SCATTER_GATHER_LIST, *PSCATTER_GATHER_LIST;

最后，我简单地假设你在AddDevice函数中分配了最大容量的分散/聚集列表：

pdx->sglist = (PSCATTER_GATHER_LIST)
  ExAllocatePool(NonPagedPool, sizeof(SCATTER_GATHER_LIST)+MAXSG*sizeof(SCATTER_GATHER_ELEMENT));

有了这些基础，你的AdapterControl例程应该像这样：

IO_ALLOCATION_ACTION AdapterControl(PDEVICE_OBJECT fdo, PIRP junk, PVOID regbase, PDEVICE_EXTENSION pdx)
{
  PIRP Irp = GetCurrentIrp(&pdx->dqReadWrite);								<--1
  PMDL mdl = Irp->MdlAddress;
  BOOLEAN isread = IoGetCurrentIrpStackLocation(Irp)->MajorFunction == IRP_MJ_READ;
  pdx->regbase = regbase;
  KeFlushIoBuffers(mdl, isread, TRUE);
  PSCATTER_GATHER_LIST sglist = pdx->sglist;

  ULONG xfer = pdx->xfer;										<--2
  PVOID vaddr = pdx->vaddr;
  pdx->xfer = 0;
  ULONG isg = 0;
  while (xfer && isg < MAXSG)										<--3
  {
    ULONG elen = xfer;
    sglist->Elements[isg].Address = 									<--4
      (*pdx->AdapterObject->DmaOperations->MapTransfer)(pdx->AdapterObject,
                            mdl,
                            regbase,
                            pdx->vaddr,
                            &elen,
                            !isread);
    sglist->Elements[isg].Length = elen;
    xfer -= elen;											<--5
    pdx->xfer += elen;
    vaddr = (PVOID) ((PUCHAR) vaddr + elen);
    ++isg;												<--6
  }
  sglist->NumberOfElements = isg;
  ...													<--7
  return DeallocateObjectKeepRegisters;									<--8
}
  1. 见早期的讨论(在“执行DMA传输”中)，关于如何在AdapterControl例程中获得正确的IRP指针。
  2. 我们以前(在StartIo)曾基于可允许的映射寄存器数量计算过pdx->xfer。我们将要开始传输那些数据，但是可以允许的分散/聚集元素个数会进一步限制这个阶段的传输量。在接下来的循环中，xfer将等于还没有被映射的字节个数，因此我们需要重新计算pdx->xfer。
  3. 这就是我们调用MapTransfer函数来构造分散/聚集元素的循环。我们将继续这个循环直到已经映射了该传输的整个阶段，或者到我们用完分散/聚集元素。
  4. 当我们为一个分散/聚集设备调用MapTransfer函数时，该函数将修改长度参数(elen)以指出给定虚拟地址(vaddr)中从哪开始是物理上连续的区域，从而可以被映射为一个单独的分散/聚集元素。它还返回这个连续区域的起始物理地址。
  5. 这里就是我们修改描述当前传输阶段变量的地方。当离开这个循环时，xfer将回到0(否则就是我们用完了分散/聚集元素)，pdx->xfer将等于我们能映射的全部元素的总数，vaddr将等于最后一个映射之后的字节，我们不用修改设备扩展中的pdx->vaddr域，我们正在DPC例程中做这件事情。另一个讨厌的细节。
  6. 这里是我们增加分散/聚集元素索引以表示我们已经使用过一个。
  7. 在这点上，我们把分散/聚集元素isg编程到设备中。然后为该请求启动设备工作。
  8. 返回DeallocateObjectKeepRegisters对于总线主控设备是合适的。理论上你可以有一个有分散/聚集能力的非主控设备，但应返回KeepObject。

设备现在大概正执行DMA传输，然后用中断通知传输完成。你的ISR请求了一个DPC，之后你的DPC例程又初始化该操作的下一个阶段。DPC例程将执行一个MapTransfer循环，就象我在初始化过程中给出的循环。我把这段代码细节交给你去练习。

使用GetScatterGatherList

Windows 2000为普通情况下的循环调用MapTransfer函数提供了一个捷径，它避免了相对麻烦的循环，你可以不使用映射寄存器或者使用不超过映射寄存器最多个数来完成整个传输。这个捷径在光盘上的SCATGATH例子中给出，它包含了对GetScatterGatherList例程的调用而不是AllocateAdapterChannel。你的StartIo例程应该像这样：

VOID StartIo(PDEVICE_OBJECT fdo, PIRP Irp)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
  NTSTATUS status = IoAcquireRemoveLock(&pdx->RemoveLock, Irp);
  if (!NT_SUCCESS(status))
  {
    CompleteRequest(Irp, status, 0);
    return;
  }
  PMDL mdl = Irp->MdlAddress;
  ULONG nbytes = MmGetMdlByteCount(mdl);
  PVOID vaddr = MmGetMdlVirtualAddress(mdl);
  BOOLEAN isread = stack->MajorFunction == IRP_MJ_READ;
  pdx->numxfer = 0;
  pdx->nbytes = nbytes;
  status = (*pdx->AdapterObject->DmaOperations->GetScatterGatherList)
    (pdx->AdapterObject,
     fdo,
     mdl,
     vaddr,
     nbytes,
     (PDRIVER_LIST_CONTROL) DmaExecutionRoutine,
     pdx,
     !isread);
  if (!NT_SUCCESS(status))
  {
    IoReleaseRemoveLock(&pdx->RemoveLock, Irp);
    CompleteRequest(Irp, status, 0);
    StartNextPacket(&pdx->dqReadWrite, fdo);
  }
}

GetScatterGatherList的调用在上面代码片段中用粗体显示，如果需要，该函数将等待，直到系统同意你使用适配器对象和所有你需要的映射寄存器。然后该函数创建一个SCATTER_GATHER_LIST结构并传递给DmaExecutionRoutine例程。之后你可以使用分散/聚集元素中的物理地址编程你的设备并开始传输：

VOID DmaExecutionRoutine(PDEVICE_OBJECT fdo, PIRP junk, PSCATTER_GATHER_LIST sglist, PDEVICE_EXTENSION pdx)
{
  PIRP Irp = GetCurrentIrp(&pdx->dqReadWrite);
  pdx->sglist = sglist;											<--1
  ...													<--2
}
  1. 在DPC例程中你会需要分散/聚集列表的地址，这个地址将被PutScatterGatherList用于释放列表。
  2. 在这个点上，使用分散/聚集列表中的地址长度对(address and length pairs)来编程设备的读写操作。如果列表中的元素个数超过设备一次能处理的能力，你需要分阶段执行整个传输。如果你能相当快速地编程一个阶段，我建议你在ISR中加入初始化下一阶段的逻辑。如果你也这么认为，你的DmaExecutionRoutine函数也许会与你的ISR同时执行，所以这个额外的逻辑不要大。我在SCATGATH例子中使用了这个想法。

当传输完成时，我们调用适配器对象的PutScatterGatherList函数释放列表和适配器：

VOID DpcForIsr(PKDPC Dpc, PDEVICE_OBJECT fdo, PIRP junk, PVOID Context)
{
  ...
  (*pdx->AdapterObject->DmaOperations->PutScatterGatherList)(pdx->AdapterObject, pdx->sglist, !isread);
  ...
}

为了判断你是否能使用GetScatterGatherList，你需要能预测是否有适合使用该函数的前提。在Intel的32位平台上，PCI或EISA总线上的分散/聚集设备可以确保不需要任何映射寄存器。即使在ISA总线上，你只被允许请求最多16个映射寄存器替代(如果是总线主控的设备，则只能有8个)，除非物理内存十分紧张以至于I/O系统不能分配那么多的中间I/O缓冲区。在那种情况下，你不能用传统模式做DMA操作，所以也没有需要担心的地方。

如果你在编写驱动程序时不能准确地预测你能否使用GetScatterGatherList，我的建议是回到传统的MapTransfer调用循环。总之，你需要放入处理GetScatterGatherList不工作情况的代码，并使驱动程序中的这两段逻辑保持简洁。

使用系统控制器的传输

如果你的设备不是总线主控方式，则需要使用系统DMA控制器来执行DMA传输，即人们常使用的词“slave DMA”，指设备不能管理自己的DMA传输。系统DMA控制器有几个特征，这影响到如何进行DMA传输过程的内部细节：

  • 所有slave设备必须共享有限的DMA通道数量。AllocateAdapterChannel在这种共享情形中具有真正意义，因为一次只能有一个设备可以使用特定的通道。
  • 你可以在PnP管理器发给你的I/O资源列表中找到一个CmResourceTypeDma资源。
  • 你的硬件应该连接到它使用的特定通道，或者是物理上或者是逻辑上。如果你能配置DMA通道连接，应该在StartDevice中发出适当的命令。
  • ISA总线的系统DMA控制器仅能访问前16兆字节的物理内存，其中有四个通道用于传输8位数据，三个通道用于传输16位数据。8位通道的控制器不能正确地处理越过64KB界限的缓冲区；16位通道的控制器不能正确地处理越过128KB界限的缓冲区。

抛开这些因素，你的驱动程序代码将与总线主控设备的代码非常相似。但StartDevice例程中准备IoGetDmaAdapter调用的过程会麻烦一些，你的AdapterControl例程和DPC例程分别负责释放适配器对象和释放映射寄存器。

在StartDevice函数中，需要用一点额外的代码来确定PnP管理器到底赋给你哪一个DMA通道，并且你还需要为IoGetDmaAdapter函数初始化DEVICE_DESCRIPTION结构中更多的域：

NTSTATUS StartDevice(...)
{
  ULONG dmachannel;   // system DMA channel #
  ULONG dmaport;      // MCA bus port number
  ...
  for (ULONG i = 0; i < nres; ++i, ++resource)
  {
    switch (resource->Type)
    {
    case CmResourceTypeDma:
      dmachannel = resource->u.Dma.Channel;							<--1
      dmaport = resource->u.Dma.Port;
      break;
    }
  }
  ...
  INTERFACE_TYPE bustype;									<--2
  IoGetDeviceProperty(...);

  DEVICE_DESCRIPTION dd;
  RtlZeroMemory(&dd, sizeof(dd));
  dd.Version = DEVICE_DESCRIPTION_VERSION;
  dd.InterfaceType = bustype;
  dd.MaximumLength = MAXTRANSFER;

  dd.DmaChannel = dmachannel;									<--3
  dd.DmaPort = dmaport;
  dd.DemandMode = ??;
  dd.AutoInitialize = ??;
  dd.IgnoreCount = ??;
  dd.DmaWidth = ??;
  dd.DmaSpeed = ??;

  pdx->AdapterObject = IoGetDmaAdapter(...);
}
  1. I/O资源列表中将含有一个DMA资源，从这个资源中你可以提取通道号和端口数。通道号指出系统DMA控制器支持的一个DMA通道。而端口号仅关系到有微通道(MCA)总线的机器。
  2. 如何确定总线类型见以前的讨论(在“执行DMA传输”段中)。
  3. 从这里开始，你必须初始化DEVICE_DESCRIPTION结构中的几个域，基于你对自己设备的了解。见表7-5。

关于适配器控制和DPC例程的每件事都与前面处理无分散/聚集能力的总线主控设备中相同，但有两个细节除外。第一，AdapterControl返回另一个值：

IO_ALLOCATION_ACTION AdapterControl(...)
{
  ...
  return KeepObject;
}

返回值KeepObject指出我们希望保留映射寄存器和DMA通道的控制。第二，因为我们在AdapterControl返回时没有释放适配器对象，所以我们必须在DPC例程中做，调用FreeAdapterChannel替代FreeMapRegisters：

VOID DpcForIsr(...)
{
  ...
  (*pdx->AdapterObject->DmaOperations->FreeAdapterChannel)(pdx->AdapterObject);
  ...
}

另外，你不需要记住被赋予多少个映射寄存器，即我以前在设备扩展的nMapRegistersAllocated变量中保存的数值，因为你不再调用FreeMapRegisters函数。

使用公用缓冲区

正如我在“传输策略”中提到的，你可能希望在执行DMA传输中为设备分配一个公共缓冲区。公共缓冲区就是一块非分页的，物理上连续的内存。驱动程序使用固定的虚拟地址来访问这种缓冲区，设备则使用固定的逻辑地址来访问同一个缓冲区。

使用公共缓冲区有多种方式，你可以使用系统DMA控制器的自动启动模式来支持设备和内存间的连续数据传输。在这种模式中，传输的完成触发了控制器立即启动下一次传输。

公用缓冲区的另一个用途是避免额外数据拷贝。MapTransfer例程通常把你提供的数据复制到I/O管理器拥有的辅助缓冲区中并在DMA传输中使用。如果你需要在ISA总线上做Slave方式的DMA，那么MapTransfer很可能按ISA DMA控制器的16MB地址和缓冲区对齐需求复制数据。但是如果你有一个公用缓冲区，你就可以避免这个复制步骤。

分配公用缓冲区

通常你应该在StartDevice中，在创建适配器对象后分配公用缓冲区：

typedef struct _DEVICE_EXTENSION {
  ...
  PVOID vaCommonBuffer;
  PHYSICAL_ADDRESS paCommonBuffer;
  ...
} DEVICE_EXTENSION, *PDEVICE_EXTENSION;

dd.Dma32BitAddresses = ??;
dd.Dma64BitAddresses = ??;
pdx->AdapterObject = IoGetDmaAdapter(...);
pdx->vaCommonBuffer = (*pdx->AdapterObject->DmaOperations->AllocateCommonBuffer)(pdx->AdapterObject,
                                         <length>,
                                         &pdx->paCommonBuffer,
                                         FALSE);

在调用IoGetDmaAdapter之前，你应该在DEVICE_DESCRIPTION结构中设置Dma32BitAddresses或Dma64BitAddresses标志以表明设备的实际寻址能力，即如果设备能使用32位物理地址寻址一个缓冲区，则设置Dma32BitAddresses为TRUE。如果设备能使用64位物理地址寻址一个缓冲区，则应设置Dma64BitAddresses为TRUE。一定要正确设置这个标志，否则在带有物理内存扩展的Intel机器上，驱动程序将不能正常工作。

在调用AllocateCommonBuffer时，第二个参数是你要分配缓冲区的字节长度。第四个参数是布尔值，它表明你是否需要使分配的内存进入CPU高速缓冲。

AllocateCommonBuffer返回一个虚拟地址。它就是你在驱动程序中用于访问已分配缓冲区的地址。AllocateCommonBuffer还通过其第三个参数设置了PHYSICAL_ADDRESS为设备使用的逻辑地址。

注意
DDK谨慎地使用术语“逻辑地址”来代表由MapTransfer返回的或由AllocateCommonBuffer第三个参数返回的地址值。在许多CPU构架中，逻辑地址通常就是CPU可以理解的物理地址。但在某些CPU中，逻辑地址可以是仅由I/O总线才理解的地址。也许“总线地址”才是一个更好的术语。

使用公用缓冲区的Slave模式DMA传输

如果你准备执行Slave模式的DMA操作，那么你必须为接收数据的虚拟地址创建一个MDL。MDL的实际目的是在最后调用MapTransfer中占用一个参数槽，它需要用MDL来找到不必复制的数据。通常应该在StartDevice函数中，紧接着分配公用缓冲区之后创建该MDL：

pdx->vaCommonBuffer = ...;
pdx->mdlCommonBuffer = IoAllocateMdl(pdx->vaCommonBuffer, <length>, FALSE, FALSE, NULL);
MmBuildMdlForNonPagedPool(pdx->mdlCommonBuffer);

为了执行一个输出操作，首先应通过某些手段(如一个明确的内存复制)使公用缓冲区中包含有要发往到设备的数据。而所需的其它DMA逻辑基本上与我以前在“执行DMA传输”中讲的一致。你将调用AllocateAdapterChannel，该函数调用你的适配器控制例程，这个例程又调用KeFlushIoBuffers(如果你分配了一个可被高速缓冲的缓冲区)，最后调用MapTransfer。你的DPC例程将调用FlushAdapterBuffers和FreeAdapterChannel函数。在所有这些调用中，你应该指定公用缓冲区的MDL来代替读写IRP携带的MDL。在输入操作的结尾，你应该把数据从公用缓冲区复制到其它某个地方。

为了完成一个传输数据量大于公用缓冲区的读写请求，你应该定时地补充或清空缓冲区。适配器对象的ReadDmaCounter函数可以帮助你了解数据传输的进展。

使用公用缓冲区的总线主控模式DMA传输

如果设备是总线主控模式，那么在分配公用缓冲区时就没有必要调用AllocateAdapterChannel、MapTransfer、FreeMapRegisters函数。因为AllocateCommonBuffer也能保留必要的映射寄存器。每个总线主控设备都有一个适配器对象，该对象不与其它设备共享，因此你不必等待。由于你拥有可以在任何时间都能访问缓冲区的虚拟地址，又由于设备的总线主控能力允许你使用物理地址(由AllocateCommonBuffer返回)访问该缓冲区，所以没有额外的工作需要做。

使用公用缓冲区的注意事项

这里有一些关于公用缓冲区分配和使用的注意事项。在运行系统中，物理上连续的内存是十分稀有的，有时你根本得不到这样的内存，除非在系统启动的早期请求这样的内存。内存管理器为了满足你的请求会对内存页进行重排列，但它的尝试十分有限，并且这个过程会推迟AllocateCommonBuffer的返回。有时尝试会失败，所以你必须能够处理这种失败情况。公用缓冲区不仅与稀有的物理内存页相关，而且还与其它设备争用映射寄存器。由于这两个原因，你应该明智地使用公用缓冲区。

关于公用缓冲区的另一个注意事项源自这个事实，内存管理器必定给你一个或多个整页内存。分配一个仅有几字节长的公用缓冲区是浪费内存，应该避免。另外，分配几页实际上并不需要物理上连续的内存同样是浪费。因此，正如DDK建议的那样，最好为不必连续的多个小块做多次请求。

释放公用缓冲区

通常应该在StopDevice例程中，在删除适配器对象之前释放被公用缓冲区占用的内存：

(*pdx->AdapterObject->DmaOperations->FreeCommonBuffer)(pdx->AdapterObject,
                               <length>,
                               pdx->paCommonBuffer,
                               pdx->vaCommonBuffer,
                               FALSE);

FreeCommonBuffer的第二个参数是与分配缓冲区时使用的相同的长度值。最后一个参数指出该段内存是否被CPU高速缓冲，它同样与AllocateCommonBuffer调用中的最后一个参数相同。

总线主控设备的一个例子

光盘上的PKTDMA例子演示了如何使用AMCC S5933芯片执行无分散/聚集的总线主控DMA操作。我已经详细讨论了该驱动程序如何在StartDevice中初始化设备，以及它如何在StartIo中初始化一个DMA传输。我还讨论了该驱动程序的AdapterControl和DpcForIsr例程中发生的几乎全部的事情。我以前曾指出，这些例程会含有一些为了启动设备上的操作而存在的与设备相关的代码；因此我写了一个名为StartTransfer的辅助函数：

VOID StartTransfer(PDEVICE_EXTENSION pdx, PHYSICAL_ADDRESS address, BOOLEAN isread)
{
  ULONG mcsr = READ_PORT_ULONG((PULONG)(pdx->portbase + MCSR);
  ULONG intcsr = READ_PORT_ULONG((PULONG)(pdx->portbase + INTCSR);
  if (isread)
  {
    mcsr |= MCSR_WRITE_NEED4 | MCSR_WRITE_ENABLE;
    intcsr |= INTCSR_WTCI_ENABLE;
    WRITE_PORT_ULONG((PULONG)(pdx->portbase + MWTC), pdx->xfer);				<--1
    WRITE_PORT_ULONG((PULONG)(pdx->portbase + MWAR), address.LowPart);
  }
  else
  {
    mcsr |= MCSR_READ_NEED4 | MCSR_READ_ENABLE;
    intcsr |= INTCSR_RTCI_ENABLE;
    WRITE_PORT_ULONG((PULONG)(pdx->portbase + MRTC), pdx->xfer);				<--1
    WRITE_PORT_ULONG((PULONG)(pdx->portbase + MRAR), address.LowPart);
  }
  WRITE_PORT_ULONG((PULONG)(pdx->portbase + INTCSR), intcsr);					<--2
  WRITE_PORT_ULONG((PULONG)(pdx->portbase + MCSR), mcsr);					<--3
}

这个例程为执行一个DMA传输设置了S5933操作寄存器，然后启动传输。过程如下：

  1. 设置地址寄存器(MxAR)和传输计数寄存器(MxTC)与数据流方向相适应。AMCC使用术语“读”来描述数据从内存流向设备的操作。因此，当我们实现IRP_MJ_WRITE时，我们在芯片级上安排了一个读操作。我们使用的地址是由MapTransfer返回的逻辑地址。
  2. 当传输计数到达0时，通过写入INTCSR寄存器允许一个中断。
  3. 设置MCSR寄存器中的传输允许位以启动传输。

实际上，S5933有同时进行DMA读操作和DMA写操作的能力，但上面代码没有明显地表现这一点。我写的PKTDMA一次仅能执行一个操作(或者读或者写)。为了实现允许同时发生两种操作的驱动程序，你需要，一、实现分离的读写IRP队列，二、创建两个设备对象和两个适配器对象(一对用于读，一对用于写)，这样就可以顺利地在AllocateAdapterChannel中多次排队相同的对象。我认为把额外的复杂代码加入到例子中会使你感到迷惑。

在PKTDMA中处理中断

PCI42包含了一个中断例程，它做了一些移动数据的工作。PKTDMA的中断例程更简单：

BOOLEAN OnInterrupt(PKINTERRUPT InterruptObject, PDEVICE_EXTENSION pdx)
{
  ULONG intcsr = READ_PORT_ULONG((PULONG) (pdx->portbase + INTCSR));
  if (!(intcsr & INTCSR_INTERRUPT_PENDING))
    return FALSE;

  ULONG mcsr = READ_PORT_ULONG((PULONG) (pdx->portbase + MCSR));
  WRITE_PORT_ULONG((PULONG) (pdx->portbase + MCSR), mcsr & ~(MCSR_WRITE_ENABLE | MCSR_READ_ENABLE));	<--1

  intcsr &= ~(INTCSR_WTCI_ENABLE | INTCSR_WTCI_ENABLE);							<--2

  BOOLEAN dpc = GetCurrentIrp(&pdx->dqReadWrite) != NULL;

  while (intcsr & INTCSR_INTERRUPT_PENDING)
  {
    InterlockedOr(&pdx->intcsr, intcsr);								<--3
    WRITE_PORT_ULONG((PULONG) (pdx->portbase + INTCSR), intcsr);
    intcsr = READ_PORT_ULONG((PULONG) (pdx->portbase + INTCSR));
  }

  if (dpc)
    IoRequestDpc(pdx->DeviceObject, NULL, NULL);

  return TRUE;
}

我仅讨论该ISR与PCI42中的ISR的不同之处：

  1. 一旦设置了MCSR中的允许位，S5933就开始传输数据。该语句清除了这两个读写允许位。如果你的驱动程序正处理并发的读写操作，你需要测试INTCSR中的中断标志来确定刚刚完成的是哪种操作。
  2. 我们立即向INTCSR回写以清除中断。这个语句还禁止了“传输至0”中断以防止再次发生中断。但能处理并发读写操作的驱动程序仅应该禁止刚发生的实际中断。
  3. InterlockedOr是我写的一个辅助例程，这样我就不必担心在累加中断标志上与DpcForIsr竞争。

测试PKTDMA

如果你有S5933DK1开发板，你就可以测试PKTDMA程序。如果你运行过PCI42测试，你应该已经安装了ISA接口卡的驱动程序S5933DK1.SYS。如果没有做过PCI42测试，你需要为这个测试安装S5933DK1.SYS。然后安装PKTDMA.SYS，它作为S5933开发板本身的驱动程序。最后运行PKTDMA\TEST\DEBUG目录中的TEST.EXE程序。TEST程序向PKTDMA发出一个8192字节的写操作。它还向S5933DK1发出一个DeviceIoControl命令从ISA卡上读回这些数据，并校验读出的内容。


第八章：电源管理

电源是计算的必要条件，但是直到最近，个人计算机才实现高效地管理其电源。由于三个原因，更高效的电源管理是十分重要的。第一，作为一个生态学问题，减少电力消耗可以最小化计算对环境的影响。不仅计算机需要电力，计算机所在房屋的空调系统也需要电力。第二，许多旅行者需要良好的电源管理：电池技术的发展不能跟上各种移动计算的需求。并且更多的消费者认为PC应该是具有电源管理能力的家用器具。现在的机器工作时风扇和磁盘都会发出噪音，并且其接通电源后的启动时间过长。减少上电启动的延迟和消除噪音(这也意味着最小化电源消耗以减轻系统的冷却需求)对于PC能成为大众消费品是必须的。

在本章中，我将讨论在Windows 2000和Windows 98系统中，WDM驱动程序在电源管理方面所扮演的角色。本章的第一节是“WDM电源管理模型”，陈述了一些需要了解的总体概念。第二节“管理电源状态转换”是本章的主要部分：我将描述一个典型功能驱动程序应该执行的非常复杂的任务。本章的最后，我将讨论WDM功能驱动程序与电源管理有关的其它责任。

  • WDM电源管理模型
  • 管理电源状态转换
  • 其它电源管理细节
  • Windows 98兼容问题


WDM电源管理模型

在Windows 2000和Windows 98中，操作系统接管了大部分电源管理工作。当然，这是因为只有操作系统才能真正了解电源管理的内部过程。例如，系统BIOS负责的电源管理不能区分应用程序使用的屏幕和屏幕保护程序使用的屏幕之间的区别。但操作系统可以区分开这种不同，从而确定是否可以关闭显示器。

作为计算机全局电源策略，操作系统支持一些用户接口元素，用户可以通过这些接口元素控制最终的电源管理策略。这些用户接口元素包括控制面板、开始菜单上的命令、控制设备唤醒特征的API。通过向设备发送IRP，内核的电源管理部件实现了操作系统的电源策略。WDM驱动程序主要是作为响应这些IRP的被动角色。

WDM驱动程序的角色

设备的某个驱动程序需要充当设备电源策略的管理者。通常都是由功能驱动程序充当这个角色。电源管理器可以改变整个系统的电源状态。功能驱动程序接收电源管理器发来的IRP(系统IRP)，作为设备电源策略的管理者，功能驱动程序用设备理解的术语翻译这些IRP并引发新的IRP(设备IRP)。当响应设备IRP时，功能驱动程序需要关心设备专有的细节。设备硬件会有自己的上下文信息，不应该在设备处于低电源期间丢失这些信息。例如，键盘驱动程序会保存锁定键(如CAPS-LOCK、NUM-LOCK、SCROLL-LOCK)、LED等信息。功能驱动程序有责任保存并恢复这些上下文信息。某些设备带有唤醒特征，当外部事件发生时，这些设备可以唤醒系统；功能驱动程序应与用户协同工作以确保唤醒特征在需要时有效。许多功能驱动程序还管理含有大量IRP的队列(设备读写IRP)，因此当设备电源状态转变时需要停止或释放这些队列。

处于设备堆栈底端的总线驱动程序有责任控制设备的电流和执行任何与设备唤醒特征相关的电气步骤。过滤器驱动程序通常作为电源管理IRP通过的管道，它们用专用的协议向下层驱动程序传递电源管理请求。

设备电源状态与系统电源状态

WDM模型使用与ACPI(Advanced Configuration and Power Interface)规范(见http://www.teleport.com/~acpi/spec.htm)相同的术语来描述电源状态。设备能呈现图8-1所描述的四种电源状态。在D0状态中，设备处于全供电状态。在D3状态中，设备处于无供电(或最小限度的电流)状态。中间的D1和D2状态指出设备的两个不同睡眠状态。随着设备从D0状态变化到D3状态，设备将消耗越来越少的电力，同时需要保留的当前状态上下文信息也越来越少。而设备再转变回D0状态的延迟期则相应增加。

图8-1. ACPI电源状态

Microsoft规定了不同类型设备的类专用电源需求。这个需求规范可以在http://www.microsoft.com/hwdev/specs/PMref/找到。例如，这个规范要求每个设备至少要支持D0和D3两个状态。输入设备(键盘、鼠标等)还应该支持D1状态。Modem设备需要另外支持D2状态。设备类上的这些不同规定可能来源于设备的用途和工业上的实践。

操作系统不直接处理设备的电源状态，由设备驱动程序专门处理。系统使用一组与ACPI设备状态类似的系统电源状态来控制电源，见图8-2。Working状态是全供电状态，计算机可以实现全部功能。程序仅能在Working状态下执行。

图8-2. 系统电源状态

其它系统电源状态对应更小的电力需求配置，在这些系统电源状态中，计算机不能执行任何指令。Shutdown状态就是电源关闭状态。Hibernate状态是另一种Shutdown状态，它把计算机的整个状态都记录到硬盘上，因此在电源恢复供电时可以使计算机快速恢复到记录前的状态。在Hibernate和Working状态之间是三个有不同电力消耗级别的中间状态。

电源状态转换

系统初始化后即进入Working状态。大部分设备也以D0状态启动，但某些设备的驱动程序会在设备启动时使设备进入低电源消耗状态。在系统启动并正常运行后，这些设备的驱动程序才使设备进入一个稳定的状态，在这个状态中，系统电源处于Working状态，而设备处于的状态取决于具体活动和设备自身的能力。

用户的活动或外部事件会导致电源状态的改变。一个常见的电源状态转换情景是用户在开始菜单上选择“关闭系统”中的“standby”选项，使计算机进入等待状态。在响应这个命令过程中，电源管理器首先向每个驱动程序发送带有IRP_MN_QUERY_POWER副功能码的IRP_MJ_POWER请求以询问设备能否接受即将到来的电源关闭请求。如果所有驱动程序都同意，电源管理器将发送第二个带有IRP_MN_SET_POWER副功能码的电源管理IRP，然后驱动程序把其设备置入低电源状态以响应这个IRP。如果有任何一个驱动程序否决了这个查询，电源管理器仍旧发出这个IRP_MN_SET_POWER请求，但它用原来的电源级别换成了请求的电源级别。

系统并不总是发送IRP_MN_QUERY_POWER请求。某些事件(如电池电力将要耗尽)必须被无条件接受，并且操作系统也不再发出查询请求。如果查询发出后，并且驱动程序也接受了请求的电源状态，那么驱动程序将不再启动任何会妨碍未来电源状态设置请求的操作。例如，磁带机驱动程序在使一个进入低电源状态的查询请求成功返回前先确保当前没有执行备份操作。另外，该驱动程序还拒绝任何后来的备份命令，除非是另一个电源状态设置请求。

处理IRP_MJ_POWER请求

电源管理器与驱动程序沟通使用IRP_MJ_POWER类型的I/O请求包。表8-1列出了当前可以使用的四个副功能码。

表8-1. IRP_MJ_POWER的副功能码

副功能码 | 描述
IRP_MN_QUERY_POWER | 确定预期的电源状态改变是否安全
IRP_MN_SET_POWER | 命令驱动程序改变电源状态
IRP_MN_WAIT_WAKE | 命令总线驱动程序使用唤醒特征；使功能驱动程序能了解唤醒信号何时发生
IRP_MN_POWER_SEQUENCE | 为上下文保存和恢复提供优化

IO_STACK_LOCATION的Parameters联合中的Power子结构有四个参数描述了电源管理请求，但大多数WDM驱动程序仅对其中两个参数感兴趣。见表8-2。

表8-2. IO_STACK_LOCATION的Parameters.Power子结构中的各个域

域名 | 描述
SystemContext | 电源管理器内部使用的上下文值
Type | DevicePowerState或SystemPowerState (POWER_STATE_TYPE类型的枚举值)
State | 电源状态，可为DEVICE_POWER_STATE或SYSTEM_POWER_STATE
ShutdownType | 指出转换到PowerSystemShutdown状态的原因代码

所有驱动程序，包括过滤器驱动程序和功能驱动程序通常都向其下面的驱动程序传递电源管理请求。唯一的例外是IRP_MN_QUERY_POWER请求。

控制如何把电源管理请求传递到低级驱动程序有特殊的规则。图8-3显示了三种可能的处理过程。第一，在释放一个电源管理请求的控制之前，你必须调用PoStartNextPowerIrp，即使你以错误状态完成该IRP，也要这样做。做这个调用的原因是，电源管理器自己需要维持一个电源管理请求队列，所以必须通知它确实可以出队一个请求，以及向设备发送下一个请求。除了调用PoStartNextPowerIrp，你还必须调用专用例程PoCallDriver(代替IoCallDriver)来向下层驱动程序发送请求。

图8-3. 处理IRP_MJ_POWER请求

注意
电源管理器需要为每个设备维护两个电源管理IRP队列。一个是系统电源管理IRP队列(如指定系统电源状态的IRP_MN_SET_POWER请求)，另一个是设备电源管理IRP队列(如指定设备电源状态的IRP_MN_SET_POWER请求)。这两种IRP可以同时处于活动状态。驱动程序还可以同时处理PnP请求和其它IRP。

下面函数显示了一个电源管理请求沿设备堆栈被向下传递的过程：

NTSTATUS DefaultPowerHandler(IN PDEVICE_OBJECT fdo, IN PIRP Irp)
{
  PoStartNextPowerIrp(Irp);									<--1
  IoSkipCurrentIrpStackLocation(Irp);								<--2
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  return PoCallDriver(pdx->LowerDeviceObject, Irp);						<--3
}
  1. PoStartNextPowerIrp通知电源管理器可以出队并发送下一个电源管理IRP。你必须在每收到一个电源管理IRP后都做这个调用。即该调用或者发生在你的派遣例程中，在你把请求发送给PoCallDriver之前，或者发生在完成例程中。
  2. 由于PoCallDriver会立即把IRP的堆栈单元指针前进一步，所以我们使用IoSkipCurrentIrpStackLocation函数使IRP的堆栈单元指针倒退一步。这与我们在向下传递一个请求并忽略其以后的处理中使用的技术相同。
  3. 我们使用PoCallDriver函数继续传递电源管理请求。Microsoft在实现这个函数上使用了在IoCallDriver函数加入一些条件逻辑的办法来处理电源管理，但这对性能带来了微小但可察觉的冲击。

功能驱动程序使用两个步骤来传递IRP并执行其设备相关的动作，如图8-4所示：当进入更低的电源状态时，它先执行设备相关的步骤，然后向下传递请求。当回到更高的电源状态时，它先向下传递请求，然后在完成例程中执行设备相关的步骤。这个巧妙的嵌套操作保证了当驱动程序操作硬件时，硬件一直处于供电状态。

图8-4. 处理系统电源管理请求

电源管理IRP到来时，你处于一个系统线程的上下文中，不要阻塞这个线程。如果你的设备有INRUSH特征，或者你清除了设备对象中的DO_POWER_PAGABLE标志，那么电源管理器将在DISPATCH_LEVEL级上向你发送IRP。而当你执行在DISPATCH_LEVEL级上时，不可能阻塞一个线程。如果你设置了DO_POWER_PAGABLE标志，你会在PASSIVE_LEVEL级上收到电源管理IRP，此时，如果你在服务一个系统IRP时请求了设备电源管理IRP然后阻塞，将导致死锁：电源管理器不会向你发送设备IRP，除非你的系统IRP派遣例程返回，因此你将永远等待下去。

功能驱动程序在处理某些电源管理请求时会执行一些步骤以获得完成时间。DDK指出，你可以在用户不易察觉的范围内延迟电源管理IRP的完成，但是能够延迟并不意味着能够阻塞。在这些操作完成时，不能阻塞意味着需要使用完成例程来使这些步骤异步。

对IRP_MN_QUERY_POWER查询你可以回答“Yes”或“No”。即你可以用该副功能码失败该IRP。失败该IRP就是说“No”。但对于IRP_MN_SET_POWER请求却没有这样的自由：你必须执行它携带的指令。


管理电源状态转换

正确地执行电源管理任务需要非常精确的编码，需要面对许多复杂的因素。例如，设备可以有唤醒系统的能力。决定成功还是失败一个查询，决定由哪个设备电源状态来对应新系统电源状态，这些都取决于设备当前是否使用了唤醒特征。你可以在没有任何活动时关闭设备的电源供应，当有IRP到来时再恢复设备的电源供应。也许你的设备是一个“INRUSH”设备，这种设备在上电时需要大的电流，因此电源管理器需要特殊对待这种设备。

当我思考用传统方法解决query-power和set-power操作时，即用通常的派遣例程和完成例程，我对需要许多全然不同的但最终都做类似工作的子例程感到沮丧。因此我决定围绕一个有限状态机来建立我自己的电源管理程序，有限状态机可以方便地处理异步活动。

我将在GENERIC.SYS中解释有限状态机，GENERIC.SYS是随书光盘中大部分例子使用的支持驱动程序。附录B的“使用GENERIC.SYS”中详细描述了GENERIC.SYS的客户接口。GENERIC.SYS等价于一个包含有WDM驱动程序可使用的辅助例程的内核模式DLL。你可以认为它是一个有广泛适用性的通用类驱动程序。客户驱动程序，包括大部分我自己的例子驱动程序，通过调用GenericDispatchPower把电源管理IRP委托给GENERIC.SYS处理。GENERIC.SYS还实现了我在第六章中讨论过的DEVQUEUE对象。

有限状态机概述

我写了一个名为HandlePowerEvent的函数来实现管理电源IRP的有限状态机。该函数有两个参数：

NTSTATUS HandlePowerEvent(PPOWCONTEXT ctx, enum POWEVENT event);

第一个参数是一个包含状态变量的上下文结构：

typedef struct _POWCONTEXT {
  LONG id;
  LONG eventcount;
  PGENERIC_EXTENSION pdx;
  PIRP irp;
  enum POWSTATE state;
  NTSTATUS status;
  PKEVENT pev;
  DEVICE_POWER_STATE devstate;
  UCHAR MinorFunction;
  BOOLEAN UnstallQueue;
} POWCONTEXT, *PPOWCONTEXT

id和eventcount域用于调试。如果你在宏VERBOSETRACE定义为非零值时编译GENERIC工程中的POWER.CPP，则POWTRACE宏将产生跟踪消息的量值。我使用这个特征来调试有限状态机。光盘上的GENERIC.SYS为预创建版本，没有使用VERBOSETRACE宏来消去跟踪消息。

pdx成员为给定设备指出GENERIC的设备扩展部分。设备扩展中有两个成员与电源管理有关，我将在后面的“新IRP的初始化处理”中再讨论它们。irp成员指向有限状态机当前工作的电源IRP；state是有限状态机的状态变量。status成员代表IRP的结束状态。在某些情况下，我们希望在HandlePowerEvent发出和完成一个设备电源IRP时等待；所以我们用pev来指向等待完成的对象。devstate成员保存设备的电源状态，我们将在设备IRP中使用这个状态值，MinorFunction保存着我们希望在IRP中使用的副功能码(IRP_MN_QUERY_POWER或IRP_MN_SET_POWER)。最后，UnstallQueue指出我们是否希望有限状态机在完成处理当前电源管理IRP时打开IRP队列。

HandlePowerEvent函数的第二个参数是一个事件代码，指出我们为什么要调用该函数。有三种事件代码：

  • NewIrp指出我们正在向有限状态机提交一个新的电源IRP。那个上下文结构中的irp成员指向这个IRP。
  • MainIrpComplete指出IRP被完成。
  • AsyncNotify指出有其它异步活动发生。

HandlePowerEvent使用了状态变量的值和事件代码来决定要采取的动作，见表8-3。(表中空的单元指出一个不可能的情况，这种情况会使checked版本的GENERIC.SYS产生ASSERT错误) 一个动作对应于电源IRP在其处理路径上的一系列程序步骤。

表8-3. 下表给出了每个事件和状态的初始动作

状态 |  | 事件 |
 | NewIrp | MainIrpComplete | AsyncNotify
InitialState | TriageNewIrp |  |
SysPowerUpPending |  | SysPowerUpComplete |
SubPowerUpPending |  |  | SubPowerUpComplete
SubPowerDownPending |  |  | SubPowerDownComplete
SysPowerDownPending |  | SysPowerDownComplete |
DevPowerUpPending |  | DevPowerUpComplete |
DevPowerDownPending |  | CompleteMainIrp |
ContextSavePending |  |  | ContextSaveComplete
ContextRestorePending |  |  | ContextRestoreComplete
DevQueryUpPending |  | DevQueryUpComplete |
DevQueryDownPending |  | DevQueryDownComplete |
QueueStallPending |  |  | QueueStallComplete
FinalState |  |  |

因为许多事件在某些情况下需要多个动作，因此HandlePowerEvent代码看起来比较特别：

NTSTATUS HandlePowerEvent(...)
{
  NTSTATUS status;
  POWACTION action = ...;
  while (TRUE)
  {
    switch (action)
    {
    case <someaction>:
      action = <someotheraction>;
      continue;
    case <anotheraction>:
      break;
    }
    break;
  }
  return status;
}

该函数有一个无限循环，循环中对action代码进行switch判断。带有continue语句的case块重复这个循环；这也是我在一个函数调用中把一系列action串起来的原因。带有break的case块使程序从switch块跳到另一个break语句，这个break语句将结束那个无限循环，从而该函数可以返回到调用者。

对有限状态机采用这种编码风格是因为我对以前熟悉的结构化编程规则仍念念不忘。我希望该函数仅有一个return语句，可以更易于证明该函数工作正常。为了辅助这个证明，我自己推导出三个原则，可以在函数的结尾处通过检查或使用ASSERT语句来测试。下面就是这些规则：

  • 每个代码路径最终都会跳到break语句，从而到达return语句。在代码执行过程中，必须有人修改status变量和state变量。
  • 任何continue语句之前都应该先修改action变量。
  • 任何会导致递归调用HandlePowerEvent函数的case块，例如调用了PoCallDriver函数，必须立即从循环中break出去，并且不要碰上下文结构或IRP。

新IRP的初始化处理

当我们收到一个新的query-power或set-power请求时，我们创建一个上下文结构来驱动有限状态机并调用HandlePowerEvent：

NTSTATUS GenericDispatchPower(PGENERIC_EXTENSION pdx, PIRP Irp)
{
  NTSTATUS status = IoAcquireRemoveLock(pdx->RemoveLock, Irp);					<--1
  if (!NT_SUCCESS(status))
    return CompleteRequest(Irp, status);
  PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
  ULONG fcn = stack->MinorFunction;
  if (fcn == IRP_MN_SET_POWER || fcn == IRP_MN_QUERY_POWER)
  {
    PPOWCONTEXT ctx = (PPOWCONTEXT) ExAllocatePool(NonPagedPool, sizeof(POWCONTEXT));		<--2
    RtlZeroMemory(ctx, sizeof(POWCONTEXT));
    ctx->pdx = pdx;
    ctx->irp = Irp;
    status = HandlePowerEvent(ctx, NewIrp);
  }
  IoReleaseRemoveLock(pdx->RemoveLock, Irp);
  return status;
}
  1. 客户驱动程序提供了一个删除锁，它和GENERIC共同使用这个锁来防止提前删除设备对象。GENERIC中的实际代码要比我在这里给出的稍复杂一些，删除锁并不是必须的。所以实际代码在使用删除锁前先测试RemoveLock指针是否为NULL。此外还有一些不太重要的考虑，包括错误检测。
  2. 对于设置和查询操作，我们先为上下文结构分配一块非分页内存并初始化。state变量初始化为InitialState，其数值上等于0。

有限状态机的初始状态是InitialState。当我们为NewIrp事件调用HandlePowerEvent函数时，发生的第一个动作是TriageNewIrp：

case TriageNewIrp:
{
  status = STATUS_PENDING;									<--1
  IoMarkIrpPending(Irp);
  IoAcquireRemoveLock(pdx->RemoveLock, Irp);							<--2
  if (stack->Parameters.Power.Type == SystemPowerState)
  {        // system IRP
    if (stack->Parameters.Power.State.SystemState < pdx->syspower)				<--3
    {
      action = ForwardMainIrp;									<--4
      ctx->state = SysPowerUpPending;								<--5
    }
    else
    {
      action = SelectDState;
      ctx->state = SubPowerDownPending;
    }
  }        // system IRP
  else
  {        // device IRP
    ctx->state = QueueStallPending;
    if (!pdx->StalledForPower)
    {
      ctx->UnstallQueue = TRUE;									<--6
      pdx->StalledForPower = TRUE;
      NTSTATUS qstatus = StallRequestsAndNotify(pdx->dqReadWrite,
                        GenericSaveRestoreComplete,
                        ctx);
      if (qstatus == STATUS_PENDING)
        break;											<--7
    }
    action = QueueStallComplete;
  }        // device IRP
  continue;
}
  1. 我们总是挂起送到我们的电源管理IRP。几乎在每种情况下，我们都需要延迟IRP的完成直到某个异步活动发生之后。
  2. 我们多一次获取了删除锁，跨越了派遣例程中的对它的获取。我们将在最后完成该IRP时释放这个锁。
  3. 如果IRP中的电源状态在数值上小于设备扩展中的syspower值，则该IRP代表一个更高级的系统电源状态。
  4. 该语句演示了HandlePowerEvent如何在一个调用中执行多个动作。后面我们将执行一个continue语句来重复这个无限循环。但action值会不同，这导致了另一段代码的执行。
  5. 该语句演示了action的case语句是如何改变了有限状态机的状态。为了简化用于调试打印语句的条件编译代码，GENERIC中的实际代码使用了一个名为SETSTATE的宏来执行这个赋值。
  6. 我们将要调用一个函数(StallRequestsAndNotify)，这个函数会造成对当前函数的递归调用。因为我们以后将不被允许访问上下文结构，所以我们现在设置这个标志。该标志意味着CompleteMainIrp函数应该调用RestartRequests来重启动队列。
  7. 该语句显示了一个action case语句如何能使HandlePowerEvent返回。这个break语句使程序从switch块中退出。紧跟这个switch块之后的语句是另一个break语句，它使程序退出while循环。

基本上，TriageNewIrp区别对待各种电源IRP(IRP的类型为SystemPowerState)，包括提高电源级别的系统电源IRP、降低电源级别的系统电源IRP、设备电源IRP(IRP的类型为DevicePowerState)，但不考虑设备电源级别是升高还是降低。在这个阶段中，有限状态机不能区别QUERY_POWER和SET_POWER请求，所以在此它们被视为非常类似的IRP。

不管电源是升还是降，我们的设备扩展都需要两个变量来保存系统电源状态和设备电源的状态：

typedef struct _GENERIC_EXTENSION {
  ...
  DEVICE_POWER_STATE devpower; // current dev power state
  SYSTEM_POWER_STATE syspower; // current sys power state
} GENERIC_EXTENSION, *PGENERIC_EXTENSION;

当客户驱动程序第一次寄存GENERIC.SYS时，我们初始化这两个值为PowerDeviceD0和PowerSystemWorking。

从上下文中你可以猜到，设备扩展还有一个名为StalledForPower的布尔成员。当这个标志被设置时，表明由于电源管理的需要，IRP队列当前已经停止。另外，你会注意到我们没有明确地同步访问电源状态域或这个标志。由于电源管理器已经强制序列化访问这些数据，所以没有必要同步。

现在我将分别讨论三个IRP初始类。

提升电源级别的系统电源IRP

如果一个系统电源IRP表明要提升系统的电源级别，你应立即把它传递给下一层驱动程序。而在你为该系统电源IRP准备的完成例程中，你应该请求对应的设备电源IRP并返回STATUS_MORE_PROCESSING_REQUIRED，以临时停止完成处理。在设备电源IRP的完成例程中，你应该完成系统电源IRP的完成处理。图8-5显示了穿过所有驱动程序的IRP流。图8-6是一个状态图，显示了我们的有限状态机如何处理这些IRP。

图8-5. 增加系统电源级别的IRP流

图8-6. 增加系统电源时的状态转换

根据代码的操作过程，TriageNewIrp函数把有限状态机置入SysPowerUpPending状态，并请求ForwardMainIrp动作，下面是这个动作的代码：

case ForwardMainIrp:
{
  IoCopyCurrentIrpStackLocationToNext(Irp);
  IoSetCompletionRoutine(Irp,
             (PIO_COMPLETION_ROUTINE) MainCompletionRoutine,
             (PVOID) ctx,
             TRUE,
             TRUE,
             TRUE);
  PoCallDriver(pdx->LowerDeviceObject, Irp);
  break;
}

HandlePowerEvent将返回STATUS_PENDING，如TriageNewIrp中的代码所要求那样。这个返回值会被GenericDispatchPower函数过滤，大概会是客户驱动程序中的IRP_MJ_POWER派遣函数。

我们下一次与该IRP接触是当总线驱动程序完成它时。我们自己的MainCompletionRoutine函数将作为完成处理的一部分而获得控制，我们把IRP的结束状态保存到上下文结构的status域，并调用有限状态机。

NTSTATUS MainCompletionRoutine(PDEVICE_OBJECT junk, PIRP Irp, PPOWCONTEXT ctx)
{
  ctx->status = Irp->IoStatus.Status;
  return HandlePowerEvent(ctx, MainIrpComplete);
}

我们的初始动作将是SysPowerUpComplete：

case SysPowerUpComplete:
{
  if (!NT_SUCCESS(ctx->status))
    action = CompleteMainIrp;									<--1
  else
  {
    if (stack->MinorFunction == IRP_MN_SET_POWER)						<--2
      pdx->syspower = stack->Parameters.Power.State.SystemState;
    action = SelectDState;
    ctx->state = SubPowerUpPending;
    status = STATUS_MORE_PROCESSING_REQUIRED;							<--3
  }
  continue;
}
  1. 如果该IRP在低级驱动程序中失败，我们将使其完成，并且对这个电源事件不做任何更多的工作。我将在后面的“处理失败”中解释CompleteMainIrp做了什么。
  2. 我们在这里记录下新的系统电源状态。当检查新系统IRP是升高还是降低电源级别时，我们使用syspower值。
  3. MainCompletionRoutine将调用我们，并且当我们要生成设备IRP时，该函数将中断系统IRP的完成。因此，我们使MainCompletionRoutine返回STATUS_MORE_PROCESSING_REQUIRED。

处理失败

如果IRP失败，我们将做CompleteMainIrp动作：

case CompleteMainIrp:
{
  PoStartNextPowerIrp(Irp);									<--1
  if (event == MainIrpComplete)									<--2
    status = ctx->status;
  else												<--3
  {
    Irp->IoStatus.Status = ctx->status;
    IoCompleteRequest(Irp, IO_NO_INCREMENT);
  }
  IoReleaseRemoveLock(pdx->RemoveLock, Irp);							<--4
  if (ctx->UnstallQueue)									<--5
  {
    pdx->StalledForPower = FALSE;
    RestartRequests(pdx->dqReadWrite, pdx->DeviceObject);
  }
  action = DestroyContext;
  continue;
}
  1. 我们必须为每个电源管理IRP调用PoStartNextPowerIrp。
  2. 如果我们进入MainIrpComplete事件的处理代码，我们的调用者必须是MainCompletionRoutine，并且第一个动作例程将设置status等于STATUS_MORE_PROCESSING_REQUIRED以短接完成过程。因为我们决定要完成这个IRP，正确的做法是返回另一个状态代码并允许完成例程按其正常方式执行。
  3. 如果我们进入其它事件处理，我们需要明确地完成该IRP。
  4. 这个IoReleaseRemoveLock调用平衡我们在TriageNewIrp中的IoAcquireRemoveLock调用。
  5. 我将在本章后面讨论设备IRP时再解释这块代码。

当处理提升电源级别的系统电源IRP时，有限状态机在MainIrpComplete事件后进入CompleteMainIrp状态。CompleteMainIrp将安排返回错误状态，这个状态是我们最初在该IRP中提取的(在MainCompletionRoutine中)。这将使完成处理继续执行。这里还有其它我们仍没有讨论过的代码分支，在那些分支中，CompleteMainIrp将调用IoCompleteRequest。CompleteMainIrp完成时会请求另一个动作：

case DestroyContext:
{
  if (ctx->pev)
    KeSetEvent(ctx->pev, IO_NO_INCREMENT, FALSE);						<--1
  else
    ExFreePool(ctx);										<--2
  break;
}
  1. 这个分支将在SendDeviceSetPower函数调用状态机去创建并等待一个设备IRP时被执行。
  2. 这个分支将在GenericDispatchPower调用状态机去处理一个IRP时被执行。

DestroyContext是有限状态机要执行的最后一个动作。

映射系统状态为设备状态

另一种办法是让SysPowerUpComplete生成一个设备电源IRP，该IRP的电源状态与系统电源状态对应。我们在SelectDState动作中把系统状态映射成设备状态：

case SelectDState:
{
  SYSTEM_POWER_STATE sysstate = stack->Parameters.Power.State.SystemState;
  if (sysstate == PowerSystemWorking)
    ctx->devstate = PowerDeviceD0;
  else
  {
    DEVICE_POWER_STATE maxstate = pdx->devcaps.DeviceState[sysstate];
    DEVICE_POWER_STATE minstate = pdx->WakeupEnabled ? pdx->devcaps.DeviceWake : PowerDeviceD3;
    ctx->devstate = minstate > maxstate ? minstate : maxsstate;
  }
  ctx->MinorFunction = stack->MinorFunction;
  action = SendDeviceIrp;
  continue;
}

注意，电源管理器绝不从一个低级系统电源状态直接转换到其它系统电源状态：它总是经过PowerSystemWorking状态。因此，我编写了SelectDState来映射各种系统电源状态。

通常，我们总是把设备设置成与设备的当前活动、设备的唤醒特征、设备的能力、以及迫近的系统状态，相一致的最低的电源状态。这些因素会以相当复杂的方式相互影响。为了详细解释这些，我需要讨论以前我在第六章没有讨论的PnP请求：IRP_MN_QUERY_CAPABILITIES。

PnP管理器在启动设备后(也可能是其它时间)的很短时间内向设备发出查询设备能力请求。该请求的参数是一个DEVICE_CAPABILITIES结构，该结构包含了与电源管理有关的几个域。因为本书仅在这里讨论这个结构，所以我将给出该结构的完整声明：

typedef struct _DEVICE_CAPABILITIES {
    USHORT Size;
    USHORT Version;
    ULONG DeviceD1:1;
    ULONG DeviceD2:1;
    ULONG LockSupported:1;
    ULONG EjectSupported:1;
    ULONG Removable:1;
    ULONG DockDevice:1;
    ULONG UniqueID:1;
    ULONG SilentInstall:1;
    ULONG RawDeviceOK:1;
    ULONG SurpriseRemovalOK:1;
    ULONG WakeFromD0:1;
    ULONG WakeFromD1:1;
    ULONG WakeFromD2:1;
    ULONG WakeFromD3:1;
    ULONG HardwareDisabled:1;
    ULONG NonDynamic:1;
    ULONG Reserved:16;

    ULONG Address;
    ULONG UINumber;

    DEVICE_POWER_STATE DeviceState[PowerSystemMaximum];
    SYSTEM_POWER_STATE SystemWake;
    DEVICE_POWER_STATE DeviceWake;
    ULONG D1Latency;
    ULONG D2Latency;
    ULONG D3Latency;
} DEVICE_CAPABILITIES, *PDEVICE_CAPABILITIES;

表8-4 描述了该结构中与电源管理有关的各个域。

表8-4. DEVICE_CAPABILITIES结构中与电源管理相关的域

域 | 描述
DeviceState | 与每个系统状态对应的可能存在的设备最高级状态数组
SystemWake | 最低级的系统电源状态，在该状态中设备可以生成唤醒信号。PowerSystemUnspecified指出设备不能唤醒系统
DeviceWake | 最低级的设备电源状态，在该状态中设备可以生成唤醒信号。PowerDeviceUnspecified指出设备不能生成唤醒信号
D1Latency | 设备从D1切换到D0状态所需的最长时间(100微秒单位)。
D2Latency | 设备从D2切换到D0状态所需的最长时间(100微秒单位)。
D3Latency | 设备从D3切换到D0状态所需的最长时间(100微秒单位)。
WakeFromD0 | 标志，指出当设备处于指定的状态中时，设备的系统唤醒特征是否是可操作的
WakeFromD1 | 同上
WakeFromD2 | 同上
WakeFromD3 | 同上

通常你需要同步处理这个查询设备能力的IRP，即向下传递该IRP并等待低层完成。下传该IRP后，你可以通过总线驱动程序对设备的能力记录做任何需要的修改。你的子派遣例程应该象这样：

NTSTATUS HandleQueryCapabilities(IN PDEVICE_OBJECT fdo, IN PIRP Irp)
{
  PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  PDEVICE_CAPABILITIES pdc = stack->Parameters.DeviceCapabilities.Capabilities;
  if (pdc->Version < 1)										<--1
    return DefaultPnpHandler(fdo, Irp);
  NTSTATUS status = ForwardAndWait(fdo, Irp);
  if (NT_SUCCESS(status))
  {
    stack = IoGetCurrentIrpStackLocation(Irp);
    pdc = stack->Parameters.DeviceCapabilities.Capabilities;
    <stuff>											<--2
    pdx->devcaps = *pdc;									<--3
  }
  return CompleteRequest(Irp, status);
}
  1. 设备能力结构中有一个版本号成员，在当前系统中总是为1。该结构被设计成总是向上兼容，因此你可以使用DDK中该结构的现在和以后版本。但如果你需要面对一个比你所用结构更旧的结构，你应该忽略这个IRP，直接下传。
  2. 在这里你可以不管总线驱动程序设置的任何能力属性。
  3. 应该做一份能力结构的拷贝。我已经描述过如何在收到系统电源IRP时使用DeviceState。有必要参考一下该结构中的其它域。

不要在下传IRP前修改characteristics结构：总线驱动程序将完全再初始化该结构。当你再次获得控制时，可以修改SystemWake和DeviceWake，使它们指定一个比总线驱动程序认为的合适状态更高的电源状态。你不能把唤醒域指定为某个低级电源状态，并且如果你的设备不能唤醒系统，你就不能超越总线驱动程序的决定。如果设备是ACPI兼容的，则ACPI过滤器将基于设备的ASL(ACPI Source Language)描述自动设置LockSupported、EjectSupported、Removable标志，因此你不用担心这些能力设置。

你也许会在能力处理程序的点“2”处设置SurpriseRemovalOK标志。设置这个标志将使Windows 2000在检测到设备被意外删除时所弹出的对话框不再出现。这对于可热拔插的USB或1394设备是正常的，因此其功能驱动程序应该设置这个标志以避免干扰用户。

回到我们对SelectDState的讨论上，假定我们正在处理一个set-power请求，该请求将把计算机从Working状态带入Sleeping1状态；因此我们将执行SelectDState中if语句的第二个分支。我们假定总线驱动程序知道设备能在系统进入Sleeping1状态时可进入D0、D1、D2、D3任何一种状态。当它回答PnP的能力查询时，它将把PowerDeviceD0填到设备能力结构中的DeviceState[PowerSystemSleeping1]中，因为D0是设备在该系统状态上所能达到的最高电源状态。我们先记下PowerDeviceD0，然后作为maxstate值。

设备还可以有唤醒特征。我们以后再讨论设备的唤醒特征。总线驱动程序将设置能力结构的DeviceWake成员等于唤醒能够发生的最低电源状态。让我们假定该值为PowerDeviceD1。如果我们的唤醒特征碰巧现在正允许，我们将设置minstate为PowerDeviceD1。

如果我们没有唤醒特征，或者我们有唤醒特征但该特征当前被禁止，那么我们就可以自由选择任何低于maxstate值的设备电源状态，maxstate值来自设备能力结构。我们可以选择D3，但这不能适合各种设备类型，因为从D3到D0的恢复时间要比从D2到D1的恢复时间长。在这种情况下的选择需要取决其它一些因素。例如，如果设备能进入D2状态，你可以用D2状态对应任何系统sleeping状态，而D3状态则对应系统的hibernate和shutdown状态。

当系统从一个sleeping状态恢复时，使设备停留在一个低电源状态是合理的。DDK建议你这样做。但有两种情况在系统进入Working状态时，你的设备应恢复到D0状态。第一种情况是你的设备具有INRUSH特征。在这种情况下，电源管理器在设备上电前不会向其它具有INRUSH特征的设备发送电源IRP。第二种情况是当你已经得到许多排队的IRP，并且这些IRP等待设备上电后运行。尽管设备处于低电源状态是个好想法，但你会注意到，我刚给你演示的SelectDState代码片段无条件地回到了D0状态。在我的测试中，如果我不这样做，Windows 2000会在从standby状态的返回中挂起。这可能是我的代码也可能是操作系统代码中的一个错误。请关注本书的勘误信息。

请求设备电源IRP

在第五章的“I/O请求包”中我们讨论了象IoAllocateIrp这样的用来创建IRP的函数。但你不能使用这些函数创建电源管理IRP(实际上，你可以用那些函数创建IRP_MN_POWER_SEQUENCE请求，但其它IRP_MJ_POWER请求不行)，使用PoRequestPowerIrp，下面代码是执行在SelectDState之后的SendDeviceIrp动作代码：

case SendDeviceIrp:
{
  if (win98 && ctx->devstate == pdx->devpower)							<--1
  {
    ctx->status = STATUS_SUCCESS;
    action = actiontable[ctx->state][AsyncNotify];
    continue;
  }
  POWER_STATE powstate;
  powstate.DeviceState = ctx->devstate;
  NTSTATUS postatus = PoRequestPowerIrp(pdx->Pdo,						<--2
                    ctx->MinorFunction,
                    powstate,
                    (PREQUEST_POWER_COMPLETE) PoCompletionRoutine,
                    ctx,
                    NULL);
  if (NT_SUCCESS(postatus))									<--3
    break;
  action = CompleteMainIrp;									<--4
  ctx->status = postatus;
  continue;
}
  1. 参见本章后面的“Windows 98兼容问题”中对这段代码的解释。
  2. PoRequestPowerIrp的第一个参数是设备PDO的地址。注意，我们请求的IRP总要先发往最顶层的过滤器设备对象(FiDO)。第二个参数是该IRP的副功能码，可以为IRP_MN_QUERY_POWER或IRP_MN_SET_POWER。第三个参数是POWER_STATE，当我们请求一个查询或设置操作时，它应该包含一个设备电源状态值。第四个和第五个参数分别是当IRP完成时的回调函数地址和该函数上下文参数。最后一个参数是一个可选的PIRP变量的地址，该变量用于接收PoRequestPowerIrp创建的IRP的地址。
  3. PoRequestPowerIrp在创建并发出一个电源IRP后通常都返回STATUS_PENDING。这个结果与任何其它的成功返回代码，实际上都意味着我们的回调函数最终会被调用。而回调函数又会调用HandlePowerEvent。
  4. 如果PoRequestPowerIrp失败，它不会再创建该IRP，并且我们的回调函数也不会被调用。因此我们使该系统IRP失败，并返回PoRequestPowerIrp返回的任何状态码。

在系统上电期间，有限状态机在我们到达SendDeviceIrp时将处于SubPowerUpPending状态。status变量将为STATUS_MORE_PROCESSING_REQUIRED，如果我们正在等待设备IRP的完成，那么MainCompletionRoutine返回这个值是正确的。然后，当我们从SendDeviceIrp中break出来时，我们就中断了系统电源IRP的完成处理。

下面我将讨论由PoRequestPowerIrp请求的设备IRP会发生什么事情。

完成系统IRP

最后，SendDeviceIrp请求的设备IRP被完成，于是电源管理器将调用回调例程PoCompletionRoutine。回调例程再调用HandlePowerEvent并给出事件代码AsyncNotify。在SubPowerUpPending状态中，我们的第一个动作将是SubPowerUpComplete：

case SubPowerUpComplete:
{
  if (status == -1)
    status = STATUS_SUCCESS;
  action = CompleteMainIrp;
  continue;
}

这个动作例程执行的唯一任务是修改status变量。做这个的原因是HandlePowerEvent的结尾有一个ASSERT语句，该语句将确保某人改变了status。在我们的假定中，我们并不关心返回的status的具体值，因为PoCompletionRoutine函数是一个无返回值函数。我想你不会希望触发一个ASSERT或一个BSOD，除非确实有什么错误存在。

SubPowerUpComplete的下一个动作是CompleteMainIrp，它将导致DestroyContext调用。

降低电源级别的系统电源IRP

如果系统电源IRP暗含着不改变或降低系统电源级别，那么你也要用相同的副功能码请求设备电源IRP，并且请求与系统电源状态对应的设备电源状态。当设备电源IRP完成时，你应该把这个系统电源IRP下传到下一层驱动程序。你需要为该系统电源IRP写一个完成例程，以便能够调用PoStartNextPowerIrp，并且还可以执行一些额外的清除操作。图8-7显示了在这种情况下该IRP如何穿过系统。

图8-7. 降低系统电源消耗时的IRP流

图8-8显示了我们的有限状态机如何处理这种类型的IRP。TriageNewIrp把有限状态机置入SubPowerDownPending状态并跳到SelectDState动作。你已经看到了SelectDState选择了一个设备电源状态并引发了一个SendDeviceIrp动作，该动作请求了一个设备电源IRP。在系统转入低电力消耗状态期间，我们应该在该设备IRP中指定一个更低的电源状态。

图8-8. 降低系统电源级别时的状态转换

当设备IRP完成时，我们执行SubPowerDownComplete：

case SubPowerDownComplete:
{
  if (status == -1)
    status = STATUS_SUCCESS;
  if (NT_SUCCESS(ctx->status))
  {
    ctx->state = SysPowerDownPending;
    action = ForwardMainIrp;
  }
  else
    action = CompleteMainIrp;
  continue;
}

正如你看到的，如果设备IRP失败，我们也使系统IRP失败。如果设备IRP成功，我们就进入SysPowerDownPending状态并经由ForwardMainIrp退出。当系统IRP完成，并且MainCompletionRoutine运行时，我们执行SysPowerDownComplete：

case SysPowerDownComplete:
{
  if (stack->MinorFunction == IRP_MN_SET_POWER)
    pdx->syspower = stack->Parameters.Power.State.SystemState;
  action = CompleteMainIrp;
  continue;
}

这个动作的唯一目的是在我们的设备扩展中记录下新的系统电源状态，然后经由CompleteMainIrp和DestroyContext退出。

设备电源IRP

实际上，我们为系统电源IRP所做的一切就是为它们形成一个通道，并在系统IRP上下穿越驱动程序堆栈时请求设备IRP。但对于设备电源IRP，我们却有更多的事情要做。

首先，我们不希望在设备电源状态正改变时有任何实质性的I/O请求到来。因此我们应该尽早地进入一个使设备电源消耗逐渐降低的序列，我们等待任何未决的操作完成，并且我们停止处理新的操作。由于我们不能阻塞接收电源IRP的系统线程，因此需要一个异步机制。一旦当前IRP完成，我们将继续处理设备IRP。

如果设备电源IRP暗含着要提高设备电源消耗级别，我们就把该IRP送到下一层驱动程序。图8-9显示了该IRP如何流过系统。总线驱动程序将使用某种专有方式处理设备的set-power请求，例如，使用总线专有的机制打开通向设备的电流，并完成该IRP。你的完成例程将开始恢复设备上下文信息所需要的任何操作，并且将返回STATUS_MORE_PROCESSING_REQUIRED以中断该设备IRP的完成处理。当上下文恢复操作完成后，你可以回到处理实质性IRP的程序中并结束设备IRP的完成操作。

图8-9. 提升设备电源级别时的IRP流

如果设备电源IRP暗含着不改变或降低设备的电源级别，那么你应该先执行任何设备相关的处理(以异步方式)，然后把该设备IRP送到下一层驱动程序，见图8-10。一个设置操作的“设备专用处理”将包括保存设备上下文信息到内存中，以便以后恢复。对于一个查询操作，除了决定是成功还是失败该IRP之外，可能没有任何设备相关的处理。总线驱动程序将完成该请求。在查询操作中，你可能希望总线驱动程序以STATUS_SUCCESS结果完成该请求，以指出允许IRP中指定的电源状态改变。在设置操作中，你可能希望总线驱动程序做任何使设备进入指定设备电源状态的与总线相关的步骤。你的完成例程通过调用PoStartNextPowerIrp做一些清除工作。

图8-10. 降低设备电源级别时的IRP流

我写了StallRequestsAndNotify例程，该例程被TriageNewIrp使用。它执行的第一步是停止请求队列。如果设备当前正忙，它就记录下回调函数地址(使用GenericSaveRestoreComplete，为了接收通知，我超载了该函数)，并返回STATUS_PENDING。然后TriageNewIrp在QueueStallPending状态下退出。

如果设备不忙，StallRequestsAndNotify将返回STATUS_SUCCESS，并且不安排任何回调函数；因为队列被停止了，设备不会处于忙状态。然后TriageNewIrp直接进入QueueStallComplete动作。

我们或者直接从TriageNewIrp到达(当设备空闲时，队列可能因为某些与电源相关的其它原因而停止)QueueStallComplete例程，或者当客户驱动程序调用StartNextPacket(以指出其已经完成当前IRP的处理)时到达QueueStallComplete例程。StartNextPacket将调用我们在StallRequestsAndNotify中给出的通知例程，并且通知例程会向有限状态机发出一个AsyncNotify事件。现在QueueStallComplete把设备IRP分成如下四类：

case QueueStallComplete:
{
  if (stack->MinorFunction == IRP_MN_SET_POWER)
  {
    if (stack->Parameters.Power.State.DeviceState < pdx->devpower)
    {
      action = ForwardMainIrp;
      SETSTATE(DevPowerUpPending);
    }
    else
      action = SaveContext;
  }
  else
  {
    if (stack->Parameters.Power.State.DeviceState < pdx->devpower)
    {
      action = ForwardMainIrp;
      SETSTATE(DevQueryUpPending);
    }
    else
      action = DevQueryDown;
  }
  continue;
}

QueueStallComplete将使我们执行下一个动作，该动作由我们正处理的IRP类型指出，类型见表8-5。

表8-5. 设备IRP的下一个动作

副功能码 | 提升还是降低电源级别？ | 下一个动作
IRP_MN_QUERY_POWER | 提升电源级别 降低或维持当前电源级别 | ForwardMainIrp DevQueryDown
IRP_MN_SET_POWER | 提升电源级别 降低或维持当前电源级别 | ForwardMainIrp SaveContext

设置更高级的设备电源状态

图8-11显示了一个IRP_MN_SET_POWER请求带来的状态转换，该请求指定了一个比当前设备电源状态更高级的电源状态。

图8-11. 设置更高级设备电源状态时发生的状态转换

ForwardMainIrp将安装一个完成例程并向下发送该IRP。当MainCompletionRoutine最后获得控制时，它发出一个MainIrpComplete事件。我们将处于DevPowerUpPending状态，因此我们将执行DevPowerUpComplete动作：

case DevPowerUpComplete:
{
  if (!NT_SUCCESS(ctx->status) || stack->MinorFunction != IRP_MN_SET_POWER)
  {
    action = CompleteMainIrp;
    continue;
  }
  status = STATUS_MORE_PROCESSING_REQUIRED;
  DEVICE_POWER_STATE oldpower = pdx->devpower;
  pdx->devpower = stack->Parameters.Power.State.DeviceState;
  if (pdx->RestoreContext)
  {
    ctx->state = ContextRestorePending;
    (*pdx->RestoreDeviceContext)(pdx->DeviceObject, oldpower, pdx->devpower, ctx);
    break;
  }
  action = ContextRestoreComplete;
  continue;
}

这里我们需要完成的主要任务是恢复在上一个power-down转换时丢失的设备上下文。由于我们不可以阻塞自己的线程，所以我们先执行任何必需的操作，然后返回STATUS_MORE_PROCESSING_REQUIRED中断设备IRP的完成。当恢复操作完成时，客户驱动程序调用GenericSaveRestoreComplete，该函数发出AsyncNotify事件。在这一点上，我们将处于ContextRestorePending状态，所以我们执行ContextRestoreComplete动作：

case ContextRestoreComplete:
{
  if (event == AsyncNotify)
    status = STATUS_SUCCESS;
  action = CompleteMainIrp;
  if (!NT_SUCCESS(ctx->status) || pdx->devpower != PowerDeviceD0)
    continue;
  ctx->UnstallQueue = TRUE;
  continue;
}

该动作例程的主要结果是，我们在得到一个使设备进入D0状态的IRP_MN_SET_POWER请求后重新启动了实质性的IRP队列。然后经由CompleteMainIrp和DestroyContext退出。

查询更高级的设备电源状态

你不应该期望收到一个比设备当前电源状态更高的IRP_MN_QUERY_POWER，但是如果碰巧收到一个，你也不要破坏系统。下面代码显示了当这样的查询在低级驱动程序完成时GENERIC是如何做的。(参见图8-12的状态图)

case DevQueryUpComplete:
{
  if (NT_SUCCESS(ctx->status) && pdx->QueryPower)
    if (!(*pdx->QueryPower)(pdx->DeviceObject,
                pdx->devpower,
                stack->Parameters.Power.State.DeviceState))
      ctx->status = STATUS_UNSUCCESSFUL;
  action = CompleteMainIrp;
  continue;
}

即，GENERIC允许客户驱动程序通过调用QueryPower函数接受或拒绝这种查询，然后经由CompleteMainIrp和DestroyContext退出。

图8-12. 关于更高级设备电源状态的查询的状态转换

设置更低级的设备电源状态

如果是设置一个低于或等于当前设备电源状态的IRP_MN_SET_POWER请求，则有限状态机将穿过图8-13所示的状态转换过程。

图8-13. 设置更低级设备电源状态时的状态转换

SaveContext将开始一个异步过程以保存设备上下文信息：

case SaveContext:
{
  DEVICE_POWER_STATE devpower = stack->Parameters.Power.State.DeviceState;
  if (pdx->SaveDeviceContext && devpower > pdx->devpower)
  {
    ctx->state = ContextSavePending;
    (*pdx->SaveDeviceContext)(pdx->DeviceObject, pdx->devpower, devpower, ctx);
    break;
  }
  action = ContextSaveComplete;
}

当保存操作完成后，客户驱动程序将调用GenericSaveRestoreComplete，该函数将发出AsyncNotify事件。在此我们会处于ContextSavePending状态，所以我们执行ContextSaveComplete动作：

case ContextSaveComplete:
{
  if (event == AsyncNotify)									<--1
    status = STATUS_SUCCESS;
  ctx->state = DevPowerDownPending;
  action = ForwardMainIrp;
  DEVICE_POWER_STATE devpower = stack->Parameters.Power.State.DeviceState;
  if (devpower <= pdx->devpower)								<--2
    continue;
  pdx->devpower = devpower;									<--3
  if (devpower > PowerDeviceD0)									<--4
    ctx->UnstallQueue = FALSE;
  continue;
}
  1. 我们直接从GenericSaveRestoreComplete到这里，我们需要改变status以防止ASSERT故障(没有其它原因)。
  2. 如果我们没有真正改变电源状态，这里就没有其它工作需要做。
  3. 当我们降低电源状态时，我们在这里记录下新的设备电源状态。
  4. 如果设备正处于low-power或no-power状态，我们将是使实质性IRP队列停止。

下一个动作是ForwardMainIrp，它向驱动程序堆栈发送设备IRP。总线驱动程序将关闭物理电流并完成该IRP。当MainCompletionRoutine通知一个MainIrpComplete事件时，我们将再次看到它，它带我们直接到达CompleteMainIrp，最后到达DestroyContext。

查询更低级的设备电源状态

一个指定了低于或等于当前设备电源状态的IRP_MN_QUERY_POWER请求是功能驱动程序表决电源级别改变时所使用的基本手段。尽管DDK没有专门说应该在处理系统查询时创建这样的请求，但这是个好想法。总之，你必须处理设备查询请求并且最好把所有查询逻辑都放到一个地方。图8-14显示了有限状态机如何处理这样的查询。

对于这种IRP，QueueStallComplete后面将跟着DevQueryDown：

case DevQueryDown:
{
  DEVICE_POWER_STATE devpower = stack->Parameters.Power.State.DeviceState;
  if (devpower > pdx->devpower
      && pdx->QueryPower
      && !(*pdx->QueryPower)(pdx->DeviceObject, pdx->devpower, devpower))
  {
    ctx->status = STATUS_UNSUCCESSFUL;
    action = DevQueryDownComplete;
    continue;
  }
  ctx->state = DevQueryDownPending);
  action = ForwardMainIrp;
  continue;
}

图8-14. 更低级设备电源状态查询的状态转换

基本上，GENERIC让客户驱动程序来决定该查询是否应该成功。如果客户驱动程序说“Yes”，我们就进入DevQueryDownPending状态，并经由ForwardMainIrp向下发出该查询请求，然后退出。该IRP的完成操作使我们进入了DevQueryDownComplete动作：

case DevQueryDownComplete:
{
  if (ctx->status == STATUS_INVALID_PARAMETER)
    ctx->status = STATUS_SUCCESS;
  if (NT_SUCCESS(ctx->status))
    ctx->UnstallQueue = FALSE;
  action = CompleteMainIrp;
  continue;
}

如果查询成功，我们所要做的基本动作是使实质性的IRP队列停止(如果CompleteMainIrp看到上下文结构中的UnstallQueue标志被设置，它将重新启动该队列)。回想一下我们在收到该查询时首先停止了这个队列。我们把这个队列停止，直到最后有人向我们发送一个set-power请求并使设备进入D0状态，那时再启动。

Windows 98的USB hub驱动程序有一个bug，会使任何指定了设备电源状态的IRP_MN_QUERY_POWER请求失败。为了使我的电源管理代码避免遇到这个永久的bug(也防止你的计算机进入standby状态)，你可以象上面粗体字那样修改DevQueryDownComplete程序。修改后的例子代码在本书的SP-2服务包中给出。


其它电源管理细节

在这一节中，我将描述其它与电源管理相关的细节，包括需要在设备对象中设置的标志，设备唤醒特征的控制，在设备空闲后的预定时间内安排设备进入更低的电源状态，以及优化上下文恢复操作。

在AddDevice中设置的标志

设备对象中的两个标志位，见表8-6，控制电源管理的各个方面。当你在AddDevice函数中调用了IoCreateDevice例程之后，这两个位都将被置0，你可以根据具体环境设置这两个位。

表8-6. DEVICE_OBJECT中的电源管理标志

标志 | 简要描述
DO_POWER_PAGABLE | 驱动程序的IRP_MJ_POWER派遣例程必须运行在PASSIVE_LEVEL级
DO_POWER_INRUSH | 该设备在上电时需要大电流

如果IRP_MJ_POWER请求的派遣函数必须运行在PASSIVE_LEVEL级别，你需要设置DO_POWER_PAGABLE标志。该标志的名称含有相关的含义，因为只有在PASSIVE_LEVEL级上才允许分页操作。如果你把该标志设置为0，则电源管理器可以在DISPATCH_LEVEL级上向你发送电源管理请求。实际上，在当前发行的Windows 2000中总应该这样做。

如果你的设备在上电时需要大电流，设置DO_POWER_INRUSH标志，这可以使多个有这样要求的设备不同时上电。另外，电源管理器会在DISPATCH_LEVEL级上向inrush设备发送电源管理请求，这意味着你不能同时设置DO_POWER_PAGABLE标志。

如果设备的ASL描述中这样指定，那么系统的ACPI过滤器驱动程序将在PDO中自动设置INRUSH标志，这样系统会正确地顺序化有INRUSH标志设备的电源供应，因此你并不需要在自己的设备对象中设置这个标志。但是，如果系统不能自动确定你的设备是否需要inrush对待，你需要自己设置这个标志。

设备的所有设备对象中的PAGABLE和INRUSH标志设置都要一致。如果PDO设置了PAGABLE标志，则其它设备对象也应该设置PAGABLE标志。否则将发生代码为DRIVER_POWER_STATE_FAILURE的bug check(一个PAGABLE设备在一个非PAGABLE设备之上是合法的，但相反则不行)。如果设备对象设置了INRUSH标志，那么它自己和任何它下面的低级设备对象都不应该设置PAGABLE标志，否则将发生代码为INTERNAL_POWER_ERROR的bug check。如果你正写一个磁盘驱动程序，不要忘了为响应设备分页文件的PnP通知，你可以随时在分页和非分页状态间改变。

设备的唤醒特征

某些设备具有硬件唤醒特征，这允许它们在外部事件发生时可以唤醒沉睡中的系统，见图8-15。当前PC上的电源开关就是一个这样的设备。许多modem和网卡也能监听进来的电话和数据包。

图8-15. 设备唤醒系统的例子

如果你的设备有唤醒特征，那么你的功能驱动程序就必须承担额外的电源管理责任，这超出了我们已经讨论过内容。第一个额外责任是处理IRP_MJ_POWER请求族中的IRP_MN_WAIT_WAKE请求。除了安装一个标准I/O完成例程和下传该IRP之外，大部分设备中与WAIT_WAKE请求对应的派遣函数不必做任何处理。例如，USB和PCI总线的驱动程序实现了总线专有的装备、解除、和探测唤醒特征的能力。更明确地说，除了总线规范中描述的相关特征之外，如果你的设备没有与设备唤醒相关的额外特征，就不必做任何专门的处理。

如果一个IRP_MN_QUERY_POWER请求中指定了与你的唤醒特征不兼容的电源状态，你应该使该IRP失败。如果是一个查询系统电源状态的请求，用设备能力结构中的SystemWake域与被提议的新状态相对比，这将得出设备可以唤醒系统所需要的最低的系统电源状态。如果是设备电源状态的查询，用DeviceWake域与被提议的新状态进行比较，这将得出设备能发出唤醒信号所需的最低的设备电源状态。如果比较的结果表明提议的电源状态太低，以STATUS_INVALID_DEVICE_STATE失败该查询IRP。否则完成该查询。

你需要在适当时间发出IRP_MN_WAIT_WAKE请求。因此，要象下面代码这样调用PoRequestPowerIrp：

typedef struct _DEVICE_EXTENSION {
  PIRP WaitWakeIrp;
};

NTSTATUS SomeFunction(...)
{
  ...
  POWER_STATE junk;
  junk.SystemState = pdx->devcaps.SystemWake;
  status = PoRequestPowerIrp(pdx->Pdo,
                 IRP_MN_WAIT_WAKE,
                 junk,
                 (PREQUEST_POWER_COMPLETE) WaitWakeCallback,
                 pdx,
                 &pdx->WaitWakeIrp);
  ...
}

关系到唤醒的最后的额外责任是当WAIT_WAKE请求不再需要时取消它，如下面代码：

PIRP Irp = (PIRP) InterlockedExchangePointer(&pdx->WaitWakeIrp, NULL);
if (Irp)
  IoCancelIrp(Irp);

对于大多数设备，当WAIT_WAKE完成时你需要执行三个任务。应该把设备扩展结构中指向活动WAIT_WAKE请求的成员置空。这可以防止驱动程序的其它部分认为该WAIT_WAKE仍旧活动。你应该发出一个设备set-power请求以恢复设备的电源。在这里，某些设备还会执行解除设备唤醒特征的某种设备相关操作。最后，你希望WAIT_WAKE请求会自动重发，以便将来再次装备设备唤醒特征。这些任务中的第一个(置空WAIT_WAKE请求的指针)应该在派遣函数安装的标准I/O完成例程中被完成。另外两个任务(为你的设备上电，请求一个新的WAIT_WAKE IRP)应该在回调函数中完成，这个回调函数(在我给出的代码片段中是WaitWakeCallback)是你调用PoRequestPowerIrp时指定的。

注意
在我认为百分之百保证你在为WAIT_WAKE调用IoCancelIrp函数时会得到一个有效的指针会十分困难。在你的I/O完成例程置空该IRP隐藏指针前的一纳秒，你可以决定取消该IRP。完成操作将运行，并且一旦你的回调例程返回，电源管理器将调用IoFreeIrp结束这个完成操作。此后，IoCancelIrp或总线驱动程序的取消例程会试图使用这个现在已经无效的IRP。这与我们在第五章所遇到的是同一类问题。为此，我和Microsoft的一个开发员给出了一个优美的解决方案，具体代码见光盘中的GENERIC例子。

何时发出WAIT_WAKE

在上一段中，我解释了如何发出WAIT_WAKE IRP、如何取消该IRP，以及其完成时我们该做什么。现在我解释何时该发出该IRP。

这个问题的第一部分回答是，你需要一种方法来了解用户是否希望你的设备使用唤醒特征。除非用户阻止，你的驱动程序应该使用唤醒特征。在系统电源降低时，用户通过使用某种用户接口元素(例如类似于POWCPL.DLL的控制面板小件)指出设备是否可以使用唤醒特征。然后用户接口元素与驱动程序沟通，使用私有的IOCTL接口或通过WMI控制来设置。在Windows 2000中的某些地方，用户模式程序可能会使用至今还没实现的RequestDeviceWakeup和CancelDeviceWakeupRequest API来触发对驱动程序的WMI调用。

这个问题的第二部分回答关系到你何时调用PoRequestPowerIrp来请求WAIT_WAKE。DDK指出，当设备处于D0状态并且现在没有设备电源转换正在进行时，你可以请求一个WAIT_WAKE。较合适的时间是在用户告诉你可以允许设备的唤醒特征时，或者当你处理一个将要降低设备电源状态的系统电源查询时。

如果用户告诉你要禁止唤醒特征，或者当你处理一个请求时，你应该禁止设备的唤醒特征(并取消未决的WAIT_WAKE)。

空闲检测

通常用户在设备不被使用时不希望其消耗任何电力。你可以向电源管理器寄存这种空闲检测要求，如果你的设备空闲了指定长度的时间，则电源管理器自动向设备发出降低电源消耗的设备IRP。这个空闲检测机制涉及两个服务函数：PoRegisterDeviceForIdleDetection和PoSetDeviceBusy。

为了寄存空闲检测，调用下面服务函数：

pdx->idlecount = PoRegisterDeviceForIdleDetection(pdx->Pdo,
                          ulConservationTimeout,
                          ulPerformanceTimeout,
                          PowerDeviceD3);

PoRegisterDeviceForIdleDetection的第一个参数是设备PDO的地址。第二个和第三个参数指定了单位为秒的超时时间。conservation时期将用于用电池供电的系统中，尽量维持电力供应。performance时期应用于使用交流电源供电的系统中，使系统达到最大性能。第四个参数指定了设备在经过指定时间的空闲状态后被强制进入的设备电源状态。

指出自己没有处于空闲状态

PoRegisterDeviceForIdleDetection的返回值是一个长整型变量的地址，系统把该变量用做计数器。每一秒钟，电源管理器都增加该整型的值。如果它到达适当的超时值，电源管理器就向你发送一个设备set-power IRP以指出你寄存的电源状态。在驱动程序的许多地方，你应该把该计数器重置为0，以重启动空闲检测周期：

if (pdx->idlecount)
  PoSetDeviceBusy(pdx->idlecount);

PoSetDeviceBusy是WDM.H文件中的一个宏，它无条件地向其指针参数中存如一个0。这表明PoRegisterDeviceForIdleDetection将返回一个NULL指针，所以你在调用PoSetDeviceBusy前不用检测其是否为NULL。

现在，我已经描述了PoSetDeviceBusy所做的工作，所以你可能看出了它的名字有些误导。它没有告诉电源管理器你的设备是“忙”的，在这种情况下，你希望以后会有另一个调用来指出设备不再“忙”。另外，它指出了在特定时刻下设备的状态，即在你使用这个宏时设备是非空闲的。这并不是咬文嚼字。如果你的设备正忙于某种活动请求，你应该希望有代码来阻止空闲检测。所以，你会希望在驱动程序的多个地方调用PoSetDeviceBusy：从各种派遣例程、从StartIo例程，等等。基本上，你应该确保这个检测时期长于你在两个PoSetDeviceBusy调用之间的最长经过时间。调用PoSetDeviceBusy是你在一个请求的正常处理中做出的。

注意
PoRegisterSystemState允许你阻止电源管理器改变系统电源状态，但你不能使用它来阻止空闲超时。此外，Windows 98没有实现该函数，所以需要在Windows 2000和Windows 98之间移植的驱动程序必须注意。

空闲超时的选择

选择恰当的空闲超时值并不十分简单。某些设备可以在其设备类中的标准电源策略上指定-1超时。在写这本书时，仅有FILE_DEVICE_DISK和FILE_DEVICE_MASS_STORAGE设备可以这样做。你可能需要超时常量有默认值，这个最后仍可以被用户控制。

除非你的设备适合系统设计者制定的一般空闲检测方案，否则你需要提供一个用户模式部件以允许用户指定超时值。为了更好地融入操作系统，这个部件应该是电源控制面板小件的属性页扩展。即，你需要提供一个实现了IShellPropSheetExt和IShellExtInit COM接口的用户模式DLL。这个DLL将符合shell extension DLL的通用描述。如果你需要了解如何写这个特殊的用户接口软件，应该研究shell extension DLL。

光盘中的WDMIDLE例子包括了一个shell extension DLL(POWCPL.DLL)，你可以复制并修改它。如果你安装了这个例子，你将看到电源选项属性单中多了一个的属性页，见图8-16。POWCPL.DLL使用用户模式函数来枚举所有已经寄存了GUID_WDMIDLE接口的设备，并在一个列表框中列出它们的“友好”名称。它使用一个私有的I/O控制(IOCTL，见第九章)方案来查询并修改WDMIDLE.SYS使用的空闲超时常量。使用IOCTL方法可以得到一个在Windows 2000和Windows 98中通用的方案。另一种可能的方法是使用COM接口，该接口是WMI的一部分。(见第十章) 这种方法十分麻烦并且不能用于Windows 98的原始版本，所以我没有用这种方法编制POWCPL.DLL。

图8-16. 空闲设备的属性页

在有用户接口的驱动程序这一边，IRP_MJ_DEVICE_CONTROL处理程序将回答各种改变电源管理设置的请求和各种查询请求。用户希望这些设置一旦指定就可以在系统重启动后保持有效。因此，驱动程序需要把这些常量的当前值记录到注册表中。另外，在StartDevice函数执行期间，驱动程序需要从注册表中读取这些固定设置，并根据这些用户期望值初始化驱动程序。

所有这些细节，尽管对一个要交付产品的十分重要，但它们都略微触及了电源管理问题，所以我不将在这里讨论它们的代码。

从空闲状态中唤醒

如果你实现了空闲检测，你还需要提供一种方法，以便以后恢复设备的电源，注意没有人为你做这些。我写了一个名为SendDeviceSetPower的函数来处理这个细节。你可以把类似下面的代码放到恢复电源IRP的派遣函数中。

NTSTATUS DispatchWrite(IN PDEVICE_OBJECT fdo, IN PIRP Irp)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  if (pdx->idlecount)										<--1
    PoSetDeviceBusy(pdx->idlecount);

  if (pdx->powerstate > PowerDeviceD0)								<--2
  {
    NTSTATUS status = SendDeviceSetPower(fdo, PowerDeviceD0, FALSE);
    if (!NT_SUCCESS(status))									<--3
      return CompleteRequest(Irp, status, 0);
  }

  IoMarkIrpPending(Irp);									<--4
  StartPacket(&pdx->dqReadWrite, fdo, Irp, OnCancel);
  return STATUS_PENDING;
}
  1. 这就是某驱动程序中用于处理IRP_MJ_WRITE请求的派遣函数。在这个例程的开始处是你应该调用PoSetDeviceBusy函数的地方之一，这个函数将重置发生于每一秒的空闲倒计时。
  2. 你应该在设备不活动一段时期后降低设备的电源，或者当系统从standby中恢复时简单地关闭设备电源。无论什么原因，系统中没有任何程序会认为设备立刻会需要电源，因此你必须启动设备上电过程。
  3. 如果设备的set-power请求因为某种原因失败，你应该失败这个写请求。
  4. 这个派遣函数的剩下部分与前面章节中讨论的相同。我们标记这个IRP为pending，把它放入写请求队列，并返回STATUS_PENDING以通知我们的调用者我们不必在派遣例程中结束该IRP。

通常，我们在任意线程上下文中获得读写请求，所以我们不应该阻塞该线程。因此当我们打开设备的电源时，我们不用等待power-up操作完成就可返回。当电源完全恢复时，DEVQUEUE将负责启动该请求。

SendDeviceSetPower辅助函数将直接调用PoRequestPowerIrp。其结果IRP将按我们曾经讨论过的方式处理。

用序列号优化状态改变

你可能希望在降低和恢复设备电源时使用优化技术。有两个背景因素会帮助你理解这种优化技术。第一，总线驱动程序并不总是降低设备电源，即使在它收到set-power IRP时。出现这种不协调情况的原因是计算机内部供电的连接方式。可能有一个或多个电源通道，而在任何给定的通道内都会连接任意组合的多个设备。这些设备共享一个电源连接。单个设备不能独自降低电源，除非在同一电源通道的所有其它设备同时降低电源。因此，我在这里举一个比较恐怖的例子，假定你要降低电源供应的modem设备与计算机控制的人工心肺机共享一个电源通道，那么系统就会拒绝降低modem的电源供应。

第二个背景因素是某些设备需要大量时间来改变电源状态。我们返回到前面的例子，假设你的modem就是一个这样的设备。在某一时刻，你的驱动程序收到并传递一个设备set-power请求，该请求将使modem进入睡眠状态。然而你并不知道总线驱动程序实际上并没有降低modem的电源供应。当到了恢复modem的电源供应时，如果你知道modem并没有被降低电源供应，你就可以节省一些时间，这就是这个优化所要针对的地方。

在你关闭设备电源时，你可以创建并发送一个带有IRP_MN_POWER_SEQUENCE副功能码的电源请求给下层驱动程序。尽管这个IRP在技术上是一个IRP_MJ_POWER请求，但你可以用IoAllocateIrp创建它。在处理该IRP时你还要使用PoStartNextPowerIrp和PoCallDriver函数。总线驱动程序在你提供的数组中保存了三个序列号后该请求才完成。序列号指出设备有多少次被置入D1、D2、D3状态。当你被调用恢复设备的电源时，你创建并发送另一个IRP_MN_POWER_SEQUENCE请求以获得一组新的序列号。如果这组新序列号与在power-down时获得的序列号相同，那么你就会知道状态改变没有发生，因此你可以跳过代价昂贵的电源恢复过程。

因为IRP_MN_POWER_SEQUENCE优化的过程在不优化的情况下也可以正常工作，所以你不用必须使用它。另外，总线驱动程序也不用必须支持它，并且你也不要把power-sequence请求的失败当作错误。光盘上的GENERIC例子包含了使用该优化方法的代码，但这里我不想再对状态机进行更复杂的文字讨论。


Windows 98兼容问题

Windows 98实现的许多电源管理特征都不完整。因此，Windows 98环境对错误的检查要比Windows 2000宽松。这虽然促进了驱动程序的初始开发，但因为Windows 98容忍了许多Windows 2000不能容忍的错误，所以你必须确保在Windows 2000下测试驱动程序的所有电源管理功能。

DO_POWER_PAGABLE的重要性

在Windows 98中，DO_POWER_PAGABLE标志会有意想不到的重要性。除非设备堆栈的每个设备对象，包括PDO和所有过滤器设备都设置了这个标志，否则I/O管理会告诉Windows 98的配置管理器该设备仅支持D0电源状态并且不能唤醒系统。这样，忘记设置DO_POWER_PAGABLE标志的一个结果是，你通过PoRegisterDeviceForIdleDetection做的任何空闲通知请求都被完全忽略，即你将不能收到表示空闲超时的电源IRP。另一个结果是，设备的唤醒特征将不被使用。

请求设备电源IRP

Windows 98中的一个bug会使PoRequestPowerIrp成功，即它返回了STATUS_PENDING，但你却不会收到设备set-power请求。当你发出一个set-power请求时，如果指定了设备当前的电源状态就会出现这样问题，Windows 98的配置管理器认为没有新的情况发生，所以它没有发出一个配置事件来向NTKERN中的配置函数报告，而NTKERN负责处理WDM驱动程序这方面的事情。因此，如果你正等待一个设备IRP的完成，你的设备将简单地在这一点上停止响应。

我用了一个明显的workaround来克服这个问题：如果我们正运行在Windows 98下并且我们将要请求一个设备电源IRP，而这个IRP中指定的电源状态与设备当前处于的电源状态相同，我将简单地假装该设备IRP已成功。根据HandlePowerEvent穿过的状态转换，我从SendDeviceIrp直接跳到适合的动作程序(SubPowerUpComplete或SubPowerDownComplete)。

PoCallDriver

在Windows 98中，PoCallDriver仅仅是调用IoCallDriver。因此，你更容易犯下用IoCallDriver去下传电源IRP的错误。然而在Windows 98中，还有一个更差的问题正等着你。

Windows 2000版本的PoCallDriver函数确保了在PASSIVE_LEVEL级上向有DO_POWER_PAGABLE标志的驱动程序发送电源IRP，在DISPATCH_LEVEL级上向有INRUSH标志的驱动程序或处于非分页内存中的驱动程序发送电源IRP。我利用了这个事实，GENERIC就是在某I/O完成例程在DISPATCH_LEVEL级上调用HandlePowerEvent时下传了电源IRP。在Windows 98，由于PoCallDriver就是IoCallDriver，并没有切换IRQL。所以在Windows 98中所有电源IRP都是在PASSIVE_LEVEL级上发出的。因此我写了一个辅助函数SafePoCallDriver来帮助GENERIC排队可执行工作项(executive work item，参见第九章)，以使电源IRP能够在PASSIVE_LEVEL级上发出。

其它不同之处

你应该了解Windows 98与Windows 2000在处理电源管理特征上的其它不同之处。我简要地描述一下这些不同之处并指出它们如何影响驱动程序开发。

当你调用PoRegisterDeviceForIdleDetection时，你必须提供PDO的地址而不是自己设备对象的地址。这是因为，在内部，系统需要找到Windows 98配置管理器使用的DEVNODE对象的地址，并且该对象仅能通过PDO访问。在Windows 2000中你还可以把PDO作为该参数，所以你首先应该以这种方式写你的代码。

PoSetPowerState支持例程在Windows 98中是一个空操作。另外，尽管该函数声称返回前一个设备或系统的电源状态，但该函数的Windows 98版本仅返回你提供的任何状态参数，这可能是一个新状态，与旧状态不同，或者仅仅是一个随机数。

PoStartNextPowerIrp在Windows 98中也是一个空操作，所以当你在Windows 98中开发驱动程序时，你可能会很容易忘记调用过该函数。

处理设备电源关系的服务例程(PoRegisterDeviceNotify和PoCancelDeviceNotify)在Windows 98中没有定义。Windows 98不能发出PowerRelations查询请求来收集支持回调函数的信息。服务例程PoRegisterSystemState、PoSetSystemState、PoUnregisterSystemState在Windows 98中也没有实现。为了装入使用了这些未定义服务函数的驱动程序，你需要提供一个带桩的虚拟设备驱动程序，参见附录A，“对付Windows 98的不兼容问题”。


第九章：专门问题

在前八章中，我描述了能适合任何硬件设备的成熟WDM驱动程序的大部分特征。但你还应该了解一些更通用的技术，我将在本章描述这些技术。在本章的第一节，我将解释如何创建过滤器驱动程序。然后描述如何登记错误。之后，我将讨论一个相当重要的主题：IOCTL(I/O控制操作)，它允许应用程序控制硬件或驱动程序的特征。这个讨论解释了WMD驱动程序如何向应用程序报告其“感兴趣”的事件。后面我还给出了如何创建系统线程的指导，如何排队工作项(work item)，使其在已存在的系统线程上下文中执行，以及如何为不回应的设备建立看门狗定时器。

  • 过滤器驱动程序
  • 登记错误
  • I/O控制操作
  • 系统线程
  • 工作项
  • 看门狗定时器
  • Windows 98兼容问题


过滤器驱动程序

WDM模型假定硬件设备可以有多个驱动程序，每个驱动程序都有自己管理设备的方法。WDM根据设备对象堆栈来完成驱动程序的分层。到现在为止，我仅讨论了管理设备主要功能的功能驱动程序。在这一节中，我将描述如何写一个过滤器驱动程序，该驱动程序可位于功能驱动程序的上面或下面，它通过过滤流经它的IRP来修改设备的行为。

处于功能驱动程序之上的过滤器驱动程序称为上层过滤器；处于功能驱动程序之下的过滤器驱动程序(仍处于总线驱动程序之上)称为下层过滤器。虽然这两种驱动程序本身用于不同的目的，但创建这两种驱动程序的机制完全相同。实际上，创建过滤器驱动程序就象创建任何其它WDM驱动程序一样，都有DriverEntry例程、AddDevice例程、一组派遣函数，等等。

上层过滤器驱动程序的用途是帮助支持这样的设备，这种设备的大多数方面都象其所属类的普通设备，但有一些附加功能。你可以依靠一个通用的功能驱动程序来支持设备的普通行为。为了处理设备的附加功能，你可以写一个上层过滤器驱动程序来干预IRP流。举一个有趣的例子，假设存在一个烤面包机设备的标准类，并且已经有人为其写了一个标准驱动程序。再假设你的特殊烤面包机有一个高级的面包片弹出特征，它可以把烤好的面包片弹到两英尺高的空中。而控制这个AWE(Advanced Waffle Eject)特征的工作就是上层过滤器驱动程序的任务，见图9-1。

图9-1. 上层过滤器驱动程序所扮演的角色

上层过滤器驱动程序的另一个用途是修正硬件或功能驱动程序中出现的bug。如果过滤器驱动程序用于这个目的，Microsoft恳求你在这个过滤器驱动程序上加上版本标签，并且如果在以后某一天这个bug被纠正，你应该在你的控制范围内修改任何相关部件的版本号。否则，Microsoft将难于使系统自动更新。

下层过滤器驱动程序不能干涉功能驱动程序直接执行的正常操作。因为功能驱动程序可能通过HAL调用直接访问硬件来实现大部分实质的请求。而下层过滤器仅能看到经过它传递的IRP，它看不到HAL调用。

下层过滤器驱动程序可以用于USB设备的驱动程序堆栈中。对于USB设备，其功能驱动程序把内部控制IRP作为URB(USB请求块)的容器。下层过滤器驱动程序可能会监视并修改这些IRP，见图9-2。

图9-2. 下层过滤器驱动程序所扮演的角色

下层过滤器驱动程序的另一个用途是帮助你写一个总线无关的驱动程序。假设一种设备有三种不同总线形式的产品，PCI总线产品、USB总线产品、PCMCIA总线产品。你可以写一个完全独立于总线结构的功能驱动程序，这样的驱动程序不直接与设备对话。另外你还要写三个下层过滤器驱动程序，每个下层过滤器对应一个总线类型，如图9-3。当功能驱动程序需要与硬件对话时，它就向相应的下层过滤器驱动程序发送IRP(可能是IRP_MJ_INTERNAL_DEVICE_CONTROL)。

图9-3. 用下层过滤器驱动程序完成总线无关性

DriverEntry例程

过滤器驱动程序的DriverEntry例程与功能驱动程序的十分类似。主要的区别是，过滤器驱动程序必须为每种类型的IRP都安装派遣函数，不仅仅是安装其希望处理的IRP类型。

extern "C" NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
  DriverObject->DriverUnload = DriverUnload;
  DriverObject->DriverExtension->AddDevice = AddDevice;
  for (int i = 0; i < arraysize(DriverObject->MajorFunction); ++i)
    DriverObject->MajorFunction[i] = DispatchAny;
  DriverObject->MajorFunction[IRP_MJ_POWER] = DispatchPower;
  DriverObject->MajorFunction[IRP_MJ_PNP] = DispatchPnp;
  return STATUS_SUCCESS;
}

过滤器驱动程序与其它驱动程序一样，也有DriverUnload函数和AddDevice函数。我在主功能表中填入了一个名为DispatchAny函数的地址，该函数把所有随机请求继续传递下去。但我为电源管理请求和PnP请求指定了特殊的派遣函数。

过滤器驱动程序必须处理每种类型的IRP的原因是调用顺序，AddDevice函数在DriverEntry中被调用。通常，过滤器驱动程序必须支持所有与紧在其下面的驱动程序支持的相同类型的IRP。如果过滤器驱动程序使一个特别的MajorFunction表项处于默认状态，则该类型的IRP将会以STATUS_INVALID_DEVICE_REQUEST结果失败。(I/O管理器含有一个默认的派遣函数，该函数就以这个结果简单地完成一个IRP。系统最初送给驱动程序对象中的所有MajorFunction表项都指向这个默认例程) 但是直到AddDevice函数把设备对象放到你下面，你才知道这个原因。你可以在AddDevice函数中调查每个下层设备驱动程序，并在你自己的MajorFunction表中插入需要的派遣指针，但要记住你可能处于多设备堆栈中，因此AddDevice调用可能是重复的。在DriverEntry函数中声明支持所有IRP会比较容易。

AddDevice例程

过滤器驱动程序有AddDevice函数，该函数被每个合适的硬件调用。你将调用IoCreateDevice函数创建一个未命名的设备对象，然后调用IoAttachDeviceToDeviceStack把该对象插入驱动程序堆栈。另外，你还需要从下层设备对象中复制出几个设置。

NTSTATUS AddDevice(PDRIVER_OBJECT DriverObject, PDEVICE_OBJECT pdo)
{
  PDEVICE_OBJECT fido;
  NTSTATUS status = IoCreateDevice(DriverObject,
                   sizeof(DEVICE_EXTENSION),
                   NULL,
                   FILE_DEVICE_UNKNOWN,
                   0,
                   FALSE,
                   &fido);
  if (!NT_SUCCESS(status))
    return status;
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fido->DeviceExtension;
  _ _try
  {
    pdx->DeviceObject = fido;
    pdx->Pdo = pdo;
    PDEVICE_OBJECT fdo = IoAttachDeviceToDeviceStack(fido, pdo);
    pdx->LowerDeviceObject = fdo;
    fido->Flags |= fdo->Flags & (DO_DIRECT_IO|DO_BUFFERED_IO);
    fido->Flags |= DO_POWER_PAGABLE
    fido->DeviceType = fdo->DeviceType;
    fido->Characteristics = fdo->Characteristics;
    fido->Flags &= ~DO_DEVICE_INITIALIZING;
  }
  _ _finally
  {
    if (!NT_SUCCESS(status))
      IoDeleteDevice(fido);
  }
  return status;
}

与功能驱动程序不同的地方使用了粗体显示。基本上，我们仅需要从下层设备对象中传递少数标志位、以及DeviceType值和Characteristics值。这样做的原因是因为I/O管理器在了解最上层设备对象时要基于这些信息。特别是判断一个读写IRP是使用MDL还是使用系统复制缓冲区，要取决于最上层设备对象是设置了DO_DIRECT_IO标志还是设置了DO_BUFFERED_IO标志。我们不必复制下层设备对象中的SectorSize或AlignmentRequirement成员，IoAttachDeviceToDeviceStack将自动做这个工作。

换句话说，上层过滤器应该复制DO_DIRECT_IO和DO_BUFFERED_IO标志，并且总是设置DO_POWER_PAGABLE标志。即使下一层驱动程序有一个非分页的电源管理派遣例程，电源管理器也能处理有分页式派遣例程的FiDO。但是，如果FiDO使用非分页派遣例程，那么低层驱动程序就不能有分页式派遣例程，否则将产生一个bug check。

如果你正写一个下层过滤器驱动程序，可以不必关心怎样处理DO_DIRECT_IO和DO_BUFFERED_IO标志，因为它们可能是上面功能驱动程序的设置。如果上层驱动程序有一个非分页的电源管理派遣例程，那么下层驱动程序就不能设置DO_POWER_PAGABLE标志。如果下层驱动程序有分页式电源管理派遣例程，那么上层驱动程序不能清除DO_POWER_PAGABLE标志。遵循这个规则的唯一方法是了解你周围的驱动程序是怎样设置这些标志的，有两个地方需要注意：一、在初始化期间，二、在它们接收IRP_MN_DEVICE_USAGE_NOTIFICATION时。除了可读写磁盘设备的低级过滤器之外，你可以假定功能驱动程序使用分页式电源管理派遣例程，在这种情况下，DO_POWER_PAGABLE标志对于上层过滤器才是正确的。但是，事先你必须和那个驱动程序的作者沟通或查看它的源代码以确定这一点。

不需要在过滤器中设置DO_POWER_INRUSH标志，因为堆栈中仅有一个设备对象需要这种标志。

SP-3包含一个修订版的FILTER，和实现了这些建议的WDMWIZ向导。

注意
由于过滤器驱动程序只能在功能驱动程序执行其AddDevice函数时复制这些设置，而且如果你的功能驱动程序后来改变了这些设置，过滤器驱动程序也没有办法知道。因此你需要在功能驱动程序的AddDevice函数中指明是buffered方式还是direct I/O方式，并且之后不要再改变。

通常，过滤器设备对象(FiDO)不必有自己的名称。如果功能驱动程序命名了它的设备对象并创建了符号连接，或者功能驱动程序为其设备读写寄存了设备接口，那么应用程序就能够打开该设备的句柄。每个发往该设备的IRP都先被发往最顶层的FiDO驱动程序，不管该FiDO是否有自己的名字。

创建FiDO对象时，不要使用FILE_DEVICE_SECURE_OPEN特征。PnP管理器会在设备对象堆栈中散布这个标志和一些其它值。对一个文件打开操作进行强制安全检测并不是过滤器驱动程序的工作，而是功能驱动程序或总线驱动程序的工作。

派遣例程

写过滤器驱动程序的首要原因是你想以某种方式修改一个设备的行为。因此，需要用一些派遣例程来修改某些IRP。但大部分IRP都被直接传递下去：

NTSTATUS DispatchAny(PDEVICE_OBJECT fido, PIRP Irp)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fido->DeviceExtension;
  NTSTATUS status = IoAcquireRemoveLock(&pdx->RemoveLock, Irp);
  if (!NT_SUCCESS(status))
    return CompleteRequest(Irp, status, 0);
  IoSkipCurrentIrpStackLocation(Irp);
  status = IoCallDriver(pdx->LowerDeviceObject, Irp);
  IoReleaseRemoveLock(&pdx->RemoveLock, Irp);
  return status;
}

NTSTATUS DispatchPnp(PDEVICE_OBJECT fido, PIRP Irp)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fido->DeviceExtension;
  NTSTATUS status = IoAcquireRemoveLock(&pdx->RemoveLock, Irp);
  if (!NT_SUCCESS(status))
    return CompleteRequest(Irp, status, 0);
  PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
  ULONG fcn = stack->MinorFunction;
  IoSkipCurrentIrpStackLocation(Irp);
  status = IoCallDriver(pdx->LowerDeviceObject, Irp);
  if (fcn == IRP_MN_REMOVE_DEVICE)
  {
    IoReleaseRemoveLockAndWait(&pdx->RemoveLock, Irp);
    IoDetachDevice(pdx->LowerDeviceObject);
    IoDeleteDevice(fido);
  }
  else
    IoReleaseRemoveLock(&pdx->RemoveLock, Irp);
  return status;
}

NTSTATUS DispatchPower(PDEVICE_OBJECT fido, PIRP Irp)
{
  PoStartNextPowerIrp(Irp);
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fido->DeviceExtension;
  NTSTATUS status = IoAcquireRemoveLock(&pdx->RemoveLock, Irp);
  if (!NT_SUCCESS(status))
    return CompleteRequest(Irp, status, 0);
  IoSkipCurrentIrpStackLocation(Irp);
  status = PoCallDriver(pdx->LowerDeviceObject, Irp);
  IoReleaseRemoveLock(&pdx->RemoveLock, Irp);
  return status;
}

在这个例子中你可以看到，有必要为过滤器驱动程序的设备对象获取和释放删除锁。第一个IoAcquireRemoveLock调用用来检测当前FiDO是否有未处理的设备删除。如果是，派遣函数就立即以STATUS_DELETE_PENDING失败该IRP，这是IoAcquireRemoveLock能返回的唯一的非成功值。当过滤器驱动程序在某个派遣函数中拥有自己的删除锁时，另一个正用DispatchPnp处理IRP_MN_REMOVE_DEVICE的线程将在其调用IoReleaseRemoveLockAndWait时被阻塞。因此应该防止调用IoDetachDevice，它会使低层设备对象消失。我们自己的设备对象被一个引用计数保护起来，该引用计数是在这个IRP到来之前，我们的调用者通过IoGetAttachedDeviceReference获得的。

除了IRP_MJ_PNP请求，过滤器驱动程序中的所有派遣函数都必须在非分页内存中，并且不要假定会在PASSIVE_LEVEL级上被调用。这里有两个实际的例子，第一、一个USB设备的下层过滤器将接收并传递包含URB的IRP_MJ_INTERNAL_DEVICE_CONTROL请求。这些IRP中的某些会在PASSIVE_LEVEL级上到达，另一些会在DISPATCH_LEVEL级上到达，因为它们来自I/O完成例程。第二个例子是磁盘驱动程序，它会在PASSIVE_LEVEL级上开始处理电源管理请求，因为它设置了DO_POWER_PAGABLE标志。但磁盘驱动程序后来可能被通知其设备用于存放一个分页文件，或者是其它某些特殊文件，于是它锁定它的电源管理程序并清除DO_POWER_PAGABLE标志。这样，在同一个堆栈上的所有过滤器驱动程序就突然都在DISPATCH_LEVEL级上获得电源管理请求。

注意
在编写一个过滤器驱动程序时，你应该遵守这样的方针：不要做有害的事情，即不要使在你之上的驱动程序和在你之下的驱动程序失败。


登记错误

到目前为止，我所讨论的错误处理仅涉及到检测(以及传播)状态代码，以及处理那些反映代码错误的辅助调试技术(checked版本)。然而在驱动程序的free版本中，有些错误会严重到应该提请系统管理员注意。例如，一个磁盘驱动程序可能会发现磁盘的物理表面有大量的坏扇区。或者驱动程序遇到异常频繁的数据错误或某种难以配置或启动的设备。

为了处理这些情况，驱动程序应该向系统日志写入一条记录。而系统管理员可以使用Windows 2000系统中的管理工具，事件查看器(Event Viewer applet)查看所发生的问题。图9-4显示了事件查看器的工作窗口。指出意外错误的另一种方式是发出一个WMI事件。我将在这一节讨论事件登记。WMI主题见第十章。

图9-4. Windows 2000的事件查看器

由错误记录产生管理报告的步骤见图9-5。驱动程序使用内核模式服务函数IoWriteErrorLogEntry向事件登记服务器发送错误登记包(error log packet)。包中含有一个代替消息文本的数值代码。如果时间允许，事件登记程序就把这个包写入磁盘上的登记文件。然后，事件查看器把登记文件中的包和一组消息文件中的消息文本组合并生成管理报告。消息文件就是一个普通的32位DLL，其中包含有用本地语言描述的对应所有可能登记事件的文本。

图9-5. 事件的登记和报告过程

作为驱动程序的作者，当发生重要的事件时应创建适当的错误登记包。另外，你大概还需要以至少一种自然语言创建消息文件。

创建错误登记包

为登记一个错误，驱动程序需要创建一个IO_ERROR_LOG_PACKET结构，然后发往内核模式登记器。这个包是一个可变长结构，带有一个固定大小的头，头中包含有登记时的常规信息，见图9-6。ErrorCode指出登记的事件；它对应消息文件中的消息文本。在固定大小的头之后是一个名为DumpData的数组，数组元素为双字，长度为DumpDataSize字节，当要求显示该事件的详细信息时，事件查看器将以十六进制形式显示这些信息。尽管该数组声明是一个32位整型数组，但数组大小的单位仍是字节。在DumpData之后是一些空结尾的Unicode串，这些串最终都会被事件查看器用格式化的消息文本所替代。串区域开始于包的StringOffset字节处，包含NumberOfStrings个字符串。

图9-6. IO_ERROR_LOG_PACKET结构

除了我刚提到的，你不必填写任何固定头成员，参考登记表项的诊断工具可以帮助你追查到问题所在。

由于登记包的长度是可变的，所以你的第一个任务就是确定被创建包需要多少内存。例如下面代码来自EVENTLOG例子中，它分配一个能容纳4字节dump数据再加上一个单串的错误登记包：

VOID LogEvent(NTSTATUS code, PDEVICE_OBJECT fdo)
{
  PWSTR myname = L"EventLog";
  ULONG packetlen = (wcslen(myname) + 1) * sizeof(WCHAR) + sizeof(IO_ERROR_LOG_PACKET) + 4;
  if (packetlen > ERROR_LOG_MAXIMUM_SIZE)
    return;
  PIO_ERROR_LOG_PACKET p = (PIO_ERROR_LOG_PACKET)
    IoAllocateErrorLogEntry(fdo, (UCHAR) packetlen);
  if (!p)
    return;
  ...
}

这段代码中有一个容易出错的地方，错误登记包有152字节的最大长度，ERROR_LOG_MAXIMUM_SIZE。另外，IoAllocateErrorLogEntry参数的大小是一个UCHAR，它仅有8字节宽。我们可以请求一个长400字节的包，但实际上仅得到144字节。(400等于0x190，144是0x90，它是前一个数值被截掉8位后的结果)

注意IoAllocateErrorLogEntry的第一个参数是设备对象的地址。如果该设备对象有名称，则其名称会出现在登记表项中以替换%1 substitution转义代码，关于转义代码将在下一节讨论。

这段代码还显示了你对分配一个登记表项出错时所做的反应：什么也不做。不能登记某些错误不被认为是一项错误，所以你不要失败任何IRP，应该生成一个bug check或仅仅终止你的处理。实际上，你会注意到这个LogEvent辅助函数的返回值为VOID，因为程序员不必关心该函数的成功或失败。

成功地分配登记包后，下一项工作是初始化该结构并把控制权交给登记器。例如：

...
  memset(p, 0, sizeof(IO_ERROR_LOG_PACKET));
  p->ErrorCode =  code;

  p->DumpDataSize = 4;
  p->DumpData[0] = <whatever>;

  p->StringOffset = sizeof(IO_ERROR_LOG_PACKET) + p->DumpDataSize;
  p->NumberOfStrings = 1;
  wcscpy((PWSTR) ((PUCHAR) p + p->StringOffset), myname);

  IoWriteErrorLogEntry(p);
}

登记设备错误时，应该在包头中填入更多的域，不仅仅是错误代码。对于这些域，参见IoAllocateErrorLogEntry函数的DDK文档。

创建消息文件

事件查看器使用包中的ErrorCode来定位消息文件中的对应消息文本，这个消息文件与你的驱动程序关联。消息文件就是一个DLL，包含由一种或多种自然语言描述的文本资源。因为WDM驱动程序使用的可执行文件格式与DLL相同，所以你私有消息的消息文件就可以是驱动程序文件本身。这里我将介绍如何创建一个消息文件。在MSDN和James D. Murray的《Windows NT Event Logging (O'Reilly & Associates, 1998)》的第125-57页还可以找到更多的信息。

图9-7显示了添加消息文本到驱动程序文件中的过程。开始，你先创建一个扩展名为MC的消息源文件。消息编译器(MC.EXE)将把你写的脚本翻译成消息。消息编译器的一个输出是包含与消息对应的符号常量的头文件；的驱动程序应该包含这个头文件，而这些常量就是被登记事件的ErrorCode值。消息编译器的另一个输出是一组中间文件和一个资源文件(.RC)，中间文件包含以一种或多种自然语言描述的消息文本，而资源文件列出了这些中间文件。然后这些资源文件进入连接器，最后生成的驱动程序将包含支持事件查看器的消息资源。

图9-7. 消息文件的创建

下面是一个消息资源文件的例子。(是EVENTLOG例子的一部分)

MessageIdTypedef = NTSTATUS									<--1

SeverityNames = (										<--2
  Success        = 0x0:STATUS_SEVERITY_SUCCESS
  Informational  = 0x1:STATUS_SEVERITY_INFORMATIONAL
  Warning        = 0x2:STATUS_SEVERITY_WARNING
  Error          = 0x3:STATUS_SEVERITY_ERROR
  )

FacilityNames = (										<--3
  System      = 0x0
  Eventlog    = 0x2A:FACILITY_EVENTLOG_ERROR_CODE
  )

LanguageNames = (										<--4
  English    = 0x0409:msg00001
  German     = 0x0407:msg00002
  French     = 0x040C:msg00003
  )

MessageId = 0x0001										<--5
Facility = Eventlog
Severity = Informational
SymbolicName = EVENTLOG_MSG_TEST
Language = English
%2 said, "Hello, world!"
.
Language = German										<--6
%2 hat gesagt, <<Wir sind nicht mehr im Kansas!>>
.
Language = French
%2 a dit, <<Mon chien a mangé mon devoir!>>
.
  1. MessageIdTypedef语句指定一个符号，该符号可以作为消息标识符常量的强制类型转换操作符，消息标识符常量由这个消息文件生成。例如后面我们将用EVENTLOG_MSG_TEST符号名定义一个消息。MessageIdTypedef语句的出现将使消息编译器在头文件中生成(NTSTATUS)0x602A0001L符号定义。
  2. SeverityNames语句允许你定义自己的四种严重性代码名称。等号左边的名(Success、Informationaland，等等)可以出现在这个文件的消息定义中。冒号后面的符号最后被定义成冒号前面的数字(在输出头文件中)。例如，#define STATUS_SEVERITY_SUCCESS 0x0。
  3. FacilityNames语句允许你定义自己的Facility代码，这些代码将被包含在消息标识符定义中。我们将在后面的Facility语句中使用Eventlog名字。消息编译器把这三行的FacilityNames语句编译成#define FACILITY_EVENTLOG_ERROR_CODE 0x2A。
  4. LanguageNames语句允许你定义自己的自然语言名称。这里我们使用名称English，它对应LANGID 0x0409，即Windows NT语言方案中的标准英语。冒号之后的名字是中间二进制文件的名称，该文件接收以这种自然语言编译的消息。
  5. 每个单独的消息定义中都包含某些头文件语句。在这个例子中，MessageId语句指定了一个绝对数字，它还可以指定对上一消息的增量(如MessageId = +1)。这里你可以指定Facility代码和严重性(使用前面定义的严重性名称)。你还可以用SymbolicName语句为该消息指定一个符号名。消息编译器将在它生成的头文件中定义这个符号。
  6. 你可以为在LanguageNames语句中定义的每种语言定义一个像这样的消息文本。它开始于一个Language语句，然后是该消息的文本。每个消息文本的定义都以一个仅包含一个点字符的单行结束。

在消息文本中，我们可以用一个百分号再加一个整数来指出替代串。%1引用生成该消息的设备对象名。这个名是创建错误登记表项时的潜在参数；你不用直接指定这个名称。转义符号%2、%3，等将对应添加到登记表项中的第一个、第二个，等Unicode串。在上面的例子中，%2将被EventLog替换。

这种指定替换串的方法可以使你自由地在文本中加入串。因此，如果你的消息文本用英语时是“The %1 %2 fox jumped over the %3 dog”，但用德语时就应该为“Der %3 Hund wurde vom %1 %2 Fuchs ubergesprungen”。(当然这仅是一个例子，你的驱动程序必须提供“quick”、“brown”、“lazy”替换串，这些替换串将以英文形式出现在该消息的各种显示版本中)

事件查看器需要参考某些注册表项来寻找消息文件。Windows NT注册表的services分支(HKLM\System\CurrentControlSet\Services)中有一个名为EventLog的键，每个驱动程序或登记了事件的其它服务都在该分支下有自己的子键。每个专用服务的子键都有名为EventMessageFile和TypesSupported的值。EventMessageFile值为REG_SZ或REG_EXPAND_SZ类型，它命名事件查看器需要访问的所有消息文件。这个值可能是象“%SystemRoot%\System32\iologmsg.dll；%SystemRoot%\System32\Drivers\EventLog.sys”这样的数据串。IOLOGMSG.DLL包含所有标准NTSTATUS.H代码的文本。参看下面文字框以了解如何在安装驱动程序时自动设置这些注册表项。TypesSupported值只能是REG_DWORD类型，应该等于7，以指出驱动程序可以生成所以可能的事件，即错误、警告、普通消息。(实际上，指定这个值可能是由于某个人为的历史原因)

关于消息文件

关于把消息资源放到驱动程序中，有两个实际问题难于发现：如何使build脚本能编译消息，如何确信系统硬件安装程序把必要的表项放入了注册表。Art Baker的《The Windows NT Device Driver Book: A Guide for Programmers (Prentice Hall, 1997)》在第308页间接地给出了第一个问题的解决办法。而DDK中关于INF文件的讨论解释了如何用AddService语句解决第二个问题。

就象本书中的其它例子程序，EVENTLOG使用Visual C++ 6.0的工程文件。我修改了工程定义以包含EVENTLOG.MC的定制创建步骤。

在本书的后面章节中我将讨论如何用INF文件安装驱动程序。为了了解如何在一个INF文件中指定消息文件，你可以先看看EVENTLOG工程目录中的DEVICE.INF文件，特别是其中的AddService语句。你将看到AddService语句指向一个EventLogLogging段，而该段又用AddReg语句指向一个EventLogAddReg段。而这个段将把EventMessageFile和TypesSupported值加入到事件登记器的服务专用子键上。


I/O控制操作

如果你观察发往设备的各种类型的请求，你会发现大部分请求都是读写数据。然而应用程序有时候会需要设备执行IOCTL操作，应用程序使用标准Win32 API函数DeviceIoControl来执行这样的操作。在驱动程序一方，这个DeviceIoControl调用被转化成一个带有IRP_MJ_DEVICE_CONTROL功能码的IRP。

DeviceIoControl API

这个用户模式的DeviceIoControl API函数有下面原型：

result = DeviceIoControl(Handle,
             Code,
             InputData,
             InputLength,
             OutputData,
             OutputLength,
             &Feedback,
             &Overlapped);

Handle(HANDLE)是一个已打开的设备句柄。以下面这种方式调用CreateFile可以获得这个句柄：

Handle = CreateFile("\\\\.\\IOCTL",
            GENERIC_READ | GENERIC_WRITE,
            0,
            NULL,
            OPEN_EXISTING,
            flags,
            NULL);
if (Handle == INVALID_HANDLE_VALUE)
  <error>
...
CloseHandle(Handle);

CreateFile的flags参数可以为FILE_FLAG_OVERLAPPED或0，指出该文件句柄能否执行异步操作。句柄打开后，应用程序可以调用ReadFile、WriteFile、DeviceIoControl函数。完成设备的访问后，应用程序可以调用CloseHandle函数明确地关闭句柄。要记住，操作系统在进程终止后会自动关闭所有仍打开的句柄。

DeviceIoControl的Code(DWORD)参数是一个控制代码，它指出应用程序要执行何种控制操作。稍后我再讨论如何定义这些代码。InputData(PVOID)和InputLength(DWORD)参数描述了为驱动程序提供数据的缓冲区(以驱动程序的角度看，该区域为驱动程序提供输入数据)。OutputData(PVOID)和OutputLength(DWORD)参数描述了接受驱动程序输出数据的缓冲区。驱动程序会更新变量Feedback(DWORD)以指出它返回了多少字节的输出数据。图9-8显示了应用程序、驱动程序和这些缓冲区之间的关系。Overlapped(OVERLAPPED)结构用于帮助控制异步操作，详细描述见后面段。如果你在CreateFile调用中指定了FILE_FLAG_OVERLAPPED标志，那么你必须指定OVERLAPPED结构指针。如果没有指定FILE_FLAG_OVERLAPPED标志，你应该在这个最后的参数中给出NULL值。

图9-8. DeviceIoControl函数的输入输出缓冲区

一个控制操作是否需要输入输出缓冲区要取决于其执行的功能。例如，一个接收驱动程序版本号的IOCTL可能仅需要输出缓冲区。一个仅用于通知驱动程序属于应用程序的某些事实的IOCTL可能仅需要输入缓冲区。有些操作可能同时需要这两种缓冲区，这完全取决于控制操作的具体内容。

DeviceIoControl的返回值是Boolean值。如果返回FALSE，则应用程序可以调用GetLastError函数查找调用失败的原因。

DeviceIoControl的同步和异步调用方式

当以同步方式调用DeviceIoControl时，调用线程将被阻塞直到控制操作完成。例如：

HANDLE Handle = CreateFile("\\\\.\\IOCTL", ..., 0, NULL);
DWORD version, junk;
if (DeviceIoControl(Handle,
            IOCTL_GET_VERSION_BUFFERED,
            NULL,
            0,
            &version,
            sizeof(version),
            &junk,
            NULL))
  printf("IOCTL.SYS version %d.%2d\n", HIWORD(version), LOWORD(version));
else
  printf("Error %d in IOCTL_GET_VERSION_BUFFERED call\n", GetLastError());

这里，我们以不指定FILE_FLAG_OVERLAPPED标志的情况下打开设备句柄。因此，在这之后对DeviceIoControl的调用将不返回，直到驱动程序对我们的请求做出回答。

当以异步方式调用DeviceIoControl函数时，调用线程不立即阻塞。相反，它继续执行直到到达某一点，而该点的执行需要控制操作的结果。在这一点上，调用者调用某个阻塞线程的API函数，线程即被阻塞直到驱动程序完成该操作。例如：

HANDLE Handle = CreateFile("\\\\.\\IOCTL", ..., FILE_FLAG_OVERLAPPED, NULL);
DWORD version, junk;
OVERLAPPED Overlapped;

Overlapped.hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
DWORD code;

if (DeviceIoControl(Handle, ..., &Overlapped))
  code = 0;
else
  code = GetLastError();

<continue processing>

if (code == ERROR_IO_PENDING)
{
  if (GetOverlappedResult(Handle, &Overlapped, &junk, TRUE))
    code = 0;
  else
    code = GetLastError();
}
CloseHandle(Overlapped.hEvent);
if (code != 0)
  <error>

这个异步例子与前面的同步例子有两个主要的不同之处。第一，在调用CreateFile时指定了FILE_FLAG_OVERLAPPED标志。第二，对DeviceIoControl的调用中指定了一个OVERLAPPED结构的地址，在这个结构中有一个hEvent事件句柄，而我们把该事件初始化成一个手动重置事件。关于事件和线程同步的全面信息见Jeffrey Richter的《Programming Applications for Microsoft Windows, Fourth Edition [Microsoft Press, 1999]》

对DeviceIoControl的异步调用将产生三种结果。第一，它可能返回TRUE，代表设备驱动程序的派遣例程能够立刻完成请求。第二，它可能返回FALSE，并且用GetLastError会得到一个特殊的错误代码ERROR_IO_PENDING。这个结果指出驱动程序的派遣例程返回了STATUS_PENDING并且推迟完成这个控制操作。注意那个ERROR_IO_PENDING并不是一个真正的错误，这是系统表明执行正常的两种方式中的一种。第三个可能结果是返回值为FALSE，GetLastError得到的值不是ERROR_IO_PENDING。这样的结果才是一个真正的错误。

在应用程序需要这个控制操作的结果的那一点上，它调用一个Win32同步原语，如GetOverlappedResult、WaitForSingleObject、或其它相类似的函数。我在这个例子中使用的是GetOverlappedResult同步原语，这个函数使用起来特别方便，因为它同时还提供传输字节量值并设置GetLastError结果。虽然你可以调用WaitForSingleObject或其它相关的API函数，并把Overlapped.hEvent事件句柄作为一个参数传递过去，但这样你不能了解到DeviceIoControl的操作结果；你仅仅知道该操作已经完成。

定义I/O控制代码

DeviceIoControl的Code参数是一个32位数值常量，图9-9显示了操作系统分割这个32位代码的一种方式。

图9-9. I/O控制代码中的域

这些域的解释如下：

  • Device type(16位，CTL_CODE宏的第一个参数) 指出能实现这个控制操作的设备类型。我并不知晓Windows 98或Windows 2000中的任何“IOCTL police”，但是我认为该域的内容实际上是任意的。习惯上我们使用与驱动程序中调用IoCreateDevice时使用的同一个值(例如，FILE_DEVICE_UNKNOWN)。
  • 访问代码(“A”占2位，CTL_CODE的第四个参数) 指出使用设备句柄发出这个控制操作时，应用程序必须具有的访问权限。
  • Function code(12位，CTL_CODE的第二个参数) 指出该代码描述的是哪一个控制操作。Microsoft保留了该域的前半部分，从0到2047的值。因此我们只能使用从2048到4095之间的值。
  • 缓冲方式(“M”占两位，CTL_CODE的第三个参数) 指出I/O管理器如何处理应用程序提供的输入输出缓冲区。在后面段中当讲述如何实现IRP_MJ_DEVICE_CONTROL时，我会更详细地讲述这个域。

需要阐明一点，编写驱动程序时，你可以自由地设计一系列IOCTL操作，应用程序也可以使用这些IOCTL操作与你的驱动程序对话。虽然有时你定义的IOCTL操作在数值上与其它人定义的完全相同，但系统不会被这种重叠所迷惑，因为你定义的IOCTL代码仅被你的驱动程序解释。

通常，如果应用程序开发者需要调用你的驱动程序，简单的办法是把你所有的IOCTL定义都放到一个专用的头文件中。光盘上的每个例子驱动程序工程都有一个名为IOCTLS.H的头文件，如下例：

#ifndef CTL_CODE
  #pragma message ("CTL_CODE undefined. Include winioctl.h or wdm.h")
#endif

#define IOCTL_GET_VERSION_BUFFERED \
  CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS)
#define IOCTL_GET_VERSION_DIRECT \
  CTL_CODE(FILE_DEVICE_UNKNOWN, 0x801, METHOD_OUT_DIRECT, FILE_ANY_ACCESS)
#define IOCTL_GET_VERSION_NEITHER \
  CTL_CODE(FILE_DEVICE_UNKNOWN, 0x802, METHOD_NEITHER, FILE_ANY_ACCESS)

#pragma语句指出未找到CTL_CODE定义时的警告信息，应该包含winioctl.h或wdm.h头文件。

处理IRP_MJ_DEVICE_CONTROL

在用户模式中对DeviceIoControl的调用将使I/O管理器创建一个带有IRP_MJ_DEVICE_CONTROL主功能码的IRP，并把该IRP发送到设备堆栈最上层驱动程序的派遣例程。最上层的堆栈单元包含表9-1中列出的参数。过滤器驱动程序会解释某些它们自己的代码，然后把其它IRP下传。一个了解如何处理IOCTL的派遣函数将驻留在驱动程序堆栈的某个地方，最可能是在功能驱动程序中。

表9-1. IRP_MJ_DEVICE_CONTROL的堆栈单元参数

Parameters.DeviceIoControl域 | 描述
OutputBufferLength | 输出缓冲区的长，DeviceIoControl的第六个参数
InputBufferLength | 输入缓冲区的长，DeviceIoControl的第四个参数
IoControlCode | 控制代码，DeviceIoControl的第二个参数
Type3InputBuffer | 输入缓冲区的用户模式虚拟地址，METHOD_NEITHER模式

控制操作的派遣函数的基本结构如下：

#pragma PAGEDCODE

NTSTATUS DispatchControl(PDEVICE_OBJECT fdo, PIRP Irp)
{
  PAGED_CODE();											<--1
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  NTSTATUS status = IoAcquireRemoveLock(&pdx->RemoveLock, Irp);					<--2
  if (!NT_SUCCESS(status))
    return CompleteRequest(Irp, status, 0);
  ULONG info = 0;

  PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);					<--3
  ULONG cbin = stack->Parameters.DeviceIoControl.InputBufferLength;
  ULONG cbout = stack->Parameters.DeviceIoControl.OutputBufferLength;
  ULONG code = stack->Parameters.DeviceIoControl.IoControlCode;

  switch (code)
  {

    ...												<--4

  default:											<--5
    status = STATUS_INVALID_DEVICE_REQUEST;
    break;

  }

  IoReleaseRemoveLock(&pdx->RemoveLock, Irp);
  return CompleteRequest(Irp, status, info);
}
  1. 你将在PASSIVE_LEVEL级上被调用，因此这个派遣函数可以在分页内存中。
  2. 象其它派遣函数一样，该派遣函数在工作时需要一个删除锁。它可以防止设备对象由于一个PnP事件而意外消失。
  3. 下几行语句从I/O堆栈的parameters联合中提取功能码和缓冲区大小值。不管处理哪个IOCTL，你都会需要这些值，所以我总是在函数的前面加入这些语句。
  4. 在这里你可以对你支持的每个IOCTL加入一个case语句。
  5. 如果你得到了一个你不认识的IOCTL操作，返回一个有意义的状态代码。

处理每个IOCTL需要依靠两个因素。第一，也是最重要的，是该IOCTL的真正目的。第二，是你选择的缓冲用户模式数据的方式。

在第七章中，我讨论了如何与用户模式程序交换数据。那时我指出，当读写请求到来时，你必须记住在AddDevice中指出的访问用户模式缓冲区所使用的模式，是buffered模式还是direct模式(或者两者都不是)。控制请求也利用这些寻址方式，但有一些差异。不是用设备对象中的标志来指定全局寻址方式，而是用IOCTL中的功能码的低两位来为每个IOCTL指定寻址方式。因此，有些IOCTL使用buffered方式，而有些IOCTL使用direct方式，还有些IOCTL使用neither方式。此外，IOCTL中指定的缓冲方式并不影响普通读写IRP的寻址缓冲区。

选择哪个缓冲方式基于下面几个因素。大部分IOCTL操作传输的数据少于一个页，应该使用METHOD_BUFFERED方式。传输超过一个页数据的操作应该使用direct方式。如果你知道你将在应用程序的线程中获得控制，这对于IOCTL通常是对的，因为你上面没有过滤器驱动程序需要挂起，你可以使用METHOD_NEITHER方式任意访问用户模式数据。

BUFFERED模式

使用METHOD_BUFFERED方式时，I/O管理器创建一个足够大的内核模式拷贝缓冲区(与用户模式输入和输出缓冲区中最大的容量相同)。当派遣例程获得控制时，用户模式的输入数据被复制到这个拷贝缓冲区。在IRP完成之前，你应该向拷贝缓冲区填入需要发往应用程序的输出数据。当IRP完成时，你应该设置IoStatus.Information域等于放入拷贝缓冲区中的输出字节数。然后I/O管理器把数据复制到用户模式缓冲区并设置反馈变量。图9-10显示了这些复制操作。

图9-10. METHOD_BUFFERED方式的缓冲区管理

在驱动程序内部，你以同一个地址访问这两个缓冲区，即IRP中的AssociatedIrp.SystemBuffer指针。这是一个内核模式虚拟地址，指向输入数据的一份拷贝。很明显，你应该在完成输入数据的处理之后再向这个缓冲区写入输出数据。

这里有一个简单的例子，是IOCTL例子中用于处理一个METHOD_BUFFERED操作的代码：

case IOCTL_GET_VERSION_BUFFERED:
{
  if (cbout < sizeof(ULONG))
  {
    status = STATUS_INVALID_BUFFER_SIZE;
    break;
  }
  PULONG pversion = (PULONG) Irp->AssociatedIrp.SystemBuffer;
  *pversion = 0x0004000A;
  info = sizeof(ULONG);
  break;
}

我们首先要确认得到的输出缓冲区的长度至少能容纳一个双字。然后我们使用SystemBuffer指针寻址系统拷贝缓冲区，在系统拷贝缓冲区中我们存入这个简单操作的结果。当外围的派遣例程完成这个IRP时，局部变量info中的值将写入IoStatus.Information域。I/O管理器将把系统拷贝缓冲区中的数据复制回用户模式输出缓冲区。

一个安全漏洞？

我总是对IOCTL功能代码中buffering和access-control的重要性感到不安。假定某个有恶意的应用程序在提交一个IOCTL时使用了另外的标志值。这会造成驱动程序失败或其它不应该发生的事情吗？通常是不会的。

大部分IOCTL请求的派遣函数带有一个switch语句。case语句所指定的数值常量必须与应用程序提供的32位代码精确匹配。所以，如果应用程序改变了某个IOCTL中的任何位，驱动程序中就没有与之匹配的case语句，那么某个默认动作将发生。

DIRECT模式

在驱动程序中，METHOD_IN_DIRECT和METHOD_OUT_DIRECT模式都以相同方式处理。仅有的不同是它们访问用户模式缓冲区时所需的访问权限；METHOD_IN_DIRECT需要读权限；METHOD_OUT_DIRECT既需要读权限又需要写权限。使用这两种模式时，I/O管理器会为输入数据提供一个内核模式拷贝缓冲区(AssociatedIrp.SystemBuffer)，为输出数据缓冲区提供一个MDL。MDL细节见第七章，图9-11描述了这种缓冲区管理方法。

图9-11. METHOD_XXX_DIRECT方式的缓冲区管理

这里有一个处理METHOD_XXX_DIRECT请求的简单例子：

case IOCTL_GET_VERSION_DIRECT:
{
  if (cbout < sizeof(ULONG))
  {
    status = STATUS_INVALID_BUFFER_SIZE;
    break;
  }
  PULONG pversion = (PULONG) MmGetSystemAddressForMdl(Irp->MdlAddress);
  *pversion = 0x0004000B;
  info = sizeof(ULONG);
  break;
}

这个例子与前一个例子的实质性区别是粗体字部分(我还改变了返回的版本号，以便能方便地知道我正从test程序调用正确的IOCTL)。对于任何一种DIRECT模式的请求，我们使用MDL(由IRP中的MdlAddress域指向)来访问用户模式输出缓冲区。你可以用这个地址来做DMA传输。在这个例子中，我仅调用了MmGetSystemAddressForMdl函数来获得一个内核模式别名地址，该地址指向由MDL描述的物理内存。

NEITHER模式

使用NEITHER模式时，I/O管理器不翻译用户模式的虚拟地址。你得到输入缓冲区的用户模式虚拟地址(在堆栈单元的Type3InputBuffer参数中)，和输出缓冲区的用户模式虚拟地址(在IRP的UserBuffer域)。除非你知道你与用户模式调用者运行在同一个进程上下文中，否则这些地址是无用的。如果你恰好知道你正运行在这样的进程上下文中，你可以直接使用这些指针：

case IOCTL_GET_VERSION_NEITHER:
{
  if (cbout < sizeof(ULONG))
  {
    status = STATUS_INVALID_BUFFER_SIZE;
    break;
  }
  PULONG pversion = (PULONG) Irp->UserBuffer;
  if (Irp->RequestorMode != KernelMode)
  {
    _ _try
    {
      ProbeForWrite(pversion, sizeof(ULONG), 1);
      *pversion = 0x0004000A;
    }
    _ _except(EXCEPTION_EXECUTE_HANDLER)
    {
      status = GetExceptionCode();
      break;
    }
  }
  else
     *pversion = 0x0004000A;
  info = sizeof(ULONG);
  break;
}

如上面粗体部分代码所示，这里唯一的毛病是你希望对这个缓冲区的写操作是正确的，而这个缓冲区来自一个未被确认的源。ProbeForWrite是一个标准的内核模式服务例程，用于测试一个给定的用户模式虚拟地址是否可写。第二个参数指出你要探测的数据区域的长度，第三个参数指出数据区域需要的对齐值。在这个例子中，我们希望能正确地写入四个字节，我们愿意忍受数据区域本身的单字节对齐要求。ProbeForWrite(或ProbeForRead)实际上是测试给定地址范围是否有正确的对齐值，以及该地址空间中的用户模式部分，它并没有真的写或读被询问的内存。

你不要用我讲的这些方式直接访问用户模式内存，因为在同一个进程中的其它线程会在ProbeForXxx调用和你访问实际内存之间调用VirtualFree函数释放掉这段内存。所以你应该总是创建一个MDL并调用MmGetSystemAddressForMdl来获得一个安全的虚拟地址。实际上，更安全的办法是直接访问用户模式指针，但必须满足三个条件：第一，你必须运行在缓冲区所属于的进程上下文中。第二，你必须完成一次对ProbeForXxx的调用，第三，你必须在结构化异常帧中执行内存访问。如果在内存访问时缓冲区的某部分仍属于不存在的页上，则内存管理器将产生一个异常而不是bug-check。你的异常句柄将捕获这个异常并阻止系统崩溃。

内部I/O控制操作

系统使用IRP_MJ_DEVICE_CONTROL来实现用户模式的DeviceIoControl调用。有时驱动程序需要相互交谈，它们可以使用IRP_MJ_INTERNAL_DEVICE_CONTROL。下面是一个典型的例子代码：

ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL);
KEVENT event;
KeInitializeEvent(&event, NotificationEvent, FALSE);
IO_STATUS_BLOCK iostatus;
PIRP Irp = IoBuildDeviceIoControlRequest(IoControlCode,
                     DeviceObject,
                     pInBuffer,
                     cbInBuffer,
                     pOutBuffer,
                     cbOutBuffer,
                     TRUE,
                     &event,
                     &iostatus);
if (IoCallDriver(DeviceObject, Irp) == STATUS_PENDING)
  KeWaitForSingleObject(&event, Executive, KernelMode, FALSE, NULL);

必须在PASSIVE_LEVEL级上调用KeInitializeEvent和IoBuildDeviceIoControlRequest函数，同样，在事件对象上阻塞也需要在PASSIVE_LEVEL级。

IoBuildDeviceIoControlRequest的IoControlCode参数是一个控制代码，它代表你要求目标设备驱动程序执行的操作。这个代码与常规控制操作中使用的是同一种代码。DeviceObject指向一个设备对象，该设备对象的驱动程序将执行指定的操作。输入和输出缓冲区参数与用户模式DeviceIoControl调用中对应的参数含义相同。第七个参数，我在这里把它指定为TRUE，它指出你创建的是一个内部控制操作。(创建IRP_MJ_DEVICE_CONTROL时该参数应为FALSE)

IoBuildDeviceIoControlRequest将创建一个IRP并用操作代码和缓冲区初始化该IRP的第一个堆栈单元。它返回该IRP的指针，以便你能做额外的初始化工作。例如，在第十一章中我将讲述如何使用内部控制请求向USB总线驱动程序提交URB。该过程的一部分就是设置堆栈参数域，使其指向URB。然后调用IoCallDriver来发送该IRP到达目标设备。无论返回什么值，你都将等待在IoBuildDeviceIoControlRequest函数的第八个参数指定的event对象上。当该IRP完成时I/O管理器将设置这个事件，并用status和information的最后值填充你的iostatus结构。最后I/O管理器将调用IoFreeIrp释放该IRP。因此，在调用IoCallDriver之后你不应该访问这个IRP指针。

因为内部控制操作需要在两个驱动程序之间协作，所以关于发送它们的规则较少。你不必使用IoBuildDeviceIoControlRequest创建这种请求，例如，你可以仅仅调用IoAllocateIrp并执行自己的初始化。如果目标驱动程序不希望在PASSIVE_LEVEL级上处理内部控制操作，你还可以在DISPATCH_LEVEL级上发送这样的IRP，即从I/O完成例程或从DPC例程中发出。(当然，在这种情况下你不能使用IoBuildDeviceIoControlRequest，并且你也不能等待该IRP的完成。但你可以发送它，因为IoAllocateIrp和IoCallDriver可以运行在DISPATCH_LEVEL级上) 你甚至不必象常规IOCTL那样准确地使用I/O堆栈的各parameter域。实际上，在调用USB总线驱动程序中用于保存URB指针的域就是输出缓冲区长度域。所以，如果你正为你自己的两个驱动程序设计一个内部控制协议，可以把IRP_MJ_INTERNAL_DEVICE_CONTROL看做是你要发的各种消息的信封。

内部和外部控制操作使用同一个派遣例程不是一个好的想法，这里有一个例子解释了其原因。假定你的驱动程序有一个外部控制接口，它允许应用程序查询驱动程序的版本号，还有一个内部控制接口，它允许被信任的内核模式调用者获得某些关键信息，这些信息不希望被用户模式应用程序共享。然后假设你使用一个例程同时处理这两个接口，下面是该例子的代码：

NTSTATUS DriverEntry(...)
{
  DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = DispatchControl;
  DriverObject->MajorFunction[IRP_MJ_INTERNAL_DEVICE_CONTROL] = DispatchControl;
  ...
}

NTSTATUS DispatchControl(...)
{
  ...
  switch (code)
  {
  case IOCTL_GET_VERSION:
    ...
  case IOCTL_INTERNAL_GET_SECRET:
    ...           //  exposed for user-mode calls
  }
}

如果有个应用程序知道了IOCTL_INTERNAL_GET_SECRET的数值，那么它就能发出一个常规的DeviceIoControl调用并越过该函数安全检查。

应用程序关注事件的通知

IOCTL操作的一个极其重要的用途就是为WDM驱动程序提供了一种方法来通知应用程序其关注事件的发生。为了引发这个讨论，假定你有一个需要与驱动程序紧密合作的应用程序，当某种硬件事件发生时，驱动程序就通知应用程序以便其作出某种用户可见的动作。例如，当按下医疗设备上的某个按钮时将触发应用程序收集和显示数据。对于这种情况，Windows为驱动程序通知应用程序提供了两种方法(异步过程调用或发送window消息)，但这些方法不能在Windows 2000中使用(缺乏必要的底层支持)，一种可行的方法是使应用程序发出IOCTL操作，而驱动程序在被关注事件发生时完成该操作。实现这个方法需要格外小心，我将详细解释这个方法。

当应用程序想从驱动程序接收事件通知时，应该调用DeviceIoControl：

HANDLE hDevice = CreateFile("\\\\.\\<driver-name>", ...);
BOOL okay = DeviceIoControl(hDevice, IOCTL_WAIT_NOTIFY, ...);

(IOCTL_WAIT_NOTIFY是我用在NOTIFY例子中的控制代码)

驱动程序将挂起这个IOCTL并在以后完成。如果没有“其它考虑”，实际代码就象下面这样简单：

NTSTATUS DispatchControl(...)
{
  ...
  switch (code)
  {
  case IOCTL_WAIT_NOTIFY:
    pdx->NotifyIrp = Irp;
    IoMarkIrpPending(Irp);
    return STATUS_PENDING;
    ...
  }
}

VOID OnInterestingEvent(...)
{
  ...
  CompleteRequest(pdx->NotifyIrp, STATUS_SUCCESS, 0);
}
使用事件通知应用程序

有时驱动程序所要做的全部就是通知应用程序事件已经发生，不必向应用程序传递任何解释性的数据。能做到这一点的一个标准技术是让驱动程序发出一个普通的Win32事件信号。为此，应用程序首先要调用CreateEvent或OpenEvent打开一个事件对象句柄，然后通过调用DeviceIoControl把该事件句柄传递给驱动程序。驱动程序通过下面函数可以把该用户模式句柄转换成一个对象指针：

PKEVENT pEvent;
status = ObReferenceObjectByHandle(hEvent,
                   EVENT_MODIFY_STATE,
                   *ExEventObjectType,
                   Irp->RequestorMode,
                   (PVOID*) &pEvent,
                   NULL);

注意该IOCTL必须在PASSIVE_LEVEL级上处理，并且在拥有hEvent句柄的进程上下文中。

在此，驱动程序有了一个KEVENT对象的指针，它可以在适当时候作为调用KeSetEvent的参数。该驱动程序还拥有对该事件对象的参考计数，因此它必须在某一点上调用ObDereferenceObject。取消对象参考的恰当时间取决于应用程序与驱动程序的合作方式。比较好的办法是作为IRP_MJ_CLOSE处理的一部分，光盘上的EVWAIT例子演示了这种使用方法。

内核服务例程IoCreateNotificationEvent和IoCreateSynchronizationEvent创建的事件对象可以被用户模式应用程序所共享。但它们在Windows 98中无效，因此对真正的WDM驱动程序也无效。

我曾提到的“其它考虑”对于打造一个正常工作的驱动程序十分重要。IRP的创造者可以取消IRP。应用程序调用CancelIo或应用程序线程的终止也可以导致某个内核模式部件调用IoCancelIrp。在任何一种情况下，我们都必须提供一个取消例程以便该IRP能够完成。如果设备失去电源，或者设备突然被从计算机上摘除，我们需要终止任何等待的IOCTL请求。通常，需要终止的IOCTL数量是不可以预测的。因此我们需要一个链表，由于有多个线程会访问这个链表，所以我们还需要一个自旋锁以使访问安全。

使用异步IOCTL

为了方便，我写了一组管理异步IOCTL的辅助函数。其中两个最重要的是CacheControlRequest和UncacheControlRequest。它们假定每个设备对象一次仅能接受一个带有特殊控制代码的异步IOCTL。因此，你可以在设备扩展中保留一个指向当前等待IRP的指针域。在NOTIFY例子中，我称这个指针域为NotifyIrp。你应该以下面方式接收异步IRP：

IoAcquireRemoveLock(...);
switch (code)
{
case IOCTL_WAIT_NOTIFY:
  if (<parameters invalid in some way>)
    status = STATUS_INVALID_PARAMETER;
  else
   status = CacheControlRequest(pdx, Irp, &pdx->NotifyIrp);
  break;
}

IoReleaseRemoveLock(...);
return status == STATUS_PENDING ? status : CompleteRequest(Irp, status, info);

这里重要的语句是CacheControlRequest调用，如果必要，它寄存该IRP以便我们以后能够取消它。它还在设备扩展的NotifyIrp成员中记录下该IRP的地址。我们希望它返回STATUS_PENDING，这样我们可以避免完成该IRP并简单地向我们的调用者返回STATUS_PENDING。

注意
你可以容易地归纳出这个方案，允许应用程序为每个打开的句柄分配一个等待类型的IRP。除了把当前IRP指针放到设备扩展中，你还可以把它放到与FILE_OBJECT关联的结构中，而FILE_OBJECT与句柄对应。你可以在IRP_MJ_CREATE、IRP_MJ_CLOSE(实际上是所有为该文件句柄生成的IRP中)的I/O堆栈单元中得到该FILE_OBJECT的指针。你还可以任意的使用该文件对象中的FsContext或FsContext2域。

之后，如果有应用程序期待的事件发生，则执行下面代码：

PIRP nfyirp = UncacheControlRequest(pdx, &pdx->NotifyIrp);
if (nfyirp)
{
  <do something>
  CompleteRequest(nfyirp, STATUS_SUCCESS, <info value>);
}

这些代码提取未决IOCTL_WAIT_NOTIFY请求的地址，向应用程序返回数据，然后完成这个未决的IRP。

辅助例程的工作原理

我在CacheControlRequest和UncacheControlRequest函数中隐藏了大量的复杂代码。这两个函数实现了线程安全和多处理器安全机制，可以跟踪异步IOCTL请求。它们使用了与排队和出队IRP时为防止其它人取消该IRP所使用的类似的技术。下面是一些特别的代码(参见NOTIFY例子中的CONTROL.CPP)：

typedef struct _DEVICE_EXTENSION {
  KSPIN_LOCK IoctlListLock;
  LIST_ENTRY PendingIoctlList;
} DEVICE_EXTENSION, *PDEVICE_EXTENSION;

NTSTATUS CacheControlRequest(PDEVICE_EXTENSION pdx, PIRP Irp, PIRP* pIrp)
{
  KIRQL oldirql;
  KeAcquireSpinLock(&pdx->IoctlListLock, &oldirql);							<--1
  NTSTATUS status;
  if (*pIrp)												<--2
    status = STATUS_UNSUCCESSFUL;
  else if (pdx->IoctlAbortStatus)									<--3
    status = pdx->IoctlAbortStatus;
  else
  {
    IoSetCancelRoutine(Irp, OnCancelPendingIoctl);							<--4
    if (Irp->Cancel && IoSetCancelRoutine(Irp, NULL))
      status = STATUS_CANCELLED;
    else
    {
      IoMarkIrpPending(Irp);										<--5
      status = STATUS_PENDING;
      PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);					<--6
      stack->Parameters.Others.Argument1 = (PVOID) *pIrp;
      IoSetCompletionRoutine(Irp,
                 (PIO_COMPLETION_ROUTINE) OnCompletePendingIoctl,
                 (PVOID) pdx,
                 TRUE,
                 TRUE,
                 TRUE);
      PFILE_OBJECT fop = stack->FileObject;
      IoSetNextIrpStackLocation(Irp);									<--7
      stack = IoGetCurrentIrpStackLocation(Irp);
      stack->DeviceObject = pdx->DeviceObject;
      stack->FileObject = fop;

      *pIrp = Irp;
      InsertTailList(&pdx->PendingIoctlList, &Irp->Tail.Overlay.ListEntry);
    }
  }
  KeReleaseSpinLock(&pdx->IoctlListLock, oldirql);
  return status;
}

VOID OnCancelPendingIoctl(PDEVICE_OBJECT fdo, PIRP Irp)
{
  KIRQL oldirql = Irp->CancelIrql;
  IoReleaseCancelSpinLock(DISPATCH_LEVEL);
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  KeAcquireSpinLockAtDpcLevel(&pdx->IoctlListLock);
  RemoveEntryList(&Irp->Tail.Overlay.ListEntry);
  KeReleaseSpinLock(&pdx->IoctlListLock, oldirql);
  Irp->IoStatus.Status = STATUS_CANCELLED;
  IoCompleteRequest(Irp, IO_NO_INCREMENT);
}

NTSTATUS OnCompletePendingIoctl(PDEVICE_OBJECT junk, PIRP Irp, PDEVICE_EXTENSION pdx)
{
  KIRQL oldirql;
  KeAcquireSpinLock(&pdx->IoctlListLock, &oldirql);
  PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
  PIRP* pIrp = (PIRP*) stack->Parameters.Others.Argument1;
  if (*pIrp == Irp)											<--8
    *pIrp = NULL;
  KeReleaseSpinLock(&pdx->IoctlListLock, oldirql);
  return STATUS_SUCCESS;
}

PIRP UncacheControlRequest(PDEVICE_EXTENSION pdx, PIRP* pIrp)
{
  KIRQL oldirql;
  KeAcquireSpinLock(&pdx->IoctlListLock, &oldirql);
  PIRP Irp = (PIRP) InterlockedExchangePointer(pIrp, NULL);						<--9
  if (Irp)
  {
    if (IoSetCancelRoutine(Irp, NULL))									<--10
    {
      RemoveEntryList(&Irp->Tail.Overlay.ListEntry);
    }
    else
      Irp = NULL;
  }
  KeReleaseSpinLock(&pdx->IoctlListLock, oldirql);
  return Irp;
}
  1. 我们使用一个自旋锁来保护未决IOCTL链表，同时也用于保护所有指向不同种类异步IOCTL请求的当前实例的保留指针单元。
  2. 这里就是那个强制规则，其实这更是一个设计决策，即一次仅能有每种类型IRP的一个实例可以等待。
  3. if语句表明这个事实，由于电源或PnP事件，我们可能在某个点上需要启动失败的IRP。
  4. 由于我们可能长时间挂起这个IRP，所以我们需要为它提供一个取消例程。
  5. 这里，我们决定前进并隐藏这个IRP以便我们能在以后完成它。因为我们最后要从DispatchControl函数返回STATUS_PENDING，所以需要调用IoMarkIrpPending。
  6. 当取消IRP时，我们需要用某种方法置空隐藏指针单元。向一个取消例程输送上下文参数十分困难，所以我决定建立一个I/O完成例程。我用堆栈单元中的Parameters.Others.Argument1来记录这个隐藏指针单元。
  7. 为了我们刚刚安装并将要调用的完成例程，我们必须调用IoSetNextIrpStackLocation函数前进I/O堆栈指针。在这个特定驱动程序中，我们知道这里必定有一个或更多的堆栈单元供我们使用，因为我们的AddDevice函数在发现我们下面没有一个驱动程序对象时会失败。后面例程需要的设备和文件对象指针来自下一个当前堆栈单元，所以我们还需要初始化它们。
  8. 这个语句安装完成例程。如果IRP被取消了，我们最后会置空隐藏指针。
  9. 在事件处理的正常过程中，这个语句揭示一个IRP。
  10. 现在我们已经揭示我们的IRP，我们不希望它被取消。如果IoSetCancelRoutine返回NULL，我们知道该IRP当前正在被取消。在这种情况下我们返回一个空IRP指针。

NOTIFY还有一个用于处理未决IOCTL的IRP_MJ_CLEANUP例程，该例程看起来与处理读写操作的清除例程相同。最后它包含一个AbortPendingIoctls辅助函数用于处理设备掉电或设备突然消失的特殊情况，如下：

VOID AbortPendingIoctls(PDEVICE_EXTENSION pdx, NTSTATUS status)
{
  InterlockedExchange(&pdx->IoctlAbortStatus, status);
  CleanupControlRequests(pdx, status, NULL);
}

CleanupControlRequests是IRP_MJ_CLEANUP的处理例程。如果该函数的第三个参数(通常是一个文件对象指针)为空则取消所有未决的IRP。

对比真正的驱动程序，NOTIFY有点太简单。下面是一些额外的考虑，你可以考虑用于你自己的设计中。

  • 一个驱动程序可以有多种能触发通知的事件类型。你可以仅用一个IOCTL代码来对应这些事件类型，但这样需要你用输出数据来指出事件类型。另一种选择是使用多个IOCTL代码。
  • 你可以允许多线程寄存事件。但这样你就不能在设备扩展中仅保存一个IRP指针，你需要一种方法来跟踪某种事件类型的所有IRP。如果对于所有通知，你仅有一个IOCTL类型，那么跟踪IRP的一种方法是依靠PendingIoctlList。然后，当一个事件发生时，执行一个循环，循环调用ExInterlockedRemoveHeadList和IoCompleteRequest来清空未决链表。(由于我强制一次只运行测试程序的一个实例，所以在NOTIFY中我避免了这个复杂性)
  • 你的IOCTL派遣例程可能与生成事件的活动相竞争。例如，在第十一章的USBINT例子中，IOCTL派遣例程和服务USB设备上某中断端点的假中断例程之间存在着一个潜在的竞争。为了避免丢失事件或产生不协调动作，你需要一个自旋锁。如何恰当使用这个自旋锁请参见光盘上的USBINT例子。(在NOTIFY中同步不是一个问题，因为当操作者执行释放事件信号的按键动作时，通知请求几乎总是挂起的。如果不是，通知请求失败)
关于NOTIFY例子的更多内容

NOTIFY由一个WDM设备驱动程序(位于SYS子目录)和一个控制台模式测试程序(在TEST子目录)组成。你可以使用添加新硬件向导或FASTINST工具安装这个驱动程序。然后运行测试程序。该程序将产生一个单独的线程并发出IOCTL_WAIT_NOTIFICATION请求。然后该测试程序提示你按任意键或按Ctrl+Break退出测试程序。如果你按了一个键，则测试程序发出一个IOCTL_GENERATE_EVENT，输入数据就是这次按键的扫描码。然后驱动程序存储这个扫描码(作为输出数据)，之后完成这个未决的通知IRP。如果你按下了Ctrl+Break，I/O管理器将取消正等待的通知IRP。


系统线程

到现在为止，本书的内容还未十分深入地涉及驱动程序例程所处于的线程上下文。我们的子例程大部分时间是运行在任意线程上下文中，这意味着我们不能阻塞该线程，也不能直接访问用户模式虚拟内存。这些限制使一些设备特别难于编程。

有些设备的最佳处理方式是循检。例如，不能异步中断CPU的设备就需要不断地检测其状态。另一些设备由于自身的特点，需要把一个操作分成多个步骤来执行，并且这些步骤间需要插入等待。例如，一个软盘驱动程序需要一系列步骤才能完成一个操作。通常，该驱动程序必须先命令软盘驱动器马达转到一定速度，等待马达转速稳定、然后开始传输、接着再等待一会、最后使驱动器马达停转。你可以设计这样一个驱动程序，其操作象一个有限状态机，使用回调函数来顺序化操作步骤。但在一个直线式程序的适当地方插入事件和计数器等待也可以使处理更容易一些。

用属于驱动程序的系统线程来处理需要定期查询的设备会更容易一些。系统线程是一个完全操作在整个操作系统进程的保护之下的线程。我仅讨论执行在内核模式中的系统线程。在下一段，我将描述创建和销毁系统线程。然后我将给出一个例子，该例子演示了如何用系统线程管理使用循检方式的输入设备。

系统线程的创建与终止

调用PsCreateSystemThread可以创建一个系统线程。该函数的一个参数是“线程过程(thread procedure)”的地址，该线程过程是新线程的主程序。当线程过程要终止其线程时，它调用PsTerminateSystemThread函数，该函数不返回。一般，你需要为PnP事件提供一种方式来通知线程终止并等待终止发生。综合所有这些因素，最后你将得到这三个子例程：

typedef struct _DEVICE_EXTENSION {
  ...
  KEVENT evKill;
  PKTHREAD thread;
};

NTSTATUS StartThread(PDEVICE_EXTENSION pdx)
{
  NTSTATUS status;
  HANDLE hthread;
  KeInitializeEvent(&pdx->evKill, NotificationEvent, FALSE);						<--1
  status = PsCreateSystemThread(&hthread,								<--2
                THREAD_ALL_ACCESS,
                NULL,
                NULL,
                NULL,
                (PKSTART_ROUTINE) ThreadProc,
                pdx);
  if (!NT_SUCCESS(status))
    return status;
  ObReferenceObjectByHandle(hthread,									<--3
                THREAD_ALL_ACCESS,
                NULL,
                KernelMode,
                (PVOID*) &pdx->thread,
                NULL);
  ZwClose(hthread);											<--4
  return STATUS_SUCCESS;
}

VOID StopThread(PDEVICE_EXTENSION pdx)
{
  KeSetEvent(&pdx->evKill, 0, FALSE);									<--5
  KeWaitForSingleObject(pdx->thread, Executive, KernelMode, FALSE, NULL);				<--6
  ObDereferenceObject(pdx->thread);									<--7
}

VOID ThreadProc(PDEVICE_EXTENSION pdx)
{
  ...
  KeWaitForXxx(<at least pdx->evKill>);									<--8
  ...
  PsTerminateSystemThread(STATUS_SUCCESS);								<--9
}
  1. 在设备扩展中声明一个名为evKill的KEVENT对象，这样，PnP事件就能通知线程终止。现在是初始化该事件对象的恰当时间。
  2. 该语句产生新线程。调用成功的结果是返回一个线程句柄，该句柄返回到函数第一个参数指定的变量中。第二个参数指出你为该线程请求的访问权限；在这里THREAD_ALL_ACCESS是一个合适的值。下三个参数属于该线程的用户模式进程部分，在WDM驱动程序中调用该函数时这些参数应该为NULL。接下来的参数(ThreadProc)为该线程指出主程序。最后一个参数(pdx)是一个上下文参数，它是线程过程仅有的一个参数。
  3. 为了等待线程终止，你需要KTHREAD对象的地址来代替从PsCreateSystemThread获得的线程句柄。调用ObReferenceObjectByHandle获得这个地址。
  4. 实际上，一旦我们得到KTHREAD的地址我们就不再需要线程句柄了，所以我们调用ZwClose关闭这个句柄。
  5. StopDevice例程在我们的驱动程序模块化设计方案中执行IRP_MN_STOP_DEVICE的设备相关部分，它调用StopThread停止该系统线程。第一步是设置evKill事件。
  6. 该调用显示了如何等待线程结束。一个内核线程对象也是一个可以等待的同步对象。当线程最后结束时，它被假定为信号态。在Windows 2000中，你应该总执行这个等待以避免当你的某个系统线程执行在其关闭处理的最后几条指令时你的驱动程序映像被解除映射。即不要仅仅等待一个特殊的“kill acknowledgment”事件，而该事件就在线程退出前被设置。在驱动程序安全卸载前线程必须执行PsTerminateSystemThread。参见本章后的Windows 98兼容问题(等待系统线程结束)。
  7. 该ObDereferenceObject调用与创建该线程时的ObReferenceObjectByHandle调用相对应。有必要使对象管理器释放由KTHREAD对象占用的内存。
  8. 线程过程中的代码逻辑取决于你要完成的实际目标。如果你为等待某个外部事件而阻塞，你应该调用KeWaitForMultipleObjects并指定evKill事件为其中一个对象。
  9. 当你检测到evKill已经进入信号态时，调用PsTerminateSystemThread函数，该函数终止这个线程，但不返回。注意除非你在该线程本身的上下文中调用该函数，否则不能终止这个系统线程。

用系统线程循检设备

如果你不得不为一个不能中断CPU的设备写驱动程序，一个系统线程可以专门用于循检设备。我将给你阐述一个用于这种目的的系统线程使用方法。这个例子基于一个有两个输入端口的假想设备。一个端口作为控制端口；当无输入数据时该端口可读出字节值0，当有输入数据时该端口可读出字节值1。从另一个端口读出一个单字节数据并重置控制端口。

在这个例子中，我们在处理IRP_MN_START_DEVICE请求时创建系统线程。当收到一个PnP请求IRP_MN_STOP_DEVICE或IRP_MN_REMOVE_DEVICE时终止该线程。该线程大部分时间处于阻塞状态。当StartIo例程开始处理IRP_MJ_READ请求时，它设置一个事件，循检线程就在这个事件上等待。循检线程然后进入服务该请求的循环。在这个循环中，循检线程首先阻塞一个固定循检间隔时间。间隔时间一到，该线程就读控制端口。如果读出的值是1，该线程就读一字节数据。然后线程重复这个循环直到请求被满足，最后它返回睡眠状态直到StartIo收到另一个请求。

POLLING例子的线程例程如下：

VOID PollingThreadRoutine(PDEVICE_EXTENSION pdx)
{
  NTSTATUS status;
  KTIMER timer;
  KeInitializeTimerEx(&timer, SynchronizationTimer);							<--1

  PVOID mainevents[] = {										<--2
    (PVOID) &pdx->evKill,
    (PVOID) &pdx->evRequest,
  };

  PVOID pollevents[] = {
    (PVOID) &pdx->evKill,
    (PVOID) &timer,
  };

  ASSERT(arraysize(mainevents) <= THREAD_WAIT_OBJECTS);
  ASSERT(arraysize(pollevents) <= THREAD_WAIT_OBJECTS);

  BOOLEAN kill = FALSE;

  while (!kill)												<--3
  {    // until told to quit
    status = KeWaitForMultipleObjects(arraysize(mainevents),						<--4
                      mainevents,
                      WaitAny,
                      Executive,
                      KernelMode,
                      FALSE,
                      NULL,
                      NULL);
    if (!NT_SUCCESS(status) || status == STATUS_WAIT_0)
      break;
    ULONG numxfer = 0;
    LARGE_INTEGER duetime = {0};
    #define POLLING_INTERVAL 500
    KeSetTimerEx(&timer, duetime, POLLING_INTERVAL, NULL);						<--5

    PIRP Irp = GetCurrentIrp(&pdx->dqReadWrite);

    while (TRUE)											<--6
    {    // read next byte
      if (Irp->Cancel)											<--7
      {
        status = STATUS_CANCELLED;
        break;
      }
      status = AreRequestsBeingAborted(&pdx->dqReadWrite);
      if (!status)
        break;
      status = KeWaitForMultipleObjects(arraysize(pollevents),						<--8
                        pollevents,
                    WaitAny,
                    Executive,
                    KernelMode,
                    FALSE,
                    NULL,
                    NULL);
      if (!NT_SUCCESS(status))
      {
        kill = TRUE;
        break;
      {
      if (status == STATUS_WAIT_0)
      {
        status = STATUS_DELETE_PENDING;
        kill = TRUE;
        break;
      }
      if (pdx->nbytes)											<--9
      {
        if (READ_PORT_UCHAR(pdx->portbase) == 1)
        {
          *pdx->buffer++ = READ_PORT_UCHAR(pdx->portbase + 1);
          --pdx->nbytes;
          ++numxfer;
        }
      }
      if (!pdx->nbytes)
        break;
    }    // read next byte
    KeCancelTimer(&timer);
    StartNextPacket(&pdx->dqReadWrite, pdx->DeviceObject);
    if (Irp)
    {
      IoReleaseRemoveLock(&pdx->RemoveLock, Irp);
      CompleteRequest(Irp, STATUS_SUCCESS, numxfer);
    }
  }    // until told to quit

  PsTerminateSystemThread(STATUS_SUCCESS);
}
  1. 我们以后将使用这个内核定时器来控制循检设备的频率。
  2. 在该函数中我们调用KeWaitForMultipleObjects两次以阻塞循检线程直到某些事情发生。这两个数组提供我们要等待的同步对象的地址。ASSERT语句检查是否有足够的等待事件。
  3. 当有错误发生时或当evKill变成信号态时这个循环终止。然后我们终止整个循检线程。
  4. 当evKill或evRequest变成信号态时这个等待终止。我们的StartIo例程将置evRequest为信号态，以指出有一个IRP需要服务。
  5. KeSetTimerEx将启动我们的计数器。这是一个反复计数器，基于启动期和周期。我们指定了0启动期，这将使我们立即循检设备。POLLING_INTERVAL的单位是毫秒。
  6. 当evKill事件变成信号态或当我们完成当前IRP时这个内圈循环终止。
  7. 当我们在这个循环中开始操作时，当前的IRP可能被取消，或者我们收到一个要求我们终止该IRP的PnP或电源管理请求。
  8. 在调用KeWaitForMultipleObjects中，我们利用这个事实：内核定时器就象一个事件对象。当evKill进入信号态(这意味着我们应该一起终止循检线程)或定时器到时间(这意味着我们应该执行下一次循检)时该调用结束。
  9. 这是该驱动程序中的真正循检步骤。我们读控制端口，其地址是由PnP管理器给出的基本端口地址。如果该值指出数据已准备好，我们就读数据端口。

与这个循检例程协作的StartIo例程首先设置设备扩展中的buffer和nbytes域；循检例程使用这些域来顺序化输入请求。然后它设置evRequest事件唤醒循检线程。

除了我阐述的这种方式，你也可以用其它方式组织一个循检驱动程序。例如，一旦新到来的请求中指出设备空闲，就创建新循检线程。该线程服务于这个请求直到设备变为空闲，然后线程终止。这个策略更适合于活动间隔时间长的设备，因为在长时间的间隔期中，使用这种方式可以使循检线程不占用虚拟内存。然而，如果设备常常处于连续性的忙状态，则第一个策略更合适，它可以避免重复启动和停止循检线程的消耗。

练习POLLING例子

你仅能在Windows 98上测试POLLING例子驱动程序。按照光盘上的指导运行DEVTEST仿真器，仿真POLLING管理的假硬件。然后运行用户模式的TEST程序执行读操作。


工作项

有时你希望通过临时降低处理器的中断请求级(IRQL)来执行某些任务或其它必须在PASSIVE_LEVEL级下执行的任务。但是降低IRQL显然是不行的。不过如果你运行在低于或等于DISPATCH_LEVEL级上，你可以排队一个工作项(work item)，之后这个工作项会请求回调驱动程序中的例程。回调将发生在PASSIVE_LEVEL级，在由操作系统所拥有的一个worker线程的上下文中运行。使用工作项(work item)可以避免自己创建仅偶尔醒来的线程的麻烦。

我将描述一种使用工作项的简单方法。首先声明一个结构，该结构开始于一个无名的WORK_QUEUE_ITEM结构实例。下面代码来自光盘上的WORKITEM例子：

struct _RANDOM_JUNK {
  struct _WORK_QUEUE_ITEM;
  <other stuff>
} RANDOM_JUNK, *PRANDOM_JUNK;
声明工作项(Work-Item)结构

Microsoft的C/C++扩展允许一个大结构包含一个未命名的联合或结构成员。在本文的例子中，你可以直接引用标准WORK_QUEUE_ITEM的成员而不必提供中间名称限定。

如果你能使用C++语法，则可以用更好的方式声明这样的结构：

struct _RANDOM_JUNK : public _WORK_QUEUE_ITEM {
  <other stuff>
};
typedef _RANDOM_JUNK RANDOM_JUNK, *PRANDOM_JUNK;

这个语法指出_RANDOM_JUNK派生自_WORK_QUEUE_ITEM，即它继承了基类的所有成员。你可能熟悉C++的类派生概念，但结构也可以派生。使用这种声明方法，你仍旧能不用额外名称限定而直接引用WORK_QUEUE_ITEM域，应该避免依靠Microsoft的扩展语法。

准备好后，从堆上分配一个该结构的实例并初始化：

PRANDOM_JUNK junk = (PRANDOM_JUNK) ExAllocatePool(NonPagedPool, sizeof(RANDOM_JUNK));
PIO_WORKITEM item = IoAllocateWorkItem(fdo);
junk->item = item;
<additional initialization>

初始化完上下文结构(即junk)后，排队这个工作项：

IoQueueWorkItem(item,
        (PIO_WORKITEM_ROUTINE) WorkItemCallback,
        DelayedWorkQueue,
        junk);

排队工作项之后，操作系统将在某个系统worker线程的上下文中回调你的例程。你将执行在PASSIVE_LEVEL级上。回调例程所做的工作大部分由你决定，但有一个要求：你必须释放由上下文结构和工作队列项占用的内存。下面是一个工作项回调例程的框架代码：

VOID WorkItemCallback(PDEVICE_OBJECT fdo, PRANDOM_JUNK junk)
{
  ...
  IoFreeWorkItem(junk->item);
  ExFreePool(junk);
}

这段代码调用了ExFreePool，与以前的内存分配相对应。

关于工作项有几个重要的地方需要指出。不能从系统队列中删除工作项。然而如果你为满足一个PnP请求而删除了你的设备，那么出现驱动程序被卸载而工作项仍然未决的情况也是有可能的。你可以使用删除锁机制来防止这种事情发生，如下：

  • 在排队一个工作项之前，调用IoAcquireRemoveLock申请一个删除锁，防止驱动程序被提前卸载。
  • 在工作项回调例程的结尾，调用IoReleaseRemoveLock释放删除锁。为此，你需要在回调函数中访问你的设备扩展。因此，你应该事先把设备扩展指针或设备对象指针放到RANDOM_JUNK结构中。

另外，你的回调函数还需要做些工作，以防止在设备意外删除或掉电的情况下其它例程访问硬件，等等。

IoXxxWorkItem

IoXxxWorkItem例程包括IoAllocateWorkItem、IoQueueWorkItem，和IoFreeWorkItem，它们在WDM.H中声明，在NTOSKRNL.LIB(不是WDM.LIB)中定义。Microsoft推荐你用它们替代以前的ExXxxWorkItem函数(executive部件输出的函数)，新函数在原来的函数外围加上了对用户指定设备对象的引用代码。这个引用可以防止设备对象意外消失。Windows 98没有实现这些新函数，所以使用工作项的驱动程序或者使用WDMSTUB.VXD，或者提供两个版本：Windows 2000版本使用IoXxxWorkItem例程，Windows 98版本使用ExXxxWorkItem。

关于WORKITEM例子

光盘上的WORKITEM例子驱动程序演示了工作项的使用。其工作过程是这样的：测试程序先发出一个带有IOCTL_SUBMIT_ITEM代码的DeviceIoControl请求。然后驱动程序把它当做一个异步IOCTL来执行。在从DEVICE_CONTROL派遣函数返回前驱动程序还排队一个工作项。当工作项回调发生时，驱动程序完成IOCTL_SUBMIT_ITEM请求。


看门狗定时器

有些设备在发生错误时并不通知你，它们仅简单地不响应。每个设备对象都有一个内置的IO_TIMER对象，你可以使用这个对象来避免不明确的操作等待。当这种定时器运行时，I/O管理器将每秒调用一次定时器回调例程。在回调例程中，你可以分步终止任何未决的操作，这些操作早应该完成但未完成。

应该在AddDevice函数中初始化定时器对象：

NTSTATUS AddDevice(...)
{
  ...
  IoInitializeTimer(fdo, (PIO_TIMER_ROUTINE) OnTimer, pdx);
  ...
}

fdo是设备对象的地址，OnTimer是定时器回调例程，pdx是I/O管理器调用OnTimer时使用的上下文参数。

通过调用IoStartTimer函数启动定时器计时，调用IoStopTimer函数停止定时器计时。在这之间，你的OnTimer例程每隔一秒被调用一次。

光盘上的PIOFAKE例子演示了一个使用IO_TIMER做看门狗的方法。我把一个timer成员放到这个假设备的设备扩展中：

typedef struct _DEVICE_EXTENSION {
  ...
  LONG timer;
  ...
} DEVICE_EXTENSION, *PDEVICE_EXTENSION;

当处理设备的第一个IRP_MJ_CREATE时，我启动定时器计时。当设备的最后一个句柄被关闭时，我停止该定时器：

NTSTATUS DispatchCreate(...)
{
  ...
  if (InterlockedIncrement(&pdx->handles) == 1)
  {
    pdx->timer = -1;
    IoStartTimer(fdo);
  }
  ...
}

NTSTATUS DispatchClose(...)
{
  ...
  if (InterlockedDecrement(&pdx->handles) == 0)
    IoStopTimer(fdo);
  ...
}

timer单元的初始值为-1。我在StartIo例程和每次中断后都设置这个值为10。即，我允许设备最长在10秒内消化掉输出的字节并为准备好接收下一字节而生成中断。(本节后面的文本框解释了这个假想设备的工作原理) OnTimer例程每隔一秒执行一次，其操作需要与ISR同步。因此我在中断自旋锁的保护下，在DIRQL级上使用KeSynchronizeExecution来调用辅助例程CheckTimer。定时器定时的执行例程与ISR和DPC例程的协作过程见下面代码：

VOID OnTimer(PDEVICE_OBJECT fdo, PDEVICE_EXTENSION pdx)
{
  KeSynchronizeExecution(pdx->InterruptObject, (PKSYNCHRONIZE_ROUTINE) CheckTimer, pdx);
}

VOID CheckTimer(PDEVICE_EXTENSION pdx)
{
  if (pdx->timer <= 0 || --pdx->timer > 0)								<--1
    return;
  PIRP Irp = GetCurrentIrp(&pdx->dqReadWrite);								<--2
  if (!Irp)
    return;
  Irp->IoStatus.Status = STATUS_IO_TIMEOUT;								<--3
  Irp->IoStatus.Information = 0;
  IoRequestDpc(pdx->DeviceObject, Irp, NULL);
}

BOOLEAN OnInterrupt(...)
{
  ...
  if (pdx->timer <= 0)											<--4
    return TRUE;
  if (!pdx->nbytes)
  {
    Irp->IoStatus.Status = STATUS_SUCCESS;
    Irp->IoStatus.Information = pdx->numxfer;
    pdx->timer = -1;
    IoRequestDpc(pdx->DeviceObject, Irp, NULL);
  }
  ...
  pdx->timer = 10;											<--5
}

VOID DpcForIsr(...)
{
  ...
  PIRP Irp = StartNextPacket(&pdx->dqReadWrite, fdo);
  IoCompleteRequest(Irp, IO_NO_INCREMENT);								<--6
  ...
}
  1. 一个值为-1的定时器意味着当前没有请求等待。值为0意味着当前请求已经超时。在这两种情况下，我们不希望或不必在这个例程中做任何工作。if表达式的第二部分递减定时器的值。如果还没有减少到0，我们就返回，不做什么工作。
  2. 该驱动程序使用一个DEVQUEUE，所以我们调用DEVQUEUE例程GetCurrentIrp来获得当前被处理请求的地址。如果该值为NULL，则设备当前空闲。
  3. 在这里，我们希望终止当前请求，因为10秒内没有发生中断。我们填充完IRP的status域后请求了一个DPC。这个状态代码STATUS_IO_TIMEOUT转换成Win32错误代码为ERROR_SEM_TIMEOUT，但其标准错误文本(“The semaphore timeout period has expired”)并没有真正指出错误所在。如果请求这个操作的应用程序还在你的控制之下，你应该提供一个更有意义的代码解释。
  4. 如果定时器等于0，则当前请求已超时。因为CheckTimer例程已经请求了DPC，所以在ISR中除了解除中断之外我们不必也不希望做更多的工作。通过设置timer为-1，我们防止了再次调用CheckTimer，否则它会为同一个请求而请求另一个DPC。PIOFAKE现在使用一个busy标志(在设备扩展中)来避免为同一个IRP请求多个DPC。
  5. 在两次中断之间我们最长允许10秒的等待时间。
  6. 不管什么活动请求了该DPC，我们还是要填充IRP的状态域。然后，我们仅需要调用IoCompleteRequest。
关于PIOFAKE

PIOFAKE例子驱动程序为一个不存在的设备工作，该设备遵从PIO模式。设备有一个输出端口，你可以向该端口写ASCII字符。在它消化完数据字节后，它会在它的DIRQ上生成一个中断。

如果在Windows 2000上安装PIOFAKE并运行相关的TEST程序，10秒内不会发生任何事情。之后PIOFAKE会报告超时，因为它没有看到任何中断，于是测试程序会报告一个超时错误。

在Windows 98中，你可以使用DEVTEST设备仿真器来练习该例子驱动程序的PIO部分。额外信息参见PIOFAKE.HTM中的指导。


Windows 98兼容问题

在本章所讨论的材料中，有一些地方在Windows 98和Windows 2000中存在着不同之处。

错误登记

Windows 98没有实现错误登记文件和事件查看器。当你在Windows 98中调用IoWriteErrorLogEntry时，所发生的全部就是在调试终端上出现几行数据。我认为这个信息的输出格式缺乏美感，所以，在Windows 98中我宁愿不使用错误登记工具。如何确定你是运行在Windows 98下还是运行在Windows 2000下参见附录A。

IOCTL与Windows 98虚拟设备驱动程序

Win32应用程序可以使用DeviceIoControl与Windows 98虚拟设备驱动程序(VxD)通信，就象与一个WDM驱动程序一样。但针对WDM驱动程序的IOCTL与针对VxD驱动程序的IOCTL之间仍存在着三个小区别。最重要的区别是你从CreateFile函数获得的设备句柄的含义。使用WDM驱动程序时，这个句柄属于某个明确的设备，但与VxD驱动程序对话时，你得到的句柄是属于驱动程序的。实际上，VxD驱动程序需要实现一个假句柄机制(嵌入到IOCTL数据流中)，以允许应用程序参考由VxD驱动程序管理的特定硬件实例。

另一个不同之处是为控制操作分配控制代码数值。正如我前面讨论的，WDM驱动程序用CTL_CODE宏来定义控制代码，因此你定义的代码不能超过2048个。对于VxD，可以是除了0和-1之外的所有32位值。如果你写的应用程序希望既能使用VxD驱动程序也能使用WDM驱动程序，应该使用CTL_CODE定义控制代码，VxD也可以使用CTL_CODE生成的结果数值。

最后的区别更小，DeviceIoControl的倒数第二个参数，一个指向反馈变量的PDWORD，当调用WDM驱动程序时这个参数是必须的，但调用VxD驱动程序时该参数是不必要的。换句话说，如果你调用WDM驱动程序，你必须提供一个指向DWORD的非空值。但如果你调用VxD驱动程序，如果你对知道有多少字节进入输出缓冲区不感兴趣，可以指定该参数为NULL。不过调用VxD驱动程序时提供反馈变量也是无害的。

挂起IOCTL操作时的注意事项

如果应用程序使用IOCTL挂起技术来等待驱动程序告诉它硬件事件，那么在运行时它必须有一个打开的句柄。如果你的设备可以从计算机上突然删除，你需要使挂起的IOCTL失败，以使应用程序关闭其句柄。在Windows 2000中，你可以推迟处理最后的IRP_MN_REMOVE_DEVICE请求直到所有句柄都被关闭。然而在Window 98中不要这样做，因为可能会发生我在第六章结尾处描述的死锁。如果你看一看我的例子驱动程序，特别是NOTIFY，你会看到这些例子在处理IRP_MJ_CREATE时没有请求删除锁。这意味着它们允许自身在设备句柄打开时仍可以被卸载。幸运的是，Windows 98能够处理这个后果，不会带来额外影响。

等待系统线程结束

Windows 98不支持线程对象指针(PKTHREAD)作为KeWaitForSingleObject或KeWaitForMultipleObjects的参数。这些支持函数简单地把它们的对象指针参数传递给VWIN32.VXD，并不做任何有效性检测，VWIN32会因为这些线程对象没有能支持同步使用的结构成员而崩溃。

在Windows 98中，如果要等待一个内核模式线程完成，你需要该线程就在其调用PsTerminateSystemThread函数之前发出一个事件。置这个事件会造成正终止的线程对等待同一个事件的其它线程失去控制。在技术上，这个正终止的线程仍旧是活的，这个结果对Windows 98并不算糟糕。在Windows 2000中，你可以容易地在终止线程时发现驱动程序被卸载；Windows 2000中,应该确保在线程对象本身上等待。


第十章：Windows管理诊断

Windows 2000支持一种称为Windows管理诊断(WMI)的控件，用于管理计算机系统。WBEM(基于Web的企业管理)是一个广泛的工业标准，而WMI是这个工业标准的Microsoft实现。WMI的目标是为系统管理和企业网络中管理数据的描述提供了一个模型，并尽可能独立于专用API或数据对象模型。这种独立性促进了能创建、传输，和显示控制数据的独立系统部件的发展。

WDM驱动程序以三种方式适应WMI，见图10-1。第一，WMI通常能响应提取性能数据的请求。第二，各种控制器应用程序可以使用WMI方式控制设备的通用特征。第三，WMI提供了一个事件通知机制，允许驱动程序通知应用程序有重要的事件发生。在这一章中，我将描述驱动程序编程的所有这三方面。为了帮助你了解本章例子驱动程序的测试程序，我将描述WMI机制的用户模式一侧是如何工作的。

  • WMI概念
  • WDM驱动程序与WMI
  • 用户模式程序与WMI
  • Windows 98兼容问题

图10-1. WDM驱动程序在WMI环境中所扮演的角色

关于WMI和WBEM的名称

公用信息模型(CIM)是由DMTF(Distributed Management Task Force)支持的基于Web的企业管理规范，以前被称为DMTF。Microsoft命名其CIM实现为“WBEM”，其本质上是“CIM for Windows”。CIM for Windows的内核模式部分称为“WMI”。为了使CIM被更广泛地采纳，DMTF启动了一个市场行动并使用WBEM作为CIM的名称。之后Microsoft重命名其WBEM实现为WMI，并重命名WMI(内核模式部分)为“WMI extensions for WDM”。这就是说，WMI兼容CIM和WBEM规范。

我担心本章的各种术语会使你混淆。我建议你把这本书或Microsoft提供的任何文档中出现的“CIM”或“WBEM”都当做“WMI”。


WMI概念

图10-2展示了WMI的整体结构，在WMI模型中，数据和事件被分成了消费者和生产者两类。数据块就是抽象类的实例，其概念与C++中的类概念一致。如同C++中的类，WMI类也有数据成员和实现对象行为的方法。数据块中的内容并不是由WMI指定，而是由数据生产者和数据的使用目的决定的。送往驱动程序的数据最有可能来自管理者本身的操作。而驱动程序发出的数据通常是某种性能的统计数据，这些数据的消费者可能是某个性能监视程序。

图10-2. WMI世界

WMI允许存在多重命名空间，每个命名空间中包含的类属于一个或多个用户模式生产者。生产者使用平台SDK中公开的COM接口来寄存Windows管理服务(Windows Management Service)。一旦Windows 2000发行，操作系统(包括所有设备驱动程序)将支持一个名为root\cimv2的命名空间，里面包含了CIM版本2。在本书写作时，CIMV2命名空间的结构还未确定，因此Microsoft决定临时为设备驱动程序类使用另一个命名空间root\wmi。

WDM驱动程序可以作为WMI类实例的生产者。一个描述了驱动程序支持的各种类(驱动程序可以为这些类提供数据)的脚本称为驱动程序规划(schema)。我们可以使用MOF(Managed Object Format)语言定义规划。系统则维护一个称为repository的数据字典，它包含了所有已知的规划定义。如果驱动程序做得正确，系统将在初始化驱动程序时自动把规划放到repository中。

一个规划例子

在本章的后面，我将演示一个名为的WMI42.SYS例子，该例子有下面MOF规划：

[Dynamic, Provider("WMIProv"),								<--1
 WMI,
 Description("Wmi42 Sample Schema"),
 guid("A0F95FD4-A587-11d2-BB3A-00C04FA330A6"),
 locale("MS\\0x409")]

class Wmi42										<--2
{
    [key, read]
     string InstanceName;

    [read]
     boolean Active;

    [WmiDataId(1), Description("The Answer to the Ultimate Question")]
     uint32 TheAnswer;
};

我不打算描述所有MOF语法细节；你可以在平台SDK或WMI SDK文档中找到相关信息。你也可以手工构造MOF，就象我在上面例子中做的那样，或者使用WBEM CIM Studio工具，该工具可以在平台SDK或WMI SDK中找到。下面是对这个例子的简要解释：

  1. 生产者名为WMIProv，是一个系统部件，它知道该如何实例化这个类。例如，它知道如何调用内核模式例程以及发送一个IRP到适当的驱动程序。它还能根据GUID找到正确的驱动程序。
  2. 该规划声明了一个WMI42类，有三个属性InstanceName、Active、TheAnswer。

作为开发者，我们用MOF编译器编译这个规划并产生一个二进制文件，这个文件最后将成为驱动程序可执行文件的一个资源。在驱动程序初始化过程中，有一部分代码就是告诉WMI生产者这个资源在哪里，以便它能读取这个规划并添入到repository。

编译完规划后，我们还应该运行WMIMOFCK.EXE工具，在DDK中可以找到该工具，该工具执行检测以便规划能与WMI兼容。

MOFF文件与Beta发行版

在Windows 2000的beta测试期间，WMI仍处于开发过程中，因此使用每个Windows 2000beta版时都要再次运行MOF编译器，并手动修改WMI repository，以便各种COM接口都能够访问驱动程序的规划。下面命令行语法把规划放到WMI命名空间中：

mofcomp -N:root\wmi <name>

之后，你就可以使用WBEMTEST.EXE工具来测试驱动程序，并且附带的控制台模式测试程序也可以工作。(对于Windows 2000，MOFTEST.EXE和WBEMTEST.EXE包含在%windir%\system32\wbem目录中。对于Windows 98，它们包含在%windir%\ system\wbem目录中。不过Windows 98系统可能需要先安装WMI支持。见本章结尾关于“Windows 98兼容问题”的附加信息)


WDM驱动程序与WMI

内核模式驱动程序对WMI的支持主要是基于对主代码为IRP_MJ_SYSTEM_CONTROL的IRP的支持。为了能接收到这种IRP，你必须先寄存这种需求：

IoWMIRegistrationControl(fdo, WMI_ACTION_REGISTER);

做这个寄存调用的恰当时间是在AddDevice例程中，寄存完成后，一旦系统认为可以安全地向驱动程序发送系统控制IRP时，它就向驱动程序发出一个IRP_MJ_SYSTEM_CONTROL请求，以获得设备的详细寄存信息。与寄存调用作用相反的调用应该在RemoveDevice函数中做出：

IoWMIRegistrationControl(fdo, WMI_ACTION_DEREGISTER);

如果在进行反寄存调用时存在着未决的WMI请求，IoWMIRegistrationControl例程将等待直到它们完成。因此有必要使驱动程序在反寄存时仍可以响应IRP。你可以以STATUS_DELETE_PENDING失败新的IRP，但必须响应。

在解释如何服务寄存请求之前，我必须描述处理系统控制IRP的一般方法。一个IRP_MJ_SYSTEM_CONTROL请求可以有表10-1列出的副功能码。

表10-1. IRP_MJ_SYSTEM_CONTROL的副功能码

副功能码 | 描述
IRP_MN_QUERY_ALL_DATA | 获得数据块中每一项的所有实例
IRP_MN_QUERY_SINGLE_INSTANCE | 获得单一数据块中的每一项
IRP_MN_CHANGE_SINGLE_INSTANCE | 替换单一数据块中的每一项
IRP_MN_CHANGE_SINGLE_ITEM | 改变数据块中的一项
IRP_MN_ENABLE_EVENTS | 允许事件生成
IRP_MN_DISABLE_EVENTS | 禁止事件生成
IRP_MN_ENABLE_COLLECTION | 开始收集"expensive"统计信息
IRP_MN_DISABLE_COLLECTION | 停止收集"expensive"统计信息
IRP_MN_REGINFO | 获得详细寄存信息
IRP_MN_EXECUTE_METHOD | 执行一个方法函数

堆栈单元中的Parameters联合包含了一个WMI子结构，里面含有系统控制请求的参数：

struct {
  ULONG_PTR ProviderId;
  PVOID DataPath;
  ULONG BufferSize;
  PVOID Buffer;
} WMI;

ProviderId指向一个设备对象，请求将被引向这个设备对象。Buffer是一个输入/输出缓冲区的地址，BufferSize是长度，该缓冲区的前几个字节被WNODE_HEADER结构所映射。派遣例程将从这个缓冲区提取某些信息，也把结果返回到这个缓冲区。在除了IRP_MN_REGINFO之外的所有副功能码中，DataPath代表一个128位GUID的地址，该GUID标识一个数据块类。对于副功能码IRP_MN_REGINFO，DataPath域可以为WMIREGISTER或WMIUPDATE(分别为0和1)，取决于你是否被要求提供初始寄存信息还是仅更新你以前提供的信息。

处理系统控制请求有两种方法，一种方法利用WMILIB支持“驱动程序”，WMILIB实际上一个内核模式DLL，它输出的服务可以被其它驱动程序调用，这些服务可以直接处理这种IRP。另一种方法就是用户直接处理系统控制IRP本身。使用WMILIB可以简化代码，但你不能使用WMI的全部特征，你被限制在仅由WMILIB支持的WMI功能子集上，另外，这样的驱动程序将不能运行在原始版本的Windows 98上，因为在Windows 98发行时WMILIB还没有实现，参见本章结尾的Windows 98兼容问题。

但WMILIB仍可以满足大部分驱动程序的需求，所以我仅讨论使用WMILIB的情况。如果你想自己处理系统控制IRP，参见DDK文档。

委托WMILIB处理IRP

在系统控制IRP的派遣例程中，你可以委托WMILIB来完成大部分工作，代码如下：

WMIGUIDREGINFO guidlist[] = {								<--1
  {&GUID_WMI42_SCHEMA, 1, WMIREG_FLAG_INSTANCE_PDO},
};

WMILIB_CONTEXT libinfo = {
  arraysize(guidlist),
  guidlist,
  QueryRegInfo,
  QueryDataBlock,
  SetDataBlock,
  SetDataItem,
  ExecuteMethod,
  FunctionControl,
};

NTSTATUS DispatchWmi(IN PDEVICE_OBJECT fdo, IN PIRP Irp)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  NTSTATUS status = IoAcquireRemoveLock(&pdx->RemoveLock, Irp);				<--2
  if (!NT_SUCCESS(status))
    return CompleteRequest(Irp, status, 0);

  SYSCTL_IRP_DISPOSITION disposition;
  status = WmiSystemControl(&libinfo, fdo, Irp, &disposition);				<--3

  switch (disposition)									<--4
  {

  case IrpProcessed:
    break;

  case IrpNotCompleted:									<--5
    IoCompleteRequest(Irp, IO_NO_INCREMENT);
    break;

  default:										<--6
  case IrpNotWmi:
  case IrpForward:									<--7
    IoSkipCurrentIrpStackLocation(Irp);
    status = IoCallDriver(pdx->LowerDeviceObject, Irp);
    break;
  }

  IoReleaseRemoveLock(&pdx->RemoveLock, Irp);
  return status;
}
  1. WMILIB_CONTEXT声明的有效范围是整个文件，它描述了驱动程序支持的类GUID，并列出几个WMILIB用于处理WMI请求的回调函数。
  2. 与其它派遣例程相同，我们在处理这种IRP时也获取和释放删除锁。我们要防止PnP事件使下层设备对象消失。我们自己的设备对象不会消失，因为IoWMIRegistrationControl调用获得了对它的一次引用。
  3. 这个语句调用WMILIB来处理该IRP。我们传递了WMILIB_CONTEXT结构的地址。通常我们应使用一个静态上下文结构，因为从一个IRP到另一个IRP，其中的信息不可能被改变。WmiSystemControl返回两个信息：一个NTSTATUS代码和一个SYSCTL_IRP_DISPOSITION值。
  4. 执行这个IRP时我们可能需要做一些额外工作，这取决于它的特征代码，如果这个代码为IrpProcessed，则该IRP已经完成，我们不需要再做任何事情。对于除IRP_MN_REGINFO之外的其它副功能码，这种情况就是通常情况。
  5. 如果代码是IrpNotCompleted，则我们有责任完成该IRP。这也是通常情况，除了IRP_MN_REGINFO。WMILIB已经填充完IRP中的IoStatus块，所以我们仅需要调用IoCompleteRequest。
  6. default和IrpNotWmi情况不应该发生在Windows 2000中。如果不能处理所有可能的特征代码，我们将到达default。如果我们向WMILIB发送一个IRP，但其副功能码在WMI中未定义，则我们到达IrpNotWmi处。
  7. IrpForward情况发生于该系统控制IRP是发往其它驱动程序的。回想一下ProviderId参数，它指出处理该IRP的驱动程序。WmiSystemControl用设备对象指针指向的值与第二个参数比较。如果不相同，它就返回到IrpForward，然后我们把该IRP下传到下一个驱动程序。

WMI消费者通过查看WMILIB_CONTEXT结构中的GUID来判断驱动程序是否是一个WMI生产者。当一个消费者想提取数据时，它通过(间接地)访问WMI数据字典( repository)，把一个符号对象名翻译成一个GUID，这个GUID就是以前提到的MOF语句的一部分，它应该与WMILIB_CONTEXT结构中的GUID一致，WMILIB会关心这个匹配。

WMILIB将回调驱动程序中的例程来执行设备相关或驱动程序相关的处理。回调函数大部分时间以同步方式执行IRP的操作。除了IRP_MN_REGINFO之外，我们可以推迟IRP处理并返回STATUS_PENDING。如果一个回调例程挂起了该IRP，那么它应该额外再调用一次IoAcquireRemoveLock。任何完成该IRP的例程都应该调用相反的IoReleaseRemoveLock函数。

QueryRegInfo回调函数

在寄存调用之后第一个到来的系统控制IRP带有IRP_MN_REGINFO副功能码。我们把该IRP传递给WmiSystemControl，该函数又回头调用QueryRegInfo函数(地址在WMILIB_CONTEXT中给出)。下面代码摘自WMI42.SYS：

NTSTATUS
QueryRegInfo(PDEVICE_OBJECT fdo,
         PULONG flags,
         PUNICODE_STRING instname,
         PUNICODE_STRING* regpath,
         PUNICODE_STRING resname,
         PDEVICE_OBJECT* pdo)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  *flags = WMIREG_FLAG_INSTANCE_PDO;
  *regpath = &servkey;
  RtlInitUnicodeString(resname, L"MofResource");
  *pdo = pdx->Pdo;
  return STATUS_SUCCESS;
}

我们设置regpath为一个UNICODE_STRING结构的地址，该结构包含驱动程序的服务键名(在...\System\CurrentControlSet\Services下)。我们的DriverEntry例程会收到这个键名并保存到全局变量servkey中。我们设置resname为我们的规划资源名。下面是WMI42.SYS的资源文件，从中我们能看到这个名字的来处：

#include <windows.h>

LANGUAGE LANG_ENGLISH, SUBLANG_NEUTRAL
MofResource MOFDATA wmi42.bmf

wmi42.bmf就是编译后的MOF文件。你可以随意命名这个资源，通常是MofResource。但这个名字的缺点是与QueryRegInfo调用时指定名字相同。

其余值的设置取决于驱动程序如何处理实例命名。关于实例命名我们稍后再讨论。最简单的选择，也是Microsoft强烈推荐的做法(WMI42.SYS采用)是：使用系统自动生成的名称，即基于总线驱动程序赋予PDO的名字。如果使用这种命名方法，我们需要在QueryRegInfo中做下面工作：

  • 在GUID列表中设置WMIREG_FLAG_INSTANCE_PDO标志，该标志是WMILIB_CONTEXT结构的一部分。这意味着相关WMI类的数据块的实例名将使用PDO的名字。
  • 在我们返回给WMILIB的flags值中设置WMIREG_FLAG_INSTANCE_PDO标志。它告诉WMILIB，我们的对象中至少有一个使用了PDO命名。
  • 在返回给WMILIB的值中设置pdo。例子驱动程序的设备扩展中有一个名为Pdo的域，我在AddDevice中设置了这个域以使它在这时有效。

基于PDO的实例名可以允许查看器程序自动确定驱动程序的友好名和其它属性而不用你在驱动程序中多做任何事。

当你在QueryRegInfo中返回成功状态后，WMILIB继续执行并创建一个复杂的结构WMIREGINFO，该结构包含了你的GUID列表、你的注册表键、你的资源名，以及与实例名相关的信息。然后返回到你的派遣例程中，最后派遣函数完成该IRP并返回。图10-3显示了这个过程。

图10-3. IRP_MN_REGINFO的控制流程

QueryDataBlock回调函数

你在开始的寄存查询中回答的信息使系统能够把相关数据操作转给你。用户模式代码可以使用各种COM接口，以多种详细程度提取或设置数据值。表10-2列出了四种详细程度。

表10-2. 数据查询形式

IRP副功能码 | WMILIB回调 | 描述
IRP_MN_QUERY_ALL_DATA | QueryDataBlock | 获得所有实例的所有项
IRP_MN_QUERY_SINGLE_INSTANCE | QueryDataBlock | 获得一个实例的所有项
IRP_MN_CHANGE_SINGLE_INSTANCE | SetDataBlock | 设置一个实例的所有项
IRP_MN_CHANGE_SINGLE_ITEM | SetDataItem | 设置一个实例的一个项

当某人想得到你手中的数据时，它们向你发送一个副功能码为IRP_MN_QUERY_ALL_DATA或IRP_MN_QUERY_SINGLE_INSTANCE的系统控制IRP。如果使用WMILIB，你可以把该IRP委托给WmiSystemControl，该函数然后调用你的回调例程QueryDataBlock。你将在QueryDataBlock中给出要求的数据，然后调用另一个WMILIB例程WmiCompleteRequest来完成该IRP，最后返回到WMILIB。此时，由于你已经完成了该IRP，WmiSystemControl将返回IrpProcessed代码。图10-4显示了这个控制流程。

图10-4. 数据查询的控制流

如果驱动程序要维护数据块的多个实例，并且数据块会随着实例的不同而改变大小，那么你的QueryDataBlock回调函数可能会成为一个复杂的函数。我以后在“处理多实例”中再讨论这种复杂情况。WMI42例子仅演示了简单情况的处理，即驱动程序仅维护WMI类的一个实例：

NTSTATUS
QueryDataBlock(PDEVICE_OBJECT fdo,
           PIRP Irp,
           ULONG guidindex,
           ULONG instindex,
           ULONG instcount,
           PULONG instlength,
           ULONG bufsize,
           PUCHAR buffer)
{
  if (!instlength || bufsize < sizeof(ULONG))								<--1
    return WmiCompleteRequest(fdo, Irp, STATUS_BUFFER_TOO_SMALL, sizeof(ULONG), IO_NO_INCREMENT);

  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;

  PULONG pvalue = (PULONG) buffer;									<--2
  *pvalue = pdx->TheAnswer;
  instlength[0] = sizeof(ULONG);

  return WmiCompleteRequest(fdo, Irp, STATUS_SUCCESS, sizeof(ULONG), IO_NO_INCREMENT);			<--3
}
  1. 我们被迫做这个检测以确定该缓冲区的长度能否容纳下我们将要填入的数据和数据长度值。测试的第一部分判断是否存在instlength数组。测试的第二部分确定该缓冲区能否容纳下一个ULONG。在这个简单的驱动程序中，我们仅提供一个ULONG值。
  2. buffer参数指向一块内存区域，在这里我们放入数据。instlength参数指向一个数组，在那里我们放入返回的每个数据实例的长度。在此，我们装入一个ULONG数据值(TheAnswer属性的值)和其长度。
  3. WMILIB规范要求我们调用WmiCompleteRequest例程完成该IRP。第四个参数指出数据值需要多大的缓冲区。

这里我没有讨论QueryDataBlock的guidindex、instindex、instcount参数。稍后在讨论WMI的复杂特征时我再回头讨论这些内容。在WMI42.SYS中，这些值分别应该是0、0、1。

SetDataBlock回调函数

系统会通过发出IRP_MN_CHANGE_SINGLE_INSTANCE请求来要求你改变某个类的整个实例。WmiSystemControl通过调用你的SetDataBlock回调例程来处理这个IRP。下面是这个例程的简化版本：

NTSTATUS
SetDataBlock(PDEVICE_OBJECT fdo,
         PIRP Irp,
         ULONG guidindex,
         ULONG instindex,
         ULONG bufsize,
         PUCHAR buffer)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  if (bufsize == sizeof(ULONG))										<--1
  {
    pdx->TheAnswer = *(PULONG) buffer;
    status = STATUS_SUCCESS;
    info = sizeof(ULONG);
  }
  else
    status = STATUS_INFO_LENGTH_MISMATCH, info = 0;
  return WmiCompleteRequest(fdo, Irp, status, info, IO_NO_INCREMENT);					<--2
}
  1. 系统应该知道每个类的实例有多大(基于MOF的声明)，因此它应该给我们正确大小的缓冲区。如果不是这样，我们应该失败该IRP。另外，我们还应该备份数据块的值。
  2. 我们有责任完成该IRP，所以调用WmiCompleteRequest。

SetDataItem回调函数

有时，消费者仅仅想改变WMI对象中的某个域。每个域都有一个标识数字，该数字出现在域的MOF声明中的WmiDataId属性中。(Active和InstanceName属性是不可修改的，而且也没有标识。此外，它们是由系统实现的，甚至不出现在我们使用的数据块中) 为了改变某个域，消费者参考该域ID，后来我们就收到一个IRP_MN_CHANGE_SINGLE_ITEM请求，该请求由WmiSystemControl通过调用我们的SetDataItem回调函数来处理：

NTSTATUS
SetDataItem(PDEVICE_OBJECT fdo,
        PIRP Irp, ULONG guidindex,
        ULONG instindex,
        ULONG id,
        ULONG bufsize,
        PUCHAR buffer)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  NTSTATUS status;
  ULONG info;

  if (bufsize == sizeof(ULONG))
  {
    pdx->TheAnswer = *(PULONG) buffer;
    status = STATUS_SUCCESS;
    info = sizeof(ULONG);
  }
  else
    status = STATUS_INFO_LENGTH_MISMATCH, info = 0;

  return WmiCompleteRequest(fdo, Irp, status, info, IO_NO_INCREMENT);
}

在WMI42.SYS例子中，你会注意到这个SetDataItem例程与SetDataBlock例程相同，因为我的类只有一项。

注意
显然，在Windows 2000的beta版中用于生成调用SetDataItem例程的WMI系统代码还不完整。调用这个例程的唯一方法是使用Microsoft的一个内部测试工具，而且还需要用一个为0的项来代替MOF规划中声明为1的项。我不知道这是不是这个内部工具的一个bug。我建议你以STATUS_WMI_NOT_SUPPORTED失败这个调用，直到你真正知道这个项目ID的含义。

高级特征

在前面，我们讨论了为监测程序提供性能信息的技术。想象一下：不是仅提供一个统计信息(TheAnswer)，你还可以累积并返回你的专有信息，而这些信息只与你的设备相关。此外，你还可以支持有更特殊目的的某些额外WMI特征。现在我们将讨论这些特征。

处理多实例

WMI允许你为单个设备对象的某个类数据块创建多个实例。如果你的设备是一个控制器设备或者是可以插入其它设备的设备，你可能希望能提供多个实例，每个实例代表一个子设备的数据。你可以在WMIGUIDREGINFO结构中指定类的实例个数。例如，如果WMI42有其标准数据块的三个不同实例，那么在它的WMILIB_CONTEXT结构中应该有如下GUID列表：

WMIGUIDREGINFO guidlist[] = {
  {&GUID_WMI42_SCHEMA, 3, WMIREG_FLAG_INSTANCE_PDO},
};

这与以前GUID列表的不同之处是实例个数，这里是3。这个列表声明这里有三个WMI42数据块的实例，每个都有自己的三个属性值(即InstanceName、Active、TheAnswer)。

如果实例的数目发生了改变，你应该用WMIREG_ACTION_UPDATE_GUID动作码调用IoWmiRegistrationControl，这将使系统向你发送另一个寄存请求，在处理这个请求时你应该使用更新后的WMILIB_CONTEXT结构。顺便说一下，如果你想修改你的寄存信息，你应该在自由内存池中申请WMILIB_CONTEXT结构和GUID列表而不是使用静态变量。

如果用户模式代码要枚举GUID_WMI42_SCHEMA的所有实例，它可以找到这三个实例。然而这个结果却使用户代码迷惑，通过枚举不可能提前知道这三个实例属于同一个设备，这无法与三个WMI42设备每个都给出同一类的一个实例的情况相区分。为了使WMI客户识别出这两种情况的不同，你的规划应该包括一个额外的属性(如设备名)。

一旦你允许多实例存在，你的各种WMILIB回调函数就需要修改：

  • QueryDataBlock应该既能返回单实例的数据块，又能返回指定索引处的任意数量的实例数据。
  • SetDataBlock应该解释其实例号参数以决定哪个实例需要改变。
  • SetDataItem也应该解释其实例号参数以定位实例。

图10-5显示了在需要提供数据块的多个实例的情况下你的QueryDataBlock函数如何使用输出缓冲区。假定你被要求提供两个实例的数据，开始于实例号2。你将复制数据值(大小会不同)到该数据缓冲区，并以8字节边界开始每个实例。在完成该查询时你应该给出复制的总共字节数，并在instlength数组中填入每个实例的数据长度。

图10-5. 获得多实例数据块

实例命名

WMI类的每个实例都有一个唯一的名字。知道这个实例名字的消费者能够执行查询或调用方法例程。不知道实例名字的消费者也可以通过枚举该类来得到。不管什么情形你都有责任生成消费者可使用或可发现的名字。

这里有一个为客户数据块命名的最简单的方式(从驱动程序角度来看)，即请求WMI自动生成一个基于设备PDO名的静态的、唯一的名字。例如，如果你的PDO名为Root\*WCO0A01\0000，那么一个单实例的基于PDO的名字应该是Root\*WCO0A01\0000_0。结尾的_0使这个名字唯一。这个名字是静态的，可以一直维持到你反寄存或更新你的寄存信息。

基于PDO名的实例名很方便，因为你所要做的就是在每个WMIGUIDREGINFO结构中设置WMIREG_FLAG_INSTANCE_PDO标志，以及在WMILIB传递给回调函数QueryRegInfo的flags中设置该标志。但消费程序的作者并不知道这个名字，因为这个名字会随着所安装的设备而变化。为了使实例名更可预测，你可以用一个常量名来代替对象实例名。通过忽略WMIGUIDREGINFO结构中的WMIREG_FLAG_INSTANCE_PDO标志来指出这种选择，下面是修改后的QueryRegInfo回调函数：

NTSTATUS
QueryRegInfo(PDEVICE_OBJECT fdo,
         PULONG flags,
         PUNICODE_STRING instname,
             PUNICODE_STRING* regpath,
         PUNICODE_STRING resname,
         PDEVICE_OBJECT* pdo)
{
  *flags = WMIREG_FLAG_INSTANCE_BASENAME;
  *regpath = &servkey;
  RtlInitUnicodeString(resname, L"MofResource");
  static WCHAR basename[] = L"WMIEXTRA";
  instname->Buffer = (PWCHAR) ExAllocatePool(PagedPool, sizeof(basename));
  if (!instname->Buffer)
    return STATUS_INSUFFICIENT_RESOURCES;
  instname->MaximumLength = sizeof(basename);
  instname->Length = sizeof(basename) - 2;
  RtlCopyMemory(instname->Buffer, basename, sizeof(basename));
}

该函数与前面例子中的QueryRegInfo函数的不同部分用粗体显示。在WMIEXTRA例子中，每个数据块仅有一个实例，名字都是WMIEXTRA。

如果你使用命名前缀，应该避免使用象Toaster这样的通用名称，因为那可能会产生混乱。这个特征的目的是让你使用象AcmeWaffleToaster这样的特殊名称。

在某些场合，静态实例名不能适应需求。如果你需要维护一个会常常改变的数据块个数，使用静态名意味着每次数据块个数改变时你都要请求一个寄存更新。更新过程比较昂贵，应该避免常常做这种请求。可以动态分配一个实例名，而这个实例名就是查询的一部分。但不幸的是，WMILIB并不支持使用动态实例名。因此，为了使用这个特征，你必须抛开WMILIB自己解释IRP_MJ_SYSTEM_CONTROL。如何自己处理这些IRP已经超出本书的范围，详细信息可以参考DDK文档。

处理多类

WMI42仅能处理一个数据块类。如果你想支持多个类，需要有一个更大的GUID信息结构数组，如WMIEXTRA中的：

WMIGUIDREGINFO guidlist[] = {
  {&GUID_WMIEXTRA_EVENT, 1, WMIREG_FLAG_INSTANCE_PDO | WMIREG_FLAG_EVENT_ONLY_GUID},
  {&GUID_WMIEXTRA_EXPENSIVE, 1, WMIREG_FLAG_EXPENSIVE | WMIREG_FLAG_INSTANCE_PDO},
  {&GUID_WMIEXTRA_METHOD, 1, WMIREG_FLAG_INSTANCE_PDO},
};

在调用某个回调例程之前，WMILIB会在你的列表中查看与IRP相关的GUID。如果该GUID不在列表中，WMILIB将失败该IRP。如果在，WMILIB将调用你的回调函数，并把guidindex参数设置为等于该GUID在列表中的GUID索引。通过检查这个参数，你可以知道请求的是哪个数据块。

你可以在一个GUID信息结构中使用特殊标志WMIREG_FLAG_REMOVE_GUID。该标志的目的是当寄存更新时从列表中删除一个特定GUID。使用这个标志也可以防止WMILIB让你在一个要被删除的GUID上执行操作。

Expensive统计

有时候，收集所有对用户或管理员有潜在用途的统计信息是一个繁重的任务。例如，磁盘驱动程序(很可能是与磁盘驱动程序在同一设备堆栈上的过滤器驱动程序)收集用于显示磁盘上某扇区I/O请求频率的数据。这个数据对磁盘碎片整理程序会有用，它可以把最频繁访问的扇区数据放到磁盘中部以优化寻道时间。然而你不会希望每次都收集这样数据，因为收集数据需要内存，并且可能出现页交换这种特殊I/O请求，所以这个内存又必须是非分页的。

WMI允许你声明一个特殊的expensive数据块，这样你就可以在需求时才收集其信息，下面代码摘自WMIEXTRA程序：

WMIGUIDREGINFO guidlist[] = {
  ...
  {&GUID_WMIEXTRA_EXPENSIVE, 1, WMIREG_FLAG_EXPENSIVE},
  ...
};

WMIREG_FLAG_EXPENSIVE标志指出由GUID_WMIEXTRA_EXPENSIVE标识的数据块有expensive特征。

当一个应用程序对expensive数据块中的值“表达兴趣”时，WMI就向你发送一个副功能码为IRP_MN_ENABLE_COLLECTION的系统控制IRP。如果没有应用程序对expensive数据块中的内容感兴趣，WMI将向你发送另一个副功能码为IRP_MN_DISABLE_COLLECTION的系统控制IRP。如果你把这些IRP委托给WMILIB，它会回头调用你的FunctionControl回调函数，或者允许或者禁止收集该数据块中的值：

NTSTATUS
FunctionControl(PDEVICE_OBJECT fdo, PIRP Irp, ULONG guidindex, WMIENABLEDISABLECONTROL fcn, BOOLEAN enable)
{
  ...
  return WmiCompleteRequest(fdo, Irp, STATUS_SUCCESS, 0, IO_NO_INCREMENT);
}

在这些参数中，guidindex是该expensive数据块在你的GUID列表中的GUID索引，fcn将等于枚举值WmiDataBlockControl，它指出是允许还是禁止收集expensive统计信息，enable是布尔量，指出是否应该收集这个统计信息。在该函数返回前，你应该调用WmiCompleteRequest。

顺便说一下，应用程序对一个数据块“感兴趣”是通过提取一个IWbemClassObject接口指针，该指针被捆绑到你的数据块的WMI类的特定实例上。虽然应用程序需要找到这个类的一个实例，但调用FunctionControl回调函数时并没有出现实例索引。因此收集或不收集expensive统计信息的指令将应用到这个类的所有实例上。

WMI事件

生产者可以使用WMI提供的方法来通知消费者其期待的事件发生。设备驱动程序也可以使用这种方法来警告用户某个设备操作需要用户干预。例如，磁盘驱动程序可以发出磁盘上有异常的大量坏块的通知。如何登记这样的事件见第九章，这也是把这个事实通知给人类世界的一种方式，管理员必须积极地查看事件登记的每一项。如果某人要写一个事件监视小程序(applet)，应该注意到这个异常并发出一个WMI事件，则这个事件应该能立即引起用户注意。

WMI事件就是用特殊方法使用的常规WMI类。在MOF语句中，你必须从抽象类WMIEvent派生出这种数据块，下面是WMIEXTRA的MOF文件：

[Dynamic, Provider("WMIProv"),
 WMI,
 Description("Event Info from WMIExtra"),
 guid("c4b678f6-b6e9-11d2-bb87-00c04fa330a6"),
 locale("MS\\0x409")]

class wmiextra_event : WMIEvent
{
    [key, read] string InstanceName;

    [read] boolean Active;

    [WmiDataId(1), read] uint32 EventInfo;

};

尽管事件可以是正常的数据块，但不希望应用程序分别读写它们，否则需要在GUID的声明中使用EVENT_ONLY标志：

WMIGUIDREGINFO guidlist[] = {
  ...
  {&GUID_WMIEXTRA_EVENT, 1, WMIREG_FLAG_INSTANCE_PDO | WMIREG_FLAG_EVENT_ONLY_GUID},
  ...
};

当应用程序对某个事件“表达兴趣”时，WMI就向驱动程序发送一个副功能码为IRP_MN_ENABLE_EVENTS的系统控制IRP。如果不再有应用程序对这个事件感兴趣，WMI会向驱动程序发送一个副功能码为IRP_MN_DISABLE_EVENTS的系统控制IRP。如果你把这些IRP委托给WMILIB来处理，它会回头调用你的FunctionControl回调函数，并在你的GUID列表中指出GUID索引，以及一个WmiEventControl的fcn代码，一个enable布尔标志。

为了发出一个事件，首先应该在非分页内存中构造一个事件类的实例然后调用WmiFireEvent。例如：

PULONG junk = (PULONG) ExAllocatePool(NonPagedPool, sizeof(ULONG));
*junk = 42;
WmiFireEvent(fdo, (LPGUID) &GUID_WMIEXTRA_EVENT, 0, sizeof(ULONG), junk);

在适当时间，WMI子系统将释放由事件对象占用的内存。

WMI方法例程

除了定义传输数据和通知事件的机制之外，WMI还为消费者调用生产者实现的方法例程规定了一种方法。下面是WMIEXTRA定义的只有一个方法例程的类：

[Dynamic, Provider("WMIProv"),
 WMI,
 Description("WMIExtra class with method"),
 guid("cd7ec27d-b6e9-11d2-bb87-00c04fa330a6"),
 locale("MS\\0x409")]

class wmiextra_method
{
    [key, read] string InstanceName;

    [read] boolean Active;

    [Implemented, WmiMethodId(1)] uint32 AnswerMethod([in,out] uint32 TheAnswer);

};

这个声明表明AnswerMethod将接受一个名为TheAnswer(一个32位无符号整数)的输入/输出参数并返回一个32位无符号整数。

如果你把系统控制IRP委托给WMILIB处理，某个方法例程会调用你的ExecuteMethod回调函数：

NTSTATUS
ExecuteMethod(PDEVICE_OBJECT fdo,
          PIRP Irp,
          ULONG guidindex,
          ULONG instindex,
          ULONG id,
          ULONG cbInbuf,
          ULONG cbOutbuf,
          PUCHAR buffer)
{
  NSTATUS status = STATUS_SUCCESS;
  ULONG bufused = 0;
  ...
  return WmiCompleteRequest(fdo, Irp, status, bufused, IO_NO_INCREMENT);
}

buffer区域包含输入类的一个映像，长度为cbInbuf。你的工作就是执行该方法并用输出类来覆盖这个buffer区域。你以输出类的字节大小(bufused)完成该请求。在WMIEXTRA中，我在省略号的地方放了如下代码(我忽略了错误检测)：

switch (guidindex)
{
case 2:
  bufused = sizeof(ULONG);
  (*(PULONG) buffer)++;
  break;
default:
  status = STATUS_WMI_GUID_NOT_FOUND;
  break;
}

这个简单的方法例程仅把输入参数值加1。

在我写本章时，方法例程调用周围的细节部分仍不明确。下面是你要考虑的一些问题：

  • 驱动程序没有办法使方法调用返回一个值。你仅能返回一个输出参数类实例。
  • 输入输出参数在描述函数时一同给出。系统把这些参数描述翻译成两个WMI类：一个是输入参数，一个是输出参数。用户模式消费者能够很容易地了解这些类的内容，但在编写驱动程序时，你必须猜测对应结构的内存分配。我猜测一个32位无符号整数参数会占用输入/输出缓冲区中的一个ULONG位置。
  • 仅枚举类实例的方法(如wmiextra_method)会触发一个数据块请求。你必须使这个数据查询成功，即使这个类仅包含方法例程没有数据成员。在这种情况下，你可以以0数据长度来完成该查询。

标准数据块

Microsoft已经为各种类型的设备定义了一些标准化数据块。如果你的设备属于某个已定义了标准化数据块的类，你应该在驱动程序中支持这些块。标准化类的定义可参考DDK中的WMICORE.MOFF文件，表10-3列出了标准数据块。

表10-3. 标准数据块

设备类型 | 标准类 | 描述
Keyboard | MSKeyboard_PortInformation | 配置和性能信息
Mouse | MSMouse_PortInformation | 配置和性能信息
Disk | MSDiskDriver_Geometry MSDiskDriver_Performance | 格式信息、性能信息
Storage | MSStorageDriver_FailurePredictStatus | 确定驱动器是否预测了一个失败
 | MSStorageDriver_FailurePredictData | 失败预测数据
 | MSStorageDriver_FailurePredictEvent | 如果预测失败则发出事件
 | MSStorageDriver_FailurePredictFunction | 与失败预测相关的方法
Serial | MSSerial_PortName | 端口名称
 | MSSerial_CommInfo | 通讯参数
 | MSSerial_HardwareConfiguration | I/O资源信息
 | MSSerial_PerformanceInformation | 性能信息
 | MSSerial_CommProperties | 通讯参数
Parallel | MSParallel_AllocFreeCounts | 分配和释放操作计数
 | MSParallel_DeviceBytesTransferred | 传输计数

为了实现对某个标准数据块的支持，包括列表中对应的GUID，应该实现获取和设置数据的支持代码，允许和禁止事件的支持代码，等等。不要在你自己的规划中包含标准数据块定义；那些类已经存在于repository中了，不要超越它们。

在许多情况下，Microsoft的类驱动程序将提供对这些标准类的实际WMI支持，因此你不用做什么工作。

标准控制

Windows 2000有一天会把WMI作为向驱动程序发送某些公用命令的方法。特别是管理程序将能使用WMI发送与电源管理有关的命令。但现在仅定义了两个这样的命令。见表10-4。

表10-4. 标准WMI命令

WMI类(WMICORE.MOFF) | GUID名称(WDMGUID.H) | 目的
MSPower_DeviceEnable | GUID_POWER_DEVICE_ENABLE | 当系统工作时设备电源能否动态开关？
MSPower_DeviceWakeEnable | GUID_POWER_DEVICE_WAKE_ENABLE | 设备是否支持唤醒特征？

如果你参考WMICORE.MOFF，你将看到DeviceEnable和DeviceWakeEnable类仅包含一个布尔成员Enable，WMI客户可以读写这个成员。在驱动程序中支持这两个类，包括GUID列表中的两个GUID的实现代码与WMI42中的代码十分相似，所以我不再给出这个代码。

如果你向前追溯一下Windows 2000的beta发行版，你会发现Microsoft最开始好象计划要实现另一个WMI类(名字可能为MSPower_DeviceTimeouts)，用于查询并设置两个超时值，这两个值用于电源管理器寄存空闲检测。这个计划好象被搁浅了。但其GUID定义(GUID_POWER_DEVICE_TIMEOUTS)仍存在于WDMGUID.H中。


用户模式程序与WMI

WMI使用COM方式支持用户模式应用程序。通过实现几个COM接口，Windows管理服务就象消费者与生产者之间的信息流交易场所。生产者经由某个接口向Windows管理程序寄存它们的存在。消费者通过接口间接地与生产者通信。平台SDK中公开了所有这些接口，因此我仅描述消费者使用的重要方法例程。但开始我还要为那些对COM没有经验的读者解释一下使用COM的基本方法。

COM是什么

本段针对那些对使用COM接口没有经验的读者。我尽量避免直接讨论COM，因为这是个唯一我认为难以理解的术语。

当你谈论COM时，你将遇到三个关键的术语。在COM中，一个对象就是一个软件实体，它实现了属于某个接口的方法。COM客户需要面对的关键元素就是一个指向某接口的指针。获得接口指针有两种途径，或者是某人给你的，或者是调用了某个API函数返回的。从客户程序的角度看，有个神秘的实体掌握着对象的创建和销毁。

接口是什么

现在让我们慢慢地了解这三个概念，从最后一个开始。接口就是一个C++类，但仅有一些虚拟成员函数没有数据成员，也没有非虚拟成员函数。你可以用多种语言实现或使用COM接口，不仅仅是C++。但C++是我们了解这种概念的常用方式。这里有一个简单接口的声明，是未被翻译成COM语言时的形式：

class IUnknown
{
public:
  virtual long _ _stdcall QueryInterface(const GUID& riid, void** ppvObject);
  virtual unsigned long _ _stdcall AddRef();
  virtual unsigned long _ _stdcall Release();
};

IUnknown(COM中的对象)实现了三个公用的虚拟函数(方法)：QueryInterface、AddRef、Release。AddRef和Release用于维持对象对客户的有效性。QueryInterface用于客户程序获得对象支持的接口指针。

使用COM的接口定义语言(IDL)，该接口描述应该象这样：

interface IUnknown
{
  HRESULT QueryInterface(REFIID riid, void** ppvObject);
  ULONG AddRef();
  ULONG Release();
};

抛开语法上的差异，这与C++的类定义非常相似。IDL编译器可以把这种接口描述形式翻译成C或C++编译器可以理解的语法形式。有些编程语言甚至能直接理解IDL语法，不需要这种翻译。

就象C++的类，一个接口可以派生自另一个接口。在COM中不可以声明超过一个基类的接口。另外，每个COM接口最终都派生自IUnknown，既每个COM对象都支持QueryInterface、AddRef、Release方法。下面是一个来自WMI世界的例子，我们以后会用到这个例子：

interface IWbemLocator : IUnknown
{
  HRESULT ConnectServer(BSTR strNetworkResource,
            BSTR strUser,
            BSTR strPassword,
            BSTR strLocale,
            long lSecurityFlags,
            BSTR strAuthority,
            IWbemContext* pCtx,
            IWbemServices** ppNameSpace);
};

因此实现了IWbemLocator的对象会有四个方法例程：QueryInterface、AddRef、Release、ConnectServer。

对象的创建与销毁

有了接口指针你就可以与对象对话，获得接口指针有多种方法。通常的办法是调用CoCreateInstance：

IWbemLocator* locator;
HRESULT hr = CoCreateInstance(CLSID_WbemLocator,
                  NULL,
                  CLSCTX_INPROC_SERVER,
                  IID_IWbemLocator,
                  (PVOID*) &locator);

CoCreateInstance首先参考注册表来定位一个服务器，该服务器能够例化一个CLSID_WbemLocator类的对象。CLSID_WbemLocator是一个GUID，称为类标识符，它标识一种或一类COM对象。注册表的HKEY_CLASSES_ROOT分支包含一个名为CLSID的键，其子键是COM了解的所有类标识符的ASCII表达。在上面例子中，CLSID_WbemLocator应该表达为{4590f811-1d3a-11d0-891f-00aa004b2e24}，它的InProcServer32子键指定了一个DLL(名为wbemprox.dll，是WMI内核的一部分)，是实现了这个类的服务器。

定位了注册表中的类键，CoCreateInstance然后把指定的服务器装入到你的地址空间。(IID_IWbemLocator是WBEMCLI.H中声明的另一个GUID，应该在你的消费者工程文件中包含这个头文件)

调用CoCreateInstance成功之后，你就获得了一个接口指针，使用接口指针就象使用C++的类指针，你可以调用接口中的任何方法函数。计算机的某个地方(甚至不是在同一个计算机中)必然存在一个实现了那些方法函数的具体对象。对象要占用存储空间，实现的执行代码也要占存储空间。一旦你利用完这个接口指针，你将销毁对象，或许还要卸载程序。但问题是，在什么时候？这就是AddRef和Release的用处。

每个COM对象都有一个参考计数。一旦有人获得了对象接口的指针，实现该对象的程序就增加参考计数。所以，CoCreateInstance总是返回一个被引用的接口指针，你可以认为这个指针一直有效。调用AddRef可以明确地增加对象的参考计数。当你利用完接口指针后，调用Release方法，Release将减少参考计数。如果参考计数为0，服务器实现代码就删除该对象。如果这个服务器不拥有任何对象，它本身就可以被卸载了。

作为一个COM客户，你的工作就是在不再需要该对象时简单地释放对接口的参考。下面是典型代码：

IWbemLocator* locator;
HRESULT hr = CoCreateInstance(...);
if (SUCCEEDED(hr))
{
  ...
  locator->Release();
}

访问WMI信息

如果想在用户模式中访问WMI信息，你首先需要与一个特殊的命名空间建立连接。在该命名空间的上下文中，你可以找到WMI类的实例。你可以查询并设置与该类实例相关的的数据块，调用它们的方法例程，监视它们生成的事件。

连接一个命名空间

当连接一个WMI命名空间时，首先需要获得WMI实现的IWbemServices接口的指针。见下面代码(基于WMI42例子中的TEST程序)：

HRESULT hr = CoInitializeEx(NULL, 0);								<--1
if (!SUCCEEDED(hr))
  return;
hr = CoInitializeSecurity(NULL,									<--2
              -1,
              NULL,
              NULL,
              RPC_C_AUTHN_LEVEL_NONE,
              RPC_C_IMP_LEVEL_IMPERSONATE,
              NULL,
              0,
              0);
if (!SUCCEEDED(hr))
{
  CoUninitialize();
  return;
}

IWbemLocator* locator;
hr = CoCreateInstance(CLSID_WbemLocator,							<--3
              NULL,
              CLSCTX_INPROC_SERVER,
              IID_IWbemLocator,
              (PVOID*) &locator);
if (SUCCEEDED(hr))
{
  IWbemServices* services;
  BSTR pnamespace = SysAllocString(L"root\\CIMV2");
  hr = locator->ConnectServer(pnamespace,							<--4
                  NULL,
                  NULL,
                  0,
                  0,
                  &services);
  SysFreeString(pnamespace);
  if (SUCCEEDED(hr))
  {
    IClientSecurity* security;									<--5
    hr = services->QueryInterface(IID_IClientSecurity, (PVOID*) &security);
    if (SUCCEEDED(hr))
    {
      security->SetBlanket(services,
               RPC_C_AUTHN_WINNT,
               RPC_C_AUTHZ_NONE,
               NULL,
               RPC_C_AUTHN_LEVEL_CONNECT,
               RPC_C_IMP_LEVEL_IMPERSONATE,
               NULL,
               EOAC_NONE);
      security->Release();
    }
    // use the services interface								<--6
    services->Release();
  }
  locator->Release();
}
CoUninitialize();
  1. 每个使用COM的程序都需要调用CoInitialize或CoInitializeEx来初始化COM库，调用CoUninitialize来关闭COM库。
  2. 先别管这个调用的原因。
  3. 我们在这里例化一个WbemLocator对象并获取其IWbemLocator接口的指针。如果该调用成功，我们最后还要释放对这个接口的参考。
  4. 我们使用IWbemLocator接口来连接CIMV2命名空间。(在beta版本中，这应该是WMI命名空间) 使用ConnectServer方法的一个不便之处是你必须调用SysAllocString来复制一份该命名空间的Unicode名称拷贝。
  5. 不要管这个！我曾用两天时间想找出在这调用IClientSecurity::SetBlanket的原因，因为在我写本章时，关于这个调用的SDK文档还没有跟上实现代码。(@#$!)
  6. 在这里你可以使用IWbemServices接口指针定位WMI类实例并访问其它的WMI服务。

枚举类实例

利用IWbemServices接口，你可以枚举一个特定WMI类的所有实例。例如，WMI42的测试程序用下面代码枚举了WMI42类的所有实例：

IEnumWbemClassObject* enumerator = NULL;							<--1
BSTR bs = SysAllocString(L"WMI42");
HRESULT hr = services->CreateInstanceEnum(bs,
  WBEM_FLAG_SHALLOW | WBEM_FLAG_RETURN_IMMEDIATELY |
  WBEM_FLAG_FORWARD_ONLY, NULL, &enumerator);
SysFreeString(bs);
if (SUCCEEDED(hr))
{
  while (TRUE)
  {
    ULONG junk;
    IWbemClassObject* cop = NULL;								<--2
    hr = Enumerator->Next(INFINITE, 1, &cop, &junk);
    if (hr == WBEM_S_FALSE)
      break;
    if (!SUCCEEDED(hr))
      break;
    // Use IWbemClassObject interface
    cop->Release();
  }
  enumerator->Release();
}
  1. IWbemServices::CreateInstanceEnum将为一个命名WMI类的所有实例创建枚举器。该接口方法有两个缺点，第一，类名必须以单独分配的BSTR传递。第二，你必须初始化目标接口指针为NULL，尽管这里假定仅有一个输出参数，如果该指针一开始就非法则会导致系统崩溃。
  2. 实例枚举器的Next方法以IWbemClassObject接口指针的形式给出后续实例的指针。如果该类的实例都被枚举，则Next方法返回WBEM_S_FALSE。为了避免系统崩溃，需要先初始化这个假定输出参数为NULL。

项目值的获取与设置

IWbemClassObject接口是打开驱动程序WMI功能的钥匙。利用该接口的指针你可以方便地获取或设置数据块中的项目值：

IWbemClassObject* cop;
VARIANT answer;
BSTR propname = SysAllocString(L"TheAnswer");
cop->Get(propname, 0, &answer, NULL, NULL);
VariantClear(&answer);

answer.vt = VT_I4;
answer.lVal = 6 * 9; // should be done in base 13!
cop->Put(propname, 0, &answer, 0);
VariantClear(&answer);

SysFreeString(propname);

在这段代码中，我们使用一个系统串来命名我们要获取或设置的属性(即我们规划中的项目)，并使用一个OLE VARIANT结构(可以存储任何类型的数据)作为数据值。调用该接口的Get方法将使驱动程序得到一个QUERY_ALL_DATA或QUERY_SINGLE_INSTANCE请求。调用Put方法会使驱动程序得到CHANGE_SINGLE_INSTANCE或CHANGE_SINGLE_ITEM请求。不要试图精确预测哪种类型的IRP将支持Get或Put调用，因为WMI生产者可以自由地以任何方便的形式包装发往驱动程序的数据请求。

接收事件通知

为了接收发生的WMI事件通知，应用程序必须对感兴趣的事件进行寄存操作。为此你必须用一种称为WQL(WMI Query Language)的语言陈诉一个询问。WQL就象关系数据库中的SQL语言。例如，为了表示要接收WMIEXTRA_EVENT通知，你应该提交下面查询：

IWbemServices* services;
BSTR query = SysAllocString(L"select * from WMIEXTRA_EVENT");
BSTR language = SysAllocString(L"WQL");
IEnumWbemClassObject* enumerator = NULL;
HRESULT hr = services->ExecNotificationQuery(language,
                         query,
                         WBEM_FLAG_FORWARD_ONLY | WBEM_FLAG_RETURN_IMMEDIATELY,
                         NULL,
                         &enumerator);
SysFreeString(language);
SysFreeString(query);
if (SUCCEEDED(hr))
{
  ...
  enumerator->Release();
}

ExecNotificationQuery的flag参数必须明确指出。

一旦有了这个枚举接口，你就可以调用它的Next方法查询事件。例如：

IWbemClassObject* cop = NULL;
DWORD junk;-
hr = enumerator->Next(1000, 1, &cop, &junk);

在Next调用中我们指出，为了获得一个事件我们将等待1000毫秒。如果事件已经发生或在等待期间发生，Next将返给我们一个IWbemClassObject(引用)指针(回忆一下前面关于驱动程序如何发出一个由WMI类实例代表的事件)，因此我们可以调用该对象的Get方法来查询事件的属性。

在真正的应用程序中，你应该使用ExecNotificationQueryAsync代替ExecNotificationQuery调用。异步形式的查询允许你提供一个IWbemObjectSink接口，WMI可以在事件发生时调用该接口。其它信息请参考平台SDK。

调用方法例程

调用一个方法例程仅需要少数简单的语句，见下面摘自WMIEXTRA测试程序的代码：

IWbemServices* services;			//  developed as shown earlier
IWbemClassObject* result = NULL;
BSTR pmethod = SysAllocString(L"AnswerMethod");
BSTR objpath;					//  more about this later
IWbemClassObject* inarg;			//  ditto

HRESULT hr = services->ExecMethod(objpath, pmethod, 0, NULL, inarg, &result, NULL);
...

result->Release();
SysFreeString(pmethod);
<more cleanup>

ExecMethod函数用于调用方法例程。inarg对象用于为输入参数提供值。方法例程调用的结果将出现在result对象中。

如果不是为了两个复杂因素，象上面这样调用一个方法例程是烦琐的。首先你必须用完整的路径名(即ExecMethod的objpath参数)寻址你要的对象。此外你还要构造并初始化一个WMI对象以便为该方法调用提供输入参数。见下面摘自WMIEXTRA测试程序的片段：

IWbemServices* services;  //  someone gives me this
BSTR pclass = SysAllocString(L"wmiextra_method");
BSTR objpath = NULL;
HRESULT hr;

IEnumWbemClassObject* enumerator = NULL;
hr = services->CreateInstanceEnum(pclass, <etc.>);
if (SUCCEEDED(hr))
{
  IWbemClassObject* instance = NULL;
  ULONG junk;
  hr = enumerator->Next(INFINITE, 1, &instance, &junk);
  if (SUCCEEDED(hr))
  {
    VARIANT instname;
    BSTR propname = SysAllocString(L"InstanceName");
    hr = instance->Get(propname, 0, &instname, NULL, NULL);
    SysFreeString(propname);
    if (SUCCEEDED(hr))
    {
      WCHAR fullpath[256];
      WCHAR escapedname[256];
      <code to double backslashes in instname>
      swprintf(fullpath, L"%ws.InstanceName=\"%s\"", pclass, escapedname);
      objpath = SysAllocString(fullpath);
      VariantClear(&instname);
    }
    instance->Release();
  }
  enumerator->Release();
}

尤其是用于穿过实例名的代码(文中已忽略)，把每个反斜线都改为双反斜线。我认为，这应该是IWbemClassObject接口上的一个方法，调用它可以获得对象的全路径名。这样的方法用于隐藏其它系统部件用于构造实例名的算法。但没人接受我的观点。

平台SDK文档中描述了如何创建输入参数(即ExecMethod的inarg参数)。下面是我在WMIEXTRA中的做法：

IWbemClassObject* cop = NULL; //  the class, not an instance
hr = services->GetObject(pclass, 0, NULL, &cop, NULL);
if (SUCCEEDED(hr))
{
  IWbemClassObject* iop = NULL; //  another class
  hr = cop->GetMethod(pmethod, 0, &iop, NULL);
  if (SUCCEEDED(hr))
  {
    IWbemClassObject* inarg = NULL; //  an instance of iop
    hr = iop->SpawnInstance(0, &inarg);
    if (SUCCEEDED(hr))
    {
      BSTR argname = SysAllocString(L"TheAnswer");
      VARIANT argval;
      argval.vt = VT_I4;
      argval.lVal = 41;
      hr = inarg->Put(argname, 0, &argval, 0);
      SysFreeString(argname);

      <the actual call to ExecMethod>

      inarg->Release();
    }
    iop->Release();
  }
  cop->Release();
}

这段代码使用数据字典来获得输入参数类(iop变量)的描述。然后它创建并初始化输入参数类的一个实例(inarg变量)作为调用方法例程的参数。

我没有检查，但我假定MFC会提供一个改进的方法来做这些事情。


Windows 98兼容问题

一个良好的驱动程序应该支持WMI，但原始版本的Windows 98没有提供对WMILIB的支持，因此你需要为WMILIB函数提供一个VxD桩。写一个VxD桩参见附录A。(附录中对WDMSTUB VxD的讨论没有包括WMILIB函数，但它描述了构造方法)

大量的bug困扰着原始版本的Windows 98对WMI的支持。Windows 98的改进版(第二版和SP1)修复了这些bug。(或者是其中某些。我有一个笔记本电脑，可以正常运行Windows 2000和WMI。但在我的桌面系统上，WMI不能在Windows 98下初始化)。标准安装方式的默认选择是不安装WMI。为了支持WMI，你可以使用控制面板中的添加/删除程序项，选择Windows安装程序标签，在Internet工具类中选择安装Web基础的企业管理项。


第十一章：USB总线

USB总线的成功关键是使用户感到了使用USB设备的方便。即插即用(PnP)概念的使用使某些硬件的安装过程得到了简化。然而，那些遗留设备如串行口、并行口、键盘，和麦克风的配置问题仍然折磨着用户。USB总线通过提供一种能连接大量自识别中低速设备的统一连接方法解决了这一问题。USB规范中指出，适合迁移到USB上的硬件限定于那些低速到中速的外设，包括调制解调器、应答机、扫描仪，和个人数字助理。即这些设备的数据传输速率低于12Mb/sec，并且能通过单一的PC接口被系统软件识别。

尽管这本书仅关注软件，但USB的某些电子和机械概念对于软件开发者仍然很重要。从普通用户的观点来看，USB的主要特征是每个设备都有同样的四芯电缆及标准化插头，都可以插入到PC后部的标准化插座或与PC相连的某个HUB上。另外，你还可以随意拔插USB设备而不管应用程序是否打开以及是否会造成电气损害。本章覆盖了下面两个主题。

  • 编程架构
  • 使用总线驱动程序

在本章的第一部分，我将描述USB的编程架构。这个架构包含了多个概念，包括把设备附着到计算机上的层次方法，电源管理的通用方案，硬件的多层次描述符的自识别标准。USB标准把定时帧(frame)分解成数据包(packet)，从而实现设备和主机之间的数据传输。最后，在主机和设备上的端点之间，USB支持四种数据传输模式。一种模式称为等时传输(isochronous)，它可以每隔1毫秒(ms)传输固定量无错误校验的数据。其它模式为：控制传输、批量传输，和中断传输，它们仅能传输少量(64字节或更少)带有错误校验的数据。

在本章的第二部分，我将描述WDM驱动程序模型对于USB设备的附加特征。USB驱动程序高度依赖其总线驱动程序(USBD.SYS)，而不直接使用HAL函数与硬件通信。USB驱动程序为了向其硬件设备发送一个请求，首先创建一个USB请求块(URB)，然后把URB提交到总线驱动程序。例如，为了配置一个USB设备，驱动程序需要提交几个URB来读取各种描述符或发送命令，最后由USBD.SYS把请求送到总线上。

USB规范的官方修订版为1.1。该规范和许多其它文档均由USB委员会制定，他们的工作组位于http://www.usb.org/developers/。Don Anderson的《Universal Serial Bus System Architecture》(Addison-Wesley, 1997)，以另一种形式概括了大部分USB规范。

关于例子程序

Anchor Chips公司(http://www.anchorchips.com/)向我提供了一份他们的EZ-USB开发包。Anchor Chips公司的USB芯片集是以一个改进的8051微处理器以及一个核心逻辑为基础来实现USB规范中的一些低级协议。开发板上还含有附加的外部存储器、一个UART、一组按钮、一组LED读出器，用于开发和调试基于Anchor Chips软件框架的8051固件(firmware)。Anchor Chips芯片集的一个关键特征是可以通过USB下载固件。对于一个像我这样恐惧硬件，特别是EEPROM编程的程序员来说，这个特征简直太好了！

随书光盘中的USB例子驱动程序演示了最简单的USB设备以及它所能执行的一些任务。如果你碰巧有一个Anchor Chips的开发包，就可以用真正的固件实验这些例子程序。每个例子都包含一个WDM驱动程序(在SYS子目录)、一个Win32测试程序(在TEST子目录)、和一个固件程序(在EZUSB子目录)。你可以按照每个例子中的HTM文件的指导编译这些部件或者直接安装光盘中的已编译好的版本。

有一点需要指出，Anchor Chips提供的是简化版本的8051开发工具，版权由Keil Elektronik GmbH保留，如果你需要开发真正的固件，必须从Keil得到工具软件全部功能的使用许可。Keil现在提供改进的32位8051开发工具uVision2。


编程架构

USB可以使程序员在不了解总线电气特性的情况下写出主机和设备的驱动软件。USB规范的第五章“USB数据流模型”和第九章“USB设备框架”描述了许多对于驱动程序作者有用的特征。在这节中，我将概述这些章。

设备层次

图11-1演示了一个简单的USB配置拓扑。USB主控制器与其它I/O设备一样直接连接到系统总线上。操作系统与主控制器通信使用I/O口或内存寄存器，通过普通的中断信号，系统可以接受主控制器的事件通知。主控制器连接一棵USB设备树。一种称为hub的设备作为其它设备的连接点。多个hub能以菊链方式连接，可以连接到USB规范中定义的最大深度。其它设备，如照相机、麦克风、键盘等等，直接连到hub上。为了精确地表达概念，USB使用术语function来描述非hub设备。

图11-1.jpg (18704 字节)

图11-1. USB设备层次结构

高速和低速设备

USB规范中定义了两种设备，高速设备和低速设备。低速设备以1.5Mb/sec速率通信，高速设备以12Mb/sec速率通信。hub能用电子方式区分这两种设备。发生在总线上的通讯通常都是高速的，hub一般不向低速设备发送数据。操作系统把任何发往低速设备的消息前加上一个前导包，这将使hub临时降为低速，并完成低速设备的数据发送。

电源

USB电缆中含有两条电源线。hub可以为连接在其上的设备提供电力，USB规范限定了总线供电设备所消耗的电流。这个限定会因为设备插入可供电hub或与最近可供电hub的远近而改变。另外，USB允许设备有睡眠状态，处于睡眠状态下的设备仅消耗非常低的电力并足够支持唤醒和配置信号。另外，设备还可以不用总线电力而直接使用外接电源。

USB设备可以唤醒睡眠中的系统。当系统进入节能状态后，操作系统也把USB总线置入节能状态。一个有远程唤醒特征的设备能发出唤醒信号，信号首先被送到hub，然后又被hub送到主控制器，最后由主控制器送往系统。

USB设备设计者应该知道唤醒特征的一些限制。首先，远程唤醒功能仅能工作在有高级电源配置接口(ACPI)BIOS的计算机上，而不能工作在早期的系统上，这些早期系统或者支持APM或者根本就没有电源管理特征。另一个限定在驱动程序的通知上。WDM提供了一种方法—— IRP_MN_WAIT_WAKE电源管理IRP，当设备唤醒系统时该IRP被发往驱动程序。然而，当USB设备从节能状态恢复到正常状态时，而此时系统又处于正常工作状态，该IRP将不出现。

设备中有什么？

一般，每个USB设备有一个或多个配置(configuration)来控制其行为，如图11-2。使用多配置的一个原因是对操作系统的支持，例如，系统BIOS可以使用一个简单的配置而操作系统中的驱动程序则使用另一个更复杂的配置。

图11-2.jpg (24684 字节)

图11-2. USB设备的配置、接口、端点。

设备的每个配置中都含有一个或更多的接口(interface)，接口指出软件应该怎样访问硬件。接口的概念与第二章(“WDM驱动程序的基本结构”)中讨论的连接命名设备中的概念类似，即支持相同接口的设备本质上可以互换，因为它们以相同的方式响应相同的命令。另外，接口一般都有替换设置(alternate setting)以适应不同的带宽需求。

设备的接口露出一个或多个端点(endpoint)，端点作为通信管道的一个终点。图11-3显示了一个多层次结构的通信模型，它表明了端点和管道所扮演的角色。在最低一级，USB电缆把主控制器与设备的总线接口连接起来。在第二级，一个控制管道把系统软件与逻辑设备连接起来。在第三级，一捆数据管道把客户软件与一组接口连接起来，这些接口组成设备的function。信息实际上是在图中两侧垂直流动，但把它理解为在这些分层的管道中水平流动更清晰。

图11-3.jpg (36793 字节)

图11-3. USB的多层次通信模型

一组由Microsoft提供的驱动程序占据了图中系统软件方块中的底部。这些驱动程序包括主控制器驱动程序(OPENHCI.SYS或者UHCD.SYS)，hub驱动程序(USBHUB.SYS)，和一个类驱动程序(USBD.SYS)，由控制器驱动程序使用。为了方便，我把USBD下面的所有驱动程序看成一个整体，我们的驱动程序主要就是与这个整体进行交互，它们管理着硬件连接和管道通信。WDM驱动程序，就是你和我将要写的，占据系统软件方块中的顶部。广义地说，WDM驱动程序的工作就是把客户软件的请求翻译成USBD能执行的事务(transaction)。客户软件处理实际的设备功能。例如，一个图象生成程序占据的客户软件槽可能与一个静态图象function(如数码相机)对应。

信息流动

USB定义了四种数据传输方式，如表11-1所示。它们的不同之处有：单个事务能携带的数据量(下一段将解释术语“事务”transaction)、能否保证特定的周期或延迟，能否自动校正错误。每种传输方式对应特定类型的端点。实际上，给定类型的端点(控制、批量、中断、等时)总是使用对应类型的传输。

表11-1. 数据传输类型

传输类型 | 描述 | 纠错 | 包容量(字节) | 延迟保证？
控制 | 用于发送和接收USB定义的结构化信息 | 是 | 少于或等于8,16,32,64 | 尽最大能力保证不延迟
批量 | 用于发送或接收小块无结构数据 | 是 | 少于或等于8,16,32,64 | 无
中断 | 与批量管道相似，但包括一个最大延迟 | 是 | 少于或等于64 | 以保证的最小速率轮询
等时 | 用于发送或接收有周期保证的大块无结构数据 | 否 | 少于或等于1023 | 每1毫秒帧中的固定部分

端点除了传输类型外还有其它几个属性。其中一个属性是单一事务中端点能够提供或消耗的最大数据量。控制和批量端点必须指定某个离散值，而中断和等时端点能指定少于或等于最大值的任何值。端点的另一个属性是传输方向，输入(数据从设备到主控制器)或输出(数据从主控制器到设备)。最后，每个端点都有一个端点号，其中包含输入输出方向，作为端点的地址使用。

当主控制器要求设备执行某些多少有些规则的功能时，USB使用一个轮检(polling)协议。当一个设备需要向主控制器发送数据时，主控制器必须注意到并且向要发送数据的设备发出一个请求使其发送数据。即USB设备不用传统方式中断主计算机，而是提供中断端点，主机周期轮检中断端点。

信息打包

当客户程序通过USB管道发送或接收数据时，它首先调用Win32 API，调用最终将使function的驱动程序收到一个IRP。而驱动程序的工作就是把客户的请求引导到有正确端点的管道上。它把请求提交到总线驱动程序，总线驱动程序再把请求分解成多个事务(transaction)，然后这些事务被送往总线。总线上的信息流以每毫秒一帧数据的形式流动。总线驱动程序必须安排好多个事务以使它们能被装入同一帧中，图11-4显示了这个过程。

图11-4.jpg (26953 字节)

图11-4. 信息流中的事务和帧模型

在USB中，事务由一个或多个阶段(phase)组成。阶段有令牌(token)、数据(data)、握手(ack)三种类型。根据不同的类型，事务有一个令牌阶段、一个可选的数据阶段、和一个可选的握手阶段组成，如图11-5所示。在令牌阶段，主控制器向所有已配置的设备广播该令牌包。令牌包中含有设备地址，通常还有端点号，仅有被寻址的设备才会处理事务；当事务寻址设备时，任何设备都不读写总线。在数据阶段，数据被放到总线上。对于输出事务，主机把数据放到总线上，而被寻址的设备消耗这些数据。对于输入事务，情况相反，设备把数据放到总线上由主机消耗。在握手阶段，由设备或主机把握手包放到总线上，包中含有状态信息。当设备发出握手包时，ACK包指出成功地接收了信息，NAK包指出忙并且不试图接收信息，STALL包指出事务被正确接收但在逻辑上无效。当主机发送握手包时，它仅能发送ACK包。

图11-5.jpg (8831 字节)

图11-5. 总线事务的阶段

你也许会注意到，没有发出握手包的事务就代表“在这个事务中出现了一个传输错误”。正在等待握手包的一方应认为缺少握手包可能是发生了错误并重试刚才的事务。USB的设计者确信这种错误很少发生，由于重试而造成的偶然延迟对于总线吞吐量不会有大的影响。

关于设备寻址的更多内容

上文提到所有被配置的设备都接收每个事务中的电子信号。这几乎是正确的，但一个真正的程序员应该了解更详细的内容。当一个USB设备第一次接入时，它使用默认地址(碰巧是0，你不必知道)响应。然后，某个电子信号通知总线驱动程序有一个新设备插入总线，于是总线驱动程序找出一个未用的设备地址并发送一个控制事务告诉“0号设备”什么才是它的真实地址。这之后，设备就放弃使用默认地址0，而用真实地址来应答。

另一些细节涉及到低速设备。低速设备的电气特征使它在面对传输率是其八倍的总线时会发生信号混乱。另外，低速设备的电缆没有电磁屏蔽，遇到高速信号会产生电子干扰。结果，低速设备在大部分时间并不连接到总线上。当总线进行高速传输时，hub就隔离低速设备。当主机需要与低速设备通信时，它就发送一个特殊的前导包把总线临时切换到低速操作，所以，低速设备只能看到低速事务，而高速设备能看到所有事务。

端点的状态

一般，端点可以进入图11-6中所示的任何一种状态。在空闲状态中，端点准备处理主机发起的新事务。在忙碌状态中，端点忙于处理手中的事务而不能接受新事务。如果主机试图向一个忙端点发起新事务(不是控制端点，下段描述)，设备将用NAK握手包响应，主机将在以后重发刚才的包。如果设备发现自己内部出现错误(不包括传输错误)，设备将在当前事务中发送一个STALL握手包然后进入停止状态。控制端点在接到新事务时会自动从停止状态恢复，但其它三种端点必须由主机明确发送一个清除特征(feature)控制请求后才能从停止状态中恢复。

图11-6.jpg (10148 字节)

图11-6. 端点的三种状态

控制传输

控制传输在主机和控制端点之间传送控制信息。例如，在操作系统配置USB设备过程中，有一步就是用控制传输从物理设备中读出各种描述符(descriptor)，配置过程的另一步是利用控制传输设置一种可能的配置并使能一个或多个接口。控制传输是一种纠错传输，在遇到传输错误时重试三遍，如果错误仍存在就放弃传输并向上层软件报告错误。如表11-1中所指出的，控制端点所能指定的最大数据传输长度为8、16、32、64字节。一个单独的事务可以包含少于最大数据传输长度的数据但不能多于。

控制事务在USB中具有最高优先级。设备必须处理控制事务。此外，总线驱动程序为控制事务保留了10%的带宽。对于轻量级的负载，主机能确保在1毫秒内完成一个控制事务。对于重量级的负载，未完成的控制传输将被强制到下一帧中传输，因此会产生一些延迟。

每个USB设备至少应有一个编号为0的控制端点以响应控制事务的输入输出。严格地讲，端点应属于配置，但端点0是一个例外，因为它是设备默认控制管道的终点。端点0甚至在设备被配置前就被激活而不管其它端点是否有效。除了端点0，一个设备没有必要拥有另外的控制端点(尽管USB规范中允许有这种可能)，因为端点0对于大部分控制请求都可以很好地完成。如果你定义了一个厂商专用的请求并且该请求不能在一帧中完成，你应该创建额外的控制端点以防止设备接收器被新事务抢先。

每个控制事务包括一个SETUP令牌，之后可以带一个可选的数据阶段和一个握手阶段，在握手阶段设备会发出ACK包或者STALL包，或者根本就不响应，如图11-7。设备必须在任何时间都能接受控制传输，并且不能用NAK响应控制端点忙。向控制端点发送一个无效请求将导致STALL响应，但当设备再次接收到一个SETUP包时会自动清除停止状态。这个特殊的STALL在USB规范(8.5.2.4段)中被称为协议停止(protocol stall)。

图11-7.jpg (12980 字节)

图11-7. 控制传输中的三个阶段

开始控制传输的SETUP令牌由8个字节组成，如图11-8所示。在下面这个和后面的数据结构图中，我按照数据在USB导线上的传输顺序列出其中的数据字节，但是每个字节中的位是以高位开始。在传输线上的字节传输是从低位开始的，但主机软件和设备固件通常以相反的方向使用字节。Intel计算机和USB总线协议使用小结尾的数据表达，即低位字节占用低地址。许多USB芯片集包括Anchor Chips芯片集使用8051微处理器，这个处理器使用大结尾的数据表达。固件程序设计者必须注意这一点。

图11-8.jpg (25441 字节)

图11-8. SETUP令牌的内容

SETUP令牌的第一字节指出信息流的方向、请求的类型、和接收控制传输的实体类型。请求类型有标准类型(由USB规范定义)、类类型(由USB工作组定义的一类设备)，和厂商类型(由设备制造者定义)。控制请求可以发到整个设备、一个指定接口、一个指定的端点，或者到设备上的厂商专有实体。SETUP令牌中的第二字节指出具体的请求，其类型由第一字节指出。表11-2列出了当前定义的标准请求。对于类专用请求，应参考对应设备类的说明(http://www.usb.org/developers/)。设备制造者可以自由定义自己专用的请求代码。例如，Anchor Chips使用请求代码A0h表示从主机下载固件程序。

注意
改变端点状态的控制请求将发往控制端点而不是发往被改变状态的端点。

表11-2 标准设备请求

请求代码 | 符号名 | 描述 | 可能的接受者
0 | GET_STATUS | 获得状态信息 | 任何
1 | CLEAR_FEATURE | 清除一个双态特征 | 任何
2 |  | (保留) |
3 | SET_FEATURE | 设置一个双态特征 | 任何
4 |  | (保留) |
5 | SET_ADDRESS | 设置设备地址 | 设备
6 | GET_DESCRIPTOR | 取设备、配置，或串描述符 | 设备
7 | SET_DESCRIPTOR | 设置一个描述符(可选) | 设备
8 | GET_CONFIGURATION | 取当前配置索引 | 设备
9 | SET_CONFIGURATION | 设置一个新的当前配置 | 设备
10 | GET_INTERFACE | 取当前的alt接口索引 | 接口
11 | SET_INTERFACE | 使能alt接口设置 | 接口
12 | SYNCH_FRAME | 报告同步帧号 | (等时)端点

SETUP包中其余的内容包括一个value代码(其含义与具体的请求相关)、一个index值(当控制请求寻址端点或接口时，index值指出具体地址)、一个length域(指出控制事务的数据阶段要传输的数据量。为0表示该事务没有数据阶段)。

我并不想详细描述各种控制请求的细节；你可以参考USB规范的第9.4段。但我确实想简要地讨论一下设备特征(feature)这个概念，USB认为，属于设备的任何可寻址实体都可以有1个特征位。已有两个特征实现了标准化。

DEVICE_REMOTE_WAKEUP特征 —— 属于设备整体的特征，它指出当外部事件发生时设备能否用这个能力(如果有)唤醒计算机。主机软件(尤其是总线驱动程序)通过SET_FEATURE或CLEAR_FEATURE命令可以允许或禁止这个特征，用value代码为1来指出允许唤醒特征。DDK中用符号名USB_FEATURE_REMOTE_WAKEUP代表这个特征码。

ENDPOINT_HALT特征 —— 属于单个端点的特征，指出端点是否处于停止状态。主机软件可以向端点发送SET_FEATURE命令并且value为0(指定ENDPOINT_HALT)来强制端点进入停止状态。当然，管理端点的设备固件也能使端点进入停止状态。如果端点被固件设为停止状态，主机软件(总线驱动程序)也可以发送一个value为0的CLEAR_FEATURE命令清除该端点的停止状态。DDK使用符号名USB_FEATURE_ENDPOINT_STALL代表这个特征码。

USB规范中并没有为厂商使用的设备或端点的特征代码规定范围。为了避免后来的标准化问题，你应该避免定义设备级或端点级的特征，而仅应该定义自己的厂商类型控制事务。在本章后面我将演示一个例子驱动程序(FEATURE)，它能控制Anchor Chips开发板上的7段LED显示。我在这个例子中定义了一个编号为42的接口级特征。(USB现在为电源管理定义了一些接口级特征，所以你不应效仿我的例子，除非你想知道特征是怎样工作的)

尽管Anchor Chips(现在是Cypress Semiconductor)的EZ-USB可以容易地从驱动程序下载新固件，但你不应在产品级设备上使用这个特征。你需要开发一个“Loader”驱动程序和一个function驱动程序，前者用于下载固件到USB设备，后者用于管理设备。不幸的是，如果计算机进入节能状态，操作系统也把USB总线置入节能状态，设备的固件内容将丢失。当回到正常电源状态时，系统卸载function驱动程序并重新装载“Loader”驱动程序，Loader再次下载固件并重新装载function驱动程序。而节能前的function驱动程序的状态信息将全部丢失，所以当你使用这个芯片时应考虑把固件放到EEPROM上。

批量传输

批量(bulk)传输能在主机和批量端点间一次传输最多64字节的数据。就象控制传输一样，批量传输是纠错传输，不同的是批量传输没有任何延迟保证。如果主机发现帧中除了预定带宽外还有剩余，它就把等待的批量传输送往总线。

图11-9显示了组成批量传输的各个阶段。传输开始于表明传输方向的IN或OUT令牌，这些令牌同时还指定目标设备和端点。对于输出事务，后面是一个数据阶段，把数据从主机送到设备，最后在握手阶段设备向主机提供状态反馈。如果端点正忙不能接收新数据，它就在握手阶段发出一个NAK包，主机以后会重试该事务。如果端点处于停止(STALL)状态，它就在握手阶段发出一个STALL包，主机在重试前必须清除端点的停止状态。如果端点正确地接收并处理了数据，将在握手阶段返回一个ACK包。最后一种情况是端点因为某种原因没有正确地接收数据并且在握手阶段也没有发出应答包，主机将检查端点是否应答，并且自动重试三次原事务。

IN令牌后面是一个批量输入传输。如果端点将数据准备好，设备将把数据发往主机，主机或者回应一个ACK包指出数据无误地接收，或者保持沉默以指出发生了某种错误。如果主机查出一个错误，ACK的缺发将使设备数据保持有效，之后主机将重试输入操作；如果端点忙或停止，设备将会在数据阶段发出NAK或STALL握手包而不是数据。NAK指出主机应在以后重试该输入操作，STALL需要主机先清除端点的停止特征再重试输入操作。

图11-9.jpg (28347 字节)

图11-9. 批量传输和中断传输中的阶段

中断传输

中断(interrupt)传输在总线操作和涉及设备方面上几乎与批量传输完全相同。它能把最大64字节的数据无误地在主机和中断端点间传输。中断传输和批量传输仅有的不同是它必须考虑延迟。中断端点需指定一个范围在1-255毫秒内的询查周期。主机保留足够的带宽以确保在指定频率上直接向中断端点发出IN或OUT事务。

注意
注意USB设备不生成异步中断，它们总是响应循检，Microsoft的主控制器驱动程序把中断端点描述符中指定的循检周期简化为不大于32的2的幂。例如，一个指定循检周期为31毫秒的端点实际上是以16毫秒进行循检。指定周期在32-255毫秒之间的循检实际上是以32毫秒为循检周期。

等时传输

等时(isochronous)传输可以在一个总线帧内最多传输1023字节数据。因为等时传输有周期保证，因此特别适用于时间敏感的数据传输，如音频信号，但这种周期保证是有代价的，等时传输在数据出错时不会自动重试。USB设计者假定等时数据流的接收者允许偶尔的数据丢失。

等时传输由IN或OUT令牌阶段后跟一个数据阶段组成。因为不进行数据纠错，所以等时传输没有握手阶段。如图11-10。

图11-10.jpg (9789 字节)

图11-10. 等时传输中的阶段

主机为等时传输和中断传输保留最多90%的带宽。实际上，系统软件应提前保留带宽以确保总线能适应所有活动的设备。

描述符

USB设备硬件中的数据结构称为描述符，可以被主机软件识别。表11-3列出了不同种类的描述符。每个描述符开始于一个两字节的头，头中指出该描述符的字节长度(包括头)和描述符类型。事实上，如果我们不讨论特殊的串描述符，描述符的长度对于相同的描述符类型是固定的，即所有给定类型的描述符长度相同。在描述符头中保存明确的长度便于描述符将来的扩展。

下面我将使用DDK(位于USB100.H文件中)中定义的数据结构描述每一种描述符。官方的解释见USB规范第9.6段。

表11-3. 描述符表

描述符类型 | 描述
设备 | 描述整个设备
配置 | 描述设备的一个配置
接口 | 描述配置中的一个接口
端点 | 描述接口中的一个端点
串 | 一个Unicode串，该串用自然语言描述设备、配置、接口，或端点
电源配置 | 描述电源管理能力
接口电源 | 描述function的电源管理能力

设备描述符

每个设备都有一个唯一的设备描述符，它向主机软件标识该设备。主机使用GET_DESCRIPTOR控制事务直接从设备的0号端点读取该描述符。该描述符在DDK中的定义如下：

typedef struct _USB_DEVICE_DESCRIPTOR {
    UCHAR bLength;
    UCHAR bDescriptorType;
    USHORT bcdUSB;
    UCHAR bDeviceClass;
    UCHAR bDeviceSubClass;
    UCHAR bDeviceProtocol;
    UCHAR bMaxPacketSize0;
    USHORT idVendor;
    USHORT idProduct;
    USHORT bcdDevice;
    UCHAR iManufacturer;
    UCHAR iProduct;
    UCHAR iSerialNumber;
    UCHAR bNumConfigurations;
} USB_DEVICE_DESCRIPTOR, *PUSB_DEVICE_DESCRIPTOR;

设备描述符的bLength域应等于18，bDescriptorType域应等于1以指出该结构是一个设备描述符。bcdUSB域包含该描述符遵循的USB规范的版本号(以BCD编码)。现在，设备可以使用值0x0100或0x0110来指出它所遵循的是1.0版本还是1.1版本的USB规范。

bDeviceClass、bDeviceSubClass、bDeviceProtocol指出设备类型。可能的设备类代码在USB规范中定义，本书所包括的类代码在表11-4中列出。USB委员会的独立设备类工作组为每个设备类定义子类和协议代码。例如，音频类有控制、流，和MIDI流接口的子类代码。大容量存储类为使用各种端点的数据传输方法定义了协议代码。

你可以为整个设备或仅在接口级指定一个类，但事实上，设备类、子类、和协议代码通常出现在接口描述符中而不是出现在设备描述符中。USB还为特殊类型的设备指定了一个特殊的设备类代码255。厂商可以使用这个代码指出其设备是一个非标准设备，并且在子类和协议域中填入厂商设定的值。例如，使用Anchor Chips芯片集的设备，其设备描述符中的类、子类，和协议代码全为255。

设备描述符的bMaxPacketSize0域给出了默认控制端点(端点0)上的数据包容量的最大值。每个设备都必须提供0号控制端点，由于USB规范并没有为该端点规定一个单独的端点描述符，所以这个域是唯一描述这个端点的地方。因为这个域在设备描述符的偏移7处，所以即使该端点使用最小的传输容量(8字节)主机也能读到这个域。一旦主机知道了端点0的最大传输容量，它就可以分块读出整个描述符。

idVendor和idProduct域指定厂商代码和厂商专用的产品标识。bcdDevice指出设备的发行版本号(0x0100对应版本1.0)。当主机软件检测设备时，这三个域决定了主机应该装入哪个驱动程序。USB组织提供厂商代码，厂商提供产品代码。

表11-4. USB设备类代码

符号名 | 类代码 | 描述
USB_DEVICE_CLASS_RESERVED | 0 | 指出类代码存在于接口描述符中
USB_DEVICE_CLASS_AUDIO | 1 | 操作模拟或数字音频、语音、和其它与声音相关的数字设备
USB_DEVICE_CLASS_COMMUNICATIONS | 2 | 电讯设备，如调制解调器、电话、应答机，等等
USB_DEVICE_CLASS_HUMAN_INTERFACE | 3 | 人类接口设备，如键盘、鼠标、麦克风，等等
USB_DEVICE_CLASS_MONITOR | 4 | 显示器
USB_DEVICE_CLASS_PHYSICAL_INTERFACE | 5 | 含有实时物理反馈的人类接口设备，如力反馈游戏杆
USB_DEVICE_CLASS_POWER | 6 | 执行电源管理的人类接口设备，如电池、充电器，等等
USB_DEVICE_CLASS_PRINTER | 7 | 打印机
USB_DEVICE_CLASS_STORAGE | 8 | 大容量存储设备，如磁盘和CD-ROM
USB_DEVICE_CLASS_HUB | 9 | USB hubs
USB_DEVICE_CLASS_VENDOR_SPECIFIC | 255 | 厂商定义的设备类
设备版本号

Microsoft强烈建议厂商在硬件或固件的修订版中增加设备版本号以便于下层软件更新。一般，厂商发行新版本硬件的同时也带来驱动程序的修订版。同样，硬件升级应该使以前用于掩盖硬件错误的软件补丁或过滤器驱动程序无效。系统的自动升级机制在遇到一个版本不明确的硬件时会失败。

iManufacturer、iProduct、和iSerialNumber域指向一个串描述符，该串描述符用人类可读的语言描述设备生产厂商、产品、和序列号。这些串是可选的，0值代表没有描述串。如果你在设备上放入了序列号串，Microsoft建议应使每个物理设备的序列号唯一。

最后，bNumConfigurations指出该设备能实现多少种配置。Microsoft的驱动程序仅工作于设备的第一种配置(1号配置)。我将在后面解释怎样使用设备的多个配置。

配置描述符

每个设备有一个或多个配置描述符，它们描述了设备能实行的各种配置方式。DDK中定义的配置描述符结构如下：

typedef struct _USB_CONFIGURATION_DESCRIPTOR {
    UCHAR bLength;
    UCHAR bDescriptorType;
    USHORT wTotalLength;
    UCHAR bNumInterfaces;
    UCHAR bConfigurationValue;
    UCHAR iConfiguration;
    UCHAR bmAttributes;
    UCHAR MaxPower;
} USB_CONFIGURATION_DESCRIPTOR, *PUSB_CONFIGURATION_DESCRIPTOR;

bLength和bDescriptorType域应为9和2，即是一个9字节长的配置描述符。wTotalLength域为该配置描述符长度加上该配置内所有接口和端点描述符长度的总和。通常，主机在发出一个GET_DESCRIPTOR请求并正确接收到9字节长的配置描述符后，就会再发出一个GET_DESCRIPTOR请求并指定这个总长度。第二个请求把这个大联合描述符传输回来。(注意这个传输不会把不属于这个配置的接口和端点描述符传输回来)

bNumInterfaces指出该配置有多少个接口。这个值仅是接口的数量，不包括接口中的替换设置。这个域的目的是允许多功能设备存在，如一个有定位器(类似于鼠标)的键盘。

bConfigurationValue域是该配置的索引值。你可以用这个值在SET_CONFIGURATION控制请求中选择这个配置。注意设备的第一个配置描述符的索引为1。(选择配置0将把设备置入未配置状态，此时仅有端点0是活动的)

iConfiguration域是一个可选的串描述符索引，指向描述该配置的Unicode字符串。此值为0表明该配置没有串描述符。

bmAttributes字节包含描述该配置中设备电源和其它特性的的位掩码，见表11-5。未提到的位为未来标准保留。一个支持远程唤醒的配置应有远程唤醒属性位。该字节最高两位与MaxPower域一起描述配置中的电源特性。基本上，设置了最高位的配置都同时在MaxPower域中指出要从USB总线上获取的最大电流量(单位为2mA)。使用外接电源的配置需要设置自供电属性位。

表11-5. 配置的属性位

位掩码 | 符号名称 | 描述
80h | USB_CONFIG_BUS_POWERED | 废弃 -- 应总为1
40h | USB_CONFIG_SELF_POWERED | 该配置为自供电
20h | USB_CONFIG_REMOTE_WAKEUP | 该配置有远程唤醒特征

接口描述符

每个配置有一个或多个接口描述符，它们描述了设备提供功能的接口。

DDK中的接口描述符结构定义如下：

typedef struct _USB_INTERFACE_DESCRIPTOR {
    UCHAR bLength;
    UCHAR bDescriptorType;
    UCHAR bInterfaceNumber;
    UCHAR bAlternateSetting;
    UCHAR bNumEndpoints;
    UCHAR bInterfaceClass;
    UCHAR bInterfaceSubClass;
    UCHAR bInterfaceProtocol;
    UCHAR iInterface;
} USB_INTERFACE_DESCRIPTOR, *PUSB_INTERFACE_DESCRIPTOR;

bLength和bDescriptorType域应为9和4。bInterfaceNumber和bAlternateSetting是索引值，用在SET_INTERFACE控制事务中以指定要激活的接口。这些值可以是任意的，但习惯上，配置中的接口号从0开始，每个接口中的替换设置也是从0开始的。

bNumEndpoints域指出该接口有多少个端点，不包括端点0，端点0被认为是总存在的，并且是接口的一部分。

bInterfaceClass、bInterfaceSubClass、和bInterfaceProtocol域描述了接口提供的功能。一个非0的类代码应该是上面讨论的类代码中的一个，同时子类和协议代码也必须有与该类相类似的含义。这些域不允许有0值，0值为未来标准保留。

最后，iInterface是一个串描述符的索引，0表示该接口无描述串。

端点描述符

接口可以没有或有多个端点描述符，它们描述了处理事务的端点。

DDK中定义的端点描述符结构如下：

typedef struct _USB_ENDPOINT_DESCRIPTOR {
    UCHAR bLength;
    UCHAR bDescriptorType;
    UCHAR bEndpointAddress;
    UCHAR bmAttributes;
    USHORT wMaxPacketSize;
    UCHAR bInterval;
} USB_ENDPOINT_DESCRIPTOR, *PUSB_ENDPOINT_DESCRIPTOR;

bLength和bDescriptorType域应为7和5。bEndpointAddress域编码端点的方向性和端点号，如图11-11所示。例如，地址值0x82指出该端点是一个端点号为2的IN端点，而0x02地址指出一个端点号为2的OUT端点。除了端点0，两个端点可以有相同的端点号但方向相反。

图11-11.jpg (9696 字节)

图11-11. 端点描述符地址域的位排列

bmAttributes的低两位指出端点的类型。见表11-6。其余的位保留给将来使用，应设为0。

表11-6. 端点类型代码

符号名称 | 值 | 端点类型
USB_ENDPOINT_TYPE_CONTROL | 0 | 控制端点
USB_ENDPOINT_TYPE_ISOCHRONOUS | 1 | 等时端点
USB_ENDPOINT_TYPE_BULK | 2 | 批量端点
USB_ENDPOINT_TYPE_INTERRUPT | 3 | 中断端点

wMaxPacketSize值指出该端点在一个事务中能传输的最大数据量，表11-1列出了每种端点的可能值。

中断端点和等时端点描述符还有一个用于指定循检间隔时间的bInterval域，时间单位为毫秒。这个数指出主机以多长的周期循检这些端点，以查看是否有可能的数据传输。对于中断端点，该值的范围为1到255毫秒，代表两次循检间的最大时间间隔。对于等时端点，该值应该为1，因为帧周期固定为1毫秒，每帧都应该循检。

串描述符

设备、配置、端点描述符都可以包含一个指向人类可读串的指针。串本身以USB串描述符的形式保存在设备中，串字符使用Unicode编码。

DDK中的串描述符结构声明如下：

typedef struct _USB_STRING_DESCRIPTOR {
    UCHAR bLength;
    UCHAR bDescriptorType;
    WCHAR bString[1];
} USB_STRING_DESCRIPTOR, *PUSB_STRING_DESCRIPTOR;

bLength值根据串数据长度可变。bDescriptorType域的值应为3。bString域包含串数据本身。注意，串的空结尾符应包含在描述符长度内。

USB设备可以以多种语言支持串描述符。0号串描述符是设备所支持语言的标识符数组，它不是一个真正的串描述符。(用在其它描述符中代表无描述串指定的值0，在这里被用于索引串语言标识数组)语言标识与Win32程序中使用的LANGID相同。例如，0x409是美国英语的代码。如果向设备询问串描述符的某种未支持语言的表达，其结果在USB规范中没有规定，所以你应该先读取串0数组。关于语言标识请参考USB规范第9.6.5段。

其它描述符

USB是一个发展的规范，我能表达的仅仅是它的概要。例如，一个USB工作组最近完成了一个接口级的电源管理规范。你可以在USB网站上读到这些内容，并且DDK头文件USB100.H中包含了这个规范的定义。时间不允许我探索这些新功能。幸好，对于WDM驱动程序的作者并不需要了解这些，解释接口特征描述符是hub驱动程序的任务而不是WDM功能驱动程序或过滤器驱动程序的任务。


使用总线驱动程序

与传统PC总线(如PCI总线)设备的驱动程序相比，USB设备驱动程序从不直接与硬件对话。相反，它仅靠创建URB(USB请求块)并把URB提交到总线驱动程序就可完成硬件操作。

可以把USBD.SYS看作是接受URB的实体，向USBD的调用被转化为带有主功能代码为IRP_MJ_INTERNAL_DEVICE_CONTROL的IRP。然后USBD再调度总线时间，发出URB中指定的操作。

在这一节，我将讲述功能驱动程序使用USBD的方法。首先讲述怎样建立并提交一个URB，然后再讨论设备配置和重配置的方法。最后，我将概述如何管理四种通信管道。

初始化请求

为了创建一个URB，你首先应该为URB分配内存，然后调用初始化例程把URB结构中的各个域填入请求要求的内容，例如，当你为响应IRP_START_DEVICE请求而配置设备时，首要的任务就是读取该设备的设备描述符。下面代码片段可以完成这个任务：

USB_DEVICE_DESCRIPTOR dd;
URB urb;
UsbBuildGetDescriptorRequest(&urb,
                 sizeof(_URB_CONTROL_DESCRIPTOR_REQUEST),
                 USB_DEVICE_DESCRIPTOR_TYPE,
                 0,
                 0,
                     &dd,
                 NULL,
                 sizeof(dd),
                 NULL);

我们首先声明一个局部变量urb来保存URB数据结构。URB在USBDI.H中声明，是一个多子结构的联合，我们将使用UrbControlDescriptorRequest子结构，它的类型是_URB_CONTROL_DESCRIPTOR_REQUEST。使用自动变量当然是可以的，但你事先必须了解系统堆栈上是否有足够的空间装下最大的URB，并且在这个URB被完成前，你不能离开当前函数，否则自动变量将被释放。

当然，你还可以在系统堆上为URB动态地分配内存：

PURB urb = (PURB) ExAllocatePool(NonPagedPool, sizeof(_URB_CONTROL_DESCRIPTOR_REQUEST));
if (!urb)
    return STATUS_INSUFFICIENT_RESOURCES;
UsbBuildGetDescriptorRequest(urb, ...);
...
ExFreePool(urb);

UsbBuildGetDescriptorRequest看上去象一个正常的服务例程，实际上它是一个宏(在USBDLIB.H中声明)，用于生成“读描述符请求子结构”各个域的初始化语句。在DDK头文件中定义了这些创建各种URB的宏，见表11-7。因为它们是预处理宏，所以在它们的参数中应避免使用有侧效的表达式。

表11-7. 用于创建URB的辅助宏

辅助宏 | 事务类型
UsbBuildInterruptOrBulkTransferRequest | 对中断或批量端点的输入和输出
UsbBuildGetDescriptorRequest | 端点0的GET_DESCRIPTOR控制请求
UsbBuildGetStatusRequest | 对设备、接口、端点的GET_STATUS请求
UsbBuildFeatureRequest | 对设备、接口、端点的SET_FEATURE或CLEAR_FEATURE请求
UsbBuildSelectConfigurationRequest | SET_CONFIGURATION
UsbBuildSelectInterfaceRequest | SET_INTERFACE
UsbBuildVendorRequest | 任何厂商定义的控制请求

在前面的代码片段中，我们指定把设备描述符信息接收到一个局部变量(dd)中，其地址和长度我们在参数中都提供了。涉及到数据传输的URB可以指定两种方式的非分页数据缓冲区。你可以象上面代码那样指定缓冲区的虚拟地址和长度。另一种方法是提供一个内存描述符列表(MDL)，但事先必须调用MmProbeAndLockPages函数对这个MDL进行探测并锁定(probe-and-lock)。

关于URB的更多内容

在内部，总线驱动程序总是用MDL描述数据缓冲区。如果你指定一个缓冲区地址，USBD就自己创建一个MDL。如果你也用MDL指定缓冲区，USBD会调用MmGetSystemAddressForMdl函数取得缓冲区的原虚拟地址，然后用这个虚拟地址再创建一个描述相同缓冲区的MDL！

URB还有一个连接域Urblink，它是USBD内部用于向主控制器驱动程序一次提交多个URB所使用的域。各种用于初始化URB的宏函数都有一个参数对应这个连接域，理论上你可以为其指定一个值，但我们总是用NULL来填充，原因是“接连URB”这个概念还没有全部实现。实际上，在此处填值可能导致系统崩溃。

发送URB

创建完URB后，你需要创建并发送一个内部I/O控制(IOCTL)请求到USBD驱动程序，USBD驱动程序位于驱动程序层次结构的低端。在大多数情况下，你需要等待设备回应，可以使用下面辅助函数：

NTSTATUS SendAwaitUrb(PDEVICE_OBJECT fdo, PURB urb)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  KEVENT event;										<--1
  KeInitializeEvent(&event, NotificationEvent, FALSE);
  IO_STATUS_BLOCK iostatus;
  PIRP Irp = IoBuildDeviceIoControlRequest(IOCTL_INTERNAL_USB_SUBMIT_URB,		<--2
                       pdx->LowerDeviceObject,
                       NULL,
                       0,
                       NULL,
                       0,
                       TRUE,
                       &event,
                       &iostatus);
  PIO_STACK_LOCATION stack = IoGetNextIrpStackLocation(Irp);				<--3
  stack->Parameters.Others.Argument1 = (PVOID) urb;
  NTSTATUS status = IoCallDriver(pdx->LowerDeviceObject, Irp);				<--4
  if (status  == STATUS_PENDING)
  {
    KeWaitForSingleObject(&event, Executive, KernelMode, FALSE, NULL);
    status = iostatus.Status;
  }
  return status;
}
  1. 我们将等待URB完成，所以我们必须先创建一个内核事件对象。这与我在第六章"即插即用"中使用的辅助函数ForwardAndWait类似。
  2. 创建内部IOCTL请求最简单的的方法是调用IoBuildDeviceIoControlRequest函数，第一个参数(IOCTL_INTERNAL_USB_SUBMIT_URB)指出I/O控制代码。第二个参数(pdx->LowerDeviceObject)指定接收请求的设备对象；IoBuildDeviceIoControlRequest使用这个指针来决定需要接收多少个堆栈单元。接下来的四个参数描述输入输出缓冲区，提交这种URB并不需要这些信息，所以例子中将它们置成NULL或0值。第七个参数为TRUE，它指出我们创建的是IRP_MJ_INTERNAL_DEVICE_CONTROL请求而不是IRP_MJ_DEVICE_CONTROL请求。最后两个参数指出等待URB完成的事件和一个接收该操作最终状态的结构IO_STATUS_BLOCK。
  3. 被提交的URB的地址被填入Parameters.Others的Argument1域。对于普通的IOCTL请求，该偏移对应OutputBufferLength域。
  4. 我们用IoCallDriver把请求发送到下一层驱动程序。USBD将处理该URB请求并完成，然后I/O管理器将那个IRP删除并置事件信号。由于我们没有提供自己的完成例程，所以不能确定I/O管理器在所有可能的完成情况下都置事件信号。我们仅当低级派遣例程返回STATUS_PENDING时才等待那个事件。
注意
需要强调的是驱动程序把URB包装成一个带有IRP_MJ_INTERNAL_DEVICE_CONTROL主功能码的普通IRP。为了使上层过滤器驱动程序可以发送自己的URB，每个USB设备驱动程序应有一个可以把IRP传递到下一层的派遣函数。

URB返回的状态

当你提交一个URB到USB总线驱动程序时，你最终将收到一个描述该操作结果的NTSTATUS代码。其间，总线驱动程序使用另一组类型名为USBD_STATUS的状态代码。这些代码并不是NTSTATUS代码。

当USBD完成一个URB时，它就把URB的UrbHeader.Status域设置为某个USBD_STATUS值。DDK中的URB_STATUS宏可以简化这个值的存取：

NTSTATUS status = SendAwaitUrb(fdo, &urb);
USBD_STATUS ustatus = URB_STATUS(&urb);
...

并没有特别的协议关于保留这个值以及把它传递回应用程序，你可以随意处理这个值。

配置

USB总线驱动程序自动检测新插入的USB设备。然后它读取设备内的设备描述符以查明插入的是何种设备，描述符中的厂商和产品标识以及其它描述符一同决定具体安装哪一个驱动程序。

配置管理器调用驱动程序的AddDevice函数。AddDevice做所有你已知的任务：创建设备对象，把设备对象连接到驱动程序堆栈上，等等。最后，配置管理器向驱动程序发送一个即插即用请求IRP_MN_START_DEVICE。在第六章中，我已经讲述了怎样处理这个请求，通过调用一个名为StartDevice的辅助函数并传递一些参数，这些参数描述了赋予设备的经过转换的和未经转换的I/O资源。有一个好消息，你不必再为USB驱动程序的I/O资源担心了，因为它们不用任何I/O资源。所以你可以按下面框架写一个StartDevice辅助函数：

NTSTATUS StartDevice(PDEVICE_OBJECT fdo)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  <configure device>
  return STATUS_SUCCESS;
}

这里我可以直接说配置设备而不是配置硬件，因为你不用再涉及I/O端口、中断、DMA适配器对象，或者任何其它面向资源的元素(我在第七章中描述过)。

驱动程序在哪里？

我将在第十二章中详细讨论WDM驱动程序的安装，为了配合本章内容我先在这里简单介绍一下。让我们假设设备的厂商标识和产品标识分别为0x547和0x102A。USB使用了许多方法来帮助操作系统定位驱动程序(或驱动程序集)，包括设备上的设备描述符、配置描述符，以及接口描述符。对于有厂商和产品标识的设备，配置管理器首先在注册表中查找名为USB\VID_0547&PID_102A的设备。如果注册表中没有这个表项，配置管理器将触发“新硬件向导”来寻找该设备的INF文件。新硬件向导向用户询问INF文件的位置，然后安装驱动程序并填写注册表。一旦配置管理器找到了注册表表项，它就可以动态地装载驱动程序。

StartDevice的执行过程大致如下，首先为设备选择一个配置。如果你的设备象大多数设备一样，应该仅有一种配置。选定了某个配置后，接着应该选择配置中的一个或多个接口。顺便说一下，支持多接口的设备并不少见。选定了一个配置和一组接口后，你应该向总线驱动程序发送配置选择URB。最后，总线驱动程序向设备发出命令使能选定的配置和接口。总线驱动程序负责创建管道和用于访问管道的句柄，管道提供功能驱动程序与选定接口端点之间的通信，它同时还创建配置句柄和接口句柄。你可以从完成的URB中提取这些句柄并保存为以后使用。至此，设备的配置过程全部结束。

多功能设备

如果你的设备有一个配置和多个接口，Microsoft的总线驱动程序将自动把这种设备当作一个组合的，或多功能设备。你必须为每个接口提供一个功能驱动程序，通过在INF中用接口类和子类替换厂商标识和产品标识。总线驱动程序为每个接口创建一个物理设备对象(PDO)，这样，PnP管理器就可以装载独立的驱动程序。当任何一个功能驱动程序读取配置描述符时，总线驱动程序就提供一个修改后的仅描述一个接口的描述符。关于INF文件中的设备标识的各种可能形式请参考第十二章。

读取配置描述符

可以把固定大小的配置描述符看做是某个可变长结构的头，这个可变长结构描述了一个配置和该配置的所有接口、以及每个接口的所有端点。见图11-12。

图11-12.jpg (22466 字节)

图11-12. 配置描述符的结构

因为硬件不允许直接访问接口和端点描述符，所以必须把整个可变长结构读入一个连续的内存区。不幸的是，在读之前我们并不能确切地知道这个结构有多长。下面代码演示了如何用两个URB来读取配置描述符：

ULONG iconfig = 1;
URB urb;
USB_CONFIGURATION_DESCRIPTOR tcd;
UsbBuildGetDescriptorRequest(&urb,
                 sizeof(_URB_CONTROL_DESCRIPTOR_REQUEST),
                 USB_CONFIGURATION_DESCRIPTOR_TYPE,
                 iconfig,
                 0,
                 &tcd,
                 NULL,
                 sizeof(tcd),
                 NULL);
SendAwaitUrb(fdo, &urb);
ULONG size = tcd.wTotalLength;
PUSB_CONFIGURATION_DESCRIPTOR pcd = (PUSB_CONFIGURATION_DESCRIPTOR) ExAllocatePool(NonPagedPool, size);
UsbBuildGetDescriptorRequest(&urb,
                 sizeof(_URB_CONTROL_DESCRIPTOR_REQUEST),
                 USB_CONFIGURATION_DESCRIPTOR_TYPE,
                 iconfig,
                 0,
                 pcd,
                 NULL,
                 size,
                 NULL);
SendAwaitUrb(fdo, &urb);
...
ExFreePool(pcd);

在这段代码中，我们先发出一个URB，把配置描述符读取到临时描述符缓冲区tcd，我指定配置号1，它是第一个配置描述符。这个描述符包含了可变长结构的总长度(wTotalLength)。然后我们分配该长度的内存块，并发出了第二个URB读取整个描述符。最后，pcd变量指向包含整个配置信息的内存区。(注意，真正的应用代码应该有错误检测)

如果设备仅有一个配置，我们可以直接前进到下一步，并使用刚读出的描述符集。否则，我们需要枚举所有配置(即，使变量iconfig从1循环到设备描述符中bNumConfigurations指定的值)并使用某种筛选算法来提取它们。

读配置描述符时使用的描述符索引来自于设备固件响应GET_DESCRIPTOR控制请求的结果，而不受USB规范的限定。尽管配置号从1开始，但配置描述符应从0开始数。

选择配置

通过向设备发送一系列控制命令来选择配置及使能需要的接口。我们将使用USBD_CreateConfigurationRequestEx函数创建这种URB。其中的一个参数指向要使能的接口描述符数组。下一步就是准备这个数组。

读串描述符

设备内部可以保存一些字符串。在USB42的例子中，设备保存了厂商、产品等的英语描述符。我写了下面读串描述符的辅助函数。

NTSTATUS GetStringDescriptor(PDEVICE_OBJECT fdo, UCHAR istring, PUNICODE_STRING s)
{
  NTSTATUS status;
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  URB urb;

  UCHAR data[256];

  if (!pdx->langid)
  {
    UsbBuildGetDescriptorRequest(&urb,
                     sizeof(_URB_CONTROL_DESCRIPTOR_REQUEST),
                     USB_STRING_DESCRIPTOR_TYPE,
                     0,
                 0,
                 data,
                 NULL,
                 sizeof(data),
                 NULL);
    status = SendAwaitUrb(fdo, &urb);
    if (!NT_SUCCESS(status))
      return status;
    pdx->langid = *(LANGID*)(data + 2);
  }

  UsbBuildGetDescriptorRequest(&urb,
                   sizeof(_URB_CONTROL_DESCRIPTOR_REQUEST),
                   USB_STRING_DESCRIPTOR_TYPE,
                   istring,
                   pdx->langid,
                   data,
                   NULL,
                   sizeof(data),
                   NULL);
  status = SendAwaitUrb(fdo, &urb);
  if (!NT_SUCCESS(status))
    return status;

  ULONG nchars = (data[0] - 2) / 2;
  PWSTR p = (PWSTR) ExAllocatePool(PagedPool, data[0]);
  if (!p)
    return STATUS_INSUFFICIENT_RESOURCES;
  memcpy(p, data + 2, nchars*2);
  p[nchars] = 0;
  s->Length = (USHORT) (2 * nchars);
  s->MaximumLength = (USHORT) ((2 * nchars) + 2);
  s->Buffer = p;

  return STATUS_SUCCESS;
}

这个函数的特殊之处在于串提取URB的初始化。我们除了要提供串索引之外，还要提供一个标准的LANGID语言标识符。这个语言标识符与在Win32应用程序中使用的语言标识符相类似。正如我以前提到的，设备中的串可以以多种自然语言来表达，而串描述符0则包含着该设备所支持的语言标识符列表。为了确保请求一个被设备支持的语言，我首先读取了串0，并选择第一个被支持的语言。在我的例子驱动程序中，语言标识符总是0x409，即美国英语。然后，USBD.SYS把这个语言标识符和串索引作为取描述符请求操作的参数发往设备。最后，由设备本身决定究竟返回哪个串。

GetStringDescriptor函数的输出是一个UNICODE_ STRING结构。最终，你可以调用RtlFreeUnicodeString函数释放这个串。

我在USB42例子中使用GetStringDescriptor函数生成关于设备的额外调试信息。例如，StartDevice中有这样的代码：

UNICODE_STRING sd;
if (pcd->iConfiguration && NT_SUCCESS(GetStringDescriptor(fdo, pcd->iConfiguration, &sd)))
{
  KdPrint(("USB42 - Selecting configuration named %ws\n", sd.Buffer));
  RtlFreeUnicodeString(&sd);
}

实际上我使用了一个宏(KdPrint)，这样我就不用重复输入相同的代码。

回想一下，当我们读配置描述符时，我们同时也把该配置的所有接口描述符读入了调整后的内存区中。这个内存区域包含了一系列描述符：一个配置描述符、一个接口描述符及其所有端点，下一个接口描述符和其所有端点，等等。有一种方法可以从这些描述符中提取你感兴趣的接口，总线驱动程序提供的USBD_ParseConfigurationDescriptorEx函数可以简化这个过程：

PUSB_INTERFACE_DESCRIPTOR pid;
pid = USBD_ParseConfigurationDescriptorEx(pcd,
                      StartPosition,
                      InterfaceNumber,
                      AlternateSetting,
                      InterfaceClass,
                      InterfaceSubclass,
                      InterfaceProtocol);

在这个函数中，pcd是那个复合配置描述符的地址。StartPosition可以是配置描述符的地址，或者是起始搜索的描述符地址。其余参数指定描述符搜索准则。值-1表示在搜索中不使用该准则。下面这些准则可以在搜索中指定：

  • InterfaceNumber
  • AlternateSetting
  • InterfaceClass
  • InterfaceSubclass
  • InterfaceProtocol

当USBD_ParseConfigurationDescriptorEx返回一个接口描述符时，你应该把它保存在USBD_INTERFACE_LIST_ENTRY数组元素的InterfaceDescriptor成员中。然后，你跨过这个接口描述符前进到下一个接口。这个接口表项数组最后将成为调用USBD_CreateConfigurationRequestEx函数的一个参数，数组元素由下面结构定义：

typedef struct _USBD_INTERFACE_LIST_ENTRY {
    PUSB_INTERFACE_DESCRIPTOR InterfaceDescriptor;
    PUSBD_INTERFACE_INFORMATION Interface;
} USBD_INTERFACE_LIST_ENTRY, *PUSBD_INTERFACE_LIST_ENTRY;

当初始化数组中的表项时，你应该把要使能接口的接口描述符地址赋予InterfaceDescriptor成员，并把Interface成员置NULL。你应该为每个接口定义一个表项，并在最后加入一个InterfaceDescriptor为NULL的结束表项。在我的USB42例子中，我事先知道仅存在一个接口，所以使用下面代码创建接口列表：

PUSB_INTERFACE_DESCRIPTOR pid = USBD_ParseConfigurationDescriptorEx(pcd,
                                    pcd,
                                    -1,
                                    -1,
                                    -1,
                                    -1,
                                    -1);
USBD_INTERFACE_LIST_ENTRY interfaces[2] = {
  {pid, NULL},
  {NULL, NULL},
};

即，析取配置描述符并定位在第一个遇到的接口描述符上。然后我定义了仅有两个元素的数组来描述这个接口。

如果你要支持多功能设备，则需要使能多个接口，在循环中重复析取调用。如下例：

ULONG size = (pcd->bNumInterfaces + 1) * sizeof(USBD_INTERFACE_LIST_ENTRY);		<--1
PUSBD_INTERFACE_LIST_ENTRY interfaces = (PUSBD_INTERFACE_LIST_ENTRY) ExAllocatePool(NonPagedPool, size);
RtlZeroMemory(interfaces, size);
ULONG i = 0;
PUSB_INTERFACE_DESCRIPTOR pid = (PUSB_INTERFACE_DESCRIPTOR) pcd;
while ((pid = USBD_ParseConfigurationDescriptorEx(pcd, pid, ...)))			<--2
  interfaces[i++].InterfaceDescriptor = pid++;						<--3
  1. 首先为接口列表申请内存并清空该内存区。
  2. 析取接口。在第一次循环中，pid指向配置描述符。在下一次循环中，它指向前一个调用返回的接口描述符。
  3. 初始化指向接口描述符的指针。如果调用USBD_ParseConfigurationDescriptorEx时遇到一个符合指定准则的接口描述符，将返回指向这个描述符的指针。

配置过程的下一步是创建URB并发送到设备：

PURB selurb = USBD_CreateConfigurationRequestEx(pcd, interfaces);

除了创建URB，USBD_CreateConfigurationRequestEx还初始化USBD_INTERFACE_LIST表项中的Interface成员，使其指向一个USBD_INTERFACE_INFORMATION结构。这个结构与URB在同一物理内存区，在调用ExFreePool释放URB时该结构一同被释放。该结构描述如下：

typedef struct _USBD_INTERFACE_INFORMATION {
  USHORT Length;
  UCHAR InterfaceNumber;
  UCHAR AlternateSetting;
  UCHAR Class;
  UCHAR SubClass;
  UCHAR Protocol;
  UCHAR Reserved;
  USBD_INTERFACE_HANDLE InterfaceHandle;
  ULONG NumberOfPipes;
  USBD_PIPE_INFORMATION Pipes[1];
} USBD_INTERFACE_INFORMATION, *PUSBD_INTERFACE_INFORMATION;

其中管道信息结构是我们在此真正关心的，因为结构中的其它域是在提交URB后由USBD填充的。管道结构描述如下：

typedef struct _USBD_PIPE_INFORMATION {
  USHORT MaximumPacketSize;
  UCHAR EndpointAddress;
  UCHAR Interval;
  USBD_PIPE_TYPE PipeType;
  USBD_PIPE_HANDLE PipeHandle;
  ULONG MaximumTransferSize;
  ULONG PipeFlags;
} USBD_PIPE_INFORMATION, *PUSBD_PIPE_INFORMATION;

这样，我们已经有了一组USBD_INTERFACE_LIST表项，每一项中的USBD_INTERFACE_INFORMATION结构又包含一组USBD_PIPE_INFORMATION结构。接下来的任务是填充管道信息结构中的MaximumTransferSize成员，如果不填充这个成员，USBD将使用默认值USBD_DEFAULT_MAXIMUM_TRANSFER_SIZE，其值等于DDK中的PAGE_SIZE。这个成员值与端点在单事务中的最大传输量(在一个总线事务中可以传输多少字节)或端点接收量(由设备上有多少有效内存决定)不直接相关。相反，它代表单一URB中能携带的最大数据量。这个值可以小于应用程序向设备或设备向应用程序发送的数据量的最大值，在这种情况下，驱动程序必须把应用程序的请求分成不大于这个值的小块。我们将在"管理批量传输管道"中再讨论这个问题。

提供最大传输量的原因是由于一个调度算法，主控制器驱动程序使用这个算法把URB请求分成总线帧中的事务。如果要发送大量数据，我们的数据可能占满整个帧而其它设备的数据会被挤出去。所以，通过为URB指定一个适当的单次传输最大值来均衡总线带宽的使用。

初始化管道信息结构可以参考下面代码：

for (ULONG ii = 0; ii < <number of interfaces>; ++ii)
{
  PUSBD_INTERFACE_INFORMATION pii = interfaces[ii].Interface;
  for (ULONG ip = 0; ip < pii->NumberOfPipes; ++ip)
    pii->Pipes[ip].MaximumTransferSize = <some constant>;
}
注意
USBD_CreateConfigurationRequestEx函数用USBD_ DEFAULT_MAXIMUM_TRANSFER_SIZE和0来初始化每个管道信息中的MaximumTransferSize成员和PipeFlags成员。

一旦你完成管道信息结构的初始化，就可以提交这个配置URB：

SendAwaitUrb(fdo, selurb);

寻找句柄

配置选择URB成功完成后，我们应该把一些句柄保存下来供以后使用：

  • URB成员UrbSelectConfiguration.ConfigurationHandle返回该配置句柄。
  • USBD_INTERFACE_INFORMATION结构中的InterfaceHandle返回接口句柄。
  • 每个USBD_PIPE_INFORMATION结构中都含有与每个端点对应的管道句柄PipeHandle。

例如，USB42例子在设备扩展中保存了两个句柄：

typedef struct _DEVICE_EXTENSION {
  ...
  USBD_CONFIGURATION_HANDLE hconfig;
  USBD_PIPE_HANDLE hpipe;
} DEVICE_EXTENSION, *PDEVICE_EXTENSION;

pdx->hconfig = selurb->UrbSelectConfiguration.ConfigurationHandle;
pdx->hpipe = interfaces[0].Interface->Pipes[0].PipeHandle;
ExFreePool(selurb);

至此，那个配置选择URB就不再需要了，应该删除。

关闭设备

当驱动程序接到一个IRP_MN_STOP_DEVICE请求时，应该把设备置成未配置状态(配置选择号0)，创建并提交一个含NULL配置指针的配置选择URB即可以达到这个目的：

URB urb;
UsbBuildSelectConfigurationRequest(&urb,
                   sizeof(_URB_SELECT_CONFIGURATION),
                   NULL);
SendAwaitUrb(fdo, &urb);

管理批量传输管道

随书光盘中的两个例子程序演示了批量传输。第一个例子名为USB42。设备上有一个批量输入端点，该端点在每次读它时都返回一个常量值42。读数据代码如下：

URB urb;
UsbBuildInterruptOrBulkTransferRequest(&urb,
                       sizeof(_URB_BULK_OR_INTERRUPT_TRANSFER),
                       pdx->hpipe,
                       Irp->AssociatedIrp.SystemBuffer,
                       NULL,
                       cbout,
                       USBD_TRANSFER_DIRECTION_IN | USBD_SHORT_TRANSFER_OK,
                       NULL);
status = SendAwaitUrb(fdo, &urb);

这些代码运行在DeviceIoControl调用的处理程序中，所以IRP的SystemBuffer域指向应提交的数据。cbout变量是我们要填充的缓冲区的大小。

置USBD_TRANSFER_DIRECTION_IN标志或不置该标志分别表示对端点的读或写。你还可以选择性地指出另一个标志位USBD_SHORT_ TRANSFER_OK，它表明提交的数据量少于端点的最大传输数据量。

LOOPBACK例子要比USB42复杂些。该例子中有两个批量端点，一个是输入端点另一个是输出端点。我们首先向输出端点送出16384字节的数据，然后再从输入端点读回这些数据。驱动程序本身使用标准的IRP_MJ_READ和IRP_MJ_WRITE请求来实现数据传输。读写请求的处理比较简单，派遣函数仅把这些请求发到名为ReadWrite的函数。

NTSTATUS DispatchRead(PDEVICE_OBJECT fdo, PIRP Irp)
{
  return ReadWrite(fdo, Irp, TRUE);
}

NTSTATUS DispatchWrite(PDEVICE_OBJECT fdo, PIRP Irp)
{
  return ReadWrite(fdo, Irp, FALSE);
}

NTSTATUS ReadWrite(PDEVICE_OBJECT fdo, PIRP Irp, BOOLEAN read)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  NTSTATUS status = IoAcquireRemoveLock(&pdx->RemoveLock, Irp);
  if (!NT_SUCCESS(status))
    return CompleteRequest(Irp, status, 0);
  ...
  IoMarkIrpPending(Irp);
  IoSetCompletionRoutine(Irp, (PIO_COMPLETION_ROUTINE) OnReadWriteComplete, ...);
  IoCallDriver(...);
  return STATUS_PENDING;
}

简单地说，ReadWrite先申请了删除锁(remove lock)，创建一个URB来做批量传输，安装了一个完成例程，最后向总线驱动程序发出URB。

LOOPBACK向总线驱动程序提交请求的总体策略是把读写IRP改变为包含有URB的IRP_MJ_INTERNAL_DEVICE_CONTROL请求，并把这个修改后的IRP送到下层驱动程序。对于我们和我们上面的驱动程序来说，该IRP看起来就是一个IRP_MJ_READ或IRP_MJ_WRITE，但对于我们下面的驱动程序，该IRP是一个内部控制请求。那个完成例程(completion routine)将再次提交这个IRP以执行大数据传输的其余部分。除去真正LOOPBACK例子中的错误检测，下面就是ReadWrite程序和与其关联的完成例程：

struct _RWCONTEXT : public _URB
{
  ULONG_PTR va;											<--1
  ULONG length;
  PMDL mdl;
  ULONG numxfer;
};

NTSTATUS ReadWrite(PDEVICE_OBJECT fdo, PIRP Irp, BOOLEAN read)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  NTSTATUS status = IoAcquireRemoveLock(&pdx->RemoveLock, Irp);					<--2
  if (!NT_SUCCESS(status))
    return CompleteRequest(Irp, status, 0);
  USBD_PIPE_HANDLE hpipe = read ? pdx->hinpipe : pdx->houtpipe;					<--3

  LONG haderr;											<--4
  if (read)
    haderr = InterlockedExchange(&pdx->inerror, 0);
  else
    haderr = InterlockedExchange(&pdx->outerror, 0);
  if (haderr && !NT_SUCCESS(ResetPipe(fdo, hpipe)))
    ResetDevice(fdo);

  PRWCONTEXT ctx = (PRWCONTEXT) ExAllocatePool(NonPagedPool, sizeof(RWCONTEXT));
  RtlZeroMemory(ctx, sizeof(RWCONTEXT));

  ULONG length = Irp->MdlAddress ? MmGetMdlByteCount(Irp->MdlAddress) : 0;			<--5
  if (!length)
  {
    IoReleaseRemoveLock(&pdx->RemoveLock, Irp);
    return CompleteRequest(Irp, STATUS_SUCCESS, 0);
  }
  ULONG_PTR va = (ULONG_PTR) MmGetMdlVirtualAddress(Irp->MdlAddress);

  ULONG urbflags = (read ? USBD_TRANSFER_DIRECTION_IN : USBD_TRANSFER_DIRECTION_OUT);

  ULONG seglen = length;									<--6
  if (seglen > MAXTRANSFER)
    seglen = (ULONG_PTR) PAGE_ALIGN(va) + PAGE_SIZE - va;

  PMDL mdl = IoAllocateMdl((PVOID) va, PAGE_SIZE, FALSE, FALSE, NULL);				<--7
  IoBuildPartialMdl(Irp->MdlAddress, mdl, (PVOID) va, seglen);

  UsbBuildInterruptOrBulkTransferRequest(ctx,							<--8
                         sizeof(_URB_BULK_OR_INTERRUPT_TRANSFER),
                     hpipe,
                     NULL,
                     mdl,
                     seglen,
                     urbflags,
                     NULL);

  ctx->va = va + seglen;
  ctx->length = length - seglen;
  ctx->mdl = mdl;
  ctx->numxfer = 0;

  PIO_STACK_LOCATION stack = IoGetNextIrpStackLocation(Irp);
  stack->MajorFunction = IRP_MJ_INTERNAL_DEVICE_CONTROL;
  stack->Parameters.Others.Argument1 = (PVOID) (PURB) ctx;
  stack->Parameters.DeviceIoControl.IoControlCode = IOCTL_INTERNAL_USB_SUBMIT_URB;

  IoSetCompletionRoutine(Irp, (PIO_COMPLETION_ROUTINE) OnReadWriteComplete, (PVOID) ctx, TRUE, TRUE, TRUE);

  IoMarkIrpPending(Irp);
  status = IoCallDriver(pdx->LowerDeviceObject, Irp);
  return STATUS_PENDING;
}

NTSTATUS OnReadWriteComplete(PDEVICE_OBJECT fdo, PIRP Irp, PRWCONTEXT ctx)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  BOOLEAN read = (ctx->UrbBulkOrInterruptTransfer.TransferFlags & USBD_TRANSFER_DIRECTION_IN) != 0;
  ctx->numxfer += ctx->UrbBulkOrInterruptTransfer.TransferBufferLength;

  NTSTATUS status = Irp->IoStatus.Status;
  if (NT_SUCCESS(status) && ctx->length)							<--9
  {
    ULONG seglen = ctx->length;									<--10
    if (seglen > MAXTRANSFER)
      seglen = (ULONG_PTR) PAGE_ALIGN(ctx->va) + PAGE_SIZE - ctx->va;

    IoBuildPartialMdl(Irp->MdlAddress, ctx->mdl, (PVOID) ctx->va, seglen);

    ctx->UrbBulkOrInterruptTransfer.TransferBufferLength = seglen;				<--11

    PIO_STACK_LOCATION stack = IoGetNextIrpStackLocation(Irp);
    stack->MajorFunction = IRP_MJ_INTERNAL_DEVICE_CONTROL;
    stack->Parameters.Others.Argument1 = (PVOID) (PURB) ctx;
    stack->Parameters.DeviceIoControl.IoControlCode = IOCTL_INTERNAL_USB_SUBMIT_URB;
    IoSetCompletionRoutine(Irp, (PIO_COMPLETION_ROUTINE) OnReadWriteComplete, (PVOID) ctx, TRUE, TRUE, TRUE);

    ctx->va += seglen;
    ctx->length -= seglen;

    IoCallDriver(pdx->LowerDeviceObject, Irp);							<--12
    return STATUS_MORE_PROCESSING_REQUIRED;
  }

  if (NT_SUCCESS(status))									<--13
    Irp->IoStatus.Information = ctx->numxfer;
  else
  {
    if (read)
      InterlockedIncrement(&pdx->inerror);
    else
      InterlockedIncrement(&pdx->outerror);
  }

  ExFreePool(ctx->mdl);
  ExFreePool(ctx);
  IoReleaseRemoveLock(&pdx->RemoveLock, Irp);

  return status;
}
  1. ReadWrite需要创建一个能与OnReadWriteComplete共享的URB，另外它还需要一个上下文信息来跟踪操作的进展。RWCONTEXT结构可以实现这两个目的。除了URB，该结构还包含va，用户模式缓冲区当前部分的虚拟地址；length，该操作中的余量计数；mdl，描述传输当前段的内存描述符表；numxfer，传输量的累加计数。
  2. 在这里我们请求了删除锁，释放删除锁的IoReleaseRemoveLock调用在完成例程中执行。
  3. 这里是ReadWrite需要区分读写请求的很少几处。在这里，我们获得了传输管道的句柄。
  4. 不管是输入管道还是输出管道都应该有一个最近错误记录，如果错误发生，设备扩展中的inerror或outerror将被置位。在进行一个新操作前，我们需要把有错误的管道重置。如果它仍不能工作，我们就重置整个设备。我将在下一段解释ResetPipe和ResetDevice辅助函数。
  5. 该驱动程序在AddDevice例程中声明使用DO_DIRECT_IO缓冲方法，所以IRP中的指针指向一个内存描述符列表，该表描述用户模式缓冲区的锁定内存页。习惯上我们从MDL中获得传输长度，而不是从堆栈单元中。
  6. 我们以块方式执行操作，块的大小不能大于一页。处理页对齐的缓冲区可以提高操作性能，我把第一个传输用于传输不足一页的零头，这样，其余的传输将全部是页对齐的。
  7. 我们为传输的每个阶段使用一个内存描述符表。分配完MDL后，调用IoBuildPartialMdl映射起始段。
  8. 现在我们可以为读或写的第一阶段创建并提交URB了。这里的关键任务并不是读或写，而是初始化下层驱动程序的堆栈单元。做这些的主要益处是当主读写IRP被取消时，我们不必处理其它辅助IRP的取消。
  9. 当第一次传输成功后，总线驱动程序调用IoCompleteRequest例程，然后我们的完成例程获得控制。如果还有数据没有传输，我们就用新缓冲区地址和长度再发一个URB。如果传输全部完成，完成例程将运行下去直至结束。注意，我们正在处理的IRP是主功能码为IRP_MJ_READ或IRP_MJ_WRITE的原始IRP。
  10. 这里，我们为下一阶段的传输设置部分MDL。因为该完成例程运行在任意线程上下文中，所以用户模式的虚拟地址没有用。然后调用IoBuildPartialMdl映射一个MDL的子集，这仅仅是从主MDL中复制物理页号，所以不必依靠任何特定的内存上下文。
  11. 这里，我们为下一阶段的传输设置URB和I/O堆栈单元。URB中需要改变的域仅有字节计数，URB的MDL指针、标志，等等都不变。(MDL自身会变，但其在内存中的位置不变) 我们需要完成下一个堆栈单元的初始化，因为IoCompleteRequest把其中大部分域设为0。
  12. 我们再次发出IRP到总线驱动程序并返回状态码STATUS_MORE_PROCESSING_REQUIRED，这将使IoCompleteRequest中的完成处理停止，当新的阶段完成时，完成例程再次获得控制。
  13. 在这里，读写请求将要全部完成，我们把IoStatus.Information域设置成总共传输的数据量，释放ReadWrite中分配的内存。释放删除自旋锁。

你可能注意到了，在这个例子中，完成例程并没有包含标准代码——有条件地调用IoMarkIrpPending，因为我们已经在ReadWrite中调用过，所以没有必要在完成例程中再次调用。

你可能也注意到了，当完成例程调用IoCallDriver发送URB后，却无条件地返回了STATUS_MORE_PROCESSING_REQUIRED状态。这里有一个重要的原因，如果总线驱动程序正常地接受了新的URB，它将向我们返回STATUS_PENDING。(USBD就是这样工作的，但这并不代表所有总线驱动程序都有这个特征) 在这种情况下，我们确实需要返回STATUS_MORE_PROCESSING_REQUIRED，因为此时我们希望IoCompleteRequest停止处理IRP，总线驱动程序将再次完成它。但是，如果总线驱动程序在新传输中失败，或者由于某种原因在派遣函数中就完成了该IRP，而派遣函数在返回(执行return语句)前要调用IoCompleteRequest，这样我们就进入了递归调用！所以，我们不应该对这个IRP做任何事，也不应该允许对IoCompleteRequest调用做任何初始化工作。在这里立即返回STATUS_MORE_PROCESSING_REQUIRED总是正确的。

错误恢复

当你用批量端点传输数据时，总线或总线驱动程序自动重试失败的传输。结果，如果URB表明成功完成，那么你可以确信数据已被正确传输。如果发生错误，你的驱动程序需要做某种恢复操作。第一步就是使处于停止状态的端点脱离停止状态，这样才可以再通信。下面是一个名为ResetPipe的辅助函数：

NTSTATUS ResetPipe(PDEVICE_OBJECT fdo, USBD_PIPE_HANDLE hpipe)
{
  URB urb;
  urb.UrbHeader.Length = (USHORT) sizeof(_URB_PIPE_REQUEST);
  urb.UrbHeader.Function = URB_FUNCTION_RESET_PIPE;
  urb.UrbPipeRequest.PipeHandle = hpipe;

  NTSTATUS status = SendAwaitUrb(fdo, &urb);
  return status;
}

如你所见，上面就是提交管道重置URB的全部过程。由于这个辅助例程间接地等待URB被完成，所以你必须在PASSIVE_LEVEL级上调用它。这个URB所做的工作用USB术语来说，就是清除该管道端点的ENDPOINT_HALT特征。

如果重置管道失败，则需要重置整个设备，使用ResetDevice函数：

VOID ResetDevice(PDEVICE_OBJECT fdo)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;

  KEVENT event;
  KeInitializeEvent(&event, NotificationEvent, FALSE);
  IO_STATUS_BLOCK iostatus;

  PIRP Irp = IoBuildDeviceIoControlRequest(IOCTL_INTERNAL_USB_RESET_PORT,
                       pdx->LowerDeviceObject,
                       NULL,
                       0,
                       NULL,
                       0,
                       TRUE,
                       &event,
                       &iostatus);
  if (!Irp)
    return;

  NTSTATUS status = IoCallDriver(pdx->LowerDeviceObject, Irp);
  if (status == STATUS_PENDING)
    KeWaitForSingleObject(&event, Executive, KernelMode, FALSE, NULL);
}

hub口重置命令可以使hub驱动程序在重新初始化设备的同时保留设备当前配置。如果该命令失败，hub驱动程序将返回STATUS_UNSUCCESSFUL。

管理中断管道

从设备方面看，中断管道与批量管道几乎完全相同。唯一的不同是，主机将以某个有保证的频率循检中断端点，除非中断端点有中断产生，否则设备将以NAK响应主机循检。报告中断事件时，设备以ACK应答并提供少量数据。

从驱动程序这边看，管理中断管道仅比管理批量管道稍复杂一点。当驱动程序读写批量管道时，它仅仅是创建URB并发送到总线驱动程序。但中断管道是用于向主机通知硬件事件，驱动程序需要始终维持一个未完成的读请求。我不推荐使用系统循检线程，因为电源管理问题将使独立线程的管理大大复杂化。维持一个读请求最好是使用我在LOOPBACK中使用的方法，用一个完成例程使URB循环提交。

USBINT例子演示了怎样用循环URB管理中断管道。我写了一些辅助例程来协助这个工作：

  • CreateInterruptUrb创建URB和一个关联的IRP。设备扩展中的域PollingUrb和PollingIrp指向这两个结构。我们在处理IRP_MN_START_DEVICE时调用这个函数。
  • DeleteInterruptUrb删除URB。无论何时，当我们关闭设备时就调用这个函数来释放IRP和URB占用的内存。
  • StartInterruptUrb发送一个URB来循检设备的中断端点。无论何时当我们激活设备时就调用这个函数。
  • OnInterrupt是一个标准的I/O完成函数，作为设备的中断服务例程。它看起来象这样：
NTSTATUS OnInterrupt(PDEVICE_OBJECT junk, PIRP Irp, PDEVICE_EXTENSION pdx)
{
  if (NT_SUCCESS(Irp->IoStatus.Status))
  {
    KdPrint(("USBINT - Interrupt!\n"));				<--1
    StartInterruptUrb(pdx->DeviceObject);			<--2
  }

  return STATUS_MORE_PROCESSING_REQUIRED;			<--3
}
  1. 这里进行中断处理。在USBINT例子中，这里的代码将增加未决中断计数或者完成一个未决的IOCTL，应用程序使用该IOCTL来了解中断何时发生。
  2. 在此，我们用相同的URB初始化另一轮中断循检。
  3. 返回STATUS_MORE_PROCESSING_REQUIRED，因为我们不希望IoCompleteRequest做任何事。
关于USBINT例子

随书光盘中的USBINT例子演示了如何管理一个有中断管道的设备。设备固件(在EZUSB子目录)定义了一个中断端点。每次当你按下然后再释放Anchor Chips开发板上的F1按钮时，固件就把LED显示的整数值加1并向中断端点送出4字节数据，在下一个主机IN事务时中断端点将交付这4个字节。驱动程序不断地尝试读该端点。测试程序发出DeviceIoControl调用来累加并显示所发生的中断数。按Ctrl+Break结束测试程序。设备上显示的数应与测试程序显示数据的低位相同。

重新初始化中断循检IRP的正确方法是调用IoReuseIrp而不是仅仅清除Cancel标志，不幸的是，Win98中没有实现IoReuseIrp函数，在Win98系统中装入WDMSTUB.VXD(包含该函数的一个实现)可以解决这个问题。

控制请求

如果你回头查看表11-2，会看到有11种标准控制请求类型。我们从不明确地发出SET_ADDRESS请求，这个请求是当总线驱动程序察觉到有新设备插入总线时发出的。而当我们的WDM驱动程序获得控制时，总线驱动程序已经为设备赋予了地址并且已经读出了设备描述符。我们已经讨论过读取描述符、设置配置或接口的控制请求。在这段中，我们将讨论其余的控制请求。

控制特征

如果我们想设置或清除设备、接口、端点的特征，可以提交一个特征URB。例如，下面代码设置了一个厂商定义的接口特征：

URB urb;
UsbBuildFeatureRequest(&urb,
URB_FUNCTION_SET_FEATURE_TO_INTERFACE,
FEATURE_LED_DISPLAY,
1,
NULL);
status = SendAwaitUrb(fdo, &urb);

函数的第二个参数指明我们设置或清除的特征是属于设备、接口、端点，还是厂商专有的实体。这个参数可以有八种可能的值，不用我告诉你，你完全可以用下面的公式组合出你需要的形式：

URB_FUNCTION_ [SET | CLEAR] _FEATURE_TO_ [DEVICE | INTERFACE | ENDPOINT | OTHER]

UsbBuildFeatureRequest中的第三个参数指明请求修改的特征。在FEATURE例子中，我发明了一个称为FEATURE_LED_DISPLAY的特征。第四个参数指出被寻址的任何类型实体。在这个例子中，我希望寻址接口1。

USB定义了两个标准特征：远程唤醒特征和端点停止特征。然而，你不必自己设置或清除这些特征，总线驱动程序会自动完成这些操作。当你发出一个IRP_MN_WAIT_WAKE请求时(参见第八章“电源管理”)，总线在设备当前配置允许远程唤醒的情况下自动使能设备的远程唤醒特征。当你发出一个RESET_PIPE URB时总线驱动程序自动向端点发出清除停止特征请求。

关于FEATURE例子

随书光盘中的FEATURE例子演示了怎样设置或清除特征。设备固件(EZUSB子目录)定义了一个没有端点的设备。该设备支持一个接口级特征42，42是FEATURE_LED_DISPLAY在驱动程序中引用的值。当该特征设置时，Anchor Chips开发板上的7段LED变量将显示一个时间值。当该特征被清除时，LED仅显示一个小数点。FEATURE设备驱动程序(位于SYS子目录)包含设置和清除特征的代码，此外还练习了一些响应IOCTL请求的其它控制命令。

测试程序(位于TEST子目录)是一个Win32控制台应用程序，它通过执行DeviceIoControl来设置特征；发出其它DeviceIoControl调用获得状态掩码，配置号，当前接口的替换设置；然后等待5秒；最后执行另一个DeviceIoControl清除特征。每次运行这个测试程序，你需要观察开发板上的LED显示5秒钟。

测定状态

如果你想获得设备、接口、端点的当前状态，你可以象下面这样给出URB：

URB urb;
USHORT epstatus;
UsbBuildGetStatusRequest(&urb,
             URB_FUNCTION_GET_STATUS_FROM_ENDPOINT,
             <index>,
             &epstatus,
             NULL,
             NULL);
SendAwaitUrb(fdo, &urb);

你可以指定四种URB功能码，如表11-8。

设备的状态掩码指出设备是否是自供电，是否可以远程唤醒，见图11-13。端点的掩码指出端点当前是否处于停止状态，见图11-14。USB规范现在又定义了接口级的电源管理状态位。参见http://www.usb.org/developers/devclass.html的"USB Feature Specification: Interface Power Management"文章。USB从不规定厂商专用状态位，它们由厂商自己定义。

表11-8. 用于取状态的URB功能码

操作码 | 从何处接收状态
URB_FUNCTION_GET_STATUS_FROM_DEVICE | 整个设备
URB_FUNCTION_GET_STATUS_FROM_INTERFACE | 指定的接口
URB_FUNCTION_GET_STATUS_FROM_ENDPOINT | 指定的端点
URB_FUNCTION_GET_STATUS_FROM_OTHER | 厂商专有对象

图11-13.jpg (13754 字节)

图11-13. 设备状态位

图11-14.jpg (9515 字节)

图11-14. 端点状态位

管理等时管道

使用等时管道可以实现主机和设备之间有规律的时间敏感的数据交换。总线驱动程序将使用90%的总线带宽进行等时和中断传输。这意味着在每1ms的帧中将有足够多的保留时间以容纳中断和等时端点(当前活动)的最大量传输。图11-15显示了这个概念。设备A和B都有一个等时端点，每帧中都有一个固定的时间段为等时端点保留。设备C有一个中断端点，其循检频率是每两帧一次；每两帧中它将拥有一小部分的保留时间。在没有中断端点循检的帧中，空出的带宽可以用于批量传输或其它目的。

图11-15.jpg (19691 字节)

图11-15. 等时端点和中断端点的带宽分配

保留带宽

当你使能一个接口时，总线驱动程序就为你保留了带宽。保留带宽就象买电影票，如果你不使用也不会退还。所以，对于含有等时端点的接口，应该在需要时才使能该端点，并且应使用最大传输量与你的要求相适应的替换设置。通常，有等时能力的设备其接口都有一个默认替换设置，该默认设置中没有任何等时或中断端点。当你需要使用等时能力时，应使能同一接口中含有等时或中断端点的替换设置。

下面例子明确了带宽保留的机制。USBISO例子中的一个接口就有两个设置，默认设置(可能是替换设置0)不包含任何端点，而替换设置1中有一个最大传输量为256字节的等时端点。见图11-16。

图11-16.jpg (14212 字节)

图11-16. USBISO设备的描述符结构

在StartDevice例程执行时，我们选择了默认接口设置。因为默认接口设置没有任何等时或中断管道，这样我们就不用保留任何带宽。当我们需要打开设备的句柄时，我们调用SelectAlternateInterface辅助函数切换到正常的接口设置(注意，这里我省略了错误检测)。

NTSTATUS SelectAlternateInterface(PDEVICE_OBJECT fdo)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  PUSB_INTERFACE_DESCRIPTOR pid = USBD_ParseConfigurationDescriptorEx(pdx->pcd,		<--1
                                      pdx->pcd,
                                      0,
                                      1,
                                     -1,
                                     -1,
                                     -1);
  ULONG npipes = pid->bNumEndpoints;
  ULONG size = GET_SELECT_INTERFACE_REQUEST_SIZE(npipes);				<--2
  PURB urb = (PURB) ExAllocatePool(NonPagedPool, size);
  RtlZeroMemory(urb, size);
  UsbBuildSelectInterfaceRequest(urb, size, pdx->hconfig, 0, 1);			<--3
  urb->UrbSelectInterface.Interface.Length = GET_USBD_INTERFACE_SIZE(npipes);		<--4
  urb->UrbSelectInterface.Interface.Pipes[0].MaximumTransferSize = PAGE_SIZE;
  NTSTATUS status = SendAwaitUrb(fdo, &urb);						<--5
  if (NT_SUCCESS(status))								<--6
  {
    pdx->hinpipe = urb.UrbSelectInterface.Interface.Pipes[0].PipeHandle;		<--7
    status = STATUS_SUCCESS;
  }
  ExFreePool(urb);
  return status;
}
  1. 在我们为URB分配空间之前，需要知道有多少个管道描述符。最普通的办法是在那个复合配置描述符中找出接口为0，替换设置为1的描述符。该描述符含有端点号，与我们要打开的管道号相同。
  2. GET_SELECT_INTERFACE_REQUEST_SIZE宏计算指定管道号的接口选择请求的字节数。然后，为URB分配内存并初始化为0。真正的例子代码还对ExAllocatePool调用是否成功做了检测。
  3. 这里，我们创建了一个接口号为0，替换设置为1的URB。
  4. 我们还必须做两个初始化步骤才能完成URB的设置。
  5. 当我们提交这个URB时，USBD自动关闭该接口的当前设置，包括它的所有端点。然后，USBD通知设备使能指定的替换设置，并为其中的端点创建管道描述符。如果新接口设置打开失败，USBD将重新打开以前的接口设置，所有以前的接口设置和管道句柄仍然有效。
  6. 如果接口打开失败，SendAwaitUrb辅助函数仅简单地返回一个错误值。关于这里的错误处理我将在后面讨论。
  7. 除了在设备级选择新接口，USBD还创建了一个管道描述符数组，我们可以从这个数组中提取管道句柄。

如果没有足够的带宽，接口选择调用也会失败。测试URB的状态可以找出失败的原因：

if (URB_STATUS(&urb) == USBD_STATUS_NO_BANDWIDTH)
  ...

处理带宽不足会有一些问题，当前的操作系统没有为竞争带宽的驱动程序提供一个便利方法来磋商带宽分配，也没有提供驱动程序请求带宽失败时的通知机制，也许我们能让出自己的一些带宽。但你还有另外两种选择，一个办法是提供多个替换接口设置，每一个设置中的等时端点都有不同的最大传输量。当申请带宽失败时，你可以选择最大传输量小一些的设置直到成功为止。

聪明的用户可能会查看Windows 2000设备管理小程序(applet)的USB主控制器属性页，见图11-17，它显示了当前的带宽分配。双击任何一个列出的设备将显示该设备的属性页，一个设计良好的属性页应能与相连的设备驱动程序通信，从而让用户修改所占用的带宽。

图11-17.jpg (35331 字节)

图11-17. USB主控制器的属性页

带宽缺乏的另一个解决办法是允许失败的IRP通知应用程序带宽不足，应用程序再向用户报告带宽不足。也许用户会从USB上拔出某些设备，这样你的设备就可以获得足够的带宽。这就是我在USBISO例子中使用的方法，我没有把响应带宽分配失败的代码放到测试程序中，TEST的反应仅仅是失败。为了采用这个方法，你需要知道失败信息是怎样被报告回用户模式的。虽然URB以USBD_STATUS_NO_BANDWIDTH失败，但从内部控制IRP中取回的代码将是STATUS_DEVICE_DATA_ERROR，这并不能说明失败的原因。应用程序调用GetLastError也只能收到ERROR_CRC错误代码。结果，到现在为止还没有简单的办法使应用程序能知道真正的失败原因。如果你对这个问题感兴趣，阅读下面文字框中的内容。

应用程序怎样发现带宽不足？

假设你已经做了USBISO所做的一切并在收到一个IRP_MJ_CREATE时试图选择一个高带宽的替换接口。再假设当带宽不足时，你完成了带有状态码STATUS_DEVICE_DATA_ERROR的IRP。但你的应用程序最终将收到ERROR_CRC错误码，就象我在上面说的那样。应用程序不能向你发送一个IOCTL去查找真正的错误原因，因为它没有你的设备句柄，你曾经响应IRP_MJ_CREATE失败，记得吗？所以你也许需要一种方法去打开不保留带宽的设备句柄，然后你需要其它办法使应用程序请求带宽，可能用一个IOCTL操作。或者你的应用程序仅仅把CreateFile调用返回的ERROR_CRC解释为带宽不足。实际的数据错误毕竟很少，所以这种解释大部分时间都是对的。

但是，最好的解决办法是有一个代表“带宽不足”的NTSTATUS代码和一个匹配的Win32错误代码。你应该把眼睛盯在NTSTATUS.H和WINERROR.H的未来版本上。

USBISO在接收到IRP_MJ_CLOSE时应再选择原来的无任何管道的默认接口设置，以释放带宽。

初始化离散的等时传输

你既可以以读写离散数据块的方式使用等时管道，也可以以生产或消费连续数据流的方式使用等时管道。数据流方式可能是使用等时管道最频繁的方式。如果你想操作流管道，除了要了解USB总线驱动程序的工作机制之外，你还必须了解并解决关于数据缓冲、速率匹配，等等的其它问题。操作系统的内核流部件可以处理所有这些问题。不幸的是，我没有时间在本书中包括关于内核流的章节。我仅讲述等时管道上的离散传输。

为了读写等时管道，你当然应使用带有适当功能码的URB。但这里还有一些关于创建和提交等时URB的问题。首先，你必须明白设备怎样把传输分成包。通常，设备可以接受或发出任何小于端点声明的最大包容量的数据(任何剩余的带宽都将空闲)。设备使用的包大小与以下这些数值没有任何必要的关系，它们是端点最大包容量、单URB能携带的最大数据量、在一系列事务中设备与应用程序可以交换的数据量。例如，对于USBISO设备的固件，尽管描述符中表明其等时端点每帧可以处理256字节，但实际上它仅以16字节的包工作。在你构造URB之前，必须先知道这些包有多大。因为URB必须为每个要交换的包添加一个描述符数组，每个描述符必须指出包有多大。

下面简化代码分配一个等时URB：

ULONG length = MmGetMdlByteCount(Irp->MdlAddress);
ULONG packsize = 16; 						// a constant in USBISO
ULONG npackets = (length + packsize - 1) / packsize;
ASSERT(npackets <= 255);
ULONG size = GET_ISO_URB_SIZE(npackets);
PURB urb = (PURB) ExAllocatePool(NonPagedPool, size);
RtlZeroMemory(urb, size);

上面代码的关键步骤是使用GET_ISO_URB_SIZE宏来计算一个等时URB所需的全部内存大小，该URB传输给定数量的数据包。一个单独的URB一次最多能容纳255个等时包。限制应用程序仅仅传输255个包是不实际的，USBISO例子中的实际程序要比上面代码复杂些，可以不受这个限制。

由于没有用于创建等时URB的宏(UsbBuildXxxRequest)，所以我们必须手动初始化新的URB：

urb->UrbIsochronousTransfer.Hdr.Length = (USHORT) size;
urb->UrbIsochronousTransfer.Hdr.Function = URB_FUNCTION_ISOCH_TRANSFER;
urb->UrbIsochronousTransfer.PipeHandle = pdx->hinpipe;
urb->UrbIsochronousTransfer.TransferFlags = USBD_TRANSFER_DIRECTION_IN |
                        USBD_SHORT_TRANSFER_OK |
                        USBD_START_ISO_TRANSFER_ASAP;
urb->UrbIsochronousTransfer.TransferBufferLength = length;
urb->UrbIsochronousTransfer.TransferBufferMDL = Irp->MdlAddress;
urb->UrbIsochronousTransfer.NumberOfPackets = npackets;

for (ULONG i = 0; i < npackets; ++i, length -= packsize)
{
  urb->UrbIsochronousTransfer.IsoPacket[i].Offset = i * packsize;
}

包描述符数组整体地描述了我们读写用的全部数据缓冲区。该缓冲区在虚拟内存中必须连续，即你必须用一个MDL来描述它。每个包描述符仅包含“部分缓冲区”的偏移和长度，而不是一个实际的指针。主控制器驱动程序有责任设置该长度；你必须设置偏移。

启动一个等时传输的第二个问题是定时(timing)。USB在每1ms的帧中标识一个自增数。这对于开始于特定帧的传输是重要的。USBD允许你在URB的StartFrame域明确地指出帧号。USBISO并不需要定时，它设置USBD_START_ISO_TRANSFER_ASAP标志表明传输应尽快开始。

等时处理的最后一个问题是传输怎样结束。尽管一个或多个包有数据错误，但URB本身是成功的。URB的ErrorCount域指出了有多少包出现了错误。如果该值在传输结束时不为0，你可以查找包描述符各自的状态域。

获得可接受的性能

为了获得多URB等时传输的可接受性能，你的驱动程序应该比我给出的例子更复杂。当一个URB完成时，你希望总线驱动程序立即开始下一个URB的处理。使用完成例程(在LOOPBACK中使用过)并不能达到足够快。使数据传输更快的最简单的策略是在USBISO例子中使用的方法：创建一组“IRP/URB对”并一起提交它们。

注意
创建多个IRP的需求将带来更复杂的取消逻辑。如果我们能使用UrbLink域把多个URB串联到一个IRP上，就可以避免使用下面这些复杂的代码。

USBISO读写逻辑中的基本思想是当上一个辅助IRP完成时，使用当前辅助IRP的完成例程完成主读写IRP。为了实现这个想法，我声明了下面有特别用途的上下文结构：

typedef struct _RWCONTEXT {
  PDEVICE_EXTENSION pdx;
  PIRP mainirp;
  NTSTATUS status;
  ULONG numxfer;
  ULONG numirps;
  LONG numpending;
  LONG refcnt;
  struct {
    PIRP irp;
    PURB urb;
    PMDL mdl;
  } sub[1];
} RWCONTEXT, *PRWCONTEXT;

IRP_MJ_READ的派遣例程(USBISO不能处理IRP_MJ_WRITE请求)计算完成传输所需要的辅助IRP个数并分配一个上面描述的上下文结构：

ULONG packsize = 16;
ULONG segsize = USBD_DEFAULT_MAXIMUM_TRANSFER_SIZE;
if (segsize / packsize > 255)
  segsize = 255 * packsize;
ULONG numirps = (length + segsize - 1);
ULONG ctxsize = sizeof(RWCONTEXT) + (numirps - 1) * sizeof(((PRWCONTEXT) 0)->sub);
PRWCONTEXT ctx = (PRWCONTEXT) ExAllocatePool(NonPagedPool, ctxsize);
RtlZeroMemory(ctx, ctxsize);
ctx->numirps = ctx->numpending = numirps;
ctx->pdx = pdx;
ctx->mainirp = Irp;
ctx->refcnt = 2;
Irp->Tail.Overlay.DriverContext[0] = (PVOID) ctx;

上面程序最后两条语句的意图将在讨论USBISO的取消逻辑时再作解释。现在我们执行一个循环来构造numirps个 IRP_MJ_INTERNAL_DEVICE_CONTROL请求，在每一次循环，我们调用IoAllocateIrp创建IRP。我们还分配一个URB来控制一个阶段的传输，和一个“部分MDL”来描述当前阶段的部分主I/O缓冲区。我们在RWCONTEXT中记录IRP、URB，和部分MDL的地址。我们用与以前相同的方式初始化URB。然后，我们初始化辅助IRP的前两个I/O堆栈单元，见下框：

IoSetNextIrpStackLocation(subirp);
PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(subirp);
stack->DeviceObject = fdo;
stack->Parameters.Others.Argument1 = (PVOID) urb;
stack->Parameters.Others.Argument2 = (PVOID) mdl;

stack = IoGetNextIrpStackLocation(subirp);
stack->MajorFunction = IRP_MJ_INTERNAL_DEVICE_CONTROL;
stack->Parameters.Others.Argument1 = (PVOID) urb;
stack->Parameters.DeviceIoControl.IoControlCode = IOCTL_INTERNAL_USB_SUBMIT_URB;

IoSetCompletionRoutine(subirp,
               (PIO_COMPLETION_ROUTINE) OnStageComplete,
               (PVOID) ctx,
               TRUE,
               TRUE,
               TRUE);

第一个堆栈单元由我们安装的OnStageComplete完成例程所使用。第二个堆栈单元由低级驱动程序使用。

一旦我们建立完所有的IRP和URB，就把它们提交到总线驱动程序。在提交之前，我们应该谨慎地检查主IRP是否被取消，并且有必要为主IRP安装一个完成例程。派遣例程的尾部代码如下。

IoSetCancelRoutine(Irp, OnCancelReadWrite);
if (Irp->Cancel)
{
  status = STATUS_CANCELLED;
  if (IoSetCancelRoutine(Irp, NULL))
    --ctx->refcnt;
}
else
  status = STATUS_SUCCESS;

IoSetCompletionRoutine(Irp,
               (PIO_COMPLETION_ROUTINE) OnReadWriteComplete,
               (PVOID) ctx,
               TRUE,
               TRUE,
               TRUE);
IoMarkIrpPending(Irp);
IoSetNextIrpStackLocation(Irp);

if (!NT_SUCCESS(status))
{
  for (i = 0; i < numirps; ++i)
  {
    if (ctx->sub[i].urb)
      ExFreePool(ctx->sub[i].urb);
    if (ctx->sub[i].mdl)
      IoFreeMdl(ctx->sub[i].mdl);
  }
  CompleteRequest(Irp, status, 0);
  return STATUS_PENDING;
}

for (i = 0; i < numirps; ++i)
  IoCallDriver(pdx->LowerDeviceObject, ctx->sub[i].irp);

return STATUS_PENDING;

主IRP的取消处理

USBISO例子使用了两个完成例程，它们是用于主IRP的OnReadWriteComplete，用于每个辅助IRP的OnStageComplete。我需要解释USBISO怎样处理主IRP的取消操作。取消操作是一种关照处理，因为我们提交大量辅助IRP时需要时间。直到所有辅助IRP都完成后主IRP才能完成。所以，我们应该提供一种方法取消主IRP和所有等待的辅助IRP。

回想一下第五章“I/O请求包”，IRP取消操作会带来许多潜在的同步问题。由于我们不能控制辅助IRP完成例程的定时，所以一旦我们提交这些辅助IRP，它们就由总线驱动程序所拥有，这使USBISO的取消逻辑变得更复杂。假设我们写出了下面取消例程：

VOID OnCancelReadWrite(PDEVICE_OBJECT fdo, PIRP Irp)
{
  IoReleaseCancelSpinLock(Irp->CancelIrql);
  PRWCONTEXT ctx = (PRWCONTEXT) Irp->Tail.Overlay.DriverContext[0];		<--1
  for (ULONG i = 0; i < ctx->numirps; ++i)
    IoCancelIrp(ctx->sub[i].irp);						<--2
    <additional steps>
}
  1. 由于前面我们已经把RWCONTEXT的地址保存到IRP的DriverContext域中，所以这里我们可以读出该地址。只要我们拥有IRP，就可以使用DriverContext。因为我们在派遣例程中返回STATUS_PENDING，所以我们仍拥有该IRP。
  2. 这里，我们取消所有的辅助IRP。如果某个IRP已经完成或者正在活动，则对应的IoCancelIrp调用将不做任何事。如果某个辅助IRP仍旧在主控制器驱动程序的队列中，那么主控制器驱动程序的取消例程将运行并完成该辅助IRP。在这三种情况中，我们可以确信所有的辅助IRP将被很快完成。

该版本的OnCancelReadWrite只差一步就完成了，我将在同步问题之后再讲述这一步骤。我在完成例程中设置两个直观的错误来演示这个问题。下面程序是阶段完成例程，它是全部传输的一个阶段：

NTSTATUS OnStageComplete(PDEVICE_OBJECT fdo, PIRP subirp, PRWCONTEXT ctx)
{
  PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);				<--1
  PIRP mainirp = ctx->mainirp;
  PURB urb = (PURB) stack->Parameters.Others.Argument1;
  if (NT_SUCCESS(Irp->IoStatus.Status))
    ctx->numxfer += urb->UrbIsochronousTransfer.TransferBufferLength;			<--2
  else
    ctx->status = Irp->IoStatus.Status;							<--3
  ExFreePool(urb);									<--4
  IoFreeMdl((PMDL) stack->Parameters.Others.Argument2);
  IoFreeIrp(subirp);						//don't do this		<--5
  if (InterlockedDecrement(&ctx->numpending) == 0)					<--6
  {
    IoSetCancelRoutine(mainirp, NULL);				//also needs some work
    mainirp->IoStatus.Status = ctx->status;
    IoCompleteRequest(mainirp, IO_NO_INCREMENT);
  }
  return STATUS_MORE_PROCESSING_REQUIRED;
}
  1. 该堆栈单元是由派遣例程分配的额外堆栈单元。我们需要该阶段的URB地址，而一个堆栈单元是保存该地址的最方便地方。
  2. 当一个阶段正常完成时，我们更新主IRP的传输累加计数。numxfer的最终值将存入主IRP的IoStatus.Information域。
  3. 由于我们把整个上下文结构清0，所以status的初始状态为STATUS_SUCCESS。如果某阶段发生错误，该语句将记录下错误状态。最后，该值将存入主IRP的IoStatus.Status域。
  4. 我们不再需要URB和该阶段的部分MDL，所以我们在这里释放了它们。
  5. 在这里调用IoFreeIrp是错误的。
  6. 当最后一个阶段完成后，我们同时也完成了主IRP。一旦我们提交辅助IRP，这里便是完成主IRP的唯一的地方，所以必须确保主IRP指针有效。

这是一个简化版本的主IRP完成例程，加入了另一个错误例子：

NTSTATUS OnReadWriteComplete(PDEVICE_OBJECT fdo, PIRP Irp, PRWCONTEXT ctx)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) ctx->pdx;
  if (Irp->Cancel)									<--1
    Irp->IoStatus.Status = STATUS_CANCELLED;
  else if (NT_SUCCESS(Irp->IoStatus.Status))
    Irp->IoStatus.Information = ctx->numxfer;

  ExFreePool(ctx);				//don't do this				<--2

  IoReleaseRemoveLock(&pdx->RemoveLock, Irp);						<--3
  return STATUS_SUCCESS;								<--4
}
  1. 如果有人试图放弃主IRP，该语句将设置对应的结束状态。
  2. 在这里释放上下文结构将是一个问题。
  3. IoReleaseRemoveLock调用释放了在派遣函数中获取的删除锁。
  4. 如果我们在此返回除STATUS_MORE_PROCESSING_REQUIRED之外的任何值，IoCompleteRequest将继续其工作，而不改变IRP的完成状态。

我已经暴露出一个与IRP取消操作关联的同步问题，假设我们在OnStageComplete中调用了IoFreeIrp，而我们的取消例程又被调用，那么传递给IoCancelIrp的指针将是非法指针。或者，如果取消例程与OnReadWriteComplete同时被调用，那么取消例程可能会遇到一个被删除的上下文结构。

你可能提出几种解决办法，能否在OnStageComplete中将上下文结构的辅助IRP指针置空？可否在调用IoCancelIrp前用OnCancelReadWrite先检查IRP的有效性？(是的，但这仍不能保证IoFreeIrp调用不挤进OnCancelReadWrite的检测期间，而此时IoCancelIrp正好完成IRP的取消操作相关域的修改) 能否用一个自旋锁来保护多个清除步骤？(这是一个可怕的想法，因为你在调用耗时函数时占有自旋锁) 能否利用Windows 2000的新特征，在APC例程中，Windows 2000总是清除已完成的IRP？(不能，原因我在第五章已讨论过)

我思考这个问题有一段时间，最后我得到了灵感。我们为什么不用一个参考计数器来保护上下文结构和辅助IRP指针，这样取消例程和主完成例程就都能清除IRP，这就是我们最后要做的。我在上下文结构中加入一个参考计数器(refcnt)并初始化为2。一个为取消例程参考，另一个主完成例程参考。我写了下面辅助函数，它用于释放产生问题的内存对象。

BOOLEAN DestroyContextStructure(PRWCONTEXT ctx)
{
  if (InterlockedDecrement(&ctx->refcnt) > 0)
    return FALSE;
  for (ULONG i = 0; i < ctx->numirps; ++i)
    if (ctx->sub[i].irp)
      IoFreeIrp(ctx->sub[i].irp);
  ExFreePool(ctx);
  return TRUE;
}

我在取消例程的最后调用了这个辅助函数：

VOID OnCancelReadWrite(PDEVICE_OBJECT fdo, PIRP Irp)
{
  IoReleaseCancelSpinLock(Irp->CancelIrql);
  PRWCONTEXT ctx = (PRWCONTEXT) Irp->Tail.Overlay.DriverContext[0];
  for (ULONG i = 0; i < ctx->numirps; ++i)
    IoCancelIrp(ctx->sub[i].irp);
  PDEVICE_EXTENSION pdx = ctx->pdx;
  if (DestroyContextStructure(ctx))
  {
    CompleteRequest(Irp, STATUS_CANCELLED, 0);
    IoReleaseRemoveLock(&pdx->RemoveLock, Irp);
  }
}

我省略了阶段完成例程中的IoFreeIrp调用，并加入几行代码以减少参考计数：

NTSTATUS OnStageComplete(PDEVICE_OBJECT fdo, PIRP subirp, PRWCONTEXT ctx)
{
  PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
  PIRP mainirp = ctx->mainirp;
  PURB urb = (PURB) stack->Parameters.Others.Argument1;
  if (NT_SUCCESS(Irp->IoStatus.Status))
    ctx->numxfer += urb->UrbIsochronousTransfer.TransferBufferLength;
  else
    ctx->status = Irp->IoStatus.Status;
  ExFreePool(urb);
  IoFreeMdl((PMDL) stack->Parameters.Others.Argument2);
  if (InterlockedDecrement(&ctx->numpending) == 0)
  {
    if (IoSetCancelRoutine(mainirp, NULL))
      InterlockedDecrement(&ctx->refcnt);
    mainirp->IoStatus.Status = ctx->status;
    IoCompleteRequest(mainirp, IO_NO_INCREMENT);
  }
  return STATUS_MORE_PROCESSING_REQUIRED;
}

IoSetCancelRoutine返回取消指针的以前值。如果为空，则取消例程已经被调用，可以调用DestroyContextStructure。如果不为空，则取消例程不可能再被调用，所以我们必须用尽取消例程在上下文结构中的全部权利。

我还把主完成例程中的无条件ExFreePool调用换成DestroyContextStructure调用：

NTSTATUS OnReadWriteComplete(PDEVICE_OBJECT fdo, PIRP Irp, PRWCONTEXT ctx)
{
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) ctx->pdx;
  if (Irp->Cancel)
    Irp->IoStatus.Status = STATUS_CANCELLED;
  else if (NT_SUCCESS(Irp->IoStatus.Status))
    Irp->IoStatus.Information = ctx->numxfer;

  if (DestroyContextStructure(ctx))
  {
    IoReleaseRemoveLock(&pdx->RemoveLock, Irp);
    return STATUS_SUCCESS;
  }
  else
    return STATUS_MORE_PROCESSING_REQUIRED;
}

这就是这个额外逻辑的工作过程。如果取消例程被调用，它将通过上下文结构为每个辅助IRP调用IoCancelIrp函数。即使这些IRP都被完成，这些调用仍旧是安全的，因为我们根本就没有调用IoFreeIrp。上下文的引用也是安全的，因为我们也没有调用ExFreePool。取消例程以DestroyContextStructure调用结束，该调用减少参考计数器的值。如果主完成例程根本就没有运行，DestroyContextStructure将返回FALSE，因此取消例程将返回。此时上下文结构仍然存在而且是完好的。完成例程的最后一次调用DestroyContextStructure将释放辅助IRP和上下文结构本身。然后，它放开删除锁并返回STATUS_SUCCESS，这样主IRP就可以结束完成操作。

假设取消例程和主完成例程的调用以另一种顺序发生，那么OnReadWriteComplete中的DestroyContextStructure调用将简单地减少参考计数并返回FALSE，而OnReadWriteComplete将返回STATUS_MORE_PROCESSING_REQUIRED。上下文结构仍然存在，这还可以确定我们仍拥有该IRP。取消例程中的DestroyContextStructure调用将把参考计数减到0，释放内存，返回TRUE。然后，取消例程释放删除锁并为主IRP调用IoCompleteRequest。这会造成对同一IRP两次调用IoCompleteRequest。虽然一个IRP不能被完成两次，但这个禁止本身并不是针对两次调用IoCompleteRequest。如果第一次IoCompleteRequest调用导致调用一个返回STATUS_MORE_PROCESSING_REQUIRED的完成例程，那么后面的重复调用不会造成问题。

这个分析的最后一种情况是当取消例程根本就没有得到调用。当然一般情况下IRP不会被取消。但这个事实会出现在我们为准备完成主IRP而调用IoSetCancelRoutine时。如果IoSetCancelRoutine返回一个非NULL值，我们知道对于主IRP，IoCancelIrp还没有被调用。(取消指针应该为NULL，IoSetCancelRoutine应该返回NULL)另外我们知道，现在我们自己的取消例程绝不会被调用了，因此也没有机会去减少参考计数。所以我们需要手工减少参考计数，以便OnReadWriteComplete能调用DestroyContextStructure释放内存。

同步在哪里？

你会注意到我前面介绍的派遣例程中的取消代码没有用自旋锁来保护测试操作。这段代码和某些IoCancelIrp假定调用者之间有潜在的同步问题，因为IoSetCancelRoutine是一个互锁交换操作，并且IoCancelIrp是在调用IoSetCancelRoutine之前设置了Cancel标志。参见第五章中IoCancelIrp的讨论。

派遣例程中的第一个IoSetCancelRoutine调用会发生在IoCancelIrp设置Cancel标志之后，在IoCancelIrp自己调用IoSetCancelRoutine之前。派遣例程会看到Cancel标志被设置并做第二次IoSetCancelRoutine调用。如果这个第二次IoSetCancelRoutine调用碰巧发生在IoCancelIrp调用IoSetCancelRoutine之前，则取消例程将不会被调用。我们还要减少对上下文结构的参考计数，以便在第一次调用DestroyContextStructure时该结构能被释放。

如果派遣例程的第二次IoSetCancelRoutine调用发生在IoCancelIrp之后，我们将不减少参考计数。其它取消例程或完成例程最终将释放上下文结构。

如果派遣例程在IoCancelIrp设置Cancel标志前测试该标志，或者如果根本就没有为该IRP调用IoCancelIrp，那么我们将继续并启动辅助IRP。如果IoCancelIrp在我们设置一个取消例程前被调用，那么它仅简单地设置Cancel标志并返回。这之后发生的事情与派遣例程在IoCancelIrp调用IoSetCancelRoutine之前置空取消指针所发生的情况一样。

所以你看，实现多处理器安全并不总是需要一个自旋锁：有时一个原子互锁操作也可以做到。

关联IRP?

IoMakeAssociatedIrp看起来象创建USBISO所需辅助IRP的另一种方式。IoMakeAssociatedIrp后面的思想是，你可以创建许多关联IRP来实现一个主IRP。当最后一个关联IRP完成时，I/O管理器会自动创建主IRP。

不幸的是，关联IRP并不能很好地解决USBISO面临的问题。最重要的是WDM驱动程序不能假定使用IoMakeAssociatedIrp。实际上Windows 98中的关联IRP完成逻辑是不正确的，它不能在最后一个关联IRP结束时为主IRP调用完成例程。甚至在Windows 2000中，I/O管理器也不能在主IRP被取消时取消关联IRP。另外，针对关联IRP的IoFreeIrp调用发生在IoCompleteRequest内部，在任意线程中。这使安全地取消关联IRP更加困难。

流等时传输

在前面段中，我描述了通过等时管道的长传输技术。你可能还需要实现连续数据流传输。我在这里给出一个快速的实现框架。

在流驱动程序中，你需要为不丢任何帧的连续传输提供一个或多个数据缓冲区。你还需要至少分配两个“IRP/URB对”用于传输。在这种情况下，即使URB连接域可以使用也不能帮助你：你需要知道每个URB完成的时间，找出这个值的唯一时间是在关联IRP的完成例程被调用时。

开始你象总线驱动程序提交所有IRP。当一个IRP完成时，你立即再次使用它(在完成例程中)。办法是使主机控制器驱动程序的请求队列中总有URB准备运行，一旦当前URB完成，队列中的URB就开始执行。你还需要调整数据缓冲区的大小和数量，以及IRP/URB对的数量以避免由于生产者或消费者一时没有跟上设备而导致缓冲区溢出。

同步等时传输

同步性是多种等时数据流的一个重要属性。为了给出一个简单例子，假设你的计算机上有两个扬声器和一个麦克风。你希望送往扬声器的声音数据能与来自麦克风的数据同步，这样，在感觉上听见的声音可以跟上麦克风的输入。另外你还希望一个扬声器的声音输出与另一个同步。

要实现可接受的同时性有几个难点。USB规范的第5.10段详细地描述了这些原因以及解决方案所需要的硬件基础。我仅概述这些难点以便我能指出USBD对驱动程序的用处。

数据的源和接收器会有不同的采样大小和速率。例如，一个麦克风每秒会生成8000字节的采样信息，而一个扬声器每秒会消耗44100个32位采样。某些硬件或软件代理必须使用缩放和插补来匹配源和接收器。

设备本身也会有内部延迟。数据源需要时间来捕获并编码数据，然后再发送给主机，数据接收器也需要时间来解码和使用数据。在这个例子中有一个数据源和两个相似的接收器，它们的延迟也许不太重要。但考虑一下多输入设备存在的情形，每个设备都有自己的延迟特点，它们捕获同一系列外部事件的不同方面。(例如，一组麦克风和MIDI设备) 某些代理程序需要了解被各种源设备带入的延迟，以整理主机接收的数据流。另一些代理程序还需要了解接收器设备的延迟以使实际的输出信号能在恰当的时间到达外部环境。因为USB需要以帧为单位测量设备的延迟，所以驱动程序可以通过在其生成的等时传输URB中明确设置StartFrame成员来处理延迟。为了设置这个域，你需要计算帧号，从某个到达的输入数据帧号(可以从完成的URB的StartFrame成员中提取)开始或从当前帧号开始。

最后，设备必须提供某种方法来使自己的时钟与系统同步。同步是第一需要，因为时钟可能会随时间漂移(由于石英晶体的频率细微差异)或跳变(由于温度或其它波动)。USB可以识别三种可选的端点时钟同步方法：异步、同步、自适应。

异步端点不能与任何外部源同步其操作。源端点通过提供的数据量来暗中通知主机其数据提交率。接收端点需要访问一个辅助同步端点，如一个中断端点，来报告其数据消耗的进展。

同步端点把其操作绑到总线的1kHz速率的帧上。它这样做或者通过把自己的时钟附属到每帧开始的SOF(start-of-frame)包上，或者强制总线帧率匹配自己的时钟。USB允许任何一个设备成为帧主控者，并允许其改变标准的1毫秒帧周期。在驱动程序一方，发出一个功能码为URB_FUNCTION_TAKE_FRAME_LENGTH_CONTROL的URB可以使自己成为帧主控者，发出功能码为URB_FUNCTION_RELEASE_FRAME_LENGTH_CONTROL的URB放弃作为帧主控者。如果你是帧主控者，你可以发出功能码为URB_FUNCTION_GET_FRAME_LENGTH或URB_FUNCTION_SET_FRAME_LENGTH的URB来获取或设置帧长度。

一个自适应源端点有某种能够接收数据接收者反馈的方法(例如一个控制管道)，这可以使其生成与接收器匹配的采样。自适应接收端点简单地把自己的接收速率转换为已接收数据流中暗含的速率。


第十二章：安装驱动程序

在驱动程序开发过程的前期，对于用户如何安装你的驱动程序以及其服务的硬件做些考虑是重要的。Windows 2000和Windows 98使用一个扩展名为INF的文本文件来控制与安装驱动程序相关的大部分活动。INF文件应该由你提供。它可以存在于软盘上，或存在于随硬件包装的光盘上，或者由Microsoft把它放到Windows 2000的安装光盘上。通过INF文件你可以告诉操作系统哪一个文件需要复制到用户硬盘上，应该添加或修改哪一个注册表项，如此等等。

在本章我将讨论驱动程序安装的几个方面，引导你熟悉一个简单INF文件的各个重要部分，帮助你联系到DDK文档中关于INF文件的语法。我还将详细解释用于各种设备类型的设备标识符格式，如何初始化设备硬件键中的属性值，如何在驱动程序和应用程序中访问这些属性。我为本书使用的所有例子“设备”定义了一个设备类，设备类的定义过程也许会对你有用。我还将讨论一种方法，使用这种方法可以在PnP管理器启动你的设备时自动运行一个应用程序。

  • INF文件
  • 定义设备类
  • 运行应用程序
  • Windows 98兼容问题


INF文件

INF文件包含一些名字由方括号括起来的段。大部分段都含有一系列“keyword = value”形式的指令。INF文件开始于一个Version段，该段确定文件中描述的设备类型：

[Version]
Signature=$CHICAGO$
Class=Sample
ClassGuid={894A7460-A033-11d2-821E-444553540000}

Signature可以是三种特定值之一：$Chicago$、$Windows NT$(含有一个空格)、$Windows 95$(也含有一个空格)。Class确定设备类。表12-1列出了Windows 2000支持的预定义类。ClassGuid唯一地标识该设备类。DDK头文件DEVGUID.H定义了标准设备类的GUID，DDK文档中关于Version段的内容也描述了这些标准设备类。

在产品级的INF文件中，Version段中还需要有DriverVer和CatalogFile语句。另外你还应该给出一个包含词“copyright”的注释(以分号开头的行)以满足CHKINF程序的检查。操作系统可以接受没有这个注释的INF文件，但Microsoft不会认证没有这项内容的驱动程序。所需的INF语法细节参见DDK文档。

把一个大的INF文件看成是一个树结构的线形描述可以更容易理解INF文件。一个段就是树上的一个节点，而每个指令就是指向另一个段的指针。图12-1显示了这个概念。

图12-1. INF文件的树形结构

表12-1. INF文件的设备类

INF类名 | 描述
1394 | IEEE 1394总线控制器(不是外设)
Battery | 电池设备
CDROM | CD-ROM驱动器，包括SCSI和IDE
DiskDrive | 硬盘驱动器
Display | 显示适配器
FDC | 软盘控制器
FloppyDisk | 软盘驱动器
HDC | 硬盘控制器
HIDClass | 人机接口设备
Image | 静态图象捕捉设备，包括数码相机和扫描仪
Infrared | Serial-IR和Fast-IR红外端口的NDIS miniport驱动程序
Keyboard | 键盘
MediumChanger | SCSI媒体交换器设备
Media | 多媒体设备，包括音频、DVD、游戏杆、全动态视频捕捉设备
Modem | 调制解调器
Monitor | 监视器
Mouse | 鼠标和其它指点设备
MTD | 内存设备的内存技术驱动程序
Multifunction | 多功能设备
MultiportSerial | 智能多端口串行卡
Net | 网络适配器
NetClient | 网络文件系统和打印提供者(客户方)
NetService | 网络文件系统的服务器方支持
NetTrans | 网络协议驱动程序
PCMCIA | PCMCIA和CardBus主机控制器(不是外设)
Ports | 串行和并行口
Printer | 打印机
SCSIAdapter | SCSI和RAID控制器，主机总线适配器miniports，和磁盘阵列控制器
SmartCardReader | 智能卡读写器
System | 系统设备
TapeDrive | 磁带驱动器
USB | USB主控制器和hub(不是外设)
Volume | 逻辑存储卷驱动程序

这棵树的根是Manufacturer段，列出了文件中有硬件描述的所有公司。例如：

[manufacturer]
"Walter Oney Software"=DeviceList
"Finest Organization On Earth Yet"=FOOEY

[DeviceList]
...

[FOOEY]
...

每个独立厂商的model(硬件型号)段(例子中的DeviceList和FOOEY)描述了一个或多个设备：

[DeviceList]
Description=InstallSectionName,DeviceId
...

Description是一个人类可读的设备描述，DeviceId标识一个硬件设备。InstallSectionName标识(或指向)INF文件中的另一个段，该段含有为特定设备安装软件的指令。下面是一个例子(摘自第七章的PKTDMA例子)：

[DeviceList]
"AMCC S5933 Development Board (DMA)"=DriverInstall,PCI\VEN_10E8&DEV_4750

系统为一个硬件安装驱动程序时需要参考Manufacturer段和model段中的信息。PnP设备以电子方式表明其存在和身份。总线驱动程序会自动发觉PnP设备并使用其板上的数据构造设备标识符。然后系统寻找这个设备的预安装INF文件。这些INF文件保存在Windows目录的INF子目录中。如果系统没有找到合适的INF文件，它会请求用户为其指定一个。

遗留设备不能表明自己的存在或身份。因此需要用户运行硬件安装向导来安装遗留设备并帮助其定位正确的INF文件。这个过程中的关键步骤是指定要安装的设备类型及厂商的名字，见图12-2。

硬件安装向导通过枚举特定类型设备的所有INF文件来构造一个类似图12-2的对话框，包括所有Manufacturer段中的语句，和每个manufacturer的所有model语句。

图12-2. 安装期间选择设备

关于硬件安装向导对话框

一旦安装向导经过搜寻PnP设备的阶段，它就创建一个设备类列表，并使用SETUPAPI.DLL中的各种SetupDi_Xxx例程提取图标和描述。SETUP API例程使用的信息最终来自注册表，ClassInstall32段中的信息将送到注册表。并不是每个设备类都会出现在列表中，安装向导将禁止使用有NoInstallClass属性的类的信息。

用户选择完设备类之后，安装向导就调用SETUP API函数构造一个如文中描述的厂商和设备的列表，ExcludeFromSelect语句中提到的设备将不出现在列表中。

Install段

install段包含指导安装程序安装所需软件的实际指示。我们还考察PKTDMA例子。对于这个设备，型号段DeviceList指出DriverInstall名。我想可以把这个名看作是一个段数组的标识，每个元素对应每个Windows平台。数组中的第0项元素含有该段(DriverInstall)的基本名称。你可以使与平台相关的数组元素的名称含有这个基本名称，再加上表12-2中列出的一个后缀。设备安装程序搜寻有最合适后缀的install段。例如，假设你有三个install段，第一个无后缀，后两个分别带有.NT后缀和.NTx86后缀。如果你安装到x86平台的Windows 2000系统中，安装程序会使用.NTx86段。如果安装到在非Intel平台上运行的Windows 2000中，它将使用.NT段。如果你安装到Windows 98中，它使用无后缀段。

表12-2. 每个平台的Install段后缀

平台 | Install段后缀
任何平台，包括Windows 98 | [none]
任何Windows 2000平台 | .NT
运行在Intel x86上的Windows 2000 | .NTx86

由于这个搜寻规则，我的例子驱动程序的所有INF文件都有无后缀的install段和有“.NT”后缀的install段。这使得这些INF文件可以很好地应付Intel平台。(你可能已经知道，Microsoft和Compaq已经不再支持Alpha平台上的32位版本Windows 2000)

以后我将讨论以install名开头的其它INF段。如果你的“数组”中有多个install段，那么这些段必须包含平台相关的后缀。例如，我将讨论一个Services段，该段用于向注册表中装入驱动程序描述。你可以用install段的名字作为基本名(如DriverInstall)再加上平台后缀(如NT)，最后加上词Services，即[DriverInstall.NT.Services]。

一个典型的Windows 2000安装段仅包含一个CopyFile指令：

[DriverInstall.nt]
CopyFiles=DriverCopyFiles

这个CopyFiles指出我们想让安装程序用另一个INF段中的信息来指导文件复制。对于PKTDMA例子，这个段名为DriverCopyFiles：

[DriverCopyFiles]
pktdma.sys,,,2

该段指示安装程序把PKTDMA.SYS复制到用户的硬盘上。

CopyFiles段中的语句有下面通用形式：

Destination,Source,Temporary,Flags

Destination是被复制文件出现在用户系统中的文件名(不包括任何路径名)。Source是源文件名，如果目标文件与源文件名字相同则这里可以为空。在Windows 98中，如果你安装的文件在安装过程中要被使用，你可以在Temporary参数中指定一个临时名。Windows 98将在下一次引导时把这个临时文件名该为目标文件名。该参数对于Windows 2000没有必要，系统会自动生成临时文件名。

Flags参数包含一个位掩码，控制着是否要解压缩文件，以及如何处理文件同名的情况。该标志的解释需要取决于Microsoft是否认证了该驱动程序包。表12-3列出了这些标志位。表中的斜体标志在有数字签名的包中被忽略。我用双线分开互斥的标志。这样，在一个未签名的包中，你可以指定NOSKIP或WARN_IF_SKIP标志，但不能同时指定。

文件名本身不能为安装程序复制文件提供足够的信息。你还需要知道文件要复制到的目录名。另外，如果安装过程中需要多个磁盘，则需要知道哪个磁盘含有源文件。这些信息来自INF文件的其它段，如图12-3所示。在PKTDMA例子中，这些段如下：

[DestinationDirs]
DefaultDestDir=10,System32\Drivers

[SourceDisksFiles]
pktdma.sys=1

[SourceDisksNames]
1="WDM Book Companion Disc",disk1

图12-3. 文件复制的源和目标信息

表12-3. CopyFile指令中的Flags标志

符号名 | 数值 | 描述
COPYFLG_REPLACEONLY | 0x00000400 | 仅在目标文件存在的情况下复制
COPYFLG_NODECOMP | 0x00000800 | 不解压缩文件
COPYFLG_FORCE_FILE_IN_USE | 0x00000008 | 总是用临时文件名复制，并在下一次引导时改名
COPYFLG_NO_OVERWRITE | 0x00000010 | 不覆盖已存在的文件(该标志不与其它标志同用)
COPYFLG_REPLACE_BOOT_FILE | 0x00001000 | 替换载入程序需要的引导文件，将提示用户重启动系统
COPYFLG_NOPRUNE | 0x00002000 | 复制该文件，即使Setup认为其已存在
COPYFLG_NOVERSIONCHECK | 0x00000004 | 覆盖一个文件，即使被覆盖文件比源文件版本更新
COPYFLG_NO_VERSION_DIALOG | 0x00000020 | 不产生版本冲突对话框
COPYFLG_OVERWRITE_OLDER_ONLY | 0x00000040 | 仅覆盖版本更旧的文件
COPYFLG_NOSKIP | 0x00000002 | 不允许用户跳过该文件
COPYFLG_WARN_IF_SKIP | 0x00000001 | 允许用户跳过该文件并给出警告

SourceDisksFiles段指出安装程序可以在1号盘上找到PKTDMA.SYS文件。SourceDisksNames段指出磁盘1有一个人类可读的标签“WDM Book Companion Disc”并包含一个名为disk1的文件，用于安装程序寻找并检验驱动器中是否有正确的磁盘。注意这两个段名中有一个容易忘记的字母“s”。

DestinationDirs段指出文件复制操作的目标路径。DefaultDestDir是未指定目标目录文件的默认目标路径。你可以用一个数值代码来指定目标路径，这个代码的完整列表见DDK文档，经常使用的仅有如下几个：

  • 目录“10”为Windows目录(例如“\Windows”或“\Winnt”)。
  • 目录“11”是System目录(例如，“\Windows\System”或“\Winnt\System32”)。
  • 目录“12”是Drivers目录，指在Windows 2000系统中(例如，“\Winnt\System32\Drivers”)。但在Windows 98中这个数值有不同的含义(例如“\Windows\System\Iosubsys”)。

WDM驱动程序存在于Driver目录中。如果你的CopyFiles段仅用于向Windows 2000安装，那么仅指定目录数值12就可以了。如果你还想使该段能向Windows 98中安装，我推荐你用“10,System32\Drivers”，它以两种方式标识Driver目录。

定义Driver Service

到现在为止我们所讨论的INF语法足够把你的驱动程序复制到用户硬盘上。另外我们还需要为PnP管理器做些安排，以使它知道应该载入哪个文件。段.Services用于实现这个目标，见下例：

[DriverInstall.NT.Services]
AddService=PKTDMA,2,DriverService

[DriverService]
ServiceType=1
StartType=3
ErrorControl=1
ServiceBinary=%10%\system32\drivers\pktdma.sys

AddService指令中的“2”指出PKTDMA service将成为该设备的功能驱动程序。该段名字由install段名再加上“Services”词组成。

这个指令的结果是使HKEY_LOCAL_MACHINE\System\CurrentControlSet\Services中出现一个PKTDMA键(AddService指令的第一个参数)。它将为内核模式(ServiceType为1)驱动程序定义service项，内核模式驱动程序由PnP管理器按需求自动装载(StartType为3)。在装入时发生的错误应该被登记，但不要阻止系统启动(ErrorControl为1)。执行映像为\Winnt\System32\Drivers\pktdma.sys(ServiceBinary的值)。如果你查看注册表，会看到执行文件的名存在于ImagePath名下而不是ServiceBinary。

service的名最好与驱动程序的二进制文件名相同。这可以使service名明显与驱动程序对应，还避免了两个不同service键指向同一个驱动程序所带来的问题：如果某设备使用的驱动程序与在其后启动的设备所使用的驱动程序相同，除非在不同的service名下，否则其自身不能被启动。

设备标识符

对于真正的即插即用设备，其设备标识符应该出现在厂商型号段中。即插即用设备可以以电子方式声明它们的存在和身份。总线枚举器能够自动找到这些设备，并能读出某些硬件信息，以便确定该设备为何种设备。例如，USB设备的设备描述符中含有厂商和产品标识代码，PCI设备的配置空间也包含厂商代码和产品代码。

当枚举器检测到一个设备时，它就构造一个设备标识串列表。列表中的一项就是设备的完整标识。而这个表项最后将用于命名注册表中的硬件键。列表中的额外表项是“兼容”标识符。PnP管理器在匹配设备与INF文件时会使用列表中的所有标识符。枚举器把更专用一些的标识符放到通用的标识符前面，这样，厂商提供的专用驱动程序就可以先于通用驱动程序而被找到。构造这个串的算法取决于枚举器，见下面：

PCI设备

设备标识符的完整形式如下：

PCI\VEN_vvvv&DEV_dddd&SUBSYS_ssssssss&REV_rr

vvvv是厂商标识符，由PCI组织赋予每个厂商，dddd是设备标识符，由厂商赋予每个设备，ssssssss是设备报告的子系统id(一般为0)，rr是修订号。

例如，我的笔记本电脑上的显示适配器(基于C&T的65550芯片)有这样的标识符：

PCI\VEN_102C&DEV_00E0&SUBSYS_00000000&REV_04

设备也可以用下面标识符形式匹配INF中的model段：

PCI\VEN_vvvv&DEV_dddd&SUBSYS_ssssssss
PCI\VEN_vvvv&DEV_dddd&REV_rr
PCI\VEN_vvvv&DEV_dddd
PCI\VEN_vvvv&DEV_dddd&REV_rr&CC_ccss
PCI\VEN_vvvv&DEV_dddd&CC_ccsspp
PCI\VEN_vvvv&DEV_dddd&CC_ccss
PCI\VEN_vvvv&CC_ccsspp
PCI\VEN_vvvv&CC_ccss
PCI\VEN_vvvv
PCI\CC_ccsspp
PCI\CC_ccss

cc来自配置空间的基础类代码，ss是子类代码，pp是编程接口。例如，下面这些标识符得自我的笔记本电脑的显示适配器，也可以匹配INF文件中的信息：

PCI\VEN_102C&DEV_00E0&SUBSYS_00000000
PCI\VEN_102C&DEV_00E0&REV_04
PCI\VEN_102C&DEV_00E0
PCI\VEN_102C&DEV_00E0&REV_04&CC_0300
PCI\VEN_102C&DEV_00E0&CC_030000
PCI\VEN_102C&DEV_00E0&CC_0300
PCI\VEN_102C&CC_030000
PCI\VEN_102C&CC_0300
PCI\VEN_102C
PCI\CC_030000
PCI\CC_0300

实际上，系统在安装该驱动程序时使用的是第三个标识符，它仅有一个厂商标识符和一个设备标识符。

PCMCIA设备

一个简单设备的标识符有如下形式：

PCMCIA\Manufacturer-Product-Crc

例如我的笔记本电脑上的3Com网卡有如下设备标识符：

PCMCIA\MEGAHERTZ-CC10BT/2-BF05

对于多功能设备上的一个独立功能，其标识符有如下形式：

PCMCIA\Manufacturer-Product-DEVdddd-Crc

Manufacturer是厂商的名字，Product是产品的名字。PCMCIA枚举器直接从卡上获取这些串。Crc是该卡的4位16进制CRC校验和。子功能号(dddd)是一个无前导0的十进制数。

如果卡没有厂商名，则标识符将会是下面三种形式之一：

PCMCIA\UNKNOWN_MANUFACTURER-Crc
PCMCIA\UNKNOWN_MANUFACTURER-DEVdddd-Crc
PCMCIA\MTD-0002

(这最后三种选择用于没有厂商标识符的闪存卡)

除了刚描述的设备标识符，INF文件的model段也可以包含这样的标识符，用4位十六进制的厂商代码串替换4位十六进制的CRC，加上一个连字号，再加上4位十六进制的厂商信息代码(都来自设备硬件)。例如：

PCMCIA\MEGAHERTZ-CC10BT/2-0128-0103

SCSI设备

完整的设备标识符为：

SCSI\ttttvvvvvvvvpppppppppppppppprrrr

tttt是设备类型代码，vvvvvvvv是一个8字符的厂商标识符，pppppppppppppppp是一个16字符的产品标识符，rrrr是一个4字符的修订级值。设备类型代码是唯一有可变长度的标识符部件。总线驱动程序通过用设备类型代码索引一个内部串表来确定这部分设备标识符，见表12-4。剩下部分正好是设备查询数据中出现的串，但含有特殊字符(包括空格、逗号、和任何非打印字符)，需要用下划线替换。

表12-4. SCSI设备的类型名

SCSI类型代码 | 设备类型 | 通用类型
DIRECT_ACCESS_DEVICE (0) | Disk | GenDisk
SEQUENTIAL_ACCESS_DEVICE (1) | Sequential |
PRINTER_DEVICE (2) | Printer | GenPrinter
PROCESSOR_DEVICE (3) | Processor |
WRITE_ONCE_READ_MULTIPLE_DEVICE (4) | Worm | GenWorm
READ_ONLY_DIRECT_ACCESS_DEVICE (5) | CdRom | GenCdRom
SCANNER_DEVICE (6) | Scanner | GenScanner
OPTICAL_DEVICE (7) | Optical | GenOptical
MEDIUM_CHANGER (8) | Changer | ScsiChanger
COMMUNICATION_DEVICE (9) | Net | ScsiNet
 | Other | ScsiOther

例如，我工作站上的一个磁盘驱动器有这样的标识符：

SCSI\DiskSEAGATE_ST39102LW_______0004

总线驱动程序也可能创建下面形式的标识符：

SCSI\ttttvvvvvvvvpppppppppppppppp
SCSI\ttttvvvvvvvv
SCSI\vvvvvvvvppppppppppppppppr
vvvvvvvvppppppppppppppppr
gggg

在这些附加标识符的第三和第四中，r仅代表修订标识符的第一个字符。在最后一个标识符中，gggg是表12-4中的通用类型代码。

总线驱动程序也可能生成下面形式的设备标识符：

SCSI\DiskSEAGATE_ST39102LW_______
SCSI\DiskSEAGATE_
SCSI\DiskSEAGATE_ST39102LW_______0
SEAGATE_ST39102LW_______0
GenDisk

这些标识符中的最后一个(GenDisk)可以用于PnP管理器自动安装设备的驱动程序。实际上，INF文件中仅含有这个通用标识符，因为SCSI驱动程序趋向于通用。

IDE设备

IDE设备的设备标识符非常类似于SCSI的标识符：

IDE\ttttvpvprrrrrrrr
IDE\vpvprrrrrrrr
IDE\ttttvpvp
vpvprrrrrrrr
gggg

tttt是一个设备类型名(与SCSI相同)；vpvp是一个串，含有厂商名、一个下划线、厂商的产品名、以及用于补足40个字符的足够下划线；rrrrrrrr是一个8字符的修订号；gggg是一个通用类型名(几乎与表12-4中的SCSI类型名相同)。除了IDE交换设备，通用类型名为GenChanger；其它IDE通用名与SCSI相同。

例如，下面是我的桌面系统中的IDE硬盘驱动器的设备标识符：

IDE\DiskMaxtor_91000D8_____________________SASX1B18
IDE\Maxtor_91000D8___________________________SASX1B18
IDE\DiskMaxtor_91000D8________________________
Maxtor_91000D8__________________________SASX1B18
GenDisk

ISAPNP设备

ISAPNP枚举器构造两个硬件标识符：

ISAPNP\id
*altid

id和altid是设备的EISA样式标识符，三个字母标识厂商，4位16进制数标识特定设备。如果设备是多功能卡上的一个功能，则列表中的第一个标识符有这样的形式：

ISAPNP\id_DEVnnnn

nnnn是该功能的10进制索引(带有前导0)

例如，Crystal Semiconductor声卡的codec功能有这两个硬件标识符：

ISAPNP\CSC6835_DEV0000
*CSC0000

第二个标识符与实际的INF文件匹配。

USB设备

完整的设备标识符为：

USB\VID_vvvv&PID_dddd&REV_rrrr

vvvv是4位16进制厂商代码，由USB委员会赋予厂商，dddd是4位16进制产品代码，由厂商赋予设备，rrrr是修订代码。这三个值都出现在设备的接口描述符或设备描述符中。

INF model段也能指定这些选择：

USB\VID_vvvv&PID_dddd
USB\CLASS_cc&SUBCLASS_ss&PROT_pp
USB\CLASS_cc&SUBCLASS_ss
USB\CLASS_cc
USB\COMPOSITE

cc是来自设备描述符或接口描述符的类代码，ss是子类代码，pp是协议代码。这些值都是2位16进制数格式。

1394设备

1394总线驱动程序为一个设备构造下面这些标识符：

1394\VendorName&ModelName
1394\UnitSpecId&UnitSwVersion

VendorName是硬件厂商的名字，ModelName标识设备，UnitSpecId标识软件规范权限，UnitSwVersion标识软件规范。构造这些标识符的信息来自设备的配置ROM。

如果设备有厂商和型号名称串，则1394总线驱动程序使用第一个标识符作为硬件ID，用第二个标识符作为唯一的兼容ID。如果设备缺少一个厂商名称串或缺少型号名称串，则总线驱动程序使用第二个标识符作为硬件ID。

我的计算机上没有1394总线，我从驱动程序开发者Jeff Kellam得到了两个例子。第一个例子是一个Sony照相机，其设备标识符为：

1394\SONY&CCM-DS250_1.08

第二个例子是处于诊断模式的1394总线本身，这个设备标识符是：

1394\031887&040892

通用设备标识符

PnP管理器还使用通用设备标识符，这些通用设备存在于许多不同的总线上。这些标识符的形式如下：

*PNPdddd

dddd是一个4位16进制的类型标识符。在本书出版时，这些标识符的官方列表见www.microsoft.com/hwdev/download/respec/devids.txt。

硬件键

硬件键记录了驱动程序所管理的特定硬件实例的信息。每个设备枚举器都有自己的注册表键，在HKEY_LOCAL_MACHINE\System\CurrentControlSet\Enum下。当枚举器找到一个有特定标识符的设备时，它就为该标识符创建一个键，并为同一个设备的每个实例创建一个子键。例如，PKTDMA设备的标识符为PCI\VEN_10E8&DEV_4750。该设备第一个实例的硬件键应该象这样：

\Registry\Machine\System\CurrentControlSet\Enum\PCI\VEN_10E8&DEV_4750\BUS_00&DEV_04&FUNC_00

标准属性

PnP管理器在硬件键中保存设备的某些标准信息。在WDM驱动程序中调用IoGetDeviceProperty函数可以获得这个信息，属性代码见表12-5。

表12-5. 硬件键中的标准设备属性

属性名 | 值名 | 源 | 描述
DevicePropertyDeviceDescription | DeviceDesc | model语句的第一个参数 | 设备描述
DevicePropertyHardwareId | HardwareID | model语句的第三个参数 | 标识设备
DevicePropertyCompatibleIDs | CompatibleIDs | 由总线驱动程序在检测期间创建 | 可匹配的设备类型
DevicePropertyClassName | Class | Version段的Class参数 | 设备类名
DevicePropertyClassGuid | ClassGUID | Version段的ClassGuid参数 | 设备类的唯一标识
DevicePropertyDriverKeyName | Driver | 安装过程中自动创建 | 指定驱动程序的服务(software)键名
DevicePropertyManufacturer | Mfg | 被找到model段中的Manufacturer | 硬件厂商名
DevicePropertyFriendlyName | FriendlyName | INF文件中的AddReg，或类安装器 | 给用户看的“友好”名

例如，为了获得设备的描述，可以使用下面代码。(见DEVPROP例子中的AddDevice函数)

WCHAR name[256];
ULONG junk;
status = IoGetDeviceProperty(pdo, DevicePropertyDeviceDescription, sizeof(name), name, &junk);
KdPrint((DRIVERNAME " - AddDevice has succeeded for '%ws' device\n", name));

注意表12-5，除了友好名，PnP管理器和总线驱动程序共同负责自动创建所有标准设备属性。你可以在INF文件中给出一个友好名：

[DriverInstall.NT.hw]
AddReg=DriverHwAddReg

[DriverHwAddReg]
HKR,,FriendlyName,,"Packet DMA Demonstration Device"

注意，在同一计算机上如果你采用这个方法，那么这种类型的每个设备最后会有相同的友好名。多个设备有同一个友好名会使用户迷惑。如果你察觉出可能会存在重复的友好名，应该提供一个安装协助DLL来计算唯一的名字。

用户模式应用程序可以使用SetupDiGetDeviceRegistryProperty函数接收标准设备属性。在使用setup API枚举已寄存接口的过程中可以使用下面方法：

HDEVINFO info = SetupDiGetClassDevs(...);
SP_DEVINFO_DATA did = {sizeof(SP_DEVINFO_DATA)};
SetupDiGetDeviceInterfaceDetail(info, ..., &did);
TCHAR fname[256];
SetupDiGetDeviceRegistryProperty(info,
                 &did,
                 SPDRP_FRIENDLYNAME,
                 NULL,
                 (PBYTE) fname,
                 sizeof(fname),
                 NULL);

用于指定接收各种属性的SPDRP_XXX值请参见SetupDiGetDeviceRegistryProperty函数的DDK文档。

正如你所见，你必须为SetupDiGetDeviceRegistryProperty提供一个设备信息集句柄(一个HDEVINFO)和一个SP_DEVINFO_DATA结构。如果你在枚举设备接口实例循环的中间，你可以很容易地作到这一点。但如果你仅有设备的符号名呢？你可以使用下面技巧：

LPCTSTR devname;   // someone gives you this
HDEVINFO info = SetupDiCreateDeviceInfoList(NULL, NULL);
SP_DEVICE_INTERFACE_DATA ifdata = {sizeof(SP_DEVICE_INTERFACE_DATA)};
SetupDiOpenDeviceInterface(info, devname, 0, &ifdata);
SP_DEVINFO_DATA did = {sizeof(SP_DEVINFO_DATA)};
SetupDiGetDeviceInterfaceDetail(info, &ifdata, NULL, 0, NULL, &did);

在此你可以继续以正常方式调用象SetupDiGetDeviceRegistryProperty这样的函数。

注意
在Windows 98和Windows NT4中，应用程序使用CFGMGR32 API来获得设备信息和与PnP管理器交互。为了兼容，Windows 98和Windows 2000继续支持这些API，但Microsoft不鼓励新代码中使用这些API。因此我在此也不给出调用这些API的例子。

非标准属性

PnP管理器在硬件键下创建了一个名为Device Parameters的子键。该子键包含设备的非标准属性。你可以在INF中的添加注册表段中初始化非标准属性：

[DriverInstall.nt.hw]
AddReg=DriverHwAddReg

[DriverHwAddReg]
HKR,,SampleInfo,,"%wdmbook%\chap7\pktdma\pktdma.htm"

WDM驱动程序可以容易地用IoOpenDeviceRegistryKey函数打开设备参数键的句柄。应用程序使用SetupDiOpenDevRegKey函数可以访问该键。

INF文件工具

如果你查看Windows 2000 DDK中的TOOLS子目录，你会找到两个用于处理INF文件的有用工具。GENINF能帮助你创建一个新INF文件，CHKINF能帮助你检验INF。

CHKINF实际上是一个运行PERL脚本的BAT文件，该脚本将检验INF文件的合法性。显然，使用这个工具你必须先有一个PERL解释程序。可从http://www.perl.com得到。

可以从命令行上运行CHKINF。例如：

E:\Ntddk\tools\chkinf>chkinf C:\wdmbook\chap12\devprop\sys\device.inf

CHKINF在一个HTM子目录中产生一个HTML输出文件。图12-4显示了我在检测DEVPROP例子的DEVICE.INF所给出的输出。

图12-4. CHKINF的输出例子

在Windows 2000中，设备安装程序在Windows NT目录的SETUPAPI.LOG文件中登记其操作的各种信息。你可以通过手工修改键HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\Setup下的注册表项来控制登记信息的详细程度和登记文件名。这些设置的详细信息请参见DDK文档。


定义设备类

假设你有一个设备不属于Microsoft定义的任何设备类。当你开始测试设备和驱动程序时，虽然使用Unknown可能会成功，但产品级的设备不应该假定为Unknown类。你可以在INF文件中定义一个新设备类，在这一节我将解释如何创建一个定制设备类。

我们以前讨论过的INF例子就是依靠一个这样的定制设备类：

[Version]
Signature=$CHICAGO$
Class=Sample
ClassGuid={894A7460-A033-11d2-821E-444553540000}

实际上，本书上的所有例子都使用Sample类。

定义新设备类时，你仅需要完成一件任务：运行GUIDGEN为这个类生成一个唯一的GUID。为了使这个设备类的用户接口更完美，你还可以做一些额外工作，如为设备管理器写一个属性页程序，放一些特殊的表项到该类可以使用的注册表键里。你还可以提供过滤器驱动程序和超越参数，它们将用于该类的每个设备。你可以用INF文件中的语句控制这些额外特征。例如：

[ClassInstall32]
AddReg=SamclassAddReg
CopyFiles=SamclassCopyFiles

[SamclassAddReg]
HKR,,,,"WDM Book Sample"
...

[SamclassCopyFiles]
...

上面给出的设备类注册表项目在设备管理器或在添加新硬件向导的设备类型列表中会变成“友好名”。我将解释一些需要添加到class键中的额外注册表项。

注意
我的每个INF文件都没有ClassInstall32段。因为例子光盘的setup程序已经把必要的类信息直接放到注册表中了。但如果你在一个产品级的驱动程序包中定义了自己的设备类，你将需要这个段。注意Microsoft鼓励用INF文件安装新类。

属性页程序

回到第一章，图1-6显示了Sample设备类的属性页。SAMCLASS例子程序是该属性页程序的源代码。下面我解释它是如何工作的。

设备类的属性页程序是一个32位DLL，包含下面内容：

  • 属性页支持的每个类的输出入口点
  • 每个属性页的对话框资源
  • 每个属性页的对话框过程

一般，一个DLL可以为多个设备类提供属性页，例如Microsoft随操作系统提供的某些属性页DLL。但SAMCLASS仅为一个设备类提供了一个页。它的唯一的输出入口点指向下面函数：

extern "C"
BOOL CALLBACK EnumPropPages (PSP_PROPSHEETPAGE_REQUEST p, LPFNADDPROPSHEETPAGE AddPage, LPARAM lParam)
{
  PROPSHEETPAGE page;
  HPROPSHEETPAGE hpage;
  memset(&page, 0, sizeof(page));
  page.dwSize = sizeof(PROPSHEETPAGE);
  page.hInstance = hInst;
  page.pszTemplate = MAKEINTRESOURCE(IDD_SAMPAGE);
  page.pfnDlgProc = PageDlgProc;
  <some more stuff>
  hpage = CreatePropertySheetPage(&page);
  if (!hpage)
    return TRUE;
  if (!(*AddPage)(hpage, lParam))
    DestroyPropertySheetPage(hpage);
  return TRUE;
}

当设备管理器要为一个设备构造属性页面时，它参考其class键，看它是否有一个属性页提供者。你可以在INF文件中指定一个提供者，如下：

[SamclassAddReg]
HKR,,EnumPropPages32,,"samclass.dll,EnumPropPages"

设备管理器装入你指定的DLL(SAMCLASS.DLL)并调用入口点函数(EnumPropPages)。如果该函数返回TRUE，则设备管理器就显示属性页，否则不显示。通过调用AddPage例程该函数还可以加入更多的页。

在SP_PROPSHEETPAGE_REQUEST结构中有两个非常有用的信息：一个是设备信息集的句柄，另一个是SP_DEVINFO_DATA结构的地址。只要属性页可见，这些数据项就是有效的，你可以在属性页的对话框过程中访问它们。Windows SDK的Programming 101会教你如何去做。首先创建一个辅助结构，其地址作为PROPSHEETPAGE结构的lParam成员传递给CreatePropertySheetPage：

struct SETUPSTUFF {
  HDEVINFO info;
  PSP_DEVINFO_DATA did;
};

BOOL EnumPropPages(...)
{
  PROPSHEETPAGE page;
  ...
  SETUPSTUFF* stuff = new SETUPSTUFF;
  stuff->info = p->DeviceInfoSet;
  stuff->did = p->DeviceInfoData;
  page.lParam = (LPARAM) stuff;

  page.pfnCallback = PageCallbackProc;
  page.dwFlags = PSP_USECALLBACK;
  ...
}

UINT CALLBACK PageCallbackProc(HWND junk, UINT msg, LPPROPSHEETPAGE p)
{
  if (msg == PSPCB_RELEASE && p->lParam)
    delete (SETUPSTUFF*) p->lParam;
  return TRUE;
}

WM_INITDIALOG消息的lParam参数是指向同一个PROPSHEETPAGE结构的指针，所以你可以在那里收到stuff指针。然后你可以使用SetWindowLong和GetWindowLong在DWL_USER中保存任何需要的信息。在SAMCLASS中，我选择了描述例子驱动程序的readme文件的名字。下面我将给出代码。

你还需要提供一种方法来删除无用的SETUPSTUFF结构。最简单的方式(不管你是否得到WM_INITDIALOG消息)是使用一个属性页回调函数。

在定制属性页中你可以做任何事情。对于一个例子类，我想要一个按钮，按下这个按钮会显示出每个例子设备的解释。为此，我把命名含有解释信息文件的值SampleInfo放到设备的硬件键中。为了调出该解释文件的查看程序，我们可以调用ShellExecute，它会解释文件扩展名并定位一个适当的查看程序。对于书中的例子，其解释文件是HTML格式，因此查看程序应该是Web浏览器。

SAMCLASS的大多数工作发生在WM_INITDIALOG处理程序中。(这里忽略了错误检测)

case WM_INITDIALOG:
{
  SETUPSTUFF* stuff = (SETUPSTUFF*) ((LPPROPSHEETPAGE) lParam)->lParam;
  BOOL okay = FALSE;
  TCHAR name[256];
  SetupDiGetDeviceRegistryProperty(stuff->info,							<--1
                   stuff->did,
                   SPDRP_FRIENDLYNAME,
                   NULL,
                   (PBYTE) name,
                   sizeof(name),
                   NULL);
  SetDlgItemText(hdlg, IDC_SAMNAME, name);

  HKEY hkey = SetupDiOpenDevRegKey(stuff->info,							<--2
                   stuff->did,
                   DICS_FLAG_GLOBAL,
                   0,
                   DIREG_DEV,
                   KEY_READ);
  DWORD length = sizeof(name);
  RegQueryValueEx(hkey, "SampleInfo", NULL, NULL, (LPBYTE) name, &length);
  LPSTR infofile;
  DoEnvironmentSubst(name, sizeof(name));							<--3
  infofile = (LPSTR) GlobalAlloc(GMEM_FIXED, strlen(name)+1);
  strcpy(infofile, name);
  SetWindowLong(hdlg, DWL_USER, (LONG) infofile);						<--4
  RegCloseKey(hkey);
  break;
}
  1. 这里我们为设备确定FriendlyName并把它放到一个静态文本控制中。
  2. 接下来的语句从硬件键的parameter子键中确定SampleInfo文件名。
  3. 放到注册表中的串的形式为%wdmbook%\chap12\devprop\devprop.htm，%wdmbook%为WDMBOOK环境变量。DoEnvironmentSubst是一个标准的Win32 API，用于扩展环境变量。
  4. 我需要在某个地方保存SampleInfo文件名，SetWindowLong可以方便地做到这一点。

当用户按下属性页上的More Information按钮时，对话框过程就收到一个WM_COMMAND消息，它的处理如下：

case WM_COMMAND:
  switch (LOWORD(wParam))
  {
    case IDB_MOREINFO:
    {
      LPSTR infofile = (LPSTR) GetWindowLong(hdlg, DWL_USER);
      ShellExecute(hdlg, NULL, infofile, NULL, NULL, SW_SHOWNORMAL);
      return TRUE;
    }
  }
  break;

ShellExecute将运行与SampleInfo文件相关联的应用程序，即你的Web浏览器，于是你就看到了这个文件的内容。

其它相关信息

在前面段中，我解释了如何用EnumPropPages32注册表项控制设备属性页的显示。这里还有一些控制类特征的其它注册表项：

  • Installer32指定一个DLL，该DLL用于执行类所属设备的安装功能。写一个类安装程序是一项巨大的工程，并不是因为DDK文档没有跟上这方面的软件。
  • Class是类名，出现在INF文件的Class=语句中。
  • Icon为该类指定用户接口显示图标。其值为一个含有十进制整数的串。正值代表Installer32 DLL中的图标；文档中说如果你没有类安装程序，系统会使用EnumPropPages32 DLL中的图标，但我没有遇到这种情况。负值代表SETUPAPI.DLL中的图标(用绝对值索引)。如果你不指定一个图标，系统会使用一个灰色菱形来代表。我决定用值-5来指定Sample类的图标，这个图标很象PCI卡。实际上，系统使用它作为网卡图标，但我喜欢这个图标。
  • NoInstallClass，如果存在且不等于0，则指出某枚举器将自动检测属于该类的任何设备。如果类有这个属性，硬件向导将不在设备类列表中列出该类。
  • SilentInstall，如果存在且不等于0，将使PnP管理器在安装该类的设备时不呈现任何对话框给用户。
  • UpperFilters和LowerFilters为过滤器驱动程序指定service名。PnP管理器为每个属于该类的驱动程序装入这些过滤器。(注意在设备硬件键中指定的过滤器驱动程序仅用于一个设备)
  • NoDisplayClass，如果存在且不等于0，则禁止设备管理器显示该类设备。

class键还可以指定DeviceCharacteristics、DeviceType、和/或Security属性，以及某种设备属性的超越值。PnP管理器在创建设备PDO时会使用这些超越值。但我怀疑某一天系统管理员也能够检测并修改这些属性。


运行应用程序

为了增加用户对硬件的体验，你可以提供一个应用程序，该应用程序在设备存在时随时可以启动。Microsoft为静态图象照相机提供了一个专用的机制，但没有提供其它设备可以使用的通用机制。在这里我将描述一个通用机制，称为AutoLaunch。

AutoLaunch服务

Windows 98和Windows 2000都可以在硬件事件发生时通知应用程序。Windows 95引入了WM_DEVICECHANGE消息。作为Windows 95的一个原始设想，系统在用户模式中为几种可能的设备事件向所有顶级窗口广播这个消息。

基于WM_DEVICECHANGE消息，当设备驱动程序允许或禁止一个寄存的设备接口时，Windows 2000就向关注的服务应用程序生成通知。我的AutoLaunch服务就是利用了这些通知。通过调用一个新的用户模式API服务RegisterDeviceNotification，该服务可以预定某接口GUID的通知。

#include <dbt.h>

DEV_BROADCAST_DEVICEINTERFACE filter = {0};
filter.dbcc_size = sizeof(filter);
filter.dbcc_devicetype = DBT_DEVTYP_DEVICEINTERFACE;
filter.dbcc_classguid = GUID_AUTOLAUNCH_NOTIFY;

HDEVNOTIFY hNotification = RegisterDeviceNotification(hService,
                              (PVOID) &filter,
                              DEVICE_NOTIFY_SERVICE_HANDLE);

为了接收这个接口通知，该服务必须在其ServiceMain函数中调用RegisterServiceCtrlHandlerEx来初始化，而不是调用RegisterServiceCtrlHandler。

hService = RegisterServiceCtrlHandlerEx(<svcname>, HandlerEx, <context>);

调用RegisterServiceCtrlHandlerEx时，你需要指定一个HandlerEx事件句柄函数，该函数有三个以上的参数，而不是调用标准的Handler服务函数：

DWORD __stdcall HandlerEx(DWORD ctlcode, DWORD evtype, PVOID evdata, PVOID context)
{
}

在这种情况下，ctlcode将等于SERVICE_CONTROL_DEVICEEVENT，evtype将等于DBT_DEVICEARRIVAL，evdata将是一个设备接口广播结构的地址。context将作为RegisterServiceCtrlHandlerEx的第三个参数。

设备接口广播结构应该象这样：

struct _DEV_BROADCAST_DEVICEINTERFACE_W {
  DWORD dbcc_size;
  DWORD dbcc_devicetype;
  DWORD dbcc_reserved;
  GUID dbcc_classguid;
  WCHAR dbcc_name[1];
};

dbcc_devicetype值为DBT_DEVTYP_DEVICEINTERFACE。dbcc_classguid是一个128位的接口GUID，dbcc_name是用于打开设备句柄的符号连接名。这个特别结构有两种版本：ANSI和Unicode。服务通知总是使用Unicode版本，而AutoLaunch使用ANSI版本。

触发AutoLaunch

为了向AutoLaunch触发一个设备接口到来通知，驱动程序必须用AutoLaunch GUID寄存并允许一个接口。

typedef struct _DEVICE_EXTENSION {
  ...
  UNICODE_STRING AutoLaunchInterfaceName;
} DEVICE_EXTENSION, *PDEVICE_EXTENSION;

NTSTATUS AddDevice(...)
{
  ...
  IoRegisterDeviceInterface(pdo, &GUID_AUTOLAUNCH_NOTIFY, NULL, &pdx->AutoLaunchInterfaceName);
  ...
}

NTSTATUS StartDevice(PDEVICE_OBJECT fdo, ...)
{
  ...
  IoSetDeviceInterfaceState(&pdx->AutoLaunchInterfaceName, TRUE);
  ...
}

第二章讨论的设备接口是作为命名一个设备的方法，利用它应用程序可以找到设备并打开其句柄。一个设备也可以寄存多个接口。在这种情况下，你可以在所支持的接口之外再寄存一个AutoLaunch接口，AutoLaunch接口的唯一目的就是为等待的服务生成通知。

当驱动程序允许其GUID_AUTOLAUNCH_NOTIFY接口时，系统向AutoLaunch服务发送一个设备到来通知，服务用下面函数处理该通知：

DWORD CAutoLaunch::HandleDeviceChange(DWORD evtype, _DEV_BROADCAST_HEADER* dbhdr)
{
  if (!dbhdr || evtype != DBT_DEVICEARRIVAL || dbhdr->dbcd_devicetype != DBT_DEVTYP_DEVICEINTERFACE)
    return 0;												<--1
  PDEV_BROADCAST_DEVICEINTERFACE_W p = (PDEV_BROADCAST_DEVICEINTERFACE_W) dbhdr;
  CString devname = p->dbcc_name;									<--2
  HDEVINFO info = SetupDiCreateDeviceInfoList(NULL, NULL);
  SP_DEVICE_INTERFACE_DATA ifdata = {sizeof(SP_DEVICE_INTERFACE_DATA)};
  SP_DEVINFO_DATA devdata = {sizeof(SP_DEVINFO_DATA)};
  SetupDiOpenDeviceInterface(info, devname, 0, &ifdata);
  SetupDiGetDeviceInterfaceDetail(info, &ifdata, NULL, 0, NULL, &devdata);
  OnNewDevice(devname, info, &devdata);
  SetupDiDestroyDeviceInfoList(info);
  return 0;
}
  1. 除了我们感兴趣的通知外，这里还有其它通知。其中一些是查询。对于某些不需要明确处理的查询我们返回0。实际上，真正的AUTOLAUNCH例子还处理DBT_DEVICEREMOVECOMPLETE通知，以便记录哪一个是处理过的通知并在系统启动时避免重复处理。为了避免混乱我去掉了这个细节。
  2. 我生成了非UNICODE的AutoLaunch例子。所以这个语句把通知结构中的Unicode连接名转换成ANSI形式。

我的OnNewDevice函数会生成一个新进程，以执行该函数在注册表中找到的任何命令行命令。使用设备的硬件键作为命令行的保存库特别方便，下面就是实现代码：

void CAutoLaunch::OnNewDevice(const CString& devname, HDEVINFO info, PSP_DEVINFO_DATA devdata)
{
  HKEY hkey = SetupDiOpenDevRegKey(info, devdata, DICS_FLAG_GLOBAL, 0, DIREG_DEV, KEY_READ);	<--1

  DWORD junk;
  TCHAR buffer[_MAX_PATH];
  DWORD size = sizeof(buffer);
  CString Command;
  RegQueryValueEx(hkey, "AutoLaunch", NULL, &junk, (LPBYTE) buffer, &size);			<--2
  Command = buffer;

  CString FriendlyName;										<--3
  SetupDiGetDeviceRegistryProperty(info,
                   devdata,
                   SPDRP_FRIENDLYNAME,
                   NULL,
                   (PBYTE) buffer,
                   sizeof(buffer),
                   NULL);
  FriendlyName.Format(_T("\"%s\""), buffer);

  RegCloseKey(hkey);

  ExpandEnvironmentStrings(Command, buffer, arraysize(buffer));					<--4
  CString name;
  name.Format(_T("\"%s\""), (LPCTSTR) devname);
  Command.Format(buffer, (LPCTSTR) name, (LPCTSTR) FriendlyName);				<--5

  STARTUPINFO si = {sizeof(STARTUPINFO)};							<--6
  si.lpDesktop = "WinSta0\\Default";
  si.wShowWindow = SW_SHOW;

  PROCESS_INFORMATION pi;
  CreateProcess(NULL, (LPTSTR) (LPCTSTR) Command, NULL, NULL, FALSE, 0, NULL, NULL, &si, &pi);	<--7
  CloseHandle(pi.hProcess);									<--8
  CloseHandle(pi.hThread);
}
  1. 该语句打开设备硬件键中的Device Parameters子键。
  2. INF文件把一个AutoLaunch值放到注册表中。我们在这里读取该值。
  3. 在这里我们提取设备的FriendlyName，并作为命令行参数使用。名中可能会有些空格，因此我们先用引号引上这个名，然后再提交该命令。
  4. 我想让注册表的命令行模板中包含环境变量。该语句扩展环境串。
  5. 我还想让命令行模板使用%s转义码，以指出设备名和友好名的出处。该语句产生一个含有代替符号的命令行。
  6. 我们要调用CreateProcess来执行该命令。除非我们小心，否则该命令将使用相同的隐含桌面作为服务进程，这对用户不会有多大用处。因此我们创建一个STARTUPINFO结构，该结构指定一个交互桌面。
  7. 我们在这里运行该应用程序。CreateProcess将很快返回；应用程序一直运行直到有人关闭它。
  8. CreateProcess还返回该进程的句柄和其初始线程的句柄。我们需要关闭这些句柄。

鸡和蛋的问题

我刚描述的进程在一个不变的情况下会工作得很好，这种情况是指当设备出现并试图运行一个专用应用程序时，AutoLaunch服务已经运行在计算机上。但另两个情况仍需要应付。

第一情况是指系统刚进入引导程序，已经安装的设备在服务管理器启动AutoLaunch服务之前要寄存它们的GUID_AUTOLAUNCH_NOTIFY接口。然而，你仍希望AutoLaunch应用程序启动。

AutoLaunch这样处理这个启动问题，当它第一次启动时将枚举该接口的所有实例：

VOID CAutoLaunch::EnumerateExistingDevices(const GUID* guid)
{
  HDEVINFO info = SetupDiGetClassDevs(guid, NULL, NULL, DIGCF_PRESENT | DIGCF_INTERFACEDEVICE);
  SP_INTERFACE_DEVICE_DATA ifdata;
  ifdata.cbSize = sizeof(ifdata);
  DWORD devindex;
  for (devindex = 0; SetupDiEnumDeviceInterfaces(info, NULL, guid, devindex, &ifdata); ++devindex)
  {
    DWORD needed;
    SetupDiGetDeviceInterfaceDetail(info, &ifdata, NULL, 0, &needed, NULL);
    PSP_INTERFACE_DEVICE_DETAIL_DATA detail = (PSP_INTERFACE_DEVICE_DETAIL_DATA) malloc(needed);
    detail->cbSize = sizeof(SP_INTERFACE_DEVICE_DETAIL_DATA);
    SP_DEVINFO_DATA devdata = {sizeof(SP_DEVINFO_DATA)};
    SetupDiGetDeviceInterfaceDetail(info, &ifdata, detail, needed, NULL, &devdata);
    CString devname = detail->DevicePath;
    free((PVOID) detail);
    OnNewDevice(devname, guid);
  }
}

这个函数中的重要代码是粗体显示部分，这些代码用于获得必要的SP_DEVINFO_DATA结构和符号连接名。然后我们调用OnNewDevice来处理这个预先存在的设备。

运行服务

第二个启动情况是当设备第一次被安装到计算机上时，而该计算机从没有见过AutoLaunch服务。你的INF文件需要定义AutoLaunch服务并复制该服务的二进制文件到用户的计算机。还要向注册表加入一个名为RunOnce的键以触发该服务。例如：

[DestinationDirs]
AutoLaunchCopyFiles=10
[etc.]

[DriverInstall.NT]
CopyFiles=DriverCopyFiles,AutoLaunchCopyFiles
AddReg=DriverAddReg.NT

[DriverAddReg.NT]
HKLM,%RUNONCEKEYNAME%,AutoLaunchStart,,\
  "rundll32 StartService,StartService AutoLaunch"

[DriverInstall.NT.Services]
AddService=AutoLaunch,,AutoLaunchService
[etc.]

[AutoLaunchCopyFiles]
AutoLaunch.exe,,,0x60
StartService.exe,,,0x60

[AutoLaunchService]
ServiceType=16
StartType=2
DisplayName="AutoLaunch Service"
ErrorControl=1
ServiceBinary=%10%\AutoLaunch.exe

[Strings]
RUNONCEKEYNAME="Software\Microsoft\Windows\CurrentVersion\RunOnce"

全部代码见AUTOLAUNCH例子的SYS目录中的DEVICE.INF文件。

设备安装完成后，系统将执行RunOnce键中的任何命令。我们放在那里的命令就是启动AutoLaunch服务。注意STARTSERVICE.DLL是一个很小的DLL，用于启动一个服务，它不显示任何用户接口信息或弹出对话框。如果你需要远程安装驱动程序包，你应该用RUNDLL32来替换RunOnce值中的命令动词。

注意
Microsoft的KBase文章第Q173039暗示RunOnce键中有立即处理行为的表项在调用RUNDLL32时会产生侧效。但一个负责设备安装程序的Microsoft开发员向我保证，RunOnce值在安装一个新设备时总是被处理。别管该文章中的暗示。


Windows 98兼容问题

在设备安装与维护方面，Windows 98使用了与Windows 2000完全不同的技术。本节我将介绍某些有影响的因素。

属性页提供程序

新设备类的属性页提供程序必须是一个16位的DLL。因此先不要丢弃你的16位编译器。

注册表用法

Windows 98使用software注册表键来定位设备驱动程序。为了初始化这个键，你的Windows 98 install段应该有一个这样的AddReg指令：

[DriverInstall]
AddReg=DriverAddReg
<other install directives>

[DriverAddReg]
HKR,,DevLoader,,*ntkern
HKR,,NTMPDriver,,pktdma.sys

即指定NTKERN.VXD作为设备载入程序，指定你的WDM驱动程序作为NTKERN寻找的NTMPDriver。另外，你应该忽略.Services段，Windows 98并不使用它。

相对于Windows 2000，Windows 98把标准和非标准的设备属性都放到了硬件键中，而不是把非标准属性分开到Device Parameters子键中。这也许会更方便，因为你不必使用IoGetDeviceProperty来获取标准属性。

获得设备属性

Windows 98(包括Windows 98第二版)用于读取设备硬件键中标准属性的函数IoGetDeviceProperty存在错误。为了在WDM驱动程序中获取这些属性，你应该使用IoOpenDeviceRegistryKey函数并用名字查询属性。DEVPROP例子显示了如何用这种方法获得标准设备描述属性。

应用程序执行

Windows 98没有服务管理器，因此你不能把AutoLaunch作为服务运行。但可以使用注册表中的Run关键字。这个AutoLaunch包的一部分是ALNCH98.EXE小程序(applet)，它可以以这种方式执行。使用它的任务条图标可以停止该程序。


Windows 98不兼容处理

附录A：Windows 98不兼容处理

本书的许多章都是以讨论Windows 98的兼容问题为结尾。Microsoft最初计划让Windows 98和Windows 2000使用同一种驱动程序二进制文件，但事实证明这个目标太高。不要惊讶，Windows 2000会继续发展，它可以支持一些Windows 98不支持的内核模式服务函数。如果一个WDM驱动程序调用了这种函数，Windows 98将拒绝装入该驱动程序，因为它不能解决其中的符号引用。在这个附录中，我将描述一个静态虚拟设备驱动程序(VxD)，即光盘上的WDMSTUB例子，它解决了其中一些符号问题。一旦你可以装入含有Windows 98不支持函数调用的驱动程序，你会发现在运行时确定所在操作系统是Windows 98还是Windows 2000是十分必要的；我会描述做这个判断的一个方法。

  • 为内核模式例程定义桩
  • 确定操作系统版本


为内核模式例程定义桩

WDMSTUB.VXD使用的桩技术与Microsoft移植数百个Windows NT内核模式支持函数到Windows 98环境中所使用的技术类似，即扩充运行时间装入器在解决输入引用时使用的符号表。为了扩充这个符号表，你需要先定义三个驻留内存的数据表：

  • 给出你定义函数名称的名称表
  • 给出这些函数地址的地址表
  • 关联函数名称与函数地址的顺序表

这里是WDMSTUB中的一些表项：

static char* names[] = {
  "PoRegisterSystemState",
  ...
  "ExSystemTimeToLocalTime",
  ...
};

static WORD ordinals[] = {
  0,
  ...,
  6,
  ...
};

static PFN addresses[] = {
  (PFN) PoRegisterSystemState,
  ...
  (PFN) ExSystemTimeToLocalTime,
  ...
};

顺序表的用途是为一个给定names表项提供address中的索引。即名为names[i]的函数地址为address[ordinals[i]]。

如果不是应付版本兼容问题，你可以调用_PELDR_AddExportTable函数：

HPEEXPORTTABLE hExportTable = 0;

extern "C" BOOL OnDeviceInit(DWORD dwRefData)
{
  _PELDR_AddExportTable(&hExportTable,
            "ntoskrnl.exe",
            arraysize(addresses), //  don't do it this way!
            arraysize(names),
            0,
            (PVOID*) names,
            ordinals,
            addresses,
            NULL);
  return TRUE;
}

_PELDR_AddExportTable调用扩展了载入程序使用的符号表。NTKERN.VXD是Windows 98中支持WDM驱动程序的主要模块，它用其支持的几百个函数的地址来初始化这个表。WDMSTUB.VXD是一个静态VxD，它的初始化顺序在NTKERN之后，但在Windows 98配置管理器之前。因此，WDMSTUB的输出定义在系统装入任何WDM驱动程序时已被确定。结果，WDMSTUB扩展了NTKERN。

版本兼容

版本兼容问题是这样的：Windows 98所支持的能够被WDM驱动程序使用的函数是Windows 2000的一个子集。Windows 98第二版支持一个更大的子集。下一版本的Windows，代码名为Millennium，将支持更大的子集(甚至是超集，因为它在Windows 2000之后发布)。你不会希望VxD桩复制任何OS支持的函数。WDMSTUB的实际工作是在系统初始化过程中展开的，是动态地构造一个表，然后传递给_PELDR_AddExportTable：

HPEEXPORTTABLE hExportTable = 0;

extern "C" BOOL OnDeviceInit(DWORD dwRefData)
{
  char** stubnames = (char**) _HeapAllocate(sizeof(names), HEAPZEROINIT);
  PFN* stubaddresses = (PFN*) _HeapAllocate(sizeof(addresses), HEAPZEROINIT);
  WORD* ordinals = (WORD*) _HeapAllocate(arraysize(names) * sizeof(WORD), HEAPZEROINIT);
  int i, istub;
  for (i = 0, istub = 0; i < arraysize(names); ++i)
  {
    if (_PELDR_GetProcAddress((HPEMODULE) "ntoskrnl.exe", names[i], NULL) == 0)
    {
      stubnames[istub] = names[i];
      ordinals[istub] = istub;
      stubaddresses[istub] = addresses[i];
      ++istub;
    }
  }
  _PELDR_AddExportTable(&hExportTable,
            "ntoskrnl.exe",
            istub,istub,
            0,
            (PVOID*) stubnames,
            ordinals,
            stubaddresses,
            NULL);
  return TRUE;
}

粗体部分是这里的关键步骤，它确保我们不会替换NTKERN中或其它系统VxD中已存在的函数。

在解决版本兼容问题上还有一个小问题。Windows 98第二版仅输出了管理IO_REMOVE_LOCK对象的四个支持函数中的三个。另外一个函数是IoRemoveLockAndWaitEx。WDMSTUB.VXD驱动程序将补偿这个遗漏，或者支持所有四个函数，或者一个也不支持，取决于系统中是否有这个函数。

桩函数

WDMSTUB.VXD的主要目的是解决驱动程序会引用但没有实际调用的符号。对于某些函数，如PoRegisterSystemState，WDMSTUB.VXD仅简单地包含一个桩，如果调用会返回一个错误指示：

PVOID PoRegisterSystemState(PVOID hstate, ULONG flags)
{
  ASSERT(KeGetCurrentIrql() < DISPATCH_LEVEL);
  return NULL;
}
创建WDMSTUB

为了正确创建WDMSTUB，我需要合并两个非标准特征。每个桩函数必须使用__stdcall调用约定，而VxD通常使用__cdecl。

我希望从VxD桩中调用KeGetCurrentIrql函数和其它WDM服务函数。标准做法是在所有VxD头文件前面包含WDM.H或NTDDK.H，并连接WDMVXD.LIB输入库。WDMVXD.LIB假定你要输入的函数以__declspec(dllimport)指示声明，当你包含了WDM.H或NTDDK.H头文件时这通常是对的。因为它们都用一个预处理宏NTKERNALAPI来声明，而该宏通常定义为__declspec(dllimport)。不幸的是，如果你定义一个标记为dllimport的函数，编译器会假定你要输出这个函数。一个VxD的第一个输出必须是定义该驱动程序的设备描述块(DDB)，不是某个随机输出的桩函数。我猜想在模块定义文件中指定DDB的顺序号为1会强制DDB成为第一个输出，但我错了。我最后得到一个不能载入的VxD。

为了越过这些输入输出声明问题，我不得不强迫NTDDK.H不以正常方式定义NTKERNELAPI。(见WDMSTUB工程中的STDVXD.H文件) 由于WDMVXD.LIB词表的限制，我不得不使用象_KeGetCurrentIrql@0这样的符号引用。在KeGetCurrentIrql的特殊情况中，驱动程序可以发出一个标准VxDCall，调用名为ObsoleteKeGetCurrentIrql的服务并到达Windows 98内核中的正确函数。另外，你也可以定义一个(如MyGetCurrentIrql)调用KeGetCurrentIrql的函数，然后把它放到源模块中，并用NTKERNELAPI的正常设置编译该模块。

虽然有时你并不需要写一个失败该函数的函数桩，实际上你可以实现该函数，如下例：

VOID ExLocalTimeToSystemTime(PLARGE_INTEGER localtime, PLARGE_INTEGER systime)
{
  systime->QuadPart = localtime->QuadPart + GetZoneBias();
}

GetZoneBias是一个辅助例程，通过查询注册表TimeZoneInformation键中的ActiveTimeBias值来确定时区偏差。

表A-1列出了WDMSTUB.VXD输出的内核模式支持函数。

表A-1. WDMSTUB.VXD输出的函数

支持函数 | 备注
ExLocalTimeToSystemTime | 实现
ExSystemTimeToLocalTime | 实现
IoAcquireRemoveLockEx | 实现
IoAllocateWorkItem | 实现
IoFreeWorkItem | 实现
IoInitializeRemoveLockEx | 实现
IoQueueWorkItem | 实现
IoReleaseRemoveLockEx | 实现
IoReleaseRemoveLockAndWaitEx | 实现
IoCreateNotificationEvent | 失败桩
IoCreateSynchronizationEvent | 失败桩
IoReportTargetDeviceChangeAsynchronous | 失败桩
KdDebuggerEnabled | 实现
KeEnterCriticalRegion | 实现
KeLeaveCriticalRegion | 实现
KeNumberProcessors | 返回1
KeSetTargetProcessorDpc | 实现
PoRegisterSystemState | 失败桩
PoSetSystemState | 失败桩
PoUnregisterSystemState | 失败桩
PsGetVersion | 实现
RtlInt64ToUnicodeString | 失败桩
RtlUlongByteSwap | 实现
RtlUlonglongByteSwap | 实现
RtlUshortByteSwap | 实现


确定操作系统版本

驱动程序一旦装入，就需要布置某些Windows 98支持例程桩，这需要在运行时确定当前操作系统的版本。例如，你要调用一个非WDM(严格地讲)函数。IoReportTargetDeviceChangeAsynchronous就是这样的函数，我在PNPEVENT例子中使用过该函数。

通过调用GetVersionEx函数，应用程序可以很容易了解操作系统。而最类似的内核模式函数是IoIsWdmVersionAvailable：

BOOLEAN IoIsWdmVersionAvailable(MajorVersion, MinorVersion);

Windows 2000支持WDM版本1.10，即WDM.H中含有WDM_MAJORVERSION(0x01)和WDM_MINORVERSION(0x10)常量。Windows 98(包括第二版)仅支持WDM1.0。你可以利用这个支持级别的不同来判断所在的操作系统。

探测操作系统版本

我过去依靠不同的探测方法来确定操作系统的版本，但随着操作系统的改进这些方法不能使用了。例如，在Windows 98的原始发行版中，当系统以正常方式调用你的DriverEntry时，驱动程序的DriverExtension中的ServiceKeyName长度为0。但在Windows 2000中，该参数是一个非空串。因此后来版本的Windows 98也是这样，这可以用于探测原始版本的Windows 98。

Vireo软件公司建议使用\Registry\Machine\SAM键的存在作为判断Windows 2000的标准。但这种方法对于在Windows 2000启动时装入的驱动程序不可行，所以也不能依靠这种测试。该公司现在推荐测试HKLM\System\CurrentControlSet\Control\Class键，该键只存在于Windows 2000种，而HKLM\System\CurrentControlSet\Services\Class键仅存在于Windows 98种。


附录B：使用GENERIC.SYS

本附录解释了支持库GENERIC.SYS的公共接口，本书中的大部分例子驱动程序都使用这个库。首先我需要解释关于GENERIC的一些事情。

我创建GENERIC的原因是因为在写本书时我需要不断地修改例子驱动程序的PnP和电源管理支持。本书出版后我仍需要修改这个支持。为了避免在每次了解到新的PnP和电源管理特征时修改20多个例子驱动程序，我决定创建GENERIC并让它处理所有的IRP_MJ_PNP和IRP_MJ_POWER请求。

我使WDMWIZ.AWX(下一附录中介绍)和GENERIC保持同步。即，如果你使用WDMWIZ创建一个驱动程序，那么不管你有没有使用GENERIC都会得到同样的结果。如果你决定使用GENERIC，你的驱动程序可以调用GENERIC来处理WDM驱动程序需要做的一些复杂事情。如果你不使用它，你的驱动程序将包含所有那些代码。

最后，我使用Microsoft的AUTODUCK工具自动生成了GENERIC输出函数的文档。AUTODUCK为源代码加上特殊格式的注释，并把它们转换为文档。如果你能记得更新注释，就可以容易地更新文档。该文档是位于GENERIC目录中的GENERIC.RTF文件。

本书的大部分例子程序需要依靠GENERIC.SYS来处理某些极其复杂的PnP和电源管理操作。当读到第六章和第八章时，你可能想了解例子程序是如何与GENERIC联系起来的，见下面：

  • 例子程序中的AddDevice函数调用InitializeGenericExtension例程，并传递一个结构，该结构含有处理PnP和电源管理请求的函数指针。
  • IRP_MJ_PNP和IRP_MJ_POWER的派遣例程把这些IRP委托给GENERIC来处理。
  • GENERIC在适当的地方回调进入驱动程序。
  • GENERIC使用的各种API和回调函数参见GENERIC目录中的GENERIC.RTF文件。

表AB-1. GENERIC使用的驱动程序回调例程

IRP类型 | 回调函数 | 目的
IRP_MJ_PNP | StartDevice | 启动设备(映射内存寄存器、连接中断，等等)
 | StopDevice | 停止设备并释放I/O资源(解除内存寄存器映射、断开中断，等等)
 | RemoveDevice | 执行与AddDevice中执行的相反的步骤(断开低层设备对象、删除设备对象，等等)
 | OkayToStop | (可选)现在能否停止该设备？(用于处理IRP_MN_QUERY_STOP_DEVICE时)
 | OkayToRemove | (可选)现在能否删除该设备？(用于处理IRP_MN_QUERY_REMOVE_DEVICE时)
IRP_MJ_POWER | QueryPower | (可选)设备能进入这个电源状态吗？(用于处理IRP_MN_QUERY_POWER时)
 | SaveDeviceContext | (可选)保存任何可能在低电源状态期间丢失的设备上下文
 | RestoreDeviceContext | (可选)低电源状态结束后恢复设备上下文


附录C：使用WDMWIZ.AWX

本附录描述了如何使用WDMWIZ.AWX向导创建能被Visual C++ 6.0使用的驱动程序工程。我写这个向导的目的是想更方便地为本书生成例子驱动程序。我想你或许能利用这个向导。

光盘上的WDMBOOK.HTM文件描述了如何把这个向导装入你的系统。一旦装入后，你会发现Visual C++在创建新工程时，New对话框中多了一个WDM Driver Wizard工程标签。

WDMWIZ.AWX并不是一个产品。因此我没有更改其用户界面的计划。另外，我对你使用WDMWIZ.AWX完成的驱动程序不做任何品质保证。

  • 基本驱动程序信息
  • DeviceIoControl代码
  • I/O资源
  • 电源管理能力
  • USB端点
  • WMI支持
  • INF文件参数
  • 注意事项


基本驱动程序信息

开始对话框(见图AC-1)要求你给出被创建驱动程序的基本信息。

对于驱动程序的类型，你有这些选择：

  • 通用功能驱动程序 为一个通用设备创建功能驱动程序。
  • 通用过滤器驱动程序 创建一个默认处理所有类型IRP的过滤器驱动程序。
  • USB功能驱动程序 创建一个USB设备的功能驱动程序。
  • 空驱动程序工程 创建一个无任何文件的工程，但设置选项已为WDM驱动程序设置好。

图AC-1. 输入基本驱动程序信息的对话框

你可以选择下面选项：

  • Verbose Debugging Trace 如果你选择该项，驱动程序工程文件将包含许多KdPrint宏调用，这可以跟踪驱动程序中的重要操作。
  • Use Buffered Method For Reads And Writes 如果你用DO_BUFFERED_IO方式来执行读写操作则设置这项。清除这项表明你希望使用DO_DIRECT_IO方法。
  • Use Old-Style For Device Naming 设置该项将生成命名设备对象。清除这项表明生成一个使用设备接口的驱动程序。这也是Microsoft推荐做的。
  • Replace ASSERT For i86 Platforms DDK的ASSERT宏将调用内核模式支持例程(RtlAssert)，该例程在free build的Windows 2000中是一个空操作。因此即使是checked build的驱动程序也不会在free build的操作系统中停下来。设置这个选项将重定义ASSERT，以便checked build的驱动程序能够在free build的操作系统中停下来。
  • Use GENERIC.SYS Library 设置这个选项将使驱动程序使用GENERIC.SYS中的标准化代码。清除这个选项将使驱动程序含有所有标准化代码。
  • Windows 98 Detection 设置这个选项将使驱动程序包含运行时间系统版本检测代码。清除该选项将使驱动程序忽略这个检测。

你还可以指定Windows 2000 DDK和本书例子程序的基本路径名。而默认值$(DDKPATH)和$(WDMBOOK)将使用本书安装程序创建的环境变量。

最后点击Dispatch Functions按钮，指定驱动程序将要处理的IRP类型，如图AC-2。该对话框含有某些不可超越的设计决定。驱动程序将支持IRP_MJ_PNP和IRP_MJ_POWER请求。如果你指定要处理IRP_MJ_CREATE，那么你也需要处理IRP_MJ_CLOSE。如果你指定要处理IRP_MJ_READ、IRP_MJ_WRITE、IRP_MJ_DEVICE_CONTROL，你也要支持IRP_MJ_CREATE以及IRP_MJ_CLOSE。WDMWIZ.AWX不能生成仅被文件系统驱动程序使用的IRP的派遣函数框架代码。

图AC-2. 指定驱动程序能处理的IRP类型


DeviceIoControl代码

如果你指定处理IRP_MJ_DEVICE_CONTROL，向导将显示另一个对话框(见图AC-3)，允许你指定控制操作的相关信息。

图AC-3. 指定I/O控制操作的对话框

图AC-4是指定某DeviceIoControl操作相关信息的例子。大部分域与CTL_CODE宏的参数直接对应，无须解释。设置异步选项将生成对异步操作完成的支持，在派遣函数返回STATUS_PENDING之后。

图AC-4. 添加和编辑I/O控制操作的对话框


I/O资源

如果驱动程序使用I/O资源，你需要在第三个对话框中填入一些信息，见图AC-5。

图AC-5. 指定I/O资源对话框


电源管理能力

向导的第四步(如图AC-6)指定各种电源管理能力。

图AC-6. 指定电源管理能力对话框

下面是该对话框中的选项：

  • Queue Reads And Writes While Power Is Off 如果驱动程序在设备掉电后还要接收并排队读写IRP则设置这项。
  • Device Supports System Wakeup 如果设备有系统唤醒能力则设置该项。否则清除该项选择。这个选项将在适当地方生成支持IRP_MN_WAIT_WAKE请求的框架代码。
  • Device Can Be Stopped While Busy With An IRP 如果驱动程序能够在IRP_MN_STOP_DEVICE到来时停止活动的读写IRP，则可以设置该项。否则不要选该项。
  • Idle Detection 如果你希望设备在用户规定的不活动时间后自动掉电，则设置该项。如果驱动程序使用GENERIC.SYS，会自动得到一组IOCTL支持。
  • Power On Only While Handles Open 该选项仅对USB设备有效。如果你在应用程序拥有设备的句柄时希望设备一直上电则选择该项。
  • Power Inrush During Power-On 如果设备在上电时需要大电流则选择该项。


USB端点

如果你在第一个对话框中选择USB功能驱动程序，则向导将呈现一个让你描述设备端点的对话框，见图AC-7。该对话框列出了设备扩展中的一些变量名。名字的顺序与设备上端点描述符的顺序相对应。

注意
该对话框不能满足多接口设备的描述，或接口有替换设置的设备。

图AC-7. 定义USB端点对话框

图AC-8显示了用于描述单一端点的对话框。Description Of Endpoint组合框与设备固件中的端点描述相关，应该是自解释的。Resources In The Driver组合框中的域如下：

  • Name Of Pipe Handle In Device Extension 给出DEVICE_EXTENSION中保存管道句柄的成员名，这个管道与这个端点相关。
  • Maximum Transfer Per URB 指出一个URB所能携带的最大字节数量。通常，这个值比端点最大传输值要大些。

图AC-8. 添加和编辑USB端点的对话框


WMI支持

如果你指出要处理IRP_MJ_SYSTEM_CONTROL请求，向导将呈现如图AC-9对话框，允许你指定定制WMI规划中的元素。

图AC-9. 指定WMI选项对话框

在这个对话框中，你应该总选择Use WMILIB选项，因为自动生成的代码不会对处理WMI请求有多大帮助。Block Identifiers列表框列出了定制规划中的类GUID名称。

图AC-10显示了如何在定制规划中描述一个类。顶级控制(无标签)是向导将要自动生成的GUID的符号名。你可以指定该类的下列属性：

  • Number Of Instances 指出驱动程序将创建该类多少个实例。
  • Expensive 指定一个expensive类。
  • Event Only 指出该类仅用于发出事件。
  • Traced 对应一个WMI选项。

你可以选择是使用基于PDO的实例命名方法还是使用带基本名的实例命名方法。Microsoft推荐使用前者。

图AC-10. 指定一个WMI类的对话框


INF文件参数

向导的最后一个对话框(如图AC-11)指定INF文件信息。

下面是该对话框中出现的域：

  • Manufacturer Name 硬件厂商名称。
  • Device Class 设备所属的标准设备类。
  • Hardware ID 设备的硬件标识符。你指定的标识符应该与总线驱动程序将创建的某个标识符相匹配。
  • Friendly Name For Device 如果你想向设备的硬件键中插入一个FriendlyName值，在这里指定其名称。
  • Auto-Launch Command 如果你希望AutoLaunch服务在设备启动时自动运行一个应用程序，在这里指定命令行。
  • Device Description 在这里插入设备的描述。

图AC-11. 指定INF文件选项对话框


注意事项

运行完向导之后，你将得到一个用于创建驱动程序的工程。由于Visual C++定制向导的限制，你需要手工加入某些工程设置。对这些设置的描述参见WDMBOOK.HTM。

生成的代码中会含有许多TODO注释，在那里你可以写入代码。可以用Find In Files命令定位这些TODO。


关于作者和译者

关于作者

Walter Oney

Walter Oney 是一个麻萨诸塞州波士顿的自由软件作家和顾问. 1968班一员，麻萨诸塞州技术学院电机工程学士和硕士。在不讲授程序设计时，他喜欢跑步，骑车，看芭蕾舞和演奏双簧管。

译者

mhs 马少华(mhsinet@263.net)

整理及制作

znsoft:驱动开发网站长，专业的驱动开发人员。


About This Electronic Book

版权

本书版权由微软出版社所有。mhs网友为方便大家使用而翻译。驱动开发网站整理提供。

关于电子版

这本电子版书是按原样创建的并可以作为印刷品购买。为了简便起见，本书已经和原样稍微有些不同。例如: 可能参考一些例子文件但它们已经在印刷品书中出现。它们在印刷品书中是可用的，但在电子版中没有提供。(译注:你可以参看附带的文件包)

放大图片

书中的图片许多是很大的，为了书的易读性，在文字中出现的是缩略图，你可以点击它们观看原始大小的图。


关于 微软出版社

[上一页]

你的信息源

微软出版社是微软公司的一个分部,是最主要的全面同步学习，培训，评估和支持资料的来源，帮助从开发者到IT专家到最终用户从微软得到最多的技术。用可利用的及时正确的信息从微软制作的超过250种的印刷品、多媒体材料、便于学习的网络解答中挑选出来。更多的信息，请访问 http://mspress.microsoft.com/.


附录D：术语表

routine 例程
function 函数
classdriver 类驱动程序
minidriver 迷你驱动程序
Side Effect 边效或副作用
context 上下文
spin lock 自旋锁
opaque field 不透明的域
function driver 功能驱动程序
filter driver 过滤器驱动程序
deferred procedure call 推迟过程调用(DPC)
hardware key 硬件键(HKLM\System\CurrentControlSet\Enum\SomeEnumerater(e.g. USB)\vid&pid\)
(instance key) (实例键)
classs key 类键
service key 服务键盘
(software key) (软件键)
bug check 一种错误处理机制
handle 句柄
handler 处理程序
side effect 边效 指单个表达式产生的侧效
副作用 单个函数内如果分配了资源，由于异常出现使函数前部分配的资源无法回收造成副作用
用结构化异常可以处理这个问题
primitives 原语
lookaside list 后援式链表
nonarbitrary threads 非任意线程
signalled
not-signalled
IRP cancellation IRP取消
I/O Stack I/O堆栈
I/O Stack Location I/O堆栈单元
partial resource list 局部资源列表
bus-master DMA 总线主控DMA(作为DMA传输的主动方)
Slave DMA 被动DMA(作为DMA传输的被动方)
finite state machine 有限状态机
token 令牌


Figure 1-1

Figure 1-1. The Windows 2000 architecture.


Figure 1-2

Figure 1-2. The Windows 98 architecture.


Figure 1-3

Figure 1-3. I/O requests in Windows 98.


Figure 1-4

Figure 1-4. Types of device drivers in Windows 2000.


Figure 1-5

Figure 1-5. Contents of a WDM driver executable "package."


Figure 1-6

Figure 1-6. A custom Device Manager property page for sample drivers.


Figure 2-1

Figure 2-1. Layering of device objects and drivers in the WDM.


Figure 2-2

Figure 2-2. Layering of recursively enumerated devices.


Figure 2-3

Figure 2-3. A hardware key in the registry.


Figure 2-4

Figure 2-4. The Device Manager properties display for a device.


Figure 2-5

Figure 2-5. A class key in the registry.


Figure 2-6

Figure 2-6. A device-specific subkey of the device's class key in the registry.


Figure 2-7

Figure 2-7. A service key in the registry.


Figure 2-8

Figure 2-8. The flow of a read request for a device on a secondary bus.


Figure 2-9

Figure 2-9. The flow of a read request for a USB device.


Figure 2-10

Figure 2-10. The flow of a device usage notification.


Figure 2-11

Figure 2-11. DEVVIEW information about USB42's PDO.


Figure 2-12

Figure 2-12. DEVVIEW information about USB42's FDO.


Figure 2-16

Figure 2-16. Using DEVVIEW to view the namespace.


Figure 2-17

Figure 2-17. The \?? directory with several symbolic links.


Figure 2-19

Figure 2-19. Using device interfaces to match up applications and devices.


Figure 3-1

Figure 3-1. Overview of kernel-mode support routines.


Figure 3-2

Figure 3-2. Format of an NTSTATUS code.


Figure 3-3

Figure 3-3. Logic of structured exception handling.


Figure 3-4

Figure 3-4. Flow of control in a try-finally block.


Figure 3-5

Figure 3-5. Flow of control in a try-except block.


Figure 3-6

Figure 3-6. The "blue screen of death."


Figure 3-11

Figure 3-11. Coalescing adjacent free blocks in a heap.


Figure 3-13

Figure 3-13. Lookaside lists.


Figure 4-2

Figure 4-2. Interrupt priority in action.


Figure 5-2

Figure 5-2. Map of the Tail union in an IRP.


Figure 5-3

Figure 5-3. Parallelism between driver and I/O stacks.


Figure 5-5

Figure 5-5. The "standard model" for IRP processing.


Figure 5-6

Figure 5-6. States of a KDEVICE_QUEUE queue.


Figure 5-7

Figure 5-7. Logic of IoCompleteRequest.


Figure 5-8

Figure 5-8. Passing an IRP down and ignoring its ending status.


Figure 5-9

Figure 5-9. Comparison of copying vs. skipping I/O stack locations.


Figure 5-10

Figure 5-10. Race conditions during IRP cancellation.


Figure 5-11

Figure 5-11. Using the cancel spin lock to guard cancellation logic.


Figure 5-12

Figure 5-12. Driver responsibility for IRP_MJ_CLEANUP.


Figure 6-1

Figure 6-1. State diagram for a device.


Figure 6-2

Figure 6-2. Arbitration of conflicting I/O resource requirements.


Figure 6-3

Figure 6-3. Timing of ForwardAndWait.


Figure 6-4

Figure 6-4. States of a DEVQUEUE object.


Figure 6-5

Figure 6-5. Flow of IRPs through a DEVQUEUE.


Figure 6-6

Figure 6-6. Operation of an IO_REMOVE_LOCK.


Figure 7-1

Figure 7-1. Structure of a partial resource list.


Figure 7-2

Figure 7-2. Accessing user-mode data buffers.


Figure 7-3

Figure 7-3. The memory descriptor list structure.


Figure 7-4

Figure 7-4. Accessing ports and registers.


Figure 7-5

Figure 7-5. Processing DPC requests.


Figure 7-6

Figure 7-6. Abstract computer model for DMA transfers.


Figure 7-7

Figure 7-7. Flow of ownership during DMA.


Figure 8-1

Figure 8-1. ACPI device power states.


Figure 8-2

Figure 8-2. System power states.


Figure 8-3

Figure 8-3. Handling IRP_MJ_POWER requests.


Figure 8-4

Figure 8-4. Handling system power requests.


Figure 8-5

Figure 8-5. IRP flow when increasing system power.


Figure 8-6

Figure 8-6. State transitions when increasing system power.


Figure 8-7

Figure 8-7. IRP flow when decreasing system power.


Figure 8-8

Figure 8-8. State transitions when decreasing system power.


Figure 8-9

Figure 8-9. IRP flow when increasing device power.


Figure 8-10

Figure 8-10. IRP flow when decreasing device power.


Figure 8-11

Figure 8-11. State transitions when setting a higher device power state.


Figure 8-12

Figure 8-12. State transitions for a query about a higher device power state.


Figure 8-13

Figure 8-13. State transitions when setting a lower device power state.


Figure 8-14

Figure 8-14. State transitions for a query about a lower device power state.


Figure 8-15

Figure 8-15. Examples of devices that wake the system.


Figure 9-3

Figure 9-3. Using lower filter drivers to achieve bus independence.


Figure 9-4

Figure 9-4. The Windows 2000 Event Viewer.


Figure 9-5

Figure 9-5. Overview of event logging and reporting.


Figure 9-6

Figure 9-6. The IO_ERROR_LOG_PACKET structure.


Figure 9-7

Figure 9-7. Creating a message file.


Figure 9-9

Figure 9-9. Fields in an I/O control code.


Figure 10-2

Figure 10-2. The world of WMI.


Figure 10-3

Figure 10-3. Control flow for IRP_MN_REGINFO.


Figure 10-4

Figure 10-4. Control flow for data queries.


Figure 10-5

Figure 10-5. Getting multiple data block instances.


Figure 11-1

Figure 11-1. Hierarchy of USB devices.


Figure 11-2

Figure 11-2. Device configurations, interfaces, and endpoints.


Figure 11-3

Figure 11-3. Layered model for USB communication.


Figure 11-4

Figure 11-4. Transaction and frame model for information flow.


Figure 11-7

Figure 11-7. Phases of a control transfer.


Figure 11-8

Figure 11-8. Contents of a SETUP token.


Figure 11-9

Figure 11-9. Phases of a bulk or interrupt transfer.


Figure 11-11

Figure 11-11. Bit assignments within an endpoint descriptor's address field.


Figure 11-12

Figure 11-12. Structure of a configuration descriptor.


Figure 11-13

Figure 11-13. Bits in device status.


Figure 11-14

Figure 11-14. Bits in endpoint status.


Figure 11-15

Figure 11-15. Allocation of bandwidth to isochronous and interrupt endpoints.


Figure 11-16

Figure 11-16. Descriptor structure for the USBISO device.


Figure 11-17

Figure 11-17. A property page for the USB host controller.


Figure 12-1

Figure 12-1. Tree structure of an INF file.


Figure 12-2

Figure 12-2. Selecting a device during installation.


Figure 12-3

Figure 12-3. Source and destination information for file copies.


Figure 12-4

Figure 12-4. Example of CHKINF output.


Figure C-1

Figure C-1. Page for entering basic driver information.


Figure C-2.

Figure C-2. Dialog box for specifying the IRP major function codes for which you want dispatch functions.


Figure C-3

Figure C-3. Page for specifying supported I/O control operations.


Figure C-5

Figure C-5. Page for specifying I/O resources.


Figure C-6

Figure C-6. Page for specifying power management capabilities.


Figure C-7

Figure C-7. Page for defining USB endpoints.


Figure C-8

Figure C-8. Dialog box for adding and editing a USB endpoint.


Figure C-9

Figure C-9. Page for specifying WMI options.


Figure C-11

Figure C-11. Page for specifying INF file options.


Programming the Microsoft Windows Driver Model

Walter Oney

目录
### | 第一章： | 导言
### | 第二章： | WDM驱动程序的基本结构
### | 第三章： | 基本编程技术
## | 第四章： | 同步
## | 第五章： | I/O请求包
## | 第六章： | 即插即用
## | 第七章： | 读写数据
## | 第八章： | 电源管理
## | 第九章： | 专门问题
## | 第十章： | Windows管理仪器
## | 第十一章： | USB总线
## | 第十二章： | 安装驱动程序
## | 附录A： | Windows 98不兼容处理
## | 附录B： | 使用GENERIC.SYS
## | 附录C： | 使用WDMWIZ.AWX
## | 附录D： | 术语表


序言

[返回目录]

关于本书

  • 前言

  • 感谢

  • 关于作者

  • 关于Microsoft Press

作者：Walter Oney

出版社：Microsoft Press

书号：ISBN 0-7356-0588-2

编辑：

采集编辑：Ben Ryan

项目编辑：Devon Musgrave

技术编辑：Robert Lyon

版权：

Copyright © 1999 by Walter Oney

前言

Windows驱动程序模型可以追溯到几年前的Windows for Workgroups 3.10操作系统。在那时，我们为Windows for Workgroups 3.10能够支持无数种不同的SCSI控制器而奋斗。我们把目标集中在直接修改Windows NT项目组已创建的各种miniport形式的SCSI控制器驱动程序上，但很快我意识到重建miniport驱动程序适合的映像载入器和执行环境要比重写并调试所有这些miniport(以某种VxD形式出现)更容易。

不幸的是，Windows for Workgroups 3.10发行时并没有支持miniport形式的SCSI驱动程序，主要是由于一些外围问题，如ASPI(高级SCSI编程接口)的兼容性。然而，在Windows和Windows NT之间共享同一个驱动程序映像的想法却保留下来，并在Windows 95中得到了实现，它可与Windows NT共享SCSI和NDIS miniport驱动程序映像。

一个共享的驱动程序模型的潜在优点是相当重要的。对于开发双平台驱动程序的开发者来说，利用共享驱动程序模型几乎可以使驱动程序的开发和测试花费减少一半。对于Microsoft，一个共享的驱动程序模型意味着从Windows 9x迁移到Windows 2000以及未来的平台会更容易。对于最终用户来说，将会有大量的更可靠的驱动程序同时支持两个平台。

那么下一步就是创建这个通用的驱动程序模型。应该使用什么形式呢？很明显，模型必须满足三个要求：它必须支持多处理器环境，它必须是处理器无关的，它必须支持PnP。正好，Windows NT 4.0的驱动程序模型满足前两个要求，而且Windows NT的下一个主要发行版本好象也支持PnP。结果，作为当前Windows NT驱动程序模型子集的WDM模型应运而生。

共享驱动程序模型的潜在优点在今天的许多设备类中已经体现出来。同样，选择WDM驱动程序模型也会受益于未来。例如，一个正确书写的WDM驱动程序仅需要重编译就可以在64位NT环境原型中运行。

WDM将继续发展以支持新平台和新设备类。未来版本的Windows 9x和Windows 2000将包含向上修订的WDM执行环境。幸好，WDM被设计成向后兼容，即用Windows 2000 DDK编写的WDM驱动程序将能运行于未来的WDM环境中。

在这本书中有许多关于WDM的东西，Walter深入地探讨了WDM体系中的每一方面。

Forrest Foltz
Architect, Windows Development
Microsoft Corporation

感谢

在此我要感谢一些人，在他们的帮助下这本书才得以面世。Devon Musgrave、Robert Lyon，和Microsoft Press的其它人，是他们使我的粗糙的WinWord手稿变成你现在手中整洁的书。我认识Ben Ryan，他是这个项目的信息采集编辑，是他花费了大量时间和路程收集其它作者和新书的信息，再次祝你好运。Microsoft的Sandy Spinrad协助查找技术信息，硬件测试，最近发布信息，和许多其它我所需要的研究资料。Windows 2000和Windows 98项目组的许多成员阅读了这些材料应值得提及，但他们要求匿名，至少，我知道他们是谁。感谢我的学生和网络团体，他们向我提出了一些另人深思的问题，给了我许多难得的观点。最后，我要感谢我的妻子Marty，在我进展艰难时她总是在我身旁。

关于作者

Walter Oney是一个自由软件顾问，住在麻省的波士顿。在MIT获得了电气工程学硕士学位。除了教授编程讲座之外，他还喜欢跑步、自行车、观看篮球比赛，和演奏双簧管。http://www.oneysoft.com

关于Microsoft Press

MSPress.gif (2645 字节)

你的信息资源

Microsoft Press是Microsoft公司的一个分部，领导广泛的学习、培训、评估，和支持资源，帮助每个人，从开发者，IT专家到最终用户获得更多的Microsoft技术信息。我们有超过250个主题的由Microsoft制作的学习方案，以印刷、多媒体、网络形式向您提供最及时最准确的信息。更多的信息请访问http://mspress.microsoft.com/。