4.1 内核方面的编程
4.1.1 生成控制设备
如果一个驱动需要和应用程序通信,那么首先要生成一个设备对象(Device Object)。在Windows驱动开发体系中,设备对象是非常重要的元素。设备对象和分发函数构成了整个内核体系的基本框架。设备对象可以在内核中暴露出来给应用层,应用层可以像操作文件一样操作它。这些细节将会在后面阐述。一般而言,用于和应用程序通信的设备往往用来“控制”这个内核驱动(这里所谓的控制就是配置、开启或者关闭某些功能,建议参考本章开头图4-1所示的例子),所以往往称之为“控制设备对象”(Control Device Object,CDO)。下面请读者先看生成控制设备的例子。
生成设备可以使用函数IoCreateDevice。这个函数的原型如下:
第一个参数是DriverObject,它可以直接从DriverEntry的参数中获得。第二个参数表示设备扩展的大小,在后面的“应用设备扩展”中会专门讲述。DeviceName是设备名,可以为空。作为控制设备,一般要提供一个设备名,以方便应用程序打开它。DeviceType是设备类型。Windows已经规定了一系列设备类型。DeviceCharacteristics是一组设备属性。至于如何填写,读者会在后面看到例子。Exclusive表示是否是一个独占设备。设置为独占设备的话,这个设备将在同一时刻只能被打开一个句柄。一般驱动都不会设置为独占设备。不过有时候这会有好处。作为控制设备,可能不希望别的进程能够打开这个设备,而只允许自己的进程打开(比如安全软件显然不希望被病毒所控制),那么设置为独占设备并由某个进程打开着永不关闭,应该可以提供一定程度的安全性。不过,这点笔者并未自己尝试过,读者有兴趣的话可以试试。最后一个参数DeviceObject是用来返回结果的。如果函数执行成功(返回值为STATUS_SUCCESS),那么*DeviceObject就是生成的设备对象的指针。
使用IoCreateDevice来生成控制设备对初学者来说会碰到一个潜在的问题,那就是这个函数生成的设备具有默认的安全属性,其结果就是只有具有管理员权限的进程才能打开它。这可能会让某些读者在用自己的应用程序与之通信时遇到麻烦:如果当前用户只是一个普通用户,就会发现无法打开设备。为此,笔者使用另一个函数来强迫生成一个任何用户都可以打开的设备。当然,作为商业软件而言,这显然是不安全的。这样做的目的仅仅是为了读者使用程序方便。
这个函数的大多数参数和上面的IoCreateDevice函数是一样的,只是增加了两个参数,其中一个是DefaultSDDLString,这个特殊格式的字符串能表示这个设备对象的安全设置;另一个参数是DeviceClassGuid,它是这个设备的GUID,是所谓的全球唯一标识符。
对于DefaultSDDLString,笔者并未深究,只是从WDK的帮助中拷贝了一个自称支持任何用户直接打开设备的字符串(读者可以在下面的例子中见到)。至于DeviceClassGuid,理论上需要用微软提供的函数CoCreateGuid来生成(注意是指调用这个函数一次,得到一个设备的GUID,以后就一直使用这个GUID,而不是每次执行驱动都生成一个)。所以,请读者在开发自己的驱动时,切记不要拷贝笔者例子中的GUID,以免同一个GUID被用到多个设备中造成潜在的冲突。
最终生成控制设备的代码如下(读者可以在coworker_sys.c下找到):
上面的字符串"D:P(A;;GA;;;WD)"就是允许任何用户访问该设备的万能安全设置字符串(当然所谓的万能就是完全不安全)。另外一个字符串cdo_name是设备的名字,将在下一节中详细介绍。
另外,值得注意的是上面的g_cdo,这是一个全局变量。一般而言,控制设备生成之后都保存在全局变量中。这是因为一个驱动程序只有一个控制设备,简单地保存在全局变量里容易在其他函数中(比如卸载或者分发函数中)识别。
4.1.2 控制设备的名字和符号链接
设备对象是可以没有名字的。但是控制设备需要有一个名字,这样它才会被暴露出来,供其他程序打开与之通信。设备的名字可以在调用IoCreateDevice或IoCreateDeviceSecure时指定。此外,应用层是无法直接通过设备的名字来打开对象的,为此必须要建立一个暴露给应用层的符号链接。符号链接就是记录一个字符串对应到另一个字符串的一种简单结构。生成符号链接的函数是:
读者可以看到这个函数非常简单,只有两个参数:一个是符号链接名,一个是设备名。一般而言,这个函数都会成功。不过,如果一个符号链接的名字已经在系统里存在了(注意,符号链接是在Windows中全局存在的),那么这个函数会返回失败。
所以,用符号链接是不太稳妥的方式,因为符号链接就是一个字符串,就算字符串再长,也不可能完全避免某个驱动生成的符号链接的名字与另一个厂商的符号链接的名字发生冲突。所以,最稳妥地是使用GUID的方式来访问设备。不过,这对本书来说并非一个关键问题。要详细了解设备对象的访问方式,请参考专业的硬件驱动开发书籍。
在这方面笔者的方式比较粗暴,就是先尝试删除该符号链接,然后再生成。这样万一有冲突,也被消弭于无形了。当然,这只是表面上的。如果另一个驱动真的需要用到同名的符号链接,那么应该会因为把请求发给错误的设备对象而崩溃。笔者写的代码如下:
注意这个设备名是cdo_name,而符号链接名是cdo_syb。这两个字符串有一定的讲究。Windows的设备都像文件一样位于一个对象树的管理之下。一般而言,设备都位于\Device\路径下(但这不是绝对的),而生成的符号链接则一般位于\??\这个路径下(这的确是两个问号,并非乱码)。这两个字符串其实是路径名。
笔者故意把这两个字符串弄得很古怪,后面加了很长的数字,就是为了避免和其他驱动程序冲突。当然,如果最终有多位读者直接拷贝了这些代码并用到了商业的软件中,而且最后产生了冲突,笔者也不会吃惊。
如何在应用程序中打开这个设备,将会在后面的内容中介绍。
4.1.3 控制设备的删除
既然在驱动中生成了控制设备及其符号链接,那么在驱动卸载时就应该删除它们;否则符号链接就会一直存在。应用程序还可能会尝试打开进行操作。代码如下,非常简单,依次删除符号链接和控制设备。
4.1.4 分发函数
分发函数是一组用来处理发送给设备对象(当然也包括控制设备)的请求的函数。这些函数由内核驱动的开发者编写,以便处理这些请求并返回给Windows。分发函数是设置在驱动对象(Driver Object)上的。也就是说,每个驱动都有一组自己的分发函数。Windows的IO管理器在收到请求时,会根据请求发送的目标,也就是一个设备对象,来调用这个设备对象所从属的驱动对象上对应的分发函数。
不同的分发函数处理不同的请求。当然,开发者也可以令所有的请求都由一个分发函数来处理,只是在分发函数中自己区别各种请求即可。请求有许多种,在本章中,笔者只用到三种请求。
●打开(Create[1]):在试图访问一个设备对象之前,必须先用打开请求“打开”它。只有得到成功的返回,才可以发送其他的请求。
●关闭(Close):在结束访问一个设备对象之后,发送关闭请求将它关闭。关闭之后,就必须再次打开才能访问。
●设备控制(Device Control):设备控制请求是一种既可以用来输入(从应用到内核),又可以用来输出(从内核到应用)的请求。因此很适合本节的需求。
一个标准的分发函数原型如下:
其中的dev就是请求要发送给的目标对象;irp则是代表请求内容的数据结构的指针。无论如何,分发函数必须首先设置给驱动对象,这个工作一般在DriverEntry中完成。笔者的DriverEntry中相关的代码如下:
注意,上面的片段中将所有的分发函数(实际上MajorFunction是一个函数指针数组,保存所有分发函数的指针)都设置成同一个函数,这是一种简单的处理方案。读者可以为每种请求设置不同的分发函数。如果有很多种请求要处理,而且每种请求的处理还都很复杂,那么放在同一个函数里做就会导致那个函数非常庞大且复杂。本节中只处理三种请求,而且都不复杂,所以采用这种简便的方式。
刚接触内核开发的读者可能对IRP这个概念上不太了解,IRP是I/O request packet的缩写,即IO请求包。在内核中,如需对某个设备进行功能请求,大部分情况下是通过发送IRP实现的,如对于文件系统的设备,当需要写某一个文件时,内核的IO管理器会生成一个用于描述“写操作”的IRP,然后发送给相应的文件设备。
IRP的结构体非常庞大,微软公开了该结构体中的某些成员,关于这些成员的具体含义与用法,在本书后面的章节中会介绍。
4.1.5 请求的处理
在分发函数中处理请求的第一步是获得请求的当前栈空间(Current Stack Location)。请求的栈空间结构是适应于Windows内核驱动中设备对象的栈结构的。但是这不是本书的重点,本书仅仅利用当前栈空间指针来获得主功能号。每种请求都有一个主功能号来说明这是一个什么请求。
●打开请求的主功能号是IRP_MJ_CREATE。
●关闭请求的主功能号是IRP_MJ_CLOSE。
●设备控制请求的主功能号是IRP_MJ_DEVICE_CONTROL。
请求的当前栈空间可以用IoGetCurrentIrpStackLocation取得,然后可以根据主功能号做不同的处理。代码如下:
读者会注意到前面需要判断请求是发给哪个设备的。这个很简单,只要判断参数dev是否是之前生成控制设备之后保存的全局变量g_cdo即可。如果请求不是发给这个设备的,那么直接返回错误即可。在分发函数中返回请求,需要以下4行代码:
设置irp→IoStatus.Information主要用于返回输出时。返回输出时请求的发起者应该已经提供了一个用于接收请求的缓冲区,这个Information用来记录这次返回到底使用了多少输出的空间,也就是说,返回的输出长度是多少。但是这个长度一定小于或者等于输出缓冲区的长度。
irp→IoStatus.Status用于记录这个请求的完成状态。这和后面分发函数的返回值是一样的。不过这只是一般情况,并不是所有的情况都一致。
IoCompleteRequest用于结束这个请求。
具体的如何处理设备控制请求的分发函数会在本章稍后的内容中给出。