Make zig-out a FHS image, and the boot volume

`zig build` now installs into a FHS-shaped zig-out that *is* the danos filesystem
and the boot volume — no more zig-out/bin or a separate esp/:

  zig-out/EFI/BOOT/BOOTX64.efi        (firmware entry; UEFI fixes this path)
  zig-out/boot/initial-ramdisk.img
  zig-out/system/kernel               (the kernel binary)
  zig-out/system/services/init  vfs
  zig-out/system/drivers/hpet   bus

Binaries land at their addressed, leaf-collapsed paths per the sub-project
resolution rule (system/services/init/init.zig -> system/services/init); vfs, hpet,
and bus are installed to their FHS homes too, so the image is complete even though
at boot they arrive inside the initial-ramdisk.

The bootloader (boot/efi.zig) now loads each artifact from its FHS path
(system\kernel, system\services\init, boot\initial-ramdisk.img); run-x86-64 mounts
zig-out directly; the QEMU test harness assembles its ESP from the FHS zig-out.

Also renames system/kernel/main.zig -> kernel.zig so the kernel follows the
name/name.zig convention (kernel/ = ring-0 code, services/ = ring-3 OS services).
Documents the resolution rule in the repository-layout section (README + coding
standard). Suite 35/35 plus host tests green.
This commit is contained in:
Daniel Samson 2026-07-10 13:57:02 +01:00
parent ceacc6b514
commit 3d1de37d0e
No known key found for this signature in database
GPG Key ID: A9EB2589C60F7268
10 changed files with 103 additions and 62 deletions

View File

