check_framedisp.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. #!/usr/bin/env python3
  2. """check_framedisp.py -- guard the [BP+off] displacement encoding.
  3. The compiler addresses a procedure's frame through BP, and there are two ways
  4. to encode an offset:
  5. 8B 46 d8 mod=01 rm=110 MOV AX,[BP+disp8]
  6. 8B 86 lo hi mod=10 rm=110 MOV AX,[BP+disp16]
  7. Both are well-formed, both decode cleanly, and only one of them reads the
  8. variable the symbol table named. Nothing else in the build can tell them
  9. apart, so this asserts the choice.
  10. The bug
  11. -------
  12. EmLoadVar, EmStoreVar and EmPushVarAddr all used to compute
  13. disp := off MOD 100H
  14. and always emit the disp8 form. That is the correct LOW BYTE for any
  15. displacement, and locals sit below BP, so their offsets are negative -- and
  16. disp8 0FEh is -2, which is right. The old code was
  17. therefore accidentally correct across -32768..+127, which is where almost
  18. every variable lives, and every existing fixture kept its exact bytes.
  19. It went wrong at +128: disp8 80h is -128 and not +128, so a read of
  20. [BP+128] became a read of [BP-128]. Procedure parameters are laid out from
  21. BP+4 and grow AWAY from it - the last declared parameter is the one at BP+4,
  22. which is what makes it the counterpart of pushing arguments as they are
  23. parsed - so with seventy of them declared the 8th parameter sits at
  24. 4 + 62*2 = 128, the 8th word of a full argument block, and that word was read
  25. from the wrong side of BP. Nobody had hit it because no fixture declared that
  26. many parameters, and because a program with no local variables has no
  27. BP-relative access at all -- so the whole BP path had zero coverage.
  28. The rule now enforced
  29. ---------------------
  30. EmBpDisp picks disp8 for off <= 127 and disp16 otherwise, where `off` is
  31. taken as a 16-bit value. Note that every offset above 32767 is *negative* as
  32. a displacement, so "otherwise" covers all of them; there is no overflow case
  33. and no 32767 ceiling. The offsets this check asks for below are therefore
  34. encoded in the 4-byte form, and it requires that form to be present and the
  35. 3-byte form to be ABSENT for those offsets -- so restoring the old
  36. `off MOD 100H` turns the test red.
  37. The expected offsets are computed here from the frame-layout rules rather
  38. than copied from the compiler's output, so this is a cross-check and not a
  39. restatement of what the compiler happens to do:
  40. locals locFree is the highest still-free byte below BP (0FFFFh = -1
  41. at entry), a local of size n is based so its LAST byte sits on
  42. locFree, and locFree then drops by n. For the INTEGER locals
  43. t27 declares that gives the first at 0FFFEh = -2, the second at
  44. 0FFFCh = -4, and so on down (a size-1 local would take the top
  45. byte, -1, and a string local n bytes at -n..-1 - the point of
  46. the rule is that its base is chosen so the whole variable, which
  47. grows UP from that base, stays below the saved BP).
  48. parameters TP3 lays the list out from BP+4 backwards: the LAST declared
  49. parameter is at BP+4 and each earlier one is 2 higher (see
  50. RESUME-TP3.md 3.11), so parameter k of n is at 4 + 2*(n-k).
  51. The region this check reads
  52. ---------------------------
  53. A displacement only means something about the instruction that contains it, so
  54. this check sweeps the program's OWN instructions, from the first one to the
  55. end of the image. Two different things in the file say where the first one
  56. is, and they are read separately:
  57. the entry jump `E9 rel16' at file offset 0, so execution begins at
  58. ENT_SZ + rel16 (Compiler.Inittur)
  59. the program found by its own signature (comimage.find_header); the
  60. header program code follows the header immediately
  61. They must agree. They are not the same measurement -- one is a jump target,
  62. the other is a self-consistency equation over header words -- so their
  63. agreement is evidence, and a disagreement means the region is undetermined,
  64. which this check reports instead of sweeping something anyway.
  65. Until this section was written the region was a literal: RT_SZ = 391, next to
  66. a comment saying it tracked Runtime.RT_Size(). It had drifted, the runtime
  67. having grown past it, so the sweep began inside the RUNTIME, part way through
  68. an instruction -- and the check passed. It passed because nothing in it could
  69. see where its own region began: the byte patterns are found wherever they are
  70. in the image, and a decode sweep that starts mid-instruction happened to reach
  71. the same offsets. A wrong input that produces the right answers is still a
  72. wrong input, and it stops being right the first time the layout moves.
  73. Fixtures
  74. --------
  75. t27_localvar.pas five locals, assigned and read back. Covers the negative
  76. half of the range, which is where every real variable is.
  77. t28_farparam.pas seventy declared parameters, of which the call passes
  78. sixteen (the call site caps arguments at 16). Laid out from
  79. BP+4 backwards, p1 is at +142 and p8 at +128: the first
  80. offsets the disp8 form cannot represent.
  81. t28 is executed as well as compiled, and that distinction matters for what its
  82. .out file may assert. `g := p55 + p70' is deterministic (1 + 16 = 17) and is
  83. the printed value: with only sixteen arguments pushed, they cover BP+4..BP+34,
  84. and those are the slots p55..p70 sit in - the last declared parameter is the
  85. one at BP+4. `unused := p8 + p1' reads stack garbage, because a call caps at
  86. 16 arguments and p1..p54 are never passed; it is computed and discarded, and
  87. its value is deliberately NOT in the .out file. A fixture that printed it
  88. would be asserting a number that depends on what the caller left on the stack,
  89. which is a test of the harness rather than of the compiler. What t28
  90. establishes for p1/p8 is the ENCODING, which is the thing that was wrong.
  91. Do not read t28 as a claim that a 70-argument call works.
  92. Usage: check_framedisp.py [-v] (from shell/)
  93. """
  94. import os
  95. import subprocess
  96. import sys
  97. import tempfile
  98. HERE = os.path.dirname(os.path.abspath(__file__))
  99. SHELL = os.path.dirname(HERE)
  100. sys.path.insert(0, HERE)
  101. import disasm16 # noqa: E402
  102. # Where the program header is, where the entry jump lands, and how big the
  103. # header is. This check used to write the runtime's size down as RT_SZ = 391
  104. # beside a comment claiming it tracked Runtime.RT_Size(), and swept from
  105. # ENT_SZ + RT_SZ - which is inside the RUNTIME, part way through an
  106. # instruction, and nowhere near the program's first instruction. It still
  107. # passed, for the two reasons a wrong region always passes: the byte patterns
  108. # below are in the image wherever they happen to be, and the decode sweep
  109. # reached the same answers from a start point that nothing in the check could
  110. # tell was wrong. So the region is now measured from the file AND asserted to
  111. # agree with itself in main(), because a check whose own input is wrong reports
  112. # confidently about bytes the program never executes.
  113. import comimage # noqa: E402
  114. COMTEST = os.path.join(SHELL, "comtest")
  115. # (fixture, opcode, [signed offsets])
  116. # Each offset is checked three ways: the decoded displacement set must contain
  117. # it, the 4-byte mod=10 encoding of it must be present in the image, and the
  118. # 3-byte mod=01 encoding of the same offset must be ABSENT. The last of those
  119. # is the one that goes red if `off MOD 100H` ever comes back.
  120. def expected_local_offsets(count):
  121. """Locals are packed top-anchored below BP: locFree is the highest still-free
  122. byte (0FFFFh = -1 at entry) and drops by each local's size. For the INTEGER
  123. locals declared here (size 2 each) that puts the first base at -2, the second
  124. at -4, and so on. Signed, because that is what a displacement is."""
  125. return [-(2 + 2 * i) for i in range(count)]
  126. def expected_param_offsets(count, index):
  127. """TP3 puts the LAST declared parameter at BP+4 and walks backwards from
  128. there, so `index` (1-based) of `count` declared is at 4 + 2*(count-index):
  129. parameter `count` at +4, parameter 1 at +4 + 2*(count-1)."""
  130. return 4 + 2 * (count - index)
  131. CASES = [
  132. # t27: `vN := const` then `g := v1 + ... + v5`. Each local is stored once
  133. # and loaded once, so each offset appears under both 89 (store) and 8B
  134. # (load).
  135. ("t27_localvar", "89", expected_local_offsets(5)),
  136. ("t27_localvar", "8B", expected_local_offsets(5)),
  137. # t28: `unused := p8 + p1` -- read but never printed, see the note above.
  138. # p8 is the first parameter whose slot a disp8 cannot reach (+128) and p1
  139. # is the farthest of the seventy (+142); the printed reads (p55, p70) are
  140. # inside the passed argument block and stay disp8.
  141. ("t28_farparam", "8B", [expected_param_offsets(70, 8),
  142. expected_param_offsets(70, 1)]),
  143. ]
  144. def link_fixtures(work):
  145. """compile and link the two fixtures, returning {name: image bytes}"""
  146. names = ["t27_localvar", "t28_farparam"]
  147. paths = [os.path.join(HERE, "fixtures", n + ".pas") for n in names]
  148. p = subprocess.run([COMTEST],
  149. input=("\n".join(paths) + "\n").encode(),
  150. stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
  151. cwd=work)
  152. # ComTest writes the .COM next to the CWD, under the fixture's basename
  153. out = {}
  154. for n in names:
  155. f = os.path.join(work, n + ".COM")
  156. if not os.path.exists(f):
  157. sys.stderr.write(p.stdout.decode("utf-8", "replace"))
  158. raise SystemExit("FAIL: %s.COM was not written" % n)
  159. with open(f, "rb") as fh:
  160. out[n] = fh.read()
  161. return out
  162. def bp_operands(code):
  163. """every instruction in `code` that is an 8r/9r with a [BP+disp] operand,
  164. as (opcode, modrm, disp_value, offset)"""
  165. got = []
  166. off = 0
  167. while off < len(code):
  168. t, n = disasm16.decode(code[off:], off)
  169. if n == 0:
  170. break
  171. op = code[off]
  172. if op in (0x8A, 0x8B, 0x88, 0x89, 0x8D) and n >= 3 \
  173. and code[off + 1] in (0x46, 0x86):
  174. modrm = code[off + 1]
  175. if modrm == 0x46: # mod=01 rm=110 -> disp8
  176. disp = code[off + 2]
  177. if disp > 127:
  178. disp -= 256
  179. else: # mod=10 rm=110 -> disp16
  180. disp = int.from_bytes(code[off + 2:off + 4], "little",
  181. signed=True)
  182. got.append(("%02X" % op, modrm, disp))
  183. off += n
  184. return got
  185. def main(argv):
  186. verbose = "-v" in argv
  187. if not os.path.exists(COMTEST):
  188. print("FAIL: %s not built; run tests/run_com_tests.sh first" % COMTEST)
  189. return 1
  190. problems = []
  191. work = tempfile.mkdtemp(prefix="framedisp.")
  192. try:
  193. images = link_fixtures(work)
  194. finally:
  195. subprocess.run(["rm", "-rf", work])
  196. for fixture, want_op, offsets in CASES:
  197. img = images[fixture]
  198. # The region this check sweeps, measured twice from the file: where the
  199. # entry jump says execution starts, and where the program header says
  200. # the runtime ends. Those are different facts read from different
  201. # bytes, so a check that knows only one of them cannot tell that its
  202. # region is wrong -- which is exactly what happened while this was a
  203. # literal: the sweep began inside the runtime and reported PASS. The
  204. # two must agree or there is no measured place to start, and a guess
  205. # would be reported here as a fact about the compiler.
  206. entry = comimage.entry_target(img)
  207. hdr = comimage.find_header(img)
  208. if entry is None:
  209. problems.append("%s: no entry jump at file offset 0, so the image "
  210. "says nothing about where the program's code "
  211. "begins and this check would have to guess"
  212. % fixture)
  213. continue
  214. if hdr is None:
  215. problems.append("%s: no program header found, so the end of the "
  216. "runtime is unknown and this check would have to "
  217. "guess where the program's code begins" % fixture)
  218. continue
  219. if entry != hdr + comimage.HDR_SZ:
  220. problems.append("%s: the entry jump lands on %d but the program "
  221. "header ends at %d -- the two measurements of "
  222. "where the program's code begins disagree, so "
  223. "neither region is swept" % (fixture, entry,
  224. hdr + comimage.HDR_SZ))
  225. continue
  226. code = img[entry:]
  227. op = int(want_op, 16)
  228. mine = [d for (o, _, d) in bp_operands(code) if o == want_op]
  229. if verbose:
  230. print("%s opcode %s [BP+..] accesses found: %s"
  231. % (fixture, want_op, ["%+d" % d for d in mine]))
  232. for off in offsets:
  233. u = off & 0xFFFF
  234. # 1. the displacement really is the one the layout rule predicts
  235. if off not in mine:
  236. problems.append("%s: no %s access at [BP%+d] (offsets seen: "
  237. "%s)" % (fixture, want_op, off,
  238. ", ".join("%+d" % d for d in mine)))
  239. continue
  240. # 2. and it is encoded in the 4-byte mod=10 form ...
  241. want = bytes([op, 0x86, u & 0xFF, (u >> 8) & 0xFF])
  242. if want not in code:
  243. problems.append("%s: expected the bytes %s for [BP%+d] and "
  244. "they are not in the image"
  245. % (fixture, want.hex(" ").upper(), off))
  246. # 3. ... and NOT in the 3-byte mod=01 form, which EmBpDisp
  247. # reserves for offsets <= 127. This is the assertion that
  248. # fails if the old `off MOD 100H` truncation returns. Note
  249. # the two failure modes are not the same severity, so they
  250. # are reported differently: for a positive offset above 127
  251. # the disp8 form reads a DIFFERENT address, while for a
  252. # negative offset it reads the right one in fewer bytes.
  253. bad = bytes([op, 0x46, u & 0xFF])
  254. if bad in code:
  255. landed = (u & 0xFF) - 256 if (u & 0xFF) > 127 else (u & 0xFF)
  256. if landed != off:
  257. problems.append(
  258. "%s: [BP%+d] is encoded as %s, a disp8 that reads "
  259. "[BP%+d] instead -- a different address"
  260. % (fixture, off, bad.hex(" ").upper(), landed))
  261. else:
  262. problems.append(
  263. "%s: [BP%+d] is encoded as %s, a disp8 form that "
  264. "EmBpDisp reserves for offsets <= 127 (it would read "
  265. "the right address, but by a different rule)"
  266. % (fixture, off, bad.hex(" ").upper()))
  267. print("frame displacement: %d local offsets (negative) and %d parameter "
  268. "offsets (+128, +142) encoded as mod=10/disp16"
  269. % (len(expected_local_offsets(5)), 2))
  270. if problems:
  271. print("FAIL: %d problem(s)" % len(problems))
  272. for p in problems:
  273. print(" - %s" % p)
  274. return 1
  275. print("PASS: every [BP+off] outside -128..+127 uses the 4-byte form, and "
  276. "no offset")
  277. print(" is truncated to a disp8 that would read a different address")
  278. return 0
  279. if __name__ == "__main__":
  280. sys.exit(main(sys.argv))