Last year, I wrote a very basic device driver for MS-DOS. All it does is create
a HELLO device, which when read from, will print HELLO, WORLD!! (plus a
newline and an ASCII EOF character). I thought it might be fun to share how
the layout of the device drivers as a quick (2-3 hours) post.
Doing an MS-DOS device driver was something I wanted to do for a long time, but
couldn't find enough easily accessible documentation to do it back in the
2010-2013 range. When Microsoft open-sourced DOS 2.x, it included a file called
DEVDRIV.txt
with the information I needed. I just didn't get around to actually
doing the driver until 2021 :D. This post is a companion to DEVDRIV.txt and
some of my own findings in the DOS 2.x source code. AFAICT, both DEVDRIV.txt
and the source code would be provided to developers back in the day who wanted
to port DOS to their 8086-based not-IBM PC systems, so this isn't cheating :).
What Is An MS-DOS Device Driver?
Essentially, a device driver for MS-DOS is a raw binary file, typically with a
.SYS file extension, that interfaces to code in the DOS kernel and allows
commands like COPY FILE.TXT COM1 (character devices) and drive letters like
C:\ (block devices) to work.
Some device drivers are built into the kernel at assemble-time, including the
AUX and CON devices. The kernel will look for other drivers by parsing the
CONFIG.SYS text file for lines such as DEVICE=C:\PATH\TO\HELLO.SYS at
boot-time, possibly making them technically an early form of kernel module?
Loadable device drivers are only supported on DOS 2.0 and above!
Character vs Block Device
Character devices operate on one byte at a time, an have an ASCII name that the
kernel will attempt to match during DOS syscalls.
In 2022, for backward compatibility reasons, MS-DOS character devices are the
reason why you can't easily name a file COM1, AUX, LPT1, PRN, CON,
and NUL in Windows, among other reserved names :).
Block devices operate on multiple bytes at a time, and are assigned drive letters by the DOS kernel. These include floppy disks, hard disks, CD-ROMs drives, and more surprising fare like RAM disks, Zip drives, and Bernoulli Boxes. I have not spent much time playing with block devices, but perhaps I'll have more to say about them in the future. This post discusses building a driver for a character device.
Required Tools/Prerequisites
For the sake of this blog post, all you need is a copy of The Netwide Assembler (NASM)! You can assemble the source using the following command:
nasm -f bin hello.asm -o hello.sys -l hello.lst
I assume familiarity with segment-offset addressing, how to use NASM, and how you would talk to IBM PC hardware via I/O ports and memory-mapped I/O addresses.
Device Driver Data Structures
Header
MS-DOS device drivers are arranged in a linked list. When loaing a device
driver, the kernel expects a header at offset 0 in the SYS file (device
drivers do not begin at offset 0x100 like for COM files). The header looks
like this:
struc header
next: resd 1
attr: resw 1
strat: resw 1
intr: resw 1
name: resb 8
endstruc
next: 32-bit segment-offset pair pointing to the next device driver header. It must be set to-1(all bits set) in the driver file. In memory,-1is a sentinel value for "last driver".attr: A 16-bit bitflag describing the device driver attributes.-
Bit 15 is set if a character device, and 0 if a block device.
-
Bit 14 means the device is
ioctl-capable. We will be ignoring this for this post, mainly because I don't actually know howioctlworks in DOS :). -
The other bits are control the default
stdin,stdout, etc devices, and we will be ignoring them for this post.I'm not actually certain even after reading the DOS 2.x source code how these other bits are used.
DEVDRIV.txtwarns you to not forget to set them when appropriate.
-
strat: An offset (in segment-offset terminology) to a device's strategy routine, explained below. The kernel will choose the right segment.intr: An offset (in segment-offset terminology) to a device's interrupt routine, explained below. The kernel will choose the right segment.name: For character devices, an 8-byte space-padded string for the name of your device. For block devices, this allocates a number of drive letters to use.
For my HELLO driver, the header looks like this, where strategy and
interrupt are labels in the same assembly file:
hdr:
istruc header
at next, dd -1
at attr, dw 0x8000
at strat, dw strategy
at intr, dw interrupt
at name, db 'HELLO '
iend
Driver Request Structure
The DOS kernel will provide a structure as input to your device driver's
interrupt routine, which is also used to return a status value. An interrupt
routine can handle many possible commands, described below. A request can
have extra data appended to it based on the command, as described
DEVDRIV.txt, but the first bytes of all requests are the same (Static Request
Header):
struc drivereq
.len: resb 1
.unit: resb 1
.cmd: resb 1
.status: resw 1
.dosq: resd 1
.devq: resd 1
endstruc
len: Length of thisdrivereqdata structure, including data that may follow thedevqfield.unit: Unit number for block devices; unused for char devices.cmd: Type of command, described below.status: Return value the driver sets to return to the kernel, explained in the Return Codes section.dosq/devq: Unused in practice, see strategy routine.
Driver Routines
An MS-DOS device driver has two subroutines- strategy and interrupt. The
strategy routine is unused in practice, and the interrupt routine is where the
device driver handles syscalls on behalf of the DOS kernel. Both routines are
far calls, and thus require retf to return from the routine.
Strategy Routine
MS-DOS was eventually supposed to be a multi-tasking OS, but for marketing reasons this did not pan out. The strategy routine was meant for drivers to communicate with the DOS kernel in what order driver requests should be handled using queues. In practice, DOS kernels immediately call the strategy routine and then the interrupt routine in succession.
The strategy routine receives as input a pointer to the Driver Request
Structure in ES:BX. The interrupt routine needs this pointer but does not
receive anything from DOS as input, so we have to save the pointer in the
driver's private storage for the interrupt routine.
The following snippet is a reasonable strategy routine for a driver- i.e. the one I use :):
packet_ptr dd 0
strategy:
mov cs:[packet_ptr], bx
mov cs:[packet_ptr+2], es
retf
Interrupt Routine
The interrupt routine is the bulk of the device driver. Using the pointer to the Driver Request Structure that the kernel gave to your driver in the strategy routine, the interrupt routine:
- Examines
drivereq'sunit/cmdfield to figure out whether the current request can be handled by this driver (e.g. is the command supported?) - Handle the command by talking to the hardware, based on the input data
following the
.devqfield ofdrivereq. - Return status information in the
statusfield ofdrivereq.
Below is the basic interrupt routine that I used for HELLO.SYS and provide
with the Skeleton Driver, where .fntab is a jump table; see the Skeleton
Driver and Commands Section for details. Additionally, AFAIR, I think
cmp si, 11 is a typo, and can safely be cmp si, 12 for DOS 2.x.
I would be cautious about pushing any more registers to the stack than those in this routine. The device driver stack that DOS provides is not very big. I have crashed DOS several times due to forgetting this and pushing maybe only 3 additional 16-bit registers? But feel free to experiment with what values crash your system :):
interrupt:
push ax
push cx
push dx
push bx
push si
push di
push bp
push ds
push es
les di, cs:[packet_ptr]
mov si, es:[di + drivereq.cmd]
cmp si, 11
ja .bad_cmd
shl si, 1
jmp [.fntab + si]
.bad_cmd:
mov al, UNKNOWN_COMMAND
.err:
xor ah, ah
or ah, (STATUS_ERROR | STATUS_DONE) >> 8
mov es:[di + drivereq.status], ax
jmp interrupt.end
.busy:
mov word es:[di + drivereq.status], STATUS_DONE | STATUS_BUSY
jmp interrupt.end
.exit:
mov word es:[di + drivereq.status], STATUS_DONE
.end:
pop es
pop ds
pop bp
pop di
pop si
pop bx
pop dx
pop cx
pop ax
retf
Commands
For DOS 2.x, at least 13 commands are defined, and not every device driver will
support every command. Many commands are provided a drivereq structure with
additional fields that follow the 13 bytes of the Driver Request Structure
listed above, which can be found in DEVDRIV.txt. I am omitting their exact
layouts for brevity, though see initreq in the Skeleton Driver for how to
lay out these extended request structures.
These are brief descriptions of each of the commands supported by DOS 2.x,
along with relevant input/output parameters in the extended fields of the
Driver Request Structure. As noted, in some cases, I'm not 100% sure how a
command works, even after consulting DEVDRIV.txt and the source. Fixing my
knowledge gaps can be the topic of future posts :):
init: Do driver initialization. Called only once. It includes a break address as an output param, which will free any memory after this address back to DOS, analogous to the transient portion of TSRs.media_chk: Treated asNOPby character devices.build_bpb: Treated asNOPby character devices.ioctl_input: Analogous to Unixioctl, I am not familiar with the details as of this writing.input: Blocking read; the input buffer and number of characters to read are provided as parameters.input_nd: Non-destructive read. Peek at the next input character without removing it from the driver's read buffer, and return it as an output param. I'm unsure whether this should set the busy code or not if a read would block.input_status: Set the busy bit if a read would block. Due to assumptions by the kernel, device drivers without a read buffer must keep the busy bit when this function is called. Based on my notes, I couldn't find any place where the DOS 2.x kernel source callsinput_status.input_flush: Flush the driver's read buffer, if any. This data is lost.output: Blocking write; the output buffer and number of characters to write are provided as parameters (possibly to a write buffer).output_verify: Blocking write that ensures the data reaches its destination. This probably implies flushing a driver's write buffer, rather than bypassing it. But the word "verify" is used exactly once inDEVDRIV.txt, so who knows? :)output_status: Set the busy bit if anoutputcommand would not start immediately due to e.g. a filled write buffer being written to the device via an active DMA request. My understanding is that drivers without a write buffer will always be ready.output_flush: Write the driver's output buffer to the device.ioctl_output: Analogous to Unixioctl, I am not familiar with the details as of this writing.
Return Code
The return code is set by the device driver in the status field of the above
drivereq. The status field consists of bitflags in the higher bits and a
4-bit status code in the lower 4 bits:
- Bit 15 is set if there was a error servicing the request.
- Bit 9 is set if the device is the busy bit, only set by the
statuscommands according toDEVDRIV.txt. However, it may also be set by theread_ndcommand too if there's no data to read, I don't remember. - Bit 8 is the done bit and is always set when the device driver exits the interrupt routine. Analogous to how the strategy routine is unused, this bit would've not always been set if multitasking DOS took off.
- Bits 0-3 hold the status code returned to the kernel. See
DEVDRIV.txtor the Skeleton Driver for their values. Many of the codes have a corresponding text string that the DOS kernel prints on a hardware error.
The Complete HELLO.SYS Driver- "Download" Now!
You can view the completed HELLO.SYS driver complete with Makefile on
Github. HELLO.SYS is
a culmination of this post, DEVDRIV.txt, and a bit of peeking at the DOS 2.x
source to demonstrate how to interface your custom code to the DOS kernel.
Right now, HELLO.SYS is a toy driver which prints the null-terminated string
'HELLO, WORLD!!', 0xD, 0xA, 26, 0 in a loop when the HELLO device is read.
In the future, I would like to add support for writing to the HELLO device to
change the message, and IOCTL support to e.g. change the pointer to the
current character.
If you just want to play around with the driver without compiling, save
following hexdump to hello.txt and then run xxd -r hello.txt hello.sys to
get a sys file ready for a DOS system:
00000000: ffff ffff 0080 2c00 3700 4845 4c4c 4f20 ......,.7.HELLO
00000010: 2020 0000 0000 4845 4c4c 4f2c 2057 4f52 ....HELLO, WOR
00000020: 4c44 2121 0d0a 1a00 1600 0000 2e89 1e12 LD!!............
00000030: 002e 8c06 1400 cb50 5152 5356 5755 1e06 .......PQRSVWU..
00000040: 2ec4 3e12 0026 8b75 0283 fe0b 7706 d1e6 ..>..&.u....w...
00000050: ffa4 7900 b003 30e4 80cc 8126 8945 03eb ..y...0....&.E..
00000060: 0e26 c745 0300 03eb 0626 c745 0300 0107 .&.E.....&.E....
00000070: 1f5d 5f5e 5b5a 5958 cb2f 0169 0069 0069 .]_^[ZYX./.i.i.i
00000080: 0093 00c2 00ed 00fb 0069 0069 0069 0069 .........i.i.i.i
00000090: 0069 0006 1f89 fec4 7c0e 8b4c 128c da8c .i......|..L....
000000a0: c88e d8e8 7a00 89f3 7407 49aa b801 0074 ....z...t.I....t
000000b0: 03e8 5500 e872 008e da89 4712 89df 8ec2 ..U..r....G.....
000000c0: eba7 8cca 8eda e857 0075 1b8c c38e c289 .......W.u......
000000d0: fab9 0100 bf2a 00e8 2f00 c606 2b00 01e8 .....*../...+...
000000e0: 3e00 89d7 8ec3 2688 450d e97c ff8c c88e >.....&.E..|....
000000f0: d8e8 2c00 0f84 71ff e966 ff8c c88e d8e8 ..,...q..f......
00000100: 2700 e964 ff8c c88e d889 c88b 3628 0081 '..d........6(..
00000110: fe28 0072 03be 1600 a4e2 f489 3628 00c3 .(.r........6(..
00000120: 803e 2b00 00a0 2a00 c3c6 062b 0000 c30e .>+...*....+....
00000130: 1fba 4501 b409 cd21 26c7 450e 2f01 268c ..E....!&.E./.&.
00000140: 4d10 e924 ff48 656c 6c6f 2057 6f72 6c64 M..$.Hello World
00000150: 2064 7269 7665 7220 696e 7374 616c 6c65 driver installe
00000160: 642e 0d0a 24 d...$
A Skeleton Driver
This is an stub version of HELLO.SYS meant to make it easy to get started
writing your own MS-DOS device driver. Although this stub assembles, I have not actually tested whether this stub driver works as is on system running DOS 2.x
or above. For instance, the DOS read syscall may block when reading from the
SKELETON device, which exits immediately when seeing the read command and
doesn't actually return valid data. I encourage you to look at the HELLO.SYS
driver source code for more ideas:
struc header
next: resd 1
attr: resw 1
strat: resw 1
intr: resw 1
name: resb 8
endstruc
struc drivereq
.len: resb 1
.unit: resb 1
.cmd: resb 1
.status: resw 1
.dosq: resd 1
.devq: resd 1
endstruc
struc initreq
.hdr: resb drivereq_size
.numunits: resb 1
.brkaddr: resd 1
.bpbaddr: resd 1
endstruc
; Status return bits- high
%define STATUS_ERROR (1 << 15)
%define STATUS_BUSY (1 << 9)
%define STATUS_DONE (1 << 8)
; Error codes (Status return bits- low)
%define WRITE_PROTECT 0
%define UNKNOWN_UNIT 1
%define DRIVE_NOT_READY 2
%define UNKNOWN_COMMAND 3
%define CRC_ERROR 4
%define BAD_DRIVE_REQ 5
%define SEEK_ERROR 6
%define UNKNOWN_MEDIA 7
%define SECTOR_NOT_FOUND 8
%define OUT_OF_PAPER 9
%define WRITE_FAULT 0xA
%define READ_FAULT 0xB
%define GENERAL_FAILURE 0xC
hdr:
istruc header
at next, dd -1
at attr, dw 0x8000
at strat, dw strategy
at intr, dw interrupt
at name, db 'SKELETON'
iend
; Driver data
packet_ptr dd 0
strategy:
mov cs:[packet_ptr], bx
mov cs:[packet_ptr+2], es
retf
; The available source for MS-DOS don't preserve flags in drivers, and no point
; in preserving SP.
interrupt:
push ax
push cx
push dx
push bx
push si
push di
push bp
push ds
push es
les di, cs:[packet_ptr]
mov si, es:[di + drivereq.cmd]
cmp si, 11
ja .bad_cmd
shl si, 1
jmp [.fntab + si]
.bad_cmd:
mov al, UNKNOWN_COMMAND
.err:
xor ah, ah
or ah, (STATUS_ERROR | STATUS_DONE) >> 8
mov es:[di + drivereq.status], ax
jmp interrupt.end
.busy:
mov word es:[di + drivereq.status], STATUS_DONE | STATUS_BUSY
jmp interrupt.end
.exit:
mov word es:[di + drivereq.status], STATUS_DONE
.end:
pop es
pop ds
pop bp
pop di
pop si
pop bx
pop dx
pop cx
pop ax
retf
.fntab:
dw .init ; 0 INIT
dw .exit ; 1 MEDIA CHECK (Block only, NOP for character)
dw .exit ; 2 BUILD BPB " " " " "
dw .exit ; 3 IOCTL INPUT (Only called if device has IOCTL)
dw .exit ; 4 INPUT (read)
dw .exit ; 5 NON-DESTRUCTIVE INPUT NO WAIT (Char devs only)
dw .exit ; 6 INPUT STATUS " " "
dw .exit ; 7 INPUT FLUSH " " "
dw .exit ; 8 OUTPUT (write)
dw .exit ; 9 OUTPUT (Write) with verify
dw .exit ; 10 OUTPUT STATUS " " "
dw .exit ; 11 OUTPUT FLUSH " " "
dw .exit ; 12 IOCTL OUTPUT (Only called if device has IOCTL)
; Init data does not need to be kept, so it goes last.
res_end:
init:
push cs
pop ds
mov dx, install_msg
mov ah, 0x09
int 0x21
mov word es:[di + initreq.brkaddr], res_end
mov word es:[di + initreq.brkaddr + 2], cs
jmp interrupt.exit
install_msg db 'Driver skeleton installed.', 0xD, 0xA, '$'
Request For Drivers
If you do anything with this post, please show me pictures of your shiny new MS-DOS device driver running on a vintage IBM PC or clone :D.