check_framedisp.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  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 are allocated downward from 0FFFEh, so their offsets
  16. are negative -- and 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
  21. upward from BP+4, so the 63rd parameter of a procedure is at 4 + 62*2 = 128
  22. and the 63rd word of every call's argument block was read from the wrong
  23. side of BP. Nobody had hit it because no fixture declared that many
  24. parameters, and because a program with no local variables has no BP-relative
  25. access at all -- so the whole BP path had zero coverage.
  26. The rule now enforced
  27. ---------------------
  28. EmBpDisp picks disp8 for off <= 127 and disp16 otherwise, where `off` is
  29. taken as a 16-bit value. Note that every offset above 32767 is *negative* as
  30. a displacement, so "otherwise" covers all of them; there is no overflow case
  31. and no 32767 ceiling. Both fixtures below therefore have every frame access
  32. encoded in the 4-byte form, and this check requires that form to be present
  33. and the 3-byte form to be ABSENT for those offsets -- so restoring the old
  34. `off MOD 100H` turns the test red.
  35. The expected offsets are computed here from the frame-layout rules rather
  36. than copied from the compiler's output, so this is a cross-check and not a
  37. restatement of what the compiler happens to do:
  38. locals locFree starts at 0FFFEh and the NEW SYMBOL IS GIVEN THE
  39. PRE-DECREMENT VALUE, so the first local of a procedure is at
  40. 0FFFEh = -2, the second at 0FFFCh = -4, and so on down.
  41. parameters parmOff starts at 4 and grows by 2 per parameter, so
  42. parameter k is at 4 + 2*(k-1).
  43. Fixtures
  44. --------
  45. t27_localvar.pas five locals, assigned and read back. Covers the negative
  46. half of the range, which is where every real variable is.
  47. t28_farparam.pas seventy declared parameters, of which the call passes
  48. sixteen (the call site caps arguments at 16). p63 and p70
  49. are at +128 and +142, the first offsets the disp8 form
  50. cannot represent.
  51. t28 is a compile-level fixture and is never executed: p63..p70 are declared
  52. but not passed, so at run time those reads would come from uninitialised
  53. stack. What it establishes is the ENCODING, which is the thing that was
  54. wrong. Do not read t28 as a claim that a 70-argument call works.
  55. Usage: check_framedisp.py [-v] (from shell/)
  56. """
  57. import os
  58. import subprocess
  59. import sys
  60. import tempfile
  61. HERE = os.path.dirname(os.path.abspath(__file__))
  62. SHELL = os.path.dirname(HERE)
  63. sys.path.insert(0, HERE)
  64. import disasm16 # noqa: E402
  65. # Re-stated, not asked of the code under test. The image starts with a
  66. # three-byte entry JMP (see Compiler.Inittur), so the program code begins at
  67. # ENT_SZ + RT_SZ rather than at the runtime size alone.
  68. RT_SZ = 391 # Runtime.RT_Size()
  69. ENT_SZ = 3 # E9 lo hi
  70. RTSZ = ENT_SZ + RT_SZ
  71. COMTEST = os.path.join(SHELL, "comtest")
  72. # (fixture, opcode, [signed offsets])
  73. # Each offset is checked three ways: the decoded displacement set must contain
  74. # it, the 4-byte mod=10 encoding of it must be present in the image, and the
  75. # 3-byte mod=01 encoding of the same offset must be ABSENT. The last of those
  76. # is the one that goes red if `off MOD 100H` ever comes back.
  77. def expected_local_offsets(count):
  78. """locFree starts at 0FFFEh; the symbol takes the pre-decrement value, so
  79. the first local is at -2. Signed, because that is what a displacement
  80. is."""
  81. return [-(2 + 2 * i) for i in range(count)]
  82. def expected_param_offsets(index):
  83. """parmOff starts at 4 and grows by 2 per parameter."""
  84. return 4 + 2 * (index - 1)
  85. CASES = [
  86. # t27: `vN := const` then `g := v1 + ... + v5`. Each local is stored once
  87. # and loaded once, so each offset appears under both 89 (store) and 8B
  88. # (load).
  89. ("t27_localvar", "89", expected_local_offsets(5)),
  90. ("t27_localvar", "8B", expected_local_offsets(5)),
  91. # t28: `g := p63 + p70`.
  92. ("t28_farparam", "8B", [expected_param_offsets(63),
  93. expected_param_offsets(70)]),
  94. ]
  95. def link_fixtures(work):
  96. """compile and link the two fixtures, returning {name: image bytes}"""
  97. names = ["t27_localvar", "t28_farparam"]
  98. paths = [os.path.join(HERE, "fixtures", n + ".pas") for n in names]
  99. p = subprocess.run([COMTEST],
  100. input=("\n".join(paths) + "\n").encode(),
  101. stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
  102. cwd=work)
  103. # ComTest writes the .COM next to the CWD, under the fixture's basename
  104. out = {}
  105. for n in names:
  106. f = os.path.join(work, n + ".COM")
  107. if not os.path.exists(f):
  108. sys.stderr.write(p.stdout.decode("utf-8", "replace"))
  109. raise SystemExit("FAIL: %s.COM was not written" % n)
  110. with open(f, "rb") as fh:
  111. out[n] = fh.read()
  112. return out
  113. def bp_operands(code):
  114. """every instruction in `code` that is an 8r/9r with a [BP+disp] operand,
  115. as (opcode, modrm, disp_value, offset)"""
  116. got = []
  117. off = 0
  118. while off < len(code):
  119. t, n = disasm16.decode(code[off:], off)
  120. if n == 0:
  121. break
  122. op = code[off]
  123. if op in (0x8A, 0x8B, 0x88, 0x89, 0x8D) and n >= 3 \
  124. and code[off + 1] in (0x46, 0x86):
  125. modrm = code[off + 1]
  126. if modrm == 0x46: # mod=01 rm=110 -> disp8
  127. disp = code[off + 2]
  128. if disp > 127:
  129. disp -= 256
  130. else: # mod=10 rm=110 -> disp16
  131. disp = int.from_bytes(code[off + 2:off + 4], "little",
  132. signed=True)
  133. got.append(("%02X" % op, modrm, disp))
  134. off += n
  135. return got
  136. def main(argv):
  137. verbose = "-v" in argv
  138. if not os.path.exists(COMTEST):
  139. print("FAIL: %s not built; run tests/run_com_tests.sh first" % COMTEST)
  140. return 1
  141. problems = []
  142. work = tempfile.mkdtemp(prefix="framedisp.")
  143. try:
  144. images = link_fixtures(work)
  145. finally:
  146. subprocess.run(["rm", "-rf", work])
  147. for fixture, want_op, offsets in CASES:
  148. img = images[fixture]
  149. code = img[RTSZ:]
  150. op = int(want_op, 16)
  151. mine = [d for (o, _, d) in bp_operands(code) if o == want_op]
  152. if verbose:
  153. print("%s opcode %s [BP+..] accesses found: %s"
  154. % (fixture, want_op, ["%+d" % d for d in mine]))
  155. for off in offsets:
  156. u = off & 0xFFFF
  157. # 1. the displacement really is the one the layout rule predicts
  158. if off not in mine:
  159. problems.append("%s: no %s access at [BP%+d] (offsets seen: "
  160. "%s)" % (fixture, want_op, off,
  161. ", ".join("%+d" % d for d in mine)))
  162. continue
  163. # 2. and it is encoded in the 4-byte mod=10 form ...
  164. want = bytes([op, 0x86, u & 0xFF, (u >> 8) & 0xFF])
  165. if want not in code:
  166. problems.append("%s: expected the bytes %s for [BP%+d] and "
  167. "they are not in the image"
  168. % (fixture, want.hex(" ").upper(), off))
  169. # 3. ... and NOT in the 3-byte mod=01 form, which EmBpDisp
  170. # reserves for offsets <= 127. This is the assertion that
  171. # fails if the old `off MOD 100H` truncation returns. Note
  172. # the two failure modes are not the same severity, so they
  173. # are reported differently: for a positive offset above 127
  174. # the disp8 form reads a DIFFERENT address, while for a
  175. # negative offset it reads the right one in fewer bytes.
  176. bad = bytes([op, 0x46, u & 0xFF])
  177. if bad in code:
  178. landed = (u & 0xFF) - 256 if (u & 0xFF) > 127 else (u & 0xFF)
  179. if landed != off:
  180. problems.append(
  181. "%s: [BP%+d] is encoded as %s, a disp8 that reads "
  182. "[BP%+d] instead -- a different address"
  183. % (fixture, off, bad.hex(" ").upper(), landed))
  184. else:
  185. problems.append(
  186. "%s: [BP%+d] is encoded as %s, a disp8 form that "
  187. "EmBpDisp reserves for offsets <= 127 (it would read "
  188. "the right address, but by a different rule)"
  189. % (fixture, off, bad.hex(" ").upper()))
  190. print("frame displacement: %d local offsets (negative) and %d parameter "
  191. "offsets (+128, +142) encoded as mod=10/disp16"
  192. % (len(expected_local_offsets(5)), 2))
  193. if problems:
  194. print("FAIL: %d problem(s)" % len(problems))
  195. for p in problems:
  196. print(" - %s" % p)
  197. return 1
  198. print("PASS: every [BP+off] outside -128..+127 uses the 4-byte form, and "
  199. "no offset")
  200. print(" is truncated to a disp8 that would read a different address")
  201. return 0
  202. if __name__ == "__main__":
  203. sys.exit(main(sys.argv))