作为写在最前面的话,我不会在本教程中专门去介绍KMDF框架、对象、方法等基础知识,因为相关资料已经不少,而且文档中都有,如果读者不了解,应该先去看看相关内容。
驱动程序作为内核模式的一部分,都是为我们的应用程序服务的。而我们在教程1中编写的HelloWord不仅没有为我们服务,而且我们还难以控制,除了在设备管理器中操作,连打印出来的信息都要特殊的工具才能看得到。所以,一方面我们要让驱动程序为我们做一些有用的东西,另一方面,我们要让驱动程序可控,让应用程序能够调用驱动程序提供的功能,能向驱动程序传入信息,也能把驱动信息输出的内容显示出来。这就是本节要完成的内容。
需要特别提前说明的是,本节依然是又臭又长的,毕竟我们才刚刚起步,会接触比较多新的东西,我会尽量把每个部分讲清楚。开始也想写得简单一点,但我们不能一直写HelloWorld,况且,本节的程序其实很简单,只是对于初学者会遇到比较多新的东西罢了。希望初学者坚持下来。
既然驱动程序属于内核,那么应用程序就需要通过操作系统提供的API来与其通信。驱动程序位于硬件和应用程序之间,接收应用程序的请求,操作硬件完成请求,返回结果给应用程序,当然,这都是由操作系统联系起来的。驱动程序的主体是设备,所以应用程序通过系统提供的设备相关的API来与驱动程序通信。
在Windows中,应用程序实现与KMDF通信的过程是:应用程序先用CreateFile打开设备,然后可以用DeviceIoControl和KMDF通信,包括从KMDF读数据和写数据给KMDF,也可以用ReadFile从KMDF读数据或用WriteFile写数据给KMDF,当应用程序退出时,用CloseHandle关闭设备。相对应的KMDF回调例程,如表2.1所示。
表2.1 Win32函数对应的KMDF回调例程
Win32函数 | KMDF回调例程 |
---|---|
CreateFile | EvtDeviceFileCreate |
ReadFile | EvtIoRead |
WriteFile | EvtIoWrite |
DeviceIoControl | EvtIoDeviceControl |
CloseHandle | EvtFileCleanup,EvtFileClose |
注:表格前一段文字及表格来源于武安河编著的《Windows设备驱动程序WDF开发》,该书难度不大,适合初学者一看。
一般的读者应该都熟悉这几个API,除了其中一个DeviceIoControl,可能接触少一些,简单的看看该API函数。
这个函数强大的功能就在于可以支持多种IO控制命令,并且既可以向驱动程序发送数据也可以从驱动读取数据,可以灵活地让应用程序和驱动程序交互通信。前提是这些IO控制命令被驱动程序支持,所以该函数的能力有多大决定于驱动程序想让它有多大。本节中我们将定义自己的IO控制码,读者将看到该函数的应用。
前面介绍了API,如何使用这些API呢?后面的函数都会使用CreateFile返回的设备句柄,如何得到这个设备句柄呢?驱动程序驱动设备,我们操作设备时操作系统会调用驱动程序,那么我们的问题就落在如何打开驱动程序注册的设备了。在教程1中,我们通过设备管理器来操作安装的设备HelloWorld。现在,我们要用程序打开并操作它。
按照我们的经验,CreateFile操作文件时使用文件名,如C:\temp.txt。同样,要操作设备,我们也要给设备给个标识,比如说命个名。Windows驱动编程中提供了两种方法:符号链接和设备接口(GUID),NT驱动一般用符号链接,WDM驱动之后用设备接口。我们也用设备接口。看起来我们可以与符号链接说拜拜了,然而并不能。CreateFile第一个参数使用的就是符号链接,我们常用的C盘D盘也是符号链接。我们编写驱动程序的时候会给设备注册一个GUID,这个设备接口会公开给应用程序,Windows会自己给设备分配符号链接名。那么本节的第一个问题来了,知道了设备的设备接口(GUID)如何得到它的符号链接名?注意这是在应用程序,而不是驱动程序中。本小节的应用程序是参考WDK驱动示例中编写的,这些示例中基本上都有一个GetDevicePath
函数,完成的就是我们前面所说的功能。读者可以直接去看示例代码。
在Windows应用程序中进行设备管理的接口是一组被称为Setup API的函数,关于这些函数比较详细的介绍请看文档或者范文庆、周彬彬和安靖编著的《Windows API开发详解——函数、接口、编程实例》中的“设备驱动管理与内核通信”一章。我们用之前简单介绍一下这些函数,明确整个流程。有目标才有方向,那我们就看看我们的目标在哪吧。它在SP_DEVICE_INTERFACE_DETAIL_DATA
结构体中,MSDN的描述已经一针见血:
An SP_DEVICE_INTERFACE_DETAIL_DATA
structure contains the path for a device interface.
SP_DEVICE_INTERFACE_DETAIL_DATA
结构包含了设备接口的路径。
事实上这个结构体也只保存了这一项内容:
cbSize为本结构体的大小,DevicePath为设备接口路径字符串。该路径可以传递到Win32函数中使用,如CreateFile。
我们通过SetupDiGetDeviceInterfaceDetail
函数获取设备接口的详细信息,也就是SP_DEVICE_INTERFACE_DETAIL_DATA结构体。该函数如下:
关于参数我就不一个一个说了。这里面又多遇到了几个设备相关的类型:HDEVINFO
、PSP_DEVICE_INTERFACE_DATA
和PSP_DEVINFO_DATA
,这个要说一下。先来看与SP_DEVICE_INTERFACE_DETAIL_DATA
结构体相近的一个:SP_DEVICE_INTERFACE_DATA
结构。前面的结构为设备接口详细信息,这个结构为设备接口信息。那它包含哪些信息呢?看看它的内容:
内容也很少,关于设备类的GUID教程1的HelloWorld已经接触过,相比大家不再陌生。关于获取该结构体的函数稍稍放到后面讲。先来看看下一个类型HDEVINFO
。按照MSDN的说法,这是一个句柄,指向一个叫做设备信息集(Device Information Set)的东西。设备信息集是一个给设备归类的东西,以便于管理,一般把具有相同设备接口类(GUID)放到同一个设备信息集。MSDN给出了一张图,如图3.1。设备信息集中每个设备用设备信息元素(Device Information Element)表示,用一个链表链接起来。每个设备可以注册多个设备接口,也通过链表链接起来,设备还包含一个设备节点(devnode),存放设备信息。HDEVINFO代表某个设备信息集,因此,有了它,我们就可以对其中的设备进行枚举,找到相关的所有设备接口。
图3.1
顺便提一下获取HDEVINFO
的函数SetupDiGetClassDevs
,指定设备接口类就可以获取相关的设备信息集。这个函数有一个叫Enumerator
的参数可能无法理解,目前大家记得这表示一个注册表项,它与设备实例有关。关于设备实例与设备接口的来历和关系,我会专门出教程之外的一个篇幅来疏通。目前大家使用时直接传递NULL值就可以了。
还有一个类型:PSP_DEVINFO_DATA
。还记得我们刚刚说到枚举设备信息集中的设备接口吗?这是通过函数SetupDiEnumDeviceInterfaces
完成的,看看这个函数的参数:
这个函数的参数DeviceInfoData
就是我们要说的类型。MSDN对该参数的解释:
A pointer to an SP_DEVINFO_DATA structure that specifies a device information element in DeviceInfoSet.
PSP_DEVINFO_DATA
结构体指针指向设备信息集中的一个设备信息元素(Device Information Element)。也就是说这个结构体表示设备信息集中的一个设备。
一个设备信息集中包含多个设备,每个设备又包含多个设备接口。那么这个函数如何枚举所有的设备接口呢?这就与这个参数有关。如果用该参数指定了某个设备,则仅枚举该设备的设备接口。如果不指定,则需要循环多次调用该函数,每次枚举一个设备,直到枚举完所有设备。为什么说SP_DEVINFO_DATA
结构体表示一个设备,结构体中有个DevInst成员,就是指向一个设备实例的句柄(也即devnode的句柄)。
好了,前面我们倒着讲了涉及到的一些知识,现在正着理一下流程。先调用SetupDiGetClassDevs
,传递GUID参数,获取设备信息集。然后循环调用SetupDiEnumDeviceInterfaces
枚举设备信息集中每个设备的设备接口。对于枚举获得的接口,调用SetupDiGetDeviceInterfaceDetail
获取接口的详细信息,其中就包含我们需要的符号链接名。
下面是GetDevicePath
的代码:
// 根据设备接口GUID获取设备路径(符号链接名)
// InterfaceGuid : 设备接口GUID的指针
// 如果失败,则返回空指针NULL
PCHAR GetDevicePath( IN LPGUID InterfaceGuid )
{
// 设备信息集
HDEVINFO DeviceInfoSet;
// 设备接口信息
SP_DEVICE_INTERFACE_DATA DeviceInterfaceData;
// 指向设备接口详细信息的指针
PSP_DEVICE_INTERFACE_DETAIL_DATA DeviceInterfaceDetailData = NULL;
// 函数指向结果返回值
BOOL bResult;
// 遍历
DWORD dwIndex;
ULONG Length, RequiredLength = 0;
// 获取指定设备接口类的设备信息集
DeviceInfoSet = SetupDiGetClassDevs(
InterfaceGuid,
NULL, // Enumerator
NULL,
(DIGCF_PRESENT | DIGCF_DEVICEINTERFACE) );
if( INVALID_HANDLE_VALUE == DeviceInfoSet )
{
printf("SetupDiGetClassDevs failed! ErrNo:%d\n", GetLastError());
return NULL;
}
// 枚举设备信息集下的所有设备的设备接口
for( dwIndex = 0; ; dwIndex++ )
{
DeviceInterfaceData.cbSize = sizeof(SP_DEVICE_INTERFACE_DATA);
bResult = SetupDiEnumDeviceInterfaces(
DeviceInfoSet,
NULL,
InterfaceGuid,
dwIndex, // 第dwIndex个设备
&DeviceInterfaceData);
if( !bResult )
{
// 259错误表示遍历完成,这里没有做特殊判断,也会打印出来
printf("SetupDiEnumDeviceInterfaces failed! ErrNo:%d\n", GetLastError());
SetupDiDestroyDeviceInfoList(DeviceInfoSet);
break;
}
// 获取设备接口详细信息
// 第一次调用,DeviceInterfaceDetailData置为NULL,获取待分配缓冲区的大小
// 根据MSDN,此次调用返回值为FALSE,而且GetLastError为122,如果进行错误检查,需要注意
SetupDiGetDeviceInterfaceDetail(
DeviceInfoSet,
&DeviceInterfaceData, // 待获取详细信息的设备接口
NULL,
0,
&RequiredLength,
NULL);
DeviceInterfaceDetailData = (PSP_DEVICE_INTERFACE_DETAIL_DATA) LocalAlloc(LMEM_FIXED, RequiredLength);
if( NULL == DeviceInterfaceDetailData )
{
printf("Failed to allocate memory!\n");
SetupDiDestroyDeviceInfoList(DeviceInfoSet);
return NULL;
}
DeviceInterfaceDetailData->cbSize = sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA);
Length = RequiredLength;
// 第二次调用,返回设备接口详细信息
bResult = SetupDiGetDeviceInterfaceDetail(
DeviceInfoSet,
&DeviceInterfaceData,
DeviceInterfaceDetailData,
Length,
&RequiredLength,
NULL);
if( !bResult )
{
printf("SetupDiGetDeviceInterfaceDetail failed(second)! ErrNo:%d\n", GetLastError());
SetupDiDestroyDeviceInfoList(DeviceInfoSet);
LocalFree(DeviceInterfaceDetailData);
return NULL;
}
return DeviceInterfaceDetailData->DevicePath;
//printf("DevicePath: %s\n", DeviceInterfaceDetailData->DevicePath);
LocalFree(DeviceInterfaceDetailData);
}
return NULL;
}
读者看到,虽然我在while循环中遍历了,但是我获取到第一个设备路径就返回了。这对于我们后面要用的示例是没有问题的,因为我们的设备类下面只有一个设备,设备类是我们在INF文件中自己定义的。但是随着我们示例的增多,或者传入系统预定义的设备类,例如卷设备GUID_DEVINTERFACE_VOLUME
,可能就不只一个了,如何定位一个设备,这就与前面提到的Enumerator
参数有关了。关于这一点,我们在另外的篇幅中再说。WDK中的示例是没有用循环,直接获取第一个,与我在循环中返回是一个道理。
ReadFile,WriteFile应用广泛,包含文件、管道和串口通信等等,最常用的应该还是文件系统,用于操作存储设备。为了让示例简单,目前我们还不涉及到硬件相关的部分,也不涉及设备堆栈。我们的示例为一个纯软件驱动,而且直接处理请求,不将请求继续下发。为了使用ReadFile,WriteFile等函数,我们用一个缓冲区模拟文件操作。具体是这样,CreateFile调用时驱动程序内部创建一个固定大小的缓冲区,IO请求都是对该缓冲区进行操作。进一步简化,该缓冲区里面保存一个字符串,WriteFile向其中写入一个字符串,ReadFile从中读取一个字符串,IO控制命令定义为移动文件指针命令,可以左右移动文件指针,从而控制读取读写的位置。IO控制命令输入为文件指针移动的偏移量,为正向后移动,为负向前移动,输出为文件指针移动后的位置。
编码示例中我定义了IO_BUFFER_SIZE
为20个字节,全局变量gFileBuffer
为文件缓冲区地址,gFilePointer
为文件指针。如下:
// 固定20个字节大小的缓冲区,模拟文件内容
#define IO_BUFFER_SIZE 20
PCHAR gFileBuffer = NULL;
// 模拟文件指针
UCHAR gFilePointer = 0;
在处理Create和Close消息中分别分配和释放内存,Create和Close消息对应于API函数CreateFile和CloseHandle,如下:
// Create
VOID EvtDeviceFileCreate( IN WDFDEVICE Device,
IN WDFREQUEST Request,
IN WDFFILEOBJECT FileObject )
{
// 在非分页内存上分配缓冲区
if( NULL == gFileBuffer )
{
gFileBuffer = (PCHAR)ExAllocatePoolWithTag(NonPagedPool, IO_BUFFER_SIZE, TAG_IO_BUFFER);
}
// 分配失败
if( NULL == gFileBuffer )
{
KdPrint(("[EvtDriverDeviceAdd] WdfMemoryCreate failed!"));
WdfRequestComplete(Request, STATUS_UNSUCCESSFUL);
}
WdfRequestComplete(Request, STATUS_SUCCESS);
}
// Close
VOID EvtFileClose( IN WDFFILEOBJECT FileObject )
{
// 释放缓冲区
if( NULL != gFileBuffer )
{
ExFreePoolWithTag((PVOID)gFileBuffer, TAG_IO_BUFFER);
gFileBuffer = NULL;
}
gFilePointer = 0;
}
WDF框架设置回调函数的地方与WDM驱动程序不同,WDM驱动中这些回调都通过MajorFunction数组来设置。WDF框架中分了不同的地方。文件相关的IRP_MJ_CREATE
、IRP_MJ_CLOSE
等所对应的放于文件对象的配置中,而IO读写、控制放于IO队列的配置中。本例中设置EvtDeviceFileCreate
和EvtFileClose
的代码为:
// 初始化文件对象
WDF_FILEOBJECT_CONFIG_INIT(&fileConfig, EvtDeviceFileCreate,
EvtFileClose, WDF_NO_EVENT_CALLBACK);
WdfDeviceInitSetFileObjectConfig(DeviceInit, &fileConfig, WDF_NO_OBJECT_ATTRIBUTES);
设置EvtIoRead
、EvtIoWrite
和EvtIoDeviceControl
的代码为:
//使用缺省队列,设置I/O请求分发处理方式为串行
WDF_IO_QUEUE_CONFIG_INIT_DEFAULT_QUEUE(&ioQueueConfig, WdfIoQueueDispatchSequential);
// 设置EvtIoRead回调例程
ioQueueConfig.EvtIoRead = EvtIoRead;
// 设置EvtIoWrite回调例程
ioQueueConfig.EvtIoWrite = EvtIoWrite;
//设置EvtIoDeviceControl回调例程
ioQueueConfig.EvtIoDeviceControl = EvtIoDeviceControl;
其它部分代码不细说了,放在github上。下面看一看测试用的应用程序:
int main( int argc, char* argv[] )
{
PCHAR DevicePath;
HANDLE hDevice = INVALID_HANDLE_VALUE;
BOOL bRetVal;
DevicePath = GetDevicePath((LPGUID)&IOSample_DEVINTERFACE_GUID);
if( NULL == DevicePath )
goto exit;
printf("device path: %s\n", DevicePath);
hDevice = CreateFile( DevicePath,
GENERIC_READ|GENERIC_WRITE,
FILE_SHARE_READ|FILE_SHARE_WRITE,
NULL,
OPEN_EXISTING,
0,
NULL );
if( INVALID_HANDLE_VALUE == hDevice )
{
printf("ERROR opening device: (%0x) returned from CreateFile\n", GetLastError());
goto exit;
}
printf("open device successfully\n");
// 写入
CHAR bufWrite[] = "Hello World!";
DWORD bytesWritten;
bRetVal = WriteFile(hDevice, bufWrite, sizeof(bufWrite), &bytesWritten, NULL );
if( !bRetVal )
{
printf("ERROR writting: (%0x) returned from WriteFile\n", GetLastError());
goto exit;
}
printf("%d characters has been written successfully!\n", bytesWritten);
// 读取
CHAR bufRead[21];
DWORD bytesRead;
bRetVal = ReadFile(hDevice, bufRead, sizeof(bufRead), &bytesRead, NULL);
if( !bRetVal )
{
printf("ERROR reading: (%0x) returned from ReadFile\n", GetLastError());
goto exit;
}
bufRead[bytesRead] = '\0';
printf("string: %s\n", bufRead);
// IO控制,移动文件指针到'W'字符处
CHAR offset = 6;
CHAR filePointer;
bRetVal = DeviceIoControl(hDevice, IOSample_IOCTL_FILE_SEEK,
&offset, 1, &filePointer, 1, &bytesRead, NULL);
if( !bRetVal )
{
printf("ERROR: (%0x) returned from DeviceIoControl\n", GetLastError());
goto exit;
}
printf("file pointer now: %d\n", filePointer);
// 读取
bRetVal = ReadFile(hDevice, bufRead, sizeof(bufRead), &bytesRead, NULL);
if( !bRetVal )
{
printf("ERROR reading: (%0x) returned from ReadFile\n", GetLastError());
goto exit;
}
bufRead[bytesRead] = '\0';
printf("new string: %s\n", bufRead);
CloseHandle(hDevice);
exit:
system("pause");
}
GetDevicePath为第3小节中所写的,用来获取框架为设备生成的设备路径。测试程序中,获取设备路径后,首先用CreateFile打开设备,然后用WriteFile写入字符串”Hello World!”,写入后读取,则应当读取出”Hello World!”。再用控制命令移动文件指针至字符’W’处,然后ReadFile读取,自然是应该读取出’World!’。下面是我机器上的测试结果:
本节中的示例功能逻辑都很简单,唯一要注意的地方是内存和边界的处理和检查,如果内存访问越界,你将会得到一个蓝屏。
教程中有些地方并没有深入剖析地很透彻,笔者能力有限。而且本教程的主线着重于驱动的开发,主要通过示例来讲解驱动开发中的知识点。需要进一步整理讲解的内容,我会另做教程外的一些小篇幅来介绍。