minix.rs
MINIX 3, in Rust, for the 64-bit era
A 64-bit-only reimplementation of MINIX 3 in Rust, preserving the original ABI.
minix.rs is a learning operating system built around a greenfield Rust microkernel. It keeps MINIX 3’s architecture — message-passing IPC, user-space servers, user-space drivers, and a fine-grained privilege model — while dropping 32-bit legacy and targeting modern 64-bit platforms under QEMU.
Highlights
- Microkernel in Rust (
no_std,no_main) — only IPC, scheduling, interrupt dispatch, and memory protection live in the kernel. - Message passing — MINIX 3’s six IPC primitives; five are live (SEND, RECEIVE, SENDREC, NOTIFY, SENDNB), SENDA is still a stub.
- User-space servers — PM, VFS, VM, RS, DS, SCHED run as separate processes.
- User-space drivers — VirtIO drivers as separate processes (planned; see the Roadmap).
- aarch64 first (Apple Silicon / QEMU virt); x86_64 planned.
- ABI-preserving — message layout, endpoints, and call numbers track MINIX 3.
Status
Phases 0–4 are complete (as of 2026-07-18): the aarch64 kernel boots through
UEFI/Limine, the system servers (PM, VFS, VM, RS, DS, SCHED) start from a
multi-module boot image and discover each other via DS, and init (PID 1)
drives a fork/exec/wait loop over a boot-embedded worker binary — all verified
in QEMU. Phase 5 — a musl libc fork and the first file systems, ending in a C
“Hello World” — is next; the working plan lives in the repository’s
docs/plan.md.
About this book
This book is the canonical, source-derived documentation for minix.rs, written from the actual kernel and server code. Chapters describe the system in the present tense only for what boots today; forward-looking design is collected in the Roadmap.
Note: The repository’s
docs/directory once held hand-written bootstrap notes used to plan the project; those have been ported into this book and retired. Only the planning tree remains there —docs/plan.md(the live phase tracker) anddocs/plans/(per-phase slice histories).
Architecture
minix.rs is a microkernel operating system written in Rust. It preserves MINIX
3’s core architectural principles — message-passing IPC, user-space servers, and
a fine-grained privilege model — while dropping 32-bit legacy and targeting
modern 64-bit platforms under QEMU. aarch64 (Apple-Silicon / QEMU virt) is
the primary target; an x86_64 port is planned (see Roadmap).
This chapter is the map of the system as it stands at the end of Phase 4. It describes the pieces that boot today and cross-references the chapters that cover each in depth. Where a MINIX-3 concept exists only as a plan, it is called out as such rather than described in the present tense.
What makes it a microkernel
In a monolithic kernel (Linux, the BSDs), the whole OS — file systems, device drivers, networking, memory management — runs in one privileged address space, so a bug in any driver can take down the system. MINIX takes the opposite approach, and minix.rs follows it: the kernel handles only four things.
- IPC — copying fixed-size messages between processes, managing the send/receive queues, and detecting deadlocks.
- Scheduling — priority run queues and timer-driven preemption.
- Interrupt dispatch — routing hardware interrupts to the process that
registered for them, delivered as
NOTIFYmessages. - Memory protection — owning the physical frame allocator and performing every page-table write, so processes cannot reach into each other’s memory.
Everything else runs as an ordinary user-space process that communicates only through messages:
+------------------------------------------------------------------+
| User programs (init, worker; musl-linked C programs — planned) |
+------------------------------------------------------------------+
| SENDREC(server, &msg) via the SVC IPC trap
v
+--------+--------+--------+--------+--------+---------------------+
| PM | VFS | VM | RS | DS | SCHED |
| fork | (skel- | page- | moni- | name → | quantum |
| exec | etal) | fault | tor | endpt | delegation |
| exit | | brk | | regis- | |
| wait | | mmap | | try | |
| signal | | | | | |
+--------+--------+--------+--------+--------+---------------------+
| Device drivers (VirtIO) and file systems (MFS/PFS) — planned |
+------------------------------------------------------------------+
| IPC messages (SEND / RECEIVE / SENDREC / NOTIFY / SENDNB)
v
+------------------------------------------------------------------+
| minix.rs microkernel (Rust) |
| IPC | scheduling | interrupt dispatch | memory protection |
| Kernel calls (SYS_*) for privileged servers |
+------------------------------------------------------------------+
| aarch64 HAL: SVC/ERET, GICv3, translation tables |
| (x86_64 — planned) |
+------------------------------------------------------------------+
| Limine boot protocol (UEFI) |
+------------------------------------------------------------------+
Relation to MINIX 3
A developer who knows MINIX 3 will recognize the IPC primitives, the server roles, and the two-tier call model (user → server → reply for POSIX calls, server → kernel for privileged operations). What differs:
| Aspect | MINIX 3 | minix.rs |
|---|---|---|
| Kernel language | C | Rust (no_std, no_main) |
| Target architectures | i386, 32-bit ARM | aarch64 (x86_64 planned) |
| Bootloader | custom / multiboot | Limine (UEFI) |
| C library | NetBSD libc | musl fork (planned, Phase 5) |
| Userland | NetBSD commands | minimal Rust (init, worker) |
| License | mixed (BSD + GPL) | BSD-3-Clause only |
| IPC linked lists | raw C pointers | table indices (Option<ProcNr>) |
| Message payload | opaque unions (m1i1, …) | typed structs per call |
minix.rs preserves MINIX 3’s ABI — the 104-byte message layout, endpoint encoding, and call-numbering conventions — as a reference point, not by shipping MINIX 3 code. See System Calls & ABI and MINIX 3 Source Mapping.
The microkernel
The kernel is the only code that runs privileged (EL1 on aarch64). Its responsibilities are the four listed above; concretely it provides:
- IPC dispatch — see IPC.
- Scheduling — priority run queues, quantum expiry, and a delegatable scheduler: a process can be scheduled by the kernel or handed to the user-space SCHED server. See Servers.
- Interrupt and timer handling — the ARM generic timer drives a 100 Hz tick; hardware IRQ routing to driver processes is groundwork for the driver era.
- Kernel calls (
SYS_*) — privileged operations requested by system servers (SYS_FORK,SYS_EXEC,SYS_VMCTL,SYS_KILL, …), gated per process. These are not available to ordinary user programs.
What the kernel does not do: file-system operations, process-lifecycle policy (PM), page-fault policy (VM), device I/O, or networking.
System servers
Each server is a separate user-space process with its own address space, communicating only through IPC. The full treatment is in Servers; in brief:
| Server | Role | State at Phase 4 |
|---|---|---|
| PM | fork, exec, exit, wait, minimal signals | real |
| VM | page-fault resolution, brk, mmap, munmap | real |
| RS | monitor / heartbeat system processes | real (detect-only) |
| DS | name → endpoint registry | real |
| SCHED | user-space scheduling policy | real |
| VFS | file-operation switch | skeletal (no file ops yet) |
Device drivers and file-system servers (MFS/PFS) are user-space processes in the same model, but exist only as stubs today — they belong to later phases (see Roadmap).
The two call paths
MINIX has two kinds of calls, and minix.rs keeps both.
POSIX system calls — a user program’s open/read/fork becomes an IPC
message to the responsible server (fork → PM, file ops → VFS, mmap → VM). The
program never traps into the kernel for these; it uses SENDREC to send a request
and block for the reply. Today the user side of this path is exercised directly
through the minixrs-ipc crate (by init and worker); the musl wrappers that
will make it transparent to C programs arrive in Phase 5.
Kernel calls (SYS_*) — servers ask the kernel for privileged operations
(edit a page table, fork a process slot, raise a signal) by sending a SENDREC to
the SYSTEM task. Each call is gated by the caller’s k_call_mask; a process
without the bit gets an error. The full catalog is in
System Calls & ABI.
Privilege model
Every system process has a privilege-table entry
(kernel/src/proc/priv_struct.rs) that controls what it may do. User processes
share a single user-class slot. The live fields are:
trap_mask(u16) — which IPC primitives the process may use. Bitiallows primitivei; ordinary user processes typically get onlySENDREC.ipc_to— bitmap of which other privileged processes it may send to.k_call_mask— bitmap of which kernel calls it may make (empty for user processes).sig_mgr— the endpoint that manages signals raised against it (PM, for user processes).
The struct also carries io_ranges, irqs, mem_ranges, and a grant_table
pointer. These are forward-declared for the driver and grant eras — device I/O,
IRQ handler registration, and grant-validated safe-copy are Phase 5+/driver work,
not yet exercised. This fine-grained model is what lets a future compromised
driver be denied kernel calls it doesn’t need and servers it has no business
talking to.
Memory and boot
- Memory management — the kernel-owned frame allocator, per-process address spaces, the page-fault path, and VM’s region tracking — is covered in Memory Management.
- The boot path — Limine, the aarch64 entry sequence, and the embedded MXBI boot image — is covered in Boot.
Crate structure
The project is a single Cargo workspace:
kernel/ microkernel (no_std, no_main)
kernel-shared/ message types, endpoints, call numbers (shared by all crates)
minixrs-ipc/ user-space IPC library (the SVC trap stubs)
server-rt/ server runtime / SEF framework
servers/ pm, vfs, vm, rs, ds, sched
drivers/ virtio-blk/net/console, memory, driver-rt (stubs — planned)
fs/ mfs, pfs (stubs — planned)
userland/ init, worker (real); sh, coreutils (stubs — planned)
kernel-shared is the glue: it defines the Message struct, endpoint encoding,
call numbers, and error codes used by both the kernel and every user-space
component, so the IPC protocol is checked at compile time.
Further reading
- Boot — Limine to first user process
- IPC — message format, primitives, deadlock detection
- Memory Management — frames, address spaces, VM
- Servers — server responsibilities and IPC flows
- Build & Toolchain — building, running, and trace forensics
- System Calls & ABI — the call catalog
- MINIX 3 Source Mapping — where things moved
- Roadmap — drivers, musl, file systems, x86_64
Boot
This chapter follows minix.rs from firmware to the first user process, as it boots today on aarch64 under QEMU. The x86_64 path is design intent, part of the planned port (see Roadmap).
Bootloader: Limine
minix.rs boots via the Limine boot protocol (BSD-licensed, so it fits the project’s BSD-3-Clause-only rule). Limine handles firmware initialization and the mode transition, and hands the kernel a clean 64-bit environment: 4-level translation tables already set up, all physical memory mapped through a Higher-Half Direct Map (HHDM), and a valid stack.
The kernel communicates with Limine through request structs placed in a
.limine_requests ELF section. Limine scans the kernel binary for these
(identified by magic IDs), fills in response pointers, and jumps to the entry
point. The kernel consumes, among others, the memory-map request (usable /
reserved regions, which seed the frame allocator) and the HHDM request (the
direct-map base offset). Limine support lives in
kernel/src/arch/aarch64/limine.rs.
Boot flow (aarch64)
edk2 / UEFI firmware (QEMU virt)
│
▼
Limine (external/limine/dist/BOOTAA64.EFI)
│ reads tools/limine.conf from the FAT EFI System Partition
│ loads the kernel ELF, sets up 4-level tables + HHDM, fills Limine responses
▼
_start (kernel/src/arch/aarch64/entry.S)
│ CPU at EL1, MMU on, interrupts masked, SP = Limine-allocated stack
│ zero the frame pointer, branch to kmain()
▼
kmain() (kernel/src/main.rs)
│ 1. resolve the PL011 UART base through the HHDM; arch::init()
│ 2. print "minix.rs booting on aarch64" and the HHDM offset
│ 3. proc::init() — clear the process/privilege tables; dump them
│ 4. mm::set_hhdm_offset() + mm::init_from_limine_memmap() — seed the frame allocator
│ 5. gic::init() + enable the virtual-timer PPI + timer::init(100 Hz)
│ 6. arch::userland_bootstrap() — load the boot image, install the demo stubs
│ 7. proc::sched::run() — first ERET into EL0
▼
User processes run (scheduler picks the highest-priority runnable proc)
There is no hand-ordered “start DS, then RS, then PM, …” sequence:
userland_bootstrap loads every boot module and marks it runnable, and the
scheduler takes over from there. Servers rendezvous at run time — each publishes
its endpoint to DS at startup and looks the others up by name — so boot order does
not matter (see Servers).
CPU state at kernel entry
When Limine branches to _start on aarch64:
- Exception level EL1, MMU enabled with Limine’s translation tables.
- Interrupts masked (all
DAIFbits set). SPpoints to a valid Limine-allocated stack.- All physical memory is reachable at
phys + hhdm_offset.
The embedded boot image (MXBI)
minix.rs does not ask the bootloader to locate server binaries on disk. Instead,
kernel/build.rs compiles every boot server for the EL0 user target, packs the
resulting ELFs into a single MXBI archive, and the kernel embeds that archive
in its own .rodata via include_bytes! (kernel/src/boot_image/mod.rs). Limine
reports the kernel image — archive included — under EXECUTABLE_AND_MODULES, so
those bytes are never visible to the frame allocator.
Archive format
All multi-byte fields are little-endian
(kernel/build.rs::pack_mxbi ↔ boot_image/mod.rs):
16-byte header: magic "MXBI" (u32 = 0x4942_584D), version (u32 = 1),
entry_count (u32), total_size (u32)
entry_count × 32-byte records: { proc_nr:i32, offset:u32, len:u32, name:[u8;20] }
then the ELF payloads back-to-back, each at its recorded offset
BootImage (boot_image/mod.rs) is a zero-copy view: BootImage::iter drives
the loader (one load_boot_server call per module), and BootImage::module_by_name
lets SYS_EXEC resolve a target binary — the mechanism worker rides on.
Boot modules
The archive holds eight modules (kernel/build.rs’s servers array). Seven have
non-negative proc numbers and are loaded into a process slot at boot; worker is
tagged with the sentinel EXEC_ONLY_PROC_NR = -1, so the loader skips it — it has
no boot slot and exists only to be resolved by name at exec time.
| Module | Proc nr | Loaded at boot? |
|---|---|---|
pm | 0 | yes |
vfs | 1 | yes (skeletal) |
rs | 2 | yes |
ds | 5 | yes |
vm | 7 | yes |
sched | 9 | yes |
init | 10 | yes — becomes PID 1 |
worker | −1 | no — exec-only target |
Proc numbers 3 (MEM), 4 (TTY), 6 (MFS), and 8 (PFS) are reserved in
kernel-shared/src/com.rs for servers that do not yet exist. Kernel tasks
(ASYNCM, IDLE, CLOCK, SYSTEM, HARDWARE) occupy the negative proc numbers
and are internal to the kernel — they have no ELF binary.
Alongside the boot modules, userland_bootstrap hand-installs four demo stubs
(A–D) at proc numbers 11–14. They are not part of the archive; they are small EL0
programs kept as a live regression battery for the IPC primitives, SCHED
delegation, and the page-fault / SIGSEGV paths that init and worker do not
exercise. So the boot trace shows eleven address-space ([as]) lines — six
servers, init, and the four stubs — but not worker, which is never loaded
until an exec names it.
Disk image and QEMU
There is no partitioned root disk yet. The cargo runner (tools/qemu-run.sh)
stages a small ESP directory and hands it to QEMU’s directory-as-FAT helper,
which is enough to land the boot banner without parted / mtools:
target/esp/
EFI/BOOT/BOOTAA64.EFI ← Limine (external/limine/dist/BOOTAA64.EFI)
limine.conf ← tools/limine.conf
boot/kernel ← the freshly built kernel ELF
tools/limine.conf names the kernel and nothing else — the servers are embedded:
timeout: 0
serial: yes
/minix.rs
protocol: limine
kernel_path: boot():/boot/kernel
The runner then launches QEMU (firmware auto-located, or set
QEMU_EFI_AARCH64):
qemu-system-aarch64 \
-M virt,gic-version=3 -cpu cortex-a72 -m 256M \
-bios <edk2-aarch64-code.fd> \
-drive file=fat:rw:fat-type=32:target/esp,format=raw,if=virtio \
-display none -serial stdio -no-reboot
Because the kernel runs indefinitely once EL0 starts, always wrap a boot run in
timeout. See Build & Toolchain for the full run recipe and the
trace-forensics rules for reading a serial log.
MINIX 3 reference
| Aspect | MINIX 3 file |
|---|---|
| Boot process table | kernel/table.c |
| Kernel init | kernel/main.c (kmain, bsp_finish_booting) |
| i386 entry / pre-init | kernel/arch/i386/head.S, pre_init.c |
IPC
IPC is the foundation of MINIX. Every interaction between a user program and the OS, and between OS components themselves, happens through message passing. Understanding IPC is understanding minix.rs.
This chapter describes IPC as it stands at the end of Phase 4: a fixed-size
message, five live primitives (with SENDA stubbed), generation-aware endpoints,
deadlock detection, and the per-process privilege bitmaps that gate it all. The
kernel side lives in kernel/src/ipc/; the shared wire types in kernel-shared;
the user-space trap stubs in minixrs-ipc.
Message structure
Every IPC message is a fixed-size, 8-aligned struct — 104 bytes on 64-bit
platforms (kernel-shared/src/message.rs):
#![allow(unused)]
fn main() {
#[repr(C, align(8))]
pub struct Message {
pub m_source: Endpoint, // i32, offset 0 — set by the kernel on delivery
pub m_type: i32, // offset 4 — call number, or result code on reply
pub payload: [u8; 96], // offset 8 — interpreted per-call
}
}
size_of::<Message>() == 104 and align_of::<Message>() == 8 are enforced by
const assertions. There is no separate padding field: m_source and m_type
occupy the first 8 bytes and the 96-byte payload starts at offset 8. The payload
is a raw byte array that each call interprets with its own typed accessors —
minix.rs uses named per-call structs rather than MINIX 3’s opaque m1i1 / m2l1
union fields, so the protocol is self-documenting.
The fixed size is deliberate: the kernel copies the whole message on delivery, so a fixed size keeps the copy fast and allocation-free, and forces protocol designers to keep messages compact. Transfers larger than the payload are the job of grant-based safe copy (planned; see Privilege model).
Note on the “64-bit MINIX 3” reference. MINIX 3 only ever shipped as a 32-bit operating system (i386, and later 32-bit ARM on the BeagleBone). Its source tree does carry x86_64 ABI definitions — including the
__x86_64__branch ofinclude/minix/ipc.hthat fixessizeof(message) == 104— but the upstream project never released a working 64-bit MINIX 3. A functioning 64-bit MINIX 3 prototype was a personal effort by this project’s author (Kevin Barnard), separate from the upstream MINIX 3 project. minix.rs takes that 104-byte layout as its ABI reference point, but is itself a clean, 64-bit-only implementation. When the docs say minix.rs “preserves the original ABI,” that means tracking those source-tree definitions, not a shipped 64-bit MINIX 3.
The IPC primitives
Six primitive numbers are defined (kernel-shared/src/ipc_const.rs); five are
live and one is a stub:
| Primitive | Number | Status | Behavior |
|---|---|---|---|
SEND | 1 | live | Blocking send; caller blocks until the receiver accepts. |
RECEIVE | 2 | live | Blocking receive from a specific endpoint or ANY. |
SENDREC | 3 | live | Atomic send-then-receive; the common client→server call. |
NOTIFY | 4 | live | Non-blocking notification; sets a bit if the target isn’t waiting. |
SENDNB | 5 | live | Non-blocking send; fails instead of blocking. |
SENDA | 16 | stub | Asynchronous send-table. Returns ENOSYS. |
SEND — if the destination is waiting to receive from us (or from ANY), the
message is delivered immediately and the destination is unblocked; otherwise the
caller blocks and is queued on the destination’s caller_q.
RECEIVE — the kernel checks, in order: pending notifications
(notify_pending), pending async messages (asyn_pending), then the caller queue
(caller_q). If nothing is ready, the caller blocks with RTS_RECEIVING set.
SENDREC — a SEND immediately followed by a RECEIVE, but atomic: the caller
transitions straight from sending to receiving without returning to user space.
This is what a POSIX wrapper uses — send a request to a server and block for the
reply.
NOTIFY — if the target is waiting, a synthetic notification message
(m_type == NOTIFY_MESSAGE, 0x1000) is delivered immediately; otherwise a bit
is set in the target’s notify_pending bitmap. NOTIFY never blocks, and
repeated notifications from the same source coalesce into one bit. Used for timer
expiry (CLOCK → server), kernel-raised signals (SYSTEM → PM), and — in the
driver era — hardware-interrupt delivery.
SENDNB — SEND semantics, but returns an error rather than blocking when the
destination is not ready to receive.
SENDA is defined for ABI completeness but is not implemented: it returns
ENOSYS, and it cannot be reached anyway because the trap_mask gate is a u16
and SENDA’s bit is 16 — outside the mask. Making it real is deferred (see
Roadmap).
The user-space trap (aarch64)
minixrs-ipc issues the IPC trap with svc #0. The register convention is:
| Register | Purpose |
|---|---|
x0 | Endpoint (src / dest / src_dest); also receives the result |
x1 | IPC primitive number |
x2 | Pointer to the 104-byte Message (null for NOTIFY, which carries none) |
The kernel’s do_ipc writes the result into the saved x0; the SVC entry saves
and restores x1..x30, so from the caller’s view only x0 is clobbered. The
public wrappers are ipc_send, ipc_receive, ipc_sendrec, ipc_sendnb, and
ipc_notify — there is no ipc_senda. The x86_64 trap ABI is design intent, part
of the planned port.
Endpoints
Processes are named by endpoints, not raw slot numbers. An Endpoint is an
i32 that encodes a generation counter and a process-table slot
(kernel-shared/src/endpoint.rs):
#![allow(unused)]
fn main() {
pub const ENDPOINT_GEN_SHIFT: i32 = 15; // proc_nr in the low 15 bits, generation above
pub const fn make_endpoint(g: GenNr, p: ProcNr) -> Endpoint;
pub const fn endpoint_proc(e: Endpoint) -> ProcNr; // sign-extends: task slots stay negative
pub const fn endpoint_gen(e: Endpoint) -> GenNr;
}
The generation increments each time a slot is reused (bump_generation, which
wraps to 1 — never back to 0, so a recycled slot can never re-alias a boot
endpoint). This catches stale-endpoint bugs: a message aimed at a dead process is
rejected rather than misdelivered to whatever now occupies the slot.
Sentinel endpoints live just below ENDPOINT_SLOT_TOP ((1 << 14) − 1 = 16383), all at generation 0 so they can never collide with a real
(gen, proc) endpoint:
| Endpoint | Value | Meaning |
|---|---|---|
ANY | 16383 (0x3FFF) | receive from any sender |
NONE | 16382 | no process |
SELF | 16381 | the calling process itself |
Kernel tasks occupy negative slots (kernel-shared/src/com.rs):
| Proc nr | Name | Role |
|---|---|---|
| -5 | ASYNCM | async-message driver |
| -4 | IDLE | idle task |
| -3 | CLOCK | timer-driven work |
| -2 | SYSTEM | handles SYS_* kernel calls |
| -1 | HARDWARE | source endpoint for IRQ notifications |
Well-known servers are numbered contiguously from 0. minix.rs renumbers these compared to MINIX 3 (which scatters the slots). Note that some numbers are reserved for servers that do not yet boot:
| Proc nr | Server | Boots today? |
|---|---|---|
| 0 | PM | yes |
| 1 | VFS | yes (skeletal) |
| 2 | RS | yes |
| 3 | MEM (memory driver) | reserved, not built |
| 4 | TTY | reserved, not built |
| 5 | DS | yes |
| 6 | MFS | reserved, not built |
| 7 | VM | yes |
| 8 | PFS | reserved, not built |
| 9 | SCHED | yes |
| 10 | init (PID 1) | yes |
Endpoints at generation 0 are boot endpoints; construct one from a task/server
ProcNr with boot_endpoint(p).
Deadlock detection
Because SEND blocks, circular send dependencies could deadlock the system:
A sends to B (blocks) ; B sends to A (blocks) → neither can proceed
Before blocking a sender, the kernel (kernel/src/ipc/deadlock.rs) traces the
chain of send dependencies starting at the destination: if the destination is
itself sending, it follows sendto_e, and so on. If the chain loops back to the
original caller, the SEND fails with ELOCKED instead of blocking. A two-party
pair (A→B, B→A) is allowed; larger cycles are rejected.
Privilege model
Every system process has a privilege-table entry
(kernel/src/proc/priv_struct.rs) controlling what IPC and kernel operations it
may perform. User processes share one user-class slot.
trap_mask(u16) — which IPC primitives the process may use; bitiallows primitivei. User processes typically hold onlySENDREC; servers holdSEND/RECEIVE/NOTIFY/… (This is also whySENDA, bit 16, is unreachable.)ipc_to— bitmap of which other privileged slots this process may send to. A user process can reach only the servers it needs: the shared user slot opensipc_to = {PM, VFS}— the process lifecycle and the file descriptors, and nothing else. Each entry is really a pair of bits, because the server needs the reverse edge to reply;populate_user_privopens both.k_call_mask— bitmap of permitted kernel calls (empty for user processes). See System Calls & ABI.notify_pending/asyn_pending— per-source bitmaps of deferred notifications and async messages, consumed byRECEIVE.sig_mgr— the endpoint that manages signals raised against this process.
The entry also reserves io_ranges, irqs, mem_ranges, and a grant_table
pointer. These are the hooks for two future mechanisms:
- Grants / safe copy — for data larger than the payload, a process will
publish a grant table (via
SYS_SETGRANT) describing memory regions and permitted operations, and the kernel will validate a grant before copying (SYS_SAFECOPY). The call numbers exist; a real grant table and validated copy are an opening Phase-5 slice, so no cross-address-space bulk copy runs yet. - I/O and IRQ ownership —
io_ranges/irqswill gate a driver’s access to ports and interrupt lines. Drivers are a later phase.
IPC status
When it delivers a message, the kernel also provides an IPC status word encoding which primitive delivered it (bits 0–5) and whether it originated in the kernel (a “from kernel” flag). This lets a receiver distinguish a plain message from a notification and handle kernel-originated messages specially — for instance, never replying to them.
Kernel IPC state
The relevant per-process fields (kernel/src/proc/):
caller_q/q_link— the head of, and links through, the queue of processes blocked trying to send to this one (Option<ProcNr>indices, not raw pointers).getfrom_e/sendto_e— the endpoint this process is receiving from / sending to while blocked.- run-time-state flags (
rts_flags) —RTS_SENDING,RTS_RECEIVING, and the others. A process is runnable iff its RTS flags are all clear.
IPC and scheduling are therefore coupled: unblocking a receiver (clearing its last
RTS bit via rts_unset) enqueues it on the run queue; blocking a sender
(rts_set) removes it. See Servers for how the
delegatable scheduler builds on this.
MINIX 3 source reference
| Concept | MINIX 3 file | Key functions |
|---|---|---|
| IPC dispatch | kernel/proc.c | do_ipc, do_sync_ipc |
| Blocking send / receive | kernel/proc.c | mini_send, mini_receive |
| Notification / async send | kernel/proc.c | mini_notify, mini_senda |
| Deadlock detection | kernel/proc.c | deadlock |
| Process / privilege tables | kernel/proc.h, priv.h | struct proc, struct priv |
| Message / IPC constants | include/minix/ipc.h, ipcconst.h | message, SEND, … |
Memory Management
minix.rs splits memory management the way MINIX 3 does — mechanism in the
kernel, policy in a user-space server — but moves physical-frame ownership
into the kernel. The kernel owns the frame allocator and performs every page
table write; the user-space VM server decides what should be mapped where
and drives the kernel through a single privileged kernel call (SYS_VMCTL).
This chapter describes the subsystem as it stands at the end of Phase 3 — a
physical frame allocator, per-process address spaces with hardware-tagged TLBs,
a page-fault path that delegates to VM, and the VM server’s region tracking for
brk, mmap, and munmap — plus the region-cloning that PM-driven fork added
in Phase 4.
Memory map (aarch64 QEMU virt)
The QEMU virt machine has a fixed device layout. With -m 256M, RAM sits at
0x4000_0000:
| Physical range | Device |
|---|---|
0x0800_0000–0x0800_FFFF | GICv3 distributor |
0x080A_0000–0x080B_FFFF | GICv3 redistributor |
0x0900_0000–0x0900_0FFF | PL011 UART |
0x0A00_0000+ | VirtIO MMIO devices |
0x4000_0000+ | RAM (256 MB default) |
The virtual address space splits at the canonical hole. TTBR1_EL1 (the upper
half) holds the kernel image and Limine’s direct map; TTBR0_EL1 (the lower
half) holds the current process:
0xFFFF_FFFF_........ kernel image (.text / .rodata / .data / .bss, embedded boot image)
0xFFFF_8000_0000_0000 HHDM base — all physical memory mapped at phys + hhdm_offset
... non-canonical hole ...
0x0000_7FFF_FFFF_FFFF user ceiling (stack region, grows down)
0x0000_0000_0200_0000 MMAP_BASE — anonymous mmap arena (bump-allocated up)
0x0000_0000_0100_0000 HEAP_BASE — heap region (brk grows up)
0x0000_0000_0010_0000 user load base — text/rodata/data/bss (servers, init, worker)
0x0000_0000_0000_0000
Translation tables
minix.rs uses a 4 KB granule with 4-level translation:
| Level | VA bits | Each entry maps |
|---|---|---|
| L0 | [47:39] | 512 GB |
| L1 | [38:30] | 1 GB |
| L2 | [29:21] | 2 MB |
| L3 | [20:12] | 4 KB |
TTBR0_EL1 holds the current process’s tables and is swapped (tagged with the
process’s ASID) on every context switch; TTBR1_EL1 holds the shared kernel
mapping and never changes. Limine maps all physical memory contiguously at
hhdm_offset, so the kernel reaches any physical address as phys + hhdm_offset
with no temporary mapping windows — every free frame, page table, and message
buffer the kernel touches is addressed this way.
Physical frame allocator
kernel/src/mm/ is the kernel-side physical allocator. At boot it walks
Limine’s MEMMAP_USABLE entries and seeds a per-region bump pointer for each
(MAX_REGIONS = 16 regions — QEMU virt and Apple-Silicon QEMU both fit
comfortably). Freed frames go onto an intrusive free-list threaded through the
frames themselves, reached via Limine’s higher-half direct map (HHDM).
Two invariants keep callers honest:
alloc_framezeroes every frame before handing it out, so a caller never observes residual state.free_framepushes the frame back through the HHDM.
Frames inside the kernel image, the embedded boot image, and the static EL0
test-stub pages live in Limine’s EXECUTABLE_AND_MODULES region and are never
visible to the allocator, so no explicit reservation logic is needed.
Per-process address spaces
Each user process has its own translation table tree, rooted at a physical
address recorded in Proc::ttbr0_pa, and an 8-bit address-space identifier in
Proc::asid. kernel/src/arch/aarch64/addrspace.rs is the page-table API:
AddrSpace::new()allocates an L0 root frame.map_page(va, pa, prot)walks the tree, allocating L1/L2/L3 tables on demand from the frame allocator, and writes the leaf PTE through the HHDM.walk_pt(va)resolves a VA to its PTE (orNone).destroy()recursively frees the intermediate tables and the L0 root (leaf frames are caller-owned and freed elsewhere).
The free functions map_page_in(ttbr0_pa, …) and unmap_page_in(ttbr0_pa, …)
do the same work keyed by a root PA, so the kernel can mutate a process’s tree
without holding an AddrSpace value — this is how SYS_VMCTL edits a target
process’s address space.
Context switch and ASIDs
On every switch into a user process, proc::sched::schedule_next:
- parks the next process’s register frame for the trap return,
- calls
switch_ttbr0_with_asid(ttbr0_pa, asid)— which writesTTBR0_EL1 = ttbr0_pa | ((asid as u64) << 48)and issues an ASID-taggedtlbi— before - flushing any pending IPC message into the user buffer.
The order matters: the message flush writes through the active TTBR0, so the
incoming process’s address space must be live first. ASIDs let the hardware keep
TLB entries for multiple address spaces without a full flush on each switch; the
allocator (asid.rs) hands them out from FIRST_ASID = 1 (0 means
“uninitialized”) and panics on 8-bit wrap — real rollover is deferred until
process churn in Phase 4 makes it reachable.
The page-fault path
When an EL0 process touches an unmapped page, the CPU traps to EL1 and
do_page_fault(esr, elr, far) runs. It classifies the abort (instruction vs
data, fault status code, write-vs-read), records the coordinates in the
faulting process’s PageFaultState, blocks the process on the RTS_PAGEFAULT
run-time state, and sends the VM server a VM_PAGEFAULT message carrying the
faulting endpoint and the fault address. Permission faults (a write to a
read-only page) are not resolvable by mapping and halt loudly.
The kernel originates that send with mini_pf_send, which models the faulting
process as a blocked sender on VM’s caller queue — the lingering RTS_PAGEFAULT
keeps it blocked even after the RTS_SENDING half clears, until VM explicitly
clears the fault. Because VM resolves the fault while running under its own
TTBR0 and the message is delivered after the switch, no cross-address-space copy
machinery is needed at this stage.
SYS_VMCTL: kernel mechanism, VM policy
SYS_VMCTL (kernel/src/system/do_vmctl.rs) is the one privileged call through
which VM drives the kernel’s paging mechanism. It is gated solely by
Priv::k_call_mask granting SYS_VMCTL; VM is its sole intended holder and is
trusted to target only processes it legitimately manages (the MINIX 3 trust
model). Each subcall names a target process by endpoint (SELF allowed):
| Subcall | Effect |
|---|---|
VMCTL_PT_MAP | Allocate a fresh zeroed frame and map it at vaddr with the requested protection; reply with the chosen PA. |
VMCTL_PT_UNMAP | Clear the PTE at vaddr and free its backing frame. Returns EINVAL if nothing is mapped there (a harmless no-op for callers sweeping a range). |
VMCTL_CLEAR_PAGEFAULT | Clear the target’s recorded fault and make it runnable again. |
VMCTL_GET_PAGEFAULT | Read the target’s recorded fault coordinates. |
VMCTL_VMINHIBIT_SET / _CLEAR | Gate scheduling of the target while VM mutates its address space. |
Every PTE change is followed by an ASID-tagged TLB invalidation. The kernel allocates frames (unlike MINIX 3, where VM owns physical memory) and VM supplies only the virtual address and protection.
VM server region tracking
The VM server (servers/vm/) is the first real user-space process. It runs a
RECEIVE(ANY) loop and dispatches on message type. It owns no heap allocator —
the kernel owns frames — so it tracks memory with a static per-process region
table (servers/vm/src/region.rs): [ClientRegions; 16], keyed by process
number, each holding up to MAX_REGIONS = 4 regions. A region is a half-open
virtual range [start, end) tagged with a Kind:
Heap— grown bybrk, based at the fixedHEAP_BASE(0x0100_0000).Mmap— anonymous mappings, bump-allocated fromMMAP_BASE(0x0200_0000).Unused— a free slot.
A page fault is satisfied only if its address lies inside one of the
faulting process’s regions: VM consults the table, and on a hit issues
SYS_VMCTL(VMCTL_PT_MAP) then SYS_VMCTL(VMCTL_CLEAR_PAGEFAULT). A fault
outside every region is a segmentation fault: VM raises
SYS_KILL(faulter, SIGSEGV), and the faulter stays blocked on RTS_PAGEFAULT
until PM terminates it (the minimal-signals path that made this a real kill
landed in Phase 4; before that VM could only leave the process wedged). VM runs
at EL0 with no console, so it is silent from the server’s side — the kill surfaces
in the kernel signal trace.
brk
VM_BRK(new_break) sets the caller’s program break. VM page-aligns the request
and grows (or first creates) the caller’s Heap region to
[HEAP_BASE, new_break), replying with the resulting break. No frames are
mapped eagerly — pages fault in lazily on first touch and are then satisfied by
the region check above.
mmap / munmap
VM_MMAP(len) is an anonymous mapping in the style of mmap(NULL, len, …): VM
page-aligns the length, bump-allocates a base address from the caller’s mmap
arena, records an Mmap region, and replies with the chosen base. As with the
heap, frames are not mapped until first touch.
VM_MUNMAP(addr, len) drops the Mmap region based at addr and unmaps each
backing page with SYS_VMCTL(VMCTL_PT_UNMAP). Pages that never faulted in were
never mapped, so the kernel returns a harmless EINVAL for them, which VM
ignores. The match is keyed on the region’s base address and the unmap sweep is
capped at the region’s own end, so an over-stated length can never reach into a
neighboring region or the heap.
The arena is bump-only for now — munmap does not return addresses to it.
Address reuse within the arena remains future work. PM-driven fork (Phase 4)
already clones a parent’s entire region set into the child: after the kernel
copies the child’s page tables via SYS_FORK, PM issues VM_FORK, and VM copies
its own ClientRegions bookkeeping so the child’s later brk / mmap / fault
lookups see the inherited heap and mmap regions.
Servers
minix.rs keeps the microkernel tiny — IPC, scheduling, memory protection, and a small set of privileged kernel calls — and runs every operating-system service as an ordinary user-space process, exactly as MINIX 3 does. These servers talk to each other and to the kernel only through message passing. A server never shares memory with a client; it acts on a request, replies, and the kernel enforces who may talk to whom via per-process privilege bitmaps.
This chapter describes the servers as they stand at the end of Phase 4: a common
runtime (SEF), a name registry (DS), a user-space scheduler (SCHED), a monitor
(RS), the process manager (PM), a still-skeletal file-system switch (VFS), and
init (PID 1) — the first real user process, which drives the whole
fork/exec/wait lifecycle through PM.
Where servers live
Servers are freestanding #![no_std] / #![no_main] ELF binaries linked with
their own user.ld (page-aligned segments based at 0x0010_0000) and branded
with the minixrs ELF identity note, which the kernel requires of every image it
loads. The kernel’s build.rs compiles each for the aarch64-unknown-minixrs
target and concatenates them into a single MXBI archive embedded in the
kernel image; the boot loader
(kernel/src/arch/aarch64/userland.rs) walks the archive and loads each module
into the proc slot named by its record. Each server gets its own per-process
TTBR0, so they all share the same low load base with no collision.
A server has no println. Its behaviour is observed through kernel-side traces
([ipc], [ksys], [pf], [alarm]), and since slice 5.1 it can also emit a
line itself through the kernel debug channel — server-rt’s diag_print /
diag_fmt issue a SYS_DIAGCTL carrying the text inline, which the kernel prints
prefixed with the caller’s own name ([diag vfs] …). That is deliberately a
debug channel, not stdio: it exists to keep working while stdio itself is under
construction. Real console output arrives via the TTY driver (see
Drivers) — slice 5.3 put the first EL0-composed text on
the serial line, and slice 5.4 puts a process’s fd 1 and 2 on top of it.
Request-number ranges
Every server’s request numbers occupy a distinct band below NOTIFY_MESSAGE, so
a message type unambiguously identifies both its server and its meaning
(kernel-shared/src/callnr.rs, const-asserted disjoint). The bands are listed —
and rendered into the generated C header — in ascending numeric order. VFS took
0x800 in slice 5.4, BDEV 0xA00 in 5.7, and the VFS↔FS band 0x900 in 5.8 —
which fills 0x700..0xC00 completely. A tenth band has no reserved slot left to
take; it has to find a home outside that span.
| Base | Value | Server / purpose |
|---|---|---|
PM_RQ_BASE | 0x700 | PM: PM_GETPID / FORK / EXIT / WAIT / EXEC |
VFS_RQ_BASE | 0x800 | VFS: VFS_WRITE / OPEN / READ / CLOSE / EXEC_STAGE |
FS_RQ_BASE | 0x900 | File systems: FS_READSUPER / LOOKUP / READ / WRITE / CREATE / TRUNC (MFS) |
BDEV_RQ_BASE | 0xA00 | Block drivers: BDEV_READ / BDEV_WRITE (memory) |
CDEV_RQ_BASE | 0xB00 | Character drivers: CDEV_WRITE (TTY, memory driver) / CDEV_READ (memory driver; TTY answers ENOSYS until Phase 6) |
VM_RQ_BASE | 0xC00 | VM: VM_PAGEFAULT / BRK / MMAP / MUNMAP / FORK |
SEF_RQ_BASE | 0xD00 | SEF control messages (ping / signal / init) |
DS_RQ_BASE | 0xE00 | DS: DS_PUBLISH / RETRIEVE / CHECK |
SCHED_RQ_BASE | 0xF00 | SCHED: SCHEDULING_NO_QUANTUM / START / STOP / SET_NICE |
SEF: the server runtime
server-rt is minix.rs’s small equivalent of MINIX 3’s SEF (System Event
Framework). A server calls sef_startup(SefConfig { init_fresh, signal_handler }),
which learns the server’s own endpoint and name from the kernel via
SYS_GETINFO(GET_WHOAMI), runs the optional init_fresh callback, and returns a
Sef handle. The main loop is then loop { if sef.receive(&mut msg) != 0 { continue } match msg.m_type { … } }:
sef.receive wraps ipc_receive(ANY, …) and transparently handles SEF control
traffic — an RS heartbeat ping, a SEF_SIGNAL from PM/RS, a SEF_INIT — so the
server only sees genuine application messages.
The classifier (server-rt/src/classify.rs, host-tested) gates each control
event on the message’s source, not its type alone: an RS ping is only honored
from RS, a signal only from a signal manager, an init only from RS. A client
holding a mere ipc_to bit to the server cannot spoof one. server-rt is
#![forbid(unsafe_code)] — callbacks travel in the config struct, not global
state. The init_fresh body most servers use is the shared
sef_publish_to_ds(endpoint, name) helper, which registers the server in DS.
DS: the name registry
Servers discover each other by name through DS (servers/ds/), a
name→endpoint registry backed by a static [Entry; 16] table
(servers/ds/src/registry.rs; the pure publish / retrieve / check helpers
are host-tested). A DS_PUBLISH request carries a 16-byte NUL-padded name in
payload 0..16 and the publisher’s endpoint in 16..20. DS is the one server
that cannot publish to itself over IPC — a SENDREC to itself before reaching
its receive loop would deadlock — so it seeds its own entry in-process during
ds_init.
SCHED: user-space scheduling
The kernel scheduler is delegatable rather than replaced. Each Proc carries
a scheduler endpoint; NONE (the boot default) means kernel-scheduled — the
kernel refills the quantum and rotates the run queue. A non-NONE value means the
process is scheduled by a user-space server: on quantum exhaustion the kernel
dequeues it, leaves RTS_NO_QUANTUM set, and sends SCHEDULING_NO_QUANTUM to its
scheduler, which decides when to re-admit it via SYS_SCHEDULE.
SCHED (servers/sched/) is that scheduler. It claims a target with
SYS_SCHEDCTL (setting scheduler = SCHED), tracks it in a static
[SchedProc; 16] policy table (servers/sched/src/policy.rs, host-tested), and
on each SCHEDULING_NO_QUANTUM refreshes the quantum at a fixed managed band
(USER_Q = 8, the boot-server band, so a CPU-bound managed process round-robins
instead of starving behind kernel-scheduled work). SCHED itself and the kernel
tasks stay NONE — a scheduler must not schedule itself. SCHEDULING_START /
STOP are the hooks PM drives during fork and exit; MINIX-style priority aging is
left for later.
RS: the reincarnation server
RS (servers/rs/) is the system-process monitor and the root of the boot
process tree. It arms a periodic one-shot alarm (SYS_SETALARM, ALARM_PERIOD = 100 ticks) and on each fire pings a fixed peer set (DS/VM/SCHED/VFS/PM) with
ipc_notify, tallying acknowledgements in a host-tested monitor
(servers/rs/src/monitor.rs). Peers acknowledge through the ordinary SEF ping
path, so no extra wiring is needed. In Phase 4 restart-on-crash is detect-only —
RS counts unresponsive peers but cannot yet re-exec them (exec of a fresh service
image is future work). The alarm expiry arrives as a kernel-originated NOTIFY
from CLOCK, which RS distinguishes from its own SEF ping by keying on
m_source == boot_endpoint(CLOCK).
PM: the process manager
PM (servers/pm/) owns the POSIX process lifecycle. Its mproc table
(servers/pm/src/mproc.rs, host-tested) records one entry per process — pid,
parent, a generation-aware endpoint, and flags. Boot servers and the demo stubs
are seeded at init; forked children are allocated from a pool
([FORK_POOL_BASE, NR_MPROCS)) where a slot’s index is also the child’s kernel
proc number.
User processes drive their whole lifecycle through PM — the POSIX shape, user →
server, never user → kernel (the shared user privilege opens ipc_to edges to PM
and VFS, and nothing else):
PM_GETPIDreplies with the caller’s pid (m_typeis the pid, MINIX result-is-pid), parent pid in the payload.PM_FORKbuilds a child in a fixed, safety-critical order: allocate themprocslot,SYS_FORK(the kernel clones a frozen child —RTS_RECEIVING | RTS_NO_PRIV),VM_FORK(VM copies the parent’s regions),SCHEDULING_START, thenSYS_PRIVCTL(PRIVCTL_SET_USER)to release the freeze — and finally replies to both halves of the shared SENDREC (child sees0, parent sees the child pid: fork returns twice). Only PM’s reply clearsRTS_RECEIVING, so the child cannot run before its identity, memory, and scheduling are fully built. Any mid-fork failure rolls back every completed step.PM_EXECissuesSYS_EXECnaming the caller as the target; the kernel replaces the caller’s image with a boot-embedded binary and resumes it at the new entry (no reply on success). Phase 4 hardcodes the target asworker; a user-supplied path arrives with the Phase-5 filesystem.PM_EXITdoesSCHEDULING_STOPthenSYS_EXIT(full teardown: address space freed, endpoint generation bumped, slot freed) and marks themprocslot a zombie holding the encoded status; the dead child gets no reply.PM_WAITreaps a zombie child (reply pid + status, free the slot) or, if a live child exists, suspends the parent until the child’s exit wakes it. There is no asyncSIGCHLDin Phase 4 — the zombie + wait-reap handshake is the only parent notification, because the kernel signal path default-terminates and would kill a handler-less parent.
Minimal signals
PM is also the signal manager for user processes. The kernel half is a small trio
(SYS_KILL / SYS_GETKSIG / SYS_ENDKSIG): SYS_KILL records a bit in the
target’s Proc::sig_pending, sets RTS_SIGNALED | RTS_SIG_PENDING, and wakes PM
with a kernel-originated NOTIFY. PM drains pending signals with SYS_GETKSIG and
disposes of each — SYS_ENDKSIG to acknowledge a survivor, or SYS_EXIT to
terminate. Handlers (catching, sigaction) are Phase 5; Phase 4’s default action
for a user process is termination.
VFS: the write, read, and exec-staging paths
VFS (servers/vfs/) turns a small integer into something you can read from or
write to. Since slice 5.4 it does the writing for real — an ordinary user process
can write(1, buf, len) and see bytes on the console — and since 5.8 it does the
reading too, against a real filesystem served by MFS.
One request, one copy
user ──VFS_WRITE{fd,buf,len}──► VFS ──CDEV_WRITE{minor,gid,len,off}──► driver
│ │
└── magic grant: caller's buf ──────────┘
(kernel copies, once)
VFS resolves the descriptor, issues a magic (third-party) grant naming the
caller’s buffer with the driver as grantee, and forwards the grant id. TTY
safecopies straight out of the caller’s address space; the memory driver’s
/dev/null and /dev/zero writes issue no SYS_SAFECOPY at all — they discard
the bytes and reply the whole count with no copy. Either way the bytes never
pass through VFS: at most one copy happens, from the process that wrote them to
the driver that transmits them. This is the first consumer of the magic grant
form on a real data path, and the rail slice 5.6’s musl write() lands on.
Three properties hold that path together:
- The grant’s owner is the kernel-stamped
m_source. VFS holdsSYS_PROC, which is what makes a magic grant legal for it at all — so a caller-supplied owner field would let any VFS client aim a privileged cross-address-space copy at a third party’s memory.VFS_WRITEhas no such field, and must never gain one. It is the same anti-confused-deputy rule that keeps a granter out of theCDEV_WRITEpayload, applied to the granting side. - VFS absorbs short writes. A character driver may move fewer bytes than asked
(
CDEV_MAX_IO, its staging limit); POSIXwrite()is not allowed to expose that, so VFS re-sends withoffsetadvanced until the buffer is out and reports the total. One grant covers the whole buffer — only the offset moves. An error after partial progress reports the progress, since those bytes really did go out. The file-backed route slice 5.10a added loops on the same rules but grants afresh each round, because the FS band deliberately has no grant-offset field: there, the grant is what moves. - Every request gets a reply, including an unknown one (
ENOSYS). VFS’s clients are all inside a SENDREC, so a dropped message blocks the caller forever.
The descriptor table
servers/vfs/src/fd.rs holds one row of descriptors per process, indexed by
kernel proc number and sized from the shared NR_SERVED_PROCS ceiling that PM’s
mproc and VM’s ClientRegions also derive from. Every row starts identical —
fds 0, 1, and 2 name the console, everything else is EBADF — which is POSIX’s
inheritance convention and is what lets init write before any filesystem exists.
Since slice 5.11 a character-device entry names its driver as well as its
minor (Fd::CharDev { dev: CharDriver, minor }, with CharDriver an enum
because the default row is a const and a DS-resolved endpoint is not) — minors
are a per-driver namespace, so the driver is half the address. open consults a
three-row device-node table (servers/vfs/src/dev.rs: /dev/console,
/dev/null, /dev/zero, matched byte-for-byte) after copying the path in and
before touching the mount, so a device open needs no filesystem; O_CREAT and
O_TRUNC are ignored on a hit, Linux’s behaviour for a device node. Everything
else falls through to MFS, /dev/other included, and there is no /dev on the
image at all. A device read() is one CDEV_READ against the descriptor’s
driver, no loop and no position; a console read() therefore reaches TTY and
hears ENOSYS from its unknown-request arm until Phase 6.
Slice 5.8’s open is what makes rows diverge, and it moved the storage to the
UnsafeCell newtype VM’s region table already uses. That brings a rule with it:
never hold a borrow of the table across a SENDREC. Fd is Copy precisely so
that is easy to obey — a resolve’s borrow dies at the destructuring let, and the
handler carries values into the round trip.
open hands out the lowest free descriptor, POSIX’s rule and the only thing
about it a client can observe without reading the file: close one and the next
open reuses that number. close frees the slot and sends the filesystem
nothing, because MFS keeps no per-open state — which is also why the FS band has
no PUTNODE.
The read path, and its two copies
user ──VFS_OPEN{path,len,flags}──► VFS ──SYS_COPY──────────► (the path, into VFS)
│
└──FS_LOOKUP{path}─────► MFS → (ino, mode)
(or FS_CREATE / FS_TRUNC, below)
user ──VFS_READ{fd,buf,len}──► VFS ──FS_READ{ino,gid,len,pos}──► MFS
│ │
└── magic grant: caller's buf ────┘
Two copies, not one, and the difference from the write path is deliberate: MFS stages a block through its own buffer before safecopying the requested slice out of it. A MinixFS read is rarely block-aligned in both the file and the destination, and a hole has no device block to copy from at all — so the staging cannot be elided. This is MINIX 3’s own shape. Only the second copy is VFS’s grant; the bytes still never pass through VFS.
open is not lookup-only. Slice 5.10b gave VFS_OPEN a third payload field,
flags (VFS_FLAGS_OFF, i32), read straight from the same kernel-shared/fcntl
that musl’s open() fills in. A plain lookup answering ENOENT is no longer the
end of the story: O_CREAT on a missing name dispatches FS_CREATE instead, and
O_TRUNC on an existing regular file dispatches FS_TRUNC after the lookup
succeeds. O_CREAT | O_TRUNC on a missing name takes the create arm and stops
there — a fresh file is already empty.
The descriptor is allocated before that truncate runs, and handed back if it
fails. A full descriptor table is EMFILE, and a caller whose open failed
has no reason to believe anything changed — so emptying the file first and then
refusing the descriptor would destroy its contents behind a failure. Linux orders
it the same way. The converse still holds too: a truncate that fails closes the
descriptor before returning, so nobody is ever left holding one onto a
half-truncated file. The access-mode bits (O_RDONLY/O_WRONLY/O_RDWR) are accepted and
ignored — there is no uid, gid, or permission check anywhere in the tree, so
honouring them would be a check with nothing behind it. A flag bit outside
O_KNOWN is EINVAL, and that comparison is written against O_KNOWN
rather than as a literal mask, so a future flag becoming real fails a stale denial
probe loudly instead of letting it pass vacuously — the same lesson slice 5.8’s
VFS_WRITE + 1 probe taught the hard way.
Two more properties, each with its own boot marker:
SYS_COPYreads the path, and its source is the kernel-stampedm_source. This is the first live consumer of decision D4’s “SYS_COPYfor small control-plane reads” sentence, and the confused-deputy rule in its sharpest form:SYS_COPYhas no per-target authorization whatsoever — the caller’sk_call_maskbit is the whole check — so a payload-supplied source process would let any client read any process’s memory through VFS.- VFS does not loop on read. It loops on
writebecause a driver’s staging limit may not reachwrite()’s return value;read()is explicitly allowed to return less than asked for, and a file read is short at EOF regardless. EOF is a read returning0— no file’s size is cached anywhere along the path, so that is the single source of truth.
Staging an executable
Slice 5.9 gave VFS one more request, and it is the only one that reads a whole file:
PM ──VFS_EXEC_STAGE{path}──► VFS ──FS_LOOKUP──► MFS → (ino, mode, size)
│
├──FS_READ × N──► MFS → bytes into EXEC_STAGE
│
└── direct grant over EXEC_STAGE (CPF_READ) ──► PM
PM hands that grant to SYS_EXEC, and the kernel reads the ELF through it —
so the bytes pass through neither PM nor the kernel’s own memory, and the kernel
gains no filesystem (decision D6). Four things are worth stating:
- The path travels inline, unlike
VFS_OPEN’s pointer-and-length. The client is PM, which already holds the path inline in thePM_EXECit is serving, so passing it by value costs noSYS_COPY— and it deletes the confused-deputy question outright, because there is no source process for a caller to misname. - Only PM may ask. Any other
m_sourceisEPERM, and init’s denial battery is the only thing that exercises that guard. - A short stream is
EIO, not a short stage. Everywhere else in VFS a partial transfer is a legitimate answer; here it is not, because an ELF cannot be loaded in pieces by a loader with no filesystem. - The staging buffer is a 256 KiB
.bssstatic, for MFS’s block-buffer reason: a server’s stack is one page, so a local would fault into VM’s SIGSEGV arm, which prints nothing the forbidden-marker list catches. Unlike MFS’s block buffer it needs no capability token and no borrow discipline — VFS never dereferences the staged bytes. MFS writes into them by safecopy and the kernel reads them through the grant; VFS only ever needs the address.
Nothing releases the grant afterwards and nothing needs to: each request re-grants the same buffer, which bumps the sequence and kills the previous id, and PM serialises exec so two staged images are never alive at once.
VFS also remains the system’s first grant client and first console client:
its startup still direct-grants a read-only buffer to PM (slice 5.2) and drives
CDEV_WRITE by hand (slice 5.3). Those are kept deliberately, as the regression
battery for three contracts the real write path never reaches — the direct-grant
form, a visible short write, and the two CDEV_WRITE refusals a well-formed
write() cannot provoke. Slice 5.7’s block-device demo, by contrast, is gone:
MFS is the real BDEV client now, so the battery moved there and VFS is back to
knowing nothing about block devices.
MFS: the file system
MFS (fs/mfs/) is the first file system in minix.rs — read-only as of slice
5.8, writable as of 5.10a, and able to create and truncate files as of 5.10b. It
sits between VFS and a block driver, and it is the piece that makes a path
resolve to bytes: VFS asks FS_LOOKUP for an inode, FS_READ for its contents,
FS_WRITE to replace them, FS_CREATE to name a new one, and FS_TRUNC to
discard one’s contents, and MFS answers by moving blocks to and from the
memory ramdisk over BDEV, decoding them with the minixrs-mfs format library
(superblock, inode, layout, dirent, read, 5.10a’s write, and 5.10b’s
allocator) that slice 5.7 began and host-tested. The image lives in RAM, so a
write survives until the machine stops — long enough to be read back and proved,
not long enough to be persistence.
The crate is split unusually hard. Its [[bin]] carries
required-features = ["server"] so the format library stays a one-dependency
crate the kernel’s build script can use for free — and the price is that the
binary is invisible to every CI job except the QEMU boot smoke test. So every
line with a decision in it lives in the library (proto.rs for the wire codec,
walk.rs for traversal and read policy), and main.rs is SEF/IPC/grant glue.
Three things characterise the server itself:
- One 4 KiB block buffer, in
.bss. A boot server’s stack is exactly one page and a block is exactly one page, so the buffer cannot be a local — the frame base would land below the mapping, and VM turns that fault into a SIGSEGV that prints nothing the forbidden-marker list catches. It is reached only through aBlockscapability token whoseread(&mut self) -> &[u8; N]makes “hold a directory block across the next fetch” a borrow-check error rather than a promise. Every intermediate the walk needs is a smallCopyvalue. - Streaming, not buffering.
tools/mkfs-mfs’sverify.rsis the reference implementation of the same reader, but it materializes a whole directory into aVec; MFS asks about one block at a time and keeps nothing but au32. Thefs.selfcheckboot marker is the one place the two readers meet over a real image. - Degraded, never fatal. Past
sef_startupnothing panics and nothing spins: a failed mount answersENODEVto every request, and every device-derived loop bound has a cap, because a corrupt inode claimingsize = i32::MAXwould otherwise spin MFS — which would block VFS, which would block init.
Two error-relay rules sit side by side and read as contradictory. A failed
BDEV_READ becomes EIO, because MFS’s client addressed a file and the device
beneath it is an implementation detail. A failed SYS_SAFECOPY against VFS’s
grant is relayed verbatim, because EPERM (“your grant does not authorize
this”) and EFAULT (“your buffer is not mapped”) are different bugs on the
caller’s side.
The write path
FS_WRITE (slice 5.10a) is FS_READ’s payload field for field — inode, grant,
length, position — because it is the same question asked in the other direction,
and one wire codec and one clamp serve both. The reply m_type is the byte count
stored. Nothing in the payload says which direction it is; the request number
does, and the dispatch arm is the only place that needs to know. The grant is the
one thing that must differ, and it differs by direction: an FS_READ’s carries
CPF_WRITE because the copy lands in the client’s buffer, an FS_WRITE’s carries
CPF_READ because the copy is taken out of it. Neither server checks that — the
kernel’s verify_grant does, and no server re-implements it.
A short write is normal here, not an error. MFS clamps every request to the
end of the block containing pos, so one call moves at most a block and usually
less. That is CDEV_WRITE’s stance and deliberately not BDEV_READ’s
refuse-or-nothing, and the two are consistent once you ask who the client is: BDEV
refuses because its client is a filesystem, which cannot interpret a fraction of a
block, while this request’s client is VFS, whose whole job is hiding staging from
POSIX. So VFS loops, one fresh grant per round.
Writing where no zone exists means allocating one, and the order is the part
worth remembering: the bitmap bit is made durable before the zone number is stored
anywhere — an inode’s zone[i], an indirect block’s slot, either. A failure
between the two therefore leaks a zone rather than letting two files share one,
and the asymmetry is the whole argument: a leak is unreachable space some future
fsck can reclaim, while a shared zone is silent corruption on a filesystem that
has no fsck to notice it. The alternative — rolling the bit back on the error
path — is worse in exactly the direction that matters, because a rollback that
itself fails hands the same zone out twice; worse still, an indirect slot whose
indirect block already existed has the zone durably referenced by that block
the instant the bit is set, so clearing the bit again on any later failure would
hand the same zone to two files rather than merely leaking it. A freshly
allocated zone is also zeroed before anything can reach it, which is what makes
a new indirect block safe to read: all 1024 of its slots come back as holes
rather than as whatever the previous owner left there, which this code would read
as zone pointers.
Slice 5.10b closes the one gap that ordering alone didn’t cover. Through
5.10a, do_write allocated a zone and only then copied the client’s bytes out
of its grant — and that copy could still fail on the client’s own account, if the
buffer it granted was unmapped. Looping write() against such a buffer leaked
one zone per call (each EFAULT, none rolled back, for the reason above), which
exhausted the image’s free zones in under 200 calls and left every write after
that — including a legitimate one — answering ENOSPC for the rest of the boot: a
reachable denial of service, not a benign leak. The fix is a second .bss
staging buffer (Stage) that do_write fills from the client’s grant before
anything is allocated, so no client-controlled failure can occur after an
allocation. Note what this is not: it is a restaging, not a rollback — the
corruption case above (an indirect slot’s already-existing block) is exactly why
rolling back was never the right fix. The fs.leak boot probe proves the closure
directly: 256 writes aimed at an unmapped buffer must all answer EFAULT and
allocate nothing, and a real write must still succeed afterwards.
The write path checks zone numbers against a lower bound the read path
deliberately lacks (write_zone_ok, requiring zone >= first_data_zone). The
reader can afford to be loose, because the worst a corrupt pointer costs it is the
wrong bytes. A write to the same pointer destroys what it is aimed at: an inode
whose zone[i] reads back as 3 would have MFS store a data block straight over
the zone bitmap, and an indirect pointer of 4 would have it patch and store a
block of the inode table. Nothing the allocator hands out can be that low — but a
zone number read off the device is whatever the device says.
One field is deliberately left alone: mtime and ctime are not updated, because
there is no clock a user-space filesystem can read yet. A written file keeps the
timestamps tools/mkfs-mfs stamped into it, on the grounds that an obviously
stale timestamp is better than an invented one — and this becomes a real field to
fill the moment a clock is reachable.
Create, truncate, and directory growth
Slice 5.10b gave the FS band two more requests. FS_CREATE reuses FS_LOOKUP’s
wire codec verbatim — same request shape, same reply shape, one parser and
one classifier for both — because a create is a path operation exactly like a
lookup, just one that is allowed to make something exist. FS_TRUNC carries only
an inode number and discards a regular file’s contents down to zero, with no
length field: O_TRUNC is the only client anywhere in the tree, and there is no
ftruncate() to serve.
Both new requests mirror the write path’s leak-over-corruption ordering, in the
opposite order from each other, on purpose. create allocates the inode and
writes it back before the directory entry names it — a failure in between
orphans an inode (a leak, reclaimable by a future fsck) rather than leaving a
directory entry pointing at an inode that was never written. And nothing
reaches that failure, because create extends do_write’s “no
client-controlled failure after an allocation” rule to its own path:
reserve_slot places the directory’s slot — including the zone its growth may
need, the only ENOSPC a client can provoke here — before alloc_inode claims
anything. A reservation that fails has allocated nothing; one that succeeds
leaves the directory one legitimately grown block larger, which is not a leak
because the parent inode names that zone. Without that ordering the path would be
the 5.10a denial of service one step later: each failure would burn one of the
image’s 128 inodes for good, and unlike a leaked zone — which do_trunc hands
back — no amount of truncating recovers an orphaned inode. do_trunc writes
the zeroed inode back before freeing the zones it used to hold — a failure in
between merely fails to reclaim some zones, where the reverse order could leave a
live inode still naming zones the allocator has already handed to someone else.
That truncate ordering has no boot probe, and the honest thing is to say so
rather than let a passing boot imply otherwise: proving it needs a failure
between the inode write-back and the zone free that nothing this slice can send
induces — the same class of gap slice 5.10a documented for the dirty half of
the write-back condition, recorded here rather than repeated by omission.
A directory grows through the same allocator a file’s data does.
find_free_slot tries every existing block first — and it does not stop at the
first free slot it finds, because a name occupying a later block would
otherwise get shadowed by a duplicate inserted ahead of it; Occupied has to win
over Free across the whole scan, not just within one block. Only when no
block has room does reserve_slot append one, and it does that through
place_zone, the exact function do_write uses for file data — so directory
growth costs no second code path and inherits the bitmap-before-pointer ordering
already proved for files. The image ships /full, a directory with . and ..
plus 62 empty files — exactly 64 entries, one block — so that a single boot-time
create is guaranteed to take the append arm; no other probe reaches it.
/etc/holey plays
the same role for the write-back condition’s other half: its first block is a
hole, so writing into it assigns a zone pointer with the file’s size unchanged,
which is the one case a size-only write-back condition would silently drop.
FS_CREATE on a name that already exists is EEXIST, checked by re-resolving
the name afterwards and confirming the inode number did not change — the
errno alone would not catch a dropped guard that shadowed the original entry with
a second one.
init: PID 1
init (userland/init/) is the first real user process and the live exercise
for everything above. Unlike the demo stubs it replaced, it is a genuine boot
module: build.rs packs it into the MXBI archive with its true proc number
(INIT_PROC_NR = 10), and the ordinary boot loop loads it and makes it runnable —
PM does not hand-release it. It runs at user grade, sharing the USER_PRIV_ID
privilege (SENDREC to PM and VFS, no kernel calls) with every forked child.
Since slice 5.4 it also speaks. Before the respawn loop it writes to fd 1 and fd 2
through VFS, which is the whole POSIX write path exercised from the one place that
proves it matters: a process with no kernel calls, no grant table, and no debug
channel of any kind. That last part is deliberate — write() is init’s only way
to say anything, so it reports on the path under test through the path under test,
and a regression takes the evidence with it. It prints a banner, a line longer than
one CDEV_WRITE can carry (whose tail marker only appears if VFS looped, and whose
returned count init checks against what it asked for), and four probes that must
each be refused: a closed descriptor (EBADF), an unknown request number
(ENOSYS — and the reply is the assertion, since a dropped request would hang
init and the boot with it), an unmapped buffer (EFAULT, from the kernel’s
page-table walk, which costs init no page fault because the copy engine walks
rather than dereferences), and a negative length (EINVAL).
init is a plain minixrs-ipc program — no SEF, because it is not a server. The rest
of its body is a respawn loop: PM_FORK; the child (m_type == 0) issues
PM_EXEC naming the binary it wants to become; the parent (m_type > 0) issues
PM_WAIT to reap the zombie, then loops. Each cycle recycles a fork-pool slot
with a fresh endpoint generation — observable in the boot trace as repeating
SYS_FORK → SYS_EXEC → SYS_EXIT triples, the proof that fork, exec,
teardown, and reap all compose.
Since slice 5.6 the exec target is the caller’s choice — PM_EXEC carries a
name, rather than PM hardcoding one — and init alternates between two
binaries, so the trace shows name=worker and name=hello on successive
cycles:
workeris slice 5.5’s exec-ABI probe. It validates the SysV initial stack against its ownspand reports the verdict as its exit status, which init prints once (keyed on the child’s pid, because PM parents the demo stubs to init and stub D’s deliberate SIGSEGV would otherwise be the first thing reaped).hellois slice 5.6’s C milestone, linked against the musl fork.
Alternating rather than switching is deliberate: retiring worker to make room
for hello would have taken the exec-ABI proof down with it. See
C Library & musl Port.
The demo stubs A–D remain installed alongside init as a live regression battery: A↔B exercise the raw SEND/RECEIVE/SENDREC primitives, C exercises the kernel→SCHED quantum-delegation round-trip, and D exercises the page-fault→VM path and the out-of-region SIGSEGV kill — coverage that init and worker, which only fork/exec/wait/getpid, do not provide.
Drivers
A driver in minix.rs is an ordinary user-space process that happens to own a
piece of hardware. It is not a special kind of kernel module and it holds no
kernel privilege beyond a k_call_mask bit or two — it is a
#![no_std]/#![no_main] ELF, loaded from the MXBI archive into its own address
space, driving a SEF receive loop exactly like a server.
What separates a driver from a server is one page in its address space that no other process has: the device’s memory-mapped registers. Everything interesting about this chapter follows from that page.
As of Phase 5 there are two drivers: TTY (drivers/tty/), the console, and
memory (drivers/memory/), the boot ramdisk plus, since slice 5.11, the
/dev/null and /dev/zero character minors. The VirtIO
block, network, and console drivers under drivers/ are still empty placeholders
(Phase 6), and so is drivers/driver-rt — the shared driver runtime they will
eventually use.
The two are worth contrasting up front, because memory is the counter-example to
the paragraph above: its window is ordinary RAM, not MMIO. It owns no hardware
at all. What makes it a driver rather than a server is its protocol — it answers
BDEV requests for the ramdisk and
CDEV requests for its null/zero minors, and knows nothing
about what its blocks contain. That is exactly the property Phase 6 needs, when
virtio-blk replaces it underneath an unchanged MFS.
The device window
The kernel and user space agree on a range of virtual addresses reserved for MMIO,
declared in kernel-shared/src/uspace.rs:
#![allow(unused)]
fn main() {
pub const USER_DEVICE_WINDOW_BASE: u64 = 0x4000_0000; // 1 GiB — one whole L1 slot
pub const USER_DEVICE_WINDOW_SIZE: u64 = 0x0100_0000; // 16 MiB
pub const TTY_UART_VA: u64 = USER_DEVICE_WINDOW_BASE; // page 0 of the window
}
That module is deliberately separate from message.rs, which holds every other
shared constant. The rest of kernel-shared describes a message ABI — bytes on
the wire between two processes, where a wrong value gets a request rejected. This
is an address ABI: the kernel installs a mapping and user code dereferences it,
with no message in between, and a wrong value is a data abort.
0x4000_0000 is chosen to be a whole L1 slot (1 GiB-aligned, so the window costs
exactly one L1 entry) and to sit clear of every occupied user VA — server images
at 1 MiB, server stacks at 2 MiB, demo-stub code and stacks at 4 and 8 MiB, VM’s
heap origin at 16 MiB, VM’s mmap arena at 32 MiB.
Two of those grow on request, and a compile-time assert on their bases would prove
only where they start. The heap’s end is whatever brk last asked for, and the mmap
arena is a bump allocator that never reuses addresses — so servers/vm/src/region.rs
bounds both with a runtime REGION_LIMIT check (ENOMEM past it) rather than
trusting the 992 MiB of slack. That matters even though nothing is exploitable
today: the window’s whole purpose is to be kernel-owned in every address space, so
Phase 6 can pre-map a device page into any driver without first asking whether VM
already promised that VA to the process’s heap.
The boot pre-map
TTY has no way to ask for its register page. There is no VMCTL_MAP_PHYS
subcall — a subcall letting a user-space server name an arbitrary physical address
would hand it the whole machine — so decision D1 deferred VM-mediated device
mapping to Phase 6 and made the one mapping Phase 5 needs a kernel bring-up step.
The kernel installs it in load_boot_server (arch/aarch64/userland.rs), right
after the ELF and stack are in place and just before the process is enqueued:
#![allow(unused)]
fn main() {
if nr == TTY_PROC_NR {
map_page_in(img.ttbr0_pa, TTY_UART_VA, uart::PL011_PHYS_BASE as u64, Prot::DEVICE_RW)
.expect("TTY UART pre-map");
}
}
Two placement details matter:
- Not in
load_exec_image. That helper is shared withsystem::do_exec, so a device mapping there would be inherited by every binary any process ever exec’d. The consequence is worth knowing in the other direction too: a process that exec’d would lose its device window. - No TLB maintenance. The address space was built moments ago and has never
been installed in TTBR0 (and a recycled ASID is always clean, because address-space
teardown flushes before returning the ASID to the pool). Beyond that,
switch_ttbr0_with_asid— which runs on TTY’s first schedule — already issuesisb; tlbi aside1; dsb ish; isb. This is the same reasoning the server stack-page mapping already relies on.
The boot log records it as [devmap] tty va=0x40000000 pa=0x9000000 attr_idx=<n>.
The two addresses are fixed constants and the boot-log checker asserts both; <n>
is whichever MAIR index the scan below settled on, which depends on what the
bootloader programmed, so it is deliberately not asserted.
Device memory: read the MAIR, never write it
An MMIO mapping must be Device memory, not the Normal write-back the rest of user space uses: the CPU must not cache a register read, must not merge two register writes into one, and must not reorder a data-register store ahead of the flag-register poll that gates it.
On aarch64 the memory type of a page comes from its descriptor’s 3-bit AttrIndx
field, which selects one of eight bytes in MAIR_EL1. The obvious move — program a
byte with a Device encoding — is the one thing the kernel must not do. Changing byte
i retroactively changes the memory type of every live mapping that uses
AttrIndx=i, and that includes Limine’s TTBR1 kernel and HHDM mappings, whose
indices this codebase has no way to enumerate. Turning the HHDM into Device memory
would be silent, unrecoverable corruption.
So mmu::init_device_attr_idx() reads MAIR_EL1 and reuses an index that
already encodes a Device type. The observation that makes reading sufficient: an
unprogrammed MAIR byte reads 0x00, and 0x00 is itself a valid MMIO encoding
(Device-nGnRnE). “An index that already encodes Device” and “an index nobody uses”
therefore coincide — either way, mapping through it is correct. Reading changes
nothing, so no barrier or TLB invalidation is needed.
Only two encodings qualify. 0x04 (Device-nGnRE) is preferred; 0x00
(Device-nGnRnE) is stricter and accepted. Device-nGRE would permit gathering, so
two data-register stores could merge into one and lose a character; Device-GRE
would additionally permit reordering the store ahead of its poll. On QEMU with
Limine today the scan settles on index 1 — reported as
[mair] device attr_idx=1 byte=0x00, which is forensic output and deliberately
not a boot-log marker, since which index is free depends on the bootloader.
Prot.device and the RAM/device invariant
Prot — the kernel’s “what may EL0 do with this page” type — carries a third flag
beside writable and executable:
#![allow(unused)]
fn main() {
pub struct Prot { pub writable: bool, pub executable: bool, pub device: bool }
}
Adding it was a compile error at every struct literal, which was the point: there
were exactly two, and both had to make a decision. do_vmctl’s VMCTL_PT_MAP
answers device: false permanently — VM may not mint device mappings.
The flag exists because a device leaf’s physical address is not a frame the
allocator owns, so every path that tears down an address space must not hand it
to free_frame. Rather than leave that as a convention, map_page_in makes it a
total invariant:
#![allow(unused)]
fn main() {
if prot.device { assert!(!is_usable_pa(pa), "device mapping of RAM PA {pa:#x}"); }
else { assert!( is_usable_pa(pa), "normal mapping of non-RAM PA {pa:#x}"); }
}
Every mapped leaf is therefore provably either (RAM ∧ ¬device) or
(device ∧ ¬RAM), with no third case — which is what makes
if !prot.device { free_frame(…) } sound in each of the five leaf sweeps
(exit teardown, fork’s copy loop, fork’s out-of-memory unwind, VMCTL_PT_UNMAP,
and the exec-load error path) rather than merely plausible. free_frame keeps its
loud out-of-range assert for the same reason: a catch-all that silently skipped
non-RAM frames would demote a forged-address or double-free bug into an
untraceable leak.
Two further consequences:
- Fork re-maps a device leaf, it does not copy it. MMIO is inherently shared, and copying 4 KiB of live device registers through the cacheable HHDM alias would read side-effecting registers into RAM.
mm::uaccessrefuses a device leaf as copy source or destination (EFAULT), closing the hole where a process grants a peer its own register window and the kernel then touches MMIO through a cacheable alias.
Because TTY never exits, the teardown path’s device arm would be untested code
sitting on a live landmine — and a missing guard is a kernel panic, not a leak. So
userland_bootstrap runs a small unconditional selftest at boot: build a throwaway
address space holding exactly one device leaf, tear it down, and assert the result.
It reports [devmap] selftest ok freed=0 devs=1, asserting both numbers —
freed=0 proves the leaf was not freed, and devs=1 proves it was actually seen
(a guard that skipped every leaf would report devs=0).
The CDEV protocol
Character drivers answer requests in the CDEV_RQ_BASE = 0xB00 band. Phase 5
defines two, sharing one payload:
| Field | Payload offset | Meaning |
|---|---|---|
| minor | 0..4 (i32) | which device; CDEV_MINOR_CONSOLE = 0 is the UART |
| grant id | 4..8 (i32) | names the client’s buffer (CPF_READ for a write, CPF_WRITE for a read) |
| length | 8..12 (i32) | bytes requested |
| offset | 16..24 (u64) | where in the granted range to start |
Five properties of that table are load-bearing.
There is no granter field. The driver takes the granter from the
kernel-stamped m_source. TTY holds SYS_SAFECOPY and its clients do not, so a
caller-supplied granter endpoint would let any client aim a privileged
cross-address-space copy at a third party’s memory through the driver — a
confused deputy. This is the same anti-spoof property DS_PUBLISH relies on, and
it binds every grant-id-carrying request in the CDEV, BDEV, and FS bands.
The reply is a byte count, not a status. m_type comes back as the number of
bytes transferred (written or read; >= 0, zero is legal) or a negative errno —
which direction depends on the request, covered in its own paragraph below. A
driver replying OK would be telling its client that the whole buffer went out.
A driver MAY answer short — never must. The client’s contract, POSIX
write()’s, is to re-send with offset advanced until the request is out; that
is what lets a driver stage through a fixed buffer in its main frame with no
allocator at all. TTY clamps to CDEV_MAX_IO (256 bytes) for exactly that
reason. The memory driver’s /dev/null and /dev/zero (slice 5.11) stage
nothing and never clamp — a CDEV_WRITE longer than CDEV_MAX_IO still comes
back reporting the whole count.
CDEV_READ is the same payload, copy reversed (slice 5.11). The reply is
the byte count read, 0 is EOF, and a short read is legal — POSIX read()’s
contract and the one VFS already assumes for FS_READ, so VFS sends one request
and reports what came back. It existed only as a plan note until /dev/zero
needed it: the 5.3 text said the two devices would be “new minors, not new
requests”, which is true of /dev/null and of writing /dev/zero and false of
reading it. TTY does not serve it until Phase 6 gives it RX (SYS_IRQCTL), and
answers it ENOSYS from its unknown-request arm until then — VFS routes a
console read() there anyway, so Phase 6 changes TTY and nothing else.
Minors are a per-driver namespace. TTY’s console is 0; the memory driver’s
/dev/null and /dev/zero are CDEV minors 3 and 5 (MINIX 3’s NULL_DEV and
ZERO_DEV), on the same driver as BDEV minor 0’s ramdisk. The request band, not
the minor value, tells them apart.
TTY
drivers/tty/ is three files:
cdev.rs— the pure, host-tested half:validate_writeapplies the checks in order (unknown minor →ENXIO, negative length →EINVAL, invalid grant id →EINVAL, then clamp toCDEV_MAX_IO) (the four-field parse moved toserver-rt::cdevin 5.11, when the memory driver became its second user). It is a total function, so a malformed request becomes an invalid value the validator rejects, never a panic.pl011.rs— the crate’s onlyunsafe: volatile accesses toFRandDRatTTY_UART_VA, pollingFR.TXFFbefore each store, translating LF to CRLF. The register offsets are deliberately duplicated from the kernel’s own PL011 writer; they cannot be shared, because the kernel crate is bare-metal-only and pinned byforced-target, so it can never be a user-space dependency — and a register layout is a hardware fact, not a shared ABI.main.rs— the SEF loop and the handler.
The handler captures m_source first (it is both the reply target and the
granter), validates, pulls the bytes across with
SYS_SAFECOPY(SAFECOPY_FROM, caller, gid, offset, staging, n), transmits, and
replies n. A negative SYS_SAFECOPY result is relayed verbatim: EPERM
(“your grant does not authorize this”) and EFAULT (“your buffer is not mapped”)
are different bugs on the client’s side.
Two departures from the server template are worth naming. The staging buffer lives
in main’s frame, never in the init callback’s — the kernel writes into it while
TTY is blocked inside the SYS_SAFECOPY SENDREC, so the frame must outlive every
call that names it (the same rule GrantPool follows). And an unknown m_type
gets a reply, where DS harmlessly drops one: a driver’s clients all SENDREC, so a
dropped request blocks the caller forever.
What the boot log proves
TTY writes its own banner straight to the UART once its mapping is in place:
minix.rs console: tty online (EL0)
That line is the milestone, and it is identifiable for a specific reason: it carries
no kernel trace prefix. Every other line in the log is [as], [ipc],
[ksys …], [diag …], [pf], [devmap] — kernel-formatted. This one was composed
at EL0 and reached the wire through a user-space store to a device register.
VFS then drives the protocol as the first client: it resolves TTY through DS (rather
than hard-coding its boot endpoint), writes a banner through a read-only direct
grant and checks the reply against the granted length, asks for CDEV_MAX_IO + 8
bytes and requires exactly CDEV_MAX_IO back, and finally issues two requests that
must be refused — minor 7 with a perfectly good grant (ENXIO, from TTY’s own
minor check) and a grant issued to PM instead of TTY (EPERM, from the kernel’s
grantee check, the property that makes a grant id safe to pass around at all).
One honest caveat: under QEMU’s TCG the Device attribute is not observably
load-bearing. QEMU’s PL011 works through a Normal write-back mapping — which is why
the kernel’s own HHDM alias has always been one — so substituting the Normal
attribute index changes no marker in the boot log. The attribute is proved by
construction and assertion, not empirically. The same is true of the FR.TXFF poll
(TCG’s FIFO never fills) and of the LF→CRLF translation (the log checker matches
literal substrings and cannot express a carriage return).
The BDEV protocol and the memory ramdisk
Block drivers answer requests in the BDEV_RQ_BASE = 0xA00 band — between VFS
(0x800) and CDEV (0xB00), with FS (0x900) between VFS and BDEV. Two
requests are defined:
| Field | Payload offset | Meaning |
|---|---|---|
| minor | 0..4 (i32) | which device; BDEV_MINOR_RAMDISK = 0 is the boot image |
| grant id | 4..8 (i32) | names the client’s buffer |
| length | 8..12 (i32) | bytes requested; at most BDEV_MAX_IO = one block |
| block | 16..24 (u64) | which block of the device |
BDEV_READ fills the client’s buffer (so the grant needs CPF_WRITE, and the
driver pushes with SAFECOPY_TO). BDEV_WRITE, real since slice 5.10a, is the
same request read backwards: it pulls the client’s bytes into the device with
SAFECOPY_FROM, so the grant has to carry CPF_READ instead. One parse and one
validation serve both — the payload is identical, and only the dispatch arm knows
which way the bytes go. The driver never checks the access bit itself, and must
not: the kernel’s verify_grant does, and a driver that re-derived the grant
rules would be a second place for them to drift.
Most of that table repeats CDEV’s rules — no granter field, and the reply m_type
is the byte count. Three things are deliberately different:
An over-long request is EINVAL, not a short read. A short write is a POSIX
contract every client already loops over. A short block read is useless: a
filesystem cannot interpret half a block, so clamping would push a retry loop into
every caller for nothing.
An out-of-range block is EINVAL, not EIO. A block device’s size is known to
its client — MFS reads it from the superblock’s s_zones — so asking past the end
is a caller bug. EIO stays reserved for Phase 6’s real media errors, where the
request was well-formed and the device failed.
BDEV_WRITE was numbered three slices before it worked. From 5.7 to 5.10a
the arm answered EROFS rather than ENOSYS, because ENOSYS is already the
unknown-m_type answer and reusing it would have made “this driver has never
heard of writes” and “this driver knows about writes and refuses them”
indistinguishable to a client. Keeping the arm dispatched also kept it probed —
and the prediction held exactly: making the ramdisk writable changed one line
inside it, the direction handed to sys_safecopy.
Where the blocks come from
kernel/build.rs builds a MinixFS v3 image at compile time (tools/mkfs-mfs,
called as a build-dependency library) and packs it into the MXBI archive as a
non-ELF blob named rootfs. At boot the kernel copies it, page by page, into
freshly allocated frames and maps them into the memory driver’s address space —
in the same load_boot_server arm the UART page uses, and for the same reason: the
driver has no way to ask.
The window is declared beside the device window, and is ordinary RAM:
#![allow(unused)]
fn main() {
pub const RAMDISK_WINDOW_BASE: u64 = 0x8000_0000; // 2 GiB — one whole L1 slot
pub const RAMDISK_WINDOW_SIZE: u64 = 0x0040_0000; // 4 MiB
pub const RAMDISK_VA: u64 = RAMDISK_WINDOW_BASE; // page 0 of the window
}
Two choices there are worth stating. It sits above the device window on
purpose: region::REGION_LIMIT is the base of the lowest kernel-owned window, so
placing every new window above that low-water mark means VM needs no edit at all
when one is added — and assert!(USER_DEVICE_WINDOW_BASE + USER_DEVICE_WINDOW_SIZE <= RAMDISK_WINDOW_BASE) is what keeps that true. And the pages are mapped
Prot::RW_DATA, not device: none of the prot.device machinery above applies, and
these frames take the ordinary free_frame path in every leaf sweep. (RW rather
than RO so slice 5.10’s write path is a change in the driver rather than in the
kernel.)
The 4 MiB size comes from the format, not from today’s image: seven direct zones
plus one single-indirect block address 7 + 1024 zones at 4 KiB, which is 4.03 MiB.
An image that fits the window is therefore an image fs/mfs’s reader can address
without a double-indirect arm.
The driver learns where it is through a new SYS_GETINFO selector, GET_RAMDISK,
which returns (va, len) and is gated on the caller being MEM_PROC_NR: the
ramdisk is mapped into exactly one address space, so the VA is meaningless — and
actively misleading — anywhere else.
The driver has no unsafe block
drivers/memory/ never dereferences its mapping. Client transfers go through
SYS_SAFECOPY, and even the boot self-check reads the image through
SYS_COPY(SELF → SELF) rather than a raw load. So a page the kernel failed to map
surfaces as an EFAULT return value from a kernel call — a better diagnostic
than TTY’s equivalent, which is an EL0 data abort — and there is no MMIO sibling
module to exclude from coverage.
The self-check is deliberately device-level, not format-level: it reads a
32-byte image header that mkfs-mfs writes into block 0’s boot block (bytes
0..1024, which MinixFS never reads), never a superblock. A block driver that
decoded a superblock would depend on the filesystem format, which is precisely the
dependency Phase 6 has to unwind when virtio-blk replaces the ramdisk. What
licenses that shortcut is a host test in tools/mkfs-mfs asserting the header’s
three fields equal the real superblock’s.
It also reads a tail label from the image’s reserved last block, whose text
differs from the header’s. That is not decoration: a kernel copy loop that failed to
advance would map 256 pages of block 0 and pass every header check. The tail is the
only thing in the boot that proves the copy reached the end of the blob — confirmed
by mutation, where sourcing every page from block 0 moved exactly one marker,
ramdisk FAIL tail label.
The character minors
Since slice 5.11 the same driver serves /dev/null (CDEV minor 3) and
/dev/zero (minor 5), as MINIX 3’s memory driver does beside its ramdisks.
Minors are a per-driver namespace, and on a driver serving two bands, the band
tells them apart — so cdev::classify refuses minor 0 here, which is TTY’s
console, and the BDEV ramdisk’s minor 0 never meets these two.
Both minors discard a CDEV_WRITE and answer the whole count with no copy
at all; /dev/null answers a CDEV_READ with 0, and /dev/zero fills the
whole request from a 256-byte static, walking the grant in CDEV_MAX_IO steps.
Nothing is clamped: CDEV_MAX_IO protects TTY’s stack staging buffer, and there
is no staging here. Two consequences worth knowing. A /dev/null write with an
unmapped buffer succeeds, as it does on Linux, because nothing reads the
buffer — so no bad-buffer probe may ever be aimed at it. And the driver still
has no unsafe block: the only copy is a kernel call.
VFS probes the validator from its prologue ([diag vfs] mem.deny ok n=5),
because VFS’s own device table maps only minors that exist and could never send
a bad one. One of those five is the first such refusal on the CDEV band: a
CDEV_READ through a read-only grant, refused by the kernel’s verify_grant
and relayed as EPERM. MFS’s bdev.deny battery already exercises the same
CPF_WRITE check against a BDEV_READ (its read-only probe), so this is a
CDEV-band first, not a kernel-wide one.
What the boot log proves
[ramdisk] mem va=0x80000000 len=1048576 pages=256
[diag memory] ramdisk ok blocks=256 tail=1
[diag mfs] bdev.ds ok ep=3
[diag mfs] bdev.tail ok match=1
[diag mfs] bdev.deny ok n=10
blocks=256 cross-checks the header’s own block count against the length
GET_RAMDISK reported — two independently derived numbers, binding the build-time
image geometry to the kernel’s runtime copy.
MFS, not VFS, is this driver’s BDEV client — since slice 5.8, when the
filesystem server took over the band. Its own mount ok root=1 bs=4096 blocks=256 marker, the superblock decoded out of the block it asked for, is
what retired the earlier bdev.read/bdev.head pair: a driver that replied
OK to the wrong page, or returned the header for both, fails to decode there
instead of printing a marker of its own. bdev.tail is the one thing mount
cannot subsume — it reads the image’s reserved last block, whose label
differs from the header’s, and is the only proof that the copy loop filling the
ramdisk reached the end of the blob rather than looping over block 0. The ten
refusals in bdev.deny include the one grant check slice 5.3 could not reach —
a CPF_READ-only grant used as a copy destination — plus, since slice 5.10a,
two BDEV_WRITE probes aimed at the write path this driver now really
implements, refused by the kernel’s grant-direction and minor checks rather
than by a since-retired EROFS stub.
Every one of those markers is identical with and without the boot-stubs feature
and with and without the musl sysroot. That is what the fixed 1 MiB image size
buys: in the sysroot-absent build the archive packs a 15 KB worker ELF under the
name hello, so a content-sized image would make every size-derived marker
config-dependent — passing in one configuration and proving nothing in the other.
The C toolchain and the musl port
Slice 5.6 is Phase 5’s milestone A: an ordinary C program, compiled against
a forked musl, running on minix.rs with printf reaching the serial console.
Since P3c it can be built either by the in-tree musl sysroot or by the minix.rs
SDK on the real aarch64-unknown-minixrs triple — see
Two toolchains, one program.
Nothing in that program is minix.rs-specific — it is plain C against plain
<stdio.h>. It works because the libc underneath it was ported, not because the
program was. That is the whole point of the exercise.
printf("minix.rs hello: Hello from C!\n");
minix.rs hello: Hello from C!
Where the pieces live
| Piece | Location |
|---|---|
| The libc fork | external/musl (submodule → minixrs/musl-minixrs, branch minixrs) |
| In-tree sysroot builder | tools/build-musl.sh → target/musl-sysroot |
| SDK prefix | $MINIXRS_SDK, default $HOME/toolchains/minixrs |
| SDK compiler | $MINIXRS_SDK/bin/clang (the minixrs/llvm-minixrs fork) |
| SDK sysroot | $MINIXRS_SDK/sysroot/usr/{include,lib} + sysroot/.stamp |
| The C program | userland/hello/hello.c (and hello.ld, musl flavor only) |
| Flavor selection | kernel/build.rs’s build_hello |
| Compile + link | build_hello_sdk / build_hello_musl |
| Generated ABI headers | cargo gen-c-headers → target/gen-c-headers/include/minixrs/ |
Two toolchains, one program
hello.c is built by whichever C toolchain is available, in a strict preference
order:
Sdk— the minix.rs SDK at$MINIXRS_SDK(default$HOME/toolchains/minixrs). Toolchain-program milestone M3.Musl— the in-tree sysroot fromtools/build-musl.sh. Slice 5.6.Worker— no C toolchain: theworkerELF is packed under the namehello.
Musl is not a fallback. CI’s blocking qemu-smoke job cannot install an
SDK — an LLVM build is hours — while tests/qemu-boot.expected requires the
five C markers, so the in-tree sysroot is that gate’s real dependency. Only
Worker loses markers.
An SDK is “usable” when exactly three files exist:
$MINIXRS_SDK/bin/clang
$MINIXRS_SDK/sysroot/.stamp
$MINIXRS_SDK/sysroot/usr/lib/libc.a
The crt objects, libclang_rt.builtins.a, and the lib/clang/<ver> resource dir
are deliberately not probed: the driver names those itself, and nothing in
this repo may hard-code the version component. From that follows the governing
rule — a present toolchain that fails is a build failure; an absent one is a
fall-through. A usable SDK that cannot build hello panics, carrying the
prefix, the stamp, a clang -### reproduce line, and the escape hatch. It never
demotes to Musl, because the boot markers are byte-identical across flavors, so
a silent demotion would report a regressed toolchain as a healthy build.
The SDK command line
$MINIXRS_SDK/bin/clang --target=aarch64-unknown-minixrs \
-O2 -Wall -Wextra -Werror -o target/hello/hello userland/hello/hello.c
That is the whole build, and the absences are the milestone. There is no
-T, -nostdinc, -isystem, --sysroot, -L, -static, -ffreestanding, no
explicit crt object, no compiler_builtins glob, and no separate rust-lld
step. From the triple alone the patched driver supplies:
| Supplied | Why it matters |
|---|---|
-static | there is no dynamic loader |
--image-base=0x100000 | LLVM patch 0006 — this is why the SDK flavor needs no linker script |
-z max-page-size=4096 | decision D13: 4 KiB pages |
-z separate-loadable-segments | no two PT_LOADs share a page; the loader maps per-segment permissions |
crt1.o, crti.o, crtn.o, -lc | from $MINIXRS_SDK/sysroot/usr/lib |
libclang_rt.builtins.a | the quad-float helpers, from the driver’s own resource dir |
Tooling’s rule applies: anything that has to be added back here is a bug to fix
in the fork, not a flag to paper over. Check the contract with
clang -###, or with tooling’s verify/check-driver.sh.
The seam: Linux syscall numbers in, IPC out
minix.rs has no Linux syscall ABI. Its only svc #0 entry point is the MINIX
IPC trap, and every OS service is a user-space server reached by message
passing. Yet musl is written throughout in terms of __syscall(SYS_write, …).
The port resolves this in one file. arch/aarch64/syscall_arch.h normally
executes svc with the syscall number in x8; in the fork, every __syscallN
instead calls __minixrs_syscall, which switches on the Linux number and issues
the corresponding server round-trip:
| Linux syscall | minix.rs mapping |
|---|---|
writev | one VFS_WRITE per iovec, counts summed |
write | one VFS_WRITE |
exit_group, exit | PM_EXIT (does not return) |
set_tid_address | constant tid 1 |
ioctl | -ENOTTY |
| everything else | -ENOSYS |
Keeping musl’s ~297 call sites unmodified is what lets the other ~1900 source
files stay byte-identical to upstream — and therefore rebase for free onto the
next release. The entire fork delta is arch/aarch64/syscall_arch.h,
src/minixrs/, a brand block in crt/crt1.c, and a MINIXRS.md.
Six syscalls is enough only because musl’s startup path avoids the rest: with
AT_UID/AT_GID/AT_SECURE absent from the auxv, __init_libc takes an early
return that skips its ppoll; __init_ssp(NULL) derives the stack canary
arithmetically; and __init_tls uses its static builtin_tls because the
program has no PT_TLS. The fork’s MINIXRS.md records each of those facts,
re-derived against v1.2.6 rather than assumed.
ioctl is load-bearing
Answering -ENOTTY is not merely harmless. __stdout_write sets f->lbf = -1
when TIOCGWINSZ fails, which makes stdout fully buffered — so printf
output does not appear when it is called, but when exit() runs __stdio_exit.
A boot log containing Hello from C! therefore proves the flush path too, not
just formatting.
What a write actually does
hello.c printf(…)
musl vfprintf → __stdio_write → __syscall(SYS_writev, …)
fork __minixrs_syscall → VFS_WRITE (SENDREC to VFS)
VFS issues a CPF_MAGIC grant over the caller's buffer → CDEV_WRITE
TTY SYS_SAFECOPY pulls the bytes in, writes the PL011
The buffer never leaves the caller’s address space until the driver copies it,
and it moves in exactly one copy. VFS names the grant’s owner from the
kernel-stamped m_source, never from the payload — a caller-supplied owner
would turn VFS into a confused deputy, since VFS holds SYS_PROC and its
clients do not.
Building
tools/build-musl.sh # configure + make into target/musl-sysroot
cargo kernel-aarch64 # build.rs picks a flavor, compiles and links userland/hello
# force a flavor
MINIXRS_SDK=/nonexistent cargo kernel-aarch64 # in-tree musl, deletes nothing
MINIXRS_SDK=~/toolchains/minixrs cargo kernel-aarch64
Never write inside $MINIXRS_SDK. Tooling’s build-musl.sh does
rm -rf $SDK/sysroot, so anything the OS tree left there would vanish without
warning. Every artifact this repo produces goes to target/hello/.
The in-tree sysroot build uses clang --target=aarch64-unknown-linux-musl with
AR and RANLIB from the pinned Rust toolchain (llvm-ar, and llvm-ar s in
place of llvm-ranlib, which that toolchain does not ship). Linking uses
rust-lld — no platform linker and no Homebrew LLVM anywhere in the path. The
SDK flavor uses the fork’s own clang and ld.lld instead, and needs neither.
build-musl.sh also runs the real errno check that slice 5.0 deferred to
here: it compiles the generated <minixrs/errno.h>’s opt-in POSIX block against
the fork’s own bits/errno.h, so a POSIX errno whose magnitude drifted from
musl’s is a build failure. Until this slice, CI could only prove macro
spellings.
If a toolchain is missing
kernel/build.rs presence-checks the sysroots, and the three cases differ:
| Case | Result |
|---|---|
No $MINIXRS_SDK (unset, default prefix absent) | silent; try the in-tree musl sysroot |
MINIXRS_SDK set but unusable | one cargo::warning naming the missing file, then try musl |
| Neither sysroot | cargo::warning; pack the worker ELF under the name hello |
In the last case a fresh clone still boots green and only the hello-specific
markers go missing. Neither builder builds its own sysroot — a multi-minute libc
build launched from a cargo build script would turn a first cargo kernel-aarch64
into a mystery, and an LLVM build would be far worse.
Presence-checking the sysroot rather than the external/musl submodule is the
same reasoning: a clone that ran git submodule update but not
tools/build-musl.sh must not trigger a libc build.
Reporting is host-side only, and Musl stays silent — it is what every CI job
builds, and warning on the norm is how people learn to ignore build-script
warnings. So: SDK warning ⇒ sdk; fallback warning ⇒ worker; neither ⇒ musl.
hello.ld — the musl flavor only
The SDK flavor has no linker script, because everything this one exists to
say is in the driver: the image base is pinned by LLVM patch 0006, and the two
-z flags come from the triple. The script below applies to the in-tree musl
flavor, which links with a stock rust-lld that knows none of it.
The linker script starts from userland/worker/user.ld and keeps the parts that
are load-bearing there: page-aligned PT_LOADs, no dynamic sections, a 1 MiB
load base, and the FILEHDR PHDRS idiom that puts PT_LOAD #0 at file offset
0. That idiom is mandatory here rather than merely nice: without it lld’s
default 64 KiB page size leaves e_phoff in an unmapped prefix, and musl’s
__init_tls walks the program headers from exactly the AT_PHDR the kernel
reports.
What it adds is everything a musl link brings that a Rust one does not, all of
it from crti.o / crtn.o / libc.a: .init and .fini (which define the
_init/_fini that crt1.c hands to __libc_start_main), .init_array /
.fini_array with their bracketing symbols, .got, and .data.rel.ro. Every
input section gets an explicit home, because an orphan placed past the last
PT_LOAD is a silent load failure — the kernel maps what the program
headers describe and never sees it.
Verify any change with:
"$(rustc --print sysroot)"/lib/rustlib/*/bin/llvm-readobj --program-headers \
--elf-output-style=GNU target/hello/hello
Quad-float builtins
musl’s vfprintf references soft-float binary128 helpers (__multf3,
__floatsitf, …): aarch64’s long double is IEEE quad with no hardware
support. musl’s configure finds no runtime library on this host, so those
symbols would be undefined at link time.
In the musl flavor they come from the pinned Rust toolchain’s own
compiler_builtins, built for the custom aarch64-unknown-minixrs target by the
server builds that run first. Note that the prebuilt aarch64-unknown-none
rlib in the rustup sysroot does not export the C-ABI names — only the
-Zbuild-std one does, which is why build_hello_musl globs the nested target
dir rather than the sysroot.
In the SDK flavor the driver links libclang_rt.builtins.a from its own
resource dir and the problem simply does not arise. That archive lives under
$MINIXRS_SDK/lib/clang/<ver>/lib/aarch64-unknown-minixrs/ — and nothing in
this repo may hard-code that <ver>. It is tooling’s rule and it is why
usable_sdk probes neither the archive nor the resource dir: one driver
invocation never mentions the version component, so let the driver derive it.
The host environment is a trap
clang folds CPATH and the *_INCLUDE_PATH family into the front of the
include search list — ahead of its resource dir and ahead of the sysroot — and
-nostdinc does not suppress them. A foreign errno.h reachable that way would
shadow musl’s, and decision D7 turns on the fork’s errno values being the ones
the kernel agrees with. So kernel/build.rs routes every clang invocation
through clang_command, which removes CPATH, C_INCLUDE_PATH,
CPLUS_INCLUDE_PATH, OBJC_INCLUDE_PATH, OBJCPLUS_INCLUDE_PATH,
LIBRARY_PATH and SDKROOT.
Check your own machine with one line:
$MINIXRS_SDK/bin/clang -E -v --target=aarch64-unknown-minixrs -x c /dev/null
Scrubbed, the list should be the resource dir, then the sysroot, then
/usr/local/include. That last entry is injected by the fork’s driver itself and
is not an environment leak; it sorts after the sysroot, so it can only
supply headers musl lacks. Resist “fixing” it with -nostdlibinc, which would
drop the sysroot along with it.
Measured shapes
Same source, same libc, two toolchains:
| SDK | in-tree musl | |
|---|---|---|
| Size | 46,664 B (45.6 KB) | 200,152 B (195.5 KB) |
PT_LOADs | 4 | 3 |
| Entry | 0x101000 | 0x101000 |
| Brand note | 0x100200 | 0x117000 |
| Last mapped byte | 0x108ce0 | 0x11caf8 |
Both sit a clear megabyte below SERVER_STACK_VA (0x200000). The size gap is
mostly -z separate-loadable-segments splitting RO/RX/RW/relro across four
segments in the SDK build versus three, plus differing section merging — not a
libc difference.
This closes a loop from slice 5.7. At 4096 bytes per MFS block, 46,664 bytes is
12 blocks — still past the seven direct zones, so hello continues to
exercise MinixFS’s single-indirect arm in the SDK flavor exactly as it does in the
musl one. /etc/pattern’s 40 KiB mandate is therefore unchanged: it is still what
keeps that arm live in the worker-fallback configuration, where hello is 4
blocks and fits inside the direct zones.
Verifying a build
# SDK flavor
$MINIXRS_SDK/bin/llvm-readelf --file-header --program-headers --notes target/hello/hello
strings target/hello/hello | grep llvm-minixrs # provenance: the fork's clang + lld
# tooling's gates -- run from the checkout, they are not installed into the SDK
~/src/tooling/verify/check-brand.sh target/hello/hello # BRANDED minixrs abi_version=1
~/src/tooling/verify/check-image.sh target/hello/hello # LOADABLE
Read target/hello/hello only after a successful build, and remember that
both flavors write that one path: a panic — or a run with a different
MINIXRS_SDK — leaves the other flavor’s binary there. For the musl flavor use
the SDK-free reader, since a musl-only developer has no $MINIXRS_SDK/bin:
"$(rustc --print sysroot)"/lib/rustlib/*/bin/llvm-readobj --program-headers \
--elf-output-style=GNU target/hello/hello
Two things nothing enforces
The SDK links its own musl, built from the same fork but at whatever commit
tooling’s build-sysroot.sh last saw. $MINIXRS_SDK/sysroot/.stamp records it as
musl=<sha>; today that is the merge commit of external/musl’s own HEAD and
git diff between them is empty. An equality check is impossible, because the two
SHAs legitimately differ. So it is a manual check, and the consequence of
skipping it is that the two flavors quietly test different libc code — rebase the
fork without re-running build-sysroot.sh and that is exactly what happens.
The stamp’s minixrs=<sha> is the same kind of snapshot for the installed
minixrs/*.h headers: it names the commit whose gen-c-headers output was
installed, and it does not track. That is tolerable only because of decision D8’s
ABI freeze — so any kernel-shared ABI change requires re-running tooling’s
scripts/build-sysroot.sh.
The ELF brand
The kernel refuses to load an unbranded ELF. For Rust binaries the 28-byte
.note.minixrs.ident note comes from minixrs_abi_note::brand!(); for C it is
emitted from crt/crt1.c as a .pushsection global asm, so every C program is
branded with no opt-in. crt1.o is linked explicitly rather than pulled from an
archive, so — unlike a libc.a member — it can never be dropped by
archive-member selection.
Regressing it fails the kernel build, not the boot: kernel/build.rs runs
the same scan_brand check when it packs the boot archive.
The ABI freeze
Slice 5.6 is the ABI freeze point (phase-5 decision D8). There is now C that depends on the message layout, the call numbers, the endpoint encoding, and the errno values, and it lives in a different repository. Past this slice those change only via a deliberate ABI-bump PR touching both repos together.
Build & Toolchain
minix.rs builds as a single Cargo workspace. There is no separate C build today
(the musl fork is future work — see Roadmap); the only non-Cargo
step is fetching the prebuilt Limine binary, and a couple of shell scripts in
tools/ stage the boot ESP and launch QEMU.
Prerequisites
- Rust nightly, pinned in
rust-toolchain.toml(a barenightlywould let new lints or fmt rules break the build with no code change). - QEMU with
qemu-system-aarch64. - aarch64 UEFI firmware (edk2 / OVMF).
tools/qemu-run.shauto-detects it in common locations, or setQEMU_EFI_AARCH64=/path/to/edk2-aarch64-code.fd.
Quick start
# One-time: fetch the pinned Limine binary into external/limine/dist/
make -C external/limine
# Build the kernel for aarch64 (the primary target)
cargo kernel-aarch64
# Build + boot under QEMU. The kernel runs indefinitely once EL0 starts, so a
# timeout is mandatory. Redirect to a file when you need to grep the log.
# Budget ~5 s for the rebuild + UEFI firmware startup before the kernel's first
# byte -- `timeout 8` can yield a log with no kernel output at all, so use 25 s
# for anything you intend to verify.
timeout 25 cargo run -p minixrs-kernel --target aarch64-unknown-none --release
# Clean, stub-free boot for debugging: --no-default-features disables the
# `boot-stubs` feature, so only the servers + init/worker boot (no demo stubs
# A-D flooding the trace). See "Boot stubs" under Cargo workspace below.
timeout 25 cargo run -p minixrs-kernel --target aarch64-unknown-none --release --no-default-features
cargo run invokes the cargo runner (tools/qemu-run.sh), which stages an ESP
directory at target/esp/, drops Limine and the freshly built kernel in, and
boots QEMU with the directory-as-FAT helper — no disk-image scripting needed (see
Boot for the ESP layout and the exact QEMU command). Early
serial output looks like:
minix.rs booting on aarch64
HHDM offset: 0xffff000000000000
Cargo workspace
The root Cargo.toml declares every crate as a workspace member: kernel,
kernel-shared, minixrs-ipc, server-rt, the six servers/*, the (stub)
drivers/* and fs/*, and userland/{init,worker,sh,coreutils}.
The kernel builds against the builtin aarch64-unknown-none target — not a
custom JSON spec. .cargo/config.toml wires the details:
[target.aarch64-unknown-none]
runner = "tools/qemu-run.sh"
rustflags = ["-C", "link-arg=-Tkernel/src/arch/aarch64/linker.ld"]
[alias]
kernel-aarch64 = "build -p minixrs-kernel --target aarch64-unknown-none --release"
The x86_64-unknown-none target block is scaffolding for the planned port; the
kernel does not boot on x86_64 yet. There is deliberately no kernel-x86_64
alias — forced-target (below) pins the kernel to aarch64 and overrides the
--target in an alias, so one would silently build aarch64 rather than fail.
Phase 8 must relax forced-target before adding it back.
Assembly and the boot image (kernel/build.rs)
The kernel’s build.rs does two build-time jobs:
- Assembly — it assembles the kernel’s
.Sfiles withclangand passes the resulting objects straight to the linker. WhenCARGO_CFG_TARGET_OS != "none"it instead emits acargo::error=line and stops, because a host build of the kernel is always a mistake (see The kernel is not host-buildable). The demo-stub blobuser_stub.Sis assembled only when theboot-stubsfeature is on (see Boot stubs). - Boot-image packing — it builds each boot server for the custom EL0 user
target
tools/targets/aarch64-unknown-minixrs.json(via-Zbuild-std) into one shared nestedCARGO_TARGET_DIR(target/minixrs-user, socore/allocare compiled once rather than per crate), checks each ELF carries the minixrs identity note, packs them into the MXBI archive (pack_mxbi), and emitsBOOT_IMAGE_PATHfor the kernel toinclude_bytes!. There is no separatemkbootimagetool. See Boot for the archive format and module set.
Boot stubs (boot-stubs feature)
The kernel installs four hand-written EL0 demo stubs A–D at boot — a live
regression battery for the IPC primitives (A↔B ping-pong), SCHED delegation (C),
and the VM page-fault / SIGSEGV path (D). They are useful but noisy: stub C’s
SYS_GETINFO loop floods the trace. The boot-stubs cargo feature (default-on)
gates them, so --no-default-features yields a clean boot of servers + init/worker
only.
The feature lives on two crates — the kernel (gates the stub code in
arch::aarch64::userland) and PM (gates the stub mproc seeding). Because
build.rs builds each server in a separate nested cargo invocation with its own
feature resolution, it reads CARGO_FEATURE_BOOT_STUBS and, when the kernel is
stub-free, passes --no-default-features to the nested PM build too — keeping the
two in lockstep. The feature is intentionally not placed on kernel-shared: a
shared-crate default feature is force-enabled by other dependents (minixrs-ipc,
server-rt) through cargo feature unification and could not be turned off. So
NR_STUB_PROCS (= 4) and FORK_POOL_BASE (= 15) are constant regardless of the
feature — disabling stubs merely leaves proc slots 11–14 unoccupied; it does not
renumber the fork pool.
The kernel is not host-buildable
minixrs-kernel compiles for target_os = "none" and nothing else: the ELF-only
link_section attributes, the _start entry path, the panic handler, and the
assembled .S objects all require it. It used to collapse to an empty fn main() {}
on the host so that cargo check --workspace stayed green — at the cost of hiding
every module behind #[cfg(target_os = "none")], and therefore hiding all 48 kernel
source files from every lint gate. That arrangement is gone.
Rather than hide the crate from workspace commands, kernel/Cargo.toml pins its
build target:
cargo-features = ["per-package-target"]
[package]
forced-target = "aarch64-unknown-none"
[[bin]]
name = "minixrs-kernel"
path = "src/main.rs"
test = false # no_std/no_main: a --test build needs the `test` crate (std)
bench = false
Every cargo invocation therefore cross-compiles the kernel instead of failing on it —
bare or --workspace, and from any IDE:
cargo check --workspace --all-targets # ok: kernel cross-compiled
cargo clippy --workspace --all-targets # ok, and it genuinely LINTS kernel code
cargo test --workspace # ok: kernel has no test target
That last point is the payoff: kernel code is now visible to the lint gates instead of
merely hidden from them. forced-target wins even over an explicit
--target x86_64-apple-darwin, so a host build is unreachable, and no --exclude or
per-developer editor setting is required.
Three things to know:
per-package-targetis unstable (cargo#9406), hence thecargo-featuresopt-in and the nightly pin inrust-toolchain.toml. If a future bump drops it, cargo fails loudly on the manifest; the fallback is a workspacedefault-memberslist omitting"kernel".test = falseis required. Without it,cargo check --all-targetsbuilds a phantom test harness for the kernel bin and fails withE0463: can't find crate for test.build.rs’scargo::error=andmain.rs’s#[cfg(not(target_os = "none"))] compile_error!are now unreachable defense-in-depth, kept in caseforced-targetever stops applying.
No cfg(target_os = ...) gates remain under kernel/src/.
The cfg_attr(target_os = "minixrs", …) attributes in servers/* and userland/* are a
different thing: those crates are host-built and host-tested, and the attribute only
hides an ELF section specifier from a Mach-O host. (They keyed on target_os = "none"
until M1 moved the user-space binaries onto the aarch64-unknown-minixrs target.)
Host tests
Logic that can run off-target lives in kernel-shared and in the host-testable
server crates:
cargo test -p minixrs-kernel-shared
cargo test -p minixrs-gen-c-headers # the C ABI header generator
There is no #[cfg(test)] code under kernel/src/ — the crate cannot be host-tested
and in-QEMU test infrastructure does not exist yet, so such tests would never run.
Pure predicates over shared ABI types belong in kernel-shared instead (user_va_ok
in kernel-shared/src/message.rs is the worked example); hardware and raw-pointer
behaviour stays in the kernel. QEMU is the primary verification for kernel code, and
CI smoke-boots it (below).
CI
.github/workflows/ci.yml runs on every PR and on pushes to main. Eleven jobs run
in parallel (sonar waits on coverage):
| Job | Blocking? | What it checks |
|---|---|---|
fmt | yes | cargo fmt --all --check (covers the kernel too) |
clippy | yes | cargo clippy --workspace --exclude minixrs-kernel --all-targets -- -D warnings (host target; kernel excluded for runner cost, see below) |
clippy-kernel | yes | cargo clippy -p minixrs-kernel --target aarch64-unknown-none -- -D warnings, twice: default features and --no-default-features |
c-headers | yes | regenerates the C ABI headers from kernel-shared and compiles them with clang -std=c11 -fsyntax-only (host + both musl triples) |
audit | yes | cargo-audit advisory scan |
deny | yes | cargo-deny (licenses / bans, config in deny.toml) |
geiger | advisory | unsafe surface report (per package, kernel filtered out) |
miri | advisory | UB check on the host-testable crates |
qemu-smoke | yes | boots the kernel and greps the serial log |
coverage | yes | cargo-llvm-cov → lcov.info (kernel excluded) |
sonar | — | feeds LCOV to SonarQube Cloud |
Notes: CI’s clippy and coverage exclude the kernel for runner cost, not
correctness — forced-target means they could build it, but only by having the x86
runner cross-assemble the .S files and run 8 nested server builds on a blocking gate.
So clippy-kernel is the only CI job that compiles kernel code (a local
cargo clippy --workspace does lint it) — which is why it blocks and runs on a native
ubuntu-24.04-arm runner. It passes no
--all-targets: the kernel is no_std/no_main, so there is no test harness to build.
qemu-smoke (also ubuntu-24.04-arm) boots for 45 s wall clock, requires exit status
124 — the timeout(1) status a healthy, never-exiting kernel must produce — and then
runs tools/check-boot-log.sh against tests/qemu-boot.expected / .forbidden; keep
those expectations timing-robust (first occurrences, never counts), because CI’s TCG is
slower than a local run. Cargo.lock is committed so audit / deny are reproducible,
and third-party actions are pinned to commit SHAs.
Generated C headers
The musl fork’s view of the minix.rs ABI is generated, never committed
(phase-5 decision D8): tools/gen-c-headers depends on kernel-shared as an
ordinary Rust crate and prints C from the live constants, so the Rust and C
views cannot drift.
cargo gen-c-headers # -> target/gen-c-headers/
cargo gen-c-headers /some/sysroot # explicit output directory
cargo gen-c-headers --stdout # eyeball the output
It emits include/minixrs/{ipc,com,callnr,errno}.h, plus two artifacts that make
the CI gate real: abi-selftest.c — a header is never a translation unit on its
own, so without a .c file none of the generated _Static_asserts would ever
fire — and abi-check/errno.h, a CI-only stand-in for the C library’s
<errno.h>.
Two things the headers are careful about:
_PROC_NRvs_EP. Every process gets both. minix.rs sign-extends the endpoint proc field instead of using MINIX 3’s offset bias, so for the kernel tasks the boot endpoint is not the process number (SYSTEM_PROC_NRis −2,SYSTEM_EPis 32766). Naming a task by its_PROC_NRin an IPC call is a bug, and the header asserts the C decode macro against the Rust-computed endpoints.- The POSIX errno block is asserted, never defined. minix.rs adopts musl’s
numbering verbatim, so those values must come from the C library’s own
<errno.h>;minixrs/errno.hdefines only the MINIX 200-band and puts the POSIX checks behindMINIXRS_ABI_CHECK_POSIX_ERRNO, which the musl build defines. See System Calls & ABI.
Debugging: QEMU trace forensics
User-space servers run at EL0 with no console — they cannot print. All server
behavior is observed through kernel-side traces ([as], [ipc], [ksys],
[pf], [alarm]). Reading those logs has some sharp edges worth knowing:
grep -a. The serial log interleaves raw single-character tick bytes, so tools treat it as binary (“Binary file matches”). Force text mode withgrep -a(orgrep -aF). Redirect the run to a file and grep that — a live tail loses lines.- TCG time skew. QEMU under TCG advances guest time slower than wall clock,
so a
timeout Nrun reaches far fewer thanN × 100ticks. For time-based behavior (alarms, quanta) read uptime-stamped traces (e.g.[alarm … at=N]) as the real clock, and run 20–25 s to observe several periods. - Sampling asymmetry.
[ipc N]head-traces the first ~12 calls plus every 100th;[ksys N]samples only every 100th, with no head carve-out. A server’s first or rare kernel call (e.g. a startupSYS_GETINFO) shows on[ipc], not[ksys]. - Zero
[ipc]samples ≠ a stuck caller. A blockingSENDRECclient (init’s fork/wait loop, say) round-trips far too rarely for the modulo sampler to catch. Confirm liveness through its downstream head-carved[ksys …]traces (SYS_FORK/SYS_EXITare head-carved), or add a temporary[DBG]trace inipc::do_ipckeyed on the caller’s proc number — and remove it before committing. - The acceptance harness.
tools/check-boot-log.sh <log>greps a captured log againsttests/qemu-boot.expectedandtests/qemu-boot.forbidden— the same check theqemu-smokeCI job runs. Update those marker files in the same change when trace formats or the boot roster shift. - Quiet the stubs. Stub C’s
SYS_GETINFOloop dominates the[ipc]/[ksys]sample stream. When you’re chasing a server or init/musl issue, boot--no-default-featuresto drop the demo stubs A–D entirely (see Boot stubs) — the trace then shows only the servers + init/worker. Note theqemu-smokemarkers assume the default (stubs-on) boot, so don’t runcheck-boot-log.shagainst a stub-free log.
Debugging with GDB
QEMU’s GDB stub works through the runner’s pass-through args:
# Terminal 1 — QEMU paused, waiting for a debugger (-S), stub on :1234 (-s)
cargo run -p minixrs-kernel --target aarch64-unknown-none --release -- -s -S
# Terminal 2
rust-gdb target/aarch64-unknown-none/release/minixrs-kernel \
-ex "target remote :1234" -ex "break kmain" -ex "continue"
Roadmap
Mostly planned — parts of Phase 5 now boot. This chapter is design intent for phases beyond Phase 4, but Phase 5 has begun landing and the sections below are no longer uniformly future tense: grants and the fault-safe copy engine are live (slices 5.1–5.2), and so is the first user-space driver — TTY, which owns the PL011 and serves
CDEV_WRITE(slice 5.3, see Drivers). Still absent: the musl fork, the VirtIO drivers, the file-system servers, and any x86_64 port. The rest of this book describes what does run; this chapter describes where the project is headed. For live phase status and slice tracking, see the repository’sdocs/plan.md.
Grants and safe copy (Phase 5, first)
Every interesting data path beyond Phase 4 — VFS read/write, FS ↔ VFS, and later
block I/O — moves bytes across address spaces, and a 96-byte message payload can’t
carry them. MINIX solves this with grants: a process publishes a grant table
(via SYS_SETGRANT) describing memory regions and permitted operations
(read / write); it passes a grant ID in a message; the peer copies through
SYS_SAFECOPY, which the kernel authorizes against the grant table before moving
any bytes. This keeps the kernel from having to trust a raw pointer from one
process on behalf of another.
The call numbers exist as stubs today (see
System Calls & ABI). A real grant table plus a
fault-safe user copy (returning EFAULT on a bad pointer rather than panicking
the kernel) is the expected opening work of Phase 5, because the musl and
file-system slices depend on it.
musl C library
User C programs will link against a fork of musl
(MIT-licensed) whose Linux syscall layer is replaced with MINIX IPC. A POSIX call
becomes a message to a server rather than a Linux syscall:
read(fd, buf, n)
→ construct Message { m_type: VFS_READ, fd, buf_ptr, count }
→ _syscall(VFS_PROC_NR, VFS_READ, &msg) // SENDREC via the IPC trap
→ VFS handles it, replies
→ _syscall() extracts the result / errno
The fork’s shape:
_syscall()— the central routing function: setm_type,ipc_sendrec, and translate a negative reply intoerrno+-1. Mirrors MINIX 3’slib/libc/sys/syscall.c.- IPC trap stubs — a tiny
.Sper architecture issuing the trap. On aarch64 that issvc #0with the register convention from IPC (x0= endpoint,x1= primitive,x2= message). - ~100 POSIX wrappers — one per call (
open,read,write,fork,execve,mmap,brk, …), each constructing a message and calling_syscall. - A cbindgen bridge — the C headers (
Message, endpoint constants, call numbers) generated from thekernel-sharedRust crate, so the wire protocol has a single source of truth. This also means thekernel-sharedABI needs to be frozen deliberately before the header bridge is stood up. - A cross-compiled sysroot —
libc.a+ thecrt*.ostartup files, linked into user programs.
The printf “Hello World” milestone that closes the early Phase-5 work needs a
console/stdio sink; whether that is a kernel diagnostic call, a minimal TTY, or
deferred is a design decision to be locked in the Phase-5 plan.
File systems
Two file-system servers are planned, each speaking the REQ_* protocol to VFS
(which is skeletal today — see Servers):
- MFS — the MINIX File System (MinixFS v3 on-disk format).
- PFS — the Pipe File System (in-memory, for pipes and FIFOs).
A root image is also needed before block drivers exist — likely an initramfs or an
MXBI-embedded FS image, another Phase-5 design decision. FS-backed exec will
reuse the kernel’s ELF loader (kernel/src/boot_image/elf.rs).
Device drivers
Drivers are user-space processes, like servers — a buggy driver can crash only itself, and RS restarts it. They talk to the kernel (interrupts, I/O) and to VFS (device protocols) over IPC.
- Block drivers answer the
BDEV_*protocol (OPEN/CLOSE/READ/WRITE/IOCTL, plus scatter-gather); character drivers answerCDEV_*(OPEN/CLOSE/READ/WRITE/IOCTL/SELECT). Bulk data rides grants +SYS_SAFECOPY. - Interrupts arrive as
NOTIFYmessages from theHARDWAREendpoint after a driver registers a handler withSYS_IRQCTL. The kernel masks the line, notifies the driver, and the driver re-enables it — no interrupt storms. - VirtIO transport — MMIO on aarch64 (devices memory-mapped in the QEMU
virtdevice tree), PCI on x86_64. Devices exchange data through virtqueues: a descriptor table plus an available ring (driver → device) and a used ring (device → driver). - The hardware-free
memorydriver already shipped in Phase 5 — the ramdisk in slice 5.7,/dev/nulland/dev/zeroin slice 5.11 — so Phase 6’s driver work is VirtIO only:virtio-blk,virtio-net, andvirtio-console(a VirtIO TTY). - A
driver-rtcrate will provide the reusableBlockDriver/CharDrivertraits and the VirtIO transport types. Only a console story is needed for the Phase-5printfmilestone; the rest is Phase 6.
Copy-on-write fork
fork today eagerly gives the child its own frames (the kernel copies page tables
via SYS_FORK; VM clones the region set via VM_FORK — see
Memory Management). Copy-on-write fork — sharing pages
read-only and duplicating only on the first write fault — is a later optimization,
not a Phase-4/5 requirement.
x86_64 port
aarch64 is the primary target. The kernel is structured for a second architecture
(the HAL split, the x86_64-unknown-none target scaffolding, and the ABI-neutral
kernel-shared types), but the x86_64 path — SYSCALL/SYSRET, GDT/IDT, APIC,
the x86_64 IPC register ABI — is not implemented. It is a late phase.
System Calls & ABI
This is a reference chapter. It documents two things and keeps them distinct:
- What dispatches today — the kernel calls the microkernel actually handles at the end of Phase 4, and the server request ranges minix.rs uses on the wire.
- The MINIX ABI minix.rs targets — the POSIX call catalog (PM and VFS) that the musl wrappers and file-system servers will wire up in Phase 5 and beyond. These numbers are the MINIX 3 reference ABI; they are not the request numbers minix.rs currently sends.
The two call paths
MINIX has two kinds of “system call,” and minix.rs keeps both.
POSIX calls (user → server). A user program’s open / read / fork
becomes an IPC message to the responsible server, sent with SENDREC:
user program → SENDREC(server_endpoint, &msg) → kernel IPC → server → reply
The kernel routes the message but does not interpret the call. Today this path is
driven directly through the minixrs-ipc crate (by init and worker, using the
live PM request numbers below). The musl C wrappers that will make it transparent
to C programs — read(fd, buf, n) constructing the message for you — arrive in
Phase 5.
Kernel calls (server → kernel). A privileged server asks the kernel for a
low-level operation by sending a SENDREC to the SYSTEM task
(m_type = the call number). Each call is gated by the caller’s k_call_mask; a
process without the bit gets an error.
Kernel calls (SYS_*) — live today
Kernel-call numbers are contiguous from KERNEL_CALL = 0x600
(kernel-shared/src/callnr.rs). Phase 4 defines 18. Twelve have real handlers;
six are placeholder stubs whose consumers arrive later (grants in Phase 5, IRQ
control in the driver era). The dispatch table is kernel/src/system/mod.rs.
| Number | Call | Status | Purpose |
|---|---|---|---|
0x600 | SYS_GETINFO | live | Kernel introspection (e.g. GET_WHOAMI). |
0x601 | SYS_PRIVCTL | live | Set up a target’s privilege slot (PRIVCTL_SET_USER). |
0x602 | SYS_FORK | live | Clone a process slot as a frozen child. |
0x603 | SYS_EXEC | live | Replace a target’s image, from the boot archive or a grant (see below). |
0x604 | SYS_EXIT | live | Full process teardown (address space, endpoint, slot). |
0x605 | SYS_COPY | stub | Inter-space copy — placeholder. |
0x606 | SYS_SAFECOPY | stub | Grant-validated copy — real grants are Phase 5. |
0x607 | SYS_IRQCTL | stub | IRQ handler registration — driver era. |
0x608 | SYS_VMCTL | live | VM’s paging-mechanism call (see subcalls below). |
0x609 | SYS_SCHEDULE | live | Set a process’s priority and quantum. |
0x60A | SYS_SETALARM | live | Arm/cancel a per-process one-shot alarm. |
0x60B | SYS_TIMES | stub | Process accounting times — placeholder. |
0x60C | SYS_DIAGCTL | live | Print inline text to the console (see subcodes below). |
0x60D | SYS_SETGRANT | stub | Register the caller’s grant table — Phase 5. |
0x60E | SYS_SCHEDCTL | live | Claim/release a process for a user-space scheduler. |
0x60F | SYS_KILL | live | Raise a signal on a target (queues toward PM). |
0x610 | SYS_GETKSIG | live | PM: fetch the next process with pending signals. |
0x611 | SYS_ENDKSIG | live | PM: acknowledge signal processing for a target. |
The SYS_EXEC initial stack
SYS_EXEC does not merely set sp to the top of the new image’s stack page — it
builds the SysV/Linux initial process stack there first and points SP_EL0
at it. That is what lets a C runtime start unpatched: musl’s crt1 →
__libc_start_main → __init_libc reads argc/argv/envp and the auxiliary
vector straight off the stack, takes libc.page_size from AT_PAGESZ, and lets
__init_tls walk the program headers from AT_PHDR. Keeping the musl fork’s
diff confined to arch/aarch64/syscall_arch.h + src/minix/ depends on the
kernel supplying this frame rather than crt being taught a new shape.
The layout, upward from sp (16-byte aligned, as the AAPCS64 requires at entry —
SCTLR_EL1.SA0 turns a violation into an EL0 alignment abort):
| Offset | Contents |
|---|---|
+0 | argc (u64) |
+8 | argv[0] — a VA pointing at the name string below |
+16 | argv NULL terminator |
+24 | envp NULL terminator (the environment is empty) |
+32 | auxiliary vector: (a_type, a_val) pairs, 16 bytes each |
| … | (AT_NULL, 0) — auxv terminator |
| … | the NUL-terminated name string |
| … | zero padding up to the stack-page top |
There is exactly one argument (argc == 1, argv[0] the exec name) and no
environment. Slice 5.9 added exec-from-FS and deliberately did not add
user-supplied argv/envp: the kernel keeps synthesising argc = 1 with
argv[0] set to the path’s basename, which is what leaves EXEC_NAME_LEN,
PROC_NAME_LEN, and this frame’s whole geometry untouched by that slice. Real
argv/envp need a place to carry an unbounded vector, which is a separate
design rather than a field. The auxiliary vector is emitted in a fixed order
rather than Linux’s incidental one, so traces and tests are deterministic:
| Entry | Value | Emitted |
|---|---|---|
AT_PHDR (3) | VA the program headers are readable at | only when a PT_LOAD maps them |
AT_PHNUM (5) | e_phnum | with AT_PHDR |
AT_PHENT (4) | e_phentsize (56) | with AT_PHDR |
AT_PAGESZ (6) | 4096 | always |
AT_NULL (0) | 0 | always, last |
The SYS_EXEC payload and its two source forms
Slice 5.9 gave SYS_EXEC a source selector, so the same call number covers
loading a boot-archive module and loading a file the filesystem staged.
| Offset | Field |
|---|---|
0..4 | target endpoint (i32) |
4..20 | argv[0] / the new proc name, NUL-padded (EXEC_NAME_LEN = 16) |
20..24 | source selector — EXEC_SRC_NAME (1) or EXEC_SRC_GRANT (2); 0 is invalid |
24..28 | granter endpoint (i32, grant form only) |
28..32 | grant id (i32, grant form only) |
32..40 | image length (u64, grant form only) |
4..20 is argv[0] and the proc’s new name in both forms; only where the
image’s bytes come from changes. In the name form that field doubles as the MXBI
module name.
The grant form is decision D6: the kernel keeps ELF authority, and PM/VFS do the
staging. VFS reads the whole file into its own buffer and direct-grants it to PM;
PM names that grant here; the kernel reads the ELF through the grant using the
same page-walking copy engine every SYS_SAFECOPY uses, a header at a time onto
its own stack. There is no kernel filesystem, no kernel heap, and no kernel
staging buffer. The grant is validated by the ordinary verify_grant — who_to
must be PM’s own stored endpoint — and the read completes before the point of no
return, so a granted buffer that turns out not to be an ELF leaves the target
untouched on its old image and PM relays ENOEXEC to it.
There is deliberately no grant-offset field: the granted buffer holds the image from its start, the rule the BDEV and FS bands already state.
AT_PHDR is conditional because it must not be invented: a linker script that
does not pull the ELF header into the first PT_LOAD leaves e_phoff in an
unmapped file prefix, and reporting a VA there would fault __init_tls.
userland/worker/user.ld uses the FILEHDR PHDRS idiom (plus
. = <base> + SIZEOF_HEADERS) so its first PT_LOAD starts at file offset 0 and
does cover the headers. Boot servers are loaded by a different path
(load_boot_server), get no frame at all, and start with sp at the page top —
their _start reads nothing.
The byte layout lives in kernel-shared/src/execstack.rs
(build_initial_stack), which is pure and host-tested; do_exec stages the
frame in a kernel-stack buffer and installs it with mm::uaccess::copy_to_user_as
against the new address space’s ttbr0_pa — that primitive is
address-space-independent, so the frame lands before the AS is ever installed. A
frame that cannot be built (E2BIG) or copied (ENOMEM) tears the fresh image
back down and leaves the target on its old one. The AT_* values are the
Linux/SysV ones and are deliberately not emitted by gen-c-headers: musl
defines them itself.
SYS_VMCTL subcalls
SYS_VMCTL is VM’s single privileged lever over the kernel’s paging mechanism.
The subcall selector is in the first payload word; the target process (endpoint,
SELF allowed) in the next. Numbers start at 1 so a zeroed payload is invalid.
| Subcall | Effect |
|---|---|
VMCTL_PT_MAP (1) | Allocate a fresh zeroed frame, map it at vaddr, reply with the PA. |
VMCTL_PT_UNMAP (2) | Unmap vaddr and free the frame (EINVAL if nothing mapped). |
VMCTL_CLEAR_PAGEFAULT (3) | Clear a recorded fault and make the target runnable. |
VMCTL_GET_PAGEFAULT (4) | Read the target’s recorded fault coordinates. |
VMCTL_VMINHIBIT_SET / _CLEAR (5/6) | Gate scheduling while VM mutates the target’s AS. |
See Memory Management for how VM uses these.
SYS_DIAGCTL subcodes
SYS_DIAGCTL is the servers’ debug channel. Servers run at EL0 with no console
of their own, so without it their behavior is only observable indirectly, through
kernel-side traces. server-rt::diag_print is the client side.
Unlike MINIX 3, which passes a (buf, len) user pointer and copies the text in,
minix.rs carries the text inline in the message payload: the subcode in
payload 0..4, the length in 4..8, and up to DIAG_TEXT_MAX (88) text bytes
from DIAG_TEXT_OFF (8). The channel therefore needs no user-copy machinery and
cannot fault — it has to keep working while the copy engine and grants are
themselves under construction. diag_print splits longer strings across
successive calls, one console line each.
| Subcode | Effect |
|---|---|
DIAGCTL_CODE_DIAG (1) | Print the inline text as one [diag <name>] … line. |
DIAGCTL_CODE_STACKTRACE (2) | Reserved (MINIX 3) — EINVAL. |
DIAGCTL_CODE_REGISTER / _UNREGISTER (3/4) | Reserved (MINIX 3 kernel-message subscription) — EINVAL. |
The <name> prefix is the caller’s name as the kernel knows it, never
anything from the payload, so a server can only ever identify itself. Text is
restricted to printable ASCII, which keeps one call to exactly one line — the
boot-marker checks in tests/qemu-boot.expected depend on that framing. No extra
privilege gate is needed: Priv::k_call_mask already limits kernel calls to
server-grade privileges, and the shared USER privilege has an empty mask, so
ordinary user processes cannot reach this call.
Server request ranges — live today
Server requests are ordinary IPC m_type values, not kernel calls. Each server
occupies a distinct band below NOTIFY_MESSAGE (0x1000), const-asserted
disjoint in kernel-shared/src/callnr.rs:
| Base | Value | Server / requests |
|---|---|---|
PM_RQ_BASE | 0x700 | PM: GETPID / FORK / EXIT / WAIT / EXEC |
VFS_RQ_BASE | 0x800 | VFS: WRITE / OPEN / READ / CLOSE / EXEC_STAGE |
FS_RQ_BASE | 0x900 | MFS: READSUPER / LOOKUP / READ / WRITE / CREATE / TRUNC |
BDEV_RQ_BASE | 0xA00 | block drivers: READ / WRITE (slice 5.7) |
CDEV_RQ_BASE | 0xB00 | character drivers: WRITE (slice 5.3) / READ (5.11) |
VM_RQ_BASE | 0xC00 | VM: PAGEFAULT / BRK / MMAP / MUNMAP / FORK |
SEF_RQ_BASE | 0xD00 | SEF control: INIT / SIGNAL |
DS_RQ_BASE | 0xE00 | DS: PUBLISH / RETRIEVE / CHECK |
SCHED_RQ_BASE | 0xF00 | SCHED: NO_QUANTUM / START / STOP / SET_NICE |
The Servers chapter documents each request. These minix.rs-specific numbers are what actually travels on the wire today — distinct from the MINIX 3 POSIX ABI numbers catalogued below.
The MINIX ABI catalog (target)
Reference, not current behavior. The tables below are the MINIX 3 POSIX call ABI that minix.rs is heading toward — the numbering musl and the file-system servers will adopt. They describe the interface, not what is dispatched today: VFS is skeletal (it handles none of these yet), and PM’s live request numbers are the
0x700band above, not these. Treat this as the map of Phase 5+ work.
PM calls (base 0x000, target)
The Process Manager handles process lifecycle, signals, credentials, and timing.
| Call | # | POSIX | Description |
|---|---|---|---|
PM_EXIT | 1 | _exit | Terminate the caller |
PM_FORK | 2 | fork | Create a child process |
PM_WAIT4 | 3 | wait4 | Wait for a child to change state |
PM_GETPID | 4 | getpid | Get the caller’s PID |
PM_SETUID/GETUID | 5/6 | setuid/getuid | Real user ID |
PM_KILL | 11 | kill | Send a signal |
PM_SETGID/GETGID | 12/13 | setgid/getgid | Real group ID |
PM_EXEC | 14 | execve | Execute a new program image |
PM_SETSID/GETPGRP | 15/16 | setsid/getpgrp | Session / process group |
PM_ITIMER | 17 | setitimer | Interval timer |
PM_SIGACTION … PM_SIGRETURN | 20–24 | sigaction, sigsuspend, sigpending, sigprocmask | Signal machinery |
PM_GETPRIORITY/SETPRIORITY | 26/27 | getpriority/setpriority | Scheduling priority |
PM_GETTIMEOFDAY | 28 | gettimeofday | Time of day |
PM_CLOCK_GETRES/GETTIME/SETTIME | 33–35 | clock_* | POSIX clocks |
PM_GETRUSAGE | 36 | getrusage | Resource usage |
PM_REBOOT | 37 | reboot | Reboot / halt |
PM_SRV_FORK … PM_GETSYSINFO | 41–47 | (MINIX) | Service management for RS |
Slots 25, 40 and a few others are unused; numbers ≥ 40 are MINIX-specific service
infrastructure. (Full detail: minix/include/minix/callnr.h.)
VFS calls (base 0x100, target)
The Virtual File System server handles file I/O, directories, mounts, and — in MINIX 3.4 — BSD sockets.
| Call | # | POSIX | Call | # | POSIX |
|---|---|---|---|---|---|
VFS_READ | 0 | read | VFS_FCNTL | 25 | fcntl |
VFS_WRITE | 1 | write | VFS_PIPE2 | 26 | pipe2 |
VFS_LSEEK | 2 | lseek | VFS_UMASK | 27 | umask |
VFS_OPEN | 3 | open | VFS_CHROOT | 28 | chroot |
VFS_CREAT | 4 | creat | VFS_GETDENTS | 29 | getdents |
VFS_CLOSE | 5 | close | VFS_SELECT | 30 | select |
VFS_LINK/UNLINK | 6/7 | link/unlink | VFS_FCHDIR | 31 | fchdir |
VFS_CHDIR | 8 | chdir | VFS_FSYNC | 32 | fsync |
VFS_MKDIR | 9 | mkdir | VFS_TRUNCATE/FTRUNCATE | 33/34 | truncate/ftruncate |
VFS_MKNOD | 10 | mknod | VFS_FCHMOD/FCHOWN | 35/36 | fchmod/fchown |
VFS_CHMOD/CHOWN | 11/12 | chmod/chown | VFS_UTIMENS | 37 | utimensat |
VFS_MOUNT/UMOUNT | 13/14 | mount/umount | VFS_STATVFS1/FSTATVFS1 | 40/41 | statvfs/fstatvfs |
VFS_ACCESS | 15 | access | VFS_SOCKET … VFS_SHUTDOWN | 49–63 | BSD sockets |
VFS_SYNC | 16 | sync | |||
VFS_RENAME | 17 | rename | |||
VFS_RMDIR | 18 | rmdir | |||
VFS_SYMLINK/READLINK | 19/20 | symlink/readlink | |||
VFS_STAT/FSTAT/LSTAT | 21–23 | stat/fstat/lstat | |||
VFS_IOCTL | 24 | ioctl |
The socket calls (49–63) route through VFS to a socket driver or network stack.
(Full detail: minix/include/minix/callnr.h.)
Safe copy and grants (planned)
Because a message payload is only 96 bytes, larger transfers use MINIX’s grant
mechanism rather than passing raw pointers the kernel would have to trust: a
process publishes a grant describing a region and the permitted operation (read /
write), passes the grant ID in a message, and the peer copies through
SYS_SAFECOPY, which the kernel authorizes against the grant table. The call
numbers (SYS_SETGRANT, SYS_SAFECOPY) exist as stubs today; a real grant table
and validated copy are an opening Phase-5 slice — every interesting Phase-5 data
path (VFS read/write, FS ↔ VFS) moves bytes across address spaces and needs them.
Generated C headers
The ABI above is described to C by headers generated from the live Rust constants and never committed (phase-5 decision D8):
| Header | Contents |
|---|---|
minixrs/ipc.h | the message struct, endpoint packing macros, IPC primitives |
minixrs/com.h | process numbers and the boot endpoints derived from them |
minixrs/callnr.h | kernel-call numbers and the server request bands |
minixrs/errno.h | the MINIX 200-band errnos; an opt-in check on the C library’s POSIX ones |
cargo gen-c-headers # -> target/gen-c-headers/
Every value comes from kernel-shared, and the headers carry _Static_asserts
pinning the message layout and the endpoint encode/decode against it — so an
ABI change on the Rust side fails to compile on the C side rather than silently
corrupting IPC. See Build & CI.
MINIX 3 source references
| File | Purpose |
|---|---|
include/minix/callnr.h | PM and VFS call numbers |
include/minix/com.h | Kernel-call numbers and IPC constants |
lib/libc/sys/syscall.c | User-space _syscall() |
lib/libsys/kernel_call.c | Server-side _kernel_call() |
MINIX 3 Source Mapping
This chapter maps MINIX 3 source files and concepts to their minix.rs equivalents — a navigation aid if you are coming from the MINIX 3 codebase or Tanenbaum’s Operating Systems: Design and Implementation. Only paths that exist in the tree today are listed as real; everything else is grouped under Planned so the table never points at a file that isn’t there.
Some rows cite 64-bit paths in the MINIX 3 tree (e.g.
lib/libc/arch/x86_64/). MINIX 3 shipped only as a 32-bit OS; a working 64-bit MINIX 3 was a personal prototype by this project’s author, not an upstream release. These are cited as an ABI/source reference only — see the note in IPC.
Kernel
| MINIX 3 | minix.rs |
|---|---|
kernel/proc.c — mini_send | kernel/src/ipc/send.rs |
kernel/proc.c — mini_receive | kernel/src/ipc/receive.rs |
kernel/proc.c — mini_notify | kernel/src/ipc/notify.rs |
kernel/proc.c — mini_senda | kernel/src/ipc/senda.rs (stub) |
kernel/proc.c — deadlock | kernel/src/ipc/deadlock.rs |
kernel/proc.c — do_ipc | kernel/src/ipc/mod.rs |
kernel/proc.h — struct proc | kernel/src/proc/proc_struct.rs (static tables in table.rs) |
kernel/priv.h — struct priv | kernel/src/proc/priv_struct.rs |
kernel/system.c | kernel/src/system/mod.rs |
kernel/system/do_fork.c | kernel/src/system/do_fork.rs |
kernel/system/do_exec.c | kernel/src/system/do_exec.rs |
kernel/system/do_vmctl.c | kernel/src/system/do_vmctl.rs |
kernel/system/do_copy.c, do_irqctl.c | kernel/src/system/stubs.rs (stub handlers) |
kernel/clock.c | kernel/src/clock.rs |
kernel/interrupt.c | kernel/src/arch/aarch64/irq.rs, gic.rs |
kernel/main.c — kmain | kernel/src/main.rs |
kernel/table.c — boot table | kernel/src/boot_image/mod.rs |
kernel/arch/i386/head.S | kernel/src/arch/aarch64/entry.S |
kernel/arch/i386/mpx.S — context switch | kernel/src/arch/aarch64/context.rs |
kernel/arch/i386/protect.c — vectors | kernel/src/arch/aarch64/exception.rs |
kernel/arch/i386/memory.c — page tables | kernel/src/arch/aarch64/mmu.rs, addrspace.rs |
Shared headers
| MINIX 3 | minix.rs |
|---|---|
include/minix/ipc.h | kernel-shared/src/message.rs |
include/minix/ipcconst.h | kernel-shared/src/ipc_const.rs |
include/minix/com.h | kernel-shared/src/com.rs |
include/minix/callnr.h | kernel-shared/src/callnr.rs |
include/minix/type.h — endpoint_t | kernel-shared/src/endpoint.rs |
| error codes | kernel-shared/src/error.rs |
| signal numbers | kernel-shared/src/signal.rs |
The arrow runs both ways for the first four rows: tools/gen-c-headers generates
minix/{ipc,com,callnr,errno}.h back out of those Rust modules for the musl fork
to include, so C never gets a hand-maintained copy of the ABI.
Errno numbering is minix.rs’s own policy rather than a port of either reference
tree (phase-5 decision D7): the POSIX block 1..=40 uses classic book-era MINIX
values — identical to Linux/musl, which is what lets musl’s stock
bits/errno.h work unmodified — while the MINIX-specific IPC errnos take modern
MINIX 3’s 200-band values, clear of Linux’s entire range. Both are stored negated
in Rust. EBADSRCDST is the one name modern MINIX lacks; it takes the value of
that tree’s EBADEPT (216).
Runtime and libraries
| MINIX 3 | minix.rs |
|---|---|
lib/libsys/sef.c | server-rt/src/sef.rs |
lib/libsys/sef_init.c | server-rt/src/init.rs |
lib/libsys/sef_signal.c | server-rt/src/signal.rs |
| SEF message classifier | server-rt/src/classify.rs |
| DS publish glue | server-rt/src/ds.rs |
lib/libsys/kernel_call.c | minixrs-ipc/src/lib.rs |
Servers
| MINIX 3 | minix.rs |
|---|---|
servers/pm/main.c, forkexit.c, exec.c, signal.c | servers/pm/src/main.rs |
servers/pm/ — process table | servers/pm/src/mproc.rs |
servers/vfs/main.c | servers/vfs/src/main.rs (skeletal) |
servers/vm/main.c | servers/vm/src/main.rs |
servers/vm/region.c | servers/vm/src/region.rs |
servers/rs/main.c | servers/rs/src/main.rs |
servers/rs/manager.c — monitoring | servers/rs/src/monitor.rs |
servers/ds/main.c | servers/ds/src/main.rs |
servers/ds/store.c | servers/ds/src/registry.rs |
servers/sched/main.c | servers/sched/src/main.rs |
servers/sched/schedule.c — policy | servers/sched/src/policy.rs |
Build system
| MINIX 3 | minix.rs |
|---|---|
build.sh, NetBSD Makefiles | root Cargo.toml workspace + .cargo/config.toml |
releasetools/ | tools/qemu-run.sh, tools/check-boot-log.sh |
Planned
These MINIX 3 areas have no minix.rs counterpart in the tree yet; the paths below are the intended destinations (see Roadmap):
| MINIX 3 | minix.rs (planned) |
|---|---|
include/minix/safecopies.h — grants | grant table + SYS_SAFECOPY (Phase 5) |
lib/libc/sys/syscall.c, _ipc.S, open.c, … | the musl fork (musl/src/minix/*) |
lib/libblockdriver/, lib/libchardriver/ | drivers/driver-rt traits |
servers/vfs/{open,read,path,mount}.c | VFS I/O (VFS is one main.rs today) |
servers/mfs/, servers/pfs/ | fs/mfs, fs/pfs (stubs) |
drivers/storage/*, drivers/net/*, drivers/tty/ | drivers/virtio-*, drivers/memory (stubs) |
| disk-image tooling | (no root disk yet — see Boot) |
Concepts unchanged
These MINIX 3 concepts carry over directly and are live today:
- Message-passing IPC with fixed-size (104-byte) messages.
- Five live IPC primitives — SEND, RECEIVE, SENDREC, NOTIFY, SENDNB (SENDA is stubbed).
- Endpoint-based process identification with generation numbers.
- Privilege bitmaps (
trap_mask,ipc_to,k_call_mask). - SEF server lifecycle (init, ping, signal).
- An embedded boot image (MXBI). Startup is not hand-ordered, though — servers rendezvous at run time through DS.
- RS heartbeat monitoring (restart-on-crash is detect-only so far).
- DS as the name → endpoint registry.
Carried over in design but not yet realized: grant-based safe copy, the BDEV/CDEV
driver protocols, and the REQ_* VFS ↔ FS protocol.
Concepts changed
| MINIX 3 | minix.rs | Why |
|---|---|---|
| raw C pointers in IPC queues | Option<ProcNr> indices | memory safety |
m1i1 / m2l1 message fields | named typed structs | readability |
EXTERN macro globals | module-scoped statics | Rust idiom |
RTS_SET / RTS_UNSET macros | rts_set / rts_unset fns | type safety |
volatile flags | AtomicU32 | Rust atomics |
| integer error returns | Result<T, E> | Rust error handling |
#ifdef arch selection | cfg(target_arch) | Rust conditional compilation |
| NetBSD libc / userland | musl fork / Rust userland (planned) | BSD license, minimal |