#!/bin/bash # Build and run the .COM linker harness (tests/ComTest.mod), then verify every # .COM it produced with an INDEPENDENT checker. # # The independent pass matters: ComTest computes the expectations from the same # Compiler state it is testing, so a bug in the compiler would be invisible to # it. The Python pass re-derives what the file must contain - the runtime's # first bytes, a zero gap, a size that covers the data area - from the # constants only, and cross-checks. set -u D=/home/eric/Projets/Projets-Modula2/MyWork/TP3-comp/shell GM2=/home/eric/bin/Modula2/Gm2/bin/gm2 cd "$D" || exit 9 FLAGS="-fiso" # build logs go beside the tree (TP3-comp/tmp), never in /tmp mkdir -p ../tmp # --check-only DIR -- skip the build and the link, and run only the # independent Python checker over the .COM files already in DIR (plus a # hand-written raw.txt in the same shape the linker emits). # # This exists for non-vacuity, and it is the only reason to make it. The # checker normally runs over a scratch directory that the EXIT trap deletes, # so there is no way to hand it a DELIBERATELY WRONG .COM and see whether it # notices. A check that has only ever been shown the truth is not a check: # it could be reporting the truth about every file because it says nothing at # all. tests/nonvacuity.sh uses this to feed it a corrupted image and # require the named assertion to go red. CHECK_ONLY="" if [ "${1:-}" = "--check-only" ]; then CHECK_ONLY=${2:-} shift 2 fi echo "== support modules ==" if [ -n "$CHECK_ONLY" ]; then echo "check-only mode: not rebuilding, not linking" OUT="$CHECK_ONLY" [ -d "$OUT" ] || { echo "RESULT: FAIL (no such directory: $OUT)"; exit 1; } else [ -f Posix.o ] || cc -c Posix.c || exit 1 for m in TextBuf Compiler Runtime Linker; do $GM2 $FLAGS -c $m.mod >../tmp/cm_c_$m 2>&1 \ || { echo "COMPILE_FAIL $m"; grep -m5 "error:" ../tmp/cm_c_$m; exit 1; } done $GM2 $FLAGS -c tests/ComTest.mod >../tmp/cm_c_ComTest 2>&1 \ || { echo "COMPILE_FAIL ComTest"; grep -m5 "error:" ../tmp/cm_c_ComTest; exit 1; } rm -f tests/ct.lst comtest $GM2 $FLAGS -fgen-module-list=tests/ct.lst -o /dev/null \ tests/ComTest.mod TextBuf.o Posix.o Compiler.o Runtime.o Linker.o \ >../tmp/cm_p1 2>&1 p1=$? $GM2 $FLAGS -fuse-list=tests/ct.lst -o comtest \ tests/ComTest.mod TextBuf.o Posix.o Compiler.o Runtime.o Linker.o \ >../tmp/cm_p2 2>&1 p2=$? if [ $p2 -ne 0 ]; then echo "LINK_FAIL p1_rc=$p1 p2_rc=$p2" grep -E "error:|undefined" ../tmp/cm_p2 | head -10 exit 1 fi echo "comtest built (p1_rc=$p1, phase 1 rc=1 is the expected rollup)" # .COM files are written beside the harness, so run it in a scratch dir OUT=$(mktemp -d) || exit 9 # TP_COM_KEEP=1 leaves the scratch dir behind, for tests/nonvacuity.sh to # corrupt a copy of a real image and hand it back through --check-only. The # alternative - rebuilding the whole toolchain inside the non-vacuity script # to produce one throwaway byte - is slow for no benefit, and the point of # the case is the CHECKER's sensitivity, not the compiler's. if [ "${TP_COM_KEEP:-0}" = "1" ]; then echo "TP_COM_KEEP=1: images left in $OUT" else trap 'rm -rf "$OUT"' EXIT fi cd "$OUT" || exit 9 ls "$D"/tests/fixtures/*.pas | "$D"/comtest > "$OUT/raw.txt" 2>&1 sed 's/^.*fixtures\///' "$OUT/raw.txt" echo "----------------------------------------------------------------" nok=$(grep -c " OK com=" "$OUT/raw.txt") nerr=$(grep -c " ERROR " "$OUT/raw.txt") nbad=$(grep -cE "WRITE_COM_FAILED|CANNOT" "$OUT/raw.txt") echo "linked: $nok .COM files, $nerr fixtures rejected at compile time, $nbad harness failures" if [ "$nbad" -ne 0 ]; then echo "RESULT: FAIL (harness could not link every compiling fixture)" exit 1 fi fi # ---- independent verification of the bytes on disk ------------------------ python3 - "$OUT" "$D/tests" <<'PYEOF' import sys, os, re, glob out = sys.argv[1] # The layout of a linked image - ENT_SZ, HDR_SZ, the load bias, initmem's # first bytes, and the function that MEASURES where the header is in a given # file - comes from tests/comimage.py and is not written down here. This # checker carried its own copy of all of it and tests/comtest.py carried a # second; a duplicated constant that has silently drifted is not an # independent check, it is a second source of truth that lies, and it lies in # the direction of looking like the compiler is broken. The runtime's SIZE # was such a constant (a literal 391 beside a comment saying it tracked # Runtime.RT_Size()), so the header was read out of the middle of the code and # every linked fixture "failed" on a header full of code bytes. # # The measurement is still independent of the compiler: find_header reads the # emitted file, not a Modula-2 variable, so the code under test cannot satisfy # it by agreeing with itself. tests/check_runtime.py pins the runtime's size # explicitly, which is where a deliberate size change should be noticed. # # The image starts with a three-byte JMP at offset 0 (Compiler.Inittur): a # .COM is entered at file offset 0, and until that jump existed this checker # ASSERTED that the runtime was at offset 0, which was precisely the bug - # every .COM began by executing initmem with whatever the loader left in AX. # A checker that pins a wrong invariant is worse than no checker, because it # makes the wrong thing look tested. RTSZ (the header's image offset), PROLOG # (RTSZ + HDR_SZ, where the entry jump must land) and DATAB (RTSZ + 1000h, the # compiler's data base) are all DERIVED PER FILE by find_header below. # # The load bias is the THIRD bias of the same family in this file, and the # subtlest: the entry jump (a jump that landed on the end of the code), the # RT_Entry offsets (CALLs that landed inside a neighbouring runtime entry) and # absolute addresses (which landed 0100h low, inside the runtime) all produce # a program that STARTS, RUNS and PRINTS something. Only running it finds the # last one; the byte checks below are all satisfied by an address that is # consistently 0100h wrong. See comimage.LOAD_BIAS and Runtime.LoadBias. sys.path.insert(0, sys.argv[2]) from comimage import (ENT_SZ, HDR_SZ, LOAD_BIAS, HEAD, HDR_DS_WORD, HDR_HEAP_WORD, find_header) raw = open(os.path.join(out, 'raw.txt')).read() rows = re.findall(r'(\S+\.pas)\s+OK\s+com=(\d+)\s+image=(\d+)\s+data=(\d+)\s+nonzeroInGap=(\d+)', raw) if not rows: print('RESULT: FAIL (no linked fixtures found in output)') sys.exit(1) bad = 0 last_rt = None for name, com, image, data, nzg in rows: com, image, data, nzg = int(com), int(image), int(data), int(nzg) # ComTest writes the .COM by BASENAME beside itself (it cannot graft a # directory onto a source path), so the checker must look for the bare # name, not the full source path the fixture was read from. path = os.path.join(out, os.path.basename(name)[:-4] + '.COM') errs = [] if not os.path.exists(path): errs.append('no .COM file') d = b'' else: d = open(path, 'rb').read() # Everything below is expressed in terms of where the header actually is, # measured from this file, rather than where a literal says it should be. hdrOff = find_header(d) if hdrOff is None: errs.append('no program header found: the layout this checker knows ' 'how to look for is not the one in the file') RTSZ, PROLOG, DATAB = ENT_SZ, ENT_SZ + HDR_SZ, ENT_SZ + 0x1000 else: RTSZ = hdrOff PROLOG = hdrOff + HDR_SZ DATAB = hdrOff + 0x1000 if RTSZ != last_rt: last_rt = RTSZ print(' measured runtime size: %d bytes (header at image offset ' '%d)' % (RTSZ - ENT_SZ, RTSZ)) # The entry jump. This is the one assertion in the whole project that can # see where execution STARTS, because it is the only one that cares. It # has caught THREE real bugs, all in the same three bytes, and all of them # invisible to every other check here: # # 1. no jump at all, so a .COM began by executing the runtime's initmem # with whatever the loader left in AX; # 2. a jump to `pc`, one byte past the last instruction, into the # zero-filled code/data gap, where the CPU slides through # `ADD [BX+SI],AL` until it faults; # 3. a jump to RTSZ, which is the program HEADER - sixteen bytes of DATA # that the CPU then decodes as instructions. This one is the reason # the target is pinned to PROLOG and not to RTSZ: whether it works # depends entirely on how those sixteen bytes happen to decode, so # writeln('hi') ran correctly by sliding through them while t07 hung # on a LOCK-prefixed ADD with a displacement crossing a page. The # correct target is the first instruction, and there is no reason for # a checker to accept a range. if len(d) >= ENT_SZ: if d[0] != 0xE9: errs.append('byte 0 is %02X, not the E9 of the entry jump' % d[0]) # The jump's displacement is measured from the end of the jump. want_rel = PROLOG - ENT_SZ got_rel = int.from_bytes(d[1:ENT_SZ], 'little') if got_rel != want_rel: errs.append('entry jump rel16=%d, want %d; it lands on image ' 'offset %d, want %d (the first instruction, %d bytes ' 'past the header - not the header at %d, and not the ' 'end of the code at %d)' % (got_rel, want_rel, ENT_SZ + got_rel, PROLOG, HDR_SZ, RTSZ, image)) # The runtime's own first bytes must follow the jump, and the jump must be # the only thing before them. if d[ENT_SZ:ENT_SZ + len(HEAD.split())].hex(' ').upper() != HEAD: errs.append('runtime not at offset %d (bytes there %s, want %s)' % (ENT_SZ, d[ENT_SZ:ENT_SZ + len(HEAD.split())].hex(' ').upper(), HEAD)) if len(d) != com: errs.append('file is %d bytes, harness said %d' % (len(d), com)) if DATAB + data != len(d): errs.append('size %d != dataBase+data %d' % (len(d), DATAB + data)) # the gap between the image and the data area must be entirely zero gap = d[image:DATAB] if any(gap): errs.append('%d non-zero bytes in the code/data gap' % sum(1 for b in gap if b)) if nzg != 0: errs.append('harness itself reported %d non-zero gap bytes' % nzg) # the program must start exactly where the runtime ends if image < RTSZ: errs.append('image %d shorter than the runtime %d' % (image, RTSZ)) # the program header sits at offset rtSz, and its words must describe the # image that is actually in the file if len(d) > RTSZ + 8: hdr = d[RTSZ:RTSZ+16] flag = int.from_bytes(hdr[0:2], 'little') cs = int.from_bytes(hdr[2:4], 'little') ds = int.from_bytes(hdr[HDR_DS_WORD:HDR_DS_WORD+2], 'little') heap = int.from_bytes(hdr[HDR_HEAP_WORD:HDR_HEAP_WORD+2], 'little') if flag != 1: errs.append('hdrFlag=%d' % flag) # hdrCS is the end of the code, as a SEGMENT offset like every other # offset in the header, so the load bias comes off before comparing it # with the harness's `image` (= rtSz + code, already an image offset). # Adding RTSZ here would count the runtime twice. if cs - LOAD_BIAS != image: errs.append('hdrCS=%d, want %d (end of image, as a segment ' 'offset)' % (cs, image + LOAD_BIAS)) if ds != DATAB + LOAD_BIAS: errs.append('hdrDS=%d, want %d (= data base %d + load bias %d)' % (ds, DATAB + LOAD_BIAS, DATAB, LOAD_BIAS)) if heap != DATAB + data + LOAD_BIAS: errs.append('hdrHeap=%d, want %d (= data base %d + %d + load bias %d)' % (heap, DATAB + data + LOAD_BIAS, DATAB, data, LOAD_BIAS)) # initmem must read hdrDS and hdrHeap, not some other pair of header # words. (It read +8, hdrMax, which the compiler patches to 0, so # the range it cleared was empty and every global kept whatever the # loader left in it.) if [d[ENT_SZ + 4], d[ENT_SZ + 7]] != [HDR_DS_WORD, HDR_HEAP_WORD]: errs.append('initmem reads header words +%d/+%d, but the data ' 'base and data end are at +%d/+%d' % (d[ENT_SZ + 4], d[ENT_SZ + 7], HDR_DS_WORD, HDR_HEAP_WORD)) if errs: bad += 1 print(' %-24s FAIL %s' % (os.path.basename(name)[:-4], '; '.join(errs))) else: print(' %-24s PASS %d bytes' % (os.path.basename(name)[:-4], com)) print('----------------------------------------------------------------') print('independent .COM check: %d checked, %d failed' % (len(rows), bad)) sys.exit(1 if bad else 0) PYEOF rc=$? [ "$rc" -eq 0 ] || exit 1 echo "RESULT: ALL PASS"