From dcb331c15198656db7a1afde1fef0f7305b52672 Mon Sep 17 00:00:00 2001 From: Anachronaut Date: Thu, 20 Aug 2026 22:13:15 -0400 Subject: [PATCH] SplitBit assembles SplitBit: M1, a single file with no includes Programs/CosmOS/Assembler/ is an assembler written in SplitBit assembly. It runs under CosmOS, reads source off a SplitBit disk, and writes a binary back to it with no host involved anywhere: > run Asm.sbx hello.asm wrote hello.bin: program 17, data 14, labels 2 THE ACCEPTANCE TEST IS THE BYTES. Tests/native.sh assembles Programs/hello.asm both ways and compares the two files byte for byte, then runs the one the machine built. "It ran" and "the sizes look right" both pass for a binary with a label one byte out, which is a program that jumps into the middle of an instruction - so the only honest test is the one SplitDisk and sbfs.asm already work under: two implementations of one written specification, each checking the other. The files are identical and the result prints Hello, World! in 70 cycles. hello.asm is the target because it is the oldest program in the repository. The first thing this machine ever ran is now the first thing it assembles for itself. TWO PASSES OVER STREAMED SOURCE. The C assembler reads every token of every file into one array; that cannot port, because cosmos.asm alone is 56,047 bytes against 64K of Data Memory. The native one streams through a 256 byte window, twice, and keeps only the label table between the passes. Two passes suffice because every length is known without resolving anything - an instruction's from its shape, a value's is one, a string's is its characters and a zero - so the first pass fixes every address and the second never needs a fixup list. A forward reference stops being a special case and becomes the reason there are two passes at all. The parts, each checked before anything was built on it: source.asm characters out of a file of any size, with a line number token.asm tokens out of characters, one character of lookahead classify.asm what a token is, in the C assembler's order, which IS the language: keyword, instruction, value, string, label labels.asm names packed in an arena, four bytes of index each numbers.asm sixteen bit arithmetic, since sbfs.asm's cannot be reached table.asm the instruction set, generated by the same script the monitor's copy is, and now BOTH are checked by docs.sh readTest.asm and tokenTest.asm check the reader and the tokenizer on their own, recorded as cosmosSource and cosmosTokens. A wrong classification does not produce a wrong byte somewhere obvious; it produces a right looking program of the wrong length, so it is worth catching where it happens. WHAT IT REFUSES: #Include, #Base, #Align, #Reserve and #Vectors are refused by name rather than ignored. Skipping a directive would produce a file that looked right and was the wrong length, which is the worst thing an assembler can do. Two traps worth recording, both already known to this project and both hit again: CALL restores A, B and DP0-DP2, so three routines returning an answer in A had it undone by their own return; and numStep works on DP0, so three sites that set DP1 left a pointer that never advanced. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW --- Programs/CosmOS/Assembler/Asm.asm | 879 ++++++++++++++++++++++++ Programs/CosmOS/Assembler/classify.asm | 792 +++++++++++++++++++++ Programs/CosmOS/Assembler/labels.asm | 337 +++++++++ Programs/CosmOS/Assembler/numbers.asm | 101 +++ Programs/CosmOS/Assembler/readTest.asm | 84 +++ Programs/CosmOS/Assembler/source.asm | 225 ++++++ Programs/CosmOS/Assembler/table.asm | 106 +++ Programs/CosmOS/Assembler/token.asm | 249 +++++++ Programs/CosmOS/Assembler/tokenTest.asm | 178 +++++ Programs/CosmOS/README.md | 6 + README.md | 1 + SplitBit Assembler Manual.md | 26 + Tests/docs.sh | 23 +- Tests/expected/cosmosSource.out | 27 + Tests/expected/cosmosTokens.out | 25 + Tests/input/cosmosSource.in | 3 + Tests/input/cosmosTokens.in | 3 + Tests/makedisks.sh | 18 + Tests/manifest | 17 + Tests/native.sh | 90 +++ makefile | 2 + 21 files changed, 3185 insertions(+), 7 deletions(-) create mode 100644 Programs/CosmOS/Assembler/Asm.asm create mode 100644 Programs/CosmOS/Assembler/classify.asm create mode 100644 Programs/CosmOS/Assembler/labels.asm create mode 100644 Programs/CosmOS/Assembler/numbers.asm create mode 100644 Programs/CosmOS/Assembler/readTest.asm create mode 100644 Programs/CosmOS/Assembler/source.asm create mode 100644 Programs/CosmOS/Assembler/table.asm create mode 100644 Programs/CosmOS/Assembler/token.asm create mode 100644 Programs/CosmOS/Assembler/tokenTest.asm create mode 100644 Tests/expected/cosmosSource.out create mode 100644 Tests/expected/cosmosTokens.out create mode 100644 Tests/input/cosmosSource.in create mode 100644 Tests/input/cosmosTokens.in create mode 100755 Tests/native.sh diff --git a/Programs/CosmOS/Assembler/Asm.asm b/Programs/CosmOS/Assembler/Asm.asm new file mode 100644 index 0000000..39fdfdf --- /dev/null +++ b/Programs/CosmOS/Assembler/Asm.asm @@ -0,0 +1,879 @@ +; The SplitBit assembler, running on SplitBit. +; +; run Asm.sbx hello.asm +; +; Reads assembly source off the disk and writes a binary back to it, with no host involved +; anywhere. The output has to be byte for byte what the C assembler produces from the same +; source, which is the only honest test of it and the one the suite runs. +; +; ---- Two passes over a file that is never held ---- +; +; The C assembler reads every token of every file into one array and works on that. It +; cannot be done that way here and never could: cosmos.asm alone is 56,047 bytes of source +; against 64K of Data Memory, and the token array for it would be several times that. So +; the source is streamed through a 256 byte window, twice, and the only thing kept between +; the passes is the label table. +; +; TWO PASSES ARE ENOUGH BECAUSE EVERY LENGTH IS KNOWN WITHOUT RESOLVING ANYTHING. How many +; bytes a token comes to falls out of what the token is - an instruction's from its shape, +; a value's is one, a string's is its characters and a zero - and never from the value of +; anything named. So the first pass can work out exactly where every label lands, and the +; second never needs a fixup list or a second look. A forward reference stops being a +; problem and becomes the reason there are two passes at all. +; +; ---- What this one does not do yet ---- +; +; #Include, #Base, #Align, #Reserve and #Vectors are refused by name rather than ignored. +; An assembler that quietly skipped a directive would produce a file that looked right and +; was the wrong length, which is the worst thing it could do. +; +; Written by Anachronaut + +#Include services.asm + +#Program + + #Base 0x2000 + +start: + SETD.0 Argument + INIB 0d23 + SWI osArgument + SETD.0 Argument + LDA.0 + BRA sayUsage + + CALL deriveName + + SETD.0 Argument + CALL srcOpen + BNQ noSource + + CALL passOne + BNQ stopped + CALL layOutImage + BNQ stopped + CALL passTwo + BNQ stopped + CALL writeImage + BNQ stopped + + CALL report + SWI osExit + +stopped: + SETD.0 StoppedText + SWI osPrintString + SWI osExit + +sayUsage: + SETD.0 UsageText + SWI osPrintString + SWI osExit + +noSource: + SETD.0 NoSourceText + SWI osPrintString + SETD.0 Argument + SWI osPrintString + SETD.0 NewLine + SWI osPrintString + SWI osExit + +; ---- The first pass: how long everything is, and where every label lands ---- + +passOne: + CALL labReset + CALL beginPass + +oneLoop: + CALL tokNext + BNQ oneDone + CALL clsToken + BNQ passFailed + + SETD.0 ClsType + LDA.0 + INIB 0d0 + XOR + BRQ oneKeyword + SETD.0 ClsType + LDA.0 + INIB 0d4 + XOR + BRQ oneDefinition + CALL checkPlacement + BNQ passFailed + BRI oneAdvance + +oneKeyword: + CALL doKeyword + BNQ passFailed + BRI oneLoop ; A keyword is no bytes, so there is nothing to advance. + +oneDefinition: + ; The colon is not part of the name. Writing a zero over it here means a definition and + ; a use of the same name compare equal without either side knowing which it is. + CALL dropColon + SETD.0 Status + LDA.0 + BRA labelNowhere + + SETD.0 ProgAt + SETD.2 Status + LDA.2 + INIB 0d1 + XOR + BRQ oneDefineHere + SETD.0 DataAt +oneDefineHere: + LDA.0 + INCD.0 + LDB.0 + SETD.0 TokText + CALL labAdd + BNQ passFailed + BRI oneLoop ; A definition is a name for a place, not a byte in it. + +oneAdvance: + CALL stepCursor + BRI oneLoop + +oneDone: + RSTA + RSTB + CCF + ADD + RET + +labelNowhere: + SETD.0 LabelNowhereText + CALL clsComplain +passFailed: + RSTA + INIB 0d1 + CCF + ADD + RET + +; ---- The second pass: the bytes themselves ---- + +passTwo: + CALL srcRewind + BNQ passFailed + CALL beginStatus ; The cursors are NOT reset. They are the first pass's answer, + ; the second pass writes through pointers of its own, and the + ; report at the end still has to say how long the segments are. + +twoLoop: + CALL tokNext + BNQ twoDone + CALL clsToken + BNQ passFailed + + SETD.0 ClsType + LDA.0 + SETD.0 EmitKind + STA.0 + + INIB 0d0 + XOR + BRQ twoKeyword + SETD.0 EmitKind + LDA.0 + INIB 0d1 + XOR + BRQ twoInstruction + SETD.0 EmitKind + LDA.0 + INIB 0d2 + XOR + BRQ twoValue + SETD.0 EmitKind + LDA.0 + INIB 0d3 + XOR + BRQ twoString + SETD.0 EmitKind + LDA.0 + INIB 0d4 + XOR + BRQ twoLoop ; A definition emits nothing; the first pass took its address. + BRI twoReference + +twoKeyword: + CALL doKeyword + BNQ passFailed + BRI twoLoop + +twoInstruction: + SETD.0 ClsOpcode + LDA.0 + CALL emitByte + + ; The selectors follow the opcode, and they are written whether or not the programmer + ; wrote them: leaving one off means Data Pointer 0 rather than no pointer at all. + RSTA + SETD.0 EmitLeft + STA.0 +twoSelectorLoop: + SETD.0 EmitLeft + LDA.0 + SETD.2 ClsWanted + LDB.2 + CCF + SUB + BRQ twoLoop + SETD.0 ClsSelectorValue + SETD.2 EmitLeft + LDA.2 + CALL byteAt + SETD.0 ClsByte + LDA.0 + CALL emitByte + SETD.0 EmitLeft + LDA.0 + INCA + STA.0 + BRI twoSelectorLoop + +twoValue: + SETD.0 ClsValue + LDA.0 + CALL emitByte + BRI twoLoop + +twoString: + SETD.0 TokText + SETD.1 EmitWalk + STD.0.1 +twoStringLoop: + SETD.1 EmitWalk + LDD.0.1 + LDA.0 + CALL emitByte + SETD.0 EmitWalk + CALL numStep + SETD.1 EmitWalk + LDD.0.1 + DPDN.0 0d01 + LDA.0 + BNA twoStringLoop ; The zero goes out with the rest and then stops the loop. + BRI twoLoop + +twoReference: + SETD.0 TokText + CALL labFind + BNQ twoUnknown + SETD.0 LabAddress + LDA.0 + CALL emitByte + SETD.0 LabAddress + INCD.0 + LDA.0 + CALL emitByte + BRI twoLoop + +twoUnknown: + SETD.0 UnknownText + CALL clsComplain + BRI passFailed + +twoDone: + RSTA + RSTB + CCF + ADD + RET + +; ---- What both passes have in common ---- + +beginPass: + CALL beginStatus + SETD.0 ProgAt + CALL numZero + SETD.0 DataAt + CALL numZero + RET + +; No segment is open until a #Program or #Data says so, at the start of either pass. +beginStatus: + RSTA + SETD.0 Status + STA.0 + RET + +; Moves the cursor of whichever segment is open along by what this token comes to. +stepCursor: + SETD.0 Status + LDA.0 + INIB 0d1 + XOR + BRQ stepProgram + SETD.0 DataAt + BRI stepBy +stepProgram: + SETD.0 ProgAt +stepBy: + SETD.2 ClsLength + LDA.2 + CALL numAddByte + RET + +; Is this token allowed where it is? The rules are the C assembler's, and they exist +; because each of these has a way of going wrong quietly. +checkPlacement: + SETD.0 ClsType + LDA.0 + INIB 0d1 + XOR + BRQ placeInstruction + SETD.0 ClsType + LDA.0 + INIB 0d3 + XOR + BRQ placeString + ; A value or a name, which needs somewhere to go but does not care which. + SETD.0 Status + LDA.0 + BRA placeNowhere + BRI placeYes + +placeInstruction: + SETD.0 Status + LDA.0 + INIB 0d1 + XOR + BNQ placeNotProgram + BRI placeYes + +placeString: + ; A string in Program Memory could not be read by the program holding it: instructions + ; reach Data Memory only. It would assemble and then be unreachable. + SETD.0 Status + LDA.0 + INIB 0d1 + XOR + BRQ placeStringInProgram + SETD.0 Status + LDA.0 + BRA placeNowhere + +placeYes: + RSTA + RSTB + CCF + ADD + RET + +placeNowhere: + SETD.0 NowhereText + CALL clsComplain + BRI placeNo +placeNotProgram: + SETD.0 NotProgramText + CALL clsComplain + BRI placeNo +placeStringInProgram: + SETD.0 StringInProgramText + CALL clsComplain +placeNo: + RSTA + INIB 0d1 + CCF + ADD + RET + +; #Program and #Data change which segment is open. Everything else the C assembler +; understands is refused by name, because an assembler that skipped a directive would +; produce a file that looked right and was the wrong length. +doKeyword: + SETD.0 TokText + SETD.1 WordProgram + CALL labSame + BRQ keywordProgram + SETD.0 TokText + SETD.1 WordData + CALL labSame + BRQ keywordData + SETD.0 NotYetText + CALL clsComplain + RSTA + INIB 0d1 + CCF + ADD + RET + +keywordProgram: + INIA 0d1 + BRI keywordSet +keywordData: + INIA 0d2 +keywordSet: + SETD.0 Status + STA.0 + RSTA + RSTB + CCF + ADD + RET + +; Writes a zero over the colon on the end of a label definition. +dropColon: + SETD.0 TokLength + LDA.0 + BRA dropColonDone + DECA + SETD.0 TokLength + STA.0 + SETD.0 TokText + SETD.1 DropWalk + STD.0.1 + SETD.0 DropWalk + SETD.2 TokLength + LDA.2 + CALL numAddByte + SETD.1 DropWalk + LDD.0.1 + RSTA + STA.0 +dropColonDone: + RET + +; ---- The output image ---- + +; Where each segment's bytes will go, and the header in front of them. Both lengths are +; known now, which is the whole reason the first pass exists. +layOutImage: + ; Nineteen bytes of format: the magic, a version, four feature flags, and a marker and + ; a length for each of the two segments. + SETD.0 ImgTotal + SETD.2 ProgAt + CALL numSet + SETD.0 ImgTotal + SETD.2 DataAt + CALL numAdd + INIA 0d19 + SETD.0 ImgTotal + CALL numAddByte + + SETD.0 ImgRoom + SETD.2 ImgTotal + CALL numCompare + BRC imageTooBig + + SETD.0 Image + SETD.1 ProgPut + STD.0.1 + SETD.0 ProgPut + INIA 0d14 + CALL numAddByte + + SETD.0 ImgWalk + SETD.2 ProgAt + CALL numSet + INIA 0d19 + SETD.0 ImgWalk + CALL numAddByte + SETD.0 Image + SETD.1 DataPut + STD.0.1 + SETD.0 DataPut + SETD.2 ImgWalk + CALL numAdd + + ; The header, written straight into the front of the image. + SETD.0 Image + SETD.1 ImgWalk + STD.0.1 + SETD.0 MagicSPBT + INIA 0d4 + CALL putBytes + INIA 0d1 + CALL putByte ; The format version. + RSTA + CALL putByte + CALL putByte + CALL putByte + CALL putByte ; Four bytes of feature flags, none of them asked for. + SETD.0 MagicPRG + INIA 0d3 + CALL putBytes + SETD.0 ProgAt + CALL putWord + + ; And the marker between the segments, which sits after the program bytes. + SETD.0 Image + SETD.1 ImgWalk + STD.0.1 + SETD.0 ImgWalk + INIA 0d14 + CALL numAddByte + SETD.0 ImgWalk + SETD.2 ProgAt + CALL numAdd + SETD.0 MagicDAT + INIA 0d3 + CALL putBytes + SETD.0 DataAt + CALL putWord + + RSTA + RSTB + CCF + ADD + RET + +imageTooBig: + SETD.0 TooBigText + SWI osPrintString + RSTA + INIB 0d1 + CCF + ADD + RET + +; Puts A down at ImgWalk and steps it. +putByte: + SETD.0 ImgHold + STA.0 + SETD.1 ImgWalk + LDD.0.1 + SETD.2 ImgHold + LDA.2 + STA.0 + INCD.0 + STD.0.1 + RET + +; Puts A bytes from DP0 down at ImgWalk. +putBytes: + SETD.1 ImgCount + STA.1 + SETD.1 ImgFrom + STD.0.1 +putBytesLoop: + SETD.0 ImgCount + LDA.0 + BRA putBytesDone + DECA + STA.0 + SETD.1 ImgFrom + LDD.0.1 + LDA.0 + CALL putByte + SETD.0 ImgFrom + CALL numStep + BRI putBytesLoop +putBytesDone: + RET + +; Puts the two byte number at DP0 down at ImgWalk, most significant first, the way every +; number in this format is stored. +putWord: + SETD.1 ImgFrom + STD.0.1 + LDA.0 + CALL putByte + SETD.1 ImgFrom + LDD.0.1 + INCD.0 + LDA.0 + CALL putByte + RET + +; Puts A into whichever segment is open, and steps that segment's pointer. +emitByte: + SETD.0 EmitHold + STA.0 + SETD.0 Status + LDA.0 + INIB 0d1 + XOR + BRQ emitToProgram + SETD.1 DataPut + BRI emitPut +emitToProgram: + SETD.1 ProgPut +emitPut: + LDD.0.1 + SETD.2 EmitHold + LDA.2 + STA.0 + INCD.0 + STD.0.1 + RET + +; The byte at DP0 offset by A, into ClsByte. The classifier has one of these; this is the +; assembler's, because a routine over there answers into a variable over there. +byteAt: + PSHA + PSHD.0 + POPB + POPA + SETD.0 EmitWalk + STA.0 + INCD.0 + STB.0 + POPA + SETD.0 EmitWalk + CALL numAddByte + SETD.1 EmitWalk + LDD.0.1 + LDA.0 + SETD.0 ClsByte + STA.0 + RET + +writeImage: + SETD.0 OutName + SETD.1 Image + SETD.2 ImgTotal + LDA.2 + INCD.2 + LDB.2 + SWI osFileSave + BNQ writeFailed + RSTA + RSTB + CCF + ADD + RET + +writeFailed: + SETD.0 NoWriteText + SWI osPrintString + SETD.0 OutName + SWI osPrintString + SETD.0 NewLine + SWI osPrintString + RSTA + INIB 0d1 + CCF + ADD + RET + +; What the source file is called with its extension replaced, so that hello.asm becomes +; hello.bin without anybody having to say so twice. +deriveName: + SETD.0 Argument + SETD.1 OutName + CALL copyName + SETD.0 OutName + SETD.1 DotAt + STD.0.1 + SETD.0 DotFound + CALL numZero + + SETD.0 OutName + SETD.1 NameWalk + STD.0.1 +deriveLoop: + SETD.1 NameWalk + LDD.0.1 + LDA.0 + BRA deriveEnd + INIB 0x2E ; '.' + XOR + BNQ deriveStep + SETD.0 DotAt + SETD.2 NameWalk + CALL numSet + INIA 0d1 + SETD.0 DotFound + STA.0 +deriveStep: + SETD.0 NameWalk + CALL numStep + BRI deriveLoop + +deriveEnd: + SETD.0 DotFound + LDA.0 + BNA deriveAtDot + SETD.0 DotAt + SETD.2 NameWalk + CALL numSet ; No extension at all, so the new one goes on the end. +deriveAtDot: + SETD.1 DotAt + LDD.1.1 + SETD.0 Extension +deriveCopy: + LDA.0 + STA.1 + BRA deriveDone + INCD.0 + INCD.1 + BRI deriveCopy +deriveDone: + RET + +; Copies the string at DP0 to DP1, up to 22 characters and the zero after them. +copyName: + INIA 0d22 + SETD.2 NameLeft + STA.2 +copyNameLoop: + LDA.0 + BRA copyNameEnd + STA.1 + INCD.0 + INCD.1 + SETD.2 NameLeft + LDA.2 + DECA + STA.2 + BNA copyNameLoop +copyNameEnd: + RSTA + STA.1 + RET + +report: + SETD.0 WroteText + SWI osPrintString + SETD.0 OutName + SWI osPrintString + SETD.0 ProgramText + SWI osPrintString + SETD.0 ProgAt + LDA.0 + INCD.0 + LDB.0 + SWI osPrintNumber + SETD.0 DataText + SWI osPrintString + SETD.0 DataAt + LDA.0 + INCD.0 + LDB.0 + SWI osPrintNumber + SETD.0 LabelsText + SWI osPrintString + SETD.0 LabCount + LDA.0 + INCD.0 + LDB.0 + SWI osPrintNumber + SETD.0 LabelsEnd + SWI osPrintString + RET + +#Data + + #Base 0x1000 + +Argument: + #Reserve 0d23 +OutName: + #Reserve 0d27 +NameWalk: + 0x00 0x00 +NameLeft: + 0x00 +DotAt: + 0x00 0x00 +DotFound: + 0x00 0x00 + +Status: + 0x00 +ProgAt: + 0x00 0x00 +DataAt: + 0x00 0x00 + +ProgPut: + 0x00 0x00 +DataPut: + 0x00 0x00 +EmitHold: + 0x00 +EmitKind: + 0x00 +EmitLeft: + 0x00 +EmitWalk: + 0x00 0x00 + +ImgTotal: + 0x00 0x00 +ImgWalk: + 0x00 0x00 +ImgFrom: + 0x00 0x00 +ImgCount: + 0x00 +ImgHold: + 0x00 +DropWalk: + 0x00 0x00 + +; How big a binary this can build. Everything the assembler makes has to fit here at once, +; because a file is written in one call and there is nowhere to put half of one. +ImgRoom: + 0x10 0x00 + +MagicSPBT: +"SPBT" +MagicPRG: +"PRG" +MagicDAT: +"DAT" +Extension: +".bin" + +WordProgram: +"#Program" +WordData: +"#Data" + +UsageText: +"say which file: run Asm.sbx hello.asm +" +NoSourceText: +"no such file: " +NewLine: +" +" +NowhereText: +"that has to be inside a segment, and no #Program or #Data has opened one" +NotProgramText: +"an instruction outside the Program Segment" +StringInProgramText: +"a string cannot go in the Program Segment, because an instruction cannot read it there" +LabelNowhereText: +"a label defined outside a segment, so there is nowhere for it to point" +UnknownText: +"no label of that name is defined anywhere in this program" +NotYetText: +"this assembler does not understand that directive yet" +TooBigText: +"the binary would be bigger than this assembler has room to build +" +NoWriteText: +"it would not write " +StoppedText: +"nothing was written +" +WroteText: +"wrote " +ProgramText: +": program " +DataText: +", data " +LabelsText: +", labels " +LabelsEnd: +" +" + +Image: + #Reserve 0d4096 + +#Include numbers.asm +#Include source.asm +#Include token.asm +#Include classify.asm +#Include labels.asm +#Include table.asm diff --git a/Programs/CosmOS/Assembler/classify.asm b/Programs/CosmOS/Assembler/classify.asm new file mode 100644 index 0000000..2ca07e7 --- /dev/null +++ b/Programs/CosmOS/Assembler/classify.asm @@ -0,0 +1,792 @@ +; What a token is. +; +; THE ORDER OF THESE TESTS IS THE LANGUAGE, and it is copied deliberately from the C +; assembler rather than reinvented, because the two have to produce the same bytes from +; the same source. A token is a keyword, then an instruction, then a literal value, then a +; string, then a label - and what a thing means depends on which of those it reaches first. +; +; ClsType 0 keyword 1 instruction 2 value 3 string +; 4 label definition 5 label reference +; +; A STRING IS NEVER ANYTHING ELSE. The quotes are gone by the time a token is looked at, so +; without that guard a string whose text reads "ADD" assembles as an instruction and one +; that begins with a zero is rejected as a malformed literal. Both have happened; the C +; assembler carries the same guard in two places and this carries it in four, because the +; keyword test needs it too and over there it does not have it. +; +; Written by Anachronaut + +#Program + +; Works out what TokText is. Q is zero if it is something the assembler understands. +clsToken: + RSTA + SETD.0 ClsLength + STA.0 + + SETD.0 TokString + LDA.0 + BNA clsIsString + + ; ---- A keyword ---- + SETD.0 TokText + LDA.0 + INIB 0x23 ; '#' + XOR + BNQ clsTryInstruction + INIA 0d0 + SETD.0 ClsType + STA.0 + BRI clsYes + +clsTryInstruction: + CALL clsInstruction + BNQ clsTryValue + INIA 0d1 + SETD.0 ClsType + STA.0 + BRI clsYes + +clsTryValue: + ; A leading zero means a literal was meant, so anything malformed after it is an error + ; rather than a label. Falling through to the label test would quietly emit two bytes + ; where one was wanted and shift everything after it. + SETD.0 TokText + LDA.0 + INIB 0x30 ; '0' + XOR + BNQ clsTryLabel + CALL clsValue + BNQ clsNo + INIA 0d2 + SETD.0 ClsType + STA.0 + INIA 0d1 + SETD.0 ClsLength + STA.0 + BRI clsYes + +clsIsString: + INIA 0d3 + SETD.0 ClsType + STA.0 + ; A string is its characters and the zero byte after them, which is why two strings + ; written in a row are two strings rather than one long one. + SETD.0 TokLength + LDA.0 + INCA + SETD.0 ClsLength + STA.0 + BRI clsYes + +clsTryLabel: + ; A colon on the end makes it a definition. Everything else is a use of a name, which + ; is two bytes of address wherever it appears. + CALL clsLastCharacter + SETD.0 ClsByte + LDA.0 + INIB 0x3A ; ':' + XOR + BNQ clsUse + INIA 0d4 + SETD.0 ClsType + STA.0 + BRI clsYes + +clsUse: + INIA 0d5 + SETD.0 ClsType + STA.0 + INIA 0d2 + SETD.0 ClsLength + STA.0 + +clsYes: + RSTA + RSTB + CCF + ADD + RET + +clsNo: + RSTA + INIB 0d1 + CCF + ADD + RET + +; The last character of the token, into ClsByte. Zero if the token is empty. +; +; INTO MEMORY, not into A, and that is not a style choice: a CALL saves and restores A, B +; and Data Pointers 0 to 2, so a routine that leaves its answer in one of those has the +; answer undone by its own return. Only Q, DP3 and memory survive. +clsLastCharacter: + SETD.0 TokLength + LDA.0 + BRA clsLastNone + SETD.0 TokText + SETD.1 ClsWalk + STD.0.1 + SETD.0 TokLength + LDA.0 + DECA + SETD.0 ClsWalk + CALL numAddByte + SETD.1 ClsWalk + LDD.0.1 + LDA.0 + SETD.0 ClsByte + STA.0 + RET +clsLastNone: + RSTA + SETD.0 ClsByte + STA.0 + RET + +; ---- Instructions ---- + +; Is TokText an instruction? Q is zero if it is, and then ClsOpcode, ClsShape, ClsLength +; and ClsSelectorValue describe it. +; +; The name is folded to upper case and the selectors are split off before anything is +; looked up, because SETD.2 is the instruction SETD naming Data Pointer 2 rather than a +; name of its own. +clsInstruction: + CALL clsSplitName + BNQ clsInstructionNo ; Longer than any mnemonic, so it is not one. + CALL clsFindName + BNQ clsInstructionNo + + ; How many selectors this shape wants. They are emitted whether or not they were + ; written, so the length is fixed by the instruction and leaving one off means zero. + SETD.0 ClsShape + LDA.0 + SETD.0 AsmShapeSelectors + CALL clsIndexByte + SETD.0 ClsByte + LDA.0 + SETD.0 ClsWanted + STA.0 + + ; More selectors than the instruction has pointers to name is a mistake worth catching: + ; it means the programmer thinks it does something it does not. + SETD.0 ClsGiven + LDA.0 + SETD.2 ClsWanted + LDB.2 + CCF + SUB + BRQ clsSelectorsFit + BRC clsSelectorsFit ; Fewer than wanted is allowed and means zero. + SETD.0 TooManySelectors + CALL clsComplain + BRI clsInstructionNo + +clsSelectorsFit: + SETD.0 ClsWanted + LDA.0 + INCA + SETD.0 ClsLength + STA.0 ; The opcode and its selectors. The operand is its own token. + RSTA + RSTB + CCF + ADD + RET + +clsInstructionNo: + RSTA + INIB 0d1 + CCF + ADD + RET + +; Splits TokText into an upper case mnemonic in ClsName, padded to four with spaces, and +; up to two selector digits in ClsSelectorValue. Q is zero if the name could be a mnemonic +; at all, which means four characters or fewer. +clsSplitName: + INIA 0x20 + SETD.0 ClsName + STA.0 + INCD.0 + STA.0 + INCD.0 + STA.0 + INCD.0 + STA.0 + INCD.0 + RSTA + STA.0 ; Four spaces and a zero, so a short name still compares. + + RSTA + SETD.0 ClsGiven + STA.0 + SETD.0 ClsSelectorValue + STA.0 + INCD.0 + STA.0 + + SETD.0 ClsNameLength + RSTA + STA.0 + + SETD.0 TokText + SETD.1 ClsWalk + STD.0.1 + +clsNameLoop: + SETD.1 ClsWalk + LDD.0.1 + LDA.0 + BRA clsSplitDone + INIB 0x2E ; '.' + XOR + BRQ clsSelectorPart + + SETD.0 ClsNameLength + LDA.0 + INIB 0d4 + CCF + SUB + BNC clsSplitTooLong ; A fifth character, so this is not a mnemonic. + + SETD.1 ClsWalk + LDD.0.1 + LDA.0 + CALL clsUpper + SETD.0 ClsByte + LDA.0 + SETD.0 ClsName + SETD.2 ClsNameLength + CALL clsPutIndexed + SETD.0 ClsNameLength + LDA.0 + INCA + STA.0 + +clsNameStep: + SETD.0 ClsWalk + CALL numStep + BRI clsNameLoop + +clsSelectorPart: + ; The character after the dot is which Data Pointer, in decimal. + SETD.0 ClsWalk + CALL numStep + SETD.1 ClsWalk + LDD.0.1 + LDA.0 + BRA clsSplitDone + INIB 0x30 + CCF + SUB + MVQA ; The digit as a number. + SETD.0 ClsDigitHold + STA.0 + + ; There are four Data Pointers, so anything above three does not name one. + INIB 0d4 + CCF + SUB + BNC clsSelectorRange + + SETD.0 ClsGiven + LDA.0 + INIB 0d2 + CCF + SUB + BNC clsSelectorSpare ; Already two, so anything more is counted and discarded; + ; the count is what the caller complains about. + SETD.0 ClsDigitHold + LDA.0 + SETD.0 ClsSelectorValue + SETD.2 ClsGiven + CALL clsPutIndexed +clsSelectorSpare: + SETD.0 ClsGiven + LDA.0 + INCA + STA.0 + BRI clsNameStep + +clsSplitDone: + SETD.0 ClsNameLength + LDA.0 + BRA clsSplitTooLong ; Nothing before the dot is not a mnemonic either. + RSTA + RSTB + CCF + ADD + RET + +clsSelectorRange: + SETD.0 BadSelector + CALL clsComplain +clsSplitTooLong: + RSTA + INIB 0d1 + CCF + ADD + RET + +; Looks ClsName up in the instruction table. Q is zero if it is there, and then ClsOpcode +; and ClsShape say what it is. +clsFindName: + SETD.0 AsmInstructions + SETD.1 ClsEntry + STD.0.1 + SETD.0 AsmInstructionCount + LDA.0 + SETD.0 ClsLeft + STA.0 + +clsFindLoop: + SETD.1 ClsEntry + LDD.0.1 + INCD.0 + INCD.0 ; Past the opcode and the shape, to the name. + SETD.1 ClsName + CALL clsSameName + BRQ clsFindGot + + ; Seven bytes to an entry: an opcode, a shape, and four characters with a zero. + INIA 0d7 + SETD.0 ClsEntry + CALL numAddByte + SETD.0 ClsLeft + LDA.0 + DECA + STA.0 + BNA clsFindLoop + + RSTA + INIB 0d1 + CCF + ADD + RET + +clsFindGot: + SETD.1 ClsEntry + LDD.0.1 + LDA.0 + SETD.1 ClsOpcode + STA.1 + SETD.1 ClsEntry + LDD.0.1 + INCD.0 + LDA.0 + SETD.1 ClsShape + STA.1 + RSTA + RSTB + CCF + ADD + RET + +; Four characters at DP0 against four at DP1. Q is zero if they are the same. +clsSameName: + INIA 0d4 + SETD.2 ClsLeft2 + STA.2 +clsSameLoop: + LDA.0 + LDB.1 + XOR + BNQ clsSameDone + INCD.0 + INCD.1 + SETD.2 ClsLeft2 + LDA.2 + DECA + STA.2 + BNA clsSameLoop +clsSameDone: + RET + +; ---- Literal values ---- + +; Is TokText a well formed literal? Q is zero if it is, and ClsValue is what it comes to. +; Anything beginning with a zero has to be one, so a failure here is an error rather than +; an invitation to try the next test. +clsValue: + SETD.0 TokText + INCD.0 + LDA.0 + INIB 0x78 ; 'x' + XOR + BRQ clsValueHex + SETD.0 TokText + INCD.0 + LDA.0 + INIB 0x64 ; 'd' + XOR + BRQ clsValueDecimal + + SETD.0 BadPrefix + CALL clsComplain + BRI clsValueNo + +clsValueHex: + INIA 0d16 + SETD.0 ClsBase + STA.0 + BRI clsValueDigits + +clsValueDecimal: + INIA 0d10 + SETD.0 ClsBase + STA.0 + +clsValueDigits: + SETD.0 TokLength + LDA.0 + INIB 0d3 + CCF + SUB + BRC clsValueEmpty ; Only the prefix, so there are no digits at all. + + RSTA + SETD.0 ClsValue + STA.0 + SETD.0 TokText + INCD.0 + INCD.0 + SETD.1 ClsWalk + STD.0.1 + +clsValueLoop: + SETD.1 ClsWalk + LDD.0.1 + LDA.0 + BRA clsValueGood + CALL clsDigit + BNQ clsValueBadDigit + + ; value = value * base + digit, and anything that will not fit in a byte is refused + ; rather than wrapped, because a literal is one byte wherever it goes. + SETD.0 ClsDigitValue + LDA.0 + SETD.2 ClsValue + LDB.2 + PSHA + SETD.0 ClsBase + LDA.0 + CALL clsMultiply + BNQ clsValueTooBig + POPA + SETD.0 ClsProduct + LDB.0 + CCF + ADD + BRC clsValueTooBig + MVQA + SETD.0 ClsValue + STA.0 + + SETD.0 ClsWalk + CALL numStep + BRI clsValueLoop + +clsValueGood: + RSTA + RSTB + CCF + ADD + RET + +clsValueEmpty: + SETD.0 NoDigits + CALL clsComplain + BRI clsValueNo +clsValueBadDigit: + SETD.0 BadDigit + CALL clsComplain + BRI clsValueNo +clsValueTooBig: + POPA + SETD.0 TooBig + CALL clsComplain + +clsValueNo: + RSTA + INIB 0d1 + CCF + ADD + RET + +; The character in A as a digit in ClsBase, into ClsDigitValue. Q is zero if it is one. +clsDigit: + SETD.0 ClsHold + STA.0 + + ; 0 to 9 + INIB 0x30 + CCF + SUB + BRC clsDigitNo + SETD.0 ClsHold + LDA.0 + INIB 0x3A + CCF + SUB + BRC clsDigitDecimal + + ; A to F, either case, and only when the base has room for them. + SETD.0 ClsBase + LDA.0 + INIB 0d16 + XOR + BNQ clsDigitNo + + SETD.0 ClsHold + LDA.0 + CALL clsUpper + SETD.0 ClsByte + LDA.0 + SETD.0 ClsHold + STA.0 + INIB 0x41 + CCF + SUB + BRC clsDigitNo + SETD.0 ClsHold + LDA.0 + INIB 0x47 + CCF + SUB + BNC clsDigitNo + + SETD.0 ClsHold + LDA.0 + INIB 0x37 ; 'A' is ten, so the offset is 0x41 less 10. + CCF + SUB + MVQA + SETD.0 ClsDigitValue + STA.0 + BRI clsDigitYes + +clsDigitDecimal: + SETD.0 ClsHold + LDA.0 + INIB 0x30 + CCF + SUB + MVQA + SETD.0 ClsDigitValue + STA.0 + ; A decimal digit is a hexadecimal one too, so this needs no test of the base. + +clsDigitYes: + RSTA + RSTB + CCF + ADD + RET + +clsDigitNo: + RSTA + INIB 0d1 + CCF + ADD + RET + +; B times A into ClsProduct. Q is not zero if it would not fit in a byte, which is the +; only answer a literal can use: there is no wider literal to promote it to. +clsMultiply: + SETD.0 ClsMulLeft + STA.0 + RSTA + SETD.0 ClsProduct + STA.0 +clsMultiplyLoop: + SETD.0 ClsMulLeft + LDA.0 + BRA clsMultiplyDone + DECA + STA.0 + SETD.0 ClsProduct + LDA.0 + CCF + ADD + BRC clsMultiplyOver + MVQA + SETD.0 ClsProduct + STA.0 + BRI clsMultiplyLoop +clsMultiplyDone: + RSTA + RSTB + CCF + ADD + RET +clsMultiplyOver: + RSTA + INIB 0d1 + CCF + ADD + RET + +; ---- Odds and ends ---- + +; The character in A, folded to upper case, into ClsByte. +clsUpper: + SETD.0 ClsHold + STA.0 + INIB 0x61 ; 'a' + CCF + SUB + BRC clsUpperDone + SETD.0 ClsHold + LDA.0 + INIB 0x7B ; One past 'z'. + CCF + SUB + BNC clsUpperDone + SETD.0 ClsHold + LDA.0 + INIB 0d32 + CCF + SUB + MVQA + SETD.0 ClsByte + STA.0 + RET +clsUpperDone: + SETD.0 ClsHold + LDA.0 + SETD.0 ClsByte + STA.0 + RET + +; The byte at DP0, offset by A, into ClsByte. +clsIndexByte: + PSHA + PSHD.0 + POPB + POPA ; The low byte is on top, the way a pointer is pushed. + SETD.0 ClsWalk + STA.0 + INCD.0 + STB.0 + POPA + SETD.0 ClsWalk + CALL numAddByte + SETD.1 ClsWalk + LDD.0.1 + LDA.0 + SETD.0 ClsByte + STA.0 + RET + +; Puts A at DP0 offset by the byte at DP2. +clsPutIndexed: + PSHA + PSHD.0 + POPB + POPA + SETD.0 ClsPut + STA.0 + INCD.0 + STB.0 + LDA.2 + SETD.0 ClsPut + CALL numAddByte + SETD.1 ClsPut + LDD.0.1 + POPA + STA.0 + RET + +; Says what is wrong, with the file and the line, the way an error ought to. +clsComplain: + SWI osPrintString + SETD.0 InFileText + SWI osPrintString + SETD.0 SrcName + SWI osPrintString + SETD.0 AtLineText + SWI osPrintString + SETD.0 TokLine + LDA.0 + INCD.0 + LDB.0 + SWI osPrintNumber + SETD.0 SaidText + SWI osPrintString + SETD.0 TokText + SWI osPrintString + SETD.0 SaidEnd + SWI osPrintString + RET + +#Data + +ClsType: + 0x00 +ClsLength: + 0x00 +ClsOpcode: + 0x00 +ClsShape: + 0x00 +ClsSelectorValue: + 0x00 0x00 +ClsGiven: + 0x00 +ClsWanted: + 0x00 +ClsNameLength: + 0x00 +ClsName: + #Reserve 0d5 +ClsValue: + 0x00 +ClsBase: + 0x00 +ClsDigitValue: + 0x00 +ClsProduct: + 0x00 +ClsMulLeft: + 0x00 +ClsHold: + 0x00 +ClsDigitHold: + 0x00 +ClsByte: + 0x00 +ClsLeft: + 0x00 +ClsLeft2: + 0x00 +ClsWalk: + 0x00 0x00 +ClsEntry: + 0x00 0x00 +ClsPut: + 0x00 0x00 + +InFileText: +" in " +AtLineText: +" at line " +SaidText: +" + it said: " +SaidEnd: +" +" +BadPrefix: +"a literal needs 0x for hexadecimal or 0d for decimal" +NoDigits: +"a literal with no digits after its prefix" +BadDigit: +"that is not a digit in the base the prefix asked for" +TooBig: +"a literal too large to fit in one byte" +TooManySelectors: +"more Data Pointer selectors than that instruction has pointers to name" +BadSelector: +"that does not name a Data Pointer, which run from 0 to 3" diff --git a/Programs/CosmOS/Assembler/labels.asm b/Programs/CosmOS/Assembler/labels.asm new file mode 100644 index 0000000..fc7e3db --- /dev/null +++ b/Programs/CosmOS/Assembler/labels.asm @@ -0,0 +1,337 @@ +; The label table: the only thing that survives between the two passes. +; +; Names are packed end to end in an arena and each index entry holds a pointer into it, +; rather than every entry carrying a field wide enough for the longest name. MEASURED on +; CosmOS, which is the biggest thing this will ever be asked to assemble: 453 labels +; averaging 11.3 characters. Packed they come to about 7,400 bytes; in 32 byte fields they +; would come to 15,400. The arena is worth the handful of extra instructions. +; +; Four bytes an index entry: two saying where the name is, two saying what it resolves to. +; +; A name is stored WITHOUT its colon, so that a definition and a use of it compare equal +; without either side having to know which it was looking at. +; +; The first pass fills this and the second only reads it. That is what makes a forward +; reference ordinary rather than special: by the time anything is emitted, every name in +; the program already has an address. +; +; Written by Anachronaut + +#Program + +; Empties the table. +labReset: + SETD.0 LabCount + CALL numZero + SETD.0 LabUsed + CALL numZero + SETD.0 LabArena + SETD.1 LabNext + STD.0.1 + SETD.0 LabIndex + SETD.1 LabBase + STD.0.1 + RET + +; Adds the name at DP0, meaning the address in A and B. Q is zero if it went in. +; +; A name already in the table is refused rather than replaced: one name may mean one place, +; and quietly taking the second would move everything that referred to the first. +labAdd: + SETD.2 LabPutAddress + STA.2 + INCD.2 + STB.2 + SETD.2 LabSubject + STD.0.2 + + CALL labFind + BNQ labAddFresh + SETD.0 LabTwice + CALL labComplain + BRI labAddNo + +labAddFresh: + SETD.0 LabCount + SETD.2 LabLimit + CALL numCompare + BNC labAddFull ; The index is as full as it goes. + + ; And the arena, counting the zero that ends the name. + SETD.1 LabSubject + LDD.0.1 + CALL labLength + SETD.0 LabEnd + SETD.2 LabUsed + CALL numSet + SETD.0 LabEnd + SETD.2 LabLength + CALL numAdd + SETD.0 LabRoom + SETD.2 LabEnd + CALL numCompare + BRC labAddCrowded ; The arena is smaller than where this name would end. + + ; The index entry: where the name is about to go, and what it means. + SETD.0 LabWhich + SETD.2 LabCount + CALL numSet + CALL labEntryAt + + SETD.1 LabEntry + LDD.0.1 + SETD.1 LabNext + LDD.1.1 + PSHD.1 + POPB + POPA ; The low byte is on top, the way a pointer is pushed. + STA.0 + INCD.0 + STB.0 + INCD.0 + SETD.2 LabPutAddress + LDA.2 + STA.0 + INCD.0 + INCD.2 + LDA.2 + STA.0 + + ; And the name itself, into the arena. + SETD.1 LabNext + LDD.1.1 + SETD.2 LabSubject + LDD.0.2 +labAddLoop: + LDA.0 + STA.1 + BRA labAddCopied + INCD.0 + INCD.1 + BRI labAddLoop +labAddCopied: + INCD.1 ; Past the zero, which was copied with the rest. + SETD.0 LabNext + STD.1.0 + + SETD.0 LabUsed + SETD.2 LabLength + CALL numAdd + SETD.0 LabCount + CALL numStep + + RSTA + RSTB + CCF + ADD + RET + +labAddFull: + SETD.0 LabFull + CALL labComplain + BRI labAddNo +labAddCrowded: + SETD.0 LabNoRoom + CALL labComplain +labAddNo: + RSTA + INIB 0d1 + CCF + ADD + RET + +; Looks up the name at DP0. Q is zero if it is there, and then LabAddress is what it means. +; +; A straight walk from the front. With 453 labels and a few thousand uses of them that is +; the slowest thing the assembler does, and it is deliberately the simple version: sorting +; the table or bucketing it on the first character are both easy later, and neither is +; worth writing before anything has been measured. +labFind: + SETD.2 LabSought + STD.0.2 + SETD.0 LabWhich + CALL numZero + +labFindLoop: + SETD.0 LabWhich + SETD.2 LabCount + CALL numCompare + BNC labFindMissing ; Walked the whole table without a match. + + CALL labEntryAt + SETD.1 LabEntry + LDD.0.1 + LDA.0 + INCD.0 + LDB.0 + SETD.0 LabNamePointer + STA.0 + INCD.0 + STB.0 + + SETD.1 LabNamePointer + LDD.0.1 + SETD.1 LabSought + LDD.1.1 + CALL labSame + BRQ labFindGot + + SETD.0 LabWhich + CALL numStep + BRI labFindLoop + +labFindGot: + SETD.1 LabEntry + LDD.0.1 + INCD.0 + INCD.0 + LDA.0 + INCD.0 + LDB.0 + SETD.0 LabAddress + STA.0 + INCD.0 + STB.0 + RSTA + RSTB + CCF + ADD + RET + +labFindMissing: + RSTA + INIB 0d1 + CCF + ADD + RET + +; Where entry number LabWhich is, into LabEntry. Four bytes an entry, so the offset is the +; number doubled twice - there being no multiply on this machine, and none needed. +labEntryAt: + SETD.0 LabOffset + SETD.2 LabWhich + CALL numSet + SETD.0 LabOffset + SETD.2 LabOffset + CALL numAdd + SETD.0 LabOffset + SETD.2 LabOffset + CALL numAdd + SETD.0 LabEntry + SETD.2 LabBase + CALL numSet + SETD.0 LabEntry + SETD.2 LabOffset + CALL numAdd + RET + +; Q is zero if the strings at DP0 and DP1 are the same, both ending in a zero byte. +labSame: + LDA.0 + LDB.1 + CCF + SUB + BNQ labSameDone + LDA.0 + BRA labSameDone ; They ended together, so they matched all the way. + INCD.0 + INCD.1 + BRI labSame +labSameDone: + RET + +; How long the string at DP0 is, counting the zero on the end, into LabLength. +labLength: + SETD.1 LabLenWalk + STD.0.1 + SETD.0 LabLength + CALL numZero +labLengthLoop: + SETD.0 LabLength + CALL numStep + SETD.1 LabLenWalk + LDD.0.1 + LDA.0 + BRA labLengthDone + SETD.0 LabLenWalk + CALL numStep + BRI labLengthLoop +labLengthDone: + RET + +labComplain: + SWI osPrintString + SETD.0 LabNamed + SWI osPrintString + SETD.0 TokText + SWI osPrintString + SETD.0 LabAtLine + SWI osPrintString + SETD.0 TokLine + LDA.0 + INCD.0 + LDB.0 + SWI osPrintNumber + SETD.0 LabNewLine + SWI osPrintString + RET + +#Data + +LabCount: + 0x00 0x00 +LabUsed: + 0x00 0x00 +LabNext: + 0x00 0x00 +LabBase: + 0x00 0x00 +LabAddress: + 0x00 0x00 +LabPutAddress: + 0x00 0x00 +LabNamePointer: + 0x00 0x00 +LabSought: + 0x00 0x00 +LabSubject: + 0x00 0x00 +LabEntry: + 0x00 0x00 +LabOffset: + 0x00 0x00 +LabWhich: + 0x00 0x00 +LabLength: + 0x00 0x00 +LabLenWalk: + 0x00 0x00 +LabEnd: + 0x00 0x00 + +; How many labels there may be, and how many bytes of name between them. Sized for a +; single program rather than for CosmOS: raising either is changing a number here, and +; running into one says so rather than writing past the end of the table. +LabLimit: + 0x01 0x00 +LabRoom: + 0x08 0x00 + +LabNamed: +": " +LabAtLine: +", at line " +LabNewLine: +" +" +LabTwice: +"that label is defined twice" +LabFull: +"too many labels" +LabNoRoom: +"no room left for label names" + +LabIndex: + #Reserve 0d1024 +LabArena: + #Reserve 0d2048 diff --git a/Programs/CosmOS/Assembler/numbers.asm b/Programs/CosmOS/Assembler/numbers.asm new file mode 100644 index 0000000..39ca9c7 --- /dev/null +++ b/Programs/CosmOS/Assembler/numbers.asm @@ -0,0 +1,101 @@ +; Sixteen bit arithmetic, for an assembler that counts in addresses. +; +; sbfs.asm has routines like these and the assembler cannot use them: it does not include +; the filesystem, because it reaches the disk through the system's services instead. That +; is the no-linker tax, paid in about a hundred and fifty bytes, and it is cheaper than the +; two and a half kilobytes including sbfs.asm would cost. +; +; Everything here works on numbers in memory rather than in registers, because a CALL puts +; A, B and Data Pointers 0 to 2 back as it found them. Only memory survives a return. +; +; Numbers are stored most significant byte first, the way every number on this machine is. +; +; Written by Anachronaut + +#Program + +; The two byte number at DP0 becomes the one at DP2. Destination first, so a call reads +; the way an assignment does. +numSet: + LDA.2 + STA.0 + INCD.2 + INCD.0 + LDA.2 + STA.0 + RET + +; The two byte number at DP0 becomes itself plus the one at DP2. +numAdd: + DPUP.0 0d01 + DPUP.2 0d01 + LDA.0 + LDB.2 + CCF + ADD + MVQA + STA.0 + DPDN.0 0d01 + DPDN.2 0d01 + LDA.0 + LDB.2 + ADD ; Carries in from the low half. Nothing between touches it. + MVQA + STA.0 + RET + +; Adds the byte in A to the two byte number at DP0. +numAddByte: + DPUP.0 0d01 + LDB.0 + CCF + ADD + MVQA + STA.0 + BNC numAddByteDone + DPDN.0 0d01 + LDA.0 + INCA + STA.0 +numAddByteDone: + RET + +; Adds one to the two byte number at DP0. +numStep: + DPUP.0 0d01 + LDA.0 + INCA + STA.0 + BNC numStepDone ; It did not wrap, so the high byte is untouched. + DPDN.0 0d01 + LDA.0 + INCA + STA.0 +numStepDone: + RET + +; Compares the two byte number at DP0 with the one at DP2. Q is zero if they are equal, +; and the Carry Flag is set if the one at DP0 is the smaller. Both come back, because +; neither Q nor the Status register is put back by a return. +numCompare: + LDA.0 + LDB.2 + CCF + SUB ; The high bytes settle it unless they are the same. + BNQ numCompareDone + INCD.0 + INCD.2 + LDA.0 + LDB.2 + CCF + SUB +numCompareDone: + RET + +; The two byte number at DP0 becomes zero. +numZero: + RSTA + STA.0 + INCD.0 + STA.0 + RET diff --git a/Programs/CosmOS/Assembler/readTest.asm b/Programs/CosmOS/Assembler/readTest.asm new file mode 100644 index 0000000..83faa47 --- /dev/null +++ b/Programs/CosmOS/Assembler/readTest.asm @@ -0,0 +1,84 @@ +; The source reader on its own, before anything is built on top of it. +; +; Everything else in the assembler reads its source through srcNext, so a fault in here +; would turn up later as a mysterious wrong byte in an output file. It is worth checking by +; itself, against a file whose contents are already known. +; +; It reads whatever it was told to, prints every character back, and then says how many +; lines went past. The file it is given is deliberately bigger than one block, so the seam +; between one block and the next is crossed rather than assumed. +; +; Written by Anachronaut + +#Include services.asm + +#Program + + #Base 0x2000 + +start: + SETD.0 Wanted + INIB 0d23 + SWI osArgument + SETD.0 Wanted + LDA.0 + BRA nothingAsked + + SETD.0 Wanted + CALL srcOpen + BNQ noFile + +readLoop: + CALL srcNext + BNQ readDone + SETD.0 SrcChar + LDA.0 + OUTA 0x00 ; Straight to the console: one character is not a string. + BRI readLoop + +readDone: + SETD.0 LinesText + SWI osPrintString + SETD.0 SrcLine + LDA.0 + INCD.0 + LDB.0 + SWI osPrintNumber + SETD.0 NewLine + SWI osPrintString + SWI osExit + +nothingAsked: + SETD.0 AskText + SWI osPrintString + SWI osExit + +noFile: + SETD.0 NoFileText + SWI osPrintString + SWI osExit + +#Data + + #Base 0x1000 + +Wanted: + #Reserve 0d23 + +LinesText: +"---- lines: " +NewLine: +" +" +AskText: +"say which file +" +NoFileText: +"no such file +" + +; The libraries go last, after both segments have been based. An included file that carries +; code brings its own #Program and #Data lines with it, and a #Base has to come before +; anything is in the segment it bases - so the bases are set here and the code arrives after. +#Include numbers.asm +#Include source.asm diff --git a/Programs/CosmOS/Assembler/source.asm b/Programs/CosmOS/Assembler/source.asm new file mode 100644 index 0000000..056eb4a --- /dev/null +++ b/Programs/CosmOS/Assembler/source.asm @@ -0,0 +1,225 @@ +; The source reader: characters out of a file of any size. +; +; Everything else in the assembler sits on this, so it is the first thing built and the +; thing most worth getting right. It hands out one character at a time and keeps a line +; number, which is what lets an error say where it happened rather than only what it was. +; +; A FILE IS NEVER HELD WHOLE. It arrives a block at a time through osFileBlock, into one +; buffer of 256 bytes, and is fetched again when the buffer runs out. That is why the +; assembler can read a source file bigger than the memory it runs in - which cosmos.asm, +; at 56,047 bytes, already is. +; +; The file is read TWICE, once per pass, and srcRewind is how the second pass starts over. +; Nothing is kept between the passes but the label table. +; +; Written by Anachronaut + +#Program + +; Opens the file DP0 names. Q is zero if it is there. +; +; The name is copied rather than pointed at, because the caller's copy is in the caller's +; memory and every later block read has to name the file again - there being no such thing +; as an open file to hold on to. +srcOpen: + SETD.1 SrcName + CALL srcKeepName + CALL srcRewind + RET + +; Back to the first character, for the second pass. +srcRewind: + SETD.0 SrcIndex + CALL numZero + SETD.0 SrcAt + CALL numZero + SETD.0 SrcCount + CALL numZero + RSTA + SETD.0 SrcEnded + STA.0 + + ; The line number counts from one, the way an editor does. + SETD.0 SrcLine + CALL numZero + SETD.0 SrcLine + CALL numStep + + ; Ask how big it is, which is both the answer to "is it there" and the thing that says + ; when to stop asking for blocks. + SETD.0 SrcName + SWI osFileInfo + BNQ srcRewindNo + PSHD.3 + POPB + POPA + SETD.0 SrcBlocks + STA.0 + INCD.0 + STB.0 + RSTA + RSTB + CCF + ADD ; Q is zero: it is there. + RET + +srcRewindNo: + INIA 0d1 + SETD.0 SrcEnded + STA.0 + RSTA + INIB 0d1 + CCF + ADD ; Q is not zero: it is not. + RET + +; The next character of the file, into SrcChar. Q is zero if there was one, and something +; else at the end of the file. +srcNext: + SETD.0 SrcEnded + LDA.0 + BNA srcAtEnd + + ; Is the buffer used up? SrcAt counts how far into it we have read and SrcCount how many + ; of its bytes are the file's, which is 256 for every block but a short last one. + SETD.0 SrcAt + SETD.2 SrcCount + CALL numCompare + BNQ srcHaveByte + CALL srcLoad + BNQ srcAtEnd + +srcHaveByte: + SETD.1 SrcPointer + LDD.0.1 + LDA.0 + INCD.0 + STD.0.1 + SETD.0 SrcChar + STA.0 + SETD.0 SrcAt + CALL numStep + + ; A newline is what makes the next character part of the next line. Counting it here, + ; as it is handed out, means the line number always describes the character just given. + SETD.0 SrcChar + LDA.0 + INIB 0x0A + XOR + BNQ srcNextDone + SETD.0 SrcLine + CALL numStep + +srcNextDone: + RSTA + RSTB + CCF + ADD ; Q is zero: there was a character. + RET + +srcAtEnd: + INIA 0d1 + SETD.0 SrcEnded + STA.0 + RSTA + INIB 0d1 + CCF + ADD ; Q is not zero: the file is finished. + RET + +; Fetches the block SrcIndex names, and steps SrcIndex past it. Q is zero if there was one. +; +; Running off the end is not a failure here: osFileBlock answers three for a block past the +; end of the file, which is how a reader finds out it has finished. Any other refusal is a +; real one, and both come back the same way because there is nothing useful to do about +; either except stop. +srcLoad: + SETD.0 SrcName + SETD.1 SrcBuffer + SETD.2 SrcIndex + LDA.2 + INCD.2 + LDB.2 + SWI osFileBlock + BNQ srcLoadNo + + ; DP3 says how many of the block's bytes belong to the file: a whole 256 except in a + ; short last one, which is why it comes back in a pointer and not a register. + PSHD.3 + POPB + POPA + SETD.0 SrcCount + STA.0 + INCD.0 + STB.0 + + SETD.0 SrcAt + CALL numZero + SETD.0 SrcIndex + CALL numStep + + ; The walking pointer starts at the front of the buffer again. + SETD.0 SrcBuffer + SETD.1 SrcPointer + STD.0.1 + + RSTA + RSTB + CCF + ADD + RET + +srcLoadNo: + RSTA + INIB 0d1 + CCF + ADD + RET + +; Copies the name at DP0 into DP1, up to 22 characters of it and the zero after them, +; which is as long as a name on this filesystem may be. +srcKeepName: + INIA 0d22 + SETD.2 SrcLeft + STA.2 +srcKeepLoop: + LDA.0 + BRA srcKeepEnd + STA.1 + INCD.0 + INCD.1 + LDA.2 + DECA + STA.2 + BNA srcKeepLoop +srcKeepEnd: + RSTA + STA.1 ; The zero that makes it a string. + RET + +#Data + +SrcName: + #Reserve 0d23 +SrcBlocks: + 0x00 0x00 +SrcIndex: + 0x00 0x00 +SrcCount: + 0x00 0x00 +SrcAt: + 0x00 0x00 +SrcLine: + 0x00 0x00 +SrcPointer: + 0x00 0x00 +SrcEnded: + 0x00 +SrcChar: + 0x00 +SrcLeft: + 0x00 + +; One block, which is the whole of what a source file costs in memory however big it is. +SrcBuffer: + #Reserve 0d256 diff --git a/Programs/CosmOS/Assembler/table.asm b/Programs/CosmOS/Assembler/table.asm new file mode 100644 index 0000000..21bb401 --- /dev/null +++ b/Programs/CosmOS/Assembler/table.asm @@ -0,0 +1,106 @@ +; The instruction set, as the assembler needs to see it. +; +; A SECOND COPY, and it is worth saying why rather than hoping nobody notices. The monitor +; has one of these in cosmos.asm, and the assembler cannot use it: the monitor's copy lives +; in the system's data at an address that moves every time CosmOS is rebuilt, and there is +; no linker to reach it by name. So the assembler carries its own 448 bytes. That is the +; cost of having no libraries, paid where it is cheapest to pay. +; +; Both copies are generated by Tests/instructiontable.py from the C assembler's own list, +; and Tests/docs.sh checks both against it. Neither can drift without the suite saying so. +; +; Seven bytes an entry: the opcode, the shape, and four characters of name with the zero +; the assembler puts after a string. Every mnemonic is four characters or fewer, so a name +; padded to four is an exact match rather than a prefix. +; +; It goes in the DATA Segment, because the assembler has to read it and an instruction can +; only read Data Memory. A table in Program Memory could not be reached by the program +; holding it, except through the memory controller. + +#Data + +; How many bytes an instruction of each shape runs to, the opcode included. The assembler +; does not use this to size a token - the operand that follows is a token of its own and +; carries its own length - but it is what says an instruction is well formed. +AsmShapeLength: + 0d1 0d3 0d2 0d2 0d3 0d4 0d3 + +; How many Data Pointer selectors an instruction of each shape names. This is what the +; assembler needs: a selector is part of the mnemonic rather than a token after it, so it +; is the one thing about an instruction's length that is not settled by the opcode alone. +; +; 0 no operand 4 a selector and a byte +; 1 an address 5 a selector and an address, which is SETD +; 2 a byte 6 two selectors, which is LDD and STD +; 3 a selector +AsmShapeSelectors: + 0d0 0d0 0d0 0d1 0d1 0d1 0d2 + +AsmInstructionCount: + 0d64 + +AsmInstructions: + 0x00 0d0 "ADD " + 0x01 0d0 "SUB " + 0x02 0d0 "AND " + 0x03 0d0 "OR " + 0x04 0d0 "XOR " + 0x05 0d0 "NOTA" + 0x06 0d0 "NOTB" + 0x07 0d0 "SHL " + 0x08 0d0 "SHR " + 0x10 0d1 "BRI " + 0x11 0d1 "BRQ " + 0x12 0d1 "BRA " + 0x13 0d1 "BRB " + 0x14 0d1 "BRC " + 0x15 0d3 "BRD " + 0x1A 0d1 "BNQ " + 0x1B 0d1 "BNA " + 0x1C 0d1 "BNB " + 0x1D 0d1 "BNC " + 0x17 0d1 "CALL" + 0x18 0d2 "SWI " + 0x19 0d0 "RETI" + 0x1F 0d0 "RET " + 0x20 0d0 "RSTA" + 0x21 0d0 "RSTB" + 0x22 0d0 "INCA" + 0x23 0d0 "INCB" + 0x24 0d0 "DECA" + 0x25 0d0 "DECB" + 0x26 0d2 "INIA" + 0x27 0d2 "INIB" + 0x28 0d0 "CCF " + 0x29 0d0 "MVQA" + 0x2A 0d0 "MVQB" + 0x2B 0d0 "SIF " + 0x2C 0d0 "CIF " + 0x30 0d0 "PSHQ" + 0x31 0d0 "PSHA" + 0x32 0d0 "PSHB" + 0x33 0d3 "PSHD" + 0x34 0d0 "POPA" + 0x35 0d0 "POPB" + 0x36 0d3 "POPD" + 0x40 0d3 "INCD" + 0x41 0d3 "DECD" + 0x42 0d3 "LDA " + 0x43 0d3 "LDB " + 0x44 0d3 "STQ " + 0x45 0d3 "STA " + 0x46 0d3 "STB " + 0x47 0d5 "SETD" + 0x48 0d4 "DPUP" + 0x49 0d4 "DPDN" + 0x4A 0d6 "LDD " + 0x4B 0d6 "STD " + 0x4C 0d3 "MVSD" + 0x4D 0d3 "MVDS" + 0xD0 0d2 "OUTQ" + 0xD1 0d2 "OUTA" + 0xD2 0d2 "OUTB" + 0xE0 0d2 "INA " + 0xE1 0d2 "INB " + 0xF0 0d0 "NOP " + 0xFF 0d0 "HALT" diff --git a/Programs/CosmOS/Assembler/token.asm b/Programs/CosmOS/Assembler/token.asm new file mode 100644 index 0000000..20b8463 --- /dev/null +++ b/Programs/CosmOS/Assembler/token.asm @@ -0,0 +1,249 @@ +; Tokens out of characters. +; +; A token is a run of characters with whitespace or a comment on either side, or anything +; between a pair of quotes. That is the whole of the lexical grammar: SplitBit assembly has +; no operators, no punctuation and no line continuation, so there is nothing here that has +; to look ahead more than one character. +; +; ONE CHARACTER OF LOOKAHEAD, and it is held here rather than in the reader. A word ends +; when something that is not part of it turns up, and that something has already been read +; by the time anyone knows - so it is put in TokPending and taken again next time. Keeping +; it at this level rather than pushing it back into the reader means the line number needs +; no arithmetic: srcNext counted the newline when it handed it out, and it stays counted. +; +; Zero means "nothing held", which is safe because a source file is text and a text file +; has no zero bytes in it. A file that did would be rejected as unassemblable long before +; the difference showed. +; +; Written by Anachronaut + +#Program + +; The next token, into TokText with a zero after it. Q is zero if there was one. +; +; TokLength is how long it is, TokString says whether it arrived in quotes, and TokLine is +; the line it STARTED on - captured before the token is read, because a token ending in a +; newline has already moved the reader on to the next line by the time it is finished. +tokNext: + RSTA + SETD.0 TokString + STA.0 + +tokSkip: + CALL tokGet + BNQ tokEnded + CALL tokIsSpace + BRQ tokSkip + + SETD.0 TokChar + LDA.0 + INIB 0x3B ; A semicolon starts a comment. + XOR + BNQ tokBegin + +tokComment: + CALL tokGet + BNQ tokEnded + SETD.0 TokChar + LDA.0 + INIB 0x0A + XOR + BNQ tokComment ; Everything up to the newline belongs to the comment. + BRI tokSkip + +tokBegin: + ; Where it starts, for anything that has to complain about it later. + SETD.0 TokLine + SETD.2 SrcLine + CALL numSet + + RSTA + SETD.0 TokLength + STA.0 + SETD.0 TokText + SETD.1 TokPointer + STD.0.1 + + SETD.0 TokChar + LDA.0 + INIB 0x22 ; A quote starts a string. + XOR + BNQ tokWord + + INIA 0d1 + SETD.0 TokString + STA.0 + +tokStringLoop: + CALL tokGet + BNQ tokDone ; The file ended inside a string. Take what there is; the + ; classifier will have something to complain about. + SETD.0 TokChar + LDA.0 + INIB 0x22 + XOR + BRQ tokDone + CALL tokAppend + BRI tokStringLoop + +tokWord: + CALL tokAppend +tokWordLoop: + CALL tokGet + BNQ tokDone + CALL tokIsSpace + BRQ tokHoldDone + SETD.0 TokChar + LDA.0 + INIB 0x3B + XOR + BRQ tokHoldDone ; A comment butting straight up against a word ends it. + CALL tokAppend + BRI tokWordLoop + +tokHoldDone: + ; Whatever ended the word was not part of it, so it goes back to be looked at again. + SETD.0 TokChar + LDA.0 + SETD.0 TokPending + STA.0 + +tokDone: + SETD.1 TokPointer + LDD.0.1 + RSTA + STA.0 ; The zero that makes it a string. + RSTA + RSTB + CCF + ADD ; Q is zero: there was a token. + RET + +tokEnded: + RSTA + SETD.0 TokLength + STA.0 + SETD.0 TokText + STA.0 + RSTA + INIB 0d1 + CCF + ADD ; Q is not zero: the source is finished. + RET + +; The next character, into TokChar. Q is zero if there was one. Takes the held one first. +tokGet: + SETD.0 TokPending + LDA.0 + BRA tokGetFresh + SETD.0 TokChar + STA.0 + RSTA + SETD.0 TokPending + STA.0 + RSTA + RSTB + CCF + ADD + RET + +tokGetFresh: + CALL srcNext + BNQ tokGetNone + SETD.0 SrcChar + LDA.0 + SETD.0 TokChar + STA.0 + RSTA + RSTB + CCF + ADD + RET + +tokGetNone: + RSTA + INIB 0d1 + CCF + ADD + RET + +; Adds TokChar to the token being built, unless it is already as long as one may be. +; +; A token that runs over is truncated rather than refused, and the classifier refuses it +; afterwards: nothing 255 characters long is a valid mnemonic, literal or label, so the +; error that comes out names what was wrong with it rather than only how long it was. +tokAppend: + SETD.0 TokLength + LDA.0 + INIB 0xFF + XOR + BRQ tokAppendFull + SETD.1 TokPointer + LDD.0.1 + SETD.2 TokChar + LDA.2 + STA.0 + INCD.0 + STD.0.1 + SETD.0 TokLength + LDA.0 + INCA + STA.0 +tokAppendFull: + RET + +; Q is zero if TokChar is whitespace: a space, or anything in the run from tab to carriage +; return, which is what the C library calls a space and what the other assembler uses. +tokIsSpace: + SETD.0 TokChar + LDA.0 + INIB 0x20 + XOR + BRQ tokSpaceYes + + SETD.0 TokChar + LDA.0 + INIB 0x09 + CCF + SUB + BRC tokSpaceNo ; Below a tab. + SETD.0 TokChar + LDA.0 + INIB 0x0E + CCF + SUB + BNC tokSpaceNo ; Past a carriage return. + +tokSpaceYes: + RSTA + RSTB + CCF + ADD + RET + +tokSpaceNo: + RSTA + INIB 0d1 + CCF + ADD + RET + +#Data + +TokLine: + 0x00 0x00 +TokPointer: + 0x00 0x00 +TokLength: + 0x00 +TokString: + 0x00 +TokChar: + 0x00 +TokPending: + 0x00 + +; As long as a token may be, and one more for the zero. The other assembler stops at the +; same 255, and the limit is worth matching rather than choosing again. +TokText: + #Reserve 0d256 diff --git a/Programs/CosmOS/Assembler/tokenTest.asm b/Programs/CosmOS/Assembler/tokenTest.asm new file mode 100644 index 0000000..b46b1d2 --- /dev/null +++ b/Programs/CosmOS/Assembler/tokenTest.asm @@ -0,0 +1,178 @@ +; The assembler's front end on its own, one line per token. +; +; Prints the line each token started on, what the token turned out to be, how many bytes +; it will come to, and the token itself between brackets so that whitespace at either end +; would show if any ever leaked in. +; +; WHAT A TOKEN IS is the part worth checking here rather than at the far end. A wrong +; classification does not produce a wrong byte in an obvious place - it produces a right +; looking program of the wrong length, with everything after it shifted, and by then the +; only symptom is that a label points at the middle of an instruction. +; +; Written by Anachronaut + +#Include services.asm + +#Program + + #Base 0x2000 + +start: + SETD.0 Wanted + INIB 0d23 + SWI osArgument + SETD.0 Wanted + LDA.0 + BRA nothingAsked + + SETD.0 Wanted + CALL srcOpen + BNQ noFile + +tokenLoop: + CALL tokNext + BNQ tokensDone + + SETD.0 TokLine + LDA.0 + INCD.0 + LDB.0 + SWI osPrintNumber + + CALL clsToken + BNQ badToken + + ; Which of the six it turned out to be. The names are four characters and a space, so + ; the columns line up without any counting. + SETD.0 TypeNames + SETD.2 ClsType + LDA.2 + CALL nameOfType + SETD.1 NamePointer + LDD.0.1 + SWI osPrintString + + SETD.0 ClsLength + LDA.0 + RSTB + PSHA + POPB + RSTA + SWI osPrintNumber + + SETD.0 OpenMark + SWI osPrintString + SETD.0 TokText + SWI osPrintString + SETD.0 CloseMark + SWI osPrintString + BRI tokenLoop + +badToken: + SETD.0 StoppedText + SWI osPrintString + SWI osExit + +; The name of type A, out of a table of fixed width entries so that no pointer arithmetic +; is needed beyond a multiply by the width. +nameOfType: + SETD.0 TypeWidth + LDB.0 + RSTA + SETD.0 NameLeft + STA.0 + SETD.2 ClsType + LDA.2 + SETD.0 NameOffset + CALL numZero +nameLoop: + SETD.2 ClsType + LDA.2 + SETD.0 NameLeft + LDB.0 + CCF + SUB + BRQ nameFound + SETD.0 TypeWidth + LDA.0 + SETD.0 NameOffset + CALL numAddByte + SETD.0 NameLeft + LDA.0 + INCA + STA.0 + BRI nameLoop +nameFound: + ; The answer is left in NamePointer rather than in DP0, because a RET puts Data Pointers + ; 0 to 2 back as they were: a routine cannot hand back a pointer, only write one down. + SETD.0 TypeNames + SETD.1 NamePointer + STD.0.1 + SETD.0 NamePointer + SETD.2 NameOffset + CALL numAdd + RET + +tokensDone: + SETD.0 DoneText + SWI osPrintString + SWI osExit + +nothingAsked: + SETD.0 AskText + SWI osPrintString + SWI osExit + +noFile: + SETD.0 NoFileText + SWI osPrintString + SWI osExit + +#Data + + #Base 0x1000 + +Wanted: + #Reserve 0d23 + +OpenMark: +" [" +CloseMark: +"] +" +; Six names of eleven characters each, counting the zero the assembler puts on the end of +; every string. Written one to a line so that adding a type is adding a line. +TypeNames: +" keyword " +" instr " +" value " +" string " +" label: " +" label " +TypeWidth: + 0d11 +NameLeft: + 0x00 +NameOffset: + 0x00 0x00 +NamePointer: + 0x00 0x00 + +StoppedText: +"---- stopped: the assembler does not understand that +" +DoneText: +"---- no more tokens +" +AskText: +"say which file +" +NoFileText: +"no such file +" + +#Include numbers.asm +#Include source.asm +#Include token.asm +#Include classify.asm +#Include table.asm diff --git a/Programs/CosmOS/README.md b/Programs/CosmOS/README.md index 10b30e7..7edb87e 100644 --- a/Programs/CosmOS/README.md +++ b/Programs/CosmOS/README.md @@ -184,6 +184,12 @@ the services CosmOS provides. The currently installed services are: | `osFileInfo` | DP0 names a file; Q reports whether it is there and DP3 returns how many blocks it occupies. | | `osFileBlock` | DP0 names a file, DP1 a destination, and A with B give which block; Q reports success and DP3 returns how many of the block's bytes belong to the file. | +The largest application CosmOS has is the assembler in `Programs/CosmOS/Assembler/`, which +is why the streaming services below exist: it reads source a block at a time, twice, and +keeps only its label table in between. It travels with CosmOS rather than with the emulator, +for the same reason the C assembler travels with the emulator - it is part of the system it +is written for. + `osFileInfo` and `osFileBlock` are how an application reads a file too big to hold. A whole file arrives through `osFileRead`, which cannot help with anything above 64K, and CosmOS's own source is above it. Neither call keeps anything open: each one names the file and says diff --git a/README.md b/README.md index 8991662..7bc0358 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ SplitBit is a custom 8 bit system designed for hobbyist projects and experimenta - Loadable Programs: A program that was not booted from carries a header saying where it belongs, and Programs/loader.asm reads one off a disk, puts it there, and runs it. - An Operating System: CosmOS boots the machine, mounts a disk, lists what is on it, loads a program and runs it, and takes the machine back when it finishes. It comes with a library of programs to run, including a game and a line editor that writes files a person typed. - System Services: A loaded program reaches the console and the disk through numbered software interrupts rather than carrying a copy of the code that drives them. The numbers are written down in one file that both sides include, so neither ever types one. It took the editor from 4941 bytes to 1983 without changing a line of what it does. +- A Native Assembler: SplitBit assembles SplitBit. Programs/CosmOS/Assembler/ is an assembler written in SplitBit assembly that runs under CosmOS, reads source off a SplitBit disk, and writes a binary back to it with no host involved. Its output has to be byte for byte identical to what the C assembler produces from the same source, which is what Tests/native.sh checks. - Streaming Reads: A file bigger than the machine's memory is read a block at a time, through services that keep nothing open between calls. CosmOS's own source is 104K against 64K of Data Memory, so this is what a self-hosted assembler will stand on. - Storage: A block device with 256 byte blocks and 16 megabytes of them, backed by an image file on the host. It knows blocks and not files, because a filesystem is meant to be software SplitBit runs. - Memory Controller: Reads and writes Program Memory, moves blocks between memory banks, reaches memory that devices bring with them, and guards a range against being written by accident. It is how a SplitBit machine loads a program. diff --git a/SplitBit Assembler Manual.md b/SplitBit Assembler Manual.md index 0c04656..20663d0 100644 --- a/SplitBit Assembler Manual.md +++ b/SplitBit Assembler Manual.md @@ -478,3 +478,29 @@ Broken: The output is `ready`, `trap`, then `device`. Note that a vector name and a routine name are kept apart, so naming both `announce` is allowed. If that reads as confusing, name them differently: nothing requires them to match. + +## The Assembler That Runs On SplitBit: + +There are two assemblers now. This manual has been describing the one that runs on a host and writes a file; `Programs/CosmOS/Assembler/` holds one written in SplitBit assembly that runs on the machine itself, under CosmOS, and reads its source off a SplitBit disk. + +``` +> load Asm.sbx +> run Asm.sbx hello.asm +wrote hello.bin: program 17, data 14, labels 2 +``` + +**Its output must be byte for byte what the host assembler produces from the same source**, and `Tests/native.sh` checks exactly that: it assembles `Programs/hello.asm` both ways and compares the files, then runs the one the machine built. This is the discipline SplitDisk and `sbfs.asm` already work under — two implementations of one written specification, each one checking the other. "It ran" is not good enough for an assembler, because a binary with a label one byte out runs right up until it jumps into the middle of an instruction. + +### How It Differs Inside: + +The host assembler reads every token of every file into one array and works on that. **That design cannot port and never could**: `cosmos.asm` alone is 56,047 bytes of source against 64K of Data Memory, and its token array would be several times that. So the native one streams its source through a 256 byte window, twice, and keeps only the label table between the passes. + +Two passes are enough because **every length is known without resolving anything**. How many bytes a token comes to falls out of what the token is — an instruction's from its shape, a value's is one, a string's is its characters and a zero — and never from the value of anything named. So the first pass works out exactly where every label lands and the second never needs a fixup list. A forward reference stops being a special case and becomes the reason there are two passes at all. + +One thing is genuinely easier here than on a host. The host assembler searches a list of include directories, because a host has directories; **SBFS is flat**, so an include is a file name and there is nowhere else to look. + +### What It Does Not Do Yet: + +`#Include`, `#Base`, `#Align`, `#Reserve` and `#Vectors` are **refused by name** rather than ignored. An assembler that quietly skipped a directive would produce a file that looked right and was the wrong length, which is the worst thing it could do; being told "this assembler does not understand that directive yet" costs nothing and hides nothing. + +So what it assembles today is a single file with no includes, which is `Programs/hello.asm` — the oldest program in the repository, and now the first one the machine assembles for itself. diff --git a/Tests/docs.sh b/Tests/docs.sh index d208e50..0c58922 100755 --- a/Tests/docs.sh +++ b/Tests/docs.sh @@ -241,21 +241,30 @@ if generated.returncode != 0: problems.append("the instruction table generator would not run") else: wanted = [line.rstrip() for line in generated.stdout.splitlines() if line.strip()] - monitor = read("Programs/CosmOS/Source/cosmos.asm") - if "\nInstructions:\n" not in monitor: - problems.append("the system has lost its instruction table") - else: - block = monitor.split("\nInstructions:\n")[1] + # TWO copies now, and both are checked. The monitor has one and the assembler that + # runs on the machine has another, because they are separate programs and there is no + # linker to let them share: the monitor's lives in the system's data at an address + # that moves every rebuild. Duplication is the cost of having no libraries, and a + # check on every copy is what keeps the cost to bytes rather than to correctness. + copies = [("the system", "Programs/CosmOS/Source/cosmos.asm", "\nInstructions:\n"), + ("the native assembler", "Programs/CosmOS/Assembler/table.asm", + "\nAsmInstructions:\n")] + for who, path, marker in copies: + text = read(path) + if marker not in text: + problems.append("%s has lost its instruction table" % who) + continue + block = text.split(marker)[1] have = [] for line in block.splitlines(): if not line.strip() or not line.startswith(" 0x"): break have.append(line.rstrip()) if have != wanted: - problems.append("the system's instruction table is not what the assembler's" + problems.append("%s's instruction table is not what the assembler's" " instruction set generates: %d entries against %d, first" " difference at %s" - % (len(have), len(wanted), + % (who, len(have), len(wanted), next((a or b for a, b in zip(have + [None] * len(wanted), wanted + [None] * len(have)) if a != b), "the end"))) diff --git a/Tests/expected/cosmosSource.out b/Tests/expected/cosmosSource.out new file mode 100644 index 0000000..1ec6d74 --- /dev/null +++ b/Tests/expected/cosmosSource.out @@ -0,0 +1,27 @@ +CosmOS +> loaded, starting at 2000 +> ; This is a basic hello world program for the SplitBit CPU. +; We'll create a loop that outputs each byte of our string to Output 0, the text console. + +#Program + +Start: + LDA ; Load a byte of the string into A. + BRA End ; If A is zero, branch out of the loop. + OUTA 0x00 ; Output the value in A to Port 0, the text console. + INCD ; Increment the Data Pointer to the next byte of the string. + BRI Start ; Branch immediately to the start of the loop. + +End: + INIA 0x0A ; We'll load a linefeed into A and output it to make it look nice. + OUTA 0x00 ; Output it to the text console. + HALT ; Terminate the program. + +#Data + +"Hello, World!" +---- lines: 21 +finished +> halted +Execution halted. +[exit 0] diff --git a/Tests/expected/cosmosTokens.out b/Tests/expected/cosmosTokens.out new file mode 100644 index 0000000..69e6f46 --- /dev/null +++ b/Tests/expected/cosmosTokens.out @@ -0,0 +1,25 @@ +CosmOS +> loaded, starting at 2000 +> 4 keyword 0 [#Program] +6 label: 0 [Start:] +7 instr 2 [LDA] +8 instr 1 [BRA] +8 label 2 [End] +9 instr 1 [OUTA] +9 value 1 [0x00] +10 instr 2 [INCD] +11 instr 1 [BRI] +11 label 2 [Start] +13 label: 0 [End:] +14 instr 1 [INIA] +14 value 1 [0x0A] +15 instr 1 [OUTA] +15 value 1 [0x00] +16 instr 1 [HALT] +18 keyword 0 [#Data] +20 string 14 [Hello, World!] +---- no more tokens +finished +> halted +Execution halted. +[exit 0] diff --git a/Tests/input/cosmosSource.in b/Tests/input/cosmosSource.in new file mode 100644 index 0000000..03af1ec --- /dev/null +++ b/Tests/input/cosmosSource.in @@ -0,0 +1,3 @@ +load readTest.sbx +run hello.asm +exit diff --git a/Tests/input/cosmosTokens.in b/Tests/input/cosmosTokens.in new file mode 100644 index 0000000..e0cd6a8 --- /dev/null +++ b/Tests/input/cosmosTokens.in @@ -0,0 +1,3 @@ +load tokenTest.sbx +run hello.asm +exit diff --git a/Tests/makedisks.sh b/Tests/makedisks.sh index 10da6a4..d83fc36 100755 --- a/Tests/makedisks.sh +++ b/Tests/makedisks.sh @@ -160,3 +160,21 @@ awk 'BEGIN { for (i = 0; i < 50; i++) printf "small %05d\n", i }' > small.txt "$ROOT/Assembler" -I "$ROOT/Programs/CosmOS/Source" \ "$ROOT/Programs/CosmOS/Apps/Stream.asm" -o "$WORK/Stream.sbx" >/dev/null "$TOOL" put "$DISKS/stream.img" "$WORK/Stream.sbx" >/dev/null + +# A disk for the assembler that runs on the machine. It holds a source file and the two +# programs that check the front end - the reader on its own and the tokenizer on its own - +# because a fault in either of those would otherwise turn up much later as a mysterious +# wrong byte in an output file. +# +# hello.asm is here rather than something written for the occasion because it is the +# oldest program in the repository: the first thing this machine ever ran is the first +# thing it assembles for itself. +"$TOOL" format "$DISKS/asm.img" 2048 4 >/dev/null +cp "$ROOT/Programs/hello.asm" hello.asm +"$TOOL" put "$DISKS/asm.img" hello.asm >/dev/null +"$ROOT/Assembler" -I "$ROOT/Programs/CosmOS/Source" -I "$ROOT/Programs/CosmOS/Assembler" \ + "$ROOT/Programs/CosmOS/Assembler/readTest.asm" -o "$WORK/readTest.sbx" >/dev/null +"$TOOL" put "$DISKS/asm.img" "$WORK/readTest.sbx" >/dev/null +"$ROOT/Assembler" -I "$ROOT/Programs/CosmOS/Source" -I "$ROOT/Programs/CosmOS/Assembler" \ + "$ROOT/Programs/CosmOS/Assembler/tokenTest.asm" -o "$WORK/tokenTest.sbx" >/dev/null +"$TOOL" put "$DISKS/asm.img" "$WORK/tokenTest.sbx" >/dev/null diff --git a/Tests/manifest b/Tests/manifest index ea90ea0..363d695 100644 --- a/Tests/manifest +++ b/Tests/manifest @@ -316,6 +316,18 @@ cosmosServices | CosmOS/Source/cosmos.asm | run | cosmosFil # notice the file moved - and that one would otherwise pass, since the blocks are still # there and still hold the same bytes. cosmosStream | CosmOS/Source/cosmos.asm | run | cosmosStream.in | - | disks/stream.img +# The assembler's front end, checked in two pieces before anything is built on it. +# +# cosmosSource reads a source file and prints it back. The file crosses a block boundary, +# so the seam is exercised rather than assumed, and the line count at the end says the +# reader knows where it is as well as what it holds. +# +# cosmosTokens says what each token IS. That is the part worth checking here rather than at +# the far end: a wrong classification does not produce a wrong byte somewhere obvious, it +# produces a right looking program of the wrong length with everything after it shifted, +# and by then the only symptom is a label pointing into the middle of an instruction. +cosmosSource | CosmOS/Source/cosmos.asm | run | cosmosSource.in | - | disks/asm.img +cosmosTokens | CosmOS/Source/cosmos.asm | run | cosmosTokens.in | - | disks/asm.img # The programs CosmOS loads, checked on their own so that a failure here reads as "the app # does not assemble" rather than as a broken disk image. app-greet | CosmOS/Apps/greet.asm | assemble | - | - @@ -328,6 +340,11 @@ app-Break | CosmOS/Apps/Break.asm | assemble | - app-Edit | CosmOS/Apps/Edit.asm | assemble | - | - app-Files | CosmOS/Apps/Files.asm | assemble | - | - app-Stream | CosmOS/Apps/Stream.asm | assemble | - | - +# The assembler that runs on the machine, and its parts. Checked on their own so that a +# failure reads as "it does not assemble" rather than as a broken disk image. +asm-Asm | CosmOS/Assembler/Asm.asm | assemble | - | - +asm-readTest | CosmOS/Assembler/readTest.asm | assemble | - | - +asm-tokenTest | CosmOS/Assembler/tokenTest.asm | assemble | - | - # ---- Programs driven by console input ---- inputTest | inputTest.asm | run | inputTest.in | - diff --git a/Tests/native.sh b/Tests/native.sh new file mode 100755 index 0000000..83344a3 --- /dev/null +++ b/Tests/native.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Checks the assembler that runs on SplitBit against the one that runs on the host. +# +# THE ONLY HONEST TEST OF AN ASSEMBLER IS THE BYTES IT PRODUCES. "It ran" and "the sizes +# look right" both pass for a binary with a label one byte out, which is a program that +# jumps into the middle of an instruction. So this assembles the same source both ways and +# compares the two files byte for byte, the same discipline SplitDisk and sbfs.asm work +# under: two implementations of one written specification, each one checking the other. +# +# Then it runs what the machine built, because a file that matches and does not work would +# mean both assemblers were wrong together. +# +# Written by Anachronaut + +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WORK="$ROOT/Tests/build/native" +ASM="$ROOT/Assembler" +TOOL="$ROOT/SplitDisk" +EMU="$ROOT/SplitBit" + +PASS=0 +FAIL=0 +FAILED_NAMES=() + +check() { + local name="$1" + shift + if "$@"; then + printf ' [ok ] %-22s %s\n' "$name" "${REPORT:-}" + PASS=$((PASS + 1)) + else + printf ' [FAIL] %-22s %s\n' "$name" "${REPORT:-}" + FAIL=$((FAIL + 1)) + FAILED_NAMES+=("$name") + fi + REPORT="" +} + +for tool in "$ASM" "$TOOL" "$EMU"; do + [ -x "$tool" ] || { echo "$(basename "$tool") is not built."; exit 1; } +done + +rm -rf "$WORK" +mkdir -p "$WORK" + +# The system it runs under, and the assembler itself, both built by the host assembler. +# Bootstrapping has to start somewhere, and this is the last place it does. +"$ASM" -I "$ROOT/Programs/Libraries" -I "$ROOT/Programs/CosmOS/Source" \ + -o "$WORK/cosmos.bin" "$ROOT/Programs/CosmOS/Source/cosmos.asm" >/dev/null || exit 1 +"$ASM" -I "$ROOT/Programs/CosmOS/Source" -I "$ROOT/Programs/CosmOS/Assembler" \ + -o "$WORK/Asm.sbx" "$ROOT/Programs/CosmOS/Assembler/Asm.asm" >/dev/null || exit 1 + +"$TOOL" format "$WORK/native.img" 2048 4 >/dev/null +"$TOOL" put "$WORK/native.img" "$ROOT/Programs/hello.asm" hello.asm >/dev/null +"$TOOL" put "$WORK/native.img" "$WORK/Asm.sbx" Asm.sbx >/dev/null + +printf 'load Asm.sbx\nrun hello.asm\nexit\n' \ + | "$EMU" --fast --cycles 50000000 -D "$WORK/native.img" "$WORK/cosmos.bin" \ + > "$WORK/session.txt" 2>&1 + +# ---- It got as far as writing something ---- +check "it wrote a file" grep -q "wrote hello.bin" "$WORK/session.txt" + +# ---- And the file is the one the host assembler makes ---- +"$ASM" -o "$WORK/reference.bin" "$ROOT/Programs/hello.asm" >/dev/null +"$TOOL" get "$WORK/native.img" hello.bin "$WORK/native.bin" >/dev/null 2>&1 +if [ -f "$WORK/native.bin" ]; then + REPORT="$(wc -c < "$WORK/native.bin" | tr -d ' ') bytes" +fi +check "byte for byte" cmp -s "$WORK/native.bin" "$WORK/reference.bin" + +# ---- And what it built actually runs ---- +"$EMU" --fast --cycles 100000 "$WORK/native.bin" > "$WORK/ran.txt" 2>&1 +check "and it runs" grep -q "^Hello, World!$" "$WORK/ran.txt" + +# ---- The report it printed says what it did ---- +REPORT="$(grep -o 'program [0-9]*, data [0-9]*, labels [0-9]*' "$WORK/session.txt" || true)" +check "it counted right" grep -q "program 17, data 14, labels 2" "$WORK/session.txt" + +echo +if [ "$FAIL" -eq 0 ]; then + echo "All $PASS native assembler checks passed." +else + echo "$PASS passed, $FAIL failed: ${FAILED_NAMES[*]}" + echo + echo "The session was:" + sed 's/^/ /' "$WORK/session.txt" + exit 1 +fi diff --git a/makefile b/makefile index 61e2bcc..2619574 100644 --- a/makefile +++ b/makefile @@ -78,6 +78,8 @@ test: $(EMU_TARGET) $(ASM_TARGET) $(DSK_TARGET) @echo @./Tests/terminal.sh @echo + @./Tests/native.sh + @echo @./Tests/docs.sh # Rebuild both tools with the address and undefined behaviour sanitizers and run