Windows内核-7-IRP和派遣函数

IRP以及派遣函数是Windows中非常重要的概念。IRP 是I/O Request Pocket的简称,意思是I/O操作的请求包,Windows中所有User和Kernel之间的交流都会被封装成一个IRP结构体,然后不同的IRP会被派遣到不同的派遣函数里面,通过派遣函数来实现I/O操作。

IRP

typedef struct _IRP {
 CSHORT                    Type;
 USHORT                    Size;
 PMDL                      MdlAddress;
 ULONG                     Flags;
 union {
   struct _IRP     *MasterIrp;
   __volatile LONG IrpCount;
   PVOID           SystemBuffer;
} AssociatedIrp;
 LIST_ENTRY                ThreadListEntry;
 IO_STATUS_BLOCK           IoStatus;
 KPROCESSOR_MODE           RequestorMode;
 BOOLEAN                   PendingReturned;
 CHAR                      StackCount;
 CHAR                      CurrentLocation;
 BOOLEAN                   Cancel;
 KIRQL                     CancelIrql;
 CCHAR                     ApcEnvironment;
 UCHAR                     AllocationFlags;
 union {
   PIO_STATUS_BLOCK UserIosb;
   PVOID            IoRingContext;
};
 PKEVENT                   UserEvent;
 union {
   struct {
     union {
       PIO_APC_ROUTINE UserApcRoutine;
       PVOID           IssuingProcess;
    };
     union {
       PVOID                 UserApcContext;
#if ...
       _IORING_OBJECT        *IoRing;
#else
       struct _IORING_OBJECT *IoRing;
#endif
    };
  } AsynchronousParameters;
   LARGE_INTEGER AllocationSize;
} Overlay;
 __volatile PDRIVER_CANCEL CancelRoutine;
 PVOID                     UserBuffer;
 union {
   struct {
     union {
       KDEVICE_QUEUE_ENTRY DeviceQueueEntry;
       struct {
         PVOID DriverContext[4];
      };
    };
     PETHREAD     Thread;
     PCHAR        AuxiliaryBuffer;
     struct {
       LIST_ENTRY ListEntry;
       union {
         struct _IO_STACK_LOCATION *CurrentStackLocation;
         ULONG                     PacketType;
      };
    };
     PFILE_OBJECT OriginalFileObject;
  } Overlay;
   KAPC  Apc;
   PVOID CompletionKey;
} Tail;
} IRP;

 

 

IRP这种机制类似于Windows的消息机制,驱动在接受到IRP之后会根据IRP的不同类型分配给不同类型的派遣函数来处理IRP。

IRP不是单独的,只要创建了IRP就会跟着创建IRP的I/O栈,有一个栈是给内核驱动用的:

img

驱动需要调用IoGetCurrentIrpStackLocation函数来获取内驱驱动对应的I/O栈。

例如:

auto stack = IoGetCurrentIrpStackLocation(Irp)

该API返回一个IO_STACK_LOCATION 结构体:

typedef struct _IO_STACK_LOCATION {
 UCHAR                  MajorFunction;
 UCHAR                  MinorFunction;
 UCHAR                  Flags;
 UCHAR                  Control;
 union {
...
...
...
...
...
} Parameters;
 PDEVICE_OBJECT         DeviceObject;
 PFILE_OBJECT           FileObject;
 PIO_COMPLETION_ROUTINE CompletionRoutine;
 PVOID                  Context;
} IO_STACK_LOCATION, *PIO_STACK_LOCATION;

这个结构体有两个重要的属性,分别是MajorFunction和MinorFunction,分别记录了IRP的主类型和子类型,操作系统根据MajorFunction来将IRP派遣到不同的派遣函数里面去处理,而还可以继续利用MinorFunction来判断更多的内容,基本上MajorFunction用的比较多。

设置IRP和派遣函数

在DriverEntry驱动对象里面有一个函数指针数组MajorFunction就是来记录派遣函数的地址,来让派遣函数和IRP一一对应,这个数组里面采用宏定义来将每个 IRP和派遣函数通过数组的索引来一一对应:

#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

这些每一个都有特定的意思,比较常用的有:

IRP_MJ_CREATE   //创建    和CreateFile对应
IRP_MJ_READ //读取 和ReadFile对应
IRP_MJ_WRITE //写入 和WriteFile对应
IRP_MJ_CLOSE //关闭 和CloseFile对应

所有的派遣函数都有一个原型:

typedef NTSTATUS DRIVER_DISPATCH (
_In_ PDEVICE_OBJECT DeviceObject,
_Inout_ PIRP Irp);
//typedef可以省去,名字自己取

