| Mode | Name | Size | |
|---|---|---|---|
| -rw-r--r-- | .gitignore | 26 | logstatsplainblame |
| -rw-r--r-- | Makefile | 1473 | logstatsplainblame |
| -rw-r--r-- | README.md | 15672 | logstatsplainblame |
| d--------- | c | 1230 | logstatsplain |
| d--------- | include | 36 | logstatsplain |
| -rw-r--r-- | shot.ppm | 69135 | logstatsplainblame |
| d--------- | src | 656 | logstatsplain |
| d--------- | tools | 256 | logstatsplain |
gbos
A tiny Unix-flavored microkernel for the Game Boy Color (MBC5 cartridge). Not Linux, not FUZIX — a from-scratch cooperative kernel that borrows the V7 process model (separate read-only text + per-process data, a syscall trap, round-robin scheduling) and squeezes it into the GBC's banked, MMU-less memory.
This is a scaffold, but a working one: it assembles into a valid 32 KiB
ROM, brings up a process table, and round-robin context-switches between two
demo tasks that each write() a byte and yield(). Verified end-to-end in the
~/dev/gbc emulator (headless serial console) — it streams ABABAB....
Why this shape (the hardware reality)
- No MMU, no memory protection. "Unix" here = the API/process model, not isolation.
- CPU only ever sees 64 KiB; RAM is banked.
- Kernel code lives in ROM (
$0000-$3FFF, up to 8 MiB via MBC5) — free, read-only. - Program text lives in ROM banks (
$4000-$7FFF) — the V7 "pure text" idea. - Per-task RW state (data/bss/heap/stack) lives in one 8 KiB cart-RAM bank
(
$A000-$BFFF, MBC5, up to 128 KiB / 16 banks). - The context switch swaps banks, so it must run from HRAM (never banked) and never touch a swappable stack mid-swap.
Memory map
$0000-$3FFF ROM0 kernel core (rst/IRQ vectors, init, sched, syscalls) - fixed
$4000-$7FFF ROMX current task TEXT (read-only program code)
$8000-$9FFF VRAM graphics (unused so far)
$A000-$BFFF SRAM current task DATA/BSS/HEAP/STACK (MBC5 RAM bank)
$C000-$CFFF WRAM0 kernel globals + kernel stack (never swapped)
$D000-$DFFF WRAMX per-process u-area (SVBK bank) (reserved, not used yet)
$FF80-$FFEE HRAM context-switch trampoline (111 bytes)
$FFEF-$FFF2 HRAM bank shadows + switch target
Process control block (include/gbos.inc)
STATE, PID, SP, ROMB(16b text bank), RAMB(data bank), WRAMB(u-area), PARENT, EXIT
— 10 bytes. MAX_PROCS = 8.
Syscall ABI
User loads C = syscall number, args in DE/B/HL, then rst $30.
Return in A (and B for wait). The trap clobbers BC/DE/HL (it uses
HL to index the dispatch table), so userland must save any pointer it needs
across a syscall (push hl / pop hl). Libc syscall stubs handle this for C.
| # | name | status |
|---|---|---|
| 0 | exit | ✅ zombie + reparent orphans + wake waiter |
| 1 | fork | ✅ copies the 8 KiB bank, child returns 0 |
| 3 | write | ✅ DE=buf B=len → serial console |
| 6 | exec | ✅ B=program id → maps ROM text bank, jumps in |
| 7 | wait | ✅ blocks; reaps a zombie child → A=pid B=code |
| 8 | getpid | ✅ |
| 11 | yield | ✅ cooperative switch |
| 2 | read | ✅ blocking console input (1 byte) via external-clock serial |
| 4,5,9,10 | open/close/kill/brk | ⛔ ENOSYS |
The shell (c/sh.c)
init execs the shell (written in C). It reads a line, tokenizes it, and runs
commands with I/O redirection and pipes:
$ echo hello > note # redirect stdout to a file
$ cat note
hello
$ wc < note # redirect stdin from a file
1 1 6
$ cat readme | wc -l # pipe (via a temp file)
2
$ echo one two three | wc
1 3 14
Operators must be space-separated (cat readme > out). Command names are
resolved by the kernel (sys_lookup + NameTable).
How I/O routing works. Each process has a stdin/stdout fd in its PCB
(default $FF = console). read/write/putc route through the kernel
(KGetc/KPutc): console when the fd is $FF, otherwise the filesystem
(getb/putb). The shell forks a child, the child setin/setouts to open
files, then execs — so the program's I/O lands in the file. A pipe
a | b is run as a > __pipe ; b < __pipe ; rm __pipe.
Process lifecycle (exit / wait / reaping)
The classic Unix zombie/reap dance, adapted to banked memory:
exit(code)setsPS_ZOMBIE+ status, reparents any children to init (pid 1), wakes the parent if it's blocked inwait(), then schedules away forever. Its resources are freed by the reaper, not here.wait()scans for aPS_ZOMBIEchild. Found → reap: free its cart-RAM bank (back to the allocator) and its PCB slot, return pid + code. Children exist but none dead → set selfPS_BLOCKEDand yield, retry on wake. No children → return$FF(ECHILD).- The scheduler skips non-
READYprocs;FindNextReadyreturns carry when nothing is runnable soyieldjust keeps the caller running (idle). - Cart-RAM banks are a free list (
wBankUsedbitmap):AllocRamBank/FreeRamBank. Verified by running 6 workers through only 3 child banks (w2w3w4w5w6w7) — each reaped bank is recycled by the nextfork.
Lifecycle demo (tasks.asm): init forks 3 workers, each execs PROG_WORKER
(prints w, exits with its pid), init waits and reaps all three →
wwwR2R3R4!.
exec (sys_exec, src/proc.asm + programs.asm)
Where the text-in-ROM model pays off. Programs are linked to run from the ROMX
window ($4000-$7FFF) and stored in their own ROM banks (programs.asm).
exec(B=program id):
- looks up
ProgramTable[B]= (ROM bank, entry), - sets
PROC_ROMBand maps the bank live into$4000-$7FFF, - resets the stack to the top of the task's (already mapped) cart-RAM bank,
jps to the entry — it never returns.
Proof it's really bank-driven: both demo programs are ORG'd at the same
address $4000 in different banks (02 and 03). The parent execs
PROG_PING, the child execs PROG_PONG, and the output 1212... (2000/2000,
zero garbage) can only happen if each process executes its own bank via
PROC_ROMB. A fuller exec would also copy .data from ROM and zero .bss.
fork (sys_fork, src/proc.asm)
The interesting syscall. It enters on the parent's user stack (which lives in
the $A000-$BFFF cart-RAM window), so before it can swap that window it
switches to a kernel stack in WRAM0. Then it:
- allocates a free PCB slot + a fresh cart-RAM bank (bump allocator),
- copies the parent's whole 8 KiB bank → the child bank, 256 bytes at a time through a WRAM bounce buffer (only one cart-RAM bank is visible at once),
- plants a switch-in frame in the child at
Uafter-8(Uafter= parent SP at entry): the copied return address is already there, so it just zeroes the savedhl/de/bc/afslots — the child resumes at the post-forkPC withA=0, - fills the child PCB (shared ROM text bank, new RAM bank, parent as PPID),
- restores the parent stack and returns the child pid.
Verified: init forks a child (P/C alternate on serial); nested forks yield
three independent processes with distinct pids (123123...).
The context switch (src/hram.asm)
The crux. hSwitchTo(DE=&incomingPCB):
- push full register context onto the outgoing task's stack
- save
SPinto the outgoing PCB (WRAM0, always mapped) - program incoming banks: RAM bank → SVBK → ROM text bank
- load
SPfrom the incoming PCB (its banks are now mapped) - pop context,
retinto the incoming task
Runs from HRAM so changing SVBK/RAM-bank never pulls the rug out from PC or
the stack. New tasks are bootstrapped with a fake frame (ProcSetupStack) so the
first switch-in rets straight to their entry point.
Build
make # -> gbos.gb (RGBDS: rgbasm/rgblink/rgbfix)
make clean
Console output goes to the serial port (tty.asm). Easiest way to watch it
is the project emulator's headless serial console:
~/dev/gbc/build/gbc gbos.gb --headless --uncapped # prints ABABAB...
A real gbos would render to VRAM; serial is the zero-VRAM debug channel.
Bring-up bugs already found & fixed (kept as cautionary tales)
- Stack vs. BSS clear: the kernel stack (
SP=$D000) grows down into$CxFF, so zeroing all of WRAM0 wiped the live return address.ClearKernelRAMnow clears only$C000-$CBFF, leaving the stack region alone. - Syscall ABI vs. dispatch:
SyscallTraporiginally usedDEto index the jump table, clobbering thewrite()buffer pointer passed inDE. Dispatch now indexes viaA/HLonly, preservingDE/Bfor the handler.
Writing programs in C
gbos programs can be written in C, compiled with SDCC (the sm83 port).
This makes porting tiny Unix CLI tools realistic. The toolchain (c/):
c/gbos.h— the userland API (writes,readc,nl,getpid,sexit).c/libc.s— syscall wrappers (asxxxx asm). Each savesBC/DE/HLaroundrst $30, since the trap clobbers them but SDCC expects them preserved.c/crt0.s— entry:call main; thenexit(0). Linked first so_startis the$4000entry.c/build.sh— compiles a.cwith SDCC's native toolchain (sdasgb+sdldgb), links code at$4000, and extracts the ROM-bank blob. The Makefile INCBINs it and registers it as a program.
#include "gbos.h"
void main(void) {
writes("hello from C on gbos!", 21); nl();
writes("my pid is ", 10);
{ char d = getpid() + '0'; writes(&d, 1); } nl();
}
libc
Syscall wrappers (c/libc.s): writes, putc, puts, nl, strlen, readc
(returns EOF=4 at end of input), getargs (this program's argument string),
getpid, sexit. C helpers (c/libc.c, linked into every program): putu
(print decimal), atou (parse decimal), argv_parse (tokenize into argc/argv).
Arguments: the shell splits the command line at the first space and leaves
"cmd\0args\0" at $A000; the child inherits it through fork, getargs()
returns the raw arg string, and argv_parse() tokenizes it:
char *argv[8];
unsigned char argc = argv_parse(argv, 8); /* args one two -> argc=3 */
CLI tools (in c/)
| tool | what it does |
|---|---|
echo |
print its arguments (echo hello world) |
cat |
print a file (cat readme) or copy stdin to stdout |
wc |
count lines/words/chars of a file or stdin (wc -l readme) |
head |
first n lines of a file or stdin (head -n 5 readme) |
ls |
list files |
save |
read a line from stdin into a file (save notes) |
rm |
delete a file (rm notes) |
args |
argc/argv demo (args one two three) |
uname |
print the system name |
pid |
print the process's pid (decimal) |
true / false |
exit 0 / 1 |
chello |
the C "hello" demo |
Options use hasflag/optval in libc: wc -l/-w/-c, head -n N.
$ echo C tools on a Game Boy
C tools on a Game Boy
$ args one two three
argc=3
argv[0]=one
argv[1]=two
argv[2]=three
$ wc (input: "hello world\nfoo\n")
2 3 16
$ head 2 (input: alpha/beta/gamma/delta)
alpha
beta
Each tool is a separate c/<name>.c, built to a ROM-bank blob, INCBIN'd, and
registered in the program table + shell command table (src/programs.asm).
Toolchain gotchas found the hard way: SDCC's --asm=rgbds mode mis-orders
instructions (emits ld [hl],a before the ld hl,sp+0 that sets the pointer) —
so we use SDCC's native asxxxx path and INCBIN the blob instead. String literals
with embedded control chars are also mangled in rgbds mode; C programs pass
explicit lengths and emit newlines via nl().
Processes & jobs
Every process has a pid (1–255, monotonic) and a parent. getpid, wait,
and a full lifecycle (fork/exec/exit/reaping) already existed; on top of
that:
pslists live processes (pid state command); state isReady,Blocked (inwait), orZombie. The PCB records the running program id (PROC_PROG) sopscan show names.kill <pid>terminates a process (sys_kill): marks it a zombie, reparents its children to init, wakes await-blocked parent. pid 1 (init/the shell) is immortal —kill 1is refused.- Background jobs:
cmd &runs without waiting; the shell prints[pid]and reaps finished jobs (non-blockingreap), printing[pid done].
$ spin & # a process that just yields forever
[2]
$ ps
1 B sh
2 R spin
3 R ps
$ kill 2
[2 done]
$ ps
1 B sh
5 R ps
Filesystem
A small block-based, persistent filesystem on battery-backed cart RAM
(src/blk.asm + src/fs.asm). Layout on the 32 KiB "disk" (256-byte blocks):
block 0 superblock (magic + version)
block 1 bitmaps (free blocks + free inodes)
blocks 2-3 inode table (32 inodes x 16 B: type, size, 8 direct block ptrs)
blocks 4.. data blocks
- Inodes + nested directories. A directory is a file of 16-byte entries
(inode# + name), each carrying
./... Files are up to 2 KiB (8 direct blocks); 32 inodes, 16 entries per directory. - Paths + per-process cwd.
open/mkdir/chdir/remove/lstake paths (absolute/a/bor relativea/b); each process has a cwd inode (PROC_CWD, inherited on fork). Shell builtins:cd, and a cwd-aware prompt. - Bounce buffer. Only
read_block/write_blocktouch cart-RAM banking; everything else works on WRAM buffers. The bitmap + inode table are cached in WRAMX; data/dir blocks stream through a one-block cache. - Persistent. Formats on first boot (magic/version check), then survives
power-off via the emulator's
.sav. - Same syscalls as before (
open/getb/putb/list/remove) so the tools (cat/ls/save/rm/wc/head) are unchanged.
$ save notes
gbos has a real filesystem now
$ cat notes
gbos has a real filesystem now
$ mkdir docs
$ cd docs
/docs$ save note
hi
/docs$ cd /
$ cat docs/note
hi
$ ls docs
note
$ wc notes # survives a reboot
1 5 31
Roadmap
- [x]
fork: copy parent's 8 KiB RAM bank → free bank, child returns 0 - [x]
exec: pointPROC_ROMBat a program in a ROM bank, reset stack, enter - [x]
exit/wait/ zombie reaping + cart-RAM bank recycling (free list) - [ ]
execrefinement: copy.datafrom ROM + zero.bssfor RW globals - [x] a real shell program:
fork+exec+waitdriven from the serial console - [x] C toolchain: SDCC (sm83) + libc shim, C programs run as gbos processes
- [x] grow libc (
puts/putc/strlen/getargs/EOF) + args via the shell - [x] CLI tools in C:
echo,cat,uname,pid,true,false - [x] Makefile builds all C programs (a
CBLOBSlist) - [x]
wc,head, and anargc/argvdemo (args) +argv_parsein libc - [x] option parsing (
hasflag/optval):wc -l/-w/-c,head -n N - [x] a RAM filesystem (WRAMX) +
ls/cat/save/rm;wc/headread files - [x] shell in C with redirection (
>/<) and pipes (|, via a temp file) - [x] per-process stdin/stdout routing (
KGetc/KPutc,setin/setout) - [ ] more tools (
rev,grep,tail), true concurrent pipes - [~] persistent filesystem rewrite (block-based, cart SRAM), directories:
- [x] stage 1: block device (
blk.asm) via a WRAM bounce buffer - [x] stage 2: inodes + root directory; variable-size files (<=2 KiB), bitmap allocators; 16 files / 32 inodes; persists across reboots
- [x] stage 3: nested directories, path resolution, per-process cwd,
cd/mkdir/ls <dir>— the whole tree persists across reboots - [ ] Preemptive scheduling: real context save in
TimerISR→hSwitchTo - [ ] Use the
$D000-$DFFFu-area (SVBK) for per-process kernel state / kstack - [ ]
brk/heap allocator inside the task bank (heap up, stack down, collision = ENOMEM) - [ ] A filesystem in remaining cart SRAM/flash (minix/v7-ish), paged 8 KiB at a time
- [ ] Swap whole tasks to cart flash when RAM banks are exhausted (UZI-style)
- [ ] VRAM console + keyboard/joypad
read()```
