Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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) and docs/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.

  1. IPC — copying fixed-size messages between processes, managing the send/receive queues, and detecting deadlocks.
  2. Scheduling — priority run queues and timer-driven preemption.
  3. Interrupt dispatch — routing hardware interrupts to the process that registered for them, delivered as NOTIFY messages.
  4. 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:

AspectMINIX 3minix.rs
Kernel languageCRust (no_std, no_main)
Target architecturesi386, 32-bit ARMaarch64 (x86_64 planned)
Bootloadercustom / multibootLimine (UEFI)
C libraryNetBSD libcmusl fork (planned, Phase 5)
UserlandNetBSD commandsminimal Rust (init, worker)
Licensemixed (BSD + GPL)BSD-3-Clause only
IPC linked listsraw C pointerstable indices (Option<ProcNr>)
Message payloadopaque 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:

ServerRoleState at Phase 4
PMfork, exec, exit, wait, minimal signalsreal
VMpage-fault resolution, brk, mmap, munmapreal
RSmonitor / heartbeat system processesreal (detect-only)
DSname → endpoint registryreal
SCHEDuser-space scheduling policyreal
VFSfile-operation switchskeletal (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. Bit i allows primitive i; ordinary user processes typically get only SENDREC.
  • 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

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 DAIF bits set).
  • SP points 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_mxbiboot_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.

ModuleProc nrLoaded at boot?
pm0yes
vfs1yes (skeletal)
rs2yes
ds5yes
vm7yes
sched9yes
init10yes — becomes PID 1
worker−1no — 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