IRP传递流程

I/O系统是以设备对象为中心,而不是以驱动对象为中心的。IRP可以在设备对象中传来传去:

img

但是不管是在怎么传递,最后都必须把这个IRP请求结束,给它完成。

完成必备操作:

NTSTATUS SysMonRead(PDEVICE_OBJECT, PIRP Irp) {
Irp->IoStatus.Status = status;//设置状态
Irp->IoStatus.Information = count;//统计处理的字节数
IoCompleteRequest(Irp, IO_NO_INCREMENT);//完成IO操作的必须返回函数
return status;
}

IoCompleteRequest这个API需要返回一个IRP大家应该没问题,但是第二个参数就比较复杂了,第二个参数是返回之后的线程级,这是因为一个线程在执行IRP的时候会等待。

这里以ReadFile为例,ReadFile函数会调用ntdll中的NtReadFile函数,然后ntdll中的NtReadFile函数又调用内核的NtReadFile函数,然后内核的NtReadFile函数创建关于Read这个类型的IO_MJ_READ类型的IRP再将它发送到内核的对应IRP的派遣函数里面,然后等待IRP对应派遣函数执行完之后再返回。所以需要设置当返回后线程又重新执行了的线程级。

User操作设备对象

前面我们说了,User只能通过符号链接来操作设备对象进行I/O交互,所以User只需要在User的API下面把通常使用的文件路径改成符号链接就好了。

比如:

CreateFile(L"\\\\.\\test", GENERIC_READ, 0, nullptr, OPEN_EXISTING, 0, nullptr)

注意C语言是有转义字符的,所以这个第一个参数看起来怪怪的。

读写方式

IRP和派遣函数是用来进行I/O操作的,I/O操作就是读写,所以很自然的有了这个读写方式的环境。

一般读写方式有三种:缓冲I/O,直接I/O,和其它方式。三种方式对应的Flags分别是DO_BUFFERED_IO、DO_DIRECT_IO和0。都在DeviceObject设备对象里面添加。

例如:

DeviceObject->Flags |= 0;

注意:并不是直接赋值

缓冲I/O

读写操作通常是WriteFile和ReadFile这类API,这类API会要求添加缓冲区指针和缓冲区大小作为函数参数,然后WriteFile/ReadFile将这段内存的数据传递给驱动程序。由于这段缓冲区是用户模式的内存地址,所以直接使用会非常危险,因为Windows是多进程操作系统,有可能在你用的时候就被别的进程改了,所以直接使用非常危险。

而缓冲I/O的原理就是,在内核模式下开辟一个缓冲空间来存储该缓冲区内容,然后读取的时候先放到内核缓冲区里面,再来进行复制和赋值操作。

比如:当调用ReadFile/WriteFile时,操作系统提供内核模式下的一段地址来存放User在WriteFile里面配置的Buffer,然后当IRP请求结束,内核区域的Buffer就会被拷贝到User/Kernel里面。

1 I/O 管理器从非分页池中分配一个与用户缓冲区大小相同的缓冲区。它将指向这个新缓冲区的指针存储在 IRP 的 AssociatedIrp->SystemBuffer 成员中。 (缓冲区大小可以在当前 I/O 堆栈位置的 Parameters.Read.Length 或 Parameters.Write.Length 中找到。)

2 对于写请求,I/O 管理器将用户的缓冲区复制到系统缓冲区。

3 现在才调用驱动程序的调度例程。驱动程序可以直接使用系统缓冲区指针而无需任何检查,因为缓冲区在系统空间中(它的地址是绝对的 - 从任何进程上下文中都一样),并且在任何 IRQL 中,因为缓冲区是从非分页池分配的,所以它不能被调出。

4 一旦驱动完成IRP(IoCompleteRequest),I/O管理器(对于读请求)将系统缓冲区复制回用户的缓冲区(复制的大小由IRP中设置的IoStatus.Information字段决定)司机)。

5 最后,I/O 管理器释放系统缓冲区。

 

图文讲解一下ReadFile的整体流程:(WriteFile以此类推)

 

 

这里是User下一个Buffer缓冲区:

img

操作系统知道是缓冲I/O后开辟了一个内核缓冲区,然后由WriteFile/ReadFile创建的Irp->AssociatedIrp.SystemBuffer来存储记录

img

然后驱动访问系统空间往里面写东西

 

img

操作系统将系统空间的内容拷贝到User缓冲区里。

img

拷贝完了,释放系统空间内存。

img

 

