Skip to content
daniel@cosenza:~/blog
FreeDOSDeep Dive February 28, 2026 4 min read

Device Drivers on FreeDOS: How .SYS Files Extend the Kernel

How character and block device drivers register with the DOS kernel through a standard request-header protocol, loaded declaratively from CONFIG.SYS.

DOS device drivers follow a genuinely elegant, minimal design for their era: a driver is a binary file with a specific header structure, loaded by CONFIG.SYS, that the kernel communicates with through a single, standardized entry point using request headers — a design that predates, but rhymes strongly with, the file_operations table pattern the Linux VFS uses decades later.

Character vs. block devices

Like Unix, DOS distinguishes character devices (byte-stream oriented — a mouse, a printer port, a serial port) from block devices (addressable in fixed-size sectors — a disk drive). Every device driver declares which kind it is in its header, which determines what set of operations the kernel expects it to support.

DEVICE=C:\FREEDOS\MOUSE.SYS
DEVICE=C:\FREEDOS\RAMDISK.SYS 2048

Character devices additionally get a name (like CON, PRN, AUX) that DOS programs can open like a file — which is why COPY CON: file.txt (reading keyboard input as if it were a file) works: CON is just a character device driver being addressed through the ordinary file API.

The driver header: what the kernel actually reads

Every .SYS driver begins with a fixed-format header the kernel parses at load time: a pointer to the next driver (for chaining), an attribute word (character vs. block, among other flags), a pointer to the driver’s strategy routine, and a pointer to its interrupt routine.

Offset  Field
0x00    Next driver pointer
0x04    Attribute word
0x06    Strategy routine offset
0x08    Interrupt routine offset
0x0A    Device name / unit count

Strategy and interrupt: the two-call protocol

DOS’s driver interface is unusual by modern standards: rather than a single function call per operation, it’s a two-step handoff. The kernel first calls the driver’s strategy routine, passing a pointer to a request header describing the operation; the strategy routine just saves that pointer. The kernel then immediately calls the driver’s separate interrupt routine, which does the actual work, reading the saved request header to know what was asked.

Request Header:
  Length       (1 byte)
  Unit         (1 byte, for block devices)
  Command      (1 byte: read, write, init, etc.)
  Status       (2 bytes, filled in by the driver)
  ...command-specific fields...

This split existed to accommodate reentrancy concerns on the original hardware — separating “receive the request” from “act on it” gave drivers a defined point to handle interrupts safely, at a time when DOS itself had essentially no concurrency protection of its own to rely on.

The INIT command: how a driver introduces itself

Every driver must handle a special INIT command, sent exactly once when CONFIG.SYS loads it — this is the driver’s chance to detect hardware, allocate any memory it needs, and report back how much of its own loaded image can be discarded (drivers commonly free their own initialization code after startup, since it’s never needed again):

DEVICE=C:\FREEDOS\HIMEM.EXE /TESTMEM:OFF

HIMEM.EXE’s INIT handler is what actually performs the extended-memory availability test this flag controls — a concrete example of initialization-time driver logic doing real hardware detection work.

A minimal driver skeleton

Conceptually (in simplified pseudo-structure), a character device driver’s shape looks like:

DEVICE_HEADER:
    dd -1                  ; next driver (none)
    dw ATTR_CHAR            ; character device
    dw STRATEGY
    dw INTERRUPT
    db 'MYDRV$$$'          ; 8-byte device name

STRATEGY:
    mov [RequestPtr], bx
    retf

INTERRUPT:
    ; read command from [RequestPtr], dispatch on it
    ; INIT, READ, WRITE, etc.
    retf

Loading order and chaining

CONFIG.SYS processes DEVICE=/DEVICEHIGH= lines top to bottom, and each loaded driver is linked into a chain via that “next driver” pointer in its header — which is how the kernel eventually walks every loaded driver to route a request to the right one, similar in spirit to how a Linux kernel walks a linked list of registered block devices, just at a much smaller and simpler scale.

Why this design still holds up

The strategy/interrupt split and the request-header protocol look unusual today, but they solve the same problem every device-driver interface solves: giving arbitrary third-party code a well-defined, minimal contract for talking to the kernel without needing to understand the kernel’s internals. It’s a strikingly compact solution — the entire protocol fits in a page of documentation — which is exactly why writing a FreeDOS device driver remains one of the most approachable ways to understand what a “device driver interface” fundamentally is, stripped of every layer of complexity a modern OS’s driver model has since accumulated.