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
+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