派遣函数中可以通过Irp的Parameters.Read.Length来知道User请求多少字节,也可以通过Parameters.Write.Length来知道要写入多少字节,但是真正执行了多少是通过Irp的IoStatus.Information字段来返回,所以WriteFile/ReadFile函数中各个参数是意义就可以解释通透了。

 

但是这样的缺点是会造成内存空间开销,因为内核空间是公有的大家都得用,而且由于大量数据的复制也会影响效率,所以针对比较小的内容采用缓冲I/O可以采取这种办法,别的还是用直接I/O吧。

//缓冲I/O的例子:
NTSTATUS SysMonRead(PDEVICE_OBJECT, PIRP Irp) {
auto status = STATUS_SUCCESS;
auto stack = IoGetCurrentIrpStackLocation(Irp);//得到当前的IRP栈
auto ulReadLength = stack->Parameters.Read.Length;//得到要读取的字节数


//完成IRP操作
Irp->IoStatus.Status = status;//设置IRP完成状态
Irp->IoStatus.Information = ulReadLength;//设置实际完成的IRP字节数
memset(Irp->AssociatedIrp.SystemBuffer, 0xAA, ulReadLength);//拷贝内容到系统地址空间
IoCompleteRequest(Irp, IO_NO_INCREMENT);//完成IRP操作
return status;
}

User下的就不用写了吧,hh,偷个懒。

直接I/O

直接I/O用了另一个办法来规避风险。

1 I/O 管理器确保用户的缓冲区有效。

img

2 将其映射到物理内存中,然后将缓冲区锁定在内存中,因此在另行通知之前无法将其调出。这解决了缓冲区访问的问题之一——不会发生页面错误,因此在任何 IRQL 中访问缓冲区都是安全的。

img

3 I/O 管理器构建内存描述符列表 (MDL),这是一种知道缓冲区如何映射到 RAM 的数据结构。该数据结构的地址存储在 IRP 的 MdlAddress 字段中。

img

4 此时,驱动程序调用派遣函数。因为用户的缓冲区被锁定在 RAM 中,不能从任意线程访问。所以当驱动程序需要访问缓冲区时,它必须调用将同一用户缓冲区映射到系统地址的函数。由于该地址被锁进了系统中所以,该地址在任何进程上下文中都是有效的。所以本质上,我们得到了到同一个缓冲区的两个映射。一个来自原始地址(仅在请求者进程的上下文中有效),另一个来自系统空间,始终有效。要调用的 API 是 MmGetSystemAddressForMdlSafe,传递由 I/O 管理器构建的 MDL。返回值是系统地址。

img

5 User或Kernel进行缓冲区修改:

img

 

6结束: 一旦驱动程序完成请求,I/O 管理器删除第二个映射(到系统空间),释放 MDL 并解锁用户缓冲区,因此它可以像任何其他用户模式内存一样正常分页。

img

 

这里全程没有用到拷贝,完完全全就是通过地址映射User->内存,然后Kernel修改内存等同于直接修改User的缓冲区。

直接I/O在用户态下:

直接I/O操作系统会在User下用MDL这个数据结构来存储相关信息:

 

 

大小保存在MDL->ByteCount里,然后这段虚拟内存的首地址在MDL->StartVa里,实际缓冲区的首地址相当于起始地址的偏移地址在ByteOffset里,DDK里面封装了几个宏来方便用

#define MmGetMdlByteCount(Mdl) ((Mdl)->MyteCount)
#define MmGetMdlByteOffset(Mdl) ((Mdl)->ByteOffset)
#define MmGetMdlVirtualAddress(Mdl) (PVOID)((PCHAR)(Mdl)->StartVa)+(Mdl)->ByteOffset)

直接I/O在Kernel下

Kernel下比较简单粗暴直接用了一个宏来得到MDL在内核模式下的地址映射

MmGetSystemAddressForMdlSafe();

然后通过前面缓冲I/O用到的一些Read的length或者Write的Length直接通过这个地址拷贝就行了。

直接I/O例子

 

