#!/usr/bin/env python3 """check_runtime.py -- structural check of the 8086 runtime blob. The runtime is hand-assembled byte by byte in Runtime.mod, so the only way to know it is right is to look at the bytes. This sweeps the code region with FCML (see disasm16.py) and asserts the properties that a wrong emitter cannot produce: 1. every instruction decodes, and the decoded lengths tile the code region exactly - a single byte-length mistake desynchronises the sweep, so a clean sweep means the lengths are all right; 2. every RT_Entry offset is an instruction boundary; 3. every relative branch and CALL target is an instruction boundary inside the code region; 4. the full disassembly matches tests/runtime.golden byte for byte; 5. each entry still begins with the instruction sequence it is supposed to begin with (semantic goldens, not positional ones, so they survive legitimate size changes but catch a wrong ModRM byte). Points 1-3 exist because they give readable diagnostics for the two most common classes of mistake (wrong instruction length, wrong fixup). They are NOT sufficient on their own - they all pass on code that decodes cleanly but means the wrong thing. Concretely: with MovAlDh emitting `8A C0` (`mov al,al`) instead of `8A C6` (`mov al,dh`), the sweep stayed in sync, every branch target stayed on a boundary, and no entry's first bytes changed, so all three checks passed on provably broken code. That is why check 4 exists: it catches any wrong-but-well-formed instruction, not just the ones that happen to break the structure. Re-baselining the golden ------------------------ Check 4 fails on *every* legitimate edit to Runtime.mod, because inserting an instruction moves every later offset. That is intended: the golden forces a human to look at the whole new disassembly and say "yes, that is what I meant". python3 tests/check_runtime.py --bless # rewrite tests/runtime.golden git diff tests/runtime.golden # READ THE DIFF, then commit Do not use --bless to silence a red test. Re-baseline only after reading the diff and confirming the change is the one you intended; write the reason in the commit message. An fcml upgrade may also change instruction *wording* (not meaning) and require a re-baseline for the same reason. Usage: check_runtime.py [--bless] [-v] [RTPROBE-DUMP] (default: build RtProbe and dump it) """ import os import re import subprocess import sys HERE = os.path.dirname(os.path.abspath(__file__)) SHELL = os.path.dirname(HERE) GM2 = "/home/eric/bin/Modula2/Gm2/bin/gm2" RTPROBE = "/tmp/tp_check_rtprobe" GOLDEN_FILE = os.path.join(HERE, "runtime.golden") RE_SIZE = re.compile(r"^(\d+) bytes$") RE_CODEEND = re.compile(r"^code ends at (\d+)$") RE_ENTRY = re.compile(r"^ entry (\d+) = (\d+)\s+\((\w+)\)$") RE_BRANCH = re.compile(r"^(?:j\w+|call|jmp)\s+([0-9a-f]+)h$") GOLDEN_TEXT_HEADER = """\ # runtime.golden -- full FCML disassembly of the runtime's code region. # # GENERATED by tests/check_runtime.py --bless, then READ THE DIFF and commit. # Every line is "OFFSET BYTES MNEMONIC". This file is a deliberate # tripwire: any edit to Runtime.mod invalidates it, because inserting an # instruction renumbers everything after it, so you are forced to look at the # whole new listing and confirm you meant every change. See the "Re-baselining # the golden" section of check_runtime.py. # # Region: bytes 0..codeEnd of the runtime image (see the code/data split in # EmitData). Relative targets are absolute offsets within the runtime, which # the compiler adds to the runtime's load address when it emits a CALL. """ # The "mod=11" column is reg-only, so reg is the second ModRM field: for # 88/8A (byte moves) ModRM = 11 010 rrr, for 89/8B (word moves) 11 001 rrr. def describe_golden_diff(have, want): """One-line summary of how the current disassembly differs from the golden, naming the first few differing instructions so the report points at the actual mistake instead of just saying 'mismatch'.""" a = have.splitlines() b = want.splitlines() out = [] for i in range(max(len(a), len(b))): x = a[i] if i < len(a) else "" y = b[i] if i < len(b) else "" if x != y: out.append("line %d: golden has %r, now %r" % (i + 1, x, y)) if len(out) == 3: break out.append("%d differing line(s) total" % sum( 1 for i in range(max(len(a), len(b))) if (a[i] if i < len(a) else None) != (b[i] if i < len(b) else None))) return "; ".join(out) # Semantic goldens: the byte sequence each entry must START with. Stated in # terms of intent ("read the argument through BP") rather than offsets, so # they survive a legitimate size change but still catch a wrong ModRM byte. # This list is the *explanation* for the bytes; runtime.golden is the # backstop. Keep the two consistent -- they read the same measured ModRM # table that audit_helpers.py enforces on the source. GOLDEN = { "initmem": "8B F0 8B 54 04 8B 4C 06", # SI:=AX(header); DX:=[SI+4]; CX:=[SI+6] "stackchk": "C3", # the no-op: a bare RET, by design "progend": "31 C0 B4 4C CD 21 C3", # XOR AX,AX; AH:=$4C; INT 21h; RET "wrint": "55 8B EC 8B 46 04", # PUSH BP; MOV BP,SP; AX:=[BP+4] "wrchar": "8B EC 8A 56 02 89 EC", # BP:=SP; DL:=[BP+2]; SP:=BP "wrbool": "8B EC 83 7E 02 00 89 EC", # BP:=SP; CMP [BP+2],0; SP:=BP "wrtinl": "5B 31 C9 8A 0F 43 B4 02", # POP BX; XOR CX,CX; CL:=[BX]; INC BX "rdln": "50", # PUSH AX, to keep the caller's } def build_and_dump(): subprocess.run([GM2, "-fiso", "-Wall", "-c", "Runtime.mod"], cwd=SHELL, check=True) subprocess.run([GM2, "-fiso", "-o", RTPROBE, os.path.join("tests", "RtProbe.mod"), "Runtime.o", "Posix.o"], cwd=SHELL, check=True) out = subprocess.run([RTPROBE], capture_output=True, text=True, check=True) return out.stdout def parse(dump): size = codeend = None entries = {} code = bytearray() inhex = False for line in dump.splitlines(): if RE_SIZE.match(line): size = int(RE_SIZE.match(line).group(1)) continue m = RE_CODEEND.match(line) if m: codeend = int(m.group(1)) continue m = RE_ENTRY.match(line) if m: entries[m.group(3)] = int(m.group(2)) continue if line.strip() == "hex:": inhex = True continue if inhex: parts = line.split() if not parts or not re.fullmatch(r"[0-9A-F]{8}", parts[0]): continue for p in parts[1:]: code.append(int(p, 16)) return size, codeend, entries, bytes(code) def main(argv): bless = "--bless" in argv verbose = "-v" in argv files = [a for a in argv[1:] if not a.startswith("-")] if len(files) > 1: print("usage: check_runtime.py [--bless] [-v] [RTPROBE-DUMP]") return 2 if files: with open(files[0]) as f: dump = f.read() else: dump = build_and_dump() size, codeend, entries, code = parse(dump) problems = [] if size is None or codeend is None: print("FAIL: could not parse the runtime dump (size/codeend missing)") return 1 if len(code) != size: problems.append("hex dump has %d bytes, header says %d" % (len(code), size)) if not entries: problems.append("no entry offsets parsed") sys.path.insert(0, HERE) import disasm16 # --- 1. clean sweep of the code region --------------------------- body = code[:codeend] starts = set() listing = [] # (pc, bytes, text) - kept structured so neither the # branch scan nor the golden has to re-parse the # pretty-printed line pc = 0 import io buf = io.StringIO() while pc < len(body): starts.add(pc) text, length = disasm16.decode(body[pc:], pc) if length == 0: buf.write("%04X: %s \n" % (pc, " ".join("%02X" % b for b in body[pc:pc + 8]))) problems.append("decode error at %04X" % pc) break raw = body[pc:pc + length] text = text or "?" listing.append((pc, raw, text)) buf.write("%04X: %-24s %s\n" % (pc, " ".join("%02X" % b for b in raw), text)) pc += length if pc != len(body): problems.append("sweep ended at %04X, code region ends at %04X" % (pc, len(body))) if verbose: sys.stderr.write(buf.getvalue()) # --- 2. entries are instruction boundaries ----------------------- for name, off in sorted(entries.items()): if off not in starts: problems.append("entry %s at %d is not an instruction boundary" % (name, off)) # --- 3. branch targets are instruction boundaries ----------------- nbranch = 0 for _at, _raw, text in listing: m = RE_BRANCH.match(text) if not m: continue nbranch += 1 tgt = int(m.group(1), 16) if tgt not in starts: problems.append("%r targets %04X, not an instruction boundary" % (text, tgt)) elif tgt >= codeend: problems.append("%r targets %04X, outside the code region" % (text, tgt)) # --- 4. full-disassembly golden ----------------------------------- # This is the only check that catches an instruction which decodes # cleanly but means the wrong thing; see the module docstring for the # MovAlDh example that motivated it. golden_text = "".join( "%04X %-11s %s\n" % (at, " ".join("%02X" % b for b in raw), text) for at, raw, text in listing) if bless: with open(GOLDEN_FILE, "w") as f: f.write(GOLDEN_TEXT_HEADER) f.write(golden_text) print("BLESSED: wrote %s (%d instructions). Read the diff before" " committing." % (GOLDEN_FILE, len(listing))) else: if not os.path.exists(GOLDEN_FILE): problems.append("no golden at %s - run with --bless once and" " commit the result" % GOLDEN_FILE) else: with open(GOLDEN_FILE) as f: have = f.read() want = GOLDEN_TEXT_HEADER + golden_text if have != want: problems.append("disassembly does not match %s (%s)" % (os.path.basename(GOLDEN_FILE), describe_golden_diff(have, want))) # --- 5. semantic goldens ----------------------------------------- for name, want in sorted(GOLDEN.items()): if name not in entries: problems.append("no entry named %s" % name) continue off = entries[name] got = " ".join("%02X" % b for b in code[off:off + len(want.split())]) if got.upper() != want.upper(): problems.append("entry %s starts %s, expected %s" % (name, got.upper(), want.upper())) print("runtime: %d bytes, code 0..%d (%d), %d entries, %d branches checked" % (size, codeend - 1, codeend, len(entries), nbranch)) for name in sorted(entries): print(" %-9s at %4d" % (name, entries[name])) if problems: print("FAIL: %d problem(s)" % len(problems)) for p in problems: print(" - %s" % p) return 1 print("PASS: runtime code region is self-consistent") return 0 if __name__ == "__main__": sys.exit(main(sys.argv))