windows驱动的简单入门(三)

简介:这是一篇继续介绍windows驱动的简单入门,包含从用户层到驱动层的简单通信的方法。

前言

通过上一篇的简单入门,我们了解了在驱动层如何进行文件的读写以及注册表的读取以及设置,那接下来要进一步学习的还是跟I/O有关,不过更加有趣,那就是从用户到驱动之间如何实现消息的传递,就像我们做web开发一样,从前端到后端的通信,这也是有相似指出,让我们拭目以待。

通信模型的介绍

虽然今天主要是介绍内核和用户层是如何通信的,但是,先了解一下这个有意思的通信模型或许会更加方便,首先看这张在微软官网拿下来的图片: 2

windows内核中处理I/O操作最重要的部分是I/O管理器,从用户端的请求,会发送到I/O管理器,I/O管理器会根据请求设置好IRP( I/ORequestPackets请求包),里面包含了传递给驱动的消息,驱动接受之后根据需要去传递给设备(可以是逻辑的或者物理的设备),回返回IRP。如果只是简单了解,这些就够了,想要进一步知道更多的信息可以看这个地址:https://learn.microsoft.com/zh-cn/windows-hardware/drivers/kernel/example-i-o-request---the-details

消息与处理

其实可以理解,IRP就是I/O管理器与驱动程序交互的介质,包括IRP的堆栈,编号,设定的处理函数等等,而说到处理函数,IRP有设定一些固定的处理函数,当在用户层想要对设备做一些固定的操作的时候(比如创建,打开等等),I/O管理器,就会设定对应的IRP发给能够处理对应设备的驱动程序,而驱动程序就必须实现对应操作的IRP指定的函数,可以看这个图,更简单的理解。

3 (上图是非常粗糙甚至有些可能不对,但是对理解起来比较容易)

IRP

通过上文的简述,就应该知道IRP会有一些预定义的操作,那么来看下都有那些,如果看文档的话直接点击这个链接就可以:https://learn.microsoft.com/zh-cn/windows-hardware/drivers/kernel/irp-major-function-codes,如果是通过VS2022,那么可以这样操作,根据之前写的驱动代码DriverEntry的实例中,参数有一个结构是PDRIVER_OBJECT,那么这个DRIVER_OBJECT的结构体就是驱动程序对象(主要描述驱动信息的结构),里面有一个成员变量叫MajorFunction,是一个数组

1
PDRIVER_DISPATCH MajorFunction[IRP_MJ_MAXIMUM_FUNCTION + 1];

这个数组的索引依次就是一组描述IRP类型的代号。在编辑器中一次Ctrl+鼠标左键单击,进入进去之后会看到每个数组索引的含义。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

//
// Define the major function codes for IRPs.
//


#define IRP_MJ_CREATE                   0x00
#define IRP_MJ_CREATE_NAMED_PIPE        0x01
#define IRP_MJ_CLOSE                    0x02
#define IRP_MJ_READ                     0x03
#define IRP_MJ_WRITE                    0x04
#define IRP_MJ_QUERY_INFORMATION        0x05
#define IRP_MJ_SET_INFORMATION          0x06
#define IRP_MJ_QUERY_EA                 0x07
#define IRP_MJ_SET_EA                   0x08
#define IRP_MJ_FLUSH_BUFFERS            0x09
#define IRP_MJ_QUERY_VOLUME_INFORMATION 0x0a
#define IRP_MJ_SET_VOLUME_INFORMATION   0x0b
#define IRP_MJ_DIRECTORY_CONTROL        0x0c
#define IRP_MJ_FILE_SYSTEM_CONTROL      0x0d
#define IRP_MJ_DEVICE_CONTROL           0x0e
#define IRP_MJ_INTERNAL_DEVICE_CONTROL  0x0f
#define IRP_MJ_SHUTDOWN                 0x10
#define IRP_MJ_LOCK_CONTROL             0x11
#define IRP_MJ_CLEANUP                  0x12
#define IRP_MJ_CREATE_MAILSLOT          0x13
#define IRP_MJ_QUERY_SECURITY           0x14
#define IRP_MJ_SET_SECURITY             0x15
#define IRP_MJ_POWER                    0x16
#define IRP_MJ_SYSTEM_CONTROL           0x17
#define IRP_MJ_DEVICE_CHANGE            0x18
#define IRP_MJ_QUERY_QUOTA              0x19
#define IRP_MJ_SET_QUOTA                0x1a
#define IRP_MJ_PNP                      0x1b
#define IRP_MJ_PNP_POWER                IRP_MJ_PNP      // Obsolete....
#define IRP_MJ_MAXIMUM_FUNCTION         0x1b

