IRP以及派遣函数是Windows中非常重要的概念。IRP 是I/O Request Pocket的简称,意思是I/O操作的请求包,Windows中所有User和Kernel之间的交流都会被封装成一个IRP结构体,然后不同的IRP会被派遣到不同的派遣函数里面,通过派遣函数来实现I/O操作。
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栈,有一个栈是给内核驱动用的:
驱动需要调用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用的比较多。
在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可以省去,名字自己取
I/O系统是以设备对象为中心,而不是以驱动对象为中心的。IRP可以在设备对象中传来传去:
但是不管是在怎么传递,最后都必须把这个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只能通过符号链接来操作设备对象进行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;
注意:并不是直接赋值
读写操作通常是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缓冲区:
操作系统知道是缓冲I/O后开辟了一个内核缓冲区,然后由WriteFile/ReadFile创建的Irp->AssociatedIrp.SystemBuffer来存储记录
然后驱动访问系统空间往里面写东西
操作系统将系统空间的内容拷贝到User缓冲区里。
拷贝完了,释放系统空间内存。
派遣函数中可以通过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用了另一个办法来规避风险。
1 I/O 管理器确保用户的缓冲区有效。
2 将其映射到物理内存中,然后将缓冲区锁定在内存中,因此在另行通知之前无法将其调出。这解决了缓冲区访问的问题之一——不会发生页面错误,因此在任何 IRQL 中访问缓冲区都是安全的。
3 I/O 管理器构建内存描述符列表 (MDL),这是一种知道缓冲区如何映射到 RAM 的数据结构。该数据结构的地址存储在 IRP 的 MdlAddress 字段中。
4 此时,驱动程序调用派遣函数。因为用户的缓冲区被锁定在 RAM 中,不能从任意线程访问。所以当驱动程序需要访问缓冲区时,它必须调用将同一用户缓冲区映射到系统地址的函数。由于该地址被锁进了系统中所以,该地址在任何进程上下文中都是有效的。所以本质上,我们得到了到同一个缓冲区的两个映射。一个来自原始地址(仅在请求者进程的上下文中有效),另一个来自系统空间,始终有效。要调用的 API 是 MmGetSystemAddressForMdlSafe,传递由 I/O 管理器构建的 MDL。返回值是系统地址。
5 User或Kernel进行缓冲区修改:
6结束: 一旦驱动程序完成请求,I/O 管理器删除第二个映射(到系统空间),释放 MDL 并解锁用户缓冲区,因此它可以像任何其他用户模式内存一样正常分页。
这里全程没有用到拷贝,完完全全就是通过地址映射User->内存,然后Kernel修改内存等同于直接修改User的缓冲区。
直接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)
Kernel下比较简单粗暴直接用了一个宏来得到MDL在内核模式下的地址映射
MmGetSystemAddressForMdlSafe();
然后通过前面缓冲I/O用到的一些Read的length或者Write的Length直接通过这个地址拷贝就行了。
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读写方式用得非常少,而且很麻烦,这里就不介绍了。
除了常用的ReadFile,WriteFile,CreateFile,CloseFile这类操作外,还可以通过另一个API DeviceIoControl来操作设备。DeviceIoControl会创建一个IRP_MJ_DEVICE_CONTROL类型的IRP,别的和其它的IRP以及派遣函数是一样的。
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: 使用其它方式操作
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也和直接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); }
其它模式用的很少,而且很麻烦,这里也不介绍了。
不管是DeviceIoControl还是ReadFile,WriteFile其实都是用作User和Kernel交互的API,其中的IRP是自带的数据结构体,里面保存了要交互的东西的信息。DeviceIoControl更像输入东西给Kernel让Kernel通过根据输入的内容和控制码来执行命令的一个东西,通过I/O来控制Kernel执行一些代码流程;而ReadFille/WriteFile可能用得更多的是在单纯的交互数据上面。