理解和使用NT驱动程序的执行上下文(二)
翻译:李华谊 horily@163.com
驱动程序的分派例程执行时所处的上下文应该引起特别的注意。在许多情况下,内核模式驱动程序的分派例程运行在调用者用户线程的上下文中。图1显示了为什么会这样。当一个用户线程向一个设备发出了I/O函数调用,例如通过调用Win32的ReadFile(…)函数,将产生一个系统服务请求。在Intel架构的处理器上,这样的请求依靠通过一个中断门的软中断来实现。中断门把处理器的当前权限级别改变到内核模式,切换内核堆栈的,然后再调用系统服务分派器。系统服务分派器接着调用操作系统内处理所请求的系统服务的函数。。对应ReadFile(…)则是I/O子系统内的NtReadFile(…)函数。NtReadFile(…)函数构造一个IRP,然后调用对应于被ReadFile(…)请求的文件句柄所引用的文件对象的驱动程序的读分派例程。所有这些均发生在IRQL级别PASSIVE_LEVEL之上。
在上面描述的整个过程中,用户请求没有被调度或者排队。所以用户线程或进程的上下文没有改变。在这个例子中,驱动程序的分派例程运行在发出ReadFile(…)请求的用户线程的上下文中。这意味着当驱动程序的读分派函数运行时,是用户线程在执行内核模式驱动程序的代码。
驱动程序的分派函数总是运行在发出请求的用户线程的上下文中吗?嗯,并非如此。内核模式驱动程序设计指南4.0版的16.4.1.1小节告诉我们,“只有最高层的NT驱动程序,例如文件系统驱动程序,可以确保它们的分派函数在用户模式线程的上下文中被调用。”从我们的例子可以看出,这个说法并不完全精确。文件系统驱动程序(FSDs)当然是在发出请求的用户线程的上下文中被调用。实际上,任何因用户I/O请求而被直接调用的驱动程序,只要不是先通过另一个驱动程序,都可确保在发出请求的用户线程的上下文中被调用。这包括了文件系统驱动程序的情况。这也意味着大多数用户编写的直接为用户应用程序提供函数的标准内核模式驱动程序,例如那些过程控制设备的驱动,它们的分派函数将在发出请求的用户线程上下文中被调用。
实际上,驱动程序分派函数不在调用者线程的上下文中被调用唯一方式是用户请求首先被定向到了一个更高层的驱动程序,例如文件系统驱动程序。如果高层驱动将请求传递给了一个系统工作线程,这将导致上下文的改变。当IRP最终传递到低层驱动程序时,不能保证转发IRP的高层驱动程序运行时所处的上下文还是发出请求的用户线程的上下文。低层驱动程序将运行在任意线程上下文中。
一般的规则是,当一个设备直接被用户访问而不涉及其他驱动程序时,该设备的驱动程序的分派线程总是运行在发出请求的用户线程中。这时就有一些十分有趣的后果,使得我们能够做一些同样有趣的事情。
影响
分派函数运行在调用者用户线程的上下文中的后果是什么?嗯,有些是有用的,有些是令人讨厌的。例如,让我们假设一个驱动程序在分派函数中用ZwCreateFile(…)创建了一个文件。当同一个驱动程序试图用ZwReadFile(..)读取那个文件时将会失败,除非读取和创建是发自同一个用户线程的上下文中。这是因为句柄和文件对象是按线程存储的。继续上面的例子,如果ZwReadFile(…)请求成功发出,驱动程序可以选择在一个和读取操作相关的事件上等待来等待读取操作完成。当这个等待发出后会发生什么呢?当前用户线程被放入等待的状态,引用着一个事件指示对象。到此为止,关于异步I/O请求的操作仅仅这么些!操作系统分派器找到下一个拥有最高优先权的就绪的线程。当事件对象因ReadFile(…)请求完成而设置为被激发的状态后,只有当用户线程再次成为一个N CPU系统的N个拥有最高优先权的就绪线程之一时,驱动程序才会运行。
在发出请求的用户线程上下文中运行也有一些非常有用的好处。例如,用句柄值-2(意味着“当前线程”)调用ZwSetInformationThread(…)函数将允许驱动程序改变当前线程的所有的各种各样的属性。类似地,用NtCurrentProcess(…)的句柄值(在ntddk.h中定义为1)调用ZwSetInformationProcess(…)将允许驱动程序当前进程的所有特性。注意,因为这两个调用在内核模式发出,所以不会进行安全性坚持。也就是说这种方式有可能改变线程自身不能访问的线程或进程属性。
然而,在发出请求的用户线程上下文中运行最有用的地方也许是直接访问用户虚拟地址的能力。例如,请考虑一个简单的,直接被用户程序使用的共享内存类型设备的驱动程序。我们假设在这个设备上的一个写操作由从用户缓冲区直接拷贝1K数据到设备的共享内存区构成,而该设备的共享内存区总是可访问的。
这个设备的驱动程序的传统设计可能使用带缓冲的I/O,因为要移动的数据量远远小于一个页面的长度。也就是说,I/O Manager将在非分页池中为每一个写请求分配一块大小和用户数据缓冲区相同的缓冲区,再从用户缓冲区拷贝数据到这个非分页池中的缓冲区。I/O Manager调用驱动程序的写分派例程,在IRP里面提供一个指向非分页池中的缓冲区的指针(Irp->AossicatedIrp.SystemBuffer)。然后,驱动程序从非分页池中的缓冲区拷贝数据到设备的共享内存区。这个设计效率有多高?嗯,为完成一件事而拷贝了两次数据,更别提I/O Manager还要为非分页池中的缓冲区进行共享池分配的事实。我可不愿称之为最低开销设计。
假设我们要增加这个设计的性能,依然使用传统方法。我们可以让驱动程序使用直接I/O。在这种情况下,I/O Manager找出并在内存锁定包含用户数据的页面。然后I/O Manager用一个内存描述符列表(MDL)描述用户数据缓冲区,指向这个MDL的指针在IRP里面提供给驱动程序(Irp->MdlAddress)。现在,当驱动程序的写分派函数得到IRP后,它需要用MDL创建一个可以用作拷贝操作数据源的系统地址。这由调用IoGetSystemAddressForMdl(…)完成,它随后调用MmMapLockedPages(…)把 MDL中的页面表入口映射到内核虚拟地址空间。利用IoGetSystemAddressForMdl(…)返回的内核虚拟地址,驱动程序用户缓冲区拷贝数据到设备的共享内存区。这个设计效率有多高?嗯,比第一个设计要好。但是映射也不是一个低开销的操作。
那么这两个传统设计的替代方案是什么?嗯,假设用户程序直接和这个驱动程序对话,我们知道驱动程序的分派例程总是在发出请求的用户线程的上下文中被调用。因此我们可以用“非I/O”来绕过带缓冲的I/O和直接I/O的设计。驱动程序通过在设备对象的标志字里面即不指定DO_DIRECT_IO位也不指定DO_BUFFERED_IO位来指明需要使用“非I/O”。当驱动程序的写分派函数被调用时,用户数据缓冲区的用户模式虚拟地址可在Irp->UserBuffer找到。因为指向用户空间位置的内核模式虚拟地址和指向同一位置的用户模式虚拟地址是相同的,驱动程序可直接使用Irp->UserBuffer,从用户数据缓冲区拷贝数据到设备的共享内存区。当然,为预防访问用户缓冲区时出错,驱动程序可将拷贝包含在一个try…except语句块中。没有映射,没有重复拷贝,没有共享池分配。就是一个直接的拷贝。没有那些我所说的低开销的操作。
但是使用“非I/O”有一个不利之处。如果用户传递了一个对驱动程序合法却对用户进程非法的缓冲区指针给驱动程序会发生什么?try…excpet语句块无法捕获这个问题。例如,一个指向者被用户进程映射为只读,但是可以在内核模式下读/写的内存的指针。在这种情况下,驱动程序的移动操作将简单地把数据放在用户程序看来是只读的地方!这是个问题吗?嗯,这取决于驱动程序和应用程序。只有你才能决定这个设计的回报是否值得冒潜在的风险。
限制
最后用一个例子演示运行在发出请求的用户线程的上下文中的驱动程序的许多可能性。这个例子将演示当驱动程序运行时,所发生的是运行在内核模式下的调用者用户进程的上下文中。我们编写了一个名叫SwitchStack的伪设备。因为是一个伪设备,它不与任何硬件相关。这个驱动程序支持创建,关闭和一个使用METHOD_NEITHER的IOCTL操作。当用户程序发出这个IOCTL时,提供一个void类型的指针作为IOCTL的输入缓冲区,以及一个函数指针(参数为一个void类型的指针并返回void)作为IOCTL的输出缓冲区。当处理这个IOCTL时,驱动程序调用指定的用户函数,将PVOID作为上下文变量传递。在用户地址空间的结果函数将在内核模式下执行。
依照NT的设计,很少有回调函数不能做的事。它能发出Win32函数调用,弹出对话框和执行文件I/O。唯一不同的是,这个用户程序将运行在内核模式下,使用内核堆栈。当一个应用程序运行在内核模式下时,它不受权限和配额限制,不受保护检查。因为在内核模式下执行的所有函数都拥有IOPL,这个用户程序甚至可以发出IN和OUT指令(当然是在Intel架构的系统上)。你的想像力(外加一点常识)只受到驱动程序所能做到的事情的类型的限制。
//++
// SwitchStackDispatchIoctl
//
// This is the dispatch routine which processes
// Device I/O Control functions sent to this device
//
// Inputs:
// DeviceObject Pointer to a Device Object
// Irp Pointer to an I/O Request Packet
//
// Returns:
// NSTATUS Completion status of IRP
//
//--
NTSTATUS
SwitchStackDispatchIoctl(IN PDEVICE_OBJECT, DeviceObject, IN PIRP Irp)
{
PIO_STACK_LOCATION Ios;
NTSTATUS Status;
//
// Get a pointer to current I/O Stack Location
//
Ios = IoGetCurrentIrpStackLocation(Irp);
//
// Make sure this is a valid IOCTL for us...
//
if(Ios->Parameters.DeviceIoControl.IoControlCode!=IOCTL_SWITCH_STACKS)
{
Status = STATUS_INVALID_PARAMETER;
}
else
{
//
// Get the pointer to the function to call
//
VOID (*UserFunctToCall)(PULONG) = Irp->UserBuffer;
//
// And the argument to pass
//
PVOID UserArg;
UserArg = Ios->Parameters.DeviceIoControl.Type3InputBuffer;
//
// Call user's function with the parameter
//
(VOID)(*UserFunctToCall)((UserArg));
Status = STATUS_SUCCESS;
}
Irp->IoStatus.Status = Status;
Irp->IoStatus.Information = 0;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return(Status);
}
上面是驱动程序的DispatchIoCtl函数。这个驱动程序在标准的Win32系统服务调用中被调用,如下所示:
DeviceIoControl (hDriver,(DWORD) IOCTL_SWITCH_STACKS,
&UserData,
sizeof(PVOID),
&OriginalWinMain,
sizeof(PVOID),
&cbReturned,
设计这个例子当然并非鼓励你编写运行在内核模式下的的程序。但是,这个例子所作的事说明了当你的驱动程序运行时,它的确是运行在一个普通的Win32程序的上下文中,带有所有的变量,队列,windows句柄,诸如此类。唯一的不同是运行在内核模式,使用内核堆栈。
总结
到这儿就搞定了。理解上下文将是有用的工具,它可帮助你避免一些讨厌的问题。当然它可以让你写出一些非常酷的驱动程序。让我们期待这对你有所帮助。祝你编写驱动快乐!