@ -30,8 +30,10 @@ channels. See [`docs/`](docs/README.md) for how each piece works.
zig build
```
Produces the UEFI bootloader (`zig-out/bin/BOOTX64.efi`) and the kernel ELF
(`zig-out/bin/kernel`).
Produces a FHS-shaped `zig-out/` that *is* the danos filesystem and the boot volume:
the UEFI bootloader at `zig-out/EFI/BOOT/BOOTX64.efi`, the kernel at
`zig-out/system/kernel`, init at `zig-out/system/services/init`, drivers under
`zig-out/system/drivers/`, and the initial-ramdisk at `zig-out/boot/`.
## Run

View File

@ -7,16 +7,18 @@ const GraphicsOutput = uefi.protocol.GraphicsOutput;
const EdidActive = uefi.protocol.edid.Active;
const MemoryMapSlice = uefi.tables.MemoryMapSlice;
/// Name of the kernel ELF on the boot volume (installed to the ESP root by
/// build.zig). UEFI wants a UTF-16, null-terminated path.
const kernel_file_name = std.unicode.utf8ToUtf16LeStringLiteral("kernel");
// The boot volume is the FHS-shaped zig-out (see build.zig / docs/README.md), so the
// loader reads each artifact from its addressed FHS path. UEFI paths use backslashes;
// the FAT driver walks the components itself, so no per-directory dance is needed.
/// Path of the init program on the boot volume (UEFI paths use backslashes;
/// the FAT driver walks the components itself, so no directory dance needed).
const init_file_name = std.unicode.utf8ToUtf16LeStringLiteral("sbin\\init");
/// The kernel image: /system/kernel.
const kernel_file_name = std.unicode.utf8ToUtf16LeStringLiteral("system\\kernel");
/// Path of the initial_ramdisk image on the boot volume (the VFS server + drivers).
const initial_ramdisk_file_name = std.unicode.utf8ToUtf16LeStringLiteral("initial-ramdisk.img");
/// The init program: /system/services/init.
const init_file_name = std.unicode.utf8ToUtf16LeStringLiteral("system\\services\\init");
/// The initial-ramdisk (the VFS server + drivers), in /boot.
const initial_ramdisk_file_name = std.unicode.utf8ToUtf16LeStringLiteral("boot\\initial-ramdisk.img");
/// Physical page size, and the sentinel UEFI uses to seek to end-of-file.
const page_size = 4096;

View File

@ -202,7 +202,7 @@ pub fn build(b: *std.Build) void {
const exe = b.addExecutable(.{
.name = "kernel",
.root_module = b.createModule(.{
.root_source_file = b.path("system/kernel/main.zig"),
.root_source_file = b.path("system/kernel/kernel.zig"),
.target = kernel_target,
.optimize = optimize,
.code_model = .kernel, // kernel runs in the top 2 GiB (higher half)
@ -233,14 +233,21 @@ pub fn build(b: *std.Build) void {
// (.text at 1 MiB), which the loader allocates and copies into.
exe.image_base = 0xFFFFFFFF80100000;
b.installArtifact(exe);
// Everything installs into a FHS-shaped zig-out: it IS the danos filesystem *and*
// the boot volume. Each binary lands at its addressed, leaf-collapsed path the
// kernel at zig-out/system/kernel (from system/kernel/kernel.zig), init at
// zig-out/system/services/init, and so on (see docs/README.md). The bootloader
// then loads these FHS paths off the volume.
const kernel_install = b.addInstallArtifact(exe, .{ .dest_dir = .{ .override = .{ .custom = "system" } } });
b.getInstallStep().dependOn(&kernel_install.step);
// --- /sbin/init: the first user-space program ---
// --- init: the first user-space program (a system service) ---
// Built by the shared user-binary recipe (see addUserBinary): freestanding,
// linked into the kernel's user region against the `runtime` runtime library, and
// started in ring 3 by the kernel's user-ELF loader.
const init_exe = addUserBinary(b, kernel_target, runtime_module, posix_module, "init", "system/services/init/init.zig");
b.installArtifact(init_exe);
const init_install = b.addInstallArtifact(init_exe, .{ .dest_dir = .{ .override = .{ .custom = "system/services" } } });
b.getInstallStep().dependOn(&init_install.step);
// --- initial_ramdisk: a bundle of extra user binaries (VFS server + drivers) ---
// Each is built by the same user-binary recipe, then packed into one image by
@ -266,9 +273,19 @@ pub fn build(b: *std.Build) void {
mk_run.addArg("bus");
mk_run.addFileArg(bus_exe.getEmittedBin());
// Install the image to zig-out/bin (so the QEMU test harness picks it up like
// the other binaries). The run-x86-64 ESP install is added below.
const initial_ramdisk_install = b.addInstallFile(initial_ramdisk_img, "bin/initial-ramdisk.img");
// Also install the packed binaries to their FHS homes, so zig-out is a true image
// of the filesystem even though at boot they arrive inside the initial-ramdisk.
for ([_]struct { *std.Build.Step.Compile, []const u8 }{
.{ vfs_exe, "system/services" },
.{ hpet_exe, "system/drivers" },
.{ bus_exe, "system/drivers" },
}) |entry| {
const step = b.addInstallArtifact(entry[0], .{ .dest_dir = .{ .override = .{ .custom = entry[1] } } });
b.getInstallStep().dependOn(&step.step);
}
// The initial-ramdisk itself installs to /boot (with the loaders).
const initial_ramdisk_install = b.addInstallFile(initial_ramdisk_img, "boot/initial-ramdisk.img");
b.getInstallStep().dependOn(&initial_ramdisk_install.step);
// Boot methods live in boot/, one per way of getting the kernel running.
@ -289,7 +306,10 @@ pub fn build(b: *std.Build) void {
}),
});
b.installArtifact(efiexe);
// UEFI firmware requires the removable-media loader at exactly \EFI\BOOT\BOOTX64.efi,
// so that path is fixed by the firmware (it is /boot's EFI stub, conceptually).
const efi_install = b.addInstallArtifact(efiexe, .{ .dest_dir = .{ .override = .{ .custom = "EFI/BOOT" } } });
b.getInstallStep().dependOn(&efi_install.step);
// --- run-x86-64: boot the x86-64 kernel in QEMU via UEFI/OVMF ---
// Firmware lives in different places per OS/distro, so probe the known
@ -320,21 +340,8 @@ pub fn build(b: *std.Build) void {
"/usr/local/share/qemu/edk2-i386-vars.fd", // macOS Homebrew (Intel)
});
// Assemble an EFI System Partition layout: esp/EFI/BOOT/BOOTX64.efi
const efi_install = b.addInstallArtifact(efiexe, .{
.dest_dir = .{ .override = .{ .custom = "esp/EFI/BOOT" } },
});
// The bootloader loads the kernel by name from the volume root, so drop the
// kernel ELF at esp/kernel.
const kernel_install = b.addInstallArtifact(exe, .{
.dest_dir = .{ .override = .{ .custom = "esp" } },
});
// The bootloader loads init from sbin/init on the same volume.
const init_install = b.addInstallArtifact(init_exe, .{
.dest_dir = .{ .override = .{ .custom = "esp/sbin" } },
});
// ...and the initial_ramdisk (VFS server + drivers) from the volume root.
const initial_ramdisk_esp_install = b.addInstallFile(initial_ramdisk_img, "esp/initial-ramdisk.img");
// The FHS zig-out (installed above) *is* the boot volume no separate ESP to
// assemble. QEMU presents it to the guest as a FAT drive below.
// The firmware needs to write NVRAM, so give it a writable copy of the vars.
const vars_copy = b.addSystemCommand(&.{ "cp", "-f", ovmf_vars });
@ -351,10 +358,10 @@ pub fn build(b: *std.Build) void {
});
run_efi.addArg("-drive");
run_efi.addPrefixedFileArg("if=pflash,format=raw,file=", vars_out);
// Present the ESP directory to the guest as a FAT drive.
// Present the FHS zig-out to the guest as a FAT drive it is the boot volume.
run_efi.addArgs(&.{
"-drive",
b.fmt("format=raw,file=fat:rw:{s}/esp", .{b.install_path}),
b.fmt("format=raw,file=fat:rw:{s}", .{b.install_path}),
"-net",
"none",
// Emulated display advertising 1280x720 as its native (EDID preferred)
@ -369,10 +376,8 @@ pub fn build(b: *std.Build) void {
// timestamped file under zig-out, so each run leaves its own log behind.
const serial_log = b.fmt("{s}/run-x86-64-serial0-{s}.log", .{ b.install_path, timestamp(b) });
run_efi.addArgs(&.{ "-serial", b.fmt("file:{s}", .{serial_log}) });
run_efi.step.dependOn(&efi_install.step);
run_efi.step.dependOn(&kernel_install.step);
run_efi.step.dependOn(&init_install.step);
run_efi.step.dependOn(&initial_ramdisk_esp_install.step);
// The whole FHS zig-out must be installed before we mount it.
run_efi.step.dependOn(b.getInstallStep());
const run_efi_step = b.step("run-x86-64", "Boot the x86-64 kernel in QEMU (UEFI/OVMF); serial0 is logged to zig-out/run-x86-64-serial0-<timestamp>.log");
run_efi_step.dependOn(&run_efi.step);

View File

@ -125,6 +125,25 @@ reach it *by module name*, never by a path into its files. The source tree delib
what you see under `system/` in the source is what a running danos represents under
`/system`.
**A sub-project is addressed by its directory; its entry point repeats the directory's
name.** `system/services/init/` contains `init.zig` (its root), and produces a binary
addressed as **`system/services/init`** — the repeated leaf resolves away:
| Source (root file) | Addressed as (module / binary / FHS path) |
|----------------------------------------|--------------------------------------------|
| `system/services/init/init.zig` | `system/services/init``/system/services/init` |
| `system/drivers/hpet/hpet.zig` | `system/drivers/hpet``/system/drivers/hpet` |
| `library/runtime/runtime.zig` | `library/runtime` (the `runtime` module) |
In **source**, a sub-project is a directory so it can hold many files — the entry is
`init/init.zig`, beside it `vfs/vfs-test.zig`, `vfs/protocol.zig`, and so on. When
**addressed or installed**, that collapses to the single canonical path: the `init`
binary installs to `/system/services/init` (a file at that path), not
`/system/services/init/init`. The repeated leaf exists only in source; the directory is
the identity, the entry file is its implementation. (Same idea as a Go package being its
directory, or a macOS `.app` bundle addressed by the bundle, not the executable within.)
A sub-project's extra files are reached through the module, never as separate paths.
```
system/ → /system danos's own internals (the self-representation)
danos.zig the kernel↔user ABI contract (the `danos` module)
@ -157,7 +176,7 @@ appears in the private-ABI path.
| Area | Code |
|------|------|
| Boot methods (one per way of booting the kernel) | `boot/``efi.zig` (UEFI) → `BOOTX64.efi` |
| Kernel entry, panic, bring-up | `system/kernel/main.zig` |
| Kernel entry, panic, bring-up | `system/kernel/kernel.zig` |
| Shared loader↔kernel contract (`BootInfo`, `Framebuffer`, `MemoryMap`, `Syscall`, ABI) | `system/danos.zig` |
| Physical frame allocator | `system/kernel/pmm.zig` |
| Kernel heap (`std.mem.Allocator`) | `system/kernel/heap.zig` |

