Files
danos/build-support/build.zig
T
Daniel Samson 15575960bd build: single-source the default import set; delegate library tests
Review findings from the pilot: the 17-module default import set was
maintained twice (root's array and build-support's userBinary) — drift
would silently make packaged binaries differ from root-built ones. It
now lives once, as build-support.defaultImports; root and userBinary
both draw from it, and root's user-binary wrapper names the shim +
link script through the kernel package like build-support does.
The root aggregate test step likewise duplicated the library domains'
14 test definitions; it now depends on each domain's own standalone
test step (host-only, so root's -Dtarget/-Doptimize deliberately do
not reach them), and the client domain gains an empty test step for
uniformity. Boot-image file list unchanged.
2026-07-26 23:10:10 +01:00

200 lines
9.7 KiB
Zig

//! The danos build API (docs/build-packages-plan.md, phase 0): the one shared
//! recipe for building a user-space binary, extracted from the root build so
//! cross-cutting build changes have a single home. Consumers declare this
//! package in their build.zig.zon (as "build-support") and @import its
//! build.zig from their own build.zig; nothing is compiled from this package
//! itself — it exports build-time functions only.
const std = @import("std");
pub fn build(b: *std.Build) void {
_ = b; // nothing to build: this package exports build-time functions only
}
/// The freestanding x86-64 target every danos binary (kernel and user) is
/// built for. 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.
pub fn freestandingTarget(b: *std.Build) std.Build.ResolvedTarget {
return b.resolveTargetQuery(.{
.cpu_arch = .x86_64,
.os_tag = .freestanding,
.abi = .none,
});
}
/// What `userBinaryFromImports` needs to know about one user binary.
pub const UserBinaryOptions = struct {
name: []const u8,
/// The program's own source file — it becomes the `program` module the
/// root shim imports; a program only defines `pub fn main`.
root_source_file: std.Build.LazyPath,
/// The shared root shim supplying the compilation-root declarations
/// (`main` re-export, panic handler, `_start` pull): library/kernel/root.zig.
shim_source_file: std.Build.LazyPath,
/// The shared user link script. Its PHDRS (segment permissions) are
/// authoritative — the kernel's W^X user-ELF loader requires exact perms.
linker_script: std.Build.LazyPath,
target: std.Build.ResolvedTarget,
/// The default set of importable modules every user binary sees — 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. Must include `start` and
/// `logging` (the root shim reaches those two directly).
default_imports: []const std.Build.Module.Import,
/// 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.
threaded: bool = false,
};
/// 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");
}
/// Build one user-space binary the same way for every program (init, the
/// services, the drivers): freestanding, ReleaseSmall, `.large` code model
/// (the image base is above 4 GiB — smaller models emit 32-bit relocations
/// that can't reach), linked 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
/// (options.shim_source_file), 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.
pub fn userBinaryFromImports(b: *std.Build, options: UserBinaryOptions) *std.Build.Step.Compile {
// Settings (target, optimize, code model, ...) live on the root module
// only; the program module inherits them.
const program_module = b.createModule(.{
.root_source_file = options.root_source_file,
.imports = options.default_imports,
});
const exe = b.addExecutable(.{
.name = options.name,
.root_module = b.createModule(.{
.root_source_file = options.shim_source_file,
.target = options.target,
.optimize = .ReleaseSmall,
.code_model = .large,
.single_threaded = !options.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(options.default_imports, "start") },
.{ .name = "logging", .module = findImport(options.default_imports, "logging") },
.{ .name = "program", .module = program_module },
},
}),
});
exe.setLinkerScript(options.linker_script);
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 `userBinaryFromImports` — 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.
pub fn programModule(exe: *std.Build.Step.Compile) *std.Build.Module {
return exe.root_module.import_table.get("program").?;
}
/// The library domain packages a user binary's default import set draws from.
/// A binary package declares these in its build.zig.zon under exactly these
/// dependency names (kernel, device, client, xkeyboard-config) and resolves
/// them with `domains`. Path dependencies deduplicate by resolved location,
/// so every binary and the root build share one instance of each module.
pub const Domains = struct {
kernel: *std.Build.Dependency,
device: *std.Build.Dependency,
client: *std.Build.Dependency,
xkeyboard_config: *std.Build.Dependency,
};
/// Resolve the four default-set domain packages by their conventional
/// dependency names — the one-liner for a binary package's build fn.
pub fn domains(b: *std.Build) Domains {
return .{
.kernel = b.dependency("kernel", .{}),
.device = b.dependency("device", .{}),
.client = b.dependency("client", .{}),
.xkeyboard_config = b.dependency("xkeyboard-config", .{}),
};
}
/// What `userBinary` needs to know about one user binary. The domain wiring
/// (default imports, root shim, link script, target) is derived.
pub const DomainUserBinaryOptions = struct {
name: []const u8,
/// The program's own source file — it becomes the `program` module.
root_source_file: std.Build.LazyPath,
domains: Domains,
/// See UserBinaryOptions.threaded.
threaded: bool = false,
};
/// THE default import set every user binary sees — 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 — assembled from the domain packages. This is the
/// single authoritative list: the root build's stanzas and every binary
/// package both draw from here, so adding a default module is a one-place
/// change (the cross-cutting rule, docs/build-packages-plan.md).
pub fn defaultImports(libraries: Domains) [17]std.Build.Module.Import {
const kernel = libraries.kernel;
const device = libraries.device;
const client = libraries.client;
return .{
.{ .name = "mmio", .module = device.module("mmio") },
.{ .name = "xkeyboard-config", .module = libraries.xkeyboard_config.module("xkeyboard-config") },
.{ .name = "acpi-ids", .module = device.module("acpi-ids") },
.{ .name = "system-call", .module = kernel.module("system-call") },
.{ .name = "ipc", .module = kernel.module("ipc") },
.{ .name = "memory", .module = kernel.module("memory") },
.{ .name = "process", .module = kernel.module("process") },
.{ .name = "thread", .module = kernel.module("thread") },
.{ .name = "time", .module = kernel.module("time") },
.{ .name = "logging", .module = kernel.module("logging") },
.{ .name = "file-system", .module = kernel.module("file-system") },
.{ .name = "service", .module = kernel.module("service") },
.{ .name = "start", .module = kernel.module("start") },
.{ .name = "driver", .module = device.module("driver") },
.{ .name = "block", .module = device.module("block") },
.{ .name = "display", .module = client.module("display") },
.{ .name = "input", .module = client.module("input") },
};
}
/// Build one user binary against the domain packages' default import set
/// (`defaultImports`). Per-binary extras go through
/// `programModule(exe).addImport`. This is THE recipe a binary package's
/// build.zig calls; the root shim and user link script come from the kernel
/// domain package's directory.
pub fn userBinary(b: *std.Build, options: DomainUserBinaryOptions) *std.Build.Step.Compile {
const default_imports = defaultImports(options.domains);
return userBinaryFromImports(b, .{
.name = options.name,
.root_source_file = options.root_source_file,
.shim_source_file = options.domains.kernel.path("root.zig"),
.linker_script = options.domains.kernel.path("user.ld"),
.target = freestandingTarget(b),
.default_imports = &default_imports,
.threaded = options.threaded,
});
}