Device Control


Windows Device Drivers

Daniel A. Norton


Daniel A. Norton has translated the most technical aspects of Windows programming into meaningful explanations in his new book, Writing Windows Device Drivers. He is the principal consultant for Cherry Hill Software, a firm that helps companies enter into and become expert in all aspects of Windows and Windows NT development. You can contact him through CompuServe at 76050,2204.

Device drivers are typically the most critical part of computer software. Ironically, they are also the most arcane and hidden part of software development. Device drivers for Microsoft Windows are no exception. If you have ever written a normal Windows application, you know that it requires a number of hidden techniques in order to get an application working reliably. As a subset of Windows applications, Windows device drivers follow this rule closely. In this article, I illustrate a working device driver that accesses I/O ports and processes interrupts, and a virtual device driver (VxD) that simulates hardware. I'll presume that you have a basic understanding of Windows programming including dynamic link libraries (DLLs).

The Device

The example device is not some piece of hardware that I developed to illustrate how to write a Windows device driver. Rather, it is a virtual device implemented entirely in software. The example code only works with the virtual device I have defined when running Windows in 386 Enhanced mode and while my virtual device driver (VxD) is installed. I will describe the source code for this device in more detail later. For now you need to know that the device has two ports: a status port and a control port, both at the same address. Figure 1 illustrates the bits used in the status port. Bit 2 indicates that a device error has occurred, bit 1 indicates that an interrupt request is pending, and bit 0 indicates that the device is busy. Bit 7 indicates that the device is present. The device always presents a zero bit here. If the device is not installed or is inaccessible, this bit will appear as a one.

