理解和使用NT驱动程序的执行上下文

来源:互联网 发布:机械行业知乎 编辑:程序博客网 时间:2024/05/17 08:06
理解Windows NT驱动程序最重要的概念之一就是驱动程序运行时所处的“执行上下文”。理解并小心地应用这个概念可以帮助你构建更快、更高效的驱动程序。
 
NT标准内核模式驱动程序编程中的一个重要观念是某个特定的驱动程序函数执行时所处的“上下文”。传统上文件系统开发者最关注这个问题,但所有类型的NT内核模式驱动程序的编写者都能从对执行上下文的深刻理解中获益。小心谨慎地使用执行上下文的概念能帮助构建更高性能、更低开销的驱动程序设计。
 
在本文中,我们将探寻执行上下文的概念。作为对概念的示范,本文在结尾描述了一个能让用户程序在内核模式下运行并拥有其中所有权限的驱动程序。在这个过程中,我们也将讨论设备驱动程序中执行上下文的实际用法。
 
什么是上下文?
 
当提及一个例程的上下文时,我们是指它的线程和进程的执行环境。在NT中,这个环境由当前的线程环境块(TEB)和进程环境块(PEB)建立。上下文因此包括虚拟内存的设置(告诉我们那个物理内存页面对应那个虚拟内存地址),句柄转换(因为句柄是基于进程的),分派器信息,堆栈,以及通用和浮点寄存器的设置。当我们问到一个特定的内核例程运行在那个上下文时,我们实际在问,“那一个是(NT内核)分派器建立的当前线程?”因为每一个线程只属于一个进程,当前线程确定了当前进程。当前线程和当前进程在一起确定了唯一标识线程和进程的所有事情(句柄、虚拟内存、调度器状态和寄存器)。
 
虚拟内存也许是上下文中对内核模式驱动程序编写者最有用的一个方面。还记得NT把用户进程映射到虚拟地址空间的低2GB,把操作系统自身的代码映射到虚拟地址空间的高2GB吗?当一个用户进程中的线程执行时,它的虚拟地址范围是02GB2GB以上的所有地址均被设置为“no access”,以此防止用户直接访问操作系统代码和结构。当操作系统代码执行时,它的虚拟地址范围是24GB,而当前用户进程(如果有的话)的地址映射到02GB。在NT3.51V4.0中,映射到高2GB地址的代码从不变化。然而,映射到低2GB地址的代码会变化,取决于当前进程是那一个。
 
此外,在NT特殊的虚拟内存排布策略中,进程P内一个合法的虚拟地址XX小于等于2GB)和内核虚拟地址X对应于相同的物理内存位置。当然,这只有在进程P是当前进程并且(也因此)进程P的物理页面映射到操作系统的低2GB虚拟地址空间时才能成立。上面这句话的另一个说法就是,“这只在P是当前进程时才能成立。”所以在同一个进程上下文中,用户虚拟地址和2GB以上的内核虚拟地址指向相同的物理位置。
 
上下文中另一个让内核模式驱动程序编写者感兴趣的方面是线程调度上下文。当一个线程在等待时(例如通过发出Win32函数WaitForSingleObject(…)来等待一个没有被激发的对象),这个线程的调度上下文对象被用来存储关于线程定义所等待的对象的信息。当发出未满足的等待时,这个线程就从就绪队列中被移出,只有当等待被满足(指定的分派器对象被激发)时才被移回。
 
上下文也影响到句柄的使用。因为句柄是针对一个特定的进程的,在一个进程中创建的句柄在其他进程上下文中是没有用的。
 
不同类型的上下文
 
内核模式的例程运行在下面三种不同的上下文之一:
系统进程上下文
特定用户线程(和进程)上下文
任意用户线程(和进程)上下文
 
