./SplitBit --disk system.img stage two CosmOS > No boot image named. The emulator shadows its built in stage one into Program Memory - boot vector included - and the CPU then does exactly what it has always done: reads the boot vector and starts where it points. NOTHING ABOUT THE CPU CHANGED to make a machine that starts itself, which is what picking shadowing over a mapped ROM bought. The ROM is generated from Programs/Boot/stage1.asm by the makefile rather than committed beside it, because a copy of a program kept next to the program is a copy that goes stale. That makes the assembler a real dependency of the emulator, which it always sort of was and now says so. od and awk rather than xxd, which is not everywhere, or python, which the README does not ask anybody to install in order to build this. loadROM is loadFile given bytes instead of a path: both go through one reader over an fmemopen stream, because a ROM is a boot image and there is no reason for the machine to have two ways of understanding one. Naming an image still works and is what every other test here does. That path is not a shortcut to apologise for - placing memory from outside is a real thing real machines allow, and it is a debugger. The help says so now. No image and no disk is the one case with nothing to run, and it says that rather than printing a usage message about a missing file. run.sh gained a "rom" mode which hands the emulator a disk and nothing else. The source column still names stage1.asm, because that is what is IN the ROM: assembling it there says the thing the emulator carries is a thing that still assembles.
219 lines
8.4 KiB
C
219 lines
8.4 KiB
C
// emulator.c
|
|
|
|
// SplitBit Emulator
|
|
// Small 8-Bit Harvard Architecture CPU
|
|
// Written by Anachronaut
|
|
// 10/15/2024
|
|
|
|
#include "rom.h"
|
|
#include "bootstrap.h"
|
|
#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++;
|
|
}
|
|
if (optind < argc) {
|
|
fprintf(stderr, "Error: Unexpected argument: %s\n", argv[optind]);
|
|
return 1;
|
|
}
|
|
// ---- Where the machine's first instruction comes from ----
|
|
//
|
|
// Named an image, it is placed into memory and started - which is what a debugger
|
|
// does, and is how every test here runs. That path is not a shortcut to apologise
|
|
// for: placing memory from outside is a real thing real machines allow.
|
|
//
|
|
// Named none, the machine starts the way hardware would: the ROM is shadowed into
|
|
// Program Memory and it reads the disk for the rest. There has to be a disk for that
|
|
// to mean anything, and no image and no disk is a machine with nothing to run.
|
|
if (programFile == NULL && options.disk == NULL) {
|
|
fprintf(stderr, "Error: No boot image and no disk, so there is nothing to run.\n");
|
|
printHelp(argv[0]);
|
|
return 1;
|
|
}
|
|
if (programFile != NULL) {
|
|
if (loadFile(programFile, Program, Data)) {
|
|
fprintf(stderr, "Error: Couldn't read file: %s\n", programFile);
|
|
return 1;
|
|
}
|
|
} else if (loadROM(bootROM, bootROMBytes, Program, Data)) {
|
|
fprintf(stderr, "Error: The boot ROM is not a boot image.\n");
|
|
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;
|
|
}
|