理解和使用NT驱动程序的执行上下文
理解Windows NT驱动程序最重要的概念之一就是驱动程序运行时所处的“执行上下文”。理解并小心地应用这个概念可以帮助你构建更快、更高效的驱动程序。
NT标准内核模式驱动程序编程的一个重要观念是某个特定的驱动程序函数执行时所处的“上下文”。传统上文件系统开发者最关注这个问题,但所有类型的NT内核模式驱动程序的编写者都能从执行上下文的深刻理解中获益。小心谨慎地使用执行上下文的概念能帮助构建更高性能、更低开销的驱动程序设计。
在本文中,我们将探寻执行上下文的概念。作为对概念的示范,本文在结尾描述了一个能让用户程序在内核模式下运行并拥有其中所有权限的驱动程序。在这个过程中,我们也将讨论设备驱动程序中执行上下文的实际用法。
什么是上下文?
当提及一个例程的上下文时,我们是指它的线程和进程的执行环境。在NT中,这个环境由当前的线程环境块(TEB)和进程环境块(PEB)建立。上下文因此包括虚拟内存的设置(告诉我们哪个物理内存页对应哪个虚拟内存地址),句柄转换(因为句柄基于进程的),分派器信息,堆栈,以及通用和浮点寄存器的设置。当我们问道一个特定的内核例程运行在哪个上下文时,我们实际在问,“哪一个是(NT内核)分派器建立的当前线程?”因为每一个线程只属于一个进程,当前线程确定了当前进程。当前线程和当前进程在一起确定了唯一标识线程和进程的所有事情(句柄、虚拟内存、调度器状态和寄存器)。
虚拟内存也是上下文中对内核模式驱动编写者最有用的一个方面。还记得NT把用户进程映射到虚拟地址空间的低2GB,把操作系统自身的代码映射到虚拟地址空间的高2GB吗?当一个用户进程中的线程执行时,它的虚拟地址范围是0到2GB,2GB以上的所有地址均被设置为“No Access”,以此防止用户直接访问操作系统代码和结构。当操作系统代码执行时,它的虚拟地址范围是2到4GB,而当前用户进程(如果有的话)的地址映射到0到2GB。在NT3.51和V4.0中,映射到高2GB地址的代码从不变化。然而,映射到低2GB地址的代码会变化,取决于当前进程是哪一个。
此外,在NT特殊的虚拟内存排布策略中,进程P内一个合法的虚拟地址X(X小于等于2GB)和内核虚拟地址X对应于相同的物理内存位置。当然,这只有在进程P是当前进程并且(也因此)进程P的物理页面映射到操作系统的低2GB虚拟地址空间时才能成立。上面这句话的另一个说法就是,“这只在P是当前进程时才能成立。”所以在同一个进程上下文中,用户虚拟地址和2GB以上的内核虚拟地址指向相同的物理位置。
PS:就是说假如ring3地址0x400000,要想内核层有效,可以访问这个地址0x400000,内核驱动必须运行在该ring3进程的上下文中,并且有物理页对应这个虚拟地址。
下文中另一个让内核模式驱动程序编写者感兴趣的方面是线程调度上下文。当一个线程在等待时(例如通过发出Win32函数WaitForSingleObject(…)来等待一个没有被激发的对象),这个线程的调度上下文对象被用来存储关于线程定义所等待的对象的信息。当发出未满足的等待时,这个线程就从就绪队列中被移出,只有当等待被满足(指定的分派器对象被激发)时才被移回。
上下文也影响到句柄的使用。因为句柄是针对一个特定的进程的,在一个进程中创建的句柄在其他进程上下文中是没有用的。
不同类型的上下文:
内核模式的例程运行在下面三种不同的上下文之一:
- 系统进程上下文
- 特定用户线程(和进程)上下文
- 任意用户线程(和进程)上下文
在执行过程中,每一个内核模式驱动程序的各部分可能运行在上面三种上下文之一。例如,一个驱动程序的DriverEntry(…)函数总是运行在系统进程的上下文中。系统进程上下文无用户上下文无关(因此没有TEB),并且也没有用户进程映射到内核虚拟地址空间的低2GB中。另一方面,DPCs(例如一个驱动程序为ISR服务的DPC或者定时器到期函数)运行在任意用户线程的上下文中。这意味者在一个DPC的执行过程中,任何用户线程都可以成为“当前”线程,因此任何用户进程都可以映射到内核虚拟地址空间的低2GB中。
PS:“因此任何用户进程都可以映射到内核虚拟地址空间的低2GB”中,这句话就是说在内核层可以使用该进程对应的低2G的有效的内存地址。晕。。。
驱动程序的分派例程执行时所处的上下文应该引起特别的注意。在许多情况下,内核模式驱动程序的分派例程运行在调用者用户线程的上下文中。图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句柄,诸如此类。唯一的不同是运行在内核模式,使用内核堆栈。
总结:
到这儿就搞定了。理解上下文将是有用的工具,它可帮助你避免一些讨厌的问题。当然它可以让你写出一些非常酷的驱动程序。让我们期待这对你有所帮助。祝你编写驱动快乐!
PS:读完后,没有啥直接感觉。
1、应用层发起一个IO调用,IO管理器构造IRP,发送到下层设备驱动(相当于文中所说的直接调用驱动),此时当前设备驱动运行在发起调用的进程上下文中。
2、应用层发起一个IO调用,IO管理器调用某个设备驱动,如果该设备驱动创建额外线程处理该IRP或者放入系统队列处理IRP(备注:在其他线程中下派IRP),这种情况下,当到达某个设备驱动时,该设备驱动就不是运行在发起IO调用的进程上下文中。
3、对于DriverEntry运行System进程上下文。
4、对于DPC例程运行在任意进程上下文中。