minix.rs
MINIX 3, in Rust, for the 64-bit era
A 64-bit-only reimplementation of MINIX 3 in Rust, preserving the original ABI.
minix.rs is a learning operating system built around a greenfield Rust microkernel. It keeps MINIX 3’s architecture — message-passing IPC, user-space servers, user-space drivers, and a fine-grained privilege model — while dropping 32-bit legacy and targeting modern 64-bit platforms under QEMU.
Highlights
- Microkernel in Rust (
no_std,no_main) — only IPC, scheduling, interrupt dispatch, and memory protection live in the kernel. - Message passing — MINIX 3’s six IPC primitives; five are live (SEND, RECEIVE, SENDREC, NOTIFY, SENDNB), SENDA is still a stub.
- User-space servers — PM, VFS, VM, RS, DS, SCHED run as separate processes.
- User-space drivers — VirtIO drivers as separate processes (planned; see the Roadmap).
- aarch64 first (Apple Silicon / QEMU virt); x86_64 planned.
- ABI-preserving — message layout, endpoints, and call numbers track MINIX 3.
Status
Phases 0–4 are complete (as of 2026-07-18): the aarch64 kernel boots through
UEFI/Limine, the system servers (PM, VFS, VM, RS, DS, SCHED) start from a
multi-module boot image and discover each other via DS, and init (PID 1)
drives a fork/exec/wait loop over a boot-embedded worker binary — all verified
in QEMU. Phase 5 — a musl libc fork and the first file systems, ending in a C
“Hello World” — is next; the working plan lives in the repository’s
docs/plan.md.
About this book
This book is the canonical, source-derived documentation for minix.rs, written from the actual kernel and server code. Chapters describe the system in the present tense only for what boots today; forward-looking design is collected in the Roadmap.
Note: The repository’s
docs/directory once held hand-written bootstrap notes used to plan the project; those have been ported into this book and retired. Only the planning tree remains there —docs/plan.md(the live phase tracker) anddocs/plans/(per-phase slice histories).
Architecture
minix.rs is a microkernel operating system written in Rust. It preserves MINIX
3’s core architectural principles — message-passing IPC, user-space servers, and
a fine-grained privilege model — while dropping 32-bit legacy and targeting
modern 64-bit platforms under QEMU. aarch64 (Apple-Silicon / QEMU virt) is
the primary target; an x86_64 port is planned (see Roadmap).
This chapter is the map of the system as it stands at the end of Phase 4. It describes the pieces that boot today and cross-references the chapters that cover each in depth. Where a MINIX-3 concept exists only as a plan, it is called out as such rather than described in the present tense.
What makes it a microkernel
In a monolithic kernel (Linux, the BSDs), the whole OS — file systems, device drivers, networking, memory management — runs in one privileged address space, so a bug in any driver can take down the system. MINIX takes the opposite approach, and minix.rs follows it: the kernel handles only four things.
- IPC — copying fixed-size messages between processes, managing the send/receive queues, and detecting deadlocks.
- Scheduling — priority run queues and timer-driven preemption.
- Interrupt dispatch — routing hardware interrupts to the process that
registered for them, delivered as
NOTIFYmessages. - Memory protection — owning the physical frame allocator and performing every page-table write, so processes cannot reach into each other’s memory.
Everything else runs as an ordinary user-space process that communicates only through messages:
+------------------------------------------------------------------+
| User programs (init, worker; musl-linked C programs — planned) |
+------------------------------------------------------------------+
| SENDREC(server, &msg) via the SVC IPC trap
v
+--------+--------+--------+--------+--------+---------------------+
| PM | VFS | VM | RS | DS | SCHED |
| fork | (skel- | page- | moni- | name → | quantum |
| exec | etal) | fault | tor | endpt | delegation |
| exit | | brk | | regis- | |
| wait | | mmap | | try | |
| signal | | | | | |
+--------+--------+--------+--------+--------+---------------------+
| Device drivers (VirtIO) and file systems (MFS/PFS) — planned |
+------------------------------------------------------------------+
| IPC messages (SEND / RECEIVE / SENDREC / NOTIFY / SENDNB)
v
+------------------------------------------------------------------+
| minix.rs microkernel (Rust) |
| IPC | scheduling | interrupt dispatch | memory protection |
| Kernel calls (SYS_*) for privileged servers |
+------------------------------------------------------------------+
| aarch64 HAL: SVC/ERET, GICv3, translation tables |
| (x86_64 — planned) |
+------------------------------------------------------------------+
| Limine boot protocol (UEFI) |
+------------------------------------------------------------------+
Relation to MINIX 3
A developer who knows MINIX 3 will recognize the IPC primitives, the server roles, and the two-tier call model (user → server → reply for POSIX calls, server → kernel for privileged operations). What differs:
| Aspect | MINIX 3 | minix.rs |
|---|---|---|
| Kernel language | C | Rust (no_std, no_main) |
| Target architectures | i386, 32-bit ARM | aarch64 (x86_64 planned) |
| Bootloader | custom / multiboot | Limine (UEFI) |
| C library | NetBSD libc | musl fork (planned, Phase 5) |
| Userland | NetBSD commands | minimal Rust (init, worker) |
| License | mixed (BSD + GPL) | BSD-3-Clause only |
| IPC linked lists | raw C pointers | table indices (Option<ProcNr>) |
| Message payload | opaque unions (m1i1, …) | typed structs per call |
minix.rs preserves MINIX 3’s ABI — the 104-byte message layout, endpoint encoding, and call-numbering conventions — as a reference point, not by shipping MINIX 3 code. See System Calls & ABI and MINIX 3 Source Mapping.
The microkernel
The kernel is the only code that runs privileged (EL1 on aarch64). Its responsibilities are the four listed above; concretely it provides:
- IPC dispatch — see IPC.
- Scheduling — priority run queues, quantum expiry, and a delegatable scheduler: a process can be scheduled by the kernel or handed to the user-space SCHED server. See Servers.
- Interrupt and timer handling — the ARM generic timer drives a 100 Hz tick; hardware IRQ routing to driver processes is groundwork for the driver era.
- Kernel calls (
SYS_*) — privileged operations requested by system servers (SYS_FORK,SYS_EXEC,SYS_VMCTL,SYS_KILL, …), gated per process. These are not available to ordinary user programs.
What the kernel does not do: file-system operations, process-lifecycle policy (PM), page-fault policy (VM), device I/O, or networking.
System servers
Each server is a separate user-space process with its own address space, communicating only through IPC. The full treatment is in Servers; in brief:
| Server | Role | State at Phase 4 |
|---|---|---|
| PM | fork, exec, exit, wait, minimal signals | real |
| VM | page-fault resolution, brk, mmap, munmap | real |
| RS | monitor / heartbeat system processes | real (detect-only) |
| DS | name → endpoint registry | real |
| SCHED | user-space scheduling policy | real |
| VFS | file-operation switch | skeletal (no file ops yet) |
Device drivers and file-system servers (MFS/PFS) are user-space processes in the same model, but exist only as stubs today — they belong to later phases (see Roadmap).
The two call paths
MINIX has two kinds of calls, and minix.rs keeps both.
POSIX system calls — a user program’s open/read/fork becomes an IPC
message to the responsible server (fork → PM, file ops → VFS, mmap → VM). The
program never traps into the kernel for these; it uses SENDREC to send a request
and block for the reply. Today the user side of this path is exercised directly
through the 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. Bitiallows primitivei; ordinary user processes typically get onlySENDREC.ipc_to— bitmap of which other privileged processes it may send to.k_call_mask— bitmap of which kernel calls it may make (empty for user processes).sig_mgr— the endpoint that manages signals raised against it (PM, for user processes).
The struct also carries io_ranges, irqs, mem_ranges, and a grant_table
pointer. These are forward-declared for the driver and grant eras — device I/O,
IRQ handler registration, and grant-validated safe-copy are Phase 5+/driver work,
not yet exercised. This fine-grained model is what lets a future compromised
driver be denied kernel calls it doesn’t need and servers it has no business
talking to.
Memory and boot
- Memory management — the kernel-owned frame allocator, per-process address spaces, the page-fault path, and VM’s region tracking — is covered in Memory Management.
- The boot path — Limine, the aarch64 entry sequence, and the embedded MXBI boot image — is covered in Boot.
Crate structure
The project is a single Cargo workspace:
kernel/ microkernel (no_std, no_main)
kernel-shared/ message types, endpoints, call numbers (shared by all crates)
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 — Limine to first user process
- IPC — message format, primitives, deadlock detection
- Memory Management — frames, address spaces, VM
- Servers — server responsibilities and IPC flows
- Build & Toolchain — building, running, and trace forensics
- System Calls & ABI — the call catalog
- MINIX 3 Source Mapping — where things moved
- Roadmap — drivers, musl, file systems, x86_64
Boot
This chapter follows minix.rs from firmware to the first user process, as it boots today on aarch64 under QEMU. The x86_64 path is design intent, part of the planned port (see Roadmap).
Bootloader: Limine
minix.rs boots via the Limine boot protocol (BSD-licensed, so it fits the project’s BSD-3-Clause-only rule). Limine handles firmware initialization and the mode transition, and hands the kernel a clean 64-bit environment: 4-level translation tables already set up, all physical memory mapped through a Higher-Half Direct Map (HHDM), and a valid stack.
The kernel communicates with Limine through request structs placed in a
.limine_requests ELF section. Limine scans the kernel binary for these
(identified by magic IDs), fills in response pointers, and jumps to the entry
point. The kernel consumes, among others, the memory-map request (usable /
reserved regions, which seed the frame allocator) and the HHDM request (the
direct-map base offset). Limine support lives in
kernel/src/arch/aarch64/limine.rs.
Boot flow (aarch64)
edk2 / UEFI firmware (QEMU virt)
│
▼
Limine (external/limine/dist/BOOTAA64.EFI)
│ reads tools/limine.conf from the FAT EFI System Partition
│ loads the kernel ELF, sets up 4-level tables + HHDM, fills Limine responses
▼
_start (kernel/src/arch/aarch64/entry.S)
│ CPU at EL1, MMU on, interrupts masked, SP = Limine-allocated stack
│ zero the frame pointer, branch to kmain()
▼
kmain() (kernel/src/main.rs)
│ 1. resolve the PL011 UART base through the HHDM; arch::init()
│ 2. print "minix.rs booting on aarch64" and the HHDM offset
│ 3. proc::init() — clear the process/privilege tables; dump them
│ 4. mm::set_hhdm_offset() + mm::init_from_limine_memmap() — seed the frame allocator
│ 5. gic::init() + enable the virtual-timer PPI + timer::init(100 Hz)
│ 6. arch::userland_bootstrap() — load the boot image, install the demo stubs
│ 7. proc::sched::run() — first ERET into EL0
▼
User processes run (scheduler picks the highest-priority runnable proc)
There is no hand-ordered “start DS, then RS, then PM, …” sequence:
userland_bootstrap loads every boot module and marks it runnable, and the
scheduler takes over from there. Servers rendezvous at run time — each publishes
its endpoint to DS at startup and looks the others up by name — so boot order does
not matter (see Servers).
CPU state at kernel entry
When Limine branches to _start on aarch64:
- Exception level EL1, MMU enabled with Limine’s translation tables.
- Interrupts masked (all
DAIFbits set). SPpoints to a valid Limine-allocated stack.- All physical memory is reachable at
phys + hhdm_offset.
The embedded boot image (MXBI)
minix.rs does not ask the bootloader to locate server binaries on disk. Instead,
kernel/build.rs compiles every boot server for the EL0 user target, packs the
resulting ELFs into a single MXBI archive, and the kernel embeds that archive
in its own .rodata via include_bytes! (kernel/src/boot_image/mod.rs). Limine
reports the kernel image — archive included — under EXECUTABLE_AND_MODULES, so
those bytes are never visible to the frame allocator.
Archive format
All multi-byte fields are little-endian
(kernel/build.rs::pack_mxbi ↔ boot_image/mod.rs):
16-byte header: magic "MXBI" (u32 = 0x4942_584D), version (u32 = 1),
entry_count (u32), total_size (u32)
entry_count × 32-byte records: { proc_nr:i32, offset:u32, len:u32, name:[u8;20] }
then the ELF payloads back-to-back, each at its recorded offset
BootImage (boot_image/mod.rs) is a zero-copy view: BootImage::iter drives
the loader (one load_boot_server call per module), and BootImage::module_by_name
lets SYS_EXEC resolve a target binary — the mechanism worker rides on.
Boot modules
The archive holds eight modules (kernel/build.rs’s servers array). Seven have
non-negative proc numbers and are loaded into a process slot at boot; worker is
tagged with the sentinel EXEC_ONLY_PROC_NR = -1, so the loader skips it — it has
no boot slot and exists only to be resolved by name at exec time.
| Module | Proc nr | Loaded at boot? |
|---|---|---|
pm | 0 | yes |
vfs | 1 | yes (skeletal) |
rs | 2 | yes |
ds | 5 | yes |
vm | 7 | yes |
sched | 9 | yes |
init | 10 | yes — becomes PID 1 |
worker | −1 | no — exec-only target |
Proc numbers 3 (MEM), 4 (TTY), 6 (MFS), and 8 (PFS) are reserved in
kernel-shared/src/com.rs for servers that do not yet exist. Kernel tasks
(ASYNCM, IDLE, CLOCK, SYSTEM, HARDWARE) occupy the negative proc numbers
and are internal to the kernel — they have no ELF binary.
Alongside the boot modules, userland_bootstrap hand-installs four demo stubs
(A–D) at proc numbers 11–14. They are not part of the archive; they are small EL0
programs kept as a live regression battery for the IPC primitives, SCHED
delegation, and the page-fault / SIGSEGV paths that init and worker do not
exercise. So the boot trace shows eleven address-space ([as]) lines — six
servers, init, and the four stubs — but not worker, which is never loaded
until an exec names it.
Disk image and QEMU
There is no partitioned root disk yet. The cargo runner (tools/qemu-run.sh)
stages a small ESP directory and hands it to QEMU’s directory-as-FAT helper,
which is enough to land the boot banner without parted / mtools:
target/esp/
EFI/BOOT/BOOTAA64.EFI ← Limine (external/limine/dist/BOOTAA64.EFI)
limine.conf ← tools/limine.conf
boot/kernel ← the freshly built kernel ELF
tools/limine.conf names the kernel and nothing else — the servers are embedded:
timeout: 0
serial: yes
/minix.rs
protocol: limine
kernel_path: boot():/boot/kernel
The runner then launches QEMU (firmware auto-located, or set
QEMU_EFI_AARCH64):
qemu-system-aarch64 \
-M virt,gic-version=3 -cpu cortex-a72 -m 256M \
-bios <edk2-aarch64-code.fd> \
-drive file=fat:rw:fat-type=32:target/esp,format=raw,if=virtio \
-display none -serial stdio -no-reboot
Because the kernel runs indefinitely once EL0 starts, always wrap a boot run in
timeout. See Build & Toolchain for the full run recipe and the
trace-forensics rules for reading a serial log.
MINIX 3 reference
| Aspect | MINIX 3 file |
|---|---|
| Boot process table | kernel/table.c |
| Kernel init | kernel/main.c (kmain, bsp_finish_booting) |
| i386 entry / pre-init | kernel/arch/i386/head.S, pre_init.c |
IPC
IPC is the foundation of MINIX. Every interaction between a user program and the OS, and between OS components themselves, happens through message passing. Understanding IPC is understanding minix.rs.
This chapter describes IPC as it stands at the end of Phase 4: a fixed-size
message, five live primitives (with SENDA stubbed), generation-aware endpoints,
deadlock detection, and the per-process privilege bitmaps that gate it all. The
kernel side lives in kernel/src/ipc/; the shared wire types in kernel-shared;
the user-space trap stubs in 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 ofinclude/minix/ipc.hthat fixessizeof(message) == 104— but the upstream project never released a working 64-bit MINIX 3. A functioning 64-bit MINIX 3 prototype was a personal effort by this project’s author (Kevin Barnard), separate from the upstream MINIX 3 project. minix.rs takes that 104-byte layout as its ABI reference point, but is itself a clean, 64-bit-only implementation. When the docs say minix.rs “preserves the original ABI,” that means tracking those source-tree definitions, not a shipped 64-bit MINIX 3.
The IPC primitives
Six primitive numbers are defined (kernel-shared/src/ipc_const.rs); five are
live and one is a stub:
| Primitive | Number | Status | Behavior |
|---|---|---|---|
SEND | 1 | live | Blocking send; caller blocks until the receiver accepts. |
RECEIVE | 2 | live | Blocking receive from a specific endpoint or ANY. |
SENDREC | 3 | live | Atomic send-then-receive; the common client→server call. |
NOTIFY | 4 | live | Non-blocking notification; sets a bit if the target isn’t waiting. |
SENDNB | 5 | live | Non-blocking send; fails instead of blocking. |
SENDA | 16 | stub | Asynchronous send-table. Returns ENOSYS. |
SEND — if the destination is waiting to receive from us (or from ANY), the
message is delivered immediately and the destination is unblocked; otherwise the
caller blocks and is queued on the destination’s caller_q.
RECEIVE — the kernel checks, in order: pending notifications
(notify_pending), pending async messages (asyn_pending), then the caller queue
(caller_q). If nothing is ready, the caller blocks with RTS_RECEIVING set.
SENDREC — a SEND immediately followed by a RECEIVE, but atomic: the caller
transitions straight from sending to receiving without returning to user space.
This is what a POSIX wrapper uses — send a request to a server and block for the
reply.
NOTIFY — if the target is waiting, a synthetic notification message
(m_type == NOTIFY_MESSAGE, 0x1000) is delivered immediately; otherwise a bit
is set in the target’s notify_pending bitmap. NOTIFY never blocks, and
repeated notifications from the same source coalesce into one bit. Used for timer
expiry (CLOCK → server), kernel-raised signals (SYSTEM → PM), and — in the
driver era — hardware-interrupt delivery.
SENDNB — SEND semantics, but returns an error rather than blocking when the
destination is not ready to receive.
SENDA is defined for ABI completeness but is not implemented: it returns
ENOSYS, and it cannot be reached anyway because the trap_mask gate is a u16
and SENDA’s bit is 16 — outside the mask. Making it real is deferred (see
Roadmap).
The user-space trap (aarch64)
minix-ipc issues the IPC trap with svc #0. The register convention is:
| Register | Purpose |
|---|---|
x0 | Endpoint (src / dest / src_dest); also receives the result |
x1 | IPC primitive number |
x2 | Pointer to the 104-byte Message (null for NOTIFY, which carries none) |
The kernel’s do_ipc writes the result into the saved x0; the SVC entry saves
and restores x1..x30, so from the caller’s view only x0 is clobbered. The
public wrappers are ipc_send, ipc_receive, ipc_sendrec, ipc_sendnb, and
ipc_notify — there is no ipc_senda. The x86_64 trap ABI is design intent, part
of the planned port.
Endpoints
Processes are named by endpoints, not raw slot numbers. An Endpoint is an
i32 that encodes a generation counter and a process-table slot
(kernel-shared/src/endpoint.rs):
#![allow(unused)]
fn main() {
pub const ENDPOINT_GEN_SHIFT: i32 = 15; // proc_nr in the low 15 bits, generation above
pub const fn make_endpoint(g: GenNr, p: ProcNr) -> Endpoint;
pub const fn endpoint_proc(e: Endpoint) -> ProcNr; // sign-extends: task slots stay negative
pub const fn endpoint_gen(e: Endpoint) -> GenNr;
}
The generation increments each time a slot is reused (bump_generation, which
wraps to 1 — never back to 0, so a recycled slot can never re-alias a boot
endpoint). This catches stale-endpoint bugs: a message aimed at a dead process is
rejected rather than misdelivered to whatever now occupies the slot.
Sentinel endpoints live just below ENDPOINT_SLOT_TOP ((1 << 14) − 1 = 16383), all at generation 0 so they can never collide with a real
(gen, proc) endpoint:
| Endpoint | Value | Meaning |
|---|---|---|
ANY | 16383 (0x3FFF) | receive from any sender |
NONE | 16382 | no process |
SELF | 16381 | the calling process itself |
Kernel tasks occupy negative slots (kernel-shared/src/com.rs):
| Proc nr | Name | Role |
|---|---|---|
| -5 | ASYNCM | async-message driver |
| -4 | IDLE | idle task |
| -3 | CLOCK | timer-driven work |
| -2 | SYSTEM | handles SYS_* kernel calls |
| -1 | HARDWARE | source endpoint for IRQ notifications |
Well-known servers are numbered contiguously from 0. minix.rs renumbers these compared to MINIX 3 (which scatters the slots). Note that some numbers are reserved for servers that do not yet boot:
| Proc nr | Server | Boots today? |
|---|---|---|
| 0 | PM | yes |
| 1 | VFS | yes (skeletal) |
| 2 | RS | yes |
| 3 | MEM (memory driver) | reserved, not built |
| 4 | TTY | reserved, not built |
| 5 | DS | yes |
| 6 | MFS | reserved, not built |
| 7 | VM | yes |
| 8 | PFS | reserved, not built |
| 9 | SCHED | yes |
| 10 | init (PID 1) | yes |
Endpoints at generation 0 are boot endpoints; construct one from a task/server
ProcNr with boot_endpoint(p).
Deadlock detection
Because SEND blocks, circular send dependencies could deadlock the system:
A sends to B (blocks) ; B sends to A (blocks) → neither can proceed
Before blocking a sender, the kernel (kernel/src/ipc/deadlock.rs) traces the
chain of send dependencies starting at the destination: if the destination is
itself sending, it follows sendto_e, and so on. If the chain loops back to the
original caller, the SEND fails with ELOCKED instead of blocking. A two-party
pair (A→B, B→A) is allowed; larger cycles are rejected.
Privilege model
Every system process has a privilege-table entry
(kernel/src/proc/priv_struct.rs) controlling what IPC and kernel operations it
may perform. User processes share one user-class slot.
trap_mask(u16) — which IPC primitives the process may use; bitiallows primitivei. User processes typically hold onlySENDREC; servers holdSEND/RECEIVE/NOTIFY/… (This is also whySENDA, bit 16, is unreachable.)ipc_to— bitmap of which other privileged slots this process may send to. A user process can reach only the servers it needs: the shared user slot opensipc_to = {PM, VFS}— the process lifecycle and the file descriptors, and nothing else. Each entry is really a pair of bits, because the server needs the reverse edge to reply;populate_user_privopens both.k_call_mask— bitmap of permitted kernel calls (empty for user processes). See System Calls & ABI.notify_pending/asyn_pending— per-source bitmaps of deferred notifications and async messages, consumed byRECEIVE.sig_mgr— the endpoint that manages signals raised against this process.
The entry also reserves io_ranges, irqs, mem_ranges, and a grant_table
pointer. These are the hooks for two future mechanisms:
- Grants / safe copy — for data larger than the payload, a process will
publish a grant table (via
SYS_SETGRANT) describing memory regions and permitted operations, and the kernel will validate a grant before copying (SYS_SAFECOPY). The call numbers exist; a real grant table and validated copy are an opening Phase-5 slice, so no cross-address-space bulk copy runs yet. - I/O and IRQ ownership —
io_ranges/irqswill gate a driver’s access to ports and interrupt lines. Drivers are a later phase.
IPC status
When it delivers a message, the kernel also provides an IPC status word encoding which primitive delivered it (bits 0–5) and whether it originated in the kernel (a “from kernel” flag). This lets a receiver distinguish a plain message from a notification and handle kernel-originated messages specially — for instance, never replying to them.
Kernel IPC state
The relevant per-process fields (kernel/src/proc/):
caller_q/q_link— the head of, and links through, the queue of processes blocked trying to send to this one (Option<ProcNr>indices, not raw pointers).getfrom_e/sendto_e— the endpoint this process is receiving from / sending to while blocked.- run-time-state flags (
rts_flags) —RTS_SENDING,RTS_RECEIVING, and the others. A process is runnable iff its RTS flags are all clear.
IPC and scheduling are therefore coupled: unblocking a receiver (clearing its last
RTS bit via rts_unset) enqueues it on the run queue; blocking a sender
(rts_set) removes it. See Servers for how the
delegatable scheduler builds on this.
MINIX 3 source reference
| Concept | MINIX 3 file | Key functions |
|---|---|---|
| IPC dispatch | kernel/proc.c | do_ipc, do_sync_ipc |
| Blocking send / receive | kernel/proc.c | mini_send, mini_receive |
| Notification / async send | kernel/proc.c | mini_notify, mini_senda |
| Deadlock detection | kernel/proc.c | deadlock |
| Process / privilege tables | kernel/proc.h, priv.h | struct proc, struct priv |
| Message / IPC constants | include/minix/ipc.h, ipcconst.h | message, SEND, … |
Memory Management
minix.rs splits memory management the way MINIX 3 does — mechanism in the
kernel, policy in a user-space server — but moves physical-frame ownership
into the kernel. The kernel owns the frame allocator and performs every page
table write; the user-space VM server decides what should be mapped where
and drives the kernel through a single privileged kernel call (SYS_VMCTL).
This chapter describes the subsystem as it stands at the end of Phase 3 — a
physical frame allocator, per-process address spaces with hardware-tagged TLBs,
a page-fault path that delegates to VM, and the VM server’s region tracking for
brk, mmap, and munmap — plus the region-cloning that PM-driven fork added
in Phase 4.
Memory map (aarch64 QEMU virt)
The QEMU virt machine has a fixed device layout. With -m 256M, RAM sits at
0x4000_0000:
| Physical range | Device |
|---|---|
0x0800_0000–0x0800_FFFF | GICv3 distributor |
0x080A_0000–0x080B_FFFF | GICv3 redistributor |
0x0900_0000–0x0900_0FFF | PL011 UART |
0x0A00_0000+ | VirtIO MMIO devices |
0x4000_0000+ | RAM (256 MB default) |
The virtual address space splits at the canonical hole. TTBR1_EL1 (the upper
half) holds the kernel image and Limine’s direct map; TTBR0_EL1 (the lower
half) holds the current process:
0xFFFF_FFFF_........ kernel image (.text / .rodata / .data / .bss, embedded boot image)
0xFFFF_8000_0000_0000 HHDM base — all physical memory mapped at phys + hhdm_offset
... non-canonical hole ...
0x0000_7FFF_FFFF_FFFF user ceiling (stack region, grows down)
0x0000_0000_0200_0000 MMAP_BASE — anonymous mmap arena (bump-allocated up)
0x0000_0000_0100_0000 HEAP_BASE — heap region (brk grows up)
0x0000_0000_0010_0000 user load base — text/rodata/data/bss (servers, init, worker)
0x0000_0000_0000_0000
Translation tables
minix.rs uses a 4 KB granule with 4-level translation:
| Level | VA bits | Each entry maps |
|---|---|---|
| L0 | [47:39] | 512 GB |
| L1 | [38:30] | 1 GB |
| L2 | [29:21] | 2 MB |
| L3 | [20:12] | 4 KB |
TTBR0_EL1 holds the current process’s tables and is swapped (tagged with the
process’s ASID) on every context switch; TTBR1_EL1 holds the shared kernel
mapping and never changes. Limine maps all physical memory contiguously at
hhdm_offset, so the kernel reaches any physical address as phys + hhdm_offset
with no temporary mapping windows — every free frame, page table, and message
buffer the kernel touches is addressed this way.
Physical frame allocator
kernel/src/mm/ is the kernel-side physical allocator. At boot it walks
Limine’s MEMMAP_USABLE entries and seeds a per-region bump pointer for each
(MAX_REGIONS = 16 regions — QEMU virt and Apple-Silicon QEMU both fit
comfortably). Freed frames go onto an intrusive free-list threaded through the
frames themselves, reached via Limine’s higher-half direct map (HHDM).
Two invariants keep callers honest:
alloc_framezeroes every frame before handing it out, so a caller never observes residual state.free_framepushes the frame back through the HHDM.
Frames inside the kernel image, the embedded boot image, and the static EL0
test-stub pages live in Limine’s EXECUTABLE_AND_MODULES region and are never
visible to the allocator, so no explicit reservation logic is needed.
Per-process address spaces
Each user process has its own translation table tree, rooted at a physical
address recorded in Proc::ttbr0_pa, and an 8-bit address-space identifier in
Proc::asid. kernel/src/arch/aarch64/addrspace.rs is the page-table API:
AddrSpace::new()allocates an L0 root frame.map_page(va, pa, prot)walks the tree, allocating L1/L2/L3 tables on demand from the frame allocator, and writes the leaf PTE through the HHDM.walk_pt(va)resolves a VA to its PTE (orNone).destroy()recursively frees the intermediate tables and the L0 root (leaf frames are caller-owned and freed elsewhere).
The free functions map_page_in(ttbr0_pa, …) and unmap_page_in(ttbr0_pa, …)
do the same work keyed by a root PA, so the kernel can mutate a process’s tree
without holding an AddrSpace value — this is how SYS_VMCTL edits a target
process’s address space.
Context switch and ASIDs
On every switch into a user process, proc::sched::schedule_next:
- parks the next process’s register frame for the trap return,
- calls
switch_ttbr0_with_asid(ttbr0_pa, asid)— which writesTTBR0_EL1 = ttbr0_pa | ((asid as u64) << 48)and issues an ASID-taggedtlbi— before - flushing any pending IPC message into the user buffer.
The order matters: the message flush writes through the active TTBR0, so the
incoming process’s address space must be live first. ASIDs let the hardware keep
TLB entries for multiple address spaces without a full flush on each switch; the
allocator (asid.rs) hands them out from FIRST_ASID = 1 (0 means
“uninitialized”) and panics on 8-bit wrap — real rollover is deferred until
process churn in Phase 4 makes it reachable.
The page-fault path
When an EL0 process touches an unmapped page, the CPU traps to EL1 and
do_page_fault(esr, elr, far) runs. It classifies the abort (instruction vs
data, fault status code, write-vs-read), records the coordinates in the
faulting process’s PageFaultState, blocks the process on the RTS_PAGEFAULT
run-time state, and sends the VM server a VM_PAGEFAULT message carrying the
faulting endpoint and the fault address. Permission faults (a write to a
read-only page) are not resolvable by mapping and halt loudly.
The kernel originates that send with mini_pf_send, which models the faulting
process as a blocked sender on VM’s caller queue — the lingering RTS_PAGEFAULT
keeps it blocked even after the RTS_SENDING half clears, until VM explicitly
clears the fault. Because VM resolves the fault while running under its own
TTBR0 and the message is delivered after the switch, no cross-address-space copy
machinery is needed at this stage.
SYS_VMCTL: kernel mechanism, VM policy
SYS_VMCTL (kernel/src/system/do_vmctl.rs) is the one privileged call through
which VM drives the kernel’s paging mechanism. It is gated solely by
Priv::k_call_mask granting SYS_VMCTL; VM is its sole intended holder and is
trusted to target only processes it legitimately manages (the MINIX 3 trust
model). Each subcall names a target process by endpoint (SELF allowed):
| Subcall | Effect |
|---|---|
VMCTL_PT_MAP | Allocate a fresh zeroed frame and map it at vaddr with the requested protection; reply with the chosen PA. |
VMCTL_PT_UNMAP | Clear the PTE at vaddr and free its backing frame. Returns EINVAL if nothing is mapped there (a harmless no-op for callers sweeping a range). |
VMCTL_CLEAR_PAGEFAULT | Clear the target’s recorded fault and make it runnable again. |
VMCTL_GET_PAGEFAULT | Read the target’s recorded fault coordinates. |
VMCTL_VMINHIBIT_SET / _CLEAR | Gate scheduling of the target while VM mutates its address space. |
Every PTE change is followed by an ASID-tagged TLB invalidation. The kernel allocates frames (unlike MINIX 3, where VM owns physical memory) and VM supplies only the virtual address and protection.
VM server region tracking
The VM server (servers/vm/) is the first real user-space process. It runs a
RECEIVE(ANY) loop and dispatches on message type. It owns no heap allocator —
the kernel owns frames — so it tracks memory with a static per-process region
table (servers/vm/src/region.rs): [ClientRegions; 16], keyed by process
number, each holding up to MAX_REGIONS = 4 regions. A region is a half-open
virtual range [start, end) tagged with a Kind:
Heap— grown bybrk, based at the fixedHEAP_BASE(0x0100_0000).Mmap— anonymous mappings, bump-allocated fromMMAP_BASE(0x0200_0000).Unused— a free slot.
A page fault is satisfied only if its address lies inside one of the
faulting process’s regions: VM consults the table, and on a hit issues
SYS_VMCTL(VMCTL_PT_MAP) then SYS_VMCTL(VMCTL_CLEAR_PAGEFAULT). A fault
outside every region is a segmentation fault: VM raises
SYS_KILL(faulter, SIGSEGV), and the faulter stays blocked on RTS_PAGEFAULT
until PM terminates it (the minimal-signals path that made this a real kill
landed in Phase 4; before that VM could only leave the process wedged). VM runs
at EL0 with no console, so it is silent from the server’s side — the kill surfaces
in the kernel signal trace.
brk
VM_BRK(new_break) sets the caller’s program break. VM page-aligns the request
and grows (or first creates) the caller’s Heap region to
[HEAP_BASE, new_break), replying with the resulting break. No frames are
mapped eagerly — pages fault in lazily on first touch and are then satisfied by
the region check above.
mmap / munmap
VM_MMAP(len) is an anonymous mapping in the style of mmap(NULL, len, …): VM
page-aligns the length, bump-allocates a base address from the caller’s mmap
arena, records an Mmap region, and replies with the chosen base. As with the
heap, frames are not mapped until first touch.
VM_MUNMAP(addr, len) drops the Mmap region based at addr and unmaps each
backing page with SYS_VMCTL(VMCTL_PT_UNMAP). Pages that never faulted in were
never mapped, so the kernel returns a harmless EINVAL for them, which VM
ignores. The match is keyed on the region’s base address and the unmap sweep is
capped at the region’s own end, so an over-stated length can never reach into a
neighboring region or the heap.
The arena is bump-only for now — munmap does not return addresses to it.
Address reuse within the arena remains future work. PM-driven fork (Phase 4)
already clones a parent’s entire region set into the child: after the kernel
copies the child’s page tables via SYS_FORK, PM issues VM_FORK, and VM copies
its own ClientRegions bookkeeping so the child’s later brk / mmap / fault
lookups see the inherited heap and mmap regions.
Servers
minix.rs keeps the microkernel tiny — IPC, scheduling, memory protection, and a small set of privileged kernel calls — and runs every operating-system service as an ordinary user-space process, exactly as MINIX 3 does. These servers talk to each other and to the kernel only through message passing. A server never shares memory with a client; it acts on a request, replies, and the kernel enforces who may talk to whom via per-process privilege bitmaps.
This chapter describes the servers as they stand at the end of Phase 4: a common
runtime (SEF), a name registry (DS), a user-space scheduler (SCHED), a monitor
(RS), the process manager (PM), a still-skeletal file-system switch (VFS), and
init (PID 1) — the first real user process, which drives the whole
fork/exec/wait lifecycle through PM.
Where servers live
Servers are freestanding #![no_std] / #![no_main] ELF binaries linked with
their own user.ld (page-aligned segments based at 0x0010_0000) and branded
with the minixrs ELF identity note, which the kernel requires of every image it
loads. The kernel’s build.rs compiles each for the aarch64-unknown-minixrs
target and concatenates them into a single MXBI archive embedded in the
kernel image; the boot loader
(kernel/src/arch/aarch64/userland.rs) walks the archive and loads each module
into the proc slot named by its record. Each server gets its own per-process
TTBR0, so they all share the same low load base with no collision.
A server has no println. Its behaviour is observed through kernel-side traces
([ipc], [ksys], [pf], [alarm]), and since slice 5.1 it can also emit a
line itself through the kernel debug channel — server-rt’s diag_print /
diag_fmt issue a SYS_DIAGCTL carrying the text inline, which the kernel prints
prefixed with the caller’s own name ([diag vfs] …). That is deliberately a
debug channel, not stdio: it exists to keep working while stdio itself is under
construction. Real console output arrives via the TTY driver (see
Drivers) — slice 5.3 put the first EL0-composed text on
the serial line, and slice 5.4 puts a process’s fd 1 and 2 on top of it.
Request-number ranges
Every server’s request numbers occupy a distinct band below NOTIFY_MESSAGE, so
a message type unambiguously identifies both its server and its meaning
(kernel-shared/src/callnr.rs, const-asserted disjoint). The bands are listed —
and rendered into the generated C header — in ascending numeric order. VFS took
0x800 in slice 5.4; 0x900 and 0xA00 stay reserved for the two bands Phase 5
still owes, BDEV and MFS.
| Base | Value | Server / purpose |
|---|---|---|
PM_RQ_BASE | 0x700 | PM: PM_GETPID / FORK / EXIT / WAIT / EXEC |
VFS_RQ_BASE | 0x800 | VFS: VFS_WRITE |
CDEV_RQ_BASE | 0xB00 | Character drivers: CDEV_WRITE (TTY) |
VM_RQ_BASE | 0xC00 | VM: VM_PAGEFAULT / BRK / MMAP / MUNMAP / FORK |
SEF_RQ_BASE | 0xD00 | SEF control messages (ping / signal / init) |
DS_RQ_BASE | 0xE00 | DS: DS_PUBLISH / RETRIEVE / CHECK |
SCHED_RQ_BASE | 0xF00 | SCHED: SCHEDULING_NO_QUANTUM / START / STOP / SET_NICE |
SEF: the server runtime
server-rt is minix.rs’s small equivalent of MINIX 3’s SEF (System Event
Framework). A server calls sef_startup(SefConfig { init_fresh, signal_handler }),
which learns the server’s own endpoint and name from the kernel via
SYS_GETINFO(GET_WHOAMI), runs the optional init_fresh callback, and returns a
Sef handle. The main loop is then loop { if sef.receive(&mut msg) != 0 { continue } match msg.m_type { … } }:
sef.receive wraps ipc_receive(ANY, …) and transparently handles SEF control
traffic — an RS heartbeat ping, a SEF_SIGNAL from PM/RS, a SEF_INIT — so the
server only sees genuine application messages.
The classifier (server-rt/src/classify.rs, host-tested) gates each control
event on the message’s source, not its type alone: an RS ping is only honored
from RS, a signal only from a signal manager, an init only from RS. A client
holding a mere ipc_to bit to the server cannot spoof one. server-rt is
#![forbid(unsafe_code)] — callbacks travel in the config struct, not global
state. The init_fresh body most servers use is the shared
sef_publish_to_ds(endpoint, name) helper, which registers the server in DS.
DS: the name registry
Servers discover each other by name through DS (servers/ds/), a
name→endpoint registry backed by a static [Entry; 16] table
(servers/ds/src/registry.rs; the pure publish / retrieve / check helpers
are host-tested). A DS_PUBLISH request carries a 16-byte NUL-padded name in
payload 0..16 and the publisher’s endpoint in 16..20. DS is the one server
that cannot publish to itself over IPC — a SENDREC to itself before reaching
its receive loop would deadlock — so it seeds its own entry in-process during
ds_init.
SCHED: user-space scheduling
The kernel scheduler is delegatable rather than replaced. Each Proc carries
a scheduler endpoint; NONE (the boot default) means kernel-scheduled — the
kernel refills the quantum and rotates the run queue. A non-NONE value means the
process is scheduled by a user-space server: on quantum exhaustion the kernel
dequeues it, leaves RTS_NO_QUANTUM set, and sends SCHEDULING_NO_QUANTUM to its
scheduler, which decides when to re-admit it via SYS_SCHEDULE.
SCHED (servers/sched/) is that scheduler. It claims a target with
SYS_SCHEDCTL (setting scheduler = SCHED), tracks it in a static
[SchedProc; 16] policy table (servers/sched/src/policy.rs, host-tested), and
on each SCHEDULING_NO_QUANTUM refreshes the quantum at a fixed managed band
(USER_Q = 8, the boot-server band, so a CPU-bound managed process round-robins
instead of starving behind kernel-scheduled work). SCHED itself and the kernel
tasks stay NONE — a scheduler must not schedule itself. SCHEDULING_START /
STOP are the hooks PM drives during fork and exit; MINIX-style priority aging is
left for later.
RS: the reincarnation server
RS (servers/rs/) is the system-process monitor and the root of the boot
process tree. It arms a periodic one-shot alarm (SYS_SETALARM, ALARM_PERIOD = 100 ticks) and on each fire pings a fixed peer set (DS/VM/SCHED/VFS/PM) with
ipc_notify, tallying acknowledgements in a host-tested monitor
(servers/rs/src/monitor.rs). Peers acknowledge through the ordinary SEF ping
path, so no extra wiring is needed. In Phase 4 restart-on-crash is detect-only —
RS counts unresponsive peers but cannot yet re-exec them (exec of a fresh service
image is future work). The alarm expiry arrives as a kernel-originated NOTIFY
from CLOCK, which RS distinguishes from its own SEF ping by keying on
m_source == boot_endpoint(CLOCK).
PM: the process manager
PM (servers/pm/) owns the POSIX process lifecycle. Its mproc table
(servers/pm/src/mproc.rs, host-tested) records one entry per process — pid,
parent, a generation-aware endpoint, and flags. Boot servers and the demo stubs
are seeded at init; forked children are allocated from a pool
([FORK_POOL_BASE, NR_MPROCS)) where a slot’s index is also the child’s kernel
proc number.
User processes drive their whole lifecycle through PM — the POSIX shape, user →
server, never user → kernel (the shared user privilege opens ipc_to edges to PM
and VFS, and nothing else):
PM_GETPIDreplies with the caller’s pid (m_typeis the pid, MINIX result-is-pid), parent pid in the payload.PM_FORKbuilds a child in a fixed, safety-critical order: allocate themprocslot,SYS_FORK(the kernel clones a frozen child —RTS_RECEIVING | RTS_NO_PRIV),VM_FORK(VM copies the parent’s regions),SCHEDULING_START, thenSYS_PRIVCTL(PRIVCTL_SET_USER)to release the freeze — and finally replies to both halves of the shared SENDREC (child sees0, parent sees the child pid: fork returns twice). Only PM’s reply clearsRTS_RECEIVING, so the child cannot run before its identity, memory, and scheduling are fully built. Any mid-fork failure rolls back every completed step.PM_EXECissuesSYS_EXECnaming the caller as the target; the kernel replaces the caller’s image with a boot-embedded binary and resumes it at the new entry (no reply on success). Phase 4 hardcodes the target asworker; a user-supplied path arrives with the Phase-5 filesystem.PM_EXITdoesSCHEDULING_STOPthenSYS_EXIT(full teardown: address space freed, endpoint generation bumped, slot freed) and marks themprocslot a zombie holding the encoded status; the dead child gets no reply.PM_WAITreaps a zombie child (reply pid + status, free the slot) or, if a live child exists, suspends the parent until the child’s exit wakes it. There is no asyncSIGCHLDin Phase 4 — the zombie + wait-reap handshake is the only parent notification, because the kernel signal path default-terminates and would kill a handler-less parent.
Minimal signals
PM is also the signal manager for user processes. The kernel half is a small trio
(SYS_KILL / SYS_GETKSIG / SYS_ENDKSIG): SYS_KILL records a bit in the
target’s Proc::sig_pending, sets RTS_SIGNALED | RTS_SIG_PENDING, and wakes PM
with a kernel-originated NOTIFY. PM drains pending signals with SYS_GETKSIG and
disposes of each — SYS_ENDKSIG to acknowledge a survivor, or SYS_EXIT to
terminate. Handlers (catching, sigaction) are Phase 5; Phase 4’s default action
for a user process is termination.
VFS: the write 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 holdsSYS_PROC, which is what makes a magic grant legal for it at all — so a caller-supplied owner field would let any VFS client aim a privileged cross-address-space copy at a third party’s memory.VFS_WRITEhas no such field, and must never gain one. It is the same anti-confused-deputy rule that keeps a granter out of theCDEV_WRITEpayload, applied to the granting side. - VFS absorbs short writes. A character driver may move fewer bytes than asked
(
CDEV_MAX_IO, its staging limit); POSIXwrite()is not allowed to expose that, so VFS re-sends withoffsetadvanced until the buffer is out and reports the total. One grant covers the whole buffer — only the offset moves. An error after partial progress reports the progress, since those bytes really did go out. - 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_FORK → SYS_EXEC name=worker →
SYS_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 withsystem::do_exec, so a device mapping there would be inherited by every binary any process ever exec’d. The consequence is worth knowing in the other direction too: a process that exec’d would lose its device window. - No TLB maintenance. The address space was built moments ago and has never
been installed in TTBR0 (and a recycled ASID is always clean, because address-space
teardown flushes before returning the ASID to the pool). Beyond that,
switch_ttbr0_with_asid— which runs on TTY’s first schedule — already issuesisb; tlbi aside1; dsb ish; isb. This is the same reasoning the server stack-page mapping already relies on.
The boot log records it as [devmap] tty va=0x40000000 pa=0x9000000 attr_idx=<n>.
The two addresses are fixed constants and the boot-log checker asserts both; <n>
is whichever MAIR index the scan below settled on, which depends on what the
bootloader programmed, so it is deliberately not asserted.
Device memory: read the MAIR, never write it
An MMIO mapping must be Device memory, not the Normal write-back the rest of user space uses: the CPU must not cache a register read, must not merge two register writes into one, and must not reorder a data-register store ahead of the flag-register poll that gates it.
On aarch64 the memory type of a page comes from its descriptor’s 3-bit AttrIndx
field, which selects one of eight bytes in MAIR_EL1. The obvious move — program a
byte with a Device encoding — is the one thing the kernel must not do. Changing byte
i retroactively changes the memory type of every live mapping that uses
AttrIndx=i, and that includes Limine’s TTBR1 kernel and HHDM mappings, whose
indices this codebase has no way to enumerate. Turning the HHDM into Device memory
would be silent, unrecoverable corruption.
So mmu::init_device_attr_idx() reads MAIR_EL1 and reuses an index that
already encodes a Device type. The observation that makes reading sufficient: an
unprogrammed MAIR byte reads 0x00, and 0x00 is itself a valid MMIO encoding
(Device-nGnRnE). “An index that already encodes Device” and “an index nobody uses”
therefore coincide — either way, mapping through it is correct. Reading changes
nothing, so no barrier or TLB invalidation is needed.
Only two encodings qualify. 0x04 (Device-nGnRE) is preferred; 0x00
(Device-nGnRnE) is stricter and accepted. Device-nGRE would permit gathering, so
two data-register stores could merge into one and lose a character; Device-GRE
would additionally permit reordering the store ahead of its poll. On QEMU with
Limine today the scan settles on index 1 — reported as
[mair] device attr_idx=1 byte=0x00, which is forensic output and deliberately
not a boot-log marker, since which index is free depends on the bootloader.
Prot.device and the RAM/device invariant
Prot — the kernel’s “what may EL0 do with this page” type — carries a third flag
beside writable and executable:
#![allow(unused)]
fn main() {
pub struct Prot { pub writable: bool, pub executable: bool, pub device: bool }
}
Adding it was a compile error at every struct literal, which was the point: there
were exactly two, and both had to make a decision. do_vmctl’s VMCTL_PT_MAP
answers device: false permanently — VM may not mint device mappings.
The flag exists because a device leaf’s physical address is not a frame the
allocator owns, so every path that tears down an address space must not hand it
to free_frame. Rather than leave that as a convention, map_page_in makes it a
total invariant:
#![allow(unused)]
fn main() {
if prot.device { assert!(!is_usable_pa(pa), "device mapping of RAM PA {pa:#x}"); }
else { assert!( is_usable_pa(pa), "normal mapping of non-RAM PA {pa:#x}"); }
}
Every mapped leaf is therefore provably either (RAM ∧ ¬device) or
(device ∧ ¬RAM), with no third case — which is what makes
if !prot.device { free_frame(…) } sound in each of the five leaf sweeps
(exit teardown, fork’s copy loop, fork’s out-of-memory unwind, VMCTL_PT_UNMAP,
and the exec-load error path) rather than merely plausible. free_frame keeps its
loud out-of-range assert for the same reason: a catch-all that silently skipped
non-RAM frames would demote a forged-address or double-free bug into an
untraceable leak.
Two further consequences:
- Fork re-maps a device leaf, it does not copy it. MMIO is inherently shared, and copying 4 KiB of live device registers through the cacheable HHDM alias would read side-effecting registers into RAM.
mm::uaccessrefuses a device leaf as copy source or destination (EFAULT), closing the hole where a process grants a peer its own register window and the kernel then touches MMIO through a cacheable alias.
Because TTY never exits, the teardown path’s device arm would be untested code
sitting on a live landmine — and a missing guard is a kernel panic, not a leak. So
userland_bootstrap runs a small unconditional selftest at boot: build a throwaway
address space holding exactly one device leaf, tear it down, and assert the result.
It reports [devmap] selftest ok freed=0 devs=1, asserting both numbers —
freed=0 proves the leaf was not freed, and devs=1 proves it was actually seen
(a guard that skipped every leaf would report devs=0).
The CDEV protocol
Character drivers answer requests in the CDEV_RQ_BASE = 0xB00 band. Phase 5
defines one:
| Field | Payload offset | Meaning |
|---|---|---|
| minor | 0..4 (i32) | which device; CDEV_MINOR_CONSOLE = 0 is the UART |
| grant id | 4..8 (i32) | names the client’s source buffer |
| length | 8..12 (i32) | bytes requested |
| offset | 16..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_writereads the four payload fields,validate_writeapplies the checks in order (unknown minor →ENXIO, negative length →EINVAL, invalid grant id →EINVAL, then clamp toCDEV_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 onlyunsafe: volatile accesses toFRandDRatTTY_UART_VA, pollingFR.TXFFbefore each store, translating LF to CRLF. The register offsets are deliberately duplicated from the kernel’s own PL011 writer; they cannot be shared, because the kernel crate is bare-metal-only and pinned byforced-target, so it can never be a user-space dependency — and a register layout is a hardware fact, not a shared ABI.main.rs— the SEF loop and the handler.
The handler captures m_source first (it is both the reply target and the
granter), validates, pulls the bytes across with
SYS_SAFECOPY(SAFECOPY_FROM, caller, gid, offset, staging, n), transmits, and
replies n. A negative SYS_SAFECOPY result is relayed verbatim: EPERM
(“your grant does not authorize this”) and EFAULT (“your buffer is not mapped”)
are different bugs on the client’s side.
Two departures from the server template are worth naming. The staging buffer lives
in main’s frame, never in the init callback’s — the kernel writes into it while
TTY is blocked inside the SYS_SAFECOPY SENDREC, so the frame must outlive every
call that names it (the same rule GrantPool follows). And an unknown m_type
gets a reply, where DS harmlessly drops one: a driver’s clients all SENDREC, so a
dropped request blocks the caller forever.
What the boot log proves
TTY writes its own banner straight to the UART once its mapping is in place:
minix.rs console: tty online (EL0)
That line is the milestone, and it is identifiable for a specific reason: it carries
no kernel trace prefix. Every other line in the log is [as], [ipc],
[ksys …], [diag …], [pf], [devmap] — kernel-formatted. This one was composed
at EL0 and reached the wire through a user-space store to a device register.
VFS then drives the protocol as the first client: it resolves TTY through DS (rather
than hard-coding its boot endpoint), writes a banner through a read-only direct
grant and checks the reply against the granted length, asks for CDEV_MAX_IO + 8
bytes and requires exactly CDEV_MAX_IO back, and finally issues two requests that
must be refused — minor 7 with a perfectly good grant (ENXIO, from TTY’s own
minor check) and a grant issued to PM instead of TTY (EPERM, from the kernel’s
grantee check, the property that makes a grant id safe to pass around at all).
One honest caveat: under QEMU’s TCG the Device attribute is not observably
load-bearing. QEMU’s PL011 works through a Normal write-back mapping — which is why
the kernel’s own HHDM alias has always been one — so substituting the Normal
attribute index changes no marker in the boot log. The attribute is proved by
construction and assertion, not empirically. The same is true of the FR.TXFF poll
(TCG’s FIFO never fills) and of the LF→CRLF translation (the log checker matches
literal substrings and cannot express a carriage return).
Build & Toolchain
minix.rs builds as a single Cargo workspace. There is no separate C build today
(the musl fork is future work — see Roadmap); the only non-Cargo
step is fetching the prebuilt Limine binary, and a couple of shell scripts in
tools/ stage the boot ESP and launch QEMU.
Prerequisites
- Rust nightly, pinned in
rust-toolchain.toml(a barenightlywould let new lints or fmt rules break the build with no code change). - QEMU with
qemu-system-aarch64. - aarch64 UEFI firmware (edk2 / OVMF).
tools/qemu-run.shauto-detects it in common locations, or setQEMU_EFI_AARCH64=/path/to/edk2-aarch64-code.fd.
Quick start
# One-time: fetch the pinned Limine binary into external/limine/dist/
make -C external/limine
# Build the kernel for aarch64 (the primary target)
cargo kernel-aarch64
# Build + boot under QEMU. The kernel runs indefinitely once EL0 starts, so a
# timeout is mandatory. Redirect to a file when you need to grep the log.
# Budget ~5 s for the rebuild + UEFI firmware startup before the kernel's first
# byte -- `timeout 8` can yield a log with no kernel output at all, so use 25 s
# for anything you intend to verify.
timeout 25 cargo run -p minixrs-kernel --target aarch64-unknown-none --release
# Clean, stub-free boot for debugging: --no-default-features disables the
# `boot-stubs` feature, so only the servers + init/worker boot (no demo stubs
# A-D flooding the trace). See "Boot stubs" under Cargo workspace below.
timeout 25 cargo run -p minixrs-kernel --target aarch64-unknown-none --release --no-default-features
cargo run invokes the cargo runner (tools/qemu-run.sh), which stages an ESP
directory at target/esp/, drops Limine and the freshly built kernel in, and
boots QEMU with the directory-as-FAT helper — no disk-image scripting needed (see
Boot for the ESP layout and the exact QEMU command). Early
serial output looks like:
minix.rs booting on aarch64
HHDM offset: 0xffff000000000000
Cargo workspace
The root Cargo.toml declares every crate as a workspace member: kernel,
kernel-shared, 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
alias — forced-target (below) pins the kernel to aarch64 and overrides the
--target in an alias, so one would silently build aarch64 rather than fail.
Phase 8 must relax forced-target before adding it back.
Assembly and the boot image (kernel/build.rs)
The kernel’s build.rs does two build-time jobs:
- Assembly — it assembles the kernel’s
.Sfiles withclangand passes the resulting objects straight to the linker. WhenCARGO_CFG_TARGET_OS != "none"it instead emits acargo::error=line and stops, because a host build of the kernel is always a mistake (see The kernel is not host-buildable). The demo-stub blobuser_stub.Sis assembled only when theboot-stubsfeature is on (see Boot stubs). - Boot-image packing — it builds each boot server for the custom EL0 user
target
tools/targets/aarch64-unknown-minixrs.json(via-Zbuild-std) into one shared nestedCARGO_TARGET_DIR(target/minixrs-user, socore/allocare compiled once rather than per crate), checks each ELF carries the minixrs identity note, packs them into the MXBI archive (pack_mxbi), and emitsBOOT_IMAGE_PATHfor the kernel toinclude_bytes!. There is no separatemkbootimagetool. See Boot for the archive format and module set.
Boot stubs (boot-stubs feature)
The kernel installs four hand-written EL0 demo stubs A–D at boot — a live
regression battery for the IPC primitives (A↔B ping-pong), SCHED delegation (C),
and the VM page-fault / SIGSEGV path (D). They are useful but noisy: stub C’s
SYS_GETINFO loop floods the trace. The boot-stubs cargo feature (default-on)
gates them, so --no-default-features yields a clean boot of servers + init/worker
only.
The feature lives on two crates — the kernel (gates the stub code in
arch::aarch64::userland) and PM (gates the stub mproc seeding). Because
build.rs builds each server in a separate nested cargo invocation with its own
feature resolution, it reads CARGO_FEATURE_BOOT_STUBS and, when the kernel is
stub-free, passes --no-default-features to the nested PM build too — keeping the
two in lockstep. The feature is intentionally not placed on kernel-shared: a
shared-crate default feature is force-enabled by other dependents (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-targetis unstable (cargo#9406), hence thecargo-featuresopt-in and the nightly pin inrust-toolchain.toml. If a future bump drops it, cargo fails loudly on the manifest; the fallback is a workspacedefault-memberslist omitting"kernel".test = falseis required. Without it,cargo check --all-targetsbuilds a phantom test harness for the kernel bin and fails withE0463: can't find crate for test.build.rs’scargo::error=andmain.rs’s#[cfg(not(target_os = "none"))] compile_error!are now unreachable defense-in-depth, kept in caseforced-targetever stops applying.
No cfg(target_os = ...) gates remain under kernel/src/.
The cfg_attr(target_os = "minixrs", …) attributes in servers/* and userland/* are a
different thing: those crates are host-built and host-tested, and the attribute only
hides an ELF section specifier from a Mach-O host. (They keyed on target_os = "none"
until M1 moved the user-space binaries onto the aarch64-unknown-minixrs target.)
Host tests
Logic that can run off-target lives in kernel-shared and in the host-testable
server crates:
cargo test -p minixrs-kernel-shared
cargo test -p minixrs-gen-c-headers # the C ABI header generator
There is no #[cfg(test)] code under kernel/src/ — the crate cannot be host-tested
and in-QEMU test infrastructure does not exist yet, so such tests would never run.
Pure predicates over shared ABI types belong in kernel-shared instead (user_va_ok
in kernel-shared/src/message.rs is the worked example); hardware and raw-pointer
behaviour stays in the kernel. QEMU is the primary verification for kernel code, and
CI smoke-boots it (below).
CI
.github/workflows/ci.yml runs on every PR and on pushes to main. Eleven jobs run
in parallel (sonar waits on coverage):
| Job | Blocking? | What it checks |
|---|---|---|
fmt | yes | cargo fmt --all --check (covers the kernel too) |
clippy | yes | cargo clippy --workspace --exclude minixrs-kernel --all-targets -- -D warnings (host target; kernel excluded for runner cost, see below) |
clippy-kernel | yes | cargo clippy -p minixrs-kernel --target aarch64-unknown-none -- -D warnings, twice: default features and --no-default-features |
c-headers | yes | regenerates the C ABI headers from kernel-shared and compiles them with clang -std=c11 -fsyntax-only (host + both musl triples) |
audit | yes | cargo-audit advisory scan |
deny | yes | cargo-deny (licenses / bans, config in deny.toml) |
geiger | advisory | unsafe surface report (per package, kernel filtered out) |
miri | advisory | UB check on the host-testable crates |
qemu-smoke | yes | boots the kernel and greps the serial log |
coverage | yes | cargo-llvm-cov → lcov.info (kernel excluded) |
sonar | — | feeds LCOV to SonarQube Cloud |
Notes: CI’s clippy and coverage exclude the kernel for runner cost, not
correctness — forced-target means they could build it, but only by having the x86
runner cross-assemble the .S files and run 8 nested server builds on a blocking gate.
So clippy-kernel is the only CI job that compiles kernel code (a local
cargo clippy --workspace does lint it) — which is why it blocks and runs on a native
ubuntu-24.04-arm runner. It passes no
--all-targets: the kernel is no_std/no_main, so there is no test harness to build.
qemu-smoke (also ubuntu-24.04-arm) boots for 45 s wall clock, requires exit status
124 — the timeout(1) status a healthy, never-exiting kernel must produce — and then
runs tools/check-boot-log.sh against tests/qemu-boot.expected / .forbidden; keep
those expectations timing-robust (first occurrences, never counts), because CI’s TCG is
slower than a local run. Cargo.lock is committed so audit / deny are reproducible,
and third-party actions are pinned to commit SHAs.
Generated C headers
The musl fork’s view of the minix.rs ABI is generated, never committed
(phase-5 decision D8): tools/gen-c-headers depends on kernel-shared as an
ordinary Rust crate and prints C from the live constants, so the Rust and C
views cannot drift.
cargo gen-c-headers # -> target/gen-c-headers/
cargo gen-c-headers /some/sysroot # explicit output directory
cargo gen-c-headers --stdout # eyeball the output
It emits include/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_NRvs_EP. Every process gets both. minix.rs sign-extends the endpoint proc field instead of using MINIX 3’s offset bias, so for the kernel tasks the boot endpoint is not the process number (SYSTEM_PROC_NRis −2,SYSTEM_EPis 32766). Naming a task by its_PROC_NRin an IPC call is a bug, and the header asserts the C decode macro against the Rust-computed endpoints.- The POSIX errno block is asserted, never defined. minix.rs adopts musl’s
numbering verbatim, so those values must come from the C library’s own
<errno.h>;minix/errno.hdefines only the MINIX 200-band and puts the POSIX checks behindMINIX_ABI_CHECK_POSIX_ERRNO, which the musl build defines. See System Calls & ABI.
Debugging: QEMU trace forensics
User-space servers run at EL0 with no console — they cannot print. All server
behavior is observed through kernel-side traces ([as], [ipc], [ksys],
[pf], [alarm]). Reading those logs has some sharp edges worth knowing:
grep -a. The serial log interleaves raw single-character tick bytes, so tools treat it as binary (“Binary file matches”). Force text mode withgrep -a(orgrep -aF). Redirect the run to a file and grep that — a live tail loses lines.- TCG time skew. QEMU under TCG advances guest time slower than wall clock,
so a
timeout Nrun reaches far fewer thanN × 100ticks. For time-based behavior (alarms, quanta) read uptime-stamped traces (e.g.[alarm … at=N]) as the real clock, and run 20–25 s to observe several periods. - Sampling asymmetry.
[ipc N]head-traces the first ~12 calls plus every 100th;[ksys N]samples only every 100th, with no head carve-out. A server’s first or rare kernel call (e.g. a startupSYS_GETINFO) shows on[ipc], not[ksys]. - Zero
[ipc]samples ≠ a stuck caller. A blockingSENDRECclient (init’s fork/wait loop, say) round-trips far too rarely for the modulo sampler to catch. Confirm liveness through its downstream head-carved[ksys …]traces (SYS_FORK/SYS_EXITare head-carved), or add a temporary[DBG]trace inipc::do_ipckeyed on the caller’s proc number — and remove it before committing. - The acceptance harness.
tools/check-boot-log.sh <log>greps a captured log againsttests/qemu-boot.expectedandtests/qemu-boot.forbidden— the same check theqemu-smokeCI job runs. Update those marker files in the same change when trace formats or the boot roster shift. - Quiet the stubs. Stub C’s
SYS_GETINFOloop dominates the[ipc]/[ksys]sample stream. When you’re chasing a server or init/musl issue, boot--no-default-featuresto drop the demo stubs A–D entirely (see Boot stubs) — the trace then shows only the servers + init/worker. Note theqemu-smokemarkers assume the default (stubs-on) boot, so don’t runcheck-boot-log.shagainst a stub-free log.
Debugging with GDB
QEMU’s GDB stub works through the runner’s pass-through args:
# Terminal 1 — QEMU paused, waiting for a debugger (-S), stub on :1234 (-s)
cargo run -p minixrs-kernel --target aarch64-unknown-none --release -- -s -S
# Terminal 2
rust-gdb target/aarch64-unknown-none/release/minixrs-kernel \
-ex "target remote :1234" -ex "break kmain" -ex "continue"
Roadmap
Mostly planned — parts of Phase 5 now boot. This chapter is design intent for phases beyond Phase 4, but Phase 5 has begun landing and the sections below are no longer uniformly future tense: grants and the fault-safe copy engine are live (slices 5.1–5.2), and so is the first user-space driver — TTY, which owns the PL011 and serves
CDEV_WRITE(slice 5.3, see Drivers). Still absent: the musl fork, the VirtIO drivers, the file-system servers, and any x86_64 port. The rest of this book describes what does run; this chapter describes where the project is headed. For live phase status and slice tracking, see the repository’sdocs/plan.md.
Grants and safe copy (Phase 5, first)
Every interesting data path beyond Phase 4 — VFS read/write, FS ↔ VFS, and later
block I/O — moves bytes across address spaces, and a 96-byte message payload can’t
carry them. MINIX solves this with grants: a process publishes a grant table
(via SYS_SETGRANT) describing memory regions and permitted operations
(read / write); it passes a grant ID in a message; the peer copies through
SYS_SAFECOPY, which the kernel authorizes against the grant table before moving
any bytes. This keeps the kernel from having to trust a raw pointer from one
process on behalf of another.
The call numbers exist as stubs today (see
System Calls & ABI). A real grant table plus a
fault-safe user copy (returning EFAULT on a bad pointer rather than panicking
the kernel) is the expected opening work of Phase 5, because the musl and
file-system slices depend on it.
musl C library
User C programs will link against a fork of musl
(MIT-licensed) whose Linux syscall layer is replaced with MINIX IPC. A POSIX call
becomes a message to a server rather than a Linux syscall:
read(fd, buf, n)
→ construct Message { m_type: VFS_READ, fd, buf_ptr, count }
→ _syscall(VFS_PROC_NR, VFS_READ, &msg) // SENDREC via the IPC trap
→ VFS handles it, replies
→ _syscall() extracts the result / errno
The fork’s shape:
_syscall()— the central routing function: setm_type,ipc_sendrec, and translate a negative reply intoerrno+-1. Mirrors MINIX 3’slib/libc/sys/syscall.c.- IPC trap stubs — a tiny
.Sper architecture issuing the trap. On aarch64 that issvc #0with the register convention from IPC (x0= endpoint,x1= primitive,x2= message). - ~100 POSIX wrappers — one per call (
open,read,write,fork,execve,mmap,brk, …), each constructing a message and calling_syscall. - A cbindgen bridge — the C headers (
Message, endpoint constants, call numbers) generated from thekernel-sharedRust crate, so the wire protocol has a single source of truth. This also means thekernel-sharedABI needs to be frozen deliberately before the header bridge is stood up. - A cross-compiled sysroot —
libc.a+ thecrt*.ostartup files, linked into user programs.
The printf “Hello World” milestone that closes the early Phase-5 work needs a
console/stdio sink; whether that is a kernel diagnostic call, a minimal TTY, or
deferred is a design decision to be locked in the Phase-5 plan.
File systems
Two file-system servers are planned, each speaking the REQ_* protocol to VFS
(which is skeletal today — see Servers):
- MFS — the MINIX File System (MinixFS v3 on-disk format).
- PFS — the Pipe File System (in-memory, for pipes and FIFOs).
A root image is also needed before block drivers exist — likely an initramfs or an
MXBI-embedded FS image, another Phase-5 design decision. FS-backed exec will
reuse the kernel’s ELF loader (kernel/src/boot_image/elf.rs).
Device drivers
Drivers are user-space processes, like servers — a buggy driver can crash only itself, and RS restarts it. They talk to the kernel (interrupts, I/O) and to VFS (device protocols) over IPC.
- Block drivers answer the
BDEV_*protocol (OPEN/CLOSE/READ/WRITE/IOCTL, plus scatter-gather); character drivers answerCDEV_*(OPEN/CLOSE/READ/WRITE/IOCTL/SELECT). Bulk data rides grants +SYS_SAFECOPY. - Interrupts arrive as
NOTIFYmessages from theHARDWAREendpoint after a driver registers a handler withSYS_IRQCTL. The kernel masks the line, notifies the driver, and the driver re-enables it — no interrupt storms. - VirtIO transport — MMIO on aarch64 (devices memory-mapped in the QEMU
virtdevice tree), PCI on x86_64. Devices exchange data through virtqueues: a descriptor table plus an available ring (driver → device) and a used ring (device → driver). - The planned set is
virtio-blk,virtio-net,virtio-console(a VirtIO TTY), and a hardware-freememorydriver (/dev/null,/dev/zero, ramdisk). - A
driver-rtcrate will provide the reusableBlockDriver/CharDrivertraits and the VirtIO transport types. Only a console story is needed for the Phase-5printfmilestone; the rest is Phase 6.
Copy-on-write fork
fork today eagerly gives the child its own frames (the kernel copies page tables
via SYS_FORK; VM clones the region set via VM_FORK — see
Memory Management). Copy-on-write fork — sharing pages
read-only and duplicating only on the first write fault — is a later optimization,
not a Phase-4/5 requirement.
x86_64 port
aarch64 is the primary target. The kernel is structured for a second architecture
(the HAL split, the x86_64-unknown-none target scaffolding, and the ABI-neutral
kernel-shared types), but the x86_64 path — SYSCALL/SYSRET, GDT/IDT, APIC,
the x86_64 IPC register ABI — is not implemented. It is a late phase.
System Calls & ABI
This is a reference chapter. It documents two things and keeps them distinct:
- What dispatches today — the kernel calls the microkernel actually handles at the end of Phase 4, and the server request ranges minix.rs uses on the wire.
- The MINIX ABI minix.rs targets — the POSIX call catalog (PM and VFS) that the musl wrappers and file-system servers will wire up in Phase 5 and beyond. These numbers are the MINIX 3 reference ABI; they are not the request numbers minix.rs currently sends.
The two call paths
MINIX has two kinds of “system call,” and minix.rs keeps both.
POSIX calls (user → server). A user program’s open / read / fork
becomes an IPC message to the responsible server, sent with SENDREC:
user program → SENDREC(server_endpoint, &msg) → kernel IPC → server → reply
The kernel routes the message but does not interpret the call. Today this path is
driven directly through the 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.
| Number | Call | Status | Purpose |
|---|---|---|---|
0x600 | SYS_GETINFO | live | Kernel introspection (e.g. GET_WHOAMI). |
0x601 | SYS_PRIVCTL | live | Set up a target’s privilege slot (PRIVCTL_SET_USER). |
0x602 | SYS_FORK | live | Clone a process slot as a frozen child. |
0x603 | SYS_EXEC | live | Replace a target’s image with a boot-embedded binary. |
0x604 | SYS_EXIT | live | Full process teardown (address space, endpoint, slot). |
0x605 | SYS_COPY | stub | Inter-space copy — placeholder. |
0x606 | SYS_SAFECOPY | stub | Grant-validated copy — real grants are Phase 5. |
0x607 | SYS_IRQCTL | stub | IRQ handler registration — driver era. |
0x608 | SYS_VMCTL | live | VM’s paging-mechanism call (see subcalls below). |
0x609 | SYS_SCHEDULE | live | Set a process’s priority and quantum. |
0x60A | SYS_SETALARM | live | Arm/cancel a per-process one-shot alarm. |
0x60B | SYS_TIMES | stub | Process accounting times — placeholder. |
0x60C | SYS_DIAGCTL | live | Print inline text to the console (see subcodes below). |
0x60D | SYS_SETGRANT | stub | Register the caller’s grant table — Phase 5. |
0x60E | SYS_SCHEDCTL | live | Claim/release a process for a user-space scheduler. |
0x60F | SYS_KILL | live | Raise a signal on a target (queues toward PM). |
0x610 | SYS_GETKSIG | live | PM: fetch the next process with pending signals. |
0x611 | SYS_ENDKSIG | live | PM: acknowledge signal processing for a target. |
SYS_VMCTL subcalls
SYS_VMCTL is VM’s single privileged lever over the kernel’s paging mechanism.
The subcall selector is in the first payload word; the target process (endpoint,
SELF allowed) in the next. Numbers start at 1 so a zeroed payload is invalid.
| Subcall | Effect |
|---|---|
VMCTL_PT_MAP (1) | Allocate a fresh zeroed frame, map it at vaddr, reply with the PA. |
VMCTL_PT_UNMAP (2) | Unmap vaddr and free the frame (EINVAL if nothing mapped). |
VMCTL_CLEAR_PAGEFAULT (3) | Clear a recorded fault and make the target runnable. |
VMCTL_GET_PAGEFAULT (4) | Read the target’s recorded fault coordinates. |
VMCTL_VMINHIBIT_SET / _CLEAR (5/6) | Gate scheduling while VM mutates the target’s AS. |
See Memory Management for how VM uses these.
SYS_DIAGCTL subcodes
SYS_DIAGCTL is the servers’ debug channel. Servers run at EL0 with no console
of their own, so without it their behavior is only observable indirectly, through
kernel-side traces. server-rt::diag_print is the client side.
Unlike MINIX 3, which passes a (buf, len) user pointer and copies the text in,
minix.rs carries the text inline in the message payload: the subcode in
payload 0..4, the length in 4..8, and up to DIAG_TEXT_MAX (88) text bytes
from DIAG_TEXT_OFF (8). The channel therefore needs no user-copy machinery and
cannot fault — it has to keep working while the copy engine and grants are
themselves under construction. diag_print splits longer strings across
successive calls, one console line each.
| Subcode | Effect |
|---|---|
DIAGCTL_CODE_DIAG (1) | Print the inline text as one [diag <name>] … line. |
DIAGCTL_CODE_STACKTRACE (2) | Reserved (MINIX 3) — EINVAL. |
DIAGCTL_CODE_REGISTER / _UNREGISTER (3/4) | Reserved (MINIX 3 kernel-message subscription) — EINVAL. |
The <name> prefix is the caller’s name as the kernel knows it, never
anything from the payload, so a server can only ever identify itself. Text is
restricted to printable ASCII, which keeps one call to exactly one line — the
boot-marker checks in tests/qemu-boot.expected depend on that framing. No extra
privilege gate is needed: Priv::k_call_mask already limits kernel calls to
server-grade privileges, and the shared USER privilege has an empty mask, so
ordinary user processes cannot reach this call.
Server request ranges — live today
Server requests are ordinary IPC m_type values, not kernel calls. Each server
occupies a distinct band below NOTIFY_MESSAGE (0x1000), const-asserted
disjoint in kernel-shared/src/callnr.rs:
| Base | Value | Server / requests |
|---|---|---|
PM_RQ_BASE | 0x700 | PM: GETPID / FORK / EXIT / WAIT / EXEC |
VM_RQ_BASE | 0xC00 | VM: PAGEFAULT / BRK / MMAP / MUNMAP / FORK |
SEF_RQ_BASE | 0xD00 | SEF control: INIT / SIGNAL |
DS_RQ_BASE | 0xE00 | DS: PUBLISH / RETRIEVE / CHECK |
SCHED_RQ_BASE | 0xF00 | SCHED: NO_QUANTUM / START / STOP / SET_NICE |
The Servers chapter documents each request. These minix.rs-specific numbers are what actually travels on the wire today — distinct from the MINIX 3 POSIX ABI numbers catalogued below.
The MINIX ABI catalog (target)
Reference, not current behavior. The tables below are the MINIX 3 POSIX call ABI that minix.rs is heading toward — the numbering musl and the file-system servers will adopt. They describe the interface, not what is dispatched today: VFS is skeletal (it handles none of these yet), and PM’s live request numbers are the
0x700band above, not these. Treat this as the map of Phase 5+ work.
PM calls (base 0x000, target)
The Process Manager handles process lifecycle, signals, credentials, and timing.
| Call | # | POSIX | Description |
|---|---|---|---|
PM_EXIT | 1 | _exit | Terminate the caller |
PM_FORK | 2 | fork | Create a child process |
PM_WAIT4 | 3 | wait4 | Wait for a child to change state |
PM_GETPID | 4 | getpid | Get the caller’s PID |
PM_SETUID/GETUID | 5/6 | setuid/getuid | Real user ID |
PM_KILL | 11 | kill | Send a signal |
PM_SETGID/GETGID | 12/13 | setgid/getgid | Real group ID |
PM_EXEC | 14 | execve | Execute a new program image |
PM_SETSID/GETPGRP | 15/16 | setsid/getpgrp | Session / process group |
PM_ITIMER | 17 | setitimer | Interval timer |
PM_SIGACTION … PM_SIGRETURN | 20–24 | sigaction, sigsuspend, sigpending, sigprocmask | Signal machinery |
PM_GETPRIORITY/SETPRIORITY | 26/27 | getpriority/setpriority | Scheduling priority |
PM_GETTIMEOFDAY | 28 | gettimeofday | Time of day |
PM_CLOCK_GETRES/GETTIME/SETTIME | 33–35 | clock_* | POSIX clocks |
PM_GETRUSAGE | 36 | getrusage | Resource usage |
PM_REBOOT | 37 | reboot | Reboot / halt |
PM_SRV_FORK … PM_GETSYSINFO | 41–47 | (MINIX) | Service management for RS |
Slots 25, 40 and a few others are unused; numbers ≥ 40 are MINIX-specific service
infrastructure. (Full detail: minix/include/minix/callnr.h.)
VFS calls (base 0x100, target)
The Virtual File System server handles file I/O, directories, mounts, and — in MINIX 3.4 — BSD sockets.
| Call | # | POSIX | Call | # | POSIX |
|---|---|---|---|---|---|
VFS_READ | 0 | read | VFS_FCNTL | 25 | fcntl |
VFS_WRITE | 1 | write | VFS_PIPE2 | 26 | pipe2 |
VFS_LSEEK | 2 | lseek | VFS_UMASK | 27 | umask |
VFS_OPEN | 3 | open | VFS_CHROOT | 28 | chroot |
VFS_CREAT | 4 | creat | VFS_GETDENTS | 29 | getdents |
VFS_CLOSE | 5 | close | VFS_SELECT | 30 | select |
VFS_LINK/UNLINK | 6/7 | link/unlink | VFS_FCHDIR | 31 | fchdir |
VFS_CHDIR | 8 | chdir | VFS_FSYNC | 32 | fsync |
VFS_MKDIR | 9 | mkdir | VFS_TRUNCATE/FTRUNCATE | 33/34 | truncate/ftruncate |
VFS_MKNOD | 10 | mknod | VFS_FCHMOD/FCHOWN | 35/36 | fchmod/fchown |
VFS_CHMOD/CHOWN | 11/12 | chmod/chown | VFS_UTIMENS | 37 | utimensat |
VFS_MOUNT/UMOUNT | 13/14 | mount/umount | VFS_STATVFS1/FSTATVFS1 | 40/41 | statvfs/fstatvfs |
VFS_ACCESS | 15 | access | VFS_SOCKET … VFS_SHUTDOWN | 49–63 | BSD sockets |
VFS_SYNC | 16 | sync | |||
VFS_RENAME | 17 | rename | |||
VFS_RMDIR | 18 | rmdir | |||
VFS_SYMLINK/READLINK | 19/20 | symlink/readlink | |||
VFS_STAT/FSTAT/LSTAT | 21–23 | stat/fstat/lstat | |||
VFS_IOCTL | 24 | ioctl |
The socket calls (49–63) route through VFS to a socket driver or network stack.
(Full detail: minix/include/minix/callnr.h.)
Safe copy and grants (planned)
Because a message payload is only 96 bytes, larger transfers use MINIX’s grant
mechanism rather than passing raw pointers the kernel would have to trust: a
process publishes a grant describing a region and the permitted operation (read /
write), passes the grant ID in a message, and the peer copies through
SYS_SAFECOPY, which the kernel authorizes against the grant table. The call
numbers (SYS_SETGRANT, SYS_SAFECOPY) exist as stubs today; a real grant table
and validated copy are an opening Phase-5 slice — every interesting Phase-5 data
path (VFS read/write, FS ↔ VFS) moves bytes across address spaces and needs them.
Generated C headers
The ABI above is described to C by headers generated from the live Rust constants and never committed (phase-5 decision D8):
| Header | Contents |
|---|---|
minix/ipc.h | the message struct, endpoint packing macros, IPC primitives |
minix/com.h | process numbers and the boot endpoints derived from them |
minix/callnr.h | kernel-call numbers and the server request bands |
minix/errno.h | the MINIX 200-band errnos; an opt-in check on the C library’s POSIX ones |
cargo gen-c-headers # -> target/gen-c-headers/
Every value comes from kernel-shared, and the headers carry _Static_asserts
pinning the message layout and the endpoint encode/decode against it — so an
ABI change on the Rust side fails to compile on the C side rather than silently
corrupting IPC. See Build & CI.
MINIX 3 source references
| File | Purpose |
|---|---|
include/minix/callnr.h | PM and VFS call numbers |
include/minix/com.h | Kernel-call numbers and IPC constants |
lib/libc/sys/syscall.c | User-space _syscall() |
lib/libsys/kernel_call.c | Server-side _kernel_call() |
MINIX 3 Source Mapping
This chapter maps MINIX 3 source files and concepts to their minix.rs equivalents — a navigation aid if you are coming from the MINIX 3 codebase or Tanenbaum’s Operating Systems: Design and Implementation. Only paths that exist in the tree today are listed as real; everything else is grouped under Planned so the table never points at a file that isn’t there.
Some rows cite 64-bit paths in the MINIX 3 tree (e.g.
lib/libc/arch/x86_64/). MINIX 3 shipped only as a 32-bit OS; a working 64-bit MINIX 3 was a personal prototype by this project’s author, not an upstream release. These are cited as an ABI/source reference only — see the note in IPC.
Kernel
| MINIX 3 | minix.rs |
|---|---|
kernel/proc.c — mini_send | kernel/src/ipc/send.rs |
kernel/proc.c — mini_receive | kernel/src/ipc/receive.rs |
kernel/proc.c — mini_notify | kernel/src/ipc/notify.rs |
kernel/proc.c — mini_senda | kernel/src/ipc/senda.rs (stub) |
kernel/proc.c — deadlock | kernel/src/ipc/deadlock.rs |
kernel/proc.c — do_ipc | kernel/src/ipc/mod.rs |
kernel/proc.h — struct proc | kernel/src/proc/proc_struct.rs (static tables in table.rs) |
kernel/priv.h — struct priv | kernel/src/proc/priv_struct.rs |
kernel/system.c | kernel/src/system/mod.rs |
kernel/system/do_fork.c | kernel/src/system/do_fork.rs |
kernel/system/do_exec.c | kernel/src/system/do_exec.rs |
kernel/system/do_vmctl.c | kernel/src/system/do_vmctl.rs |
kernel/system/do_copy.c, do_irqctl.c | kernel/src/system/stubs.rs (stub handlers) |
kernel/clock.c | kernel/src/clock.rs |
kernel/interrupt.c | kernel/src/arch/aarch64/irq.rs, gic.rs |
kernel/main.c — kmain | kernel/src/main.rs |
kernel/table.c — boot table | kernel/src/boot_image/mod.rs |
kernel/arch/i386/head.S | kernel/src/arch/aarch64/entry.S |
kernel/arch/i386/mpx.S — context switch | kernel/src/arch/aarch64/context.rs |
kernel/arch/i386/protect.c — vectors | kernel/src/arch/aarch64/exception.rs |
kernel/arch/i386/memory.c — page tables | kernel/src/arch/aarch64/mmu.rs, addrspace.rs |
Shared headers
| MINIX 3 | minix.rs |
|---|---|
include/minix/ipc.h | kernel-shared/src/message.rs |
include/minix/ipcconst.h | kernel-shared/src/ipc_const.rs |
include/minix/com.h | kernel-shared/src/com.rs |
include/minix/callnr.h | kernel-shared/src/callnr.rs |
include/minix/type.h — endpoint_t | kernel-shared/src/endpoint.rs |
| error codes | kernel-shared/src/error.rs |
| signal numbers | kernel-shared/src/signal.rs |
The arrow runs both ways for the first four rows: tools/gen-c-headers generates
minix/{ipc,com,callnr,errno}.h back out of those Rust modules for the musl fork
to include, so C never gets a hand-maintained copy of the ABI.
Errno numbering is minix.rs’s own policy rather than a port of either reference
tree (phase-5 decision D7): the POSIX block 1..=40 uses classic book-era MINIX
values — identical to Linux/musl, which is what lets musl’s stock
bits/errno.h work unmodified — while the MINIX-specific IPC errnos take modern
MINIX 3’s 200-band values, clear of Linux’s entire range. Both are stored negated
in Rust. EBADSRCDST is the one name modern MINIX lacks; it takes the value of
that tree’s EBADEPT (216).
Runtime and libraries
| MINIX 3 | minix.rs |
|---|---|
lib/libsys/sef.c | server-rt/src/sef.rs |
lib/libsys/sef_init.c | server-rt/src/init.rs |
lib/libsys/sef_signal.c | server-rt/src/signal.rs |
| SEF message classifier | server-rt/src/classify.rs |
| DS publish glue | server-rt/src/ds.rs |
lib/libsys/kernel_call.c | minix-ipc/src/lib.rs |
Servers
| MINIX 3 | minix.rs |
|---|---|
servers/pm/main.c, forkexit.c, exec.c, signal.c | servers/pm/src/main.rs |
servers/pm/ — process table | servers/pm/src/mproc.rs |
servers/vfs/main.c | servers/vfs/src/main.rs (skeletal) |
servers/vm/main.c | servers/vm/src/main.rs |
servers/vm/region.c | servers/vm/src/region.rs |
servers/rs/main.c | servers/rs/src/main.rs |
servers/rs/manager.c — monitoring | servers/rs/src/monitor.rs |
servers/ds/main.c | servers/ds/src/main.rs |
servers/ds/store.c | servers/ds/src/registry.rs |
servers/sched/main.c | servers/sched/src/main.rs |
servers/sched/schedule.c — policy | servers/sched/src/policy.rs |
Build system
| MINIX 3 | minix.rs |
|---|---|
build.sh, NetBSD Makefiles | root Cargo.toml workspace + .cargo/config.toml |
releasetools/ | tools/qemu-run.sh, tools/check-boot-log.sh |
Planned
These MINIX 3 areas have no minix.rs counterpart in the tree yet; the paths below are the intended destinations (see Roadmap):
| MINIX 3 | minix.rs (planned) |
|---|---|
include/minix/safecopies.h — grants | grant table + SYS_SAFECOPY (Phase 5) |
lib/libc/sys/syscall.c, _ipc.S, open.c, … | the musl fork (musl/src/minix/*) |
lib/libblockdriver/, lib/libchardriver/ | drivers/driver-rt traits |
servers/vfs/{open,read,path,mount}.c | VFS I/O (VFS is one main.rs today) |
servers/mfs/, servers/pfs/ | fs/mfs, fs/pfs (stubs) |
drivers/storage/*, drivers/net/*, drivers/tty/ | drivers/virtio-*, drivers/memory (stubs) |
| disk-image tooling | (no root disk yet — see Boot) |
Concepts unchanged
These MINIX 3 concepts carry over directly and are live today:
- Message-passing IPC with fixed-size (104-byte) messages.
- Five live IPC primitives — SEND, RECEIVE, SENDREC, NOTIFY, SENDNB (SENDA is stubbed).
- Endpoint-based process identification with generation numbers.
- Privilege bitmaps (
trap_mask,ipc_to,k_call_mask). - SEF server lifecycle (init, ping, signal).
- An embedded boot image (MXBI). Startup is not hand-ordered, though — servers rendezvous at run time through DS.
- RS heartbeat monitoring (restart-on-crash is detect-only so far).
- DS as the name → endpoint registry.
Carried over in design but not yet realized: grant-based safe copy, the BDEV/CDEV
driver protocols, and the REQ_* VFS ↔ FS protocol.
Concepts changed
| MINIX 3 | minix.rs | Why |
|---|---|---|
| raw C pointers in IPC queues | Option<ProcNr> indices | memory safety |
m1i1 / m2l1 message fields | named typed structs | readability |
EXTERN macro globals | module-scoped statics | Rust idiom |
RTS_SET / RTS_UNSET macros | rts_set / rts_unset fns | type safety |
volatile flags | AtomicU32 | Rust atomics |
| integer error returns | Result<T, E> | Rust error handling |
#ifdef arch selection | cfg(target_arch) | Rust conditional compilation |
| NetBSD libc / userland | musl fork / Rust userland (planned) | BSD license, minimal |