Files
asmhatreandClaude Sonnet 5.5 09af523f8c 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
2026-09-30 19:10:56 +00:00

72 lines
3.0 KiB
Python
Executable File

#!/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()