AspectMINIX 3 file
Boot process tablekernel/table.c
Kernel initkernel/main.c (kmain, bsp_finish_booting)
i386 entry / pre-initkernel/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 of include/minix/ipc.h that fixes sizeof(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:

PrimitiveNumberStatusBehavior
SEND1liveBlocking send; caller blocks until the receiver accepts.
RECEIVE2liveBlocking receive from a specific endpoint or ANY.
SENDREC3liveAtomic send-then-receive; the common client→server call.
NOTIFY4liveNon-blocking notification; sets a bit if the target isn’t waiting.
SENDNB5liveNon-blocking send; fails instead of blocking.
SENDA16stubAsynchronous 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.

SENDNBSEND 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:

RegisterPurpose
x0Endpoint (src / dest / src_dest); also receives the result
x1IPC primitive number
x2Pointer 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:

EndpointValueMeaning
ANY16383 (0x3FFF)receive from any sender
NONE16382no process
SELF16381the calling process itself

Kernel tasks occupy negative slots (kernel-shared/src/com.rs):

Proc nrNameRole
-5ASYNCMasync-message driver
-4IDLEidle task
-3CLOCKtimer-driven work
-2SYSTEMhandles SYS_* kernel calls
-1HARDWAREsource 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 nrServerBoots today?
0PMyes
1VFSyes (skeletal)
2RSyes
3MEM (memory driver)reserved, not built
4TTYreserved, not built
5DSyes
6MFSreserved, not built
7VMyes
8PFSreserved, not built
9SCHEDyes
10init (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; bit i allows primitive i. User processes typically hold only SENDREC; servers hold SEND/RECEIVE/NOTIFY/… (This is also why SENDA, 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 opens ipc_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_priv opens 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 by RECEIVE.
  • 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 ownershipio_ranges / irqs will 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

ConceptMINIX 3 fileKey functions
IPC dispatchkernel/proc.cdo_ipc, do_sync_ipc
Blocking send / receivekernel/proc.cmini_send, mini_receive
Notification / async sendkernel/proc.cmini_notify, mini_senda
Deadlock detectionkernel/proc.cdeadlock
Process / privilege tableskernel/proc.h, priv.hstruct proc, struct priv
Message / IPC constantsinclude/minix/ipc.h, ipcconst.hmessage, 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 rangeDevice
0x0800_00000x0800_FFFFGICv3 distributor
0x080A_00000x080B_FFFFGICv3 redistributor
0x0900_00000x0900_0FFFPL011 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:

LevelVA bitsEach 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_frame zeroes every frame before handing it out, so a caller never observes residual state.
  • free_frame pushes 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 (or None).
  • 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:

  1. parks the next process’s register frame for the trap return,
  2. calls switch_ttbr0_with_asid(ttbr0_pa, asid) — which writes TTBR0_EL1 = ttbr0_pa | ((asid as u64) << 48) and issues an ASID-tagged tlbibefore
  3. 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):

SubcallEffect
VMCTL_PT_MAPAllocate a fresh zeroed frame and map it at vaddr with the requested protection; reply with the chosen PA.
VMCTL_PT_UNMAPClear 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_PAGEFAULTClear the target’s recorded fault and make it runnable again.
VMCTL_GET_PAGEFAULTRead the target’s recorded fault coordinates.
VMCTL_VMINHIBIT_SET / _CLEARGate 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 by brk, based at the fixed HEAP_BASE (0x0100_0000).
  • Mmap — anonymous mappings, bump-allocated from MMAP_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.

BaseValueServer / purpose
PM_RQ_BASE0x700PM: PM_GETPID / FORK / EXIT / WAIT / EXEC
VFS_RQ_BASE0x800VFS: VFS_WRITE / OPEN / READ / CLOSE / EXEC_STAGE
FS_RQ_BASE0x900File systems: FS_READSUPER / LOOKUP / READ / WRITE / CREATE / TRUNC (MFS)
BDEV_RQ_BASE0xA00Block drivers: BDEV_READ / BDEV_WRITE (memory)
CDEV_RQ_BASE0xB00Character drivers: CDEV_WRITE (TTY, memory driver) / CDEV_READ (memory driver; TTY answers ENOSYS until Phase 6)
VM_RQ_BASE0xC00VM: VM_PAGEFAULT / BRK / MMAP / MUNMAP / FORK
SEF_RQ_BASE0xD00SEF control messages (ping / signal / init)
DS_RQ_BASE0xE00DS: DS_PUBLISH / RETRIEVE / CHECK
SCHED_RQ_BASE0xF00SCHED: 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_GETPID replies with the caller’s pid (m_type is the pid, MINIX result-is-pid), parent pid in the payload.
  • PM_FORK builds a child in a fixed, safety-critical order: allocate the mproc slot, SYS_FORK (the kernel clones a frozen child — RTS_RECEIVING | RTS_NO_PRIV), VM_FORK (VM copies the parent’s regions), SCHEDULING_START, then SYS_PRIVCTL(PRIVCTL_SET_USER) to release the freeze — and finally replies to both halves of the shared SENDREC (child sees 0, parent sees the child pid: fork returns twice). Only PM’s reply clears RTS_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_EXEC issues SYS_EXEC naming 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 as worker; a user-supplied path arrives with the Phase-5 filesystem.
  • PM_EXIT does SCHEDULING_STOP then SYS_EXIT (full teardown: address space freed, endpoint generation bumped, slot freed) and marks the mproc slot a zombie holding the encoded status; the dead child gets no reply.
  • PM_WAIT reaps 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 async SIGCHLD in 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 holds SYS_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_WRITE has no such field, and must never gain one. It is the same anti-confused-deputy rule that keeps a granter out of the CDEV_WRITE payload, applied to the granting side.
  • VFS absorbs short writes. A character driver may move fewer bytes than asked (CDEV_MAX_IO, its staging limit); POSIX write() is not allowed to expose that, so VFS re-sends with offset advanced 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_COPY reads the path, and its source is the kernel-stamped m_source. This is the first live consumer of decision D4’s “SYS_COPY for small control-plane reads” sentence, and the confused-deputy rule in its sharpest form: SYS_COPY has no per-target authorization whatsoever — the caller’s k_call_mask bit 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 write because a driver’s staging limit may not reach write()’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 returning 0 — 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 the PM_EXEC it is serving, so passing it by value costs no SYS_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_source is EPERM, 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 .bss static, 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 a Blocks capability token whose read(&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 small Copy value.
  • Streaming, not buffering. tools/mkfs-mfs’s verify.rs is the reference implementation of the same reader, but it materializes a whole directory into a Vec; MFS asks about one block at a time and keeps nothing but a u32. The fs.selfcheck boot marker is the one place the two readers meet over a real image.
  • Degraded, never fatal. Past sef_startup nothing panics and nothing spins: a failed mount answers ENODEV to every request, and every device-derived loop bound has a cap, because a corrupt inode claiming size = i32::MAX would 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_FORKSYS_EXECSYS_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:

  • worker is slice 5.5’s exec-ABI probe. It validates the SysV initial stack against its own sp and 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).
  • hello is 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 with system::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 issues isb; 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::uaccess refuses 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:

FieldPayload offsetMeaning
minor0..4 (i32)which device; CDEV_MINOR_CONSOLE = 0 is the UART
grant id4..8 (i32)names the client’s buffer (CPF_READ for a write, CPF_WRITE for a read)
length8..12 (i32)bytes requested
offset16..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_write applies the checks in order (unknown minor → ENXIO, negative length → EINVAL, invalid grant id → EINVAL, then clamp to CDEV_MAX_IO) (the four-field parse moved to server-rt::cdev in 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 only unsafe: volatile accesses to FR and DR at TTY_UART_VA, polling FR.TXFF before 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 by forced-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:

FieldPayload offsetMeaning
minor0..4 (i32)which device; BDEV_MINOR_RAMDISK = 0 is the boot image
grant id4..8 (i32)names the client’s buffer
length8..12 (i32)bytes requested; at most BDEV_MAX_IO = one block
block16..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

PieceLocation
The libc forkexternal/musl (submodule → minixrs/musl-minixrs, branch minixrs)
In-tree sysroot buildertools/build-musl.shtarget/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 programuserland/hello/hello.c (and hello.ld, musl flavor only)
Flavor selectionkernel/build.rs’s build_hello
Compile + linkbuild_hello_sdk / build_hello_musl
Generated ABI headerscargo gen-c-headerstarget/gen-c-headers/include/minixrs/

Two toolchains, one program

hello.c is built by whichever C toolchain is available, in a strict preference order:

  1. Sdk — the minix.rs SDK at $MINIXRS_SDK (default $HOME/toolchains/minixrs). Toolchain-program milestone M3.
  2. Musl — the in-tree sysroot from tools/build-musl.sh. Slice 5.6.
  3. Worker — no C toolchain: the worker ELF is packed under the name hello.

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:

SuppliedWhy it matters
-staticthere is no dynamic loader
--image-base=0x100000LLVM patch 0006 — this is why the SDK flavor needs no linker script
-z max-page-size=4096decision D13: 4 KiB pages
-z separate-loadable-segmentsno two PT_LOADs share a page; the loader maps per-segment permissions
crt1.o, crti.o, crtn.o, -lcfrom $MINIXRS_SDK/sysroot/usr/lib
libclang_rt.builtins.athe 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 syscallminix.rs mapping
writevone VFS_WRITE per iovec, counts summed
writeone VFS_WRITE
exit_group, exitPM_EXIT (does not return)
set_tid_addressconstant 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:

CaseResult
No $MINIXRS_SDK (unset, default prefix absent)silent; try the in-tree musl sysroot
MINIXRS_SDK set but unusableone cargo::warning naming the missing file, then try musl
Neither sysrootcargo::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:

SDKin-tree musl
Size46,664 B (45.6 KB)200,152 B (195.5 KB)
PT_LOADs43
Entry0x1010000x101000
Brand note0x1002000x117000
Last mapped byte0x108ce00x11caf8

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 bare nightly would 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.sh auto-detects it in common locations, or set QEMU_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 aliasforced-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 .S files with clang and passes the resulting objects straight to the linker. When CARGO_CFG_TARGET_OS != "none" it instead emits a cargo::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 blob user_stub.S is assembled only when the boot-stubs feature 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 nested CARGO_TARGET_DIR (target/minixrs-user, so core/alloc are compiled once rather than per crate), checks each ELF carries the minixrs identity note, packs them into the MXBI archive (pack_mxbi), and emits BOOT_IMAGE_PATH for the kernel to include_bytes!. There is no separate mkbootimage tool. 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-target is unstable (cargo#9406), hence the cargo-features opt-in and the nightly pin in rust-toolchain.toml. If a future bump drops it, cargo fails loudly on the manifest; the fallback is a workspace default-members list omitting "kernel".
  • test = false is required. Without it, cargo check --all-targets builds a phantom test harness for the kernel bin and fails with E0463: can't find crate for test.
  • build.rs’s cargo::error= and main.rs’s #[cfg(not(target_os = "none"))] compile_error! are now unreachable defense-in-depth, kept in case forced-target ever 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):

JobBlocking?What it checks
fmtyescargo fmt --all --check (covers the kernel too)
clippyyescargo clippy --workspace --exclude minixrs-kernel --all-targets -- -D warnings (host target; kernel excluded for runner cost, see below)
clippy-kernelyescargo clippy -p minixrs-kernel --target aarch64-unknown-none -- -D warnings, twice: default features and --no-default-features
c-headersyesregenerates the C ABI headers from kernel-shared and compiles them with clang -std=c11 -fsyntax-only (host + both musl triples)
audityescargo-audit advisory scan
denyyescargo-deny (licenses / bans, config in deny.toml)
geigeradvisoryunsafe surface report (per package, kernel filtered out)
miriadvisoryUB check on the host-testable crates
qemu-smokeyesboots the kernel and greps the serial log
coverageyescargo-llvm-covlcov.info (kernel excluded)
sonarfeeds LCOV to SonarQube Cloud

Notes: CI’s clippy and coverage exclude the kernel for runner cost, not correctnessforced-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_NR vs _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_NR is −2, SYSTEM_EP is 32766). Naming a task by its _PROC_NR in 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.h defines only the MINIX 200-band and puts the POSIX checks behind MINIXRS_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 with grep -a (or grep -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 N run reaches far fewer than N × 100 ticks. 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 startup SYS_GETINFO) shows on [ipc], not [ksys].
  • Zero [ipc] samples ≠ a stuck caller. A blocking SENDREC client (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_EXIT are head-carved), or add a temporary [DBG] trace in ipc::do_ipc keyed on the caller’s proc number — and remove it before committing.
  • The acceptance harness. tools/check-boot-log.sh <log> greps a captured log against tests/qemu-boot.expected and tests/qemu-boot.forbidden — the same check the qemu-smoke CI 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_GETINFO loop dominates the [ipc]/[ksys] sample stream. When you’re chasing a server or init/musl issue, boot --no-default-features to drop the demo stubs A–D entirely (see Boot stubs) — the trace then shows only the servers + init/worker. Note the qemu-smoke markers assume the default (stubs-on) boot, so don’t run check-boot-log.sh against 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’s docs/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: set m_type, ipc_sendrec, and translate a negative reply into errno + -1. Mirrors MINIX 3’s lib/libc/sys/syscall.c.
  • IPC trap stubs — a tiny .S per architecture issuing the trap. On aarch64 that is svc #0 with 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 the kernel-shared Rust crate, so the wire protocol has a single source of truth. This also means the kernel-shared ABI needs to be frozen deliberately before the header bridge is stood up.
  • A cross-compiled sysrootlibc.a + the crt*.o startup 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 answer CDEV_* (OPEN/CLOSE/READ/WRITE/IOCTL/SELECT). Bulk data rides grants + SYS_SAFECOPY.
  • Interrupts arrive as NOTIFY messages from the HARDWARE endpoint after a driver registers a handler with SYS_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 virt device 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 memory driver already shipped in Phase 5 — the ramdisk in slice 5.7, /dev/null and /dev/zero in slice 5.11 — so Phase 6’s driver work is VirtIO only: virtio-blk, virtio-net, and virtio-console (a VirtIO TTY).
  • A driver-rt crate will provide the reusable BlockDriver / CharDriver traits and the VirtIO transport types. Only a console story is needed for the Phase-5 printf milestone; 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:

  1. 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.
  2. 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.

NumberCallStatusPurpose
0x600SYS_GETINFOliveKernel introspection (e.g. GET_WHOAMI).
0x601SYS_PRIVCTLliveSet up a target’s privilege slot (PRIVCTL_SET_USER).
0x602SYS_FORKliveClone a process slot as a frozen child.
0x603SYS_EXECliveReplace a target’s image, from the boot archive or a grant (see below).
0x604SYS_EXITliveFull process teardown (address space, endpoint, slot).
0x605SYS_COPYstubInter-space copy — placeholder.
0x606SYS_SAFECOPYstubGrant-validated copy — real grants are Phase 5.
0x607SYS_IRQCTLstubIRQ handler registration — driver era.
0x608SYS_VMCTLliveVM’s paging-mechanism call (see subcalls below).
0x609SYS_SCHEDULEliveSet a process’s priority and quantum.
0x60ASYS_SETALARMliveArm/cancel a per-process one-shot alarm.
0x60BSYS_TIMESstubProcess accounting times — placeholder.
0x60CSYS_DIAGCTLlivePrint inline text to the console (see subcodes below).
0x60DSYS_SETGRANTstubRegister the caller’s grant table — Phase 5.
0x60ESYS_SCHEDCTLliveClaim/release a process for a user-space scheduler.
0x60FSYS_KILLliveRaise a signal on a target (queues toward PM).
0x610SYS_GETKSIGlivePM: fetch the next process with pending signals.
0x611SYS_ENDKSIGlivePM: 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):

OffsetContents
+0argc (u64)
+8argv[0] — a VA pointing at the name string below
+16argv NULL terminator
+24envp NULL terminator (the environment is empty)
+32auxiliary 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:

EntryValueEmitted
AT_PHDR (3)VA the program headers are readable atonly when a PT_LOAD maps them
AT_PHNUM (5)e_phnumwith AT_PHDR
AT_PHENT (4)e_phentsize (56)with AT_PHDR
AT_PAGESZ (6)4096always
AT_NULL (0)0always, 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.

OffsetField
0..4target endpoint (i32)
4..20argv[0] / the new proc name, NUL-padded (EXEC_NAME_LEN = 16)
20..24source selector — EXEC_SRC_NAME (1) or EXEC_SRC_GRANT (2); 0 is invalid
24..28granter endpoint (i32, grant form only)
28..32grant id (i32, grant form only)
32..40image 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_grantwho_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.

SubcallEffect
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.

SubcodeEffect
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:

BaseValueServer / requests
PM_RQ_BASE0x700PM: GETPID / FORK / EXIT / WAIT / EXEC
VFS_RQ_BASE0x800VFS: WRITE / OPEN / READ / CLOSE / EXEC_STAGE
FS_RQ_BASE0x900MFS: READSUPER / LOOKUP / READ / WRITE / CREATE / TRUNC
BDEV_RQ_BASE0xA00block drivers: READ / WRITE (slice 5.7)
CDEV_RQ_BASE0xB00character drivers: WRITE (slice 5.3) / READ (5.11)
VM_RQ_BASE0xC00VM: PAGEFAULT / BRK / MMAP / MUNMAP / FORK
SEF_RQ_BASE0xD00SEF control: INIT / SIGNAL
DS_RQ_BASE0xE00DS: PUBLISH / RETRIEVE / CHECK
SCHED_RQ_BASE0xF00SCHED: 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 0x700 band 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#POSIXDescription
PM_EXIT1_exitTerminate the caller
PM_FORK2forkCreate a child process
PM_WAIT43wait4Wait for a child to change state
PM_GETPID4getpidGet the caller’s PID
PM_SETUID/GETUID5/6setuid/getuidReal user ID
PM_KILL11killSend a signal
PM_SETGID/GETGID12/13setgid/getgidReal group ID
PM_EXEC14execveExecute a new program image
PM_SETSID/GETPGRP15/16setsid/getpgrpSession / process group
PM_ITIMER17setitimerInterval timer
PM_SIGACTIONPM_SIGRETURN20–24sigaction, sigsuspend, sigpending, sigprocmaskSignal machinery
PM_GETPRIORITY/SETPRIORITY26/27getpriority/setpriorityScheduling priority
PM_GETTIMEOFDAY28gettimeofdayTime of day
PM_CLOCK_GETRES/GETTIME/SETTIME33–35clock_*POSIX clocks
PM_GETRUSAGE36getrusageResource usage
PM_REBOOT37rebootReboot / halt
PM_SRV_FORKPM_GETSYSINFO41–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#POSIXCall#POSIX
VFS_READ0readVFS_FCNTL25fcntl
VFS_WRITE1writeVFS_PIPE226pipe2
VFS_LSEEK2lseekVFS_UMASK27umask
VFS_OPEN3openVFS_CHROOT28chroot
VFS_CREAT4creatVFS_GETDENTS29getdents
VFS_CLOSE5closeVFS_SELECT30select
VFS_LINK/UNLINK6/7link/unlinkVFS_FCHDIR31fchdir
VFS_CHDIR8chdirVFS_FSYNC32fsync
VFS_MKDIR9mkdirVFS_TRUNCATE/FTRUNCATE33/34truncate/ftruncate
VFS_MKNOD10mknodVFS_FCHMOD/FCHOWN35/36fchmod/fchown
VFS_CHMOD/CHOWN11/12chmod/chownVFS_UTIMENS37utimensat
VFS_MOUNT/UMOUNT13/14mount/umountVFS_STATVFS1/FSTATVFS140/41statvfs/fstatvfs
VFS_ACCESS15accessVFS_SOCKETVFS_SHUTDOWN49–63BSD sockets
VFS_SYNC16sync
VFS_RENAME17rename
VFS_RMDIR18rmdir
VFS_SYMLINK/READLINK19/20symlink/readlink
VFS_STAT/FSTAT/LSTAT21–23stat/fstat/lstat
VFS_IOCTL24ioctl

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):

HeaderContents
minixrs/ipc.hthe message struct, endpoint packing macros, IPC primitives
minixrs/com.hprocess numbers and the boot endpoints derived from them
minixrs/callnr.hkernel-call numbers and the server request bands
minixrs/errno.hthe 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

FilePurpose
include/minix/callnr.hPM and VFS call numbers
include/minix/com.hKernel-call numbers and IPC constants
lib/libc/sys/syscall.cUser-space _syscall()
lib/libsys/kernel_call.cServer-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 3minix.rs
kernel/proc.cmini_sendkernel/src/ipc/send.rs
kernel/proc.cmini_receivekernel/src/ipc/receive.rs
kernel/proc.cmini_notifykernel/src/ipc/notify.rs
kernel/proc.cmini_sendakernel/src/ipc/senda.rs (stub)
kernel/proc.cdeadlockkernel/src/ipc/deadlock.rs
kernel/proc.cdo_ipckernel/src/ipc/mod.rs
kernel/proc.hstruct prockernel/src/proc/proc_struct.rs (static tables in table.rs)
kernel/priv.hstruct privkernel/src/proc/priv_struct.rs
kernel/system.ckernel/src/system/mod.rs
kernel/system/do_fork.ckernel/src/system/do_fork.rs
kernel/system/do_exec.ckernel/src/system/do_exec.rs
kernel/system/do_vmctl.ckernel/src/system/do_vmctl.rs
kernel/system/do_copy.c, do_irqctl.ckernel/src/system/stubs.rs (stub handlers)
kernel/clock.ckernel/src/clock.rs
kernel/interrupt.ckernel/src/arch/aarch64/irq.rs, gic.rs
kernel/main.ckmainkernel/src/main.rs
kernel/table.c — boot tablekernel/src/boot_image/mod.rs
kernel/arch/i386/head.Skernel/src/arch/aarch64/entry.S
kernel/arch/i386/mpx.S — context switchkernel/src/arch/aarch64/context.rs
kernel/arch/i386/protect.c — vectorskernel/src/arch/aarch64/exception.rs
kernel/arch/i386/memory.c — page tableskernel/src/arch/aarch64/mmu.rs, addrspace.rs

Shared headers

MINIX 3minix.rs
include/minix/ipc.hkernel-shared/src/message.rs
include/minix/ipcconst.hkernel-shared/src/ipc_const.rs
include/minix/com.hkernel-shared/src/com.rs
include/minix/callnr.hkernel-shared/src/callnr.rs
include/minix/type.hendpoint_tkernel-shared/src/endpoint.rs
error codeskernel-shared/src/error.rs
signal numberskernel-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 3minix.rs
lib/libsys/sef.cserver-rt/src/sef.rs
lib/libsys/sef_init.cserver-rt/src/init.rs
lib/libsys/sef_signal.cserver-rt/src/signal.rs
SEF message classifierserver-rt/src/classify.rs
DS publish glueserver-rt/src/ds.rs
lib/libsys/kernel_call.cminixrs-ipc/src/lib.rs

Servers

MINIX 3minix.rs
servers/pm/main.c, forkexit.c, exec.c, signal.cservers/pm/src/main.rs
servers/pm/ — process tableservers/pm/src/mproc.rs
servers/vfs/main.cservers/vfs/src/main.rs (skeletal)
servers/vm/main.cservers/vm/src/main.rs
servers/vm/region.cservers/vm/src/region.rs
servers/rs/main.cservers/rs/src/main.rs
servers/rs/manager.c — monitoringservers/rs/src/monitor.rs
servers/ds/main.cservers/ds/src/main.rs
servers/ds/store.cservers/ds/src/registry.rs
servers/sched/main.cservers/sched/src/main.rs
servers/sched/schedule.c — policyservers/sched/src/policy.rs

Build system

MINIX 3minix.rs
build.sh, NetBSD Makefilesroot 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 3minix.rs (planned)
include/minix/safecopies.h — grantsgrant 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}.cVFS 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 3minix.rsWhy
raw C pointers in IPC queuesOption<ProcNr> indicesmemory safety
m1i1 / m2l1 message fieldsnamed typed structsreadability
EXTERN macro globalsmodule-scoped staticsRust idiom
RTS_SET / RTS_UNSET macrosrts_set / rts_unset fnstype safety
volatile flagsAtomicU32Rust atomics
integer error returnsResult<T, E>Rust error handling
#ifdef arch selectioncfg(target_arch)Rust conditional compilation
NetBSD libc / userlandmusl fork / Rust userland (planned)BSD license, minimal