Somewhere to send the fault about there being nowhere to send it

Dispatching through a vector with nothing in it was the one fault this machine
could not hand over, because the thing that would hand it over is the thing that
has just found nothing to hand it to. It stopped the machine and no program
could do anything about it - so calling a service the system does not implement
was fatal, and that is an ordinary mistake to make.

Two new fault vectors: 5 when a software vector was empty, 6 when a device
interrupted and its hardware entry was. Separate, because they are separate
mistakes with separate fixes - one is a program calling something that is not
there, the other a program that asked to be interrupted and forgot the handler.

WHICH ENTRY WAS EMPTY ARRIVES IN Q, and it is the only thing on this machine a
handler is given in a register. Not a fault cause register by another route: the
vector still says what happened and Q says which of the 256 entries it happened
about, which is a parameter and not a cause. It costs no new state at all,
because the frame already saved the Q the interrupted program had and RETI puts
it back.

The escalation happens once. If vector 5 or 6 is itself empty the machine stops
the way it always did, having genuinely run out of places to go.

swiFaultTest is what guards that, and it was written long before any of this: it
installs nothing, so it must still get the old halt. Breaking the escalation
fails the two new tests and not that one; making the escalation unbounded fails
that one and not the two new ones. Each break fails exactly the half it belongs
to.

noDeviceTest is fed no input on purpose. The console raises its line once when
input ENDS as well as when a byte arrives - which exists so a program driven by
interrupts is told when nothing more is coming - so with no input at all, that
end is what turns up.

