check_runtime.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. #!/usr/bin/env python3
  2. """check_runtime.py -- structural check of the 8086 runtime blob.
  3. The runtime is hand-assembled byte by byte in Runtime.mod, so the only way
  4. to know it is right is to look at the bytes. This sweeps the code region
  5. with FCML (see disasm16.py) and asserts the properties that a wrong emitter
  6. cannot produce:
  7. 1. every instruction decodes, and the decoded lengths tile the code region
  8. exactly - a single byte-length mistake desynchronises the sweep, so a
  9. clean sweep means the lengths are all right;
  10. 2. every RT_Entry offset is an instruction boundary;
  11. 3. every relative branch and CALL target is an instruction boundary inside
  12. the code region;
  13. 4. the full disassembly matches tests/runtime.golden byte for byte;
  14. 5. each entry still begins with the instruction sequence it is supposed to
  15. begin with (semantic goldens, not positional ones, so they survive
  16. legitimate size changes but catch a wrong ModRM byte).
  17. Points 1-3 exist because they give readable diagnostics for the two most
  18. common classes of mistake (wrong instruction length, wrong fixup). They are
  19. NOT sufficient on their own - they all pass on code that decodes cleanly but
  20. means the wrong thing. Concretely: with MovAlDh emitting `8A C0`
  21. (`mov al,al`) instead of `8A C6` (`mov al,dh`), the sweep stayed in sync, every
  22. branch target stayed on a boundary, and no entry's first bytes changed, so all
  23. three checks passed on provably broken code. That is why check 4 exists: it
  24. catches any wrong-but-well-formed instruction, not just the ones that happen
  25. to break the structure.
  26. Re-baselining the golden
  27. ------------------------
  28. Check 4 fails on *every* legitimate edit to Runtime.mod, because inserting an
  29. instruction moves every later offset. That is intended: the golden forces a
  30. human to look at the whole new disassembly and say "yes, that is what I meant".
  31. python3 tests/check_runtime.py --bless # rewrite tests/runtime.golden
  32. git diff tests/runtime.golden # READ THE DIFF, then commit
  33. Do not use --bless to silence a red test. Re-baseline only after reading the
  34. diff and confirming the change is the one you intended; write the reason in the
  35. commit message. An fcml upgrade may also change instruction *wording* (not
  36. meaning) and require a re-baseline for the same reason.
  37. Usage: check_runtime.py [--bless] [-v] [RTPROBE-DUMP]
  38. (default: build RtProbe and dump it)
  39. """
  40. import os
  41. import re
  42. import subprocess
  43. import sys
  44. HERE = os.path.dirname(os.path.abspath(__file__))
  45. SHELL = os.path.dirname(HERE)
  46. GM2 = "/home/eric/bin/Modula2/Gm2/bin/gm2"
  47. RTPROBE = "/tmp/tp_check_rtprobe"
  48. GOLDEN_FILE = os.path.join(HERE, "runtime.golden")
  49. RE_SIZE = re.compile(r"^(\d+) bytes$")
  50. RE_CODEEND = re.compile(r"^code ends at (\d+)$")
  51. RE_ENTRY = re.compile(r"^ entry (\d+) = (\d+)\s+\((\w+)\)$")
  52. RE_BRANCH = re.compile(r"^(?:j\w+|call|jmp)\s+([0-9a-f]+)h$")
  53. GOLDEN_TEXT_HEADER = """\
  54. # runtime.golden -- full FCML disassembly of the runtime's code region.
  55. #
  56. # GENERATED by tests/check_runtime.py --bless, then READ THE DIFF and commit.
  57. # Every line is "OFFSET BYTES MNEMONIC". This file is a deliberate
  58. # tripwire: any edit to Runtime.mod invalidates it, because inserting an
  59. # instruction renumbers everything after it, so you are forced to look at the
  60. # whole new listing and confirm you meant every change. See the "Re-baselining
  61. # the golden" section of check_runtime.py.
  62. #
  63. # Region: bytes 0..codeEnd of the runtime image (see the code/data split in
  64. # EmitData). Relative targets are absolute offsets within the runtime, which
  65. # the compiler adds to the runtime's load address when it emits a CALL.
  66. """
  67. # The "mod=11" column is reg-only, so reg is the second ModRM field: for
  68. # 88/8A (byte moves) ModRM = 11 010 rrr, for 89/8B (word moves) 11 001 rrr.
  69. def describe_golden_diff(have, want):
  70. """One-line summary of how the current disassembly differs from the
  71. golden, naming the first few differing instructions so the report points
  72. at the actual mistake instead of just saying 'mismatch'."""
  73. a = have.splitlines()
  74. b = want.splitlines()
  75. out = []
  76. for i in range(max(len(a), len(b))):
  77. x = a[i] if i < len(a) else "<end of golden>"
  78. y = b[i] if i < len(b) else "<end of disassembly>"
  79. if x != y:
  80. out.append("line %d: golden has %r, now %r" % (i + 1, x, y))
  81. if len(out) == 3:
  82. break
  83. out.append("%d differing line(s) total" % sum(
  84. 1 for i in range(max(len(a), len(b)))
  85. if (a[i] if i < len(a) else None) != (b[i] if i < len(b) else None)))
  86. return "; ".join(out)
  87. # Semantic goldens: the byte sequence each entry must START with. Stated in
  88. # terms of intent ("read the argument through BP") rather than offsets, so
  89. # they survive a legitimate size change but still catch a wrong ModRM byte.
  90. # This list is the *explanation* for the bytes; runtime.golden is the
  91. # backstop. Keep the two consistent -- they read the same measured ModRM
  92. # table that audit_helpers.py enforces on the source.
  93. #
  94. # The four read entries are here for the same reason the three earlier 8086
  95. # ModRM traps are: each of them is a hand-written sequence whose bytes are
  96. # well formed whether or not they mean anything, and each has in fact been
  97. # wrong while decoding cleanly. See the notes in Runtime.mod on each.
  98. GOLDEN = {
  99. "initmem": "8B F0 8B 54 04 8B 4C 06", # SI:=AX(header); DX:=[SI+4]; CX:=[SI+6]
  100. "stackchk": "C3", # the no-op: a bare RET, by design
  101. "progend": "31 C0 B4 4C CD 21 C3", # XOR AX,AX; AH:=$4C; INT 21h; RET
  102. "wrint": "55 8B EC 8B 46 04", # PUSH BP; MOV BP,SP; AX:=[BP+4]
  103. # wrchar: AL, not DL. DOS INT 21h AH=02h takes the character in AL, and
  104. # DL is the *other* 8086 convention (BIOS teletype). This read DL, so
  105. # every write of a character printed whatever the *last* character read
  106. # had been - usually nothing at all, since DL starts undefined. The
  107. # bytes are `8A 46 02` = MOV AL,[BP+2]; the old golden said `8A 56 02`
  108. # = MOV DL,[BP+2] and was RIGHT about what the code did and WRONG about
  109. # what it should do. That is the whole reason this check exists: a
  110. # positional golden blesses whatever is there.
  111. "wrchar": "8B EC 8A 46 02 89 EC", # BP:=SP; AL:=[BP+2]; SP:=BP
  112. "wrbool": "8B EC 83 7E 02 00 89 EC", # BP:=SP; CMP [BP+2],0; SP:=BP
  113. "wrtinl": "5B 31 C9 8A 0F 43 B4 02", # POP BX; XOR CX,CX; CL:=[BX]; INC BX
  114. "rdln": "50", # PUSH AX, to keep the caller's
  115. # rdint: PUSH BP; MOV BP,SP; PUSH AX,BX,CX,DX,DI - five saved registers,
  116. # because the digit has to survive MUL (which owns DX) and be parked in
  117. # DI across it. A three-register version was not possible.
  118. "rdint": "55 8B EC 50 53 51 52 57",
  119. # rdchar: PUSH BP; MOV BP,SP; PUSH AX,BX,DI - three, no CX and no DX.
  120. # "DI" is how the caller's address gets in (there is no [BX] form), and
  121. # the store is `88 15` = MOV [DI],DL - a BYTE store, so readln of a CHAR
  122. # touches one byte and not the two rdint would have written over it.
  123. "rdchar": "55 8B EC 50 53 57",
  124. # rdbool: PUSH BP; MOV BP,SP; PUSH AX,BX,CX,DI - four. It needs no DX
  125. # (rdint's MUL is what forces DX to be saved) and saves nothing else; the
  126. # answer lives in CX, from "T/t/Y/y/1" test, and has to reach the
  127. # caller's address in DI.
  128. "rdbool": "55 8B EC 50 53 51 57",
  129. }
  130. def build_and_dump():
  131. subprocess.run([GM2, "-fiso", "-Wall", "-c", "Runtime.mod"],
  132. cwd=SHELL, check=True)
  133. subprocess.run([GM2, "-fiso", "-o", RTPROBE,
  134. os.path.join("tests", "RtProbe.mod"),
  135. "Runtime.o", "Posix.o"],
  136. cwd=SHELL, check=True)
  137. out = subprocess.run([RTPROBE], capture_output=True, text=True, check=True)
  138. return out.stdout
  139. def parse(dump):
  140. size = codeend = None
  141. entries = {}
  142. code = bytearray()
  143. inhex = False
  144. for line in dump.splitlines():
  145. if RE_SIZE.match(line):
  146. size = int(RE_SIZE.match(line).group(1))
  147. continue
  148. m = RE_CODEEND.match(line)
  149. if m:
  150. codeend = int(m.group(1))
  151. continue
  152. m = RE_ENTRY.match(line)
  153. if m:
  154. entries[m.group(3)] = int(m.group(2))
  155. continue
  156. if line.strip() == "hex:":
  157. inhex = True
  158. continue
  159. if inhex:
  160. parts = line.split()
  161. if not parts or not re.fullmatch(r"[0-9A-F]{8}", parts[0]):
  162. continue
  163. for p in parts[1:]:
  164. code.append(int(p, 16))
  165. return size, codeend, entries, bytes(code)
  166. def main(argv):
  167. bless = "--bless" in argv
  168. verbose = "-v" in argv
  169. files = [a for a in argv[1:] if not a.startswith("-")]
  170. if len(files) > 1:
  171. print("usage: check_runtime.py [--bless] [-v] [RTPROBE-DUMP]")
  172. return 2
  173. if files:
  174. with open(files[0]) as f:
  175. dump = f.read()
  176. else:
  177. dump = build_and_dump()
  178. size, codeend, entries, code = parse(dump)
  179. problems = []
  180. if size is None or codeend is None:
  181. print("FAIL: could not parse the runtime dump (size/codeend missing)")
  182. return 1
  183. if len(code) != size:
  184. problems.append("hex dump has %d bytes, header says %d"
  185. % (len(code), size))
  186. if not entries:
  187. problems.append("no entry offsets parsed")
  188. sys.path.insert(0, HERE)
  189. import disasm16
  190. # --- 1. clean sweep of the code region ---------------------------
  191. body = code[:codeend]
  192. starts = set()
  193. listing = [] # (pc, bytes, text) - kept structured so neither the
  194. # branch scan nor the golden has to re-parse the
  195. # pretty-printed line
  196. pc = 0
  197. import io
  198. buf = io.StringIO()
  199. while pc < len(body):
  200. starts.add(pc)
  201. text, length = disasm16.decode(body[pc:], pc)
  202. if length == 0:
  203. buf.write("%04X: %s <DECODE ERROR>\n"
  204. % (pc, " ".join("%02X" % b for b in body[pc:pc + 8])))
  205. problems.append("decode error at %04X" % pc)
  206. break
  207. raw = body[pc:pc + length]
  208. text = text or "?"
  209. listing.append((pc, raw, text))
  210. buf.write("%04X: %-24s %s\n"
  211. % (pc, " ".join("%02X" % b for b in raw), text))
  212. pc += length
  213. if pc != len(body):
  214. problems.append("sweep ended at %04X, code region ends at %04X"
  215. % (pc, len(body)))
  216. if verbose:
  217. sys.stderr.write(buf.getvalue())
  218. # --- 2. entries are instruction boundaries -----------------------
  219. for name, off in sorted(entries.items()):
  220. if off not in starts:
  221. problems.append("entry %s at %d is not an instruction boundary"
  222. % (name, off))
  223. # --- 3. branch targets are instruction boundaries -----------------
  224. nbranch = 0
  225. for _at, _raw, text in listing:
  226. m = RE_BRANCH.match(text)
  227. if not m:
  228. continue
  229. nbranch += 1
  230. tgt = int(m.group(1), 16)
  231. if tgt not in starts:
  232. problems.append("%r targets %04X, not an instruction boundary"
  233. % (text, tgt))
  234. elif tgt >= codeend:
  235. problems.append("%r targets %04X, outside the code region"
  236. % (text, tgt))
  237. # --- 4. full-disassembly golden -----------------------------------
  238. # This is the only check that catches an instruction which decodes
  239. # cleanly but means the wrong thing; see the module docstring for the
  240. # MovAlDh example that motivated it.
  241. golden_text = "".join(
  242. "%04X %-11s %s\n" % (at, " ".join("%02X" % b for b in raw), text)
  243. for at, raw, text in listing)
  244. if bless:
  245. with open(GOLDEN_FILE, "w") as f:
  246. f.write(GOLDEN_TEXT_HEADER)
  247. f.write(golden_text)
  248. print("BLESSED: wrote %s (%d instructions). Read the diff before"
  249. " committing." % (GOLDEN_FILE, len(listing)))
  250. else:
  251. if not os.path.exists(GOLDEN_FILE):
  252. problems.append("no golden at %s - run with --bless once and"
  253. " commit the result" % GOLDEN_FILE)
  254. else:
  255. with open(GOLDEN_FILE) as f:
  256. have = f.read()
  257. want = GOLDEN_TEXT_HEADER + golden_text
  258. if have != want:
  259. problems.append("disassembly does not match %s (%s)"
  260. % (os.path.basename(GOLDEN_FILE),
  261. describe_golden_diff(have, want)))
  262. # --- 5. semantic goldens -----------------------------------------
  263. for name, want in sorted(GOLDEN.items()):
  264. if name not in entries:
  265. problems.append("no entry named %s" % name)
  266. continue
  267. off = entries[name]
  268. got = " ".join("%02X" % b for b in code[off:off + len(want.split())])
  269. if got.upper() != want.upper():
  270. problems.append("entry %s starts %s, expected %s"
  271. % (name, got.upper(), want.upper()))
  272. print("runtime: %d bytes, code 0..%d (%d), %d entries, %d branches checked"
  273. % (size, codeend - 1, codeend, len(entries), nbranch))
  274. for name in sorted(entries):
  275. print(" %-9s at %4d" % (name, entries[name]))
  276. if problems:
  277. print("FAIL: %d problem(s)" % len(problems))
  278. for p in problems:
  279. print(" - %s" % p)
  280. return 1
  281. print("PASS: runtime code region is self-consistent")
  282. return 0
  283. if __name__ == "__main__":
  284. sys.exit(main(sys.argv))