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

minix-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; 0x900 and 0xA00 stay reserved for the two bands Phase 5 still owes, BDEV and MFS.

BaseValueServer / purpose
PM_RQ_BASE0x700PM: PM_GETPID / FORK / EXIT / WAIT / EXEC
VFS_RQ_BASE0x800VFS: VFS_WRITE
CDEV_RQ_BASE0xB00Character drivers: CDEV_WRITE (TTY)
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 path

VFS (servers/vfs/) turns a small integer into something you can write to. That is the whole of its job in Phase 5 so far, and since slice 5.4 it does it for real: an ordinary user process can write(1, buf, len) and see bytes on the console. Mount points, open, and reads arrive with the MFS server in slice 5.8.

One request, one copy

user ──VFS_WRITE{fd,buf,len}──► VFS ──CDEV_WRITE{minor,gid,len,off}──► TTY
                                 │                                      │
                                 └── 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 then safecopies straight out of the caller’s address space. The bytes never pass through VFS: there is exactly one copy, 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.
  • 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. Nothing opens or closes yet, so every row is 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. Because nothing mutates, the storage is an ordinary immutable static and VFS carries no unsafe; slice 5.8’s open flips it to the UnsafeCell newtype that VM’s region table already uses.

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.

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 minix-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 to become the worker binary, which runs a few PM_GETPID round-trips and exits; 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_EXEC name=workerSYS_EXIT triples, the proof that fork, exec, teardown, and reap all compose.

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 is exactly one driver, TTY (drivers/tty/), the console. 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 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 one:

FieldPayload offsetMeaning
minor0..4 (i32)which device; CDEV_MINOR_CONSOLE = 0 is the UART
grant id4..8 (i32)names the client’s source buffer
length8..12 (i32)bytes requested
offset16..24 (u64)where in the granted range to start

Three 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 written (>= 0; zero is legal) or a negative errno. A driver replying OK would be telling its client that the whole buffer went out.

An over-long request is a short write, not a failure. A request longer than CDEV_MAX_IO (256 bytes) moves the first CDEV_MAX_IO bytes and reports that count; the client re-sends with offset advanced. That is POSIX write()’s contract, and it is what lets a driver stage through a fixed buffer in its main frame with no allocator at all.

CDEV_READ is deliberately absent: receive needs interrupts (SYS_IRQCTL) and arrives in Phase 6. The /dev/null and /dev/zero devices planned for slice 5.11 are new minors of CDEV_WRITE, not new request numbers.

TTY

drivers/tty/ is three files:

  • cdev.rs — the pure, host-tested half: parse_write reads the four payload fields, validate_write applies the checks in order (unknown minor → ENXIO, negative length → EINVAL, invalid grant id → EINVAL, then clamp to CDEV_MAX_IO). Both are total functions, 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).

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, minix-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 (minix-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/minix/{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>; minix/errno.h defines only the MINIX 200-band and puts the POSIX checks behind MINIX_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 planned set is virtio-blk, virtio-net, virtio-console (a VirtIO TTY), and a hardware-free memory driver (/dev/null, /dev/zero, ramdisk).
  • 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 minix-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 with a boot-embedded binary.
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.

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