commit 09af523f8c8607da9bec1a7e27843953cebf698c Author: asmhatre Date: Wed Sep 30 19:10:56 2026 +0000 Add interrupts module: INT 3 (CC breakpoint) vs INT 21h (DOS services) NASM sources, DOSBox/FreeDOS Debug/Unicorn harness, captured output and 31 assertions backing the ankurm.com article "INT 3 vs INT 21h in 8086 Assembly". Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01KqJyCidz3ZgRyHABv2GVJh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5dc71c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +interrupts/build/ +__pycache__/ +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..fce0535 --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# 8086-Programs + +Runnable companion code for the 8086 assembly articles on [ankurm.com](https://ankurm.com/). +Each topic lives in its own directory with its sources, a script that rebuilds everything, and the captured output the article quotes. + +| Module | What it covers | Article | +|---|---|---| +| [`interrupts/`](interrupts/) | `INT 3` (the `CC` breakpoint) versus `INT 21h` (DOS services chosen by `AH`): opcode bytes, stack frame, DEBUG behaviour, INT 21h function tour | [INT 3 vs INT 21h in 8086 Assembly](https://ankurm.com/understanding-int-3h-vs-int-21h-in-8086-assembly/) | diff --git a/interrupts/README.md b/interrupts/README.md new file mode 100644 index 0000000..6da5c0c --- /dev/null +++ b/interrupts/README.md @@ -0,0 +1,48 @@ +# interrupts -- INT 3 (breakpoint) vs INT 21h (DOS services) + +Companion module for the article +[INT 3 vs INT 21h in 8086 Assembly](https://ankurm.com/understanding-int-3h-vs-int-21h-in-8086-assembly/). +Everything the article quotes as program output was produced by `scripts/run-all.sh` and is stored in `output/`. + +## What was actually run, and what was not + +| Tool | Version | Used for | +|---|---|---| +| NASM | 2.16.01 | assembling every `.asm` file (`bits 16`, flat `.COM` layout) | +| DOSBox | 0.74-3 | running the `.COM` programs: a real INT 21h implementation, DOSBox's own default INT 3 vector | +| FreeDOS Debug | 2.50 (MIT) | a DEBUG.EXE-compatible debugger, driven by a script, to watch CC vs CD 03 traps | +| Unicorn | 2.1.4 | a software "debugger" experiment on a 16-bit x86 core (opcode lengths, return addresses) | + +**Not run:** emu8086, Microsoft's own `DEBUG.EXE`, MASM/TASM, real MS-DOS on real hardware, a real 8086. +DOSBox and Unicorn are emulators; the article says so wherever it matters. Claims about those unrun environments +are labelled "documented, not executed". + +## Files + +| Path | Purpose | +|---|---| +| `asm/opcodes.asm` | which bytes `int3`, `int 3`, `db 0CDh,3`, `int 21h` assemble to (listing only) | +| `asm/hello21.asm` | smallest INT 21h program: `AH=09h` print, `AH=4Ch` exit with code 7 | +| `asm/fn_tour.asm` | one line of output per important INT 21h function (console, vectors, memory, files, EXEC) | +| `asm/int3_handler.asm` | installs an INT 3 handler with `AH=25h`; triggers it with `CC` and with `CD 03` | +| `asm/int3_default.asm` | executes INT 3 with no handler of its own | +| `asm/dbg_cc.asm`, `asm/dbg_cd03.asm` | targets for the DEBUG sessions | +| `asm/bp_target.asm` | flat routine patched by the Unicorn breakpoint experiment | +| `asm/common.inc` | print helpers (`puts`, `putdec`, `puthex16`) | +| `scripts/run-all.sh` | rebuild everything in `output/` and run the checks | +| `scripts/dosbox_run.py` | assemble + run the `.COM` files headless in DOSBox | +| `scripts/bp_experiment.py` | Unicorn breakpoint experiment | +| `scripts/check.py` | 31 assertions that pin every claim the article makes about the output | +| `scripts/fetch-debug.sh` | downloads FreeDOS Debug into `build/` (not committed) | +| `output/` | captured results, numbered in the order the article uses them | + +## Quickstart + +```bash +sudo apt-get install nasm dosbox # Debian/Ubuntu +pip install unicorn +./scripts/run-all.sh # prints PASS/FAIL per assertion +``` + +DOSBox runs headless (`SDL_VIDEODRIVER=dummy`). The memory figure on the `AH=48h` line and the segment numbers in the +DEBUG transcripts depend on the DOSBox build and configuration. diff --git a/interrupts/asm/bp_target.asm b/interrupts/asm/bp_target.asm new file mode 100644 index 0000000..ceb0ffd --- /dev/null +++ b/interrupts/asm/bp_target.asm @@ -0,0 +1,13 @@ +; bp_target.asm -- a flat 16-bit routine used by the Unicorn breakpoint experiment. +; Three one-byte INC CX instructions, so a two-byte CD 03 patch on the first one +; overwrites the first byte of the second one too. +bits 16 +org 0x7C00 +start: + xor cx, cx +site: + inc cx ; 41 <- breakpoint goes here + inc cx ; 41 + inc cx ; 41 +done: + nop diff --git a/interrupts/asm/common.inc b/interrupts/asm/common.inc new file mode 100644 index 0000000..0106625 --- /dev/null +++ b/interrupts/asm/common.inc @@ -0,0 +1,64 @@ +; common.inc -- tiny output helpers shared by the demo programs (DOS .COM, NASM syntax) + +; print the '$'-terminated string at DS:DX (INT 21h, AH=09h) +puts: + mov ah, 09h + int 21h + ret + +; print CRLF +crlf: + mov dx, s_crlf + jmp puts +s_crlf db 0Dh, 0Ah, '$' + +; print AX as four hex digits using INT 21h AH=02h +puthex16: + push ax + mov al, ah + call puthex8 + pop ax +puthex8: + push ax + shr al, 1 + shr al, 1 + shr al, 1 + shr al, 1 + call nib + pop ax + and al, 0Fh +nib: + add al, '0' + cmp al, '9' + jbe .ok + add al, 7 +.ok: + mov dl, al + mov ah, 02h + int 21h + ret + +; print AX as unsigned decimal +putdec: + push bx + push cx + push dx + xor cx, cx + mov bx, 10 +.d1: + xor dx, dx + div bx + push dx + inc cx + test ax, ax + jnz .d1 +.d2: + pop dx + add dl, '0' + mov ah, 02h + int 21h + loop .d2 + pop dx + pop cx + pop bx + ret diff --git a/interrupts/asm/dbg_cc.asm b/interrupts/asm/dbg_cc.asm new file mode 100644 index 0000000..a1e5573 --- /dev/null +++ b/interrupts/asm/dbg_cc.asm @@ -0,0 +1,8 @@ +; dbg_cc.asm -- target for a DEBUG session: a real one-byte breakpoint (CC) between two MOVs. +bits 16 +org 0x100 + mov ax, 1111h + int3 ; CC + mov ax, 2222h + mov ax, 4C00h + int 21h diff --git a/interrupts/asm/dbg_cd03.asm b/interrupts/asm/dbg_cd03.asm new file mode 100644 index 0000000..75567aa --- /dev/null +++ b/interrupts/asm/dbg_cd03.asm @@ -0,0 +1,8 @@ +; dbg_cd03.asm -- the same program, but the breakpoint is the two-byte CD 03. +bits 16 +org 0x100 + mov ax, 1111h + db 0CDh, 03h ; CD 03 (NASM's `int 3` would emit exactly this) + mov ax, 2222h + mov ax, 4C00h + int 21h diff --git a/interrupts/asm/fn_tour.asm b/interrupts/asm/fn_tour.asm new file mode 100644 index 0000000..3be6446 --- /dev/null +++ b/interrupts/asm/fn_tour.asm @@ -0,0 +1,366 @@ +; fn_tour.asm -- exercises the important INT 21h functions, one labelled line of output each. +; Run it under DOS (the harness uses DOSBox) with stdin redirected from IN.TXT. +bits 16 +org 0x100 + ; ---- shrink our memory block: a .COM owns all conventional memory (AH=4Ah) ---- + mov bx, 1000h ; keep 64 KB = 1000h paragraphs + mov ah, 4Ah ; ES = our PSP segment already + int 21h + + ; ---- AH=30h get DOS version ---- + mov dx, l_ver + call puts + mov ah, 30h + int 21h ; AL = major, AH = minor + push ax + xor ah, ah + call putdec + mov dl, '.' + mov ah, 02h + int 21h + pop ax + mov al, ah + xor ah, ah + call putdec + call crlf + + ; ---- AH=2Ah / AH=2Ch date and time (values change every run, so we only range-check) ---- + mov dx, l_date + call puts + mov ah, 2Ah ; CX = year, DH = month, DL = day + int 21h + cmp cx, 1980 + jb .baddate + cmp dh, 12 + ja .baddate + mov dx, s_ok + jmp .dateout +.baddate: + mov dx, s_bad +.dateout: + call puts + mov ah, 2Ch ; CH = hour, CL = minute, DH = second + int 21h + mov dx, s_ok2 + cmp ch, 23 + jbe .timeok + mov dx, s_bad +.timeok: + call puts + call crlf + + ; ---- AH=02h / AH=06h character output ---- + mov dx, l_chr + call puts + mov dl, 'A' + mov ah, 02h + int 21h + mov dl, 'B' + mov ah, 06h ; direct console output (no ^C check) + int 21h + call crlf + + ; ---- AH=01h then AH=0Ah stdin: one echoed char, then a buffered line ---- + mov dx, l_in + call puts + mov ah, 01h ; AL = char read (and echoed) + int 21h + mov [ch1], al + mov dx, buf + mov ah, 0Ah ; buf[0] = max, buf[1] = count read, buf[2..] = text + int 21h + call crlf + mov dx, l_got + call puts + mov al, [ch1] + call puthex8 ; the character AH=01h returned + mov dx, l_cnt + call puts + xor ah, ah + mov al, [buf+1] ; number of characters AH=0Ah stored (CR not counted) + mov bx, ax + call putdec + mov byte [buf+2+bx], '$' + mov dx, l_txt + call puts + mov dx, buf+2 + call puts + call crlf + + ; ---- AH=25h / AH=35h set and get an interrupt vector ---- + mov dx, l_vec + call puts + mov ax, 2560h + mov dx, dummy_isr + int 21h + mov ax, 3560h + int 21h ; ES:BX = vector 60h + mov dx, s_bad + cmp bx, dummy_isr + jne .vecout + mov ax, es + mov cx, cs + cmp ax, cx + jne .vecout + mov dx, s_ok +.vecout: + call puts + call crlf + + ; ---- AH=48h / AH=49h allocate and free memory; failure returns CF=1, AX=8, BX=largest ---- + mov dx, l_mem + call puts + mov bx, 0100h ; 100h paragraphs = 4 KB + mov ah, 48h + int 21h + jc .memfail + mov es, ax ; ES = segment of the new block + mov ah, 49h + int 21h + mov dx, s_ok + jmp .memout +.memfail: + mov dx, s_bad +.memout: + call puts + call crlf + mov bx, 0FFFFh ; ask for 1 MB: must fail + mov ah, 48h + int 21h + mov [err], ax ; save the results before printing clobbers them + mov [big], bx + mov byte [cfch], '0' + jnc .memcf + mov byte [cfch], '1' +.memcf: + mov dx, l_memfail + call puts + mov dl, [cfch] + mov ah, 02h + int 21h + mov dx, l_ax + call puts + mov ax, [err] ; 0008h = insufficient memory + call puthex16 + mov dx, l_big + call puts + mov ax, [big] ; BX = largest block available, in paragraphs + call puthex16 + call crlf + + ; ---- file I/O: AH=3Ch create, 40h write, 3Eh close, 3Dh open, 3Fh read, 42h lseek ---- + mov dx, l_file + call puts + mov dx, fname + xor cx, cx ; normal attributes + mov ah, 3Ch + int 21h + jc .fileerr + mov bx, ax ; BX = handle + mov dx, payload + mov cx, payload_len + mov ah, 40h + int 21h + mov ah, 3Eh + int 21h + mov dx, fname + mov ax, 3D00h ; open read-only + int 21h + jc .fileerr + mov bx, ax + mov dx, rdbuf + mov cx, 64 + mov ah, 3Fh + int 21h ; AX = bytes actually read + call putdec ; bytes actually read + mov dx, l_read + call puts + mov ax, 4202h ; LSEEK from end, offset 0 + xor cx, cx + xor dx, dx + int 21h ; DX:AX = file size + mov [fsize], ax + mov dx, l_seek + call puts + mov ax, [fsize] + call putdec + mov ah, 3Eh + int 21h + call crlf + jmp .fileok +.fileerr: + mov dx, s_bad + call puts + call crlf +.fileok: + + ; ---- AH=56h rename, AH=4Eh findfirst (DTA defaults to PSP:0080h), AH=41h delete ---- + mov dx, l_ff + call puts + mov dx, fname + mov di, fname2 + push ds + pop es + mov ah, 56h + int 21h + mov dx, fname2 + xor cx, cx + mov ah, 4Eh + int 21h + jc .ffbad + mov dx, 80h+1Eh ; DTA+1Eh = ASCIZ name found + mov si, dx ; copy it to a '$'-terminated buffer so AH=09h can print it + mov di, ffname +.cp: + lodsb + test al, al + jz .cpend + stosb + jmp .cp +.cpend: + mov al, '$' + stosb + mov dx, ffname + call puts + mov dx, l_size + call puts + mov ax, [80h+1Ah] ; DTA+1Ah = file size (low word) + call putdec + jmp .ffend +.ffbad: + mov dx, s_bad + call puts +.ffend: + call crlf + mov dx, l_del + call puts + mov dx, fname2 + mov ah, 41h ; delete + int 21h + mov dx, fname2 + mov ax, 3D00h ; open it again: must fail + int 21h + mov [err], ax ; save AX and CF before printing clobbers them + mov byte [cfch], '0' + jnc .cfout + mov byte [cfch], '1' +.cfout: + mov dl, [cfch] + mov ah, 02h + int 21h + mov dx, l_ax + call puts + mov ax, [err] + call puthex16 + call crlf + + ; ---- AH=39h mkdir, AH=3Ah rmdir, AH=19h current drive, AH=47h current directory ---- + mov dx, l_dir + call puts + mov dx, dname + mov ah, 39h + int 21h + mov dx, dname + mov ah, 3Ah + int 21h + mov dx, s_ok + jnc .dirout + mov dx, s_bad +.dirout: + call puts + mov dx, l_drv + call puts + mov ah, 19h ; AL = 0 for A:, 1 for B:, 2 for C: + int 21h + xor ah, ah + call putdec + call crlf + + ; ---- AH=4Bh EXEC a child, AH=4Dh read its return code ---- + mov dx, l_exec + call puts + mov ax, cs + mov [pb+4], ax ; command-tail segment + mov [pb+8], ax ; FCB1 segment + mov [pb+12], ax ; FCB2 segment + mov dx, child + mov bx, pb + mov ax, 4B00h ; load and execute + int 21h + jc .execbad + mov ah, 4Dh ; AL = child's return code, AH = termination type + int 21h + push ax + mov dx, l_rc + call puts + pop ax + push ax + xor ah, ah + call putdec + mov dx, l_tt + call puts + pop ax + mov al, ah + xor ah, ah + call putdec + call crlf + jmp .done +.execbad: + mov dx, s_bad + call puts + call crlf +.done: + mov ax, 4C00h ; AH=4Ch: exit, return code 0 + int 21h + +dummy_isr: + iret + +l_ver db 'AH=30h DOS version : $' +l_date db 'AH=2Ah/2Ch date+time ranges : $' +l_chr db 'AH=02h + AH=06h : $' +l_in db 'AH=01h + AH=0Ah (stdin) : echo -> $' +l_got db ' AH=01h got 0x$' +l_txt db ', text = $' +l_cnt db ' ; AH=0Ah count = $' +l_vec db 'AH=25h/35h vector 60h : $' +l_mem db 'AH=48h/49h alloc+free 4 KB : $' +l_memfail db 'AH=48h ask for 1 MB (fails) : CF=$' +l_big db ' ; largest block (paragraphs) = $' +l_file db 'AH=3Ch/40h/3Dh/3Fh/42h : $' +l_read db ' bytes read ; $' +l_ff db 'AH=56h + AH=4Eh findfirst : $' +l_size db ' size = $' +l_del db 'AH=41h delete, reopen fails : CF=$' +l_seek db 'LSEEK(end) says size = $' +l_ax db ' AX=$' +l_dir db 'AH=39h/3Ah mkdir+rmdir : $' +l_drv db ' ; AH=19h drive = $' +l_exec db 'AH=4Bh exec child : $' +l_rc db 'AH=4Dh return code = $' +l_tt db ' type = $' +s_ok db 'ok$' +s_ok2 db ' ok$' +s_bad db 'FAILED$' +fname db 'T.TXT', 0 +fname2 db 'U.TXT', 0 +dname db 'SUBD', 0 +child db 'HELLO21.COM', 0 +payload db 'DOS file I/O' +payload_len equ $ - payload +ch1 db 0 +err dw 0 +fsize dw 0 +big dw 0 +cfch db 0 +buf db 20, 0 + times 22 db 0 +ffname times 16 db 0 +rdbuf times 64 db 0 +; EXEC parameter block: env seg (0 = inherit), cmd tail ptr, FCB1 ptr, FCB2 ptr +pb dw 0, tail, 0, fcb1, 0, fcb2, 0 +tail db 0, 0Dh +fcb1 times 16 db 0 +fcb2 times 16 db 0 + +%include "common.inc" diff --git a/interrupts/asm/hello21.asm b/interrupts/asm/hello21.asm new file mode 100644 index 0000000..aa2c893 --- /dev/null +++ b/interrupts/asm/hello21.asm @@ -0,0 +1,9 @@ +; hello21.asm -- the smallest useful INT 21h program: AH=09h then AH=4Ch with a return code +bits 16 +org 0x100 ; .COM program + mov ah, 09h ; AH selects the DOS function: write '$'-terminated string + mov dx, msg ; DS:DX -> string (DS = CS in a .COM file) + int 21h ; CD 21 -- the DOS entry point + mov ax, 4C07h ; AH=4Ch terminate, AL=7 return code (ERRORLEVEL) + int 21h +msg db 'Hello from INT 21h, AH=09h', 0Dh, 0Ah, '$' diff --git a/interrupts/asm/int3_default.asm b/interrupts/asm/int3_default.asm new file mode 100644 index 0000000..edce2bf --- /dev/null +++ b/interrupts/asm/int3_default.asm @@ -0,0 +1,13 @@ +; int3_default.asm -- execute INT 3 with NO handler of our own and see whether the program survives. +bits 16 +org 0x100 + mov dx, m1 + call puts + int3 + mov dx, m2 + call puts + mov ax, 4C00h + int 21h +m1 db 'before INT3', 0Dh, 0Ah, '$' +m2 db 'after INT3 (program survived)', 0Dh, 0Ah, '$' +%include "common.inc" diff --git a/interrupts/asm/int3_handler.asm b/interrupts/asm/int3_handler.asm new file mode 100644 index 0000000..9d4596c --- /dev/null +++ b/interrupts/asm/int3_handler.asm @@ -0,0 +1,123 @@ +; int3_handler.asm -- install our own INT 3 handler (AH=25h), then trigger it twice: +; once with the one-byte CC and once with the two-byte CD 03. +; Prints how far past the trapping instruction the pushed return IP points, and the +; state of IF (interrupt flag) inside the handler versus after IRET. +bits 16 +org 0x100 + ; --- save the old vector with AH=35h, install ours with AH=25h --- + mov ax, 3503h + int 21h ; ES:BX = current INT 3 handler + mov [old_off], bx + mov [old_seg], es + mov ax, 2503h + mov dx, handler + int 21h ; DS:DX = new handler for vector 3 + + sti ; make sure IF=1 before we trap, so the handler's IF=0 is visible + mov word [target], trap_cc + mov dx, m_cc + call puts +trap_cc: + int3 ; CC + call report + + mov word [target], trap_cd + mov dx, m_cd + call puts +trap_cd: + db 0CDh, 03h ; CD 03 + call report + + ; --- restore the old vector and exit --- + push ds + mov dx, [old_off] + mov ds, [old_seg] + mov ax, 2503h + int 21h + pop ds + mov ax, 4C00h + int 21h + +; ------------------------------------------------------------------ +; The handler runs with IF=0 and TF=0 (the CPU cleared them when it took the interrupt). +handler: + push bp + mov bp, sp + push ax + ; stack now: [bp+0]=saved BP, [bp+2]=return IP, [bp+4]=return CS, [bp+6]=FLAGS + mov ax, [bp+2] + mov [ret_ip], ax + mov ax, [bp+6] + mov [flags_pushed], ax + pushf + pop ax ; FLAGS as the handler itself sees them + mov [flags_inside], ax + pop ax + pop bp + iret + +report: + mov dx, m_delta + call puts + mov ax, [ret_ip] + sub ax, [target] ; how many bytes past the trapping instruction? + call putdec + mov dx, m_ifpush + call puts + mov ax, [flags_pushed] + mov cl, 9 + shr ax, cl + and al, 1 + add al, '0' + mov dl, al + mov ah, 02h + int 21h + mov dx, m_ifin + call puts + mov ax, [flags_inside] + mov cl, 9 + shr ax, cl + and al, 1 + add al, '0' + mov dl, al + mov ah, 02h + int 21h + mov dx, m_tfin + call puts + mov ax, [flags_inside] + mov cl, 8 + shr ax, cl + and al, 1 + add al, '0' + mov dl, al + mov ah, 02h + int 21h + mov dx, m_ifafter + call puts + pushf + pop ax + mov cl, 9 + shr ax, cl + and al, 1 + add al, '0' + mov dl, al + mov ah, 02h + int 21h + jmp crlf + +m_cc db 'CC (INT3): $' +m_cd db 'CD 03 (INT 3): $' +m_delta db 'return IP = trap address + $' +m_ifpush db ', IF pushed = $' +m_ifin db ', IF in handler = $' +m_tfin db ', TF in handler = $' +m_ifafter db ', IF after IRET = $' + +old_off dw 0 +old_seg dw 0 +target dw 0 +ret_ip dw 0 +flags_pushed dw 0 +flags_inside dw 0 + +%include "common.inc" diff --git a/interrupts/asm/opcodes.asm b/interrupts/asm/opcodes.asm new file mode 100644 index 0000000..d35daa8 --- /dev/null +++ b/interrupts/asm/opcodes.asm @@ -0,0 +1,9 @@ +; opcodes.asm -- which bytes does each spelling assemble to? (read the listing, never run) +bits 16 +org 0x100 + int3 ; the one-byte breakpoint -> CC + int 3 ; "INT n" with n = 3 in NASM -> CD 03 + db 0CDh, 03h ; the same two bytes, written out -> CD 03 + int 21h ; DOS entry point -> CD 21 + int 10h ; BIOS video -> CD 10 + into ; one-byte INTO -> CE diff --git a/interrupts/output/01-opcodes.lst b/interrupts/output/01-opcodes.lst new file mode 100644 index 0000000..4e4be47 --- /dev/null +++ b/interrupts/output/01-opcodes.lst @@ -0,0 +1,9 @@ + 1 ; opcodes.asm -- which bytes does each spelling assemble to? (read the listing, never run) + 2 bits 16 + 3 org 0x100 + 4 00000000 CC int3 ; the one-byte breakpoint -> CC + 5 00000001 CD03 int 3 ; "INT n" with n = 3 in NASM -> CD 03 + 6 00000003 CD03 db 0CDh, 03h ; the same two bytes, written out -> CD 03 + 7 00000005 CD21 int 21h ; DOS entry point -> CD 21 + 8 00000007 CD10 int 10h ; BIOS video -> CD 10 + 9 00000009 CE into ; one-byte INTO -> CE diff --git a/interrupts/output/02-hello21.txt b/interrupts/output/02-hello21.txt new file mode 100644 index 0000000..05de29e --- /dev/null +++ b/interrupts/output/02-hello21.txt @@ -0,0 +1,2 @@ +Hello from INT 21h, AH=09h +ERRORLEVEL is at least 7 diff --git a/interrupts/output/03-fn-tour.txt b/interrupts/output/03-fn-tour.txt new file mode 100644 index 0000000..90a8d09 --- /dev/null +++ b/interrupts/output/03-fn-tour.txt @@ -0,0 +1,15 @@ +AH=30h DOS version : 5.0 +AH=2Ah/2Ch date+time ranges : ok ok +AH=02h + AH=06h : AB +AH=01h + AH=0Ah (stdin) : echo -> Ankur + + AH=01h got 0x5A ; AH=0Ah count = 5, text = Ankur +AH=25h/35h vector 60h : ok +AH=48h/49h alloc+free 4 KB : ok +AH=48h ask for 1 MB (fails) : CF=1 AX=0008 ; largest block (paragraphs) = 8E6C +AH=3Ch/40h/3Dh/3Fh/42h : 12 bytes read ; LSEEK(end) says size = 12 +AH=56h + AH=4Eh findfirst : U.TXT size = 12 +AH=41h delete, reopen fails : CF=1 AX=0002 +AH=39h/3Ah mkdir+rmdir : ok ; AH=19h drive = 2 +AH=4Bh exec child : Hello from INT 21h, AH=09h +AH=4Dh return code = 7 type = 0 diff --git a/interrupts/output/04-int3-handler.txt b/interrupts/output/04-int3-handler.txt new file mode 100644 index 0000000..72e5a23 --- /dev/null +++ b/interrupts/output/04-int3-handler.txt @@ -0,0 +1,2 @@ +CC (INT3): return IP = trap address + 1, IF pushed = 1, IF in handler = 0, TF in handler = 0, IF after IRET = 1 +CD 03 (INT 3): return IP = trap address + 2, IF pushed = 1, IF in handler = 0, TF in handler = 0, IF after IRET = 1 diff --git a/interrupts/output/05-int3-default.txt b/interrupts/output/05-int3-default.txt new file mode 100644 index 0000000..02c1ef0 --- /dev/null +++ b/interrupts/output/05-int3-default.txt @@ -0,0 +1,2 @@ +before INT3 +after INT3 (program survived) diff --git a/interrupts/output/06-debug-cc.txt b/interrupts/output/06-debug-cc.txt new file mode 100644 index 0000000..c8ae3f7 --- /dev/null +++ b/interrupts/output/06-debug-cc.txt @@ -0,0 +1,26 @@ +-u 100 L9 +072E:0100 B81111 MOV AX,1111 +072E:0103 CC INT 3 +072E:0104 B82222 MOV AX,2222 +072E:0107 B8004C MOV AX,4C00 +-g +Unexpected breakpoint interrupt +AX=1111 BX=0000 CX=000C DX=0000 SP=FFFE BP=0000 SI=0000 DI=0000 +DS=072E ES=072E SS=072E CS=072E IP=0104 NV UP EI PL ZR NA PE NC +072E:0104 B82222 MOV AX,2222 +-r +AX=1111 BX=0000 CX=000C DX=0000 SP=FFFE BP=0000 SI=0000 DI=0000 +DS=072E ES=072E SS=072E CS=072E IP=0104 NV UP EI PL ZR NA PE NC +072E:0104 B82222 MOV AX,2222 +-t +AX=2222 BX=0000 CX=000C DX=0000 SP=FFFE BP=0000 SI=0000 DI=0000 +DS=072E ES=072E SS=072E CS=072E IP=0107 NV UP EI PL ZR NA PE NC +072E:0107 B8004C MOV AX,4C00 +-r +AX=2222 BX=0000 CX=000C DX=0000 SP=FFFE BP=0000 SI=0000 DI=0000 +DS=072E ES=072E SS=072E CS=072E IP=0107 NV UP EI PL ZR NA PE NC +072E:0107 B8004C MOV AX,4C00 +-g + +Program terminated normally (0000) +-q diff --git a/interrupts/output/07-debug-cd03.txt b/interrupts/output/07-debug-cd03.txt new file mode 100644 index 0000000..74d0abf --- /dev/null +++ b/interrupts/output/07-debug-cd03.txt @@ -0,0 +1,25 @@ +-u 100 L9 +072E:0100 B81111 MOV AX,1111 +072E:0103 CD03 INT 03 +072E:0105 B82222 MOV AX,2222 +072E:0108 B8004C MOV AX,4C00 +-g +AX=1111 BX=0000 CX=000D DX=0000 SP=FFFE BP=0000 SI=0000 DI=0000 +DS=072E ES=072E SS=072E CS=072E IP=0104 NV UP EI PL ZR NA PE NC +072E:0104 03B82222 ADD DI,[BX+SI+2222] DS:2222=0000 +-r +AX=1111 BX=0000 CX=000D DX=0000 SP=FFFE BP=0000 SI=0000 DI=0000 +DS=072E ES=072E SS=072E CS=072E IP=0104 NV UP EI PL ZR NA PE NC +072E:0104 03B82222 ADD DI,[BX+SI+2222] DS:2222=0000 +-t +AX=1111 BX=0000 CX=000D DX=0000 SP=FFFE BP=0000 SI=0000 DI=0000 +DS=072E ES=072E SS=072E CS=072E IP=0108 NV UP EI PL ZR NA PE NC +072E:0108 B8004C MOV AX,4C00 +-r +AX=1111 BX=0000 CX=000D DX=0000 SP=FFFE BP=0000 SI=0000 DI=0000 +DS=072E ES=072E SS=072E CS=072E IP=0108 NV UP EI PL ZR NA PE NC +072E:0108 B8004C MOV AX,4C00 +-g + +Program terminated normally (0000) +-q diff --git a/interrupts/output/08-bp-experiment.txt b/interrupts/output/08-bp-experiment.txt new file mode 100644 index 0000000..e31f955 --- /dev/null +++ b/interrupts/output/08-bp-experiment.txt @@ -0,0 +1,9 @@ +clean run, no breakpoint: + CX=3 + +CC patch=CC trap: vector=3 return IP=7C03 (site=7C02, +1) debugger restores IP=7C02 + after resume: CX=3 (expected 3), final IP=7C05 +CD 03 patch=CD03 trap: vector=3 return IP=7C04 (site=7C02, +2) debugger restores IP=7C03 + after resume: CX=0 (expected 3), final IP=7C64 + +RESULT: PASS diff --git a/interrupts/output/09-tool-versions.txt b/interrupts/output/09-tool-versions.txt new file mode 100644 index 0000000..af93400 --- /dev/null +++ b/interrupts/output/09-tool-versions.txt @@ -0,0 +1,8 @@ +Title: DEBUG +Version: 2.50 +Entered-date: 2024-06-01 +Copying-policy: MIT License +nasm: NASM version 2.16.01 +dosbox: DOSBox version 0.74-3 +unicorn: 2.1.4 +python: Python 3.11.15 diff --git a/interrupts/output/10-checks.txt b/interrupts/output/10-checks.txt new file mode 100644 index 0000000..994d2a8 --- /dev/null +++ b/interrupts/output/10-checks.txt @@ -0,0 +1,31 @@ +PASS int3 assembles to the single byte CC +PASS NASM 'int 3' assembles to CD 03 +PASS int 21h assembles to CD 21 +PASS into assembles to CE +PASS hello21 prints its string +PASS AH=4Ch return code 7 visible as ERRORLEVEL +PASS AH=30h reports a version +PASS AH=2Ah/2Ch ranges ok +PASS AH=02h+06h write AB +PASS AH=01h returned 5A ('Z') and AH=0Ah counted 5 chars 'Ankur' +PASS AH=25h/35h round trip +PASS AH=48h/49h alloc+free ok +PASS AH=48h 1 MB request fails CF=1 AX=0008 +PASS file I/O: 12 bytes read, LSEEK says 12 +PASS findfirst sees U.TXT size 12 +PASS reopening deleted file: CF=1 AX=0002 +PASS mkdir/rmdir ok +PASS child exit code 7, termination type 0 via AH=4Dh +PASS CC: return IP = trap + 1 +PASS CD 03: return IP = trap + 2 +PASS IF and TF are 0 inside the handler, IF back to 1 after IRET +PASS IF pushed on the stack was 1 +PASS INT 3 with no handler: program survives under DOSBox +PASS DEBUG + CC: 'Unexpected breakpoint interrupt' and IP=0104 +PASS DEBUG + CC: next instruction after trap is MOV AX,2222 +PASS DEBUG + CC: single-step then AX=2222 +PASS DEBUG + CD 03: no breakpoint message +PASS DEBUG + CD 03: stops at IP=0104 inside the INT 03 instruction +PASS DEBUG + CD 03: MOV AX,2222 never runs (AX stays 1111 after stepping) +PASS Unicorn: CC patch trap return IP = site+1, resume gives CX=3 +PASS Unicorn: CD 03 patch trap return IP = site+2 and resume goes wrong diff --git a/interrupts/scripts/bp_experiment.py b/interrupts/scripts/bp_experiment.py new file mode 100755 index 0000000..95f274b --- /dev/null +++ b/interrupts/scripts/bp_experiment.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Mini software debugger on Unicorn (16-bit x86 core). + +Puts a breakpoint on the first INC CX of bp_target.asm twice: once patched with the +one-byte CC, once with the two-byte CD 03. A real debugger saves ONE byte (because the +breakpoint opcode is one byte) and, on a trap, backs IP up by ONE. The experiment shows +why that only works for CC. + +Unicorn is a modern x86 core in real mode, not an 8086: it is used here only for +instruction-decoding and control-flow facts (opcode lengths, return addresses). +""" +import subprocess, sys, pathlib +from unicorn import Uc, UcError, UC_ARCH_X86, UC_MODE_16, UC_HOOK_INTR +from unicorn.x86_const import UC_X86_REG_IP, UC_X86_REG_CX, UC_X86_REG_EFLAGS, UC_X86_REG_CS + +HERE = pathlib.Path(__file__).resolve().parent +ASM = HERE.parent / "asm" / "bp_target.asm" +BASE = 0x7C00 + +def assemble(): + out = HERE.parent / "build" / "bp_target.bin" + out.parent.mkdir(exist_ok=True) + subprocess.run(["nasm", "-f", "bin", str(ASM), "-o", str(out)], check=True) + return out.read_bytes() + +def run(code, patch, label): + SITE, DONE = BASE + 2, BASE + 5 # offsets fixed by the listing (xor cx,cx = 2 bytes) + uc = Uc(UC_ARCH_X86, UC_MODE_16) + uc.mem_map(0, 0x100000) + uc.mem_write(BASE, code) + saved = bytes(uc.mem_read(SITE, 1)) # a debugger saves exactly ONE byte: CC is one byte + uc.mem_write(SITE, patch) + uc.reg_write(UC_X86_REG_CS, 0) + uc.reg_write(UC_X86_REG_EFLAGS, 0x202) + events = [] + def on_int(uc, intno, _): + ip = uc.reg_read(UC_X86_REG_IP) # return address (first byte after the trapping instruction) + events.append((intno, ip)) + if intno == 3: + bp = ip - 1 # "back up one byte" -- only right for CC + if bp == SITE: + uc.mem_write(SITE, saved) # restore the original byte, re-run it + uc.reg_write(UC_X86_REG_IP, bp) + uc.hook_add(UC_HOOK_INTR, on_int) + err = None + try: + uc.emu_start(BASE, DONE, count=50) + except UcError as e: + err = str(e) + cx = uc.reg_read(UC_X86_REG_CX) + ip = uc.reg_read(UC_X86_REG_IP) + print("%-8s patch=%-6s trap: vector=%d return IP=%04X (site=%04X, +%d) " % ( + label, patch.hex().upper(), events[0][0], events[0][1], SITE, events[0][1] - SITE) + + "debugger restores IP=%04X" % (events[0][1] - 1)) + print(" after resume: CX=%d (expected 3), final IP=%04X%s" % (cx, ip, (" [" + err + "]") if err else "")) + return cx, err + +def main(): + code = assemble() + print("clean run, no breakpoint:") + uc = Uc(UC_ARCH_X86, UC_MODE_16); uc.mem_map(0, 0x100000); uc.mem_write(BASE, code) + uc.reg_write(UC_X86_REG_CS, 0); uc.emu_start(BASE, BASE + 5, count=50) + print(" CX=%d\n" % uc.reg_read(UC_X86_REG_CX)) + cx1, e1 = run(code, b"\xCC", "CC") + cx2, e2 = run(code, b"\xCD\x03", "CD 03") + ok = (cx1 == 3 and e1 is None and cx2 != 3) + print("\nRESULT:", "PASS" if ok else "UNEXPECTED") + sys.exit(0 if ok else 1) + +if __name__ == "__main__": + main() diff --git a/interrupts/scripts/check.py b/interrupts/scripts/check.py new file mode 100755 index 0000000..a09b321 --- /dev/null +++ b/interrupts/scripts/check.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Assertions that pin every claim the article makes about the captured output.""" +import pathlib, re, sys +OUT = pathlib.Path(__file__).resolve().parent.parent / "output" +fails = [] +def rd(n): return (OUT / n).read_text() +def check(name, cond): + print(("PASS " if cond else "FAIL ") + name) + if not cond: fails.append(name) + +lst = rd("01-opcodes.lst") +check("int3 assembles to the single byte CC", re.search(r" CC\s+int3", lst) is not None) +check("NASM 'int 3' assembles to CD 03", re.search(r" CD03\s+int 3", lst) is not None) +check("int 21h assembles to CD 21", re.search(r" CD21\s+int 21h", lst) is not None) +check("into assembles to CE", re.search(r" CE\s+into", lst) is not None) + +t = rd("02-hello21.txt") +check("hello21 prints its string", "Hello from INT 21h, AH=09h" in t) +check("AH=4Ch return code 7 visible as ERRORLEVEL", "ERRORLEVEL is at least 7" in t) + +t = rd("03-fn-tour.txt") +check("AH=30h reports a version", re.search(r"AH=30h DOS version\s+: \d+\.\d+", t) is not None) +check("AH=2Ah/2Ch ranges ok", "okok" not in t and re.search(r"ranges : ok ok", t) is not None) +check("AH=02h+06h write AB", re.search(r"AH=06h\s+: AB", t) is not None) +check("AH=01h returned 5A ('Z') and AH=0Ah counted 5 chars 'Ankur'", "AH=01h got 0x5A ; AH=0Ah count = 5, text = Ankur" in t) +check("AH=25h/35h round trip", re.search(r"vector 60h\s+: ok", t) is not None) +check("AH=48h/49h alloc+free ok", re.search(r"alloc\+free 4 KB\s+: ok", t) is not None) +check("AH=48h 1 MB request fails CF=1 AX=0008", "(fails) : CF=1 AX=0008" in t) +check("file I/O: 12 bytes read, LSEEK says 12", "12 bytes read ; LSEEK(end) says size = 12" in t) +check("findfirst sees U.TXT size 12", "U.TXT size = 12" in t) +check("reopening deleted file: CF=1 AX=0002", "CF=1 AX=0002" in t) +check("mkdir/rmdir ok", "mkdir+rmdir : ok" in t) +check("child exit code 7, termination type 0 via AH=4Dh", "AH=4Dh return code = 7 type = 0" in t) + +t = rd("04-int3-handler.txt") +check("CC: return IP = trap + 1", "CC (INT3): return IP = trap address + 1" in t) +check("CD 03: return IP = trap + 2", "CD 03 (INT 3): return IP = trap address + 2" in t) +check("IF and TF are 0 inside the handler, IF back to 1 after IRET", + t.count("IF in handler = 0, TF in handler = 0, IF after IRET = 1") == 2) +check("IF pushed on the stack was 1", t.count("IF pushed = 1") == 2) + +t = rd("05-int3-default.txt") +check("INT 3 with no handler: program survives under DOSBox", "after INT3 (program survived)" in t) + +if (OUT / "06-debug-cc.txt").exists(): + t = rd("06-debug-cc.txt") + check("DEBUG + CC: 'Unexpected breakpoint interrupt' and IP=0104", "Unexpected breakpoint interrupt" in t and "IP=0104" in t) + check("DEBUG + CC: next instruction after trap is MOV AX,2222", "0104 B82222 MOV AX,2222" in t) + check("DEBUG + CC: single-step then AX=2222", "AX=2222" in t) + t = rd("07-debug-cd03.txt") + check("DEBUG + CD 03: no breakpoint message", "Unexpected breakpoint interrupt" not in t) + check("DEBUG + CD 03: stops at IP=0104 inside the INT 03 instruction", "IP=0104" in t and "03B82222 ADD DI,[BX+SI+2222]" in t) + check("DEBUG + CD 03: MOV AX,2222 never runs (AX stays 1111 after stepping)", "AX=2222" not in t) +else: + print("SKIP DEBUG checks (run scripts/fetch-debug.sh first)") + +t = rd("08-bp-experiment.txt") +check("Unicorn: CC patch trap return IP = site+1, resume gives CX=3", "CC patch=CC trap: vector=3 return IP=7C03 (site=7C02, +1)" in t and "CX=3 (expected 3)" in t) +check("Unicorn: CD 03 patch trap return IP = site+2 and resume goes wrong", "return IP=7C04 (site=7C02, +2)" in t and "CX=0 (expected 3)" in t) + +sys.exit(1 if fails else 0) diff --git a/interrupts/scripts/dosbox_run.py b/interrupts/scripts/dosbox_run.py new file mode 100755 index 0000000..fcc1050 --- /dev/null +++ b/interrupts/scripts/dosbox_run.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Assemble the .COM demos with NASM and run them inside DOSBox (headless). + +DOSBox provides a real MS-DOS-compatible INT 21h implementation (and its own INT 3 +default vector), so this is where the INT 21h and INT 3 behaviour is actually executed. +""" +import os, pathlib, re, shutil, subprocess, sys, tempfile + +ROOT = pathlib.Path(__file__).resolve().parent.parent +ASM, BUILD, OUT = ROOT / "asm", ROOT / "build", ROOT / "output" +BUILD.mkdir(exist_ok=True); OUT.mkdir(exist_ok=True) + +PROGRAMS = { # source -> DOS 8.3 name + "hello21": "HELLO21.COM", "fn_tour": "TOUR.COM", "int3_handler": "I3H.COM", + "int3_default": "I3D.COM", "dbg_cc": "DBGCC.COM", "dbg_cd03": "DBGCD.COM", +} + +def nasm(src, out, listing=None): + cmd = ["nasm", "-f", "bin", str(ASM / (src + ".asm")), "-o", str(out)] + if listing: + cmd += ["-l", str(listing)] + subprocess.run(cmd, check=True, cwd=ASM) + +def clean(text): + text = text.replace("\r\n", "\n").replace("\r", "\n") + return "\n".join(l.rstrip() for l in text.split("\n")).rstrip("\n") + "\n" + +def main(): + # 1. opcode listing (assembled, never executed) + nasm("opcodes", BUILD / "opcodes.bin", OUT / "01-opcodes.lst") + lst = (OUT / "01-opcodes.lst").read_text() + lst = "\n".join(l for l in lst.split("\n") if l.strip()) + (OUT / "01-opcodes.lst").write_text(lst + "\n") + + work = pathlib.Path(tempfile.mkdtemp(prefix="dosbox_")) + for src, dos in PROGRAMS.items(): + nasm(src, work / dos) + have_debug = (BUILD / "DEBUG.COM").exists() + if have_debug: + shutil.copy(BUILD / "DEBUG.COM", work / "DEBUG.COM") + (work / "IN.TXT").write_bytes(b"ZAnkur\r\n") # stdin for fn_tour: AH=01h reads Z, AH=0Ah reads Ankur + # DEBUG script: list code, run to the breakpoint, show registers, single-step once, show registers, run on, quit + (work / "DC.TXT").write_bytes(b"u 100 L9\r\ng\r\nr\r\nt\r\nr\r\ng\r\nq\r\n") + + steps = [ + ("HELLO21.COM > O1.TXT", None), + ("if errorlevel 7 echo ERRORLEVEL is at least 7 >> O1.TXT", None), + ("TOUR.COM < IN.TXT > O2.TXT", None), + ("I3H.COM > O3.TXT", None), + ("I3D.COM > O4.TXT", None), + ] + if have_debug: + steps += [("DEBUG DBGCC.COM < DC.TXT > O5.TXT", None), ("DEBUG DBGCD.COM < DC.TXT > O6.TXT", None)] + conf = "[sdl]\nfullscreen=false\n[autoexec]\nmount c %s\nc:\n%s\nexit\n" % (work, "\n".join(s for s, _ in steps)) + (work / "run.conf").write_text(conf) + env = dict(os.environ, SDL_VIDEODRIVER="dummy", SDL_AUDIODRIVER="dummy") + subprocess.run(["dosbox", "-conf", str(work / "run.conf"), "-noconsole"], env=env, timeout=180, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + names = {"O1.TXT": "02-hello21.txt", "O2.TXT": "03-fn-tour.txt", "O3.TXT": "04-int3-handler.txt", + "O4.TXT": "05-int3-default.txt", "O5.TXT": "06-debug-cc.txt", "O6.TXT": "07-debug-cd03.txt"} + for src, dst in names.items(): + p = work / src + if p.exists(): + (OUT / dst).write_text(clean(p.read_text(errors="replace"))) + else: + print("missing", src, "(DEBUG.COM not fetched? run scripts/fetch-debug.sh)", file=sys.stderr) + shutil.rmtree(work, ignore_errors=True) + +if __name__ == "__main__": + main() diff --git a/interrupts/scripts/fetch-debug.sh b/interrupts/scripts/fetch-debug.sh new file mode 100755 index 0000000..13e1be0 --- /dev/null +++ b/interrupts/scripts/fetch-debug.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Download FreeDOS "Debug" (MIT licence, a DEBUG.EXE-compatible debugger) into ../build/. +# It is NOT committed to the repository. +set -euo pipefail +cd "$(dirname "$0")/.." +mkdir -p build +if [ ! -f build/DEBUG.COM ]; then + curl -fsSL -o build/debug.zip https://www.ibiblio.org/pub/micro/pc-stuff/freedos/files/repositories/1.3/base/debug.zip + unzip -o -q -j build/debug.zip BIN/DEBUG.COM APPINFO/DEBUG.LSM -d build +fi +grep -E '^(Title|Version|Entered-date|Copying-policy):' build/DEBUG.LSM || true diff --git a/interrupts/scripts/run-all.sh b/interrupts/scripts/run-all.sh new file mode 100755 index 0000000..68238fb --- /dev/null +++ b/interrupts/scripts/run-all.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Rebuild everything in output/ and check it. Needs: nasm, dosbox, python3 with `unicorn`. +set -euo pipefail +cd "$(dirname "$0")" +./fetch-debug.sh > ../output/09-tool-versions.txt || true +{ + echo "nasm: $(nasm -v)" + echo "dosbox: $(dosbox --version 2>/dev/null | grep -i 'version' | head -1 | sed 's/, copyright.*//')" + echo "unicorn: $(python3 -c 'import unicorn;print(unicorn.__version__)')" + echo "python: $(python3 --version)" +} | grep -v 'Picked up' >> ../output/09-tool-versions.txt +python3 dosbox_run.py +python3 bp_experiment.py > ../output/08-bp-experiment.txt +python3 check.py | tee ../output/10-checks.txt