| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346 |
- #!/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"
- # Scratch artifacts live in the repo's own tmp/ folder, beside the tree that
- # produced them, so a failing run's evidence cannot be lost in /tmp.
- TMP = os.path.normpath(os.path.join(SHELL, "..", "tmp"))
- RTPROBE = os.path.join(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 "<end of golden>"
- y = b[i] if i < len(b) else "<end of disassembly>"
- 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.
- #
- # The four read entries are here for the same reason the three earlier 8086
- # ModRM traps are: each of them is a hand-written sequence whose bytes are
- # well formed whether or not they mean anything, and each has in fact been
- # wrong while decoding cleanly. See the notes in Runtime.mod on each.
- 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: AL, not DL. DOS INT 21h AH=02h takes the character in AL, and
- # DL is the *other* 8086 convention (BIOS teletype). This read DL, so
- # every write of a character printed whatever the *last* character read
- # had been - usually nothing at all, since DL starts undefined. The
- # bytes are `8A 46 02` = MOV AL,[BP+2]; the old golden said `8A 56 02`
- # = MOV DL,[BP+2] and was RIGHT about what the code did and WRONG about
- # what it should do. That is the whole reason this check exists: a
- # positional golden blesses whatever is there.
- # wrchar and wrbool: PUSH BP; MOV BP,SP; ...; MOV SP,BP; POP BP.
- #
- # These two goldens were the *shape* of the fault found by execution and
- # not by any byte check. They read `8B EC 8A 46 02 89 EC` -- BP:=SP,
- # read, SP:=BP, no PUSH and no POP -- and they blessed it: the bytes were
- # well formed, the size did not change, the decode said exactly what it
- # said, and the entries still worked. What was wrong was that BP is the
- # one register an entry may keep, because the driver keeps its cursor into
- # the case record there, so an entry that borrows BP to reach its argument
- # and never gives it back sends the NEXT call to a garbage address. The
- # machine triple-faulted and restarted, which printed the record header
- # twice and hung.
- #
- # So the PUSH and the POP are now in the golden, which is the only place in
- # the byte-checking suite where "this entry must hand BP back" was ever
- # stated. Note the displacement moved 2 -> 4 with them: BP is pushed
- # first, so the argument is four bytes up. rt_exec.py checks the same rule
- # from the other side, and the two disagreeing is the point -- a golden
- # says what the bytes must be, and the contract says why.
- "wrchar": "55 8B EC 8A 46 04 89 EC 5D", # PUSH BP; BP:=SP; AL:=[BP+4]; SP:=BP; POP BP
- "wrbool": "55 8B EC 83 7E 04 00 89 EC", # PUSH BP; BP:=SP; CMP [BP+4],0; SP:=BP
- # ...and wrbool's POP BP is at 0086, after the INT 21h and not next to its
- # MOV SP,BP at 0076, so the six bytes above stop short of it on purpose. A
- # golden that had to span the whole frame would be asserting a code layout
- # rather than a prologue. rt_exec.py's POP_FRAME-free screen is what covers
- # that one, and it is a screen rather than a proof -- see the note there on
- # why 5D cannot be located exactly without a disassembler.
- "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
- # rdint: PUSH BP; MOV BP,SP; PUSH AX,BX,CX,DX,DI - five saved registers,
- # because the digit has to survive MUL (which owns DX) and be parked in
- # DI across it. A three-register version was not possible.
- "rdint": "55 8B EC 50 53 51 52 57",
- # rdchar: PUSH BP; MOV BP,SP; PUSH AX,BX,DI - three, no CX and no DX.
- # "DI" is how the caller's address gets in (there is no [BX] form), and
- # the store is `88 15` = MOV [DI],DL - a BYTE store, so readln of a CHAR
- # touches one byte and not the two rdint would have written over it.
- "rdchar": "55 8B EC 50 53 57",
- # rdbool: PUSH BP; MOV BP,SP; PUSH AX,BX,CX,DI - four. It needs no DX
- # (rdint's MUL is what forces DX to be saved) and saves nothing else; the
- # answer lives in CX, from "T/t/Y/y/1" test, and has to reach the
- # caller's address in DI.
- "rdbool": "55 8B EC 50 53 51 57",
- }
- def build_and_dump():
- os.makedirs(TMP, exist_ok=True)
- 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 <DECODE ERROR>\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))
|