Zig 3009 lines
const std = @import("std");
const builtin = @import("builtin");
// Hard pin: FlashOS uses inline asm, freestanding aarch64, custom linker
// scripts and patchable-function-entry hooks that are sensitive to Zig
// compiler changes. Bumping is a deliberate act — install the new Zig,
// raise REQUIRED_ZIG_VERSION here and `minimum_zig_version` in
// build.zig.zon, fix any breakage, commit. The .zigversion file mirrors
// this for version managers (zigup / zvm / anyzig).
const REQUIRED_ZIG_VERSION = std.SemanticVersion{ .major = 0, .minor = 16, .patch = 0 };
comptime {
const v = builtin.zig_version;
const r = REQUIRED_ZIG_VERSION;
if (v.major != r.major or v.minor != r.minor or v.patch != r.patch) {
@compileError(std.fmt.comptimePrint(
"FlashOS requires Zig {d}.{d}.{d} exactly. Found Zig {d}.{d}.{d}. " ++
"To upgrade: bump REQUIRED_ZIG_VERSION in build.zig and " ++
"minimum_zig_version in build.zig.zon, then fix breakage.",
.{ r.major, r.minor, r.patch, v.major, v.minor, v.patch },
));
}
}
// Native Zig build for the FlashOS kernel (AArch64; rpi4b + virt boards).
//
// Layout:
// * src/start.zig — root, comptime-imports every kernel module
// * arch/aarch64/*.S, *.inc — CPU-ISA core: boot/entry/sched/timer/etc.
// * src/*.S — machine-independent asm (symbol table, trace)
// * src/board/<board>/* — per-board driver bag + linker script
// * user_space/init_main.flash — pid1.elf root, staged into the initramfs
// * src/board/<board>/linker.ld — per-board link script (.initramfs section)
//
// The build produces:
// * kernel8.img — raw binary loaded by the GPU bootloader (or QEMU `-kernel`)
// * armstub8.bin — small EL3→EL1 bootstrap shim (rpi4b only)
//
// Optional `populate-syms` step runs nm on the linked ELF, regenerates
// src/symbol_area.S via scripts/generate_syms.zig, then relinks so the
// trace/ksyms machinery has a real symbol table to look up.
const Board = enum { rpi4b, virt };
// Host-test wiring helper. Covers all three call patterns the suite
// uses (shared-stub leaf, shared-stub + named imports, per-target stub
// + imports) and returns the created test Module so a caller can reuse
// it as a named-import target downstream — e.g. wait_queue's test
// module is also pipe's "wait_queue" import.
const HostTest = struct {
src: []const u8,
// When set, the test compiles this generated source instead of b.path(src).
// Used for Flash-transpiled modules whose .zig lives in the build cache (a
// composed WriteFiles directory) rather than on disk; `src` stays the
// human-readable label.
src_lazy: ?std.Build.LazyPath = null,
stubs: ?*std.Build.Step.Compile = null,
extra_stubs: []const *std.Build.Step.Compile = &.{},
imports: []const struct {
name: []const u8,
mod: *std.Build.Module,
} = &.{},
};
// Set from the -Dcoverage option in build(); read by addHostTest below.
var host_tests_use_llvm = false;
// Set from the -Dtest-filter option in build(); read by addHostTest below. When
// non-null, only tests whose name contains this substring run (zig test filter).
var host_test_filter: ?[]const u8 = null;
// Source files of every host test addHostTest wires, in registration order.
// The `test` step's final tally (scripts/test_tally.sh) counts the `test "…"`
// blocks across exactly these files, so the printed pass count is derived from
// the build graph itself and can never drift from the suite. Fixed cap — there
// are ~60 host tests; bump if the suite ever outgrows it.
var host_test_srcs: [256][]const u8 = undefined;
var host_test_n: usize = 0;
fn addHostTest(b: *std.Build, step: *std.Build.Step, cfg: HostTest) *std.Build.Module {
const m = b.createModule(.{
.root_source_file = if (cfg.src_lazy) |lp| lp else b.path(cfg.src),
.target = b.graph.host,
.optimize = .Debug,
});
if (cfg.stubs) |s| m.addObject(s);
for (cfg.extra_stubs) |s| m.addObject(s);
for (cfg.imports) |imp| m.addImport(imp.name, imp.mod);
const t = b.addTest(.{
.root_module = m,
.use_llvm = if (host_tests_use_llvm) true else null,
.filters = if (host_test_filter) |f| &.{f} else &.{},
});
step.dependOn(&b.addRunArtifact(t).step);
host_test_srcs[host_test_n] = cfg.src;
host_test_n += 1;
return m;
}
// Set from the -Dflashc option in build(); read by addFlashSource below.
var flashc_path: []const u8 = "flashc";
// Flash transpile helper. Registers a flashc run step (Flash -> Zig) and
// returns the generated .zig as a LazyPath usable as a module root. The
// .flash file is the source of truth: the generated Zig lands in the
// build cache and is never committed. The step always re-runs
// (has_side_effects): flashc is an external binary the cache cannot
// fingerprint, so a stale cached output could otherwise green a boot
// that no longer matches its source.
fn addFlashSource(b: *std.Build, src: []const u8) std.Build.LazyPath {
const run = b.addSystemCommand(&.{flashc_path});
run.setName(b.fmt("flashc {s}", .{src}));
run.addFileArg(b.path(src));
run.addArg("-o");
const out = run.addOutputFileArg(b.fmt("{s}.zig", .{std.fs.path.stem(src)}));
run.has_side_effects = true;
return out;
}
pub fn build(b: *std.Build) void {
const target = b.resolveTargetQuery(.{
.cpu_arch = .aarch64,
.os_tag = .freestanding,
.abi = .none,
// Force +strict-align so LLVM never widens a byte copy or a
// >16-byte by-value return into a NEON `str q` aimed at an
// only-8-aligned slot. Those stores fault under SCTLR_EL1.A on real
// silicon (data abort, DFSC 0x21) while sailing through QEMU's
// lenient TCG. Covers the kernel and every freestanding EL0 program
// that shares this target, so the whole class is closed at codegen
// instead of with per-site `align(16)` / volatile dodges.
.cpu_features_add = std.Target.aarch64.featureSet(&.{.strict_align}),
});
// Default .ReleaseSmall keeps the kernel inside its symbol/image
// budget, but it also compiles out the integer-overflow and
// bounds-check traps: a missed overflow becomes silent UB instead of a
// panic. Deliberate ceiling — arithmetic on untrusted input carries
// explicit checks at the source (the ELF p_vaddr/p_memsz range+wrap
// guards in src/elf.zig, the clusterLba fail-closed guard in
// src/fat32.zig). Pass -Doptimize=ReleaseSafe to restore the traps.
const optimize: std.builtin.OptimizeMode = b.option(
std.builtin.OptimizeMode,
"optimize",
"Prioritize performance, safety, or binary size",
) orelse .ReleaseSmall;
const board: Board = b.option(
Board,
"board",
"Target board (rpi4b | virt)",
) orelse .rpi4b;
// Expose the active board to Zig source via @import("build_options").
// src/board.zig switches on this at comptime to alias each driver
// module to the right `src/board/<board>/*.zig`.
const build_options = b.addOptions();
build_options.addOption(Board, "board", board);
// Project version, single-sourced from build.zig.zon (.version). Flows to
// fsh via build_options so the homescreen banner never hardcodes it: a
// release bumps build.zig.zon and the shell line follows automatically.
build_options.addOption([]const u8, "version", @import("build.zig.zon").version);
// Opt-in fork tracing: prints a `created pid N at <kva>` line on every
// fork. Off by default so normal and CI boots read clean; flip on with
// `-Dverbose-fork` when debugging the scheduler / process lifecycle.
const verbose_fork = b.option(
bool,
"verbose-fork",
"Print a 'created pid N at <kva>' line on every fork (debug)",
) orelse false;
build_options.addOption(bool, "verbose_fork", verbose_fork);
// CI auto-login seed (default OFF — secure by default). PID 1 (pid1.elf)
// injects `flash\nflash\n` into the console RX ring before exec'ing
// /bin/login so the unattended QEMU boot watchdog authenticates with no
// interactive typist (run_qemu_test.sh feeds QEMU `</dev/null`). On real
// hardware that seed must NOT fire — the boot has to stop at the `login:`
// prompt and demand a password — so the injection is gated on this flag
// and `zig build deploy` (which omits it) ships a kernel that requires a
// real login. The watchdog steps build with `-Dci-login-seed=true`; a
// forgotten flag fails loud (the boot hangs at `login:` → watchdog
// timeout) rather than silently shipping an open shell. The login path
// itself is identical either way — only the pre-seed differs.
const ci_login_seed = b.option(
bool,
"ci-login-seed",
"Seed flash/flash into the console before /bin/login for the unattended QEMU watchdog (CI only; never for hardware deploys)",
) orelse false;
build_options.addOption(bool, "ci_login_seed", ci_login_seed);
// In-kernel self-test harness gate (default OFF). When set, PID 1 runs
// the [TEST] scenario suite + tally before handing off to /bin/login —
// the boot-as-test path the QEMU watchdog (run_qemu_test.sh) asserts
// (28 scenarios, 32 free-page checkpoints). Default OFF so `zig build
// deploy` / `run` produce a clean boot straight to the login prompt with
// no test wall. The watchdog/CI builds pass `-Dboot-selftest=true`
// alongside `-Dci-login-seed=true`; a forgotten flag fails loud (no
// scenarios → watchdog guard mismatch) rather than silently shipping an
// unvalidated boot. Comptime-gated, so a `-Dboot-selftest=true` build is
// byte-identical to the pre-gate harness build (the boot contract and
// free-page checkpoints never move when the flag is on).
const boot_selftest = b.option(
bool,
"boot-selftest",
"Run the in-kernel [TEST] self-test harness at PID 1 (CI/validation builds); default OFF for a clean boot",
) orelse false;
build_options.addOption(bool, "boot_selftest", boot_selftest);
// Statistical kernel profiler (default OFF — the released kernel carries
// zero of it). With -Dtrace the timer/IRQ entry threads the saved
// exception frame to a frame-pointer-walking sampler that prints a
// symbolized kernel backtrace at tick boundaries. Two things flip
// together off this one flag: the C-preprocessor macro FLASHOS_TRACE
// (added to the kernel module below, so entry.S inserts the one
// `mov x0, sp` that hands the frame to handle_irq) and a Zig comptime
// gate (so the sampler code is only compiled in). Default build: no
// macro, no sampler — entry.S and every kernel symbol are byte-identical
// to a non-trace build, so the boot contract and symbol table never move.
const trace = b.option(
bool,
"trace",
"Build the kernel with the statistical FP-walk profiler (prints a symbolized backtrace at each tick; off by default, zero footprint when off)",
) orelse false;
build_options.addOption(bool, "trace", trace);
// One shared build_options module for every module that links into the
// kernel ELF. `addOptions` would mint a *new* module from the same
// generated options.zig per call; once fork.zig became its own Flash
// module (it imports build_options for the verbose-fork gate) that second
// module collided with kernel_mod's — "file exists in modules
// 'build_options' and 'build_options0'". A single createModule() shared
// via addImport keeps the file in exactly one module. Standalone
// executables / host tests are separate compilations and keep addOptions.
const build_options_mod = build_options.createModule();
// Coverage builds force the LLVM backend for host test binaries:
// zig's self-hosted x86_64 backend (the Debug-mode default on
// x86_64-linux) emits DWARF that kcov cannot read, so coverage data
// silently vanishes. Only host test binaries are affected; kernel
// artifacts never see this option.
host_tests_use_llvm = b.option(
bool,
"coverage",
"Force the LLVM backend for host test binaries (kcov-readable DWARF)",
) orelse false;
// Substring filter for the host-test step: `zig build test -Dtest-filter=foo`
// runs only tests whose name contains "foo". Null (default) runs the suite.
host_test_filter = b.option(
[]const u8,
"test-filter",
"Run only host tests whose name contains this substring",
);
// Path to the flashc transpiler (Flash -> Zig). Modules ported to
// Flash (*.flash) transpile at build time via addFlashSource; the
// pinned compiler revision lives in flash-toolchain.lock. The default
// expects that checkout at ~/Flash, built with `zig build`.
flashc_path = b.option(
[]const u8,
"flashc",
"Path to the flashc transpiler binary (default: ~/Flash/zig-out/bin/flashc-stage1)",
) orelse blk: {
const home = b.graph.environ_map.get("HOME") orelse break :blk "flashc-stage1";
break :blk b.pathJoin(&.{ home, "Flash", "zig-out", "bin", "flashc-stage1" });
};
// ---- hygiene checks (trailing space, hard tabs, lowercase hex) ----
const hygiene_step = b.step("check-hygiene", "Fail on whitespace or hex-literal regressions");
const whitespace_check = b.addSystemCommand(&.{ "sh", "scripts/check_whitespace_hygiene.sh" });
hygiene_step.dependOn(&whitespace_check.step);
const hex_check = b.addSystemCommand(&.{ "sh", "scripts/check_hex_hygiene.sh" });
hygiene_step.dependOn(&hex_check.step);
// Shared syscall ID constants — single source of truth for the
// kernel-side dispatch table (src/sys.zig) and the user-side
// wrappers (user_space/kernel_tests.zig). Exposed as a named module
// because Zig 0.16 forbids `@import` reaching outside the importing
// module's root directory.
// lib/syscall_defs.flash is the source of truth; flashc transpiles it to
// Zig at build time via addFlashSource. The generated .zig is shared by
// the kernel/userspace module here and the host-test module below, so the
// transpile runs once.
const syscall_defs_src = addFlashSource(b, "lib/syscall_defs.flash");
const syscall_defs_mod = b.createModule(.{
.root_source_file = syscall_defs_src,
.target = target,
.optimize = optimize,
});
// console_ui — shared terminal look (status tags, ANSI palette, the
// boot-success marker, and the line/stage/banner renderers). Pure and
// target-agnostic (no .target, like shadow_mod): the one source compiles
// into every console-drawing binary — the kernel boot log and the
// userspace tools — so the whole system restyles from a single file.
// Output is routed through a caller-supplied Sink, so it depends on
// neither kernel internals nor flibc. Added to consumers below
// (kernel_mod, fsh_mod); unused until a call site @imports it, so staging
// it leaves every image byte-identical.
// console_ui is a multi-file Flash module: console_ui.flash re-exports its
// palette / tags / screen siblings through relative imports. flashc
// transpiles one file at a time, so compose the generated .zig into a single
// directory where each `@import("palette.zig")` sibling resolves — the same
// per-stage WriteFiles composition the Flash toolchain uses for its own
// std/selfhost modules. lib/console_ui/*.flash is the source of truth.
const console_ui_dir = b.addWriteFiles();
const console_ui_files = [_][]const u8{ "palette", "tags", "screen", "console_ui" };
var console_ui_root: std.Build.LazyPath = undefined;
var console_ui_screen_src: std.Build.LazyPath = undefined;
for (console_ui_files) |name| {
const gen = addFlashSource(b, b.fmt("lib/console_ui/{s}.flash", .{name}));
const dest = console_ui_dir.addCopyFile(gen, b.fmt("{s}.zig", .{name}));
if (std.mem.eql(u8, name, "console_ui")) console_ui_root = dest;
if (std.mem.eql(u8, name, "screen")) console_ui_screen_src = dest;
}
const console_ui_mod = b.createModule(.{
.root_source_file = console_ui_root,
});
// User-space virtual address layout (text/data/heap/stack bases +
// per-region permission bits). Kernel-only consumer for now —
// src/fork.zig (prepare_move_to_user_elf) and src/mm_user.zig
// (map_page, do_data_abort) share the constants. Same module-level
// exposure pattern as syscall_defs_mod.
const user_layout_src = addFlashSource(b, "src/user_layout.flash");
const user_layout_mod = b.createModule(.{
.root_source_file = user_layout_src,
.target = target,
.optimize = optimize,
});
// TaskStruct/CoreContext/etc. layout module. Already implicitly
// imported by kernel-root modules via `@import("task_layout.zig")`,
// but the named modules (wait_queue, pipe) need
// an explicit named import to keep task_layout.zig from being
// pulled into two sibling named modules through relative paths
// (which Zig 0.16 rejects as "file exists in two modules").
const task_layout_src = addFlashSource(b, "src/task_layout.flash");
const task_layout_mod = b.createModule(.{
.root_source_file = task_layout_src,
.target = target,
.optimize = optimize,
});
// WaitQueue API. Named module so both kernel and
// host-test builds reach it via `@import("wait_queue")` — the host
// test wiring at the bottom of this file mirrors this for the
// pipe.zig test root. The one flashc transpile (wait_queue_src) is
// shared across the kernel root and both test roots below.
const wait_queue_src = addFlashSource(b, "src/wait_queue.flash");
const wait_queue_mod = b.createModule(.{
.root_source_file = wait_queue_src,
.target = target,
.optimize = optimize,
});
wait_queue_mod.addImport("task_layout", task_layout_mod);
// Anonymous-pipe module. Pulls in wait_queue for
// the blocking read/write paths; kernel-only for now (future work
// generalises to a tagged ?*File once the FS lands). The one flashc
// transpile (pipe_src) is shared across the kernel root and both
// test roots below.
const pipe_src = addFlashSource(b, "src/pipe.flash");
const pipe_mod = b.createModule(.{
.root_source_file = pipe_src,
.target = target,
.optimize = optimize,
});
pipe_mod.addImport("wait_queue", wait_queue_mod);
pipe_mod.addImport("task_layout", task_layout_mod);
// Initramfs parser module. Pure-data newc cpio
// walker with linker-provided section bounds; no external imports
// needed in freestanding (the host-test build flips a comptime
// branch onto fixture globals — see src/initramfs.flash). Ported to
// Flash; the one flashc transpile is shared with the host-test builds
// below (src_lazy), including initramfs_backend's test module.
const initramfs_src = addFlashSource(b, "src/initramfs.flash");
const initramfs_mod = b.createModule(.{
.root_source_file = initramfs_src,
.target = target,
.optimize = optimize,
});
// ELF64 header + program-header parser. The loader in src/fork.zig
// imports it as the named module "elf". Ported to Flash and promoted
// to a named module: the generated .zig lives in the flashc cache, so
// fork.zig's former file-relative @import("elf.zig") could no longer
// resolve. Imports user_layout for the TEXT_BASE / DATA_BASE /
// STACK_LOW bounds the validators pin against.
const elf_src = addFlashSource(b, "src/elf.flash");
const elf_mod = b.createModule(.{
.root_source_file = elf_src,
.target = target,
.optimize = optimize,
});
elf_mod.addImport("user_layout", user_layout_mod);
// Physical page allocator (bitmap over the MALLOC pool). A leaf
// module — its only dependencies are assembly-side externs
// (memzero / main_output*). Ported to Flash and promoted to a named
// module: the generated .zig lives in the flashc cache, so
// start.zig's former file-relative @import("page_alloc.zig") could
// no longer resolve. The one transpile is shared with the host-test
// build below (src_lazy).
const page_alloc_src = addFlashSource(b, "src/page_alloc.flash");
const page_alloc_mod = b.createModule(.{
.root_source_file = page_alloc_src,
.target = target,
.optimize = optimize,
});
// User-page mapping + page-table walk + the EL0 fault handlers.
// Ported to Flash; flashc transpiles it via addFlashSource. start.zig
// pulls the export fns into the ELF with `_ = @import("mm_user")`;
// sys.zig / fork.zig reach copy_from_user / map_page / do_data_abort
// through C-ABI `extern fn`. The one transpile is shared with the
// host-test build below (src_lazy).
const mm_user_src = addFlashSource(b, "src/mm_user.flash");
const mm_user_mod = b.createModule(.{
.root_source_file = mm_user_src,
.target = target,
.optimize = optimize,
});
mm_user_mod.addImport("task_layout", task_layout_mod);
mm_user_mod.addImport("user_layout", user_layout_mod);
// File handle module. Owns the open_files
// lifetime helpers (alloc / unref / fdAlloc / fdGet / fdClose /
// dupAll / closeAll). Imports task_layout for TaskStruct + File
// (which lives in task_layout.zig to break the circular import
// with the typed `open_files: [_]?*File` slot).
const file_mod = b.createModule(.{
.root_source_file = b.path("src/file.zig"),
.target = target,
.optimize = optimize,
});
file_mod.addImport("task_layout", task_layout_mod);
// The one flashc transpile (fdtable_src) is shared across the kernel
// root and both test roots below.
const fdtable_src = addFlashSource(b, "src/fdtable.flash");
const fdtable_mod = b.createModule(.{
.root_source_file = fdtable_src,
.target = target,
.optimize = optimize,
});
fdtable_mod.addImport("task_layout", task_layout_mod);
fdtable_mod.addImport("pipe", pipe_mod);
fdtable_mod.addImport("file", file_mod);
// VFS dispatch layer. 1-bit superblock tag +
// two-slot mount table; imports `file` for the File type its
// vtable signatures reference. Host-test wiring for vfs.zig lives
// at the bottom of this file.
const vfs_mod = b.createModule(.{
.root_source_file = b.path("src/vfs.zig"),
.target = target,
.optimize = optimize,
});
vfs_mod.addImport("file", file_mod);
// vfs.zig re-exports the shared Dirent ABI type for the
// readdir vtable signature.
vfs_mod.addImport("syscall_defs", syscall_defs_mod);
// Initramfs VFS backend. Thin wrapper turning
// initramfs.zig's locate/read/seek into a VfsOps vtable — kept
// separate from initramfs.zig so the parser stays VFS-agnostic
// and host-testable in isolation.
// Transpiled from src/initramfs_backend.flash; shared with the
// host-test build below (src_lazy).
const initramfs_backend_src = addFlashSource(b, "src/initramfs_backend.flash");
const initramfs_backend_mod = b.createModule(.{
.root_source_file = initramfs_backend_src,
.target = target,
.optimize = optimize,
});
initramfs_backend_mod.addImport("initramfs", initramfs_mod);
initramfs_backend_mod.addImport("vfs", vfs_mod);
initramfs_backend_mod.addImport("file", file_mod);
// Block-device abstraction. Single global
// `sd_dev` vtable that the FAT32 backend reads + writes
// through; the board layer (src/board/<board>/emmc2.zig)
// populates `read_fn` / `write_fn` post-init. No tests
// (pure data + one extern struct).
const block_dev_src = addFlashSource(b, "src/block_dev.flash");
const block_dev_mod = b.createModule(.{
.root_source_file = block_dev_src,
.target = target,
.optimize = optimize,
});
// SDHCI command encoder + CSD parser.
// Named module so the rpi4b BCM2711 EMMC2 driver
// (src/board/rpi4b/emmc2.zig) can `@import("sdhci_cmd")`
// for the CMD0..ACMD41 encodings and parseCsdV2. Ported to Flash;
// flashc transpiles it via addFlashSource and the one transpile is
// shared with the host-test build below (src_lazy).
const sdhci_cmd_src = addFlashSource(b, "src/sdhci_cmd.flash");
const sdhci_cmd_mod = b.createModule(.{
.root_source_file = sdhci_cmd_src,
.target = target,
.optimize = optimize,
});
// VideoCore mailbox — property-tag message construction + parsing.
// Pure data; the rpi4b board side
// (src/board/rpi4b/mailbox.zig) wraps it with the MMIO doorbell so
// the EMMC2 driver can read the firmware-set base clock and derive
// a safe SDHCI divider. Ported to Flash; flashc transpiles it via
// addFlashSource and the one transpile is shared with the host-test
// build below (src_lazy).
const mailbox_src = addFlashSource(b, "src/mailbox.flash");
const mailbox_mod = b.createModule(.{
.root_source_file = mailbox_src,
.target = target,
.optimize = optimize,
});
// USB descriptor set + SETUP decode (DWC2 gadget). Pure data; the
// rpi4b board side (src/board/rpi4b/usb.zig) imports it as
// "usb_descriptors". Ported to Flash; flashc transpiles it via
// addFlashSource and the one transpile is shared with the host-test
// build below (src_lazy).
const usb_descriptors_src = addFlashSource(b, "src/usb_descriptors.flash");
const usb_descriptors_mod = b.createModule(.{
.root_source_file = usb_descriptors_src,
.target = target,
.optimize = optimize,
});
// Bulk-IN TX byte-ring for the DWC2 CDC-ACM gadget. Pure
// data + logic; src/board/rpi4b/usb.zig imports it as "usb_tx_ring"
// and keeps only the MMIO FIFO push. Ported to Flash; flashc transpiles
// it via addFlashSource and the one transpile is shared with the
// host-test build below (src_lazy).
const usb_tx_ring_src = addFlashSource(b, "src/usb_tx_ring.flash");
const usb_tx_ring_mod = b.createModule(.{
.root_source_file = usb_tx_ring_src,
.target = target,
.optimize = optimize,
});
// Per-board driver leaves (src/board/<board>/*). Ported to Flash, these
// can no longer be reached by src/board.zig's relative `@import(
// "board/<board>/x.zig")` — the generated .zig lives in the build cache.
// Each is promoted to a named module; board.zig's comptime switch selects
// the active board's prong via the named import below. The non-selected
// prong is dead comptime code, so registering both boards' leaves here is
// harmless (the unused module is never compiled).
const virt_timer_src = addFlashSource(b, "src/board/virt/timer.flash");
const virt_timer_mod = b.createModule(.{
.root_source_file = virt_timer_src,
.target = target,
.optimize = optimize,
});
const virt_gpio_src = addFlashSource(b, "src/board/virt/gpio.flash");
const virt_gpio_mod = b.createModule(.{
.root_source_file = virt_gpio_src,
.target = target,
.optimize = optimize,
});
const virt_power_src = addFlashSource(b, "src/board/virt/power.flash");
const virt_power_mod = b.createModule(.{
.root_source_file = virt_power_src,
.target = target,
.optimize = optimize,
});
const virt_mailbox_src = addFlashSource(b, "src/board/virt/mailbox.flash");
const virt_mailbox_mod = b.createModule(.{
.root_source_file = virt_mailbox_src,
.target = target,
.optimize = optimize,
});
const virt_usb_src = addFlashSource(b, "src/board/virt/usb.flash");
const virt_usb_mod = b.createModule(.{
.root_source_file = virt_usb_src,
.target = target,
.optimize = optimize,
});
const virt_emmc2_src = addFlashSource(b, "src/board/virt/emmc2.flash");
const virt_emmc2_mod = b.createModule(.{
.root_source_file = virt_emmc2_src,
.target = target,
.optimize = optimize,
});
virt_emmc2_mod.addImport("block_dev", block_dev_mod);
// FDT parser — a sibling of uart/irq, not a board.zig switch prong;
// virt_uart and virt_irq reach it as the named "virt_dtb" import.
const virt_dtb_src = addFlashSource(b, "src/board/virt/dtb.flash");
const virt_dtb_mod = b.createModule(.{
.root_source_file = virt_dtb_src,
.target = target,
.optimize = optimize,
});
const virt_uart_src = addFlashSource(b, "src/board/virt/uart.flash");
const virt_uart_mod = b.createModule(.{
.root_source_file = virt_uart_src,
.target = target,
.optimize = optimize,
});
virt_uart_mod.addImport("virt_dtb", virt_dtb_mod);
// Kernel-log byte-ring (overwrite-oldest) backing dmesg. Pure
// data + logic; src/utilc.zig tees main_output into it and src/sys.zig
// snapshots it for sys_klog_read — both reach the one `klog` global
// through this single named module. Imports syscall_defs for KLOG_SIZE
// (the ring capacity is ABI-shared with userland dmesg). Ported to Flash;
// flashc transpiles it via addFlashSource and the one transpile is shared
// with the host-test build below (src_lazy).
const klog_ring_src = addFlashSource(b, "src/klog_ring.flash");
const klog_ring_mod = b.createModule(.{
.root_source_file = klog_ring_src,
.target = target,
.optimize = optimize,
});
klog_ring_mod.addImport("syscall_defs", syscall_defs_mod);
// utilc — kernel utility exports (hex render, byte mem*, UART tee,
// panic). Ported to Flash; flashc transpiles it to Zig at build time
// via addFlashSource. start.zig pulls the symbols into the ELF with
// `_ = @import("utilc")` (named, like sched/execve); every other
// consumer reaches them through a C-ABI `extern fn` declaration, so the
// import graph needs only this one named module. The one transpile is
// shared with the host-test build below (src_lazy).
const utilc_src = addFlashSource(b, "src/utilc.flash");
const utilc_mod = b.createModule(.{
.root_source_file = utilc_src,
.target = target,
.optimize = optimize,
});
utilc_mod.addImport("task_layout", task_layout_mod);
utilc_mod.addImport("klog_ring", klog_ring_mod);
// sha256 — SHA-256 / HMAC / PBKDF2 / constant-time compare.
// Target-agnostic (no .target) so both the freestanding kernel and the
// host-side gen_shadow tool import the one source. Pure, no imports.
//
// Always ReleaseSmall, even in Debug kernel builds: sys_authenticate
// runs the PBKDF2 → HMAC → SHA-256 chain on the per-task kernel stack
// (the 4 KiB TaskStruct page, ~2.4 KiB usable), and Debug-mode frames
// (no register allocation, 256-byte compress W-array + value-copied
// hasher states per level) overflow that budget — the overflow lands in
// the TaskStruct tail and silently corrupts the credential fields.
// ReleaseSmall keeps the deepest chain comfortably inside the page (and
// makes the boot-path KDF an order of magnitude faster under QEMU TCG).
// The module is pure wrapping arithmetic (+%), so no Debug safety
// checks are lost that the host-test target (its own Debug module)
// doesn't still run. Ported to Flash; flashc transpiles it via
// addFlashSource and the one transpile is shared with the host-test
// build below (src_lazy). The .ReleaseSmall pin stays on this module.
const sha256_src = addFlashSource(b, "src/sha256.flash");
const sha256_mod = b.createModule(.{
.root_source_file = sha256_src,
.optimize = .ReleaseSmall,
});
// shadow — /etc/shadow line parser + hex decoder. Pure. Ported to
// Flash; the one transpile is shared with the host-test build below.
const shadow_src = addFlashSource(b, "src/shadow.flash");
const shadow_mod = b.createModule(.{
.root_source_file = shadow_src,
});
// perm — Unix discretionary access check. Pure decision
// function (checkAccess) shared by the syscall-layer enforcement
// sites; the truth-table host test below is the gate.
const perm_src = addFlashSource(b, "src/perm.flash");
const perm_mod = b.createModule(.{
.root_source_file = perm_src,
});
// overlay — FAT32 permission-overlay parser. Pure parse +
// lookup consumed by fat32_backend (PERMS.TAB -> per-file mode/uid/gid).
const overlay_src = addFlashSource(b, "src/overlay.flash");
const overlay_mod = b.createModule(.{
.root_source_file = overlay_src,
});
// pwfile — /etc/passwd parser. Pure name/uid lookups shared
// by the kernel (sys_passwd authorization), /bin/login, and fsh's
// whoami builtin.
const pwfile_src = addFlashSource(b, "src/pwfile.flash");
const pwfile_mod = b.createModule(.{
.root_source_file = pwfile_src,
});
// hwrng — kernel entropy source (timer-backed SplitMix64 fallback).
// Ported to Flash; flashc transpiles it via addFlashSource. start.zig
// pulls hwrng_init into the ELF with `_ = @import("hwrng")` and sys.zig
// reaches fill()/Source through the same named module; kernel.zig calls
// hwrng_init via a C-ABI `extern fn`. console_ui supplies the boot Sink.
// The one transpile is shared with the host-test build below (src_lazy).
const hwrng_src = addFlashSource(b, "src/hwrng.flash");
const hwrng_mod = b.createModule(.{
.root_source_file = hwrng_src,
.target = target,
.optimize = optimize,
});
hwrng_mod.addImport("console_ui", console_ui_mod);
// generic_timer — generic ARM timer driver (absolute CNTP_CVAL cadence).
// Ported to Flash; flashc transpiles it via addFlashSource. start.zig
// pulls generic_timer_init / handle_generic_timer into the ELF with
// `_ = @import("generic_timer")`; kernel.zig calls generic_timer_init via
// a C-ABI `extern fn` and irq.S reaches handle_generic_timer by symbol.
// Pure externs — no module imports.
const generic_timer_src = addFlashSource(b, "src/generic_timer.flash");
const generic_timer_mod = b.createModule(.{
.root_source_file = generic_timer_src,
.target = target,
.optimize = optimize,
});
// FAT32 on-disk layout decode + cluster/FAT/dir helpers.
// Pure data-shape module — no VFS / file / page
// imports; takes the BlockDev vtable by runtime pointer so the
// host tests can swap in an in-memory fake.
// fat32_backend.zig consumes this module to wire the real VfsOps.
// Ported to Flash; the one flashc transpile is shared with the
// host-test builds below (src_lazy), including fat32_backend's test
// module.
const fat32_src = addFlashSource(b, "src/fat32.flash");
const fat32_mod = b.createModule(.{
.root_source_file = fat32_src,
.target = target,
.optimize = optimize,
});
fat32_mod.addImport("block_dev", block_dev_mod);
// FAT32 VFS backend. Wraps fat32.zig's
// on-disk decode in the real VfsOps vtable; replaces the earlier
// fat32_stub.
// Ported to Flash; the one flashc transpile is shared with the
// host-test build below (src_lazy).
const fat32_backend_src = addFlashSource(b, "src/fat32_backend.flash");
const fat32_backend_mod = b.createModule(.{
.root_source_file = fat32_backend_src,
.target = target,
.optimize = optimize,
});
fat32_backend_mod.addImport("fat32", fat32_mod);
fat32_backend_mod.addImport("vfs", vfs_mod);
fat32_backend_mod.addImport("file", file_mod);
fat32_backend_mod.addImport("block_dev", block_dev_mod);
// Permission overlay: PERMS.TAB parse + lookup.
fat32_backend_mod.addImport("overlay", overlay_mod);
// Console RX layer. 256-byte ring + WaitQueue
// backing the unified console read. Same named-module wiring as wait_queue
// / pipe so the kernel build and the host-test build share one
// task_layout Module instance.
// The one flashc transpile (console_src) is shared across the kernel
// root and the host-test root below.
const console_src = addFlashSource(b, "src/console.flash");
const console_mod = b.createModule(.{
.root_source_file = console_src,
.target = target,
.optimize = optimize,
});
console_mod.addImport("wait_queue", wait_queue_mod);
console_mod.addImport("task_layout", task_layout_mod);
// Scheduler module. Promoted from a relative-path
// import to a named module so sys.zig can `@import("sched")` and call
// the pure helpers (pick_next_running / refill_counters /
// zombify_and_wake_parent) without re-declaring extern signatures.
// Imports pipe + task_layout because sched.zig consumes both
// (pipe.closeAll in do_wait_impl; TaskStruct from task_layout).
// Ported to Flash; the one transpile feeds the kernel module and
// sched's own host test below (src_lazy).
const sched_src = addFlashSource(b, "src/sched.flash");
const sched_mod = b.createModule(.{
.root_source_file = sched_src,
.target = target,
.optimize = optimize,
});
sched_mod.addImport("task_layout", task_layout_mod);
sched_mod.addImport("fdtable", fdtable_mod);
// Pure cwd-aware path-resolution helper. Hosts
// joinResolve, the single non-recursive `.` / `..` collapse shared
// by sys_chdir, sys_openFile, and execveKernel. Pure — no imports,
// no externs — so the freestanding kernel module and the host-test
// module reach the same source through this single named module.
// Ported to Flash; the one transpile feeds the kernel module, the
// execve host-test "path" alias, and path's own host test below.
const path_src = addFlashSource(b, "src/path.flash");
const path_mod = b.createModule(.{
.root_source_file = path_src,
.target = target,
.optimize = optimize,
});
// Path-resolved ELF loader. Ported to Flash; flashc transpiles it via
// addFlashSource. The one transpile is shared between the kernel module
// and the execve host-test below (src_lazy). start.zig pulls execve_impl
// into the ELF; fork imports execve for the ArgvBlock type.
const execve_src = addFlashSource(b, "src/execve.flash");
const execve_mod = b.createModule(.{
.root_source_file = execve_src,
.target = target,
.optimize = optimize,
});
// Kernel-build imports for execveKernel (path-resolve + PT_LOAD stream).
// The host-test build (build.zig below) wires src/execve.flash with no
// kernel imports; the comptime is_kernel guard keeps these out of host
// analysis.
execve_mod.addImport("task_layout", task_layout_mod);
execve_mod.addImport("vfs", vfs_mod);
execve_mod.addImport("user_layout", user_layout_mod);
execve_mod.addImport("path", path_mod);
// Permission gate: exec-intent check + the EACCES constant.
execve_mod.addImport("perm", perm_mod);
execve_mod.addImport("syscall_defs", syscall_defs_mod);
// Process creation + move-to-user ELF loader. Ported to Flash; flashc
// transpiles it via addFlashSource. start.zig pulls the export fns
// (copy_process_impl / prepare_move_to_user_elf / move_to_user_elf_argv)
// into the ELF with `_ = @import("fork")`; sys.zig / kernel.zig reach
// them through C-ABI `extern fn`. fork imports execve for the ArgvBlock
// type (execve itself reaches back through the exported trampoline, so
// there is no import cycle). The one transpile is shared with the
// host-test build below (src_lazy).
const fork_src = addFlashSource(b, "src/fork.flash");
const fork_mod = b.createModule(.{
.root_source_file = fork_src,
.target = target,
.optimize = optimize,
});
fork_mod.addImport("task_layout", task_layout_mod);
fork_mod.addImport("fdtable", fdtable_mod);
fork_mod.addImport("user_layout", user_layout_mod);
fork_mod.addImport("elf", elf_mod);
fork_mod.addImport("execve", execve_mod);
fork_mod.addImport("build_options", build_options_mod);
// Syscall dispatch table + handlers. Ported to Flash; flashc transpiles
// it via addFlashSource. Moved from a relative `@import("sys.zig")` in
// start.zig to a named module — the generated .zig lives in the build
// cache, so the path import no longer resolves. start.zig force-includes
// it (`_ = @import("sys")`) so the dispatch table + every export fn land
// in the ELF; entry.S reaches sys_call_table by symbol and kernel.flash
// calls sys_call_table_relocate through a C-ABI `extern fn`. The board
// driver bag is not imported here — sys reaches its three board entry
// points through C-ABI trampolines in src/start.zig (the kernel root
// module, which imports the board bag as a named module). No host test:
// sys.zig carries no test blocks.
const sys_src = addFlashSource(b, "src/sys.flash");
const sys_mod = b.createModule(.{
.root_source_file = sys_src,
.target = target,
.optimize = optimize,
});
sys_mod.addImport("syscall_defs", syscall_defs_mod);
sys_mod.addImport("task_layout", task_layout_mod);
sys_mod.addImport("user_layout", user_layout_mod);
sys_mod.addImport("pipe", pipe_mod);
sys_mod.addImport("console", console_mod);
sys_mod.addImport("sched", sched_mod);
sys_mod.addImport("vfs", vfs_mod);
sys_mod.addImport("file", file_mod);
sys_mod.addImport("fdtable", fdtable_mod);
sys_mod.addImport("path", path_mod);
sys_mod.addImport("klog_ring", klog_ring_mod);
sys_mod.addImport("sha256", sha256_mod);
sys_mod.addImport("shadow", shadow_mod);
sys_mod.addImport("perm", perm_mod);
sys_mod.addImport("pwfile", pwfile_mod);
sys_mod.addImport("hwrng", hwrng_mod);
// Boot sequence + main loop. Ported to Flash; flashc transpiles it via
// addFlashSource. Moved from a relative `@import("kernel.zig")` in
// start.zig to a named module — the generated .zig lives in the build
// cache, so the path import no longer resolves. start.zig force-includes
// it (`_ = @import("kernel")`) so kernel_main_impl + the other export fns
// land in the ELF; boot.S/entry.S reach kernel_main by symbol. The board
// driver bag is not imported here — kernel reaches its board entry points
// through C-ABI trampolines in src/start.zig (the kernel root imports the
// board bag as a named module). No host test: kernel.zig carries no tests.
const kernel_src = addFlashSource(b, "src/kernel.flash");
const kernel_kmod = b.createModule(.{
.root_source_file = kernel_src,
.target = target,
.optimize = optimize,
});
kernel_kmod.addImport("initramfs", initramfs_mod);
kernel_kmod.addImport("initramfs_backend", initramfs_backend_mod);
kernel_kmod.addImport("fat32_backend", fat32_backend_mod);
kernel_kmod.addImport("fdtable", fdtable_mod);
kernel_kmod.addImport("task_layout", task_layout_mod);
kernel_kmod.addImport("console_ui", console_ui_mod);
kernel_kmod.addImport("build_options", build_options_mod);
// rpi4b board driver leaves promoted to Flash named modules (same
// pattern as the virt set above). gpio/timer/power/uart are board.zig
// switch prongs; rpi4b_mailbox is the VideoCore MMIO doorbell consumed
// by the rpi4b emmc2/usb board drivers (it can't take the name
// "mailbox" — that's the pure message-layout data module it imports).
// Created here so rpi4b_uart can reach console_mod and rpi4b_mailbox
// can reach mailbox_mod.
const rpi4b_gpio_src = addFlashSource(b, "src/board/rpi4b/gpio.flash");
const rpi4b_gpio_mod = b.createModule(.{
.root_source_file = rpi4b_gpio_src,
.target = target,
.optimize = optimize,
});
const rpi4b_timer_src = addFlashSource(b, "src/board/rpi4b/timer.flash");
const rpi4b_timer_mod = b.createModule(.{
.root_source_file = rpi4b_timer_src,
.target = target,
.optimize = optimize,
});
const rpi4b_power_src = addFlashSource(b, "src/board/rpi4b/power.flash");
const rpi4b_power_mod = b.createModule(.{
.root_source_file = rpi4b_power_src,
.target = target,
.optimize = optimize,
});
const rpi4b_mailbox_src = addFlashSource(b, "src/board/rpi4b/mailbox.flash");
const rpi4b_mailbox_mod = b.createModule(.{
.root_source_file = rpi4b_mailbox_src,
.target = target,
.optimize = optimize,
});
rpi4b_mailbox_mod.addImport("mailbox", mailbox_mod);
const rpi4b_uart_src = addFlashSource(b, "src/board/rpi4b/uart.flash");
const rpi4b_uart_mod = b.createModule(.{
.root_source_file = rpi4b_uart_src,
.target = target,
.optimize = optimize,
});
rpi4b_uart_mod.addImport("console", console_mod);
const rpi4b_emmc2_src = addFlashSource(b, "src/board/rpi4b/emmc2.flash");
const rpi4b_emmc2_mod = b.createModule(.{
.root_source_file = rpi4b_emmc2_src,
.target = target,
.optimize = optimize,
});
rpi4b_emmc2_mod.addImport("sdhci_cmd", sdhci_cmd_mod);
rpi4b_emmc2_mod.addImport("block_dev", block_dev_mod);
rpi4b_emmc2_mod.addImport("mailbox", mailbox_mod);
rpi4b_emmc2_mod.addImport("rpi4b_mailbox", rpi4b_mailbox_mod);
const rpi4b_usb_src = addFlashSource(b, "src/board/rpi4b/usb.flash");
const rpi4b_usb_mod = b.createModule(.{
.root_source_file = rpi4b_usb_src,
.target = target,
.optimize = optimize,
});
rpi4b_usb_mod.addImport("usb_descriptors", usb_descriptors_mod);
rpi4b_usb_mod.addImport("usb_tx_ring", usb_tx_ring_mod);
rpi4b_usb_mod.addImport("mailbox", mailbox_mod);
rpi4b_usb_mod.addImport("rpi4b_mailbox", rpi4b_mailbox_mod);
rpi4b_usb_mod.addImport("console", console_mod);
// Trace-cluster named modules (kept in Zig; the profiler is opt-in and
// not on the boot contract). Promoted from kernel_mod-relative imports so
// the Flash-sourced IRQ drivers can reach the sampler by name — a named
// module can no longer be pulled in through a relative path once its
// source moves to the build cache. ksyms is force-imported by start.zig
// in every build (its symbol table backs the trace machinery); sampler +
// fp_walk are referenced only by the IRQ handlers' -Dtrace seam, so the
// default build never compiles them (the import sits in a dead comptime
// branch). Promoting all three to named modules keeps ksyms a member of
// exactly one module instead of landing in both kernel_mod and sampler.
const ksyms_mod = b.createModule(.{
.root_source_file = b.path("src/trace/ksyms.zig"),
.target = target,
.optimize = optimize,
});
const fp_walk_mod = b.createModule(.{
.root_source_file = b.path("src/trace/fp_walk.zig"),
.target = target,
.optimize = optimize,
});
const sampler_mod = b.createModule(.{
.root_source_file = b.path("src/trace/sampler.zig"),
.target = target,
.optimize = optimize,
});
sampler_mod.addImport("task_layout", task_layout_mod);
sampler_mod.addImport("ksyms", ksyms_mod);
sampler_mod.addImport("fp_walk", fp_walk_mod);
// Per-board interrupt controllers — GICv2 (GIC-400) on rpi4b, GICv3 on
// virt — promoted to Flash named modules. board.zig's irq prong selects
// the active one; the non-selected prong is dead comptime code. Both
// reach the -Dtrace sampler by name (trace_sampler seam in handle_irq);
// the default build leaves that import in a dead comptime branch, so no
// trace module is compiled and the kernel image is byte-identical.
const rpi4b_irq_src = addFlashSource(b, "src/board/rpi4b/irq.flash");
const rpi4b_irq_mod = b.createModule(.{
.root_source_file = rpi4b_irq_src,
.target = target,
.optimize = optimize,
});
rpi4b_irq_mod.addImport("console", console_mod);
rpi4b_irq_mod.addImport("rpi4b_usb", rpi4b_usb_mod);
rpi4b_irq_mod.addImport("build_options", build_options_mod);
rpi4b_irq_mod.addImport("task_layout", task_layout_mod);
rpi4b_irq_mod.addImport("sampler", sampler_mod);
const virt_irq_src = addFlashSource(b, "src/board/virt/irq.flash");
const virt_irq_mod = b.createModule(.{
.root_source_file = virt_irq_src,
.target = target,
.optimize = optimize,
});
virt_irq_mod.addImport("virt_dtb", virt_dtb_mod);
virt_irq_mod.addImport("virt_uart", virt_uart_mod);
virt_irq_mod.addImport("console", console_mod);
virt_irq_mod.addImport("build_options", build_options_mod);
virt_irq_mod.addImport("task_layout", task_layout_mod);
virt_irq_mod.addImport("sampler", sampler_mod);
// board: comptime indirection that aliases each driver slot to the active
// board's leaf module. Ported to Flash; flashc transpiles it via
// addFlashSource. Moved from a relative `@import("board.zig")` in start.zig
// to a named module — the generated .zig lives in the build cache, so the
// path import no longer resolves. start.zig force-imports it (`_ =
// board.uart` etc.) so each driver's `export fn` decls land in the ELF.
// Both boards' leaf modules are imported here; board.zig's comptime switch
// on build_options.board selects the active set, so the linker only keeps
// the chosen prong.
const board_src = addFlashSource(b, "src/board.flash");
const board_mod = b.createModule(.{
.root_source_file = board_src,
.target = target,
.optimize = optimize,
});
board_mod.addImport("build_options", build_options_mod);
board_mod.addImport("virt_uart", virt_uart_mod);
board_mod.addImport("rpi4b_uart", rpi4b_uart_mod);
board_mod.addImport("virt_gpio", virt_gpio_mod);
board_mod.addImport("rpi4b_gpio", rpi4b_gpio_mod);
board_mod.addImport("virt_timer", virt_timer_mod);
board_mod.addImport("rpi4b_timer", rpi4b_timer_mod);
board_mod.addImport("virt_irq", virt_irq_mod);
board_mod.addImport("rpi4b_irq", rpi4b_irq_mod);
board_mod.addImport("virt_emmc2", virt_emmc2_mod);
board_mod.addImport("rpi4b_emmc2", rpi4b_emmc2_mod);
board_mod.addImport("virt_usb", virt_usb_mod);
board_mod.addImport("rpi4b_usb", rpi4b_usb_mod);
board_mod.addImport("virt_power", virt_power_mod);
board_mod.addImport("rpi4b_power", rpi4b_power_mod);
board_mod.addImport("virt_mailbox", virt_mailbox_mod);
board_mod.addImport("rpi4b_mailbox", rpi4b_mailbox_mod);
// ---- kernel executable ----
const kernel_mod = b.createModule(.{
.root_source_file = b.path("src/start.zig"),
.target = target,
.optimize = optimize,
.strip = false, // keep symbols so `populate-syms` can nm the ELF
.unwind_tables = .none,
// NOTE: -Dtrace deliberately does NOT force -fno-omit-frame-pointer.
// arch/aarch64/boot.S uses x29 as a scratch LR stash during early boot, and the
// per-task kernel stack is only ~2.4 KiB; reserving x29 as a frame
// pointer kernel-wide corrupts the boot and trips a safety panic (it
// wild-branches under ReleaseSmall). The sampler therefore walks the
// FP chain best-effort (whatever frames LLVM kept) and always emits
// the leaf PC, which needs no frame pointer.
.omit_frame_pointer = null,
});
const kernel = b.addExecutable(.{
.name = "kernel8.elf",
.root_module = kernel_mod,
});
kernel.step.dependOn(hygiene_step);
const asm_files = [_][]const u8{
"arch/aarch64/boot.S",
"arch/aarch64/entry.S",
"arch/aarch64/utils.S",
"arch/aarch64/mm.S",
"arch/aarch64/sched.S",
"arch/aarch64/irq.S",
"arch/aarch64/generic_timer.S",
"src/symbol_area.S",
"src/trace/hook.S",
"src/trace/patchable_trampolines.S",
};
for (asm_files) |path| {
kernel_mod.addAssemblyFile(b.path(path));
}
// Board-specific assembly: per-board boot quirks (and any future
// timer init etc.) live under src/board/<board>/. virt additionally
// ships a Linux arm64 image header so UEFI/GRUB can identify the
// kernel binary in Phase B; rpi4b's firmware loads kernel8.img raw
// and does not expect the header.
const board_asm_files: []const []const u8 = if (board == .virt)
&.{ "image_header.S", "boot_quirks.S" }
else
&.{"boot_quirks.S"};
for (board_asm_files) |path| {
kernel_mod.addAssemblyFile(b.path(b.fmt("src/board/{s}/{s}", .{ @tagName(board), path })));
}
// The kernel .S files use `#include "asm_defs.inc"`, which now lives
// alongside them under arch/aarch64/. That bridge header in turn pulls in
// `board_asm_defs.inc` from the active board's directory — both search
// paths are added below so the ISA + per-board layout resolves. src/ is
// kept on the path for any remaining top-level .S includes.
kernel_mod.addIncludePath(b.path("arch/aarch64"));
kernel_mod.addIncludePath(b.path("src"));
kernel_mod.addIncludePath(b.path(b.fmt("src/board/{s}", .{@tagName(board)})));
// -Dtrace: define FLASHOS_TRACE for the C-preprocessed .S files (the .S
// extension routes them through clang's preprocessor, the same path that
// resolves their #include "asm_defs.inc"). entry.S keys its one extra
// `mov x0, sp` on this; absent the macro that instruction is not emitted,
// so the default kernel image is byte-identical.
if (trace) kernel_mod.addCMacro("FLASHOS_TRACE", "1");
kernel_mod.addImport("build_options", build_options_mod);
kernel_mod.addImport("syscall_defs", syscall_defs_mod);
kernel_mod.addImport("user_layout", user_layout_mod);
kernel_mod.addImport("task_layout", task_layout_mod);
kernel_mod.addImport("wait_queue", wait_queue_mod);
kernel_mod.addImport("pipe", pipe_mod);
kernel_mod.addImport("fdtable", fdtable_mod);
kernel_mod.addImport("console", console_mod);
kernel_mod.addImport("sched", sched_mod);
kernel_mod.addImport("sys", sys_mod);
kernel_mod.addImport("execve", execve_mod);
kernel_mod.addImport("path", path_mod);
kernel_mod.addImport("initramfs", initramfs_mod);
kernel_mod.addImport("elf", elf_mod);
kernel_mod.addImport("file", file_mod);
kernel_mod.addImport("vfs", vfs_mod);
kernel_mod.addImport("initramfs_backend", initramfs_backend_mod);
kernel_mod.addImport("fat32_backend", fat32_backend_mod);
kernel_mod.addImport("fat32", fat32_mod);
kernel_mod.addImport("block_dev", block_dev_mod);
kernel_mod.addImport("page_alloc", page_alloc_mod);
kernel_mod.addImport("mm_user", mm_user_mod);
kernel_mod.addImport("fork", fork_mod);
kernel_mod.addImport("sdhci_cmd", sdhci_cmd_mod);
kernel_mod.addImport("mailbox", mailbox_mod);
kernel_mod.addImport("usb_descriptors", usb_descriptors_mod);
kernel_mod.addImport("usb_tx_ring", usb_tx_ring_mod);
// Per-board driver leaves promoted to named modules (see comment above
// their createModule). board.zig's comptime switch picks the active set.
kernel_mod.addImport("virt_timer", virt_timer_mod);
kernel_mod.addImport("virt_gpio", virt_gpio_mod);
kernel_mod.addImport("virt_power", virt_power_mod);
kernel_mod.addImport("virt_usb", virt_usb_mod);
kernel_mod.addImport("virt_emmc2", virt_emmc2_mod);
// virt_dtb + virt_uart: board.zig's uart prong selects virt_uart; the
// virt IRQ controller (virt_irq) reaches both by name (a sibling of
// uart/irq, not its own board.zig prong).
kernel_mod.addImport("virt_dtb", virt_dtb_mod);
kernel_mod.addImport("virt_uart", virt_uart_mod);
// rpi4b board leaves: gpio/timer/power/uart are board.zig prongs;
// rpi4b_mailbox is reached by the still-Zig rpi4b emmc2/usb drivers.
kernel_mod.addImport("rpi4b_gpio", rpi4b_gpio_mod);
kernel_mod.addImport("rpi4b_timer", rpi4b_timer_mod);
kernel_mod.addImport("rpi4b_power", rpi4b_power_mod);
kernel_mod.addImport("rpi4b_mailbox", rpi4b_mailbox_mod);
kernel_mod.addImport("rpi4b_uart", rpi4b_uart_mod);
kernel_mod.addImport("rpi4b_emmc2", rpi4b_emmc2_mod);
kernel_mod.addImport("rpi4b_usb", rpi4b_usb_mod);
// Per-board IRQ controllers (board.zig's irq prong) + the unconditional
// ksyms force-import (start.zig pulls its symbol table into every image).
// sampler/fp_walk are not added here: only the IRQ modules' -Dtrace seam
// references the sampler, so kernel_mod never imports them directly.
kernel_mod.addImport("rpi4b_irq", rpi4b_irq_mod);
kernel_mod.addImport("virt_irq", virt_irq_mod);
// board: the comptime driver-alias bag (src/board.flash). start.zig
// force-imports it so each active driver's export fns reach the linker.
kernel_mod.addImport("board", board_mod);
kernel_mod.addImport("ksyms", ksyms_mod);
kernel_mod.addImport("klog_ring", klog_ring_mod);
kernel_mod.addImport("utilc", utilc_mod);
kernel_mod.addImport("sha256", sha256_mod);
kernel_mod.addImport("shadow", shadow_mod);
kernel_mod.addImport("perm", perm_mod);
kernel_mod.addImport("hwrng", hwrng_mod);
// sys_passwd authorization: uid -> login-name lookup against
// /etc/passwd (the same parser /bin/login and fsh's whoami import).
kernel_mod.addImport("pwfile", pwfile_mod);
// console_ui: shared terminal look for the boot log. Staged but not yet
// @imported by any kernel source, so the kernel image stays byte-identical
// until the migration call sites land.
kernel_mod.addImport("console_ui", console_ui_mod);
kernel_mod.addImport("generic_timer", generic_timer_mod);
kernel_mod.addImport("kernel", kernel_kmod);
// ---- hello.elf — payload for [TEST] exec-elf ----
// Built as a standalone aarch64-freestanding ET_EXEC, staged into
// the initramfs at /test/hello.elf. The exec-elf scenario opens it
// via sys_openFile, reads it into an EL0 buffer, and hands the
// bytes to sys_exec. Source is Flash (tools/hello.flash) — flashc
// transpiles it to Zig at build time via addFlashSource.
const hello_mod = b.createModule(.{
.root_source_file = addFlashSource(b, "tools/hello.flash"),
.target = target,
.optimize = .ReleaseSmall,
.strip = true,
});
const hello = b.addExecutable(.{
.name = "hello.elf",
.root_module = hello_mod,
});
hello.pie = false; // ET_EXEC, not ET_DYN — the loader rejects PIE.
hello.bundle_compiler_rt = false;
// Tiny p_align so LLD doesn't pad the file out to a page-sized
// offset — the ELF loader caps blob_size at PAGE_SIZE because it
// snapshots the blob into one kernel page. p_vaddr is still
// 0x1000-aligned via the linker script's `. = 0x100000`, which is
// what FlashOS's page-grain mapper actually requires; p_align only
// governs the ELF spec's `p_vaddr ≡ p_offset (mod p_align)` rule,
// and the kernel loader does not enforce p_align.
hello.link_z_max_page_size = 0x80;
hello.link_z_common_page_size = 0x80;
// Custom linker script: stock LLD output splits .eh_frame_hdr /
// .eh_frame into a separate LOAD segment ahead of .text, which
// pushes .text to a non-page-aligned VA. The script collapses to
// a single R+X PT_LOAD and discards the unwind / dyn metadata.
hello.setLinkerScript(b.path("tools/hello_linker.ld"));
hello.entry = .disabled; // ENTRY(_start) lives in the linker script
// ---- stackbomb.elf — payload for [TEST] stack-overflow ----
// Same recipe as hello.elf, swapping the source for a payload that
// recurses without termination. The kernel's do_data_abort detects
// the guard-zone fault, prints a kernel-side diagnostic and zombies
// the task; the parent's sys_wait reaps it so the per-process page
// balance returns to baseline (which is what the harness verifies).
// Source is Flash (tools/stackbomb.flash) — flashc transpiles it to
// Zig at build time via addFlashSource.
const stackbomb_mod = b.createModule(.{
.root_source_file = addFlashSource(b, "tools/stackbomb.flash"),
.target = target,
.optimize = .ReleaseSmall,
.strip = true,
});
stackbomb_mod.addImport("user_layout", user_layout_mod);
const stackbomb = b.addExecutable(.{
.name = "stackbomb.elf",
.root_module = stackbomb_mod,
});
stackbomb.pie = false;
stackbomb.bundle_compiler_rt = false;
stackbomb.link_z_max_page_size = 0x80;
stackbomb.link_z_common_page_size = 0x80;
// The hello linker script is a generic single-PT_LOAD layout —
// reuse it verbatim. If the two payloads ever need different
// section discards or VA bases, fork into tools/stackbomb_linker.ld.
stackbomb.setLinkerScript(b.path("tools/hello_linker.ld"));
stackbomb.entry = .disabled;
// ---- flibc — userland mini-libc, ELF-demo dependency ----
// Userland mini-libc: SVC wrappers, printf/puts on sys_writeConsole,
// bump allocator over sys_brk/sbrk, fork/wait/exit/execve. Exposed
// as a named module so ELF demos (and future fsh / coreutils
// payloads) can `addImport("flibc", flibc_mod)` and stay one
// `@import` deep. Pulls in syscall_defs for the SVC IDs — same
// module the kernel and the kernel_tests user-side wrappers consume.
// flibc is a multi-file module: flibc.zig pulls its siblings in by relative
// import. As the port advances each module flips from a copied-verbatim .zig
// to a flashc-generated one; both land in a single composed WriteFiles
// directory so every `@import("sibling.zig")` resolves there (the same
// composition console_ui uses above). Until a module is ported it is copied
// straight from the source tree; ported modules are transpiled from .flash.
const flibc_dir = b.addWriteFiles();
const flibc_flash = [_][]const u8{ "heap", "io", "keys", "completion", "pager", "readline", "syscalls", "process", "execvp", "flibc" };
const flibc_zig = [_][]const u8{};
// Every composed sibling's in-dir LazyPath, keyed by module name. A flibc
// host test compiles the in-dir copy (where each `@import("sibling.zig")`
// resolves) rather than the on-disk file, which breaks the moment a sibling
// flips from a copied .zig to a flashc-generated one.
var flibc_srcs = std.StringHashMap(std.Build.LazyPath).init(b.allocator);
for (flibc_flash) |name| {
const gen = addFlashSource(b, b.fmt("user_space/lib/flibc/{s}.flash", .{name}));
flibc_srcs.put(name, flibc_dir.addCopyFile(gen, b.fmt("{s}.zig", .{name}))) catch @panic("OOM");
}
for (flibc_zig) |name| {
const dest = flibc_dir.addCopyFile(
b.path(b.fmt("user_space/lib/flibc/{s}.zig", .{name})),
b.fmt("{s}.zig", .{name}),
);
flibc_srcs.put(name, dest) catch @panic("OOM");
}
const flibc_mod = b.createModule(.{
.root_source_file = flibc_srcs.get("flibc").?,
.target = target,
.optimize = .ReleaseSmall,
});
flibc_mod.addImport("syscall_defs", syscall_defs_mod);
// ---- flibc_demo.elf — payload for [TEST] flibc ----
// Same recipe as hello.elf / stackbomb.elf, swapping the source for
// a flibc-driven body: printf("flibc hello %d\n", 42), malloc 32 B,
// pattern write+verify, exit. The forked linker script
// (tools/flibc_demo_linker.ld) folds .rodata / .data / .bss into the
// single R+X PT_LOAD so flibc's state-free heap design carries
// through to a one-segment ELF that once fit inside the retired
// loader's PAGE_SIZE snapshot cap. Source is Flash
// (tools/flibc_demo.flash) — flashc transpiles it to Zig at build time
// via addFlashSource.
const flibc_demo_mod = b.createModule(.{
.root_source_file = addFlashSource(b, "tools/flibc_demo.flash"),
.target = target,
.optimize = .ReleaseSmall,
.strip = true,
});
flibc_demo_mod.addImport("flibc", flibc_mod);
const flibc_demo = b.addExecutable(.{
.name = "flibc_demo.elf",
.root_module = flibc_demo_mod,
});
flibc_demo.pie = false;
flibc_demo.bundle_compiler_rt = false;
flibc_demo.link_z_max_page_size = 0x80;
flibc_demo.link_z_common_page_size = 0x80;
flibc_demo.setLinkerScript(b.path("tools/flibc_demo_linker.ld"));
flibc_demo.entry = .disabled;
// ---- argv_echo.elf — payload for [TEST] execve ----
// Same recipe as flibc_demo.elf, but its entry is the flibc _start
// argc/argv shim (user_space/lib/flibc/start.flash) rather than a bespoke
// _start, and it carries a 4 KiB .rodata PAD so the linked ELF crosses
// one page — proving sys_execve's PT_LOAD streaming path loads payloads
// the long-retired PAGE_SIZE snapshot cap could not. The shim lives
// in its own module (not flibc/process.zig) because flibc.zig re-exports
// process into every flibc program, and Zig 0.16 rejects two _start
// exports in one compilation; argv_echo opts in via addImport below plus
// the `link "flibc_start"` in argv_echo.flash. Source is Flash —
// flashc transpiles it to Zig at build time via addFlashSource.
const flibc_start_mod = b.createModule(.{
.root_source_file = addFlashSource(b, "user_space/lib/flibc/start.flash"),
.target = target,
.optimize = .ReleaseSmall,
});
// Freestanding memcpy / memset / strlen for payloads that actually
// exercise execvp / the tokenizer / per-arg length scans — LLVM
// lowers those loops to libcalls that bundle_compiler_rt=false leaves
// unprovided. Opt-in (imported only by fsh / echo / cat), so the
// payloads that dodge the idiom (argv_echo, flibc_demo) stay lean.
const flibc_mem_mod = b.createModule(.{
.root_source_file = addFlashSource(b, "user_space/lib/flibc/mem.flash"),
.target = target,
.optimize = .ReleaseSmall,
});
const argv_echo_mod = b.createModule(.{
.root_source_file = addFlashSource(b, "tools/argv_echo.flash"),
.target = target,
.optimize = .ReleaseSmall,
.strip = true,
});
argv_echo_mod.addImport("flibc", flibc_mod);
argv_echo_mod.addImport("flibc_start", flibc_start_mod);
const argv_echo = b.addExecutable(.{
.name = "argv_echo.elf",
.root_module = argv_echo_mod,
});
argv_echo.pie = false;
argv_echo.bundle_compiler_rt = false;
argv_echo.link_z_max_page_size = 0x80;
argv_echo.link_z_common_page_size = 0x80;
argv_echo.setLinkerScript(b.path("tools/argv_echo_linker.ld"));
argv_echo.entry = .disabled;
// ---- fsh.elf — the FlashOS shell (/bin/fsh) ----
// Same recipe as argv_echo.elf (flibc _start argc/argv shim entry,
// pie=false, ReleaseSmall, strip, own single R+X PT_LOAD linker
// script — no PAD; fsh need not cross a page). fsh and its pure
// tokenizer are both Flash now; fsh.flash imports the tokenizer as a
// relative sibling, so the two flashc-generated files are composed into
// one WriteFiles directory (the same composition console_ui / flibc use)
// for that @import("tokenize.zig") to resolve. The tokenizer is
// host-tested separately in the test section below.
// Staged into the initramfs at /bin/fsh and exec'd by the PID-1
// hand-off after the harness tally; the boot watchdog keys on fsh's
// homescreen marker as the success signal. (The in-harness
// [TEST] fsh scenario is disabled — see user_space/kernel_tests.zig.)
const fsh_dir = b.addWriteFiles();
const tokenize_gen = addFlashSource(b, "user_space/fsh/tokenize.flash");
_ = fsh_dir.addCopyFile(tokenize_gen, "tokenize.zig");
const fsh_mod = b.createModule(.{
.root_source_file = fsh_dir.addCopyFile(addFlashSource(b, "user_space/fsh/fsh.flash"), "fsh.zig"),
.target = target,
.optimize = .ReleaseSmall,
.strip = true,
});
fsh_mod.addImport("flibc", flibc_mod);
fsh_mod.addImport("flibc_start", flibc_start_mod);
// whoami builtin: uid -> login-name lookup against
// /etc/passwd via the same parser the kernel and /bin/login use.
fsh_mod.addImport("pwfile", pwfile_mod);
// console_ui: shared terminal look for the homescreen/prompt. fsh renders
// its homescreen banner through it, fed the build_options version below.
fsh_mod.addImport("console_ui", console_ui_mod);
// build_options carries the project version (from build.zig.zon) into the
// homescreen banner — single source, no hardcoded version in fsh.
fsh_mod.addOptions("build_options", build_options);
// fsh is the first payload to actually exercise execvp + the
// tokenizer's @memcpy, so LLVM lowers those to memcpy / strlen
// libcalls; flibc_mem supplies the freestanding providers.
fsh_mod.addImport("flibc_mem", flibc_mem_mod);
const fsh = b.addExecutable(.{
.name = "fsh.elf",
.root_module = fsh_mod,
});
fsh.pie = false;
fsh.bundle_compiler_rt = false;
fsh.link_z_max_page_size = 0x80;
fsh.link_z_common_page_size = 0x80;
fsh.setLinkerScript(b.path("tools/fsh_linker.ld"));
fsh.entry = .disabled;
// ---- echo.elf / cat.elf — minimal coreutils ----
// Same recipe as fsh.elf (flibc _start shim,
// flibc_mem, pie=false, ReleaseSmall, strip) over a shared
// single-PT_LOAD linker script. Staged at /bin/echo and /bin/cat;
// exercised interactively via fsh (the `echo hi | cat` acceptance).
// The coreutil set also carries ls / meminfo / forkbomb. echo / cat /
// ls source from Flash (tools/{echo,cat,ls}.flash) — flashc transpiles
// them to Zig at build time via addFlashSource.
const echo_mod = b.createModule(.{
.root_source_file = addFlashSource(b, "tools/echo.flash"),
.target = target,
.optimize = .ReleaseSmall,
.strip = true,
});
echo_mod.addImport("flibc", flibc_mod);
echo_mod.addImport("flibc_start", flibc_start_mod);
echo_mod.addImport("flibc_mem", flibc_mem_mod);
const echo = b.addExecutable(.{
.name = "echo.elf",
.root_module = echo_mod,
});
echo.pie = false;
echo.bundle_compiler_rt = false;
echo.link_z_max_page_size = 0x80;
echo.link_z_common_page_size = 0x80;
echo.setLinkerScript(b.path("tools/coreutil_linker.ld"));
echo.entry = .disabled;
const cat_mod = b.createModule(.{
.root_source_file = addFlashSource(b, "tools/cat.flash"),
.target = target,
.optimize = .ReleaseSmall,
.strip = true,
});
cat_mod.addImport("flibc", flibc_mod);
cat_mod.addImport("flibc_start", flibc_start_mod);
cat_mod.addImport("flibc_mem", flibc_mem_mod);
// EACCES-aware diagnostic: cat names the permission denial.
cat_mod.addImport("syscall_defs", syscall_defs_mod);
const cat = b.addExecutable(.{
.name = "cat.elf",
.root_module = cat_mod,
});
cat.pie = false;
cat.bundle_compiler_rt = false;
cat.link_z_max_page_size = 0x80;
cat.link_z_common_page_size = 0x80;
cat.setLinkerScript(b.path("tools/coreutil_linker.ld"));
cat.entry = .disabled;
// ---- grep.elf — line-pattern search coreutil ----
// grep [-i] PATTERN [FILE...]: streams each input (a FILE, or fd 0 with
// none) line by line and writes the lines containing PATTERN to fd 1. The
// matcher is the pure tools/grep_match.flash (host-tested below); this ELF
// is only the driver (flag/argv parsing, open/read, line assembly). Same
// recipe as cat (flibc _start shim, flibc_mem, pie=false, ReleaseSmall,
// strip, shared coreutil_linker.ld). Staged at /bin/grep; kept out of the
// CI FSH_SCRIPT so the free-page baseline stays deterministic.
const grep_match_gen = addFlashSource(b, "tools/grep_match.flash");
const grep_match_mod = b.createModule(.{
.root_source_file = grep_match_gen,
.target = target,
.optimize = .ReleaseSmall,
});
const grep_mod = b.createModule(.{
.root_source_file = addFlashSource(b, "tools/grep.flash"),
.target = target,
.optimize = .ReleaseSmall,
.strip = true,
});
grep_mod.addImport("flibc", flibc_mod);
grep_mod.addImport("flibc_start", flibc_start_mod);
grep_mod.addImport("flibc_mem", flibc_mem_mod);
// EACCES-aware diagnostic: grep names the permission denial, like cat.
grep_mod.addImport("syscall_defs", syscall_defs_mod);
// Pure, host-tested substring matcher.
grep_mod.addImport("grep_match", grep_match_mod);
const grep = b.addExecutable(.{
.name = "grep.elf",
.root_module = grep_mod,
});
grep.pie = false;
grep.bundle_compiler_rt = false;
grep.link_z_max_page_size = 0x80;
grep.link_z_common_page_size = 0x80;
grep.setLinkerScript(b.path("tools/coreutil_linker.ld"));
grep.entry = .disabled;
// ---- cp.elf / mv.elf / rm.elf — file-management coreutils ----
// The consumers of the FAT32 write ABI: cp creates + copies (SYS_CREATE),
// rm removes (SYS_UNLINK), mv renames same-dir (SYS_RENAME) with a
// cp+unlink fallback for cross-dir. Same recipe as cat (flibc _start shim,
// flibc_mem, pie=false, ReleaseSmall, strip, shared coreutil_linker.ld).
// Staged at /bin/{cp,mv,rm}; kept out of the CI FSH_SCRIPT (they mutate the
// FAT32 card, which only exists on real hardware) so the free-page baseline
// stays deterministic.
const cp_mod = b.createModule(.{
.root_source_file = addFlashSource(b, "tools/cp.flash"),
.target = target,
.optimize = .ReleaseSmall,
.strip = true,
});
cp_mod.addImport("flibc", flibc_mod);
cp_mod.addImport("flibc_start", flibc_start_mod);
cp_mod.addImport("flibc_mem", flibc_mem_mod);
const cp = b.addExecutable(.{
.name = "cp.elf",
.root_module = cp_mod,
});
cp.pie = false;
cp.bundle_compiler_rt = false;
cp.link_z_max_page_size = 0x80;
cp.link_z_common_page_size = 0x80;
cp.setLinkerScript(b.path("tools/coreutil_linker.ld"));
cp.entry = .disabled;
const mv_mod = b.createModule(.{
.root_source_file = addFlashSource(b, "tools/mv.flash"),
.target = target,
.optimize = .ReleaseSmall,
.strip = true,
});
mv_mod.addImport("flibc", flibc_mod);
mv_mod.addImport("flibc_start", flibc_start_mod);
mv_mod.addImport("flibc_mem", flibc_mem_mod);
const mv = b.addExecutable(.{
.name = "mv.elf",
.root_module = mv_mod,
});
mv.pie = false;
mv.bundle_compiler_rt = false;
mv.link_z_max_page_size = 0x80;
mv.link_z_common_page_size = 0x80;
mv.setLinkerScript(b.path("tools/coreutil_linker.ld"));
mv.entry = .disabled;
const rm_mod = b.createModule(.{
.root_source_file = addFlashSource(b, "tools/rm.flash"),
.target = target,
.optimize = .ReleaseSmall,
.strip = true,
});
rm_mod.addImport("flibc", flibc_mod);
rm_mod.addImport("flibc_start", flibc_start_mod);
rm_mod.addImport("flibc_mem", flibc_mem_mod);
const rm = b.addExecutable(.{
.name = "rm.elf",
.root_module = rm_mod,
});
rm.pie = false;
rm.bundle_compiler_rt = false;
rm.link_z_max_page_size = 0x80;
rm.link_z_common_page_size = 0x80;
rm.setLinkerScript(b.path("tools/coreutil_linker.ld"));
rm.entry = .disabled;
// ---- ls.elf — directory-listing coreutil ----
// The first consumer of sys_readdir (slot 37): loops readdir(path, i)
// 0.. and writes each basename (a trailing '/' for DT_DIR) to fd 1.
// Same recipe as echo / cat (flibc _start shim, flibc_mem, pie=false,
// ReleaseSmall, strip, shared coreutil_linker.ld). Staged at /bin/ls;
// exercised by `ls /bin` in FSH_SCRIPT + [TEST] readdir in the stage-
// closing commit.
const ls_mod = b.createModule(.{
.root_source_file = addFlashSource(b, "tools/ls.flash"),
.target = target,
.optimize = .ReleaseSmall,
.strip = true,
});
ls_mod.addImport("flibc", flibc_mod);
ls_mod.addImport("flibc_start", flibc_start_mod);
ls_mod.addImport("flibc_mem", flibc_mem_mod);
const ls = b.addExecutable(.{
.name = "ls.elf",
.root_module = ls_mod,
});
ls.pie = false;
ls.bundle_compiler_rt = false;
ls.link_z_max_page_size = 0x80;
ls.link_z_common_page_size = 0x80;
ls.setLinkerScript(b.path("tools/coreutil_linker.ld"));
ls.entry = .disabled;
// ---- dmesg.elf — kernel-log dumper coreutil ----
// The consumer of sys_klog_read (slot 38): one snapshot of the kernel
// log ring (src/klog_ring.zig) written to fd 1, so the boot log is
// readable over the USB-C console without the Mini-UART adapter. Same
// recipe as ls / cat / echo (flibc _start shim, flibc_mem, pie=false,
// ReleaseSmall, strip, shared coreutil_linker.ld). Staged at /bin/dmesg;
// Pi-interactive surface — the CI harness asserts the ring + syscall
// directly via [TEST] klog, the way meminfo / forkbomb stay out of the
// FSH_SCRIPT. Source is Flash (tools/dmesg.flash) — flashc transpiles it
// to Zig at build time via addFlashSource.
const dmesg_mod = b.createModule(.{
.root_source_file = addFlashSource(b, "tools/dmesg.flash"),
.target = target,
.optimize = .ReleaseSmall,
.strip = true,
});
dmesg_mod.addImport("flibc", flibc_mod);
dmesg_mod.addImport("flibc_start", flibc_start_mod);
dmesg_mod.addImport("flibc_mem", flibc_mem_mod);
const dmesg = b.addExecutable(.{
.name = "dmesg.elf",
.root_module = dmesg_mod,
});
dmesg.pie = false;
dmesg.bundle_compiler_rt = false;
dmesg.link_z_max_page_size = 0x80;
dmesg.link_z_common_page_size = 0x80;
dmesg.setLinkerScript(b.path("tools/coreutil_linker.ld"));
dmesg.entry = .disabled;
// ---- meminfo.elf / forkbomb.elf — demo coreutils ----
// meminfo is the standalone /bin form of fsh's `free` built-in (one
// sys_dump_free line); forkbomb is a capped (N=16) fork/reap leak
// detector that never approaches OOM. Both print via the legacy slot-0
// console write and are Pi-interactive only — kept out of the CI
// FSH_SCRIPT (meminfo's live value breaks the baseline count; forkbomb
// must not approach exhaustion while OOM still panics today). Same
// recipe as echo / cat / ls. meminfo's source is Flash
// (tools/meminfo.flash) — flashc transpiles it to Zig at build time via
// addFlashSource.
const meminfo_mod = b.createModule(.{
.root_source_file = addFlashSource(b, "tools/meminfo.flash"),
.target = target,
.optimize = .ReleaseSmall,
.strip = true,
});
meminfo_mod.addImport("flibc", flibc_mod);
meminfo_mod.addImport("flibc_start", flibc_start_mod);
meminfo_mod.addImport("flibc_mem", flibc_mem_mod);
const meminfo = b.addExecutable(.{
.name = "meminfo.elf",
.root_module = meminfo_mod,
});
meminfo.pie = false;
meminfo.bundle_compiler_rt = false;
meminfo.link_z_max_page_size = 0x80;
meminfo.link_z_common_page_size = 0x80;
meminfo.setLinkerScript(b.path("tools/coreutil_linker.ld"));
meminfo.entry = .disabled;
// forkbomb's source is Flash (tools/forkbomb.flash) — flashc
// transpiles it to Zig at build time via addFlashSource.
const forkbomb_mod = b.createModule(.{
.root_source_file = addFlashSource(b, "tools/forkbomb.flash"),
.target = target,
.optimize = .ReleaseSmall,
.strip = true,
});
forkbomb_mod.addImport("flibc", flibc_mod);
forkbomb_mod.addImport("flibc_start", flibc_start_mod);
forkbomb_mod.addImport("flibc_mem", flibc_mem_mod);
const forkbomb = b.addExecutable(.{
.name = "forkbomb.elf",
.root_module = forkbomb_mod,
});
forkbomb.pie = false;
forkbomb.bundle_compiler_rt = false;
forkbomb.link_z_max_page_size = 0x80;
forkbomb.link_z_common_page_size = 0x80;
forkbomb.setLinkerScript(b.path("tools/coreutil_linker.ld"));
forkbomb.entry = .disabled;
// ---- sysinfo.elf — one-shot system summary coreutil ----
// First consumer of the console_ui screen-layer kv() renderer (the
// full-screen-navigation scaffold): prints the FlashOS version, the
// logged-in user, and the free-page count as aligned key/value rows, then
// exits. Imports console_ui for kv(), pwfile for the uid -> name lookup, and
// build_options for the version (single-sourced from build.zig.zon). Same
// recipe as ls / meminfo (flibc _start shim, flibc_mem, pie=false,
// ReleaseSmall, strip, shared coreutil_linker.ld). Staged at /bin/sysinfo;
// kept out of the CI FSH_SCRIPT like meminfo (its free-page value is live).
// Source is Flash (tools/sysinfo.flash) — flashc transpiles it to Zig at
// build time via addFlashSource.
const sysinfo_mod = b.createModule(.{
.root_source_file = addFlashSource(b, "tools/sysinfo.flash"),
.target = target,
.optimize = .ReleaseSmall,
.strip = true,
});
sysinfo_mod.addImport("flibc", flibc_mod);
sysinfo_mod.addImport("flibc_start", flibc_start_mod);
sysinfo_mod.addImport("flibc_mem", flibc_mem_mod);
sysinfo_mod.addImport("pwfile", pwfile_mod);
sysinfo_mod.addImport("console_ui", console_ui_mod);
sysinfo_mod.addOptions("build_options", build_options);
const sysinfo = b.addExecutable(.{
.name = "sysinfo.elf",
.root_module = sysinfo_mod,
});
sysinfo.pie = false;
sysinfo.bundle_compiler_rt = false;
sysinfo.link_z_max_page_size = 0x80;
sysinfo.link_z_common_page_size = 0x80;
sysinfo.setLinkerScript(b.path("tools/coreutil_linker.ld"));
sysinfo.entry = .disabled;
// ---- cpuinfo.elf — one-shot CPU monitor coreutil ----
// Prints the SoC temperature and ARM clock (sys_cpu_temp / sys_cpu_freq
// over the VideoCore mailbox) as aligned kv rows, then exits — the
// focused sibling of sysinfo's broader summary. Imports flibc + console_ui
// only (no pwfile / build_options). Same recipe as ls / sysinfo (flibc
// _start shim, flibc_mem, pie=false, ReleaseSmall, strip, shared
// coreutil_linker.ld). Staged at /bin/cpuinfo; kept out of the CI
// FSH_SCRIPT like sysinfo (live readings would break the baseline count).
// Source is Flash (tools/cpuinfo.flash) — flashc transpiles it to Zig at
// build time via addFlashSource.
const cpuinfo_mod = b.createModule(.{
.root_source_file = addFlashSource(b, "tools/cpuinfo.flash"),
.target = target,
.optimize = .ReleaseSmall,
.strip = true,
});
cpuinfo_mod.addImport("flibc", flibc_mod);
cpuinfo_mod.addImport("flibc_start", flibc_start_mod);
cpuinfo_mod.addImport("flibc_mem", flibc_mem_mod);
cpuinfo_mod.addImport("console_ui", console_ui_mod);
const cpuinfo = b.addExecutable(.{
.name = "cpuinfo.elf",
.root_module = cpuinfo_mod,
});
cpuinfo.pie = false;
cpuinfo.bundle_compiler_rt = false;
cpuinfo.link_z_max_page_size = 0x80;
cpuinfo.link_z_common_page_size = 0x80;
cpuinfo.setLinkerScript(b.path("tools/coreutil_linker.ld"));
cpuinfo.entry = .disabled;
// ---- uptime.elf — one-shot uptime monitor coreutil ----
// Prints the humanised seconds-since-boot (sys_uptime, the architectural
// counter) as one kv row, then exits — the focused sibling of sysinfo's
// uptime row, the way cpuinfo focuses temperature + clock. Imports flibc +
// console_ui only (no pwfile / build_options). Same recipe as cpuinfo /
// sysinfo (flibc _start shim, flibc_mem, pie=false, ReleaseSmall, strip,
// shared coreutil_linker.ld). Staged at /bin/uptime; kept out of the CI
// FSH_SCRIPT like sysinfo (a live reading would break the baseline count).
// Source is Flash (tools/uptime.flash) — flashc transpiles it to Zig at
// build time via addFlashSource.
const uptime_mod = b.createModule(.{
.root_source_file = addFlashSource(b, "tools/uptime.flash"),
.target = target,
.optimize = .ReleaseSmall,
.strip = true,
});
uptime_mod.addImport("flibc", flibc_mod);
uptime_mod.addImport("flibc_start", flibc_start_mod);
uptime_mod.addImport("flibc_mem", flibc_mem_mod);
uptime_mod.addImport("console_ui", console_ui_mod);
const uptime = b.addExecutable(.{
.name = "uptime.elf",
.root_module = uptime_mod,
});
uptime.pie = false;
uptime.bundle_compiler_rt = false;
uptime.link_z_max_page_size = 0x80;
uptime.link_z_common_page_size = 0x80;
uptime.setLinkerScript(b.path("tools/coreutil_linker.ld"));
uptime.entry = .disabled;
// ---- less.elf — full-screen text pager ----
// First interactive consumer of the navigation scaffold: takes over the
// console with console_ui.screen (alt-screen + panelTop title bar), reads
// keys through flibc.readKey's VT100 decoder, and scrolls a single named
// file with the pure flibc.Pager core. A proof of the full-screen loop the
// way sysinfo proved the print-and-exit kv() renderer. Imports flibc +
// console_ui only (no pwfile / build_options). Same recipe as ls / sysinfo
// (flibc _start shim, flibc_mem, pie=false, ReleaseSmall, strip, shared
// coreutil_linker.ld). Staged at /bin/less; kept out of the CI FSH_SCRIPT
// like sysinfo (interactive; the free-page baseline must stay deterministic).
// Source is Flash (tools/less.flash) — flashc transpiles it to Zig at build
// time via addFlashSource.
const less_mod = b.createModule(.{
.root_source_file = addFlashSource(b, "tools/less.flash"),
.target = target,
.optimize = .ReleaseSmall,
.strip = true,
});
less_mod.addImport("flibc", flibc_mod);
less_mod.addImport("flibc_start", flibc_start_mod);
less_mod.addImport("flibc_mem", flibc_mem_mod);
less_mod.addImport("console_ui", console_ui_mod);
const less = b.addExecutable(.{
.name = "less.elf",
.root_module = less_mod,
});
less.pie = false;
less.bundle_compiler_rt = false;
less.link_z_max_page_size = 0x80;
less.link_z_common_page_size = 0x80;
less.setLinkerScript(b.path("tools/coreutil_linker.ld"));
less.entry = .disabled;
// ---- edit.elf — full-screen text editor ----
// Second interactive consumer of the navigation scaffold and the first
// writer: slurps a file into a heap-backed gap buffer (the first real heap
// user — brk/sbrk + flibc malloc), edits it via the pure flibc.gapbuf cores,
// and writes it back on ctrl-O (unlink + create + write — FAT32 has no
// truncate). Reuses grep_match.find for ctrl-W search. Imports flibc +
// console_ui (like less) plus the two pure cores. Same recipe as less
// (flibc _start shim, flibc_mem, pie=false, ReleaseSmall, strip, shared
// coreutil_linker.ld). Staged at /bin/edit; kept out of the CI FSH_SCRIPT
// (interactive — no QEMU stdin — so the free-page baseline stays
// deterministic). gapbuf is a standalone module like grep_match, not part of
// the flibc aggregate, so it adds no footprint to existing boot binaries.
// Source is Flash (tools/edit.flash) — flashc transpiles it at build time.
const gapbuf_gen = addFlashSource(b, "user_space/lib/flibc/gapbuf.flash");
const gapbuf_mod = b.createModule(.{
.root_source_file = gapbuf_gen,
.target = target,
.optimize = .ReleaseSmall,
});
const edit_mod = b.createModule(.{
.root_source_file = addFlashSource(b, "tools/edit.flash"),
.target = target,
.optimize = .ReleaseSmall,
.strip = true,
});
edit_mod.addImport("flibc", flibc_mod);
edit_mod.addImport("flibc_start", flibc_start_mod);
edit_mod.addImport("flibc_mem", flibc_mem_mod);
edit_mod.addImport("console_ui", console_ui_mod);
edit_mod.addImport("gapbuf", gapbuf_mod);
edit_mod.addImport("grep_match", grep_match_mod);
const edit = b.addExecutable(.{
.name = "edit.elf",
.root_module = edit_mod,
});
edit.pie = false;
edit.bundle_compiler_rt = false;
edit.link_z_max_page_size = 0x80;
edit.link_z_common_page_size = 0x80;
edit.setLinkerScript(b.path("tools/coreutil_linker.ld"));
edit.entry = .disabled;
// ---- clear.elf — terminal-clear coreutil ----
// Smallest console_ui consumer: emits the shared screen-clear sequence
// (console_ui.screen.clear) and exits, so the escape bytes stay
// single-sourced. Imports flibc + console_ui only, like less. Same recipe
// (flibc _start shim, flibc_mem, pie=false, ReleaseSmall, strip, shared
// coreutil_linker.ld). Staged at /bin/clear. Source is Flash
// (tools/clear.flash) — flashc transpiles it to Zig at build time via
// addFlashSource; the cross-import proves a Flash program can consume the
// existing Zig flibc + console_ui modules.
const clear_mod = b.createModule(.{
.root_source_file = addFlashSource(b, "tools/clear.flash"),
.target = target,
.optimize = .ReleaseSmall,
.strip = true,
});
clear_mod.addImport("flibc", flibc_mod);
clear_mod.addImport("flibc_start", flibc_start_mod);
clear_mod.addImport("flibc_mem", flibc_mem_mod);
clear_mod.addImport("console_ui", console_ui_mod);
const clear = b.addExecutable(.{
.name = "clear.elf",
.root_module = clear_mod,
});
clear.pie = false;
clear.bundle_compiler_rt = false;
clear.link_z_max_page_size = 0x80;
clear.link_z_common_page_size = 0x80;
clear.setLinkerScript(b.path("tools/coreutil_linker.ld"));
clear.entry = .disabled;
// ---- login.elf — credential gate + session supervisor ----
// PID-1 execs /bin/login instead of /bin/fsh: it prompts for a username
// (echoed) + password (echo suppressed via SYS_SET_CONSOLE_MODE), has the
// kernel verify against the active shadow (sys_authenticate), then runs
// the session as a child — the child drops privilege (setgid + setuid)
// per /etc/passwd and execs the user's shell while login stays root,
// waits, reaps, and re-prompts (the logout lifecycle). Same coreutil
// recipe as dmesg / ls; imports syscall_defs for the echo mode bit and
// pwfile for the /etc/passwd lookup.
// Source is Flash (tools/login.flash) — flashc transpiles it to Zig at
// build time via addFlashSource.
const login_mod = b.createModule(.{
.root_source_file = addFlashSource(b, "tools/login.flash"),
.target = target,
.optimize = .ReleaseSmall,
.strip = true,
});
login_mod.addImport("flibc", flibc_mod);
login_mod.addImport("flibc_start", flibc_start_mod);
login_mod.addImport("flibc_mem", flibc_mem_mod);
login_mod.addImport("syscall_defs", syscall_defs_mod);
login_mod.addImport("pwfile", pwfile_mod);
const login = b.addExecutable(.{
.name = "login.elf",
.root_module = login_mod,
});
login.pie = false;
login.bundle_compiler_rt = false;
login.link_z_max_page_size = 0x80;
login.link_z_common_page_size = 0x80;
login.setLinkerScript(b.path("tools/coreutil_linker.ld"));
login.entry = .disabled;
// ---- passwd.elf — interactive password change ----
// `passwd [user]` collects the current + new password (kernel echo
// off) and calls sys_passwd; the KDF + splice-safe shadow rewrite
// live in the kernel. Same coreutil recipe as login; imports pwfile
// for the uid -> own-login-name default and syscall_defs for EACCES.
// Source is Flash (tools/passwd.flash) — flashc transpiles it to Zig at
// build time via addFlashSource.
const passwd_bin_mod = b.createModule(.{
.root_source_file = addFlashSource(b, "tools/passwd.flash"),
.target = target,
.optimize = .ReleaseSmall,
.strip = true,
});
passwd_bin_mod.addImport("flibc", flibc_mod);
passwd_bin_mod.addImport("flibc_start", flibc_start_mod);
passwd_bin_mod.addImport("flibc_mem", flibc_mem_mod);
passwd_bin_mod.addImport("syscall_defs", syscall_defs_mod);
passwd_bin_mod.addImport("pwfile", pwfile_mod);
const passwd_bin = b.addExecutable(.{
.name = "passwd.elf",
.root_module = passwd_bin_mod,
});
passwd_bin.pie = false;
passwd_bin.bundle_compiler_rt = false;
passwd_bin.link_z_max_page_size = 0x80;
passwd_bin.link_z_common_page_size = 0x80;
passwd_bin.setLinkerScript(b.path("tools/coreutil_linker.ld"));
passwd_bin.entry = .disabled;
// ---- pid1.elf — the ELF-loaded PID 1 ----
// Replaces the user_init.o blob. Instead of compiling
// user_space/init.zig into the kernel object and wrapping it in
// linker.ld's user_start / user_end, PID 1 is now a standalone
// aarch64-freestanding ET_EXEC staged into the initramfs at
// /sbin/init. kernel_process locates that entry and hands its
// bytes to prepare_move_to_user_elf — the same ELF loader the
// exec-elf / stackbomb / flibc test payloads travel.
//
// Recipe mirrors hello.elf (pie=false, strip, ReleaseSmall, tiny
// p_align so LLD doesn't page-pad the file). The forked linker
// script tools/pid1_linker.ld folds .rodata / .data / .bss into
// the single R+X PT_LOAD. Unlike the test payloads pid1.elf is
// loaded by kernel_process directly at boot, so there is no
// snapshot cap on its size — prepare_move_to_user_elf walks the
// PT_LOAD page by page.
// init_main (PID 1) is Flash; it imports the in-kernel test harness
// (kernel_tests.flash) as a relative sibling. flashc transpiles the
// harness at build time, and the generated init_main.zig + the transpiled
// kernel_tests.zig are composed into one WriteFiles directory so the
// relative @import("kernel_tests.zig") resolves.
const pid1_dir = b.addWriteFiles();
_ = pid1_dir.addCopyFile(addFlashSource(b, "user_space/kernel_tests.flash"), "kernel_tests.zig");
const pid1_mod = b.createModule(.{
.root_source_file = pid1_dir.addCopyFile(addFlashSource(b, "user_space/init_main.flash"), "init_main.zig"),
.target = target,
.optimize = .ReleaseSmall,
.strip = true,
});
pid1_mod.addImport("syscall_defs", syscall_defs_mod);
// pid1 reads build_options for the CI auto-login seed gate (see the
// ci-login-seed option above). Off by default → the shipped boot stops
// at `login:`; the watchdog builds with the flag for unattended auth.
pid1_mod.addOptions("build_options", build_options);
const pid1 = b.addExecutable(.{
.name = "pid1.elf",
.root_module = pid1_mod,
});
pid1.pie = false;
pid1.bundle_compiler_rt = false;
pid1.link_z_max_page_size = 0x80;
pid1.link_z_common_page_size = 0x80;
pid1.setLinkerScript(b.path("tools/pid1_linker.ld"));
pid1.entry = .disabled;
// ---- /etc/shadow generator ----
// Host tool: runs the kernel's PBKDF2 (src/sha256.zig) over fixed test
// credentials to emit a deterministic /etc/shadow, staged into the
// initramfs below. Reusing the kernel KDF guarantees the baked verifier
// matches what sys_authenticate recomputes at login. Output is a pure
// function of the in-tool constants, so the kernel image stays byte-
// reproducible (Pi hash baseline).
const gen_shadow_mod = b.createModule(.{
.root_source_file = b.path("tools/gen_shadow.zig"),
.target = b.graph.host,
.optimize = .Debug,
});
gen_shadow_mod.addImport("sha256", sha256_mod);
const gen_shadow = b.addExecutable(.{
.name = "gen_shadow",
.root_module = gen_shadow_mod,
});
const gen_shadow_cmd = b.addRunArtifact(gen_shadow);
const shadow_file = gen_shadow_cmd.addOutputFileArg("shadow");
// Install a copy at zig-out/shadow so the deploy step (a literal-path
// shell script, like its kernel8.img reference) can seed the real SD
// card with the same bytes the initramfs and the QEMU image carry.
const install_shadow = b.addInstallFileWithDir(shadow_file, .prefix, "shadow");
// ---- initramfs.cpio ----
// newc cpio archive embedded into the kernel image via the
// .initramfs section (linker.ld on both boards). Stages the
// real payloads: pid1.elf at /sbin/init (kernel_process ELF-loads
// it as PID 1), and the three test ELFs at /test/*.elf (the
// exec-elf / stack-overflow / flibc scenarios open + read + exec
// them via the file syscalls instead of the retired .text.user
// bridge slots).
//
// The cpio_stage WriteFiles step collects each ELF under a stable
// arc name ("sbin/init", "test/hello.elf", …); the encoder below
// walks a fixed, lexicographically-sorted arc list and reads bytes
// from the staged directory, so the archive layout never depends
// on filesystem walk order. src/initramfs.zig canonicalises the
// emitted "./<arc>" prefix to "/<arc>" so locate("/sbin/init")
// matches.
//
// Step 10 replaced the previous addSystemCommand cpio(1) block
// (bsdcpio on macOS, GNU cpio on Linux) with the hand-rolled Zig
// encoder at scripts/build_initramfs.zig. The old block stamped
// host-clock mtime + non-zero inode at byte 12, so two clean
// builds produced different kernel8.img sha256 sums and blocked
// Pi-hash baseline refresh. The encoder fixes mtime / uid / gid /
// nlink / mode and assigns monotonic ino, making the archive a
// pure function of file contents + name list.
const cpio_stage = b.addNamedWriteFiles("initramfs_stage");
_ = cpio_stage.addCopyFile(pid1.getEmittedBin(), "sbin/init");
_ = cpio_stage.addCopyFile(hello.getEmittedBin(), "test/hello.elf");
_ = cpio_stage.addCopyFile(stackbomb.getEmittedBin(), "test/stackbomb.elf");
_ = cpio_stage.addCopyFile(flibc_demo.getEmittedBin(), "test/flibc_demo.elf");
_ = cpio_stage.addCopyFile(argv_echo.getEmittedBin(), "test/argv_echo.elf");
_ = cpio_stage.addCopyFile(cat.getEmittedBin(), "bin/cat");
_ = cpio_stage.addCopyFile(clear.getEmittedBin(), "bin/clear");
_ = cpio_stage.addCopyFile(cp.getEmittedBin(), "bin/cp");
_ = cpio_stage.addCopyFile(cpuinfo.getEmittedBin(), "bin/cpuinfo");
_ = cpio_stage.addCopyFile(dmesg.getEmittedBin(), "bin/dmesg");
_ = cpio_stage.addCopyFile(echo.getEmittedBin(), "bin/echo");
_ = cpio_stage.addCopyFile(edit.getEmittedBin(), "bin/edit");
_ = cpio_stage.addCopyFile(forkbomb.getEmittedBin(), "bin/forkbomb");
_ = cpio_stage.addCopyFile(fsh.getEmittedBin(), "bin/fsh");
_ = cpio_stage.addCopyFile(grep.getEmittedBin(), "bin/grep");
_ = cpio_stage.addCopyFile(less.getEmittedBin(), "bin/less");
_ = cpio_stage.addCopyFile(ls.getEmittedBin(), "bin/ls");
_ = cpio_stage.addCopyFile(meminfo.getEmittedBin(), "bin/meminfo");
_ = cpio_stage.addCopyFile(mv.getEmittedBin(), "bin/mv");
_ = cpio_stage.addCopyFile(rm.getEmittedBin(), "bin/rm");
_ = cpio_stage.addCopyFile(sysinfo.getEmittedBin(), "bin/sysinfo");
_ = cpio_stage.addCopyFile(uptime.getEmittedBin(), "bin/uptime");
_ = cpio_stage.addCopyFile(b.path("user_space/fsh/fshrc"), "etc/fshrc");
_ = cpio_stage.addCopyFile(login.getEmittedBin(), "bin/login");
_ = cpio_stage.addCopyFile(passwd_bin.getEmittedBin(), "bin/passwd");
_ = cpio_stage.addCopyFile(b.path("user_space/etc/passwd"), "etc/passwd");
_ = cpio_stage.addCopyFile(shadow_file, "etc/shadow");
const initramfs_encoder = b.addExecutable(.{
.name = "build_initramfs",
.root_module = b.createModule(.{
.root_source_file = b.path("scripts/build_initramfs.zig"),
.target = b.graph.host,
.optimize = .Debug,
}),
});
// Arc names sorted lexicographically — the encoder writes them in
// argv order, so this list is the single source of truth for the
// archive's entry order and therefore its sha256. Keep sorted.
//
// Each arc carries its newc mode: binaries are 0o100755
// (the dropped-privilege shell must still exec them), config files
// are 0o100644 (world-readable), and /etc/shadow is 0o100600 so the
// VFS permission layer holds the "non-root read → EACCES" line.
// This list is the single policy source; the encoder just stamps
// what it is told.
const initramfs_arcs = [_]struct { arc: []const u8, mode: u32 }{
.{ .arc = "bin/cat", .mode = 0o100755 },
.{ .arc = "bin/clear", .mode = 0o100755 },
.{ .arc = "bin/cp", .mode = 0o100755 },
.{ .arc = "bin/cpuinfo", .mode = 0o100755 },
.{ .arc = "bin/dmesg", .mode = 0o100755 },
.{ .arc = "bin/echo", .mode = 0o100755 },
.{ .arc = "bin/edit", .mode = 0o100755 },
.{ .arc = "bin/forkbomb", .mode = 0o100755 },
.{ .arc = "bin/fsh", .mode = 0o100755 },
.{ .arc = "bin/grep", .mode = 0o100755 },
.{ .arc = "bin/less", .mode = 0o100755 },
.{ .arc = "bin/login", .mode = 0o100755 },
.{ .arc = "bin/ls", .mode = 0o100755 },
.{ .arc = "bin/meminfo", .mode = 0o100755 },
.{ .arc = "bin/mv", .mode = 0o100755 },
.{ .arc = "bin/passwd", .mode = 0o100755 },
.{ .arc = "bin/rm", .mode = 0o100755 },
.{ .arc = "bin/sysinfo", .mode = 0o100755 },
.{ .arc = "bin/uptime", .mode = 0o100755 },
.{ .arc = "etc/fshrc", .mode = 0o100644 },
.{ .arc = "etc/passwd", .mode = 0o100644 },
.{ .arc = "etc/shadow", .mode = 0o100600 },
.{ .arc = "sbin/init", .mode = 0o100755 },
.{ .arc = "test/argv_echo.elf", .mode = 0o100755 },
.{ .arc = "test/flibc_demo.elf", .mode = 0o100755 },
.{ .arc = "test/hello.elf", .mode = 0o100755 },
.{ .arc = "test/stackbomb.elf", .mode = 0o100755 },
};
const cpio_cmd = b.addRunArtifact(initramfs_encoder);
const initramfs_bin = cpio_cmd.addOutputFileArg("initramfs.cpio");
cpio_cmd.addDirectoryArg(cpio_stage.getDirectory());
for (initramfs_arcs) |e| cpio_cmd.addArg(b.fmt("{s}:{o}", .{ e.arc, e.mode }));
// Stage the cpio next to a directory the assembler can `-I` so
// tools/initramfs.S's `.incbin "initramfs.cpio"` resolves
// regardless of CWD — same pattern hello_elf.S / stackbomb_elf.S
// / flibc_demo_elf.S use above.
const initramfs_bin_stage = b.addNamedWriteFiles("initramfs_bin_stage");
_ = initramfs_bin_stage.addCopyFile(initramfs_bin, "initramfs.cpio");
kernel_mod.addAssemblyFile(b.path("tools/initramfs.S"));
kernel_mod.addIncludePath(initramfs_bin_stage.getDirectory());
kernel.setLinkerScript(b.path(b.fmt("src/board/{s}/linker.ld", .{@tagName(board)})));
kernel.entry = .disabled; // _start lives in boot.S
kernel.link_z_max_page_size = 0x1000;
kernel.link_gc_sections = false;
const install_kernel_elf = b.addInstallArtifact(kernel, .{});
// ELF → raw binary using the system aarch64-elf-objcopy.
const objcopy_kernel = b.addSystemCommand(&.{
"aarch64-elf-objcopy",
});
objcopy_kernel.addArtifactArg(kernel);
objcopy_kernel.addArg("-O");
objcopy_kernel.addArg("binary");
const kernel_img = objcopy_kernel.addOutputFileArg("kernel8.img");
const install_kernel_img = b.addInstallFileWithDir(kernel_img, .prefix, "kernel8.img");
const kernel_step = b.step("kernel", "Build kernel8.img");
kernel_step.dependOn(&install_kernel_elf.step);
kernel_step.dependOn(&install_kernel_img.step);
// ---- aggregate / default ----
// The default `all` step bundles per-board artifacts. armstub and
// the SD-card deploy are Pi-specific (BCM2711 EL3→EL1 shim,
// bcm2711-rpi-4-b.dtb / start4.elf), so they live in the
// `if (board == .rpi4b)` arm below.
const all_step = b.step("all", "Build everything (default)");
all_step.dependOn(kernel_step);
b.default_step.dependOn(all_step);
if (board == .rpi4b) {
// ---- armstub (EL3→EL1 shim, separate tiny ELF linked at .text=0) ----
const armstub_mod = b.createModule(.{
.root_source_file = b.path("armstub/src/root.zig"), // empty — real code is in armstub8.S
.target = target,
.optimize = optimize,
// Match the kernel's frame-pointer policy under -Dtrace; harmless
// otherwise (this module is asm-only). null = leave it alone.
.omit_frame_pointer = if (trace) false else null,
});
const armstub = b.addExecutable(.{
.name = "armstub8.elf",
.root_module = armstub_mod,
});
armstub_mod.addAssemblyFile(b.path("armstub/src/armstub8.S"));
armstub_mod.addIncludePath(b.path("armstub/src"));
armstub.setLinkerScript(b.path("armstub/src/linker.ld"));
armstub.entry = .disabled; // _start defined in armstub8.S
armstub.link_z_max_page_size = 0x1000;
armstub.link_gc_sections = false;
armstub.bundle_compiler_rt = false;
const objcopy_armstub = b.addSystemCommand(&.{
"aarch64-elf-objcopy",
});
objcopy_armstub.addArtifactArg(armstub);
objcopy_armstub.addArg("-O");
objcopy_armstub.addArg("binary");
const armstub_bin = objcopy_armstub.addOutputFileArg("armstub8.bin");
const install_armstub_bin = b.addInstallFileWithDir(armstub_bin, .prefix, "armstub8.bin");
const install_armstub_elf = b.addInstallArtifact(armstub, .{});
const armstub_step = b.step("armstub", "Build armstub8.bin");
armstub_step.dependOn(&install_armstub_elf.step);
armstub_step.dependOn(&install_armstub_bin.step);
all_step.dependOn(armstub_step);
}
// ---- optional: regenerate symbol_area.S from the linked kernel ELF ----
// Two-pass workflow: build kernel once, run nm | generate_syms.zig to
// overwrite src/symbol_area.S, then re-run `zig build` to relink with
// the populated table. Exposed as its own step so the default
// build stays single-pass.
// `grep -v 'compiler_rt\.'` drops the namespaced compiler-rt aliases
// (e.g. `compiler_rt.aarch64_outline_atomics.__aarch64_cas16_acq_rel`,
// 59+ chars) that overflow generate_syms.zig's fixed-width entry.
// The short alias (`__aarch64_cas16_acq_rel`) sits at the same
// address and survives the filter, so trace coverage is unchanged —
// only the redundant long name is dropped.
const populate = b.addSystemCommand(&.{
"sh", "-c",
"aarch64-elf-nm -n " ++
"\"$1\" | sort | grep -v '\\$' | grep -v 'compiler_rt\\.' | " ++
"zig run scripts/generate_syms.zig",
"--",
});
populate.addArtifactArg(kernel);
const populate_step = b.step(
"populate-syms",
"Regenerate src/symbol_area.S from the current kernel ELF (run `zig build` again afterwards)",
);
populate_step.dependOn(&populate.step);
populate_step.dependOn(kernel_step);
// ---- deploy: copy artifacts + RPi firmware to the SD card. ----
// Mirrors the old `make deploy` recipe; tweak the env-var defaults below
// for a different mount point or firmware tree. Pi-only — references
// armstub8.bin and BCM2711 firmware blobs.
if (board == .rpi4b) {
const deploy = b.addSystemCommand(&.{
"sh", "-c",
\\set -eu
\\: "${SD_BOOT:=/Volumes/BOOT}"
\\: "${FIRMWARE:=firmware}"
\\# Refuse to wipe anything that is not a mounted FAT volume: a typo'd
\\# SD_BOOT (e.g. /Volumes or $HOME) must never reach the rm -rf below.
\\if ! mount | grep -q " on $SD_BOOT (msdos"; then
\\ echo "error: $SD_BOOT is not a mounted FAT32 volume — refusing to wipe it" >&2
\\ exit 1
\\fi
\\rm -rf "$SD_BOOT"/*
\\cp zig-out/kernel8.img zig-out/armstub8.bin config.txt "$SD_BOOT/"
\\cp "$FIRMWARE/bcm2711-rpi-4-b.dtb" "$SD_BOOT/"
\\cp "$FIRMWARE/start4.elf" "$SD_BOOT/"
\\cp "$FIRMWARE/fixup4.dat" "$SD_BOOT/"
\\mkdir -p "$SD_BOOT/overlays"
\\cp "$FIRMWARE/overlays/miniuart-bt.dtbo" "$SD_BOOT/overlays/"
\\# Re-seed the FAT32 roundtrip test files: the wipe above removed
\\# them, and the in-kernel fs-roundtrip scenario needs both present
\\# to run its write/verify phases (8.3 names; see scripts/format_sd.sh).
\\dd if=/dev/zero of="$SD_BOOT/ROUNDTR.DAT" bs=4096 count=1 2>/dev/null
\\dd if=/dev/zero of="$SD_BOOT/ROUNDTR.MAG" bs=1 count=1 2>/dev/null
\\rm -f "$SD_BOOT"/._ROUNDTR* 2>/dev/null || true
\\# 0-byte EMPTY.TXT for [TEST] fs-empty-write: the first write
\\# must allocate this file's first cluster (fat32_backend.write
\\# step 0). Stays 0 bytes until that scenario writes it.
\\: > "$SD_BOOT/EMPTY.TXT"
\\rm -f "$SD_BOOT"/._EMPTY* 2>/dev/null || true
\\# Identity seeds: the writable shadow (the boot login
\\# reads it first; passwd rewrites it) + the permission overlay
\\# that keeps it 0600 root:root. Same bytes as the QEMU image.
\\cp zig-out/shadow "$SD_BOOT/SHADOW"
\\cp user_space/etc/perms.tab "$SD_BOOT/PERMS.TAB"
\\rm -f "$SD_BOOT"/._SHADOW "$SD_BOOT"/._PERMS* 2>/dev/null || true
\\sync
\\diskutil eject "$SD_BOOT"
});
deploy.step.dependOn(all_step);
deploy.step.dependOn(&install_shadow.step);
const deploy_step = b.step(
"deploy",
"Copy kernel8.img, armstub8.bin, config.txt and RPi firmware to $SD_BOOT (default /Volumes/BOOT)",
);
deploy_step.dependOn(&deploy.step);
}
// ---- clean: blow away cache + outputs. ----
const clean = b.addSystemCommand(&.{ "sh", "-c", "rm -rf .zig-cache zig-out" });
const clean_step = b.step("clean", "Remove .zig-cache and zig-out");
clean_step.dependOn(&clean.step);
// ---- run targets — board-specific QEMU machines ----
// `zig build -Dboard=rpi4b run` boots on `-M raspi4b` (Pi 4 model);
// `zig build -Dboard=virt run-virt` boots on `-M virt`. Each step
// is only registered for its board; calling `run` with virt or
// `run-virt` with rpi4b yields a "step not found" error.
if (board == .rpi4b) {
// SD-card backing image for QEMU's raspi4b SDHCI peripheral.
// scripts/make_test_disk.sh emits a
// deterministic 64 MiB zero-filled file at zig-out/test_sd.img;
// both raspi4b QEMU steps below depend on it and pass it via
// `-drive if=sd,file=...,format=raw`. virt steps do NOT take
// the flag — QEMU's `-M virt` rejects `if=sd` because the
// machine has no SDHCI peripheral.
const make_test_disk_cmd = b.addSystemCommand(&.{
"sh", "scripts/make_test_disk.sh",
});
// Identity seeds: the generated shadow (same bytes as the
// initramfs /etc/shadow) lands at ::/SHADOW, the permission
// overlay at ::/PERMS.TAB — so the rpi4b QEMU target exercises
// the writable-shadow + overlay path end to end. LazyPath args
// also give this step its dependency on gen_shadow.
make_test_disk_cmd.addFileArg(shadow_file);
make_test_disk_cmd.addFileArg(b.path("user_space/etc/perms.tab"));
const qemu_cmd = b.addSystemCommand(&.{
"qemu-system-aarch64",
"-M",
"raspi4b",
"-display",
"none",
"-serial", "null", // PL011 (UART4) → discarded
"-serial", "stdio", // Mini-UART (UART1) → host stdio
"-kernel", "zig-out/kernel8.img",
"-drive", "if=sd,file=zig-out/test_sd.img,format=raw",
});
// qemu reads zig-out/kernel8.img via a literal path string, so
// the install step must finish before qemu launches. Without
// this dependency, a clean tree (post `zig build clean`) races
// qemu against the install and qemu sees no kernel image. The
// same race exists for test_sd.img → depend on make_test_disk_cmd.
qemu_cmd.step.dependOn(&install_kernel_img.step);
qemu_cmd.step.dependOn(&make_test_disk_cmd.step);
const run_step = b.step("run", "Run Flash in QEMU (raspi4b)");
run_step.dependOn(&install_kernel_img.step); // depends on kernel8.img
run_step.dependOn(&qemu_cmd.step);
// Self-validating QEMU run: the watchdog tails the serial log,
// exits 0 on `[ OK ] Reached target Shell.` (with no `[FAIL]` / `ERROR CAUGHT` and
// the expected free-page-checkpoint counts), exits 1 on
// `ERROR CAUGHT`, any `[FAIL]`, count drift, or timeout.
// Same QEMU args as `run`. raspi4b is slow (~5–8 min); the
// 720s timeout matches the historical bash-watchdog ceiling.
const test_rpi4b_cmd = b.addSystemCommand(&.{
"scripts/run_qemu_test.sh",
"720",
"qemu-system-aarch64",
"-M",
"raspi4b",
"-display",
"none",
"-serial",
"null",
"-serial",
"stdio",
"-kernel",
"zig-out/kernel8.img",
"-drive",
"if=sd,file=zig-out/test_sd.img,format=raw",
});
test_rpi4b_cmd.step.dependOn(&install_kernel_img.step);
test_rpi4b_cmd.step.dependOn(&make_test_disk_cmd.step);
// The kernel image is passed as a literal path string, not a tracked
// file input, so the build graph cannot see it change between runs.
// Without this, a second `test-rpi4b` is cache-skipped and reports
// success without ever booting QEMU — a false green. A boot test must
// always run, so mark it as having side effects.
test_rpi4b_cmd.has_side_effects = true;
const test_rpi4b_step = b.step("test-rpi4b", "Boot raspi4b in QEMU and assert the boot reaches the fsh prompt");
test_rpi4b_step.dependOn(&test_rpi4b_cmd.step);
}
if (board == .virt) {
const qemu_virt_cmd = b.addSystemCommand(&.{
"qemu-system-aarch64",
"-M",
"virt,gic-version=3",
"-cpu",
"cortex-a72",
"-m",
"1G",
"-nographic", // PL011 → host stdio (no separate -serial needed)
"-kernel",
"zig-out/kernel8.img",
});
// Same install-before-launch ordering as the rpi4b branch.
qemu_virt_cmd.step.dependOn(&install_kernel_img.step);
const run_virt_step = b.step("run-virt", "Run FlashOS in QEMU (virt)");
run_virt_step.dependOn(&install_kernel_img.step);
run_virt_step.dependOn(&qemu_virt_cmd.step);
// Self-validating QEMU run for virt — same contract as
// `test-rpi4b`. virt boots in seconds; 60s is generous.
const test_virt_cmd = b.addSystemCommand(&.{
"scripts/run_qemu_test.sh",
"60",
"qemu-system-aarch64",
"-M",
"virt,gic-version=3",
"-cpu",
"cortex-a72",
"-m",
"1G",
"-nographic",
"-kernel",
"zig-out/kernel8.img",
});
test_virt_cmd.step.dependOn(&install_kernel_img.step);
// Always boot — same reason as test-rpi4b: the kernel path is a literal
// string, so the step would otherwise cache-skip and false-green.
test_virt_cmd.has_side_effects = true;
const test_virt_step = b.step("test-virt", "Boot virt in QEMU and assert the boot reaches the fsh prompt");
test_virt_step.dependOn(&test_virt_cmd.step);
}
// ---- iso: GRUB-EFI rescue ISO for VMware Fusion / UEFI hosts ----
// virt-only — Pi has no use for an EFI ISO since the GPU bootloader
// chain expects a raw kernel8.img + RPi firmware. Calling
// `zig build -Dboard=rpi4b iso` triggers the failure branch with a
// clear message, matching the workflow doc's acceptance criterion.
const iso_step = b.step("iso", "Build flashos.iso (board=virt only)");
if (board == .virt) {
const make_iso = b.addSystemCommand(&.{"scripts/make_iso.sh"});
make_iso.step.dependOn(&install_kernel_img.step);
iso_step.dependOn(&make_iso.step);
} else {
const iso_fail = b.addSystemCommand(&.{
"sh", "-c", "echo 'iso target requires -Dboard=virt' >&2; exit 1",
});
iso_step.dependOn(&iso_fail.step);
}
// Host-side unit tests. One test target per kernel module under test
// — the module file IS the test root, so its inline `test "…"` blocks
// land in `builtin.test_functions`. The shared `tests/host_stubs.zig`
// object satisfies the kernel module's `extern fn` HW-side
// dependencies at link time. The natural alternative — a single test
// root that imports `src/start.zig` — fails to link because
// `start.zig` transitively pulls in assembly-only externs
// (`set_pgd`, `ret_from_fork`, `ksyms_init`, …) that no host stub
// can satisfy.
const host_alloc_obj = b.addObject(.{
.name = "host_alloc",
.root_module = b.createModule(.{
.root_source_file = b.path("tests/host_alloc.zig"),
.target = b.graph.host,
.optimize = .Debug,
}),
});
const stubs_obj = b.addObject(.{
.name = "host_stubs",
.root_module = b.createModule(.{
.root_source_file = b.path("tests/host_stubs.zig"),
.target = b.graph.host,
.optimize = .Debug,
}),
});
const test_step = b.step("test", "Run host-side unit tests");
test_step.dependOn(hygiene_step);
// Shared task_layout module — see kernel-build comment above for
// why the named modules must share a single Module instance.
const task_layout_test_mod = b.createModule(.{
.root_source_file = task_layout_src,
.target = b.graph.host,
.optimize = .Debug,
});
const user_layout_test_mod = b.createModule(.{
.root_source_file = user_layout_src,
.target = b.graph.host,
.optimize = .Debug,
});
// Host-target alias of the pure path module so execve.zig's host
// build can satisfy its `@import("path")`. The pure
// joinResolve helper itself is host-tested via the standalone
// src/path.zig target wired below.
const path_test_mod = b.createModule(.{
.root_source_file = path_src,
.target = b.graph.host,
.optimize = .Debug,
});
// Host-target alias of the shared ABI file so vfs.zig's host build
// can satisfy its `@import("syscall_defs")` for the Dirent type
// Pure comptime constants — no externs, no stubs.
const syscall_defs_test_mod = b.createModule(.{
.root_source_file = syscall_defs_src,
.target = b.graph.host,
.optimize = .Debug,
});
const fork_stubs_mod = b.createModule(.{
.root_source_file = b.path("tests/fork_stubs.zig"),
.target = b.graph.host,
.optimize = .Debug,
});
fork_stubs_mod.addImport("task_layout", task_layout_test_mod);
const host_stubs_fork_mod = b.createModule(.{
.root_source_file = b.path("tests/host_stubs_fork.zig"),
.target = b.graph.host,
.optimize = .Debug,
});
host_stubs_fork_mod.addImport("task_layout", task_layout_test_mod);
// execve.zig — argv-block encoder host coverage. Pure
// layout function, no externs, so no stubs. The returned Module is
// reused as the fork.zig test target's "execve" import — fork.zig
// names execve.ArgvBlock in prepare_move_to_user_elf_argv. The
// `path` import is satisfied even on host because the file
// top-level @imports the module unconditionally; the kernel-only
// join site sits behind the comptime is_kernel guard.
const execve_test_mod = addHostTest(b, test_step, .{
.src = "src/execve.flash",
.src_lazy = execve_src,
.imports = &.{.{ .name = "path", .mod = path_test_mod }},
});
// path.zig — cwd-aware path-resolution host coverage.
// Pure joinResolve: no externs, no stubs. The freestanding kernel
// and the host test exercise the same source through the `path`
// module wired above.
_ = addHostTest(b, test_step, .{ .src = "src/path.flash", .src_lazy = path_src });
// trace/fp_walk.zig — the -Dtrace sampler's AAPCS64 frame-pointer
// chain decoder. Pure `walkChain` over a flat stack-page view (no
// kernel externs), so the FP-record math + the bounds/alignment/
// monotonic guards are host-verified deterministically. The live
// sampler only fires on real-Pi async timer ticks, so this is the
// decode-correctness gate; no stubs, no imports.
_ = addHostTest(b, test_step, .{ .src = "src/trace/fp_walk.zig" });
// flibc readline.zig — line-editor state-machine host coverage.
// Pure `step` transition + `State` buffer; the SVC
// driver sits behind a comptime `has_driver` gate so the host
// build never analyses inline asm. No stubs, no imports.
_ = addHostTest(b, test_step, .{ .src = "user_space/lib/flibc/readline.flash", .src_lazy = flibc_srcs.get("readline").? });
// flibc execvp.flash — bare-name → `/bin/<name>` resolver host
// coverage. Pure `resolve` path-build; the SVC driver
// sits behind the same `has_driver` gate as readline.
_ = addHostTest(b, test_step, .{ .src = "user_space/lib/flibc/execvp.flash", .src_lazy = flibc_srcs.get("execvp").? });
// fsh tokenize — whitespace splitter + single-`|` split host
// coverage. Pure `tokenize`: fills a caller argv array
// from a line + scratch buffer; no externs, no stubs, no SVC. Compiles
// the flashc-generated module (tokenize.flash is the source of truth).
_ = addHostTest(b, test_step, .{ .src = "user_space/fsh/tokenize.flash", .src_lazy = tokenize_gen });
// grep match core — pure windowed substring matcher with ASCII case-fold.
// No externs, no stubs, no SVC; the open/read/line-assembly driver sits in
// tools/grep.flash. Compiles the flashc-generated module (the .flash is the
// source of truth).
_ = addHostTest(b, test_step, .{ .src = "tools/grep_match.flash", .src_lazy = grep_match_gen });
// flibc keys.zig — VT100 input Decoder host coverage (arrows / ctrl / tab).
// Pure `Decoder.feed`; the SVC readKey driver sits behind the same
// has_driver gate as readline. No stubs, no imports.
_ = addHostTest(b, test_step, .{ .src = "user_space/lib/flibc/keys.flash", .src_lazy = flibc_srcs.get("keys").? });
// flibc completion.zig — tab-completion core host coverage (parse,
// hasPrefix, commonPrefixLen). Pure; the readdir-driven gathering lives in
// readline's driver. No stubs, no imports.
_ = addHostTest(b, test_step, .{ .src = "user_space/lib/flibc/completion.flash", .src_lazy = flibc_srcs.get("completion").? });
// console_ui screen — panel / kv / cursor renderer host coverage. The
// test blocks live in the Flash source; compile the generated screen.zig
// from the composed console_ui directory so its sibling import resolves.
_ = addHostTest(b, test_step, .{ .src = "lib/console_ui/screen.flash", .src_lazy = console_ui_screen_src });
// flibc pager.zig — pure scroll / line-index core host coverage (init line
// indexing, line slicing, scroll clamping). The screen.enter + readKey
// driver lives in tools/less_elf.zig. No stubs, no imports.
_ = addHostTest(b, test_step, .{ .src = "user_space/lib/flibc/pager.flash", .src_lazy = flibc_srcs.get("pager").? });
// flibc gapbuf.zig — pure editing core host coverage (gap insert/delete/
// moveGap/grow, segment line index, cursor motions, viewport scroll). The
// interactive loop the editor builds on it lives in tools/edit.flash and is
// Pi-only (no QEMU stdin), so these host tests are the correctness proof. A
// standalone module like grep_match — not part of the flibc aggregate, so it
// adds no footprint to existing boot binaries. The generated source is shared
// with edit.elf's module (gapbuf_gen, declared at the edit wiring above). No
// stubs, no imports.
_ = addHostTest(b, test_step, .{ .src = "user_space/lib/flibc/gapbuf.flash", .src_lazy = gapbuf_gen });
// virt DTB parser — pure big-endian FDT decode + bounds guards.
// The handoff entry (`fromHandoff`) reads the `dtb_pa` extern and the
// linear map, so it stays kernel-only; the tests build a `Dtb` over a
// hand-written blob and exercise findNode/getProp/findReg/findInterrupt
// plus the corrupt-length guard. Imports only std → no stubs.
_ = addHostTest(b, test_step, .{ .src = "src/board/virt/dtb.flash", .src_lazy = virt_dtb_src });
// Host-target build of the Flash-sourced elf module for fork.zig's
// @import("elf"). Shares the one flashc transpile (elf_src); the kernel
// build's elf_mod is aarch64-freestanding, so the test needs its own.
const elf_for_fork_mod = b.createModule(.{
.root_source_file = elf_src,
.target = b.graph.host,
.optimize = .Debug,
});
elf_for_fork_mod.addImport("user_layout", user_layout_test_mod);
const fork_test_mod = addHostTest(b, test_step, .{
.src = "src/fork.flash",
.src_lazy = fork_src,
.stubs = b.addObject(.{
.name = "host_stubs_fork",
.root_module = host_stubs_fork_mod,
}),
.imports = &.{
.{ .name = "task_layout", .mod = task_layout_test_mod },
.{ .name = "user_layout", .mod = user_layout_test_mod },
.{ .name = "fdtable", .mod = fork_stubs_mod },
.{ .name = "execve", .mod = execve_test_mod },
.{ .name = "elf", .mod = elf_for_fork_mod },
},
});
// fork.zig top-level @imports build_options for the verbose-fork gate;
// the kernel build gets it via kernel_mod, the host test needs it wired
// explicitly since this module is built standalone.
fork_test_mod.addOptions("build_options", build_options);
const mm_user_stubs_mod = b.createModule(.{
.root_source_file = b.path("tests/host_stubs_mm_user.zig"),
.target = b.graph.host,
.optimize = .Debug,
});
mm_user_stubs_mod.addImport("task_layout", task_layout_test_mod);
_ = addHostTest(b, test_step, .{
.src = "src/mm_user.flash",
.src_lazy = mm_user_src,
.stubs = b.addObject(.{
.name = "host_stubs_mm_user",
.root_module = mm_user_stubs_mod,
}),
.imports = &.{
.{ .name = "task_layout", .mod = task_layout_test_mod },
.{ .name = "user_layout", .mod = user_layout_test_mod },
},
});
// vanilla single-module test targets — shared stubs, no named imports.
_ = addHostTest(b, test_step, .{ .src = "src/page_alloc.flash", .src_lazy = page_alloc_src, .stubs = stubs_obj });
_ = addHostTest(b, test_step, .{
.src = "src/elf.flash",
.src_lazy = elf_src,
.stubs = stubs_obj,
.imports = &.{.{ .name = "user_layout", .mod = user_layout_test_mod }},
});
// wait_queue is its own test target AND the named module pipe.zig
// imports — capture the helper's returned Module so the pipe call
// below can plug it back in as the "wait_queue" import.
const wq_test_mod = addHostTest(b, test_step, .{
.src = "src/wait_queue.flash",
.src_lazy = wait_queue_src,
.stubs = stubs_obj,
.imports = &.{.{ .name = "task_layout", .mod = task_layout_test_mod }},
});
// pipe.zig pulls in wait_queue + task_layout as named modules + its
// own page-allocator stub so it doesn't double-define get_free_page
// / free_page against the page_alloc test target. stubs_obj is
// already pulled in transitively via wq_test_mod, so omitting it
// from `stubs` here keeps the host stubs single-defined.
_ = addHostTest(b, test_step, .{
.src = "src/pipe.flash",
.src_lazy = pipe_src,
.extra_stubs = &.{host_alloc_obj},
.imports = &.{
.{ .name = "wait_queue", .mod = wq_test_mod },
.{ .name = "task_layout", .mod = task_layout_test_mod },
},
});
// console.zig — ring + WaitQueue host coverage.
// Same wiring as pipe.zig minus the page allocator (ring is BSS,
// shared stubs_obj alone suffices). stubs_obj arrives transitively
// via wq_test_mod, so the helper's `stubs` field stays unset.
_ = addHostTest(b, test_step, .{
.src = "src/console.flash",
.src_lazy = console_src,
.imports = &.{
.{ .name = "wait_queue", .mod = wq_test_mod },
.{ .name = "task_layout", .mod = task_layout_test_mod },
},
});
// sched.zig — pure-helper host coverage. sched.zig
// itself exports current / preempt_disable / preempt_enable /
// schedule, so the shared stubs_obj would double-define those at
// link time. Dedicated sched-stub object plugs only the HW-side gap
// (core_switch_to, set_pgd, irq_*, free_page*, _schedule) plus a
// null get_free_page for the transitively-imported pipe module.
const sched_stubs_obj = b.addObject(.{
.name = "host_stubs_sched",
.root_module = b.createModule(.{
.root_source_file = b.path("tests/host_stubs_sched.zig"),
.target = b.graph.host,
.optimize = .Debug,
}),
});
// Dedicated wait_queue / pipe Modules for the sched test target —
// can't reuse the helper-built wq_test_mod (which carries stubs_obj)
// or a pipe equivalent (which would carry pipe_stubs_obj) because
// either path re-introduces same-symbol collisions against
// sched_stubs_obj. Hand-build a stub-free chain instead.
const wq_sched_mod = b.createModule(.{
.root_source_file = wait_queue_src,
.target = b.graph.host,
.optimize = .Debug,
});
wq_sched_mod.addImport("task_layout", task_layout_test_mod);
const pipe_sched_mod = b.createModule(.{
.root_source_file = pipe_src,
.target = b.graph.host,
.optimize = .Debug,
});
pipe_sched_mod.addImport("wait_queue", wq_sched_mod);
pipe_sched_mod.addImport("task_layout", task_layout_test_mod);
// file_sched_mod — same stub-free pattern as pipe_sched_mod above.
// sched.zig imports `file` for the do_wait_impl reap plumbing;
// sched_stubs_obj already provides the
// get_free_page / free_page / preempt_* externs file.zig needs.
const file_sched_mod = b.createModule(.{
.root_source_file = b.path("src/file.zig"),
.target = b.graph.host,
.optimize = .Debug,
});
file_sched_mod.addImport("task_layout", task_layout_test_mod);
const fdtable_sched_mod = b.createModule(.{
.root_source_file = fdtable_src,
.target = b.graph.host,
.optimize = .Debug,
});
fdtable_sched_mod.addImport("task_layout", task_layout_test_mod);
fdtable_sched_mod.addImport("pipe", pipe_sched_mod);
fdtable_sched_mod.addImport("file", file_sched_mod);
_ = addHostTest(b, test_step, .{
.src = "src/sched.flash",
.src_lazy = sched_src,
.stubs = sched_stubs_obj,
.imports = &.{
.{ .name = "task_layout", .mod = task_layout_test_mod },
.{ .name = "fdtable", .mod = fdtable_sched_mod },
},
});
// initramfs.zig — newc cpio parser. Pure data parser with no externs
// in host builds — the shared stubs_obj is
// linked for parity with the other test targets, not because the
// module needs it.
_ = addHostTest(b, test_step, .{
.src = "src/initramfs.flash",
.src_lazy = initramfs_src,
.stubs = stubs_obj,
});
// file.zig — File handle helpers. Same shape as pipe.zig: dedicated
// per-target stub so the bump
// allocator's get_free_page / free_page don't clash with the
// page_alloc test target's real allocator. The stub additionally
// ships a typed `current: ?*TaskStruct` (instead of the shared
// host_stubs.zig's `?*anyopaque`) so future initramfs/file tests
// can reach into `current.open_files` directly — see
// the post-mortem doc for why this is a new per-target stub
// file rather than a widening of
// host_stubs.zig. Both this stub's module and the file.zig test
// module share `task_layout_test_mod` so the `?*TaskStruct` type
// matches at link time.
const file_stubs_mod = b.createModule(.{
.root_source_file = b.path("tests/host_stubs_initramfs.zig"),
.target = b.graph.host,
.optimize = .Debug,
});
file_stubs_mod.addImport("task_layout", task_layout_test_mod);
const file_stubs_obj = b.addObject(.{
.name = "host_stubs_initramfs",
.root_module = file_stubs_mod,
});
_ = addHostTest(b, test_step, .{
.src = "src/file.zig",
.stubs = file_stubs_obj,
.extra_stubs = &.{host_alloc_obj},
.imports = &.{.{ .name = "task_layout", .mod = task_layout_test_mod }},
});
// vfs.zig — VFS dispatch layer. vfs.zig imports the
// `file` named module for the `File` type its vtable signatures
// reference; a dedicated stub-free file module (same pattern as
// file_sched_mod above) shares task_layout_test_mod so the File
// type matches at link, and vfs_stubs_obj plugs file.zig's
// get_free_page / free_page / preempt_* externs.
const file_test_mod = b.createModule(.{
.root_source_file = b.path("src/file.zig"),
.target = b.graph.host,
.optimize = .Debug,
});
file_test_mod.addImport("task_layout", task_layout_test_mod);
_ = addHostTest(b, test_step, .{
.src = "src/fdtable.flash",
.src_lazy = fdtable_src,
.stubs = file_stubs_obj,
.extra_stubs = &.{host_alloc_obj},
.imports = &.{
.{ .name = "task_layout", .mod = task_layout_test_mod },
.{ .name = "pipe", .mod = pipe_sched_mod },
.{ .name = "file", .mod = file_test_mod },
},
});
const vfs_stubs_obj = b.addObject(.{
.name = "host_stubs_vfs",
.root_module = b.createModule(.{
.root_source_file = b.path("tests/host_stubs_vfs.zig"),
.target = b.graph.host,
.optimize = .Debug,
}),
});
_ = addHostTest(b, test_step, .{
.src = "src/vfs.zig",
.stubs = vfs_stubs_obj,
.extra_stubs = &.{host_alloc_obj},
.imports = &.{
.{ .name = "file", .mod = file_test_mod },
.{ .name = "syscall_defs", .mod = syscall_defs_test_mod },
},
});
// sdhci_cmd.flash — pure-data SDHCI command encoder + CSD parser.
// No externs, no fixture state.
_ = addHostTest(b, test_step, .{ .src = "src/sdhci_cmd.flash", .src_lazy = sdhci_cmd_src });
// mailbox.flash — pure-data VideoCore property-tag builder + parser.
// No externs; the MMIO doorbell lives in
// src/board/rpi4b/mailbox.zig.
_ = addHostTest(b, test_step, .{ .src = "src/mailbox.flash", .src_lazy = mailbox_src });
// usb_descriptors.flash — byte-exact USB descriptor set + SETUP decode
// (DWC2 gadget). No externs; pure data + pure functions.
_ = addHostTest(b, test_step, .{ .src = "src/usb_descriptors.flash", .src_lazy = usb_descriptors_src });
// usb_tx_ring.flash — bulk-IN TX byte-ring (DWC2 gadget).
// No externs; pure ring arithmetic (push/peek/advance/clear).
_ = addHostTest(b, test_step, .{ .src = "src/usb_tx_ring.flash", .src_lazy = usb_tx_ring_src });
// klog_ring.zig — kernel-log byte-ring (overwrite-oldest) host coverage.
// Pure ring arithmetic (push / overwrite-oldest / snapshot);
// imports syscall_defs only for KLOG_SIZE. The returned Module is reused
// as the utilc.zig test target's "klog_ring" import (utilc tees
// main_output into the ring), mirroring how wait_queue's test module
// doubles as pipe's import.
const klog_ring_test_mod = addHostTest(b, test_step, .{
.src = "src/klog_ring.flash",
.src_lazy = klog_ring_src,
.imports = &.{.{ .name = "syscall_defs", .mod = syscall_defs_test_mod }},
});
// fat32.flash — FAT32 on-disk layout decode.
// Pure data module: imports only the host-only block_dev Module
// (BlockDev type), uses an in-memory 64 KiB fake disk built by the
// inline test fixture. No page-alloc or task-layout externs needed.
const block_dev_test_mod = b.createModule(.{
.root_source_file = block_dev_src,
.target = b.graph.host,
.optimize = .Debug,
});
_ = addHostTest(b, test_step, .{
.src = "src/fat32.flash",
.src_lazy = fat32_src,
.imports = &.{.{ .name = "block_dev", .mod = block_dev_test_mod }},
});
// fat32_backend.zig — FAT32 VFS backend host-test. Asserts the
// sub-sector splice contract that write():203-208 fulfills. See
// the comment block at the end of
// src/fat32_backend.zig for the bug-class link and the
// ReleaseSmall reproducibility note. Created modules for fat32
// and vfs because the kernel-side fat32_mod / vfs_mod are wired
// for aarch64 freestanding, not host.
const fat32_for_backend_mod = b.createModule(.{
.root_source_file = fat32_src,
.target = b.graph.host,
.optimize = .Debug,
});
fat32_for_backend_mod.addImport("block_dev", block_dev_test_mod);
const vfs_for_backend_mod = b.createModule(.{
.root_source_file = b.path("src/vfs.zig"),
.target = b.graph.host,
.optimize = .Debug,
});
vfs_for_backend_mod.addImport("file", file_test_mod);
vfs_for_backend_mod.addImport("syscall_defs", syscall_defs_test_mod);
// overlay.zig — FAT32 permission-overlay parser host coverage.
// Pure parse/lookup truth table — the gate for the /mnt overlay: the
// fat32_backend wiring (applyOverlay + open lookup) does not ship until
// every row passes. The returned Module doubles as fat32_backend's
// "overlay" import below (mirroring the klog_ring/utilc pattern). Pins
// the format shared with the seed file (user_space/etc/perms.tab) and
// the deploy / make_test_disk seeding.
const overlay_test_mod = addHostTest(b, test_step, .{ .src = "src/overlay.flash", .src_lazy = overlay_src });
_ = addHostTest(b, test_step, .{
.src = "src/fat32_backend.flash",
.src_lazy = fat32_backend_src,
.stubs = vfs_stubs_obj,
.imports = &.{
.{ .name = "block_dev", .mod = block_dev_test_mod },
.{ .name = "fat32", .mod = fat32_for_backend_mod },
.{ .name = "vfs", .mod = vfs_for_backend_mod },
.{ .name = "file", .mod = file_test_mod },
.{ .name = "overlay", .mod = overlay_test_mod },
},
});
_ = addHostTest(b, test_step, .{
.src = "src/initramfs_backend.flash",
.src_lazy = initramfs_backend_src,
.stubs = stubs_obj,
.imports = &.{
.{ .name = "initramfs", .mod = b.createModule(.{
.root_source_file = initramfs_src,
.target = b.graph.host,
.optimize = .Debug,
}) },
.{ .name = "vfs", .mod = vfs_for_backend_mod },
.{ .name = "file", .mod = file_test_mod },
},
});
// utilc.zig — kernel utility host coverage.
// Trivial hex/mem helpers; stubs provided for board-specific UARTs.
const utilc_stubs_obj = b.addObject(.{
.name = "host_stubs_utilc",
.root_module = b.createModule(.{
.root_source_file = b.path("tests/host_stubs_utilc.zig"),
.target = b.graph.host,
.optimize = .Debug,
}),
});
_ = addHostTest(b, test_step, .{
.src = "src/utilc.flash",
.src_lazy = utilc_src,
.stubs = utilc_stubs_obj,
.imports = &.{
.{ .name = "task_layout", .mod = task_layout_test_mod },
// utilc.main_output now tees into the kernel log ring; the host
// test build needs the same module the kernel build wires.
.{ .name = "klog_ring", .mod = klog_ring_test_mod },
},
});
// sha256.zig — SHA-256 / HMAC-SHA256 / PBKDF2-HMAC-SHA256 host coverage.
// Pure compute, no externs, no imports, no allocation. The
// vector tests (NIST FIPS 180-2, RFC 4231, the published PBKDF2 set,
// plus std.crypto differentials) are the gate for the authentication
// work: no kernel consumer of these primitives ships until they pass.
_ = addHostTest(b, test_step, .{ .src = "src/sha256.flash", .src_lazy = sha256_src });
// shadow.zig — /etc/shadow line parser + hex decoder. Pure,
// no imports; pins the format shared by sys_authenticate + gen_shadow.
_ = addHostTest(b, test_step, .{ .src = "src/shadow.flash", .src_lazy = shadow_src });
// perm.zig — VFS permission check host coverage. Pure
// checkAccess truth table (owner/group/other × read/write/exec ×
// root bypass) — the gate for the permission layer: no enforcement
// site ships until every row passes.
_ = addHostTest(b, test_step, .{ .src = "src/perm.flash", .src_lazy = perm_src });
// pwfile.zig — /etc/passwd parser host coverage. Pure
// name/uid lookups shared by sys_passwd (kernel), /bin/login, and
// fsh's whoami builtin; pins the 5-field format against
// user_space/etc/passwd.
_ = addHostTest(b, test_step, .{ .src = "src/pwfile.flash", .src_lazy = pwfile_src });
// build_initramfs.zig — newc encoder host coverage. Pins the
// mode/uid/gid byte offsets shared with the kernel parser
// (src/initramfs.zig); an encoder/parser drift here would be a silent
// permission bypass.
_ = addHostTest(b, test_step, .{ .src = "scripts/build_initramfs.zig" });
// hwrng.zig — kernel entropy source host coverage. The pure
// SplitMix64 mixer is vector- and differential-tested; the kernel glue
// (fill / hwrng_init) runs against host_stubs' ramping get_sys_count,
// so the boot self-test + announce path is exercised end-to-end.
_ = addHostTest(b, test_step, .{
.src = "src/hwrng.flash",
.src_lazy = hwrng_src,
.stubs = stubs_obj,
.imports = &.{.{ .name = "console_ui", .mod = console_ui_mod }},
});
// Final pass banner. Zig's build runner is silent on a fully-green test
// step (counts only surface with `--summary all`), so wire a last system
// command that depends on every test run added above — it executes only
// after they all pass — and prints one green "<N> tests passed" line. The
// file list it counts is host_test_srcs, populated by addHostTest, so the
// number tracks the build graph and never drifts. A filtered run prints a
// count-free banner instead (only a subset ran).
{
const argv = b.allocator.alloc([]const u8, 3 + host_test_n) catch @panic("OOM");
argv[0] = "sh";
argv[1] = "scripts/test_tally.sh";
argv[2] = host_test_filter orelse "";
for (host_test_srcs[0..host_test_n], 0..) |src, i| argv[3 + i] = src;
const tally = b.addSystemCommand(argv);
// Depend on the hygiene checks + every test run already attached to
// test_step, so the banner is the last thing printed.
for (test_step.dependencies.items) |dep| tally.step.dependOn(dep);
test_step.dependOn(&tally.step);
}
}