在执行过程中,每一个内核模式驱动程序的各部分可能运行在上面三种上下文之一。例如,一个驱动程序的DriverEntry(…)函数总是运行在系统进程的上下文中。系统进程上下文无用户上下文无关(因此没有TEB),并且也没有用户进程映射到内核虚拟地址空间的低2GB中。另一方面,DPCs(例如一个驱动程序为ISR服务的DPC或者定时器到期函数)运行在任意用户线程的上下文中。这意味者在一个DPC的执行过程中,任何用户线程都可以成为“当前”线程,因此任何用户进程都可以映射到内核虚拟地址空间的低2GB中。
 
驱动程序的分派例程执行时所处的上下文应该引起特别的注意。在许多情况下,内核模式驱动程序的分派例程运行在调用者用户线程的上下文中。图1显示了为什么会这样。当一个用户线程向一个设备发出了I/O函数调用,例如通过调用Win32ReadFile(…)函数,将产生一个系统服务请求。在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_NEITHERIOCTL操作。当用户程序发出这个IOCTL时,提供一个void类型的指针作为IOCTL的输入缓冲区,以及一个函数指针(参数为一个void类型的指针并返回void)作为IOCTL的输出缓冲区。当处理这个IOCTL时,驱动程序调用指定的用户函数,将PVOID作为上下文变量传递。在用户地址空间的结果函数将在内核模式下执行。
 
