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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KqJyCidz3ZgRyHABv2GVJh
This commit is contained in:
2026-09-30 19:10:56 +00:00
co-authored by Claude Sonnet 5.5
commit 09af523f8c
27 changed files with 1029 additions and 0 deletions
+48
View File
@@ -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.
+13
View File
@@ -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
+64
View File
@@ -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
+8
View File
@@ -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
+8
View File
@@ -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
+366
View File
@@ -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"
+9
View File
@@ -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, '$'
+13
View File
@@ -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"
+123
View File
@@ -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"
+9
View File
@@ -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
+9
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
Hello from INT 21h, AH=09h
ERRORLEVEL is at least 7
+15
View File
@@ -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
+2
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
before INT3
after INT3 (program survived)
+26
View File
@@ -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
+25
View File
@@ -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
+9
View File
@@ -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
+8
View File
@@ -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
+31
View File
@@ -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
+71
View File
@@ -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()
+61
View File
@@ -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)
+71
View File
@@ -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()
+11
View File
@@ -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
+14
View File
@@ -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