danos/build.zig

1242 lines
71 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const std = @import("std");
const builtin = @import("builtin");
/// danos is developed against Zig 0.16.x. Pre-1.0 Zig makes breaking API changes
/// between minor releases, and the .zon's `minimum_zig_version` only enforces a
/// floor — so reject anything off the 0.16 line to keep the build reproducible.
fn ensureZigVersion() void {
const v = builtin.zig_version;
if (v.major != 0 or v.minor != 16) {
std.debug.print(
"danos requires Zig 0.16.x, but this is {d}.{d}.{d}. " ++
"Zig makes breaking changes between minor releases pre-1.0.\n",
.{ v.major, v.minor, v.patch },
);
std.process.exit(1);
}
}
/// Return the first path in `candidates` that exists on the build host, else the
/// first candidate as a fallback so a missing-firmware error still names a
/// concrete (and, by convention, the primary) path. Used to locate OVMF firmware
/// across distro/OS layouts without configuration.
fn firstExisting(io: std.Io, candidates: []const []const u8) []const u8 {
for (candidates) |path| {
std.Io.Dir.accessAbsolute(io, path, .{}) catch continue;
return path;
}
return candidates[0];
}
/// A UTC timestamp like "20260708-153045", for naming a per-run artifact so
/// repeated runs don't clobber each other's logs. Resolved when `build.zig` runs
/// (i.e. at `zig build` invocation), which is moments before QEMU launches.
fn timestamp(b: *std.Build) []const u8 {
const ns = std.Io.Clock.now(.real, b.graph.io).nanoseconds;
const secs: u64 = @intCast(@divFloor(ns, std.time.ns_per_s));
const es = std.time.epoch.EpochSeconds{ .secs = secs };
const yd = es.getEpochDay().calculateYearDay();
const md = yd.calculateMonthDay();
const ds = es.getDaySeconds();
return b.fmt("{d:0>4}{d:0>2}{d:0>2}-{d:0>2}{d:0>2}{d:0>2}", .{
yd.year,
md.month.numeric(),
@as(u32, md.day_index) + 1,
ds.getHoursIntoDay(),
ds.getMinutesIntoHour(),
ds.getSecondsIntoMinute(),
});
}
/// Build one user-space binary the same way for every program (init, and later
/// the VFS server + drivers): freestanding, ReleaseSmall, `.large` code model
/// (the image base is above 4 GiB — smaller models emit 32-bit relocations that
/// can't reach), linked against the `runtime` runtime library with the shared user
/// link script. Pinned to LLVM + LLD so the script's PHDRS (segment permissions)
/// are authoritative — the kernel's W^X user-ELF loader requires exact perms.
///
/// The compilation root is not the program's own file but the shared shim
/// library/kernel/root.zig, which supplies the root declarations (`main`
/// re-export, panic handler, `_start` pull) so a program only defines
/// `pub fn main`. The program's file becomes the `program` module the shim
/// imports; reach it through `programModule` to add per-binary imports.
fn addUserBinary(
b: *std.Build,
target: std.Build.ResolvedTarget,
default_imports: []const std.Build.Module.Import,
name: []const u8,
root: []const u8,
) *std.Build.Step.Compile {
return addUserBinaryImpl(b, target, default_imports, name, root, false);
}
/// As `addUserBinary`, but built multi-threaded (`single_threaded = false`) so real
/// atomics/TLS work — required before a binary may call `Thread.spawn`
/// (docs/threading.md). Threads are a deliberate per-binary opt-in.
fn addThreadedUserBinary(
b: *std.Build,
target: std.Build.ResolvedTarget,
default_imports: []const std.Build.Module.Import,
name: []const u8,
root: []const u8,
) *std.Build.Step.Compile {
return addUserBinaryImpl(b, target, default_imports, name, root, true);
}
/// The module registered under `name` in `imports` — the root shim reaches the
/// couple of concern modules it needs (start, logging) out of the default set.
fn findImport(imports: []const std.Build.Module.Import, name: []const u8) *std.Build.Module {
for (imports) |import| {
if (std.mem.eql(u8, import.name, name)) return import.module;
}
@panic("default_imports is missing a module the root shim needs");
}
fn addUserBinaryImpl(
b: *std.Build,
target: std.Build.ResolvedTarget,
default_imports: []const std.Build.Module.Import,
name: []const u8,
root: []const u8,
threaded: bool,
) *std.Build.Step.Compile {
// Every user binary gets the same default set of importable modules — the library/kernel
// concern modules (ipc, memory, process, time, logging, file-system, ...), the device/
// service clients (driver, block, display, input), mmio, acpi-ids, and xkeyboard-config.
// Per-binary extras go through programModule(exe).addImport. Settings (target, optimize,
// code model, ...) live on the root module only; the program module inherits them.
const program_module = b.createModule(.{
.root_source_file = b.path(root),
.imports = default_imports,
});
const exe = b.addExecutable(.{
.name = name,
.root_module = b.createModule(.{
.root_source_file = b.path("library/kernel/root.zig"),
.target = target,
.optimize = .ReleaseSmall,
.code_model = .large,
.single_threaded = !threaded, // a threaded binary needs real atomics/TLS
.sanitize_c = .off,
.stack_check = false,
.stack_protector = false,
// The root shim itself imports only start (_start + panic) and logging
// (std_options); the program's own file reaches the full default set.
.imports = &.{
.{ .name = "start", .module = findImport(default_imports, "start") },
.{ .name = "logging", .module = findImport(default_imports, "logging") },
.{ .name = "program", .module = program_module },
},
}),
});
exe.setLinkerScript(b.path("library/kernel/user.ld"));
exe.entry = .{ .symbol_name = "_start" };
exe.image_base = 0x7000_0000_0000;
exe.use_llvm = true;
exe.use_lld = true;
return exe;
}
/// The `program` module of a binary built by `addUserBinary` — the module rooted
/// at the program's own source file. Per-binary imports (protocol modules, bus
/// ABIs) go here, not on the root shim: module imports are not transitive, so an
/// import added to the root would be invisible to the program's code.
fn programModule(exe: *std.Build.Step.Compile) *std.Build.Module {
return exe.root_module.import_table.get("program").?;
}
/// The modules the kernel imports, gathered once so both kernel variants (the
/// installed one and the serial-enabled one `run-x86-64` boots) are built from
/// the same set. `build_options` is *not* here — it carries `serial`/`test_case`,
/// which differ per variant, so `addKernel` builds it fresh each time.
const KernelModules = struct {
boot_handoff: *std.Build.Module,
abi: *std.Build.Module,
device_abi: *std.Build.Module,
architecture: *std.Build.Module,
platform: *std.Build.Module,
parameters: *std.Build.Module,
initial_ramdisk: *std.Build.Module,
};
/// Build the freestanding x86_64 kernel ELF. Factored so we can build it twice
/// from one recipe: the installed/flashable image (serial off by default) and the
/// serial-enabled variant `run-x86-64` boots — they differ only in the `serial`
/// build option baked into `build_options`.
fn addKernel(
b: *std.Build,
kernel_target: std.Build.ResolvedTarget,
optimize: std.builtin.OptimizeMode,
modules: KernelModules,
test_case: ?[]const u8,
serial: bool,
) *std.Build.Step.Compile {
// Compile-time configuration the kernel reads as `@import("build_options")`:
// the QEMU harness's -Dtest-case, and whether the serial log sink is compiled
// in (see the -Dserial option). Built per variant since `serial` differs.
const build_options = b.addOptions();
build_options.addOption(?[]const u8, "test_case", test_case);
build_options.addOption(bool, "serial", serial);
const build_options_module = build_options.createModule();
const exe = b.addExecutable(.{
.name = "kernel",
.root_module = b.createModule(.{
.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)
.red_zone = false, // interrupts would corrupt the SystemV red zone
.single_threaded = false, // SMP: the big kernel lock's atomics must be real across cores
.sanitize_c = .off, // the UBSan runtime needs f128/SSE support we don't provide
.stack_check = false, // stack-probe calls have no runtime to land in
.stack_protector = false,
.strip = optimize != .Debug, // DWARF info doubles the flashable image; keep it only for debug builds
.imports = &.{
.{ .name = "boot-handoff", .module = modules.boot_handoff },
.{ .name = "abi", .module = modules.abi },
.{ .name = "device-abi", .module = modules.device_abi },
.{ .name = "architecture", .module = modules.architecture },
.{ .name = "platform", .module = modules.platform },
.{ .name = "parameters", .module = modules.parameters },
.{ .name = "build_options", .module = build_options_module },
.{ .name = "initial-ramdisk", .module = modules.initial_ramdisk },
},
}),
});
exe.setLinkerScript(b.path("system/kernel/architecture/x86_64/linker.ld"));
exe.entry = .{ .symbol_name = "_start" };
// The self-hosted linker ignores parts of the linker script (PHDRS,
// /DISCARD/, AT(), section order); the higher-half layout depends on the
// script being authoritative, so pin the kernel to LLVM + LLD.
exe.use_llvm = true;
exe.use_lld = true;
// Higher-half virtual base (matches KERNEL_VIRT_BASE in linker.ld); the
// linker's AT() clauses give each segment a low physical load address
// (.text at 1 MiB), which the loader allocates and copies into.
exe.image_base = 0xFFFFFFFF80100000;
return exe;
}
/// One user binary and its FHS home on the boot volume (and in zig-out).
const BundledBinary = struct { path: []const u8, binary: std.Build.LazyPath };
/// Assemble the bootable FAT32 image (the in-repo Python builder) holding the
/// EFI stub, the kernel, and every user binary at its FHS path — the volume's
/// /system tree IS the system image; the EFI loader walks it at boot and builds
/// the in-RAM initial_ramdisk from it. Factored so the serial-enabled
/// `run-x86-64` variant can bundle its own serial kernel while sharing the
/// loader and user tree (the loader's boot breadcrumbs and init's heartbeat both
/// follow the top-level -Dserial). Returns the image's LazyPath.
fn addBootImage(
b: *std.Build,
kernel_bin: std.Build.LazyPath,
efi_bin: std.Build.LazyPath,
manifest: std.Build.LazyPath,
capsule: std.Build.LazyPath,
bundled: []const BundledBinary,
) std.Build.LazyPath {
const mk_fat = b.addSystemCommand(&.{"python3"});
mk_fat.addFileArg(b.path("tools/make-fat-image.py"));
const fat_image = mk_fat.addOutputFileArg("danos-usb.img");
mk_fat.addArg("64"); // MiB
mk_fat.addArg("EFI/BOOT/BOOTX64.efi");
mk_fat.addFileArg(efi_bin);
mk_fat.addArg("system/kernel");
mk_fat.addFileArg(kernel_bin);
mk_fat.addArg("system/manifest");
mk_fat.addFileArg(manifest);
mk_fat.addArg("boot/system.img");
mk_fat.addFileArg(capsule);
for (bundled) |item| {
mk_fat.addArg(item.path);
mk_fat.addFileArg(item.binary);
}
return fat_image;
}
pub fn build(b: *std.Build) void {
ensureZigVersion();
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// The three shared contracts, each with its own audience so every import
// declares which one it speaks (no target is set, so each inherits the target of
// whichever binary imports it). See docs/coding-standards.md.
// boot-handoff : loader <-> kernel (BootInformation, framebuffer, VM layout)
// abi : kernel <-> runtime, core (SystemCall, mmap prot flags, page_size)
// device-abi : kernel <-> user, devices (DeviceDescriptor, DeviceClass, ...)
const boot_handoff_module = b.addModule("boot-handoff", .{
.root_source_file = b.path("system/boot-handoff.zig"),
});
const abi_module = b.addModule("abi", .{
.root_source_file = b.path("system/abi.zig"),
});
// The devices sub-project's public interface (the flat wire types), exposed as
// its own module like vfs-protocol — importable by user space, unlike the
// kernel-internal device model it also feeds (system/kernel/device-model.zig).
const device_abi_module = b.addModule("device-abi", .{
.root_source_file = b.path("library/device/model/device-abi.zig"),
});
// PCI class-code decoding (class/subclass/prog-IF -> names). Pure reference data,
// shared by kernel discovery (the device-tree dump) and any user-space PCI tool.
const pci_class_module = b.addModule("pci-class", .{
.root_source_file = b.path("library/device/pci/pci-class.zig"),
});
// Shared CSV helpers (comment stripping, field iteration) for the /etc/*.csv
// config files — the device registry and the init service list both parse them.
const csv_module = b.addModule("csv", .{
.root_source_file = b.path("library/csv/csv.zig"),
});
// The device registry: parse /etc/devices.csv into match rules and bind a
// reported device to a driver — the data-driven, authoritative replacement for
// the manager's hand-written switch tables. Pure logic (no hardware, no
// syscalls), so it unit-tests on the host; the manager imports it.
const device_registry_module = b.addModule("device-registry", .{
.root_source_file = b.path("library/device/registry/device-registry.zig"),
.imports = &.{.{ .name = "csv", .module = csv_module }},
});
// ACPI/PnP hardware-ID (_HID) names — the flat analog of pci-class for acpi_device
// nodes. Also shared reference data.
// The AML interpreter, a build module so the ring-3 acpi service can run the
// same parser the kernel does (docs/discovery.md — the shared AML module).
// Pure Zig, no kernel imports — one source, two builds.
const aml_module = b.addModule("aml", .{
.root_source_file = b.path("library/device/acpi/aml/aml.zig"),
});
const acpi_ids_module = b.addModule("acpi-ids", .{
.root_source_file = b.path("library/device/acpi/acpi-ids.zig"),
});
// The USB device-framework wire ABI (chapter-9 set-up packets, standard +
// class requests, descriptors) and the USB class-code taxonomy — the flat
// reference the xHCI bus driver, the USB class drivers, and the device
// manager's identity matcher all share. Pure data, like pci-class/acpi-ids.
const usb_abi_module = b.addModule("usb-abi", .{
.root_source_file = b.path("library/device/usb/usb-abi.zig"),
});
const usb_ids_module = b.addModule("usb-ids", .{
.root_source_file = b.path("library/device/usb/usb-ids.zig"),
});
// The USB transfer protocol: what a USB class driver says to the xHCI bus
// driver to drive its device (open / control / interrupt / bulk). A protocol
// module like vfs-protocol, shared by the bus driver and every class driver.
const usb_transfer_protocol_module = b.addModule("usb-transfer-protocol", .{
.root_source_file = b.path("library/protocol/usb-transfer/usb-transfer-protocol.zig"),
});
// The block-device protocol: read/write of fixed-size blocks, spoken between a
// filesystem and a block driver (usb-storage). A protocol module like the rest.
const block_protocol_module = b.addModule("block-protocol", .{
.root_source_file = b.path("library/protocol/block/block-protocol.zig"),
});
// Kernel tunables (maximum_cpus, stack sizes, tick rate). A dependency-free module of
// compile-time constants, imported wherever a knob is read; keeps the trade-offs
// in one place instead of scattered across the tree. See system/parameters.zig.
const parameters_module = b.addModule("parameters", .{
.root_source_file = b.path("system/parameters.zig"),
});
// Architecture-specific kernel code (CPU ops, entry, later GDT/IDT/paging).
// The generic kernel imports this as "architecture" and never names x86_64, so a new
// architecture is a matter of pointing this module at a different directory.
const architecture_module = b.addModule("architecture", .{
.root_source_file = b.path("system/kernel/architecture/x86_64/cpu.zig"),
.imports = &.{
.{ .name = "boot-handoff", .module = boot_handoff_module }, // paging uses BootInformation/memory-map + physicalToVirtual
.{ .name = "abi", .module = abi_module }, // paging works in page_size units
.{ .name = "parameters", .module = parameters_module }, // maximum_cpus, ist_stack_size, timer_hz
},
});
// CPU-exception stubs — real assembly, since they need cross-symbol
// jumps/calls that Zig inline asm can't express (see the file's header).
architecture_module.addAssemblyFile(b.path("system/kernel/architecture/x86_64/isr.s"));
// The AP bring-up trampoline: 16-/32-/64-bit mode-switch code that can't be
// inline asm (it runs relocated to a low page, not at its link address).
architecture_module.addAssemblyFile(b.path("system/kernel/architecture/x86_64/trampoline.s"));
// Firmware-agnostic device discovery. The generic kernel imports this as
// "platform" and asks it to enumerate hardware into a backend-neutral device
// tree, never naming ACPI (or, later, device-tree) — the same discipline the
// architecture module applies to CPU code. The backend is selected at runtime from
// the boot handoff (see system/kernel/platform.zig).
const platform_module = b.addModule("platform", .{
.root_source_file = b.path("system/kernel/platform.zig"),
.imports = &.{
.{ .name = "boot-handoff", .module = boot_handoff_module }, // BootInformation (carries the ACPI RSDP), physicalToVirtual
.{ .name = "abi", .module = abi_module }, // acpi.zig works in page_size units
.{ .name = "device-abi", .module = device_abi_module }, // device-model's DeviceClass/ResourceKind live here
.{ .name = "parameters", .module = parameters_module }, // maximum_cpus (the discovery pool)
},
});
// The VFS wire protocol: the vfs sub-project's public interface, exposed as its
// own module. Both the vfs server and the runtime's file layer (unistd/stdio)
// depend on this contract by name — neither reaches into the other's files. This
// is the first "protocol module" (see docs/driver-model.md); usb/block will
// expose theirs the same way.
const vfs_protocol_module = b.addModule("vfs-protocol", .{
.root_source_file = b.path("library/protocol/vfs/vfs-protocol.zig"),
});
// The input wire protocol: the input service's public interface, exposed as its own
// module the same way vfs-protocol is. Shared by the input service, the runtime's
// `input` helper (subscribe/publish), and every source and subscriber.
const input_protocol_module = b.addModule("input-protocol", .{
.root_source_file = b.path("library/protocol/input/input-protocol.zig"),
});
// The danos-native user-space runtime: system_call wrappers, the C-convention
// heap, IPC helpers, the process start shim, device access. This is the stable
// application ABI; POSIX compatibility is a separate library on top (see below).
// Compiled into every user binary (see addUserBinary), so it inherits each exe's
// `.large` code model — do NOT set a target/code_model here. It imports `abi`
// for the shared SystemCall numbers / mmap flags, `device-abi` for the device
// types its `device` helper wraps, and re-exports `vfs-protocol` for the VFS
// server. It never touches `boot-handoff` — user space has no business with the
// loader↔kernel handoff.
// Wire-protocol modules the kernel-library client wrappers (device-manager/block/display)
// and the services speak. The `runtime` module itself is defined below, after the
// library/kernel concern modules it shims over.
const device_manager_protocol_module = b.addModule("device-manager-protocol", .{
.root_source_file = b.path("library/protocol/device-manager/device-manager-protocol.zig"),
});
const display_protocol_module = b.addModule("display-protocol", .{
.root_source_file = b.path("library/protocol/display/display-protocol.zig"),
});
// The scanout protocol: the compositor's outbound present channel to a native scanout
// driver (virtio-gpu), separate from the client-facing display protocol (docs/display-v2.md).
// No runtime client speaks it — imported directly by the compositor and the scanout driver.
const scanout_protocol_module = b.addModule("scanout-protocol", .{
.root_source_file = b.path("library/protocol/scanout/scanout-protocol.zig"),
});
// The power protocol: system power's domain-named surface (docs/power.md). No runtime
// client speaks it — imported directly by init and the acpi discovery service.
const power_protocol_module = b.addModule("power-protocol", .{
.root_source_file = b.path("library/protocol/power/power-protocol.zig"),
});
// Typed volatile MMIO register access + memory-ordering barriers, for drivers on
// top of an mmio_map grant. Depends only on `builtin` (arch-conditional barriers);
// no target set, so it inherits each driver's. See library/device/mmio/mmio.zig.
const mmio_module = b.addModule("mmio", .{
.root_source_file = b.path("library/device/mmio/mmio.zig"),
});
// --- library/kernel: the userspace private-ABI library (kernel32-style), split by
// concern into directly-importable modules. `system.zig` (the old dumping ground) and
// `runtime.zig` (the old aggregator) are compatibility shims re-exporting these until the
// consumers migrate to direct imports (reorg C1C5). The graph is a DAG: memory depends on
// thread (heap needs Thread.Mutex), and thread does its own raw mmap so there is no cycle.
const system_call_module = b.addModule("system-call", .{
.root_source_file = b.path("library/kernel/system-call.zig"),
.imports = &.{.{ .name = "abi", .module = abi_module }},
});
const ipc_module = b.addModule("ipc", .{
.root_source_file = b.path("library/kernel/ipc.zig"),
.imports = &.{ .{ .name = "abi", .module = abi_module }, .{ .name = "system-call", .module = system_call_module } },
});
const time_module = b.addModule("time", .{
.root_source_file = b.path("library/kernel/time.zig"),
.imports = &.{.{ .name = "system-call", .module = system_call_module }},
});
const thread_module = b.addModule("thread", .{
.root_source_file = b.path("library/kernel/thread.zig"),
.imports = &.{ .{ .name = "abi", .module = abi_module }, .{ .name = "system-call", .module = system_call_module } },
});
const logging_module = b.addModule("logging", .{
.root_source_file = b.path("library/kernel/logging.zig"),
.imports = &.{ .{ .name = "abi", .module = abi_module }, .{ .name = "system-call", .module = system_call_module } },
});
const process_module = b.addModule("process", .{
.root_source_file = b.path("library/kernel/process.zig"),
.imports = &.{
.{ .name = "abi", .module = abi_module },
.{ .name = "system-call", .module = system_call_module },
.{ .name = "ipc", .module = ipc_module },
.{ .name = "time", .module = time_module },
},
});
const file_system_module = b.addModule("file-system", .{
.root_source_file = b.path("library/kernel/file-system.zig"),
.imports = &.{
.{ .name = "abi", .module = abi_module },
.{ .name = "system-call", .module = system_call_module },
.{ .name = "ipc", .module = ipc_module },
.{ .name = "vfs-protocol", .module = vfs_protocol_module },
},
});
const memory_module = b.addModule("memory", .{
.root_source_file = b.path("library/kernel/memory/memory.zig"),
.imports = &.{
.{ .name = "abi", .module = abi_module },
.{ .name = "system-call", .module = system_call_module },
.{ .name = "ipc", .module = ipc_module },
.{ .name = "thread", .module = thread_module },
},
});
const service_module = b.addModule("service", .{
.root_source_file = b.path("library/kernel/service.zig"),
.imports = &.{
.{ .name = "abi", .module = abi_module },
.{ .name = "ipc", .module = ipc_module },
.{ .name = "process", .module = process_module },
},
});
const start_module = b.addModule("start", .{
.root_source_file = b.path("library/kernel/start.zig"),
.imports = &.{ .{ .name = "process", .module = process_module }, .{ .name = "logging", .module = logging_module } },
});
// The driver author's interface (library/device/driver): device access + the
// device-manager hello handshake, folded together.
const driver_module = b.addModule("driver", .{
.root_source_file = b.path("library/device/driver/driver.zig"),
.imports = &.{
.{ .name = "abi", .module = abi_module },
.{ .name = "device-abi", .module = device_abi_module },
.{ .name = "system-call", .module = system_call_module },
.{ .name = "ipc", .module = ipc_module },
.{ .name = "time", .module = time_module },
.{ .name = "device-manager-protocol", .module = device_manager_protocol_module },
},
});
// The block-device client — a device type, so library/device/block.
const block_client_module = b.addModule("block", .{
.root_source_file = b.path("library/device/block/block.zig"),
.imports = &.{
.{ .name = "ipc", .module = ipc_module },
.{ .name = "time", .module = time_module },
.{ .name = "block-protocol", .module = block_protocol_module },
},
});
// Userspace-service clients live in library/client (they talk to services, not the kernel).
const display_client_module = b.addModule("display", .{
.root_source_file = b.path("library/client/display/display.zig"),
.imports = &.{
.{ .name = "ipc", .module = ipc_module },
.{ .name = "time", .module = time_module },
.{ .name = "display-protocol", .module = display_protocol_module },
},
});
const input_client_module = b.addModule("input", .{
.root_source_file = b.path("library/client/input/input.zig"),
.imports = &.{
.{ .name = "ipc", .module = ipc_module },
.{ .name = "time", .module = time_module },
.{ .name = "input-protocol", .module = input_protocol_module },
},
});
// A device driver's view of its claimed PCI function: config-space header fields, BAR
// decode + map, capability walks (legacy + extended), MSI/MSI-X programming, power
// state, and function-level reset (library/device/pci/pci.zig). The generic PCI
// mechanics every leaf PCI driver used to re-derive inline. Imports the driver (device
// access) client + mmio + the pci-class data module (config-space layout constants) +
// time (the D0 and FLR settle delays).
const pci_module = b.addModule("pci", .{
.root_source_file = b.path("library/device/pci/pci.zig"),
.imports = &.{
.{ .name = "driver", .module = driver_module },
.{ .name = "mmio", .module = mmio_module },
.{ .name = "pci-class", .module = pci_class_module },
.{ .name = "time", .module = time_module },
},
});
// The USB class-driver transfer client (library/device/usb/usb.zig): open a device on
// the xHCI bus and drive it (control / interrupt / bulk). Bus-family logic a class
// driver imports directly — over ipc + time. Re-exports usb-abi / usb-ids as
// usb.abi / usb.ids for a single USB import.
const usb_module = b.addModule("usb", .{
.root_source_file = b.path("library/device/usb/usb.zig"),
.imports = &.{
.{ .name = "ipc", .module = ipc_module },
.{ .name = "time", .module = time_module },
.{ .name = "usb-transfer-protocol", .module = usb_transfer_protocol_module },
.{ .name = "usb-abi", .module = usb_abi_module },
.{ .name = "usb-ids", .module = usb_ids_module },
},
});
// Keyboard layouts compiled from the X11 xkeyboard-config database into native Zig
// (keycode + modifiers -> keysym/character). The `layouts` tables are generated by
// tools/make-xkeyboard-config.py; `xkeyboard-config` is the hand-written API over them.
// No target set, so each inherits its importer's. See library/xkeyboard-config/.
const xkb_layouts_module = b.addModule("layouts", .{
.root_source_file = b.path("library/xkeyboard-config/generated/layouts.zig"),
});
const xkeyboard_config_module = b.addModule("xkeyboard-config", .{
.root_source_file = b.path("library/xkeyboard-config/xkeyboard-config.zig"),
.imports = &.{
.{ .name = "layouts", .module = xkb_layouts_module },
},
});
// The initial_ramdisk container format, shared by the kernel (unpacks it) and
// the EFI loader (packs it in RAM from the boot volume's /system tree). No
// dependencies.
const initial_ramdisk_module = b.addModule("initial-ramdisk", .{
.root_source_file = b.path("system/initial-ramdisk.zig"),
});
// Compile-time configuration 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 system/kernel/tests.zig)");
// The serial-console log sink. Off by default: a real machine often has no
// working legacy COM1, and the boot log is kept in RAM (klog) and flushed to
// disk instead — serial is now only a QEMU convenience. `run-x86-64` and the
// QEMU test harness (test/qemu_test.py, which asserts on serial markers) turn
// it on; a flashable `zig build` image leaves it out. See serial.zig.
const serial = b.option(bool, "serial", "Compile the serial-console log sink into the kernel (default: off; run-x86-64 and the test harness enable it)") orelse false;
// The diagnose boot: init skips the display service (and demo), so the
// on-screen boot transcript is never suppressed — the full timestamped
// timeline stays on the screen for real-hardware debugging by eye.
const diagnose = b.option(bool, "diagnose", "Boot without the display service so the timestamped boot transcript stays on screen (real-hardware debugging)") orelse false;
// --- Kernel: freestanding x86_64 ELF, jumped to by the bootloader ---
// SSE2 is part of the x86_64 baseline and UEFI leaves it enabled at handoff,
// so we keep it: disabling it forces soft-float and makes the compiler unable
// to encode the vector ops that std's formatting/runtime still emit.
const kernel_target = b.resolveTargetQuery(.{
.cpu_arch = .x86_64,
.os_tag = .freestanding,
.abi = .none,
});
const kernel_modules = KernelModules{
.boot_handoff = boot_handoff_module,
.abi = abi_module,
.device_abi = device_abi_module,
.architecture = architecture_module,
.platform = platform_module,
.parameters = parameters_module,
.initial_ramdisk = initial_ramdisk_module,
};
// The installed/flashable kernel: serial follows -Dserial (off by default).
const exe = addKernel(b, kernel_target, optimize, kernel_modules, test_case, serial);
// 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);
// --- init: the first user-space program (a system service) ---
// The default module set every user binary can import directly: the library/kernel
// concern modules, the device/service clients, mmio, the keyboard layouts, and the ACPI
// id registry. Per-binary extras are added with programModule(exe).addImport.
const default_imports = [_]std.Build.Module.Import{
.{ .name = "mmio", .module = mmio_module },
.{ .name = "xkeyboard-config", .module = xkeyboard_config_module },
.{ .name = "acpi-ids", .module = acpi_ids_module },
.{ .name = "system-call", .module = system_call_module },
.{ .name = "ipc", .module = ipc_module },
.{ .name = "memory", .module = memory_module },
.{ .name = "process", .module = process_module },
.{ .name = "thread", .module = thread_module },
.{ .name = "time", .module = time_module },
.{ .name = "logging", .module = logging_module },
.{ .name = "file-system", .module = file_system_module },
.{ .name = "service", .module = service_module },
.{ .name = "start", .module = start_module },
.{ .name = "driver", .module = driver_module },
.{ .name = "block", .module = block_client_module },
.{ .name = "display", .module = display_client_module },
.{ .name = "input", .module = input_client_module },
};
// Built by the shared user-binary recipe (see addUserBinary): freestanding,
// linked into the kernel's user region against the library/kernel modules, and
// started in ring 3 by the kernel's user-ELF loader.
const init_exe = addUserBinary(b, kernel_target, &default_imports, "init", "system/services/init/init.zig");
programModule(init_exe).addImport("power-protocol", power_protocol_module);
// init parses its boot service list from /etc/init.csv with the shared csv helpers.
programModule(init_exe).addImport("csv", csv_module);
// init reads the same `serial` flag the kernel does: its liveness heartbeat is a
// serial/test-build diagnostic (the QEMU harness's init tests assert on it, and
// -Dserial images emit it), so a flashable image runs a purely event-driven PID 1
// that wakes only for real work. The test harness builds with -Dserial=true, so
// the heartbeat stays present under test.
const init_options = b.addOptions();
init_options.addOption(bool, "serial", serial);
// Which services init starts is no longer a comptime option: it reads /etc/init.csv,
// and -Ddiagnose selects which init.csv is bundled (see the `bundled` list below).
programModule(init_exe).addImport("build_options", init_options.createModule());
// --- the rest of the boot tree: /system services and drivers, /test fixtures ---
// Each is built by the same user-binary recipe and laid out at its FHS path on
// the boot volume (see `bundled` below). The EFI loader walks the tree at boot
// and hands the kernel an in-RAM initial_ramdisk of it (system/initial-ramdisk.zig).
const vfstest_exe = addUserBinary(b, kernel_target, &default_imports, "vfs-test", "test/system/services/vfs-test/vfs-test.zig");
const ps2_bus_exe = addUserBinary(b, kernel_target, &default_imports, "ps2-bus", "system/drivers/ps2-bus/ps2-bus.zig");
const ps2_keyboard_exe = addUserBinary(b, kernel_target, &default_imports, "ps2-keyboard", "system/drivers/ps2-bus/keyboard.zig");
programModule(ps2_keyboard_exe).addImport("input-protocol", input_protocol_module);
const ps2_mouse_exe = addUserBinary(b, kernel_target, &default_imports, "ps2-mouse", "system/drivers/ps2-bus/mouse.zig");
programModule(ps2_mouse_exe).addImport("input-protocol", input_protocol_module);
const usb_xhci_bus_exe = addUserBinary(b, kernel_target, &default_imports, "usb-xhci-bus", "system/drivers/usb-xhci-bus/usb-xhci-bus.zig");
programModule(usb_xhci_bus_exe).addImport("device-manager-protocol", device_manager_protocol_module);
// MSI setup: the claimed-function view (enable bits + MSI capability programming).
programModule(usb_xhci_bus_exe).addImport("pci", pci_module);
// The xHCI bus driver builds chapter-9 requests and decodes descriptors from
// usb-abi, and reports each interface's (class,subclass,protocol) identity via
// usb-ids.packTriple.
programModule(usb_xhci_bus_exe).addImport("usb-abi", usb_abi_module);
programModule(usb_xhci_bus_exe).addImport("usb-ids", usb_ids_module);
programModule(usb_xhci_bus_exe).addImport("usb-transfer-protocol", usb_transfer_protocol_module);
// The USB HID class drivers: keyboard and mouse. They own no hardware — each
// opens its device through runtime.usb (the transfer protocol) and publishes to
// the input service. They build chapter-9 class requests from usb-abi.
const usb_hid_keyboard_exe = addUserBinary(b, kernel_target, &default_imports, "usb-hid-keyboard", "system/drivers/usb-hid/keyboard.zig");
programModule(usb_hid_keyboard_exe).addImport("usb", usb_module);
programModule(usb_hid_keyboard_exe).addImport("usb-abi", usb_abi_module);
programModule(usb_hid_keyboard_exe).addImport("input-protocol", input_protocol_module);
const usb_hid_mouse_exe = addUserBinary(b, kernel_target, &default_imports, "usb-hid-mouse", "system/drivers/usb-hid/mouse.zig");
programModule(usb_hid_mouse_exe).addImport("usb", usb_module);
programModule(usb_hid_mouse_exe).addImport("usb-abi", usb_abi_module);
programModule(usb_hid_mouse_exe).addImport("input-protocol", input_protocol_module);
// The USB mass-storage class driver: opens its device via runtime.usb, drives it
// with Bulk-Only Transport + SCSI, and serves the block protocol under `.block`.
const usb_storage_exe = addUserBinary(b, kernel_target, &default_imports, "usb-storage", "system/drivers/usb-storage/usb-storage.zig");
programModule(usb_storage_exe).addImport("usb", usb_module);
programModule(usb_storage_exe).addImport("block-protocol", block_protocol_module);
// The FAT filesystem server: mounts the block device and serves it into the VFS
// at /mnt/usb. Its engine (engine.zig / on-disk.zig) is imported relatively.
const fat_exe = addUserBinary(b, kernel_target, &default_imports, "fat", "system/services/fat/fat.zig");
programModule(fat_exe).addImport("vfs-protocol", vfs_protocol_module);
// Threaded: the display runs a mouse-listener thread alongside its compositor loop
// (docs/threading.md, docs/display.md), so it opts into real atomics/TLS.
const display_exe = addThreadedUserBinary(b, kernel_target, &default_imports, "display", "system/services/display/display.zig");
programModule(display_exe).addImport("display-protocol", display_protocol_module);
programModule(display_exe).addImport("scanout-protocol", scanout_protocol_module);
const display_demo_exe = addUserBinary(b, kernel_target, &default_imports, "display-demo", "system/services/display-demo/display-demo.zig");
const virtio_gpu_exe = addUserBinary(b, kernel_target, &default_imports, "virtio-gpu", "system/drivers/virtio-gpu/virtio-gpu.zig");
programModule(virtio_gpu_exe).addImport("pci", pci_module); // library/device/pci — the claimed-function view
programModule(virtio_gpu_exe).addImport("display-protocol", display_protocol_module);
programModule(virtio_gpu_exe).addImport("scanout-protocol", scanout_protocol_module);
const shared_memory_server_exe = addUserBinary(b, kernel_target, &default_imports, "shared-memory-server", "test/system/services/shared-memory-server/shared-memory-server.zig");
const shared_memory_client_exe = addUserBinary(b, kernel_target, &default_imports, "shared-memory-client", "test/system/services/shared-memory-client/shared-memory-client.zig");
const fat_test_exe = addUserBinary(b, kernel_target, &default_imports, "fat-test", "test/system/services/fat-test/fat-test.zig");
const pci_bus_exe = addUserBinary(b, kernel_target, &default_imports, "pci-bus", "system/drivers/pci-bus/pci-bus.zig");
programModule(pci_bus_exe).addImport("device-manager-protocol", device_manager_protocol_module);
// The PCI bus driver decodes each function's class triple to human names in its
// boot log (class/subclass/prog-IF), so pull in the shared pci-class reference.
programModule(pci_bus_exe).addImport("pci-class", pci_class_module);
// A test fixture, not a real driver: hellos to the device manager, then faults —
// what the driver-restart scenario drives the crash-loop cap with.
const crash_test_exe = addUserBinary(b, kernel_target, &default_imports, "crash-test", "test/system/services/crash-test/crash-test.zig");
programModule(crash_test_exe).addImport("device-manager-protocol", device_manager_protocol_module);
const device_list_exe = addUserBinary(b, kernel_target, &default_imports, "device-list", "test/system/services/device-list/device-list.zig");
programModule(device_list_exe).addImport("device-manager-protocol", device_manager_protocol_module);
// A test fixture: claims the pci-caps case's extra unclaimed NIC and exercises the
// driver-side PCI library surface (capabilities, MSI, MSI-X, power, FLR) against it.
const pci_cap_test_exe = addUserBinary(b, kernel_target, &default_imports, "pci-cap-test", "test/system/services/pci-cap-test/pci-cap-test.zig");
programModule(pci_cap_test_exe).addImport("pci", pci_module);
programModule(pci_cap_test_exe).addImport("pci-class", pci_class_module);
// The IOMMU-enforcement negative test: claims an unclaimed e1000e and fires a rogue
// DMA that VT-d must fault. Same PCI building blocks as pci-cap-test.
const iommu_fault_test_exe = addUserBinary(b, kernel_target, &default_imports, "iommu-fault-test", "test/system/services/iommu-fault-test/iommu-fault-test.zig");
programModule(iommu_fault_test_exe).addImport("pci", pci_module);
programModule(iommu_fault_test_exe).addImport("pci-class", pci_class_module);
// The discovery service: one swappable process per firmware
// (docs/discovery.md), bundled under the neutral ramdisk name
// "discovery" so the device manager never learns which firmware it is on.
// x86 boots describe hardware with ACPI; the Raspberry Pis hand over a
// flattened device tree — the aarch64 target flips the default when it
// lands (docs/arm.md). Both are placeholders until M20.1 (acpi) and the
// ARM bring-up (fdt).
const Discovery = enum { acpi, fdt };
const discovery = b.option(Discovery, "discovery", "Which discovery service fills the ramdisk's 'discovery' slot (default: acpi)") orelse Discovery.acpi;
const discovery_source: []const u8 = switch (discovery) {
.acpi => "system/services/acpi/acpi.zig",
.fdt => "system/services/fdt/fdt.zig",
};
const discovery_exe = addUserBinary(b, kernel_target, &default_imports, "discovery", discovery_source);
if (discovery == .acpi) programModule(discovery_exe).addImport("aml", aml_module);
if (discovery == .acpi) programModule(discovery_exe).addImport("device-manager-protocol", device_manager_protocol_module);
if (discovery == .acpi) programModule(discovery_exe).addImport("power-protocol", power_protocol_module);
const device_manager_exe = addUserBinary(b, kernel_target, &default_imports, "device-manager", "system/services/device-manager/device-manager.zig");
programModule(device_manager_exe).addImport("device-manager-protocol", device_manager_protocol_module);
// Driver matching is data-driven: the manager parses /etc/devices.csv into this
// module's rules and binds each reported device by most-specific match.
programModule(device_manager_exe).addImport("device-registry", device_registry_module);
// The input service and its exercisers: the fan-out server, a hardware-free synthetic
// source, and a subscriber that doubles as the `input` test's oracle. See docs/input.md.
const input_exe = addUserBinary(b, kernel_target, &default_imports, "input", "system/services/input/input.zig");
programModule(input_exe).addImport("input-protocol", input_protocol_module);
const input_source_exe = addUserBinary(b, kernel_target, &default_imports, "input-source", "test/system/services/input-source/input-source.zig");
const input_test_exe = addUserBinary(b, kernel_target, &default_imports, "input-test", "test/system/services/input-test/input-test.zig");
const args_echo_exe = addUserBinary(b, kernel_target, &default_imports, "args-echo", "test/system/services/args-echo/args-echo.zig");
const process_test_exe = addUserBinary(b, kernel_target, &default_imports, "process-test", "test/system/services/process-test/process-test.zig");
const logger_exe = addUserBinary(b, kernel_target, &default_imports, "logger", "system/services/logger/logger.zig");
// The first multi-threaded binary: exercises runtime.Thread over the thread ABI
// (docs/threading.md). Built threaded so its shared-memory poll is real.
const thread_test_exe = addThreadedUserBinary(b, kernel_target, &default_imports, "thread-test", "test/system/services/thread-test/thread-test.zig");
// Every user binary and its FHS home on the boot volume. There is no packed
// ramdisk artifact any more: make-fat-image.py lays each binary out at this
// path on the image, and the EFI loader walks /system and /test at boot and
// builds the in-RAM initial_ramdisk table from the trees — the volume's file
// structure is the single source of truth. Entry names (and hence argv[0] and
// task names) are these paths with a leading slash. Test fixtures mirror their
// repo home: test/system/services/<name> in the source tree IS the boot path.
// init's boot service list is data (/etc/init.csv). -Ddiagnose selects the
// variant that omits the display stack (so the kernel's boot transcript stays
// on screen); both are bundled at the same /etc/init.csv path.
const init_csv_source = if (diagnose) "etc/init-diagnose.csv" else "etc/init.csv";
const production_bundled = [_]BundledBinary{
.{ .path = "system/services/init", .binary = init_exe.getEmittedBin() },
.{ .path = "system/services/fat", .binary = fat_exe.getEmittedBin() },
.{ .path = "system/services/display", .binary = display_exe.getEmittedBin() },
.{ .path = "system/services/display-demo", .binary = display_demo_exe.getEmittedBin() },
.{ .path = "system/services/device-manager", .binary = device_manager_exe.getEmittedBin() },
.{ .path = "system/services/input", .binary = input_exe.getEmittedBin() },
.{ .path = "system/services/discovery", .binary = discovery_exe.getEmittedBin() },
.{ .path = "system/services/logger", .binary = logger_exe.getEmittedBin() },
// A data file, not a binary: the device registry the manager reads at boot.
// Packing it under /etc makes the kernel auto-mount /etc as a read-only
// initrd tree (system/kernel/vfs.zig setInitialRamdisk), so the manager can
// fs.open("/etc/devices.csv") with no filesystem service running.
.{ .path = "etc/devices.csv", .binary = b.path("etc/devices.csv") },
// init's service list, likewise read from the kernel-served initrd /etc.
.{ .path = "etc/init.csv", .binary = b.path(init_csv_source) },
.{ .path = "system/drivers/ps2-bus", .binary = ps2_bus_exe.getEmittedBin() },
.{ .path = "system/drivers/ps2-keyboard", .binary = ps2_keyboard_exe.getEmittedBin() },
.{ .path = "system/drivers/ps2-mouse", .binary = ps2_mouse_exe.getEmittedBin() },
.{ .path = "system/drivers/usb-xhci-bus", .binary = usb_xhci_bus_exe.getEmittedBin() },
.{ .path = "system/drivers/usb-hid-keyboard", .binary = usb_hid_keyboard_exe.getEmittedBin() },
.{ .path = "system/drivers/usb-hid-mouse", .binary = usb_hid_mouse_exe.getEmittedBin() },
.{ .path = "system/drivers/usb-storage", .binary = usb_storage_exe.getEmittedBin() },
.{ .path = "system/drivers/virtio-gpu", .binary = virtio_gpu_exe.getEmittedBin() },
.{ .path = "system/drivers/pci-bus", .binary = pci_bus_exe.getEmittedBin() },
};
// The userspace test fixtures under /test. A plain `zig build` produces a clean
// image WITHOUT them; they are bundled only for a test build — which the QEMU
// harness signals by passing -Dtest-case=<name> for every scenario, exactly when
// these fixtures must be on the boot volume. Merely building this array never
// forces a compile: the fixture exes build only if `bundled` (below) includes them.
const test_bundled = [_]BundledBinary{
.{ .path = "test/system/services/vfs-test", .binary = vfstest_exe.getEmittedBin() },
.{ .path = "test/system/services/fat-test", .binary = fat_test_exe.getEmittedBin() },
.{ .path = "test/system/services/shared-memory-server", .binary = shared_memory_server_exe.getEmittedBin() },
.{ .path = "test/system/services/shared-memory-client", .binary = shared_memory_client_exe.getEmittedBin() },
.{ .path = "test/system/services/crash-test", .binary = crash_test_exe.getEmittedBin() },
.{ .path = "test/system/services/device-list", .binary = device_list_exe.getEmittedBin() },
.{ .path = "test/system/services/pci-cap-test", .binary = pci_cap_test_exe.getEmittedBin() },
.{ .path = "test/system/services/iommu-fault-test", .binary = iommu_fault_test_exe.getEmittedBin() },
.{ .path = "test/system/services/input-source", .binary = input_source_exe.getEmittedBin() },
.{ .path = "test/system/services/input-test", .binary = input_test_exe.getEmittedBin() },
.{ .path = "test/system/services/args-echo", .binary = args_echo_exe.getEmittedBin() },
.{ .path = "test/system/services/process-test", .binary = process_test_exe.getEmittedBin() },
.{ .path = "test/system/services/thread-test", .binary = thread_test_exe.getEmittedBin() },
};
// A no-option build assumes neither -Dtest-case nor -Ddiagnose: it ships the
// production set only. Test fixtures join in only under -Dtest-case; the
// diagnose display-omission is already handled by init_csv_source above.
var bundled_list: std.ArrayListUnmanaged(BundledBinary) = .empty;
bundled_list.appendSlice(b.allocator, &production_bundled) catch @panic("OOM");
if (test_case != null) bundled_list.appendSlice(b.allocator, &test_bundled) catch @panic("OOM");
const bundled = bundled_list.items;
// The boot manifest: the FHS path of every bundled binary, one per line. The
// EFI loader reads THIS by name and opens each listed path by name — FAT
// name lookup is case-insensitive and firmware-portable, unlike directory
// ENUMERATION, whose returned names vary by firmware (bare 8.3 entries come
// back uppercase on some FAT drivers). The tree walk remains only as the
// loader's fallback for hand-assembled sticks without a manifest.
var manifest_text: std.ArrayListUnmanaged(u8) = .empty;
for (bundled) |item| {
manifest_text.append(b.allocator, '/') catch @panic("OOM");
manifest_text.appendSlice(b.allocator, item.path) catch @panic("OOM");
manifest_text.append(b.allocator, '\n') catch @panic("OOM");
}
const manifest_files = b.addWriteFiles();
const manifest_file = manifest_files.add("manifest", manifest_text.items);
const manifest_install = b.addInstallFileWithDir(manifest_file, .prefix, "system/manifest");
b.getInstallStep().dependOn(&manifest_install.step);
// The boot capsule: the same bundled list packed into ONE file (v2
// initial_ramdisk format), because a single open + sequential read is the
// only firmware file I/O shape that is fast everywhere — a per-file tree
// walk measured MINUTES on real firmware. The loader tries this first,
// then the manifest, then the walk; the running system cannot tell the
// difference (it always receives the same in-RAM table). Derived from the
// tree in the same build graph, so the two cannot drift.
const mk_capsule = b.addSystemCommand(&.{"python3"});
mk_capsule.addFileArg(b.path("tools/pack-system-image.py"));
const capsule_img = mk_capsule.addOutputFileArg("system.img");
for (bundled) |item| {
mk_capsule.addArg(item.path);
mk_capsule.addFileArg(item.binary);
}
const capsule_install = b.addInstallFile(capsule_img, "boot/system.img");
b.getInstallStep().dependOn(&capsule_install.step);
// Install every bundled binary to its FHS home, so zig-out is a true image of
// the filesystem — the same tree make-fat-image.py lays out on the boot volume.
for (bundled) |item| {
const install = b.addInstallFileWithDir(item.binary, .prefix, item.path);
b.getInstallStep().dependOn(&install.step);
}
// Boot methods live in 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.
// The loader reads -Dserial too, so its boot-progress breadcrumbs (con_out,
// which firmware may mirror to a serial console) are silenced by default — a
// real-hardware boot stays quiet. Fatal-error messages ignore this and always
// show, so a failed boot still explains itself on screen. See boot/efi.zig.
const loader_options = b.addOptions();
loader_options.addOption(bool, "serial", serial);
const loader_options_module = loader_options.createModule();
const efiexe = b.addExecutable(.{
.name = "BOOTX64",
.root_module = b.createModule(.{
.root_source_file = b.path("boot/efi.zig"),
.target = b.resolveTargetQuery(.{
.cpu_arch = .x86_64,
.os_tag = .uefi,
}),
.optimize = optimize,
.imports = &.{
// The bootloader speaks the handoff contract and the ramdisk
// container it packs the /system tree into — never the user ABI.
.{ .name = "boot-handoff", .module = boot_handoff_module },
.{ .name = "initial-ramdisk", .module = initial_ramdisk_module },
.{ .name = "build_options", .module = loader_options_module },
},
}),
});
// 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);
// --- danos-usb.img: the bootable FAT32 USB image ---
// Format a real FAT32 image (the in-repo Python builder, no external tools)
// holding the EFI stub, the kernel, and the whole /system tree of user
// binaries at their FHS paths. QEMU presents this image as a USB mass-storage
// device the guest boots from (see run-x86-64 and the test harness), and the
// danos fat driver mounts the same image at /mnt/usb.
const fat_image = addBootImage(b, exe.getEmittedBin(), efiexe.getEmittedBin(), manifest_file, capsule_img, bundled);
const fat_image_install = b.addInstallFile(fat_image, "danos-usb.img");
b.getInstallStep().dependOn(&fat_image_install.step);
// The image `run-x86-64` boots: identical to the flashable one but with the
// serial log sink compiled in, so a developer always gets the machine-readable
// log captured to serial0 — without baking serial into the image users flash.
// Built lazily (only when `run-x86-64` is requested), and never installed.
const exe_serial = addKernel(b, kernel_target, optimize, kernel_modules, test_case, true);
const fat_image_serial = addBootImage(b, exe_serial.getEmittedBin(), efiexe.getEmittedBin(), manifest_file, capsule_img, bundled);
// `zig build check-fat-image` — validate the produced image is a real FAT32
// with the EFI stub present (the builder's own --verify, no external tools).
const check_fat = b.addSystemCommand(&.{"python3"});
check_fat.addFileArg(b.path("tools/make-fat-image.py"));
check_fat.addArg("--verify");
check_fat.addFileArg(fat_image);
const check_fat_step = b.step("check-fat-image", "Verify the FAT32 USB image is valid and bootable");
check_fat_step.dependOn(&check_fat.step);
// --- release-x86-64: danos-x86-64.iso, the flashable release image ---
// Wrap the FAT32 boot volume in a hybrid ISO (the in-repo Python builder
// again, no xorriso/isohybrid): an ISO9660 whose El Torito EFI boot entry
// and MBR ESP partition entry both point at the embedded FAT image. One
// file then boots every way release media is consumed — flashed raw to a
// USB stick with Etcher or dd, or burned to optical media — while
// danos-usb.img stays the raw superfloppy QEMU and the test harness boot.
const mk_iso = b.addSystemCommand(&.{"python3"});
mk_iso.addFileArg(b.path("tools/make-iso-image.py"));
const iso_image = mk_iso.addOutputFileArg("danos-x86-64.iso");
mk_iso.addFileArg(fat_image);
const iso_install = b.addInstallFile(iso_image, "danos-x86-64.iso");
const release_step = b.step("release-x86-64", "Build the flashable x86-64 release ISO (zig-out/danos-x86-64.iso; flash with Etcher or dd)");
release_step.dependOn(&iso_install.step);
// `zig build check-iso-image` — the ISO builder's own --verify (mirroring
// check-fat-image): the MBR partition, the El Torito catalog, and the
// embedded FAT32 image must all agree.
const check_iso = b.addSystemCommand(&.{"python3"});
check_iso.addFileArg(b.path("tools/make-iso-image.py"));
check_iso.addArg("--verify");
check_iso.addFileArg(iso_image);
const check_iso_step = b.step("check-iso-image", "Verify the release ISO is a valid hybrid (MBR ESP partition + El Torito EFI entry)");
check_iso_step.dependOn(&check_iso.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
// layouts (Architecture, Debian/Ubuntu, Fedora, macOS Homebrew) and use the first
// that exists. Override with -Dovmf-code / -Dovmf-vars if yours is elsewhere.
const ovmf_code = b.option(
[]const u8,
"ovmf-code",
"Path to the OVMF_CODE firmware image",
) orelse firstExisting(b.graph.io, &.{
"/usr/share/edk2/x64/OVMF_CODE.4m.fd", // Architecture
"/usr/share/OVMF/OVMF_CODE_4M.fd", // Debian/Ubuntu
"/usr/share/OVMF/OVMF_CODE.fd", // older Debian/Ubuntu
"/usr/share/edk2-ovmf/x64/OVMF_CODE.fd", // Fedora
"/opt/homebrew/share/qemu/edk2-x86_64-code.fd", // macOS Homebrew (Apple Silicon)
"/usr/local/share/qemu/edk2-x86_64-code.fd", // macOS Homebrew (Intel)
});
const ovmf_vars = b.option(
[]const u8,
"ovmf-vars",
"Path to the OVMF_VARS firmware image (a writable copy is made)",
) orelse firstExisting(b.graph.io, &.{
"/usr/share/edk2/x64/OVMF_VARS.4m.fd", // Architecture
"/usr/share/OVMF/OVMF_VARS_4M.fd", // Debian/Ubuntu
"/usr/share/OVMF/OVMF_VARS.fd", // older Debian/Ubuntu
"/usr/share/edk2-ovmf/x64/OVMF_VARS.fd", // Fedora
"/opt/homebrew/share/qemu/edk2-i386-vars.fd", // macOS Homebrew (Apple Silicon)
"/usr/local/share/qemu/edk2-i386-vars.fd", // macOS Homebrew (Intel)
});
// The guest boots the self-contained FAT image (attached as USB storage below),
// not the installed FHS zig-out — see the run step's drive/device flags.
// The firmware needs to write NVRAM, so give it a writable copy of the vars.
const vars_copy = b.addSystemCommand(&.{ "cp", "-f", ovmf_vars });
const vars_out = vars_copy.addOutputFileArg("OVMF_VARS.4m.fd");
const run_efi = b.addSystemCommand(&.{
"qemu-system-x86_64",
"-device",
"qemu-xhci,id=xhci",
"-device",
"usb-mouse,bus=xhci.0",
"-device",
"usb-kbd,bus=xhci.0",
// "-usb",
// "-device",
// "usb-ehci,id=ehci",
// "-device",
// "usb-tablet,bus=usb-bus.0",
// "-device",
// "usb-mouse,bus=ehci.0",
"-machine",
"q35",
"-m",
"128M",
"-drive",
b.fmt("if=pflash,format=raw,readonly=on,file={s}", .{ovmf_code}),
});
run_efi.addArg("-drive");
run_efi.addPrefixedFileArg("if=pflash,format=raw,file=", vars_out);
// Boot off the FAT32 USB image: a mass-storage device on the same xHCI bus as
// the keyboard and mouse. OVMF finds \EFI\BOOT\BOOTX64.efi on it and boots.
// The serial-enabled variant, so serial0 carries the log for this dev boot.
run_efi.addArg("-drive");
run_efi.addPrefixedFileArg("if=none,id=bootusb,format=raw,file=", fat_image_serial);
run_efi.addArgs(&.{
"-device",
"usb-storage,bus=xhci.0,drive=bootusb,removable=on,bootindex=0",
"-net",
"none",
// Emulated display advertising 1280x720 as its native (EDID preferred)
// resolution, so the kernel's native-resolution switch has something to
// find. `-vga none` avoids a second, default adapter.
"-vga",
"none",
"-device",
"VGA,edid=on,xres=1280,yres=720",
});
// Capture the guest's serial0 (danos's machine-readable log) to the qemu-test
// scratch area — a dev/host artifact, kept out of the FHS boot volume we mount.
// (/var/log/system is reserved for the kernel's own logging system later.) One
// timestamped file per run.
const log_dir = b.fmt("{s}/qemu-test", .{b.install_path});
const make_log_dir = b.addSystemCommand(&.{ "mkdir", "-p", log_dir });
const serial_log = b.fmt("{s}/run-x86-64-serial0-{s}.log", .{ log_dir, timestamp(b) });
run_efi.addArgs(&.{ "-serial", b.fmt("file:{s}", .{serial_log}) });
// We boot the self-contained `fat_image_serial` (added as a file arg above, so
// it's already a dependency) — not the installed FHS zig-out — so `run-x86-64`
// builds only the serial kernel, never the flashable one. Just make the serial
// scratch dir first.
run_efi.step.dependOn(&make_log_dir.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/qemu-test/run-x86-64-serial0-<timestamp>.log");
run_efi_step.dependOn(&run_efi.step);
// --- run-x86-64-gpu: the same boot plus a virtio-gpu adapter ---
// The VGA device still supplies the boot (GOP) framebuffer the compositor starts
// on; the virtio-gpu function is discovered by the device-manager stack, its
// driver announces a shared scanout, and the compositor upgrades off the GOP
// floor to fenced, tear-free native presents (docs/display-v2.md).
// This is the interactive twin of the `display-native` test case, and 512M
// matches it (the whole driver stack + the compositor's surfaces at once).
// QEMU shows one head per adapter: pick the virtio-gpu head in the View menu
// to watch the native output.
const run_gpu = b.addSystemCommand(&.{
"qemu-system-x86_64",
"-device",
"qemu-xhci,id=xhci",
"-device",
"usb-mouse,bus=xhci.0",
"-device",
"usb-kbd,bus=xhci.0",
"-machine",
"q35",
"-m",
"512M",
"-drive",
b.fmt("if=pflash,format=raw,readonly=on,file={s}", .{ovmf_code}),
});
run_gpu.addArg("-drive");
run_gpu.addPrefixedFileArg("if=pflash,format=raw,file=", vars_out);
run_gpu.addArg("-drive");
run_gpu.addPrefixedFileArg("if=none,id=bootusb,format=raw,file=", fat_image_serial);
run_gpu.addArgs(&.{
"-device",
"usb-storage,bus=xhci.0,drive=bootusb,removable=on,bootindex=0",
"-net",
"none",
"-vga",
"none",
"-device",
"VGA,edid=on,xres=1280,yres=720",
"-device",
"virtio-gpu-pci",
});
const gpu_serial_log = b.fmt("{s}/run-x86-64-gpu-serial0-{s}.log", .{ log_dir, timestamp(b) });
run_gpu.addArgs(&.{ "-serial", b.fmt("file:{s}", .{gpu_serial_log}) });
run_gpu.step.dependOn(&make_log_dir.step);
const run_gpu_step = b.step("run-x86-64-gpu", "Boot in QEMU with a virtio-gpu adapter: the compositor upgrades to fenced (tear-free) native presents; watch the virtio-gpu head in QEMU's View menu");
run_gpu_step.dependOn(&run_gpu.step);
// const run_cmd = b.addRunArtifact(exe);
// const run_step = b.step("run", "Run the app");
// run_step.dependOn(&run_cmd.step);
// run_cmd.step.dependOn(b.getInstallStep());
//
// if (b.args) |args| {
// run_cmd.addArgs(args);
// }
// Tests run on the host. The kernel and bootloader target freestanding/UEFI
// and can't be executed natively, so only the shared contracts are unit-tested
// here (compiled for the host rather than inheriting a freestanding target) —
// which also compile-checks that the three-way split stays self-consistent.
const test_step = b.step("test", "Run tests");
for ([_][]const u8{
"system/boot-handoff.zig",
"system/abi.zig",
"system/initial-ramdisk.zig", // v2 path-named entries: find/basename/magic
"library/device/model/device-abi.zig",
"library/device/pci/pci-class.zig", // class/subclass/prog-IF name decoding
"library/device/acpi/acpi-ids.zig", // _HID name decoding
"library/device/acpi/aml/aml.zig", // AML parse + interpret, incl. Notify dispatch (M21)
"library/device/usb/usb-abi.zig", // wire sizes + bit packings + set-up packet encodings
"library/device/usb/usb-ids.zig", // class/subclass/protocol code assignments
"library/csv/csv.zig", // shared /etc/*.csv comment-strip + field-split helpers
"library/device/mmio/mmio.zig", // barriers assemble + registers round-trip
"system/drivers/ps2-bus/scancode.zig", // set-2 decode + keyboard state machine
"system/drivers/ps2-bus/mouse-packet.zig", // 3-byte mouse packet assembly
"system/drivers/usb-hid/hid-report.zig", // HID boot-report keyboard/mouse decode
"system/drivers/usb-storage/bulk-only-transport.zig", // CBW/CSW wrapper sizes
"system/drivers/usb-storage/scsi.zig", // SCSI CDB encodings (big-endian)
"library/protocol/vfs/vfs-protocol.zig", // NodeKind / DirectoryEntry sizes + op values
"system/services/fat/on-disk.zig", // FAT on-disk struct sizes + type detection
"system/services/fat/engine.zig", // FAT read/write over a RAM-backed image
"system/services/display/compositor.zig", // Rect math + fill/composite/blit-tile
"library/protocol/display/display-protocol.zig", // pack(): native pixel encoding per format
"system/drivers/virtio-gpu/virtio-gpu-protocol.zig", // virtio-gpu command struct sizes
"system/drivers/virtio-gpu/virtio-pci.zig", // virtio 1.0 PCI transport struct sizes
}) |root| {
const mod_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path(root),
.target = target,
.optimize = optimize,
}),
});
test_step.dependOn(&b.addRunArtifact(mod_tests).step);
}
// The device registry imports the shared `csv` module, so its tests need that
// import wired and don't fit the plain loop above. These prove the /etc/devices.csv
// parse + most-specific driver match (incl. virtio 1AF4:1050 beating a class rule).
const device_registry_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("library/device/registry/device-registry.zig"),
.target = target,
.optimize = optimize,
.imports = &.{.{ .name = "csv", .module = csv_module }},
}),
});
test_step.dependOn(&b.addRunArtifact(device_registry_tests).step);
// The xkeyboard-config keymap tests need its generated `layouts` import wired, so they
// don't fit the plain loop above. Its keycode->character assertions are the end-to-end
// proof that the xkb-data -> generator -> Zig-lookup pipeline is correct.
const xkb_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("library/xkeyboard-config/xkeyboard-config.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "layouts", .module = xkb_layouts_module },
},
}),
});
test_step.dependOn(&b.addRunArtifact(xkb_tests).step);
// The tagged kernel log ring: append/wrap/reclaim/sequence-gap behavior over
// a RAM buffer. Needs the `abi` module (record header layout), so it doesn't
// fit the plain loop above.
const log_ring_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("system/kernel/log-ring.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "abi", .module = abi_module },
},
}),
});
test_step.dependOn(&b.addRunArtifact(log_ring_tests).step);
// runtime.time's Instant/Duration arithmetic. time.zig pulls in system.zig (the
// syscall wrappers), which needs the `abi` module, so it doesn't fit the plain
// loop above.
const time_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("library/kernel/time.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "abi", .module = abi_module },
},
}),
});
test_step.dependOn(&b.addRunArtifact(time_tests).step);
// runtime.Thread's lock/condvar state machines (Mutex/Condition/RwLock/WaitGroup). Its
// Futex seam falls back to std.Thread.Futex off the danos target, so the tests exercise
// them with real host threads (docs/threading-plan.md M11). Like time.zig it pulls in
// system.zig (syscall wrappers), which needs the `abi` module.
const thread_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("library/kernel/thread.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "abi", .module = abi_module },
},
}),
});
test_step.dependOn(&b.addRunArtifact(thread_tests).step);
// Convenience: `zig build gen-xkeyboard-config` regenerates the layout tables from the
// vendored data (offline). `fetch` (the network step) stays a manual script run.
const gen_xkb = b.addSystemCommand(&.{ "python3", "tools/make-xkeyboard-config.py", "generate" });
const gen_xkb_step = b.step("gen-xkeyboard-config", "Regenerate library/xkeyboard-config/generated from the vendored data");
gen_xkb_step.dependOn(&gen_xkb.step);
}