1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
|
# 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`, then `rst $30`. Return in `A`.
| # | 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,4,5,9,10 | read/open/close/kill/brk | ⛔ `ENOSYS` |
### 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 `exec`s PROG_WORKER
(prints `w`, exits with its pid), init `wait`s 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. `jp`s 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 `exec`s
`PROG_PING`, the child `exec`s `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 `ret`s straight to their entry point.
## Build
```sh
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:
```sh
~/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 `TimerISR` → `hSwitchTo`
- [ ] 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()`
```
|