派遣函数
派遣函数是WIndows驱动程序中的重要概念。驱动程序的主要功能是负责处理I/O请求,其中大部分I/O请求是在派遣函数中处理的。
用户模式下所有驱动程序的I/O请求,全部由操作系统转化为一个叫IRP的数据结构,不同的IRP会被派遣到不同的派遣函数(Dispatch Function)中。
1、IRP与派遣函数
IRP的处理机制类似WIndows应用程序中的“消息处理”机制,驱动程序接收到不同类型的IRP后,会进入到不同的派遣函数,在派遣函数中IRP得到处理。
1.1、IRP
在WIndows内核中,有一种数据结构叫IRP(I/O Request Package)。它是与输入输出相关的重要数据结构。上层应用程序与底层驱动程序通信时,应用程序会发出I/O请求。操作系统将I/O请求转化为相应的IRP数据结构,不同类型的IRP会根据类型传递到不同的派遣函数内。
IRP的两个基本属性MajorFunction和MinorFunction,分别记录IRP的主类型和子类型。操作系统根据MajorFunction将IRP派遣到不同的派遣函数中,在派遣函数中还可以继续判断这个IRP属于那种MinorFunction。
在进入DriverEntry之前,操作系统会将_IoPInvalidDeviceRequest的地址填满整个MajorFunction数组。IRP与派遣函数的联系如下:
1.2、IRP类型
文件I/O的相关函数,如CreateFile、ReadFile、WriteFile、CloseHandle等函数会使操作系统产生出IRP_MJ_CREATE、IRP_MJ_READ、IRP_MJ_WRITE、IRP_MJ_CLOSE等不同的IRP,这些IRP会被传送到驱动程序的派遣函数中。
还有些IRP是由系统的某个组件创建的,比如IRP_MJ_SHUTDOWN是在Windows的即插即用组件在即将关闭系统的时候发出的。
1.3、对派遣函数的简单处理
大部分的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的内部操作大体是这样的:
a)、ReadFile调用ntdll中的NtReadFile。其中的ReadFile函数的Win32API,而NtReadFile函数是Native API
b)、ntdll中的NtReadFile进入内核模式,并调用系统服务中的NtReadFile函数。
c)、系统服务函数NtReadFile创建IRP_MJ_WRITE类型的IRP,然后它将这个IRP发送到某个驱动程序的派遣函数中。NtReadFile然后去等待一个事件,这时当前线程进入“睡眠”状态,也可以说当前线程被阻塞住或者线程处于“Pending”状态。
d)、在派遣函数中一般会将IRP请求结束,结束IRP是通过IoCompleteRequest函数。该函数内部会设置刚才等待的事件,“睡眠”的线程被恢复运行。
在读一个很大的文件或者设备时,ReadFile不会立刻返回,而是等待一段时间。这段时间就是当前线程“睡眠”的那段时间。IRP请求被结束,标志这个操作系统完毕,这时“睡眠”的线程被唤醒。
IoCompleteRequest函数的PriorityBoost参数代表一种优先级,指的是被阻塞的线程以何种优先级恢复运行。一般情况下,优先级设置为IO_NO_INCREMENT。对某些特殊情况,需要将阻塞的线程以“优先”的身份恢复运行。
1.4、通过设备连接打开设备
要打开设备,必须通过设备的名字才能得到该设备的句柄。设别名无法被用户模式下的应用程序查询到,设备名智能被内核模式下的程序查询到。在应用程序中,设备可以通过符号链接进行访问,驱动程序通过IoCreateSymblicLink函数创建符号链接。
1.5、编写更通用的派遣函数
IRP会被操作系统发送到设备栈的顶层,如果顶层的设备对象的派遣函数结束了IRP的请求,则这次IO请求结束,如果没有将IRP的请求结束,那么操作系统将IRP转发到设备栈的下一层设备处理。如果这个设备的派遣函数依然不能结束该IRP请求,则继续向下层设备进行转发。
因此,一个IRP可能会被转发多次。为了记录IRP在每层设备中做的操作,IRP会有一个IO_STACK_LOCATION数组。数组的元素数应该大于IRP穿越过的设备数。每个IO_STACK_LOCATION元素记录着对应设备中做的操作。对于本层设备对应的IO_STACK_LOCATION,可以通过IoGetCurrentIrpStackLocation函数得到。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子域记录。
以“缓冲区”方式读设备时,操作系统会分配一段内核模式下的内存。这段内存大小等于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这个子域记录设备实际操作了多少字节。
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、缓冲区设备模拟文件读写
a)、写操作。在应用程序中,通过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;
}
b)、读操作。在应用程序中通过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;
}
c)、读取文件长度。读取文件长度依靠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、其他方式读写操作
4.1、其他方式设备
在使用其他方式读写设备时,派遣函数直接读写应用程序提供的缓冲区地址。只有驱动程序与应用程序运行在相同线程上下文的情况下,才能使用这种方式。
用其他方式读写时,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将这段内存映射到内核模式下的内存地址。
6、小结
本章重点介绍了驱动程序中的处理IRP请求的派遣函数。所有对设备的操作最终将转化为IRP请求,这些IRP请求会被传送到派遣函数处理。本章主要介绍了IRP_MJ_READ、IRP_MJ_WRITE、IRP_MJ_DEVICE_CONTROL的派遣函数。这些IRP请求分别有缓冲区方式,直接方式和其他方式的操作。其中,缓冲区方式和直接方式是驱动程序开发中经常用到的。
以上内容参考自张帆 史彩成等编著的《Windows 驱动开发技术详解》第七章