comimage.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. #!/usr/bin/env python3
  2. """comimage.py -- the layout of a linked .COM, measured from the file itself.
  3. Every check in this project that reads an emitted image has to answer the same
  4. three questions before it can say anything at all:
  5. where is the program header? find_header(d)
  6. where does the runtime end? find_header(d) - HDR_SZ
  7. where does execution start? entry_target(d)
  8. Until now each check answered for itself. comtest.py carried one find_header,
  9. the independent checker inside run_com_tests.sh carried a second copy of it,
  10. and check_framedisp.py carried neither: it wrote RT_SZ = 391 beside a comment
  11. saying it tracked Runtime.RT_Size(), and swept the region beginning
  12. ENT_SZ + RT_SZ - which is inside the RUNTIME, part way through an instruction.
  13. The check passed anyway, for the two reasons a restated offset always passes:
  14. the byte patterns it searched for are in the image wherever they happen to be,
  15. and the decode sweep reached the same answers from a start point that nothing
  16. in the check could tell was wrong. A region that is wrong in a way its
  17. assertions cannot see is the same fault as a constant that has drifted, only
  18. harder to notice, because the report it prints is a clean PASS.
  19. So the layout lives in one file, is measured rather than remembered, and every
  20. reader imports it. tests/check_comimage.py asserts that there is exactly one
  21. definition of each part and that every reader gets it from here, because a
  22. shared helper that somebody copies again is four sources of truth instead of
  23. one - and four is how this started.
  24. What is written down here, and what is not
  25. ------------------------------------------
  26. The FORMAT is written down: the entry jump is three bytes, the header is
  27. sixteen, initmem's first bytes and the two header words it reads, and the load
  28. bias. Those are this project's knowledge of what it emits, and asking the code
  29. under test for them would let the code under test satisfy every check by
  30. agreeing with itself.
  31. The SIZES are not written down. The runtime's size was written down twice
  32. (385, then 391), beside a comment saying it tracked the runtime; it did not,
  33. and each stale value sent a checker into the middle of the code, where it
  34. reported a well-formed image as a compiler fault. A duplicated constant that
  35. has silently drifted is not an independent check, it is a second source of
  36. truth that lies, and it lies in the direction of looking like the thing under
  37. test is broken.
  38. Measuring is not the same as asking the compiler: everything here reads the
  39. emitted file. tests/check_runtime.py is where the runtime's size is pinned on
  40. purpose, and tests/run_com_tests.sh prints the size it measures on every run.
  41. Usage: this is a library; import it from a check.
  42. """
  43. # ---- the format, written down -------------------------------------------
  44. # ENT_SZ and HDR_SZ are the layout, and the load bias below is a decision this
  45. # project made about how a DOS .COM is loaded. Those stay written down: they
  46. # are knowledge about the format, not sizes that move when code is edited.
  47. ENT_SZ = 3 # E9 lo hi, the entry jump (Compiler.Inittur)
  48. HDR_SZ = 16 # 5 header words + 3 buffer words (Compiler)
  49. # The load bias. A DOS .COM's first byte is at CS:0100 and CS = DS, so an image
  50. # offset K is at DS:(K + 0100h); every ABSOLUTE address the image contains has
  51. # to carry it, or it points 0100h low and - since the code region and the
  52. # runtime are both below the bias - almost always lands inside the runtime
  53. # instead of inside the data. Relative encodings must not carry it, because
  54. # both of their operands shift together.
  55. # Restated, not asked of the code under test. See Runtime.LoadBias.
  56. LOAD_BIAS = 0x100
  57. # initmem's prologue, which is the runtime's only reader of the program header.
  58. # The displacements +4 and +6 below are the whole point of this constant: the
  59. # header checks in every checker read hdrDS at +4 and hdrHeap at +6, and
  60. # initmem has to read the SAME two words or it clears the wrong range. It read
  61. # +8 (hdrMax, which the compiler patches to 0), so it zeroed nothing at all and
  62. # nothing here noticed - the emitted loop was perfectly well formed, it just
  63. # never ran. Asserting these bytes together with the header offsets is what
  64. # closes that gap.
  65. #
  66. # It is the first eleven bytes of the runtime, so it sits at ENT_SZ, not 0.
  67. HEAD = "8B F0 8B 54 04 8B 4C 06" # MOV SI,AX / MOV DX,[SI+4] / MOV CX,[SI+6]
  68. HDR_DS_WORD = 4 # header word holding the data base
  69. HDR_HEAP_WORD = 6 # header word holding the data end
  70. assert [int(HEAD.split()[4], 16), int(HEAD.split()[7], 16)] == \
  71. [HDR_DS_WORD, HDR_HEAP_WORD], \
  72. "initmem no longer reads the two header words the checks verify"
  73. # ---- what the file says, measured ---------------------------------------
  74. def find_header(d):
  75. """The image offset of the program header in `d`, or None.
  76. The header is eight words, and the layout says what they are (offsets here
  77. are BYTES into the header, which is why HDR_DS_WORD is 4 and not 2 - the
  78. words are two bytes each):
  79. +0 1 hdrFlag, always 1
  80. +2 code end + bias hdrCS
  81. +4 data base + bias hdrDS, where data base = hdrOff + 1000h
  82. +6 data end + bias hdrHeap, which is hdrDS + dataBytes
  83. hdrDS ties the header to its OWN offset: the data base is header offset +
  84. 1000h, and hdrDS is that with the load bias added, so the offset is
  85. recoverable from the file with no remembered runtime size - which is the
  86. whole point. A candidate is accepted only if hdrFlag is 1, hdrDS satisfies
  87. that equation, hdrHeap is above hdrDS (a heap below its own base is not a
  88. layout, it is a coincidence), and hdrCS leaves room for the header itself.
  89. initmem is the only code in the image that reads the header, so its bytes
  90. are pinned at ENT_SZ: a candidate that also has them there is not a
  91. coincidence in the code stream.
  92. A header that cannot be found is returned as None rather than as the
  93. nearest guess, because a caller that guesses reports confidently about the
  94. wrong bytes - which is what the restated runtime size used to do.
  95. """
  96. head = bytes(int(x, 16) for x in HEAD.split())
  97. if d[ENT_SZ:ENT_SZ + len(head)] != head:
  98. return None # no runtime: nothing to measure
  99. for off in range(ENT_SZ, len(d) - HDR_SZ + 1):
  100. w = (lambda b: int.from_bytes(d[off + b:off + b + 2], "little"))
  101. if w(0) != 1: # hdrFlag
  102. continue
  103. if w(HDR_DS_WORD) != off + 0x1000 + LOAD_BIAS: # hdrDS
  104. continue
  105. if w(HDR_HEAP_WORD) <= w(HDR_DS_WORD): # hdrHeap
  106. continue
  107. if w(2) - LOAD_BIAS < off + HDR_SZ: # hdrCS
  108. continue
  109. return off
  110. return None
  111. def entry_target(d):
  112. """The image offset the entry JMP at file offset 0 lands on, or None.
  113. A .COM is entered at its first byte, so byte 0 is `E9 rel16' and the first
  114. instruction of the program is at ENT_SZ + rel16 (Compiler.Inittur). This
  115. is where EXECUTION starts, which is a different statement from where the
  116. runtime ends: the program header sits between the two, and a check that
  117. measures only one of them cannot tell that it has started its sweep in the
  118. wrong place. A check that measures both is asked to make them agree.
  119. """
  120. if len(d) < ENT_SZ or d[0] != 0xE9:
  121. return None
  122. return ENT_SZ + int.from_bytes(d[1:ENT_SZ], "little", signed=True)