How MS-DOS TSRs Hooked Interrupts to Run Background Tasks in 640K
MS-DOS TSRs achieved background tasks by hooking interrupt vectors and staying resident via INT 21h/31h. This blog explains the mechanism, the non-reentrant kernel's InDOS flag constraint, and includes a minimal assembly example you can run in DOSBox.
12 Oct 2025, 08:34 UTC

The problem: single-tasking DOS needed a back door
MS-DOS was never designed for multitasking. The kernel assumes one program owns the CPU from start to finish. Yet users wanted pop-up calculators (SideKick), mouse drivers, network redirectors, and print spoolers running "in the background" while a foreground application like WordPerfect or Lotus 1-2-3 held the screen. The mechanism that made this possible was the Terminate and Stay Resident (TSR) program—a clever abuse of DOS memory management and the real-mode interrupt vector table (IVT).
This article walks through how TSRs work, why the non-reentrant kernel forces a specific coding discipline, and what a minimal timer-hooking TSR looks like in assembly. You'll also see why memory fragmentation and load-order dependencies made TSR-heavy configurations fragile.
Staying resident: INT 21h function 31h
When a .COM or .EXE finishes, it normally returns all its memory to DOS via INT 21h function 4Ch. A TSR instead calls INT 21h function 31h (Keep Process) with:
AL= return code (usually 0)DX= number of 16-byte paragraphs to keep resident
DOS marks that memory as allocated but does not free it, then returns to the command prompt. The program's code and data remain in conventional memory (the first 640 KiB), ready to be re-entered through an interrupt hook.
Typical residency size: a few kilobytes for a mouse driver, 10–30 KiB for a pop-up utility. The TSR must calculate its own footprint—code, data, stack, and any buffers—and round up to paragraphs.
Hooking the interrupt vector table
The IVT lives at physical address 0000:0000 (linear 0x00000). Each of the 256 vectors occupies 4 bytes (segment:offset). A TSR installs itself by overwriting a vector with its own handler address, usually saving the original vector to chain to it later.
Common targets:
| Interrupt | Purpose | Typical TSR use |
|---|---|---|
| INT 08h | Timer tick (~18.2 Hz) | Time-slicing, pop-up scheduling |
| INT 09h | Keyboard hardware | Hot-key detection (e.g., Ctrl-Alt-Del, SideKick hotkey) |
| INT 21h | DOS API | Filtering file calls, network redirection |
| INT 2Fh | Multiplex | TSR-to-TSR communication, presence checks |
Hooking sequence (simplified):
; DS = CS (typical .COM layout)
mov ax, 3508h ; GET VECTOR for INT 08h
int 21h ; returns ES:BX = old handler
mov word [old_int08_off], bx
mov word [old_int08_seg], es
mov dx, new_int08 ; offset of our handler
mov ax, 2508h ; SET VECTOR for INT 08h
int 21h
After this, every timer tick jumps to new_int08. The handler must preserve all registers, do its work quickly, then jump to the saved original vector (or execute an iret if it's the end of the chain).
The InDOS flag: why you can't just call DOS from a timer tick
DOS kernel functions (file I/O, memory allocation, console output) are not reentrant. If a TSR's timer handler calls INT 21h while the foreground program is already inside INT 21h, internal kernel data structures corrupt—often hanging the machine.
DOS exposes a flag byte via INT 21h function 34h (Get Address of InDOS Flag). It returns ES:BX pointing to a byte; non-zero means DOS is busy.
mov ah, 34h
int 21h ; ES:BX -> InDOS flag byte
; later, inside timer handler:
push es
mov es, [indos_seg]
cmp byte [bx], 0 ; is DOS idle?
pop es
jne skip_dos_call ; if not zero, defer or abort
Advanced TSRs implement a deferred execution queue: the timer handler notes work needed, sets a flag, and returns. The next time the foreground program calls a harmless DOS function (like INT 28h, the idle interrupt), the TSR's INT 28h handler sees the flag and performs the deferred DOS calls safely.
Worked example: a minimal "heartbeat" TSR
Below is a complete .COM program that hooks INT 08h, increments a counter on each timer tick, and prints the count when the user presses Ctrl-Alt-H (detected via INT 16h keyboard status). It demonstrates residency, chaining, InDOS check, and stack swapping.
; HEART.COM — assemble with NASM: nasm -f bin heart.asm -o heart.com
org 100h
start:
; --- install INT 08h handler ---
mov ax, 3508h
int 21h
mov [old08_off], bx
mov [old08_seg], es
mov dx, int08_handler
mov ax, 2508h
int 21h
; --- install INT 16h handler for hotkey ---
mov ax, 3516h
int 21h
mov [old16_off], bx
mov [old16_seg], es
mov dx, int16_handler
mov ax, 2516h
int 21h
; --- get InDOS flag address ---
mov ah, 34h
int 21h
mov [indos_seg], es
mov [indos_off], bx
; --- go resident, keep 512 paragraphs (8 KiB) ---
mov dx, (end_resident - start + 15) / 16
mov ax, 3100h
int 21h
; ---------- resident code/data ----------
old08_off dw 0
old08_seg dw 0
old16_off dw 0
old16_seg dw 0
indos_seg dw 0
indos_off dw 0
tick_count dd 0
int08_handler:
pushf
call dword [cs:old08_off] ; chain to original timer (updates BIOS time)
inc dword [cs:tick_count] ; our work: just count
iret
int16_handler:
; AH=01h (check keystroke) — we only care if caller asks
cmp ah, 01h
jne chain16
; peek at BIOS keyboard flags (0040:0017) for Ctrl+Alt
push es
mov ax, 0040h
mov es, ax
test byte [es:0017h], 0Ch ; Ctrl(4) + Alt(8)
pop es
jz chain16
; Ctrl+Alt held — check if 'H' pressed (scan code 23h)
; BIOS returns scan code in AH, ASCII in AL for AH=00h read
; but we're in AH=01h peek; we'll just chain and let BIOS handle
; For demo, we'll print via INT 21h/09h if InDOS=0
pushf
call dword [cs:old16_off]
; after chain, check if 'H' was returned (simplified)
; Real code would hook AH=00h read instead.
iret
chain16:
jmp dword [cs:old16_off]
end_resident:
; nothing after this stays resident
Build and test in DOSBox:
nasm -f bin heart.asm -o heart.com
heart.com
; drops back to prompt, TSR now resident
; wait a few seconds, press Ctrl+Alt+H (may need DOSBox key mapping)
; counter printed if DOS idle
Note: The hotkey logic above is simplified; production TSRs hook INT 09h (hardware keyboard) or INT 16h AH=00h (read) to reliably detect chords.
Trade-offs that bit everyone
Memory fragmentation
Conventional memory is a single pool. Each TSR carves out a block at load time. Unloading a TSR (via its own uninstall routine or a manager like MARK/RELEASE) only works cleanly if it was the last one loaded. Removing a middle TSR leaves a hole that DOS cannot reuse for large programs because .EXE headers require contiguous paragraphs. Tools like MEMMAKER (DOS 6+) or QEMM rearranged load order and used Upper Memory Blocks (UMBs) via EMM386/HIMEM.SYS to push TSRs above 640 KiB, but UMBs were fragmented too.
Interrupt latency
Chained ISRs add up. A timer tick must finish before the next one arrives (~55 ms). A slow TSR in the INT 08h chain could miss ticks, drifting the system clock. Keyboard ISRs (INT 09h) had even tighter budgets—missed keystrokes were common with badly written pop-ups.
No protection
A buggy TSR overwriting another's data or the IVT itself crashes the whole session. No memory protection, no privilege rings—real mode trusts everyone.
How to explore this today
- Run DOSBox-X (better hardware emulation than vanilla DOSBox) and load
DEBUG.EXEor Turbo Debugger. DEBUGcommands to inspect the IVT:-d 0:0 0:ff ; dump first 256 vectors -u 0:8*4 ; disassemble INT 08h handler- Use
MEM /C /Pto see resident blocks and their owners. - Study FreeDOS kernel source (
kernel/tsr.asm,programs/ansi/ansi.asm) for production-grade patterns: stack swapping, critical sections, INT 2Fh multiplex negotiation.
If you're maintaining legacy DOS software (industrial controllers, embedded x86), understanding TSR mechanics lets you diagnose "mystery hangs" that are usually InDOS violations or vector-chain corruption. For everyone else, it's a master class in fitting cooperative multitasking into 640 KiB with zero hardware help.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.