Device Control


Encapsulating the DOS IOCTL Interface

Tom Nelson


Tom Nelson is an independent programmer and technical writer. His current interests are systems programming for DOS/Windows and OOP designs expressed in C++. He may be reached at 5004 W. Mt. Hope Rd., Lansing, MI 48917. Telephone (517) 322-2072.

IOCTL (Input/Output Control) constitutes a subset of the MS-DOS API and forms an interface between application programs and MS-DOS device drivers. Programmers can use IOCTL to perform a wide variety of system tasks in a relatively hardware-independent manner. Though it may seem inconvenient and somewhat obscure, IOCTL is possibly among the most under-exploited group of DOS functions. It consists of a collection of some 18 subfunctions and 20 minor functions providing a wide assortment of services, albeit with its share of quirks and idiosyncrasies.

Since many of IOCTL's functions seem unrelated, it's difficult to perceive them as part of a consistent, logical unit. This inconsistency, combined with its quirks, makes DOS IOCTL a good candidate for some form of encapsulation. In this article I describe how encapsulating this OS-dependent interface in C++ can make your code more maintainable and extensible, thus easier to use. You may also find this article useful if you need to convert another interface or functional group into something manageable.

What IOCTL Can Do

IOCTL is concerned mainly with device (and file) I/O status, configuration, and control (see Table 1) . IOCTL was introduced with MS-DOS 2.0 to deal with installable device drivers — then a novelty for MS-DOS. Starting with MS-DOS 3.2, Generic IOCTL added low-level access to disk drives and character devices. ROM BIOS duplicated much of this capability (such as sector read/write and track format), but Generic IOCTL removed some concerns regarding incompatibilities between different BIOS implementations, allowing programmers a greater measure of device independence.

You can divide IOCTL into either handle- or block-device-based functions. You can further break the handle-based functions into those dealing with files or character devices (Table 1) . The file and character-device functions take an integer handle that you get from calling open or the equivalent. The block device functions take a logical drive number that is always "1-based"; that is, drive A: = 1, B: = 2, etc., while you indicate the default drive with 0.

Class Design Issues

IOCTL's natural division into two main groups (handle- or drive-based) provides a framework on which to implement an encapsulated design, shown in Figure 1. The base class manages access to features common to both character and block drivers. From there, each descendant class focuses on more specific capabilities and requirements. The resulting design restricts each object to a particular handle or block device. This constraint prevents errors arising from use of invalid handles or drive codes when you access class member functions.

If you want access to a character-device driver, for instance, you pass the device's handle to the class constructor for initialization. Thereafter, each member function uses only that validated handle as an argument. To access another device, you must create another IOCTL object. This design provides you with a secure pathway when you want to access a particular device driver. It's secure because of all the error checking and other implementation-specific stuff built into the class, but hidden from outside view.

Handling Constructor Failure

A practical design must still confront the problem of constructor failure, an issue that is easy to overlook. Since constructors can't return a value indicating success or failure, the design must provide some other way to check on the constructor's success. One possible design would require each of the member functions to check for correct initialization. A slightly better approach would use a single, specially-designed member function to validate the object's initialization. However, this approach still has a drawback: in the event of constructor failure, you may need to delete the object, which you could easily forget to do.

The solution I have adopted requires users to call a special front-end function or class "initializer" to create a new object [1]. The initializer calls the (protected) constructor, which does the actual work of initializing the object. The initializer then tests an internal data member, set by the constructor, for an indication of constructor success or failure. The initializer returns a pointer to a new object if construction was successful, or NULL otherwise. From a user's point of view, this approach makes object construction feel similar to calling fopen to open a file. (The term "class initializer" may seem like a misnomer in that it doesn't do any real initializing by itself. However, the term still seems appropriate since it's the only member function in public view empowered to create and initialize a new object.)

The initializer must also be a static member of the class it initializes. Recall that static member functions, unlike normal member functions, may be called before an object of their class type is created. Therefore, if the initializer returns NULL, no object is created and the program has nothing to clean up. The code fragment in Figure 2 illustrates the use of static class initializers.

Class Implementation Overview

The design implementation mirrors the basic division of the IOCTL subset into handle- and block-based functions (see Figure 1) . Class IoctlBase sits at the top of the class hierarchy and provides two descendant classes, IoctlHandle and IoctlBlock. Class IoctlHandle manages access to handle-based functions for files and character device drivers, while IoctlBlock operates similarly for block drivers. Since you use handles to access both character devices and files, class IoctlHandle further serves as a base class for two other derived classes: class charHandle works only with handles created for character devices, while class fileHandle works only with file handles.