而每个索引对应的元素就是处理该消息的回调函数。因此,另一角度理解就是,在你的驱动程序中,你可以主动设置对应MajorFunction中不同索引所对应的函数的指针。这样,当用户层触发针对设备的某一个行为的时候,你的这个函数就会被调用。(是不是到这里就通畅了)

DispatchXxx

关于回调函数,其实也不难,因为这个函数也是有定义要求的,具体看下文:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
DRIVER_DISPATCH DispatchXxx;

_Use_decl_annotations_
NTSTATUS
  DispatchXxx(
    struct _DEVICE_OBJECT  *DeviceObject,
    struct _IRP  *Irp
    )
  {
      // Function body
  }

// 关于为什么这类函数都行简单看下:
_Function_class_(DRIVER_DISPATCH)
_IRQL_requires_max_(DISPATCH_LEVEL)
_IRQL_requires_same_
typedef
NTSTATUS
DRIVER_DISPATCH (
    _In_ struct _DEVICE_OBJECT *DeviceObject,
    _Inout_ struct _IRP *Irp
    );

typedef DRIVER_DISPATCH *PDRIVER_DISPATCH;
//首先这是一个函数类的定义,也就是符合DRIVER_DISPATCH这种结构的都是这类函数
//而MajorFunction的成员元素只要都是DRIVER_DISPATCH这类的函数就都可以。

以上就是你要实现的回调函数的样子,只要能完成这样一个函数,同时再配置好MajorFunction,用户层调用不就能触发了。

几个相关的函数

既然理解了原理,那么我们就需要通过windows给出的方法来实践这个过程,那就是看看对应有哪些函数来帮助我们实现这个通信过程。

IoCreateDevice

通过上面的了解,驱动程序的含义顾名思义就是为了驱动设备的,所以需要先有这个设备,故此,这个函数是创建一个设备对象,以供驱动程序调用。

1
2
3
4
5
6
7
8
9
NTSTATUS IoCreateDevice(
  [in]           PDRIVER_OBJECT  DriverObject,
  [in]           ULONG           DeviceExtensionSize,
  [in, optional] PUNICODE_STRING DeviceName,
  [in]           DEVICE_TYPE     DeviceType,
  [in]           ULONG           DeviceCharacteristics,
  [in]           BOOLEAN         Exclusive,
  [out]          PDEVICE_OBJECT  *DeviceObject
);

参数列表介绍如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
[in] DriverObject
// 要驱动这个设备的驱动程序的指针,也就是当前DriversEntry的参数PDRIVER_OBJECT

[in] DeviceExtensionSize
//设备扩展分配字节,没啥写的填写0就OK

[in, optional] DeviceName
// 设备命名,这个很重要,因为用户层调用是通过符号链接的名称调用,
//然后对应到这个名称才能调用到设备的,而且是一个
UNICODE_STRING deviceName = RTL_CONSTANT_STRING(L"\\Device\\newDevice");

注意:关于命名这里有个解释:https://learn.microsoft.com/zh-cn/windows-hardware/drivers/kernel/nt-device-names,需要在\Device目录下创建名称

1
2
3
4
[in] DeviceType

// 指定设备类型,因为类型很多选一个,选下面这个更接近
#define FILE_DEVICE_UNKNOWN             0x00000022

其他的设备类型可以看这个链接:https://learn.microsoft.com/zh-cn/windows-hardware/drivers/kernel/specifying-device-types

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
[in] DeviceCharacteristics
//指定设备的特征,其实就是指定还有啥其他的要特别指定的
// 一般就用这个参数
FILE_DEVICE_SECURE_OPEN

[in] Exclusive
// 是否是独占设备,一般False

[out] DeviceObject
// 返回的设备对象的结构体指针( DEVICE_OBJECT )

