windows驱动的简单入门(四)

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

前言

上一篇我们聊了关于在用户层和驱动层通信的方式https://daliu.net/posts/20241229/,又更加清楚的知道了调用的模型和调用的过程,那么今天这一篇介绍一个更加便捷的方法,并引入一个关于物理地址的概念。

IRP的控制代码

从上一篇中我们已经知道,I/O管理器,会根据不同的请求,创建指定的IRP,而IRP有若干主函数代码,不同的请求对应不同的IRP也就对应不同的主函数,有一个主函数代码,也是从用户层接受请求然后被触发建立对应IRP,并跟驱动函数进行通信的,这个主函数代码是:IRP_MJ_DEVICE_CONTROL

那么,我们既然知道了如何在驱动层设定这个主函数或者说是回调函数(跟上一篇一样操作),那么如何才能通过用户层进行调用才是重点。

在下面这篇文档中做了一些介绍,https://learn.microsoft.com/zh-cn/windows-hardware/drivers/kernel/introduction-to-i-o-control-codes,利用的就是一个用户层的一个api函数,DeviceIoControl,在用户模式下通过调用 DeviceIoControl 将 IOCTL 发送到驱动程序, 调用 DeviceIoControl 会导致 I/O 管理器创建 IRP_MJ_DEVICE_CONTROL 请求并将其发送到驱动程序。

而这个IOCTL,就是所谓的控制代码,更进一步的去理解就是,用户层和驱动层共同定义了一组控制代码,然后用户层通过DeviceIoControl函数调用,并传入控制代码,然后驱动层通过设定IRP_MJ_DEVICE_CONTROL所对应的回调函数,当被触发的时候,根据传来的控制代码来判断需要做的事情是什么。

控制代码的创建与使用

控制代码是需要自己定义的,这样DIY其实反而更加灵活。至于定义的方法,无论是在用户层,还是在驱动层,都有一个宏可以用来定义CTL_CODE,定义如下:

1
#define IOCTL_Device_Function CTL_CODE(DeviceType, Function, Method, Access)

控制代码的具体结构看这个官方的图片,更加的详细与清晰: 2

其中参数列表的含义如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
DeviceType
//设备类型,上一篇我们写过,依旧可以写成FILE_DEVICE_UNKNOWN

FunctionCode
//其实就是给你的驱动程序一个信号,标识下要制定什么函数,或者说,你要干的事情是什么,需要一个标识
//注意:小于 0x800 的值是为 Microsoft 保留的,咱自定义就要大于这个值

TransferType
//这个参数最关键,顾名思义,就是如何运送数据,从用户到驱动程序之间。有三种类型,具体下面展开说。

RequiredAccess
//表示用户层调用的时候想要访问的类型,如果不想做选择可以直接写FILE_ANY_ACCESS
//这样就不用考虑授予什么样的权限了

关于数据传送方式

在设定控制代码的时候,需要设定TransferType这个数据传送类型,类型总共有三种方式: 缓冲 I/O、直接 I/O、既不是缓冲 I/O,也不是直接 I/O

  • 缓冲I/O方式 选项的值是:METHOD_BUFFERED 具体操作系统会建立一个跟用户缓冲区大小一样的系统缓冲区,每当用户层跟驱动交互的时候,都是通过这个系统缓冲区作为过渡,比如是读数据的请求,驱动把数据放到系统缓冲区,然后系统把系统缓冲区的数据复制到用户缓冲区,类似于下图的感觉。

