The disk's status has always had a bit meaning "still going", and the header beside it has always said to honour it. Nothing did, because nothing could: the host finished the transfer inside the instruction that asked for it, so the bit could never be seen up and asking about it was asking about something that cannot happen. --disk-cycles gives it a latency. The command is still checked at once, because a refusal is not work - a block that is not there fails before any head moves - but the transfer is remembered and done when the machine has run that far. Until then the buffer holds the block BEFORE this one. That last part is the point. A program that does not wait gets the wrong bytes rather than an error, which is the failure the bit exists to prevent and the one that would never have shown up. With a latency of two thousand, CosmOS could not even mount: sbfsMount reads block zero and looks straight at the buffer. deviceTick is the general shape rather than a disk feature. Called once per instruction with the machine's clock, it lets anything whose moment has come finish - which is what a display that refreshes, or a port that waits on the host, would want in exactly the same way. The filesystem watches the bit now, in one small routine reached with RCAL. That is not decoration: what it hands back is the settled status in A, and CALL puts A back the way it found it, so an ordinary call cannot carry the one thing this exists to carry. Two bytes of Stack rather than ten, in a routine that runs on every block the machine ever touches - the first place in the system where the new call is the right one rather than merely a cheaper one. The manifest takes a @N after a disk, the way it already takes :ro, so a test can ask for a slow one. cosmosSlowDisk lists a directory at two thousand cycles a block and gets the same listing as everything else, which is the whole assertion: a filesystem that did not wait would print nonsense rather than fail. Zero is the default and every other test runs at it. What waiting costs, on a directory heavy run: 229k cycles at zero, 275k at five hundred, 415k at two thousand, 1.16M at ten thousand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
719 lines
28 KiB
C
719 lines
28 KiB
C
// io.c
|
|
// I/O for the SplitBit CPU Emulator
|
|
// Written by Anachronaut
|
|
// 10/16/2024
|
|
|
|
#include "io.h"
|
|
#include "../Assembler/assembly.h" // For the fault vector numbers.
|
|
#include "controller.h"
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <signal.h>
|
|
#include <errno.h>
|
|
#include <termios.h>
|
|
#include <unistd.h>
|
|
#include <poll.h>
|
|
|
|
// ---- The console ----
|
|
//
|
|
// The console owns its own reading rather than going through getchar. stdio keeps a
|
|
// buffer, and the status port asks the operating system what is waiting; those two
|
|
// disagree the moment stdio has read ahead, and the status port would then swear nothing
|
|
// was there while a read returned instantly. One byte of pushback here is enough, because
|
|
// nothing needs to look further ahead than the byte it is about to take.
|
|
|
|
static int consoleKeyMode = 0;
|
|
static int consoleEnded = 0;
|
|
static int consolePushback = -1; // A byte already taken from the host, or -1.
|
|
static int consoleInterrupts = 0; // Whether an arriving byte puts the line up.
|
|
static struct termios consoleSavedTerminal;
|
|
static int consoleTerminalSaved = 0; // There is a copy of how the terminal was found.
|
|
static int consoleTerminalRaw = 0; // The terminal is currently in this machine's mode.
|
|
static int consoleGuardsInstalled = 0; // The handlers below are in place.
|
|
|
|
// Hands the terminal back exactly as it was found, WITHOUT forgetting what the program
|
|
// asked for. Separate from consoleRestore because the two are wanted in different places:
|
|
// a machine that is stopping wants both, and a machine that is being suspended wants only
|
|
// this, since it is going to carry on wanting keys when it is resumed.
|
|
static void consoleReleaseTerminal(void) {
|
|
if (consoleTerminalRaw) {
|
|
tcsetattr(STDIN_FILENO, TCSANOW, &consoleSavedTerminal);
|
|
consoleTerminalRaw = 0;
|
|
}
|
|
}
|
|
|
|
static void consoleTakeTerminal(void) {
|
|
if (consoleTerminalRaw || !consoleTerminalSaved) {
|
|
return;
|
|
}
|
|
struct termios raw = consoleSavedTerminal;
|
|
raw.c_lflag &= (tcflag_t)~(ICANON | ECHO);
|
|
raw.c_cc[VMIN] = 1;
|
|
raw.c_cc[VTIME] = 0;
|
|
if (tcsetattr(STDIN_FILENO, TCSANOW, &raw) == 0) {
|
|
consoleTerminalRaw = 1;
|
|
}
|
|
}
|
|
|
|
void consoleRestore(void) {
|
|
consoleReleaseTerminal();
|
|
consoleKeyMode = 0;
|
|
// Whatever the console was in the middle of asking for is withdrawn along with the
|
|
// mode. A line left standing here would be answered by whatever ran next, which had
|
|
// nothing to do with it and never asked to be interrupted.
|
|
consoleInterrupts = 0;
|
|
clearInterrupt(PORT_CONSOLE);
|
|
}
|
|
|
|
// ---- Giving the terminal back whatever happens ----
|
|
//
|
|
// A machine that stops in key mode and does not undo it leaves the shell that started it
|
|
// with no echo and no line editing, which is a far worse failure than anything the program
|
|
// was doing, and one the user has no obvious way to connect to this program.
|
|
//
|
|
// atexit covers stopping on purpose and nothing else. It does NOT run when a process is
|
|
// killed by a signal, so every way of dying that matters has to be caught and undone by
|
|
// hand. The list below is every signal whose default action ends the process and which can
|
|
// be caught at all - SIGKILL and SIGSTOP cannot, and nothing can be done about those.
|
|
//
|
|
// SIGHUP is on the list for a specific reason, learned the hard way: it is what arrives
|
|
// when the terminal or the session that started this machine goes away, which is exactly
|
|
// what happens when whatever launched it crashes. Handling INT and TERM and stopping there
|
|
// covers the polite endings and misses the one that actually leaves a broken terminal
|
|
// behind.
|
|
|
|
// Puts the terminal back and then dies the way it would have died anyway, so that whatever
|
|
// is waiting sees the signal it expected rather than a machine that exited quietly.
|
|
static void consoleFatalSignal(int signalNumber) {
|
|
consoleReleaseTerminal();
|
|
signal(signalNumber, SIG_DFL);
|
|
raise(signalNumber);
|
|
}
|
|
|
|
static void consoleContinueSignal(int signalNumber);
|
|
|
|
// Suspending is not dying, so the terminal goes back but the mode is remembered. Whoever
|
|
// gets the terminal next is entitled to find it as they left it, and this machine is
|
|
// entitled to have its keys again when it is resumed.
|
|
static void consoleStopSignal(int signalNumber) {
|
|
consoleReleaseTerminal();
|
|
signal(SIGCONT, consoleContinueSignal);
|
|
signal(signalNumber, SIG_DFL);
|
|
raise(signalNumber);
|
|
}
|
|
|
|
static void consoleContinueSignal(int signalNumber) {
|
|
(void)signalNumber;
|
|
signal(SIGTSTP, consoleStopSignal);
|
|
signal(SIGCONT, consoleContinueSignal);
|
|
if (consoleKeyMode) {
|
|
consoleTakeTerminal();
|
|
}
|
|
}
|
|
|
|
static void consoleInstallGuards(void) {
|
|
if (consoleGuardsInstalled) {
|
|
return;
|
|
}
|
|
consoleGuardsInstalled = 1;
|
|
// Installed on the first use of key mode rather than at startup, so a run that never
|
|
// asks for it installs nothing at all.
|
|
atexit(consoleRestore);
|
|
|
|
static const int fatal[] = {
|
|
SIGHUP, SIGINT, SIGQUIT, SIGILL, SIGABRT, SIGFPE,
|
|
SIGBUS, SIGSEGV, SIGPIPE, SIGALRM, SIGTERM
|
|
};
|
|
for (size_t i = 0; i < sizeof(fatal) / sizeof(fatal[0]); i++) {
|
|
signal(fatal[i], consoleFatalSignal);
|
|
}
|
|
signal(SIGTSTP, consoleStopSignal);
|
|
signal(SIGCONT, consoleContinueSignal);
|
|
}
|
|
|
|
static void consoleSetMode(int wantKeys) {
|
|
if (wantKeys == consoleKeyMode) {
|
|
return;
|
|
}
|
|
if (!wantKeys) {
|
|
consoleRestore();
|
|
return;
|
|
}
|
|
// Nothing to configure when input is not a terminal, but the mode is still recorded:
|
|
// a program asking the status port what mode it is in should be told what it asked
|
|
// for, whether or not there was a terminal to carry it out on.
|
|
consoleKeyMode = 1;
|
|
if (!isatty(STDIN_FILENO)) {
|
|
return;
|
|
}
|
|
if (!consoleTerminalSaved) {
|
|
if (tcgetattr(STDIN_FILENO, &consoleSavedTerminal) != 0) {
|
|
return;
|
|
}
|
|
consoleTerminalSaved = 1;
|
|
}
|
|
consoleInstallGuards();
|
|
consoleTakeTerminal();
|
|
}
|
|
|
|
// Puts the line up if the console has something to say and has been asked to say it.
|
|
// Called wherever news arrives and wherever a program declares it wants to hear news, so
|
|
// that enabling interrupts while a byte is already waiting is not a way to miss it.
|
|
static void consoleAnnounce(void) {
|
|
if (consoleInterrupts && (consolePushback >= 0 || consoleEnded)) {
|
|
raiseInterrupt(PORT_CONSOLE);
|
|
}
|
|
}
|
|
|
|
// The whole control port in one write. The two bits are independent, so both are read out
|
|
// of the byte and applied, and neither is inferred from the other.
|
|
static void consoleSetControl(uint8_t control) {
|
|
// The mode goes first because turning key mode off restores the terminal, and that
|
|
// withdraws any standing request along with it. Setting the interrupt bit afterwards
|
|
// means one write can ask for line mode and interrupts together, which is an ordinary
|
|
// thing to want and would otherwise be undone in the same breath as it was asked for.
|
|
consoleSetMode((control & CONSOLE_MODE_KEY) != 0);
|
|
|
|
int wantInterrupts = (control & CONSOLE_CONTROL_INTERRUPT) != 0;
|
|
if (!wantInterrupts) {
|
|
// Asking to stop being interrupted takes down whatever was already asked for. A
|
|
// request that outlived the setting that made it would arrive at a program that
|
|
// had just said it did not want it.
|
|
clearInterrupt(PORT_CONSOLE);
|
|
}
|
|
consoleInterrupts = wantInterrupts;
|
|
consoleAnnounce();
|
|
}
|
|
|
|
// Everything already written is put where it can be seen before the machine asks the host
|
|
// anything. Standard output is line buffered on a terminal, so a prompt with no newline
|
|
// after it - "> " is exactly that, and exactly why this matters - would sit in the buffer
|
|
// while the machine waited for an answer to a question nobody had been shown.
|
|
//
|
|
// getchar used to do this by accident, because reading through stdio flushes the line
|
|
// buffered streams first. Reading with read() does not, so what was a side effect of the
|
|
// old way is done deliberately here.
|
|
static void consoleShowWhatIsWritten(void) {
|
|
fflush(stdout);
|
|
}
|
|
|
|
uint8_t consoleReadByte(void) {
|
|
// Taking the byte answers whatever the console was asking about, so the line comes
|
|
// down here as well as when the CPU acknowledges it. Otherwise a program that reads
|
|
// the data port with the Interrupt Flag down would be interrupted afterwards on
|
|
// behalf of a byte it already has, and find nothing waiting when it looked.
|
|
clearInterrupt(PORT_CONSOLE);
|
|
if (consolePushback >= 0) {
|
|
uint8_t byte = (uint8_t)consolePushback;
|
|
consolePushback = -1;
|
|
return byte;
|
|
}
|
|
consoleShowWhatIsWritten();
|
|
unsigned char byte;
|
|
for (;;) {
|
|
ssize_t got = read(STDIN_FILENO, &byte, 1);
|
|
if (got == 1) {
|
|
return byte;
|
|
}
|
|
if (got == 0) {
|
|
// End of input. Still 0xFF, which is what getchar's EOF became when this was
|
|
// the only answer available, so nothing written against the old behaviour
|
|
// changes. The ENDED bit is the new way to know it was not a real byte.
|
|
consoleEnded = 1;
|
|
return 0xFF;
|
|
}
|
|
if (errno != EINTR) {
|
|
consoleEnded = 1;
|
|
return 0xFF;
|
|
}
|
|
// Interrupted before anything arrived, so ask again.
|
|
}
|
|
}
|
|
|
|
// Asking the host whether anything is waiting, and TAKING IT IF THERE IS. The byte goes
|
|
// into the pushback and the next read of the data port hands it over, so nothing is lost
|
|
// and no program can tell that it was fetched early.
|
|
//
|
|
// Fetching it early is what makes the answer worth having. The operating system will say a
|
|
// pipe is readable when what is waiting is the end of it, so asking without reading can
|
|
// only report that SOMETHING is there. Reading settles which: a byte, or the end. Without
|
|
// this, ENDED could not go up until a program had already read the 0xFF that stands for
|
|
// it, and every program would have to swallow one imaginary byte to find out there were
|
|
// none.
|
|
static void consoleFetch(void) {
|
|
if (consolePushback >= 0 || consoleEnded) {
|
|
return;
|
|
}
|
|
// Flushed here too. A program that draws something and then polls rather than reads is
|
|
// just as entitled to have the drawing appear, and it never reaches the read that
|
|
// would otherwise have flushed for it.
|
|
consoleShowWhatIsWritten();
|
|
struct pollfd waiting = { .fd = STDIN_FILENO, .events = POLLIN, .revents = 0 };
|
|
if (poll(&waiting, 1, 0) <= 0 || (waiting.revents & (POLLIN | POLLHUP)) == 0) {
|
|
return;
|
|
}
|
|
unsigned char byte;
|
|
ssize_t got = read(STDIN_FILENO, &byte, 1);
|
|
if (got == 1) {
|
|
consolePushback = byte;
|
|
} else if (got == 0) {
|
|
consoleEnded = 1;
|
|
}
|
|
// A read that failed for any other reason is left alone: the next attempt asks again,
|
|
// and an interrupted poll is not news.
|
|
//
|
|
// Anything that was news puts the line up. This is the only place a byte arrives from
|
|
// the outside world, so it is the only place that has to, and it raises AT MOST ONCE
|
|
// PER BYTE for free: the pushback holds one, and while it is full there is nothing to
|
|
// fetch and so nothing to announce. A handler that does not read what it was called
|
|
// about is simply not called again, the way a receive register with one byte in it
|
|
// stops asking. The end of input announces itself once for the same reason - it is
|
|
// discovered once, and every later look leaves before it gets here.
|
|
consoleAnnounce();
|
|
}
|
|
|
|
// How many instructions the machine runs between glances at the console. Nothing here
|
|
// happens alongside the CPU, so noticing a keystroke costs a system call, and asking on
|
|
// every instruction costs more than executing one: a poll is about 150ns against roughly
|
|
// 9ns for an instruction at full tilt, so it would slow the machine by nearly twenty
|
|
// times. At the emulated clock this stride is a quarter of a millisecond between glances,
|
|
// which no one typing has ever been able to tell from immediately.
|
|
#define CONSOLE_SERVICE_STRIDE 256
|
|
|
|
void serviceDevices(void) {
|
|
// The common case is a machine nobody is interrupting, and it costs one test.
|
|
if (!consoleInterrupts) {
|
|
return;
|
|
}
|
|
static unsigned int untilNextGlance = 0;
|
|
if (untilNextGlance > 0) {
|
|
untilNextGlance--;
|
|
return;
|
|
}
|
|
untilNextGlance = CONSOLE_SERVICE_STRIDE - 1;
|
|
consoleFetch();
|
|
}
|
|
|
|
static uint8_t consoleStatus(void) {
|
|
uint8_t status = 0;
|
|
if (consoleKeyMode) {
|
|
status |= CONSOLE_STATUS_KEYMODE;
|
|
}
|
|
if (consoleInterrupts) {
|
|
status |= CONSOLE_STATUS_INTERRUPT;
|
|
}
|
|
consoleFetch();
|
|
if (consoleEnded) {
|
|
// READY IS NOT SET HERE, although a read would answer immediately. The bit means
|
|
// "there is a byte to be had", and at the end of input there is not; what a read
|
|
// returns then is 0xFF standing in for nothing. A program looping while READY
|
|
// stops on its own at the end, which is the behaviour worth having, and one that
|
|
// wants to know why asks ENDED.
|
|
return status | CONSOLE_STATUS_ENDED;
|
|
}
|
|
if (consolePushback >= 0) {
|
|
status |= CONSOLE_STATUS_READY;
|
|
}
|
|
return status;
|
|
}
|
|
|
|
// One bit per port, so a device can ask for attention without anything having to poll
|
|
// it. Eight ports to the byte, low bit first.
|
|
#define INTERRUPT_LINE_BYTES 32
|
|
|
|
static uint8_t pendingInterrupts[INTERRUPT_LINE_BYTES];
|
|
|
|
void raiseInterrupt(uint8_t port) {
|
|
pendingInterrupts[port >> 3] |= (uint8_t)(1u << (port & 7));
|
|
}
|
|
|
|
void clearInterrupt(uint8_t port) {
|
|
pendingInterrupts[port >> 3] &= (uint8_t)~(1u << (port & 7));
|
|
}
|
|
|
|
int nextPendingInterrupt(void) {
|
|
// Lowest numbered port wins. This is a scan rather than a priority encoder, which
|
|
// means there is no arbitration to explain and a programmer can work out what
|
|
// happens next by reading the port numbers.
|
|
for (int group = 0; group < INTERRUPT_LINE_BYTES; group++) {
|
|
if (pendingInterrupts[group] == 0) {
|
|
continue;
|
|
}
|
|
for (int bit = 0; bit < 8; bit++) {
|
|
if (pendingInterrupts[group] & (1u << bit)) {
|
|
return group * 8 + bit;
|
|
}
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
// ---- Refusing ----
|
|
//
|
|
// Set when a device will not do what it was asked, and read by the CPU immediately
|
|
// after the instruction that asked. It is not a queue: an instruction does one thing to
|
|
// one port, so there is only ever one refusal outstanding.
|
|
|
|
static uint8_t refusedVector = 0;
|
|
static uint8_t refusedPort = 0;
|
|
|
|
void refuseAccess(uint8_t faultVector) {
|
|
refusedVector = faultVector;
|
|
}
|
|
|
|
uint8_t takeRefusal(void) {
|
|
uint8_t vector = refusedVector;
|
|
refusedVector = 0;
|
|
return vector;
|
|
}
|
|
|
|
uint8_t refusingPort(void) {
|
|
return refusedPort;
|
|
}
|
|
|
|
// ---- The disk ----
|
|
//
|
|
// A block device and nothing more. It knows numbered blocks and has never heard of a
|
|
// file, which is the whole point: a filesystem is software this machine will run, not
|
|
// something the host does on its behalf. A disk that understood filenames would be the
|
|
// emulator doing the work and the machine pretending it had.
|
|
|
|
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;
|
|
|
|
uint8_t attachDisk(const char *path, uint8_t writeProtect) {
|
|
diskProtected = writeProtect ? 1 : 0;
|
|
diskImage = fopen(path, "r+b");
|
|
if (diskImage == NULL) {
|
|
// It may be there and simply not writable, which is a read only disk rather than
|
|
// a missing one. Try that before deciding to make a new one.
|
|
diskImage = fopen(path, "rb");
|
|
if (diskImage != NULL) {
|
|
diskProtected = 1;
|
|
}
|
|
}
|
|
if (diskImage == NULL) {
|
|
// Nothing there, so make one. A fresh image is zeroes, which is what an unwritten
|
|
// block should read as.
|
|
diskImage = fopen(path, "w+b");
|
|
if (diskImage == NULL) {
|
|
fprintf(stderr, "Error: Couldn't open or create the disk image: %s\n", path);
|
|
return 1;
|
|
}
|
|
static const uint8_t empty[DISK_BLOCK_BYTES] = {0};
|
|
for (uint32_t i = 0; i < DISK_DEFAULT_BLOCKS; i++) {
|
|
if (fwrite(empty, 1, DISK_BLOCK_BYTES, diskImage) != DISK_BLOCK_BYTES) {
|
|
fprintf(stderr, "Error: Couldn't write the disk image: %s\n", path);
|
|
fclose(diskImage);
|
|
diskImage = NULL;
|
|
return 1;
|
|
}
|
|
}
|
|
}
|
|
if (fseek(diskImage, 0, SEEK_END) != 0) {
|
|
fprintf(stderr, "Error: Couldn't measure the disk image: %s\n", path);
|
|
fclose(diskImage);
|
|
diskImage = NULL;
|
|
return 1;
|
|
}
|
|
long size = ftell(diskImage);
|
|
// A part written block at the end is not a block, so it is not counted.
|
|
diskBlockCount = (size > 0) ? (uint32_t)(size / DISK_BLOCK_BYTES) : 0;
|
|
// The protect bit is a standing property, so it reads true before anything has been
|
|
// asked of the disk rather than only after a write has been turned away.
|
|
diskStatus = diskProtected ? DISK_STATUS_PROTECTED : 0;
|
|
return 0;
|
|
}
|
|
|
|
void detachDisk(void) {
|
|
if (diskImage != NULL) {
|
|
fclose(diskImage);
|
|
diskImage = NULL;
|
|
}
|
|
}
|
|
|
|
// Reads or writes the block the block registers name. The line goes up either way: the
|
|
// operation finished, and whether it worked is what Status is for.
|
|
static void diskCommand(uint8_t command) {
|
|
// The protect bit describes the disk rather than the operation, so it survives.
|
|
diskStatus = diskProtected ? DISK_STATUS_PROTECTED : 0;
|
|
if (command == DISK_COMMAND_WRITE && diskProtected) {
|
|
diskStatus |= DISK_STATUS_ERROR;
|
|
raiseInterrupt(PORT_DISK);
|
|
return;
|
|
}
|
|
if (diskImage == NULL || diskBlock >= diskBlockCount) {
|
|
diskStatus |= DISK_STATUS_ERROR;
|
|
raiseInterrupt(PORT_DISK);
|
|
return;
|
|
}
|
|
long offset = (long)diskBlock * DISK_BLOCK_BYTES;
|
|
if (fseek(diskImage, offset, SEEK_SET) != 0) {
|
|
diskStatus |= DISK_STATUS_ERROR;
|
|
raiseInterrupt(PORT_DISK);
|
|
return;
|
|
}
|
|
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
|
|
// byte written, which stands in for a disk controller reading a sector: the CPU asks for
|
|
// something and the memory it owns then holds the answer. The waiting is taken out so a
|
|
// test runs the same way every time.
|
|
|
|
#define DEVICE_MEMORY_BYTES 256
|
|
|
|
static uint8_t deviceMemoryBlock[DEVICE_MEMORY_BYTES];
|
|
|
|
uint8_t *deviceMemory(uint8_t port, uint32_t *capacity) {
|
|
if (port == PORT_MEMORY) {
|
|
*capacity = DEVICE_MEMORY_BYTES;
|
|
return deviceMemoryBlock;
|
|
}
|
|
if (port == PORT_DISK) {
|
|
// The disk's buffer is one block. Reading fills it and writing takes what is in
|
|
// it, and the only way to reach it is to register it as a bank and go through the
|
|
// controller.
|
|
*capacity = DISK_BLOCK_BYTES;
|
|
return diskBuffer;
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
// ---- The bus registry ----
|
|
//
|
|
// What is plugged into this machine. The table is fixed when the machine is built: a
|
|
// program cannot write to it, because writing would only let a program lie to itself
|
|
// about what hardware exists. Which routine handles a device is a different question,
|
|
// and the vector table already answers it.
|
|
//
|
|
// Nothing here touches the device being asked about. That matters more than it looks:
|
|
// reading a port is a real operation, and asking the console what it is by reading it
|
|
// would take a character off standard input and block waiting for one.
|
|
|
|
typedef struct {
|
|
uint8_t port;
|
|
uint8_t deviceClass;
|
|
uint8_t flags;
|
|
} DeviceRecord;
|
|
|
|
static const DeviceRecord deviceTable[] = {
|
|
{ PORT_CONSOLE, DEVICE_CONSOLE, 0 },
|
|
{ PORT_TEST, DEVICE_TEST, 0 },
|
|
{ PORT_REFUSE, DEVICE_REFUSE, 0 },
|
|
{ PORT_MEMORY, DEVICE_MEMORY, DEVICE_FLAG_HAS_MEMORY },
|
|
{ PORT_DISK, DEVICE_DISK, DEVICE_FLAG_HAS_MEMORY },
|
|
{ PORT_REGISTRY, DEVICE_REGISTRY, 0 },
|
|
};
|
|
static const int deviceCount = (int)(sizeof(deviceTable) / sizeof(deviceTable[0]));
|
|
|
|
// Which port the registry is currently being asked about, and how far through that
|
|
// port's record it has been read. Selecting a port starts the record again.
|
|
static uint8_t registrySelected = 0;
|
|
static uint8_t registryCursor = 0;
|
|
|
|
static const DeviceRecord controllerRecord = { CONTROLLER_PORT_BASE, DEVICE_CONTROLLER, 0 };
|
|
|
|
static const DeviceRecord *deviceOnPort(uint8_t port) {
|
|
// The controller answers on a block of ports rather than one, so every port in the
|
|
// block reports it. Its own memory is bank 2, which is already registered, so it
|
|
// does not set the flag that means "this brings memory somebody has to register".
|
|
if (port >= CONTROLLER_PORT_BASE && port <= CONTROLLER_PORT_TOP) {
|
|
return &controllerRecord;
|
|
}
|
|
if (port > PORT_CONSOLE && port <= PORT_CONSOLE_TOP) {
|
|
// The status and control ports are the same device as the data port, which is the
|
|
// one in the table and the one the console raises its line on.
|
|
return deviceOnPort(PORT_CONSOLE);
|
|
}
|
|
if (port > PORT_DISK && port <= PORT_DISK_TOP) {
|
|
// The base port is in the table proper, since that is the one that owns the
|
|
// memory and raises the line. The rest of the block reports the same device.
|
|
return deviceOnPort(PORT_DISK);
|
|
}
|
|
for (int i = 0; i < deviceCount; i++) {
|
|
if (deviceTable[i].port == port) {
|
|
return &deviceTable[i];
|
|
}
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
// One byte of the selected port's record. Everything about a port that is not there
|
|
// reads as zero, which is the same answer an absent registry would give.
|
|
static uint8_t readRegistry(void) {
|
|
const DeviceRecord *device = deviceOnPort(registrySelected);
|
|
uint8_t answer = 0;
|
|
if (device != NULL && registryCursor < DEVICE_RECORD_BYTES) {
|
|
answer = (registryCursor == 0) ? device->deviceClass : device->flags;
|
|
}
|
|
if (registryCursor < DEVICE_RECORD_BYTES) {
|
|
registryCursor++;
|
|
}
|
|
return answer;
|
|
}
|
|
|
|
uint8_t OutputHandler(uint8_t DataByte, uint8_t Address) {
|
|
// Whichever port is being talked to is the one that would be doing any refusing.
|
|
refusedPort = Address;
|
|
// The controller answers on a block of ports, which is a range rather than a list.
|
|
if (Address >= CONTROLLER_PORT_BASE && Address <= CONTROLLER_PORT_TOP) {
|
|
return controllerWrite(DataByte, Address);
|
|
}
|
|
// This function sends the DataByte to the appropriate place based on the Port Address.
|
|
switch(Address) {
|
|
case CONSOLE_DATA:
|
|
// If data is sent here, it should be written to STDOUT.
|
|
// For now, I'll implement this so it simply writes each byte out as it comes in.
|
|
// Later, I'll want to use a buffer for this for performance, probably.
|
|
putchar(DataByte);
|
|
break;
|
|
case CONSOLE_CONTROL: consoleSetControl(DataByte); break;
|
|
case CONSOLE_STATUS:
|
|
// Read only. A device saying how it is does not take instructions through the
|
|
// same hole, so a write here is ignored rather than meaning something.
|
|
break;
|
|
case DISK_BLOCK_HIGH: diskBlock = (uint16_t)(DataByte << 8) | (diskBlock & 0x00FF); break;
|
|
case DISK_BLOCK_LOW: diskBlock = (diskBlock & 0xFF00) | DataByte; break;
|
|
case DISK_COMMAND: diskCommand(DataByte); break;
|
|
case PORT_MEMORY:
|
|
// Fills the memory this device owns with the byte written. Nothing is
|
|
// reachable from here: to get at it, register it as a bank and go through
|
|
// the controller, which is the only thing that can reach a device's memory.
|
|
memset(deviceMemoryBlock, DataByte, DEVICE_MEMORY_BYTES);
|
|
break;
|
|
case PORT_REFUSE:
|
|
// A device that refuses everything. It exists so that a device's ability to
|
|
// stop the CPU can be tested before anything depends on it, and so that the
|
|
// path stays tested once the memory controller is the only real user.
|
|
refuseAccess(VECTOR_GUARD_VIOLATION);
|
|
break;
|
|
case PORT_REGISTRY:
|
|
// Names the port the registry is being asked about. This is the only thing
|
|
// that can be written to the registry, and it changes nothing about the
|
|
// machine: it selects a question, it does not give an answer.
|
|
registrySelected = DataByte;
|
|
registryCursor = 0;
|
|
break;
|
|
case PORT_TEST:
|
|
// A test device, and about the simplest one that can exist: writing to it
|
|
// puts its own line up. It stands in for the shape a real device has, where
|
|
// the CPU asks for something and is interrupted once the answer is ready,
|
|
// with the waiting taken out so that a test runs the same way every time.
|
|
// The byte written is ignored; only the asking matters.
|
|
raiseInterrupt(PORT_TEST);
|
|
break;
|
|
default:
|
|
// Writes to unused Output Ports are ignored.
|
|
return 1;
|
|
break;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
uint8_t InputHandler(uint8_t Address) {
|
|
refusedPort = Address;
|
|
if (Address >= CONTROLLER_PORT_BASE && Address <= CONTROLLER_PORT_TOP) {
|
|
return controllerRead(Address);
|
|
}
|
|
switch(Address) {
|
|
case CONSOLE_DATA:
|
|
// If data is sent here, it should be read from STDIN.
|
|
return consoleReadByte();
|
|
break;
|
|
case CONSOLE_STATUS: return consoleStatus();
|
|
case CONSOLE_CONTROL:
|
|
// Write only. Reading it gives zero rather than what was last written, because
|
|
// everything it sets is reported by the status port and one fact wants one
|
|
// place to live.
|
|
return 0;
|
|
break;
|
|
case DISK_BLOCK_HIGH: return (uint8_t)(diskBlock >> 8);
|
|
case DISK_BLOCK_LOW: return (uint8_t)(diskBlock & 0xFF);
|
|
case DISK_STATUS: return diskStatus;
|
|
case PORT_REFUSE:
|
|
// Refuses reads as well, so both directions are covered.
|
|
refuseAccess(VECTOR_GUARD_VIOLATION);
|
|
return 0;
|
|
break;
|
|
case PORT_REGISTRY:
|
|
// One byte of the selected port's record, then the next, and zero once the
|
|
// record has run out.
|
|
return readRegistry();
|
|
break;
|
|
default:
|
|
// Reading from an unused port is ignored.
|
|
return 0;
|
|
break;
|
|
}
|
|
}
|