//结构如下:
typedef struct _DEVICE_OBJECT {
  CSHORT                   Type;
  USHORT                   Size;
  LONG                     ReferenceCount;
  struct _DRIVER_OBJECT    *DriverObject;
  struct _DEVICE_OBJECT    *NextDevice;
  struct _DEVICE_OBJECT    *AttachedDevice;
  struct _IRP              *CurrentIrp;
  PIO_TIMER                Timer;
  ULONG                    Flags;
  ULONG                    Characteristics;
  __volatile PVPB          Vpb;
  PVOID                    DeviceExtension;
  DEVICE_TYPE              DeviceType;
  CCHAR                    StackSize;
  union {
    LIST_ENTRY         ListEntry;
    WAIT_CONTEXT_BLOCK Wcb;
  } Queue;
  ULONG                    AlignmentRequirement;
  KDEVICE_QUEUE            DeviceQueue;
  KDPC                     Dpc;
  ULONG                    ActiveThreadCount;
  PSECURITY_DESCRIPTOR     SecurityDescriptor;
  KEVENT                   DeviceLock;
  USHORT                   SectorSize;
  USHORT                   Spare1;
  struct _DEVOBJ_EXTENSION *DeviceObjectExtension;
  PVOID                    Reserved;
} DEVICE_OBJECT, *PDEVICE_OBJECT;

这个结构体的具体含义不多赘述,可以参考这个链接:https://learn.microsoft.com/zh-cn/windows-hardware/drivers/ddi/wdm/ns-wdm-_device_object,创建的代码写到下面可以参考:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
UNICODE_STRING deviceName = RTL_CONSTANT_STRING(L"\\Device\\newDevice");
PDEVICE_OBJECT PDeviceObj = NULL;
//创建设备
NTSTATUS status=IoCreateDevice(pDrvierObject,0,&deviceName, FILE_DEVICE_UNKNOWN, FILE_DEVICE_SECURE_OPEN, FALSE,&PDeviceObj);

if (!NT_SUCCESS(status))
{
    DbgPrint("创建一个设备对象失败");
    return status;
}

虽然设备创建好了,但是想要在用户层就能直接调用,是不可以的,需要创建一个类似于快捷方式的东西,也就是符号链接,通过调用符号链接对应的名称,然后系统会通过符号链接找到设备的名称,进而找到设备以及驱动程序,进行下一步的执行与调用。

所以这个函数就是干这个事情的,它的定义如下:

1
2
3
4
5
6
7
8
NTSTATUS IoCreateSymbolicLink(
  [in] PUNICODE_STRING SymbolicLinkName,
  [in] PUNICODE_STRING DeviceName
);

//这个函数用起来也比较简单
//第一个参数是符号链接名称
//第二个参数就是上文创建的设备名称
  • 关于内核和用户模式下的符号链接的表达: 在内核和用户模式下,对符号链接的命名规则会有些区别,MS-DOS 设备名称是对象管理器中的符号链接,其名称的格式为 \DosDevices\DosDeviceName,例如:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//内核模式
UNICODE_STRING DeviceName;
UNICODE_STRING DosDeviceName;
NTSTATUS status;

RtlInitUnicodeString(&DeviceName, L"\\Device\\DeviceName");
RtlInitUnicodeString(&DosDeviceName, L"\\DosDevices\\DosDeviceName");
status = IoCreateSymbolicLink(&DosDeviceName, &DeviceName);
if (!NT_SUCCESS(status)) {
  /* Symbolic link creation failed.  Handle error appropriately. */
}

//以上就是建立设备名称和符号名称对应的关系

// 用户模式
file = CreateFileW(L"\\\\.\\DosDeviceName",
  GENERIC READ | GENERIC WRITE,
    0,
    NULL,
    OPEN_EXISTING,
    0,
    NULL);
// 可以看到用户模式下访问符号链接就用\\\\.\\DosDeviceName来表示

另外,内核模式还可以用这种方式表示:

1
UNICODE_STRING deviceSymbloName = RTL_CONSTANT_STRING(L"\\??\\newDevice");

IoGetCurrentIrpStackLocation

我们上文已经完成了,设备的创建,也知道如何定义回调函数,并给驱动指定回调函数,而回调函数的参数列表中就包含I/O管理器派给驱动的IRP,那接下来就应该进一步在回调函数里读取解析IRP,读取用户层传递的消息的内容了。

这个函数是获取描述堆栈的结构体地址,也就是IO_STACK_LOCATION 这个结构体,具体可以看这个链接https://learn.microsoft.com/zh-cn/windows-hardware/drivers/ddi/wdm/ns-wdm-_io_stack_location

