M8: initrd handoff + multi-binary shipping

Ship more than one user binary: the bootloader now ferries an initrd bundle
(the VFS server + future drivers) alongside sbin/init, and the kernel unpacks
and spawns each program.

- src/user/proto/initrd.zig: the container format (Header{magic,count} +
  Entry{name[32],offset,len} + blobs) with a validating Reader, shared by the
  kernel and the packer.
- tools/mkinitrd.py: host-side packer (Python — trivial format, and sidesteps
  the reworked Zig 0.16 std fs/args API). build.zig runs it on the built user
  binaries via addSystemCommand and installs initrd.img to zig-out/bin + the
  ESP root.
- BootInfo gains initrd_base/initrd_len; efi.zig loadInit refactored into a
  shared loadFile, and loadInitrd ferries \initrd.img into surviving
  LoaderData like init.
- main.zig startInitrdBinaries(): parse the image, spawn every entry (kernel-
  spawns-all for now; init takes over via sys_spawn later).
- sbin/vfs.zig: a heartbeat stub (the real VFS server is M9), packed into the
  initrd to prove the pipeline.
- New `initrd` test: parse + spawn + confirm the vfs stub reaches ring 3 and
  heartbeats. Harness ships initrd.img on the ESP. Suite 30/30.
This commit is contained in:
Daniel Samson 2026-07-09 07:36:25 +01:00
parent eabb81684a
commit 750a73f050
No known key found for this signature in database
GPG Key ID: A9EB2589C60F7268
9 changed files with 272 additions and 10 deletions

View File