3

  • 直接I/O方式 选项的值是:METHOD_IN_DIRECT或METHOD_OUT_DIRECT 之所以有两个,就是因为实际上只有一个缓冲区了,需要通过设定值来区分,用户层发出的是读还是写的请求。当只有一个缓冲区的时候,需要确保用户层和驱动层可以同时访问同一个地址,但是实际上为了实现这个事情是一个比较复杂的事情,毕竟两个地址空间不一样,逻辑地址都不一样。所以这个方式实际上,操作系统是通过设定一个物理页映射成用户空间和驱动空间的不同的逻辑地址。这样就能够在两个地址空间但是却读取的是同一个物理内存区域。而实现这个功能的就是MDL,某种意义上将,就是将用户层的缓冲区映射到了驱动层,使得驱动层可以直接在这个缓冲区进行读写。

  • MDL 要详细了解MDL(内存描述符列表 ),后面可以详细展开,这里只是简单几笔概括下,MDL就是用来描述虚拟地址缓冲区的物理页布局,通过MDL我们就能更进一步获取缓冲区的相关信息等等。

  • 既不是缓冲 I/O,也不是直接 I/O 这种方式,操作系统没有提供任何缓冲区,纯纯是把用户的缓冲区的地址给驱动,让驱动自己切换到用户线程上再去用这个地址访问对应的内容。

实践一下

通过上面展示的几种方式,我们可以看出,在前言所描述的更直接传递数据的方式其实就是直接I/O的方式,接下来通过代码简单的实践一下具体看下缓冲和直接的效果。

指定控制代码

在建立用户到驱动层的通道前,最先要做的事情是需要指定一个双方共同的控制代码,那么按照上文给的宏方法我们来建立给读写分别建立一个控制代码

1
2
3
4
5
6
7
8
//读的代码
CTL_CODE(FILE_DEVICE_UNKNOWN,0X801, METHOD_BUFFERED, FILE_ANY_ACCESS)
//写的代码
CTL_CODE(FILE_DEVICE_UNKNOWN,0X802, METHOD_BUFFERED, FILE_ANY_ACCESS)

//考虑到使用的时候写这么一长串很不方便,可以用宏定义来简写
#define MSG_CODE_WRITE CTL_CODE(FILE_DEVICE_UNKNOWN,0X801, METHOD_BUFFERED, FILE_ANY_ACCESS)
#define MSG_CODE_READ CTL_CODE(FILE_DEVICE_UNKNOWN,0X802, METHOD_BUFFERED, FILE_ANY_ACCESS)

缓冲I/O方式

如果用缓冲的方式,其实跟直接用读写的IRP效果差不多,其实都是用系统的缓冲区来做数据的复制交换,唯一不同的就是你不知道驱动被指派的IRP对应的请求是读还是写,因为他是唯一的IRP_MJ_DEVICE_CONTROL,因此你需要获取从用户侧指定的控制代码是什么,比如是上面说的读,还是写呢?

首先,假设我们在驱动层指定处理IRP_MJ_DEVICE_CONTROL的IRP的函数是这样写的:

1
pDrvierObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = DispatchControl;

那么DispatchControl这个函数的定义应该这样写:

 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

NTSTATUS
DispatchControl(
    struct _DEVICE_OBJECT* DeviceObject,
    struct _IRP* Irp
)
{
    //因为是系统缓冲区,还是一样获取缓冲区的方式
    PVOID buff = Irp->AssociatedIrp.SystemBuffer;
    PIO_STACK_LOCATION ioStack = IoGetCurrentIrpStackLocation(Irp);
    ULONG inBuffLen = ioStack->Parameters.DeviceIoControl.InputBufferLength;
    ULONG outBuffLen = ioStack->Parameters.DeviceIoControl.OutputBufferLength;
    
    //通过这个参数获取控制代码,所以在驱动层和用户层,上文中定义控制代码的两个宏都要写出来
    ULONG opCode = ioStack->Parameters.DeviceIoControl.IoControlCode;
    if (MSG_CODE_WRITE== opCode)
    {
        DbgPrint("InputBufferLength===%d", inBuffLen);
        DbgPrint("buff====%s", buff);
        Irp->IoStatus.Information = 1;
    }
    else
    {
        DbgPrint("InputBufferLength===%d", outBuffLen);
        memcpy(buff,"Hello Driver",strlen("Hello Driver")+1);
        Irp->IoStatus.Information = strlen("Hello Driver") + 1;
    }
    
    IoCompleteRequest(Irp, IO_NO_INCREMENT);

}

通过以上代码可以看出来,使用控制码代码的方式似乎更加精简,而且缓冲I/O的方式跟用其他读写IRP的方式差不多。

