354 lines
21 KiB
Markdown
354 lines
21 KiB
Markdown
# 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](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 `syscall`
|
|
interface so that stock `x86_64-linux` binaries 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.fs` is essentially gone.** It is now path helpers plus deprecated aliases;
|
|
there is no `std.fs.File`, `std.fs.Dir`, or `std.fs.cwd()`. File and directory
|
|
work goes through **`std.Io`** — a single runtime **vtable** (`Io.zig`) of
|
|
function pointers handed to `main` as `std.process.Init.io`. `std.Io.File` and
|
|
`std.Io.Dir` are thin forwarders to that vtable. `Io.zig` and the `fs` shim carry
|
|
**zero** per-OS branches.
|
|
- **`std.posix` is one generic body** parameterised over a single `system` module.
|
|
With no libc, `system` resolves **per target OS**: `.linux => std.os.linux`,
|
|
`.plan9 => std.os.plan9`, and so on. The generic `std.posix.read`/`write`/`open`
|
|
bodies are just `system.read(...)` plus an errno switch — *identical for every
|
|
OS*. The only variable is what `system` binds to.
|
|
- **`std.os.<tag>`** (e.g. `std/os/linux.zig`) is therefore the real porting seam: a
|
|
low-level, C-ABI-shaped module of `read/write/open/close/lseek/mmap/clock/exit/…`
|
|
plus an `errno` enum 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](../README.md)): the
|
|
kernel exposes a minimal syscall ABI ([syscall.md](os-development/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 mirror `std/os/linux.zig`.** This is the load-bearing, *non-throwaway* artifact:
|
|
when we fork Zig, `runtime.os` is copy-pasted (near-verbatim) into `std.os.danos`.
|
|
- **`runtime.fs` — the thin native file API** danos programs use *today*, layered
|
|
over `runtime.os`. It is also the concrete backing for the `std.Io` vtable'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](../system/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](os-development/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 `danos` to `std.Target.Os.Tag`, in the "no version range" group alongside
|
|
plan9/serenity;
|
|
- add `danos` to the freestanding/other **no-op `_start` list** in `std`'s `start.zig`,
|
|
so std does *not* emit its own System-V `_start` — danos keeps owning the entry shim
|
|
and `Init`/argv construction it already builds ([sysv.md](os-development/sysv.md));
|
|
- wire the `system` selector `.danos => std.os.danos` in `std.posix`;
|
|
- add `std/os/danos.zig` — **the seam itself**, promoted near-verbatim from the
|
|
`runtime.os` developed 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_allocator` via `root.os.heap.page_allocator` (a thin override over
|
|
danos `mmap`). This sits **outside** the `std.Io` vtable, so it is wired separately.
|
|
- Wire fd 0/1/2 to a console **byte** stream (today output only reaches `debug_write`;
|
|
input is structured `InputEvent` IPC — a byte tty is a new, small thing in both
|
|
directions).
|
|
- Add a `getcwd`/`chdir` anchor so `std.fs.cwd()`-style resolution has something to
|
|
resolve against.
|
|
- Build `runtime.fs` over `runtime.os`; migrate the five `posix` callers to it; delete
|
|
`library/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](../library/protocol/vfs/vfs-protocol.zig)) and the FAT engine
|
|
([engine.zig](../system/services/fat/engine.zig)), then expose them via `runtime.os`.
|
|
- Extend `stat` beyond `{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-threaded`** removes `std.Thread` entirely — `Thread.spawn` is a hard
|
|
compile error under it, and `std.Io`'s threaded backend runs inline. The unwritten
|
|
`std.Thread` seam 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-lld`** keeps codegen and linking **in-process** (the self-hosted
|
|
x86-64 backend + self-linker), so a single `build-exe` **never 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_os` sites, plus
|
|
"unsupported OS" `@compileError` dead-ends a new tag must be routed around), and the
|
|
entire `std.Io` layer is new in 0.16 and still moving. Stay pinned to one 0.16.x,
|
|
keep additions localised and `else`-friendly. Watch the closed-enum gotcha: adding
|
|
`danos` to `Os.Tag` can break existing *exhaustive* switches that lack an `else`, 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. Keep `single_threaded` asserted 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
|
|
full `spawn`/`wait` surface — the hardest microkernel piece — and danos's
|
|
`system_spawn` only 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 `runtime` is fine for
|
|
danos's *own* native programs, but the compiler `import`s *upstream* `std`, which on
|
|
a non-target hits the void `system` stub. 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 into `runtime.os` + the VFS/FAT operations, which both the
|
|
fork *and* a future musl consume.
|
|
- **`"w"`/`O_CREAT` not truncating was a silent-corruption bug on this road — fixed in
|
|
Phase 2.** The FAT engine's `writeFile` only *grows* `node.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.
|
|
- **Exit status is categorical, not numeric.** `process_exit_reason` returns an
|
|
`ExitReason` *category*, not a numeric code (`WEXITSTATUS`). Fine while spawn is
|
|
stubbed; the day `zig build` or 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:
|
|
|
|
1. **The `runtime.fs` / `std.Io` question resolves at the vtable seam.** Because 0.16
|
|
routes *all* output through the `std.Io` vtable's file-write entry, and stdout/stderr
|
|
are just `File`s with well-known handles, build `runtime.fs` (and the console stdout)
|
|
as the concrete backing for that entry — not as a bespoke `std.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.
|
|
2. **The boot-log `truncate` caveat is now fixed** (Phase 2a). It was the same
|
|
`writeFile`-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](vision.md) — the north star this serves.
|
|
- [syscall.md](os-development/syscall.md) — the kernel↔runtime ABI `runtime.os` is built on.
|
|
- [sysv.md](os-development/sysv.md) — the entry stack (`argc/argv/envp/auxv`) danos already constructs.
|
|
- [ipc.md](device-driver-development/ipc.md) — the IPC the VFS/FAT operations travel over.
|
|
- [file-system-hierarchy.md](file-system-development/file-system-hierarchy.md) — the
|
|
filesystem layout the file surface serves.
|
|
- [coding-standards.md](coding-standards.md) — danos naming (why the compat spellings
|
|
are confined, and now retired).
|