Yes, I still use the same hard disk platter as a drink coaster. But I need more ISA cards in my collection.


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, -1 is 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 how ioctl works 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.txt warns 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 this drivereq data structure, including data that may follow the devq field.
  • 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:

  1. Examines drivereq's unit/cmd field to figure out whether the current request can be handled by this driver (e.g. is the command supported?)
  2. Handle the command by talking to the hardware, based on the input data following the .devq field of drivereq.
  3. Return status information in the status field of drivereq.

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 :):

  1. 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.
  2. media_chk: Treated as NOP by character devices.
  3. build_bpb: Treated as NOP by character devices.
  4. ioctl_input: Analogous to Unix ioctl, I am not familiar with the details as of this writing.
  5. input: Blocking read; the input buffer and number of characters to read are provided as parameters.
  6. 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.
  7. 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 calls input_status.
  8. input_flush: Flush the driver's read buffer, if any. This data is lost.
  9. output: Blocking write; the output buffer and number of characters to write are provided as parameters (possibly to a write buffer).
  10. 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 in DEVDRIV.txt, so who knows? :)
  11. output_status: Set the busy bit if an output command 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.
  12. output_flush: Write the driver's output buffer to the device.
  13. ioctl_output: Analogous to Unix ioctl, 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 status commands according to DEVDRIV.txt. However, it may also be set by the read_nd command 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.txt or 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.


You must log in to comment.

in reply to @cr1901's post: