# 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. ![CosmOS assembling a program, running it, and disassembling what it built](Media/CosmOS.png) *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/`](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`](Source/Emulator) | The machine: CPU, memory controller, devices, console, disk - and the two front ends that present it | | [`Source/Assembler`](Source/Assembler) | The assembler that runs on a host | | [`Source/DiskTool`](Source/DiskTool) | SplitDisk, which reads and writes SplitBit's filesystem | | [`Source/Linter`](Source/Linter) | SplitLint, which points out needlessly long assembly forms | | [`Source/Patch`](Source/Patch) | SoundPatch, which turns a soundThing patch into a table the sound device takes | | [`Programs/Examples`](Programs/Examples) | Programs to read: hello, a calculator, Fibonacci, a prime sieve, Life, the colours | | [`Programs/Libraries`](Programs/Libraries) | Code included by name rather than linked, since there is no linker | | [`Programs/Sounds`](Programs/Sounds) | Patches as soundThing saved them, and the tables SoundPatch made from them, both checked in so the build never needs soundThing | | [`Programs/Loader`](Programs/Loader) | The standalone loader CosmOS grew out of | | [`Programs/CosmOS`](Programs/CosmOS) | The operating system, its applications, and the native assembler | | [`Programs/testPrograms`](Programs/testPrograms) | What the test suite drives | | [`Tests`](Tests) | The suite: the manifest, the recorded output, and the scripts that check it | ## Two Front Ends: `make` builds **SplitBit**, which is the machine with a terminal attached, and where Raylib is installed it also builds **Voyager**, which is the same machine with a screen and a speaker. Everything that is actually the machine - every instruction, every device, every cycle - is shared between them, and each brings one file of its own: a terminal or a window. Voyager is deliberately not required. The machine, the assembler, the disk tool, the linter and the whole test suite build and run on a host with no graphics library at all, because a project about a small understandable CPU should not need OpenGL to run its tests. Where Raylib is missing, `make` says so once and builds everything else. The suite holds the two to being the same machine rather than taking it on trust: it runs the entire manifest through Voyager as well, with `--headless`, and requires it to satisfy every recorded result byte for byte. **Control, Shift and R is the reset button.** On real hardware it is not a key at all - a Voyager has a button on the case, and what a window has instead of a case is a gesture. Three things follow from that. It must not be a key software might want, because a machine with a keyboard has function keys and something will eventually have a use for them. It must not be reachable by accident, because restarting throws away everything in memory and a single key that does that sits one mistake away from losing work. And **it must be a gesture the host has no opinion about**. That last one rules out the obvious answer. Control, Alt and Delete has meant this since 1981 and cannot be used: it is a secure attention key, reserved by every serious operating system so that it always reaches the system and never an application - precisely so a program cannot imitate a login screen. On Windows an application cannot see it without a kernel driver, and on Linux the desktop takes it. It is unavailable for the same reason it seemed right. It does exactly what writing to the machine port does: the machine starts the way it started, so the boot chain runs again and finds whatever the disk now says to run. **And it works on a machine that has stopped** - one that halted, or faulted, or is a bare metal demo that ended. That is the whole point of a button: a machine which is not going anywhere is exactly the one worth restarting, and it is the one that cannot notice a request by itself, because a reset is otherwise seen between instructions and a halted machine runs none. This part is **emulator magic and known to be**. There is no reset line on this machine yet and no keyboard controller to assert one; the window reaches in and sets the same flag the machine port sets. When those are designed, a keyboard controller will have to see the gesture and pull reset regardless of what the CPU is doing - which is the property that matters, and the one a port write can never have, since a port write needs a program willing and able to make it. That is what makes a bare metal program escapable. `Once` puts one in front of the next start and deletes the request before jumping, so a demo that has taken the whole machine is one gesture away from the system coming back, without closing the window and opening it again. It works on a machine that is stuck waiting for a key, too, which is when a reset button earns its keep. **Escape reaches the machine.** Raylib closes a window on Escape unless it is told not to, and this machine sends Escape to the console like any other key - so a program reading keys could be ended by one of them, taking whatever was in memory with it. **The screen belongs to the machine, not to the window.** The video device is a tile engine on ports 0x30 to 0x3F that brings its own bank of video memory, and it renders into a buffer that is a pure function of that memory - so the same program draws the same picture whether or not anybody is watching. The console draws on it: it is a display controller as well as a port, with a font, a cursor and scrollback, which is why CosmOS runs in a window without a line of it being changed. Voyager puts that buffer on the glass and decides nothing about it. Either binary will save a picture of the screen with `--screen`, which is how a test suite on a host with no display checks what was drawn. See **The Screen** in the Programming Manual. ## 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**, 72 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 five 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: ``` make run-voyager ``` `make` builds a disk as well as the tools, so there is one to boot. `make run-cosmos` is the same system in the terminal, for a machine with no graphics library. Both put a second disk in drive 1, at `Disks/personal.img`. It is made once and then never rebuilt, cleaned or committed: everything else here can be thrown away and made again from source, and that one is where anything made ON the machine lives. Starting the machine copies it first, three starts back, as `personal.img.1` and so on. Not one copy: the way a disk is lost is that something goes wrong and the very next thing anybody does is start the machine again to see how bad it is, which is exactly when a single backup would be overwritten by the wreckage. 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. Every source in `Programs/` is on that disk, under `/Source`, so anything not shipped as a binary can still be assembled on the machine: `cd /Source/Examples` and `Asm colours.asm`. `make run-voyager` boots the same disk on the machine with a screen instead of a terminal. Both targets depend on the disk, so a disk built before a change to the machine is rebuilt rather than booted as it stands: **what is on a disk is whatever was built when the disk was made**, and a system whose console has changed will happily start an image full of programs written for the old one. ## 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 ` | 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. | **The boot image is optional now.** Named one, the emulator places it into memory and starts it, which is what a debugger does and how every test here runs - a real thing real machines allow, not a shortcut to apologise for. Given only a disk, the machine starts the way hardware would: ``` ./SplitBit --disk system.img ``` The emulator carries `Programs/Boot/stage1.asm` as a **shadowed ROM**: at reset its bytes are copied into Program Memory, boot vector included, and the CPU then does exactly what it has always done - reads the boot vector and starts where it points. Nothing about the CPU changed to make a machine that starts itself. Because it is a copy rather than a mapping, those bytes are ordinary Program Memory once stage one has jumped away. The system may write over them, and a reset puts them back, which is why rebooting has to be a reset rather than a jump - and `SWI SoftReset` is the instruction for it. The ROM is generated from the assembly by the makefile rather than kept beside it, because a copy of a program stored next to the program is a copy that goes stale. **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. **Which is what a nominal 1 MHz means here**: about 270,000 instructions a second, not a million. The same program takes 3.72 times the wall clock it did when a cycle was an instruction - nothing got slower, the number got honest, and the number it replaced described no machine anybody could build. Two measurements that put that in proportion, taken on the machine assembling its own operating system: | | | | --- | --- | | The emulator, in `--fast` | 195 million cycles a second | | CosmOS assembling CosmOS | 654 million cycles: 11 minutes at 1 MHz, 3.3 seconds at `--fast` | At a hypothetical 100 MHz that build is six and a half seconds, and that is a pessimistic figure rather than a hopeful one: it assumes hardware overlaps nothing, which is the same conservative reading the memory controller's cost model takes and the same one that wants measuring before it is designed. **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, while a move within one bank cannot and costs twice as much. A fill has nothing to read and goes at the between-banks rate. Against the ten cycles a transfer used to cost - the five port writes that set it up, and nothing at all for the quarter of a kilobyte that moved. **And the controller's path to memory is sixteen bits wide.** A transfer whose source, destination and length are all even moves two bytes a cycle between banks and one within a bank; anything odd falls back to a byte a cycle, because lining bytes up across word boundaries is a second design and this is not it. So a 256 byte block is 129 cycles between banks and 257 within one when it is aligned, and 257 and 513 when it is not. The CPU still sees eight bits and no instruction means anything different: this is a peripheral being faster, not a new machine. 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 ` | Write the output to this path. | | `-I ` | Look in this directory for included files. May be given more than once. | | `-M ` | Write out which source files the output depends on, as a make rule. | | `-S ` | 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`. ## Making A Patch: SoundPatch ``` ./SoundPatch