Four things SplitLint wanted, and they build on each other. EVERY WARNING NAMES ITS RULE, in brackets at the end the way a compiler names the flag that produced it. Twelve rules, listed by --help. That makes the other three possible: suppressions can name one rule and leave the line honest about the others, the harness can assert on a rule's identity rather than on the wording of its message, and --machine can print one tab separated line per warning - file, line, rule, message, help - so nothing downstream reads prose. This file's own output was parsed with regular expressions three times in one day before it had a shape to rely on. A CLEAN RUN SAYS SO: No style warnings: 121 files checked against 12 rules. It used to exit in silence, which does not tell you it found nothing - it tells you nothing at all, and from outside the two are identical. A MARKER THAT SILENCES NOTHING IS ITSELF REPORTED, as dead-suppression. An exception that outlived whatever made it necessary is the thing the required reason exists to prevent, and naming the wrong rule now gets you both the warning you meant to silence and a note that your suppression is doing nothing. AND THE CORPUS IS HELD TO A BASELINE. Sixty one warnings are left in it deliberately and nothing stopped a sixty second. Tests/lint-baseline.txt records how many of each rule each file should produce, so a new one fails make test while the sixty one stay quiet; confirmed by adding an INIA 0d0 to Say.asm and watching it name the file, the rule and the count. It counts per file and rule rather than recording line numbers, because line numbers would churn the whole baseline whenever anything was inserted above a warning - the same reason cycle counts are stripped from recorded output here. ./Tests/lint.sh --bless records it again. One thing to know for next time: the rule name was inserted before the line number at all twenty one call sites, and the signature was changed to match rather than the twenty one call sites being fixed. (path, rule, line) reads no worse than (path, line, rule) and one edit has fewer ways to go wrong than twenty one.
25 KiB
SplitBit
SplitBit is an 8 bit computer that does not exist: a CPU with its own instruction set, split Program and Data memories, an interrupt and vector system, a bus that programs can enumerate, a memory controller that can write code into memory, and a disk. This repository is a C implementation of the machine, an assembler for it, a tool for its disks, and the software that runs on it, which now includes an operating system and an assembler written in SplitBit's own assembly language.
The whole loop in one screen. Asm.sbx assembles hello.asm into hello.sbx; the shell loads that and runs it; then the monitor disassembles what is at 0x2000, which is the program the machine wrote for itself at the top of the screen. No host is involved at any point.
SplitBit assembles SplitBit. Programs/CosmOS/Assembler/ runs on the machine, reads source off a SplitBit disk, and writes a boot image or a loadable program back to it with no host involved. It builds the operating system it is running under, and it builds itself, both byte for byte identical to what the C assembler produces from the same source. The test suite then boots the CosmOS that CosmOS built and has that one assemble CosmOS again, and the second generation is identical to the first, so the machinery has been through itself. After that the host is a convenience rather than a necessity.
> load Asm.sbx
> run cosmos.asm
wrote cosmos.bin: program 7036, data 2448, labels 475
> run Asm.asm
wrote Asm.sbx: program 7533, data 4099, labels 555
What Is In Here:
| Directory | What it holds |
|---|---|
Source/Emulator |
The machine: CPU, memory controller, devices, console, disk |
Source/Assembler |
The assembler that runs on a host |
Source/DiskTool |
SplitDisk, which reads and writes SplitBit's filesystem |
Source/Linter |
SplitLint, which points out needlessly long assembly forms |
Programs/Examples |
Programs to read: hello, a calculator, Fibonacci, a prime sieve, Life |
Programs/Libraries |
Code included by name rather than linked, since there is no linker |
Programs/Loader |
The standalone loader CosmOS grew out of |
Programs/CosmOS |
The operating system, its applications, and the native assembler |
Programs/testPrograms |
What the test suite drives |
Tests |
The suite: the manifest, the recorded output, and the scripts that check it |
The Machine:
- Harvard architecture. Two 64K memories, one for instructions and one for data. An instruction can only read the second, which is why strings live there and why the memory controller exists.
- Its own instruction set, 71 instructions, four Data Pointers, and a Q register that holds what the ALU last worked out. Small enough that the table describing it fits in the machine's own memory, which is what lets it disassemble and assemble for itself.
- Interrupts. Software traps, hardware lines from devices, and faults, all arriving through one vector table with a full context save.
- A bus programs can enumerate, so a program can ask what a machine is made of rather than being told.
- A memory controller that reads and writes Program Memory, moves blocks between 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.
- Storage: a block device with 256 byte blocks and up to 16 megabytes of them, backed by an image file. It knows blocks and not files, because a filesystem is meant to be software SplitBit runs.
The Software:
- SBFS, a filesystem read and written by SplitBit itself and by a host tool that speaks the same format, so an image can be moved either way and each implementation checks the other.
- CosmOS, an operating system: it boots the machine, mounts a disk, lists what is on it, loads a program and runs it, and takes the machine back when the program finishes. It comes with a library of programs including a game, a line editor, and a monitor that examines memory, disassembles, assembles a line at a time, and sets breakpoints.
- Loadable programs. A program that was not booted from carries a header saying where it belongs, and may bring interrupt handlers of its own for the loader to install and take back again.
- System services. A loaded program reaches the console and the disk through numbered software interrupts rather than carrying a copy of the code that drives them. The numbers are written down in one file both sides include, so neither ever types one. It took the editor from 4941 bytes to 1983 without changing a line of what it does.
- Streaming reads. A file bigger than the machine's memory is read a block at a time, through services that keep nothing open between calls. CosmOS's own source is far larger than its 64K of Data Memory, and this is what the native assembler stands on.
Getting Started:
Clone it and build the four tools. You need gcc and make, or similar:
git clone https://github.com/RealBusinessAccount/SplitBit-Emulator.git
cd SplitBit-Emulator
make
The sources are ISO C and build clean under -std=c11 -pedantic with -Wall -Wextra. Beyond ISO C they need POSIX.1-2008, which the makefile asks for by name, and getopt_long for the long form of the command line options.
Building needs nothing else. Running the tests needs bash and Python 3, because two of the checking scripts are Python and one of them opens a pseudo-terminal to ask questions a recorded file cannot answer. They also use stty, timeout, and the usual text utilities: sed, awk, grep, cmp, diff, tr, sort, wc.
Assemble something and run it:
./Assembler Programs/Examples/hello.asm
./SplitBit hello.bin
Or boot the operating system, with a disk of programs and all of its own source on it:
cd Programs
make run-cosmos
Then dir to see what is there, load Snake.sbx and run to play something, or load Asm.sbx and run cosmos.asm to watch the machine build itself.
Running Programs: SplitBit
./SplitBit [options] [boot image]
| Option | What it does |
|---|---|
-d, --debug |
Single step through cycles. Each key press advances one instruction. |
-c, --cycles N |
Stop after N cycles rather than running until the program halts. Useful for programs that never halt, and for getting the same output from a run every time. |
-f, --fast |
Run as fast as the host allows, ignoring the emulated cycle rate. |
-D, --disk <file> |
Attach a disk image, creating a 128K one if the file is not there. |
-L, --disk-cycles N |
How many cycles a block read or write takes. Zero, the default, finishes before the next instruction starts. |
-W, --write-protect |
Attach the disk read only. A disk whose image the host will not let you write is read only whether you ask for this or not. |
-h, --help |
Show help and usage information. |
A cycle is one access to memory, not one instruction. Fetching an opcode is a cycle, fetching each byte after it is another, reading or writing Data Memory is one, every byte a CALL pushes or a RET pops is one, and reaching a device port is one. Nothing overlaps - there is no fetching the next instruction while this one finishes - so the count is simply how many times the machine used the bus.
That makes the numbers describe something buildable. RSTA costs 1 and SETD costs 4,
because one is a byte and the other is four. CALL and RET together cost 24 and RCAL
and RRET cost 8, because the first pair moves twenty bytes of Stack and the second moves
four. Counting instructions said those were the same, which is not true of any machine
anybody could build - and it is the emulator's job to be the thing the hardware is designed
against.
The average SplitBit instruction costs 3.72 cycles, measured over the native assembler assembling a program.
The memory controller is charged for what it moves, on the same terms. Banks are separate memories, and that is what sets the rate: a move between two of them can overlap its read and its write, so it settles at a byte a cycle, while a move within one bank cannot and costs two. A fill has nothing to read and costs one. So a 256 byte block is 257 cycles between banks and 513 within one, against the ten it used to cost - which was the five port writes that set it up and nothing for the quarter of a kilobyte that moved.
The transfer stalls the program that asked for it. Whether hardware would let the two run at once is left open, the same way pipelining is: the memories are separate, so it plausibly could, and the measurements say it would buy less than it sounds like.
The disk can be given a latency with --disk-cycles, and then it really does take that
long: it says busy, finishes when the machine has run that far, and a program that does not
wait reads the block before the one it asked for. That is not an error anywhere - just
quietly the wrong bytes - which is why the filesystem now watches the busy bit rather than
trusting the answer to be there. Zero is the default and is how the machine has always run.
The waiting is one small routine, and it is reached with RCAL rather than CALL because
what it hands back is the settled status in A, and an ordinary call would put A back the way
it found it.
And waiting is not the same kind of cycle as working. A machine stopped in a WAIT is
clocked but is not using the bus, so those cycles are counted apart from the rest and the
halt line says so when there are any:
Execution halted after 1042474 cycles, 119772 of them waiting.
Added together they are elapsed time, which is what --cycles measures. Told apart they
say whether a program was working or waiting - and that distinction is the only thing that
separates a machine which slept through a slow disk from one which spun on it. The two take
the same wall clock time and print the same characters. When the filesystem's wait was
first written, taking the line-clearing out of WAIT moved the total by a single cycle,
20,100 against 20,099, while the idle half halved.
Whether real hardware would overlap a fetch with the end of the previous instruction is left open, and deliberately: this is the conservative model, and pipelining is a decision to make while drawing the hardware rather than one to inherit from an emulator.
If the CPU reads a byte that is not an instruction, it goes to the fault handler the program installed. If it installed none, it raises the Fault Flag and halts, and the emulator reports the byte and the address it was found at and exits with a non zero status. The same happens if a program or a device asks for a handler that was never installed.
Assembling: Assembler
./Assembler [options] [assembly file]
| Option | What it does |
|---|---|
-o <file> |
Write the output to this path. |
-I <dir> |
Look in this directory for included files. May be given more than once. |
-M <file> |
Write out which source files the output depends on, as a make rule. |
-S <file> |
Write every label and the address it was given, in address order. |
-h, --help |
Show help and usage information. |
-S is the only thing that knows what a program's addresses are called. A program on the
disk is bytes; the machine's own monitor can disassemble it but has no idea what any of it
is named. So a count of which addresses get called says a great deal and names nothing, and
this is what turns such a count into a list of routine names.
Without -o the output takes the source file's name, in the directory you called the assembler from, with the extension the format asks for: .bin for a boot image and .sbx for a loadable program. Included files are looked for beside the file that includes them, and then along the directories given with -I.
Checking Assembly: SplitLint
./SplitLint [--fatal-warnings] [--machine] <sourcefile> [sourcefile ...]
Every warning names the rule that produced it, in brackets the way a compiler names a
flag, and --machine prints one tab-separated line per warning and nothing else - file,
line, rule, message, help - so that nothing downstream has to read prose. A run that finds
nothing says so rather than exiting silently, because a tool that says nothing has not told
you it found nothing; it has told you nothing at all, and from outside those look the same:
No style warnings: 121 files checked against 12 rules.
SplitLint reports valid assembly that has a shorter direct expression, beginning with
zero loads that can use RSTA or RSTB, Q-to-register transfers that need not pass
through the stack, self-push/pop pairs, register assignments overwritten by the next
instruction, instructions with no ordinary fallthrough path, and one-byte Data Pointer
changes that can use INCD or DECD, including direct branches whose target is already
the next labeled address. It also tracks symbolic Data Pointer bases and known offsets to
find a SETD that reloads an address the pointer already holds. Device input and stack
pops are excluded from dead-assignment checks because consuming their input is itself an effect.
Nothing it knows survives a call. CALL really does restore A, B and Data Pointers 0
to 2, so a pointer set before one is genuinely still set after it - and saying so produced
advice that was correct today and unsafe to take. 122 of the 178 redundant SETDs it first
found were redundant only because of that restore, and removing them would have become a
wrong-pointer bug the moment the callee was reached with RCAL instead, which is what
RCAL was added to this machine for. Worse, the linter would have gone quiet rather than
complained, because it forgets everything across an RCAL. Fifty four recommendations that
stay true are worth more than a hundred and seventy eight that are conditional on a change
the project intends to make.
SplitLint's control-flow knowledge is otherwise deliberately local: labels begin reachable regions, while an unparsed
directive or data item ends the current claim. It does not yet expand includes or build a
complete control-flow graph. Warnings normally leave a successful exit status, while
--fatal-warnings makes any warning fail the command for use in automated checks.
A line whose comment says splitlint: <reason> is not reported on, and the reason is
required. A suppression with no explanation is a way to make a tool quiet rather than a
way to say something, and a bare marker is refused rather than honoured. The corpus has
three of them, all in test programs: branchTest.asm exists to check that a branch whose
carry is known behaves correctly, so a diagnostic saying the outcome is known is exactly
right and exactly unwanted. splitlint[rule]: <reason> silences one rule and leaves the
line honest about the others.
Suppressions are counted and reported at the end of a run, and a marker that no longer silences anything is itself reported - an exception that outlived its reason is the thing the required reason was meant to prevent.
The corpus is held to a baseline. Sixty one warnings are left in it on purpose, and
Tests/lint-baseline.txt records how many of each rule each file is expected to produce,
so a sixty second fails make test while the sixty one stay quiet. It counts rather than
recording line numbers, because recording lines would churn the whole file whenever
anything was inserted above a warning. ./Tests/lint.sh --bless records it again once
warnings have been deliberately fixed or deliberately accepted.
The same local model tracks whether carry is known set or clear. It reports a redundant
CCF, a BRC or BNC whose outcome is already determined, and computes carry through
increment, decrement, addition, and subtraction when their inputs are known.
Managing Disks: SplitDisk
./SplitDisk <command> <image> [arguments]
| Command | What it does |
|---|---|
format <image> [blocks] [dirblocks] |
Lay down a fresh filesystem. 512 blocks and 8 of directory by default, which is 128K and room for 64 entries. |
list <image> [path] |
Show the whole disk, or one directory of it. |
put <image> <file> [path] |
Put a host file onto it. Without a path it uses the file's own name, which is often longer than the 22 characters a name may be. |
get <image> <path> [file] |
Take one off it. |
delete <image> <path> |
Remove a file. |
mkdir <image> <path> |
Make a directory. |
rmdir <image> <path> |
Remove an empty one. |
SplitDisk speaks the same on disk format SplitBit does, so an image it makes is one the machine can read, and one the machine writes is one it can read back. It is a convenience rather than a necessity: SplitBit writes its own filesystem, and now assembles its own programs, so a disk can be filled without leaving the machine.
Files are laid down contiguously, so a disk can have free blocks without having them in one piece. When that happens put says so rather than putting part of a file on.
A path is names with / between them, always from the root, since a command line tool has nowhere to keep a working directory between one run and the next. . and .. mean what they usually do, and .. from the root is the root.
A disk has two ceilings and it is usually the less obvious one that bites: blocks, and entries. Every file and every directory costs one entry, and list says how many of them are gone as well as how many blocks are. On a disk of small files the entries run out long before the space does, which is a matter of how the disk was formatted rather than a limit of the format - dirblocks is carried per disk, and each one is 256 bytes and holds eight entries.
Two Versions:
| Version | What it means |
|---|---|
| 1 | Flat. Every file is in the root, because there is nowhere else. |
| 2 | Directories. Each entry says which directory it is in. |
A version one disk is already a valid version two disk. The parent is stored as an entry index plus one, so the zeroes a version one disk has in those bytes read as "in the root" - which is exactly where all of its files are. There is nothing to convert.
A disk is at the lowest version that describes what is on it, so format makes a version one disk and mkdir is what raises it. That is deliberate: a disk stays readable by anything that has never heard of a directory right up until it actually has one. Compatibility runs one way, which is the ordinary shape of it - version one code reading a version two disk would list directories as strange empty files.
Building Programs With Make:
The assembler is built to work with make. -o puts the output where the build system wants it, and -M writes out which libraries went into it, so that editing a library reassembles everything that includes it.
Programs/makefile does this for the programs in this repository:
cd Programs
make
The rule it uses is small enough to copy into your own projects:
$(BUILD)/%.bin: %.asm
@mkdir -p $(@D)
$(ASM) $(INCLUDES) -M $(@:.bin=.d) -o $@ $<
-include $(BINARIES:.bin=.d)
Tests:
make test
The suite assembles and runs every program in Programs/ and compares the results against recorded output. Tests are defined in Tests/manifest, one line per program. To record the current output as the expected result, after you have checked that it is correct:
make bless
Programs are built inside Tests/build, so running the suite never overwrites anything in Programs/. To run only some of the tests, call the runner directly with their names:
./Tests/run.sh hello 8bitFibonacci
The disk images tests read from are built first by Tests/makedisks.sh, using SplitDisk. A
test that reads one is therefore checked against a filesystem written by different code from
the same written specification, rather than against itself.
Tests/run.sh drives that comparison. Six more scripts run alongside it, and each exists
because a recorded file cannot answer its question:
Tests/disk.shchecks the disk tool on its own: files of every awkward size onto an image and off again, and the things the format says cannot happen refused rather than half done.Tests/terminal.shchecks what a recorded file cannot see. Piped output is buffered and flushed at exit, so a prompt shown before its answer is asked for and one shown an hour late produce identical files; and key mode only touches a terminal when there is one. Both have gone wrong here, and both were found by a person whose terminal stopped working rather than by anything in this suite. So it runs the emulator under a pseudo-terminal and asks directly: that a prompt arrives before input is read, that a keystroke arrives without Return, that the terminal is handed back however the machine dies, and that suspending and resuming leave it as they found it. It also asks the one question about cycles that a recorded file cannot, since the count is stripped from every one: whether a program on a slow disk slept through the wait or spun on it. Both print the same characters and take the same elapsed time, and only the split between idle and bus cycles tells them apart.Tests/native.shchecks the assembler that runs on SplitBit against the one that runs on the host, byte for byte, on a boot image and four loadable programs, and then on CosmOS and on itself, and then on the CosmOS that CosmOS built.Tests/agree.shchecks the two implementations of SBFS against each other rather than each against itself, by building the same disk with SplitDisk and with CosmOS and comparing the images byte for byte. Every field one of them writes and the other only reads is checked there and nowhere else.Tests/lint.shchecks SplitLint against a fixture written so that every line of it trips exactly one rule. It compares which warning came out and at which line rather than how many came out in total: a count stays right while the thing behind it goes wrong, and breaking one rule's message left the total untouched at twenty three.Tests/docs.shchecks the manuals against the code: that every instruction has a row and every row is an instruction, that the counts in the headings are right, that every directive is written down, that every service the system implements is described and every service described is implemented, that every routine the manuals promise exists, that CosmOS still fits in the half of the machine its memory map gives it, and that the worked examples still assemble to the bytes printed beside them.
A cycle count is deliberately not part of a recorded result. The last line of the emulator's output has the number taken out before anything is compared, keeping only whether the program stopped on its own or ran into its limit, which is behaviour. Two instructions added to CosmOS used to move that number in six unrelated files at once, so a real difference would have arrived in a crowd of meaningless ones. Anything that wants to measure cycles should say so in a test of its own.
To rebuild all four tools with the address and undefined behaviour sanitizers and run the suite under them:
make sanitize
This catches reads and writes past the end of an array, use after free, leaks, and undefined arithmetic. It also fills fresh allocations with a junk pattern, which turns a read of uninitialised memory from something that quietly works into something the tests notice. It runs everything make test runs, takes about twice as long, and puts the ordinary binaries back when it finishes.
Documentation:
Three documents, divided by what they are about rather than by who reads them.
SplitBit Programming Manual describes the machine: the instruction set, the registers, the vector table, interrupts, devices, the memory controller, the console, storage, and faults. Everything here is true of any SplitBit, whatever is running on it.
SplitBit Assembler Manual describes the language and the file formats: literal values, labels, segments, the directives, the loadable program header, and the assembler that runs on SplitBit itself.
Programs/CosmOS/README.md describes the operating system: its shell, its applications, what a program may ask it for, and the libraries it owns. A different system on the same machine would answer all of that differently, which is why it is documented with the system rather than with the CPU.
License:
Apache License, Version 2.0. You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0.
