comimage.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  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. DATA_IMAGE = 0x8000 # image offset of the globals; memory 8100h
  58. # initmem's prologue, which is the runtime's only reader of the program header.
  59. # The displacements +4 and +6 below are the whole point of this constant: the
  60. # header checks in every checker read hdrDS at +4 and hdrHeap at +6, and
  61. # initmem has to read the SAME two words or it clears the wrong range. It read
  62. # +8 (hdrMax, which the compiler patches to 0), so it zeroed nothing at all and
  63. # nothing here noticed - the emitted loop was perfectly well formed, it just
  64. # never ran. Asserting these bytes together with the header offsets is what
  65. # closes that gap.
  66. #
  67. # It is the first eleven bytes of the runtime, so it sits at ENT_SZ, not 0.
  68. HEAD = "8B F0 8B 54 04 8B 4C 06" # MOV SI,AX / MOV DX,[SI+4] / MOV CX,[SI+6]
  69. HDR_DS_WORD = 4 # header word holding the data base
  70. HDR_HEAP_WORD = 6 # header word holding the data end
  71. assert [int(HEAD.split()[4], 16), int(HEAD.split()[7], 16)] == \
  72. [HDR_DS_WORD, HDR_HEAP_WORD], \
  73. "initmem no longer reads the two header words the checks verify"
  74. # ---- what the file says, measured ---------------------------------------
  75. def find_header(d):
  76. """The image offset of the program header in `d`, or None.
  77. The header is eight words, and the layout says what they are (offsets here
  78. are BYTES into the header, which is why HDR_DS_WORD is 4 and not 2 - the
  79. words are two bytes each):
  80. +0 1 hdrFlag, always 1
  81. +2 code end + bias hdrCS
  82. +4 data base + bias hdrDS = 8000h + bias, a FIXED high area
  83. +6 data end + bias hdrHeap, which is hdrDS + dataBytes
  84. The .COM is code-only: hdrDS is above the file and initmem zeroes it at
  85. startup, exactly as the original's separate DS segment is zeroed. The
  86. header offset comes from the entry jump (the jump targets hdrOff+HDR_SZ);
  87. hdrCS - bias must equal the file length.
  88. initmem is the only code in the image that reads the header, so its bytes
  89. are pinned at ENT_SZ: a candidate that also has them there is not a
  90. coincidence in the code stream.
  91. A header that cannot be found is returned as None rather than as the
  92. nearest guess, because a caller that guesses reports confidently about the
  93. wrong bytes - which is what the restated runtime size used to do.
  94. """
  95. head = bytes(int(x, 16) for x in HEAD.split())
  96. if len(d) < ENT_SZ or d[ENT_SZ:ENT_SZ + len(head)] != head:
  97. return None # no runtime: nothing to measure
  98. if d[0] != 0xE9:
  99. return None
  100. # The program header sits immediately before the first instruction, and the
  101. # entry jump names that instruction. The data base is now a FIXED high
  102. # area (DataStart = 8000h image, 8100h memory), not header+1000h: the .COM
  103. # is code-only, as the original's separate DS segment made it.
  104. target = ENT_SZ + int.from_bytes(d[1:ENT_SZ], "little", signed=True)
  105. off = target - HDR_SZ
  106. if off < ENT_SZ or off + HDR_SZ > len(d):
  107. return None
  108. w = (lambda b: int.from_bytes(d[off + b:off + b + 2], "little"))
  109. if w(0) != 1: # hdrFlag
  110. return None
  111. if w(HDR_DS_WORD) != DATA_IMAGE + LOAD_BIAS: # hdrDS
  112. return None
  113. if w(HDR_HEAP_WORD) <= w(HDR_DS_WORD): # hdrHeap
  114. return None
  115. if w(2) - LOAD_BIAS != len(d): # hdrCS = code end
  116. return None
  117. return off
  118. def entry_target(d):
  119. """The image offset the entry JMP at file offset 0 lands on, or None.
  120. A .COM is entered at its first byte, so byte 0 is `E9 rel16' and the first
  121. instruction of the program is at ENT_SZ + rel16 (Compiler.Inittur). This
  122. is where EXECUTION starts, which is a different statement from where the
  123. runtime ends: the program header sits between the two, and a check that
  124. measures only one of them cannot tell that it has started its sweep in the
  125. wrong place. A check that measures both is asked to make them agree.
  126. """
  127. if len(d) < ENT_SZ or d[0] != 0xE9:
  128. return None
  129. return ENT_SZ + int.from_bytes(d[1:ENT_SZ], "little", signed=True)