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:
Executable
+71
@@ -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()
|
||||
Executable
+61
@@ -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)
|
||||
Executable
+71
@@ -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()
|
||||
Executable
+11
@@ -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
|
||||
Executable
+14
@@ -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
|
||||
Reference in New Issue
Block a user