# rtdrv.s -- call runtime entries, with known arguments, and report what # happened. Assembled by tests/rt_exec.py into a complete .COM image. # # This replaces the Unicorn harness. The expectations did not change; only the # machine did. Everything here exists because qemu, unlike an emulator with a # Python API, gives you one thing: a serial port. So a check has to be # expressed as a program that reports on itself. # # The report is a framed record on the serial port: # # 01 02 03 # 04 # # The four control bytes occur in no expected output, and rt_exec.py REFUSES to # run a case whose expectation contains one -- so a marker can never be mistaken # for output, and a driver that emitted nothing is a parse failure rather than # a silent pass. # # Why one qemu boot per check instead of one boot for all of them: the runtime # keeps state that outlives a call -- the one-character pushback slot, and the # input cursor the boot sector's INT 21h shim owns. One boot for everything # would make each check depend on the ones before it, so a check could fail # because of its neighbour and a green run would prove less than it appears to. # A boot costs about a tenth of a second and 35 of them cost under four, which # buys every check a machine that has provably never executed anything. (The # input descriptor is still re-armed on every boot, because it lives in the # image and the image is the same bytes each time.) # # Two things about the 8086 shape the code below, and both were found by # running it rather than by reading it: # # * Every entry preserves BP and SP and NO other register. So the cursor # into the case record is BP: a loop counter in CX or DI would be destroyed # by the first call. The entry's own BP frame is pushed and restored, so # our BP comes back. # # * `movb n(%bp), %cl' leaves the HIGH byte of CX exactly as it was. Reading # the input length that way and then writing CX to INLEN handed the boot # sector's INT 21h shim a 16-bit length with an undefined top half. The # 8086 has no memory-to-memory move and no zero-extending byte load, so the # high half has to be cleared by hand, every time. # # * `dec' sets the sign flag from the RESULT, so `decw cnt / js done' runs the # body for cnt = 1 (1 -> 0, SF clear) and stops on the wrap (0 -> FFFF). # It reads like an off-by-one and is not; the comment at the loop says so # again because the next person will wonder. # # The image is assembled in ONE piece, here, so every address is a label or an # .org and nothing has to be agreed with Python in two places. What Python does # have to agree about -- the runtime's base, and the input descriptor -- is # checked rather than trusted: it assembles this file and then asserts that the # blob it got from RtProbe really is at RT_BASE in the bytes that came out. .set LOAD_BIAS, 0x0100 # a .COM lives at CS:0100 (that is the PSP) .set SOH, 0x01 .set STX, 0x02 .set ETX, 0x03 .set EOT, 0x04 # Restated from tests/exec/bootcom.s's layout block ON PURPOSE. A harness that # asks the boot sector where its input buffer is proves only that the two agree # with each other; two independent statements of the same layout have to be # written down, and a disagreement is a loud failure rather than a program that # is fed nothing. (Same reasoning, and the same lesson, as the hard-coded # RT_SZ that run_com_tests.sh used to carry: the descriptor has to be where the # shim looks, and the only way to be sure is to have written both down and # checked.) .set INLEN, 0x2000 # word: how many input bytes follow .set INCUR, 0x2002 # word: index into INBUF, NOT an address .set INBUF, 0x2004 # the bytes themselves # The case record, as rt_exec.py writes it. These offsets are the contract; the # two places that read them (this file, and case_bytes() over there) are # separate statements of it on purpose, and the cases only pass if both are # right. SLOT0 is the one that was wrong the first time round. .set OFF_DUMPLEN, 0 # word how many bytes to report .set OFF_DUMPADR, 2 # word image offset to report them from .set OFF_INLEN, 4 # byte bytes of input to hand the shim .set OFF_INBUF, 5 # byte inbuf[8] .set OFF_CASE, 13 # byte the index printed in the header .set OFF_NCALLS, 14 # word how many call slots follow .set SLOT0, 16 # call slots, 6 bytes each: # +0 word entry, +2 word arg, # +4 byte flags, +5 pad .set SLOT_SZ, 6 .set CASE_SZ, 40 # 16 + 4 slots # The image layout. RT_BASE is where the runtime blob goes; everything else is # above 0x0800 so that a runtime which grew by a few hundred bytes would not # silently run into the test scaffolding. rt_exec.py asserts # RT_BASE + blob size <= HDR, so the bound is a checked one. .set RT_BASE, 0x0200 .set HDR, 0x0800 .set DATA, 0x0810 .set DLEN, 32 # bytes of data area for initmem to clear .set STORE, 0x0840 # where a read entry's result is stored .set CASE, 0x0860 # the case record .set D_DUMPLEN, 0x0890 # the driver's own three words .set D_DUMPADR, 0x0892 .set D_CNT, 0x0894 .code16 .text .globl _start # The entry JMP -- the same three bytes every image this compiler writes opens # with. bootcom.s jumps to 0000:0100, which is this instruction. It is # written as raw bytes rather than `jmp drive' because gas would shorten that to # a two-byte EB 00 short jump, and the byte it dropped would then be executed as # the first instruction of the driver. _start: .byte 0xE9 # JMP rel16 .word drive - _start - 3 # --------------------------------------------------------------- the driver drive: cld # 1. Poison the two places a check is allowed to look. STORE gets EEEH so # a read entry that stores nothing is visible as such, and the data # area gets AAH so initmem has something to clear. The storage below # is zeroed; the poison is what turns a zero into a RESULT. movw $STORE + LOAD_BIAS, %si movw $0xEEEE, %ax movw %ax, (%si) movw $DATA + LOAD_BIAS, %si movw $DLEN, %cx movb $0xAA, %al .Lpoison: movb %al, (%si) incw %si loop .Lpoison # BP is the case-record cursor, and it is the only register that survives a # call into the runtime, so everything the loop needs is read through it # or parked in the driver's own words first. movw $CASE + LOAD_BIAS, %bp # 2. Open the record: SOH, the case index as two hex digits, STX. movb $SOH, %al call putc movb OFF_CASE(%bp), %al call hexb movb $STX, %al call putc # 3. Arm the input. INCUR is an INDEX into INBUF, and zeroing it is what # makes this boot's input start at its first byte. CX is cleared # first: see the note at the top about the high byte of CX. xorw %cx, %cx movb OFF_INLEN(%bp), %cl movw %cx, INLEN movw $0, INCUR movw $CASE + OFF_INBUF + LOAD_BIAS, %si movw $INBUF, %di xorw %ax, %ax repe cmpsb # 4. The calls. dumplen and the report address are needed after the loop, # by which time BP has moved, so they are parked now; so is the count, # because CX is gone by the second iteration. movw OFF_DUMPLEN(%bp), %ax movw %ax, D_DUMPLEN + LOAD_BIAS movw OFF_DUMPADR(%bp), %ax movw %ax, D_DUMPADR + LOAD_BIAS movw OFF_NCALLS(%bp), %ax movw %ax, D_CNT + LOAD_BIAS addw $SLOT0, %bp # BP now points AT the first call slot .Lcall: decw D_CNT + LOAD_BIAS # 1 -> 0 has SF clear, so the body runs once; jns .Lgo # 0 -> FFFF is what ends the loop jmp .Lcalldone .Lgo: call doslot addw $SLOT_SZ, %bp jmp .Lcall .Lcalldone: # 5. Close the output half and report the memory. A store that never # happened is the EEEH left in step 1, which is why it is there. movb $ETX, %al call putc movw D_DUMPLEN + LOAD_BIAS, %cx jcxz .Lnodump movw D_DUMPADR + LOAD_BIAS, %si addw $LOAD_BIAS, %si .Ldump: lodsb call hexb loop .Ldump .Lnodump: movb $EOT, %al call putc # 6. Exit. Reaching here IS the assertion that every entry returned: an # entry that hung produces no EOT, and the harness says so. movw $0x4C00, %ax # INT 21h AH=4Ch, AL=0 int $0x21 # doslot: call the entry described by the 6 bytes at BP, with the same # conventions Compiler.IoCall uses -- one 16-bit argument on the stack, popped # by the caller -- except for initmem, which takes the header in AX. # # The flags are re-read after the call because the entry has just used AX. doslot: movb 4(%bp), %al testb $1, %al jz .Lnostack pushw 2(%bp) .Lnostack: testb $2, %al jz .Lnoax movw 2(%bp), %ax .Lnoax: movw (%bp), %si # image-absolute, as RT_Entry reports it addw $LOAD_BIAS, %si # so the load bias is added here, not baked in testb $4, %al jnz .Linl call *%si movb 4(%bp), %al testb $1, %al jz .Lnoclean addw $2, %sp # caller-cleaned .Lnoclean: ret # wrtinl's argument is NOT a stack word: the entry reads a length byte and that # many characters from its own return address. That is the caller's half of # the contract, so it is written literally here -- the length byte and the # characters must be the bytes immediately after the call, which is why this is # a second copy of the instruction rather than a jump. The compiler's own # placement of the same three things is what the string-literal fixtures in # run_com_exec.py check; duplicating a variable one here would be a second # thing to keep right, and a check that cannot fail is not a check. .Linl: call *%si .byte 5 .byte 'h', 'e', 'l', 'l', 'o' ret # putc: AL to the serial port. No status polling -- qemu's 16550 always accepts, # and a poll that never went ready would hang the harness instead of failing it. putc: pushw %ax movw $0x03f8, %dx outb %al, %dx popw %ax ret # hexb: AL as two uppercase hex digits, high nibble first. # # The 8086 has no `SHR r/m16, imm8' and no shift-by-four, so the high nibble # comes from four one-bit shifts through CL. Both facts have to be written # down: gas assembles `shr $4, %ah' without complaint and rejects `shrw $1, %ah' # outright, so the only thing standing between this file and an image that # assembles cleanly and dies on a real 8086 is refusing the modern encodings. hexb: pushw %ax pushw %cx movb %al, %ah movb $1, %cl shrw %cl, %ax shrw %cl, %ax shrw %cl, %ax shrw %cl, %ax popw %cx call putdig popw %ax andb $0x0f, %al movb %al, %ah call putdig ret # putdig: the nibble in AH as one character, moved into AL so that setting AH for # INT 21h AH=02h cannot overwrite the digit it is about to print. putdig: movb %ah, %al cmpb $10, %al jb .Ldigit addb $0x37, %al # 0Ah + 37h = 'A' jmp .Lemit .Ldigit: addb $0x30, %al # '0' .Lemit: movb $2, %ah int $0x21 ret # --------------------------------------------------------------- the image # The runtime blob, from tests/RtProbe.mod built at RT_BASE. The gap is the # checked bound: rt_exec.py asserts that 0x0180..RT_BASE is still all zeros, so # a driver that outgrew its space fails the suite instead of overwriting the # library it is testing. .org RT_BASE .incbin "rtblob.bin" # The program header initmem is handed: +0 flag, +2 code, +4 data base, +6 data # end. Built HERE, from the same DATA and DLEN the poison loop uses, because # the compiler's own header layout is a separate claim with its own check and # this harness is not that check. The two fields initmem READS are memory # addresses, so they carry the bias; the caller passes AX the header's memory # address too, which is the one thing rt_exec.py gets right only by saying so # here and in case_bytes(). .org HDR HDR: .word 1 # hdrFlag .word 0 # hdrCS, unused by initmem .word DATA + LOAD_BIAS # hdrDS = data base .word DATA + DLEN + LOAD_BIAS # hdrHeap = data end .org DATA .zero DLEN # poisoned to AAH by the driver .org STORE .zero 2 # poisoned to EEEH by the driver .org CASE .incbin "rtcase.bin" .org D_DUMPLEN .zero 6 # D_DUMPLEN, D_DUMPADR, D_CNT