本篇我们介绍开发之前的准备工作,包括开发环境准备、预备知识。
开发环境准备
对于开发WDM驱动程序来说,我们有以下三个常用组合:
1. 直接使用Windows DDK
2. 使用DriverStudio
3. 使用Windriver
下面我们分别比较三种方式的优缺点。
第一种:,开发难度大一些,而且有很多烦琐的工作要作,大部分都是通用的基础性的工作。但如果选用这种方式的话你将对整个体系结构会有很好的理解和把握。
第二种:难度低一些,工具软件已经帮你作了很多基础性的工作。也封装了一些细节,你只要专心去作你需要的操作,但由于封装的问题,可能会带来一些bug。有可能导致项目的失败。
第三种:几乎没有难度(从开发驱动的角度)。很容易,但只能开发硬件相关的驱动,事实上你写的只是定制和调用它提供的通用驱动而已。效率上有问题。工作频率不是很高。但开发花费的时间很少。是上面的几 乃至几十分之一。
建议:
用windriver作驱动程序的原型,用driverstudio作最终发行的驱动程序,如果驱动程序很复杂的话,建议直接使用ddk开发。
上面的几种情况都需要vc++作为辅助开发环境。(ddk也可以直接用命令行工具,但比较烦),前两种情况都需要ddk。开发时间上,第一种最长,第三种最短,第二种可以认为是前面两种方案的折衷。
如果更具体一点的话,我们可以把以上三种形式比作三种开发工具,那就是 ms c,vc++,Vb。
如果SDK没bug的话,用ms c开发的纯sdk程序的bug是最少的。Vc++由于对sdk进行了封装,必然会引出一些新的bug。Vb开发程序虽然快了一些,但运行效率比前两种方式差了很多。
这样说明这三种方式的话,大家一定会明白了。
我们为了简便起见,使用ddk+VC的方式。
首先,我们按正常方式安装好vc++ 6.0。不过据微软文档说ddk98只支持vc++ 5.0。我手里没有vc++5.0,在vc++6.0下试了一下,证明可以使用,不过设置很困难的。当然,如果你不觉得烦的话,也可以直接用build工具即可。在安装好vc++后再安装ddk开发包,这样不容易出错。
如果你使用DriverStudio开发包,请先安装好vc++6.0,然后再安装它,在安装softice时注意选择通用显卡驱动,这样一般情况下都能正常使用。
预备知识
在开发环境安装完成后,我们将要步入开发过程。在实际动手之前,我们先要学习一些预备知识。
在设备驱动程序中,要作很多工作,包括初始化,设备对象创建等等工作,其中一些是很重要的,必须实现,一些是可选的,如果你的驱动对这些功能的要求不是很高的话,可以不实现。
要实现的功能主要有以下几个:
初始化
创建和删除设备
I/O请求的超时处理
I/O请求的撤消
访问硬件资源
处理Windows的输入/输出请求
串行化对设备的访问
调用其它驱动程序
处理一个可热拔插的设备被加入或删除的情况
处理电源管理请求
使用Windows管理诊断功能
处理Windows的打开和关闭文件句柄的请求
从实际工作情况来看,只有初始化模块是必不可少的。但是只有初始化模块的驱动程序什么工作也干不了,只能说它仅仅是一个概念意义上的驱动程序而已,好比失去感觉的植物人(躯体存在,但已经没有了意志)。通常情况下,一个完整的驱动程序至少要能响应用户态程序发出的I/O访问请求。大多数情况下驱动程序要访问它们所支持的硬件资源,并且要支持简单的电源管理功能和Windows管理诊断功能或能向系统日志写入信息。
WDM驱动程序通常由PnP管理器载入内存,然后调用它之中的AddDevice例程来创建设备。当然,在此时还要需要一个inf安装文件而来指明该驱动程序需要的一些参数。
系统内核通常通过向驱动程序发送IRP包来运行驱动程序中的实现代码。我们以Windows向设备发出的ReadFile调用为例:此时Windows向驱动程序发出一个“读”请求的IRP包,读取缓冲区的大小和位置作为IRP包中的参数指定(IRP实际上是一个数据结构,包含几个域)。如果你作过Windows的程序,特别是用 VC作过开发的话,你应该知道,windows用户态应用程序是消息驱动的,应用程序中的代码是通过消息机制的触发而获得运行的机会的,需要的参数是通过消息的域(wParam、lParam)传给应用程序。实际上驱动程序的动作还是可以看作是一种消息驱动方式,只不过内核态的“消息”已经不再称作消息,而是被称作I/O请求包(IRP)。
驱动程序通常使用DriverEntry作为入口点,与我们在Windows应用程序中定义的WinMain相似。通常情况下,它是驱动程序的默认入口点。
注: 标准Build脚本将驱动程序入口点定为DriverEntry,你最好遵守这个假设,否则必须修改Build脚本。
在这个入口函数中,我们必须作必要的初始化设置,并设置必要的回调函数。我们可以这样理解:我们用c++(特别是用VC++)时,我们在类的构造函数中要作必要的初始化操作,并要在类中作消息处理方法的映射,这样才能让需要的消息得到适当的处理。我们也要在DriverEntry例程中设置必要的IRP处理函数。
一般情况下,DriverEntry例程要设置以下几个IRP处理函数:
• DriverUnload 指向驱动程序的清除例程。I/O管理器会在卸载驱动程序前调用该例程。通常,WDM驱动程序的DriverEntry例程一般不分配任何资源,所以DriverUnload例程也没有什么清除工作要做。
• DriverExtension->AddDevice 指向驱动程序的AddDevice函数。PnP管理器将为每个硬件实例调用一次AddDevice例程。这样将创建一个该设备对象。
• DriverStartIo 如果驱动程序使用标准的IRP排队方式,应该设置该成员,使其指向驱动程序的StartIo例程。如果你不理解什么是“标准”排队方式,不要着急,能后的教程中你就会完全明白,许多驱动程序都使用这种方法。
• MajorFunction 是一个指针数组,I/O管理器把每个数组元素都初始化成指向一个空函数,这个空函数仅返回失败。驱动程序可能仅需要处理几种类型的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; //设置各个IRP的处理函数 ,这三个IRP是每一个WDM驱动程序必须处理的。
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例程出错后产生任何副作用,必须在它返回错误代码前消除副作用(释放掉申请的系统资源)。
一般情况下,一个驱动程序可以被多个设备利用。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) 是设备类型。这个值可以被设备硬件键(注册表中包含该硬件信息的键值)或类键(注册表中包含该类驱动信息的键值)中的可替换值(overriding values)所替代,如果这两个键都含有该参数的替换值,那么硬件键中的可替换值具有更高的优先权。对于属于某个已存在类的设备,必须在这些地方指定正确的值,因为驱动程序与外围系统的交互需要依靠这个值。另外,设备对象的默认安全设置也依靠这个设备类型值。
第五个参数(FILE_DEVICE_SECURE_OPEN) 为设备对象提供Characteristics标志。这些标志主要关系到块存储设备(如软盘、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;
}
为设备命名
Windows 使用对象管理器集中管理系统中的大量的内部数据结构(每个对象在系统中都表现为一个数据结构),包括驱动程序对象和设备对象。为了便于区别,每个对象都有名称,对象管理器用一个层次化的命名空间来管理这些名称。图中是DevView(一个设备观察工具,在驱动开发网站<http://www.driverdevelop.com有下载>)显示的顶层对象名。此工具以文件夹形式显示的对象是目录对象,它可以包含子目录或常规对象,其它图标则代表正常对象。
通常设备对象都把自己的名字放到/Device目录中。在Windows 2000中,设备的名称有两个用途。第一个用途,通过命名后,其它内核模式部件可以通过调用IoGetDeviceObjectPointer函数找到该设备,找到设备对象后,就可以向该设备的驱动程序发送IRP(I/O请求包)。
另一个用途,允许用户态的应用程序打开命名设备的句柄,这样它们就可以向驱动程序发送IRP。应用程序可以使用标准的CreateFile API打开命名设备句柄,然后用ReadFile、WriteFile,和DeviceIoControl向驱动程序发出请求(关于这些API函数的详细说明和使用,我们将在后面的文章中详述)。应用程序打开设备句柄时使用//./路径前缀。在C/C++语言程序中使用时,需要转化为’////.//’,这是由语法规定的。在内部,I/O管理器在执行名称搜索前自动把//./转换成/??/。为了把/??目录中的名字与名字在其它目录(例如,在/Device目录)中的对象相连接,对象管理器实现了一种称为符号连接(symbolic link)的对象。
符号连接
符号连接有点象WIDOWS桌面上的快捷方式,符号连接在Windows NT/2K中的主要用途是把处于列表前面的DOS形式的名称连接到设备上。符号连接可以使对象管理器在分析一个名称时能跳到命名空间的某个地方。例如我们通常见到的C盘,其实它是就是一个设备(磁盘)的符号链接。如果你用过unix/linux操作系统的话,你会对符号链接有所理解,这里的符号链接相当于unix/linux系统中的软链接。
术语
类键
所有设备类的类键都出现在HKLM/System/CurrentControlSet/Control/Class键中。它们的键名是由Microsoft赋予的GUID值。
硬件键
硬件键包含单个设备的信息。
在上期中我们讲了符号连接,在应用层开发中我们可以调用以下函数来创建一个/??目录下的符号链接:
BOOL okay = DefineDosDevice(DDD_RAW_TARGET_PATH, "barf", "//Device//SECTEST_0");
调用成功后,将会在设备命名空间的/??目录下生成一个名为”barf“的符号链接,该链接指向”“//Device//SECTEST_0“这个对象。
在核心态的驱动程序中,我们需要调用以下的函数来创建相应的符号链接:
IoCreateSymbolicLink(linkname, targname);
Linkname是要创建的符号链接名,相当于上面函数中的”barf”,targname是该链接指向的设备对象。
如果你创建了一个指向不存在的设备对象的符号链接,系统并不会作任何检查,当你访问这个符号链接时只会收到一个错误报告。所以你必须要自己保证链接的目的对象真正存在。如果你想允许用户模式程序能超越这个连接而转到其它地方,应使用IoCreateUnprotectedSymbolicLink函数替代上面的IoCreateSymbolicLink函数。
给设备命名后我们就可以很方便地打开该设备进行访问了。但在方便的同时你需要注意一个很严重的问题:“安全性”。一旦为设备命名后,符何核心态的驱动程序都可以打开该设备的句柄,从而访问此设备。而且更糟的是,任何用户态的应用程序也可以通过建立该设备名的符号链接而访问到该设备。而这种情况可能是你不愿意看到的。
一旦你决定要为你的设备命名时,你应该将这个设备对象的名称放到对象名空间的“/Device”目录中,我们可以使用以下的核心态函数来创建设备,同时给设备命名:
UNICODE_STRING devname;
RtlInitUnicodeString(&devname, L"//Device//Simple0");
IoCreateDevice(DriverObject, sizeof(DEVICE_EXTENSION), &devname, ...);
这里的UNICODE_STRING devname就是用来存放设备名的地方。RtlInitUnicodeString是unicode串初始化函数,第一个参数是要初始化的变量地址,第二个为设备名常量。第二个参数前的大写L是将这个常量转换成此函数需要的宽字符串。一般我们使用如下的格式为设备命名:
设备名0
其中的0为设备的实例号(即产生实例的顺序)。
说到这里我要提醒一下大家,在驱动程序中一般不使用Ansi字符串,取而代之的是UniCode字符串,它以16位表示一个字符。这点和在WinCE下开发软件很相似。
在以前的老式驱动程序中(Win 3.2 or Win95)中大量使用设备命名(包括直接用名字和名字的符号链接)的方式来访问设备。这样做有两个很主要的问题。一是安全性问题。在上面我们已经讲了这样做有潜在的安全性问题,符何程序只要知道该设备的名字就可以访问它。第二个问题是你的应用程序要访问该设备必须事先知道它的名字,否则不能访问。这在测试用的设备或私有设备(只为你的应用程序服务而不向第三方提供接口)的情况下是可以的。但是如果你的硬件设备还要为第三方的程序服务或者有可能有第三方的公司为你的设备写驱动时就会有很多问题。可能你对该设备的命名会和其它的设备相重复。而且这样的命名很依赖程序员本身所使用的自然语言。
为了解决这个问题,微软在设计WDM框架时引入了一个新的命名方案。该方案与任何自然语言无关,且易于扩展,可广泛地用于软件用硬件,并且易于归档。该方案依靠一个设备接口的概念。它基本上是软件如何访问硬件的一个说明。一个设备接口由一个唯一的128位的GUID标识。一般情况下我们可以使用GUIDGEN工具生成这个标识(GUIDGEN工具可以在VC++企业版的可执行程序目录下找到)。由于采用了独特的生成算法,你永远也不用担心重复出现GUID的情况。这样一个GUID就唯一标识了一种设备接口。
生成的代码如下所示
// {CAF53C68-A94C-11D2-BB4A-00C04FA330A6}
DEFINE_GUID(<<name>>,//将此处改成你的名字
0xCAF53C68, 0xA94C, 0x11D2, 0xBB, 0x4A, 0x00, 0xC0, 0x4F, 0xA3, 0x30, 0xA6);
此为GUIDGEN程序工作时的截屏。可以选择四种格式输出。一般情况下我们选择第二种。并且为了便于管理,我们把要用于的GUID声明集中放到一个头文件中。
你可以把设备接口想象成锁和钥匙。这样应用程序就可以准确地访问需要访问的设备。
我们可以在功能驱动程序的AddDevice例程序中注册一个或多个设备接口,程序如下:
#include <initguid.h> #include "guids.h"
//其它声明
NTSTATUS AddDevice(...)
{
//其它代码
IoRegisterDeviceInterface(pdo, &GUID_SIMPLE, NULL, &pdx->ifname);
//其它代码
}
其中的GUID_SIPMLE就是我们要注册的接口的GUID的定义。对此段代码,我们作如下说明:
我们包含了GUIDS.H头文件,那里定义了DEFINE_GUID宏。DEFINE_GUID通常声明一个外部变量。在驱动程序的某些地方,我们不得不为将要引用的每个GUID保留初始化的存储空间。系统头文件INITGUID.H利用某些预编译指令使DEFINE_GUID宏在已经定义的情况下仍能保留该存储空间。
我使用单独的头文件来保存我要引用的GUID定义。这是一个好的想法,因为用户模式的代码也需要包含这些定义,但它们不需要那些仅与内核模式驱动程序有关的声明。
IoRegisterDeviceInterface的第一个参数必须是设备PDO的地址。第二个参数指出与接口关联的GUID,第三个参数指出额外的接口细分类名。只有Microsoft的代码才使用名称细分类方案。第四个参数是一个UNICODE_STRING串的地址,该串用于接收设备对象的符号连接名。
IoRegisterDeviceInterface的返回值是一个Unicode 字符串,这样可以在不知道驱动程序的具体编码的情况下(也就是说没看过你的驱动程序的具体代码),应用程序可以确定并打开该设备的句柄。这个返回值是很奇怪的,形如以下情形:/DosDevices/0000000000000007#{CAF53C68-A94C-11d2-BB4A-00C04FA330A6}.
即它的名字是0000000000000007#{CAF53C68-A94C-11d2-BB4A-00C04FA330A6}
注册过程实际上是先创建一个符号链接,然后把它记入注册表。当驱动程序在响应PnP请求IRP+MN_START_DEVICE时,驱动程序将调用IoSetDeviceInterfaceState函数”使能”该接口:
IoSetDeviceInterfaceState(&pdx->ifname, TRUE);
所谓使能也就是使此符号链接指向具体的PDO对象。
在响应这个调用过程中,I/O管理器将创建一个指向设备PDO的符号连接对象。以后,驱动程序会执行一个功能相反的调用禁止该接口(用FALSE做参数调用IoSetDeviceInterfaceState)。最后,I/O管理器删除符号连接对象,但它保留了注册表项,即这个名字将总与设备的这个实例关联;但符号连接对象与硬件一同到来或消失。
枚举设备接口
内核模式代码和用户模式代码都能定位含有它们感兴趣接口的设备。下面我将解释如何在用户模式中枚举所有含有特定接口的设备。枚举代码写起来十分冗长,最后我不得不写一个C++类来实现。你可以在DEVICELIST.CPP和DEVICELIST.H文件中找到这些代码。它们声明并实现了一个CDeviceList类,该类包含一个CDeviceListEntry对象数组。
一些声明代码去掉了,详细的文章请看驱动开发网上的志宁专栏(http://www.driverdevelop.com/column.php?sortid=3)。
所有实际的工作都发生在CDeviceList::Initialize函数中。其执行过程大致是这样:先枚举所有接口GUID与构造函数得到的GUID相同的设备,然后确定一个“友好”名,我们希望向最终用户显示这个名字。最后返回找到的设备号。下面是这个函数的代码:
int CDeviceList::Initialize()//枚举设备的接口的具体实现代码
{
HDEVINFO info = SetupDiGetClassDevs(&m_guid, NULL, NULL, DIGCF_PRESENT | DIGCF_INTERFACEDEVICE);
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)
{
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 did = {sizeof(SP_DEVINFO_DATA)};
SetupDiGetDeviceInterfaceDetail(info, &ifdata, detail, needed, NULL, &did));
TCHAR fname[256];
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);
free((PVOID) detail);
m_list.Add(e);
}
SetupDiDestroyDeviceInfoList(info);
return m_list.GetSize();
}
该语句打开一个枚举句柄,我们用它寻找包含了指定GUID接口的所有设备。
循环调用SetupDiEnumDeviceInterfaces以寻找每个与此相匹配的设备。
有两项信息是我们需要的,接口的“细节”信息和设备实例信息。这个“细节”信息就是设备的符号名。因为它的长度可变,所以我们两次调用了SetupDiGetDeviceInterfaceDetail。第一次调用确定了长度,第二次调用获得了名字。
通过询问注册表中的FriendlyName键或DeviceDesc键,我们获得了设备的“友好”名称。
我们用设备符号名同时作为连接名和友好名创建了类CDeviceListEntry的一个临时实例e。
友好名
你可能会疑惑,注册表怎么会有设备的FriendlyName名。安装设备驱动程序的INF文件中有一个指定设备参数的段,这些参数将被添加到注册表中。通常我们可以在这里为设备提供一个FriendlyName名。
注:在windows 2k下和Windows 98下的inf文件有少许的不同,即使是同一个设备的inf文件,也要作过适当修改后才能同时用于两个平台下。
作好以上工作后,我们还要初始化一些其它的数据结构才能完成设备加载工作。
在AddDevice中还需要加入其它一些步骤来初始化设备对象,下面我将按顺序描述这些步骤。
设备扩展的内容和管理全部由用户决定。该结构中的数据成员应直接反映硬件的专有细节以及对设备的编程方式。大多数驱动程序都会在这里放入一些数据项,下面代码声明了一个设备扩展结构:
typedef struct _DEVICE_EXTENSION {
PDEVICE_OBJECT DeviceObject;
PDEVICE_OBJECT LowerDeviceObject;
PDEVICE_OBJECT Pdo;
UNICODE_STRING ifname;
IO_REMOVE_LOCK RemoveLock;
DEVSTATE devstate;
DEVSTATE prevstate;
DEVICE_POWER_STATE devpower;
SYSTEM_POWER_STATE syspower;
DEVICE_CAPABILITIES devcaps;
...//其它一些结构
} DEVICE_EXTENSION, *PDEVICE_EXTENSION;
我模仿DDK中官方的结构声明模式声明了这个结构。
我们可以用设备对象中的DeviceExtension指针定位自己的设备扩展。同样,我们有时也需要在给定设备扩展时能定位设备对象。因为某些函数的逻辑参数就是设备扩展本身(这里有设备每个实例的全部信息)。所以,我认为这里应该有一个DeviceObject指针。
我在一些地方曾提到过,在调用IoAttachDeviceToDeviceStack函数时,应该把紧接着你下面的设备对象的地址保存起来。LowerDeviceObject成员用于保存这个地址。
有一些服务例程需要PDO的地址,而不是堆栈中某个高层设备对象的地址。由于定位PDO非常困难,所以最好的办法是在AddDevice执行时在设备扩展中保存一个PDO地址。
无论你用什么方法(符号连接或设备接口)命名你的设备,都希望能容易地获得这个名字。所以,这里我用一个Unicode串成员ifname来保存设备接口名。如果你使用一个符号连接名而不是设备接口,应该使用一个有相关含义的成员名,例如“linkname”。
当你调用IoDeleteDevice删除这个设备对象时,需要使用一个自旋锁来解决同步安全问题,我将在第六章中讨论同步问题。因此,需要在设备扩展中分配一个IO_REMOVE_LOCK对象。AddDevice有责任初始化这个对象。
你可能需要一个成员来记录设备当前的PnP状态和电源状态。DEVSTATE和POWERSTATE是枚举类型变量,我假设事先已经在头文件中声明了这些变量类型。我将在后面章节中讨论这些状态变量的用途。
电源管理的另一个部分涉及电源能力设置的恢复,设备扩展中的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)不能调用用于报告IRP完成的函数(IoCompleteRequest)。利用DPC(延迟过程调用)可以避开这个限制。你的设备对象中应包含一个辅助DPC对象,它可以调度你的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。
在AddDevice中最后一件需要做的事是清除设备对象中的DO_DEVICE_INITIALIZING标志:
fdo->Flags &= ~DO_DEVICE_INITIALIZING;
当这个标志设置时,I/O管理器将拒绝任何打开该设备句柄的请求或向该设备对象上附着其它设备对象的请求。在驱动程序完成初始化后,必须清除这个标志。在以前版本的Windows NT中,大部分驱动程序在DriverEntry中创建所有需要的设备对象。当DriverEntry返回时,I/O管理器自动遍历设备对象列表并清除该标志。但在WDM驱动程序中,设备对象在DriverEntry返回后才创建,所以I/O管理器不会自动清除这个标志,驱动程序必须自己清除它。
这是本节的内容。下节中我们将介绍在驱动程序中经常要使用到的内核函数。
前面讲解了驱动程序原理和驱动程序的基本结构,这一节中我们将要一起学习核心层编程环境。
大家都知道,普通的应用程序的运行需要一套API支持,在核心层也一样,并且应用层的API在核心层几乎不能使用(如果严格地说的话也有少许API可以同时用于核心层和应用层的)。核心层API按其用途分为以下几类:
I/O管理器类,此类函数以Io打头,这些函数用来和I/O管理器打交道的。
进程结构模块相关函数,此类函数以Ps打头。创建并管理内核模式的线程。
Executive执行支持函数,这类函数以Ex打头。提供堆管理和同步服务。
对象管理类函数,提供各种数据对象管理功能,此类函数以Ob打头。
安全引用监视类函数,使文件系统驱动程序执行安全检测。通常I/o请求到达驱动程序端时系统I/O管理器已经作了安全检测。此类函数以Se打头。
内存管理类函数,控制页表,页表提供虚拟内存到物理内存之间映射关系的定义,此类函数以Mm作为前缀。
运行时间库,这些函数以Rtl打头,提供一些常用函数,比如列表和串管理等,在内核模式程序中不能再调用ANSI标准函数了,以这些函数来代替它们的功能。
内核函数,这些函数以Ke打头。
内核流IRP管理函数,此类函数以Ks打头。
Win32例程调用函数。此类函数以Zw打头。通常情况下,内核模式程序不能调用提供给Win32应用程序的API函数,DDK为了使驱动程序也能调用Win32用户态api,提供了这样一组函数.。不过DDK中只提供了少数这样的函数给驱动程序调用。此类函数主要提供文件系统和注册表数据库的访问功能。
电源管理类函数,此类函数以Po打头。
硬件抽象层函数,此类函数以Hal打头。一般情况下,Windows NT/2K操作系统在硬件和核心层之间提供一组硬件抽象层函数来实现系统功能,这样可以实现驱动程序硬件无关的跨平台(源码级),核心层驱动只能调用这些函数来实现它们需要的功能。
在写驱动程序时,我们经常需要作一些内存访问和字符串运算等操作。在写应用程序时我们自然会想到调用ANSI标准库函数来实现这些功能,但在核心层,我们通常应调用Rtl打头的类似函数来实现这些功能。严格意义上来说,标准运行库中的一些函数也可以在核心层使用,但由于你需要搞清很多不同的细节,所以最好调用DDK中提供的Rtl族函数来实现。这将提供最好的调用安全性(不会因细节不同原因而导致宕机)。
调用DDK中的函数需要注意一些问题,概括如下:
1. 花括号问题
我们调用的DDk函数中有一部分其实是宏定义,而且DDK中的一些宏定义其实很差劲,比如以下定义:
#define ReMoveHeadList(ListHead)
(Listhead)->Flink;
{RemoveEntryList((ListHead)->Flink)}
如果以以下方式调用RemoveHeadList,则将导致编译错误:
if(SomethingInList)
Entry=RemoveHeadList(list);
使这个调用安全的唯一方法是使用花括号。
If(SomethingInList)
{
Entry=RemoveHeadList(list);
}
因此,为了调用安全起见,建议最好在所有的if、for和while等语句中使用花括号。
2. 函数调用的边效
同样由于DDK中的许多函数其实是宏定义,我们都知道应该避免在宏的参数中使用带有边效的表达式,原因是宏的参数可能多次被调用。如:
int a=2,b=42,c;
c=min(a++,b);
而这个min()函数其实是一个定义如下的宏:
#define min(x,y) ((x)<(y)?(x):(y))
如果我们用上面的表达式调用这个宏的话,将会产生边效。将上面的调用展开如下:
a++<b?a++:b
最后我们将发现a的值变成了4,而min()返回的值是3。这是因为a++这个表达式被调用了两次导致的。Min()的返回值是在第一次调用后得到的。
我们在编写驱动程序时要明确一点,那就是内存的使用必须非常严谨,该释放的一定要释放,该确定大小的一定要计算确定,而不能图省事分配一个大数组。如果不严谨的话会造成很多宕机的后果而找不到原因。
在驱动程序中,为了描述内核结构,我们大量使用使用Object这个词,这里的对象不是指C++对象而是,而是包含一些可用内核函数来访问的域的数据结构。在实际存储上和C++的类有相似之处,但意义完全不同。
还有一点我们必须明确,那就是处理器分中断级。
我们在微机原理中都学过中断,中断分硬件中断和软件中断。一个中断可以打断正在运行的普通程序的执行,并强制处理器执行一段代码。并且中断有优先级之分,低优先级的中断可以被高优先级的中断打断,软件中断也可以被硬件中断打断。这样可以保证重要的任务优先得到执行。
抽象的处理器中断级
中断性质 IRQL(中断级) 描述
无中断 PASSIVE_LEVEL 常规线程执行
软件中断 APC_LEVEL 异步过程调用执行
DISPATCH_LEVEL 线程调度,延时过程调用执行
硬件中断 DIRQL 设备中断请求级处理程序执行
PROFILE_LEVEL 配置文件定时器
CLOCK2_LEVEL 时钟
SYNCH_LEVEL 同步级
IPI_LEVEL 处理器之间中断级
POWER_LEVEL 电源故障级
驱动程序通常只使用这之中的三个中断级。驱动程序分发例程一般在PASSIVE_LEVEL级调用,此时为没有中断发生的时候。驱动程序中的许多回调例程在DISPATCH_LEVEL级运行。当发生硬件中断时,中断服务例程在DIRQL级(设备中断请求级)运行。
DIRQL是在处理器上可用的许多硬件中断级的总称。其中包括磁盘中断,串口中断,并口中断等等,并且它们之间也有优先级高低的问题。比如当同时发生磁盘和串口中断时,磁盘中断比串口中断优先得到执行。通常情况下,一个驱动程序只有一个DIRQL级,但是如果需要的话,系统也允许它有超过一个的DIRQL级。驱动程序的中断优先级用它的DIRQL的最高级表示(如果一个驱动中只有一个DIRQL的话,驱动程序的优先级则是这个DIRQL的优先级,否则 则是里面最高DIRQL的优先级)。驱动程序的优先级很重要,因为它决定了这个驱动程序可以执行的操作类型。例如,驱动程序的硬件中断程序不能访问换出到交换文件中的内存。
一个驱动程序有可能在任何时候被比它的优先级高的中断程序打断。这同时也意味着驱动程序的中断服务例程可以中断它自己的分发例程。如果一个常规的驱动程序例程要与它自己的硬件打交道,它可以使用一个内核函数把自己的中断级临时提高到DIRQL,这时,它自己的中断级别低的中断程序也将被停止执行。
与中断优先级对应的还有调度优先级。所有线程通常在最低优先级PASSIVE_LEVEL运行,而调度程序使用优先级值确定下一步要运行哪个线程。在驱动中,它可以为调用它的线程指定一个临时优先级,这样可以提高系统的响应速度。
处理硬件中断的服务例程会停止正常的程序的执行。因此,必须使中断处理程序尽快地执行完毕。不是必需在中断处理程序中运行的代码应该尽量推迟到以后执行。为此,操作系统提供延迟过程调用(DPC)例程,这些例程在DISPATCH_LEVEL级运行,用于处理不是必须在中断处理例程中操作的中断相关代码。提示当前I/O完成的请求操作必须在DISPATCH_LEVEL级运行,因此,它应该在DPC例程中完成。
人总会犯错误的,软件也不例外。我们必须对软件中的错误作出恰当的处理。如果错误很少或不影响程序的执行,我们对它加以适当的纠正。如果程序错误很严重,那我们必须清除它分配的资源,并停止程序的执行。如果发生的是可以忽略的错误,那可以继续执行下面的代码。我们有三种方式来处理错误:状态代码、结构化异常处理、和Bug Check。通常情况下内核模式函数会向调用者返回一个状态代码来表示执行的结果是成功还是失败。
通常情况下,内核模式的函数都会返回一个32位的NTSTATUS状态码。此状态码标示出执行是否成功。NTSTATUS是一个由多个子域组成的32位整数,如图3-2。高两位(Severity)指出状态的严重性――成功、信息、警告、错误。客户位(Customer)是一个标志,完成的IRP将携带一个表明完成状态的状态代码,如果这个状态代码中的Customer标志被设置,那么这个状态代码将被不修改地传回应用程序(应用程序通过调用GetLastError函数获得)。通常,状态代码在返给应用程序前要翻译成Win32错误代码(Win32错误代码可以在KBase Q113996文章中查到)。facility代码指出该状态是由哪个系统部件导致的,一般用于减少开发组之间的代码关联。剩下的16位代码指出实际的状态。我们应该总是检测例程的返回状态。为了不让大量的错误处理代码干扰例子代码所表达的实际意图,我经常省略代码片段中错误检测部分,但你在实际练习中不要效仿我。
如果状态码高位为0,那么不管其它位是否设置,该状态代码仍旧代表成功。所以,绝对不要用状态代码与0比较来判断操作是否成功,应该使用NT_SUCCESS宏:
NTSTATUS status = SomeFunction(...);
if(!NT_SUCCESS(status))
{
<handle error>
}
不仅要检测调用例程的返回状态,还要向调用你的例程返回状态代码。在上一章中,我讲述了两个驱动程序例程,DriverEntry和AddDevice,它们都定义了NTSTATUS返回代码。所以,如果这些例程成功,则应返回STATUS_SUCCESS。如果在某个地方出错,应返回一个适当的错误状态代码,有时函数返回的状态代码就是出错函数返给你的状态代码。
并不是所有被调用例程导致的错误都要处理,有些错误是可以忽略的。例如,在电源管理中,带有IRP_MN_POWER_SEQUENCE子类型的电源管理请求,使用它可以避免上电过程中不必要的状态恢复过程。这个请求不仅对你是可选的,而且总线驱动程序在实现该请求上也是可选的。所以如果该请求执行失败,你不用做任何处理,继续其它工作。同样,你也可以忽略IoAllocateErrorLogEntry产生的错误,因为不能向错误登记表添加一条记录根本不是什么严重错误。
Windows 提供了一种处理异常情况的方法,它可以帮助我们避免潜在的系统崩溃。结构化异常处理与编译器的代码生成器紧密集成,它允许你在自己的代码段周围加上保护语句,如果被保护代码段中的任何语句出现异常,系统将自动调用异常处理程序。结构化异常处理还便于你提供清除语句,不管控制以何种方式离开被保护代码段,清除代码都会被执行。
许多读者并不熟悉结构化异常方法(在vc++中也支持结构化异常处理,在DELPHI中你将看到它生成的代码中便应用了结构化异常处理技术),所以我在这里先解释一些基本概念。使用这个方法可以写出更好更稳固的代码。在许多情况下,WDM驱动程序例程接收到的参数都是经过其它代码严格检验的,一般不会成为导致异常的原因。但我们仍要遵循这样基本原则:对用户模式虚拟内存直接引用的代码段应该用结构化异常帧保护起来。这样的引用通常发生在调用MmProbeAndLockPages、ProbeForRead,和ProbeForWrite函数时(以后会讲这些函数)。
结构化异常机制可以使内核模式代码在访问一个非法的用户模式地址后避免系统崩溃。但它不能捕捉其它处理器异常,例如被零除或试图访问非法的内核模式地址。从这一点上看,这种机制在内核模式中不象在用户模式中那样具有通用性。
当使用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>。由于某种原因控制离开被保护体,计算机执行终止处理程序。
这里有一个简单的例子:
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,告诉操作系统返回到异常发生的地方。
上一篇中我们学习了核心层编程环境和异常处理的一部分,这篇我们继续学习异常处理的余下部分和内存管理。
在上一篇中,我们知道在Try-Except块中的过滤表达式值可以有以下三种:
EXCEPTION_EXECUTE_HANDLE (1)
EXCEPTION_CONTINUE_SEARCH (0)
EXCEPTION_CONTINUE_EXECUTION (-1)
EXCEPTION_EXECUTE_HANDLE 指示错误处理程序执行Except块中的用户定义异常处理语句。
EXCEPTION_CONTINUE_SEARCH 操作系统将继续扫描堆栈以定位相应的错误处理程序。此时用户定义的错误处理程序将得不到执行,如果找不到合适的其它错误处理程序来执行,此时系统将崩溃。
EXCEPTION_CONTINUE_EXECUTION 指示操作系统返回到出现异常处重试。在内核模式中不能返回此种类型的异常,因为你没办法改变导致异常的情况。
更多的有关异常的信息可以调用以下两个函数来获得:(它们是微软编译器内部所实现的,这也从一个侧面说明了其它厂商的编译器不能生成驱动的原因)
注:微软的驱动程序使用一些专有的格式,比如,vxd就是使用LE格式,但普通Win32程序则使用PE格式文件,由于微软不向其它厂商开放使用许可,所以象borland等厂商的编译器不能生成驱动程序。
GetExceptionCode() 返回当前异常的数值代码。它是一个NTSTATUS类型。此函数只能用于__except表达式和它后面的处理代码中。
GetExceptionInformation() 返回EXCEPTION_POINTERS结构的内存地址,此结构包含异常相关的详细信息,包括发生时的寄存器详细内容等。
程序中的bug可以导致异常并使系统调用异常处理机制。应用程序开发者应该熟悉Win32 API中的RaiseException函数,它可以生成任意异常。在WDM驱动程序中,你可以调用表中列出的例程。由于下面规则,我不能给你举一个使用这些函数的例子:
仅当你知道存在一个异常处理代码并知道你真正在做什么时,才可以在非任意线程上下文下生成一个异常。
用于生成异常的服务函数
服务函数 描述
ExRaiseStatus 用指定状态代码触发异常
ExRaiseAccessViolation 触发STATUS_ACCESS_VIOLATION异常
ExRaiseDatatypeMisalignment 触发STATUS_DATATYPE_MISALIGNMENT异常
特别地,不要通过触发异常来告诉你的调用者你一般执行状态中的信息,你完全可以返回状态代码。应该尽量避免使用异常,因为堆栈回卷机制非常消耗资源。
系统错误 BugCheck(导致系统不能继续运行的致命错误)
Bug check是系统检测到的错误,一旦发现这种错误,系统立即以一种可控制的方式关闭。许多内核模式部件运行时都进行一致性检测,如果某个系统部件发现一个不可恢复的错误,将生成一个bug check。如果可能,所有内核模式部件都先登记遇到的错误,然后继续运行,而不是调用KeBugCheckEx,除非这种错误将使系统本身变得不可靠。程序可以在任何IRQL上调用KeBugCheckEx。如果程序发现一个不可恢复的错误,并且该程序继续运行将会破坏系统,那么该程序就调用KeBugCheckEx函数,这个函数将使系统以一种可控制的方式关闭。
当内核模式中出现不可恢复错误时,会出现一个称为死亡蓝屏(BSOD blue screen of death)的画面,驱动程序开发者应该十分熟悉它。在内部,这种错误被称为bug check,它的主要特征是,系统尽可能以正常的方式关闭并弹出一个死亡蓝屏。一旦死亡蓝屏出现,则表明系统已经死掉必须重启动。
可以按下面方式调用KeBugCheckEx:
KeBugCheckEx(bugcode, info1, info2, info3, info4);
bugcode是一个数值,指出出错的原因,info1、info2等是整型参数,将出现在死亡蓝屏中以帮助程序员了解错误细节。该函数从不返回(!)。
我不将解释死亡蓝屏中的信息。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,那么我们什么时候使用它呢?回答是决不,或者仅在驱动程序的内部调试中使用。我们不可能写出这样的驱动程序,它发现了一个错误并且只有通过关闭系统才能解决。更好的做法是记录这个错误(使用错误登记工具,如系统事件日志或WMI)并返回一个状态码。
这是在不得已的情况下为了尽可能减少损失而采取的措施。如果不这样处理,系统将以一种不可预料的方式结束运行,可能会造成不可挽回的损失。我曾经在编写驱动时发生过源代码无缘故地丢失的情况,这也是不健壮的驱动程序产生的副作用。
下面我们将要一起学习核心层的内存管理。在windows下由于采用了保护模式,内存和实模式的dos下完全不同。记得在dos下我们可以访问几乎所有的内存区域,如访问硬盘的Bios记取硬的序列号,主板的B ios区以记取主板序列号,破除BIOS口令等。但在Windows下,系统对内存进行分区访问,分为系统区和用户区。并且由于采用了硬盘交换技术,我们可以申请超过实际内存大小很多的内存。但此时我们得到的内址地址再也不是内存的物理地址了,取而代之是的虚拟内存地址。
虽然Windows这样的帖心保护给我们写应用程序带来了很大方便,不用再费心考虑内存是否够用的问题,但也给我们写驱动程序带来了很多麻烦。我们在驱动程序中使用内存时要加倍小心,否则一丁点小的错误都会要了系统的命(死亡蓝屏或立即死机)。
首先我们来看几个概念性的问题:
虚拟内存:
把一定的空间划分成固定大小的块,这些块在技术上叫“页”,X86系列的处理器的页的大小为4k,alpha系统的为8K,这些页可以放在内存中,也可以放到磁盘上。对于程序来说,它们所使用的内存就是由一系列处理过的内存地址表示的区域(虚拟内存地址),可能在物理内存中也可能在磁盘上的文件中。它并不清楚所使用的内存的真实形态。这一切由操作系统透明处理。当然,应用程序也可以显式地申请操作系统告诉它这些内存的物理地址。
如上图所示,假设一个程序使用了24K的内存,我们把它分成6页。如果系统只有12K的物理内存可用时,它会把频繁使用或将要使用的区域放到物理内存中,以加快运行速度和满足内存分配需要。但如果所有的应用程序经常所需的内存总和大大超过物理内存的数量,那操作系统就会不断地在物理内存和磁盘文件之间换进换出这些分页块,虽然还能运行程序,但这将导致系统变得很慢。这是用时间换空间的方法了。
分页一(物理内存中)
分页二(磁盘上)
分页三(物理内存中)
分页四(磁盘上)
分页五(物理内存中)
分页六(磁盘上)
可分页内存和不可分页内存:
上面讲了内存可以分页,但并不是每个分页内存区都可以换到磁盘文件中。我们把可以换出来的分页叫作“可分页内存”,只能永久驻留在物理内存中而不能被换出来页的叫作“非分页内存”。
如果在DISPATCH_LEVEL或者更高的中断级中访问分页内存,就会引起缺页故障,内核会崩溃。如果在PASSIVE_LEVEL中断级访问没有驻留在物理内存中的分页内存时,内核会阻塞我们的线程,直到内存管理器把此页重新装回物理内存中。
不要随意使用非分页内存,因为系统的资源是有限的,如果永久驻留在物理内存中的页太多,将导致可分页内存更加频繁地进行交换,降低系统性能。
在开发驱动程序时我们必须遵守这样几个原则:
决不(或几乎从不)直接引用用户模式的内存地址。
因为我们不能确切知道用户模式内存地址所指向的真实物理地址。
执行在高于或等于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。
我们可以调用以下几个函数来分配可分而和非可分页内存块:
ExAllocatePool(…)
调用方式如下:
PVOID p = ExAllocatePool(type, nbytes);
type参数是表2中列出的POOL_TYPE枚举常量,nbytes是要分配的字节数。返回值是一个内核模式虚拟地址指针,指向已分配的内存块。如果内存不足,则返回一个NULL指针。如果指定的内存池类型为“must succeed”类型,即NonPagedPoolMustSucceed或NonPagedPoolCacheAlignedMustS,那么内存不足将导致一个代码为MUST_SUCCEED_POOL_EMPTY的bug check。
表2
内存池类型 描述
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行的最少可能数,使用XxxCacheAligned类型代码可以达到这个要求。如果请求的内存多于一页,那么内存块将从页的边界开始。
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 同上,但加入一个标签。
释放内存块
调用ExFreePool可以释放由ExAllocatePool分配的内存块:
ExFreePool((PVOID) p);
你确实需要记录分配的内存以便在该内存不再需要时释放它,因为没有人为你做这些事。例如,在AddDevice函数中,有一个IoRegisterDeviceInterface调用,该函数存在副作用:它分配了一块内存以保存接口名。你有责任在以后释放该内存。
不用说,访问从内核模式内存池中分配来的内存必须格外小心。因为驱动程序代码可能执行在处理器的最高特权模式下,在这里,系统对内存数据没有任何保护。
运行时控制分页能力
表3列出了一些服务函数,你可以在运行时使用它们调整驱动程序的分页布局。这些函数的功能是释放被不再需要的代码和数据所占用的物理内存。在第八章中,我将讲述如何向电源管理器寄存你的设备,这样,在一段不活动时期后设备可以自动掉电。掉电期间是释放锁定内存页的最佳时期。
表3. 动态锁定和解锁驱动程序占用内存页的例程
服务函数 描述
MmLockPagableCodeSection 锁定含有给定地址的代码段
MmLockPagableDataSection 锁定含有给定地址的数据段
MmLockPagableSectionByHandle 用MmLockPagableCodeSection返回的句柄锁定代码段(仅用于Windows 2000)
MmPageEntireDriver 解锁所有属于某驱动程序的页
MmResetDriverPaging 恢复整个驱动程序的编译时分页属性
MmUnlockPagableImageSection 为一个锁定代码段或数据段解锁
限于篇幅,这类函数详细的用法可以参见DDK文档说明。这部分我们主要学习了内存管理部分。如我们前面所说,内存管理在内核编程中是非常重要的。通常我们在应用程序编写中的不良习惯都不应该带到驱动程序开发中。你必须清楚一点:在用户态最多只是弹出一个提示框的故障,在内核模式中等待你的将是“死机”。