audit_helpers.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. #!/usr/bin/env python3
  2. """audit_helpers.py -- check that Runtime.mod's one-liner emitter procedures
  3. emit the instruction their name claims.
  4. Why this exists
  5. ---------------
  6. Runtime.mod hand-assembles 8086 by writing raw bytes. Most of it is wrapped
  7. up as one-line helpers:
  8. PROCEDURE MovSiBx ; BEGIN B (89H) ; B (0DCH) END MovSiBx ;
  9. and those names are the only documentation of what the bytes mean. That is
  10. exactly the setup for a silent, expensive mistake: write the right opcode and
  11. the wrong ModRM, and nothing complains. It happened twice here, in the same
  12. direction both times:
  13. MovSiBx emitted 89 DC -- which is MOV SP,BX, not MOV SI,BX
  14. CmpSiBx emitted 39 DC -- ditto, CMP SP,BX
  15. The comment next to CmpSiBx even spelled the ModRM out as "11 011 100" and
  16. still got it wrong, because 11 011 100 in mod=11 means rm=100=SP; SI is
  17. rm=100 only in mod=00, where it means [SI]. Both bugs shipped together into
  18. EmitWrInt, which then stored decimal digits through an SI register it had
  19. never initialised. The structural checks in check_runtime.py could not see
  20. any of this: the bytes decoded cleanly, the sweep stayed in sync, the branch
  21. targets were all on boundaries and none of the entry prologues moved. Only a
  22. *name* versus a *decode* comparison finds it, because only that knows what
  23. the author was trying to say.
  24. So: for every helper whose body is a literal byte list, disassemble those
  25. bytes with FCML and require the decode to match the name. The name grammar
  26. is deliberately narrow and mechanical:
  27. <Op><Dest><Src> e.g. MovSiBx, StDiAx, CmpSpBx, MovDlSi
  28. <Op><Reg> e.g. XorAxAx, NegAx, NotDx, IncSi, DecSi
  29. with Op in {Mov, Lea, St, Ld, Cmp, Add, Sub, Xor, And, Or, Not, Inc, Dec,
  30. Push, Pop, Int, Jmp, Jne, Jge, Jnl, Jle, Jl, Je, Jae, Jbe, Jb, Ja, JaE...}
  31. and the register/operand words below. A helper whose name does not parse is
  32. reported as UNPARSED rather than silently skipped -- a name the grammar does
  33. not understand is a name we are not checking, and that must be visible.
  34. This is a source-level check, so it needs no re-baselining: it is a function
  35. of the current source, and it gets stricter as more names are added to the
  36. grammar. It also runs without building anything.
  37. Usage: audit_helpers.py [MODULE.mod] # default ../Runtime.mod
  38. audit_helpers.py -v # print every helper, passing or not
  39. """
  40. import os
  41. import re
  42. import sys
  43. HERE = os.path.dirname(os.path.abspath(__file__))
  44. SHELL = os.path.dirname(HERE)
  45. sys.path.insert(0, HERE)
  46. import disasm16 # noqa: E402 (path set above)
  47. RE_HELPER = re.compile(
  48. r"^PROCEDURE\s+(\w+)\s*;\s*BEGIN\s+(.*?)\s+END\s+\1\s*;", re.MULTILINE)
  49. # The H suffix is optional: the source mixes B (8AH) and B (0) for the same
  50. # kind of literal, and a byte written without H used to be silently dropped
  51. # from the audit, which made three helpers look like truncated prefixes.
  52. RE_BYTE = re.compile(r"B\s*\(\s*([0-9A-Fa-f]+)H?\s*\)")
  53. # --- name grammar -----------------------------------------------------
  54. #
  55. # An ordered table of (name regex, mnemonic, operand specs, allowed opcodes).
  56. #
  57. # Why the opcode column exists
  58. # ----------------------------
  59. # "MovDlSi" and "MovBpSp" are ambiguous from the name alone: SI and BP are
  60. # both in the register list and in the memory-base list. What settles it is
  61. # the opcode, and the rule is the asymmetry that makes 16-bit hand-assembly
  62. # so error-prone:
  63. #
  64. # 88 /r MOV r/m8, r8 reg is the SOURCE (store)
  65. # 89 /r MOV r/m16, r16 reg is the SOURCE (store)
  66. # 8A /r MOV r8, r/m8 reg is the DESTINATION (load)
  67. # 8B /r MOV r16, r/m16 reg is the DESTINATION (load)
  68. #
  69. # A byte move (8A/88) to or from a bare base register can only be a memory
  70. # access, because "mov dl, si" is not an instruction. A word move (8B/89)
  71. # could be either, so for word moves the register reading is tried first.
  72. # Encoding that rule in the table, rather than in pattern order alone, is what
  73. # stops a helper being "verified" against the wrong reading of its own name.
  74. #
  75. # Operand spec tokens:
  76. #
  77. # "r:Name" a bare register spelled Name (Ax, Al, Dx, Si, Ds, ...)
  78. # "i:N" the immediate N; the name spells it in DECIMAL
  79. # "ih:N" like i: but the name spells it in HEX (only Int, whose
  80. # vector 21h FCML prints as "21h" and which reads as decimal
  81. # 33 if you do not notice)
  82. # "m:Base" memory at [Base], with no displacement
  83. # "m:Base+D" memory at [Base + D]
  84. #
  85. # "Arg" is the emitter's alias for BP: on entry BP points at the return
  86. # address, so a word argument starts at [BP+2]. Naming it "Arg" rather than
  87. # "Bp" is what stops these being misread as [BP] accesses, and it is also
  88. # how the grammar knows to expect the +2.
  89. #
  90. # A name matching NO pattern is reported UNPARSED, which counts as a failure
  91. # on purpose: a helper the audit cannot read is a helper nobody is checking.
  92. REGS = ["Ax", "Bx", "Cx", "Dx", "Si", "Di", "Bp", "Sp",
  93. "Al", "Bl", "Cl", "Dl", "Ah", "Bh", "Ch", "Dh"]
  94. # Segment registers: the 8086 can only PUSH/POP them, never MOV to or from
  95. # one, so they need names of their own.
  96. SEGS = ["Ds", "Es", "Cs", "Ss"]
  97. JCCS = ["Je", "Jne", "Jz", "Jnz", "Jge", "Jnl", "Jle", "Jl", "Ja", "Jae",
  98. "Jb", "Jbe", "Jg", "Jns", "Js", "Jo", "Jno", "Jp", "Jnp", "Jcxz",
  99. "Jecxz", "Jrcxz", "Loop", "Loope", "Loopne"]
  100. _ALT = "|".join(REGS)
  101. _BASE = "Si|Di|Bx|Bp|Sp|Arg|Data"
  102. _STORE = (0x88, 0x89) # reg is the source
  103. _LOAD = (0x8A, 0x8B) # reg is the destination
  104. PATTERNS = [
  105. # --- no-operand and fixed-operand forms -------------------------
  106. (re.compile(r"^RetR?$"), "ret", [], None),
  107. (re.compile(r"^LeaveR?$"), "leave", [], None),
  108. (re.compile(r"^Int([0-9A-Fa-f]+)$"), "int", ["ih:%(1)s"], {0xCD}),
  109. (re.compile(r"^(Push|Pop)(%s)$" % "|".join(SEGS)),
  110. None, ["r:%(2)s"], None),
  111. (re.compile(r"^(Push|Pop)(%s)$" % _ALT), None, ["r:%(2)s"], None),
  112. # --- jumps: the target is a fixup, so only the mnemonic is checked
  113. (re.compile(r"^(%s)(8|16)?$" % "|".join(JCCS)), None, [], None),
  114. (re.compile(r"^Jmp(%s)$" % _ALT), "jmp", ["r:%(1)s"], {0xFF}),
  115. # --- MUL names one operand; 16-bit DIV names two because it always
  116. # divides DX:AX, so the decode has an AX the name has no room for
  117. (re.compile(r"^Mul(%s)$" % _ALT), "mul", ["r:%(1)s"], {0xF7}),
  118. (re.compile(r"^Div(%s)$" % _ALT), "div", ["r:Ax", "r:%(1)s"], {0xF7}),
  119. # --- store: name is St<base><reg>, decode puts the register last --
  120. (re.compile(r"^St(%s)(%s)$" % (_BASE, _ALT)),
  121. "mov", ["m:%(1)s", "r:%(2)s"], _STORE),
  122. # --- load a register from memory, with a displacement ------------
  123. # The displacement makes the name unambiguous, so any load opcode works.
  124. (re.compile(r"^Mov(%s)(%s)(\d+)$" % (_ALT, _BASE)),
  125. "mov", ["r:%(1)s", "m:%(2)s+%(3)s"], _LOAD),
  126. # --- two registers: MOV BP,SP / CMP SI,BX / XOR AX,AX / ADD DI,AX -
  127. # Tried before the bare-base reading below, because for a word move
  128. # "MovBpSp" means BP := SP and only "MovDlSi" means DL := [SI]. The two
  129. # readings cannot both match, because one demands a register operand
  130. # where the other demands a memory operand.
  131. (re.compile(r"^(Mov|Cmp|Add|Sub|Xor|And|Or|Xchg)(%s)(%s)$" % (_ALT, _ALT)),
  132. None, ["r:%(2)s", "r:%(3)s"], None),
  133. # --- byte move from a bare base register: necessarily memory ------
  134. # Restricted to 8A/88 on purpose: if this ever matched an 8B/89 it would
  135. # be a register move already claimed by the pattern above.
  136. (re.compile(r"^Mov(%s)(%s)$" % (_ALT, _BASE)),
  137. "mov", ["r:%(1)s", "m:%(2)s"], (0x8A, 0x88)),
  138. # --- immediate against a register --------------------------------
  139. (re.compile(r"^(Add|Sub|Cmp|Xor|And|Or)(%s)(\d+)$" % _ALT),
  140. None, ["r:%(2)s", "i:%(3)s"], {0x83}),
  141. # --- one register: INC CX / DEC SI / NOT DX / NEG AX ------------
  142. (re.compile(r"^(Inc|Dec|Not|Neg|Shl|Shr|Sar)(%s)$" % _ALT),
  143. None, ["r:%(2)s"], None),
  144. ]
  145. def register_word(tok):
  146. """Map an FCML operand token to a REGS/SEGS word, or None."""
  147. t = tok.strip().lower()
  148. if not t or t.startswith("word ptr ") or t.startswith("byte ptr "):
  149. return None
  150. t = t.split()[-1]
  151. for r in REGS + SEGS:
  152. if t == r.lower():
  153. return r
  154. return None
  155. def match_operand(spec, tok):
  156. """Does one FCML operand token satisfy one token spec? Returns None on
  157. success, or a human-readable reason on failure."""
  158. kind, arg = spec.split(":", 1)
  159. t = tok.strip()
  160. if kind in ("i", "ih"):
  161. # FCML prints an immediate as hex with an h suffix ("0h", "2h", "21h")
  162. raw = t.lower().rstrip("h")
  163. try:
  164. v = int(raw, 16)
  165. except ValueError:
  166. return "%r is not an immediate" % t
  167. want = int(arg, 16) if kind == "ih" else int(arg, 10)
  168. if v != want:
  169. return "immediate is %d, name says %d" % (v, want)
  170. return None
  171. if kind == "r":
  172. got = register_word(t)
  173. if got != arg:
  174. return "%r is not the register %s" % (t, arg)
  175. return None
  176. if kind == "m":
  177. base, _, disp = arg.partition("+")
  178. real = {"Arg": "bp", "Data": "si"}.get(base, base.lower())
  179. if base == "Arg" and not disp:
  180. disp = "2" # the first word argument lives at [BP+2]
  181. u = t.lower()
  182. u = re.sub(r"^(word|byte) ptr ", "", u)
  183. if not (u.startswith("[") and u.endswith("]")):
  184. return "%r is not a memory reference" % t
  185. body = u[1:-1]
  186. if not body.startswith(real):
  187. return "memory base is %r, name says [%s]" % (body, real)
  188. if not disp:
  189. if body != real:
  190. return ("memory is %r, name says [%s] with no displacement"
  191. % (body, real))
  192. return None
  193. got = body[len(real):].strip()
  194. m = re.fullmatch(r"\+\s*([0-9a-f]+)h?", got)
  195. if not m:
  196. return "displacement %r is not a number" % got
  197. if int(m.group(1), 16) != int(disp):
  198. return "displacement is %d, name says %s" \
  199. % (int(m.group(1), 16), disp)
  200. return None
  201. return "bad spec %r" % spec
  202. def operands_of(decode):
  203. """Split an FCML decode like "mov si,bx" or "cmp word ptr [bp+4h],0h"
  204. into operand tokens. Returns (mnemonic, [tokens])."""
  205. i = decode.find(" ")
  206. if i < 0:
  207. return decode.strip(), []
  208. return decode[:i].strip(), decode[i + 1:].split(",")
  209. def main(argv):
  210. verbose = "-v" in argv
  211. files = [a for a in argv[1:] if not a.startswith("-")]
  212. path = files[0] if files else os.path.join(SHELL, "Runtime.mod")
  213. with open(path) as f:
  214. src = f.read()
  215. n_ok = n_unparsed = n_bad = 0
  216. problems = []
  217. if verbose:
  218. print("name bytes FCML decode")
  219. for m in RE_HELPER.finditer(src):
  220. name, body = m.group(1), m.group(2)
  221. by = [int(b, 16) for b in RE_BYTE.findall(body)]
  222. if not by:
  223. continue
  224. raw = bytes(by)
  225. # Disassemble; helpers are 1-2 bytes so one instruction is the norm,
  226. # but StCx / Shl / Div style helpers can be longer, so decode all.
  227. decodes = []
  228. off = 0
  229. while off < len(raw):
  230. text, length = disasm16.decode(raw[off:], off)
  231. if length == 0:
  232. decodes.append(("<DECODE ERROR>", ""))
  233. break
  234. decodes.append(operands_of(text or "?"))
  235. off += length
  236. hexs = " ".join("%02X" % b for b in raw)
  237. dec_text = "; ".join(d[0] + " " + ", ".join(d[1]) for d in decodes)
  238. if verbose:
  239. print("%-20s %-16s %s" % (name, hexs, dec_text))
  240. # A name can have more than one reading (MovBpSp: BP := SP, or
  241. # BP := [SP]). Take the first reading whose operand specs the decode
  242. # actually satisfies; if two readings both fit, the name is genuinely
  243. # ambiguous and that is itself reported, because it would mean the
  244. # audit could be satisfied by the wrong one.
  245. reads = []
  246. nopattern = True
  247. for rx, pmnem, specs, ops in PATTERNS:
  248. mm = rx.match(name)
  249. if not mm:
  250. continue
  251. if ops is not None and by[0] not in ops:
  252. continue
  253. nopattern = False
  254. gmap = {str(i): g for i, g in enumerate(mm.groups(), 1)}
  255. mn = pmnem
  256. if mn is None:
  257. mn = mm.group(1).lower()
  258. elif "%(" in mn:
  259. mn = mn % gmap
  260. if len(decodes) != 1:
  261. reads.append((mm, mn, specs,
  262. "emits %d instructions, name describes one"
  263. % len(decodes)))
  264. continue
  265. gmnem, got = decodes[0]
  266. if gmnem != mn:
  267. reads.append((mm, mn, specs,
  268. "FCML decodes %r, name says %s" % (gmnem, mn)))
  269. continue
  270. if len(got) != len(specs):
  271. reads.append((mm, mn, specs,
  272. "name asserts %d operand(s), FCML decoded %d"
  273. % (len(specs), len(got))))
  274. continue
  275. why = [w for w in
  276. (match_operand(sp % gmap if "%(" in sp else sp, tok)
  277. for sp, tok in zip(specs, got)) if w]
  278. if why:
  279. reads.append((mm, mn, specs, "; ".join(why)))
  280. continue
  281. reads.append((mm, mn, specs, None))
  282. if nopattern:
  283. n_unparsed += 1
  284. problems.append("%s: no name pattern accepts it (emits %s %r) - "
  285. "extend PATTERNS or fix the name"
  286. % (name, hexs, dec_text))
  287. continue
  288. fitting = [r for r in reads if r[3] is None]
  289. if len(fitting) > 1:
  290. n_bad += 1
  291. problems.append("%s: %d name readings fit the same bytes - the "
  292. "name is ambiguous" % (name, len(fitting)))
  293. continue
  294. if not fitting:
  295. n_bad += 1
  296. problems.append("%s: %s" % (name, reads[0][3]))
  297. continue
  298. n_ok += 1
  299. total = n_ok + n_unparsed + n_bad
  300. print("\n%d one-line emitter helpers audited: %d agree with their name, "
  301. "%d disagree, %d outside the grammar"
  302. % (total, n_ok, n_bad, n_unparsed))
  303. if problems:
  304. print("FAIL: %d problem(s)" % len(problems))
  305. for p in problems:
  306. print(" - %s" % p)
  307. return 1
  308. print("PASS: every audited helper emits what its name says")
  309. return 0
  310. if __name__ == "__main__":
  311. sys.exit(main(sys.argv))