Classic PC Assembly Language: dissecting the DOS copy command
Every DOS user knows COPY LETTER.TXT BACKUP.TXT — one line, file duplicated.
But that innocent command touches nearly every important idea in classic PC assembly language:
CPU registers, interrupts, the filesystem, buffers, and error handling with processor flags.
In this article we open it up and rebuild it ourselves, with every line commented. No prior
assembly experience needed — if you can read any programming language, you can read this.
The ground rules: registers, interrupts, and INT 21h
An 8086-class CPU has a handful of 16-bit registers — think of them as named variables that live inside the chip itself:
| Register | Conventional job |
|---|---|
AX (split into AH/AL) | the accumulator — function numbers and arithmetic results |
BX | base — in DOS, the file handle goes here |
CX | counter — byte counts for reads and writes |
DX | data — pointers to strings and buffers |
DOS offers its services through software interrupts. The granddaddy is
INT 21h: you put a function number in AH, parameters in the other
registers, execute int 21h, and DOS does the work, returning results in registers
and the carry flag (CF) — which is set when something failed. That
"number in AH, call INT 21h" rhythm is 90% of DOS programming.
The four DOS calls behind copy
| Function (AH) | What it does | Inputs | Returns |
|---|---|---|---|
3Dh | open an existing file | AL=0 (read), DS:DX→filename | AX=handle, or CF set + error code |
3Ch | create/truncate a file | CX=attributes, DS:DX→filename | AX=handle, or CF set |
3Fh | read from a handle | BX=handle, CX=max bytes, DS:DX→buffer | AX=bytes actually read (0 = end of file) |
40h | write to a handle | BX=handle, CX=bytes, DS:DX→data | AX=bytes written, or CF set |
Filenames are ASCIIZ strings: the characters followed by a zero byte, e.g.
'LETTER.TXT',0. The zero marks the end, because DOS has no idea how long your
string is otherwise.
A minimal file copier, in full (MASM syntax)
Here is the whole program — open, loop read/write, close, exit. Comments explain each line.
; COPY.ASM — a minimal file copier for MS-DOS (real-mode x86)
; Assemble: MASM COPY; LINK COPY; Run: COPY
.model small ; one code segment + one data segment
.stack 100h ; 256 bytes of stack
.data
srcName db 'LETTER.TXT',0 ; ASCIIZ source filename
dstName db 'BACKUP.TXT',0 ; ASCIIZ destination filename
srcHandle dw ? ; 16-bit handle DOS gives back on open
dstHandle dw ?
buffer db 32768 dup(?) ; 32 KB staging area — the secret of every copier
errMsg db 'Copy failed.$' ; '$' terminates strings for DOS print
okMsg db '1 file(s) copied.$'
.code
start:
mov ax, @data ; the data segment doesn't load itself —
mov ds, ax ; point DS at our variables first
; ---------- open the source file ----------
mov ah, 3Dh ; DOS function 3Dh: open file
mov al, 0 ; access mode 0 = read only
mov dx, offset srcName ; DX:offset of the ASCIIZ name (DS already set)
int 21h ; call DOS
jc failed ; carry flag set? the open failed — bail out
mov srcHandle, ax ; success: AX holds the handle; save it
; ---------- create the destination file ----------
mov ah, 3Ch ; DOS function 3Ch: create/truncate
mov cx, 0 ; normal file attributes
mov dx, offset dstName
int 21h
jc failed
mov dstHandle, ax ; save the destination handle too
; ---------- the copy loop ----------
copyLoop:
mov ah, 3Fh ; DOS function 3Fh: read from handle
mov bx, srcHandle ; BX = handle to read from
mov cx, 32768 ; ask for a full 32 KB
mov dx, offset buffer ; where to put the bytes
int 21h
jc failed
mov cx, ax ; AX = bytes ACTUALLY read (may be less!)
jcxz done ; CX = 0 means end of file — we're finished
mov ah, 40h ; DOS function 40h: write to handle
mov bx, dstHandle ; BX = handle to write to
mov dx, offset buffer ; write exactly what we just read
int 21h ; CX still holds the byte count
jc failed
jmp copyLoop ; go back for the next chunk
; ---------- cleanup ----------
done:
mov ah, 3Eh ; DOS function 3Eh: close handle
mov bx, srcHandle
int 21h
mov ah, 3Eh
mov bx, dstHandle
int 21h
mov ah, 09h ; print the success message ('$'-terminated)
mov dx, offset okMsg
int 21h
mov ax, 4C00h ; DOS function 4Ch: exit, return code 0
int 21h
failed:
mov ah, 09h ; print the error message
mov dx, offset errMsg
int 21h
mov ax, 4C01h ; exit with return code 1 (signals error)
int 21h
end start
The ideas hiding in those thirty lines
- Why a buffer at all? Files are bigger than memory was. The loop never asks "how big is the file?" — it just reads 32 KB, writes what arrived, repeats, and stops when a read returns zero. A 4 MB file and a 4 GB one use identical code.
- Read less than requested. The last chunk of any file is partial;
AXtells the truth about how many bytes arrived, and we write exactly that many. Copy programs that assume a full buffer corrupt files — a classic beginner bug. - The carry flag is the error channel. Assembly has no exceptions;
jc failedafter every call is the discipline that keeps small failures from becoming silent data corruption. - Handles are numbers. Open returns a small integer; every later call passes
it back in
BX. If that feels familiar from C'sFILE*or modern file APIs — they all inherited this shape from here.
Typing it live with DEBUG
You didn't need a compiler to try assembly in 1990 — every DOS disk shipped with DEBUG.EXE, which assembles instructions straight into memory. Here is a real session that prints the letter A:
C:\> debug
-a 100 ; start assembling at offset 100h (COM programs live here)
1373:0100 mov ah,02 ; DOS function 02h: print one character
1373:0102 mov dl,41 ; DL = 41h = ASCII 'A'
1373:0104 int 21 ; call DOS
1373:0106 int 20 ; classic "exit to DOS" for COM programs
1373:0108 ; empty line = stop assembling
-g ; GO — run what we just typed
A ; the program's entire output
Program terminated normally
- ; back at DEBUG's prompt; 'q' quits
Numbers are hex by default (41 means 41h), addresses show as
segment:offset, and the program runs from the very memory you typed it into.
The same -u (unassemble) command would let you watch COMMAND.COM's own code the
other direction.
What the real COPY does that ours doesn't
COMMAND.COM's version is our loop plus patience: it parses wildcards and walks directory
matches with FindFirst/FindNext (functions 4Eh/4Fh), treats
CON and PRN as file names (the OS makes devices look like files — the
same handles, the same read/write calls), concatenates multiple sources into one destination,
honours the /V verify flag (read-back after write), and streams through a buffer
sized from free memory. The bones are identical to our 30 lines; everything else is
convenience layered on top.
Where to go next
Read the listing until jcxz done feels inevitable, then find an emulator
(DOSBox runs DEBUG and MASM happily on any modern machine) and type the DEBUG program yourself.
After that, natural next stops are the directory-search calls behind wildcards, and
INT 13h — the BIOS disk services one layer below DOS, where "files" stop existing
and only sectors remain. Every idea in modern I/O — buffers, handles, partial reads, error
codes — started right here.