danos/docs/os-development/system-image.md

6.6 KiB
Raw Blame History

system.img — the boot capsule

What it is

boot/system.img is the boot capsule: every bundled user binary — init, the services, the drivers, the test programs — packed into one file on the boot volume. It is not a filesystem image and it is not compressed; it is exactly the kernel's initial-ramdisk wire format (system/initial-ramdisk.zig, format v2), written to disk ahead of time. The EFI loader reads it in a single sequential pass and hands the bytes to the kernel unmodified.

The capsule is a performance artifact, not a source of truth. The boot volume's /system and /test file trees remain the canonical layout (see file-system-hierarchy.md); the capsule is a pre-baked snapshot of the same binaries, derived from the same build graph, so the running system is identical whether the loader read the capsule or walked the tree.

Why it exists

Firmware file I/O has exactly one fast shape: one open + one sequential read. Everything else is a lottery. Loading the system per-file — dozens of opens, seeks, and short reads through the firmware's FAT driver — measured minutes on real hardware, against milliseconds in QEMU/OVMF. Packing the binaries into a single file turns the whole of user space into the shape firmware is good at.

Because the capsule already is the ramdisk wire format, the loader doesn't even repack it: loadCapsule (boot/efi.zig) validates the magic and passes the buffer straight through as BootInformation.initial_ramdisk_base/len.

The format

The container is deliberately trivial — danos owns both producer and consumer, so it need be no fancier. Little-endian throughout:

Header        magic: u32 = "DNR2" (0x32524E44), count: u32
Entry × count name: [64]u8 (NUL-padded hierarchy path), offset: u64, len: u64
blobs...      each entry's file bytes, at its offset within the image
  • Names are full hierarchy paths (/system/services/init), not basenames — that is what "v2" means. The 64-byte capacity matches abi.maximum_process_name, so a task named after its binary path is never truncated. Paths longer than 63 bytes are a build error (pack-system-image.py rejects them).
  • The v1 magic ("DNRD", basename entries) is rejected, not tolerated: a stale image should fail loudly at Reader.init, not misparse names.
  • initial_ramdisk.Reader is the one validated view over the bytes — magic check, table bounds, per-blob bounds — used by the kernel and shared with the loader. Reader.find resolves a binary by exact path first, then by unique basename, ASCII case-insensitively (the entries come from a FAT volume, whose name lookups are case-insensitive by definition).

How it is built

build.zig maintains one bundled list — every user binary and its hierarchy home. Three artifacts are derived from that same list, in the same build graph, so they cannot drift apart:

  1. The tree: each binary installed at its hierarchy path (zig-out/system/... and zig-out/test/..., mirrored onto the FAT boot volume by tools/make-fat-image.py).
  2. The manifest (system/manifest): the hierarchy path of every bundled binary, one per line — the loader's per-file fallback input.
  3. The capsule: tools/pack-system-image.py packs the same binaries into the v2 container, installed at zig-out/boot/system.img and placed on the boot volume at boot/system.img.

Note what the capsule does not contain: the kernel (system/kernel is loaded separately by loadKernel, as an ELF) and the EFI loader itself. It is user space only.

How it is loaded

loadSystemTree (boot/efi.zig) tries three strategies, most portable first — the running system cannot tell which one ran, because all three produce the same in-RAM ramdisk image:

  1. The capsule — open boot\system.img, read it whole, check the magic, hand it over as-is. The normal path on any build-produced volume.
  2. The manifest — read system\manifest and open each listed path by name. FAT name lookup is case-insensitive and firmware-portable, unlike directory enumeration. The loader assembles the v2 image in RAM itself.
  3. The tree walk — enumerate /system and /test recursively (/test is optional: a stick without fixtures still boots). Last resort for hand-assembled sticks with neither file: some firmware FAT drivers return bare 8.3 names uppercase from enumeration, which is why this is the fallback and not the primary path.

All three are best-effort: a kernel-only volume still boots — the kernel just has no user binaries to spawn and reports the absence.

One operational consequence of the ordering: the capsule shadows the tree. If you hand-edit binaries on a stick that also carries a boot/system.img, your edits are invisible — the loader boots the capsule's snapshot. Delete boot/system.img from the volume to force the manifest/tree path.

What the kernel does with it

The loader records the image's physical base and length in BootInformation; the kernel (kernel.zig) then publishes the same bytes twice, to two consumers:

  • The process layer (process.zig): system_spawn looks binaries up in the ramdisk via Reader.find — exact hierarchy path, or unique basename for pre-path callers — and loads them as fresh ring-3 processes. The stored path becomes the task's name.
  • The VFS root (vfs.zig, setInitialRamdisk): the image is mounted as kernel-backed, read-only mounts — one per top-level tree named by the entry paths, so /system and, when the fixtures are bundled, /test. Directory nodes are derived from the entry paths (the unique parents), so the trees are listable and their files readable over the normal VFS protocol — the boot tree every process sees comes straight out of the capsule bytes.

The image is never copied after the handoff and never mutated: the initrd is immutable, which is what makes the VFS's node serving lock-free.

What it is not

  • Not danos-usb.img. That is the 64 MiB FAT32 boot volume built by tools/make-fat-image.py — the thing a machine actually boots, which contains boot/system.img alongside the loader, kernel, manifest, and tree. See efi.md and release-iso.md.
  • Not a mountable filesystem. No FAT, no block device, no driver — just a header, a table, and concatenated blobs, parsed by ~90 lines of initial-ramdisk.zig.
  • Not required. It is the fast path, with two slower equivalents behind it.
  • Not a place where state lives. It is regenerated on every build from the bundled binaries; nothing writes to it, at build time or runtime.