Memory controller implemented.

This commit is contained in:
Anachronaut
2026-08-15 20:43:53 -04:00
parent c4b2c27a2d
commit 04dfcd707b
32 changed files with 1842 additions and 21 deletions
+40
View File
@@ -0,0 +1,40 @@
; Running off the end of a bank.
;
; Bank 2 is the bank table: 256 banks at eight bytes each, so 2048 bytes exactly. The
; last byte is at 0x07FF and there is nothing at 0x0800. A bank knowing how big it is
; means the difference shows up as a fault at the instruction that overran, instead of
; quietly reading whatever happened to be next in the emulator's memory.
;
; Nothing is installed for BankFault here, so the machine stops.
;
; Correct output is:
; O the last byte of the bank was read without complaint
; a fault naming port 233, which is the controller's Data port, and a non zero exit.
#Program
start:
INIA 0d2
OUTA 0xE0 ; SourceBank = 2
INIA 0x07
OUTA 0xE1
INIA 0xFF
OUTA 0xE2 ; 0x07FF, the last byte there is
INA 0xE9 ; Fine.
INIA 0d79 ; 'O'
OUTA 0x00
INIA 0x0A
OUTA 0x00
INIA 0x08
OUTA 0xE1
RSTA
OUTA 0xE2 ; 0x0800, one past the end
INA 0xE9 ; Refused: BankFault.
; Never reached.
INIA 0d88 ; 'X'
OUTA 0x00
HALT
+210
View File
@@ -0,0 +1,210 @@
; Blits and fills.
;
; A blit moves a run of bytes between any two banks, including from one place in a bank
; to another. A fill writes one byte across a run. Both are instantaneous: waiting
; belongs to a peripheral that has something to wait for, not to the moving of bytes.
;
; Everything a transfer will touch is checked before any of it moves, so a transfer that
; would run out of bank refuses outright rather than stopping halfway and leaving memory
; in a state nobody asked for. The last case here proves that: the blit is refused, and
; the bytes it would have written are still what they were.
;
; Correct output is:
; blitted copied from one place in Data Memory to another
; .... filled with a byte of our choosing
; DE AD blitted from Data Memory into Program Memory
; untouched a refused blit moved nothing at all
; ABABCDEFGH an overlapping blit slid the bytes rather than repeating one
#Include print.asm
#Program
start:
; ---- A blit inside Data Memory, which is what a memcpy is here. ----
SETD.0 Message
CALL aimSource
SETD.0 Landing
CALL aimDest
INIA 0d8
CALL setLength ; "blitted" and its terminator.
INIA 0x01
OUTA 0xE8 ; Blit.
SETD.0 Landing
CALL printString
CALL lineFeed
; ---- A fill, with a byte of our choosing rather than only zero. ----
SETD.0 Landing
CALL aimDest
INIA 0x2E ; '.', into SourceLow, which is where a fill takes its byte.
OUTA 0xE2
INIA 0d4
CALL setLength
INIA 0x02
OUTA 0xE8 ; Fill.
; Terminate it so printString knows where to stop.
SETD.0 Landing
DPUP.0 0d04
RSTA
STA.0
SETD.0 Landing
CALL printString
CALL lineFeed
; ---- Data Memory into Program Memory. This is what a loader does. ----
SETD.0 Code
CALL aimSource
SETD.0 Target
CALL aimDestProgram
INIA 0d2
CALL setLength
INIA 0x01
OUTA 0xE8 ; Blit.
; Read it back out of Program Memory to prove it landed.
SETD.0 Target
CALL aimSourceProgram
CALL readAndPrint
CALL readAndPrint
CALL lineFeed
; ---- A blit that would run past the end of bank 2. ----
; Bank 2 is 2048 bytes. Starting at 2040 and asking for 16 does not fit, so the whole
; thing is refused and Landing keeps what it already had.
SETD.0 Untouched
CALL aimSource
SETD.0 Landing
CALL aimDest
INIA 0d10
CALL setLength
INIA 0x01
OUTA 0xE8 ; This one works, so Landing says "untouched".
INIA 0d2
OUTA 0xE0 ; SourceBank = 2, the bank table.
INIA 0x07
OUTA 0xE1
INIA 0xF8
OUTA 0xE2 ; 0x07F8, eight bytes from the end.
SETD.0 Landing
CALL aimDest
INIA 0d16
CALL setLength
INIA 0x01
OUTA 0xE8 ; Refused: it would read past the end of bank 2.
SETD.0 Landing
CALL printString
CALL lineFeed
; ---- A blit whose source and destination overlap. ----
; Sliding a run of bytes along inside its own bank is an ordinary thing to want, so it
; works rather than quietly repeating the first byte the way a naive forward copy does.
SETD.0 Slide
CALL aimSource
SETD.0 Slide
DPUP.0 0d02
CALL aimDest
INIA 0d8
CALL setLength
INIA 0x01
OUTA 0xE8
SETD.0 Slide
CALL printString
CALL lineFeed
HALT
; ---- Helpers. DP0 holds the address to aim at. ----
aimSource:
INIA 0d1 ; Bank 1, Data Memory.
OUTA 0xE0
BRI aimSourceAddress
aimSourceProgram:
RSTA ; Bank 0, Program Memory.
OUTA 0xE0
aimSourceAddress:
PSHD.0
POPA
POPB
OUTB 0xE1
OUTA 0xE2
RET
aimDest:
INIA 0d1 ; Bank 1, Data Memory.
OUTA 0xE3
BRI aimDestAddress
aimDestProgram:
RSTA ; Bank 0, Program Memory.
OUTA 0xE3
aimDestAddress:
PSHD.0
POPA
POPB
OUTB 0xE4
OUTA 0xE5
RET
; A is the length, which is never more than a byte in this program.
setLength:
PSHA
RSTA
OUTA 0xE6
POPA
OUTA 0xE7
RET
readAndPrint:
INA 0xE9
CALL printByteHex
CALL blankSpace
RET
; The refused blit is caught so the program can carry on and show that nothing moved.
; The command write is two bytes, an opcode and a port, so stepping by two gets past it.
refusedHandler:
MVSD.0
DPUP.0 0d14
LDA.0
CCF
INIB 0d2
ADD
MVQA
STA.0
BRC refusedCarried
RETI
refusedCarried:
DPDN.0 0d01
LDA.0
INCA
STA.0
RETI
; Never executed, only written over.
Target:
0x00 0x00
#Data
Message:
"blitted"
Untouched:
"untouched"
Code:
0xDE 0xAD
Landing:
#Reserve 0d16
Slide:
"ABCDEFGH"
#Reserve 0d4
#Vectors
BankFault refusedHandler
@@ -0,0 +1,66 @@
; Reads Program Memory through the memory controller.
;
; Nothing on SplitBit could do this before. The CPU cannot reach its own code, which is
; what a Harvard machine is, and this is the device that can.
;
; The bytes read back are a marker written into the Program Segment after the HALT, where
; nothing executes them. The controller is aimed at it using the label's own address, so
; this checks the controller against what the source says rather than against a guess
; about where the assembler put things.
;
; The address steps on by itself, so reading a run of bytes is a loop over one
; instruction rather than four.
;
; Correct output is:
; DE AD BE EF the marker, read out of Program Memory
; 01 FF bank 0's record in bank 2: present, owned by the machine
; 00 00 and its capacity, where zero means the whole 64K
#Include print.asm
#Program
start:
; Aim the controller at the marker, using the marker's own address.
SETD.0 Marker
PSHD.0 ; High byte, then low, so the low byte comes off first.
POPA
POPB
PSHA ; Keep the low byte while A is needed for something else.
RSTA
OUTA 0xE0 ; SourceBank = 0, which is Program Memory.
OUTB 0xE1 ; SourceHigh
POPA
OUTA 0xE2 ; SourceLow
CALL readAndPrint
CALL readAndPrint
CALL readAndPrint
CALL readAndPrint
CALL lineFeed
; Now the bank table, which is bank 2. Bank 0's record comes first.
INIA 0d2
OUTA 0xE0
RSTA
OUTA 0xE1
OUTA 0xE2
CALL readAndPrint ; Flags: present.
CALL readAndPrint ; Owner: the machine itself.
CALL lineFeed
CALL readAndPrint ; Capacity, high byte.
CALL readAndPrint ; Capacity, low byte.
CALL lineFeed
HALT
readAndPrint:
INA 0xE9
CALL printByteHex
CALL blankSpace
RET
; Never executed. It is here to be read rather than run.
Marker:
0xDE 0xAD 0xBE 0xEF
@@ -0,0 +1,127 @@
; Writes Program Memory through the controller, and gets turned away from what it may
; not touch.
;
; Writing code is the thing SplitBit's instruction set deliberately cannot do. The
; controller can, which is what makes a loader possible. The marker below is written and
; then read back through the controller, so the change is observed rather than assumed.
;
; The two refusals are the other half. Bank 2 is the bank table and is read only, so
; writing to it is a GuardViolation. Bank 200 has nothing registered in it, so naming it
; is a BankFault. Both handlers step the saved address past the instruction that was
; refused, the way any handler carrying on past a fault has to.
;
; Correct output is:
; AA BB the marker after being written through the controller
; readonly
; nobank
; done
#Include print.asm
#Program
start:
; Aim both ends of the controller at the marker.
SETD.0 Marker
PSHD.0
POPA
POPB
PSHA
PSHB
RSTA
OUTA 0xE0 ; SourceBank = 0
OUTA 0xE3 ; DestBank = 0
POPB
OUTB 0xE1 ; SourceHigh
OUTB 0xE4 ; DestHigh
POPA
OUTA 0xE2 ; SourceLow
OUTA 0xE5 ; DestLow
; Write two bytes over the marker. This is Program Memory being changed at run time.
INIA 0xAA
OUTA 0xE9
INIA 0xBB
OUTA 0xE9
; Read them back the same way, to prove they landed.
CALL readAndPrint
CALL readAndPrint
CALL lineFeed
; Bank 2 is the bank table, and it is read only.
INIA 0d2
OUTA 0xE3
RSTA
OUTA 0xE4
OUTA 0xE5
INIA 0d1
OUTA 0xE9 ; Refused: GuardViolation.
; Bank 200 has nothing in it.
INIA 0d200
OUTA 0xE3
INIA 0d1
OUTA 0xE9 ; Refused: BankFault.
SETD.0 Done
CALL printString
CALL lineFeed
HALT
readAndPrint:
INA 0xE9
CALL printByteHex
CALL blankSpace
RET
readOnlyHandler:
SETD.0 ReadOnly
CALL printString
CALL lineFeed
BRI stepPastRefusal
noBankHandler:
SETD.0 NoBank
CALL printString
CALL lineFeed
BRI stepPastRefusal
; Steps the saved address past the refused instruction. OUT is two bytes, an opcode and
; a port, so two is what it takes.
stepPastRefusal:
MVSD.0
DPUP.0 0d14
LDA.0
CCF
INIB 0d2
ADD
MVQA
STA.0
BRC carried
RETI
carried:
DPDN.0 0d01
LDA.0
INCA
STA.0
RETI
; Never executed.
Marker:
0x00 0x00
#Data
ReadOnly:
"readonly"
NoBank:
"nobank"
Done:
"done"
#Vectors
GuardViolation readOnlyHandler
BankFault noBankHandler
+172
View File
@@ -0,0 +1,172 @@
; The fence.
;
; A guarded range is a fence rather than a wall. Any program may raise one and any
; program may lower it, so nobody is ever told no. What it stops is walking into
; something by accident, which is the failure that costs an afternoon: without it, code
; that gets walked over is found much later, when the wreckage is finally executed and
; the evidence is long gone.
;
; The handler reads the controller's destination registers to say where the refused write
; was aimed. That is the whole point of catching it at the instruction that did it: the
; address is still sitting there to be read.
;
; The addresses here are absolute rather than labels, so that this says the same thing no
; matter what else ends up in the Data Segment. Data Memory is 64K and this program uses
; a hundred bytes of it, so 0x0200 is empty ground.
;
; Correct output is:
; outside ok a write just below the fence went through
; caught 0200 a write inside it was refused, and the handler says where
; caught 01FC a blit that merely clipped it was refused whole
; read ok reading inside the fence is allowed: it guards writing
; lowered ok after GuardOff the same write goes through
; fenced 05 the bank table shows bank 1 present and fenced
#Include print.asm
#Program
start:
; Fence off 0x0200 to 0x020F in Data Memory.
INIA 0d1
OUTA 0xEB ; GuardBank = 1
INIA 0x02
OUTA 0xEC
RSTA
OUTA 0xED ; GuardStart = 0x0200
INIA 0x02
OUTA 0xEE
INIA 0x0F
OUTA 0xEF ; GuardEnd = 0x020F
INIA 0x10
OUTA 0xE8 ; GuardOn
; Just below the fence is ordinary ground.
INIA 0x01
OUTA 0xE4
INIA 0xFF
OUTA 0xE5 ; Dest = 0x01FF
INIA 0d1
OUTA 0xE3 ; DestBank = 1
INIA 0x41
OUTA 0xE9
SETD.0 OutsideOk
CALL say
; Inside it is not.
INIA 0x02
OUTA 0xE4
RSTA
OUTA 0xE5 ; Dest = 0x0200
INIA 0x42
OUTA 0xE9 ; Refused.
; A blit from 0x01FC for eight bytes runs to 0x0203, so it clips the fence. It is
; refused whole rather than doing the four bytes that would have fitted.
INIA 0x01
OUTA 0xE4
INIA 0xFC
OUTA 0xE5 ; Dest = 0x01FC
INIA 0d1
OUTA 0xE0
RSTA
OUTA 0xE1
OUTA 0xE2 ; Source = bank 1, 0x0000
OUTA 0xE6
INIA 0d8
OUTA 0xE7 ; Length = 8
INIA 0x01
OUTA 0xE8 ; Refused.
; Reading inside a fence is allowed. A debugger has to be able to see what is being
; protected.
INIA 0d1
OUTA 0xE0
INIA 0x02
OUTA 0xE1
RSTA
OUTA 0xE2 ; Source = 0x0200
INA 0xE9
SETD.0 ReadOk
CALL say
; Lower it, and the write that was refused goes through.
INIA 0x11
OUTA 0xE8 ; GuardOff
INIA 0x02
OUTA 0xE4
RSTA
OUTA 0xE5
INIA 0x43
OUTA 0xE9
SETD.0 LoweredOk
CALL say
; Raise it again, and read the flags back out of the bank table, where a fence is
; published along with everything else about a bank.
INIA 0x10
OUTA 0xE8
INIA 0d2
OUTA 0xE0
RSTA
OUTA 0xE1
INIA 0d8
OUTA 0xE2 ; Bank 1's record begins at 8.
SETD.0 Fenced
CALL printString
CALL blankSpace
INA 0xE9
CALL printByteHex
CALL lineFeed
HALT
; Says where the refused write was aimed, by reading the controller's own registers.
guardHandler:
SETD.0 Caught
CALL printString
CALL blankSpace
INA 0xE4 ; DestHigh
CALL printByteHex
INA 0xE5 ; DestLow
CALL printByteHex
CALL lineFeed
; Step past the refused instruction. Every write here is an opcode and a port.
MVSD.0
DPUP.0 0d14
LDA.0
CCF
INIB 0d2
ADD
MVQA
STA.0
BRC stepCarried
RETI
stepCarried:
DPDN.0 0d01
LDA.0
INCA
STA.0
RETI
say:
CALL printString
CALL lineFeed
RET
#Data
OutsideOk:
"outside ok"
Caught:
"caught"
ReadOk:
"read ok"
LoweredOk:
"lowered ok"
Fenced:
"fenced"
#Vectors
GuardViolation guardHandler
+125
View File
@@ -0,0 +1,125 @@
; A program that loads a program.
;
; This is what the memory controller was built for. The bytes of a small routine sit in
; the Data Segment the way a program read off a disk would. The loader blits them into
; Program Memory, writes their address into a software vector, and calls it. Nothing
; about that routine was known to the assembler as code: to the loader it is data, and it
; becomes code only because it was put somewhere the CPU fetches from.
;
; Installing the vector at run time is the part worth watching. The argument that started
; the whole controller design was that a vector table nothing can write is not really a
; vector table. Vector 20 is empty when this program starts and holds a real handler by
; the time it is called, and nothing but the controller could have put it there.
;
; WHAT THIS DOES NOT SOLVE:
;
; The payload works at whatever address it lands at only because it contains no
; addresses. It is INIA and OUTA and a RETI, and none of those name a place. The moment a
; loaded routine contains a branch, a CALL, or a SETD, it holds an address that was fixed
; when it was assembled, and it will be wrong everywhere except where it was assembled
; for. SplitBit has no answer to that yet. Loading works; relocating does not exist.
;
; Correct output is:
; before the vector is still empty here
; loaded printed by code that was data a moment ago
; back and RETI came home
#Include print.asm
#Program
start:
SETD.0 Before
CALL say
; ---- Put the payload into Program Memory. ----
SETD.0 Payload
CALL aimSourceData
SETD.0 LandingZone
CALL aimDestProgram
INIA 0d29
CALL setLength
INIA 0x01
OUTA 0xE8 ; Blit: Data Memory into Program Memory.
; ---- Write its address into software vector 20. ----
; The table is at 0xFC00 and entries are two bytes, so vector 20 is at 0xFC28.
RSTA
OUTA 0xE3 ; DestBank = 0, Program Memory.
INIA 0xFC
OUTA 0xE4
INIA 0x28
OUTA 0xE5
SETD.0 LandingZone
PSHD.0
POPA
POPB
OUTB 0xE9 ; High byte of the handler's address.
OUTA 0xE9 ; Low byte. The Data port steps on by itself.
; ---- Call it. ----
SWI 0d20
SETD.0 Back
CALL say
HALT
; DP0 holds the address to aim at.
aimSourceData:
INIA 0d1
OUTA 0xE0
PSHD.0
POPA
POPB
OUTB 0xE1
OUTA 0xE2
RET
aimDestProgram:
RSTA
OUTA 0xE3
PSHD.0
POPA
POPB
OUTB 0xE4
OUTA 0xE5
RET
setLength:
PSHA
RSTA
OUTA 0xE6
POPA
OUTA 0xE7
RET
say:
CALL printString
CALL lineFeed
RET
; Where the payload lands. Reserving it means the assembler knows the room is spoken for,
; so nothing else is ever placed here.
LandingZone:
#Reserve 0d48
#Data
Before:
"before"
Back:
"back"
; The payload, as bytes rather than as code. It prints "loaded" and returns. Written out
; by hand because SplitBit has no linker: to this program it is just so much data.
;
; INIA 'l' OUTA 0 INIA 'o' OUTA 0 ... and so on, then RETI
Payload:
0x26 0x6C 0xD1 0x00
0x26 0x6F 0xD1 0x00
0x26 0x61 0xD1 0x00
0x26 0x64 0xD1 0x00
0x26 0x65 0xD1 0x00
0x26 0x64 0xD1 0x00
0x26 0x0A 0xD1 0x00
0x19
@@ -0,0 +1,25 @@
; A device refuses, and nothing is installed to deal with it.
;
; The machine has to stop where it stands and say which port refused and where. Letting
; execution carry on would mean the program continues as though the access had worked,
; which is the failure the fault trap exists to prevent.
;
; Correct output is:
; O
; a fault naming port 17, and a non zero exit.
#Program
start:
INIA 0d79 ; 'O', so it is clear how far it got.
OUTA 0x00
INIA 0x0A
OUTA 0x00
INIA 0d1
OUTA 0x11 ; The device refuses, and no handler is installed.
; Never reached.
INIA 0d88 ; 'X'
OUTA 0x00
HALT
+74
View File
@@ -0,0 +1,74 @@
; Tests a device refusing what it was asked to do.
;
; Interrupting is a device asking for attention later. Refusing is a device saying no to
; the instruction happening now, so it has to stop the machine where it stands instead of
; letting execution carry on past the mistake.
;
; The device on port 0x11 refuses everything, in both directions, so that this path can
; be exercised without the memory controller. A refusal names a software vector, so the
; handler knows what happened from the entry it arrived through.
;
; The handler steps the saved address past the instruction that was refused, the same way
; the fault handler steps past a byte that would not decode. Without that a bare RETI
; would meet the same refused instruction again forever.
;
; Correct output is:
; caught the handler caught a refused write
; caught and a refused read
; done
#Include print.asm
#Program
start:
INIA 0d1
OUTA 0x11 ; Refused. The handler runs and steps us past this.
INA 0x11 ; Refused as well, and reads are caught the same way.
SETD.0 Done
CALL printString
CALL lineFeed
HALT
; A refusal arrives with a full frame, so the handler may use whatever it likes.
refusalHandler:
CALL reportRefusal
; Step the saved address past the instruction that was refused. Both OUT and IN are
; two bytes, an opcode and a port, so two is what it takes.
MVSD.0
DPUP.0 0d14
LDA.0
CCF
INIB 0d2
ADD
MVQA
STA.0
BRC carried
RETI
carried:
DPDN.0 0d01
LDA.0
INCA
STA.0
RETI
reportRefusal:
SETD.0 Caught
CALL printString
CALL lineFeed
RET
#Data
Caught:
"caught"
Done:
"done"
#Vectors
GuardViolation refusalHandler
+132
View File
@@ -0,0 +1,132 @@
; Giving a bank number to a device's memory.
;
; This is the whole sequence an OS goes through: ask the bus registry which ports bring
; memory, give one of them a bank number, and then reach it with the controller like any
; other bank. A device's memory is unreachable until it has a number, and the controller
; is the only thing that can reach it even then.
;
; How big the bank is comes from the device rather than from this program. Capacity was
; settled when the machine was built, so software asserting it could only ever be wrong.
;
; The device on port 0x12 owns 256 bytes and fills them with whatever byte is written to
; its port, which stands in for a disk controller reading a sector.
;
; Correct output is:
; flags 01 the registry says port 0x12 brings memory
; 01 12 01 00 bank 5's record: present, owned by port 0x12, 256 bytes
; 5A 5A the device's memory, reached through the controller
; refused registering over one of the machine's own banks
; refused and registering memory from a port that brings none
#Include print.asm
#Program
start:
; Ask the registry whether port 0x12 brings memory.
INIA 0x12
OUTA 0xFF
INA 0xFF ; Class, which we do not need here.
INA 0xFF ; Flags: bit 0 says it brings memory.
PSHA
SETD.0 Flags
CALL printString
CALL blankSpace
POPA
CALL printByteHex
CALL lineFeed
; Tell the device to fill its memory, the way a disk controller would be told to read.
INIA 0x5A
OUTA 0x12
; Give that memory bank number 5.
INIA 0d5
OUTA 0xE3 ; DestBank = 5, the number being handed out.
INIA 0x12
OUTA 0xE2 ; SourceLow = the port that owns it.
INIA 0x03
OUTA 0xE8 ; RegisterBank.
; Read bank 5's record out of the bank table. Eight bytes a bank, so bank 5 is at 40.
INIA 0d2
OUTA 0xE0
RSTA
OUTA 0xE1
INIA 0d40
OUTA 0xE2
CALL readAndPrint ; Flags: present.
CALL readAndPrint ; Owner: port 0x12.
CALL readAndPrint ; Capacity, high byte.
CALL readAndPrint ; Capacity, low byte.
CALL lineFeed
; Now reach the device's memory the way any other bank is reached.
INIA 0d5
OUTA 0xE0
RSTA
OUTA 0xE1
OUTA 0xE2
CALL readAndPrint
CALL readAndPrint
CALL lineFeed
; Banks 0 to 2 are the machine's own and cannot be handed out.
INIA 0d1
OUTA 0xE3
INIA 0x12
OUTA 0xE2
INIA 0x03
OUTA 0xE8 ; Refused.
; Neither can memory from a port that brings none. Port 0x10 is the test device.
INIA 0d6
OUTA 0xE3
INIA 0x10
OUTA 0xE2
INIA 0x03
OUTA 0xE8 ; Refused.
HALT
readAndPrint:
INA 0xE9
CALL printByteHex
CALL blankSpace
RET
; Both refusals arrive here, and they really are the same fault: a bank that cannot be
; registered, for two different reasons. Saying so twice is more honest than inventing a
; distinction the machine does not draw.
bankHandler:
SETD.0 Refused
CALL printString
CALL lineFeed
; Step past the refused command write, which is an opcode and a port.
MVSD.0
DPUP.0 0d14
LDA.0
CCF
INIB 0d2
ADD
MVQA
STA.0
BRC bankCarried
RETI
bankCarried:
DPDN.0 0d01
LDA.0
INCA
STA.0
RETI
#Data
Flags:
"flags"
Refused:
"refused"
#Vectors
BankFault bankHandler
+5 -2
View File
@@ -1,7 +1,7 @@
# SplitBit Emulator
## Overview:
SplitBit is a custom CPU designed for hobbyist projects and experimentation. The SplitBit Emulator is a C implementation of its bespoke instruction set architecture. It allows users to load and run binary programs created for the SplitBit CPU interactively from the command line.
SplitBit is a custom 8 bit system designed for hobbyist projects and experimentation: a CPU with its own instruction set, an interrupt and vector system, a bus that programs can enumerate, and a memory controller that can load code. The SplitBit Emulator is a C implementation of it. It allows users to load and run binary programs created for SplitBit interactively from the command line.
### Features:
- 8-bit Harvard Architecture: The system memory is separated into two 64k banks, one for the Program Memory and another for the Data Memory.
@@ -10,7 +10,10 @@ SplitBit is a custom CPU designed for hobbyist projects and experimentation. The
- CLI Based: Debug messages and CPU input and output are supported through the command line.
- Binary File Support: Load programs and data from binary files.
- Modular Codebase: Mostly clean separation of CPU, I/O, and utility functions for easy modification.
- Assembler: Assemble human readable assembly language files directly into SplitBit compatible binary files. Supports including external files, handling labels, and defining Program and Data segments.
- Interrupts: Software traps, hardware lines from devices, and faults, all arriving through one vector table with a full context save.
- Devices: A bus registry that says what a machine is made of, so a program can ask rather than being told.
- Memory Controller: Reads and writes Program Memory, moves blocks between memory banks, reaches memory that devices bring with them, and guards a range against being written by accident. It is how a SplitBit machine loads a program.
- Assembler: Assemble human readable assembly language files directly into SplitBit compatible binary files. Supports including external files, handling labels, alignment and reservation, and defining Program, Data and Vector segments.
### Installation:
1) Clone the repository:
+5 -1
View File
@@ -62,7 +62,11 @@
#define VECTOR_BOOT 0
#define VECTOR_SOFT_RESET 1
#define VECTOR_INVALID_OPCODE 2
// Vectors 3 to 15 are held back for faults that do not exist yet, so that each cause
// A device refused a write, because it landed inside a raised fence.
#define VECTOR_GUARD_VIOLATION 3
// A bank was named that has nothing registered in it, or an access ran past its end.
#define VECTOR_BANK_FAULT 4
// Vectors 5 to 15 are held back for faults that do not exist yet, so that each cause
// can have an entry of its own rather than sharing one and needing a cause register to
// tell them apart. Everything from 16 up belongs to programs.
#define VECTOR_FIRST_FREE 16
+5 -3
View File
@@ -144,9 +144,11 @@ static const struct {
const char *name;
uint8_t index;
} reservedVectors[] = {
{ "Boot", VECTOR_BOOT },
{ "SoftReset", VECTOR_SOFT_RESET },
{ "BadOpcode", VECTOR_INVALID_OPCODE },
{ "Boot", VECTOR_BOOT },
{ "SoftReset", VECTOR_SOFT_RESET },
{ "BadOpcode", VECTOR_INVALID_OPCODE },
{ "GuardViolation", VECTOR_GUARD_VIOLATION },
{ "BankFault", VECTOR_BANK_FAULT },
};
static const int reservedVectorCount = (int)(sizeof(reservedVectors) / sizeof(reservedVectors[0]));
+331
View File
@@ -0,0 +1,331 @@
// 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;
}
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);
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.
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)) {
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;
}
uint8_t value = banks[sourceBank].memory[sourceAddress];
sourceAddress++;
status = 0;
return value;
}
}
return 0;
}
+94
View File
@@ -0,0 +1,94 @@
// controller.h
// The SplitBit Memory Controller.
// Written by Anachronaut
#ifndef CONTROLLER_H
#define CONTROLLER_H
#include <stdint.h>
// ---- Ports ----
//
// Sixteen registers, one to a port, written and read directly. A register file behind a
// single port would be smaller but stateful, and losing your place in a device that
// moves memory corrupts memory rather than an answer.
#define CONTROLLER_PORT_BASE 0xE0
#define CONTROLLER_PORT_TOP 0xEF
#define CTRL_SOURCE_BANK 0xE0
#define CTRL_SOURCE_HIGH 0xE1
#define CTRL_SOURCE_LOW 0xE2
#define CTRL_DEST_BANK 0xE3
#define CTRL_DEST_HIGH 0xE4
#define CTRL_DEST_LOW 0xE5
#define CTRL_LENGTH_HIGH 0xE6
#define CTRL_LENGTH_LOW 0xE7
#define CTRL_COMMAND 0xE8
#define CTRL_DATA 0xE9
#define CTRL_STATUS 0xEA
#define CTRL_GUARD_BANK 0xEB
#define CTRL_GUARD_START_HIGH 0xEC
#define CTRL_GUARD_START_LOW 0xED
#define CTRL_GUARD_END_HIGH 0xEE
#define CTRL_GUARD_END_LOW 0xEF
// ---- Commands ----
//
// Written to the Command port, which performs them at once. A blit is instantaneous from
// the CPU's point of view: waiting belongs to the peripheral that has something to wait
// for, not to the moving of bytes.
#define COMMAND_BLIT 0x01
#define COMMAND_FILL 0x02
// Gives a bank number to the memory owned by a device. How big it is comes from the
// device, not from software: a program asserting a hardware fact could only ever be
// wrong about it.
#define COMMAND_REGISTER_BANK 0x03
// Raises and lowers the fence over the bank named by GuardBank. Any program may do
// either: this is a fence rather than a wall, and nobody is ever told no. What it stops
// is walking into something by accident, not walking into it on purpose.
#define COMMAND_GUARD_ON 0x10
#define COMMAND_GUARD_OFF 0x11
// ---- Banks ----
//
// Program and Data are banks like any other; being banks 0 and 1 is the only thing
// special about them. Bank 2 is the controller's own memory, and the bank table lives
// in it, which is how anything finds out what banks exist without a second protocol.
#define BANK_PROGRAM 0
#define BANK_DATA 1
#define BANK_TABLE 2
#define BANK_COUNT 256
// Eight bytes each, so bank n's record begins at n * 8.
//
// 0 Flags
// 1 The port that owns it, or the machine itself for banks 0 to 2
// 2 - 3 Capacity, where zero means the whole 64K
// 4 - 5 First guarded address
// 6 - 7 Last guarded address
//
// What is published is a description. The pointer a bank really holds is never in here:
// a program that could write one would be setting a host address, which means nothing on
// hardware and everything to the emulator running it.
#define BANK_RECORD_BYTES 8
#define BANK_TABLE_BYTES (BANK_COUNT * BANK_RECORD_BYTES)
#define BANK_FLAG_PRESENT 0x01
#define BANK_FLAG_READ_ONLY 0x02
#define BANK_FLAG_GUARDED 0x04
// Banks 0 to 2 belong to the machine rather than to any device.
#define BANK_OWNER_MACHINE 0xFF
void initializeController(uint8_t *programMemory, uint8_t *dataMemory);
uint8_t controllerWrite(uint8_t value, uint8_t port);
uint8_t controllerRead(uint8_t port);
#endif // CONTROLLER_H
+39 -5
View File
@@ -67,6 +67,25 @@ static uint8_t enterInterrupt(CPURegisters *cpu, uint16_t base, uint8_t index, u
return 0;
}
// A device that refused what it was asked stops the machine where it stands, rather than
// raising a line and letting execution carry on past the mistake. The frame carries the
// address of the instruction that asked, so a handler can see which one it was, and so a
// bare RETI meets it again the way every other fault on this machine does.
//
// Returns 1 if the machine has stopped because nothing was installed to catch it.
static uint8_t answerRefusal(CPURegisters *cpu, uint16_t site) {
uint8_t refusal = takeRefusal();
if (refusal == 0) {
return 0;
}
if (enterInterrupt(cpu, SOFTWARE_VECTOR_BASE, refusal, site)) {
cpu->Fault = FAULT_DEVICE_REFUSED;
cpu->FaultVector = refusingPort();
cpu->ProgramCounter = site - 1;
}
return 0;
}
void initializeCPU(CPURegisters *cpu, uint8_t *programMemory, uint8_t *dataMemory) {
cpu->A = 0;
cpu->B = 0;
@@ -544,33 +563,48 @@ uint8_t executeOperation(uint8_t Instruction, CPURegisters *cpu) {
//
// Dx - Output Operations:
//
case 0xD0:
case 0xD0: {
// OUTQ - Write the value of Q to an output port.
uint16_t site = cpu->ProgramCounter;
cpu->ProgramCounter++;
OutputHandler(cpu->Q, cpu->Program[cpu->ProgramCounter]);
answerRefusal(cpu, site);
}
break;
case 0xD1:
case 0xD1: {
// OUTA - Write the value of A to an output port.
uint16_t site = cpu->ProgramCounter;
cpu->ProgramCounter++;
OutputHandler(cpu->A, cpu->Program[cpu->ProgramCounter]);
answerRefusal(cpu, site);
}
break;
case 0xD2:
case 0xD2: {
// OUTB - Write the value of B to an output port.
uint16_t site = cpu->ProgramCounter;
cpu->ProgramCounter++;
OutputHandler(cpu->B, cpu->Program[cpu->ProgramCounter]);
answerRefusal(cpu, site);
}
break;
//
// Ex - Input Operations:
//
case 0xE0:
case 0xE0: {
// INA - Read an Input to A.
uint16_t site = cpu->ProgramCounter;
cpu->ProgramCounter++;
cpu->A = InputHandler(cpu->Program[cpu->ProgramCounter]);
answerRefusal(cpu, site);
}
break;
case 0xE1:
case 0xE1: {
// INB - Read an Input to B.
uint16_t site = cpu->ProgramCounter;
cpu->ProgramCounter++;
cpu->B = InputHandler(cpu->Program[cpu->ProgramCounter]);
answerRefusal(cpu, site);
}
break;
//
// Fx - Special Operations:
+2 -1
View File
@@ -47,7 +47,8 @@ typedef enum {
FAULT_NONE = 0,
FAULT_BAD_OPCODE, // A byte that does not decode to an instruction.
FAULT_NO_HANDLER, // Dispatched through a software vector with nothing in it.
FAULT_NO_DEVICE_HANDLER // A device interrupted, and its vector was empty.
FAULT_NO_DEVICE_HANDLER, // A device interrupted, and its vector was empty.
FAULT_DEVICE_REFUSED // A device refused, and nothing was installed to catch it.
} FaultCause;
// The struct containing the CPU registers.
+7
View File
@@ -9,6 +9,7 @@
#include <stdint.h>
#include <stdlib.h>
#include "cpu.h"
#include "controller.h"
#include "utility.h"
#include <string.h>
#include <getopt.h>
@@ -90,6 +91,9 @@ int main (int argc, char *argv[]) {
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);
@@ -140,6 +144,9 @@ int main (int argc, char *argv[]) {
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);
+82
View File
@@ -4,7 +4,10 @@
// 10/16/2024
#include "io.h"
#include "../Assembler/assembly.h" // For the fault vector numbers.
#include "controller.h"
#include <stdio.h>
#include <string.h>
// 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.
@@ -37,6 +40,48 @@ int nextPendingInterrupt(void) {
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;
}
// ---- 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) {
return NULL;
}
*capacity = DEVICE_MEMORY_BYTES;
return deviceMemoryBlock;
}
// ---- The bus registry ----
//
// What is plugged into this machine. The table is fixed when the machine is built: a
@@ -57,6 +102,8 @@ typedef struct {
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_REGISTRY, DEVICE_REGISTRY, 0 },
};
static const int deviceCount = (int)(sizeof(deviceTable) / sizeof(deviceTable[0]));
@@ -66,7 +113,15 @@ static const int deviceCount = (int)(sizeof(deviceTable) / sizeof(deviceTable[0]
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;
}
for (int i = 0; i < deviceCount; i++) {
if (deviceTable[i].port == port) {
return &deviceTable[i];
@@ -90,6 +145,12 @@ static uint8_t readRegistry(void) {
}
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 PORT_CONSOLE:
@@ -98,6 +159,18 @@ uint8_t OutputHandler(uint8_t DataByte, uint8_t Address) {
// Later, I'll want to use a buffer for this for performance, probably.
putchar(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
@@ -122,11 +195,20 @@ uint8_t OutputHandler(uint8_t DataByte, uint8_t Address) {
}
uint8_t InputHandler(uint8_t Address) {
refusedPort = Address;
if (Address >= CONTROLLER_PORT_BASE && Address <= CONTROLLER_PORT_TOP) {
return controllerRead(Address);
}
switch(Address) {
case PORT_CONSOLE:
// If data is sent here, it should be read from STDIN.
return getchar();
break;
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.
+38 -2
View File
@@ -16,6 +16,8 @@
#define PORT_CONSOLE 0x00
#define PORT_TEST 0x10
#define PORT_REFUSE 0x11
#define PORT_MEMORY 0x12
#define PORT_REGISTRY 0xFF
// ---- Device classes ----
@@ -29,10 +31,13 @@
#define DEVICE_NONE 0x00
#define DEVICE_REGISTRY 0x01
#define DEVICE_CONSOLE 0x02
#define DEVICE_CONTROLLER 0x03
#define DEVICE_TEST 0x10
#define DEVICE_REFUSE 0x11
#define DEVICE_MEMORY 0x12
// What a device brings besides itself. The memory controller will want the first of
// these to find out which ports own memory it can reach.
// What a device brings besides itself. This means memory that somebody has to register
// with the controller, so the controller's own bank 2 does not count: it is already there.
#define DEVICE_FLAG_HAS_MEMORY 0x01
// How many bytes a device's entry in the registry runs to. Reading past the end gives
@@ -59,4 +64,35 @@ void clearInterrupt(uint8_t port);
// The lowest numbered port with its line up, or -1 if none of them are.
int nextPendingInterrupt(void);
// ---- Refusing ----
//
// A device can refuse what it was asked to do. Interrupting is a device asking for
// attention later; refusing is a device saying no to the instruction happening now, so
// it has to stop the machine where it stands rather than raise a line and let execution
// carry on past the mistake.
//
// The refusal names a software vector, so the cause is known from the entry it arrives
// through, the same way every other fault on this machine works.
void refuseAccess(uint8_t faultVector);
// ---- Memory a device brings ----
//
// Returns the memory owned by the device on this port, or NULL if it owns none, and
// fills in how much of it there is. This is how the controller finds out what a device
// brings when it is told to register a bank.
//
// It is a direct look at the machine's device table rather than a conversation through
// the registry's port. The port protocol remembers which port it was asked about, so a
// controller that used it would silently lose the place of any enumeration a program had
// in progress. Same table, two consumers, and only one of them needs the protocol.
uint8_t *deviceMemory(uint8_t port, uint32_t *capacity);
// The vector a device refused with, or 0 if none did. Reading it clears it, because a
// refusal is answered once.
uint8_t takeRefusal(void);
// Which port did the refusing. Only meaningful alongside a refusal.
uint8_t refusingPort(void);
#endif // IO_H
+4 -2
View File
@@ -137,13 +137,15 @@ Every line names a vector and then the label of the routine that handles it.
Device 0x10 diskReady
```
Three names already mean something:
Five names already mean something:
| Name | Vector |
| --- | --- |
| Boot | Where the machine begins at power on. Without this a program starts at the beginning of its Program Segment. |
| SoftReset | A warm restart. SWI SoftReset is how a program asks for one. |
| BadOpcode | The CPU met a byte that is not an instruction. |
| GuardViolation | A device refused a write, because it landed inside a raised fence. |
| BankFault | A bank was named that has nothing in it, or an access ran past its end. |
Anything else you name is a software interrupt of your own. You do not choose its number and you never write one: the assembler allocates them in the order they appear, starting above the range held back for faults that do not exist yet. That is the same bargain as labels everywhere else in SplitBit assembly, where you name a thing and let the assembler work out where it went.
@@ -157,7 +159,7 @@ A device is different, because its number is not a choice. A device interrupts o
The assembler will refuse two handlers for the same vector, a name used with SWI that no Vector Segment gives a handler to, and a handler that is not a label.
Vectors 3 through 15 are held back for faults that have not been defined yet. They have no names, so there is currently no way to write a handler for one, and none is needed: each will be given a name of its own as the fault it stands for is defined. Because you never write a vector number, there is no way to land on one of them by accident either.
Vectors 5 through 15 are held back for faults that have not been defined yet. They have no names, so there is currently no way to write a handler for one, and none is needed: each will be given a name of its own as the fault it stands for is defined, the way GuardViolation and BankFault were. Because you never write a vector number, there is no way to land on one of them by accident either.
## Including Other Files:
+180 -4
View File
@@ -55,7 +55,9 @@ The software vectors are given out like this:
| 0 | The Boot Vector. Where the machine begins at power on. |
| 1 | The Soft Reset Vector. A warm restart. |
| 2 | A byte that is not an instruction. |
| 3 to 15 | Held back for faults not yet defined. |
| 3 | A device refused a write, because it landed inside a raised fence. GuardViolation. |
| 4 | A bank was named that has nothing in it, or an access ran past its end. BankFault. |
| 5 to 15 | Held back for faults not yet defined. |
| 16 and up | A program's own, given out by the assembler in the order they are named. |
A programmer does not write vector numbers. Handlers are named in the Vector Segment of an assembly file and used by name, the same way every other address in SplitBit is worked out by the assembler rather than typed. See the SplitBit Assembler Manual.
@@ -126,6 +128,9 @@ If a device interrupts and its vector is empty, that is a fault: the machine sto
| --- | --- | --- |
| 0x00 | The console. Writing sends a byte to standard output, reading takes one from standard input. | 0x02 |
| 0x10 | A test device. Writing anything to it puts its own line up, so that interrupt handling can be exercised without waiting on anything. The byte written is ignored. | 0x10 |
| 0x11 | A device that refuses everything, in both directions, so that refusal can be exercised without the memory controller. | 0x11 |
| 0x12 | A device that owns 256 bytes of memory. Writing to its port fills that memory with the byte written, standing in for a disk controller reading a sector. Its memory is unreachable until it is registered as a bank. | 0x12 |
| 0xE0 - 0xEF | The memory controller. See The Memory Controller below. | 0x03 |
| 0xFF | The bus registry. See Asking What Is There below. | 0x01 |
## Asking What Is There:
@@ -162,9 +167,180 @@ One thing to be careful of: the registry remembers which port it was asked about
| 0x00 | Nothing. |
| 0x01 | Bus registry. |
| 0x02 | Console. |
| 0x03 - 0x0F | Reserved for the machine itself. |
| 0x10 | Test device. |
| 0x11 - 0xFF | Peripherals. |
| 0x03 | Memory controller. |
| 0x04 - 0x0F | Reserved for the machine itself. |
| 0x10 | Test device, which raises its own line. |
| 0x11 | Test device, which refuses everything. |
| 0x12 | Test device, which owns memory. |
| 0x13 - 0xFF | Peripherals. |
## The Memory Controller:
SplitBit's instruction set cannot write Program Memory. That is what a Harvard machine is, and it is worth keeping true of the instructions: a machine where any instruction stream can rewrite its own code has given away the separation it was built for. The memory controller can, so writing code is a capability reached deliberately through a port rather than something every program has by accident.
It answers on ports 0xE0 to 0xEF, one register to a port.
| Port | Register |
| --- | --- |
| 0xE0 | SourceBank |
| 0xE1 | SourceHigh |
| 0xE2 | SourceLow |
| 0xE3 | DestBank |
| 0xE4 | DestHigh |
| 0xE5 | DestLow |
| 0xE6 | LengthHigh |
| 0xE7 | LengthLow |
| 0xE8 | Command |
| 0xE9 | Data |
| 0xEA | Status |
| 0xEB | GuardBank |
| 0xEC | GuardStartHigh |
| 0xED | GuardStartLow |
| 0xEE | GuardEndHigh |
| 0xEF | GuardEndLow |
Reading the Data port takes a byte from the source and steps the source address on. Writing to it puts a byte at the destination and steps the destination address on. Reading a run of bytes is therefore a loop over one instruction rather than four.
Status says how the last thing the controller was asked to do went. Zero means it worked; anything else is the vector it refused with.
### Commands:
Writing to the Command port performs it at once. A transfer is instantaneous as far as the CPU is concerned: waiting belongs to a peripheral that has something to wait for, not to the moving of bytes.
| Value | Command | Does |
| --- | --- | --- |
| 0x01 | Blit | Moves Length bytes from Source to Dest. Either end may be any bank, including the same one. |
| 0x02 | Fill | Writes the byte in SourceLow across Dest, Length times. SourceBank and SourceHigh mean nothing here, because a fill has nowhere to read from. |
| 0x10 | GuardOn | Raises a fence over the bank named by GuardBank, across the range in the guard registers. |
| 0x11 | GuardOff | Lowers that bank's fence. |
| 0x03 | RegisterBank | Gives the bank number in DestBank to the memory owned by the device on port SourceLow. |
Length is how many bytes, and zero means the whole 64K, since a transfer of nothing is never what anyone meant. Both commands leave the addresses past whatever they touched and leave Length as it was, so asking again carries straight on from where the last one stopped.
A blit may overlap itself. Sliding a run of bytes along inside its own bank works, rather than repeating the first byte the way a plain forward copy would.
Everything a transfer would touch is checked before any of it moves. A transfer that would run out of bank, or write somewhere it may not, is refused whole: nothing moves at all. A transfer that stopped halfway would leave memory in a state no program asked for, and the diagnostic would arrive after the damage rather than instead of it.
Filling is worth reaching for. Clearing a page with one Fill instead of a store and a loop takes about a tenth off the running time of the segmented sieve, which spends most of its life zeroing its window.
### Banks:
Memory the controller can reach is divided into banks of up to 64K each, numbered 0 to 255. Program and Data are banks like any other; being 0 and 1 is the only thing special about them.
| Bank | Holds |
| --- | --- |
| 0 | Program Memory. |
| 1 | Data Memory. |
| 2 | The controller's own memory, which is where the bank table lives. |
| 3 and up | Registered by software, for devices that bring memory of their own. |
Banks 0 to 2 belong to the machine and cannot be handed out. Everything above them is registered by whoever enumerated the hardware, so which number a device's memory answers to is the operating system's business rather than the machine's. That is what lets a device own no banks, one, or several.
Registering asks the bus registry which ports bring memory, then names one:
```
INIA 0d5
OUTA 0xE3 ; The bank number being handed out.
INIA 0x12
OUTA 0xE2 ; The port that owns the memory.
INIA 0x03
OUTA 0xE8 ; RegisterBank.
```
How big the bank is comes from the device, not from the program. Capacity was settled when the machine was built, so a program asserting it could only ever be wrong. Registering a bank over one that already holds something is allowed: nothing was allocated that could be lost by changing your mind.
Registering fails if the number is one of the machine's own, or if the port named brings no memory. Either would put a bank in the table that leads nowhere.
A reset clears the table back to banks 0, 1 and 2. Banks are soft state, so a program that wants a device's memory registers it during setup, which is a handful of writes and needs no operating system.
Banks are reachable only through the controller. There is no bank register that changes what the CPU sees, and there never will be: a Data Pointer addresses bank 1, always, so an instruction never means something different depending on state you cannot see in the listing.
### The Bank Table:
Bank 2 holds a description of every bank, eight bytes each, so bank n's record begins at n times eight.
| Byte | Holds |
| --- | --- |
| 0 | Flags: bit 0 present, bit 1 read only, bit 2 fenced. |
| 1 | The port that owns it, or 0xFF for the machine itself. |
| 2 and 3 | Capacity, where zero means the whole 64K. |
| 4 and 5 | The first guarded address. |
| 6 and 7 | The last guarded address. |
A program reads it the way it reads anything else, by pointing the controller at bank 2. There is no separate query for it, and nothing to interfere with an enumeration already in progress.
Bank 2 is read only. That is what keeps the table something only the controller changes, and the table is what every transfer routes through: corrupt it and everything afterwards goes somewhere arbitrary. What the table holds is a description rather than the machinery, so writing to it could never redirect a bank even if it were allowed.
### The Fence:
Every bank may have one guarded range. A write that lands inside it is refused, and so is any transfer that so much as overlaps it: a blit that clipped the edge would otherwise do the part that fitted and leave the rest undone.
Set GuardBank to say which bank, put the first and last guarded addresses in the guard registers, and write GuardOn. GuardOff lowers it again.
**Any program may lower any fence.** This is a fence rather than a wall, and nobody is ever told no. What it stops is walking into something by accident. A program that genuinely means to write there lowers the fence first, which costs one instruction and says plainly in the listing what it intended.
A fence guards writing, not reading. Whatever is behind it can still be read, because a debugger has to be able to see what it is protecting.
A range that ends before it starts is refused. No address could be inside it, so it would catch nothing while looking like it caught something, and a program that raised one would believe it was protected when it was not.
A raised fence is published in the bank table along with everything else about the bank, so a program can see what is guarded without having to remember.
### Loading A Program:
Everything the controller does adds up to one thing a SplitBit machine could not do before: run code that was not in the binary it started from.
The sequence is short. Put the bytes of a routine somewhere, blit them into Program Memory, write their address into a vector, and call it.
```
; Vector 20 lives at 0xFC00 plus twice twenty, which is 0xFC28.
RSTA
OUTA 0xE3 ; DestBank = 0, Program Memory.
INIA 0xFC
OUTA 0xE4
INIA 0x28
OUTA 0xE5
OUTB 0xE9 ; The handler's address, high byte then low.
OUTA 0xE9
SWI 0d20
```
A vector that is empty when a program starts and holds a working handler by the time it is called is the whole reason this device exists. A vector table nothing can write is not really a vector table.
### What Loading Does Not Solve:
A routine that has been moved works at its new address only if it does not contain any addresses of its own.
INIA, OUTA and RETI name no places, so a routine built only from those runs correctly wherever it is put. A branch, a CALL, or a SETD is different: each of them carries an address that was decided when it was assembled, and that address is wrong everywhere except where it was assembled for. Nothing in SplitBit adjusts them.
So loading works and relocating does not exist. A program can be put into memory at the address it was built for, and it will run. Putting it anywhere else is an unsolved problem, and a real one, because a machine that can only ever load a program to one place cannot load two programs at once.
### What The Fence Does Not Do:
It is worth being plain about the limit, because the name suggests more than it delivers.
Every write to Program Memory goes through the controller, so a fence over bank 0 catches all of them. That is what it is for: code that gets walked over is otherwise discovered much later, when the wreckage is finally executed, thousands of cycles from the mistake and with the evidence gone. A fence turns that into a fault at the instruction responsible, with the address still in the controller's registers to be read.
Data Memory is different. STA, STB, STQ and STD write bank 1 directly and never go near the controller, so a runaway Data Pointer walking over a program's variables is **not** caught and cannot be. Catching it would mean putting the check inside the CPU's store path, which would make an instruction behave differently depending on state that does not appear in the listing. That is the thing this machine does not do.
So the fence protects code from a mistaken loader. It is not general memory protection, and a program should not be written as though it were.
### Being Turned Away:
A bank knows how big it is, so an access past the end of one is a BankFault rather than a read of whatever happens to be next. Naming a bank with nothing registered in it is the same fault.
A write the controller will not perform is a GuardViolation. There are two reasons for one: the bank is read only, or the write touches a fence that has been raised over it.
Both arrive at the instruction that asked, so a handler sees which one it was. A handler that means to carry on past it steps the saved address on by two, since the input and output instructions are an opcode and a port.
## Refusing:
A device can refuse what it was asked to do. This is not the same as interrupting. An interrupt is a device asking for attention later, answered between instructions once the CPU is ready. A refusal is a device saying no to the instruction happening now, so the machine stops where it stands rather than carrying on as though the access had worked.
A refusal names a software vector, so a handler knows what happened from the entry it arrived through, the same as every other fault. The frame carries the address of the instruction that was refused, so a handler can see which one it was.
That means a handler returning with a bare RETI will meet the same refused instruction again. A handler that means to carry on past it steps the saved address on by two, since the input and output instructions are an opcode and a port.
If nothing is installed for the vector a device refused with, the machine stops and the emulator says which port refused and where.
## Faults:
+4
View File
@@ -0,0 +1,4 @@
Fault: The device on port 233 refused the access at Program Address 0x001D, and nothing is installed to deal with it.
O
Execution halted after 16 cycles.
[exit 1]
+7
View File
@@ -0,0 +1,7 @@
blitted
....
DE AD
untouched
ABABCDEFGH
Execution halted after 464 cycles.
[exit 0]
+5
View File
@@ -0,0 +1,5 @@
DE AD BE EF
01 FF
00 00
Execution halted after 315 cycles.
[exit 0]
+6
View File
@@ -0,0 +1,6 @@
AA BB
readonly
nobank
done
Execution halted after 245 cycles.
[exit 0]
+8
View File
@@ -0,0 +1,8 @@
outside ok
caught 0200
caught 01FC
read ok
lowered ok
fenced 05
Execution halted after 535 cycles.
[exit 0]
+5
View File
@@ -0,0 +1,5 @@
before
loaded
back
Execution halted after 132 cycles.
[exit 0]
+4
View File
@@ -0,0 +1,4 @@
Fault: The device on port 17 refused the access at Program Address 0x000A, and nothing is installed to deal with it.
O
Execution halted after 6 cycles.
[exit 1]
+5
View File
@@ -0,0 +1,5 @@
caught
caught
done
Execution halted after 136 cycles.
[exit 0]
+7
View File
@@ -0,0 +1,7 @@
flags 01
01 12 01 00
5A 5A
refused
refused
Execution halted after 443 cycles.
[exit 0]
+27
View File
@@ -46,6 +46,33 @@ dispatchTest | testPrograms/dispatchTest.asm | run | -
# ---- Moving an ALU result back into an operand register ----
moveQTest | testPrograms/moveQTest.asm | run | - | -
# ---- The memory controller ----
# Reading and writing Program Memory, which the instruction set deliberately cannot do,
# and the bank table that says what is reachable.
controllerReadTest | testPrograms/controllerReadTest.asm | run | - | -
controllerWriteTest | testPrograms/controllerWriteTest.asm | run | - | -
# Block transfers: between banks, within one, overlapping, and one that is refused.
blitTest | testPrograms/blitTest.asm | run | - | -
# A program that loads a program: blits code into Program Memory, installs a vector at
# run time, and calls it. If the vector install ever silently failed, the SWI would fault
# with "no handler" rather than printing, so this test cannot pass by accident.
loaderTest | testPrograms/loaderTest.asm | run | - | -
# Giving a bank number to a device's memory, which is what an OS does at boot.
registerBankTest | testPrograms/registerBankTest.asm | run | - | -
# The fence: raised, walked into, clipped by a blit, read through, and lowered again.
fenceTest | testPrograms/fenceTest.asm | run | - | -
# Meant to fault: a bank knows how big it is.
bankBoundsTest | testPrograms/bankBoundsTest.asm | run | - | -
# ---- A device saying no ----
# Refusing is not interrupting: it stops the instruction that asked, rather than asking
# for attention later. The device on port 0x11 refuses everything so this path stays
# tested before the memory controller becomes its only real user.
refusalTest | testPrograms/refusalTest.asm | run | - | -
# The same, with nothing installed to catch it. Meant to fault.
refusalFaultTest | testPrograms/refusalFaultTest.asm | run | - | -
# ---- Asking the machine what it is made of ----
registryTest | testPrograms/registryTest.asm | run | - | -
+1 -1
View File
@@ -23,7 +23,7 @@ SRC_DIR_ASM = Source/Assembler
OBJ_DIR = Object
# Source files
EMU_SRCS = emulator.c io.c utility.c cpu.c bootstrap.c assembly.c
EMU_SRCS = emulator.c io.c controller.c utility.c cpu.c bootstrap.c assembly.c
ASM_SRCS = Assembler.c assembly.c firstPass.c Assm-util.c secondPass.c
EMU_OBJS = $(EMU_SRCS:%.c=$(OBJ_DIR)/%.o)