使用也比较简单,直接传入IRP指针就行:

1
2
3
__drv_aliasesMem PIO_STACK_LOCATION IoGetCurrentIrpStackLocation(
  [in] PIRP Irp
);

IoCompleteRequest

接着,如果当前回调函数结束了,没有进一步需要处理的就可以用这个宏了(这是一个宏)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#define IoCompleteRequest(a,b)  IofCompleteRequest(a,b)

_IRQL_requires_max_(DISPATCH_LEVEL)
NTKERNELAPI
VOID
FASTCALL
IofCompleteRequest(
    _In_ PIRP Irp,
    _In_ CCHAR PriorityBoost
    );

就是把IRP传进去,另一个参数就写IO_NO_INCREMENT即可(有关是否增加线程来处理中断)

IoDeleteSymbolicLink和IoDeleteDevice

当最后想要退出驱动的时候,需要把建立好的符号链接和设备都删除掉,那么就需要用到这两个函数了,其实使用起来也很简单,传入对应参数即可

1
2
3
4
5
6
7
8
9
NTSTATUS IoDeleteSymbolicLink(
  [in] PUNICODE_STRING SymbolicLinkName
);
// 删除符号链接

void IoDeleteDevice(
  [in] PDEVICE_OBJECT DeviceObject
);
// 删除设备对象

模拟情景

既然有了上面的了解,那我们来模拟一个场景,通过用户层调用驱动层进行文件创建、读和写。

驱动层的代码

三个回调函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
#include<ntifs.h>

//创建行为的派发函数
NTSTATUS
DispatchCreate(
    struct _DEVICE_OBJECT* DeviceObject,
    struct _IRP* Irp
)
{
    
    DbgPrint("IRP_MJ_CREATE");
    IoCompleteRequest(Irp, IO_NO_INCREMENT);
}

//读取的派发函数
NTSTATUS
DispatchRead(
    struct _DEVICE_OBJECT* DeviceObject,
    struct _IRP* Irp
)
{
    //从IRP中获取系统分配的IRP的缓存区位置,看下文
    PVOID buff=Irp->AssociatedIrp.SystemBuffer;
    //
    memcpy(buff,"hello Driver",strlen("hello Driver")+1);
    //
    Irp->IoStatus.Information =100;
    IoCompleteRequest(Irp, IO_NO_INCREMENT);
}

//写入的派发函数
NTSTATUS
DispatchWrite(
    struct _DEVICE_OBJECT* DeviceObject,
    struct _IRP* Irp
)
{
    PVOID buff = Irp->AssociatedIrp.SystemBuffer;
    PIO_STACK_LOCATION ioStack= IoGetCurrentIrpStackLocation(Irp);
    ULONG buffLen = ioStack->Parameters.Write.Length;
    DbgPrint("buffLen===%d", buffLen);
    DbgPrint("buff====%s", buff);
    Irp->IoStatus.Information = 1;
    IoCompleteRequest(Irp, IO_NO_INCREMENT);
}

/*
关于Irp->AssociatedIrp.SystemBuffer这个参数,有一段描述可以参考下:
这个参数是:指向系统空间缓冲区的指针。

如果驱动程序使用缓冲 I/O,则缓冲区的用途由 IRP 主要函数代码确定,如下所示:

SystemBuffer.IRP_MJ_READ
缓冲区从设备或驱动程序接收数据。
缓冲区的长度由驱动程序的 IO_STACK_LOCATION 结构中的 Parameters.Read.Length 指定。

SystemBuffer.IRP_MJ_WRITE
缓冲区为设备或驱动程序提供数据。 
缓冲区的长度由驱动程序的 IO_STACK_LOCATION 结构中的 Parameters.Write.Length 指定。

SystemBuffer.IRP_MJ_DEVICE_CONTROL 或 IRP_MJ_INTERNAL_DEVICE_CONTROL
缓冲区表示提供给 DeviceIoControl 和 IoBuildDeviceIoControlRequest 的输入和输出缓冲区。 输出数据将覆盖输入数据。

对于输入,缓冲区的长度由驱动程序IO_STACK_LOCATION结构中的Parameters.DeviceIoControl.InputBufferLength 指定。
对于输出,缓冲区的长度由驱动程序IO_STACK_LOCATION结构中的Parameters.DeviceIoControl.OutputBufferLength 指定。

*/

