The controller now reaches bank memory two bytes at a time, so a transfer whose source, destination and length are all even moves two bytes a cycle between banks and one within a bank - twice what each was. A 256 byte block between banks falls from 257 cycles to 129. Alignment is required all three ways because a word is read at an even address and written at an even address; an odd anything would mean shifting bytes across word boundaries to line them up, which is a different design. A misaligned transfer falls back to the byte a cycle it cost before, so nothing already written got slower. THE CPU DOES NOT CHANGE. It still sees eight bits, a Data Pointer still addresses a byte, and no instruction means anything different. This is a peripheral getting faster, which is why it is worth doing now rather than after more is built on top of it. The rule is deliberately visible rather than smoothed over: aligning a buffer costs nothing and halves what moving it costs, and a cost a program cannot see is a cost it cannot avoid. Tests/cycles.sh is new, and is the test the Test Manual has always said this kind of change would need - run.sh strips the cycle count from every recorded result, so nothing else in the suite can see any of this. It pins the RATE rather than a total: each case runs twice from programs whose instructions are identical but for the byte written to the Command port, once asking for the transfer and once for GuardOff, which costs nothing beyond the port write. The difference is the transfer and nothing else. Verified by disabling the widening, which failed exactly the three aligned cases and left the five misaligned ones passing. The Programming Manual gains a section saying what a transfer costs, which it never said at all - it only promised a transfer does not wait, which is a different claim and could be read as promising it is free. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
370 lines
15 KiB
C
370 lines
15 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;
|
|
|
|
// ---- Sixteen bits wide, when the addresses let it be ----
|
|
//
|
|
// The controller reaches bank memory two bytes at a time, so an aligned transfer moves two
|
|
// bytes in the time a misaligned one moves one. A word is read at an even address and
|
|
// written at an even address, which is why the source, the destination AND the length must
|
|
// all be even: an odd anything would have the controller shifting bytes across word
|
|
// boundaries to line them up, and that is a second design rather than this one.
|
|
//
|
|
// Misaligned falls back to a byte a cycle, which is exactly what the machine did before it
|
|
// was widened, so nothing already written got slower.
|
|
//
|
|
// THE RULE IS VISIBLE ON PURPOSE. A program that cares can align what it moves, and a cost
|
|
// a program cannot see is a cost it cannot avoid. It is also the honest thing to model:
|
|
// hardware this shape really does behave this way.
|
|
static int wideRun(uint32_t addressesAndLength) {
|
|
return (addressesAndLength & 1u) == 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 word read and a word 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.
|
|
//
|
|
// Wide when everything is even, so an aligned move between banks settles at two bytes a
|
|
// cycle and an aligned move within one at a byte a cycle - each twice what it was.
|
|
unsigned long moves = wideRun(sourceAddress | destAddress | count) ? count / 2 : count;
|
|
pendingCycles += (sourceBank == destBank) ? 2 * moves + 1 : moves + 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 - including for the alignment, which
|
|
// asks only about where the bytes are going and how many there are.
|
|
pendingCycles += (wideRun(destAddress | count) ? count / 2 : 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;
|
|
}
|