NTSTATUS TestRead(IN PDEVICE_OBJECT pDevObj,
IN PIRP pIrp)
{
KdPrint(("Enter HelloDDKRead\n"));

PDEVICE_EXTENSION pDevExt = (PDEVICE_EXTENSION)pDevObj->DeviceExtension;
NTSTATUS status = STATUS_SUCCESS;

PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(pIrp);

ULONG ulReadLength = stack->Parameters.Read.Length;
KdPrint(("ulReadLength:%d\n",ulReadLength));

ULONG mdl_length = MmGetMdlByteCount(pIrp->MdlAddress);
PVOID mdl_address = MmGetMdlVirtualAddress(pIrp->MdlAddress);
ULONG mdl_offset = MmGetMdlByteOffset(pIrp->MdlAddress);

KdPrint(("mdl_address:0X%08X\n",mdl_address));
KdPrint(("mdl_length:%d\n",mdl_length));
KdPrint(("mdl_offset:%d\n",mdl_offset));

if (mdl_length!=ulReadLength)
{
pIrp->IoStatus.Information = 0;
status = STATUS_UNSUCCESSFUL;
}else
{
//ÓÃMmGetSystemAddressForMdlSafe
PVOID kernel_address = MmGetSystemAddressForMdlSafe(pIrp->MdlAddress,NormalPagePriority);
KdPrint(("kernel_address:0X%08X\n",kernel_address));
memset(kernel_address,0XAA,ulReadLength);
pIrp->IoStatus.Information = ulReadLength; // bytes xfered
}

pIrp->IoStatus.Status = status;

IoCompleteRequest( pIrp, IO_NO_INCREMENT );
KdPrint(("Leave TestRead\n"));

return status;
}

其它

其它的I/O读写方式用得非常少,而且很麻烦,这里就不介绍了。

 

IO设备控制操作

除了常用的ReadFile,WriteFile,CreateFile,CloseFile这类操作外,还可以通过另一个API DeviceIoControl来操作设备。DeviceIoControl会创建一个IRP_MJ_DEVICE_CONTROL类型的IRP,别的和其它的IRP以及派遣函数是一样的。

DeviceIoControl和驱动交互

DeviceIoControl除了可以被用来读写还可以用在其它操作上。

BOOL DeviceIoControl(
 HANDLE       hDevice, //设备对象句柄
 DWORD        dwIoControlCode, //控制码
 LPVOID       lpInBuffer, //输入缓冲区
 DWORD        nInBufferSize, //输入缓冲区大小
 LPVOID       lpOutBuffer, //输出缓冲区
 DWORD        nOutBufferSize, //输出缓冲区大小
 LPDWORD      lpBytesReturned, //实际返回字节数 这个对应这Irp->IoStatus.Information
 LPOVERLAPPED lpOverlapped //是否OVERLAP操作
);

dwIoControlCode是I/O控制码,也叫IOCTL值。

