Ó
1997 OSR Open Systems Resources, Inc.
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, well 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, well also discuss the practical uses that can be made of execution context within device drivers.
When we refer to a routines 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, "Whats 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, its 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 NTs 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 Ps physical pages are mapped into the operating systems 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 threads 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.
Kernel mode routines run in one of three different classes of context:
During its execution, parts of every kernel mode driver might run in each of the three context classes above. For example, a drivers 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 kernels virtual address space. On the other hand, DPCs (such as a drivers 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 drivers dispatch routines run can be particularly interesting. In many cases, a kernel mode drivers 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 processors 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.
Figure 1
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 drivers dispatch routine is running in the context of the user thread that issued the ReadFile(...) request. This means that when the drivers read dispatch function is running, it is the user thread executing the kernel mode driver code.
Does a drivers 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 drivers dispatch routine will not be called in the context of the calling users thread, is if the users 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 drivers 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, lets 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 users 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 threads 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. Lets say that a write operation on this device comprises copying up to 1K of data from a users 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 users data buffer, and copy the data from the users buffer into this non-page pool buffer. The I/O Manager will then call the drivers 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 devices 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 non-paged 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 users data will be probed for accessibility and locked in memory by the I/O Manager. The I/O Manager will then describe the users 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 drivers 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 users buffer to the devices shared memory area. How efficient is this design? Well, its better than the first design. But mapping is also not a low overhead operation.So whats the alternative to these two conventional designs? Well, assuming the user application talks directly to this driver, we know that the drivers 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 drivers write dispatch function is called, the user-mode virtual address of the users 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 devices 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 thats what Id 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 users process? The try except block wont catch this problem. One example of such a pointer might be one that references memory that is mapped read-only by the users 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.
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 thats 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 users 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.
//
// 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 contains the code for the drivers DispatchIoCtl function. The driver is called using the standard Win32 system service call, as shown below:
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 differences is that its running in Kernel Mode, on the Kernel stack.
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!