@ -147,6 +147,12 @@ pub fn build(b: *std.Build) void {
},
});
// The initrd container format, shared by the kernel (unpacks it) and the
// build-time packer tools/mkinitrd.zig (produces it). No dependencies.
const initrd_mod = b.addModule("initrd", .{
.root_source_file = b.path("src/user/proto/initrd.zig"),
});
// Compile-time config the kernel reads as `@import("build_options")`. The
// QEMU test harness sets -Dtest-case=<name> to run one self-test at boot.
const test_case = b.option([]const u8, "test-case", "Kernel self-test case to run at boot (see src/kernel/tests.zig)");
@ -182,6 +188,7 @@ pub fn build(b: *std.Build) void {
.{ .name = "platform", .module = platform_mod },
.{ .name = "config", .module = config_mod },
.{ .name = "build_options", .module = build_options_mod },
.{ .name = "initrd", .module = initrd_mod },
},
}),
});
@ -206,6 +213,26 @@ pub fn build(b: *std.Build) void {
const init_exe = addUserBinary(b, kernel_target, rt_mod, "init", "sbin/init.zig");
b.installArtifact(init_exe);
// --- initrd: a bundle of extra user binaries (VFS server + drivers) ---
// Each is built by the same user-binary recipe, then packed into one image by
// the host-side mkinitrd tool. The bootloader ferries the image to the kernel,
// which unpacks it and spawns each program (src/user/proto/initrd.zig).
const vfs_exe = addUserBinary(b, kernel_target, rt_mod, "vfs", "sbin/vfs.zig");
// Pack the user binaries into the initrd image with the host-side Python tool
// (the container format is trivial, and Python sidesteps std API churn). Args:
// mkinitrd.py <out> [<name> <file>]... one name/file pair per binary.
const mk_run = b.addSystemCommand(&.{"python3"});
mk_run.addFileArg(b.path("tools/mkinitrd.py"));
const initrd_img = mk_run.addOutputFileArg("initrd.img");
mk_run.addArg("vfs");
mk_run.addFileArg(vfs_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 initrd_install = b.addInstallFile(initrd_img, "bin/initrd.img");
b.getInstallStep().dependOn(&initrd_install.step);
// Boot methods live in src/boot/, one per way of getting the kernel running.
// Each is its own binary/entry (a loader is built for its own target); today
// that's UEFI for x86-64, with room for e.g. a device-tree path for the Pis.
@ -268,6 +295,8 @@ pub fn build(b: *std.Build) void {
const init_install = b.addInstallArtifact(init_exe, .{
.dest_dir = .{ .override = .{ .custom = "esp/sbin" } },
});
// ...and the initrd (VFS server + drivers) from the volume root.
const initrd_esp_install = b.addInstallFile(initrd_img, "esp/initrd.img");
// The firmware needs to write NVRAM, so give it a writable copy of the vars.
const vars_copy = b.addSystemCommand(&.{ "cp", "-f", ovmf_vars });
@ -305,6 +334,7 @@ pub fn build(b: *std.Build) void {
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(&initrd_esp_install.step);
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);

18
sbin/vfs.zig Normal file
View File

@ -0,0 +1,18 @@
//! /sbin/vfs the user-space VFS server. Shipped in the initrd and spawned as a
//! ring-3 process. This is a placeholder that only heartbeats, proving the initrd
//! pipeline ships and spawns it; the real path namespace + IPC dispatch loop
//! (open/read/write/stat forwarded to driver processes) is built in M9.
const rt = @import("rt");
pub fn main() void {
while (true) {
_ = rt.sys.write("vfs: alive\n");
rt.sys.sleep(1000);
}
}
pub const panic = rt.panic;
comptime {
_ = &rt.start._start;
}

View File

@ -15,6 +15,9 @@ const kernel_file_name = std.unicode.utf8ToUtf16LeStringLiteral("kernel");
/// the FAT driver walks the components itself, so no directory dance needed).
const init_file_name = std.unicode.utf8ToUtf16LeStringLiteral("sbin\\init");
/// Path of the initrd image on the boot volume (the VFS server + drivers).
const initrd_file_name = std.unicode.utf8ToUtf16LeStringLiteral("initrd.img");
/// Physical page size, and the sentinel UEFI uses to seek to end-of-file.
const page_size = 4096;
const seek_end = 0xffff_ffff_ffff_ffff;
@ -65,6 +68,13 @@ fn boot() !noreturn {
log(") - booting without user space\r\n");
};
// Best effort: the initrd (VFS server + drivers) is optional too.
loadInitrd(bs, &boot_info) catch |err| {
log("danos: no initrd (");
logBytes(@errorName(err));
log(")\r\n");
};
// Build the page tables the kernel starts life on: identity + a physmap of
// low RAM, plus the higher-half kernel image once it links high. Allocated
// now, while boot services (and the memory map) are still stable nothing
@ -344,12 +354,11 @@ fn handoff(cr3: u64, entry: usize, boot_info: *const BootInfo) noreturn {
unreachable;
}
/// Read the init program (sbin/init) into memory that outlives the loader and
/// record it in the handoff. The pool buffer is deliberately NOT freed: it's
/// LoaderData, which the memory-map conversion classifies as reserved, so the
/// kernel identity-maps it and reads the ELF from there. The kernel does the
/// loading itself (into ring-3 mappings) the loader just ferries the bytes.
fn loadInit(bs: *uefi.tables.BootServices, boot_info: *BootInfo) !void {
/// Read a whole file off the boot volume into a pool buffer that outlives the
/// loader. The buffer is deliberately NOT freed: it's LoaderData, which the
/// memory-map conversion classifies as reserved, so the kernel identity-maps it
/// and reads from there. Returns the buffer (pointer + length).
fn loadFile(bs: *uefi.tables.BootServices, name: [*:0]const u16) ![]u8 {
const loaded = (try bs.handleProtocol(uefi.protocol.LoadedImage, uefi.handle)) orelse
return error.NoLoadedImage;
const device = loaded.device_handle orelse return error.NoBootDevice;
@ -359,7 +368,7 @@ fn loadInit(bs: *uefi.tables.BootServices, boot_info: *BootInfo) !void {
const root = try fs.openVolume();
defer _ = root.close() catch {};
const file = try root.open(init_file_name, .read, .{});
const file = try root.open(name, .read, .{});
defer _ = file.close() catch {};
try file.setPosition(seek_end);
@ -375,12 +384,26 @@ fn loadInit(bs: *uefi.tables.BootServices, boot_info: *BootInfo) !void {
if (n == 0) return error.UnexpectedEof;
read_total += n;
}
return image[0..size];
}
/// Ferry the init program (sbin/init) to the kernel. The kernel does the ELF
/// loading itself (into ring-3 mappings) the loader just carries the bytes.
fn loadInit(bs: *uefi.tables.BootServices, boot_info: *BootInfo) !void {
const image = try loadFile(bs, init_file_name);
boot_info.init_base = @intFromPtr(image.ptr);
boot_info.init_len = size;
boot_info.init_len = image.len;
log("danos: sbin/init loaded\r\n");
}
/// Ferry the initrd (the VFS server + drivers) to the kernel, same as init.
fn loadInitrd(bs: *uefi.tables.BootServices, boot_info: *BootInfo) !void {
const image = try loadFile(bs, initrd_file_name);
boot_info.initrd_base = @intFromPtr(image.ptr);
boot_info.initrd_len = image.len;
log("danos: initrd loaded\r\n");
}
/// Validate the ELF, copy every PT_LOAD segment to its physical address, and
/// record each segment's layout so the kernel can re-map itself with the right
/// permissions.

View File

@ -8,6 +8,7 @@ const pmm = @import("pmm.zig");
const heap = @import("heap.zig");
const scheduler = @import("scheduler.zig");
const process = @import("process.zig");
const initrd = @import("initrd");
const platform = @import("platform");
const tests = @import("tests.zig");
const build_options = @import("build_options");
@ -270,6 +271,11 @@ fn kmain(boot_info: *const BootInfo) noreturn {
status("no /sbin/init on the boot volume.\n");
}
// Spawn the extra user binaries the loader ferried in the initrd (the VFS
// server, and later device drivers). For now the kernel launches them all;
// once init is a real service supervisor it will spawn them itself (sys_spawn).
startInitrdBinaries(boot_info);
// Become the idle task: drop below every real task and halt until an
// interrupt. The timer keeps preempting into init and any other work.
scheduler.setPriority(0);
@ -277,6 +283,26 @@ fn kmain(boot_info: *const BootInfo) noreturn {
arch.halt();
}
/// Spawn every program bundled in the initrd as its own ring-3 process. A bad
/// image or a program that fails to load is logged and skipped the rest of the
/// system still runs.
fn startInitrdBinaries(boot_info: *const danos.BootInfo) void {
if (boot_info.initrd_len == 0) return;
const image = @as([*]const u8, @ptrFromInt(danos.physToVirt(boot_info.initrd_base)))[0..boot_info.initrd_len];
const rd = initrd.Reader.init(image) orelse {
status("initrd: bad image, skipping\n");
return;
};
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
statusPrint("starting /sbin/{s} (from initrd)...\n", .{item.name});
process.spawnProcess(item.blob, 4) catch |err| {
statusPrint("initrd: {s} failed to load: {s}\n", .{ item.name, @errorName(err) });
};
}
}
/// Wake the application processors the firmware left parked. Allocates the low
/// trampoline page (and makes it executable), then wakes each non-boot core in turn,
/// handing it a fresh kernel stack and its per-CPU slot. Cores that don't report in

View File

@ -19,6 +19,7 @@ const sched = @import("scheduler.zig");
const ipc = @import("ipc.zig");
const ipcsync = @import("ipc_sync.zig");
const process = @import("process.zig");
const initrd = @import("initrd");
/// Formatted write straight to serial, independent of the framebuffer console.
fn log(comptime fmt: []const u8, args: anytype) void {
@ -104,6 +105,8 @@ pub fn run(case: []const u8, boot_info: *const BootInfo) void {
initTest(boot_info);
} else if (eql(case, "process")) {
processTest(boot_info);
} else if (eql(case, "initrd")) {
initrdTest(boot_info);
} else if (eql(case, "poweroff")) {
powerTest(.off);
} else if (eql(case, "reboot")) {
@ -936,6 +939,51 @@ fn initTest(boot_info: *const BootInfo) void {
result();
}
/// The initrd path: the bootloader handed over an image bundling extra user
/// binaries; parse it, spawn every program, and confirm one (the vfs stub)
/// reaches ring 3 and heartbeats proving the whole ferry-parse-spawn pipeline.
fn initrdTest(boot_info: *const BootInfo) void {
log("DANOS-TEST-BEGIN: initrd\n", .{});
check("bootloader handed over an initrd", boot_info.initrd_len != 0);
if (boot_info.initrd_len == 0) {
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(danos.physToVirt(boot_info.initrd_base)))[0..boot_info.initrd_len];
const rd = initrd.Reader.init(image) orelse {
check("initrd image is valid", false);
result();
return;
};
check("initrd image is valid", true);
check("initrd contains at least one binary", rd.count >= 1);
process.write_count = 0;
process.write_from_user = false;
var spawned: u32 = 0;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (process.spawnProcess(item.blob, 4)) spawned += 1 else |err| {
log("DANOS-INITRD-ERR: {s}: {s}\n", .{ item.name, @errorName(err) });
}
}
check("every initrd binary spawned", spawned == rd.count);
// Wait for the spawned program(s) to heartbeat (the vfs stub sleeps ~1 s).
sched.setPriority(1);
const deadline = arch.millis() + 8000;
while (process.write_count < 2 and arch.millis() < deadline) sched.yield();
sched.setPriority(4);
const prefix = "vfs: alive";
const beat_ok = process.write_len >= prefix.len and eql(process.write_buf[0..prefix.len], prefix);
check("an initrd process heartbeats (>=2)", process.write_count >= 2);
check("heartbeat text arrived intact", beat_ok);
check("heartbeats came from user mode (CPL 3)", process.write_from_user);
result();
}
fn faultInvalidOpcode() void {
log("DANOS-TEST-BEGIN: fault-ud\n", .{});
asm volatile ("ud2");

View File

@ -182,4 +182,9 @@ pub const BootInfo = extern struct {
/// kernel boots without user space. Grows into a full initrd handoff later.
init_base: u64 = 0,
init_len: u64 = 0,
/// The initrd image (a bundle of extra user binaries the VFS server and
/// device drivers), read off the boot volume into memory that survives the
/// handoff, same as `init` above. 0/0 = no initrd. See src/user/proto/initrd.zig.
initrd_base: u64 = 0,
initrd_len: u64 = 0,
};

58
src/user/proto/initrd.zig Normal file
View File

@ -0,0 +1,58 @@
//! The initrd (initial ramdisk) container format shared by the build-time
//! packer (tools/mkinitrd.zig) and the kernel that unpacks it. Deliberately
//! trivial: a header, a table of fixed-size entries, then the concatenated file
//! blobs. We own both producer and consumer, so it need be no fancier.
//!
//! Layout:
//! Header (magic, count)
//! Entry * count (name, offset, len) offset/len into the image
//! blob bytes... (each entry's file, at its offset)
const std = @import("std");
/// "DNRD" identifies a danos initrd image.
pub const magic: u32 = 0x444E5244;
pub const Header = extern struct {
magic: u32,
count: u32,
};
pub const Entry = extern struct {
name: [32]u8, // NUL-padded file name (basename)
offset: u64, // byte offset of the blob within the image
len: u64, // blob length in bytes
};
/// A validated view over an initrd image. `init` checks the magic and that the
/// entry table fits; `entry` bounds-checks each blob against the image.
pub const Reader = struct {
image: []const u8,
count: u32,
pub fn init(image: []const u8) ?Reader {
if (image.len < @sizeOf(Header)) return null;
const h = std.mem.bytesToValue(Header, image[0..@sizeOf(Header)]);
if (h.magic != magic) return null;
const table_end = @sizeOf(Header) + @as(usize, h.count) * @sizeOf(Entry);
if (table_end > image.len) return null;
return .{ .image = image, .count = h.count };
}
pub const Item = struct { name: []const u8, blob: []const u8 };
pub fn entry(self: Reader, i: u32) ?Item {
if (i >= self.count) return null;
const off = @sizeOf(Header) + @as(usize, i) * @sizeOf(Entry);
const e = std.mem.bytesToValue(Entry, self.image[off..][0..@sizeOf(Entry)]);
if (e.offset > self.image.len or e.len > self.image.len - e.offset) return null;
// The name is stored in the entry's fixed field; return a stable slice
// into the image (not the value copy) up to the NUL terminator.
const name_field = self.image[off .. off + 32];
const nlen = std.mem.indexOfScalar(u8, name_field, 0) orelse name_field.len;
return .{
.name = name_field[0..nlen],
.blob = self.image[@intCast(e.offset)..][0..@intCast(e.len)],
};
}
};

View File

@ -57,8 +57,9 @@ ARCHES = {
],
"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.
"extra": [("sbin/init", "init")],
# Further files shipped on the ESP: the init user program and the initrd
# (VFS server + drivers), both copied from zig-out/bin.
"extra": [("sbin/init", "init"), ("initrd.img", "initrd.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",
@ -177,6 +178,11 @@ CASES = [
"smp": 4,
"expect": r"DANOS-TEST-RESULT: PASS",
"fail": r"DANOS-TEST-RESULT: FAIL"},
# The initrd: the loader ferries a bundle of user binaries; the kernel parses
# it and spawns each as a ring-3 process (here the VFS-server stub heartbeats).
{"name": "initrd",
"expect": r"DANOS-TEST-RESULT: PASS",
"fail": r"DANOS-TEST-RESULT: FAIL"},
# The ACPI power path succeeds by QEMU *exiting* (S5 off / reset), so match the
# pre-transition marker; the FAIL line only appears if the transition didn't take.
{"name": "poweroff",

48
tools/mkinitrd.py Normal file
View File

@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Build-time initrd packer. Concatenates user binaries into one image the
bootloader ferries to the kernel.
Usage: mkinitrd.py <out.img> [<name> <file>]...
Image layout (little-endian), mirroring src/user/proto/initrd.zig:
Header : magic u32 ("DNRD"=0x444E5244), count u32
Entry*N : name [32]u8 (NUL-padded), offset u64, len u64
blobs : each entry's file bytes at its offset
"""
import struct
import sys
MAGIC = 0x444E5244
HEADER = struct.Struct("<II") # magic, count
ENTRY = struct.Struct("<32sQQ") # name[32], offset, len
def main() -> int:
out_path = sys.argv[1]
rest = sys.argv[2:]
if len(rest) % 2 != 0:
sys.stderr.write("usage: mkinitrd.py <out.img> [<name> <file>]...\n")
return 2
items = [(rest[i], rest[i + 1]) for i in range(0, len(rest), 2)]
table_end = HEADER.size + len(items) * ENTRY.size
entries = b""
blobs = []
off = table_end
for name, path in items:
with open(path, "rb") as f:
data = f.read()
entries += ENTRY.pack(name.encode()[:31], off, len(data))
blobs.append(data)
off += len(data)
with open(out_path, "wb") as f:
f.write(HEADER.pack(MAGIC, len(items)))
f.write(entries)
for b in blobs:
f.write(b)
return 0
if __name__ == "__main__":
sys.exit(main())