依照NT的设计,很少有回调函数不能做的事。它能发出Win32函数调用,弹出对话框和执行文件I/O。唯一不同的是,这个用户程序将运行在内核模式下,使用内核堆栈。当一个应用程序运行在内核模式下时,它不受权限和配额限制,不受保护检查。因为在内核模式下执行的所有函数都拥有IOPL,这个用户程序甚至可以发出INOUT指令(当然是在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句柄,诸如此类。唯一的不同是运行在内核模式,使用内核堆栈。
 
总结
 
到这儿就搞定了。理解上下文将是有用的工具,它可帮助你避免一些讨厌的问题。当然它可以让你写出一些非常酷的驱动程序。让我们期待这对你有所帮助。祝你编写驱动快乐!

Understanding and Using Execution Context in NT Drivers
 
One of the most important concepts to understand about drivers in Windows NT is the "execution
context" in which the drivers run. Understanding this concept, and applying what you know about it
carefully, can help you build faster, more efficient drivers.
 
An important aspect of NT standard kernel mode drivers is the "context" in which particular driver
functions execute. Traditionally of concern mostly to file systems developers, writers of all types NT kernel
mode drivers can benefit from a solid understanding of execution context. When used with care,
understanding execution context can enable the creation of higher performance, lower overhead device
driver designs.
 
In this article, we’ll explore the concept of execution context. As a demonstration of the concepts
presented, this article ends with the description of a driver that allows user applications to execute in kernel
mode, with all the rights and privileges thereof. Along the way, we’ll also discuss the practical uses that can
be made of execution context within device drivers.
 
What IS Context?
When we refer to a routine’s context we are referring to its thread and process execution environment. In
NT, this environment is established by the current Thread Environment Block (TEB) and Processes
Environment Block (PEB). Context therefore includes the virtual memory settings (telling us which
physical memory pages correspond to which virtual memory addresses), handle translations (since handles
are process specific), dispatcher information, stacks, and general purpose and floating point register sets.
When we ask in what context a particular kernel routine is running, we are really asking, "What’s the
current thread as established by the (NT kernel) dispatcher?" Since every thread belongs to only one
process, the current thread implies a specific current process. Together, the current thread and current
process imply all those things (handles, virtual memory, scheduler state, registers) that make the thread and
process unique.
 
Virtual memory context is perhaps the aspect of context that is most useful to kernel mode driver writers.
Recall that NT maps user processes into the low 2GB of virtual address space, and the operating system
code itself into the high 2GB of virtual address space. When a thread from a user process is executing, it’s
virtual addresses will range from 0 to 2GB, and all addresses above 2GB will be set to "no access",
preventing direct user access to operating system code and structures. When the operating system code is
executing, its virtual addresses range from 2-4GB, and the current user process (if there is one) is mapped
into the addresses between 0 and 2GB. In NT V3.51 and V4.0, the code mapped into the high 2GB of
address never changes. However, the code mapped into the lower 2GB of address space changes, based on
which process is current.
 
In addition to the above, in NT’s specific arrangement of virtual memory, a given valid user virtual address
X within process P (where X is less than or equal to 2GB) will correspond to the same physical memory
location as kernel virtual address X. This is true, of course, only when process P is the current process and
(therefore) process P’s physical pages are mapped into the operating system’s low 2GB of virtual
addresses. Another way of expressing this last sentence is, "This is true only when P is the current process
context." So, user virtual addresses and kernel virtual addresses up to 2GB refer to the same physical
locations, given the same process context.
 
Another aspect of context of interest to kernel mode driver writers is thread scheduling context. When a
thread waits (such as by issuing the Win32 function WaitForSingleObject(...) for an object that is not
signaled), that thread’s scheduling context is used to store information which defines what the thread is
waiting for. When issuing an unsatisfied wait, the thread is removed from the ready queue, to return only
when the wait has been satisfied (by the indicated dispatcher object being signaled).
 
Context also impacts the use of handles. Since handles are specific to a particular process, a handle created
within the context of one process will be of no use in another processes context.
 
Different Contexts
 
Kernel mode routines run in one of three different classes of context:
-System process context;
-A specific user thread (and process) context;
-Arbitrary user thread (and process) context.
 
During its execution, parts of every kernel mode driver might run in each of the three context classes
above. For example, a driver’s DriverEntry(...)function always runs in the context of the system process.
System process context has no associated user-thread context (and hence no TEB), and also has no user
process mapped into the lower 2GB of the kernel’s virtual address space. On the other hand, DPCs (such as
a driver’s DPC for ISR or timer expiration function) run in an arbitrary user thread context. This means that
during the execution of a DPC, any user thread might be the "current" thread, and hence any user process
might be mapped into the lower 2GB of kernel virtual addresses.
 
The context in which a driver’s dispatch routines run can be particularly interesting. In many cases, a kernel
mode driver’s dispatch routines will run in the context of the calling user thread. Figure 1 shows why this
is so. When a user thread issues an I/O function call to a device, for example by calling the Win32
ReadFile(...)function, this results in a system service request. On Intel architecture processors, such
requests are implemented using software interrupts which pass through an interrupt gate. The interrupt gate
changes the processor’s current privilege level to kernel mode, causes a switch to the kernel stack, and then
calls the system service dispatcher. The system service dispatcher in turn, calls the function within the
operating system that handles the specific system service that was requested. For ReadFile(...) this is the
NtReadFile(...) function within the I/O Subsystem. The NtReadFile(...)function builds an IRP, and calls
the read dispatch routine of the driver that corresponds to the file object referenced by the file handle in the
ReadFile(...) request. All this happens at IRQL PASSIVE_LEVEL.
 
 
Throughout the entire process described above, no scheduling or queuing of the user request has taken
place. Therefore, no change in user thread and process context could have taken place. In this example,
then, the driver’s dispatch routine is running in the context of the user thread that issued the ReadFile(...)
request. This means that when the driver’s read dispatch function is running, it is the user thread executing
the kernel mode driver code.
 
Does a driver’s dispatch routine always run in the context of the requesting user thread? Well, no. Section
16.4.1.1 of the V4.0 Kernel Mode Drivers Design Guide tells us, "Only highest-level NT drivers, such as
File System Drivers, can be sure their dispatch routines will be called in the context of such a user-mode
thread." As can be seen from our example, this is not precisely correct. It is certainly true that FSDs will be
called in the context of the requesting user thread. The fact is that any driver called directly as a result of a
user I/O request, without first passing through another driver, is guaranteed to be called in the context of
the requesting user thread. This includes FSDs. But it also means that most user-written standard kernel
mode drivers providing functions directly to use applications, such as those for process control devices, will
have their dispatch functions called in the context of the requesting user thread.
 
In fact, the only way a driver’s dispatch routine will not be called in the context of the calling user’s thread,
is if the user’s request is first directed to a higher level driver, such as a file system driver. If the higher
level driver passes the request to a system worker thread, there will be a resulting change in context. When
the IRP is finally passed down to the lower level driver, there is no guarantee that the context in which the
higher level driver was running when it forwarded the IRP was that of the original requesting user thread.
The lower-level driver will then be running in an arbitrary thread context.
 
The general rule, then, is that when a device is accessed directly by the user, with no other drivers
intervening, the driver’s dispatch routines for that device will always run in the context of the requesting
user thread. As it happens, this has some pretty interesting consequences, and allows us to do some equally
interesting things.
 
Impacts
 
What are the consequences of a dispatch functions running in the context of the calling user thread? Well,
some are useful and some are annoying. For example, let’s suppose a driver creates a file using the
ZwCreateFile(...)function from a dispatch function. When that same driver tries to read from that file using
ZwReadFile(...), the read will fail unless issued in the context of the same user process from which the
create was issued. This is because both handles and file objects are stored on a per-process basis.
Continuing the example, if the ZwReadFile(...) request is successfully issued, the driver could optionally
choose to wait for the read to be completed by waiting on an event associated with the read operation. What
happens when this wait is issued? The current user thread is placed in a wait state, referencing the indicated
event object. So much for asynchronous I/O requests! The dispatcher finds the next highest priority
runnable thread. When the event object is set to signaled state as a result of the ReadFile(...) request
completing, the driver will only run when the user’s thread is once again one of the N highest priority
runnable threads on an N CPU system.
 
Running in the context of the requesting user thread can also have some very useful consequences. For
example, calls to ZwSetInformationThread(...)using a handle value of -2 (meaning "current thread") will
allow the driver to change all of the current thread’s various properties. Similarly, calls
ZwSetInformationProcess(...)using a handle value of NtCurrentProcess(...) (which ntddk.h defines as -
1) will allow the driver to change the characteristics of the current processes. Note that since both of these
calls are issued from kernel mode, no security checks are made. Thus, it is possible this way to change
thread and/or process attributes that the thread itself could not access.
 
However, it is the ability to directly access user virtual addresses that is perhaps the most useful
consequence of running within the context of the requesting user thread. Consider, for example, a driver for
a simple shared-memory type device that is used directly by a user-mode application. Let’s say that a write
operation on this device comprises copying up to 1K of data from a user’s buffer directly to a shared
memory area on the device, and that the devices shared memory area is always accessible.
 
The traditional design of a driver for this device would probably use buffered I/O since the amount of data
to be moved is significantly less than a page in length. Thus, on each write request the I/O Manager will
allocate a buffer in non-paged pool that is the same size as the user’s data buffer, and copy the data from
the user’s buffer into this non-page pool buffer. The I/O Manager will then call the driver’s write dispatch
routine, providing a pointer to the non-paged pool buffer in the IRP (in
Irp->AssociatedIrp.SystemBuffer). The driver will then copy the data from the non-paged pool buffer to
the device’s shared memory area. How efficient is this design? Well, for one thing the data is always
copied twice. Not to mention the fact that the I/O Manager needs to do the pool allocation for the nonpaged
pool buffer. I would not call this the lowest possible overhead design.
 
Say we try to increase the performance of this design, again using traditional methods. We might change
the driver to use direct I/O. In this case, the page containing the user’s data will be probed for accessibility
and locked in memory by the I/O Manager. The I/O Manager will then describe the user’s data buffer using
a Memory Descriptor List (MDL), a pointer to which is supplied to the driver in the IRP (at
Irp->MdlAddress). Now, when the driver’s write dispatch function gets the IRP, it needs to use the MDL
to build a system address that it can use as a source for its copy operation. This entails calling
IoGetSystemAddressForMdl(...), which in turn calls MmMapLockedPages(...) to map the page table
entries in the MDL into kernel virtual address space. With the kernel virtual address returned from
IoGetSystemAddressForMdl(...), the driver can then copy the data from the user’s buffer to the device’s
shared memory area. How efficient is this design? Well, it’s better than the first design. But mapping is also
not a low overhead operation.
 
So what’s the alternative to these two conventional designs? Well, assuming the user application talks
directly to this driver, we know that the driver’s dispatch routines will always be called in the context of the
requesting user thread. As a result we can bypass the overhead of both the direct and buffered I/O designs
by using "neither I/O". The driver indicates that it wants to use "neither I/O" by setting neither the
DO_DIRECT_IO nor the DO_BUFFERED_IO bits in the flags word of the device object. When the
driver’s write dispatch function is called, the user-mode virtual address of the user’s data buffer will be
located in the IRP at location Irp->UserBuffer. Since kernel mode virtual addresses for user space
locations are identical to user-mode virtual addresses for those same locations, the driver can use the
address from Irp->UserBuffer directly, and copy the data from the user data buffer to the device’s shared
memory area. Of course, to prevent problems with user buffer access the driver will want to perform the
copy within a try… except block. No mapping; No recopy; No pool allocations. Just a straight-forward
copy. No that’s what I’d call low overhead.
 
There is one down-side to using the "neither I/O" approach however. What happens if the user passes a
buffer pointer that is valid to the driver, but invalid within the user’s process? The try… except block won’t
catch this problem. One example of such a pointer might be one that references memory that is mapped
read-only by the user’s process, but is read/write from kernel mode. In this case, the move of the driver will
simply put the data in the space that the user app sees as read-only! Is this a problem? Well, it depends on
the driver and the application. Only you can decide if the potential risks are worth the rewards of this
design.
 
Take It To The Limit
 
A final example will demonstrate many of the possibilities of a driver running within the context of the
requesting user thread. This example will demonstrate that when a driver is running, all that’s happening is
that the driver is running in the context of the calling user process in kernel mode.
We have written a pseudo-device driver called SwitchStack. Since it is a pseudo-device driver it is not
associated with any hardware. This driver supports create, close, and a single IOCTL using
METHOD_NEITHER. When a user application issues the IOCTL, it provides a pointer to a variable of
type void as the IOCTL input buffer and a pointer to a function (taking a pointer to void and returning void)
as the IOCTL output buffer. When processing the IOCTL, the driver calls the indicated user function,
passing the PVOID as a context argument. The resulting function, within the user’s address space, then
executes in kernel mode.
 
Given the design of NT, there is very little that the called-back user function cannot do. It can issue Win32
function calls, pop up dialog boxes, and perform File I/O. The only difference is that the user-application is
running in kernel mode, on the kernel stack. When an application is running in kernel mode it is not subject
to privilege limits, quotas, or protection checking. Since all functions executing in kernel mode have IOPL,
the user application can even issue IN and OUT instructions (on an Intel architecture system, of course).
Your imagination (coupled with common sense) is the only limit on the types of things you could do with
this driver.
 
//++
// 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);
}
Figure 2 -- IOCTL Dispatch Function
Figure 2 contains the code for the driver’s DispatchIoCtl function. The driver is called using the standard
Win32 system service call, as shown below:
DeviceIoControl (hDriver,(DWORD) IOCTL_SWITCH_STACKS,
&UserData,
sizeof(PVOID),
&OriginalWinMain,
sizeof(PVOID),
&cbReturned,
 
This example is not designed to encourage you to write programs that run in kernel mode, of course.
However, what the example does demonstrate is that when your driver is running, it is really just running in
the context of an ordinary Win32 program, with all its variables, message queues, window handles, and the
like. The only difference is that it’s running in Kernel Mode, on the Kernel stack.
 
Summary
 
So there you have it. Understanding context can be a useful tool, and it can help you avoid some annoying
problems. And, of course, it can let you write some pretty cool drivers. Let us hear from you if this idea has
helped you out. Happy driver writing!
0 0