A blit cost ten cycles, which were the five port writes that set it up. The quarter of a kilobyte that moved cost nothing, and no hardware moves a quarter of a kilobyte for nothing. BANKS ARE SEPARATE MEMORIES, AND THAT IS WHAT SETS THE RATE. A move between two of them can overlap its read and its write - fetch the next byte while the last one is stored - so it settles at a byte a cycle. A move within one bank cannot, and costs two. A fill has nothing to read and costs one whatever the banks are. The odd cycle on each is the pipeline filling. That is not a modelling choice so much as a reading of the structure the machine already has: a Program to Data blit is inherently twice the rate of a Data to Data one, and it is legible why. Measured: 256 bytes is 297 cycles across banks and 518 within one, both including the instructions that ask for it. WHAT IT TAUGHT, which was not what I expected. Charging for movement costs the native assembler 0.4 per cent and costs directory work 13.4. The assembler reads a block and then thinks about it for a long time, so the move is amortised into nothing; the filesystem reads a block in order to look at it and does nothing else in between. So the case for a blitter that runs alongside the CPU is weaker than it sounds. Concurrency pays when there is other work to do during the transfer, and the place that spends its time moving memory is exactly the place with nothing else to do - it blits a block precisely so that it can read it. What that workload wants is a FASTER controller, not a concurrent one: a wider data path halves the wait, and the machine is waiting either way. Video is the case that would still want concurrency, since a frame can be moved while the next one is worked out. That is an argument about software nobody has written yet, and it is now an argument with numbers on the other side of it. The byte at a time port is charged too, for the byte it moves beyond reaching the port. Nothing polls CTRL_STATUS, so the transfer stalls whoever asked for it, which is the conservative reading and the one the software already assumes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
347 lines
14 KiB
C
347 lines
14 KiB
C
// controller.c
|
|
// The SplitBit Memory Controller.
|
|
//
|
|
// This is the only thing on the machine that can write Program Memory. That is the whole
|
|
// reason it exists: SplitBit is a Harvard machine and its instruction set cannot reach
|
|
// its own code, which is worth keeping true of the instructions. Routing it through a
|
|
// device instead makes writing code a capability, reached deliberately through a port,
|
|
// rather than something every instruction stream can do by accident.
|
|
//
|
|
// Written by Anachronaut
|
|
|
|
#include "controller.h"
|
|
#include "io.h"
|
|
#include "../Assembler/assembly.h" // For the fault vector numbers.
|
|
#include <string.h>
|
|
|
|
typedef struct {
|
|
uint8_t *memory; // Never published. See the note in controller.h.
|
|
uint32_t capacity; // In bytes. A full bank is 65536, which is why this is not 16 bit.
|
|
uint8_t flags;
|
|
uint8_t ownerPort;
|
|
uint16_t guardStart;
|
|
uint16_t guardEnd;
|
|
} Bank;
|
|
|
|
static Bank banks[BANK_COUNT];
|
|
|
|
// Bank 2's contents: the description of every bank, for anything that wants to read it.
|
|
static uint8_t bankTable[BANK_TABLE_BYTES];
|
|
|
|
// The registers, exactly as the ports name them.
|
|
static uint8_t sourceBank, destBank, guardBank;
|
|
static uint16_t sourceAddress, destAddress, length;
|
|
static uint16_t guardStart, guardEnd;
|
|
static uint8_t status;
|
|
|
|
// Writes a bank's description into the table that bank 2 publishes. Called whenever
|
|
// anything about a bank changes, so the published table and the real one cannot drift.
|
|
static void publishBank(int number) {
|
|
uint8_t *record = bankTable + number * BANK_RECORD_BYTES;
|
|
record[0] = banks[number].flags;
|
|
record[1] = banks[number].ownerPort;
|
|
// Zero means the whole 64K, the same convention Length uses, because a capacity of
|
|
// nothing is never what anyone meant.
|
|
record[2] = (uint8_t)((banks[number].capacity >> 8) & 0xFF);
|
|
record[3] = (uint8_t)(banks[number].capacity & 0xFF);
|
|
record[4] = (uint8_t)(banks[number].guardStart >> 8);
|
|
record[5] = (uint8_t)(banks[number].guardStart & 0xFF);
|
|
record[6] = (uint8_t)(banks[number].guardEnd >> 8);
|
|
record[7] = (uint8_t)(banks[number].guardEnd & 0xFF);
|
|
}
|
|
|
|
static void defineBank(int number, uint8_t *memory, uint32_t capacity, uint8_t flags, uint8_t owner) {
|
|
banks[number].memory = memory;
|
|
banks[number].capacity = capacity;
|
|
banks[number].flags = flags | BANK_FLAG_PRESENT;
|
|
banks[number].ownerPort = owner;
|
|
banks[number].guardStart = 0;
|
|
banks[number].guardEnd = 0;
|
|
publishBank(number);
|
|
}
|
|
|
|
void initializeController(uint8_t *programMemory, uint8_t *dataMemory) {
|
|
memset(banks, 0, sizeof(banks));
|
|
memset(bankTable, 0, sizeof(bankTable));
|
|
for (int i = 0; i < BANK_COUNT; i++) {
|
|
banks[i].ownerPort = BANK_OWNER_MACHINE;
|
|
publishBank(i);
|
|
}
|
|
|
|
defineBank(BANK_PROGRAM, programMemory, 0x10000, 0, BANK_OWNER_MACHINE);
|
|
defineBank(BANK_DATA, dataMemory, 0x10000, 0, BANK_OWNER_MACHINE);
|
|
// The table describes itself, so a program that walks it finds bank 2 in there along
|
|
// with everything else. It is read only, which is what keeps RegisterBank the only
|
|
// way to change what the controller routes through.
|
|
defineBank(BANK_TABLE, bankTable, BANK_TABLE_BYTES, BANK_FLAG_READ_ONLY, BANK_OWNER_MACHINE);
|
|
|
|
sourceBank = destBank = guardBank = 0;
|
|
sourceAddress = destAddress = length = 0;
|
|
guardStart = guardEnd = 0;
|
|
status = 0;
|
|
}
|
|
|
|
// Refuses, remembering why so that Status can be read afterwards.
|
|
static void refuse(uint8_t faultVector) {
|
|
status = faultVector;
|
|
refuseAccess(faultVector);
|
|
}
|
|
|
|
// Is this somewhere the controller can read? A bank has to be there, and the address has
|
|
// to be inside it.
|
|
static int canRead(uint8_t bank, uint16_t address) {
|
|
if (!(banks[bank].flags & BANK_FLAG_PRESENT) || address >= banks[bank].capacity) {
|
|
refuse(VECTOR_BANK_FAULT);
|
|
return 0;
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
// The same, and then the two reasons a write in particular gets turned away.
|
|
static int canWrite(uint8_t bank, uint16_t address) {
|
|
if (!canRead(bank, address)) {
|
|
return 0;
|
|
}
|
|
if (banks[bank].flags & BANK_FLAG_READ_ONLY) {
|
|
refuse(VECTOR_GUARD_VIOLATION);
|
|
return 0;
|
|
}
|
|
if ((banks[bank].flags & BANK_FLAG_GUARDED)
|
|
&& address >= banks[bank].guardStart && address <= banks[bank].guardEnd) {
|
|
refuse(VECTOR_GUARD_VIOLATION);
|
|
return 0;
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
// A length of zero means the whole 64K, because a transfer of no bytes is never what
|
|
// anyone meant, and 65536 does not fit in the two bytes that carry it.
|
|
static uint32_t transferLength(void) {
|
|
return (length == 0) ? 0x10000u : (uint32_t)length;
|
|
}
|
|
|
|
// Everything a transfer will touch is checked before any of it moves. A blit that ran
|
|
// out of bank halfway would leave memory in a state no program asked for, and the
|
|
// diagnostic would arrive after the damage rather than instead of it. So these answer
|
|
// for the whole range or refuse the whole thing.
|
|
static int rangeReadable(uint8_t bank, uint16_t address, uint32_t count) {
|
|
if (!(banks[bank].flags & BANK_FLAG_PRESENT)
|
|
|| (uint32_t)address + count > banks[bank].capacity) {
|
|
refuse(VECTOR_BANK_FAULT);
|
|
return 0;
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
static int rangeWritable(uint8_t bank, uint16_t address, uint32_t count) {
|
|
if (!rangeReadable(bank, address, count)) {
|
|
return 0;
|
|
}
|
|
if (banks[bank].flags & BANK_FLAG_READ_ONLY) {
|
|
refuse(VECTOR_GUARD_VIOLATION);
|
|
return 0;
|
|
}
|
|
if (banks[bank].flags & BANK_FLAG_GUARDED) {
|
|
uint32_t last = (uint32_t)address + count - 1;
|
|
// Any overlap at all with the fence, not just a write that starts inside it.
|
|
if (!(last < banks[bank].guardStart || address > banks[bank].guardEnd)) {
|
|
refuse(VECTOR_GUARD_VIOLATION);
|
|
return 0;
|
|
}
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
// What the moves below have cost since anybody last asked.
|
|
static unsigned long pendingCycles = 0;
|
|
|
|
unsigned long controllerTakeCycles(void) {
|
|
unsigned long taken = pendingCycles;
|
|
pendingCycles = 0;
|
|
return taken;
|
|
}
|
|
|
|
static void doBlit(void) {
|
|
uint32_t count = transferLength();
|
|
if (!rangeReadable(sourceBank, sourceAddress, count)) {
|
|
return;
|
|
}
|
|
if (!rangeWritable(destBank, destAddress, count)) {
|
|
return;
|
|
}
|
|
// memmove rather than memcpy, because source and destination may be the same bank
|
|
// and may overlap. Sliding a buffer along itself is an ordinary thing to want, and
|
|
// getting it silently wrong is exactly the sort of failure this machine keeps
|
|
// designing against.
|
|
memmove(banks[destBank].memory + destAddress,
|
|
banks[sourceBank].memory + sourceAddress, count);
|
|
// A byte read and a byte written. Two banks are two memories and the pair overlaps;
|
|
// one bank is one memory and they do not. The odd cycle is the pipeline filling.
|
|
pendingCycles += (sourceBank == destBank) ? 2 * count + 1 : count + 1;
|
|
sourceAddress = (uint16_t)(sourceAddress + count);
|
|
destAddress = (uint16_t)(destAddress + count);
|
|
status = 0;
|
|
}
|
|
|
|
static void doFill(void) {
|
|
uint32_t count = transferLength();
|
|
if (!rangeWritable(destBank, destAddress, count)) {
|
|
return;
|
|
}
|
|
// A fill has nowhere to read from, only a value, so SourceLow carries the byte and
|
|
// the rest of the source registers mean nothing here.
|
|
pendingCycles += count + 1;
|
|
memset(banks[destBank].memory + destAddress, (int)(sourceAddress & 0xFF), count);
|
|
destAddress = (uint16_t)(destAddress + count);
|
|
status = 0;
|
|
}
|
|
|
|
// DestBank is the number being given out, and SourceLow says which port owns the memory.
|
|
// The capacity is asked of the device rather than supplied, because how big a bank is
|
|
// was settled when the machine was built.
|
|
static void doRegisterBank(void) {
|
|
if (destBank <= BANK_TABLE) {
|
|
// Banks 0 to 2 are the machine's own and are not anybody's to hand out.
|
|
refuse(VECTOR_BANK_FAULT);
|
|
return;
|
|
}
|
|
uint8_t port = (uint8_t)(sourceAddress & 0xFF);
|
|
uint32_t capacity = 0;
|
|
uint8_t *memory = deviceMemory(port, &capacity);
|
|
if (memory == NULL) {
|
|
// Either nothing is on that port or what is there brings no memory. Registering
|
|
// it would put a bank in the table that leads nowhere.
|
|
refuse(VECTOR_BANK_FAULT);
|
|
return;
|
|
}
|
|
// Registering over a bank that already has something in it is allowed. Which number
|
|
// a device's memory answers to is the OS's business, and nothing was allocated that
|
|
// could be lost by changing its mind.
|
|
defineBank(destBank, memory, capacity, 0, port);
|
|
status = 0;
|
|
}
|
|
|
|
// The guard registers stage a range; this is what commits it. Raising a fence over a
|
|
// bank that is not there would protect nothing while looking like it protected
|
|
// something, so it is refused rather than quietly accepted.
|
|
static void doGuardOn(void) {
|
|
if (!(banks[guardBank].flags & BANK_FLAG_PRESENT)) {
|
|
refuse(VECTOR_BANK_FAULT);
|
|
return;
|
|
}
|
|
if (guardStart > guardEnd) {
|
|
// No address can be inside a range that ends before it starts, so this fence
|
|
// would catch nothing. A program that raised one would believe it was protected
|
|
// and would not be, which is worse than having no fence at all.
|
|
refuse(VECTOR_BANK_FAULT);
|
|
return;
|
|
}
|
|
banks[guardBank].guardStart = guardStart;
|
|
banks[guardBank].guardEnd = guardEnd;
|
|
banks[guardBank].flags |= BANK_FLAG_GUARDED;
|
|
publishBank(guardBank);
|
|
status = 0;
|
|
}
|
|
|
|
static void doGuardOff(void) {
|
|
if (!(banks[guardBank].flags & BANK_FLAG_PRESENT)) {
|
|
refuse(VECTOR_BANK_FAULT);
|
|
return;
|
|
}
|
|
banks[guardBank].flags &= (uint8_t)~BANK_FLAG_GUARDED;
|
|
publishBank(guardBank);
|
|
status = 0;
|
|
}
|
|
|
|
uint8_t controllerWrite(uint8_t value, uint8_t port) {
|
|
switch (port) {
|
|
case CTRL_SOURCE_BANK: sourceBank = value; break;
|
|
case CTRL_SOURCE_HIGH: sourceAddress = (uint16_t)(value << 8) | (sourceAddress & 0x00FF); break;
|
|
case CTRL_SOURCE_LOW: sourceAddress = (sourceAddress & 0xFF00) | value; break;
|
|
case CTRL_DEST_BANK: destBank = value; break;
|
|
case CTRL_DEST_HIGH: destAddress = (uint16_t)(value << 8) | (destAddress & 0x00FF); break;
|
|
case CTRL_DEST_LOW: destAddress = (destAddress & 0xFF00) | value; break;
|
|
case CTRL_LENGTH_HIGH: length = (uint16_t)(value << 8) | (length & 0x00FF); break;
|
|
case CTRL_LENGTH_LOW: length = (length & 0xFF00) | value; break;
|
|
|
|
case CTRL_GUARD_BANK: guardBank = value; break;
|
|
case CTRL_GUARD_START_HIGH: guardStart = (uint16_t)(value << 8) | (guardStart & 0x00FF); break;
|
|
case CTRL_GUARD_START_LOW: guardStart = (guardStart & 0xFF00) | value; break;
|
|
case CTRL_GUARD_END_HIGH: guardEnd = (uint16_t)(value << 8) | (guardEnd & 0x00FF); break;
|
|
case CTRL_GUARD_END_LOW: guardEnd = (guardEnd & 0xFF00) | value; break;
|
|
|
|
case CTRL_DATA:
|
|
// A byte into the destination, and the address steps on so that writing a
|
|
// run of bytes is a loop over one instruction rather than four.
|
|
if (canWrite(destBank, destAddress)) {
|
|
pendingCycles++; // The byte itself, beyond reaching the port.
|
|
banks[destBank].memory[destAddress] = value;
|
|
if (destBank == BANK_TABLE) {
|
|
// Unreachable while the table is read only, and here so that it stays
|
|
// true if that ever changes: the published bytes are a description,
|
|
// and nothing may write through them into a real bank.
|
|
publishBank(BANK_TABLE);
|
|
}
|
|
destAddress++;
|
|
status = 0;
|
|
}
|
|
break;
|
|
|
|
case CTRL_COMMAND:
|
|
// Both leave the addresses past whatever they touched and Length as it was,
|
|
// so asking again carries straight on from where the last one stopped.
|
|
switch (value) {
|
|
case COMMAND_BLIT: doBlit(); break;
|
|
case COMMAND_FILL: doFill(); break;
|
|
case COMMAND_REGISTER_BANK: doRegisterBank(); break;
|
|
case COMMAND_GUARD_ON: doGuardOn(); break;
|
|
case COMMAND_GUARD_OFF: doGuardOff(); break;
|
|
default:
|
|
// Refusing an unknown command is better than ignoring it, since a
|
|
// program that asked for something is entitled to find out that it
|
|
// did not happen.
|
|
refuse(VECTOR_BANK_FAULT);
|
|
break;
|
|
}
|
|
break;
|
|
|
|
default:
|
|
// Status is read only.
|
|
break;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
uint8_t controllerRead(uint8_t port) {
|
|
switch (port) {
|
|
case CTRL_SOURCE_BANK: return sourceBank;
|
|
case CTRL_SOURCE_HIGH: return (uint8_t)(sourceAddress >> 8);
|
|
case CTRL_SOURCE_LOW: return (uint8_t)(sourceAddress & 0xFF);
|
|
case CTRL_DEST_BANK: return destBank;
|
|
case CTRL_DEST_HIGH: return (uint8_t)(destAddress >> 8);
|
|
case CTRL_DEST_LOW: return (uint8_t)(destAddress & 0xFF);
|
|
case CTRL_LENGTH_HIGH: return (uint8_t)(length >> 8);
|
|
case CTRL_LENGTH_LOW: return (uint8_t)(length & 0xFF);
|
|
case CTRL_STATUS: return status;
|
|
|
|
case CTRL_GUARD_BANK: return guardBank;
|
|
case CTRL_GUARD_START_HIGH: return (uint8_t)(guardStart >> 8);
|
|
case CTRL_GUARD_START_LOW: return (uint8_t)(guardStart & 0xFF);
|
|
case CTRL_GUARD_END_HIGH: return (uint8_t)(guardEnd >> 8);
|
|
case CTRL_GUARD_END_LOW: return (uint8_t)(guardEnd & 0xFF);
|
|
|
|
case CTRL_DATA: {
|
|
// A byte out of the source, stepping on the same way a write does.
|
|
if (!canRead(sourceBank, sourceAddress)) {
|
|
return 0;
|
|
}
|
|
pendingCycles++; // As above, the other way round.
|
|
uint8_t value = banks[sourceBank].memory[sourceAddress];
|
|
sourceAddress++;
|
|
status = 0;
|
|
return value;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|