TSR Programs: How DOS Ran Background Tasks Without Multitasking
DOS had no scheduler and no processes in the modern sense — so how did pop-up utilities, mouse drivers, and print spoolers run 'in the background'? By staying resident and hooking interrupts.
DOS runs one program at a time, full stop — there’s no scheduler, no process table, no preemption. And yet the DOS era was full of software that seemed to run continuously in the background: mouse drivers that tracked movement between keystrokes, print spoolers that fed a printer while you kept working, pop-up utilities (SideKick was the famous commercial example) that appeared over whatever program you were running at a keystroke. The mechanism behind all of it is the TSR — Terminate and Stay Resident.
What “terminate and stay resident” actually means
A normal DOS program terminates via interrupt INT 21h function 4Ch (or the older INT 20h), which tells DOS: this program is done, reclaim its memory. A TSR instead terminates via function 31h (INT 21h, AH=31h, “Keep Process”) or the older, cruder INT 27h — both tell DOS the opposite: this program is done running for now, but do not reclaim its memory. The program’s code and data stay parked in RAM, untouched, after control returns to whatever invoked it (usually COMMAND.COM).
mov dx, offset resident_end - offset psp_start
shr dx, 4 ; convert bytes to paragraphs
mov ax, 3100h ; AH=31h: Terminate and Stay Resident
int 21h
Staying resident is necessary but not sufficient — parked memory alone doesn’t do anything. The second half of the mechanism is what makes a TSR actually useful.
Hooking interrupts: how a TSR gets control back
A TSR earns its keep by hooking one or more interrupt vectors before it terminates — rewriting entries in the interrupt vector table (the first 1KB of memory, one 4-byte far pointer per vector) to point at its own code instead of whatever handled that interrupt before:
mov ax, 3509h ; AH=35h: Get Interrupt Vector, INT 09h
int 21h ; ES:BX now holds the OLD handler address
mov [old_handler], bx
mov [old_handler+2], es
mov dx, offset new_kb_handler
mov ax, 2509h ; AH=25h: Set Interrupt Vector, INT 09h
int 21h
INT 9 (keyboard hardware interrupt) and INT 8 (the 18.2Hz timer tick) were the two most commonly hooked vectors — a keyboard hook lets a TSR watch every keystroke for a hotkey combination (SideKick’s trigger was famously Ctrl-Alt); a timer hook gives a TSR a steady heartbeat to do periodic work without anything else needing to cooperate. Critically, a well-behaved TSR’s handler ends by chaining to the old handler it saved — jumping to the original address rather than returning directly — so every previously-installed handler in the chain still runs. Stack enough TSRs and you get a genuine chain of hooks, each one calling the next.
Popping up over the running program
The keystroke-triggered “pop-up” behavior needed one more trick: a TSR’s keyboard handler can’t just start drawing on screen mid-interrupt, because it might be interrupting any program, in any state, possibly already inside a DOS or BIOS call that isn’t reentrant. The standard technique was to hook INT 28h (DOS’s “idle” interrupt, fired whenever DOS is waiting for keyboard input at a safe reentrancy point) and defer the actual pop-up rendering to that safer moment — checking a flag set by the keyboard handler, and only then taking over the screen.
The multiplex interrupt: avoiding double-loading
Loading the same TSR twice wastes memory and, if both copies hook the same vectors, can produce genuinely broken behavior. DOS TSRs conventionally used INT 2Fh — the multiplex interrupt — as a detection and communication channel: a TSR would claim an unused multiplex number, and respond to a specific “are you installed?” query on that channel so its own installer (or another program) could check before loading a second copy:
mov ax, 0ab00h ; multiplex ID 0ABh, function 00h: "installed check?"
int 2fh
cmp al, 0ffh ; 0FFh = "yes, I'm here"
je already_loaded
Why unloading a TSR was so fragile
Removing a TSR cleanly means restoring every vector it hooked back to what it saved — but that’s only safe if no later-loaded TSR has since hooked the same vector on top of it. In practice, TSRs could only be safely removed in exact reverse order of loading (LIFO), and only if every one of them cooperated correctly; a single non-conforming TSR anywhere in the stack could make removing any of the others crash the machine. This fragility is a direct consequence of the mechanism itself: interrupt hooking has no built-in concept of “the next handler in the chain unloaded,” so nothing detects the inconsistency until something calls a vector pointing at memory that’s no longer there.
Why this mattered enough to shape DOS memory management
TSRs are a big part of why loading things into Upper Memory Blocks mattered so much in practice — every resident TSR permanently reduced the conventional 640KB available to whatever ran afterward, so LOADHIGH (covered in FreeDOS’s memory-management model) existed largely because of TSRs competing for the same scarce space. The entire pattern — hook a vector, chain to the old handler, communicate over INT 2Fh, stay parked in RAM indefinitely — was DOS’s only real answer to “run code in response to events without an actual scheduler,” and it shaped both the software people wrote and the memory-tuning habits every DOS power user eventually picked up.