kmdf驱动教程2——驱动程序与应用程序通信

1 简述

作为写在最前面的话,我不会在本教程中专门去介绍KMDF框架、对象、方法等基础知识,因为相关资料已经不少,而且文档中都有,如果读者不了解,应该先去看看相关内容。

驱动程序作为内核模式的一部分,都是为我们的应用程序服务的。而我们在教程1中编写的HelloWord不仅没有为我们服务,而且我们还难以控制,除了在设备管理器中操作,连打印出来的信息都要特殊的工具才能看得到。所以,一方面我们要让驱动程序为我们做一些有用的东西,另一方面,我们要让驱动程序可控,让应用程序能够调用驱动程序提供的功能,能向驱动程序传入信息,也能把驱动信息输出的内容显示出来。这就是本节要完成的内容。

需要特别提前说明的是,本节依然是又臭又长的,毕竟我们才刚刚起步,会接触比较多新的东西,我会尽量把每个部分讲清楚。开始也想写得简单一点,但我们不能一直写HelloWorld,况且,本节的程序其实很简单,只是对于初学者会遇到比较多新的东西罢了。希望初学者坚持下来。

2 通过哪些系统API调用驱动程序?

既然驱动程序属于内核,那么应用程序就需要通过操作系统提供的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控制码,读者将看到该函数的应用。

3 如何调用驱动程序?

前面介绍了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结构体。该函数如下:

关于参数我就不一个一个说了。这里面又多遇到了几个设备相关的类型:HDEVINFOPSP_DEVICE_INTERFACE_DATAPSP_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中的示例是没有用循环,直接获取第一个,与我在循环中返回是一个道理。

4 本节的示例

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_CREATEIRP_MJ_CLOSE等所对应的放于文件对象的配置中,而IO读写、控制放于IO队列的配置中。本例中设置EvtDeviceFileCreateEvtFileClose的代码为:

// 初始化文件对象
WDF_FILEOBJECT_CONFIG_INIT(&fileConfig, EvtDeviceFileCreate,
    EvtFileClose, WDF_NO_EVENT_CALLBACK);

WdfDeviceInitSetFileObjectConfig(DeviceInit, &fileConfig, WDF_NO_OBJECT_ATTRIBUTES);

设置EvtIoReadEvtIoWriteEvtIoDeviceControl的代码为:

//使用缺省队列,设置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!’。下面是我机器上的测试结果:


图片4.1

5 总结

本节中的示例功能逻辑都很简单,唯一要注意的地方是内存和边界的处理和检查,如果内存访问越界,你将会得到一个蓝屏。

教程中有些地方并没有深入剖析地很透彻,笔者能力有限。而且本教程的主线着重于驱动的开发,主要通过示例来讲解驱动开发中的知识点。需要进一步整理讲解的内容,我会另做教程外的一些小篇幅来介绍。

你可能感兴趣的:(kmdf驱动开发,Windows内核,逆向工程)