diff --git a/Programs/CosmOS/Source/sbfs.asm b/Programs/CosmOS/Source/sbfs.asm index 691530d..e0cc7b5 100644 --- a/Programs/CosmOS/Source/sbfs.asm +++ b/Programs/CosmOS/Source/sbfs.asm @@ -1727,7 +1727,7 @@ sbfsWriteBlock: OUTA 0x21 INIA 0x02 OUTA 0x22 - INA 0x23 + RCAL sbfsWaitDisk INIB 0x02 AND RET @@ -1742,11 +1742,30 @@ sbfsReadBlock: OUTA 0x21 INIA 0x01 OUTA 0x22 - INA 0x23 + RCAL sbfsWaitDisk INIB 0x02 AND ; Q is the error bit, so zero means it worked. RET +; ---- Waiting for the disk ---- +; +; The status port has a bit that means the operation is still going, and until now nothing +; here looked at it: the answer was always there before the next instruction was, so asking +; would have been asking about something that could not happen. A disk that takes any time +; at all makes it real, and a program that does not wait reads the block BEFORE the one it +; asked for - which is not an error anywhere, just quietly the wrong bytes. +; +; A REACHES THE CALLER, which is why this is RCAL and not CALL. What comes back is the +; settled status, and CALL would put A back the way it found it - so the one thing this +; exists to hand over is the one thing an ordinary call cannot carry. Two bytes of Stack +; instead of ten, as well, in a routine that runs on every block the machine ever touches. +sbfsWaitDisk: + INA 0x23 + INIB 0x01 + AND + BNQ sbfsWaitDisk ; The busy bit is up, so round again. + RRET ; A is the status, with the busy bit down. + ; Copies the disk's buffer, a whole block of it, to Data Memory at DP1. sbfsBufferOut: INIA 0d3 diff --git a/README.md b/README.md index e8ed6c0..0e6e69f 100644 --- a/README.md +++ b/README.md @@ -113,12 +113,23 @@ writes that set it up and nothing for the quarter of a kilobyte that moved. The transfer stalls the program that asked for it. Whether hardware would let the two run at once is left open, the same way pipelining is: the memories are separate, so it plausibly -could, and the measurements say it would buy less than it sounds like. Whether real hardware would overlap a fetch with the end of the +could, and the measurements say it would buy less than it sounds like. + +**The disk can be given a latency** with `--disk-cycles`, and then it really does take that +long: it says busy, finishes when the machine has run that far, and a program that does not +wait reads the block *before* the one it asked for. That is not an error anywhere - just +quietly the wrong bytes - which is why the filesystem now watches the busy bit rather than +trusting the answer to be there. Zero is the default and is how the machine has always run. + +The waiting is one small routine, and it is reached with `RCAL` rather than `CALL` because +what it hands back is the settled status in A, and an ordinary call would put A back the way +it found it. Whether real hardware would overlap a fetch with the end of the previous instruction is left open, and deliberately: this is the conservative model, and pipelining is a decision to make while drawing the hardware rather than one to inherit from an emulator. | `-f`, `--fast` | Run as fast as the host allows, ignoring the emulated cycle rate. | | `-D`, `--disk ` | Attach a disk image, creating a 128K one if the file is not there. | +| `-L`, `--disk-cycles N` | How many cycles a block read or write takes. Zero, the default, finishes before the next instruction starts. | | `-W`, `--write-protect` | Attach the disk read only. A disk whose image the host will not let you write is read only whether you ask for this or not. | | `-h`, `--help` | Show help and usage information. | diff --git a/Source/Emulator/emulator.c b/Source/Emulator/emulator.c index e897eda..9d9b382 100644 --- a/Source/Emulator/emulator.c +++ b/Source/Emulator/emulator.c @@ -104,6 +104,7 @@ int main (int argc, char *argv[]) { } CycleTimer timer; + setDiskLatency(options.diskCycles); cycle_timer_init(&timer, CYCLE_RATE); uint8_t limitReached = 0; @@ -137,6 +138,8 @@ int main (int argc, char *argv[]) { unsigned long took = cpu.busCycles - before; spent += (long)took; cycleCount += took; + // Time has passed, so anything waiting on it may be finished. + deviceTick(cycleCount); if (cpu.Status & STATUS_HALT) { // We've halted. break; diff --git a/Source/Emulator/io.c b/Source/Emulator/io.c index 001797e..ec98561 100644 --- a/Source/Emulator/io.c +++ b/Source/Emulator/io.c @@ -382,6 +382,22 @@ uint8_t refusingPort(void) { static FILE *diskImage = NULL; static uint32_t diskBlockCount = 0; static uint8_t diskBuffer[DISK_BLOCK_BYTES]; + +// ---- A disk that takes time ---- +// +// The command is checked at once, because a refusal is not work: asking for a block that +// is not there, or writing to a protected disk, fails before any head moves. What takes +// time is the transfer, so that is remembered here and done when the machine has run far +// enough - and until then the buffer holds the block BEFORE this one, which is exactly +// what a program that ignores the busy bit deserves to read. +// The machine's clock as devices see it, which the emulator advances as the CPU spends +// cycles. A device says when it will be finished in these, and is believed. +static unsigned long deviceNow = 0; +static void diskTransfer(uint8_t command); + +static unsigned long diskLatency = 0; +static unsigned long diskReadyAt = 0; +static uint8_t diskPending = 0; static uint16_t diskBlock = 0; static uint8_t diskStatus = 0; static uint8_t diskProtected = 0; @@ -458,23 +474,55 @@ static void diskCommand(uint8_t command) { raiseInterrupt(PORT_DISK); return; } - size_t moved = 0; - if (command == DISK_COMMAND_READ) { - moved = fread(diskBuffer, 1, DISK_BLOCK_BYTES, diskImage); - } else if (command == DISK_COMMAND_WRITE) { - moved = fwrite(diskBuffer, 1, DISK_BLOCK_BYTES, diskImage); - fflush(diskImage); - } else { + if (command != DISK_COMMAND_READ && command != DISK_COMMAND_WRITE) { diskStatus |= DISK_STATUS_ERROR; raiseInterrupt(PORT_DISK); return; } + if (diskLatency == 0) { + diskTransfer(command); + return; + } + // It is going to take a while. Say so, and remember what to do when it is over. + diskStatus |= DISK_STATUS_BUSY; + diskPending = command; + diskReadyAt = deviceNow + diskLatency; +} + +// The transfer itself, whenever it happens to happen. The seek is done here rather than at +// the command, because nothing else may touch the image in between and doing it twice is +// the same answer. +static void diskTransfer(uint8_t command) { + size_t moved = 0; + long offset = (long)diskBlock * DISK_BLOCK_BYTES; + if (fseek(diskImage, offset, SEEK_SET) != 0) { + diskStatus |= DISK_STATUS_ERROR; + } else if (command == DISK_COMMAND_READ) { + moved = fread(diskBuffer, 1, DISK_BLOCK_BYTES, diskImage); + } else { + moved = fwrite(diskBuffer, 1, DISK_BLOCK_BYTES, diskImage); + fflush(diskImage); + } if (moved != DISK_BLOCK_BYTES) { diskStatus |= DISK_STATUS_ERROR; } + diskStatus &= (uint8_t)~DISK_STATUS_BUSY; raiseInterrupt(PORT_DISK); } +void setDiskLatency(unsigned long cycles) { + diskLatency = cycles; +} + +void deviceTick(unsigned long now) { + deviceNow = now; + if (diskPending && now >= diskReadyAt) { + uint8_t command = diskPending; + diskPending = 0; + diskTransfer(command); + } +} + // ---- A device that brings memory ---- // // The simplest thing that owns a bank. Writing to its port fills its memory with the diff --git a/Source/Emulator/io.h b/Source/Emulator/io.h index 70dfd23..bb69696 100644 --- a/Source/Emulator/io.h +++ b/Source/Emulator/io.h @@ -145,9 +145,12 @@ uint8_t consoleReadByte(void); #define DISK_COMMAND_READ 0x01 #define DISK_COMMAND_WRITE 0x02 -// Set while an operation is still going. It always reads clear here, because the host -// finishes before the next instruction does, but a machine with a slower disk would set -// it and a program that ignores it would break there. Honour it anyway. +// Set while an operation is still going, and it now really is set: a disk given a latency +// says busy, takes that many cycles, and finishes then. A program that does not wait gets +// whatever was in the buffer before, which is what the hardware would give it. +// +// It reads clear the whole time when the latency is zero, which is the default and how +// every test here has always run. #define DISK_STATUS_BUSY 0x01 // Set when the disk cannot be written at all. Unlike the two bits above it, this is not // about the last operation: it is a standing property of the medium, readable before @@ -160,6 +163,22 @@ uint8_t consoleReadByte(void); // so it says so rather than stopping the machine. #define DISK_STATUS_ERROR 0x02 +// ---- Devices that take time ---- +// +// A real device does not finish inside the instruction that asked it to. It says it is +// busy, takes as long as it takes, and is done when the machine has run that far - so the +// emulator needs somewhere to notice that time has passed. That is this: called once per +// instruction with the machine's clock, it lets any device whose moment has come finish. +// +// It is written for the disk and is not about the disk. Anything that will take time - a +// display that refreshes, a port that waits on the host - wants exactly this shape. +void deviceTick(unsigned long now); + +// How many cycles a block read or write takes. Zero means the answer is there before the +// next instruction is, which is what this machine has always done and what every recorded +// test assumes. +void setDiskLatency(unsigned long cycles); + // Attaches an image, making one if it is not there. A disk is read only if the host will // not let the file be written, or if writeProtect asks for it, which is the emulated // equivalent of the tab on the side of a floppy. Returns 1 if it could not attach. diff --git a/Source/Emulator/utility.c b/Source/Emulator/utility.c index 7ad3f05..e9face9 100644 --- a/Source/Emulator/utility.c +++ b/Source/Emulator/utility.c @@ -18,6 +18,8 @@ void printHelp(const char *programName) { printf(" -c, --cycles N Stop after N cycles instead of running until the program halts.\n"); printf(" -f, --fast Run as fast as possible, ignoring the emulated cycle rate.\n"); printf(" -D, --disk FILE Attach a disk image, making one if it is not there.\n"); + printf(" -L, --disk-cycles N How many cycles a block read or write takes. Zero, the\n"); + printf(" default, finishes before the next instruction starts.\n"); printf(" -W, --write-protect Attach the disk read only. A disk the host will not let\n"); printf(" you write is read only whether you ask for this or not.\n"); printf(" -h, --help Display this help message.\n"); @@ -30,6 +32,7 @@ uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options) { {"fast", no_argument, 0, 'f'}, {"disk", required_argument, 0, 'D'}, {"write-protect", no_argument, 0, 'W'}, + {"disk-cycles", required_argument, 0, 'L'}, {"help", no_argument, 0, 'h'}, {0, 0, 0, 0 } }; @@ -41,9 +44,10 @@ uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options) { options->cycles = 0; options->disk = NULL; options->writeProtect = 0; + options->diskCycles = 0; // Parse options - while ((opt = getopt_long(argc, argv, "dc:fhD:W", long_options, &option_index)) != -1) { + while ((opt = getopt_long(argc, argv, "dc:fhD:WL:", long_options, &option_index)) != -1) { switch (opt) { case 'd': options->debug = 1; @@ -70,6 +74,9 @@ uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options) { case 'W': options->writeProtect = 1; break; + case 'L': + options->diskCycles = strtoul(optarg, NULL, 0); + break; case 'h': printHelp(argv[0]); return OPTIONS_HELP; diff --git a/Source/Emulator/utility.h b/Source/Emulator/utility.h index b731e40..696284a 100644 --- a/Source/Emulator/utility.h +++ b/Source/Emulator/utility.h @@ -19,6 +19,7 @@ typedef struct { uint8_t debug; // Step one instruction at a time, printing the registers. uint8_t fast; // Ignore the cycle rate and run as fast as the host allows. unsigned long cycles; // Stop after this many cycles. Zero means run until the program halts. + unsigned long diskCycles; // How long a block move takes. Zero is instant, and the default. const char *disk; // Disk image to attach, or NULL for a machine with no disk. uint8_t writeProtect; // Attach the disk read only, the way a tab on a floppy would. } EmulatorOptions; diff --git a/Tests/expected/cosmosSlowDisk.out b/Tests/expected/cosmosSlowDisk.out new file mode 100644 index 0000000..fd0fe38 --- /dev/null +++ b/Tests/expected/cosmosSlowDisk.out @@ -0,0 +1,16 @@ +CosmOS +> greet.sbx 210 +hello.sbx 52 +Life.sbx 1411 +Snake.sbx 2175 +Keys.sbx 663 +Say.sbx 155 +Break.sbx 148 +notes.txt 21 +8 files +> loaded, starting at 4000 +> it says: the disk took its time +finished +> halted +Execution halted. +[exit 0] diff --git a/Tests/input/cosmosSlowDisk.in b/Tests/input/cosmosSlowDisk.in new file mode 100644 index 0000000..2754313 --- /dev/null +++ b/Tests/input/cosmosSlowDisk.in @@ -0,0 +1,4 @@ +dir +load Say.sbx +run the disk took its time +exit diff --git a/Tests/manifest b/Tests/manifest index d29ea87..93d0e5c 100644 --- a/Tests/manifest +++ b/Tests/manifest @@ -369,6 +369,15 @@ cosmosTreeWrite | CosmOS/Source/cosmos.asm | run | cosmosTre # The listing at the end is the point: 266 bytes, which is what was reserved and what was # written, after the refusal and the honest commit that follows it. cosmosClaim | CosmOS/Source/cosmos.asm | run | cosmosClaim.in | - | disks/claim.img +# The filesystem waiting for a disk that takes time. Every other test here runs with the +# disk finishing before the next instruction starts, which is what it has always done and +# what hides whether anything honours the busy bit. This one gives it a latency. +# +# It is the same directory listing as any other; the point is that it is the SAME. A +# filesystem that did not wait would read the block before the one it asked for, which is +# not an error anywhere - just quietly the wrong bytes - and this listing would be nonsense +# rather than a failure with a message. +cosmosSlowDisk | CosmOS/Source/cosmos.asm | run | cosmosSlowDisk.in | - | disks/cosmos.img@2000 # The machine building its own tree. It starts with a blank version one disk and makes # every directory on it, which is the half of the filesystem the machine could only read # until now. diff --git a/Tests/run.sh b/Tests/run.sh index a49c33a..1daacb9 100755 --- a/Tests/run.sh +++ b/Tests/run.sh @@ -205,6 +205,14 @@ while IFS='|' read -r name src mode stdin limit disk; do # A trailing :ro attaches the image write protected, so that a test can # check the device bars writes rather than the filesystem asking nicely. DISKFILE="${disk%:ro}" + # And a trailing @N gives the disk a latency, so that a test can check the + # filesystem waits for it. Every other test runs with the answer there + # before the next instruction, which is the one condition under which not + # waiting looks like working. + DISKWAIT="" + case "$DISKFILE" in + *@*) DISKWAIT="${DISKFILE##*@}"; DISKFILE="${DISKFILE%@*}" ;; + esac # A name with a directory in it is one of the images makedisks.sh built, # and is used as it stands. A bare name is scratch: it is removed first so # that nothing a test writes can be seen by the next one, and the emulator @@ -214,7 +222,8 @@ while IFS='|' read -r name src mode stdin limit disk; do *) rm -f "$BUILD/$DISKFILE" ;; esac EMUARGS+=(--disk "$BUILD/$DISKFILE") - [ "$disk" != "$DISKFILE" ] && EMUARGS+=(--write-protect) + [ -n "$DISKWAIT" ] && EMUARGS+=(--disk-cycles "$DISKWAIT") + case "$disk" in *:ro) EMUARGS+=(--write-protect) ;; esac fi timeout "$RUN_TIMEOUT" "$EMULATOR" "${EMUARGS[@]}" "$BIN" <"$IN" >"$OUT" 2>&1 STATUS=$?