306 lines
14 KiB
Zig
306 lines
14 KiB
Zig
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(),
|
|
});
|
|
}
|
|
|
|
pub fn build(b: *std.Build) void {
|
|
ensureZigVersion();
|
|
|
|
const target = b.standardTargetOptions(.{});
|
|
const optimize = b.standardOptimizeOption(.{});
|
|
|
|
// Shared handoff definitions (BootInfo, Framebuffer, ...). No target is set,
|
|
// so the module inherits the target of whichever binary imports it — the
|
|
// freestanding kernel or the UEFI bootloader.
|
|
const mod = b.addModule("danos", .{
|
|
.root_source_file = b.path("src/root.zig"),
|
|
});
|
|
|
|
// Kernel tunables (max_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 src/config.zig.
|
|
const config_mod = b.addModule("config", .{
|
|
.root_source_file = b.path("src/config.zig"),
|
|
});
|
|
|
|
// Architecture-specific kernel code (CPU ops, entry, later GDT/IDT/paging).
|
|
// The generic kernel imports this as "arch" and never names x86_64, so a new
|
|
// architecture is a matter of pointing this module at a different directory.
|
|
const arch_mod = b.addModule("arch", .{
|
|
.root_source_file = b.path("src/kernel/arch/x86_64/cpu.zig"),
|
|
.imports = &.{
|
|
.{ .name = "danos", .module = mod }, // paging uses the shared BootInfo/memory-map types
|
|
.{ .name = "config", .module = config_mod }, // max_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).
|
|
arch_mod.addAssemblyFile(b.path("src/kernel/arch/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).
|
|
arch_mod.addAssemblyFile(b.path("src/kernel/arch/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
|
|
// arch module applies to CPU code. The backend is selected at runtime from
|
|
// the boot handoff (see src/device/platform.zig).
|
|
const platform_mod = b.addModule("platform", .{
|
|
.root_source_file = b.path("src/device/platform.zig"),
|
|
.imports = &.{
|
|
.{ .name = "danos", .module = mod }, // BootInfo (carries the ACPI RSDP)
|
|
.{ .name = "config", .module = config_mod }, // max_cpus (the discovery pool)
|
|
},
|
|
});
|
|
|
|
// 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)");
|
|
const build_options = b.addOptions();
|
|
build_options.addOption(?[]const u8, "test_case", test_case);
|
|
const build_options_mod = build_options.createModule();
|
|
|
|
// --- 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 exe = b.addExecutable(.{
|
|
.name = "kernel",
|
|
.root_module = b.createModule(.{
|
|
.root_source_file = b.path("src/kernel/main.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 SysV 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,
|
|
.imports = &.{
|
|
.{ .name = "danos", .module = mod },
|
|
.{ .name = "arch", .module = arch_mod },
|
|
.{ .name = "platform", .module = platform_mod },
|
|
.{ .name = "config", .module = config_mod },
|
|
.{ .name = "build_options", .module = build_options_mod },
|
|
},
|
|
}),
|
|
});
|
|
exe.setLinkerScript(b.path("src/kernel/arch/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;
|
|
|
|
b.installArtifact(exe);
|
|
|
|
// --- /sbin/init: the first user-space program ---
|
|
// Its own tiny freestanding binary, linked at a fixed address inside the
|
|
// kernel's user region (usermode.zig) and started in ring 3 by the kernel's
|
|
// user-ELF loader. `.large` because the image base is above 4 GiB — small/
|
|
// medium code models emit 32-bit absolute relocations that can't reach.
|
|
// Pinned to ReleaseSmall: the user region gives it a 2 MiB budget and its
|
|
// size has no reason to track the kernel's optimize mode.
|
|
const init_exe = b.addExecutable(.{
|
|
.name = "init",
|
|
.root_module = b.createModule(.{
|
|
.root_source_file = b.path("sbin/init.zig"),
|
|
.target = kernel_target,
|
|
.optimize = .ReleaseSmall,
|
|
.code_model = .large,
|
|
.single_threaded = true,
|
|
.sanitize_c = .off,
|
|
.stack_check = false,
|
|
.stack_protector = false,
|
|
}),
|
|
});
|
|
init_exe.setLinkerScript(b.path("sbin/linker.ld"));
|
|
init_exe.entry = .{ .symbol_name = "_start" };
|
|
init_exe.image_base = 0x7000_0000_0000;
|
|
b.installArtifact(init_exe);
|
|
|
|
// 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.
|
|
const efiexe = b.addExecutable(.{
|
|
.name = "BOOTX64",
|
|
.root_module = b.createModule(.{
|
|
.root_source_file = b.path("src/boot/efi.zig"),
|
|
.target = b.resolveTargetQuery(.{
|
|
.cpu_arch = .x86_64,
|
|
.os_tag = .uefi,
|
|
}),
|
|
.optimize = optimize,
|
|
.imports = &.{
|
|
.{ .name = "danos", .module = mod },
|
|
},
|
|
}),
|
|
});
|
|
|
|
b.installArtifact(efiexe);
|
|
|
|
// --- 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 (Arch, 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", // Arch
|
|
"/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", // Arch
|
|
"/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)
|
|
});
|
|
|
|
// Assemble an EFI System Partition layout: esp/EFI/BOOT/BOOTX64.efi
|
|
const efi_install = b.addInstallArtifact(efiexe, .{
|
|
.dest_dir = .{ .override = .{ .custom = "esp/EFI/BOOT" } },
|
|
});
|
|
// The bootloader loads the kernel by name from the volume root, so drop the
|
|
// kernel ELF at esp/kernel.
|
|
const kernel_install = b.addInstallArtifact(exe, .{
|
|
.dest_dir = .{ .override = .{ .custom = "esp" } },
|
|
});
|
|
// The bootloader loads init from sbin/init on the same volume.
|
|
const init_install = b.addInstallArtifact(init_exe, .{
|
|
.dest_dir = .{ .override = .{ .custom = "esp/sbin" } },
|
|
});
|
|
|
|
// 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",
|
|
"-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);
|
|
// Present the ESP directory to the guest as a FAT drive.
|
|
run_efi.addArgs(&.{
|
|
"-drive",
|
|
b.fmt("format=raw,file=fat:rw:{s}/esp", .{b.install_path}),
|
|
"-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",
|
|
});
|
|
// Always capture the guest's serial0 (the kernel's machine-readable log) to a
|
|
// timestamped file under zig-out, so each run leaves its own log behind.
|
|
const serial_log = b.fmt("{s}/run-x86-64-serial0-{s}.log", .{ b.install_path, timestamp(b) });
|
|
run_efi.addArgs(&.{ "-serial", b.fmt("file:{s}", .{serial_log}) });
|
|
run_efi.step.dependOn(&efi_install.step);
|
|
run_efi.step.dependOn(&kernel_install.step);
|
|
run_efi.step.dependOn(&init_install.step);
|
|
|
|
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);
|
|
|
|
// 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 module is unit-tested
|
|
// here (compiled for the host rather than inheriting a freestanding target).
|
|
const mod_tests = b.addTest(.{
|
|
.root_module = b.createModule(.{
|
|
.root_source_file = b.path("src/root.zig"),
|
|
.target = target,
|
|
.optimize = optimize,
|
|
}),
|
|
});
|
|
|
|
const run_mod_tests = b.addRunArtifact(mod_tests);
|
|
|
|
const test_step = b.step("test", "Run tests");
|
|
test_step.dependOn(&run_mod_tests.step);
|
|
}
|