HALT is terminal - stepCPU returns at once when the Halt Flag is up, so a halted machine does not execute, service devices, or take an interrupt - and that has to stay true, because every test ends with a halt and "halted" is how a program says it has finished. The consequence was that SplitBit had no way to wait at all. Every wait was a spin, and a spin is bus traffic: 11.5% of Type over a 14K file on a disk of ten thousand cycles, after read-ahead had already hidden three quarters of the latency. WAIT is 0xFE, one byte, no operands, sitting under HALT where the instruction that almost stops the machine belongs. Three decisions in it: - A line already standing means there is nothing to wait for, so WAIT does nothing. That is what makes test-then-wait race-free. - Any line ends the wait, masked or not, so a program can sleep on a device it has no handler for and read its status afterwards. Masking says who answers a request, not whether it happened. - A line that wakes the CPU without being dispatched is taken down by the WAIT. Left standing it would be found by the next WAIT, which would return at once - the program would spin exactly as before while looking as though it slept. Waiting is NOT a Status bit, and that is the trap avoided rather than a gap: Status rides into the interrupt frame and comes back out, so a machine interrupted mid-wait would return from its handler still waiting, and wait again for what it had already been given. An internal field instead. Idle cycles are counted apart from bus cycles and the halt line says so when there are any, which is what makes the difference observable at all - with the line-clearing removed the total moves by ONE cycle, 20,100 against 20,099, and only the idle half changes, halving to 9,976. A test on totals could never have seen it. Tests/terminal.sh asks that question, being the file for things a recorded output cannot see, and fails with the clear removed while "both reads finished" still passes. Three collisions, all found by building it: - 0xFE was the assembler's "not an instruction" sentinel. getOpcode now answers a negative NOT_AN_OPCODE, which is outside the range of every possible answer instead of inside the unused part of it. - 0xFE was also what faultTest and faultResumeTest executed to provoke a fault. They now use 0xFD and say why, because they did not fail when it became an instruction - they HUNG, having started sleeping instead. - Keys.asm has had a label called "wait" for a year, and mnemonics are matched uppercased. What that reported was "Branch without label" at the BRQ thirty lines away. The assembler now refuses a label that is already an instruction, at the label, by name; every instruction added takes a word out of the space of label names, so this will happen again.
202 lines
7.5 KiB
C
202 lines
7.5 KiB
C
// emulator.c
|
|
|
|
// SplitBit Emulator
|
|
// Small 8-Bit Harvard Architecture CPU
|
|
// Written by Anachronaut
|
|
// 10/15/2024
|
|
|
|
#include <stdio.h>
|
|
#include <stdint.h>
|
|
#include <stdlib.h>
|
|
#include "cpu.h"
|
|
#include "controller.h"
|
|
#include "io.h"
|
|
#include "utility.h"
|
|
#include <string.h>
|
|
#include <getopt.h>
|
|
#include <time.h>
|
|
|
|
// nanoseconds per second
|
|
#define NS_PER_SEC 1000000000LL
|
|
#define CYCLE_RATE 1000000
|
|
|
|
typedef struct {
|
|
long long cycles_per_sec; // e.g. 1000000 for 1 MHz
|
|
long long accumulator_ns; // unspent nanoseconds
|
|
struct timespec prev;
|
|
} CycleTimer;
|
|
|
|
static inline long long timespec_diff_ns(struct timespec a, struct timespec b) {
|
|
return (a.tv_sec - b.tv_sec) * NS_PER_SEC + (a.tv_nsec - b.tv_nsec);
|
|
}
|
|
|
|
void cycle_timer_init(CycleTimer *t, long long cycles_per_sec) {
|
|
t->cycles_per_sec = cycles_per_sec;
|
|
t->accumulator_ns = 0;
|
|
clock_gettime(CLOCK_MONOTONIC, &t->prev);
|
|
}
|
|
|
|
// Call once per host frame. Returns how many SplitBit cycles to execute.
|
|
int cycle_timer_tick(CycleTimer *t) {
|
|
struct timespec now;
|
|
clock_gettime(CLOCK_MONOTONIC, &now);
|
|
|
|
long long elapsed = timespec_diff_ns(now, t->prev);
|
|
t->prev = now;
|
|
|
|
// optional: clamp to avoid spiral-of-death on hitches
|
|
if (elapsed > NS_PER_SEC / 10) elapsed = NS_PER_SEC / 10;
|
|
|
|
t->accumulator_ns += elapsed;
|
|
|
|
long long period_ns = NS_PER_SEC / t->cycles_per_sec;
|
|
int cycles = (int)(t->accumulator_ns / period_ns);
|
|
t->accumulator_ns %= period_ns;
|
|
return cycles;
|
|
}
|
|
|
|
// How many cycles to run between glances at the wall clock. In fast mode there is
|
|
// no clock to keep pace with, so run a large batch before looking up.
|
|
#define FAST_BATCH 65536
|
|
|
|
unsigned long cycleCount = 0;
|
|
char *programFile = NULL;
|
|
|
|
// Memory Banks:
|
|
uint8_t Program[0x10000], Data[0x10000];
|
|
|
|
// How the run is reported. The idle half is mentioned only when there is one, so that
|
|
// every program written before WAIT existed prints exactly the line it always did.
|
|
//
|
|
// THE TWO ARE NOT THE SAME KIND OF TIME. A bus cycle is the machine using memory; an idle
|
|
// cycle is the machine stopped in a WAIT while a device catches up. Added together they
|
|
// are elapsed time, which is what a cycle limit measures; told apart they say whether a
|
|
// program was working or waiting.
|
|
static void reportCycles(const CPURegisters *cpu, unsigned long cycleCount) {
|
|
if (cpu->idleCycles > 0) {
|
|
printf("Execution halted after %lu cycles, %lu of them waiting.\n",
|
|
cycleCount, cpu->idleCycles);
|
|
} else {
|
|
printf("Execution halted after %lu cycles.\n", cycleCount);
|
|
}
|
|
}
|
|
|
|
int main (int argc, char *argv[]) {
|
|
EmulatorOptions options;
|
|
uint8_t result = parseOptions(argc, argv, &options);
|
|
if (result == OPTIONS_HELP) {
|
|
// The user asked for help and got it, which is not a failure.
|
|
return 0;
|
|
} else if (result == OPTIONS_ERROR) {
|
|
// Bad command line, don't execute.
|
|
return 1;
|
|
}
|
|
if (optind < argc) {
|
|
programFile = argv[optind];
|
|
optind++;
|
|
} else {
|
|
fprintf(stderr, "Error: No boot image specified.\n");
|
|
printHelp(argv[0]);
|
|
return 1;
|
|
}
|
|
if (optind < argc) {
|
|
fprintf(stderr, "Error: Unexpected argument: %s\n", argv[optind]);
|
|
return 1;
|
|
}
|
|
if (loadFile(programFile, Program, Data)) {
|
|
fprintf(stderr, "Error: Couldn't read file: %s\n", programFile);
|
|
return 1;
|
|
}
|
|
if (options.disk != NULL && attachDisk(options.disk, options.writeProtect)) {
|
|
return 1;
|
|
}
|
|
CPURegisters cpu;
|
|
// The controller has to know where the memories are before anything can reach
|
|
// them through it. Banks 0 and 1 are those two arrays.
|
|
initializeController(Program, Data);
|
|
initializeCPU(&cpu, Program, Data);
|
|
if(options.debug) {
|
|
printRegisters(&cpu, Program, Data);
|
|
}
|
|
|
|
CycleTimer timer;
|
|
setDiskLatency(options.diskCycles);
|
|
cycle_timer_init(&timer, CYCLE_RATE);
|
|
|
|
uint8_t limitReached = 0;
|
|
while (!(cpu.Status & STATUS_HALT) && !limitReached) {
|
|
if (options.debug) {
|
|
// Wait before advancing, not after, so that a keypress is what moves the
|
|
// machine on rather than something that happens once it already has.
|
|
// Through the console rather than getchar, so that everything reading standard
|
|
// input reads it the same way and the console's pushback stays the only place
|
|
// a byte can be sitting.
|
|
consoleReadByte();
|
|
}
|
|
int cycles;
|
|
if (options.debug) {
|
|
// Debug mode advances one instruction per keypress, so the wall clock
|
|
// has no say in how many cycles to run.
|
|
cycles = 1;
|
|
} else if (options.fast) {
|
|
cycles = FAST_BATCH;
|
|
} else {
|
|
cycles = cycle_timer_tick(&timer);
|
|
}
|
|
// ---- Spending a budget of cycles, not running a count of instructions ----
|
|
//
|
|
// An instruction costs what it touches, so a batch is finished when the cycles are
|
|
// gone rather than after so many steps. In debug mode the budget is one, and any
|
|
// instruction costs at least the fetch of its own opcode, so one step still runs.
|
|
for (long spent = 0; spent < cycles; ) {
|
|
// Both kinds of cycle, because both are time passing. A step that waits
|
|
// spends no bus at all, and a budget measured only in bus cycles would never
|
|
// be spent - the machine would sit inside one batch forever and the device it
|
|
// was waiting for would never be given a moment to finish.
|
|
unsigned long before = cpu.busCycles + cpu.idleCycles;
|
|
stepCPU(&cpu);
|
|
unsigned long took = (cpu.busCycles + cpu.idleCycles) - 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;
|
|
}
|
|
if (options.cycles && cycleCount >= options.cycles) {
|
|
limitReached = 1;
|
|
break;
|
|
}
|
|
}
|
|
if (options.debug) {
|
|
printRegisters(&cpu, Program, Data);
|
|
printf("Cycle: %lu\n", cycleCount);
|
|
}
|
|
}
|
|
detachDisk();
|
|
if (limitReached) {
|
|
printf("Execution stopped after %lu cycles. (cycle limit reached)\n", cycleCount);
|
|
} else if (cpu.Status & STATUS_FAULT) {
|
|
// The Program Counter is still pointing at whatever the CPU could not get past.
|
|
reportCycles(&cpu, cycleCount);
|
|
if (cpu.Fault == FAULT_NO_HANDLER) {
|
|
fprintf(stderr, "Fault: Software vector %u, dispatched from Program Address 0x%04X, has no handler installed.\n",
|
|
cpu.FaultVector, cpu.ProgramCounter);
|
|
} else if (cpu.Fault == FAULT_DEVICE_REFUSED) {
|
|
fprintf(stderr, "Fault: The device on port %u refused the access at Program Address 0x%04X, and nothing is installed to deal with it.\n",
|
|
cpu.FaultVector, cpu.ProgramCounter);
|
|
} else if (cpu.Fault == FAULT_NO_DEVICE_HANDLER) {
|
|
fprintf(stderr, "Fault: The device on port %u interrupted at Program Address 0x%04X, and hardware vector %u has no handler installed.\n",
|
|
cpu.FaultVector, cpu.ProgramCounter, cpu.FaultVector);
|
|
} else {
|
|
fprintf(stderr, "Fault: 0x%02X at Program Address 0x%04X is not an instruction.\n",
|
|
Program[cpu.ProgramCounter], cpu.ProgramCounter);
|
|
}
|
|
return 1;
|
|
} else {
|
|
reportCycles(&cpu, cycleCount);
|
|
}
|
|
return 0;
|
|
}
|