aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-07-15 23:44:28 +0200
committeruser <user@clank>2026-07-15 23:44:28 +0200
commitde725da74db5cba0f9bfd0755269e1683423d87b (patch)
treedc39eca9fbd290393ed51de31eee5e4bd6af8375
downloadgbos-de725da74db5cba0f9bfd0755269e1683423d87b.tar.gz
gbos-de725da74db5cba0f9bfd0755269e1683423d87b.tar.xz
gbos-de725da74db5cba0f9bfd0755269e1683423d87b.zip
gbos scaffold: working cooperative microkernel (verified ABAB in emulator)
- fix ClearKernelRAM clobbering the kernel stack (only clear $C000-$CBFF) - fix SyscallTrap clobbering DE (buffer arg) during table dispatch - README: run via ~/dev/gbc --headless; note bring-up bugs
-rw-r--r--.gitignore3
-rw-r--r--Makefile34
-rw-r--r--README.md102
-rw-r--r--include/gbos.inc86
-rw-r--r--src/boot.asm90
-rw-r--r--src/hram.asm142
-rw-r--r--src/kdata.asm22
-rw-r--r--src/proc.asm159
-rw-r--r--src/sched.asm75
-rw-r--r--src/syscall.asm117
-rw-r--r--src/tasks.asm33
-rw-r--r--src/tty.asm15
12 files changed, 878 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7d20801
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+build/
+*.gb
+*.sav
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..23551ad
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,34 @@
+# gbos - Game Boy Color Unix-flavored microkernel (scaffold)
+# Toolchain: RGBDS (rgbasm/rgblink/rgbfix)
+
+ROM := gbos.gb
+BUILD := build
+SRCS := $(wildcard src/*.asm)
+OBJS := $(patsubst src/%.asm,$(BUILD)/%.o,$(SRCS))
+
+ASFLAGS := -I include -Wall
+# MBC5 + RAM + battery; -r 3 = 32 KiB cart RAM (bump for more task banks)
+FIXFLAGS := -v -m MBC5+RAM+BATTERY -r 3 -p 0xFF -t GBOS
+
+.PHONY: all clean run
+
+all: $(ROM)
+
+$(BUILD)/%.o: src/%.asm | $(BUILD)
+ rgbasm $(ASFLAGS) -o $@ $<
+
+$(ROM): $(OBJS)
+ rgblink -m $(BUILD)/gbos.map -n $(BUILD)/gbos.sym -o $@ $(OBJS)
+ rgbfix $(FIXFLAGS) $@
+
+$(BUILD):
+ mkdir -p $(BUILD)
+
+clean:
+ rm -rf $(BUILD) $(ROM)
+
+# convenience: run in an emulator if one is on PATH
+run: $(ROM)
+ @which sameboy >/dev/null 2>&1 && sameboy $(ROM) || \
+ which bgb >/dev/null 2>&1 && bgb $(ROM) || \
+ echo "no emulator found (sameboy/bgb)"
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..bd6c5cf
--- /dev/null
+++ b/README.md
@@ -0,0 +1,102 @@
+# 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 | ✅ marks zombie, reschedules |
+| 3 | write | ✅ `DE`=buf `B`=len → serial console |
+| 8 | getpid | ✅ |
+| 11| yield | ✅ cooperative switch |
+| 1,2,4,5,6,7,9,10 | fork/read/open/close/exec/wait/kill/brk | ⛔ `ENOSYS` |
+
+## 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
+
+- [ ] `fork`: copy parent's 8 KiB RAM bank → free bank, dup u-area, child returns 0
+- [ ] `exec`: point `PROC_ROMB` at a flashed program, reset RAM-bank layout
+- [ ] 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()`
+```
diff --git a/include/gbos.inc b/include/gbos.inc
new file mode 100644
index 0000000..c22344a
--- /dev/null
+++ b/include/gbos.inc
@@ -0,0 +1,86 @@
+; =============================================================================
+; gbos.inc - core constants, memory map, PCB layout, syscall numbers
+; Target: Game Boy Color, MBC5 + RAM + BATTERY
+; =============================================================================
+ IF !DEF(GBOS_INC)
+DEF GBOS_INC EQU 1
+
+; -----------------------------------------------------------------------------
+; Hardware registers we touch (subset; full defs would come from hardware.inc)
+; -----------------------------------------------------------------------------
+DEF rIF EQU $FF0F ; interrupt flags
+DEF rIE EQU $FFFF ; interrupt enable
+DEF rSVBK EQU $FF70 ; CGB WRAM bank select ($D000-$DFFF): 1..7
+DEF rKEY1 EQU $FF4D ; CGB speed switch
+DEF rSB EQU $FF01 ; serial data
+DEF rSC EQU $FF02 ; serial control
+DEF rLCDC EQU $FF40
+DEF rSTAT EQU $FF41
+DEF rLY EQU $FF44
+
+; -----------------------------------------------------------------------------
+; MBC5 mapper register addresses (writes to ROM space hit the mapper)
+; -----------------------------------------------------------------------------
+DEF MBC5_RAM_ENABLE EQU $0000 ; write $0A to enable cart RAM
+DEF MBC5_ROMB_LO EQU $2000 ; ROM bank bits 0..7
+DEF MBC5_ROMB_HI EQU $3000 ; ROM bank bit 8
+DEF MBC5_RAMB EQU $4000 ; RAM bank bits 0..3 ($A000-$BFFF)
+
+; -----------------------------------------------------------------------------
+; Memory map (the gbos plan)
+; $0000-$3FFF ROM0 kernel core (fixed, always mapped)
+; $4000-$7FFF ROMX current task TEXT (read-only program code)
+; $8000-$9FFF VRAM graphics
+; $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)
+; $FF80-$FFFE HRAM bank-switch trampoline + context switch
+; -----------------------------------------------------------------------------
+DEF TASK_RAM_BASE EQU $A000
+DEF TASK_RAM_TOP EQU $C000 ; one past end of the 8 KiB window
+DEF TASK_STACK_TOP EQU $BFFF ; task stacks grow down from here
+
+DEF KSTACK_TOP EQU $D000 ; kernel stack top (grows down into WRAM0)
+
+; -----------------------------------------------------------------------------
+; Process table
+; -----------------------------------------------------------------------------
+DEF MAX_PROCS EQU 8
+
+; process states
+DEF PS_FREE EQU 0
+DEF PS_READY EQU 1
+DEF PS_RUN EQU 2
+DEF PS_BLOCKED EQU 3
+DEF PS_ZOMBIE EQU 4
+
+; PCB (process control block) field offsets
+RSRESET
+DEF PROC_STATE RB 1 ; PS_*
+DEF PROC_PID RB 1
+DEF PROC_SP RW 1 ; saved stack pointer (into task's RAM bank)
+DEF PROC_ROMB RW 1 ; text ROM bank (16-bit; MBC5 up to 511)
+DEF PROC_RAMB RB 1 ; data/stack cart-RAM bank
+DEF PROC_WRAMB RB 1 ; u-area SVBK bank (1..7)
+DEF PROC_PARENT RB 1
+DEF PROC_EXIT RB 1 ; exit code (valid when PS_ZOMBIE)
+DEF PROC_SIZE RB 0
+
+; -----------------------------------------------------------------------------
+; Syscall numbers (passed in C; args in DE/HL/B per call, ret in A)
+; -----------------------------------------------------------------------------
+DEF SYS_EXIT EQU 0
+DEF SYS_FORK EQU 1
+DEF SYS_READ EQU 2
+DEF SYS_WRITE EQU 3
+DEF SYS_OPEN EQU 4
+DEF SYS_CLOSE EQU 5
+DEF SYS_EXEC EQU 6
+DEF SYS_WAIT EQU 7
+DEF SYS_GETPID EQU 8
+DEF SYS_KILL EQU 9
+DEF SYS_BRK EQU 10
+DEF SYS_YIELD EQU 11
+DEF SYS_MAX EQU 12
+
+ ENDC
diff --git a/src/boot.asm b/src/boot.asm
new file mode 100644
index 0000000..513c392
--- /dev/null
+++ b/src/boot.asm
@@ -0,0 +1,90 @@
+; =============================================================================
+; boot.asm - cartridge header, reset vectors, kernel entry & init
+; =============================================================================
+INCLUDE "include/gbos.inc"
+
+; -----------------------------------------------------------------------------
+; RST vectors. We reserve rst $30 as the syscall trap.
+; -----------------------------------------------------------------------------
+SECTION "rst00", ROM0[$00]
+ ret
+SECTION "rst30_syscall", ROM0[$30]
+ jp SyscallTrap ; user code does: ld c, SYS_x ; rst $30
+
+; -----------------------------------------------------------------------------
+; Interrupt vectors
+; -----------------------------------------------------------------------------
+SECTION "vblank", ROM0[$40]
+ reti
+SECTION "stat", ROM0[$48]
+ reti
+SECTION "timer", ROM0[$50]
+ jp TimerISR ; drives preemptive scheduling
+SECTION "serial", ROM0[$58]
+ reti
+SECTION "joypad", ROM0[$60]
+ reti
+
+; -----------------------------------------------------------------------------
+; Entry point. rgbfix writes the Nintendo logo + header over $104-$14F.
+; -----------------------------------------------------------------------------
+SECTION "entry", ROM0[$100]
+ nop
+ jp KernelInit
+ ds $150 - @ ; reserve $104-$14F for logo + header (rgbfix fills)
+
+; -----------------------------------------------------------------------------
+; Kernel init
+; -----------------------------------------------------------------------------
+SECTION "kinit", ROM0
+
+KernelInit:
+ di
+ ld sp, KSTACK_TOP
+
+ ; enable cart RAM (MBC5)
+ ld a, $0A
+ ld [MBC5_RAM_ENABLE], a
+
+ call ClearKernelRAM
+ call CopyHramCode ; move context-switch trampoline into HRAM
+ call ProcInit ; wipe process table
+
+ ; --- spawn the two demo tasks (text-in-ROM model) ---
+ ; task A: entry TaskA, RAM bank 0, u-area WRAM bank 1
+ ld hl, TaskA
+ ld b, 0 ; RAM bank
+ ld c, 1 ; WRAM u-area bank
+ call ProcCreate
+
+ ld hl, TaskB
+ ld b, 1
+ ld c, 2
+ call ProcCreate
+
+ call SchedInit ; pick first task, set wCurProc
+
+ ; interrupts: enable timer for preemption
+ xor a
+ ld [rIF], a
+ ; ld a, IEF_TIMER ; (timer bit) - leave masked for cooperative demo
+ ; ld [rIE], a
+
+ ; hand control to the first task via a context switch-in
+ jp SchedRunFirst
+
+; -----------------------------------------------------------------------------
+ClearKernelRAM:
+ ; zero the kernel globals region only ($C000-$CBFF). We must NOT touch
+ ; $CC00-$CFFF: the kernel stack (SP=$D000) grows down into it, and our
+ ; own return address lives there while this routine runs.
+ ld hl, $C000
+ ld bc, $0C00
+.loop
+ xor a
+ ld [hl+], a
+ dec bc
+ ld a, b
+ or c
+ jr nz, .loop
+ ret
diff --git a/src/hram.asm b/src/hram.asm
new file mode 100644
index 0000000..7c1910c
--- /dev/null
+++ b/src/hram.asm
@@ -0,0 +1,142 @@
+; =============================================================================
+; hram.asm - the trampoline.
+;
+; The context switch writes SVBK ($D000-$DFFF) and the MBC5 RAM bank
+; ($A000-$BFFF). Because a task's stack lives in the RAM window, the code
+; performing the swap MUST NOT execute from any bank it is about to change and
+; MUST NOT touch the stack across the swap. HRAM is never banked, so the
+; switch lives here. We assemble it with HRAM addresses (LOAD) but store the
+; bytes in ROM0 and copy them up at boot.
+;
+; Convention: to compute &(PCB + OFF) we do
+; ld a,e : add OFF : ld l,a : ld a,d : adc 0 : ld h,a ; HL = DE + OFF
+; =============================================================================
+INCLUDE "include/gbos.inc"
+
+SECTION "hram_vars", HRAM
+hCurBankRAM: DS 1 ; shadow of active cart-RAM bank
+hCurBankWRAM: DS 1 ; shadow of active SVBK bank
+hSwitchTgt: DS 2 ; &incoming PCB (set before the pushes clobber DE)
+
+; -----------------------------------------------------------------------------
+; Source image (lives in ROM0); CopyHramCode blits it to HRAM at boot.
+; -----------------------------------------------------------------------------
+SECTION "hram_src", ROM0
+
+HramSrc:
+LOAD "hram_code", HRAM
+; ---------------------------------------------------------------------------
+; hSwitchTo: switch from the current task (wCurProc) to the PCB in DE.
+; in: DE = &incoming PCB
+; Runs from HRAM. Saves/restores full context. Clobbers everything.
+; ---------------------------------------------------------------------------
+hSwitchTo::
+ ; stash target while DE is still valid
+ ld a, e
+ ld [hSwitchTgt], a
+ ld a, d
+ ld [hSwitchTgt+1], a
+
+ ; --- save outgoing context on its (currently-mapped) stack ---
+ push af
+ push bc
+ push de
+ push hl
+
+ ; SP -> outgoing PCB.PROC_SP (outgoing = wCurProc, in WRAM0, always mapped)
+ ld hl, sp+0 ; HL = SP
+ ld b, h
+ ld c, l ; BC = SP value
+ ld a, [wCurProc]
+ add PROC_SP
+ ld l, a
+ ld a, [wCurProc+1]
+ adc 0
+ ld h, a ; HL = &outgoing.PROC_SP
+ ld a, c
+ ld [hl+], a
+ ld a, b
+ ld [hl], a
+
+ ; --- incoming becomes current ---
+ ld a, [hSwitchTgt]
+ ld e, a
+ ld [wCurProc], a
+ ld a, [hSwitchTgt+1]
+ ld d, a ; DE = &incoming PCB
+ ld [wCurProc+1], a
+
+ ; --- program banks for incoming task (order: RAM, WRAM, ROM) ---
+ ; cart-RAM bank
+ ld a, e
+ add PROC_RAMB
+ ld l, a
+ ld a, d
+ adc 0
+ ld h, a
+ ld a, [hl]
+ ld [hCurBankRAM], a
+ ld [MBC5_RAMB], a
+ ; u-area WRAM bank via SVBK
+ ld a, e
+ add PROC_WRAMB
+ ld l, a
+ ld a, d
+ adc 0
+ ld h, a
+ ld a, [hl]
+ ld [hCurBankWRAM], a
+ ld [rSVBK], a
+ ; text ROM bank (16-bit)
+ ld a, e
+ add PROC_ROMB
+ ld l, a
+ ld a, d
+ adc 0
+ ld h, a
+ ld a, [hl+]
+ ld [MBC5_ROMB_LO], a
+ ld a, [hl]
+ ld [MBC5_ROMB_HI], a
+
+ ; --- restore incoming SP (its banks are now mapped) ---
+ ld a, e
+ add PROC_SP
+ ld l, a
+ ld a, d
+ adc 0
+ ld h, a
+ ld a, [hl+]
+ ld c, a
+ ld a, [hl]
+ ld h, a
+ ld l, c
+ ld sp, hl
+
+ ; --- pop incoming context and resume it ---
+ pop hl
+ pop de
+ pop bc
+ pop af
+ ret
+ENDL
+HramSrcEnd:
+
+; -----------------------------------------------------------------------------
+; CopyHramCode - blit the LOAD'd image up to HRAM. Runs once at boot.
+; -----------------------------------------------------------------------------
+SECTION "hram_copy", ROM0
+
+CopyHramCode::
+ ld hl, HramSrc
+ ld de, hSwitchTo
+ ld bc, HramSrcEnd - HramSrc
+.loop
+ ld a, [hl+]
+ ld [de], a
+ inc de
+ dec bc
+ ld a, b
+ or c
+ jr nz, .loop
+ ret
diff --git a/src/kdata.asm b/src/kdata.asm
new file mode 100644
index 0000000..efc780a
--- /dev/null
+++ b/src/kdata.asm
@@ -0,0 +1,22 @@
+; =============================================================================
+; kdata.asm - kernel globals in fixed WRAM0 (always mapped, never swapped)
+; =============================================================================
+INCLUDE "include/gbos.inc"
+
+SECTION "kernel_bss", WRAM0[$C000]
+
+wProcTable:: DS MAX_PROCS * PROC_SIZE ; the process table
+wCurProc:: DS 2 ; pointer to running PCB
+wCurPid:: DS 1 ; running pid (reserved)
+wNextPid:: DS 1 ; pid allocator
+wSchedNext:: DS 1 ; round-robin cursor (slot index)
+wKernelPCB:: DS PROC_SIZE ; outgoing-context holder for 1st switch
+ ; (kernel/idle context; never resumed)
+
+; scratch used by ProcCreate
+wTmpEntry:: DS 2
+wTmpRamB:: DS 1
+wTmpWramB:: DS 1
+wTmpSlot:: DS 1
+wTmpPid:: DS 1
+wTmpSP:: DS 2
diff --git a/src/proc.asm b/src/proc.asm
new file mode 100644
index 0000000..762b30e
--- /dev/null
+++ b/src/proc.asm
@@ -0,0 +1,159 @@
+; =============================================================================
+; proc.asm - process table management, task creation, fork/exec (stubs)
+; =============================================================================
+INCLUDE "include/gbos.inc"
+
+SECTION "proc", ROM0
+
+; -----------------------------------------------------------------------------
+; ProcInit - table already zeroed by ClearKernelRAM (PS_FREE=0). Seed pids.
+; -----------------------------------------------------------------------------
+ProcInit::
+ ld a, 1
+ ld [wNextPid], a
+ ret
+
+; -----------------------------------------------------------------------------
+; PcbPtr - slot -> PCB pointer. in: A=slot out: DE=&PCB. preserves BC.
+; -----------------------------------------------------------------------------
+PcbPtr::
+ push bc
+ ld hl, wProcTable
+ ld bc, PROC_SIZE
+ and a
+ jr z, .done
+.mul
+ add hl, bc
+ dec a
+ jr nz, .mul
+.done
+ ld d, h
+ ld e, l
+ pop bc
+ ret
+
+; -----------------------------------------------------------------------------
+; ProcCreate - allocate a PCB and build an initial (runnable) task.
+; in: HL = task entry, B = cart-RAM bank, C = SVBK u-area bank
+; out: A = pid, or 0 if the table is full
+; -----------------------------------------------------------------------------
+ProcCreate::
+ ; stash params
+ ld a, l
+ ld [wTmpEntry], a
+ ld a, h
+ ld [wTmpEntry+1], a
+ ld a, b
+ ld [wTmpRamB], a
+ ld a, c
+ ld [wTmpWramB], a
+
+ ; find a free slot
+ ld c, 0
+.find
+ ld a, c
+ call PcbPtr ; DE = &PCB[c]
+ ld a, [de]
+ cp PS_FREE
+ jr z, .found
+ inc c
+ ld a, c
+ cp MAX_PROCS
+ jr c, .find
+ xor a ; table full
+ ret
+
+.found
+ ld a, c
+ ld [wTmpSlot], a
+
+ ; fill fields; HL walks the PCB
+ ld h, d
+ ld l, e
+ ld a, PS_READY
+ ld [hl+], a ; PROC_STATE
+ ld a, [wNextPid]
+ ld [hl+], a ; PROC_PID
+ ld [wTmpPid], a
+ inc a
+ ld [wNextPid], a
+ inc hl ; skip PROC_SP (filled below)
+ inc hl
+ ld a, 1
+ ld [hl+], a ; PROC_ROMB lo (placeholder text bank)
+ xor a
+ ld [hl+], a ; PROC_ROMB hi
+ ld a, [wTmpRamB]
+ ld [hl+], a ; PROC_RAMB
+ ld a, [wTmpWramB]
+ ld [hl+], a ; PROC_WRAMB
+ xor a
+ ld [hl+], a ; PROC_PARENT
+ ld [hl], a ; PROC_EXIT
+
+ ; build the initial stack frame in the task's RAM bank
+ ld a, [wTmpEntry]
+ ld l, a
+ ld a, [wTmpEntry+1]
+ ld h, a ; HL = entry
+ ld a, [wTmpRamB]
+ ld b, a ; B = RAM bank
+ call ProcSetupStack ; out: HL = initial SP
+ ld a, l
+ ld [wTmpSP], a
+ ld a, h
+ ld [wTmpSP+1], a
+
+ ; store SP into PCB.PROC_SP
+ ld a, [wTmpSlot]
+ call PcbPtr ; DE = &PCB
+ ld a, e
+ add PROC_SP
+ ld l, a
+ ld a, d
+ adc 0
+ ld h, a ; HL = &PROC_SP
+ ld a, [wTmpSP]
+ ld [hl+], a
+ ld a, [wTmpSP+1]
+ ld [hl], a
+
+ ld a, [wTmpPid]
+ ret
+
+; -----------------------------------------------------------------------------
+; ProcSetupStack - lay a fresh switch-in frame at the top of a task RAM bank.
+; in: HL = entry, B = cart-RAM bank
+; out: HL = initial SP (points at the saved-HL slot)
+; The frame matches hSwitchTo's restore sequence:
+; [SP+0..1]=hl [+2..3]=de [+4..5]=bc [+6]=f [+7]=a [+8..9]=entry (ret target)
+; -----------------------------------------------------------------------------
+ProcSetupStack::
+ ld a, b
+ ld [MBC5_RAMB], a ; map task's RAM bank into $A000-$BFFF
+ push hl ; save entry
+ ld hl, TASK_STACK_TOP - 9 ; frame base = $BFF6
+ xor a
+ ld [hl+], a ; hl_lo
+ ld [hl+], a ; hl_hi
+ ld [hl+], a ; de_lo
+ ld [hl+], a ; de_hi
+ ld [hl+], a ; bc_lo
+ ld [hl+], a ; bc_hi
+ ld [hl+], a ; f
+ ld [hl+], a ; a
+ pop de ; DE = entry
+ ld a, e
+ ld [hl+], a ; entry lo (ret target)
+ ld a, d
+ ld [hl], a ; entry hi
+ ld hl, TASK_STACK_TOP - 9 ; initial SP
+ ret
+
+; -----------------------------------------------------------------------------
+; sys_fork / sys_exec - TODO. Plan:
+; fork: alloc slot, copy parent's 8 KiB RAM bank -> child bank, dup u-area,
+; patch child's saved-A to 0 (child return), parent gets child pid.
+; exec: point PROC_ROMB at a flashed program image, reset RAM-bank layout
+; (copy .data from ROM, zero .bss, fresh heap/stack), longjmp in.
+; -----------------------------------------------------------------------------
diff --git a/src/sched.asm b/src/sched.asm
new file mode 100644
index 0000000..c871f7a
--- /dev/null
+++ b/src/sched.asm
@@ -0,0 +1,75 @@
+; =============================================================================
+; sched.asm - round-robin scheduler + yield + timer stub
+; =============================================================================
+INCLUDE "include/gbos.inc"
+
+SECTION "sched", ROM0
+
+; -----------------------------------------------------------------------------
+; SchedInit - prime the round-robin cursor so the first pick lands on slot 0.
+; -----------------------------------------------------------------------------
+SchedInit::
+ ld a, MAX_PROCS - 1
+ ld [wSchedNext], a
+ ret
+
+; -----------------------------------------------------------------------------
+; SchedRunFirst - hand control to the first runnable task. Never returns; the
+; kernel-init context is saved into wKernelPCB and simply abandoned.
+; -----------------------------------------------------------------------------
+SchedRunFirst::
+ ld hl, wKernelPCB
+ ld a, l
+ ld [wCurProc], a
+ ld a, h
+ ld [wCurProc+1], a
+ call FindNextReady ; DE = &first ready PCB
+ jp hSwitchTo
+
+; -----------------------------------------------------------------------------
+; SchedYield - cooperative switch to the next runnable task. Returns to the
+; caller when this task is eventually rescheduled.
+; -----------------------------------------------------------------------------
+SchedYield::
+ call FindNextReady ; DE = &next PCB
+ call hSwitchTo
+ ret
+
+; -----------------------------------------------------------------------------
+; FindNextReady - round-robin from wSchedNext. out: DE = &PCB (updates cursor)
+; Scaffold: assumes at least one PS_READY task exists.
+; -----------------------------------------------------------------------------
+FindNextReady::
+ ld a, [wSchedNext]
+ ld b, MAX_PROCS
+.scan
+ inc a
+ cp MAX_PROCS
+ jr c, .nowrap
+ xor a
+.nowrap
+ push af ; save candidate slot
+ call PcbPtr ; DE = &PCB[slot] (preserves BC)
+ ld a, [de] ; state
+ cp PS_READY
+ jr z, .found
+ pop af ; restore candidate slot
+ dec b
+ jr nz, .scan
+ ; nothing ready: fall back to slot 0 (idle behavior)
+ xor a
+ call PcbPtr
+ ret
+.found
+ pop af ; A = chosen slot
+ ld [wSchedNext], a
+ call PcbPtr ; DE = &PCB
+ ret
+
+; -----------------------------------------------------------------------------
+; TimerISR - preemption hook (TODO). A real preemptive switch must save the
+; interrupted context and call hSwitchTo from here. For now the demo is
+; cooperative (tasks call SYS_YIELD), so we just acknowledge and return.
+; -----------------------------------------------------------------------------
+TimerISR::
+ reti
diff --git a/src/syscall.asm b/src/syscall.asm
new file mode 100644
index 0000000..71e1f2b
--- /dev/null
+++ b/src/syscall.asm
@@ -0,0 +1,117 @@
+; =============================================================================
+; syscall.asm - the trap entry, dispatch table, and handlers.
+;
+; ABI: user loads C = syscall number, args in DE/B/HL, then `rst $30`.
+; return value in A. ($30 vector jp's here - see boot.asm)
+; =============================================================================
+INCLUDE "include/gbos.inc"
+
+SECTION "syscall", ROM0
+
+; -----------------------------------------------------------------------------
+SyscallTrap::
+ ld a, c
+ cp SYS_MAX
+ jr nc, .bad
+ ; index the table WITHOUT touching DE/B (those are syscall args).
+ ld hl, SyscallTable
+ ld a, c
+ add a ; A = C*2 (word entries; C < SYS_MAX)
+ add l
+ ld l, a
+ jr nc, .nocarry
+ inc h
+.nocarry
+ ld a, [hl+]
+ ld h, [hl]
+ ld l, a
+ jp hl ; tail-call; handler's `ret` returns to user
+.bad
+ ld a, $FF ; ENOSYS-ish
+ ret
+
+; -----------------------------------------------------------------------------
+; Dispatch table (indexed by syscall number)
+; -----------------------------------------------------------------------------
+SyscallTable:
+ dw sys_exit ; 0
+ dw sys_nosys ; 1 FORK (TODO)
+ dw sys_nosys ; 2 READ (TODO)
+ dw sys_write ; 3 WRITE
+ dw sys_nosys ; 4 OPEN (TODO)
+ dw sys_nosys ; 5 CLOSE (TODO)
+ dw sys_nosys ; 6 EXEC (TODO)
+ dw sys_nosys ; 7 WAIT (TODO)
+ dw sys_getpid ; 8 GETPID
+ dw sys_nosys ; 9 KILL (TODO)
+ dw sys_nosys ; 10 BRK (TODO)
+ dw sys_yield ; 11 YIELD
+
+; -----------------------------------------------------------------------------
+sys_nosys:
+ ld a, $FF
+ ret
+
+; -----------------------------------------------------------------------------
+; sys_write(fd=B, buf=DE, len=B?) -- scaffold: fd ignored, DE=buf, B=len.
+; Writes to the serial console. Returns A = bytes written.
+; -----------------------------------------------------------------------------
+sys_write:
+ ld a, b
+ or a
+ ret z ; len 0
+ ld c, b ; C = remaining
+.loop
+ ld a, [de]
+ call PutChar
+ inc de
+ dec c
+ jr nz, .loop
+ ld a, b ; return len
+ ret
+
+; -----------------------------------------------------------------------------
+; sys_getpid() -> A = current pid
+; -----------------------------------------------------------------------------
+sys_getpid:
+ ld a, [wCurProc]
+ add PROC_PID
+ ld l, a
+ ld a, [wCurProc+1]
+ adc 0
+ ld h, a
+ ld a, [hl]
+ ret
+
+; -----------------------------------------------------------------------------
+; sys_yield()
+; -----------------------------------------------------------------------------
+sys_yield:
+ call SchedYield
+ ret
+
+; -----------------------------------------------------------------------------
+; sys_exit(code=B) -- mark zombie and schedule away forever.
+; -----------------------------------------------------------------------------
+sys_exit:
+ ; mark current PCB as zombie, store exit code
+ ld a, [wCurProc]
+ ld l, a
+ ld a, [wCurProc+1]
+ ld h, a ; HL = &PCB
+ ld a, PS_ZOMBIE
+ ld [hl], a ; PROC_STATE
+ ; PROC_EXIT is at offset PROC_EXIT
+ ld a, [wCurProc]
+ add PROC_EXIT
+ ld l, a
+ ld a, [wCurProc+1]
+ adc 0
+ ld h, a
+ ld a, b
+ ld [hl], a ; exit code
+ ; never return to this task
+ call SchedYield
+ ; if we somehow come back (no other ready task), spin
+.halt
+ jr .halt
diff --git a/src/tasks.asm b/src/tasks.asm
new file mode 100644
index 0000000..1f84142
--- /dev/null
+++ b/src/tasks.asm
@@ -0,0 +1,33 @@
+; =============================================================================
+; tasks.asm - two demo userland tasks (text-in-ROM model).
+; Each loops: write its letter to the console, then yield. This exercises the
+; whole pipeline: syscall trap, console, and the HRAM context switch swapping
+; RAM banks / SVBK between tasks.
+; =============================================================================
+INCLUDE "include/gbos.inc"
+
+SECTION "tasks", ROM0
+
+TaskA::
+.loop
+ ld de, .msg
+ ld b, 1 ; len
+ ld c, SYS_WRITE
+ rst $30
+ ld c, SYS_YIELD
+ rst $30
+ jr .loop
+.msg
+ db "A"
+
+TaskB::
+.loop
+ ld de, .msg
+ ld b, 1
+ ld c, SYS_WRITE
+ rst $30
+ ld c, SYS_YIELD
+ rst $30
+ jr .loop
+.msg
+ db "B"
diff --git a/src/tty.asm b/src/tty.asm
new file mode 100644
index 0000000..d2c1234
--- /dev/null
+++ b/src/tty.asm
@@ -0,0 +1,15 @@
+; =============================================================================
+; tty.asm - console output over the serial port (emulator debug channel).
+; A real gbos would render to VRAM; serial is a zero-VRAM stub that shows up
+; in emulator serial logs (bgb/SameBoy) and on link hardware.
+; =============================================================================
+INCLUDE "include/gbos.inc"
+
+SECTION "tty", ROM0
+
+; PutChar - A = character. Push to serial data and kick a transfer.
+PutChar::
+ ld [rSB], a
+ ld a, $81 ; start transfer, internal clock
+ ld [rSC], a
+ ret