IRP和派遣函数
什么是派遣函数?
派遣函数是 WIndows 驱动程序中的重要概念。驱动程序的主要功能是负责处理I/O请求,其中大部分I/O请求是在派遣函数中处理的。也就是说,派遣函数是用来处理驱动程序提交过来的 I/O 请求。
那什么是 I/O 请求呢?
上层程序与驱动程序之间通信时,上层会发出I/O请求,即输入输出请求包(I/O Request package)
用户模式程序与所有驱动程序之间的I/O请求,全部由操作系统转化为一个叫 IRP 的数据结构,不同的 IRP 会被派遣到不同的派遣函数(Dispatch Function)中。
什么是 IRP?
在 WIndows 内核中,有一种数据结构叫 IRP(I/O Request Package)。它是与输入输出相关的重要数据结构。上层应用程序与底层驱动程序通信时(也就是 .exe 与 .sys 之间的通信)应用程序会发出I/O请求。操作系统将I/O请求转化为相应的 IRP 数据结构,不同类型的 IRP 会根据不同的类型传递到不同的派遣函数内。
IRP 的两个基本属性 MajorFunction 和 MinorFunction,分别记录 IRP 的主类型和子类型。操作系统根据 MajorFunction 将 IRP 派遣到不同的派遣函数中,在派遣函数中还可以继续判断这个 IRP 属于那种 MinorFunction。
在进入 DriverEntry (驱动入口函数)之前,操作系统会将_IoPInvalidDeviceRequest
(IoP无效的设备请求)的地址填满整个 MajorFunction
数组。
所以,在用到派遣函数是,必须在驱动程序的入口函数 DriverEntry 函数过程中注册派遣函数的操作类型。
如:
#define PAGEDCODE cod_seg("PAGE") //注意,是PAGEDCODE cod_seg ,名称写错了会蓝屏
#pragma PAGEDCODE
VOID Unload(PDRIVER_OBJECT pDriverObject);
extern "C" NTSTATUS DriverEntry(PDRIVER_OBJECT pDriverObject, PUNICODE_STRING pUnicodeString)
{
///////////////////////////// 注册派遣函数和类型 ////////////////////////////
pDriverObject->MajorFunction[IRP_MJ_CREATE] = ddk_DispatchFunction;
pDriverObject->MajorFunction[IRP_MJ_READ] = ddk_DispatchFunction;
pDriverObject->MajorFunction[IRP_MJ_WRITE] = ddk_DispatchFunction;
pDriverObject->MajorFunction[IRP_MJ_CLOSE] = ddk_DispatchFunction;
pDriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = ddk_DispatchFunction;
///////////////////////////// 注册派遣函数和类型 ////////////////////////////
return STATUS_SUCCESS;
}
1 IRP 与 派遣函数的联系
IRP 的处理机制类似 WIndows 应用程序中的“消息处理”机制,驱动程序接收到不同类型的 IRP 后,会进入到不同的派遣函数,在派遣函数中, IRP 会得到处理。
1.1 IRP类型
文件 I/O 的相关函数,如 CreateFile、ReadFile、WriteFile、CloseHandle 等函数会使操作系统产生出
IRP_MJ_CREATE 0x00 //CreateFile;
IRP_MJ_CLOSE 0x02 //CloseHandle;
IRP_MJ_READ 0x03 //ReadFile;
IRP_MJ_WRITE 0x04 //WriteFile;
IRP_MJ_DEVICE_CONTROL 0x0e //DeviceIoControl
等不同的 IRP,这些 IRP 会被传送到驱动程序的派遣函数中。还有些 IRP 是由系统的某个组件创建的,比如 IRP_MJ_SHUTDOWN
是在 Windows 的即插即用组件在即将关闭系统的时候发出的。
1.2 对派遣函数的简单处理
大部分的 IRP 都源于文件的I/O处理Win32 API,如 CreateFile、ReadFile 等。处理这些IRP 最简单的方法就是在相应的派遣函数中,将 IRP 的状态设置为成功,然后结束 IRP 的请求,并让派遣函数返回成功。结束 IRP 的请求使用IoCompleteRequest。
VOID IoCompleteRequest(
IN PIRP Irp, //需要被结束的IRP
IN CCHAR PriorityBoost //代表线程恢复的优先级别
);
为了解释优先级的概念,需要了解一下与文件 I/O
相关的 WIn32 API
的内部操作过程。这里以 ReadFile
为例,ReadFile
的内部操作大体是这样的:
-
ReadFile 调用 ntdll 中的 NtReadFile。其中的 ReadFile 函数的 Win32 API,而NtReadFile 函数是 Native API
-
ntdll中的NtReadFile进入内核模式,并调用系统服务中的NtReadFile函数。
-
系统服务函数 NtReadFile 创建
IRP_MJ_WRITE
类型的 IRP,然后它将这个IRP发送到某个驱动程序的派遣函数中。NtReadFile
然后去等待一个事件,这时当前线程进入“睡眠”状态,也可以说当前线程被阻塞住或者线程处于“Pending”状态。 -
在派遣函数中一般会将 IRP 请求结束,结束 IRP 是通过
IoCompleteRequest
函数。该函数内部会设置刚才等待的事件,“睡眠”的线程被恢复运行。
在读一个很大的文件或者设备时,ReadFile 不会立刻返回,而是等待一段时间。这段时间就是当前线程“睡眠”的那段时间。IRP 请求被结束,标志这个操作系统完毕,这时“睡眠”的线程被唤醒。
IoCompleteRequest
函数的 PriorityBoost
参数代表一种优先级,指的是被阻塞的线程以何种优先级恢复运行。一般情况下,优先级设置为 IO_NO_INCREMENT
(INCREMENT 增量)。对某些特殊情况,需要将阻塞的线程以“优先”的身份恢复运行。
1.3 通过设备连接打开设备
要打开设备,必须通过设备的名字才能得到该设备的句柄。设备名无法被用户模式下的应用程序查询到,设备名只能被内核模式下的程序查询到。在应用程序中,设备可以通过符号链接进行访问,驱动程序通过 IoCreateSymblicLink
函数创建符号链接。
1.4 编写更通用的派遣函数
IRP会被操作系统发送到设备栈的顶层,如果顶层的设备对象的派遣函数结束了 IRP 的请求,则这次 IO 请求结束,如果没有将 IRP 的请求结束,那么操作系统将 IRP 转发到设备栈的下一层设备处理。如果这个设备的派遣函数依然不能结束该 IRP 请求,则继续向下层设备进行转发。
因此,一个 IRP 可能会被转发多次。为了记录 IRP 在每层设备中做的操作,IRP 会有一个IO_STACK_LOCATION(堆栈、位置)数组。数组的元素数应该大于 IRP 穿越过的设备数。每个IO_STACK_LOCATION 元素记录着对应设备中做的操作。对于本层设备对应的IO_STACK_LOCATION,可以通过 IoGetCurrentIrpStackLocation (获取当前的IRP在堆栈的位置)函数得到。IO_STACK_LOCATION 结构中会记录 IRP 的类型,即IO_STACK_LOCATION中的 MajorFunction 子域。
2 缓冲区方式读写操作
驱动程序所创建的设备一般会有三种读写方式,一种是缓冲区方式,一种是直接方式,一种是其他方式。以下介绍缓冲区读写:
2.1 缓冲区设备
在驱动程序创建设备对象的时候,需要考虑好该设备是采用何种读写方式。当 IoCreateDevice 创建完设备后,需要对设备对象的 Flags (标志)子域进行设置。设置不同的Flags 会导致以不同的方式操作设备。Flags 的三个不同的值分别为:DO_BUFFERED_IO(缓冲)、DO_DIRECT_IO(直接)和 0。
读写操作一般是由 ReadFile 或者 WriteFile 函数引起的,这里先以 WriteFile 函数为例进行介绍:WriteFile 要求用户提供一段缓冲区,并且声明缓冲区的大小,然后WriteFile 将这段内存的数据传入到驱动程序中。
这段缓冲区内存是用户模式的内存地址,驱动程序如果直接饮用这段内存是十分危险的,因为 Windows 操作系统是多任务的,它可能随时切换到别的进程。如果驱动程序需要访问这段内存,而这时操作系统可能已经切换到另外一个进程。如果这样,驱动程序访问的内存地址必定是错误的,这种错误会引起系统崩溃。
其中一个解决方法是操作系统将应用程序提供缓冲区的数据复制到内核模式下的地址中。这样,无论操作系统如何切换进程,内核模式地址都不会改变。IRP 的派遣函数将会对内核模式下的缓冲区操作,而不是用户模式地址的缓冲区。但是这样需要在用户模式和内核模式之间复制数据,影响了运行效率。
2.2 缓冲区设备读写
以“缓冲区”方式写设备时,操作系统将 WriteFile 提供的用户模式的缓冲区复制到内核模式地址下。这个地址由 WriteFile 创建的 IRP 的 AssociatedIrp.SystemBuffer (联合IRP.系统缓冲区)子域记录。
以“缓冲区”方式读设备时,操作系统会分配一段内核模式下的内存。这段内存大小等于ReadFile 或者 WriteFile 指定的字节数,并且 ReadFile 或者 WriteFile 创建的 IRP 的AssociatedIrp.SystemBuffer 子域会记录这段内存地址。当 IRP 请求结束时,这段内存地址会被复制到 ReadFile 提供的缓冲区中。
以缓冲区方式无论是读还是写设备,都会发生用户模式地址与内核模式地址的数据复制。复制的过程由操作系统负责。用户模式地址由 ReadFile 或者 WriteFile 提供,内核模式地址由操作系统负责分配和回收。由 IO_STACK_LOCATION 中的 Parameters.Read.Length 子域知道ReadFile 请求多少字节,由 IO_STACK_LOCATION 中的 Parameters.Write.Length 子域知道 WriteFile 请求多少字节。
然而,WriteFile 和 ReadFile 指定对设备操作多少字节并不真正意味着操作了这么多的字节。在派遣函数中,应该设置 IRP 的子域 IoStatus.Information (Io返回值.信息)这个子域记录设备实际操作了多少字节。
NTSTATUS HelloDDKRead(IN PDEVICE_OBJECT pDevObj, IN PIRP pIrp)
{
NTSTATUS status = STATUS_SUCCESS;
//得到当前堆栈
PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(pIrp);
//得到需要读设备的字节数
ULONG ulReadLength = stack->Parameters.Read.Length;
//完成IRP
pIrp->IoStatus.Status = status;
//设置IRP操作了多少字节
pIrp->IoStatus.Information = ulReadLength;
//设置内核模式下的缓冲区
memset(pIrp->AssociatedIrp.SystemBuffer,0xAA,ulReadLength);
//处理IRP
IoCompleteRequest(pIrp,IO_NO_INCREMENT);
return status;
}
应用程序使用ReadFile对设备进行读写:
#include<windows.h>
#include<stdio.h>
int main()
{
//打开设备句柄
HANDLE hDevice = CreateFile("\\\\.\\HelloDDK",GENERIC_READ|GENERIC_WRITE,0,NULL,OPEN_EXISTING,FILE_ATTRIBUTE_NORMAL.NULL);
//判断打开是否成功
if(hDevice == INVALID_HANDLE_VALUE)
{
//打开失败
return 1;
}
UCHAR buffer[10];
ULONG ulRead;
//对设备读写
BOOL bRet = ReadFile(hDevice,buffer,10,&ulRead,NULL);
if(bRet)
{
printf("Read %d bytes:",ulRead);
for(int i=0;i<(int)ulRead;i++)
{
printf("%02X ",buffer[i]);
}
}
//关闭设备句柄
CloseHandle(hDevice);
return 0;
}
2.3 缓冲区设备模拟文件读写
写操作。在应用程序中,通过WriteFile函数对设备进行写操作。
//应用程序的写操作。
UCHAR buffer[10];
memset(buffer,0xBB,10);
ULONG ulRead;
ULONG ulWrite;
BOOL bRet;
//对设备写操作
bRet = WriteFile(hDevice,buffer,10,&ulWrite,NULL);
if(bRet)
{
//成功将ulWrite写入设备
}
WriteFile内部会创建IRP_MJ_WRITE类型的IRP,操作系统会将这个IRP传递给驱动程序。IRP_MJ_WRITE的派遣函数需要将传送进来的数据保存起来,以便读取该设备的时候读取。在本例中这个数据存储在一个缓冲区中,缓冲区的地址记录在设备扩展中。在设备启动的时候,驱动程序负责分配这个缓冲区,在设备被卸载的时候,驱动程序回收该缓冲区。
对于IRP_MJ_WRITE的派遣函数,主要任务是将写入的数据存储在这段缓冲区中。如果写入的字节数过大,超过缓冲区的大小,派遣函数将IRP的状态设置成错误状态。另外,在设备扩展中有一个变量记录着这个虚拟文件设备的文件长度。对设备的写操作会更改这个变量。
//驱动程序中的写操作
NTSTATUS HelloDDKWrite(IN PDEVICE_OBJECT pDevObj,IN PIRP pIrp)
{
NTSTATUS status = STATUS_SUCCESS;
PDEVICE_EXTENSION pDevExt = (PDEVICE_EXTENSION)pDevObj->DeviceExtension;
PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(pIrp);
//获取存储的长度
ULONG ulWriteLength = stack->Parameters.Write.Length;
//获取存储的偏移量
ULONG ulWriteOffset = (ULONG)stack->Parameters.Write.ByteOffset.QuadPart;
if(ulWriteOffset+ulWriteLength>MAX_FILE_LENGTH)
{
//如果存储长度+偏移量大于缓冲区长度,则返回无效
status = STATUS_FILE_INVALID;
ulWriteLength = 0;
}
else
{
//将写入的数据,存储在缓冲区内
memcpy(pDevExt->buffer+ulWriteOffset,pIrp->AssociatedIrp.SystemBuffer,ulWriteLength);
status = STATUS_SUCCESS;
//设置新的文件长度
if(ulWriteLength+ulWriteOffset>pDevExt->file_length)
{
pDevExt->file_length = ulWriteLength+ulWriteOffset;
}
}
pIrp->IoStatus.Status = status;//设置IRP的完成状态
pIrp->IoStatus.Information = ulWriteLength;//实际操作多少字节
IoCompleteRequest(pIrp,IO_NO_INCREMENT);//结束IRP请求
return status;
}
读操作。在应用程序中通过ReadFile来从设备读取数据:
bRet = ReadFile(hDevice,buffer,10,&ulRead,NULL);//从设备读取10个字节
if(bRet)
{
//显示读取的数据
for(int i=0;i<(int)ulRead;i++)
{
printf("%02X ",buffer[i]);
}
}
ReadFile内部会创建IRP_MJ_READ类型的IRP,操作系统会将这个IRP传递给驱动程序中IRP_MJ_READ的派遣函数。IRP_MJ_READ的派遣函数的主要任务是把记录的数据复制到AssociatedIrp.SystemBuffer中。
NTSTATUS HelloDDKRead(IN PDEVICE_OBJECT pDevObj,IN PIRP pIrp)
{
PDEVICE_EXTENSION pDevExt = (PDEVICE_EXTENSION)pDevObj->DeviceExtension;
NTSTATUS status = STATUS_SUCCESS;
PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(pIrp);
ULONG ulReadLength = stack->Parameters.Read.Length;
ULONG ulReadOffset = (ULONG)stack->Parameters.Read.ByteOffset.QuadPart;
if(ulReadOffset+ulReadLength>MAX_FILE_LENGTH)
{
status = STATUS_FILE_INVALID;
ulReadLength = 0;
}
else
{
//将数据存储在AssociatedIrp.SystemBuffer,以便应用程序使用
memcpy(pIrp->AssociatedIrp.SystemBuffer,pDevExt->buffer+ulReadOffset,ulReadLength);
status = STATUS_SUCCESS;
}
//设置IRP完成状态
pIrp->IoStatus.Status = status;
//设置IRP操作字节数
pIrp->IoStatus.Information = ulReadLength;
//结束IRP
IoCompleteRequest(pIrp,IO_NO_INCREMENT);
return status;
}
读取文件长度。读取文件长度依靠GetFileSize Win32 API获得。GetFileSize内部会创建IRP_MJ_QUERY_INFORMATION类型的IRP。这个IRP请求的作用是向设备查询一些信息,这包括查询文件长度、设备创建的时间、设备属性等。在本例中,IRP_MJ_QUERY_INFORMATION派遣函数的主要任务是告诉应用程序这个设备的长度。
3 直接方式读写操作
3.1 直接读取设备
和缓冲区方式读写设备不同,直接方式读写设备,操作系统会将用户模式下的缓冲区锁住,然后操作操作系统将这段缓冲区在内核模式地址再映射一遍。这样,用户模式的缓冲区和内核模式的缓冲区指向的是同一区域的物理内存。无论操作系统如何切换进程,内核模式地址都保持不变。
操作系统先将用户模式的地址锁定后,操作系统用内存描述表(MDL数据结构)记录这段内存。用户模式的这段缓冲区在虚拟内存上是连续的,但是物理内存可能是离散的。
MDL记录这段虚拟内存,这段虚拟内存的大小存储在mdl->ByteCount里,这段虚拟内存的第一个页地址mdl->StartVa,这段虚拟内存的首地址对于第一个页地址偏移量是mdl->ByteOffset。因此,这段虚拟内存的首地址应该是mdl->StartVa+mdl->ByteOffset。
3.2、直接读取设备的读写
应用程序调用Win32 API ReadFile,操作系统将IRP_MJ_READ转发到相应的派遣函数中。派遣函数通过读取IO堆栈的stack->Parameters.Read.Length来获取这次读取的长度。这个长度就是ReadFile函数的第三个参数nNumberOfByteToRead
派遣函数盛泽pIrp->IoStatus.Information告诉ReadFile实际读取了多少字节,这个数字对应着ReadFile的第四个参数
BOOL ReadFile(
HANDLE hFile,//文件句柄
LPVOCE lpBuffer,//缓冲区
DWORD nNumberOfBytesToRead,//希望读的字节数
LPDWORD lpNumberOfBytesRead,//实际读的字节数
LPOVERLAPPED lpOverlapped//overlap数据结构地址
);
4 其他方式读写操作
在使用其他方式读写设备时,派遣函数直接读写应用程序提供的缓冲区地址。只有驱动程序与应用程序运行在相同线程上下文的情况下,才能使用这种方式。
用其他方式读写时,ReadFile或者WriteFile提供的缓冲区内存地址,可以在派遣函数中通过IRP的pIrp->UserBuffer字段得到。读取的字节数可以从I/O堆栈中的stack->Parameters.Read.Length字段中得到。使用用户模式的内存时要格外小心,因为ReadFile有可能把空指针地址或者非法地址传递给驱动程序。因此,驱动程序使用用户模式地址前,需要探测这段内存是否可读或者可写,探测时应使用ProbeForWrite函数和try块。
5 IO设备控制操作
除了用ReadFile和WriteFile以外,应用程序还可以通过另外一个Win32 API DeviceIoControl操作设备。DeviceIoControl内部会使操作系统创建一个IRP_MJ_DEVICE_CONTROL类型的IRP,然后操作系统会将这个IRP转发到派遣函数中。
程序员可以用DeviceIoControl定义除读写之外的其他操作,它可以让应用程序和驱动程序进行通信。例如,要对一个设备进行初始化操作,程序员自定义一种I/O控制码,然后用DeviceIoControl将这个控制码和请求一起传递给驱动程序。在派遣函数中,分别对不同的I/O控制码进行处理。
5.1 DeviceIoControl与驱动交互
BOOL DeviceIoControl(
HANDLE hDevice,//已经打开的设备
DWORD dwIoControlCode,//控制码
LPVOID lpInBuffer,//输入缓冲区
DWORD nInBufferSize,//输入缓冲区大小
LPVOID lpOutBuffer,//输出缓冲区
DWORD nOutBufferSize,//输出缓冲区大小
LPDWORD lpBytesReturned,//实际返回字节数
LPOVERLAPPED lpOverlapped//是否OVERLAP操作
);
DeviceIoControl的第二个参数是I/O控制码,控制码也称IOCTL值,是一个32位的无符号整型。IOCTL需要符合DDK的规定:
DDK特意提供了一个宏CTL_CODE:
CTL_CODE(DeviceType,Function,Method,Access)
DeviceType:设备对象的类型,这个类型应和创建设备IoCreateDevice时的类型相匹配。一般形如FILE_DEVICE_XX的宏
Function:这是驱动程序定义的IOCTL码。其中
-
0X0000到0X7FFF:为微软保留
-
0X800到0XFFF:由程序员自己定义
Method:这个是操作模式,可以是下列四种模式之一:
-
METHOD_BUFFERED:使用缓冲区方式操作。
-
METHOD_IN_DIRECT:使用直接方式操作。
-
METHOD_OUT_DIRECT:使用直接方式操作。只读方式打开将会失败
-
METHOD_NEITHER:使用其他方式操作。
5.2 缓冲内存模式IOCTL
在CTL_CODE
宏定义这种IOCTL
时,应该制定Method参数为METHOD_BUFFERED
。前面曾经多次提到,在驱动中最好不要直接访问用户模式下的内存地址,缓冲区方式可以避免程序员访问内存模式下的内存地址。
派遣函数先通过IoGetCurrentIrpStackLocation
函数得到当前I/O堆栈(IO_STACK_LOCATION
)。派遣函数通过stack->Parameters.DeviceIoControl.OutputBufferLength
得到输出缓冲区大小。最后通过stack->Parameters.DeviceIoControl.IoControlCode
得到IOCTL。在派遣函数中通过C语言中的switch语句分别处理不同的IOCTL。
5.3 直接内存模式IOCTL
在用CTL_CODE
宏定义这种IOCTL
时,应该指定Method
参数为METHOD_OUT_DIRECT
或者METHOD_IN_DIRECT
。直接模式的IOCTL
同样可以避免驱动程序访问用户模式的内存地址。
在调用DeviceIoControl
时,输入缓冲区的内容被复制到IRP中的pIrp->AssociatedIrp.SystemBuffer
内存地址,复制的字节数按照DeviceIoControl指定输入的字节数。但对于DeviceIoControl
指定的输出缓冲区的处理,直接模式的IOCTL
和缓冲区模式的IOCTL
却是以不同方式处理的。操作系统会将DeviceIoControl制定的输出缓冲区锁定,然后在内核模式地址下重新映射一段地址。
派遣函数中的IRP结构中的pIrp->MdlAddress
记录DeviceIoControl
指定的输出缓冲区。派遣函数应该使用MmGetSystemAddressForMdlSafe
将这段内存映射到内核模式下的内存地址。
- 原文作者:Binean
- 原文链接:https://bzhou830.github.io/post/20191220IRP%E6%B4%BE%E9%81%A3%E5%87%BD%E6%95%B0/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。