View File

@ -137,6 +137,11 @@ single word or acronym needs no hyphen: `scheduler.zig`, `paging.zig`, `apic.zig
`idt.zig`. (The module *alias* a file is imported under still follows the code
conventions above — `snake_case` — because it's an identifier, not a filename.)
**A sub-project's entry point repeats its directory's name** — `init/init.zig`,
`runtime/runtime.zig`, `hpet/hpet.zig` — and the sub-project is addressed by the
*directory* (`system/services/init`, `library/runtime`), with the repeated leaf
resolving away. See the repository-layout section of [README.md](README.md).
## Why acronyms are the line
Because an acronym has no letters to restore. `MMIO` doesn't become "memory mapped

View File

@ -20,14 +20,17 @@ UEFI boots by looking for a FAT-formatted partition called the **EFI System
Partition (ESP)** and running a file at a well-known fallback path:
```
esp/EFI/BOOT/BOOTX64.efi <- the "removable media" default for x86-64
EFI/BOOT/BOOTX64.efi <- the "removable media" default for x86-64
```
That's exactly the layout `build.zig` assembles. It builds `boot/efi.zig` for the
`uefi` target, installs it to `esp/EFI/BOOT/BOOTX64.efi`, and drops the kernel ELF
at `esp/kernel`. The `run-x86-64` step then points QEMU at OVMF (UEFI firmware for
virtual machines) and presents that `esp/` directory to the guest as a FAT drive.
The firmware finds `BOOTX64.efi` and runs it — that's our `main()`.
The boot volume is the **FHS-shaped `zig-out`** itself (see the repository-layout note
in [README.md](README.md)): `build.zig` installs `boot/efi.zig` (built for the `uefi`
target) to `zig-out/EFI/BOOT/BOOTX64.efi` — the one path UEFI firmware fixes — and lays
the rest out by FHS path: the kernel at `zig-out/system/kernel`, init at
`zig-out/system/services/init`, the initial-ramdisk at `zig-out/boot/`. The
`run-x86-64` step points QEMU at OVMF (UEFI firmware for virtual machines) and presents
`zig-out` to the guest as a FAT drive. The firmware finds `BOOTX64.efi` and runs it —
that's our `main()`, which then loads the kernel and init from their FHS paths.
## Boot services: the firmware's API
@ -164,13 +167,13 @@ the loader writes are the bytes the kernel reads.
```
power on
-> UEFI firmware initialises hardware
-> finds esp/EFI/BOOT/BOOTX64.efi, runs it (our efi.zig main)
-> finds EFI/BOOT/BOOTX64.efi on the FHS volume, runs it (our efi.zig main)
-> grab boot services
-> queryFramebuffer (via GOP: EDID native res, setMode, describe fb)
-> loadKernel (read danos ELF, load PT_LOAD segments to 0x100000)
-> loadKernel (read system/kernel ELF, load PT_LOAD segments to 0x100000)
-> exitBootServices (retry until the memory-map key holds)
-> jump to e_entry, boot_info pointer in RDI
-> kernel _start (system/kernel/main.zig: framebuffer console, then halt)
-> kernel _start (system/kernel/kernel.zig: framebuffer console, then halt)
```
Bottom line: **UEFI's job is to give us a CPU, memory, and a framebuffer, then

View File

@ -83,7 +83,7 @@ treats the call:
signature for a kernel entry point — the bootloader jumps in and nothing ever
jumps back out.
You can see the chain in `system/kernel/main.zig`: `_start` is `noreturn`, it calls
You can see the chain in `system/kernel/kernel.zig`: `_start` is `noreturn`, it calls
`kmain` which is `noreturn`, which ends by calling `arch.halt()` which is
`noreturn`. The "never returns" property is threaded all the way down.

View File

@ -1,5 +1,5 @@
//! Shared definitions that form the contract between a bootloader
//! (boot/, e.g. efi.zig built as BOOTX64.efi) and the kernel (system/kernel/main.zig).
//! (boot/, e.g. efi.zig built as BOOTX64.efi) and the kernel (system/kernel/kernel.zig).
//!
//! Both binaries import this as the "danos" module, so the handoff layout is
//! defined in exactly one place.

View File

@ -55,11 +55,14 @@ ARCHES = {
"/opt/homebrew/share/qemu/edk2-i386-vars.fd", # macOS Homebrew (Apple Silicon)
"/usr/local/share/qemu/edk2-i386-vars.fd", # macOS Homebrew (Intel)
],
"efi_app": ("EFI/BOOT/BOOTX64.efi", "BOOTX64.efi"), # (dest in ESP, name in zig-out/bin)
"kernel": ("kernel", "kernel"),
# Further files shipped on the ESP: the init user program and the initial_ramdisk
# (VFS server + drivers), both copied from zig-out/bin.
"extra": [("sbin/init", "init"), ("initial-ramdisk.img", "initial-ramdisk.img")],
# zig-out is a FHS-shaped image and the boot volume; the harness copies the
# boot-critical files from their FHS paths into a fresh ESP with the same
# layout. (dest in ESP, source path under zig-out) — identical here.
"efi_app": ("EFI/BOOT/BOOTX64.efi", "EFI/BOOT/BOOTX64.efi"),
"kernel": ("system/kernel", "system/kernel"),
# The init user program and the initial-ramdisk (VFS server + drivers).
"extra": [("system/services/init", "system/services/init"),
("boot/initial-ramdisk.img", "boot/initial-ramdisk.img")],
# Built as a function so we can splice in per-run paths.
"qemu_args": lambda a, esp, vars_fd, serial: [
"-machine", "q35", "-m", "128M",
@ -237,14 +240,16 @@ def make_esp(arch):
esp = os.path.join(WORK, "esp")
if os.path.exists(esp):
shutil.rmtree(esp)
efi_dest, efi_name = arch["efi_app"]
kern_dest, kern_name = arch["kernel"]
efi_dest, efi_src = arch["efi_app"]
kern_dest, kern_src = arch["kernel"]
fhs = os.path.join(REPO, "zig-out") # zig-out is the FHS image
os.makedirs(os.path.join(esp, os.path.dirname(efi_dest)), exist_ok=True)
shutil.copy(os.path.join(REPO, "zig-out", "bin", efi_name), os.path.join(esp, efi_dest))
shutil.copy(os.path.join(REPO, "zig-out", "bin", kern_name), os.path.join(esp, kern_dest))
for dest, name in arch.get("extra", []):
os.makedirs(os.path.join(esp, os.path.dirname(kern_dest)), exist_ok=True)
shutil.copy(os.path.join(fhs, efi_src), os.path.join(esp, efi_dest))
shutil.copy(os.path.join(fhs, kern_src), os.path.join(esp, kern_dest))
for dest, src in arch.get("extra", []):
os.makedirs(os.path.join(esp, os.path.dirname(dest)), exist_ok=True)
shutil.copy(os.path.join(REPO, "zig-out", "bin", name), os.path.join(esp, dest))
shutil.copy(os.path.join(fhs, src), os.path.join(esp, dest))
return esp