TL;DR: The thing that surprises most developers when they first dig into DOS 3. x/4.
📖 Reading time: ~37 min
What's in this article
- Before Stack Overflow: Shipping Code When the Machine Was the Debugger
- The Hardware Constraints That Shaped Every Decision
- The Actual Toolchain People Used
- Setting Up a DOS Dev Environment Today (DOSBox-X and Real Hardware)
- Writing Your First .COM vs .EXE Program — and Why the Difference Matters
- Debugging Without a Debugger (and With DEBUG.COM)
- Memory Management: The Part That Will Break You
- The 3 Things That Still Surprise Developers Who Dig Into This Era
Before Stack Overflow: Shipping Code When the Machine Was the Debugger
The thing that surprises most developers when they first dig into DOS 3.x/4.x code is how total the control was. Your program didn't compete for CPU time. It didn't wait on a scheduler. When your code ran, it owned everything — RAM, interrupts, the keyboard buffer, the video hardware. No kernel standing between you and the metal, no MMU throwing a segfault when you walked off the end of an array. You just corrupted memory silently and wondered why the screen turned green twenty instructions later.
I got into this space by inheriting a codebase for an industrial controller that was still shipping on DOS 4.01 in 2018. Before you laugh — go count how many point-of-sale terminals, medical devices, and embedded HMIs are running some variant of a real-mode x86 environment right now. The constraints from 1988 didn't disappear; they got frozen into production systems that nobody wants to rewrite because they haven't crashed in fifteen years. Understanding early DOS development isn't nostalgia, it's practical archaeology that pays actual money.
What this piece covers is the real texture of that environment: the toolchain (Turbo C 2.0, MASM 5.x, DEBUG.COM as your last-resort disassembler), the memory model nightmare that burned everyone at least once, and the interrupt-driven I/O patterns that modern async programming quietly reinvented. I'm also going to talk about what debugging felt like when your only feedback loop was a POST card, a hex dump, and the sound of a speaker beep you coded yourself. For a broader look at how developer productivity has evolved since then, see our or the Vetusware mirror rather than random abandonware sites where the zips may be modified. Borland C++ 3.1 is a step up from TC++ 3.0 and has better IDE integration, but its legal status is grayer — it was never officially declared freeware, so you're in abandonware territory. For serious work I use Turbo Pascal 7.0 because the licensing is clean and the compiler is fast. Once you have the zip extracted to ~/dos_projects/tp7, the setup inside DOSBox-X is just:
mount C ~/dos_projects
C:
cd tp7\bin
tpc /CP+ hello.pas
Real hardware is genuinely different, and I don't mean that romantically. I mean the timing behaviors, the memory bus contention, the way a real ISA Sound Blaster responds to port I/O — none of it is fully emulatable. A 486DX2-66 or a Pentium 75 machine is still findable on eBay for under $100 most weeks, and a machine with a working ISA slot matters if you want to deal with period hardware (ISA slots disappeared around the late Pentium II era). The experience of writing a TSR that hooks INT 9h and actually watching it work on real hardware, where timing bugs will manifest that DOSBox-X silently tolerates, teaches you things that emulation simply won't. That said, real hardware is where you go after you've got a working workflow in emulation — the iteration cycle of edit-compile-test is too slow on period hardware to use it as your primary environment.
Writing Your First .COM vs .EXE Program — and Why the Difference Matters
The thing that surprised me most when I first cracked open a DOS .COM file in a hex editor was how naked it was. No header, no magic bytes at the start — just raw x86 instructions beginning at offset 0x00. The OS loads it at segment:offset CS:0100h and immediately jumps. That 256-byte gap before 0100h is the Program Segment Prefix (PSP), which DOS uses to pass command-line args and environment info. Your code never owns those bytes, but it can read them. The whole model is almost absurdly simple: one segment, max 64KB including code, data, and stack, no relocation needed because there's nothing to relocate. That simplicity is exactly why a .COM is so easy to fully understand — you can read the entire thing in a disassembler in an afternoon.
A minimal MASM .COM that prints a string and exits cleanly looks like this:
; hello.asm — assemble with: masm hello.asm; link hello.obj;
; then rename hello.exe to hello.com (or use EXE2BIN)
; Alternatively: nasm -f bin -o hello.com hello.asm
org 100h ; tell assembler code starts at offset 100h
start:
mov dx, offset msg ; DS:DX must point to '$'-terminated string
mov ah, 09h ; INT 21h function 09h: print string
int 21h
mov ax, 4C00h ; AH=4Ch terminate, AL=exit code (0)
int 21h
msg db 'Hello, DOS!', 0Dh, 0Ah, '$' ; CR+LF, '$' is the terminator
The org 100h directive is load-bearing — without it, all your offset calculations are wrong by exactly 256 bytes and you'll spend an hour debugging a working program. The $ string terminator for INT 21h/09h is one of those DOS-isms that trips people up; it has nothing to do with null-termination. Mixing up the two termination styles will print garbage until it hits a dollar sign somewhere in memory.
.EXE files are a different world. The MZ header (named after Mark Zbikowski, whose initials are the first two bytes: 4D 5A) contains a relocation table that lets the loader fix up segment references at load time. This is what allows multiple code and data segments to coexist. When you link with Microsoft's LINK.EXE from the MASM 5.x or 6.x era, the /MAP flag is genuinely essential during development — it produces a .MAP file that lists every segment, its size, and its relative address. Without it, you're guessing why your 20KB program somehow allocates 48KB:
LINK hello.obj, hello.exe, hello.map /MAP /NOE
; /NOE = no extended dictionary, avoids duplicate symbol errors with older libs
; The .MAP file will show you segment order, sizes, and public symbols
A .MAP excerpt looks like 0000:0000 00019H _TEXT — that tells you the text segment starts at offset 0 and is 25 bytes. I've fixed more mysterious crashes by reading a map file than by attaching a debugger.
The memory model question in Borland C++ and Microsoft C 6.0 is where things get genuinely dangerous at scale. The six models — TINY, SMALL, MEDIUM, COMPACT, LARGE, HUGE — control whether code and data pointers are near (16-bit, same segment) or far (32-bit, segment:offset). SMALL gives you one 64KB code segment and one 64KB data segment, which is fine for most utilities. LARGE gives you multiple segments for both, with far pointers everywhere. HUGE adds special runtime support for individual data items larger than 64KB. Picking SMALL when your data grows past 64KB gives you silent pointer wrap-around — malloc succeeds, you write to the pointer, and you've clobbered something else in the segment. The 2am version of this bug is realizing your char * and a char far * are pointing to the same physical memory only by accident, because you mixed near and far pointers across a module boundary. Borland's huge keyword and Microsoft's __far let you force far semantics per-variable without switching the whole model, which is how you patch this without recompiling everything.
- TINY: everything in one 64KB segment — this is literally what produces a .COM file via
EXE2BIN
- SMALL: one code segment, one data segment — default for most small tools, near pointers everywhere
- MEDIUM: multiple code segments, one data segment — right choice for programs with lots of functions but small data
- COMPACT: one code segment, multiple data segments — unusual; fits data-heavy but logic-light programs
- LARGE: multiple code and data segments, far pointers default — what you use when you need the space and accept the overhead
- HUGE: like LARGE but
sizeof(array) > 64KBis legal — pointer arithmetic crosses segment boundaries via runtime normalization, which is measurably slower
Debugging Without a Debugger (and With DEBUG.COM)
The thing that broke me early on wasn't writing bad code — it was not knowing where the bad code was. You'd assemble your .COM file, run it, the screen would go black or the machine would lock, and that was it. No stack trace. No error message. Just silence. That's when DEBUG.COM became the most important tool in the box.
DEBUG.COM ships with every version of DOS, lives in your PATH, and requires zero setup. You launch it with your .COM file as an argument and it drops you at a hyphen prompt with the file loaded at offset 0x100 (where all .COM programs live in the PSP). The five commands you had to internalize were non-negotiable:
- d — dump memory as hex + ASCII.
d DS:0100shows you what's actually at your program start. - u — unassemble.
u CS:0100disassembles from that address forward. Vital for understanding what the assembler actually emitted vs. what you thought you wrote. - r — show/set registers. Bare
rdumps AX, BX, CX, DX, SP, BP, SI, DI, DS, ES, SS, CS, IP, and the flags word in one shot. - t — single-step one instruction, showing updated registers after each one.
- g — go/run.
g =100 1A3starts execution from offset 100h and sets a breakpoint at 1A3h. When it hits, you're back at the hyphen prompt with full register state.
The actual workflow looked like this: crash, hard reboot (or soft reboot via Ctrl+Alt+Del if you were lucky), boot back to DOS, then:
C:\> DEBUG MYPROG.COM
-r ; check initial register state, IP should be 0100
-u 100 140 ; disassemble first chunk to find your suspect code
-g =100 1A3 ; run until the address just before the bad branch
-r ; examine AX, BX — did the comparison set flags right?
-t ; step one instruction
-t ; step again
-d DS:0200 ; dump the data segment if you're chasing a memory issue
Borland's Turbo Debugger (TD.EXE) was a genuine revelation after that workflow. Source-level debugging. Watch windows. You could split the screen and see your C source code in one pane and the generated x86 assembly directly below it, stepping through both simultaneously. The thing that caught me off guard the first time I used it: you had to compile with -v in Turbo C to embed debug info, and TD.EXE had to be able to find the .C source files at the paths recorded at compile time — move your project folder and it'd silently fall back to assembly-only mode. Microsoft's CodeView (CV.EXE) had the same gotcha but different flags: compile with /Zi and link with /CO, otherwise CodeView loads fine but shows you nothing useful.
; Turbo C debug build
tcc -v -N myprog.c ; -v = debug symbols, -N = stack overflow check
td myprog.exe ; launch Turbo Debugger
; Microsoft C / CodeView
cl /Zi myprog.c /link /CO ; /Zi embeds symbols, /CO passes debug flag to linker
cv myprog.exe ; launch CodeView
Both debuggers had a class of bugs they couldn't reliably catch: anything timing-sensitive. Hardware interrupt handlers, code that polled the 8253 timer, anything where the debugger's own INT hooks perturbed execution. For those, I fell back to printf-style debugging via INT 21h Function 02h — single character output directly through DOS, no library overhead:
; Drop this inline wherever you need a breadcrumb
; Outputs 'A' to stdout without touching any library code
mov ah, 02h
mov dl, 'A' ; change the letter at each checkpoint
int 21h
For the cases where you didn't trust even INT 21h (deep inside an ISR, for example), you read the BIOS Data Area directly. The BDA starts at physical address 0040:0000 and holds the machine's low-level state — keyboard buffer head/tail pointers at 0040:001A/001C, equipment flags at 0040:0010, video mode at 0040:0049. Reading it directly told you what the hardware thought was happening, independent of whatever DOS or your own code believed. The move was to d 40:00 in DEBUG and just read the dump manually against the BIOS reference chart you'd photocopied from the IBM Technical Reference manual. No fancy tooling — just knowing what the bytes meant.
Memory Management: The Part That Will Break You
The thing that gets everyone first isn't the 640KB ceiling itself — it's that the ceiling is actually lower than that before your program even starts. DOS loads, your CONFIG.SYS drivers pile in, your AUTOEXEC.BAT TSRs grab chunks, and by the time your application gets control, you might have 560KB or less of conventional memory. I remember shipping a program that worked fine on my dev machine and crashed silently on a customer's box because their CD-ROM driver ate another 18KB. That's the DOS development experience in a nutshell.
The memory map looked like this, and you had to hold all of it in your head simultaneously:
0x00000 - 0x9FFFF : Conventional memory (640KB) — your arena
0xA0000 - 0xBFFFF : Video memory (EGA/VGA buffers live here)
0xC0000 - 0xEFFFF : Upper Memory Blocks (UMBs) — ROM, option ROMs, mappable space
0xF0000 - 0xFFFFF : System BIOS ROM
; Above 1MB (only reachable via protected mode or EMS/XMS trampolines)
0x100000+ : Extended memory (XMS) — HMA starts at 0x100000
UMBs were the hack that let you reclaim real estate by shoving drivers into the 384KB between 640KB and 1MB. The config that made this work required exact load order in CONFIG.SYS:
DEVICE=C:\DOS\HIMEM.SYS ; MUST be first — installs the A20 handler
DEVICE=C:\DOS\EMM386.EXE NOEMS ; enables UMB access; swap NOEMS for RAM if you need EMS
DOS=HIGH,UMB ; move DOS kernel into HMA (the first 64KB above 1MB)
DEVICEHIGH=C:\DOS\SETVER.EXE ; now this loads into UMB, not conventional memory
Get that order wrong and EMM386 fails silently or, worse, loads but reports no UMBs available. The common mistake was putting a driver that needed EMS before EMM386.EXE finished initializing. Your game would launch, call INT 67h to detect the EMS driver, get a zero back, and bail with a cryptic "Expanded Memory Manager not found" message that had nothing to do with the actual problem.
EMS (via the LIM 4.0 spec) and XMS were two completely different interfaces solving the same problem in incompatible ways. EMS mapped 64KB "pages" into a physical page frame in the UMB area — you had to explicitly map pages in and out through INT 67h calls, which meant your code looked like this:
; Map EMS logical page 3 into physical page 0 of the page frame
mov ax, 4400h ; AH=44h: map unallocated page / AH=44h Function: Map Pages
mov bx, 3 ; logical page number
mov cx, 0 ; physical page (0-3)
mov dx, ems_handle ; handle from earlier alloc call
int 67h
or ah, ah
jnz ems_error ; AH != 0 means failure — check it every single time
XMS was cleaner — you moved blocks above 1MB using a far call through the XMS driver, not an interrupt. The driver address came from INT 2Fh AX=4310h. If you mixed EMS and XMS calls in the same program without careful state tracking, you'd corrupt memory in ways that wouldn't manifest until three function calls later, making the bug almost impossible to trace with the tools available at the time.
TSRs deserve their own horror story. INT 27h was the old way to go resident — it was simple but limited you to 64KB and, critically, it didn't close open file handles. INT 21h AH=31h was the right approach: you set DX to the number of paragraphs to keep, and DOS marked that memory as owned. The real trap was interrupt vector cleanup. If your TSR hooked INT 9h (keyboard) or INT 1Ch (timer tick) and the user unloaded it out of order, your vectors now pointed at freed memory:
; On TSR install, save the old vector before replacing it
mov ax, 3509h ; Get Interrupt Vector for INT 9h
int 21h
mov [old_int9_seg], es
mov [old_int9_off], bx
; On unload, check that your handler is still the current one FIRST
; If another TSR loaded after you and also hooked INT 9h, you cannot safely remove
; yourself — you'd orphan their handler. Most TSRs just didn't bother with unload.
The heap fragmentation problem in real mode is subtle and I watched it bite experienced C programmers. malloc() under Borland C++ called DOS INT 21h AH=48h, which allocated paragraphs (16-byte blocks). DOS used a simple first-fit or best-fit strategy depending on AH=58h settings. If you allocated and freed blocks of varying sizes — say, a 200-byte struct, then a 1000-byte buffer, then a 50-byte string — you'd end up with holes that couldn't satisfy a 4KB allocation even if the total free bytes exceeded 4KB. There was no compaction. The solution was to think in terms of pools: allocate large blocks once, subdivide them yourself.
Far pointers are where Borland C developers lost entire weekends. In the large or compact memory model, void far *ptr stored a segment and an offset as a 32-bit value — 16 bits each. The bug pattern looked innocent:
char far *p = (char far *)0x50000010L; // segment 0x5000, offset 0x0010
char far *q = p + 0xFFF5; // offset wraps around! 0x0010 + 0xFFF5 = 0x0005
// segment is STILL 0x5000
// q now points BELOW p in physical memory
// Normalized form (same physical address, different segment:offset):
// Physical = segment * 16 + offset
// 0x5000 * 16 + 0x0010 = 0x50010 (physical byte 0x50010)
// After bad arithmetic: 0x5000 * 16 + 0x0005 = 0x50005 — different physical address!
Borland's _fptrnorm() could normalize a pointer, but most developers forgot it existed. The real lesson was: never do pointer arithmetic across a 16-bit offset boundary without normalizing first. Two far pointers that appeared equal with == could point to different physical locations if they weren't normalized. Turbo Debugger could show you the raw segment:offset, which was the only way to diagnose this — and even then it required you to already suspect the pointer was the problem.
The 3 Things That Still Surprise Developers Who Dig Into This Era
The thing that hits hardest when you actually sit down with a DOS-era codebase is how much INT 21h could do on its own. One software interrupt, and you get file I/O, console input/output, process termination, environment variable access, and memory allocation — all dispatched by whatever value you loaded into AH before triggering it. Function 0x3C creates a file, 0x3F reads from a handle, 0x40 writes. No libc wrapper, no syscall table abstraction — just:
; Write "Hello" to stdout (handle 1) using INT 21h AH=40h
mov ah, 40h ; function: write to file/device
mov bx, 1 ; handle 1 = stdout
mov cx, 5 ; byte count
mov dx, offset msg ; pointer to buffer
int 21h ; DOS dispatcher picks it up from AH
; On return: AX = bytes written, CF set on error
What surprises modern devs isn't the simplicity — it's the completeness. The full function list across INT 21h covers maybe 80+ services. The entire API surface of a 1980s operating system fit in a single interrupt handler. I spent time cross-referencing against Ralf Brown's interrupt list and kept expecting to find some parallel mechanism for certain features — there isn't one. It's all in there. That's philosophically different from how we design systems now, where surface area sprawl is treated as normal.
The BIOS documentation thing genuinely caught me off guard. The IBM PC Technical Reference Manual — the original 1981 edition and its successors — doesn't just describe the hardware. It includes the actual BIOS source code, printed in the appendix, in 8086 assembly, with comments. Every INT vector (INT 10h for video, INT 13h for disk, INT 16h for keyboard) is documented with entry conditions, return values, and register preservation guarantees. The memory map starting from segment 0000h is spelled out with what lives at every significant address: 0040:0000 through 0040:00FF is the BIOS Data Area, and you knew exactly what offset held the cursor position for each video page, what held the keyboard buffer head pointer, what held the equipment flags. This wasn't reverse-engineered after the fact — IBM handed you the map. Hardware transparency at that level simply doesn't exist anymore. Intel's Architecture Software Developer's Manual is thorough, but it's 5,000 pages and describes a chip you can't fully observe at runtime.
The practical consequence of that transparency was that shipping software required — and produced — developers who understood the whole stack. Not aspirationally, not as a career goal, but because you had no choice. A game developer in 1990 writing a sound driver for the OPL2 chip on an Ad Lib card was reading the Yamaha YM3812 register map, directly poking I/O ports at 0x388 and 0x389, and timing the writes manually because the chip needed a 23-microsecond delay between register select and data write or it would silently corrupt state:
; Write to OPL2 register — timing matters, no driver abstracts this for you
mov dx, 388h ; OPL2 status/address port
mov al, reg_num
out dx, al
; Burn ~23 microseconds — on a 4.77MHz 8088, 6 I/O reads does it
in al, dx
in al, dx
in al, dx
in al, dx
in al, dx
in al, dx
inc dx ; 389h = data port
mov al, value
out dx, al
; Now burn ~84 microseconds before next register write
There was no HAL, no kernel driver model, no audio API. You either knew the chip or your audio was broken. That constraint produced a specific kind of developer competence that's genuinely rare now — not better or worse, just different. When something didn't work, the answer was always in a document you could actually read, not a closed firmware blob or a kernel subsystem with 400,000 lines of history. The debugging workflow was: read the manual, check your register setup, verify your timing. The entire observable universe of the problem fit in your head. That's the thing modern developers who dig into this era find most disorienting — not the constraint, but the legibility.
When You Should NOT Try to Write DOS Code (Honest Assessment)
If your goal is shipping something to real users in 2025, I'll be blunt: close this tab and go back to whatever framework you were ignoring. DOS development is archaeology. The toolchain is fragile, the documentation is scattered across abandonware sites and 30-year-old PDFs, and the skills don't transfer to your next sprint. I spent a weekend getting a simple text-mode menu rendering correctly under DOSBox and the main thing I shipped was a headache. Fun archaeology, zero career ROI for most of us.
The one situation where I'd argue this pays off immediately is legacy maintenance — and I mean real legacy, not "we still use jQuery." There are CNC machines, patient monitoring systems, and industrial control panels running MS-DOS 6.22 on actual hardware in hospitals and factory floors right now. If you're the person who gets the call when one of those breaks, knowing how INT 21h file I/O works or how to read the BIOS parameter block off a FAT12 floppy image is not academic. It's the difference between a 2-hour fix and a $40,000 equipment replacement conversation with management.
As a learning tool for x86 internals, DOS is genuinely useful — but only after you've hit a ceiling with modern abstractions. The moment I actually understood what a GDT entry does in protected mode Linux was after I'd manually set up a segment descriptor in real mode DOS. Segmentation makes zero sense when you first read Intel's Vol. 3 manual cold. It makes complete sense after you've written code where CS:IP is a thing you track manually and far pointers exist because your address space is 1MB. Same with interrupt dispatch — writing a TSR that hooks INT 9h to intercept keystrokes demystifies what your kernel's interrupt controller abstraction is actually doing underneath.
The crossover to embedded work is more direct than most people expect. If you're writing bare-metal firmware for an STM32, an ESP32, or anything RISC-V without an RTOS underneath, the mental model is almost identical to DOS: no MMU protecting you from yourself, no scheduler handing off the CPU, no libc you can trust blindly. You are the OS. The habit of thinking "what memory does this pointer actually point to, and who owns it right now" that DOS forces on you transfers directly to fighting a HardFault on Cortex-M4 at 3am. The tooling is totally different — you're in GCC, OpenOCD, and gdb with a J-Link — but the reasoning is the same.
- Ship a product to real users? Don't. Use something with a package manager and a Stack Overflow presence.
- Maintain actual DOS-era industrial or medical equipment? This knowledge pays immediately — find a copy of Ralf Brown's Interrupt List and bookmark it now.
- Learn x86 internals or OS concepts from scratch? Valid, but go in knowing it's a ladder you kick away once you've climbed it. OSDev wiki + MIT 6.828 will take you further once DOS has given you the intuition.
- Bare-metal embedded without an OS? The mental model maps directly. The specifics don't, but the discipline of owning every byte of memory does.
The honest filter is: are you trying to understand something, or build something? DOS development is one of the best tools I know for understanding — the layer cake of PC hardware, how an OS actually bootstraps itself, why protected mode exists. As a building platform in 2025, it's a dead end. Most developers reading this should treat it the way you treat reading K&R C — illuminating, worth doing once, not your daily driver.
Resources That Are Actually Worth Your Time
Ralf Brown's Interrupt List is the one resource I keep coming back to no matter what. Every INT call, every register expected on entry, every possible return value — it's all there. The original is a massive text dump (RBIL in zip form), but searchable HTML versions exist at sites like before assuming anything is freely redistributable. The reason these matter: if you're reading source from that era or trying to reproduce a build environment, GCC isn't a drop-in substitute. The memory model assumptions, inline assembly syntax, and interrupt handler pragmas are compiler-specific. Turbo Pascal's {$F+} far call directives and Borland C's interrupt keyword are not things you replicate trivially.
DOSBox-X on GitHub is the fork to use for development work. The original DOSBox targets game compatibility; DOSBox-X targets accuracy and covers things like PC-98 hardware, different machine types, more complete EMS/XMS implementations, and better debugger integration. The built-in debugger alone is worth it — you can set breakpoints, inspect segment registers, and step through real-mode code. The wiki is solid, and more importantly, the issue tracker is actually useful: if something behaves unexpectedly, there's a good chance someone already filed it with reproduction steps. Running it looks like this:
# Clone and build on Linux (needs SDL2, libfluidsynth, etc.)
git clone https://github.com/joncampbell123/dosbox-x.git
cd dosbox-x
./build-dosbox.sh
# Or grab a release binary and point it at your DOS directory
dosbox-x -conf my_dos.conf -c "mount c /home/user/dos" -c "c:"
For quick experiments where you don't want to configure a local emulator, . Follow for more developer-focused tooling reviews and productivity guides.
SOCIAL SHARE CARD GENERATOR