check_runtime.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  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 (Jcc, JMP, LOOP) and CALL target is an instruction
  12. boundary inside 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. # Scratch artifacts live in the repo's own tmp/ folder, beside the tree that
  48. # produced them, so a failing run's evidence cannot be lost in /tmp.
  49. TMP = os.path.normpath(os.path.join(SHELL, "..", "tmp"))
  50. RTPROBE = os.path.join(TMP, "tp_check_rtprobe")
  51. GOLDEN_FILE = os.path.join(HERE, "runtime.golden")
  52. RE_SIZE = re.compile(r"^(\d+) bytes$")
  53. RE_CODEEND = re.compile(r"^code ends at (\d+)$")
  54. RE_ENTRY = re.compile(r"^ entry (\d+) = (\d+)\s+\((\w+)\)$")
  55. # j\w+ covers the Jcc family and JMP, `call` the near call, and `loop\w*`
  56. # LOOP/LOOPE/LOOPNE. LOOP was absent until cmpstr became the runtime's first
  57. # user of it: a LOOP target is a relative branch like any other and has to land
  58. # on an instruction boundary, and without this the one LOOP was the one branch
  59. # the boundary check did not look at.
  60. RE_BRANCH = re.compile(r"^(?:j\w+|call|loop\w*)\s+([0-9a-f]+)h$")
  61. GOLDEN_TEXT_HEADER = """\
  62. # runtime.golden -- full FCML disassembly of the runtime's code region.
  63. #
  64. # GENERATED by tests/check_runtime.py --bless, then READ THE DIFF and commit.
  65. # Every line is "OFFSET BYTES MNEMONIC". This file is a deliberate
  66. # tripwire: any edit to Runtime.mod invalidates it, because inserting an
  67. # instruction renumbers everything after it, so you are forced to look at the
  68. # whole new listing and confirm you meant every change. See the "Re-baselining
  69. # the golden" section of check_runtime.py.
  70. #
  71. # Region: bytes 0..codeEnd of the runtime image (see the code/data split in
  72. # EmitData). Relative targets are absolute offsets within the runtime, which
  73. # the compiler adds to the runtime's load address when it emits a CALL.
  74. """
  75. # The "mod=11" column is reg-only, so reg is the second ModRM field: for
  76. # 88/8A (byte moves) ModRM = 11 010 rrr, for 89/8B (word moves) 11 001 rrr.
  77. def describe_golden_diff(have, want):
  78. """One-line summary of how the current disassembly differs from the
  79. golden, naming the first few differing instructions so the report points
  80. at the actual mistake instead of just saying 'mismatch'."""
  81. a = have.splitlines()
  82. b = want.splitlines()
  83. out = []
  84. for i in range(max(len(a), len(b))):
  85. x = a[i] if i < len(a) else "<end of golden>"
  86. y = b[i] if i < len(b) else "<end of disassembly>"
  87. if x != y:
  88. out.append("line %d: golden has %r, now %r" % (i + 1, x, y))
  89. if len(out) == 3:
  90. break
  91. out.append("%d differing line(s) total" % sum(
  92. 1 for i in range(max(len(a), len(b)))
  93. if (a[i] if i < len(a) else None) != (b[i] if i < len(b) else None)))
  94. return "; ".join(out)
  95. # Semantic goldens: the byte sequence each entry must START with. Stated in
  96. # terms of intent ("read the argument through BP") rather than offsets, so
  97. # they survive a legitimate size change but still catch a wrong ModRM byte.
  98. # This list is the *explanation* for the bytes; runtime.golden is the
  99. # backstop. Keep the two consistent -- they read the same measured ModRM
  100. # table that audit_helpers.py enforces on the source.
  101. #
  102. # The four read entries are here for the same reason the three earlier 8086
  103. # ModRM traps are: each of them is a hand-written sequence whose bytes are
  104. # well formed whether or not they mean anything, and each has in fact been
  105. # wrong while decoding cleanly. See the notes in Runtime.mod on each.
  106. GOLDEN = {
  107. "initmem": "8B F0 8B 54 04 8B 4C 06", # SI:=AX(header); DX:=[SI+4]; CX:=[SI+6]
  108. "stackchk": "C3", # the no-op: a bare RET, by design
  109. "progend": "31 C0 B4 4C CD 21 C3", # XOR AX,AX; AH:=$4C; INT 21h; RET
  110. "wrint": "55 8B EC 8B 46 04", # PUSH BP; MOV BP,SP; AX:=[BP+4]
  111. # wrintw reads the VALUE at [BP+6] (the width is at [BP+4]), because the
  112. # caller pushes the value first and the width second. BX starts at 0 and
  113. # becomes the character count. Wrong offsets here would print a width
  114. # as a number and a value as a width, both plausible and both silent.
  115. "wrintw": "55 8B EC BB 00 00 8B 46 06", # PUSH BP; MOV BP,SP; BX:=0; AX:=[BP+6]
  116. # real_from_int: PUSH BP; MOV BP,SP; PUSH BX; PUSH DX; PUSH SI; PUSH DI.
  117. # It keeps the destination in DI and reads the source through SI, so both
  118. # are saved and restored.
  119. "real_from_int": "55 8B EC 53 52 56 57",
  120. # real_add/real_sub share a frame and the readd core.
  121. "real_add": "55 8B EC 53 52 56 57",
  122. "real_sub": "55 8B EC 53 52 56 57",
  123. "real_mul": "55 8B EC 53 52 56 57",
  124. # wrchar: AL, not DL. DOS INT 21h AH=02h takes the character in AL, and
  125. # DL is the *other* 8086 convention (BIOS teletype). This read DL, so
  126. # every write of a character printed whatever the *last* character read
  127. # had been - usually nothing at all, since DL starts undefined. The
  128. # bytes are `8A 46 02` = MOV AL,[BP+2]; the old golden said `8A 56 02`
  129. # = MOV DL,[BP+2] and was RIGHT about what the code did and WRONG about
  130. # what it should do. That is the whole reason this check exists: a
  131. # positional golden blesses whatever is there.
  132. # wrchar and wrbool: PUSH BP; MOV BP,SP; ...; MOV SP,BP; POP BP.
  133. #
  134. # These two goldens were the *shape* of the fault found by execution and
  135. # not by any byte check. They read `8B EC 8A 46 02 89 EC` -- BP:=SP,
  136. # read, SP:=BP, no PUSH and no POP -- and they blessed it: the bytes were
  137. # well formed, the size did not change, the decode said exactly what it
  138. # said, and the entries still worked. What was wrong was that BP is the
  139. # one register an entry may keep, because the driver keeps its cursor into
  140. # the case record there, so an entry that borrows BP to reach its argument
  141. # and never gives it back sends the NEXT call to a garbage address. The
  142. # machine triple-faulted and restarted, which printed the record header
  143. # twice and hung.
  144. #
  145. # So the PUSH and the POP are now in the golden, which is the only place in
  146. # the byte-checking suite where "this entry must hand BP back" was ever
  147. # stated. Note the displacement moved 2 -> 4 with them: BP is pushed
  148. # first, so the argument is four bytes up. rt_exec.py checks the same rule
  149. # from the other side, and the two disagreeing is the point -- a golden
  150. # says what the bytes must be, and the contract says why.
  151. "wrchar": "55 8B EC 8A 46 04 89 EC 5D", # PUSH BP; BP:=SP; AL:=[BP+4]; SP:=BP; POP BP
  152. "wrbool": "55 8B EC 83 7E 04 00 89 EC", # PUSH BP; BP:=SP; CMP [BP+4],0; SP:=BP
  153. # ...and wrbool's POP BP is at 0086, after the INT 21h and not next to its
  154. # MOV SP,BP at 0076, so the six bytes above stop short of it on purpose. A
  155. # golden that had to span the whole frame would be asserting a code layout
  156. # rather than a prologue. rt_exec.py's POP_FRAME-free screen is what covers
  157. # that one, and it is a screen rather than a proof -- see the note there on
  158. # why 5D cannot be located exactly without a disassembler.
  159. "wrtinl": "5B 31 C9 8A 0F 43 B4 02", # POP BX; XOR CX,CX; CL:=[BX]; INC BX
  160. "rdln": "50", # PUSH AX, to keep the caller's
  161. # rdint: PUSH BP; MOV BP,SP; PUSH AX,BX,CX,DX,DI - five saved registers,
  162. # because the digit has to survive MUL (which owns DX) and be parked in
  163. # DI across it. A three-register version was not possible.
  164. "rdint": "55 8B EC 50 53 51 52 57",
  165. # rdbyte: the same frame as rdint - PUSH BP; MOV BP,SP; PUSH
  166. # AX,BX,CX,DX,DI - because it is the same scan under its own frame. What
  167. # differs between the two is the store, `88 05` for AL against `89 05` for
  168. # AX, and that is two bytes past the point a prefix golden can reach
  169. # without asserting where the whole entry sits. So this asserts the frame
  170. # and the contract (rt_exec.py finds the POP BP inside this entry's own
  171. # slice), and the WIDTH is asserted by execution: the rdbyte probes put a
  172. # poison byte just past the variable and require it to survive.
  173. "rdbyte": "55 8B EC 50 53 51 52 57",
  174. # rdchar: PUSH BP; MOV BP,SP; PUSH AX,BX,DI - three, no CX and no DX.
  175. # "DI" is how the caller's address gets in (there is no [BX] form), and
  176. # the store is `88 15` = MOV [DI],DL - a BYTE store, so readln of a CHAR
  177. # touches one byte and not the two rdint would have written over it.
  178. "rdchar": "55 8B EC 50 53 57",
  179. # rdbool: PUSH BP; MOV BP,SP; PUSH AX,BX,CX,DI - four. It needs no DX
  180. # (rdint's MUL is what forces DX to be saved) and saves nothing else; the
  181. # answer lives in CX, from "T/t/Y/y/1" test, and has to reach the
  182. # caller's address in DI.
  183. "rdbool": "55 8B EC 50 53 51 57",
  184. # wrstr: PUSH BP; MOV BP,SP; PUSH BX; PUSH CX; PUSH AX; MOV BX,[BP+4].
  185. # It borrows BP to reach its argument (there is no [SP] form), exactly as
  186. # wrchar does, so it must hand BP back - rt_exec.py's BP-contract screen
  187. # checks that from the other side. The address it loads through BP is the
  188. # caller's string; the print loop behind it is wrtinl's, so this prefix
  189. # stops at the address load rather than asserting the whole entry.
  190. "wrstr": "55 8B EC 53 51 50 8B 5E 04",
  191. # rdstr: readln(s)/read(s) of a string variable. The same five-register
  192. # frame as rdint/rdbyte - PUSH BP; MOV BP,SP; PUSH AX,BX,CX,DX,DI - kept
  193. # because the loop calls getch (which owns BX) and has to reload its
  194. # counter, and DI holds the destination base for the length byte that
  195. # closes the entry. The world in which these bytes could be wrong is the
  196. # world this golden exists to catch: a wrong ModRM on the [BP+4] address
  197. # load still decodes cleanly and still reads "some address", so the
  198. # prefix pins the frame and rt_exec pins the store.
  199. "rdstr": "55 8B EC 50 53 51 52 57",
  200. # strstore: s := t. PUSH BP; MOV BP,SP; PUSH AX,BX,CX,DX - DI is not
  201. # saved because there is no dest base register: both addresses arrive on
  202. # the stack, dest at [BP+4] and source at [BP+6]. The payload goldens
  203. # for the two stack loads are pinned by the same ModRM table the audit
  204. # enforces (8B 46 04 / 8B 5E 06).
  205. "strstore": "55 8B EC 50 53 51 52",
  206. # cmpstr: string compare, AX := -1 / 0 / +1 for left <, =, > right. PUSH
  207. # BP; MOV BP,SP; PUSH BX,CX,DX - and deliberately NOT AX. The first
  208. # version saved AX with the others and popped it back in the epilogue, so
  209. # every return handed the caller back the CALLER's AX: for `a < b' that is
  210. # the address of the RIGHT operand, still in AX from the LEA that pushed
  211. # it, i.e. a positive number. Every ordering test then read `greater' and
  212. # `<'/`<='/`='/`<>' came back FALSE TRUE FALSE FALSE TRUE TRUE over any
  213. # left<right pair, which is what execution found and no byte check could.
  214. # AX is a RESULT here, so there is nothing to save; the three-register
  215. # frame is the fix, and the sign is pinned by t48/t49 on qemu.
  216. "cmpstr": "55 8B EC 53 51 52",
  217. # strcat: s := s + t. The same argument shape as strstore - the caller
  218. # pushes the source then the destination, so dest is [BP+4] - but a
  219. # SIX-register frame: PUSH BP; MOV BP,SP; PUSH AX,BX,CX,DX,SI,DI. SI and
  220. # DI are saved because a string operand may be an ELEMENT (`a[i] + b'),
  221. # whose address the compiler is holding in DI while it pushes the operand
  222. # after it; the append loop walks the destination in DI and the source in
  223. # BX, and cmpstr's three-register frame is not enough. This is the
  224. # runtime's largest frame, and the prefix pins both the frame and the
  225. # argument convention the two emitted calls depend on.
  226. "strcat": "55 8B EC 50 53 51 52 56 57 8B 46 04",
  227. }
  228. def build_and_dump():
  229. os.makedirs(TMP, exist_ok=True)
  230. subprocess.run([GM2, "-fiso", "-Wall", "-c", "Runtime.mod"],
  231. cwd=SHELL, check=True)
  232. subprocess.run([GM2, "-fiso", "-o", RTPROBE,
  233. os.path.join("tests", "RtProbe.mod"),
  234. "Runtime.o", "Posix.o"],
  235. cwd=SHELL, check=True)
  236. out = subprocess.run([RTPROBE], capture_output=True, text=True, check=True)
  237. return out.stdout
  238. def parse(dump):
  239. size = codeend = None
  240. entries = {}
  241. code = bytearray()
  242. inhex = False
  243. for line in dump.splitlines():
  244. if RE_SIZE.match(line):
  245. size = int(RE_SIZE.match(line).group(1))
  246. continue
  247. m = RE_CODEEND.match(line)
  248. if m:
  249. codeend = int(m.group(1))
  250. continue
  251. m = RE_ENTRY.match(line)
  252. if m:
  253. entries[m.group(3)] = int(m.group(2))
  254. continue
  255. if line.strip() == "hex:":
  256. inhex = True
  257. continue
  258. if inhex:
  259. parts = line.split()
  260. if not parts or not re.fullmatch(r"[0-9A-F]{8}", parts[0]):
  261. continue
  262. for p in parts[1:]:
  263. code.append(int(p, 16))
  264. return size, codeend, entries, bytes(code)
  265. def main(argv):
  266. bless = "--bless" in argv
  267. verbose = "-v" in argv
  268. files = [a for a in argv[1:] if not a.startswith("-")]
  269. if len(files) > 1:
  270. print("usage: check_runtime.py [--bless] [-v] [RTPROBE-DUMP]")
  271. return 2
  272. if files:
  273. with open(files[0]) as f:
  274. dump = f.read()
  275. else:
  276. dump = build_and_dump()
  277. size, codeend, entries, code = parse(dump)
  278. problems = []
  279. if size is None or codeend is None:
  280. print("FAIL: could not parse the runtime dump (size/codeend missing)")
  281. return 1
  282. if len(code) != size:
  283. problems.append("hex dump has %d bytes, header says %d"
  284. % (len(code), size))
  285. if not entries:
  286. problems.append("no entry offsets parsed")
  287. sys.path.insert(0, HERE)
  288. import disasm16
  289. # --- 1. clean sweep of the code region ---------------------------
  290. body = code[:codeend]
  291. starts = set()
  292. listing = [] # (pc, bytes, text) - kept structured so neither the
  293. # branch scan nor the golden has to re-parse the
  294. # pretty-printed line
  295. pc = 0
  296. import io
  297. buf = io.StringIO()
  298. while pc < len(body):
  299. starts.add(pc)
  300. text, length = disasm16.decode(body[pc:], pc)
  301. if length == 0:
  302. buf.write("%04X: %s <DECODE ERROR>\n"
  303. % (pc, " ".join("%02X" % b for b in body[pc:pc + 8])))
  304. problems.append("decode error at %04X" % pc)
  305. break
  306. raw = body[pc:pc + length]
  307. text = text or "?"
  308. listing.append((pc, raw, text))
  309. buf.write("%04X: %-24s %s\n"
  310. % (pc, " ".join("%02X" % b for b in raw), text))
  311. pc += length
  312. if pc != len(body):
  313. problems.append("sweep ended at %04X, code region ends at %04X"
  314. % (pc, len(body)))
  315. if verbose:
  316. sys.stderr.write(buf.getvalue())
  317. # --- 2. entries are instruction boundaries -----------------------
  318. for name, off in sorted(entries.items()):
  319. if off not in starts:
  320. problems.append("entry %s at %d is not an instruction boundary"
  321. % (name, off))
  322. # --- 3. branch targets are instruction boundaries -----------------
  323. nbranch = 0
  324. for _at, _raw, text in listing:
  325. m = RE_BRANCH.match(text)
  326. if not m:
  327. continue
  328. nbranch += 1
  329. tgt = int(m.group(1), 16)
  330. if tgt not in starts:
  331. problems.append("%r targets %04X, not an instruction boundary"
  332. % (text, tgt))
  333. elif tgt >= codeend:
  334. problems.append("%r targets %04X, outside the code region"
  335. % (text, tgt))
  336. # --- 4. full-disassembly golden -----------------------------------
  337. # This is the only check that catches an instruction which decodes
  338. # cleanly but means the wrong thing; see the module docstring for the
  339. # MovAlDh example that motivated it.
  340. golden_text = "".join(
  341. "%04X %-11s %s\n" % (at, " ".join("%02X" % b for b in raw), text)
  342. for at, raw, text in listing)
  343. if bless:
  344. with open(GOLDEN_FILE, "w") as f:
  345. f.write(GOLDEN_TEXT_HEADER)
  346. f.write(golden_text)
  347. print("BLESSED: wrote %s (%d instructions). Read the diff before"
  348. " committing." % (GOLDEN_FILE, len(listing)))
  349. else:
  350. if not os.path.exists(GOLDEN_FILE):
  351. problems.append("no golden at %s - run with --bless once and"
  352. " commit the result" % GOLDEN_FILE)
  353. else:
  354. with open(GOLDEN_FILE) as f:
  355. have = f.read()
  356. want = GOLDEN_TEXT_HEADER + golden_text
  357. if have != want:
  358. problems.append("disassembly does not match %s (%s)"
  359. % (os.path.basename(GOLDEN_FILE),
  360. describe_golden_diff(have, want)))
  361. # --- 5. semantic goldens -----------------------------------------
  362. for name, want in sorted(GOLDEN.items()):
  363. if name not in entries:
  364. problems.append("no entry named %s" % name)
  365. continue
  366. off = entries[name]
  367. got = " ".join("%02X" % b for b in code[off:off + len(want.split())])
  368. if got.upper() != want.upper():
  369. problems.append("entry %s starts %s, expected %s"
  370. % (name, got.upper(), want.upper()))
  371. print("runtime: %d bytes, code 0..%d (%d), %d entries, %d branches checked"
  372. % (size, codeend - 1, codeend, len(entries), nbranch))
  373. for name in sorted(entries):
  374. print(" %-9s at %4d" % (name, entries[name]))
  375. if problems:
  376. print("FAIL: %d problem(s)" % len(problems))
  377. for p in problems:
  378. print(" - %s" % p)
  379. return 1
  380. print("PASS: runtime code region is self-consistent")
  381. return 0
  382. if __name__ == "__main__":
  383. sys.exit(main(sys.argv))