An architecture overview for new contributors.
This document describes the handle-based object/capability API that Object
RISC's libc exposes to C programs: tools/cc/lib/obj.h
and tools/cc/lib/obj.c. It is the layer a C program
uses to allocate objects, derive sub-capabilities, attach receive queues, and
SEND/receive messages without ever holding a capability as a C value —
because the v1 compiler can't.
It is a current-implementation document (the active Phase-4 migration surface),
not part of the formal architecture spec. The firmware capability model —
references, descriptors, capability bits, the object table — is
docs/OBJECT_SYSTEM.md (Volume III); the firmware
primitives this API wraps are in
docs/SYSTEM_FIRMWARE_INTERFACE.md (Volume VI).
This guide covers only the thin libc layer on top, and how to migrate a client
onto it. Every non-obvious claim cites file:line.
Object RISC keeps capabilities in 16 dedicated object registers O0..O15; a
capability may never be byte-spilled to general memory (that would defeat the
capability invariant — Volume III §5). The v1 pcc backend has no way to keep an
__or capability value live in an object register across a function call:
it would have to spill it, which the architecture forbids
(obj.h:1-12,
tools/cc/arch/orisc/OREG_MIGRATION_PLAN.md).
So instead of returning capabilities to C, this API keeps them in a small
per-task table that libc owns, and hands the program an opaque integer handle
obj_t (obj.h:25-28) — exactly the
file-descriptor pattern host_io.c already uses for
hostfsd fds. Capabilities never appear as C values, so handle-based code compiles
and runs on the current toolchain today.
This is the substitute that shipped while the
__or-VALUE object API (which would let C hold capability values directly) stays blocked behind the OR-spill work. Treatobj_tas "a file descriptor for a capability."
A handle is just an index 0..OBJ_NHANDLE-1 into the table; OBJ_NULL (-1) is
the error / "no handle" sentinel. Every handle-returning call yields OBJ_NULL on
failure; every status-returning call yields <0 on error
(obj.h:14-19).
The table lives at byte offset OBJ_TABLE_OFFSET = 1704 of the O12
task-table OBJSTORE — the OR-typed per-task store libc parks in O12 at
task_init (see wm-terminal-overview.md §3, boot-OR
contract). It holds OBJ_NHANDLE = 8 capability slots, 8 bytes each
(obj.h:28,35):
O12 OBJSTORE (OR-typed storage; OREFLD/OREFST only)
┌─ … libc slots (DIR_RESULT@616, WM_SLOT@680, …) …
├─ 1696 compiler OR-spill anchor (OR_SPILL_ANCHOR_OFFSET, task.c:262)
├─ 1704 handle 0 ┐
├─ 1712 handle 1 │
├─ 1720 handle 2 │ 8 capability slots, 8 bytes each
├─ … │ obj_inuse bitmask (obj.c:25) tracks which are live
└─ 1760 handle 7 ┘
Properties worth knowing:
- It is reserved by oversizing, not allocated separately.
task.cbumpsORX_STATE_BYTESso the O12 allocation extends past the compiler's OR-spill anchor (1696) to cover theOBJ_NHANDLE*8 = 64table bytes (task.c:251-262). - The per-slot offsets are hard-coded because
OREFLD/OREFSTtake only an immediate offset — so the slot↔O-register moves areswitch (h)ladders over the compile-time offsets (obj.c:33-91), one ladder per target register (O1,O2,O3). Atypedef-based static assertion (obj__off_check,obj.c:22) catches base drift at compile time ifOBJ_TABLE_OFFSETever moves. - An in-use bitmask gates every access.
obj_inuse(obj.c:25) marks live handles; every API call rejects a handle whose bit is clear, so a stale ref left in a freed slot is unreachable. obj_initis idempotent. It returns-1ifO12is null (i.e.task_inithasn't run), and otherwise zeroesobj_inuseonly on the first call, so a second migrated subsystem callingobj_initdoesn't clobber live handles (obj.c:106-120).
The orx.c idiom recurs throughout: a firmware call leaves the fresh reference
in O1, and obj__store_o1 OREFSTs it into the handle's slot immediately,
before anything can clobber O1 (e.g. obj_alloc, obj.c:147).
Lifecycle: call task_init() (sets up O12), then obj_init() once, then the
rest. Grouped by purpose (obj.h):
| Function | Wraps | Notes |
|---|---|---|
obj_init() |
— | one-time; after task_init (obj.c:106) |
obj_alloc(len,tag,caps) |
#0x100 ObjAlloc |
byte object → handle (obj.c:124) |
obj_alloc_store(len,tag,caps) |
#0x106 ObjAllocStore |
OR-typed storage; len % 8 == 0 (obj.c:152) |
obj_derive(src,caps) |
#0x103 ObjDerive |
sub-cap → new handle (obj.c:178) |
obj_free(h) |
#0x101 ObjFree |
frees object (needs V) + releases handle (obj.c:205) |
obj_drop(h) |
— | release a derived/borrowed handle without freeing its object (obj.c:225) |
obj_adopt_dir_result() |
— | adopt the cap dir_walk left in DIR_RESULT@616 (obj.c:241) |
obj_adopt_o6() |
— | adopt boot O6 (the keyboard service) (obj.c:263) |
obj_isnull/eq/len/tag/caps(h) |
oisn/oeq/olen/otag/ocap |
inspection, no memory access (obj.c:287-346) |
obj_loadw/storew(h[,v]) |
olw/osw |
word at offset 0 (needs R/W) (obj.c:350-365) |
obj_queue_attach(h,depth) |
#0x203 ReceiveQueueAttach |
make a mailbox (obj.c:369) |
obj_send(h,a0..a3) |
SEND |
int payload only, null OR payload (obj.c:389) |
obj_send_or(h,or_h,a0..a3) |
SEND |
O2 = or_h's cap (or null) — the subscribe wrapper (obj.c:409) |
obj_send_bytes(svc,src,reply,a0..a3) |
SEND |
the data-send keystone (below) (obj.c:433) |
obj_recv(h) |
#0x204 ReceiveQueuePoll |
block; returns the R3 word only (obj.c:465) |
obj_poll(h,out[4]) |
#0x204 |
non-blocking; out[0..3] = R3..R6 (obj.c:494) |
obj_recv_full(h,out[4]) |
#0x204 |
blocking sibling of obj_poll (obj.c:525) |
Type tags and capability bits are mirrored as OBJ_TAG_* / OBJ_CAP_*
(obj.h:37-49); they match the firmware values in
Volume III §5.
The wire mechanics (the four int words R4..R7, the four OR slots O1..O4, and
the register shift that delivers a queued SEND's R4..R7 into the receiver's
R3..R6) are explained once in
wm-terminal-overview.md §2; here is just how the
wrappers map onto them.
obj_send— recipient inO1,O2..O4nulled,R4..R7 = a0..a3. Pure int-payload notification.obj_send_or— likeobj_sendbutO2carriesor_h's capability (or a nullO2whenor_h == OBJ_NULL, the coarse v1 "unsubscribe" convention). Used to hand a service a sub-cap of your own mailbox — i.e. to subscribe (obj.h:113-117).obj_send_bytes— the data-send keystone. A SEND's int words can't carry a string or pixel buffer, so the sender puts a segment reference inO2and a byte offset/length in the int payload, and the receiverObjFetchByteses (#0x108) the bytes out.srcselects the segment:OBJ_SRC_STACK→ boot stack (O11),OBJ_SRC_DATA→ boot data (O15),OBJ_SRC_NONE→ nullO2;replyoptionally provides an ack mailbox inO3(obj.h:119-134,obj.c:433). This is what every message-with-data client needs (console, grid, raster, dir, host_io).
obj_send*return0even on a wire fault — aSENDtraps on error rather than reporting status, so there is no status to relay (obj.c:406). They return-1only for a bad handle.
obj_poll/obj_recv_full return five values (status + four payload words) but
pcc allows only four asm outputs, so the status R2 is sw'd to a file-scope
global (obj__poll_status, obj.c:492) from inside
the asm body and read back in C.
Migrating a wire-asm client onto handles follows a fixed shape, established by
raster.c, pointer.c, and vector.c. Three steps:
1. Adopt the service cap into a handle at init. Bring the capability into the handle world inside libc, so it never crosses a call boundary in an O-register:
static obj_t svc_h = OBJ_NULL;
int foo_init_from_dir_result(void) {
if (obj_init() != 0) return -1;
svc_h = obj_adopt_dir_result(); /* cap left by wm_bind_surface */
return (svc_h < 0) ? -1 : 0;
}raster_init_from_dir_result (raster.c:33-41),
vec_init_from_dir_result (vector.c:74-78), and
pointer_init_from_dir_result (pointer.c:46-49)
adopt from the dir-walk result (DIR_RESULT@616); term_init
(term.c:182-183) uses obj_adopt_o6() for the
boot keyboard service.
2. Each helper guards on the handle, then sends. Compute any byte offset, then call the matching wrapper:
- pure ops →
obj_send(e.g.vec_*,vector.c:63); - subscribe/unsubscribe →
obj_send_or(e.g.pointer_subscribe,pointer.c:69-72;termkeyboard,term.c:206); - byte data →
obj_send_bytes(e.g.raster_blit,raster.c:42-63); - receive →
obj_poll/obj_recv_full(e.g.pointer_getevent,term_getkeyatterm.c:465).
3. Restore the boot OPRs the SEND clobbered. A SEND's reply-overlay clobbers
O2..O4. Clients restore O2 = boot stack (O11) and O3 = boot data (O15) after
every SEND, or a following print_str would read its string through a clobbered
O2 — see _vec_restore_or (vector.c:38-50).
This is the same OPR-hygiene contract the wire-asm clients already followed.
| Client | File | State |
|---|---|---|
| handle layer | obj.{h,c} |
the API itself |
| raster | raster.c |
migrated — obj_send_bytes blits |
| pointer | pointer.c |
migrated — obj_send_or / obj_poll |
| vector | vector.c |
migrated (Phase 4) — obj_send draws |
| terminal | term.c |
partly migrated — keyboard on handles; the console path (term_print*) is still raw asm to O5 (term.c:33-34) |
| grid | grid.c |
not migrated (wire-asm) |
| host I/O | host_io.c |
not migrated (wire-asm; see Open questions) |
Every obj_send_bytes is fire-and-forget: the receiver issues its
ObjFetchBytes for the payload after the sending CPU has moved on, with no ack
in the wire protocol. So the source buffer must stay alive and unchanged until
that async fetch — a transient stack buffer overwritten by a later (deeper,
blocking) call is read as garbage, and because the symptom is non-deterministic
and shifts when you add a debug print, it masquerades as a compiler /
register-allocation Heisenbug. It is not.
This trap, its recognition signature, and the fixes are written up in full in
wm-terminal-overview.md §7 — read it before sending
byte data from a transient frame; it is not restated here. (Vector/pointer ops are
immune because they carry their whole payload in the int words, with no byte
source — vector.c:13-19.)
host_io.cmigration is not done, and is non-trivial. Its hostfsd reply mailbox lives inO8and is not private —term_print_n_syncand the supervisor read it too. A migration must keep the mailbox cap mirrored inO8and adopt the hostfsd service ref (O10), which would need adopt/park helpers this branch'sobj.{h,c}does not yet provide (onlyobj_adopt_dir_resultandobj_adopt_o6exist). Verify theO8contract before migrating.grid.cis still wire-asm. It is the obvious nextobj_send_bytescandidate (same positioned-text shape as the console), but had not been migrated at the time of writing.- The table holds only 8 handles (
OBJ_NHANDLE). A client needing more than eight simultaneously-live capabilities will getOBJ_NULLfromobj_alloc/obj_derive/ the adopt helpers; there is no dynamic growth. No current client comes close, but a future one might. obj_recvreturns only theR3word (obj.h:136-139); callers needing the full four-word payload must useobj_recv_full/obj_poll.