Contents of the DOSDEV2.DOC file
Creating User-Installable Device Drivers in MS-DOS 2.0+
Bruce Bordner, 1985
1713 4th Avenue
Asbury Park, NJ 07712
Prior to version 2.0, driver programs to support new (or non-
IBM) peripherals required some complex and ugly programming to
interface with DOS. This became so much of a problem that it was
fixed in the first major revision (2.0) by providing a "legal" way
to install device drivers and interface with the DOS I/O functions.
This was primarily intended as a convenience for OEMs, but
Microsoft did include a chapter (14) about the subject in the DOS
manual. I haven't found any better source on the subject; which
means that I found practically no other information. [Only other
source: "Modifying MS-DOS Device Drivers" by Mike Higgins, Computer
Language 3/85 - a good article which can clarify the DOS
documentation, but does not treat several points explained here.]
Assuming for the moment that driver software can be home-
brewed, it is not an obviously useful technique. You could make
your own driver for a 500 megabyte drive rather than waiting for
the manufacturer to do it, but this is a last-ditch move. However,
a driver does not necessarily have to be controlling a physical
device. A DOS device driver must be a COM file, which limits the
code space to 64K total. It has a specified header and function
call structure. DOS will only make and accept I/O operations which
are given in the manual. You cannot return an error code other than
those used by DOS, and most of those are not returned to your
calling program. Other than that, you can do as you like. This
opens up many possibilities. Driver software becomes a part of the
memory-resident sections of DOS during the boot-up operation. It
becomes another resource available to any of your programs. The
classic example, as given in the DOS manual, is a ram-based disk
simulator. This is a "virtual" device, where the code and data is
seen as a package by the operating system and user programs. This
module can perform any function which you can fit into a COM
program, with predefined interfaces to DOS, other programs, and
other device drivers. Although Microsoft built some limiting
assumptions into DOS which make it difficult to implement certain
functions, many possibilities remain. One option which I would
like to explore is to offload a driver to an outboard processor for
concurrent background operation.
_DOS Function Calls for Device Drivers_
In order to keep things simple, I only used the "extended file
management" (version 2.0) functions under system interrupt 21H.
These are used by the "fread()" and "fwrite()" functions of the C86
C compiler. The primary functions affecting drivers are:
3D Open a file or device, return a 16-bit file handle in
3E Close the file or device associated with the handle in
3F Read from a file or device. The following registers must
be loaded as indicated:
BX => file handle of device
CX => number of bytes to read ( 64K max)
DX => segment offset of your data buffer storage
DS => segment of your data buffer
Reads CX bytes from a device into the buffer address
given. What is not explained by Microsoft is that DOS
actually requests only one byte per call to the device
driver, making CX number of calls. This has all sorts
of ugly side effects, which will become evident in the
description of my sample driver. Microsoft has
apparently built in the assumption that all character-
oriented devices talk to fairly slow serial hardware,
like printers. If I read this right, DOS has the
ability to respond to interrupts between each byte
transferred, but it does make things clumsy. DOS
apparently increments the DS:DX buffer location after
each call. The driver will be re-run from START for each
byte, and must maintain its own data pointers to maintain
synchronism with the DOS transfer.
40 Write to a file or device.
Same as above, but data is transferred to your buffer
area. Same warning.
44 I/O Control for Devices. This function has 8 subfunction
codes which are used for some rudimentary device
controls. Subfunction 2 is to read CX number of bytes
from the "control channel" of the device, with the same
register settings as for the normal read. The stated
purpose is to provide a way of reading device driver
status rather than data from the device itself. However,
up to 64K bytes made be transferred per call. What your
device does with it is up to you. Subfunction 3 is the
corresponding write call.
For these calls, DOS actually requests CX bytes from
the device on one call. This is used in the second
version of my sample driver, which is much simpler than
the standard read-write calls used in the first version.
_How DOS Translates Your Read/Write Function Calls into Device
When any of the above functions is called by your application
program, DOS develops a data structure called the "Request Header"
by the manual. This structure consists of a 13-byte defined header
which may be followed by other data bytes depending on the function
requested. The fixed part of the request header is as follows:
0 Length in bytes of the total request header (0-255)
1 Unit code, used to determine subunit to use in block
devices (not used for character devices).
2 Command code (0-12) to activate specific device
3-4 Status word, returned by the driver
5-12 The manual states that this area is "reserved for
DOS". Another source indicates that this consists
of two double-word (4-byte) pointers to be used to
maintain a linked list of request headers for this
device and a list of all current device requests
being processed by DOS. This is apparently in the
works for a future concurrent-DOS.
The 13 command codes are detailed on pages 14-12 of the
manual; only the following are used by the character devices
explained in this paper:
0 INIT - perform all initialization required at DOS
boot time to install the driver and set local driver
3 IOCTL INPUT - read a specified number of bytes from
the device driver's IO control channel.
4 INPUT - normal device "read". Reads a number of
bytes from the device your driver is controlling.
8 OUTPUT - normal device "write" call from user
12 IOCTL OUTPUT - write bytes to driver control
For each of these function calls, the driver receives the
INIT: This function must be built into any driver program. It
is called only by DOS during boot time, to reserve the system
memory needed to hold the driver and to link the driver into the
set of active devices managed by DOS. DOS sends: 13-byte request
BYTE number of units (not used by char devices)
DWORD ending address of driver
DWORD pointer to BPB array (not used by char devices)
The driver program must load the ending address at a minimum; any
local initialization may also be performed at this time.
INPUT, OUTPUT, IOCTL INPUT, or IOCTL OUTPUT:
For all of these, DOS sends:
13-byte request header
BYTE media descriptor (not used for char devices)
DWORD offset and segment of the data buffer in calling program
WORD number of bytes to transfer in this call
WORD starting sector (not used for char devices)
The driver must perform the requested read or write function, set
the "number of bytes to transfer" location to the number actually
done, and set the status word in the request header to indicate any
The actual use of these structures will be detailed in the
driver function description.
_Required Structure for a Device Driver_
Listing 1 (DOSDEV.ASM) is a template containing the minimum
requirements for a character-oriented device driver. This is
detailed in pages 14-3 to 14-8 of the DOS manual. The driver
program must meet the requirements of a normal COM file. However,
COM files usually start with an ORG 100H to allow room for the DOS
Program Segment Prefix structure. For a driver, you must use ORG
0, as the PSP is not used. The Device Header data structure must
be the first object defined in your file. It consists of:
DWORD Pointer to the next device driver currently
installed. This should be initialized to -1,
DOS will fill this field as necessary during system
WORD Device attribute. I used C000H to indicate that
this is a character device with IOCTL capability.
This field is also used to indicate if this device
is to be the standard output or input device.
WORD Pointer to "device strategy" function in the driver.
This function is called whenever a request is made
to the driver, and must store the location of the
request header from DOS.
WORD Pointer to function which activates driver routines
to perform the command in the current request
header. This is called by DOS after the call to
the strategy function, and should reset to the
request header address stored by "strategy", to
allow for the possibility of interrupts between the
8-BYTES Name field. For character devices, fill this with
the name which you must use when opening the device.
After this structure, you may include any local data
definitions needed for the internal operation of your driver. The
DOSDEV example includes only the minimum; a pointer to the request
header and a table of addresses of the functions which will be
called by the command code from DOS. The function addresses are
arranged according to their calling function code (0 to 12) so that
the function router can use the DOS command code as an offset into
_Required Device Driver Functions_
For simplicity, I will discuss these functions as given in the
XDV STRAT: This function is called directly by DOS when a
request has been made to use this device. Its only purpose is to
save a segment and offset pointer to the request header. At the
time DOS calls the device, the segment of the request header is in
register ES and the offset is in register BX. These values are
copied into the variables RH SEG and RH OFF. The fact that
Microsoft calls this a "device strategy" function leads me to
believe that more complex processing will be required in this
function when DOS becomes multi-user or multi-processing oriented.
XDV FUNC: This is called by DOS immediately after XDV STRAT.
The function pushes all machine registers to save the current data
until the device has finished the requested operation. Data
segment register DS is set to the Code segment value, as all local
variables exist in the code segment. Registers ES and BX are
loaded from RH SEG and RH OFF to reset them to the start of the DOS
request header. The command code from the request header (at
ES:[BX+2] ) is then used as an offset into the function address
table FUNTAB to initiate the driver function requested. In DOSDEV,
only the INIT function has been coded, all others drop out to EXIT
after setting the status word of the request header to "done; no
error". All you need to do is fill in the function code for any
driver function you intend to use.
INIT: When DOS is booted, it reads your CONFIG.SYS file to
determine which programs to install as device drivers
(DEVICE=filename.ext). After loading the file image into memory,
DOS sends a request header with the command code "0" to the device.
The INIT function must load an offset (at ES:[BX+14]) and segment
value (at ES:[BX+16]) into the request header to indicate the
ending address for the driver program, including space for any
memory used as a virtual device. The function may also do any
initial variable setting within the driver. INIT then exits back
to DOS, which uses the address given to set the boundary of DOS
including the new driver storage.
EXIT: This function restores all machine registers and
returns to DOS.
_Examples of Character-Oriented Device Drivers_
Listing 1 (STKDEV.ASM) and 2 (STKDEV2.ASM) show the use of a
virtual device driver to implement a "stack". User programs may
"push" bytes or entire records by writing them to the device, and
"pop" them with a read request. I/O control calls are used to set
the record size to be used by the driver. This may not be very
useful in itself, but this example shows solutions to most of the
problems without being difficult to read. STKDEV is constructed
in the recommended fashion; I got much of the code from the example
device driver in the DOS manual. Read and write calls from the
user program activate the functions INPUT and OUTPUT, while IOCTL
IN and IOCTL OUT are used to read and write the record size
setting. I developed the first version in a few days, but then
spent two months of spare time trying to find out why it wouldn't
work. It's an undocumented feature of MS-DOS, although I can see
some hints of it in the manual - now that I know what to look for.
When your user program makes a read or write call to a device,
you send DOS the number of bytes to transfer, which may be 1 to
64K. You make one call to DOS (interrupt 21H). The request
header for I/O contains a full word to contain the byte count sent
from DOS. I made the mistake of assuming that when I make a 10
byte I/O request to my driver, the driver would see a 10 byte
count. Actually, it sees 10 unrelated 1-byte requests from DOS.
STKDEV's INPUT and OUTPUT functions show the effects. I had
to establish two new variables (NUM2READ and NUM2WRITE) to keep
track of how many bytes had been transferred, so that the driver
would know if it was done with a "record". This is required
because the "top of stack" pointer (CURRENT) is set to the next
free address following the last byte written. A "pop" operation
(INPUT) requires decrementing the pointer by the record size,
transferring a full record in the byte order written, then
resetting the pointer back to the used record's start to allow
overwriting and repeated "pops". There must be an easier way to
do this, but I think this mess shows the problems more clearly.
STKDEV2 uses IOCTL functions rather than the standard I/O. On
IOCTL calls, DOS sends the full byte count in the request header.
This made things simpler in my driver code, but complicated my user
programs by requiring custom read/write functions. Take your
choice. DOS apparently starts at the buffer address which your
program supplies in the I/O call, transferring one byte with a
request to the specified driver, then incrementing the buffer
pointer by 1, and repeating until the specified number of bytes is
copied. The device driver must track this indexing carefully in
some applications, for others it may not matter.
Mike Higgins' article included many debugging tips. One of
them is the "yell" macro at the beginning of STKDEV. This displays
one character on the screen by writing directly to the video
memory. If you use DOS function calls to display the status of
your driver, DOS will overwrite the request header which your
driver has started processing. I have left "yell" invocations
throughout the function code; it was this macro that finally showed
me what my device was receiving from DOS.
Functional Description of STKDEV.ASM:
The procedure and device name is XSTK, which must be used when
opening the device for I/O. It is assembled and linked normally,
then use EXE2BIN to convert the EXE file to COM form. I used
EXE2BIN STKDEV.EXE XSTK.SYS, changing the file name because any
references to XSTK once it is installed cause weirdness. The
CONFIG.SYS file must contain DEVICE=XSTK.SYS. Reboot and XSTK has
added 32K+ to memory-resident DOS.
This is the "device strategy" function, which is called first
by DOS for every request header. DOS has set ES and BX to the
address of the request header; these are stored RH SEG and RH OFF
to ensure that the driver will be able to find the request header.
It may be omitted for DOS 2.0.
DOS calls this entry point second on all device requests. The
call is a signal to begin processing the data in the request
header. DOS is now suspended (in this version) until your device
returns to it. All machine registers are saved on the stack, and
ES and BX are reloaded to the address stored by XSTK STRAT. The
command code at ES:[BX+2] is used as an index to jump to the
This function is called only by DOS, only during installation
(boot) time. As it will not be needed while the device is
operating, INIT could be located after the end address returned to
DOS, saving some memory. STORAGE is the variable marking the
end of the XSTK code. However, I add 32K for stack storage. This
value is then copied to the request header and returned to DOS for
memory allocation. Local variables are set to default "stack
Used to read the current record size from the driver into the
calling program's data buffer. In order to use the REP MOVSB
instruction, CX is set to the requested byte count, DS and SI point
to the internal variable RECSIZE, ES and DI point to the buffer
address contained in the request header. SI and DI are incremented
by the REPeat prefix until CX bytes have been transferred. ES and
BX are then reset to the request header address.
Write a new record size to the device. Same deal as above,
Processes read requests. The double word at ES:[BX+14]
contains the address of the data buffer in the calling program, and
the word at ES:[BX+18] is the byte count for the request. This
value will always be 1 for DOS 2.0, but this is subject to change.
The first process required is to check whether the previous
write (OUTPUT) completed storing RECSIZE bytes. This is done by
checking the NUM2WRITE variable. If NUM2WRITE is not 0, the
CURRENT pointer is set to a record boundary before reading.
Next, INPUT checks to see if it is in the process of reading a
record or if it is starting a new record. If NUM2READ is 0, INPUT
must reset CURRENT to the start of the last record written. At
this time, INPUT checks CURRENT against BOTMEM to ensure that reads
will not go past the bottom of the stack space. I tried to return
an error code of 30H, to give my calling program a different error
than those used by DOS. However, DOS apparently checks this value
against the approved list, and I get a "Disk drive error" on the
display. So, it appears that only the given error codes will be
sent to calling programs. The actual read transfer is set up
at PULLIT. Again, I used the REP MOVSB instruction, even though
DOS will only call for one byte per request header. CX is loaded
with the count from the request header, DS and SI have been set to
the proper address in the stack storage, ES and DI are set to the
data buffer address of the calling program. NUM2READ is
decremented on each request. While NUM2READ is not 0, the value
of SI is stored in CURRENT; SI has been incremented by the REP
MOVSB to point at the next byte of the record. If NUM2READ is 0,
a full record has been read and CURRENT must be reset to the
starting address of the record. Finally, ES and BX are reset to
point to the DOS request header. The status word of the request
header is filled with the code for "done; no error" and the process
completes through EXIT.
Similar to INPUT, except that CURRENT always increments.
Description of STKDEV2:
This version reverses the use of IOCTL and INPUT/OUTPUT. Most
of the code is the same as STKDEV, but the variables NUM2READ and
NUM2WRITE are no longer needed, as the DOS request header will
request the actual number of bytes given by the calling program.
THerefore, the driver implicitly knows that each request will
consist of a complete record. If you compare IOCTL IN with INPUT,
and IOCTL OUT with OUTPUT of STKDEV, it is obvious that this
approach was easier to code for this application.
_Testing the Sample Device Drivers_
Listing 4 (TXSTK.C) is a C program to perform simple test
calls to STKDEV. Listing 5 (TXSTK2.C) tests STKDEV2 by using IOCTL
calls in place of the read/write system calls used in TXSTK.
TXSTK uses segread() to determine the DS segment value of
itself. This is used to pass DOS the segment value of the data
buffer "instr". Then XSTK is opened. I used sysint21() calls
instead of fopen, fwrite() and fread() just to simplify matters.
"Outstr" is then written to XSTK. Although I fill callregs.cx
with the count of 5, I know that DOS will make 5 one-byte calls.
XSTK is currently set to the default RECSIZE of one, so a write and
corresponding read produces "olleH" from my string "Hello" written.
I then use IOCTL calls to reset XSTK's RECSIZE to 5 bytes.
Although the following write and read are in the same form as
before, XSTK now knows to treat input as 5-byte records. So,
"Hello" returns "Hello".
TXSTK2 is the same through opening XSTK. However, the first
byte-at-a-time write/read must use a loop to cycle through the 5
characters of the output and input strings. After resetting XSTK's
RECSIZE to 5 using I/O calls, the write/read calls request 5 bytes,
which is now processed in one call to XSTK. The strings are
returned as with TXSTK; 1"olleH" and "Hello".