21 KiB
Running Zig on danos: the self-hosting roadmap
A design note (not built yet) on the path to making danos a real Zig target — a
target you can name (-target x86_64-danos) and, eventually, run the Zig compiler
itself on. It is forward-looking, like vision.md: it sets a direction
and the decisions that follow from it, so the code we write now bends toward it
instead of away.
This note deliberately does not cover a text editor or terminal. Those are easier (single-process, I/O-bound) and fall out of the early phases here almost for free; the hard, shaping problem is the standard-library surface, so that is what this roadmap is about.
The analysis behind it was done against Zig 0.16 (the pinned toolchain). Zig's standard library moves between releases — especially the parts described here — so treat upstream references as "the shape in 0.16.x," and expect to re-check them on a toolchain bump.
The win condition
danos runs the Zig compiler when a bare
zig build-exe hello.zig
completes on danos and produces a runnable danos binary. Note the milestone is
build-exe, not zig build: the zig build runner spawns child processes (the
build steps), which needs a whole process-control surface danos does not have yet.
A single build-exe needs none of that (see Phase 3). Reaching build-exe is
"self-hosting"; reaching zig build is a later, separate lift.
Non-goals
- No Linux syscall/ABI emulation. danos will not implement the Linux
syscallinterface so that stockx86_64-linuxbinaries run. That is a permanent compatibility treadmill and it inverts the microkernel design — explicitly out. - No musl port yet. A musl libc port is a reasonable later effort (it unlocks the C ecosystem), but it is not on the critical path to Zig-on-danos, and it is deferred. The roadmap below is arranged so the work still pays off if musl ever happens (see "The same surface, twice").
- Editor/terminal are out of scope for this note (they are downstream of Phase 1).
On FFI. Foreign-function interop splits the same way as the doors below. Zig-level
and C-ABI-exposing FFI (extern, callconv(.c), C-ABI structs) work on a real target
immediately — and the std.os.danos seam is C-ABI-shaped by construction, so it is
FFI-friendly from the start. Consuming C libraries (@cImport, linking archives) is
the part that needs a libc + headers, i.e. the deferred musl door. So an eventual FFI
need reinforces keeping that door open; it does not change the plan.
The realization that shapes everything: 0.16 gives us one seam
The instinct "to target Zig we'd have to reimplement all the std namespaces" was
how older Zig worked. Zig 0.16 (post-"writergate") is far kinder:
std.fsis essentially gone. It is now path helpers plus deprecated aliases; there is nostd.fs.File,std.fs.Dir, orstd.fs.cwd(). File and directory work goes throughstd.Io— a single runtime vtable (Io.zig) of function pointers handed tomainasstd.process.Init.io.std.Io.Fileandstd.Io.Dirare thin forwarders to that vtable.Io.zigand thefsshim carry zero per-OS branches.std.posixis one generic body parameterised over a singlesystemmodule. With no libc,systemresolves per target OS:.linux => std.os.linux,.plan9 => std.os.plan9, and so on. The genericstd.posix.read/write/openbodies are justsystem.read(...)plus an errno switch — identical for every OS. The only variable is whatsystembinds to.std.os.<tag>(e.g.std/os/linux.zig) is therefore the real porting seam: a low-level, C-ABI-shaped module ofread/write/open/close/lseek/mmap/clock/exit/…plus anerrnoenum and the constant tables (O_*,CLOCK_*,S_*).
Put together: to port danos we write std.os.danos once — the ~30-operation
seam — and the whole std.posix / std.fs / std.Io tower above it lights up
generically, because none of it branches on the OS. That is a dramatically smaller
and more contained target than "reimplement the namespaces."
Three doors, and why we take the first
| Door | What it is | Verdict |
|---|---|---|
1. Implement the std seam (std.os.danos) |
Write the ~30-op system module over danos's native ABI + VFS; the generic std tower lights up. |
Take this. The only door that touches neither C nor the Linux ABI. |
| 2. Port musl | Port musl libc to danos, link Zig against it. | Defer. Good later for the C ecosystem; barely helps Zig (std only uses libc on the libc-linked path). |
| 3. Emulate the Linux ABI | Implement Linux syscalls so stock linux binaries run. | Reject. Bottomless compatibility treadmill; against the design. |
The same surface, twice
Doors 1 and 2 are the same native surface at different layers. std.posix.read
is system.read(...) + an errno switch regardless of OS — the only question is
whether system is std.os.danos (Zig) or musl (C). Either way, the set of
danos-facing operations you must implement is the same ~30 ops, all bottoming out
in danos's native syscalls + the VFS/FAT server.
So the runtime work below is not throwaway if musl ever happens: you are building the danos-native implementations of that surface either way. Door 1 just packages them as Zig; a future musl re-uses the identical kernel/VFS operations underneath. The two symmetries worth keeping in mind: doors 1 and 2 converge at the top (identical POSIX surface); doors 2 and 3 converge at the bottom (unmodified musl needs the Linux syscall ABI). Door 1 is the only one that avoids both C and Linux.
A fork is table stakes — for any door
std.Target.Os.Tag is a closed enum baked into the compiler binary and into
the std linked with every program; -target x86_64-danos resolves through it. So
adding danos as a name requires patching and rebuilding the compiler — even the
musl door needs this. "Fork Zig" is therefore not an extra cost unique to door 1; it
is the price of admission for any real target. What door 1 adds on top is small and
localised (below).
The architecture decision: runtime.os + runtime.fs, and retire posix
danos already has the right split (the private-ABI boundary): the
kernel exposes a minimal syscall ABI (syscall.md); the runtime
library is the stable, danos-native application ABI. What this roadmap adds:
runtime.os— the seam. A C-ABI-shaped module of the ~30 operations (read/write/open/close/lseek/mmap/munmap/clock/exit/…) + an errno enum + the constant tables, each backed by danos's native syscalls and the VFS. Structure it to mirrorstd/os/linux.zig. This is the load-bearing, non-throwaway artifact: when we fork Zig,runtime.osis copy-pasted (near-verbatim) intostd.os.danos.runtime.fs— the thin native file API danos programs use today, layered overruntime.os. It is also the concrete backing for thestd.Iovtable's file-write entry once we're a real target, which is why program stdout, diagnostics, and file writes should all be decided once at that seam rather than as bespoke per-call helpers (see "How this informs decisions now").
Do not hand-mirror the high-level std namespaces. std.fs/std.Io/std.process
are generic and OS-agnostic; once std.os.danos exists and we fork, upstream gives
them to danos for free. Hand-writing runtime.std.fs to imitate them would be
redundant the day the fork works, and it would chase a moving target (0.16's std.Io
is large and still shifting). Build the seam well; take the tower for free.
Why not a library called std? Because @import("std") resolves to the
compiler-provided standard library; a user module named std would shadow it for
anything that imports it that way. That is the real reason the seam lives inside a
forked std as std/os/danos.zig, not as a runtime.std library — and why danos's end
state (@import("std") just working, and knowing danos) is the most natively Zig it can
be. runtime.os is only the interim staging ground: developed against the stock
toolchain so Phase 1 need not wait on the fork, then promoted near-verbatim into the
fork's std/os/danos.zig.
Retire library/posix (done)
The posix compatibility layer (unistd, stdio) was the right instinct too early.
Its whole value was POSIX spellings for POSIX software — and danos has no POSIX
software; every caller was danos-native code that could use runtime.fs
directly. The real POSIX story arrives later and from elsewhere (musl, or upstream
std's own posix over std.os.danos), which supersedes a hand-rolled shim. So it was
premature abstraction that added a "which layer do I use?" fork with no payoff.
Its footprint was tiny: five call sites, all unistd file operations —
system/services/fat/fat.zig (mount), the vfs-test and fat-test clients, and
(from the boot-log work) init.zig and log-flush.zig. stdio.zig was dead — nothing
imported it. The plan — build runtime.fs, migrate those five to it, delete
library/posix/, and drop the posix module from build.zig's addUserBinary — has
since been carried out: library/ today holds only mmio, runtime, and
xkeyboard-config.
Where danos stands: coverage vs. the gaps
What the seam needs, and what danos already provides:
| std need | danos today | Gap |
|---|---|---|
| open / read / write / close / lseek | VFS (via runtime.fs; the unistd shim is retired) |
none — repackage |
directory read (getdents) |
VFS readdir |
none — repackage |
| mmap / munmap | native syscalls (abi.zig) | none |
| page allocator | over mmap, via root.os.heap.page_allocator override |
~30-line hook |
| monotonic clock | clock syscall |
none |
| args / argv | SysV entry stack (sysv.md), runtime.process.Init |
none |
| stdout / stderr | debug_write today |
wire fd 1/2 to a console byte stream |
| mkdir / unlink / rename / truncate | done — engine + VFS + runtime.fs (Phase 2) |
— |
| stat fields | {size, kind, mtime} |
mode / inode still missing (cache validity) |
| wall-clock / realtime | done — wall_clock syscall (CMOS RTC, Phase 2d) |
— |
| environment variables | Init has no env field |
missing (can start empty) |
| cwd / chdir | paths are absolute or bare | missing (no cwd anchor) |
| entropy / random | — | missing (needed behind vtable.random) |
| process spawn + exit status | system_spawn starts a named ramdisk binary; ExitReason is a category |
no exec-of-path, no numeric WEXITSTATUS |
| threads | native threads — thread_spawn/futex/thread_join (runtime.Thread) |
std.Thread seam unwritten; avoided via -fsingle-threaded (below) |
| symlinks | NodeKind has the tag; unimplemented |
low priority |
The clustering is clear: reads and memory are basically done; the real work is
filesystem mutation + richer stat + wall-clock, and a few small seam pieces
(page-allocator hook, stdio bytes, entropy). Process spawning and threads are
side-stepped entirely for a single build-exe.
The roadmap
Phase 0 — Make danos a real target
Host, target, self-host — keep the three roles straight. The host is where the
compiler runs (your mac + linux dev machines); the target is what it emits (danos);
and eventually danos becomes a host too (self-hosting — the win condition). So the move
is: fork the compiler, build it for your dev hosts, and teach it to cross-compile
to danos. You already do this — danos is cross-compiled freestanding from your dev
host today; Phase 0 swaps that freestanding target for a real x86_64-danos one, which
is what unlocks the native std.
Why a compiler fork, not just a --zig-lib-dir override. std.Target.Os.Tag is a
closed enum compiled into the compiler binary, so -target x86_64-danos will not even
parse unless the compiler itself knows the tag. Overriding the std lib directory alone
cannot add a target — and there is no libc-only shortcut (a future musl needs the same
patch). The only alternative, staying on freestanding + hand-shims, is exactly the
non-native feel we are leaving: @import("std") there is stubbed, not real.
The fork. Clone ziglang/zig at the pinned 0.16 tag; build it with a stock
same-version zig (zig build in the tree — a standard, LLVM-pulling, roughly one-time
build); point danos's build.zig/CI at the resulting binary. Four localised patches:
- add
danostostd.Target.Os.Tag, in the "no version range" group alongside plan9/serenity; - add
danosto the freestanding/other no-op_startlist instd'sstart.zig, so std does not emit its own System-V_start— danos keeps owning the entry shim andInit/argv construction it already builds (sysv.md); - wire the
systemselector.danos => std.os.danosinstd.posix; - add
std/os/danos.zig— the seam itself, promoted near-verbatim from theruntime.osdeveloped first in Phase 1 (against the stock toolchain, so the fork is not a prerequisite for starting).
This is the fork treadmill we accept once. Keep the patch set tiny and else-friendly,
pin to one 0.16.x, and rebase on point releases.
Phase 1 — runtime.os read-side + allocator + stdio + cwd; retire posix
Author runtime.os (→ std.os.danos): the errno enum, the constant tables, and
the C-convention read / write / open / openat / close / lseek / mmap / munmap / exit, each returning result-or--errno. Most backing already exists (VFS + native
mmap + clock).
- Provide
page_allocatorviaroot.os.heap.page_allocator(a thin override over danosmmap). This sits outside thestd.Iovtable, so it is wired separately. - Wire fd 0/1/2 to a console byte stream (today output only reaches
debug_write; input is structuredInputEventIPC — a byte tty is a new, small thing in both directions). - Add a
getcwd/chdiranchor sostd.fs.cwd()-style resolution has something to resolve against. - Build
runtime.fsoverruntime.os; migrate the fiveposixcallers to it; deletelibrary/posix/and drop its build module.
After Phase 1, the surface an editor or terminal needs (open/read/write/close/lseek/ readdir/isatty/args/exit) exists. Those are downstream and out of scope here.
Phase 2 — Filesystem mutation + real stat (the compiler's cache tower)
danos's biggest genuine gap, and the correctness-critical one:
- Add mkdir / unlink / rename / truncate to both the VFS wire protocol
(vfs-protocol.zig) and the FAT engine
(engine.zig), then expose them via
runtime.os. - Extend
statbeyond{size, kind}to carry mtime + inode + mode —std's file stat needs them for build-cache validity — which in turn needs wall-clock time (danos is monotonic-only today; an RTC/time service is the dependency).
Because std.fs/std.Io have no per-OS branches, finishing this in runtime.os
lights up the whole file tower for the compiler at once. Environment can stay an empty
map until the kernel populates a non-empty envp.
Status — Phase 2 complete. truncate (O_TRUNC, closing the boot-log stale-tail
bug), mkdir, unlink, and rename are all wired through the FAT engine, the VFS
protocol + router, and runtime.fs (makeDirectory / remove / rename) —
host-tested and QEMU-tested (fat-mutations + fat-rename make a directory, write+read
a file in it, rename it, then remove it through the mount). removeFile and rename
are LFN-aware; rename is same-directory + 8.3 (cross-directory and long-name-
preserving rename are noted limitations). Wall-clock is now a kernel syscall
(wall_clock, a CMOS-RTC read anchored to the monotonic clock), and the FAT engine
stamps and reports mtime — stat / runtime.fs.Attributes carry a real
modification time (the fat-mtime case reads it back within seconds of the host clock).
The remaining stat fields, mode/inode, are deferred (not needed until the
compiler's cache layer wants them). Everything past here is gated on Phase 0 (the
fork): the runtime.os seam, cwd, stdio-as-fds, and the compiler bring-up.
Phase 3 — Single-threaded, self-linked compiler bring-up
Build the compiler with two load-bearing flags:
-fsingle-threadedremovesstd.Threadentirely —Thread.spawnis a hard compile error under it, andstd.Io's threaded backend runs inline. The unwrittenstd.Threadseam is therefore not a blocker (danos has native threads now —thread_spawn/futex — but the seam need not cover them yet). Parallel codegen is a throughput optimisation, not a correctness requirement.-fno-llvm -fno-lldkeeps codegen and linking in-process (the self-hosted x86-64 backend + self-linker), so a singlebuild-exenever forks a child. That is what lets us defer the entire spawn/exec/wait surface.
Then supply the few remaining seam pieces: now (wrap the danos clock), an entropy
source behind vtable.random (randomSecure can alias it initially — low volume, for
temp-file names and hashmap seeds), and the Phase-2 mkdir/rename/unlink for cache dir
trees and atomic temp-then-rename output.
Explicitly deferred (not on the build-exe path): child-process spawn/exec (only
zig build and external tools need it), std.Thread, fsync (FAT is write-through
today), symlinks, and musl.
Risks and gotchas
- The std-fork rebase treadmill is the main ongoing cost. A new OS tag touches the
same broad file set plan9/serenity touch (hundreds of
native_ossites, plus "unsupported OS"@compileErrordead-ends a new tag must be routed around), and the entirestd.Iolayer is new in 0.16 and still moving. Stay pinned to one 0.16.x, keep additions localised andelse-friendly. Watch the closed-enum gotcha: addingdanostoOs.Tagcan break existing exhaustive switches that lack anelse, so expect to touch switch sites beyond the ones you implement. - Single-threaded is load-bearing. The "no
std.Thread" simplification rests entirely on-fsingle-threaded. If a dependency or flag flips threading back on, you inherit an unescapable compile error (no root-hook exists) — the only outs are a full thread-impl fork or linking libc for pthreads. Keepsingle_threadedasserted end to end. - In-process linking is load-bearing. Reaching the compiler without fork/exec
depends on
-fno-llvm -fno-lld. The moment you shell out to LLD/ld, you need the fullspawn/waitsurface — the hardest microkernel piece — and danos'ssystem_spawnonly starts a named ramdisk binary, not exec of an arbitrary path. Verify the self-hosted backend covers the target output before assuming child processes are optional. - The shim cannot host the compiler. danos's current
runtimeis fine for danos's own native programs, but the compilerimports upstreamstd, which on a non-target hits the voidsystemstub. So the compiler forces the real target (Phase 0's fork). Do not over-invest in extending the hand-shim for compiler purposes; put that effort intoruntime.os+ the VFS/FAT operations, which both the fork and a future musl consume. "w"/O_CREATnot truncating was a silent-corruption bug on this road — fixed in Phase 2. The FAT engine'swriteFileonly growsnode.size, so overwriting a shorter file used to leave trailing garbage — for a compiler that means corrupt.o/cache files that look like nondeterministic compiler bugs.engine.truncate- the O_TRUNC open flag (wired through the VFS protocol and
runtime.fs) closed this before the compiler ever writes cache.
- the O_TRUNC open flag (wired through the VFS protocol and
- Exit status is categorical, not numeric.
process_exit_reasonreturns anExitReasoncategory, not a numeric code (WEXITSTATUS). Fine while spawn is stubbed; the dayzig buildor external tools arrive, plan a kernel exit-record extension — do not let it surprise you.
How this informs decisions now
Two current decisions fall out of this roadmap:
- The
runtime.fs/std.Ioquestion resolves at the vtable seam. Because 0.16 routes all output through thestd.Iovtable's file-write entry, and stdout/stderr are justFiles with well-known handles, buildruntime.fs(and the console stdout) as the concrete backing for that entry — not as a bespokestd.Io.Writer-only shim. Decide it once, at the seam, and program stdout, diagnostics, and file writes all flow through the same danos VFS/console path. - The boot-log
truncatecaveat is now fixed (Phase 2a). It was the samewriteFile-only-grows gap that on the self-hosting road would corrupt build output;engine.truncate+ an O_TRUNC open flag now free the old chain so a shorter rewrite leaves no stale tail, and the boot-log flush opens with it.
Related
- vision.md — the north star this serves.
- syscall.md — the kernel↔runtime ABI
runtime.osis built on. - sysv.md — the entry stack (
argc/argv/envp/auxv) danos already constructs. - ipc.md — the IPC the VFS/FAT operations travel over.
- file-system-hierarchy.md — the filesystem layout the file surface serves.
- coding-standards.md — danos naming (why the compat spellings are confined, and now retired).