Due to the volume of code necessary to implement this design, I discuss only the handle-based classes in this article. [Code for the IoctlBlock class appears on this month's code disk. — mb] However, the sidebar presents a synopsis of all the IOCTL class member functions. Also note that class charHandle does not include member functions to manage device code pages. If you need these, you can easily modify charHandle to incorporate them, or place them in a derived class.

The Base Class

Listing 1 and Listing 2 present IoctlBase, the IOCTL base class. enum blocks in IoctlBase list all IOCTL subfunctions and minor codes. Function int21_44h invokes the specified IOCTL subfunction via intdosx. Protected REGS and SREGS objects facilitate access to int21_44h (and intdosx) by derived classes. int21_44h sets _dos_error and calls the virtual function IoctlError if it detects a DOS error (by inspecting the returned carry flag). You can override IoctlError in any derived class to provide customized error handling.

The IoctlBase constructor determines the MS-DOS version and places it in a form allowing direct version comparisons. Switching the high and low bytes obtained from DOS puts the major version number in the most significant position for greater/less than comparisons. The classes I present here do not prevent access to member functions based on the DOS version. You may want to add access restrictions to these classes to take best advantage of IOCTL's wide variety of version-specific services.

Handle-based IOCTL

Listing 3 presents the IoctlHandle class definition. You can use IoctlHandle objects to access either character device drivers or files. The inclusion of files in this context might seem inconsistent in that device drivers for files don't exist. However, because both character devices and files require handle access, they're grouped into one category here.

As I mentioned earlier, I designed the IOCTL classes to provide constructor error recovery through special initialization functions. The static function IoctlHandle::Init (Listing 4) returns a pointer to an IoctlHandle object, guaranteed to be correctly initialized, or your memory back. If Init detects incorrect object initialization, it deletes the new object and returns NULL. Using such a class initializer requires you to allocate new objects from the heap via the operator new; you can't allocate IoctlHandle objects on the stack. Similarly, you must explicitly deallocate IoctlHandle objects using the delete operator.

Note also that IoctlHandle::Init is overloaded. You can pass Init either an existing handle (even a pre-opened, standard handle such as stdprn, but be careful of redirection) or a pathname that specifies the file or device to open. In either case, Init calls the appropriate constructor (also overloaded) to do the actual initialization. The constructor sets the internal data member _handle to -1 if you passed it an invalid handle or pathname. If successful, it assigns the validated handle to _handle (the "current handle"). IoctlHandle member functions use _handle as an argument to the corresponding IOCTL service. When the constructor returns, Init checks for an invalid handle and proceeds as outlined earlier.

The constructor IoctlHandle::IoctlHandle also reads the handle information word from DOS and assigns it to the internal data member _info. IoctlHandle uses _info mainly for small bit manipulator functions, declared inline. Although using _info probably makes negligible difference in terms of execution time, it seems less awkward than rereading the handle information word each time from DOS.

Extending IoctlHandle

You can initialize IoctlHandle objects using either file or device handles. However, some member functions are valid only for file handles and others only for device handles. As originally designed, the class offered little protection, other than function (error) return codes, against someone attempting to use the wrong member functions (for example, passing IOCTL data to or from a device driver when the IoctlHandle object was initialized with a file handle). Even though return codes provided a workable solution, I wanted to provide superior error protection by preventing errors of this type in the first place, instead of dealing with them after the fact.

To implement this concept, I removed all file- and device-specific member functions from IoctlHandle. That left IoctlHandle with functions that you can safely call using either file or device handles. I placed the remaining functions into two new classes, charHandle and fileHandle, deriving both from IoctlHandle.

Class charHandle (Listing 5 and Listing 6) restricts IoctlHandle so that you can create new objects only by passing charHandle::Init a valid character device name. The private non-member function getcat attempts to match the device name argument you supplied with a pre-initialized table of valid device types. Object initialization fails if getcat finds no match, _handle refers to a file, or if the parent IoctlHandle constructor fails for any reason. If successful, the constructor assigns the correct device category to _cat. Member functions can use _cat when accessing any of the Generic IOCTL functions and thus also disregard errors arising from invalid device types.

In analogous fashion, class fileHandle (Listing 7 and Listing 8) confines IoctlHandle objects to valid file handles. It also uses overloaded initializers so that you can create new objects using either path/file names or pre-opened file handles. Both versions of fileHandle::Init fail if the device bit is set in the handle information word. Effectively limiting user access to a class means that you can trap certain errors up front, instead of leaving this to member functions.

Code Demonstration

Listing 9 presents a simple file print utility that demonstrates the use of both the charHandle and fileHandle classes. You can use FilePrint objects in an application to print files in the background while the user does something else in the foreground. Note that FilePrint does not inherit any of the IOCTL classes, but simply initializes pointers to them when you attach an output device and submit a file to print. FilePrint doesn't need an internal view of charHandle or fileHandle. It utilizes the services they provide to open and validate the output device. It also ensures that the file you want to print is really a file and not a character device.

Text References

1 Burk, Ron. "Practical C++: Constructors that Fail," Windows/DOS Developer's Journal, August 1993, pp. 29-34.

2 Microsoft Corp. MS-DOS Programmer's Reference. 2nd ed., Version 6.0. Microsoft Press, Redmond, WA.