# bootcom.s -- load a DOS .COM from the boot floppy and run it, with INT 21h # wired to the serial port instead of a screen. # # This is the piece that lets a .COM produced by this compiler actually run. # It is deliberately not DOS: it is a 512-byte boot sector that does the four # things a DOS .COM loader does, and sends everything the program prints out # the serial port so a test harness can read it. # # 1. install an INT 21h vector pointing at a handler below # 2. INT 13h AH=02h: read the .COM off drive A: to 0000:0100 # 3. SS:SP = 0000:FFFE, the segment top -- where DOS puts a .COM's stack # 4. JMP 0000:0100 # # The INT 21h handler implements the four functions this runtime actually # calls, and nothing else: # # AH=02h display character in AL # AH=09h display the $-terminated string at DS:DX # AH=08h read a character without echo; 1Ah at end of input # AH=0Bh input status: AL=FFh if a character waits, 0 if not # AH=4Ch terminate # # Those five are the complete set. See Runtime.mod: every Int21 in it is one # of them (the "MovAh (0)" in EmitRdInt is not an INT 21h call, it is the # MOV AH,0 that loads a digit into AL before an ADD -- an easy thing to # misread as a fourth function). # # Two properties are load-bearing: # # * The handler NEVER writes to the serial port as a marker. The program's # output is arbitrary bytes, so any sentinel could collide with real # output. Termination travels out of band instead, through the # isa-debug-exit device at port 0501h: the program exits with code 0 and # qemu exits (value<<1)|1 = 1. A boot failure exits with value 7Fh, i.e. # qemu exit code 255, which is unambiguous. Every byte on the serial port # is therefore program output, with nothing to strip. # # * The program is loaded at 0000:0100, so segment 0 holds the IVT at # 0000:0000-00FF and the program from 0100 up. The input descriptor at # 7000h sits above the largest image that may reach 6F00h and below the # boot sector at 7C00h, and is simply part of the disk image the harness # writes, so the addresses here are assembly constants. # # The handler owns no scratch inside the loaded region. It used to keep # the AH=09h cursor in memory at 0702h -- image offset 602h, inside every # fixture's image, because a .COM is padded out to the data base at # rtSz+1000h. That was a real limit, and it was hit: string concatenation # emits enough code that a program's code can reach 602h, and the AH=09h # that prints a writeln's CRLF then overwrote that program's own `call # progend'. The cursor lives in SI now, not in memory at all -- lodsb # advances SI and ser_put leaves SI alone -- so the handler cannot scribble # on the program, and the code window is the linker's 4 KiB again rather # than this handler's 602h. See the layout block at the end of this file. # # Built and driven by tests/run_com_exec.py. tests/exec/rtdrv.s assembles the # other half of this machinery -- the driver that calls runtime entries directly # -- and reuses this file rather than growing a second boot path. .code16 .text .globl _start _start: cli xorw %ax, %ax movw %ax, %ds movw %ax, %es movw %ax, %ss movw $0xfffe, %sp sti # IVT entry 21h lives at 0000:0084 (21h * 4): offset word then segment. # `handler' is a section-relative offset because the section starts at 0; # the segment this sector was loaded at is 7C00h. movw $handler, %ax addw $0x7c00, %ax movw %ax, 0x84 movw $0, %ax movw %ax, 0x86 # INT 13h AH=00h: reset the drive controller. A floppy that has just # been attached needs this before a read will succeed. movb $0x00, %ah int $0x13 # Retry the read: qemu's floppy is a file image and a read can fail while # it settles. Three attempts, then give up loudly. # # The read lands at SCRATCH, not at 0100h, and is copied down afterwards. # That is not ceremony. 0400h-04FFh is the BIOS data area, and SeaBIOS # keeps live state in it; a transfer whose destination covers that window # destroys it, and SeaBIOS writes part of it back *after* the DMA, so ten # bytes of BIOS data end up on top of the image once the transfer is over. # # The damage is the worst kind. It is the right length, in the right # place, and nothing reports an error -- the read sets CF=0, exactly as # the jnc below expects. It was found by a 19-byte probe that read 0440h # as its first instruction after the jump and got the BIOS's own bytes # back, and then confirmed by dumping the whole loaded image: one 10-byte # run wrong inside an otherwise byte-perfect 512-byte sector, which no # partial-read or sector-count bug can produce. # # 1000:0000 (linear 10000h) is clear of the IVT (0-3FFh), the BDA # (400-4FFh), SeaBIOS's stack at 700h and the option-ROM window at C000h. # REP MOVSW is executed code, so the copy is the LAST thing that touches # 0100h-7100h, and it stops below the boot sector at 7C00h. The copy is # what the program then runs out of, unchanged. # The image is read one track at a time. A single INT 13h call cannot # cross a track reliably, and a per-sector loop has to keep CHS and the # destination across a BIOS call that may clobber any register. Five # fixed calls are simpler: track 0 starts at sector 2 (sector 1 is this # boot sector), every later track starts at sector 1, and the last read # takes the one sector that is left of the 72. .set READS, 5 # Read the image one sector at a time. The loop state lives in registers, # and every register is pushed around INT 13h: the BIOS is allowed to # clobber all of them, and POP does not touch the carry, so the error flag # survives the restore. One sector per call also cannot cross a track. movw $SECTORS, %si # sectors still to read movw $SCRATCH_SEG, %ax movw %ax, %es xorw %bx, %bx # destination offset in ES xorw %cx, %cx # CH=cylinder 0, CL=sector 0 xorw %dx, %dx # DH=head 0, DL=drive A movb $2, %cl # sector 2 -- the .COM, 1-based .Lread: movw $3, %bp .Lretry: movb $0x02, %ah movb $1, %al pushw %es pushw %bp pushw %si pushw %bx pushw %cx pushw %dx int $0x13 popw %dx popw %cx popw %bx popw %si popw %bp popw %es jnc .Lreadok decw %bp jnz .Lretry jmp .Lbootfail .Lreadok: addw $512, %bx incb %cl cmpb $19, %cl jb .Lreadnext movb $1, %cl xorb $1, %dh jnz .Lreadnext incb %ch .Lreadnext: decw %si jnz .Lread jmp .Lcopy .Lbootfail: movb $0x7f, %al movw $0x0501, %dx # isa-debug-exit outb %al, %dx cli hlt .Lcopy: movw $SCRATCH_SEG, %ax movw %ax, %ds xorw %si, %si # source 1000:0000 xorw %ax, %ax movw %ax, %es movw $0x0100, %di # destination 0000:0100 movw $(COPY_BYTES / 2), %cx # bytes / 2 cld rep movsw xorw %ax, %ax movw %ax, %ds # the .COM expects DS=0 .byte 0xEA, 0x00, 0x01, 0x00, 0x00 # jmp 0000:0100 # ---------------------------------------------------------------- INT 21h # Called with the program's registers. DS is the caller's data segment # (0000h here) and is preserved, so AH=09h can reach DS:DX the way DOS does. # Every path ends at .Ldone, which restores everything and IRETs -- except # AH=4Ch, which does not return at all. handler: pushw %ax pushw %bx pushw %cx pushw %dx pushw %si pushw %di pushw %ds pushw %es # Direction flag, before anything reads it. Both lodsb's below walk # FORWARD, and DF belongs to the interrupted program, not to the # handler: a handler that assumes the caller's DF is clear is correct # only for as long as no caller ever sets it. It has never fired - # nothing in Runtime.mod uses a string instruction, so DF is whatever # the BIOS left, and that is 0 - which is exactly why it is worth # writing down. DF is not restored, because nothing downstream reads # it and restoring it would mean saving EFLAGS around the whole handler. cld cmpb $0x02, %ah je .Lh02 cmpb $0x09, %ah je .Lh09 cmpb $0x08, %ah je .Lh08 cmpb $0x0b, %ah je .Lh0b cmpb $0x3d, %ah je .Lh3d cmpb $0x4c, %ah je .Lh4c stc # unknown function jmp .Ldone .Lh02: # display character in AL call ser_put clc jmp .Ldone .Lh09: # display the $-terminated string at DS:DX movw %dx, %si # SI is the cursor. lodsb walks it forward, .Lh09next: # and ser_put leaves SI alone -- it clobbers lodsb # only DX, which is why the pointer cannot cmpb $0x24, %al # simply stay in DX across the call. je .Lh09done call ser_put jmp .Lh09next .Lh09done: clc jmp .Ldone .Lh08: # read a character, no echo, 1Ah at EOF # NOTE: this is the one function whose RESULT is in AL, so it must return # through .Ldone8 and not .Ldone - see there. movw INLEN, %ax # inlen movw INCUR, %bx # input cursor # EOF is when the cursor has REACHED the length, i.e. INCUR >= INLEN. # `cmpw %bx, %ax / jbe' tests AX <= BX, that is INLEN <= INCUR, which is # true on the FIRST character: it returned 1Ah immediately, so readln saw # an empty input and then looped for the line terminator that never came. # t29_readln hung with no output at all. (This was a bug in the harness, # not in the compiler - but a harness that feeds the program nothing can # never tell you whether the program handles input, so it is a bug that # hides bugs.) cmpw %ax, %bx # INCUR vs INLEN jae .Lh08eof # INCUR is an index INTO the buffer, not an address: the bytes live at # INBUF, and SI = INCUR alone reads the interrupt vector table at 0000:0000 # - so AH=08h returned IVT[0] (a low timer vector byte) as the first # character of input. INCUR is 0 on the first call, which is the one # address in segment 0 that is guaranteed to be wrong. movw %bx, %si addw $INBUF, %si lodsb incw INCUR clc jmp .Ldone8 # NOT .Ldone: AL is the result here .Lh08eof: movb $0x1a, %al clc jmp .Ldone8 .Lh0b: # input status: AL=FFh if a character waits movw INLEN, %ax movw INCUR, %bx cmpw %ax, %bx # INCUR vs INLEN: >= is end of input jae .Lh0b_none movb $0xff, %al clc jmp .Ldone8 # AL is the result, as with AH=08h .Lh0b_none: xorw %ax, %ax # AL = 0: nothing waiting clc jmp .Ldone8 .Lh3d: # open file: no filesystem in this harness # The runtime uses real DOS AH=3Dh. A boot floppy has no DOS and no # filesystem driver, so answer the one question the file-operation # fixture asks - "the file is absent" - the way DOS does: CF=1, # AX=2 (File not found). The result must survive the epilogue, and # CF must be visible to the guest, so this uses .LdoneFile. movw $2, %ax stc jmp .LdoneFile .Lh4c: # terminate: exit with AL as the code movw $0x0501, %dx # isa-debug-exit outb %al, %dx cli hlt jmp .Lh4c .Ldone: popw %es popw %ds popw %di popw %si popw %dx popw %cx popw %bx popw %ax iret # The same, but for the one function that RETURNS something in AL. DOS # AH=08h hands the character back in AL, so restoring AX on the way out # throws the answer away and the caller reads whatever AX held on entry. # # This was silent in a way that is worth recording: the shim's read path was # structurally correct - it found the buffer, advanced the cursor, cleared # the carry - and the character was plainly in AL, one instruction before the # return. The discard happened in the epilogue, which every OTHER function # needs. So t29_readln did not see a wrong byte; it saw the *uninitialised* # AX the runtime had at the call, which is the first byte of a pointer it was # about to overwrite with the parsed value. readln compared that against 0Dh, # 0Ah and 1Ah, rejected it, and looped forever - a hang with no output, from # a read that demonstrably worked. # # AX is therefore popped only on the paths where it is not a result, and # AH=02h/09h (which also leave AL alone in DOS) keep using .Ldone. .LdoneFile: # AX is the result and CF is the DOS error flag. POP does not change # CF, so restore the saved registers first, then copy CF into the saved # FLAGS word before dropping the saved AX and IRETing. The 16-bit ADD # used to discard AX would clobber CF, so set FLAGS before it. popw %es popw %ds popw %di popw %si popw %dx popw %cx popw %bx # 16-bit addressing has no [SP] form. Borrow BX as the frame pointer # for two instructions; POP does not affect CF, so the branch still sees # the handler's result. After the push, SP points at the saved BX, so # saved AX is +2 and saved FLAGS are +8. pushw %bx movw %sp, %bx jnc .Ldf_ok orw $1, 8(%bx) # saved FLAGS: set CF jmp .Ldf_drop .Ldf_ok: andw $0xfffe, 8(%bx) # saved FLAGS: clear CF .Ldf_drop: popw %bx addw $2, %sp # drop the saved AX, keep the result iret .Ldone8: popw %es popw %ds popw %di popw %si popw %dx popw %cx popw %bx addw $2, %sp # drop the saved AX, keep AL iret # ser_put: send AL to the serial port, leaving AL and DX alone. # No line-status polling: qemu's 16550 always accepts a byte, and a poll # that never goes ready would hang the harness rather than fail it. ser_put: pushw %ax movw $0x03f8, %dx outb %al, %dx popw %ax ret # --------------------------------------------------------------- layout # The input descriptor lives at 7000h. The image may reach 6F00h, the copy # ends at 7100h, and the boot sector at 7C00h is never touched; the read covers # enough sectors to bring the descriptor down with the image. So it is simply # part of the disk image the harness writes, not something it has to # patch into this boot sector afterwards -- which means the addresses here are # assembly constants and the harness only has to know where they land in the # file. See run_com_exec.py, FLAT_OFF. # # SCRATCH is where the sector read parks the bytes before the copy moves them # to 0100h. Nothing may ever be placed there: the region is not reserved by # this file, it is merely unused, and a future change that put a table at # 10000h would be overwritten by the read and would not notice. .set SECTORS, 72 # 36,864 bytes read; only COPY_BYTES is moved down .set COPY_BYTES, 0x7000 # up to the input, below the boot sector at 7C00h .set SCRATCH_SEG, 0x1000 # read buffer at 1000:0000, outside the 64K .set INLEN, 0x7000 # word: number of input bytes .set INCUR, 0x7002 # word: cursor, starts at INBUF .set INBUF, 0x7004 # the bytes themselves .set INMAX, 0x00fc # 7100h - 7004h, the most that fits .org 510 .byte 0x55, 0xAA