《Windows驱动开发技术详解》之IRP的同步
- 应用程序对设备的同步异步操作:
大部分IRP都是由应用程序的Win32 API函数发起的。这些Win32 API本身就支持同步和异步操作。例如,ReadFile、WriteFile和DeviceIoControl等,它们都有异步和同步两种操作方式。DeviceIoControl的同步操作如图所示:
同步操作时,DeviceIoControl的内部会调用WaitForSingleObject函数去等待一个事件。这个事件直到IRP结束才会触发。如果通过反汇编IoCompleteRequest内核函数,就会发现在IoCompleteRequest内部设置了该事件。DeviceIoControl会暂时进入睡眠状态,直到IRP结束。
而对于异步操作,其处理过程如图所示:
在异步操作的情况下,当DeviceIoControl被调用时,其内部会产生IRP,并将该IRP传递给驱动内部的派遣函数。但此时 DeviceIoControl不会等待该IRP结束,而是直接返回。当IRP经过一段时间被结束时,操作系统会出发一个IRP相关事件。这个事件可以通知应用程序IRP请求被执行完毕。
同步操作设备:
CreateFile的函数声明如下:
1 HANDLE WINAPI CreateFile( 2 _In_ LPCTSTR lpFileName, 3 _In_ DWORD dwDesiredAccess, 4 _In_ DWORD dwShareMode, 5 _In_opt_ LPSECURITY_ATTRIBUTES lpSecurityAttributes, 6 _In_ DWORD dwCreationDisposition, 7 _In_ DWORD dwFlagsAndAttributes, 8 _In_opt_ HANDLE hTemplateFile 9 );
CreateFile中的第六个参数dwFlagsAndAttributes是同步异步操作的关键。如果这个参数中没有设置FILE_FLAG_OVERLAPPED,则以后对该设备的操作都是同步操作,否则所有操作为异步操作。
异步操作设备方式一:
先来看一下OVERLAPPED的结构:
其中最后一个参数hEvent,这个事件用于该操作完成之后通知应用程序。
示例代码如下:
1 #define BUFFER_SIZE 1024 2 3 int main(){ 4 HANDLE hDevice = CreateFile("test.dat", 5 GENERIC_READ | GENERIC_WRITE, 6 0, 7 NULL, 8 CREATE_ALWAYS, 9 FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED,//这里表示异步方式打开 10 NULL 11 ); 12 if (hDevice == INVALID_HANDLE_VALUE){ 13 printf("Error!\n"); 14 return 1; 15 } 16 UCHAR buffer[BUFFER_SIZE]; 17 DWORD dwRead; 18 OVERLAPPED overlap = { 0 }; 19 overlap.hEvent = CreateEvent(NULL, FALSE, FALSE, NULL); 20 ReadFile(hDevice, buffer, BUFFER_SIZE, &dwRead, &overlap); 21 WaitForSingleObject(overlap.hEvent, INFINITE); 22 CloseHandle(hDevice); 23 system("pause"); 24 return 0; 25 26 }
异步操作设备方式二:
除了使用OVERLAPPED结构之外,ReadFile、WriteFile这两个API还有对应的专门用于异步读写操作的API,它们就是ReadFileEx和WriteFileEx。
以ReadFileEx为例,其原型如下:
1 BOOL WINAPI ReadFileEx( 2 _In_ HANDLE hFile, 3 _Out_opt_ LPVOID lpBuffer, 4 _In_ DWORD nNumberOfBytesToRead, 5 _Inout_ LPOVERLAPPED lpOverlapped, 6 _In_ LPOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine 7 );
ReadFileEx将读请求传递到驱动之后立刻返回。驱动程序在结束读操作后,会调用ReadFileEx提供的回调例程。这类似一个软中断,即当读操作后,系统立刻回调ReadFileEx提供的回调例程。Windows称这种机制为异步过程调用(APC)。
然而,APC的回调函数被调用是有条件的。只有线程处于警惕状态时,回调函数才有可能被调用。有多个API可以使系统进入警惕状态,如SleepEx、WaitForSingleObjectEx、WaitForMultipleObjectsEx等。它们的参数bAlertable如果被设置为TRUE,则进入警惕状态。当系统进入警惕模式之后,操作系统就会枚举当前线程的APC队列。驱动程序一旦结束读取操作,就会把ReadFileEx提供的完成例程插入到APC队列。回调例程一般会报告本次操作的完成状况,比如是成功还是失败。同时会报告本次读取操作实际读取的字节数等。一般回调例程声明如下:
1 VOID CALLBACK FileIOCompletionRoutine( 2 _In_ DWORD dwErrorCode, 3 _In_ DWORD dwNumberOfBytesTransfered, 4 _Inout_ LPOVERLAPPED lpOverlapped 5 );
举例如下:
1 VOID CALLBACK MyFileIoCompletionRoutine( 2 DWORD dwErrorCode, 3 DWORD dwNumberOfBytesTransfered, 4 LPOVERLAPPED lpOverlapped 5 ){ 6 printf("IO operation end!\n"); 7 } 8 9 int main(){ 10 HANDLE hDevice = CreateFile("\\\\.\\HelloDDK", 11 GENERIC_READ | GENERIC_WRITE, 12 0, NULL, 13 OPEN_EXISTING, 14 FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED, 15 NULL); 16 if (hDevice == INVALID_HANDLE_VALUE){ 17 printf("Error!\n"); 18 return 1; 19 } 20 UCHAR buffer[BUFFER_SIZE]; 21 OVERLAPPED overlap = { 0 }; 22 ReadFileEx(hDevice, buffer, BUFFER_SIZE, &overlap, MyFileIoCompletionRoutine); 23 printf("Before SleepEx!\n"); 24 Sleep(5000); 25 //进入警惕状态,唤醒回调例程 26 SleepEx(0, TRUE); 27 CloseHandle(hDevice); 28 system("pause"); 29 return 0; 30 }
输出结果为:
- IRP的同步完成与异步完成
派遣函数有两种方式处理IRP请求,一种是在派遣函数中直接结束掉IRP,这可以认为是一种同步的处理方法。另一种方法是在派遣函数中不结束IRP请求,而是让派遣函数直接返回,IRP在以后的某个时刻再去处理。
IRP的同步完成:
下面介绍下Win32 API是如何一层层通过调用进入到派遣函数的。
(1)在应用程序中调用CreateFile API;
(2)CreateFile API函数内部调用了ntdll.dll中的NtCreateFile函数。
(3)ntdll.dll中的NtCreateFile函数进入内核模式,然后调用ntoskrnl.exe中的NtCreateFile函数。
(4)内核模式中的ntoskrnl.exe的NtCreateFile函数创建IRP_MJ_CREATE类型的IRP,然后调用相应驱动程序的派遣函数,并将IRP的指针传递给该派遣函数。
(5)派遣函数调用IoCompleteRequest,将IRP结束。
(6)操作系统按照原路返回,一直退到CreateFile API。至此,CreateFile函数返回。
而此时如果有后续的ReadFile操作会有三种情况:
如果是用ReadFile进行同步读取时:
(1)ReadFile内部会创建一个事件,这个事件连同IRP一起被传递到派遣函数中。
(2)派遣函数调用IoCompleteRequest时,其内部会设置IRP的UserEvent事件。
(3)操作系统按照原路一直返回到ReadFile函数,ReadFile函数会等待这个事件。因为该事件已经被设置,所以无需等待。
(4)如果在派遣函数中没有调用IoCompleteRequest,该事件就没有被设置,ReadFile就会一直等IRP被结束。
如果ReadFile是异步读取:
(1)ReadFile内部不会创建事件,但ReadFile函数会接收overlap参数。overlap参数中会提供一个事件,被用作同步处理。
(2)IoCompleteRequest内部会设置overlap提供的事件。
(3)在ReadFile函数退出前,它不会检测该事件是否被设置,因此可以不等待操作是否真的被完成。
(4)当IRP操作被完成后,overlap提供的事件被设置,这个事件会通知应用程序IRP请求被完成。
用ReadFileEx函数进行异步读取操作:
(1)ReadFileEx不提供时间,但提供一个回调函数,这个回调函数的地址会作为IRP的参数传递给派遣函数。
(2)IoCompleteRequest会将这个完成函数插入到APC队列。
(3)应用程序只要进入警惕模式,APC队列会自动出队列,完成函数会被执行,这相当于通知应用层操作已经完成。
IRP的异步完成:
IRP的异步完成是指不在派遣函数中调用IoCompleteRequest内核函数。调用了IoCompleteRequest意味着IRP请求的结束。如果派遣函数不调用IoCompleteRequest函数,则需要告诉操作系统此IRP处于“挂起”状态。这需要调用内核函数IoMarkIrpPending。
CreateFile异步打开时,三种ReadFile如何处理IRP:
为了演示异步处理IRP,下面的示例假设IRP_MJ_READ的派遣函数仅仅是返回“挂起”。应用程序关闭设备的时候会产生IRP_MJ_CLEANUP类型的IRP。在IRP_MJ_CLEANUP的派遣函数中结束那些“挂起”的IRP_MJ_READ。为了能够存储有那些IRP_MJ_READ被挂起,这里使用一个队列,也就把每个挂起的IRP_MJ_READ的指针都插入队列,最后IRP_MJ_CLEANUP的派遣函数将一个个IRP出队列,并且调用IoCompleteRequest函数将它们结束。首先,要定义好队列的数据结构:
在设备扩展中加入“队列”这个变量,这样,驱动程序的所有派遣函数都可以使用这个队列。在DriverEntry中初始化该队列,并在DriverUnload中收回该队列。在IRP_MJ_READ派遣函数中,将IRP插入堆栈,然后返回“挂起”状态。
示例代码如下:
首先,设备扩展内容如下:
现在DriverEntry中初始化链表:
Read的派遣函数
CloseHandle的派遣函数:
R3的代码:
1 int main(){ 2 getchar(); 3 HANDLE hDevice = 4 CreateFile("\\\\.\\HelloDDK", 5 GENERIC_READ | GENERIC_WRITE, 6 0, NULL, 7 OPEN_EXISTING, 8 FILE_ATTRIBUTE_NORMAL, 9 NULL); 10 if (hDevice == INVALID_HANDLE_VALUE){ 11 printf("Open device failed!\n"); 12 } 13 else{ 14 printf("Open device succeed!\n"); 15 } 16 OVERLAPPED overlap1; 17 HANDLE g_hOverlappedEvent = CreateEvent(NULL, FALSE, FALSE, NULL); 18 19 if (g_hOverlappedEvent == NULL || hDevice == INVALID_HANDLE_VALUE) 20 { 21 printf("Open device failed!\n"); 22 } 23 24 memset(&overlap1, 0, sizeof(OVERLAPPED)); 25 overlap1.hEvent = g_hOverlappedEvent; 26 OVERLAPPED overlap2 = { 0 }; 27 UCHAR buffer[10]; 28 ULONG ulRead; 29 BOOL bRead = ReadFile(hDevice, buffer, 10, &ulRead, &overlap1); 30 if (!bRead && GetLastError() == ERROR_IO_PENDING){ 31 printf("The operation is pending!\n"); 32 } 33 bRead = ReadFile(hDevice, buffer, 10, &ulRead, &overlap2); 34 if (!bRead && GetLastError() == ERROR_IO_PENDING){ 35 printf("The operation is pending!\n"); 36 } 37 Sleep(2000); 38 printf("After sleeping..\n"); 39 getchar(); 40 CloseHandle(hDevice); 41 system("pause"); 42 return 0; 43 }
一开始蓝屏,我以为是初始化链表或者插入链表有错,但是我在DriverEntry后加了两句打印,发现数据打印没错:
会输出正确的数据:
然后我用windbg跟踪,发现一运行到CloseHandle就蓝屏:
等到下次用windbg双机调试的时候,发现会有提示:
说我结束掉了一个已经被结束掉的IRP,事实证明,如果你的代码时这样的:
返回的是STATUS_SUCCESS,那么也会被认为是结束掉了IRP。CloseHandle中再去结束,就会造成蓝屏。
而如果改成这样:
虽然避免了0x00000044蓝屏,但跟踪发现上层的ReadFile无法返回,卡在ReadFile这一步:
为什么会卡在这里?后来我发现,CreateFile没有使用异步的方式打开,书上有一段话描述的很清楚,而我却忽略了:
因此,R3层的代码应该是:
1 int main(){ 2 getchar(); 3 HANDLE hDevice = 4 CreateFile("\\\\.\\HelloDDK", 5 GENERIC_READ | GENERIC_WRITE, 6 0, NULL, 7 OPEN_EXISTING, 8 FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED, 9 NULL); 10 if (hDevice == INVALID_HANDLE_VALUE){ 11 printf("Open device failed!\n"); 12 } 13 else{ 14 printf("Open device succeed!\n"); 15 } 16 OVERLAPPED overlap1; 17 HANDLE g_hOverlappedEvent = CreateEvent(NULL, FALSE, FALSE, NULL); 18 19 if (g_hOverlappedEvent == NULL || hDevice == INVALID_HANDLE_VALUE) 20 { 21 printf("Open device failed!\n"); 22 } 23 24 memset(&overlap1, 0, sizeof(OVERLAPPED)); 25 overlap1.hEvent = g_hOverlappedEvent; 26 OVERLAPPED overlap2 = { 0 }; 27 UCHAR buffer[10]; 28 ULONG ulRead; 29 BOOL bRead = ReadFile(hDevice, buffer, 10, &ulRead, &overlap1); 30 if (!bRead && GetLastError() == ERROR_IO_PENDING){ 31 printf("The operation is pending!\n"); 32 } 33 bRead = ReadFile(hDevice, buffer, 10, &ulRead, &overlap2); 34 if (!bRead && GetLastError() == ERROR_IO_PENDING){ 35 printf("The operation is pending!\n"); 36 } 37 Sleep(2000); 38 printf("After sleeping..\n"); 39 getchar(); 40 CloseHandle(hDevice); 41 system("pause"); 42 return 0; 43 }
历尽千辛万苦,终于成功了:
通过这次蓝屏事件,受到的启示是:我们可以有空去研究研究蓝屏的编号所对应的问题:
取消IRP:
上面讲到的方法是在CloseHandle中将挂起的IRP结束掉,还有另外一个办法将挂起的IRP结束,这就是取消IRP请求。内核函数IoSetCancelRoutine可以设置取消IRP请求的回调函数。IoSetCancelRoutine可以将一个取消例程与该IRP关联,一旦取消IRP请求的时候,这个取消例程会被执行
在IoCancelIrp内部使用了一个叫做cancel的自旋锁来进行同步。IoCancelIrp在内部会首先获得该自旋锁,IoCancelIrp会调用取消回调例程,因此,释放该自旋锁的任务就留给了取消回调例程。获得取消自旋锁的函数是IoAcquireCancelSpinLock函数,而释放取消自旋锁的函数是IoReleaseCancelSpinLock函数。
在应用程序中,可以调用CancelIo函数取消IRP请求。在CancelIo的内部会枚举所有没有被完成的IRP,然后依次调用IoCancelIrp。另外,如果应用程序没有调用CancelIo函数,应用程序在关闭设备时同样会自动调用CancelIo。
R0代码如下:
1 VOID CancelReadIRP(PDEVICE_OBJECT pDevObj, PIRP pIrp){ 2 UNREFERENCED_PARAMETER(pDevObj); 3 DbgPrint("Enter CancelReadIRP!\n"); 4 // PDEVICE_EXTENSION pDevExt = (PDEVICE_EXTENSION) 5 // pDevObj->DeviceExtension; 6 pIrp->IoStatus.Status = STATUS_SUCCESS; 7 pIrp->IoStatus.Information = 0; 8 IoCompleteRequest(pIrp, IO_NO_INCREMENT); 9 IoReleaseCancelSpinLock(pIrp->CancelIrql); 10 DbgPrint("Leave CancelReadIRP!\n"); 11 } 12 13 NTSTATUS HelloDDKRead_Cancel(PDEVICE_OBJECT pDevObj, 14 PIRP pIrp){ 15 DbgPrint("Enter HelloDDKRead_Cancel!\n"); 16 UNREFERENCED_PARAMETER(pDevObj); 17 // PDEVICE_EXTENSION pDevExt = (PDEVICE_EXTENSION) 18 // pDevObj->DeviceExtension; 19 IoSetCancelRoutine(pIrp, CancelReadIRP); 20 IoMarkIrpPending(pIrp); 21 DbgPrint("Leave HelloDDKRead_Cancel!\n"); 22 return STATUS_SUCCESS; 23 }
R3代码如下:
程序结束之后会出现0x00000076号蓝屏:
其原因主要如下:原因是我忘记更改CloseHandle的分发函数了,仍然用的是之前的派遣函数:
第二个错误就是这里写错了(但这个错误不会导致蓝屏):
这个值只是返回一个调用IoCompleteRequest前IRP的状态。
真正的错误出现在这里:
要改成:
否则我们看输出内容会发现,并没有进入取消例程:
所以才会造成Clean up uncorrectly错误。
但是还是要问一句,Why?
我们来看下微软MSDN怎么解释这个0x00000076号蓝屏:https://msdn.microsoft.com/zh-cn/library/windows/hardware/ff559194
意思是,一个驱动没有正确释放那些被锁住的页面,就会造成这个崩溃。现在来想想,为什么return STATUS_SUCCESS会蓝屏。就是因为如果Read分发函数返回的是STATUS_SUCCESS那么就不会调用CancelReadIrp这个取消例程了,也就不会调用这个取消例程中释放自旋锁的代码了。但是由于R3的代码中调用了CancelIo,这个函数中会有获取自旋锁的代码,这一步是肯定会被执行到的。所以,只上了锁却没有释放锁,就造成了这个蓝屏。
修改之后输出正确结果:
同步异步参考微软的文档:
https://msdn.microsoft.com/en-us/library/windows/hardware/ff549422(v=vs.85).aspx
https://msdn.microsoft.com/en-us/library/windows/desktop/aa365683%28v=vs.85%29.aspx
https://msdn.microsoft.com/en-us/library/aa365683(v=vs.85).aspx