// 其次,Irp->IoStatus是一个结构体,结构如下,其中Information主要就是设定完成的状态或信息
// 比如,想要读取,那就是要驱动在缓冲区写内容,然后这个值就要修改成写入的大小或者一个适当的值
// 如果是写,那这个其实并没什么,或者说可以设置Status为1,Information为0

typedef struct _IO_STATUS_BLOCK {
  union {
    NTSTATUS Status;
    PVOID    Pointer;
  };
  ULONG_PTR Information;
} IO_STATUS_BLOCK, *PIO_STATUS_BLOCK;

定义驱动和指定回调函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
void DriverUnload(PDRIVER_OBJECT pDrvierObject)
{
    //驱动退出部分
    UNICODE_STRING deviceSymbloName = RTL_CONSTANT_STRING(L"\\??\\newDevice");
    IoDeleteSymbolicLink(&deviceSymbloName);
    IoDeleteDevice(pDrvierObject->DeviceObject);
    DbgPrint("DriverUnload");
}

NTSTATUS DriverEntry(PDRIVER_OBJECT pDrvierObject,PUNICODE_STRING pRetPath)
{
    
    UNICODE_STRING deviceName = RTL_CONSTANT_STRING(L"\\Device\\newDevice");
    PDEVICE_OBJECT PDeviceObj = NULL;
    
    NTSTATUS status=IoCreateDevice(pDrvierObject,0,&deviceName, FILE_DEVICE_UNKNOWN, FILE_DEVICE_SECURE_OPEN, FALSE,&PDeviceObj);
    
    if (!NT_SUCCESS(status))
    {
        DbgPrint("创建一个设备对象失败");
        return status;
    }
    //创建符号链接
    UNICODE_STRING deviceSymbloName = RTL_CONSTANT_STRING(L"\\??\\newDevice");
    status = IoCreateSymbolicLink(&deviceSymbloName, &deviceName);
    if (!NT_SUCCESS(status))
    {
        DbgPrint("创建符号链接失败");
        return status;
    }
    //数据交互方式
    PDeviceObj->Flags |= DO_BUFFERED_IO;
    //设置消息回调函数 派遣函数
    pDrvierObject->MajorFunction[IRP_MJ_CREATE]= DispatchCreate;
    pDrvierObject->MajorFunction[IRP_MJ_READ] = DispatchRead;
    pDrvierObject->MajorFunction[IRP_MJ_WRITE] = DispatchWrite;
    pDrvierObject->DriverUnload = DriverUnload;
    return STATUS_SUCCESS;
}

用户层代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
DWORD realRead = 0;
DWORD realWrite = 0;

//通过创建文件来调用,具体就不展开了
HANDLE handle=CreateFileW(L"\\\\.\\newDevice",GENERIC_ALL,0,NULL,OPEN_EXISTING,FILE_ATTRIBUTE_SYSTEM,0);

if (handle)
{
    PVOID buff = malloc(0x100);
    memset(buff,0,0x100);
    memcpy(buff, "newDevice", strlen("newDevice") + 1);

    //实现文件得读写操作
    printf("打开设备成功\n");
    char buff[0x100] = { 0 };
    ReadFile(handle, buff,0x100,&realRead,NULL);

    //尝试实现WriteFile
    WriteFile(handle,"newDevice",strlen("newDevice")+1,&realWrite,NULL);
    printf("realRead=%d\n", realRead);
    printf("buff====%s\n", buff);
    
    CloseHandle(handle);
    //方便观察
    system("pause");
}
else
{
    printf("打开设备失败!");
}

实践一下

最终,将代码分别放到vs2022中,用户层的代码新建一个c++空项目即可,编译方式要选一下,因为虚拟机里有可能没有对应链接的库文件。所以,在新项目右侧点击,到属性窗口-c/c++-代码生成,选择运行库为MTD方式,这样直接生成的exe文件就可以直接在虚拟机里运行。然后驱动文件跟之前说的方法一样直接生成就行。

最终放到虚拟机里,用驱动文件装载,注意,用户层的pe文件(也就是这个exe文件)需要用管理员方式运行,运行之后,就看到如下内容了

4

5

这样,从用户层到驱动层的简单通信方式就这样实现了。

updatedupdated2024-12-292024-12-29