Skip to content
FreeDOSDeep Dive Published Updated 7 min readViews unavailable

DOS File Handles: Open, Read, Inherit, and Redirect I/O

Follow the DOS INT 21h handle API from open through close, and understand how handle-based I/O enables command-line redirection.

DOS 2.0 added a path-based, handle-oriented interface alongside the older File Control Block calls. A program asks DOS to open a name and receives a small integer token; subsequent calls use that token rather than resolving the pathname again. DOS can make a disk file, a character device, or a redirected stream look similar to an application, but a handle is still a DOS process resource, not a Unix descriptor and not a promise that the target is seekable.

The central functions are INT 21h/AH=3Dh open, 3Eh close, 3Fh read, and 40h write. For 3Dh, DS:DX points to a zero-terminated path and AL selects access. The common values are 0 for read, 1 for write, and 2 for read/write; the DOS version also affects whether sharing-mode extensions are understood. On success AX is the new handle. Read and write take the handle in BX, a buffer at DS:DX, and a requested byte count in CX. A clear carry flag reports success and AX reports the number of bytes transferred; carry set means AX is an error code instead. Never interpret AX until the carry flag has been checked.

Function Inputs to verify Important result
3Ch create/truncate CX attributes, DS:DX path New handle in AX; an existing file may be replaced/truncated
3Dh open AL access/share mode, DS:DX path Existing-file handle in AX
3Eh close BX handle Handle is no longer valid after successful close
3Fh read BX, CX, DS:DX AX byte count; zero is end-of-file for a regular disk file
40h write BX, CX, DS:DX AX byte count; a short write must be handled
42h seek AL origin, BX handle, CX:DX displacement New position in DX:AX for seekable files

That table summarizes the classic interface, not every extension implemented by every DOS-compatible kernel. In particular, character devices such as CON, AUX, PRN, and NUL do not necessarily have regular-file EOF, position, or error behavior. Use the target kernel’s reference for network sharing, extended errors, and large-file extensions.

The process starts with standard handles

The DOS process environment includes standard input and output conventions so a program can communicate without hard-coding a console device name. The customary handles are 0 for standard input, 1 for standard output, 2 for standard error, 3 for auxiliary input, and 4 for printer output. A command interpreter can open a destination file or device and arrange the child’s handle table before executing it. The child then calls its normal write path; it need not know whether handle 1 names CON, a file, or a device.

This indirection explains shell redirection. PROGRAM > RESULT.TXT is not a feature of the program’s INT 21h write function: it is parsed and applied by the shell before the child starts. DOS handle-duplication calls (45h duplicate and 46h force-duplicate on DOS 2+) let software create or replace mappings. Redirection syntax and edge cases belong to the particular command shell, so do not infer support for a construct such as separate stderr redirection merely from the existence of handle 2.

Open mode, create, and truncation are different decisions

3Dh opens an existing path and does not create a missing file. 3Ch creates a file and, in the common DOS contract, truncates an existing file of that name. A utility that intends to append should open appropriately and seek to the end, or use a documented append facility; blindly using create can destroy prior output. Validate path spelling, current drive and directory, read-only attributes, and available handles before blaming the disk.

Access mode is not the same as a file-sharing guarantee. Early DOS did not provide modern multiuser locking semantics, while later DOS and network redirectors added access/share fields and APIs. Programs that run under a redirector or multitasker should choose a mode supported by their target and handle sharing errors rather than assuming that another process cannot change the file. The original MS-DOS system-call reference and FreeDOS kernel implementation are useful baselines, but extensions are version-sensitive.

Read loops must distinguish partial data from EOF

A read can return fewer bytes than requested without being an error. For a regular file, a successful zero-byte result means end-of-file; the program should process any positive count first and then request more. A console or device read can have different blocking or termination rules. If reading a fixed-size structure, a single successful call is not proof that the whole structure arrived: continue until the requested amount has been collected, an error occurs, or EOF is reached.

The same discipline applies to writes. A successful write can report a positive count smaller than CX; advance the buffer by that count and write the remainder. If CX is nonzero but AX returns zero, stop or report no progress rather than looping forever. On a disk-full or media failure, preserve the original source, report how many bytes were written, close the handle if possible, and do not announce a completed output merely because no carry was set on the first call.

A safer assembly outline

This MASM/TASM-style sketch demonstrates a regular-file read loop. It is intentionally not a complete buildable program: the data segment, stack, error-reporting routines, and a write loop are application-specific. It preserves the returned handle, checks carry after each call, and treats a short positive read as data rather than failure.

        mov     ax, 3D00h          ; open an existing file, read-only
        mov     dx, OFFSET path    ; DS must address path
        int     21h
        jc      open_error
        mov     handle, ax

read_again:
        mov     bx, handle
        mov     cx, 512
        mov     dx, OFFSET buffer
        mov     ah, 3Fh
        int     21h
        jc      read_error         ; AX is a DOS error code
        or      ax, ax
        jz      end_of_file        ; regular-file EOF
        ; Process exactly AX bytes, not all 512 bytes.
        jmp     read_again

end_of_file:
        mov     bx, handle
        mov     ah, 3Eh
        int     21h
        jc      close_warning
        jmp     finished

read_error:
        ; Preserve the primary error, then attempt close.
        mov     bx, handle
        mov     ah, 3Eh
        int     21h
        jmp     report_read_error

Production code must also ensure the buffer does not cross a segment boundary in a way the target DOS call cannot handle, keep stack/data setup valid for the executable format, preserve an error value before cleanup calls overwrite registers, and close all acquired handles. In a C program, use the compiler’s documented DOS runtime functions unless there is a specific reason to call interrupts directly; mixing runtime-managed streams with raw handles can otherwise desynchronize buffering.

Validate redirection and failure paths explicitly

Test the same utility with a console, a small file, an empty file, a device, and a destination with insufficient free space. Compare output byte-for-byte after redirecting it to a file. Confirm append-versus-truncate behavior with a known preexisting target. Test invalid paths and handle exhaustion, and verify that every error path closes handles without masking the original error. If the program uses AH=42h, test a non-seekable device separately and handle its failure rather than treating every handle as a disk file.

The interface’s power is a stable small contract: acquire a handle, check every result, transfer the count actually reported, then release it. Redirection works because the shell prepares those handles, not because DOS secretly changes each program’s file logic. That distinction is the key to debugging utilities that work interactively but fail when piped, redirected, run through a device, or used on a nearly full disk.

For data-producing utilities, treat successful transfer and successful close as separate checks. Preserve the first write error before cleanup calls can replace registers, still attempt to close the handle, and report that the output may be incomplete if either operation fails. Do not overwrite the only prior result with 3Ch until you have decided how to recover from interruption; writing a temporary filename and promoting it only after validation can reduce the chance of leaving a plausible but partial output. Whether rename/replace operations are available and atomic depends on the DOS version and filesystem, so document those assumptions rather than promising database-style transactions.

Related:

Sources:

Comments