#!/usr/bin/env python3 """disasm16.py -- linear-sweep 16-bit x86 disassembler built on FCML. Why this exists --------------- We need an *independent* check on the bytes our Modula-2 compiler and runtime emit. The obvious candidates all turned out to be unusable: * objdump / binutils: has no 16-bit x86 disassembler at all. `-m i8086` silently falls back to the 32-bit i386 rules. * unicorn 2.1.4: UC_MODE_16 mis-decodes 16-bit ModRM memory operands. FCML (libfcml, Debian package `fcml`) does have a real 16-bit x86 disassembler, and we validated it against GNU as's `.code16` *encoder* (assemble there, disassemble here). See tests/FCML_PROBE.md. FCML is driven through the `fcml-disasm` CLI because the package ships no development headers, so ctypes struct layout would be guesswork. The CLI happens to be a linear sweeper that reports "Instruction code length", which is all this driver needs. The length agreement is the important part: if we emit a 3-byte instruction where a 4-byte one was required, the sweep desynchronises and the very next line points straight at the offending offset. Usage: disasm16.py FILE [BASE] # BASE defaults to 0, decimal or 0x-hex disasm16.py --bytes '89 6e 04' # inline bytes, for one-off probes """ import re import subprocess import sys FCML = "fcml-disasm" # The Debian 1.3.0 `fcml-disasm` wrapper aborts (SIGABRT, rc=134) on any # buffer of 16 bytes or more: it linearly sweeps the *whole* input and blows # up on the resulting instruction list. Reproduce with 16 NOPs: # fcml-disasm -m16 0x90909090909090909090909090909090 # rc=134 # 15 bytes is safe, and 15 >= the longest real-mode instruction (7 bytes, # e.g. `EA off16 seg16`), so a 15-byte window always contains the whole first # instruction. We only ever read the first instruction's length. MAX_WINDOW = 15 RE_LEN = re.compile(r"^\s*Instruction code length:\s*(\d+)\s*$") RE_TEXT = re.compile(r"^\s*Disassembled instruction:\s*(.*?)\s*$") def decode(code, base): """Decode the first instruction of `code` (bytes). Returns (text, length). `code` is the remaining stream; at most MAX_WINDOW bytes are handed to FCML. No padding is invented: an under-length buffer must surface as a decode error rather than being silently completed with made-up bytes. """ win = code[:MAX_WINDOW] hexs = "".join("%02X" % b for b in win) out = subprocess.run( [FCML, "-m16", "-rh", "-rz", "-ip", hex(base), "0x" + hexs], capture_output=True, text=True) if out.returncode != 0: return None, 0 text = None length = None for line in out.stdout.splitlines(): m = RE_TEXT.match(line) if m: text = m.group(1) m = RE_LEN.match(line) if m: length = int(m.group(1)) if length is None or length == 0 or length > len(code): return text, 0 return text, length def disasm(code, base=0, out=sys.stdout): """Linear-sweep `code` from `base`, printing one line per instruction.""" pc = 0 ninstr = 0 while pc < len(code): text, length = decode(code[pc:], base + pc) if length == 0: out.write("%04X: %-24s \n" % (base + pc, " ".join("%02X" % b for b in code[pc:pc + 8]))) return ninstr, False shown = code[pc:pc + length] out.write("%04X: %-24s %s\n" % (base + pc, " ".join("%02X" % b for b in shown), text if text else "")) pc += length ninstr += 1 return ninstr, True def main(argv): if len(argv) < 2: sys.stderr.write(__doc__) return 2 if argv[1] == "--bytes": code = bytes.fromhex(argv[2].replace(" ", "")) base = int(argv[3], 0) if len(argv) > 3 else 0 else: with open(argv[1], "rb") as f: code = f.read() base = int(argv[2], 0) if len(argv) > 2 else 0 ninstr, ok = disasm(code, base) sys.stderr.write("-- %d instructions, %d bytes\n" % (ninstr, len(code))) return 0 if ok else 1 if __name__ == "__main__": sys.exit(main(sys.argv))