Figure 2 illustrates the bits used in the control port. Bit 1 indicates to the device that the CPU is finished processing an interrupt. Bit 0 indicates to the device that it can begin I/O processing. (For now, don't worry about what the device actually does. Instead, focus on how to write a device driver for such a device that provides hardware interrupts.)

An MS-DOS Device Driver

Listing 1 shows dostest.asm, a normal MS-DOS device driver that talks to the device. Though simple and small it contains the salient components of a device driver that processes interrupts.

The device driver starts by checking the high bit of the status port to confirm that the device is present. Next, it hooks the MS-DOS interrupt vector for interrupt 11. The driver saves the previous value stored in this vector so that it can replace the value when the program exits.

The device driver then prompts the user to Start or Quit. If the user presses S, the program begins I/O transfer. When the user presses Q, the program disables the device, restores the interrupt vector, and exits.

To start I/O, the MS-DOS driver first unmasks the programmable interrupt controller (PIC) for the device's interrupt level (11 in this example). Next, the driver starts device I/O by writing a 1 into bit 0 of the device control port. Since interrupts are enabled, the interrupt service routine (ISR) will take control when the device interrupts.

When the device interrupts, the ISR first acknowledges the interrupt by sending an EOI to the device (writing a 1 to bit 1 of the device control port) and to the PIC. If the program is in the process of exiting, the ISR is done. Otherwise, the ISR re- initiates I/O transfer by writing a 1 into bit 0 of the device control port. Thus, this interrupt service routine restarts I/O whenever an interrupt occurs so that the device is continuously performing input/output. In addition to continuing I/O, the ISR increments a count (dwCount1) each time it processes an interrupt.

While I/O is in progress, the program watches the interrupt count, displays a dot (".") for each completed I/O transfer, and continues to scan the keyboard to see if the user wants to stop transfer.

To end the program, the user presses Q. The program sets a flag that tells the ISR to stop processing. Once I/O stops, the program masks the interrupt level at the PIC and restores the interrupt vector.

A Windows Device Driver

The extremely trivial MS-DOS device driver just described is substantially more difficult to implement in Windows. When writing a Windows device driver that handles interrupts, you need to use a different architecture than used with an MS-DOS driver. Specifically, you need to isolate the interrupt handling component from the application component. Instead of a single program handling both the ISR and the user interface as in MS-DOS, in Windows you must divide these functions up into separate program modules called a Dynamic Link Library (DLL) and an Application Program Interface (API).

The Driver DLL

When writing Windows applications, you normally only concern yourself with two types of segments in a program module: moveable and discardable. Program data segments are moveable, that is, their linear addresses in memory may change as the Windows memory manager needs to organize memory. The selector and offset used to access a particular memory item remain fixed, but underneath the selector/offset scheme, Windows can move the actual data around in linear memory.

Program-code segments are also moveable, but have the additional attribute of being discardable. Their contents may be discarded entirely and, when needed, read back from disk, because you cannot write to and/or modify the information in a program-code segment. If a segment happens to be discarded when called from elsewhere in a Windows program, the Windows memory manager will automatically access the disk and read in the (formerly) discarded segment.

So how does this affect the code for an ISR? Since an interrupt can occur at any time, if you discarded the ISR code, you might have trouble loading it back in memory when an interrupt occurs. So instead, you declare the segment as FIXED, rather than MOVEABLE or DISCARDABLE. A FIXED segment will remain in a single location in linear memory and will not be discarded, even if it contains code. That way, if an interrupt occurs, the code will be available and ready to run.

One not-so-well known fact about FIXED segments, however, is that Windows only honors FIXED segments that are declared in a DLL. A FIXED segment in a normal program module is treated as MOVEABLE. Thus, Windows will not allow you to put the ISR in a normal program module. Instead you must put it in a DLL.

Listing 2 shows bogusa.asm, the assembler source code for a DLL that contains an ISR that will run in the Windows environment. The routine IntSvcRtn looks very much like its MS-DOS counterpart. However, instead of just incrementing a count variable, this ISR also posts a Windows message to a queue. To avoid overflowing the queue, it only posts the message when it increments the counter variable wCount from zero to one. The program relies on the higher-level Windows program to reset wCount to zero after it has processed a message.

This seems simple at first glance, but hooking the interrupt under Windows is not nearly as simple as it is under MS-DOS.

The Driver API

In addition to a separate program module for the ISR (in the form of a Windows DLL), you also need a program module for the user interface, called the API. Listing 3 contains a sample API called bogus.h. It contains four entry points into the DLL.

BogusCheck simply checks for the presence of a device. It returns TRUE if it detects the device hardware (bit 7 of the status port) or FALSE if not.

BogusStart and BogusStop start and stop the device. In addition, BogusStart enables interrupts and hooks the hardware interrupt, and BogusStop disables device interrupts and restores the hardware interrupt.

BogusGetEvent returns the number of interrupts processed since the device was first started or since the last call to BogusGetEvent. (BogusGetEvent zeros the interrupt count each time it is called.)

Interrupts in Windows Standard Mode

When writing a driver that might run in Windows standard mode, you must consider the possibility that an interrupt might occur with the processor running in real mode. Even if you run only Windows applications and not MS-DOS applications, the processor frequently switches between real and protected modes. Because Windows 3.1 is not an operating system, but, rather, a user-interface environment, it relies on its operating system (namely MS-DOS) to perform a number of basic functions, including file I/O.

Therefore, while a Windows application performs MS-DOS file I/O with the processor in real mode, a device can interrupt the CPU. By default, if a DLL has hooked an interrupt, Windows would switch the CPU back to protected mode to process the interrupt and — once the ISR is finished — would switch the CPU back to real mode to continue MS-DOS processing.

Although less of a concern with an 80386 CPU, switching the processor from protected mode to real mode on an 80286 processor creates tremendous overhead, requiring a controlled reset of the CPU and taking on the order of milliseconds to complete. If you need faster average response times, you must keep the processor from switching to protected mode when it receives an interrupt while the processor is in real mode.

Hooking the protected-mode interrupt vector from a Windows DLL is trivial as illustrated in the SetPMVector routine in Listing 4 (bogus.c). You hook the vector in the same way that you would in MS-DOS — by simply calling the MS-DOS setvector function. Unlike the call from MS-DOS, however, in Windows you pass a selector and offset instead of a segment and offset. The Windows kernel takes care of everything, so you don't need to be concerned with this anomoly. You pass a normal selector and offset (the natural far pointer for Windows) and not a segment and offset (the natural far pointer for MS-DOS).

As just mentioned, however, hooking the protected-mode interrupt vector is not enough. You must also hook the real-mode interrupt vector, a not-so-trivial task.

The MS-DOS Protected Mode Interface

In order to hook a real-mode vector from protected-mode Windows code, you need to work with the MS-DOS Protected Mode Interface (DPMI). (The current version of DPMI is level 1.0, but Windows only fully implements level 0.9. A few 1.0 functions are implemented in Windows 3.1)

The function DPMI_SetRMVector calls DPMI to set a real-mode vector. As you can see, the DPMI interfaces via registers (AX always contains the function code) and INT31h. I've included a high-level interface to this and other DPMI functions (available on the code disk or the online sources only) so that you can access the DPMI functions from C, and to isolate the assembly-language code in case you should happen to be using something other than a Microsoft C Compiler.

The function DPMI_AllocateRMCallback calls DPMI to allocate a callback, an address that can be called from real mode that will transfer control to protected-mode code. For example, an MS-DOS TSR can call code in a Windows DLL through a callback.

DPMI_AllocateRMCallback accepts two parameters: the address of the protected-mode code that will be called back and a register structure that is updated when the actual callback is made, so that the protected-mode code can examine the contents of the real-mode registers at the time of the callback.

DPMI_FreeRMCallback frees all of the structures that were allocated as a result of the call to DPMI_AllocateRMCallback. DPMI_FreeRMCallback must be called after you no longer need the callback.

The Real-Mode ISR

Although I mentioned that it is best to provide a separate real-mode ISR, in this example I'm not doing that. Instead, I am providing you with the routines you would need to implement it yourself from C. In fact, this example hooks the real-mode interrupts only to switch the CPU to protected mode to process this interrupt. This is the default behavior of Windows when the real mode interrupts are not hooked at all, so I am illustrating a few gyrations that have no effect, beyond the exercise of seeing how it all works.

Look at the code for BogusStart. Essentially, it works as it would in MS-DOS. It saves the old interrupt value, hooks in the current value, and starts the device up. Instead of hooking just the protected-mode vector, however, it hooks both realand protected-mode vectors. In hooking the real-mode vector, it calls AllocIntReflector to point the real-mode interrupt vector to a callback which simply calls the protected-mode ISR. BogusStart starts the device the same in protected mode as it does in real mode. It unmasks the IRQ for the PIC, and starts up the device by writing a 1 to the START bit of the device-control port. Once an application calls this routine, interrupts are processed and messages are posted according to the ISR.

BogusStop is trivial and simply turns off the device and undoes the hooks set up by BogusStart. All that is left is an application program to show us I/O activity.

The WINTEST Application

The application that shows I/O activity, wintest.c (Listing 5) , consists primarily of a non-modal dialog box that continuously shows the number of interrupts processed since the program started.

MainDlgProc calls BogusStart during WM_INITDIALOG processing, passing the window handle of the dialog box as the parameter. The ISR posts messages to this handle when the interrupt count changes from zero to one.

MainDlgProc keeps a running total count of interrupts in wCountTotal. Whenever the dialog receives a WM_COMMAND message with wParam equal to IDM_BOGUSEVENT, the routine updates the total count displayed in the dialog box. Note that although the ISR only posts a message when the count changes from zero to one, it is possible (and quite likely) that a number of interrupts may be processed before the WM_COMMAND message is actually passed to the dialog procedure. The method shown here, where the ISR only posts the message at the first transition and BogusCheck clears the count, ensures an accurate count of the number of interrupts even if, at the application level, you are unable to keep up with each interrupt as it occurs.

When running this program, you can watch the count of interrupts in the dialog box increase steadily, indicating the number of I/O operations performed.

The Virtual Device Driver

The file vxd2.asm (Listing 6 and Listing 7) is the source code to the bogus device driver. Note that to build this driver you need the Microsoft Windows Device Driver Kit (DDK) because the code is written for the 32-bit assembler provided in the DDK (MASM5). The resultant module can be linked only with a DDK linker (LINK386) and a post-linkage utility (ADDHDR). In addition, this source code relies on a number of include files that are only included in the DDK.

Besides the obligatory include files, typical VxDs begin with a Declare_Virtual_Device macro invocation which creates a data block that describes the virtual driver to the Windows kernel. This data block, in fact, is the only symbol exported from a VxD. All other entry points are derived from the data contained within. Among other things, this macro declares the name of the device, its initialization pecking order, and its entry points. It is possible for a VxD to service requests from real- and protected-mode applications. The entry points for such services are declared by this macro, too.

Device Control Events

As Windows progresses through its various stages, from Windows initialization, through VM initialization, and so-forth, every installed VxD is called repeatedly, once for each phase. Table 1 lists the phases of Windows and the major events for which each VxD is called.

This example VxD only processes the Device_Init control. It does this one to acquire and "virtualize" the I/O port and interrupt level 11. Normally a VxD would virtualize I/O ports and an interrupt corresponding to physical hardware. But in this case, the VxD can and does virtualize a port and interrupt that has no corresponding hardware attached.

You call Install_IO_Handler to virtualize a single I/O port. Subsequently, whenever the specified I/O port is accessed from a VM, the Windows Virtual Machine Manager (VMM) calls back the VxD to allow the VxD to simulate the I/O access.

You call VPICD_Virtualize_IRQ to virtualize the interrupt level. By doing this, you can simulate a hardware interrupt (IRQ 11 specifically) into a virtual machine.

The "Bogus" Device

When the device's I/O port (141) is accessed by a VM (either in real or protected mode), the VM calls the VxD Port_IO_Callback routine (see Listing 6) . In that routine, the Dispatch_Byte_IO routine simplifies the many possible types of I/O access (byte, word, dword, string, etc.) into two, byte input and byte output.

With this example device, byte input represents a read from the device status register. It simply returns a variable that it stores in memory.

Byte output is a little more complicated, since it represents the actual activity of the device. Starting the device also starts a timer that calls back (to TimeoutProc) in 1/10 second and sets the BUSY status. If the output indicates an interrupt acknowledgement, it clears the virtual interrupt request by calling VPICD_Clear_Int_Request and clears the status in the status register.

The callback to TimeoutProc represents the end of a device I/O operation at which time it simulates a hardware interrupt into the VM by calling VPICD_Set_Int_Request and clearing the device busy status. The device driver in dostest and wintest would typically process the interrupt by acknowledging it (sending EOI) and restarting the process all over again.

Note the procedures VxD2_VInt_Proc and VxD2_IRET_Proc. These two procedures are referenced in the structure that was passed to VPICD_Virtualize_IRQ. They are called at the beginning and end of interrupt virtualization into a VM. All that they do is raise and restore the priority of the VM. This raises the priority of a VM that is processing this interrupt temporarily. In this way a VxD can control a VM's priority when it deems appropriate. (You generally want interrupt service in any VM to take priority over normal processing in other VMs.)

Installing the VxD

Finally, once the VxD is built, before accessing it from a Windows program, you must add it as a device= line in the [386Enh] section of system.ini. Windows must be restarted to enable the VxD and the virtual device. After this, you can run and test the wintest and dostest applications.

It Gets More Complicated

As complicated as Windows device drivers may now seem, there is a tremendous amount of power offered by normal and virtual Windows device drivers. To put the complexity of VxDs into perspective, however, consider how much more complex they must be on a MIPS machine running Windows NT and 80x86 emulator code to allow a MS-DOS virtual machine to run. But that's a topic for another article.