aboutsummaryrefslogtreecommitdiffstats
ModeNameSize
-rw-r--r--.gitignore18logstatsplainblame
-rw-r--r--Makefile867logstatsplainblame
-rw-r--r--README.md8446logstatsplainblame
d---------include36logstatsplain
d---------src333logstatsplain

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 (PROG_SH)

init execs a tiny shell: prompt → read a line (with echo) → match a command → fork+exec+wait. Commands live in a table (worker, hello). Runs entirely over the headless serial console:

$ hello
hello world!
$ worker
worker!
$ foo
?
$

Process lifecycle (exit / wait / reaping)

The classic Unix zombie/reap dance, adapted to banked memory:

  • exit(code) sets PS_ZOMBIE + status, reparents any children to init (pid 1), wakes the parent if it's blocked in wait(), then schedules away forever. Its resources are freed by the reaper, not here.
  • wait() scans for a PS_ZOMBIE child. Found → reap: free its cart-RAM bank (back to the allocator) and its PCB slot, return pid + code. Children exist but none dead → set self PS_BLOCKED and yield, retry on wake. No children → return $FF (ECHILD).
  • The scheduler skips non-READY procs; FindNextReady returns carry when nothing is runnable so yield just keeps the caller running (idle).
  • Cart-RAM banks are a free list (wBankUsed bitmap): AllocRamBank / FreeRamBank. Verified by running 6 workers through only 3 child banks (w2w3w4w5w6w7) — each reaped bank is recycled by the next fork.

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):

  1. looks up ProgramTable[B] = (ROM bank, entry),
  2. sets PROC_ROMB and maps the bank live into $4000-$7FFF,
  3. resets the stack to the top of the task's (already mapped) cart-RAM bank,
  4. 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:

  1. allocates a free PCB slot + a fresh cart-RAM bank (bump allocator),
  2. 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),
  3. 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 saved hl/de/bc/af slots — the child resumes at the post-fork PC with A=0,
  4. fills the child PCB (shared ROM text bank, new RAM bank, parent as PPID),
  5. 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):

  1. push full register context onto the outgoing task's stack
  2. save SP into the outgoing PCB (WRAM0, always mapped)
  3. program incoming banks: RAM bank → SVBK → ROM text bank
  4. load SP from the incoming PCB (its banks are now mapped)
  5. pop context, ret into 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. ClearKernelRAM now clears only $C000-$CBFF, leaving the stack region alone.
  • Syscall ABI vs. dispatch: SyscallTrap originally used DE to index the jump table, clobbering the write() buffer pointer passed in DE. Dispatch now indexes via A/HL only, preserving DE/B for the handler.

Roadmap

  • [x] fork: copy parent's 8 KiB RAM bank → free bank, child returns 0
  • [x] exec: point PROC_ROMB at a program in a ROM bank, reset stack, enter
  • [x] exit / wait / zombie reaping + cart-RAM bank recycling (free list)
  • [ ] exec refinement: copy .data from ROM + zero .bss for RW globals
  • [ ] a real shell program: fork+exec+wait driven from the serial console
  • [ ] Preemptive scheduling: real context save in TimerISRhSwitchTo
  • [ ] Use the $D000-$DFFF u-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() ```