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
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
|
# 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 `exec`s 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 `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.
## 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 saves
`BC/DE/HL` around `rst $30`, since the trap clobbers them but SDCC expects
them preserved.
- `c/crt0.s` — entry: `call main`; then `exit(0)`. Linked first so `_start` is
the `$4000` entry.
- `c/build.sh` — compiles a `.c` with 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.
```c
#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
`writes`, `putc`, `puts`, `nl`, `strlen`, `readc` (returns `EOF`=4 at end of
input), `getargs` (this program's argument string), `getpid`, `sexit`.
Arguments: the shell splits the command line at the first space and leaves
`"cmd\0args\0"` at `$A000`; the child inherits it through `fork`, and
`getargs()` returns the args.
### CLI tools (in `c/`)
| tool | what it does |
|------|--------------|
| `echo` | print its arguments (`echo hello world`) |
| `cat` | copy stdin to stdout until EOF (Ctrl-D / pipe end) |
| `uname`| print the system name |
| `pid` | print the process's pid (decimal) |
| `true` / `false` | exit 0 / 1 |
| `chello` | the C "hello" demo |
```
$ uname
gbos sm83 (Game Boy Color)
$ echo C tools on a Game Boy
C tools on a Game Boy
$ pid
5
$ cat
hi from cat
hi from cat
```
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()`.
## 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
- [x] a real shell program: `fork`+`exec`+`wait` driven 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 `CBLOBS` list)
- [ ] more tools (`wc`, `head`, `rev`), a real `argc/argv`, a RAM filesystem
- [ ] 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()`
```
|