Groundwork for CosmOS's fault screen, which wanted to catch these two and could
not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
This commit is contained in:
Anachronaut
2026-09-01 14:40:53 -04:00
co-authored by Claude Opus 5
parent 4d976fc22a
commit 000a6d39cb
11 changed files with 206 additions and 5 deletions
+51
View File
@@ -0,0 +1,51 @@
; A device raising its line with nothing installed to answer it, CAUGHT.
;
; The other half of the fault that could not be handed over. A program that asks a device to
; interrupt it and then forgets the handler used to stop the machine dead; now it goes to a
; vector of its own with the port in Q, so the program can say which device it was that
; nobody was listening to.
;
; NOTHING IS TYPED AT THIS. The console raises its line once at the end of input as well as
; for an arriving byte - which is exactly so that a program driven by interrupts is told when
; nothing more is coming - and the test is run with no input at all, so that end is what
; arrives.
;
; Correct output is:
; nobody listening on port 00
; and a clean halt.
#Include console.asm
#Program
start:
INIA 0x02 ; Interrupt when the console has something to say.
OUTA 0x02
SIF
spin:
; Never touches the console. Whatever happens below is something that interrupted this.
BRI spin
; Q holds the port whose entry was empty. The console is port zero.
nobody:
SETD.0 NobodyText
CALL printString
MVQA
CALL printByteHex
CALL newLine
; The console back to how it was found, so nothing else is asked for, and stop. There is
; nothing to resume to: the loop above only exists to be interrupted.
RSTA
OUTA 0x02
HALT
#Data
NobodyText:
"nobody listening on port "
#Vectors
Boot start
NoDevice nobody
+56
View File
@@ -0,0 +1,56 @@
; A software interrupt that names a vector with nothing in it, CAUGHT.
;
; That fault used to be the one the machine had no way of handing over, because the thing
; that would hand it over is the thing that has just found nothing to hand it to. It stopped
; the machine, and no program could do anything about it - which made calling a service the
; system does not implement fatal, and that is an ordinary mistake to make.
;
; It goes to a vector of its own now, with the number of the empty entry in Q. This catches
; it, says which one it was, steps over the SWI and its operand, and carries on - so the
; output says both that the fault arrived and that a program can survive it.
;
; Correct output is:
; empty vector 28
; carried on
; and a clean halt.
#Include console.asm
#Program
start:
SWI 0d40 ; Forty is 0x28, and nothing is installed there.
SETD.0 Carried
CALL printString
CALL newLine
HALT
; Entered because there was nowhere to go. Q holds which entry was empty, and it is the only
; thing on this machine a handler is handed in a register.
missing:
SETD.0 EmptyText
CALL printString
MVQA
CALL printByteHex
CALL newLine
; ---- Carrying on past it ----
;
; The frame holds the address after the SWI and the byte naming its vector, so a bare RETI
; already lands past the instruction that faulted. That is not true of every fault here -
; a refused port or a byte that does not decode both resume ON the thing that failed - so
; it is worth saying out loud which kind this one is.
RETI
#Data
EmptyText:
"empty vector "
Carried:
"carried on"
#Vectors
Boot start
NoHandler missing
+25 -1
View File
@@ -66,7 +66,31 @@
#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
// ---- The two faults that used to be uncatchable ----
//
// A vector was dispatched through and had nothing in it. That is discovered by the very
// thing that would dispatch, so for a long time it could only stop the machine: there was
// no way to hand a program a fault about there being nowhere to hand it.
//
// These two are where it goes instead, and they are separate for the same reason every
// other cause is - a missing software vector and a device nobody is listening to are
// different mistakes with different fixes, and a handler should not have to work out which
// it was.
//
// WHICH ENTRY WAS EMPTY ARRIVES IN Q, and that is the only thing on this machine a handler
// is given in a register. It is not a cause register by the back door: the vector still
// says what happened, and Q says which of the 256 entries it happened about, which is a
// parameter rather than a cause. It costs no new state, because the frame already saved the
// Q the interrupted program had and RETI puts it back.
//
// A vector THESE dispatch through and find empty stops the machine, and has to: a machine
// that cannot report a fault about a missing handler by any route has run out of places to
// go, and looping there would be worse than stopping.
#define VECTOR_NO_HANDLER 5
#define VECTOR_NO_DEVICE 6
// Vectors 7 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, in two halves.
//
+2
View File
@@ -226,6 +226,8 @@ static const struct {
{ "BadOpcode", VECTOR_INVALID_OPCODE },
{ "GuardViolation", VECTOR_GUARD_VIOLATION },
{ "BankFault", VECTOR_BANK_FAULT },
{ "NoHandler", VECTOR_NO_HANDLER },
{ "NoDevice", VECTOR_NO_DEVICE },
};
static const int reservedVectorCount = (int)(sizeof(reservedVectors) / sizeof(reservedVectors[0]));
+30 -1
View File
@@ -69,9 +69,31 @@ static uint16_t readVector(const uint8_t *programMemory, uint16_t base, uint8_t
// Note that a zero entry means "no handler" to everything that dispatches, including the
// two entries the CPU treats as start addresses when it reads them at reset. The
// exemption belongs to that one read, not to the entries themselves.
static uint8_t enterInterrupt(CPURegisters *cpu, uint16_t base, uint8_t index, uint16_t resumeAddress) {
static uint8_t dispatch(CPURegisters *cpu, uint16_t base, uint8_t index,
uint16_t resumeAddress, int mayEscalate) {
uint16_t handler = readVector(cpu->Program, base, index);
if (handler == 0x0000) {
// ---- Nowhere to go is itself something to report ----
//
// This is the one fault the machine used to have no way of handing over, because
// the thing that would hand it over is the thing that has just found nothing to
// hand it to. So it goes to a vector of its own instead, with the number of the
// empty entry in Q - and a missing software vector and a device nobody is
// listening to are separate entries, because they are separate mistakes.
//
// NOT WHEN ALREADY ESCALATING. If the fault vector for this is itself empty then
// the machine really has run out of places to go, and stopping is the only honest
// answer left.
if (mayEscalate) {
const uint8_t escalation = (base == HARDWARE_VECTOR_BASE)
? VECTOR_NO_DEVICE : VECTOR_NO_HANDLER;
if (!dispatch(cpu, SOFTWARE_VECTOR_BASE, escalation, resumeAddress, 0)) {
// After the frame, so the Q the interrupted program had is safely in it and
// RETI will put it back. What the handler sees is which entry was empty.
cpu->Q = index;
return 0;
}
}
cpu->Fault = FAULT_NO_HANDLER;
cpu->FaultVector = index;
cpu->Status |= STATUS_FAULT;
@@ -109,6 +131,13 @@ static uint8_t enterInterrupt(CPURegisters *cpu, uint16_t base, uint8_t index, u
return 0;
}
// Dispatching the ordinary way: through the vector asked for, and through the fault vector
// for a missing one if that is what it turns out to be.
static uint8_t enterInterrupt(CPURegisters *cpu, uint16_t base, uint8_t index,
uint16_t resumeAddress) {
return dispatch(cpu, base, index, resumeAddress, 1);
}
// 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
+1 -1
View File
@@ -633,7 +633,7 @@ A `#Vectors` line that names a **handler** says this program implements that vec
`Boot` in a loadable program fills the **entry** field rather than being installed. Vector zero is where the whole machine starts, and a program being loaded into a running system has no business saying anything about that. A boot image is the one thing that does, so there it is installed like any other.
`Device` is named by the port it is plugged into, because that is what decides which vector it arrives through. `Device`, `Boot`, `SoftReset`, `BadOpcode`, `GuardViolation` and `BankFault` are matched **without regard to case**, the way mnemonics are: they are part of the language rather than names the programmer chose.
`Device` is named by the port it is plugged into, because that is what decides which vector it arrives through. `Device`, `Boot`, `SoftReset`, `BadOpcode`, `GuardViolation`, `BankFault`, `NoHandler` and `NoDevice` are matched **without regard to case**, the way mnemonics are: they are part of the language rather than names the programmer chose.
**A declaration and an implementation are the same entry.** `services.asm` says a service is called `osPrintString` and has number 16; `cosmos.asm` says `osPrintString` is handled by `handlePrintString`. Both sides include the first file, so the name is met twice and the second time fills in the handler. That is what lets one shared file serve both a program that calls a service and the system that implements it - and it is why the first pass declares and the second implements, a handler being an address and no address being known until every label has been placed.
+23 -1
View File
@@ -547,7 +547,17 @@ If a device interrupts and its vector is empty, that is a fault: the machine sto
If the CPU reads a byte from Program Memory that does not decode to an instruction, it dispatches through Software Vector 2.
Faults get a vector each rather than sharing one. Vector 2 is the only cause defined so far, and vectors 3 through 15 are held back for the ones that come later, so that a handler always knows what happened from the entry it arrived through. That is why the machine has no fault cause register to read.
Faults get a vector each rather than sharing one, so that a handler always knows what happened from the entry it arrived through. That is why the machine has no fault cause register to read.
| Vector | What happened |
| --- | --- |
| 2 | A byte in Program Memory does not decode to an instruction. |
| 3 | A device refused a write that landed inside a raised fence. |
| 4 | A bank was named that has nothing in it, or an access ran past its end. |
| 5 | A software vector was dispatched through and had no handler. |
| 6 | A device interrupted and its hardware vector had no handler. |
Vectors 7 through 15 are held back for the causes that come later.
The address in the frame is the address of the offending byte itself, not the one after it. A handler can therefore read the byte that failed and say what it was. It also means a handler that returns with a bare RETI will meet the same byte again, because resuming past a fault means deciding where to resume, and only the handler knows that.
@@ -555,6 +565,18 @@ If nothing is installed at Vector 2, the CPU sets the Fault Flag and the Halt Fl
Stopping matters because the alternative is worse. A byte that means nothing is almost always a sign that execution has wandered into data, or that a program was built for a machine with instructions this one does not have. Stepping over it and carrying on turns a clear failure into a program that appears to run and quietly does the wrong thing.
### Nowhere To Go:
Vectors 5 and 6 are the fault of dispatching through an empty entry, and they were a long time coming, because the thing that would hand that fault over is the thing which has just found nothing to hand it to. Until they existed, a `SWI` naming a service the system does not implement stopped the machine and no program could do anything about it - and calling a service that is not there is an ordinary mistake.
**Which entry was empty arrives in Q**, and it is the only thing on this machine a handler is given in a register. That is not a fault cause register by another route: the vector still says *what* happened, and Q says which of the 256 entries it happened about, which is a parameter and not a cause. It costs nothing, because the frame already saved the Q the interrupted program had and RETI puts it back.
The two are separate entries because they are separate mistakes with separate fixes. A missing software vector is usually a program calling something that is not there; a device with nobody listening is usually a program that asked to be interrupted and forgot the handler.
**The frame's address is past the SWI**, unlike every other fault here, because the instruction did dispatch - it was the entry that was empty. A bare RETI therefore carries straight on, where a bad opcode or a refused port would meet the same instruction again.
If Vector 5 or 6 is *itself* empty, the machine stops the way it always did. It has run out of places to go, and looping there would be worse than stopping. A handler that commits the same fault it was called about recurses like any other, which is the same bargain as a Vector 2 handler containing a byte that does not decode.
## 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.
+1 -1
View File
@@ -79,7 +79,7 @@ from `make`, not from here.
### 1. Recorded output
`Tests/run.sh` assembles each program named in `Tests/manifest`, runs it, and compares
everything it printed against a file in `Tests/expected`. 192 tests, of which 130 run, 35
everything it printed against a file in `Tests/expected`. 194 tests, of which 132 run, 35
only assemble, 16 are expected to fail to assemble, and 11 boot from ROM with no image
given at all.
+3
View File
@@ -0,0 +1,3 @@
nobody listening on port 00
Execution halted.
[exit 0]
+4
View File
@@ -0,0 +1,4 @@
empty vector 28
carried on
Execution halted.
[exit 0]
+10
View File
@@ -190,6 +190,16 @@ maskTest | testPrograms/maskTest.asm | run | -
# reach on its own. The round trip through a handler is covered by hand built
# binaries until #Vectors arrives.
swiFaultTest | testPrograms/swiFaultTest.asm | run | - | -
# And the same fault CAUGHT, which nothing could do until the machine had somewhere to send
# it. Calling a service the system does not implement used to be fatal, and it is an ordinary
# mistake. The number of the empty entry arrives in Q, and the handler steps over the SWI and
# carries on - so the recording says both that the fault was delivered and that a program can
# live through one.
noHandlerTest | testPrograms/noHandlerTest.asm | run | - | -
# The other half: a device raising its line with nothing installed to answer it. Nothing is
# typed at this on purpose - the console raises its line once when input ENDS as well as when
# a byte arrives, and with no input at all that end is what turns up.
noDeviceTest | testPrograms/noDeviceTest.asm | run | - | -
# ---- Meeting a byte that is not an instruction ----
# This one is meant to fault. It checks the CPU stops, says what it found and