直接I/O方式

接下来是重头戏,直接I/O的方式相对会复杂一些,但是看起来用户层和驱动层的连接反而更加的近。根据这个文档链接(https://learn.microsoft.com/zh-cn/windows-hardware/drivers/kernel/using-direct-i-o),我们可以知道有两种情况会使用直接I/O

情况一

上篇说过的两个读写IRP,IRP_MJ_READIRP_MJ_WRITE 当设定了这两个IRP对应的回调函数的时候,如果想使用直接I/O方式,那么需要把创建的设别的数据交换方式设置为DO_DIRECT_IO

1
PDeviceObj->Flags |= DO_BUFFERED_IO;

在这样的情况下,当在用户层使用ReadFile和WriteFile调用驱动的时候,对应回调函数内就可以通过MDL来直接获取对应缓冲区的地址,而缓冲区的地址其实就是用户层设定的缓冲区的地址

情况二

当用户层使用DeviceIoControl这个API,或者说驱动层设定了IRP_MJ_DEVICE_CONTROL这个IRP的时候,当IOCTL 代码(控制代码)的TransferType值为METHOD_IN_DIRECTMETHOD_OUT_DIRECT那么就会采用这样的直接I/O的方式。

如何读写呢?

无论是情况一还是情况二,想要读写首先需要获取缓冲区的地址,因为,这次没有系统缓冲区了,只有用户缓冲区,而驱动层拥有这个缓冲区所对应的MDL,描述了这个缓冲区的物理页内存等相关信息,这个MDL实际是不透明的一个结构,不过通过万能的互联网,也能查到一些大概的介绍:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
//0x30 bytes (sizeof)
struct _MDL
{
    struct _MDL* Next;                 //0x0
    SHORT Size;                        //0x8
    SHORT MdlFlags;                    //0xa
    USHORT AllocationProcessorNumber;  //0xc
    USHORT Reserved;                   //0xe
    struct _EPROCESS* Process;         //0x10
    VOID* MappedSystemVa;              //0x18
    VOID* StartVa;                     //0x20
    ULONG ByteCount;                   //0x28
    ULONG ByteOffset;                  //0x2c
}; 

// 其中这个VOID* StartVa; 应该就是缓冲区页的虚拟其实地址
// ULONG ByteOffset; 应该就是缓冲区的页偏移地址

而直接I/O方式下,系统给与的MDL就在IRP中:

1
2
3
4
5
6
7
typedef struct _IRP {
  CSHORT                    Type;
  USHORT                    Size;
  PMDL                      MdlAddress;
.....

//也就是这个 Irp->MdlAddress成员

虽然,MDL是不透明的,而且MDL的结构也可能会随着操作系统内核的更新而改变,我们无法直接通过结构体获取其中的用户层逻辑地址,但是我们可以通过一个windows提供的函数来获取用户层缓冲区的地址:MmGetSystemAddressForMdlSafe

1
2
3
4
5
PVOID MmGetSystemAddressForMdlSafe(
  [in] PMDL  Mdl,
  [in] ULONG Priority
);
// 第二个参数是优先级选项,文件相关的可以填个NormalPagePriority 

更多的内容可以看这个链接:https://learn.microsoft.com/zh-cn/windows-hardware/drivers/kernel/using-mdls,其中获取虚拟地址还有MmGetMdlVirtualAddress,但是为什么选用上面这个,一个主要的原因就是,驱动层访问用户层的逻辑地址的时候,需要确保是在一个线程的上线文,逻辑地址才是对的,否则对应的地址是访问不到内容的,所以该函数其实是帮助你把用户地址映射到系统空间里。

总结

今天的内容就这里的,主要的目的是为了介绍直接读写的方式,在windows的设计中直接读写更多是为了配合DMA这种高速读写,能够让用户更加直接的控制DMA硬件来实现I/O操作,关于DMA这类貌似在计算机考研的408中有提到过。不过,我们能够接触这个的目的还是为了能够实现用户层和驱动层的通信手段,这样可以更加方便的实现一些功能。

updatedupdated2025-01-022025-01-02