const std = @import("std"); const builtin = @import("builtin"); // The danos build API (docs/build-packages-plan.md): the shared user-binary // recipe lives in the build-support package; this root build orchestrates — // what ships (the bundled list), the kernel + loader, and the test aggregate. // Image assembly and the QEMU run steps live beside it in build/. const build_support = @import("build-support"); const images = @import("build/images.zig"); const qemu = @import("build/qemu.zig"); /// 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); } } /// 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 row of the production ship table: which package, which of its /// artifacts, and the FHS boot path. For most binaries all three share one /// name; the helpers below make a row from just that name. const ShipRow = struct { path: []const u8, package: []const u8, artifact: []const u8 }; fn service(comptime name: []const u8) ShipRow { return .{ .path = "system/services/" ++ name, .package = name, .artifact = name }; } fn driver(comptime name: []const u8) ShipRow { return .{ .path = "system/drivers/" ++ name, .package = name, .artifact = name }; } /// An extra artifact of a multi-binary driver package (ps2-bus, usb-hid), /// bundled at its own flattened /system/drivers path. fn driverArtifact(comptime package: []const u8, comptime artifact: []const u8) ShipRow { return .{ .path = "system/drivers/" ++ artifact, .package = package, .artifact = artifact }; } /// The production ship table — what a plain `zig build` image contains, /// beyond the specials the build fn adds around it (init, discovery, the /// /system/configuration data files; the /test fixtures join only under /// -Dtest-case). /// Selecting what goes into a build = selecting rows: a package in no row is /// not just unshipped, its build file is never even loaded /// (docs/build-packages-plan.md). const production_ship = [_]ShipRow{ service("fat"), service("display"), service("display-demo"), service("device-manager"), service("input"), service("logger"), driver("pci-bus"), driver("ps2-bus"), driverArtifact("ps2-bus", "ps2-keyboard"), driverArtifact("ps2-bus", "ps2-mouse"), driver("usb-xhci-bus"), driverArtifact("usb-hid", "usb-hid-keyboard"), driverArtifact("usb-hid", "usb-hid-mouse"), driver("usb-storage"), driver("virtio-gpu"), }; pub fn build(b: *std.Build) void { ensureZigVersion(); const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); // The library domain packages (docs/build-packages-plan.md, phase 1): each // domain owns a build.zig/zon that wires and exports its modules, and this // root build is a consumer — a library interface change now happens in the // domain's own build file, not here. The per-module commentary lives with // each domain's build.zig. const kernel_library = b.dependency("kernel", .{}); const device_library = b.dependency("device", .{}); const client_library = b.dependency("client", .{}); const protocol_library = b.dependency("protocol", .{}); const csv_library = b.dependency("csv", .{}); const xkeyboard_config_library = b.dependency("xkeyboard-config", .{}); // 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, ...) // boot-handoff stays a root module (a system/ source the loader <-> kernel // pair speaks); abi is exported by the kernel library package (its source // also lives in system/), device-abi by the device package. const boot_handoff_module = b.addModule("boot-handoff", .{ .root_source_file = b.path("system/boot-handoff.zig"), }); const abi_module = kernel_library.module("abi"); const device_abi_module = device_library.module("device-abi"); // 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 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= 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 --- // (See build-support/build.zig for why SSE2 stays enabled.) const kernel_target = build_support.freestandingTarget(b); 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). // The serial-enabled twin is what `run-x86-64` boots — built lazily (only // when its image is requested), never installed. const exe = addKernel(b, kernel_target, optimize, kernel_modules, test_case, serial); const exe_serial = addKernel(b, kernel_target, optimize, kernel_modules, test_case, true); // --- what ships: the boot tree --- // 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 its // 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. // // The uniform rows live in `production_ship` (the table above `build`); // spelled out here are only the genuinely non-uniform entries: init // (receives the root's -Dserial as a dependency option — its liveness // heartbeat is a serial/test-build diagnostic the QEMU harness asserts // on; a flashable image leaves it out), discovery (the -Ddiscovery pick), // and the /system/configuration data files. Each binary builds itself // against the domain packages via build-support's shared recipe; the root // just takes artifacts (docs/build-packages-plan.md). var bundled_list: std.ArrayListUnmanaged(images.BundledBinary) = .empty; bundled_list.append(b.allocator, .{ .path = "system/services/init", .binary = b.dependency("init", .{ .serial = serial }).artifact("init").getEmittedBin(), }) catch @panic("OOM"); // 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). Each firmware's service is its own LAZY package; // both export an artifact named "discovery", and this option picks which // one ships — the unselected package's build file is never even loaded. // (lazyDependency returns null only for an unfetched remote package; these // are in-repo path dependencies, so a null means the directory is gone.) 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_exe = switch (discovery) { .acpi => (b.lazyDependency("acpi", .{}) orelse @panic("system/services/acpi is missing")).artifact("discovery"), .fdt => (b.lazyDependency("fdt", .{}) orelse @panic("system/services/fdt is missing")).artifact("discovery"), }; bundled_list.append(b.allocator, .{ .path = "system/services/discovery", .binary = discovery_exe.getEmittedBin(), }) catch @panic("OOM"); // The ship table: every uniform row, one line each. for (production_ship) |row| { bundled_list.append(b.allocator, .{ .path = row.path, .binary = b.dependency(row.package, .{}).artifact(row.artifact).getEmittedBin(), }) catch @panic("OOM"); } // Data files, not binaries: packing them under /system/configuration rides // the kernel's read-only initrd mount of /system (system/kernel/vfs.zig // setInitialRamdisk) — the device manager reads its registry and init its // service list with no filesystem service running. -Ddiagnose selects the // init.csv variant that omits the display stack (so the kernel's boot // transcript stays on screen); both bundle at the same // /system/configuration/init.csv path. const init_csv_source = if (diagnose) "system/configuration/init-diagnose.csv" else "system/configuration/init.csv"; bundled_list.append(b.allocator, .{ .path = "system/configuration/devices.csv", .binary = b.path("system/configuration/devices.csv") }) catch @panic("OOM"); bundled_list.append(b.allocator, .{ .path = "system/configuration/init.csv", .binary = b.path(init_csv_source) }) catch @panic("OOM"); // A no-option build assumes neither -Dtest-case nor -Ddiagnose: it ships the // production set only. The userspace test fixtures under /test join in only // for a test build — which the QEMU harness signals by passing // -Dtest-case= for every scenario, exactly when they must be on the // boot volume. They are LAZY dependencies too. Fixture packages are // uniform — the dependency name, the artifact name, and the boot path's // leaf all match the directory — so a name is a whole entry. if (test_case != null) for ([_][]const u8{ "vfs-test", // the user-space VFS round-trip client "fat-test", "shared-memory-server", "shared-memory-client", "crash-test", // hellos to the device manager, then faults — drives the crash-loop cap "device-list", "pci-cap-test", // exercises the driver-side PCI library against the pci-caps NIC "iommu-fault-test", // fires the rogue DMA that VT-d must fault "input-source", "input-test", "args-echo", "process-test", "thread-test", // the multi-threaded fixture (its package sets .threaded) "user-memory-test", // aims deliberately bad user pointers at the checked copy layer }) |fixture| { const package = b.lazyDependency(fixture, .{}) orelse @panic("a test fixture package is missing under test/system/services"); bundled_list.append(b.allocator, .{ .path = b.fmt("test/system/services/{s}", .{fixture}), .binary = package.artifact(fixture).getEmittedBin(), }) catch @panic("OOM"); }; const bundled = bundled_list.items; // 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 }, }, }), }); // Image assembly (the FHS install tree, boot manifest + capsule, both FAT32 // images, the release ISO, the check steps) and the QEMU run steps live in // build/ — the root decides what ships, those files own how it runs. const fat_image_serial = images.addImageSteps(b, .{ .kernel = exe, .kernel_serial = exe_serial, .efi = efiexe, .bundled = bundled, }); qemu.addRunSteps(b, fat_image_serial); // 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 }) |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 library domains and the binary packages own their unit tests (each // package's standalone `zig build test` step); the root aggregate // delegates to those steps so one command still runs everything and a // test added inside a package can never be silently skipped here. Package // tests are host-only, so root's -Dtarget/-Doptimize deliberately do not // reach them. for ([_]*std.Build.Dependency{ kernel_library, device_library, client_library, protocol_library, csv_library, xkeyboard_config_library, b.dependency("fat", .{}), b.dependency("display", .{}), b.dependency("ps2-bus", .{}), b.dependency("usb-hid", .{}), b.dependency("usb-storage", .{}), b.dependency("virtio-gpu", .{}), }) |package| { test_step.dependOn(&package.builder.top_level_steps.get("test").?.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); // 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); }