Windows有一些内置的I/O控制码可以选用:(在官方文档上可以获取:DeviceIoControl function (ioapiset.h) - Win32 apps | Microsoft Docs

 

同样的我们也可以自己定义,Windows提供了控制码的定义规定:

 

 

 

在ddk头文件里面有一个宏定义方便我们使用:

#define CTL_CODE( DeviceType, Function, Method, Access ) (((DeviceType) << 16) | ((Access) << 14) | ((Function) << 2) | (Method))

DeviceType(31-16):设备对象的类型,这个和IoCreateDevice时设置的设备对象类型一样。

Access(15-14):访问权限,如果没有特殊要求,一般采用FILE_ANY_ACCESS

Funciton:0X000~0X7FF由微软保留,0x800~0xFFF由程序员自己定义

Method:操作模式,以下四种之一:

1 METHOD_BUFFERED: 使用缓冲区方式操作

2 METHOD_IN_DIRECT: 使用直接写方式操作

3 METHOD_OUT_DIRECT: 使用直接读方式操作

4 METHOD_NEITHER: 使用其它方式操作

 

缓冲内存模式IOCTL

DeviceIoControl的缓冲读取和前面的缓冲I/O有一点不一样,前面的流程都一样,都是复制到系统进程的缓冲区里面,然后这个缓冲区地址可以由Irp->AssociatedIrp.SystemBuffer来获取。不一样的是DeviceIoControl会传入输入和输出两个缓冲区,但是两个缓冲区对应的是一个地址,因为如果是输入就是输出到Kernel里,Kernel可以进行操作后,再把这个缓冲区修改了然后作为输出,输出到User里。

首先定义一个自己的IOCTL码

#define IOCTL_TEST CTL_CODE(FILE_DEVICE_UNKNOWN,0X800,METHOD_BUFFERED,FILE_ANY_ACCESS)

在内核状态下要使用IOCTL需要添加ntddk头文件,在User下需要添加winioctl.h头文件

NTSTATUS DeviceIoControl(PDEVICE_OBJECT pDevObj, PIRP pIrp)
{
auto stack = IoGetCurrentIrpStackLocation(pIrp);

auto status = STATUS_SUCCESS;

//得到输入缓冲区大小
auto cbIn = stack->Parameters.DeviceIoControl.InputBufferLength;

//得到输出缓冲区大小
auto cbOut = stack->Parameters.DeviceIoControl.OutputBufferLength;

//得到IOCTL控制码
auto code = stack->Parameters.DeviceIoControl.IoControlCode;
ULONG info = 0;
switch (code)
{
case IOCTL_TEST1:
{
//处理输入给内核的缓冲区内容
UCHAR* InputBuffer = (UCHAR*)pIrp->AssociatedIrp.SystemBuffer;
for (ULONG i = 0; i < cbIn; i++)
{
KdPrint(("%x\n", InputBuffer[i]));
}

//处理输出给User的缓冲区内容
UCHAR* OutputBuffer = (UCHAR*)pIrp->AssociatedIrp.SystemBuffer;
memset(OutputBuffer, 0xAA, cbOut);
info = cbOut;
break;
}
default:
{
status = STATUS_INVALID_VARIANT;
break;
}

}

pIrp->IoStatus.Information = info;
pIrp->IoStatus.Status = status;

IoCompleteRequest(pIrp, IO_NO_INCREMENT);

}

 

直接内存模式IOCTL

直接内存模式IOCTL也和直接I/O有稍许区别,直接IOCTL中的输入缓冲区就是前面的缓冲内存模式下的缓冲区,直接开辟一个系统缓冲区,但是输出缓冲区就是前面的直接I/O,通过地址映射来得到的地址。所以直接内存模式的IOCTL需要分两种来处理,一种是输入缓冲区当缓冲I/O处理,另一种是输出缓冲区当直接I/O来处理。

这里需要说明一下直接模式的

METHOD_IN_DIRECT: 使用直接写方式操作

METHOD_OUT_DIRECT: 使用直接读方式操作

这两种方式的区别,在调用DeviceIoControl的时候是会指定打开的模式是只读还是只写,还是可读可写,就对应着这两种,如果是只读,那么只有METHOD_IN_DIRECT编写的IOCTL控制代码才会识别才会有用,以此类推。

直接内存模式IOCTL的例子:

1 先创建IOCTL

#define IOCTL_TEST1 CTL_CODE(FILE_DEVICE_UNKNOWN,0X801,METHOD_IN_DIRECT,FILE_ANY_ACCESS)

2 编写对应IOCTL的派遣函数

NTSTATUS DeviceIoControl(PDEVICE_OBJECT pDevObj, PIRP pIrp)
{
auto stack = IoGetCurrentIrpStackLocation(pIrp);

auto status = STATUS_SUCCESS;

//得到输入缓冲区大小
auto cbIn = stack->Parameters.DeviceIoControl.InputBufferLength;

//得到输出缓冲区大小
auto cbOut = stack->Parameters.DeviceIoControl.OutputBufferLength;

//得到IOCTL控制码
auto code = stack->Parameters.DeviceIoControl.IoControlCode;
ULONG info = 0;
switch (code)
{
case IOCTL_TEST1:
{
//处理输入给内核的缓冲区内容
UCHAR* InputBuffer = (UCHAR*)pIrp->AssociatedIrp.SystemBuffer;
for (ULONG i = 0; i < cbIn; i++)
{
KdPrint(("%x\n", InputBuffer[i]));
}

//处理输出给User的缓冲区内容
UCHAR* OutputBuffer = (UCHAR*)pIrp->AssociatedIrp.SystemBuffer;
memset(OutputBuffer, 0xAA, cbOut);
info = cbOut;
break;
}
case IOCTL_TEST2:
{
//当是IOCTL_TEST2的IOCTL时的代码逻辑
auto InputBuffer = (UCHAR*)pIrp->AssociatedIrp.SystemBuffer;
for (int i = 0; i < cbIn; i++)
{
KdPrint(("%X\n", InputBuffer[i]));
}

auto OutputBuffer = (UCHAR*)MmGetSystemAddressForMdlSafe(pIrp->MdlAddress, NormalPagePriority);
memset(OutputBuffer, 0xAA, cbOut);
info = cbOut;
break;
}
default:
{
status = STATUS_INVALID_VARIANT;
break;
}

}

pIrp->IoStatus.Information = info;
pIrp->IoStatus.Status = status;

IoCompleteRequest(pIrp, IO_NO_INCREMENT);

}

 

其它内存模式IOCTL

其它模式用的很少,而且很麻烦,这里也不介绍了。

 

 

总结

不管是DeviceIoControl还是ReadFile,WriteFile其实都是用作User和Kernel交互的API,其中的IRP是自带的数据结构体,里面保存了要交互的东西的信息。DeviceIoControl更像输入东西给Kernel让Kernel通过根据输入的内容和控制码来执行命令的一个东西,通过I/O来控制Kernel执行一些代码流程;而ReadFille/WriteFile可能用得更多的是在单纯的交互数据上面。