Ruediger R. Asche
Microsoft Developer Network Technology Group
October 17, 1995
Click to open or copy the files in the NTDDWZD sample application for this technical article.
In the article "Wizards Simplify Windows NT Kernel-Mode Driver Design," I describe how custom AppWizards for Microsoft® Visual C++™ work in general, and how the AppWizard technology can be used to implement a wizard that builds a skeleton for a Windows NT™ kernel-mode device driver. This article shows how the custom AppWizard (hereafter referred to, more appropriately, as custom driver wizard) can be employed to take the sting out of device driver writing.
Disclaimer: This article is part of a series of articles about writing and debugging Windows NT kernel-mode device drivers using Visual C++. Please be aware that the material covered in this article series is rather experimental. If you have any questions about the material in this article, or in any other article in this series, do not contact Microsoft Product Support Services. Please e-mail me directly (ruediger@microsoft.com) or contact the Microsoft Developer Network.
I have studied a number of existing driver sources to determine which tasks all (or enough) drivers have in common to make some kind of general code generation useful. I may have overlooked a number of things, but I hope that my findings will be meaningful enough to make your life as a device driver writer easier.
Generally, most of the code in a device driver that can be automated is initialization code. Aside from providing a few smart heuristics, software cannot really anticipate how your driver should implement, say, a Read or Write function.
Let me give you a list of tasks that I think are important for all drivers. The custom driver wizard currently implements these tasks.
Every driver must export a routine called DriverEntry, which is called by the system upon driver initialization.
Within DriverEntry, the following tasks must be performed:
When you build a driver skeleton, you will traverse at least three dialog boxes in which you specify the driver characteristics. In the first dialog box, you specify whether you want C or C++ support (the C++ support is based on the classes described in the article "Writing Windows NT Kernel-Mode Drivers in C++"). In the second dialog box, you tell the driver which function prototypes and skeletons to generate and how many device objects the driver supports. In the remaining dialog boxes, you specify characteristics for each supported device in turn.
Note that the custom driver wizard initially generates only one source file: the name of the project followed by dep (for device entry point) and the extension (.C for C source files or .CPP for C++ files). For more complex drivers, it makes perfect sense to split the driver code into logically separate modules, or move some of the code that the wizard generated for you into auxiliary functions. By the same token, you may want to collapse some of the I/O functions into common entry points. This is perfectly fine, but not supported by the wizard—instead, you could manually change the code generated by the wizard if necessary.
First the function bodies: The user can specify whether he or she wants implementations of Open, Close, Read, Write, Flush, Cleanup, or IoCtl, or any combination of the above. For each function selected, a function prototype is added to the header file, a minimal function body is generated, and the code to register the function with the driver object passed to DriverEntry is added to DriverEntry.
If the user has also requested a StartIO function, the respective I/O function will simply mark the I/O request packet (IRP) as pending and call IoStartPacket. Thus, if you request a StartIO routine, all the code that will eventually handle the I/O request should go into the StartIO routine (a switch statement that dispatches in response to the appropriate major function codes has already been incorporated into that routine for you) or into the respective I/O functions.
Note that if you request an I/O control routine, you'll find that the code to retrieve the I/O control call ID and dispatch on the ID has already been provided for you.
The first dialog box also contains an entry for the number of devices supported by the driver. After the user has completed this dialog box, the custom driver wizard displays a dialog box for each device requested. That dialog box prompts for the name of the device, a name for an optional symbolic link, and a few properties of the device object, such as whether the device supports buffered I/O.
Determining and registering a device's name and the name for its symbolic link generate a lot of code in a device driver, because a number of data structures typically need to be allocated for the strings to hold the names. Furthermore, the names are frequently assembled at run time from data found in the registry. In the simplest case (a name hard-coded into the driver), all it takes is one call to RtlInitUnicodeString. In the more complex cases, a significant amount of work is necessary to build the name string.
The custom driver wizard builds code for you to allocate a variable of type UNICODE_STRING for each device and symbolic link name. If an absolute name for a device and/or symbolic link is given, the string is automatically initialized to that name; otherwise, you need to manually add the code to determine the name. In any case, the strings are passed to IoCreateDevice or IoCreateSymbolicLink, respectively.
If the user also requested an interrupt service routine (ISR), an ISR function body is created, and within DriverEntry, calls to HalGetInterruptVector and IoConnectInterrupt are added for each device that requested an ISR. Because the logic needed to connect an ISR varies greatly from driver to driver, the code to connect to an interrupt is kept rather generic and included in comments, with a number of slots to be filled by auxiliary code. In most cases, all devices that are supported by a particular driver share the same ISR, so a function body for only one ISR is generated.
Because the code to create the devices along with their respective properties can be fairly complex, the custom driver wizard will generate the CreateDevices function, which contains all the code for creating and initializing the devices. CreateDevices is called from within DriverEntry.
Let's look at a simple example of a driver that can be written really quickly using the driver wizard. The driver we are going to write is a very simple RAM disk driver: A user-mode application can write to or read three strings using the Microsoft® Win32® WriteFile or ReadFile function. With an I/O control call, the user-mode application can choose one of the three strings. Let's first look at a very small user-mode application that utilizes the driver:
#include <stdio.h>
#include <windows.h>
#include <winioctl.h>
#define IOCTL_BOGUS_SELECT_STRING CTL_CODE(33000,0x800,METHOD_BUFFERED,FILE_ANY_ACCESS)
main()
{ HANDLE hOpenFile;
int NoBytesWritten;
char szBuf[100];
char chSel;
int inBuf,outBuf,iBytesRet;
hOpenFile = CreateFile("\\\\.\\RAc0",GENERIC_READ|GENERIC_WRITE,FILE_SHARE_READ,
NULL,OPEN_ALWAYS,FILE_ATTRIBUTE_NORMAL,
NULL);
if (hOpenFile == INVALID_HANDLE_VALUE)
{ printf("Error creating file on RAc: %d",GetLastError());
scanf("%d",&NoBytesWritten);
return (0);
};
inBuf = 0;
scanf("%c",&chSel);
while (chSel != 'q')
{ switch (chSel)
{ case 'r':
if (!ReadFile(hOpenFile,&szBuf,100,&NoBytesWritten,NULL))
{ printf("Error reading from file on RAc: %d",GetLastError());
}
else printf("String read: %s, size read: %d\n",szBuf,NoBytesWritten);
break;
case 'w':
printf("String to enter: ");
scanf("%s",szBuf);
if (!WriteFile(hOpenFile,&szBuf,strlen(szBuf)+1,&NoBytesWritten,NULL))
{ printf("Error writing to file on RAc: %d",GetLastError());
}
else printf ("Write to File RAc succeeded...\n");
break;
case 'c':
printf("Index to manipulate: ");
scanf("%d",&inBuf);
if (!DeviceIoControl(hOpenFile,IOCTL_BOGUS_SELECT_STRING,
&inBuf,4,&outBuf,4,&iBytesRet,NULL))
printf("Error sending IOCTL call to Driver: %d",GetLastError());
break;
default: printf ("Command %c not recognized: Use c, r or w\n");
};
scanf("%c",&chSel);
scanf("%c",&chSel);
};
// End of file manipulation stuff; now release handle
CloseHandle(hOpenFile);
printf("Completed Test for RAc: Done.");
return(0);
}
First, the application must be able to address the driver. This is done through a call to CreateFile from the application:
hOpenFile= CreateFile("\\\\.\\RAc0:",...)
(For a user-mode application to be able to address our device, note that the driver must create a symbolic link object that links the name RAc0: to the device. We will see how this is accomplished within the driver later.)
After the application has obtained a handle to the file, it enters a loop in which the user repeatedly types "r" (to read the contents of the current string), "w" (to change the current string), or "c" followed by an index (to change the current index). These requests are passed on to the device driver as Read, Write, or IOCtl requests. After the user is done interacting with the driver, the handle is closed. Thus, the interaction between the user and the driver is as simple as can be—it uses only the system-provided services CreateFile, CloseHandle, Read, Write, and DeviceIOControl (calls that the driver sees through its own entry points for IRP_MJ_OPEN,IRP_MJ_CLOSE,IRP_MJ_READ,IRP_MJ_WRITE, and IRP_MJ_DEVICE_CONTROL, respectively).
To build the driver using our application wizard, you first need to build the custom driver wizard. After that, select New Project Workspace from the File menu and select Windows NT Kernel Mode Device Driver from the Type combo box. Let's assume that your driver is named BOGUS.
Select C or C++ support, whichever you prefer (in the code below, C is assumed). In the second dialog box, choose Open, Close, Read, Write, and IOCtl from the Supported Operations group box. Leave "1" in the Number of Supported Devices edit box, and add Unload and/or StartIO to your selections in the Supported Operations group box if you wish.
When you click Next, a dialog box will ask you for the properties of the device that your driver will be supporting. Set the focus to the Driver Name dialog box and choose, say, Bogus0.
We will also want to address the device from a user-mode application, so we need to create a symbolic link. Set the focus to the Symbolic Link Name edit box and enter RAc0: (or however you wish to identify your device to a user-mode application).
Select Finish, review the choices you made, and let the custom driver wizard generate the project files for you. Visual C++ will now generate and open a makefile for you. The first thing you want to do is discard the makefile because, as I mentioned earlier, the project options for a kernel-mode device driver are radically different from the options for a Microsoft Foundation Class Library (MFC) application, so you must use a different makefile. Select Open from the File menu and select BOGUS1.MAK, which is a makefile that the custom driver wizard generated for you. You can now build and test the driver.
Note Because of the problem with the debug support that I discussed in "Writing Windows NT Kernel-Mode Drivers in C++," debug ("checked") versions of the driver that can be symbolically debugged with the Windbg kernel debugger must be linked manually. For your convenience, the BOGUS.LNK file has been generated by the wizard for that purpose.
The driver skeleton that we just built will compile and yield a valid driver, but the driver won't do anything until we add the code for the Read, Write, and IOCtl logic, as well as some initialization code. For example, add three global variables to your project:
char szReadData[3][MAX_BOGUS_STRING_LENGTH];
int iReadLength[3];
int iReadArrayIndex;
szReadData holds the strings that the user-mode application can interact with, iReadLength is the current length of each string, and iReadArrayIndex determines which string is currently selected.
In DriverEntry, right before or right after the call to CreateDevices, add code to initialize the variables; for example:
RtlMoveMemory(szReadData[0],"String #1 default, length is 32",32);
iReadLength[0] = 32;
RtlMoveMemory(szReadData[1],"String #2 default, length is 32",32);
iReadLength[1] = 32;
RtlMoveMemory(szReadData[2],"String #3 default, length is 32",32);
iReadLength[2] = 32;
iReadArrayIndex = 0;
Depending on whether you requested a StartIO routine, the following code should go either into the IRP_MJ_READ switch in the StartIO routine, or into the body of the BogusRead function. (Note that the user-mode buffer referenced here assumes buffered I/O; if you requested unbuffered I/O, the address of the buffer must be obtained differently.)
This code snippet will simply copy the current string into the buffer provided by the user-mode application:
RtlMoveMemory(Irp->AssociatedIrp.SystemBuffer,
szReadData[iReadArrayIndex],
iReadLength[iReadArrayIndex]);
Irp->IoStatus.Status = STATUS_SUCCESS;
Irp->IoStatus.Information = iReadLength[iReadArrayIndex];
Accordingly, the code to Write looks like this:
RtlMoveMemory(szReadData[iReadArrayIndex],
Irp->AssociatedIrp.SystemBuffer,
Irp->Tail.Overlay.CurrentStackLocation->Parameters.Write.Length);
iReadLength[iReadArrayIndex] =
Irp->Tail.Overlay.CurrentStackLocation->Parameters.Write.Length;
Irp->IoStatus.Status = STATUS_SUCCESS;
This code is slightly more complex, because now we will also need to set the length of the current string to the length of the string provided by the user.
Finally, this is how you can implement the IOCtl functionality (note that the body of the switch expression has already been generated for you):
switch (curIRPStack->Parameters.DeviceIoControl.IoControlCode)
{ case IOCTL_BOGUS_SELECT_STRING:
iReadArrayIndex = *(int *)Irp->AssociatedIrp.SystemBuffer;
status = STATUS_SUCCESS;
break;
default:
status = STATUS_IO_DEVICE_ERROR;
};
Voila! Your little driver is now ready. After registering the driver with the registry, you should now be able to communicate with your driver from an application like the one I sketched earlier. Not bad—you added only four little code snippets to a driver skeleton that the driver wizard created interactively.
Although this is a simple driver, it needs to go a long way before it can be shipped. In particular, it lacks error handling. Almost every system call submitted anywhere in the driver code (IoCreateDevice, IoCreateSymbolicLink, RtlInitUnicodeString, IoStartPacket, and so on) can fail, and it is your responsibility as a device driver writer to handle all possible cases of failure in a predictable and stable manner. The code generated by the wizard stores the return values of all system calls in a local variable, status, which you need to check immediately after the call returns. If the call does not return successfully, you must add code that recovers gracefully from the failure. Graceful recovery normally includes error logging, deallocation of previously allocated data structures, and error return from the enveloping function.
The custom driver wizard I presented can significantly reduce the work involved in writing Windows NT™ kernel-mode device drivers. However, the code generated by the wizard can only serve as a starting point and may require considerable rewriting to yield a working prototype for a driver.
The custom driver wizard could be made smarter and more powerful in a few areas. For example, it is possible to generate driver code that queries the registry and builds driver and symbolic link names dynamically. Another possible enhancement is to automatically reverse all initialization code in an Unload routine, if that routine is requested. (Currently, the Unload routine doesn't do anything.)
A small installation application would probably help significantly in device driver writing. This application would copy the driver binary to the system directory and add the registry entries necessary to register the driver with Windows NT. The skeleton for such an installation application could easily be written using another custom AppWizard.
In any case, it is important to realize that the custom driver wizard only does some of the grunt work for you. The creative and challenging work underlying device driver creation—namely, implementing the I/O performed by the supported devices, synchronizing user-mode I/O requests with hardware services, and so on—cannot be performed or automated by software.