Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e6e21bf0a | ||
|
|
0fbd2c8f12 | ||
|
|
1379b699f3 | ||
|
|
1ff0991452 | ||
|
|
ac01f627d1 | ||
|
|
f3bc23cb81 | ||
|
|
8d4a7cf240 | ||
|
|
c4f16a5448 | ||
|
|
e4da4e0610 | ||
|
|
9a3238025d | ||
|
|
c96ef87714 | ||
|
|
de9870175f | ||
|
|
be04ebe954 | ||
|
|
62d6a7a150 | ||
|
|
fa8203cdba | ||
|
|
e53d6ebafb | ||
|
|
4476208361 | ||
|
|
c621b649f6 | ||
|
|
d27670ec39 | ||
|
|
ab7594df6e | ||
|
|
d03942b543 | ||
|
|
3f9b6813f7 | ||
|
|
bc2eb67581 | ||
|
|
cb98a9844e | ||
|
|
4701fbd123 | ||
|
|
3b23b11b0e | ||
|
|
902e4a0a9e | ||
|
|
15575960bd | ||
|
|
6f4fdc2789 | ||
|
|
721288c516 | ||
|
|
4194bb6e32 | ||
|
|
fc0b934b7f | ||
|
|
6a687fbc2b | ||
|
|
4e7cbc9792 | ||
|
|
e94adcfc02 | ||
|
|
f477ef7d9f | ||
|
|
9e649178bf | ||
|
|
e376c9e908 | ||
|
|
48b9ed4001 | ||
|
|
081ba1d74e | ||
|
|
203528c8a7 | ||
|
|
bf0c3fd3e0 | ||
|
|
3af0110483 | ||
|
|
757c6f14c3 |
@@ -63,7 +63,7 @@ zig build release-x86-64
|
||||
|
||||
Produces `zig-out/danos-x86-64.iso`, a hybrid ISO that boots flashed raw to a
|
||||
USB stick (balenaEtcher, dd) or burned to optical media — see
|
||||
[docs/release-iso.md](docs/os-development-guide/release-iso.md). `zig build check-iso-image`
|
||||
[docs/release-iso.md](docs/os-development/release-iso.md). `zig build check-iso-image`
|
||||
validates it without booting.
|
||||
|
||||
## Run
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! The danos build API (docs/build-packages-plan.md): the one shared recipe
|
||||
//! for building a user-space binary. A binary package's build.zig names its
|
||||
//! binary and EXACTLY the modules its source imports — the moral equivalent
|
||||
//! of a C file's include list — and `userBinary` resolves each name from the
|
||||
//! library domain package that exports it. Nothing is pre-wired: an @import
|
||||
//! the package did not declare is a compile error, and a domain none of the
|
||||
//! imports come from never appears in the package's manifest. The only
|
||||
//! implicit dependency is the kernel package, because the shared root shim
|
||||
//! (root.zig, user.ld) lives there and itself reaches start + logging.
|
||||
//!
|
||||
//! Consumers declare this package in their build.zig.zon (as "build-support")
|
||||
//! and @import its build.zig from their own build.zig; nothing is compiled
|
||||
//! from this package itself — it exports build-time functions only.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
pub fn build(b: *std.Build) void {
|
||||
_ = b; // nothing to build: this package exports build-time functions only
|
||||
}
|
||||
|
||||
/// The freestanding x86-64 target every danos binary (kernel and user) is
|
||||
/// built for. SSE2 is part of the x86_64 baseline and UEFI leaves it enabled
|
||||
/// at handoff, so we keep it: disabling it forces soft-float and makes the
|
||||
/// compiler unable to encode the vector ops that std's formatting/runtime
|
||||
/// still emit.
|
||||
pub fn freestandingTarget(b: *std.Build) std.Build.ResolvedTarget {
|
||||
return b.resolveTargetQuery(.{
|
||||
.cpu_arch = .x86_64,
|
||||
.os_tag = .freestanding,
|
||||
.abi = .none,
|
||||
});
|
||||
}
|
||||
|
||||
/// Resolve one imported module by searching the packages this binary DECLARED
|
||||
/// in its own build.zig.zon — the C include path made literal: an import can
|
||||
/// only be satisfied by a domain the binary claims, and each domain's own
|
||||
/// build.zig (its addModule exports) is the single statement of who owns
|
||||
/// what. There is no name table here to drift.
|
||||
fn moduleFromDeclaredDependencies(b: *std.Build, name: []const u8) *std.Build.Module {
|
||||
for (b.available_deps) |declared| {
|
||||
const dependency = b.dependency(declared[0], .{});
|
||||
if (dependency.builder.modules.get(name)) |module| return module;
|
||||
}
|
||||
@panic(b.fmt(
|
||||
"no declared dependency exports a module named '{s}' — declare the domain that owns it in this package's build.zig.zon",
|
||||
.{name},
|
||||
));
|
||||
}
|
||||
|
||||
/// What `userBinary` needs to know about one user binary.
|
||||
pub const UserBinaryOptions = struct {
|
||||
name: []const u8,
|
||||
/// The program's own source file — it becomes the `program` module the
|
||||
/// root shim imports; a program only defines `pub fn main`.
|
||||
root_source_file: std.Build.LazyPath,
|
||||
/// Exactly the modules the program's source @imports (directly or through
|
||||
/// its same-directory files) — no more, no less. Order is free; sorted
|
||||
/// reads best. An undeclared @import fails the compile; a name no
|
||||
/// declared domain exports fails the build graph, naming the miss.
|
||||
imports: []const []const u8,
|
||||
/// Built multi-threaded (`single_threaded = false`) so real atomics/TLS
|
||||
/// work — required before a binary may call `Thread.spawn`
|
||||
/// (docs/threading.md). Threads are a deliberate per-binary opt-in.
|
||||
threaded: bool = false,
|
||||
};
|
||||
|
||||
/// Build one user-space binary the same way for every program (init, the
|
||||
/// services, the drivers): freestanding, ReleaseSmall, `.large` code model
|
||||
/// (the image base is above 4 GiB — smaller models emit 32-bit relocations
|
||||
/// that can't reach), linked with the shared user link script. Pinned to
|
||||
/// LLVM + LLD so the script's PHDRS (segment permissions) are authoritative —
|
||||
/// the kernel's W^X user-ELF loader requires exact perms.
|
||||
///
|
||||
/// The compilation root is not the program's own file but the shared shim
|
||||
/// (the kernel package's root.zig), which supplies the root declarations
|
||||
/// (`main` re-export, panic handler, `_start` pull) so a program only defines
|
||||
/// `pub fn main`. The program's file becomes the `program` module the shim
|
||||
/// imports; reach it through `programModule` to add per-binary non-library
|
||||
/// modules (compile-time options).
|
||||
pub fn userBinary(b: *std.Build, options: UserBinaryOptions) *std.Build.Step.Compile {
|
||||
const kernel = b.dependency("kernel", .{});
|
||||
var imports: std.ArrayListUnmanaged(std.Build.Module.Import) = .empty;
|
||||
for (options.imports) |name| {
|
||||
imports.append(b.allocator, .{
|
||||
.name = name,
|
||||
.module = moduleFromDeclaredDependencies(b, name),
|
||||
}) catch @panic("OOM");
|
||||
}
|
||||
// Settings (target, optimize, code model, ...) live on the root module
|
||||
// only; the program module inherits them.
|
||||
const program_module = b.createModule(.{
|
||||
.root_source_file = options.root_source_file,
|
||||
.imports = imports.items,
|
||||
});
|
||||
const exe = b.addExecutable(.{
|
||||
.name = options.name,
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = kernel.path("root.zig"),
|
||||
.target = freestandingTarget(b),
|
||||
.optimize = .ReleaseSmall,
|
||||
.code_model = .large,
|
||||
.single_threaded = !options.threaded, // a threaded binary needs real atomics/TLS
|
||||
.sanitize_c = .off,
|
||||
.stack_check = false,
|
||||
.stack_protector = false,
|
||||
// The root shim itself imports only start (_start + panic) and
|
||||
// logging (std_options) — straight from the kernel package, so a
|
||||
// program's own import list stays exactly its own.
|
||||
.imports = &.{
|
||||
.{ .name = "start", .module = kernel.module("start") },
|
||||
.{ .name = "logging", .module = kernel.module("logging") },
|
||||
.{ .name = "program", .module = program_module },
|
||||
},
|
||||
}),
|
||||
});
|
||||
exe.setLinkerScript(kernel.path("user.ld"));
|
||||
exe.entry = .{ .symbol_name = "_start" };
|
||||
exe.image_base = 0x7000_0000_0000;
|
||||
exe.use_llvm = true;
|
||||
exe.use_lld = true;
|
||||
return exe;
|
||||
}
|
||||
|
||||
/// The `program` module of a binary built by `userBinary` — the module rooted
|
||||
/// at the program's own source file. Per-binary non-library modules (an
|
||||
/// addOptions build_options) go here, not on the root shim: module imports
|
||||
/// are not transitive, so an import added to the root would be invisible to
|
||||
/// the program's code.
|
||||
pub fn programModule(exe: *std.Build.Step.Compile) *std.Build.Module {
|
||||
return exe.root_module.import_table.get("program").?;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.{
|
||||
.name = .build_support,
|
||||
.version = "0.0.0",
|
||||
.fingerprint = 0xad91962994f4be41, // Changing this has security and trust implications.
|
||||
.minimum_zig_version = "0.16.0",
|
||||
.dependencies = .{},
|
||||
.paths = .{""},
|
||||
}
|
||||
+46
-8
@@ -32,6 +32,51 @@
|
||||
// Once all dependencies are fetched, `zig build` no longer requires
|
||||
// internet connectivity.
|
||||
.dependencies = .{
|
||||
// The danos build API — the shared user-binary recipe every build file
|
||||
// (root and per-binary packages) consumes (docs/build-packages-plan.md).
|
||||
.@"build-support" = .{ .path = "build-support" },
|
||||
// The library domains, each a package exporting its modules.
|
||||
.kernel = .{ .path = "library/kernel" },
|
||||
.device = .{ .path = "library/device" },
|
||||
.client = .{ .path = "library/client" },
|
||||
.protocol = .{ .path = "library/protocol" },
|
||||
.csv = .{ .path = "library/csv" },
|
||||
.@"xkeyboard-config" = .{ .path = "library/xkeyboard-config" },
|
||||
// Binary packages (phase 2), consumed as artifacts for the boot image.
|
||||
.@"pci-bus" = .{ .path = "system/drivers/pci-bus" },
|
||||
.init = .{ .path = "system/services/init" },
|
||||
.fat = .{ .path = "system/services/fat" },
|
||||
.display = .{ .path = "system/services/display" },
|
||||
.@"display-demo" = .{ .path = "system/services/display-demo" },
|
||||
.@"device-manager" = .{ .path = "system/services/device-manager" },
|
||||
.input = .{ .path = "system/services/input" },
|
||||
.logger = .{ .path = "system/services/logger" },
|
||||
// The discovery pair and the /test fixtures are lazy: only what a
|
||||
// given build actually ships gets its build file loaded and compiled
|
||||
// (-Ddiscovery picks one of the pair; -Dtest-case pulls the fixtures).
|
||||
.acpi = .{ .path = "system/services/acpi", .lazy = true },
|
||||
.fdt = .{ .path = "system/services/fdt", .lazy = true },
|
||||
.@"ps2-bus" = .{ .path = "system/drivers/ps2-bus" },
|
||||
.@"usb-xhci-bus" = .{ .path = "system/drivers/usb-xhci-bus" },
|
||||
.@"usb-hid" = .{ .path = "system/drivers/usb-hid" },
|
||||
.@"usb-storage" = .{ .path = "system/drivers/usb-storage" },
|
||||
.@"virtio-gpu" = .{ .path = "system/drivers/virtio-gpu" },
|
||||
.@"vfs-test" = .{ .path = "test/system/services/vfs-test", .lazy = true },
|
||||
.@"fat-test" = .{ .path = "test/system/services/fat-test", .lazy = true },
|
||||
.@"shared-memory-server" = .{ .path = "test/system/services/shared-memory-server", .lazy = true },
|
||||
.@"shared-memory-client" = .{ .path = "test/system/services/shared-memory-client", .lazy = true },
|
||||
.@"crash-test" = .{ .path = "test/system/services/crash-test", .lazy = true },
|
||||
.@"device-list" = .{ .path = "test/system/services/device-list", .lazy = true },
|
||||
.@"pci-cap-test" = .{ .path = "test/system/services/pci-cap-test", .lazy = true },
|
||||
.@"iommu-fault-test" = .{ .path = "test/system/services/iommu-fault-test", .lazy = true },
|
||||
.@"input-source" = .{ .path = "test/system/services/input-source", .lazy = true },
|
||||
.@"input-test" = .{ .path = "test/system/services/input-test", .lazy = true },
|
||||
.@"args-echo" = .{ .path = "test/system/services/args-echo", .lazy = true },
|
||||
.@"process-test" = .{ .path = "test/system/services/process-test", .lazy = true },
|
||||
.@"thread-test" = .{ .path = "test/system/services/thread-test", .lazy = true },
|
||||
.@"user-memory-test" = .{ .path = "test/system/services/user-memory-test", .lazy = true },
|
||||
.@"protocol-registry-test" = .{ .path = "test/system/services/protocol-registry-test", .lazy = true },
|
||||
.@"protocol-denied-test" = .{ .path = "test/system/services/protocol-denied-test", .lazy = true },
|
||||
// See `zig fetch --save <url>` for a command-line interface for adding dependencies.
|
||||
//.example = .{
|
||||
// // When updating this field to a new URL, be sure to delete the corresponding
|
||||
@@ -70,12 +115,5 @@
|
||||
// Paths are relative to the build root. Use the empty string (`""`) to refer to
|
||||
// the build root itself.
|
||||
// A directory listed here means that all files within, recursively, are included.
|
||||
.paths = .{
|
||||
"build.zig",
|
||||
"build.zig.zon",
|
||||
"src",
|
||||
// For example...
|
||||
//"LICENSE",
|
||||
//"README.md",
|
||||
},
|
||||
.paths = .{""},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Boot-image assembly (docs/build-packages-plan.md, phase 3): everything
|
||||
//! between "here are the built binaries" and "here is a bootable volume".
|
||||
//! The FHS-shaped zig-out install tree, the boot manifest, the boot capsule,
|
||||
//! the FAT32 USB image (+ its serial-enabled twin for the QEMU run steps),
|
||||
//! and the release ISO — with their check steps. The root build.zig decides
|
||||
//! WHAT ships (the bundled list); this file owns HOW it becomes an image.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
/// One user binary and its FHS home on the boot volume (and in zig-out).
|
||||
pub const BundledBinary = struct { path: []const u8, binary: std.Build.LazyPath };
|
||||
|
||||
pub const Options = struct {
|
||||
/// The installed/flashable kernel (serial follows the root -Dserial).
|
||||
kernel: *std.Build.Step.Compile,
|
||||
/// The serial-enabled kernel variant the `run-x86-64` image boots.
|
||||
kernel_serial: *std.Build.Step.Compile,
|
||||
/// The UEFI loader (BOOTX64).
|
||||
efi: *std.Build.Step.Compile,
|
||||
/// Every user binary and data file at its FHS path.
|
||||
bundled: []const BundledBinary,
|
||||
};
|
||||
|
||||
/// Wire up the install tree, both FAT32 boot images, the release ISO, and the
|
||||
/// check steps. Returns the serial-enabled FAT image for the QEMU run steps.
|
||||
pub fn addImageSteps(b: *std.Build, options: Options) std.Build.LazyPath {
|
||||
// Everything installs into a FHS-shaped zig-out: it IS the danos filesystem *and*
|
||||
// the boot volume. Each binary lands at its addressed, leaf-collapsed path — the
|
||||
// kernel at zig-out/system/kernel (from system/kernel/kernel.zig), init at
|
||||
// zig-out/system/services/init, and so on (see docs/README.md). The bootloader
|
||||
// then loads these FHS paths off the volume.
|
||||
const kernel_install = b.addInstallArtifact(options.kernel, .{ .dest_dir = .{ .override = .{ .custom = "system" } } });
|
||||
b.getInstallStep().dependOn(&kernel_install.step);
|
||||
|
||||
// UEFI firmware requires the removable-media loader at exactly \EFI\BOOT\BOOTX64.efi,
|
||||
// so that path is fixed by the firmware (it is /boot's EFI stub, conceptually).
|
||||
const efi_install = b.addInstallArtifact(options.efi, .{ .dest_dir = .{ .override = .{ .custom = "EFI/BOOT" } } });
|
||||
b.getInstallStep().dependOn(&efi_install.step);
|
||||
|
||||
// The boot manifest: the FHS path of every bundled binary, one per line. The
|
||||
// EFI loader reads THIS by name and opens each listed path by name — FAT
|
||||
// name lookup is case-insensitive and firmware-portable, unlike directory
|
||||
// ENUMERATION, whose returned names vary by firmware (bare 8.3 entries come
|
||||
// back uppercase on some FAT drivers). The tree walk remains only as the
|
||||
// loader's fallback for hand-assembled sticks without a manifest.
|
||||
var manifest_text: std.ArrayListUnmanaged(u8) = .empty;
|
||||
for (options.bundled) |item| {
|
||||
manifest_text.append(b.allocator, '/') catch @panic("OOM");
|
||||
manifest_text.appendSlice(b.allocator, item.path) catch @panic("OOM");
|
||||
manifest_text.append(b.allocator, '\n') catch @panic("OOM");
|
||||
}
|
||||
const manifest_files = b.addWriteFiles();
|
||||
const manifest_file = manifest_files.add("manifest", manifest_text.items);
|
||||
const manifest_install = b.addInstallFileWithDir(manifest_file, .prefix, "system/manifest");
|
||||
b.getInstallStep().dependOn(&manifest_install.step);
|
||||
|
||||
// The boot capsule: the same bundled list packed into ONE file (v2
|
||||
// initial_ramdisk format), because a single open + sequential read is the
|
||||
// only firmware file I/O shape that is fast everywhere — a per-file tree
|
||||
// walk measured MINUTES on real firmware. The loader tries this first,
|
||||
// then the manifest, then the walk; the running system cannot tell the
|
||||
// difference (it always receives the same in-RAM table). Derived from the
|
||||
// tree in the same build graph, so the two cannot drift.
|
||||
const mk_capsule = b.addSystemCommand(&.{"python3"});
|
||||
mk_capsule.addFileArg(b.path("tools/pack-system-image.py"));
|
||||
const capsule_img = mk_capsule.addOutputFileArg("system.img");
|
||||
for (options.bundled) |item| {
|
||||
mk_capsule.addArg(item.path);
|
||||
mk_capsule.addFileArg(item.binary);
|
||||
}
|
||||
const capsule_install = b.addInstallFile(capsule_img, "boot/system.img");
|
||||
b.getInstallStep().dependOn(&capsule_install.step);
|
||||
|
||||
// Install every bundled binary to its FHS home, so zig-out is a true image of
|
||||
// the filesystem — the same tree make-fat-image.py lays out on the boot volume.
|
||||
for (options.bundled) |item| {
|
||||
const install = b.addInstallFileWithDir(item.binary, .prefix, item.path);
|
||||
b.getInstallStep().dependOn(&install.step);
|
||||
}
|
||||
|
||||
// --- danos-usb.img: the bootable FAT32 USB image ---
|
||||
// Format a real FAT32 image (the in-repo Python builder, no external tools)
|
||||
// holding the EFI stub, the kernel, and the whole /system tree of user
|
||||
// binaries at their FHS paths. QEMU presents this image as a USB mass-storage
|
||||
// device the guest boots from (see run-x86-64 and the test harness), and the
|
||||
// danos fat driver mounts the same image at /volumes/usb.
|
||||
const fat_image = addBootImage(b, options.kernel.getEmittedBin(), options.efi.getEmittedBin(), manifest_file, capsule_img, options.bundled);
|
||||
const fat_image_install = b.addInstallFile(fat_image, "danos-usb.img");
|
||||
b.getInstallStep().dependOn(&fat_image_install.step);
|
||||
|
||||
// The image `run-x86-64` boots: identical to the flashable one but with the
|
||||
// serial log sink compiled in, so a developer always gets the machine-readable
|
||||
// log captured to serial0 — without baking serial into the image users flash.
|
||||
// Built lazily (only when `run-x86-64` is requested), and never installed.
|
||||
const fat_image_serial = addBootImage(b, options.kernel_serial.getEmittedBin(), options.efi.getEmittedBin(), manifest_file, capsule_img, options.bundled);
|
||||
|
||||
// `zig build check-fat-image` — validate the produced image is a real FAT32
|
||||
// with the EFI stub present (the builder's own --verify, no external tools).
|
||||
const check_fat = b.addSystemCommand(&.{"python3"});
|
||||
check_fat.addFileArg(b.path("tools/make-fat-image.py"));
|
||||
check_fat.addArg("--verify");
|
||||
check_fat.addFileArg(fat_image);
|
||||
const check_fat_step = b.step("check-fat-image", "Verify the FAT32 USB image is valid and bootable");
|
||||
check_fat_step.dependOn(&check_fat.step);
|
||||
|
||||
// --- release-x86-64: danos-x86-64.iso, the flashable release image ---
|
||||
// Wrap the FAT32 boot volume in a hybrid ISO (the in-repo Python builder
|
||||
// again, no xorriso/isohybrid): an ISO9660 whose El Torito EFI boot entry
|
||||
// and MBR ESP partition entry both point at the embedded FAT image. One
|
||||
// file then boots every way release media is consumed — flashed raw to a
|
||||
// USB stick with Etcher or dd, or burned to optical media — while
|
||||
// danos-usb.img stays the raw superfloppy QEMU and the test harness boot.
|
||||
const mk_iso = b.addSystemCommand(&.{"python3"});
|
||||
mk_iso.addFileArg(b.path("tools/make-iso-image.py"));
|
||||
const iso_image = mk_iso.addOutputFileArg("danos-x86-64.iso");
|
||||
mk_iso.addFileArg(fat_image);
|
||||
const iso_install = b.addInstallFile(iso_image, "danos-x86-64.iso");
|
||||
const release_step = b.step("release-x86-64", "Build the flashable x86-64 release ISO (zig-out/danos-x86-64.iso; flash with Etcher or dd)");
|
||||
release_step.dependOn(&iso_install.step);
|
||||
|
||||
// `zig build check-iso-image` — the ISO builder's own --verify (mirroring
|
||||
// check-fat-image): the MBR partition, the El Torito catalog, and the
|
||||
// embedded FAT32 image must all agree.
|
||||
const check_iso = b.addSystemCommand(&.{"python3"});
|
||||
check_iso.addFileArg(b.path("tools/make-iso-image.py"));
|
||||
check_iso.addArg("--verify");
|
||||
check_iso.addFileArg(iso_image);
|
||||
const check_iso_step = b.step("check-iso-image", "Verify the release ISO is a valid hybrid (MBR ESP partition + El Torito EFI entry)");
|
||||
check_iso_step.dependOn(&check_iso.step);
|
||||
|
||||
return fat_image_serial;
|
||||
}
|
||||
|
||||
/// Assemble the bootable FAT32 image (the in-repo Python builder) holding the
|
||||
/// EFI stub, the kernel, and every user binary at its FHS path — the volume's
|
||||
/// /system tree IS the system image; the EFI loader walks it at boot and builds
|
||||
/// the in-RAM initial_ramdisk from it. Factored so the serial-enabled
|
||||
/// `run-x86-64` variant can bundle its own serial kernel while sharing the
|
||||
/// loader and user tree (the loader's boot breadcrumbs and init's heartbeat both
|
||||
/// follow the top-level -Dserial). Returns the image's LazyPath.
|
||||
fn addBootImage(
|
||||
b: *std.Build,
|
||||
kernel_bin: std.Build.LazyPath,
|
||||
efi_bin: std.Build.LazyPath,
|
||||
manifest: std.Build.LazyPath,
|
||||
capsule: std.Build.LazyPath,
|
||||
bundled: []const BundledBinary,
|
||||
) std.Build.LazyPath {
|
||||
const mk_fat = b.addSystemCommand(&.{"python3"});
|
||||
mk_fat.addFileArg(b.path("tools/make-fat-image.py"));
|
||||
const fat_image = mk_fat.addOutputFileArg("danos-usb.img");
|
||||
mk_fat.addArg("64"); // MiB
|
||||
mk_fat.addArg("EFI/BOOT/BOOTX64.efi");
|
||||
mk_fat.addFileArg(efi_bin);
|
||||
mk_fat.addArg("system/kernel");
|
||||
mk_fat.addFileArg(kernel_bin);
|
||||
mk_fat.addArg("system/manifest");
|
||||
mk_fat.addFileArg(manifest);
|
||||
mk_fat.addArg("boot/system.img");
|
||||
mk_fat.addFileArg(capsule);
|
||||
for (bundled) |item| {
|
||||
mk_fat.addArg(item.path);
|
||||
mk_fat.addFileArg(item.binary);
|
||||
}
|
||||
return fat_image;
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
//! The QEMU run steps (docs/build-packages-plan.md, phase 3): `run-x86-64`
|
||||
//! boots the serial-enabled FAT image via UEFI/OVMF; `run-x86-64-gpu` adds a
|
||||
//! virtio-gpu adapter for the native-present display path. OVMF firmware is
|
||||
//! probed across distro/OS layouts (-Dovmf-code / -Dovmf-vars override).
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
/// Wire up the `run-x86-64` and `run-x86-64-gpu` steps around the given
|
||||
/// serial-enabled boot image (the guest boots that self-contained image
|
||||
/// attached as USB storage, not the installed FHS zig-out).
|
||||
pub fn addRunSteps(b: *std.Build, fat_image_serial: std.Build.LazyPath) void {
|
||||
// Firmware lives in different places per OS/distro, so probe the known
|
||||
// layouts (Architecture, Debian/Ubuntu, Fedora, macOS Homebrew) and use the first
|
||||
// that exists. Override with -Dovmf-code / -Dovmf-vars if yours is elsewhere.
|
||||
const ovmf_code = b.option(
|
||||
[]const u8,
|
||||
"ovmf-code",
|
||||
"Path to the OVMF_CODE firmware image",
|
||||
) orelse firstExisting(b.graph.io, &.{
|
||||
"/usr/share/edk2/x64/OVMF_CODE.4m.fd", // Architecture
|
||||
"/usr/share/OVMF/OVMF_CODE_4M.fd", // Debian/Ubuntu
|
||||
"/usr/share/OVMF/OVMF_CODE.fd", // older Debian/Ubuntu
|
||||
"/usr/share/edk2-ovmf/x64/OVMF_CODE.fd", // Fedora
|
||||
"/opt/homebrew/share/qemu/edk2-x86_64-code.fd", // macOS Homebrew (Apple Silicon)
|
||||
"/usr/local/share/qemu/edk2-x86_64-code.fd", // macOS Homebrew (Intel)
|
||||
});
|
||||
const ovmf_vars = b.option(
|
||||
[]const u8,
|
||||
"ovmf-vars",
|
||||
"Path to the OVMF_VARS firmware image (a writable copy is made)",
|
||||
) orelse firstExisting(b.graph.io, &.{
|
||||
"/usr/share/edk2/x64/OVMF_VARS.4m.fd", // Architecture
|
||||
"/usr/share/OVMF/OVMF_VARS_4M.fd", // Debian/Ubuntu
|
||||
"/usr/share/OVMF/OVMF_VARS.fd", // older Debian/Ubuntu
|
||||
"/usr/share/edk2-ovmf/x64/OVMF_VARS.fd", // Fedora
|
||||
"/opt/homebrew/share/qemu/edk2-i386-vars.fd", // macOS Homebrew (Apple Silicon)
|
||||
"/usr/local/share/qemu/edk2-i386-vars.fd", // macOS Homebrew (Intel)
|
||||
});
|
||||
|
||||
// The firmware needs to write NVRAM, so give it a writable copy of the vars.
|
||||
const vars_copy = b.addSystemCommand(&.{ "cp", "-f", ovmf_vars });
|
||||
const vars_out = vars_copy.addOutputFileArg("OVMF_VARS.4m.fd");
|
||||
|
||||
// Capture the guest's serial0 (danos's machine-readable log) to the qemu-test
|
||||
// scratch area — a dev/host artifact, kept out of the boot volume we mount.
|
||||
// (/system/logs on the volume belongs to the guest's own logger.) One
|
||||
// timestamped file per run.
|
||||
const log_dir = b.fmt("{s}/qemu-test", .{b.install_path});
|
||||
const make_log_dir = b.addSystemCommand(&.{ "mkdir", "-p", log_dir });
|
||||
|
||||
// --- run-x86-64: boot the x86-64 kernel in QEMU via UEFI/OVMF ---
|
||||
const run_efi = b.addSystemCommand(&.{
|
||||
"qemu-system-x86_64",
|
||||
"-device",
|
||||
"qemu-xhci,id=xhci",
|
||||
"-device",
|
||||
"usb-mouse,bus=xhci.0",
|
||||
"-device",
|
||||
"usb-kbd,bus=xhci.0",
|
||||
"-machine",
|
||||
"q35",
|
||||
"-m",
|
||||
"128M",
|
||||
"-drive",
|
||||
b.fmt("if=pflash,format=raw,readonly=on,file={s}", .{ovmf_code}),
|
||||
});
|
||||
run_efi.addArg("-drive");
|
||||
run_efi.addPrefixedFileArg("if=pflash,format=raw,file=", vars_out);
|
||||
// Boot off the FAT32 USB image: a mass-storage device on the same xHCI bus as
|
||||
// the keyboard and mouse. OVMF finds \EFI\BOOT\BOOTX64.efi on it and boots.
|
||||
// The serial-enabled variant, so serial0 carries the log for this dev boot.
|
||||
run_efi.addArg("-drive");
|
||||
run_efi.addPrefixedFileArg("if=none,id=bootusb,format=raw,file=", fat_image_serial);
|
||||
run_efi.addArgs(&.{
|
||||
"-device",
|
||||
"usb-storage,bus=xhci.0,drive=bootusb,removable=on,bootindex=0",
|
||||
"-net",
|
||||
"none",
|
||||
// Emulated display advertising 1280x720 as its native (EDID preferred)
|
||||
// resolution, so the kernel's native-resolution switch has something to
|
||||
// find. `-vga none` avoids a second, default adapter.
|
||||
"-vga",
|
||||
"none",
|
||||
"-device",
|
||||
"VGA,edid=on,xres=1280,yres=720",
|
||||
});
|
||||
const serial_log = b.fmt("{s}/run-x86-64-serial0-{s}.log", .{ log_dir, timestamp(b) });
|
||||
run_efi.addArgs(&.{ "-serial", b.fmt("file:{s}", .{serial_log}) });
|
||||
// We boot the self-contained `fat_image_serial` (added as a file arg above, so
|
||||
// it's already a dependency) — not the installed FHS zig-out — so `run-x86-64`
|
||||
// builds only the serial kernel, never the flashable one. Just make the serial
|
||||
// scratch dir first.
|
||||
run_efi.step.dependOn(&make_log_dir.step);
|
||||
|
||||
const run_efi_step = b.step("run-x86-64", "Boot the x86-64 kernel in QEMU (UEFI/OVMF); serial0 is logged to zig-out/qemu-test/run-x86-64-serial0-<timestamp>.log");
|
||||
run_efi_step.dependOn(&run_efi.step);
|
||||
|
||||
// --- run-x86-64-gpu: the same boot plus a virtio-gpu adapter ---
|
||||
// The VGA device still supplies the boot (GOP) framebuffer the compositor starts
|
||||
// on; the virtio-gpu function is discovered by the device-manager stack, its
|
||||
// driver announces a shared scanout, and the compositor upgrades off the GOP
|
||||
// floor to fenced, tear-free native presents (docs/display-v2.md).
|
||||
// This is the interactive twin of the `display-native` test case, and 512M
|
||||
// matches it (the whole driver stack + the compositor's surfaces at once).
|
||||
// QEMU shows one head per adapter: pick the virtio-gpu head in the View menu
|
||||
// to watch the native output.
|
||||
const run_gpu = b.addSystemCommand(&.{
|
||||
"qemu-system-x86_64",
|
||||
"-device",
|
||||
"qemu-xhci,id=xhci",
|
||||
"-device",
|
||||
"usb-mouse,bus=xhci.0",
|
||||
"-device",
|
||||
"usb-kbd,bus=xhci.0",
|
||||
"-machine",
|
||||
"q35",
|
||||
"-m",
|
||||
"512M",
|
||||
"-drive",
|
||||
b.fmt("if=pflash,format=raw,readonly=on,file={s}", .{ovmf_code}),
|
||||
});
|
||||
run_gpu.addArg("-drive");
|
||||
run_gpu.addPrefixedFileArg("if=pflash,format=raw,file=", vars_out);
|
||||
run_gpu.addArg("-drive");
|
||||
run_gpu.addPrefixedFileArg("if=none,id=bootusb,format=raw,file=", fat_image_serial);
|
||||
run_gpu.addArgs(&.{
|
||||
"-device",
|
||||
"usb-storage,bus=xhci.0,drive=bootusb,removable=on,bootindex=0",
|
||||
"-net",
|
||||
"none",
|
||||
"-vga",
|
||||
"none",
|
||||
"-device",
|
||||
"VGA,edid=on,xres=1280,yres=720",
|
||||
"-device",
|
||||
"virtio-gpu-pci",
|
||||
});
|
||||
const gpu_serial_log = b.fmt("{s}/run-x86-64-gpu-serial0-{s}.log", .{ log_dir, timestamp(b) });
|
||||
run_gpu.addArgs(&.{ "-serial", b.fmt("file:{s}", .{gpu_serial_log}) });
|
||||
run_gpu.step.dependOn(&make_log_dir.step);
|
||||
|
||||
const run_gpu_step = b.step("run-x86-64-gpu", "Boot in QEMU with a virtio-gpu adapter: the compositor upgrades to fenced (tear-free) native presents; watch the virtio-gpu head in QEMU's View menu");
|
||||
run_gpu_step.dependOn(&run_gpu.step);
|
||||
}
|
||||
|
||||
/// Return the first path in `candidates` that exists on the build host, else the
|
||||
/// first candidate as a fallback so a missing-firmware error still names a
|
||||
/// concrete (and, by convention, the primary) path. Used to locate OVMF firmware
|
||||
/// across distro/OS layouts without configuration.
|
||||
fn firstExisting(io: std.Io, candidates: []const []const u8) []const u8 {
|
||||
for (candidates) |path| {
|
||||
std.Io.Dir.accessAbsolute(io, path, .{}) catch continue;
|
||||
return path;
|
||||
}
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
/// A UTC timestamp like "20260708-153045", for naming a per-run artifact so
|
||||
/// repeated runs don't clobber each other's logs. Resolved when `zig build`
|
||||
/// runs, which is moments before QEMU launches.
|
||||
fn timestamp(b: *std.Build) []const u8 {
|
||||
const ns = std.Io.Clock.now(.real, b.graph.io).nanoseconds;
|
||||
const secs: u64 = @intCast(@divFloor(ns, std.time.ns_per_s));
|
||||
const es = std.time.epoch.EpochSeconds{ .secs = secs };
|
||||
const yd = es.getEpochDay().calculateYearDay();
|
||||
const md = yd.calculateMonthDay();
|
||||
const ds = es.getDaySeconds();
|
||||
return b.fmt("{d:0>4}{d:0>2}{d:0>2}-{d:0>2}{d:0>2}{d:0>2}", .{
|
||||
yd.year,
|
||||
md.month.numeric(),
|
||||
@as(u32, md.day_index) + 1,
|
||||
ds.getHoursIntoDay(),
|
||||
ds.getMinutesIntoHour(),
|
||||
ds.getSecondsIntoMinute(),
|
||||
});
|
||||
}
|
||||
+79
-66
@@ -3,102 +3,102 @@
|
||||
Notes on how danos boots and draws, written to explain the *why* behind the code
|
||||
rather than restate it. Roughly in the order things happen at runtime:
|
||||
|
||||
1. **[efi.md](os-development-guide/efi.md) — EFI / the boot process.** How UEFI firmware finds and
|
||||
1. **[efi.md](os-development/efi.md) — EFI / the boot process.** How UEFI firmware finds and
|
||||
runs the bootloader, what the loader gathers before `ExitBootServices`, how it
|
||||
loads the kernel ELF, and the ABI contract for the jump into the kernel. Start
|
||||
here.
|
||||
2. **[system-image.md](os-development-guide/system-image.md) — system.img, the boot capsule.** The
|
||||
2. **[system-image.md](os-development/system-image.md) — system.img, the boot capsule.** The
|
||||
bundled user binaries packed into one file in the initial-ramdisk wire
|
||||
format, because one open + one sequential read is the only file I/O shape
|
||||
firmware is fast at. The trivial container format, the three artifacts one
|
||||
build list derives (tree, manifest, capsule), the loader's three-strategy
|
||||
fallback chain, and the capsule's kernel-side life as both the spawn table
|
||||
and the read-only `/system` mount.
|
||||
3. **[gop.md](os-development-guide/gop.md) — the Graphics Output Protocol.** How UEFI exposes graphics
|
||||
3. **[gop.md](os-development/gop.md) — the Graphics Output Protocol.** How UEFI exposes graphics
|
||||
modes (unlike fixed VGA modes), how we detect the monitor's native resolution
|
||||
from EDID and switch to it, and the pixel formats we accept or reject.
|
||||
4. **[framebuffer.md](os-development-guide/framebuffer.md) — the framebuffer.** What the linear
|
||||
4. **[framebuffer.md](os-development/framebuffer.md) — the framebuffer.** What the linear
|
||||
framebuffer the loader hands over actually is, and what **pitch** (stride)
|
||||
means versus width — the detail you have to get right to avoid a skewed image.
|
||||
5. **[memory-map.md](os-development-guide/memory-map.md) — the memory map.** How the loader learns what
|
||||
5. **[memory-map.md](os-development/memory-map.md) — the memory map.** How the loader learns what
|
||||
physical RAM exists and hands it to the kernel in danos's own neutral format,
|
||||
rather than leaking UEFI's memory descriptors across the boundary.
|
||||
6. **[frame-allocator.md](os-development-guide/frame-allocator.md) — the physical frame allocator.** The
|
||||
6. **[frame-allocator.md](os-development/frame-allocator.md) — the physical frame allocator.** The
|
||||
bitmap allocator that hands out and reclaims 4 KiB physical frames from that
|
||||
map — the primitive page tables and the heap are built on.
|
||||
7. **[interrupts.md](os-development-guide/interrupts.md) — interrupts and exceptions.** The GDT, IDT and
|
||||
7. **[interrupts.md](os-development/interrupts.md) — interrupts and exceptions.** The GDT, IDT and
|
||||
TSS, the exception stubs, and the handler that reports a CPU fault in red instead
|
||||
of letting it triple-fault into a silent reset.
|
||||
8. **[paging.md](os-development-guide/paging.md) — the kernel's page tables.** Building our own 4-level
|
||||
8. **[paging.md](os-development/paging.md) — the kernel's page tables.** Building our own 4-level
|
||||
page tables, identity-mapping the low 4 GiB, and switching CR3 off the firmware's
|
||||
tables onto ours.
|
||||
9. **[device-interrupts.md](device-driver-development-guide/device-interrupts.md) — device interrupts.** The Local
|
||||
9. **[device-interrupts.md](device-driver-development/device-interrupts.md) — device interrupts.** The Local
|
||||
APIC and its timer — the kernel's first interrupt that is *handled and returned
|
||||
from*, giving it a heartbeat.
|
||||
10. **[heap.md](os-development-guide/heap.md) — the kernel heap.** A growable free-list allocator built on
|
||||
10. **[heap.md](os-development/heap.md) — the kernel heap.** A growable free-list allocator built on
|
||||
the VMM, exposed as a `std.mem.Allocator` so std containers work — dynamic
|
||||
allocation for the kernel.
|
||||
11. **[scheduling.md](os-development-guide/scheduling.md) — the scheduler.** Fixed-priority preemptive
|
||||
11. **[scheduling.md](os-development/scheduling.md) — the scheduler.** Fixed-priority preemptive
|
||||
multitasking: kernel threads, the context switch, O(1) priority selection, and
|
||||
blocking (sleep, wait queues) — the leap to a running system.
|
||||
12. **[ipc.md](device-driver-development-guide/ipc.md) — inter-process communication.** Bounded blocking
|
||||
12. **[ipc.md](device-driver-development/ipc.md) — inter-process communication.** Bounded blocking
|
||||
message-passing channels, then synchronous call/reply between *processes* over
|
||||
endpoints — the backbone the microkernel's isolated servers talk over.
|
||||
13. **[syscall.md](os-development-guide/syscall.md) — system calls.** How ring 3 asks the kernel for
|
||||
13. **[syscall.md](os-development/syscall.md) — system calls.** How ring 3 asks the kernel for
|
||||
something: the `syscall`/`sysret` fast path, the trap frame, and why the table is
|
||||
deliberately tiny. The numbers are a **private** ABI — [vdso.md](os-development-guide/vdso.md) designs
|
||||
deliberately tiny. The numbers are a **private** ABI — [vdso.md](os-development/vdso.md) designs
|
||||
the public boundary that will hide them.
|
||||
14. **[vfs-protocol.md](file-system-development/vfs-protocol.md) — the VFS wire protocol.** The language-neutral
|
||||
byte-level spec of the file protocol spoken over IPC: request/reply headers,
|
||||
the operation table, mount routing, and the append-only evolution rules — the
|
||||
first IPC protocol documented as public ABI.
|
||||
15. **[drivers.md](device-driver-development-guide/drivers.md) — writing a driver.** The payoff: a driver is an
|
||||
15. **[drivers.md](device-driver-development/drivers.md) — writing a driver.** The payoff: a driver is an
|
||||
ordinary ring-3 process that claims a device, maps its registers, and **sleeps
|
||||
until its hardware interrupts it**. The claim is the capability; `irq_ack` is the
|
||||
unmask.
|
||||
16. **[driver-model.md](device-driver-development-guide/driver-model.md) — buses, classes and host controllers.** How
|
||||
16. **[driver-model.md](device-driver-development/driver-model.md) — buses, classes and host controllers.** How
|
||||
real driver stacks factor into three shapes and how families share code. The
|
||||
three primitives it proposed are long since built (M13 capability passing,
|
||||
M14 DMA + barriers, M15 MSI), and the driver *contract* on top of them —
|
||||
hello, supervision, restart — is built too (device-manager.md, M18).
|
||||
17. **[usb-hub.md](device-driver-development-guide/usb-hub.md) — USB hubs.** Built (M22): why hub topology is handled
|
||||
17. **[usb-hub.md](device-driver-development/usb-hub.md) — USB hubs.** Built (M22): why hub topology is handled
|
||||
*inside* the `usb-xhci-bus` driver rather than a separate hub class driver — a
|
||||
device behind a hub is reached by the **controller**, programmed with a route
|
||||
string in its slot context — plus the compound-hub reality (a USB 3.0 hub is
|
||||
physically two hubs) and detection via the hub's status-change interrupt endpoint.
|
||||
18. **[process-management.md](os-development-guide/process-management.md) — process management.** The
|
||||
18. **[process-management.md](os-development/process-management.md) — process management.** The
|
||||
microkernel's `ps`/`kill`/SIGCHLD: enumerate as a table snapshot, the
|
||||
supervision link as the kill authority, and child-exit notifications over the
|
||||
same endpoints IRQs arrive on.
|
||||
19. **[process-lifecycle.md](os-development-guide/process-lifecycle.md) — the process lifecycle.** Built
|
||||
19. **[process-lifecycle.md](os-development/process-lifecycle.md) — the process lifecycle.** Built
|
||||
(M17): signals over IPC as the one lifecycle vocabulary every process speaks — the
|
||||
POSIX.1-1990 words with message delivery instead of stack hijack, the stable
|
||||
`process` module interface, exit reasons, published exit events any stateful
|
||||
service can subscribe to (the VFS releasing dead clients' handles), and the two
|
||||
iron rules (cleanup is the kernel's job; kill is not a signal).
|
||||
20. **[device-manager.md](device-driver-development-guide/device-manager.md) — the device manager.** Built (M18,
|
||||
20. **[device-manager.md](device-driver-development/device-manager.md) — the device manager.** Built (M18,
|
||||
through the app surface): the
|
||||
tree, the matcher, and the supervisor. Tree structure lives in the manager,
|
||||
authority stays in the kernel; bus drivers report what they see; drivers are
|
||||
restarted through the lifecycle vocabulary — the plan that turns
|
||||
[resilience.md](os-development-guide/resilience.md)'s restart goal into increments.
|
||||
21. **[input.md](device-driver-development-guide/input.md) — the input module.** Broadcasting input events (keyboard,
|
||||
[resilience.md](os-development/resilience.md)'s restart goal into increments.
|
||||
21. **[input.md](device-driver-development/input.md) — the input module.** Broadcasting input events (keyboard,
|
||||
mouse, joystick): why a synchronous rendezvous can't fan out to many listeners, the
|
||||
asynchronous `ipc_send` primitive built to fix it, and the per-device subscribe/publish
|
||||
service layered on top.
|
||||
22. **[display.md](device-driver-development-guide/display.md) — the display service.** The display half of the GUI
|
||||
22. **[display.md](device-driver-development/display.md) — the display service.** The display half of the GUI
|
||||
track: a user-space compositor that owns the framebuffer, composes a layer stack into
|
||||
a double buffer, and presents it. Why GOP and the PCI display device are two views of
|
||||
one controller, the device-node + write-combining handoff, and what flicker-free buys
|
||||
that tear-free doesn't. Plan: [display-plan.md](device-driver-development-guide/display-plan.md). **v2** (complete) makes
|
||||
that tear-free doesn't. Plan: [display-plan.md](device-driver-development/display-plan.md). **v2** (complete) makes
|
||||
scanout a pluggable backend — GOP floor + a native virtio-gpu driver, hot-attached, with
|
||||
runtime mode-set, EDID, fenced vsync presents, and restart re-attach:
|
||||
[display-v2.md](device-driver-development-guide/display-v2.md), plan [display-v2-plan.md](device-driver-development-guide/display-v2-plan.md). Looking
|
||||
[display-v2.md](device-driver-development/display-v2.md), plan [display-v2-plan.md](device-driver-development/display-v2-plan.md). Looking
|
||||
further out, three research snapshots survey what a *native* driver for real GPU silicon
|
||||
would take as another `.scanout` backend: [nvidia-gpus.md](device-driver-development-guide/nvidia-gpus.md) (RTX 3060 /
|
||||
Ampere), [amd-gpus.md](device-driver-development-guide/amd-gpus.md) (RX 6600 / RDNA2), and [intel-igpu.md](device-driver-development-guide/intel-igpu.md)
|
||||
would take as another `.scanout` backend: [nvidia-gpus.md](device-driver-development/nvidia-gpus.md) (RTX 3060 /
|
||||
Ampere), [amd-gpus.md](device-driver-development/amd-gpus.md) (RX 6600 / RDNA2), and [intel-igpu.md](device-driver-development/intel-igpu.md)
|
||||
(Intel iGPU).
|
||||
23. **[halting.md](os-development-guide/halting.md) — halting.** Why a kernel can't just "exit", and
|
||||
23. **[halting.md](os-development/halting.md) — halting.** Why a kernel can't just "exit", and
|
||||
how `while (true) hlt` parks the CPU safely once there's nothing left to do.
|
||||
|
||||
Start with the north star:
|
||||
@@ -108,7 +108,7 @@ Start with the north star:
|
||||
**resilience** (restartable components). Win condition: runs on the author's PC and
|
||||
both Raspberry Pis, ideally with a GUI. Real-time is an option to explore, not a
|
||||
requirement. The *why* that shapes everything below.
|
||||
- **[resilience.md](os-development-guide/resilience.md) — resilience.** A design note (not built yet) on
|
||||
- **[resilience.md](os-development/resilience.md) — resilience.** A design note (not built yet) on
|
||||
fault isolation + live restart — the reincarnation-server + capability model that
|
||||
makes "if I break it, I can restart it" real. danos's core motivation.
|
||||
- **[zig-self-hosting.md](zig-self-hosting.md) — running Zig on danos.** A design note
|
||||
@@ -117,14 +117,14 @@ Start with the north star:
|
||||
port to **one seam** (`std.os.danos`), so we build an `os` seam module (→ that seam) plus
|
||||
the thin `file-system` module, retire the `posix` shim, and follow a phased path to
|
||||
`zig build-exe hello.zig` running on danos — **not** Linux-ABI emulation.
|
||||
- **[threading.md](os-development-guide/threading.md) — threads, the std-shaped way.** **Built** (M1–M6):
|
||||
- **[threading.md](os-development/threading.md) — threads, the std-shaped way.** **Built** (M1–M6):
|
||||
the `thread` module's `Thread` mirrors `std.Thread`'s API (spawn/join/detach, Mutex/Condition/
|
||||
Semaphore) over a **private** thread ABI — several tasks sharing one address space via
|
||||
a `thread_spawn` syscall, futex-backed blocking, address-space refcounting. Why it's the
|
||||
native type and not literal `std.Thread` (the [private ABI](os-development-guide/syscall.md)), and why
|
||||
threads stay a narrow opt-in against the [resilience](os-development-guide/resilience.md) default. Build
|
||||
plan + gates: [threading-plan.md](os-development-guide/threading-plan.md).
|
||||
- **[vdso.md](os-development-guide/vdso.md) — the vDSO, the public system-call boundary.** A design note
|
||||
native type and not literal `std.Thread` (the [private ABI](os-development/syscall.md)), and why
|
||||
threads stay a narrow opt-in against the [resilience](os-development/resilience.md) default. Build
|
||||
plan + gates: [threading-plan.md](os-development/threading-plan.md).
|
||||
- **[vdso.md](os-development/vdso.md) — the vDSO, the public system-call boundary.** A design note
|
||||
(not built yet) on keeping `abi.zig` genuinely private: a kernel-supplied, C-ABI
|
||||
entry blob mapped into every process as the *only* way into the kernel — so the
|
||||
syscall numbers can be renumbered or randomised at will, and Rust/C binaries get a
|
||||
@@ -137,69 +137,69 @@ Cutting across all of these:
|
||||
hardware needed to run danos: minimum specs (UEFI x86-64, ACPI, PCIe ECAM,
|
||||
xHCI, ~128 MiB RAM) grounded in what the boot path actually assumes, plus a
|
||||
plain-language guide matching Intel/AMD CPU generations by name.
|
||||
- **[release-iso.md](os-development-guide/release-iso.md) — the release ISO.** The flashable boot
|
||||
- **[release-iso.md](os-development/release-iso.md) — the release ISO.** The flashable boot
|
||||
media: `zig build release-x86-64` wraps the FAT32 boot volume in a hybrid ISO
|
||||
(MBR ESP partition + El Torito EFI entry, one embedded image) that Etcher/dd
|
||||
flash to USB or a burner writes to disc — built by an in-repo pure-Python
|
||||
tool, like the FAT image itself.
|
||||
- **[architecture.md](os-development-guide/architecture.md) — the architecture split.** How CPU-specific code is kept
|
||||
- **[architecture.md](os-development/architecture.md) — the architecture split.** How CPU-specific code is kept
|
||||
behind a build-time `arch` module so the generic kernel never names x86_64,
|
||||
leaving room for other systems (e.g. an AArch64 Raspberry Pi) later.
|
||||
- **[arm.md](os-development-guide/arm.md) — ARM targets.** The Raspberry Pi landscape the arch split is
|
||||
- **[arm.md](os-development/arm.md) — ARM targets.** The Raspberry Pi landscape the arch split is
|
||||
aiming at: `arm` (32-bit, Pi Zero W) vs `aarch64` (64-bit, Pi 3-5), UEFI vs
|
||||
device-tree boot, and what each layer needs.
|
||||
- **[discovery.md](os-development-guide/discovery.md) — device discovery.** A design note on learning what
|
||||
- **[discovery.md](os-development/discovery.md) — device discovery.** A design note on learning what
|
||||
hardware exists via ACPI (x86) or device tree (ARM) behind one neutral device model —
|
||||
when to build it, and how to keep it architecture-agnostic.
|
||||
- **[acpi.md](os-development-guide/acpi.md) — finding the ACPI tables.** The concrete x86 locator chain:
|
||||
- **[acpi.md](os-development/acpi.md) — finding the ACPI tables.** The concrete x86 locator chain:
|
||||
how the loader captures the **RSDP**, hands its physical address across in `BootInformation`,
|
||||
and how the platform derives the **RSDT/XSDT** from it and walks the SDTs — plus the
|
||||
live event side (the SCI, the power button, GPE/Notify) the ring-3 acpi service runs.
|
||||
- **[power.md](os-development-guide/power.md) — the power service.** System power as a domain-named
|
||||
- **[power.md](os-development/power.md) — the power service.** System power as a domain-named
|
||||
service: button/lid/battery events published to subscribers, and init's orderly
|
||||
shutdown composing the [lifecycle](os-development-guide/process-lifecycle.md) stop sequence with an ACPI
|
||||
shutdown composing the [lifecycle](os-development/process-lifecycle.md) stop sequence with an ACPI
|
||||
S5 write. Firmware-neutral — a PSCI backend drops in on ARM.
|
||||
- **[timers.md](os-development-guide/timers.md) — timers and time.** The ring-3 surface for reading the
|
||||
- **[timers.md](os-development/timers.md) — timers and time.** The ring-3 surface for reading the
|
||||
clock and waiting: why `now()` is a syscall rather than a service, and the one-shot
|
||||
timer notification (`timer_bind`) that gives supervisors a timed wait — built on the
|
||||
LAPIC heartbeat and calibrated TSC of [device-interrupts.md](device-driver-development-guide/device-interrupts.md).
|
||||
- **[smp.md](os-development-guide/smp.md) — multiple cores.** A design/research note on how microkernels
|
||||
LAPIC heartbeat and calibrated TSC of [device-interrupts.md](device-driver-development/device-interrupts.md).
|
||||
- **[smp.md](os-development/smp.md) — multiple cores.** A design/research note on how microkernels
|
||||
(L4, seL4) handle SMP — big kernel lock vs per-CPU vs multikernel — and how the
|
||||
right choice depends on whether danos is chasing real-time or resilience.
|
||||
- **[coding-standards.md](coding-standards.md) — coding standards.** The naming rule the
|
||||
tree follows: non-acronyms are spelled out in full (`message`, not `msg`), files are
|
||||
`kebab-case`, code follows Zig's case conventions, and the handful of exceptions
|
||||
(POSIX/C ABI names, `init`/`len`/`ptr`, acronyms).
|
||||
- **[sysv.md](os-development-guide/sysv.md) — the calling convention.** What "the kernel is SysV" means,
|
||||
- **[sysv.md](os-development/sysv.md) — the calling convention.** What "the kernel is SysV" means,
|
||||
and why the loader→kernel boundary has to pin it (the RDI-vs-RCX handoff).
|
||||
- **[testing.md](testing.md) — testing.** How the kernel is tested by booting it in
|
||||
QEMU and asserting on its serial output — reproducibly, and structured so the
|
||||
same tests run across architectures.
|
||||
- **[logging.md](os-development-guide/logging.md) — logging.** The multi-sink diagnostic log (serial,
|
||||
- **[logging.md](os-development/logging.md) — logging.** The multi-sink diagnostic log (serial,
|
||||
0xE9 debugcon, file later) kept separate from the framebuffer display, plus the
|
||||
robustness path: optional framebuffer, POST-code checkpoints, and a persistent
|
||||
panic breadcrumb so the kernel survives — and can be diagnosed — with no output.
|
||||
|
||||
## How the pieces relate
|
||||
|
||||
The boot flow ties them together: UEFI runs the loader ([efi.md](os-development-guide/efi.md)), which
|
||||
queries the **GOP** to pick a graphics mode ([gop.md](os-development-guide/gop.md)), hands the kernel a
|
||||
**framebuffer** to draw into ([framebuffer.md](os-development-guide/framebuffer.md)) and a **memory
|
||||
map** of physical RAM ([memory-map.md](os-development-guide/memory-map.md)); the kernel turns that map
|
||||
into a **frame allocator** ([frame-allocator.md](os-development-guide/frame-allocator.md)), installs
|
||||
its **descriptor tables** so CPU faults are caught ([interrupts.md](os-development-guide/interrupts.md)),
|
||||
builds its own **page tables** and switches onto them ([paging.md](os-development-guide/paging.md)),
|
||||
brings up the **heap** for dynamic allocation ([heap.md](os-development-guide/heap.md)), starts the
|
||||
**scheduler** ([scheduling.md](os-development-guide/scheduling.md)) and the **timer** that preempts it
|
||||
([device-interrupts.md](device-driver-development-guide/device-interrupts.md)) — with tasks blocking, sleeping and
|
||||
passing messages over **[IPC](device-driver-development-guide/ipc.md)** channels — runs, its CPU-specific bits
|
||||
behind the [architecture](os-development-guide/architecture.md) boundary, and when idle, or on a panic, it **halts**
|
||||
([halting.md](os-development-guide/halting.md)).
|
||||
The boot flow ties them together: UEFI runs the loader ([efi.md](os-development/efi.md)), which
|
||||
queries the **GOP** to pick a graphics mode ([gop.md](os-development/gop.md)), hands the kernel a
|
||||
**framebuffer** to draw into ([framebuffer.md](os-development/framebuffer.md)) and a **memory
|
||||
map** of physical RAM ([memory-map.md](os-development/memory-map.md)); the kernel turns that map
|
||||
into a **frame allocator** ([frame-allocator.md](os-development/frame-allocator.md)), installs
|
||||
its **descriptor tables** so CPU faults are caught ([interrupts.md](os-development/interrupts.md)),
|
||||
builds its own **page tables** and switches onto them ([paging.md](os-development/paging.md)),
|
||||
brings up the **heap** for dynamic allocation ([heap.md](os-development/heap.md)), starts the
|
||||
**scheduler** ([scheduling.md](os-development/scheduling.md)) and the **timer** that preempts it
|
||||
([device-interrupts.md](device-driver-development/device-interrupts.md)) — with tasks blocking, sleeping and
|
||||
passing messages over **[IPC](device-driver-development/ipc.md)** channels — runs, its CPU-specific bits
|
||||
behind the [architecture](os-development/architecture.md) boundary, and when idle, or on a panic, it **halts**
|
||||
([halting.md](os-development/halting.md)).
|
||||
|
||||
Above that line the microkernel proper begins: **discovery** ([discovery.md](os-development-guide/discovery.md),
|
||||
[acpi.md](os-development-guide/acpi.md)) learns what hardware exists, ring-3 processes ask the kernel for
|
||||
things through the small **[syscall](os-development-guide/syscall.md)** table, isolated servers reach each
|
||||
other over IPC **endpoints** ([ipc.md](device-driver-development-guide/ipc.md)), and a **[driver](device-driver-development-guide/drivers.md)** claims
|
||||
Above that line the microkernel proper begins: **discovery** ([discovery.md](os-development/discovery.md),
|
||||
[acpi.md](os-development/acpi.md)) learns what hardware exists, ring-3 processes ask the kernel for
|
||||
things through the small **[syscall](os-development/syscall.md)** table, isolated servers reach each
|
||||
other over IPC **endpoints** ([ipc.md](device-driver-development/ipc.md)), and a **[driver](device-driver-development/drivers.md)** claims
|
||||
a device, maps its registers, and sleeps until the hardware interrupts it — which is
|
||||
the whole reason for the arrangement ([vision.md](vision.md)).
|
||||
|
||||
@@ -208,7 +208,7 @@ the whole reason for the arrangement ([vision.md](vision.md)).
|
||||
danos is a **monorepo of sub-projects**. Each service or driver is a directory that is
|
||||
its own Zig module — it can hold as many files as it needs, and other sub-projects
|
||||
reach it *by module name*, never by a path into its files. The source tree deliberately
|
||||
**mirrors the runtime FHS** ([danos-file-system-hierarchy-FSH.md](file-system-development/danos-file-system-hierarchy-FSH.md)):
|
||||
**mirrors the runtime file-system hierarchy** ([file-system-hierarchy.md](file-system-development/file-system-hierarchy.md)):
|
||||
what you see under `system/` in the source is what a running danos represents under
|
||||
`/system`.
|
||||
|
||||
@@ -216,7 +216,7 @@ what you see under `system/` in the source is what a running danos represents un
|
||||
name.** `system/services/init/` contains `init.zig` (its root), and produces a binary
|
||||
addressed as **`system/services/init`** — the repeated leaf resolves away:
|
||||
|
||||
| Source (root file) | Addressed as (module / binary / FHS path) |
|
||||
| Source (root file) | Addressed as (module / binary / hierarchy path) |
|
||||
|----------------------------------------|--------------------------------------------|
|
||||
| `system/services/init/init.zig` | `system/services/init` → `/system/services/init` |
|
||||
| `system/drivers/ps2-bus/ps2-bus.zig` | `system/drivers/ps2-bus` → `/system/drivers/ps2-bus` |
|
||||
@@ -263,9 +263,20 @@ test/ → /test the test tree: the QEMU harness (qemu_test.py, h
|
||||
system/services/ beside the on-image test fixtures — vfs-test/ thread-test/
|
||||
crash-test/ … — whose repo path IS their boot-volume path
|
||||
(/test/system/services/<name>)
|
||||
build-support/ the danos build API (build-time only, nothing on the image):
|
||||
the shared user-binary recipe + default-import wiring every
|
||||
build file consumes (docs/build-packages-plan.md)
|
||||
build/ root-build helpers: image assembly (images.zig) + the QEMU
|
||||
run steps (qemu.zig)
|
||||
tools/ host-side build scripts
|
||||
```
|
||||
|
||||
**Builds are packages** (docs/build-packages-plan.md): each `library/` domain owns a
|
||||
`build.zig`/`build.zig.zon` exporting its modules (with a standalone `zig build test`),
|
||||
every binary directory is a ~15-line package build, and the root `build.zig`
|
||||
orchestrates — the kernel + loader, what ships, and the aggregate test step — with
|
||||
image assembly in `build/images.zig` and the QEMU run steps in `build/qemu.zig`.
|
||||
|
||||
**Wire protocols live in `library/protocol/`**, one module per directory
|
||||
(`library/protocol/vfs/vfs-protocol.zig` is the `vfs-protocol` module), imported by module
|
||||
name. A protocol is the seam between a low-level driver and the higher-level service it
|
||||
@@ -320,5 +331,7 @@ exception in [coding-standards.md](coding-standards.md) applies to that seam.
|
||||
| System services (init, the `fat` filesystem, the device-manager) | `system/services/` |
|
||||
| Device drivers, one sub-project each (`pci-bus`, `ps2-bus`, `usb-xhci-bus` bus drivers) | `system/drivers/` |
|
||||
| On-image test fixtures for the QEMU cases (`vfs-test`, `crash-test`, `thread-test`, …) → `/test/system/services` | `test/system/services/` |
|
||||
| Build + `run-x86-64` (QEMU/OVMF) + `release-x86-64` (the flashable ISO) | `build.zig` |
|
||||
| Build orchestration (kernel + loader, what ships, the aggregate test step) | `build.zig` (root; the shared user-binary recipe is `build-support/`, and each `library/` domain + binary package carries its own `build.zig`) |
|
||||
| Image assembly + `release-x86-64` (the flashable ISO) | `build/images.zig` |
|
||||
| `run-x86-64` / `run-x86-64-gpu` (QEMU/OVMF) | `build/qemu.zig` |
|
||||
| QEMU integration test harness | `test/qemu_test.py` |
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
# Plan: packages — hierarchical builds for libraries and binaries
|
||||
|
||||
**Status: complete** (branch `claude/build-packages-plan-174144`). Phase 0
|
||||
(`build-support`), phase 1 (all six library domains), phase 2 (every binary —
|
||||
the pci-bus pilot first, then services, drivers, and test fixtures in waves;
|
||||
multi-binary directories like ps2-bus and usb-hid are one package exporting
|
||||
several artifacts, and the acpi/fdt discovery pair each export an artifact
|
||||
named "discovery" that the root's -Ddiscovery picks between), and phase 3 (the
|
||||
root split into `build/images.zig` + `build/qemu.zig`; the root `build.zig` is
|
||||
~460 lines of orchestration, down from ~1,250). Every phase landed green: unit
|
||||
tests, the QEMU suite at parity with main, boot-image file list unchanged.
|
||||
The `lazyDependency` payoff (What-this-buys #4) is in too: the /test fixtures
|
||||
and the unselected discovery package are lazy — a build loads and compiles
|
||||
only what it ships. And imports are exact: the pre-wired default set is gone;
|
||||
every binary names precisely the modules its source imports and carries only
|
||||
those domains in its manifest (rule 1 below).
|
||||
|
||||
## Why
|
||||
|
||||
`build.zig` was ~1,250 lines, growing by three hand-written stanzas per binary;
|
||||
at a driver per device family that does not scale. More fundamentally: in one
|
||||
monolithic build every binary compiles against library *source*, so a library
|
||||
interface break is silently absorbed by whoever edits everything in one commit —
|
||||
the interface never has to be honest. danos is about isolation; the build should
|
||||
mirror it.
|
||||
|
||||
A **package** here is a build-time unit only — a directory owning a `build.zig`
|
||||
(recipe: what it exports, how to test it) and a `build.zig.zon` (manifest: name
|
||||
+ dependencies). Binaries remain fully static freestanding ELFs; packages change
|
||||
who declares what, not what links to what. Source code is untouched: `@import`
|
||||
uses module names (`"pci"`, `"service"`) exactly as today — only build files
|
||||
know where anything lives.
|
||||
|
||||
## Target shape
|
||||
|
||||
```
|
||||
build-support/ package: the danos build API (userBinary(), defaultImports(), targets)
|
||||
library/kernel/ package "kernel": modules abi, ipc, service, memory, process, logging, time, ... (depends on protocol)
|
||||
library/device/ package "device": modules driver, pci, usb-abi, model, ... (depends on kernel, protocol, csv)
|
||||
library/protocol/ package "protocol": the wire protocols
|
||||
library/client/ package "client" (depends on kernel, protocol)
|
||||
library/csv/ package "csv"
|
||||
library/xkeyboard-config/ package "xkeyboard-config"
|
||||
system/services/<name>/ one package per binary: ~15-line build.zig + zon
|
||||
system/drivers/<name>/ one package per binary
|
||||
build.zig (root) orchestrator: dependency() per binary, image assembly, QEMU, test steps
|
||||
```
|
||||
|
||||
The three shared contracts: `boot-handoff` stays a root module (only the
|
||||
loader↔kernel pair speaks it); `abi` is exported by the kernel package from
|
||||
`../../system/abi.zig` (the source stays with the kernel; userspace's one view
|
||||
of it lives in the package, so every consumer names the same module instance);
|
||||
`device-abi` is exported by device. Reaching outside the package root means the
|
||||
kernel package is valid only as an in-repo path dependency — it could never be
|
||||
fetched by hash — which is fine: path dependencies are the only way any of
|
||||
these packages is consumed.
|
||||
|
||||
Rules:
|
||||
|
||||
- **Imports are exact and per binary.** A binary's build.zig names precisely
|
||||
the modules its source `@import`s — the moral equivalent of a C file's
|
||||
include list — and its zon names only the domains those modules come from
|
||||
(plus `build-support` and `kernel`, which is implicit in every binary: the
|
||||
root shim and user link script live there). Nothing is pre-wired: an
|
||||
undeclared `@import` is a compile error, and build-support resolves each
|
||||
name by searching the packages the zon declares — the domains' own
|
||||
addModule exports are the single statement of who owns what, with no name
|
||||
table anywhere to drift. Availability
|
||||
never meant bloat — Zig only compiles what a program actually imports — but
|
||||
exactness makes the declared interface honest and machine-checked.
|
||||
- **Modules export source, not artifacts** — each consumer compiles libraries
|
||||
with its own flags, so per-binary optimization choices keep working; Zig's
|
||||
cache deduplicates.
|
||||
- **Zon paths are relative and that is accepted.** Binaries sit exactly three
|
||||
levels deep, so the `../../../` prefix is a constant idiom; a library-domain
|
||||
move is a rare, already-breaking event fixed by one sed across manifests, and
|
||||
a stale path fails loudly before anything compiles.
|
||||
- **Cross-cutting build changes live in `build-support` only** — that is the
|
||||
contract that keeps per-binary build files declarative.
|
||||
|
||||
## What this buys
|
||||
|
||||
1. Library interfaces become machine-checked: a consumer can only import what
|
||||
it declared — per binary, down to the single module — and each domain's zon
|
||||
declares what it needs (claim-before-touch, applied to source). A keyboard
|
||||
driver carries `xkeyboard-config` in its manifest; nothing else does.
|
||||
2. Each library domain gets a standalone `zig build test` — runtime-library
|
||||
stability testing in isolation.
|
||||
3. Adding a binary = adding a directory (source + two small files), not editing
|
||||
three places in a 1,250-line file.
|
||||
4. `lazyDependency` lets an image target build only what it ships: the /test
|
||||
fixtures resolve only under -Dtest-case, and only the -Ddiscovery-selected
|
||||
discovery package ever loads.
|
||||
|
||||
## Phases
|
||||
|
||||
Each phase ends green: `zig build test` passes (88/88 QEMU) and the boot
|
||||
image's file list is unchanged. Byte-identical binaries are expected but not
|
||||
required (module reorganization can perturb symbol order); file list is the
|
||||
hard gate.
|
||||
|
||||
**Phase 0 — `build-support`.** Extract `addUserBinary`/`addThreadedUserBinary`,
|
||||
the freestanding target setup, and the default-import wiring into the
|
||||
`build-support` package. Root build consumes it; nothing else moves. This is
|
||||
the cross-cutting-change home, so it lands first.
|
||||
|
||||
**Phase 1 — library domains become packages.** In dependency order: `protocol`
|
||||
and `csv` (the roots) → `kernel` (depends on protocol: file-system speaks
|
||||
vfs-protocol) → `device`, `client`; `xkeyboard-config` stands alone. Each gets
|
||||
build.zig + zon + a standalone test step (client's is empty until its modules
|
||||
grow host tests — kept for uniformity, since the root aggregate depends on
|
||||
every domain's test step). The root build swaps its `createModule` calls for
|
||||
`b.dependency("<domain>").module("<name>")`. **No binary moves in this phase**
|
||||
— the root build is the pilot consumer, which proves the packages without
|
||||
touching 30 binaries.
|
||||
|
||||
**Phase 2 — binaries become packages, in waves.** The template was shaken out
|
||||
by the pci-bus pilot (see Status). Wave A: services (done). Wave B: the
|
||||
remaining drivers (done). Wave C: test fixtures (done). Root build shrank to
|
||||
orchestration per wave. init's `-Dserial` heartbeat flag rides a dependency
|
||||
option; a directory with several binaries (ps2-bus, usb-hid) is one package
|
||||
exporting several artifacts.
|
||||
|
||||
**Phase 3 — root cleanup (done).** What remained of the root build split into
|
||||
`build/images.zig` (the FHS install tree, boot manifest + capsule, FAT32
|
||||
images, release ISO, check steps) and `build/qemu.zig` (the run steps + OVMF
|
||||
probing), imported by a short root `build.zig`.
|
||||
|
||||
**Afterwards** (outside this plan): the intel-uhd-graphics-750 driver is
|
||||
(re)created as a greenfield package. The new-driver checklist's build step
|
||||
(docs/device-driver-development/new-driver-checklist.md, step 2) is already
|
||||
rewritten against the package template.
|
||||
|
||||
## Execution notes (the finished shape)
|
||||
|
||||
- The shared recipe lives in `build-support/build.zig`: `userBinary` (what
|
||||
every binary package calls; each named import resolves by searching the
|
||||
packages the binary's zon declares) and `programModule` (for per-binary
|
||||
addOptions modules). The `start` root shim and `user.ld` are named through the kernel
|
||||
package (Dependency.path).
|
||||
- Adding a binary = adding a directory with source + a ~15-line build.zig +
|
||||
zon (copy any existing binary package, e.g.
|
||||
`system/drivers/pci-bus/build.zig`) listing exactly the modules the source
|
||||
imports and the domains they come from, then one dependency + one bundled
|
||||
entry in the root build.zig and one zon line.
|
||||
- The boot-tree array in the root (search `"etc/init.csv"` or
|
||||
`.getEmittedBin()`) is the image file list — the authoritative comparison
|
||||
target for any future build change.
|
||||
- Package unit tests live in each package's own `test` step; the root
|
||||
aggregate depends on every test-bearing package's step, so `zig build test`
|
||||
at the root still runs everything.
|
||||
|
||||
Verification per phase:
|
||||
|
||||
- Unit tests: `zig build test`.
|
||||
- QEMU integration suite: `python3 test/qemu_test.py` (docs/testing.md; the
|
||||
full suite, all cases must pass).
|
||||
- Image file list: the boot-tree array is the source of truth — snapshot it
|
||||
(paths only) before phase 0 and diff after each phase; `zig build
|
||||
check-fat-image` must also stay green.
|
||||
|
||||
Context a fresh session should read first: this doc, docs/testing.md,
|
||||
docs/coding-standards.md (kebab-case names, no abbreviations), and the
|
||||
`userBinary`/`userBinaryFromImports` bodies in build-support/build.zig. Commit
|
||||
style: no Co-Authored-By trailers.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- Zig version churn: the package API (`b.dependency`, zon schema) has moved
|
||||
between releases; the work pins against the repo's current Zig and any
|
||||
upgrade lands separately, never mid-phase.
|
||||
- The QEMU size-check tests hardcode source paths (e.g. virtio-gpu protocol
|
||||
struct sizes) — they moved into their binaries' packages with their waves,
|
||||
discharging the carry-along obligation.
|
||||
- Doc updates ride each phase: docs/README.md (repo layout + source map),
|
||||
docs/device-driver-development/new-driver-checklist.md (step 2) and
|
||||
devices-csv.md ("Adding a driver"), and the docs that cite the build recipe
|
||||
(driver-model.md, threading.md, system-requirements.md) reference build
|
||||
shapes that keep changing.
|
||||
@@ -0,0 +1,205 @@
|
||||
# The C library compatibility layer
|
||||
|
||||
A design note and milestone plan for **libdanos-c** — the mini C library that lets
|
||||
`zig cc` cross-compile C programs for danos. It is milestone **P0** of
|
||||
[python-on-danos-milestones.md](python-on-danos-milestones.md), expanded here the
|
||||
way [character-devices-and-tty.md](character-devices-and-tty.md) expands P1.
|
||||
CPython is the driving consumer, but the layer is general: any portable C program
|
||||
within its surface should build.
|
||||
|
||||
## What it is — and the three things it is not
|
||||
|
||||
The deliverable is a **sysroot**: a set of C headers plus a static `libdanos-c.a`,
|
||||
handed to `zig cc -target x86_64-freestanding-none` via `-isystem` and linked into
|
||||
every C binary. Three explicit non-goals keep it small:
|
||||
|
||||
- **Not a musl port.** Whole-musl assumes Linux syscall semantics at its bottom
|
||||
(the door the Zig roadmap deferred, twice now). We *lift* musl's pure-computation
|
||||
source files and *write* a danos-native bottom — see the layer split below.
|
||||
- **Not full POSIX — *yet*.** Stage 1's surface is "what CPython's minimal
|
||||
configuration and ordinary portable C need" — roughly 100–150 functions — and
|
||||
at that stage absence is a *feature*: configure scripts probe and adapt, and a
|
||||
linker error is honest. But the end state is a **full C compatibility layer**
|
||||
(see "The road to full coverage" below); the absence table is a schedule of
|
||||
arrivals, not a wall.
|
||||
- **Not a second runtime.** The library is a thin C-ABI re-spelling of the same
|
||||
danos-native surface `runtime` already provides. It contains no policy of its
|
||||
own; when the Zig track's `runtime.os` seam is authored, the libc bottom
|
||||
re-targets it near-mechanically — the fourth appearance of the roadmap's "same
|
||||
surface" symmetry.
|
||||
|
||||
One scoping rule sits above all three — the **size doctrine**: this layer serves
|
||||
**applications only**. The kernel and the system services never link libdanos-c;
|
||||
they stay danos-native Zig over `runtime`, small and static, because leanness is
|
||||
an operating-system property. Applications have their own budget and may be as
|
||||
big as they need to be. The libc is how big software *lands on* danos, never how
|
||||
danos itself is built.
|
||||
|
||||
## The layer split: lift the mathematics, write the plumbing
|
||||
|
||||
The realization that makes 100–150 functions tractable: a libc is two very
|
||||
different kinds of code, and the hard kind is portable.
|
||||
|
||||
| Layer | Contents | Source |
|
||||
|-------|----------|--------|
|
||||
| **Pure computation** | `string.h`/`memcpy` family, all of libm, `strtod`/`dtoa`, `strtol`, `qsort`, `ctype` tables, `gmtime` calendar math, the `printf`/`scanf` engines, `setjmp` (a dozen instructions of x86-64 asm) | **Lift from musl**, vendored under `library/c/third-party/musl/` (MIT; files compile standalone) |
|
||||
| **OS plumbing** | fds (`open`/`read`/`write`/`close`/`lseek`/`stat`/`getcwd`/`chdir`/`isatty`), `mmap`/`munmap`, clocks, `exit`, `getenv`, `getentropy` | **Write in Zig**, exporting C ABI over the `runtime` syscall + VFS client surface |
|
||||
| **The middle** | `malloc` over danos `mmap` (simple free-list; CPython's arenas sit above), `FILE*` buffering, `errno` | **Write in Zig** (small, danos-shaped) |
|
||||
| **Entry** | `crt0`: the existing danos entry shim ([sysv.md](os-development/sysv.md)) bridged to C `main(argc, argv, envp)`, `environ` initialised, `exit` flushing stdio | **Write** |
|
||||
|
||||
Two liftings deserve their own line because getting them wrong is silent
|
||||
corruption rather than a linker error:
|
||||
|
||||
- **`strtod`/float formatting.** Python's float `repr` guarantees shortest
|
||||
round-trip; that property lives entirely in these routines. musl's are correct;
|
||||
an improvised one would be subtly wrong for years. Lift, never write.
|
||||
- **The stdio engines.** musl's `vfprintf`/`vfscanf` are self-contained around
|
||||
its `FILE` abstraction (function-pointer read/write slots), so the whole
|
||||
formatted-I/O engine lifts too — we implement only the fd-backed slots
|
||||
(`__stdio_write`-shaped) and the buffering glue.
|
||||
|
||||
## Header policy
|
||||
|
||||
Hand-write the headers as danos's own minimal set rather than importing musl's
|
||||
(musl's are entangled with Linux ABI details), borrowing declarations freely.
|
||||
Freestanding compiler headers (`stdint.h`, `stddef.h`, `stdarg.h`, `stdbool.h`,
|
||||
`float.h`, `limits.h`) come from clang via `zig cc` — do not duplicate them.
|
||||
`errno.h` values are the danos errno enum re-spelled with POSIX names; there is no
|
||||
Linux numbering to be compatible with, so the enum is the truth.
|
||||
|
||||
Deliberate absences, and their planned arrivals — this table is the
|
||||
compatibility matrix, and "the road to full coverage" below is the schedule
|
||||
that empties it:
|
||||
|
||||
| Absent | Arrives with |
|
||||
|--------|--------------|
|
||||
| `pthread.h` | the post-P5 pthread subset over `thread_spawn`/futex — but see the risk below |
|
||||
| real `signal.h` (beyond no-op `signal()`/`raise` stubs) | M17 signals-over-IPC in the libc |
|
||||
| `dlfcn.h` | [dynamic-libraries.md](dynamic-libraries.md) D1 |
|
||||
| `fork`/`exec*`/`wait*` | P5 exposes danos spawn as `posix_spawn`; `fork` itself never (see below) |
|
||||
| `socket.h` | a future networking track |
|
||||
| locale beyond `"C"` | stage 3 evaluation (CPython is UTF-8-mode happy without it) |
|
||||
| pipes (`pipe()`) | P5 process-control cluster |
|
||||
|
||||
## The road to full coverage
|
||||
|
||||
The layer grows in three stages; only stage 1 is a current milestone (P0), but
|
||||
the stages exist so stage-1 decisions never have to be unmade:
|
||||
|
||||
- **Stage 1 — CPython-minimal** (P0, the slicing below): ~100–150 functions,
|
||||
static-only, absences honest.
|
||||
- **Stage 2 — the danos-complete layer**: the full hosted C11 standard library,
|
||||
plus every POSIX facility danos semantics support, landing as its enabling
|
||||
milestone lands — pipes and `posix_spawn` at P5, real signals at M17, the
|
||||
pthread subset after P5, `dlfcn.h` at
|
||||
[dynamic-libraries](dynamic-libraries.md) D1, sockets with networking. Stage 2
|
||||
is not one milestone but the standing rule that **every system capability
|
||||
gets its C spelling when it ships**, so the matrix above drains as the OS
|
||||
grows.
|
||||
- **Stage 3 — ecosystem grade**: the point where "portable C program" generally
|
||||
means "builds on danos" (autotools-style probing included). Reaching it is
|
||||
mostly stage 2 compounding, plus the long tail (locale, wide-char,
|
||||
`fnmatch`/`glob`/`regex` — the last three lift from musl like the rest). At
|
||||
this stage, re-evaluate hand-grown-vs-musl-port once with real data; the
|
||||
standing recommendation remains danos-native — musl's bottom assumes Linux
|
||||
syscall semantics, and by stage 3 the danos bottom exists and is tested —
|
||||
with musl continuing as the quarry for computation code.
|
||||
|
||||
Two boundaries are permanent and worth stating at every stage: **`fork` never
|
||||
comes** — danos is a spawn-shaped OS, and `fork`'s address-space-duplication
|
||||
semantics are hostile to everything from capabilities to threads; software that
|
||||
hard-requires `fork` (not `posix_spawn`) stays off the platform. And the
|
||||
**public ABI stays the vDSO + IPC protocols** — a full libc is a compatibility
|
||||
*layer*, not a second stable system ABI.
|
||||
|
||||
## Milestone slicing
|
||||
|
||||
1. **sysroot-skeleton** — layout under `library/c/` (a build package:
|
||||
`include/`, Zig sources, vendored musl subtree); `crt0`; string/mem +
|
||||
`ctype` lifted; a `build.zig` step making C binaries first-class targets.
|
||||
*Test:* a C program using only computation links and runs in QEMU
|
||||
(`c-hello` printing via a raw `write` extern to `debug_write`).
|
||||
2. **fd-plumbing** — `errno`; open/read/write/close/lseek/stat/unlink/mkdir/
|
||||
rename over the `runtime` VFS client; `getcwd`/`chdir`/`getenv`/
|
||||
`getentropy` arriving as P1 lands them (stubbed truthfully until then:
|
||||
`getenv` empty, `getentropy` `ENOSYS`). *Test:* QEMU `c-file-io` — create,
|
||||
write, reopen, read back, stat size + mtime through FAT.
|
||||
3. **malloc** — free-list allocator over danos `mmap`; `calloc`/`realloc`/
|
||||
`free`; alignment guarantees documented. *Test:* host + QEMU allocator
|
||||
torture (interleaved sizes, realloc growth, alignment asserts).
|
||||
4. **stdio** — `FILE*`, buffering modes, the lifted printf/scanf engines wired
|
||||
to the fd slots; `snprintf` family; stdin/stdout/stderr over fd 0/1/2.
|
||||
*Test:* host round-trip suite for format engines (especially `%.17g`
|
||||
float round-trip); QEMU `c-stdio` cooked-line echo once P1's console exists.
|
||||
5. **mathematics-and-time** — libm lifted wholesale; `strtod`/`strtol`;
|
||||
`clock_gettime` (monotonic + realtime over `clock`/`wall_clock`);
|
||||
`gmtime`/`mktime`/`strftime` (UTC only — no timezone database);
|
||||
`setjmp`/`longjmp`; `qsort`/`bsearch`; `abort`/`assert`. *Test:* host
|
||||
`strtod`/`dtoa` vectors against known-hard cases; QEMU `c-time` sanity
|
||||
against the wall clock.
|
||||
|
||||
Slices 1, 3, 4-host, and 5-host have **no dependency on P1** and can start
|
||||
immediately; slice 2 and the QEMU halves interleave with P1 as it lands.
|
||||
|
||||
**Exit for the layer as a whole** (= P0's exit): `c-hello` and `c-file-io` green
|
||||
in the QEMU suite, and the host-side computation tests green — at which point P2
|
||||
(CPython configure) becomes the layer's real integration test.
|
||||
|
||||
## Testing strategy: two targets, on purpose
|
||||
|
||||
The computation layer is target-independent, so it is unit-tested **on the host**
|
||||
(built for the host triple, compared against the host libc's answers —
|
||||
thousands of cheap oracle checks for `strtod`, `printf`, libm edge cases). The
|
||||
plumbing layer only means anything **on danos**, so it is tested in the QEMU
|
||||
suite like every other subsystem. Keeping the split explicit stops the slow-QEMU
|
||||
suite from absorbing tests that a host `zig test` runs in milliseconds.
|
||||
|
||||
## Risks and gotchas
|
||||
|
||||
- **CPython's configure may insist on pthreads.** WASI-class targets build
|
||||
threadless, but verify this *first* in P2 bring-up; the fallback is a
|
||||
truthfully-single-threaded `pthread.h` stub set (create returns `EAGAIN`,
|
||||
mutexes are no-ops — valid when only one thread can exist). Decide from
|
||||
evidence, not assumption.
|
||||
- **`long double` is x87 80-bit on x86-64.** musl's libm handles it, but keep
|
||||
CPython away from it (`configure` uses `double` throughout by default);
|
||||
don't hand-write anything touching x87.
|
||||
- **errno is a contract, not a convention.** The Zig plumbing must map every
|
||||
`runtime` error to a POSIX name consistently — CPython turns errno into
|
||||
exception types (`FileNotFoundError` is `ENOENT`). One table, tested.
|
||||
- **`malloc` alignment**: 16-byte minimum on x86-64 (SSE spills in
|
||||
compiled C). The free-list must guarantee it from day one; retrofitting
|
||||
alignment bugs out of an allocator is misery.
|
||||
- **Vendoring discipline.** The musl subtree is lift-only — never edited in
|
||||
place (patches live beside it if ever needed), pinned to one musl release,
|
||||
with the file list documented so a version bump is a re-copy, not an
|
||||
archaeology dig.
|
||||
- **stdio buffering vs. crashes.** Buffered stdout + a crashing program eats
|
||||
output — the classic debugging trap. `stderr` stays unbuffered (per C
|
||||
standard) and `exit`/`abort` flush; document that `_exit` does not.
|
||||
|
||||
## Decisions needing sign-off
|
||||
|
||||
- **Lift-from-musl for all pure computation** (vendored, pinned, unedited) rather
|
||||
than writing or porting whole-musl.
|
||||
- **Hand-written danos-native headers**; danos errno values are the numbering.
|
||||
- **`library/c/` as a build package** producing both the sysroot and the
|
||||
first-class C-binary build step.
|
||||
- The **deliberate-absence table** as the living compatibility matrix, drained
|
||||
by the three-stage road above — with exactly one permanent "never": `fork`.
|
||||
- **Full coverage as the end state** (stage 3), reached by the standing rule
|
||||
that every system capability ships with its C spelling — not by a musl port.
|
||||
|
||||
## Related
|
||||
|
||||
- [python-on-danos-milestones.md](python-on-danos-milestones.md) — this is P0.
|
||||
- [dynamic-libraries.md](dynamic-libraries.md) — ships in this sysroot
|
||||
(`dlfcn.h` + the loader) once its D1 lands.
|
||||
- [python-on-danos.md](python-on-danos.md) — the design note that scoped the
|
||||
layer.
|
||||
- [character-devices-and-tty.md](character-devices-and-tty.md) — P1; supplies
|
||||
the console that makes stdio interactive.
|
||||
- [zig-self-hosting.md](zig-self-hosting.md) — the `runtime.os` seam the
|
||||
plumbing layer will re-target when it exists.
|
||||
- [os-development/sysv.md](os-development/sysv.md) — the entry stack `crt0`
|
||||
bridges.
|
||||
@@ -0,0 +1,174 @@
|
||||
# Character devices, the console, and the tty question
|
||||
|
||||
A design note for the **stream** half of the device world. danos has block devices
|
||||
(the USB storage service behind the FAT mount) but no character devices — and three
|
||||
tracks now need them at once: the terminal application, Zig self-hosting Phase 1
|
||||
("wire fd 0/1/2 to a console byte stream"), and [Python on danos](python-on-danos.md)
|
||||
Phase 1. This note settles what a character device *is* on danos before any of those
|
||||
tracks build one.
|
||||
|
||||
## The Unix picture, briefly
|
||||
|
||||
Unix splits devices in two: **block devices** are seekable arrays of fixed-size
|
||||
sectors (disks); **character devices** are unseekable byte streams (keyboards,
|
||||
serial ports, terminals, `/dev/null`, entropy). A **tty** is the canonical
|
||||
character device — a byte stream plus a *line discipline* (echo, line buffering,
|
||||
erase handling, Ctrl-C-to-signal) that lives in the kernel. A **pty** is a pair of
|
||||
character devices (master/slave) that exists so a *userspace* program — a terminal
|
||||
emulator — can impersonate terminal hardware to the kernel's in-kernel line
|
||||
discipline.
|
||||
|
||||
The identification asked for and confirmed: yes, tty and pty are character
|
||||
devices in this taxonomy.
|
||||
|
||||
## The realization that shapes everything: danos already has the mechanism
|
||||
|
||||
A Unix character device is an in-kernel dispatch table: major/minor numbers route
|
||||
`read()`/`write()` to a driver. danos already has exactly that dispatch — the VFS:
|
||||
`fs_resolve` routes a path to a mounted backend service, and `Operation.mount`
|
||||
attaches a backend *endpoint* at a prefix. What is missing is not a device model;
|
||||
it is **one node kind with stream semantics**. And the protocol already reserved
|
||||
it: `NodeKind.character_device = 2` sits unimplemented in
|
||||
[vfs-protocol.zig](../library/protocol/vfs/vfs-protocol.zig), exactly like
|
||||
`symbolic_link`.
|
||||
|
||||
So the design is small:
|
||||
|
||||
**A character device on danos is a VFS node, served by an ordinary service over
|
||||
the existing VFS wire protocol, whose read/write have stream semantics.**
|
||||
|
||||
No device numbers, no `/dev` special casing, no new syscalls, no new protocol —
|
||||
a service is reachable at a path, clients open it with `runtime.fs` like any
|
||||
file, and the node kind says what it is. (Since the protocol namespace landed
|
||||
in design, that path is `/protocol/console` — a protocol node, see
|
||||
[os-development/protocol-namespace.md](os-development/protocol-namespace.md) —
|
||||
rather than a mounted device file; the stream semantics below are unchanged.)
|
||||
|
||||
### Stream semantics (the actual contract change)
|
||||
|
||||
For a node whose kind is `character_device`:
|
||||
|
||||
- **`offset` is ignored** on read and write; there is no seek position. (`lseek`,
|
||||
when the C layer exists, returns `ESPIPE`.)
|
||||
- **Reads block** until at least one byte is available, then return what is there —
|
||||
**short reads are normal**, not EOF. A zero-length read reply means the stream
|
||||
is closed (hangup), not end-of-file-at-size.
|
||||
- **`FileStatus.size` is 0** and means nothing; `mtime` may be 0.
|
||||
- Writes may be short if the service's buffer is full; the client loops as it
|
||||
already must for the 256-byte message cap.
|
||||
|
||||
This is a semantics note on existing operations, not a wire change — the `Request`
|
||||
and `Reply` structs are untouched. The one true protocol addition is a **`control`
|
||||
operation** (appended to `Operation`, values stable): a typed request the stream's
|
||||
service interprets. Deliberately *not* an `ioctl` grab-bag — the control payloads
|
||||
are enumerated per protocol, starting with the terminal set below.
|
||||
|
||||
## The first character device is a pseudo-device
|
||||
|
||||
The first device is deliberately **not hardware**: an in-memory **loopback** — a
|
||||
byte queue served over the stream contract, where bytes written to one end are
|
||||
read from the other. It is the reference implementation of the semantics above
|
||||
(blocking reads, short reads, hangup on close, the `control` round-trip), it
|
||||
tests deterministically with no QEMU serial scripting, and it keeps hardware off
|
||||
the critical path entirely. `null` and `zero` come along nearly for free as
|
||||
degenerate cases. This is a decision, not a convenience: the dead-COM1 boot bug
|
||||
on real hardware already proved serial cannot be assumed present or alive, so
|
||||
**nothing in this milestone writes to COM1**. (A serial-backed stream node can
|
||||
exist *later* as one more optional backend for headless debugging; it is on
|
||||
nobody's critical path.)
|
||||
|
||||
The loopback is also not throwaway — it is the seed of P5's `pipe()`, which is
|
||||
the same object with two fds.
|
||||
|
||||
## The console service
|
||||
|
||||
A `console` service owns the line discipline — **in userspace**, where a
|
||||
microkernel wants it, not in the kernel as Unix has it:
|
||||
|
||||
- **The discipline is a pure library first**: bytes and key events in, bytes
|
||||
out, no I/O of its own — developed and host-tested against in-memory buffers,
|
||||
then shared verbatim between the console and the future terminal application.
|
||||
- **Input**: subscribes to keyboard `InputEvent` IPC (the structured events that
|
||||
exist today) and cooks them into bytes. Cooked mode is the default: echo, line
|
||||
buffering, backspace/erase, so a line is delivered on Enter. Raw mode delivers
|
||||
bytes as they come (the REPL's line editor and any full-screen program need it).
|
||||
- **Output is a pluggable sink**, and the stream contract is independent of it:
|
||||
the bring-up sink is in-memory (readable back by tests, mirrored to the boot
|
||||
log), and the real one is the framebuffer text renderer when the display
|
||||
track's font work lands.
|
||||
- **Control set** (the `control` payloads): mode raw/cooked, echo on/off, and
|
||||
window-size query — the minimal termios. Ctrl-C-to-signal joins when M17
|
||||
signals-over-IPC lands; until then Ctrl-C is just a byte.
|
||||
- Mounts itself at `/device/console` as a `character_device` node.
|
||||
|
||||
**fd 0/1/2** then stop being special: spawn hands the child three open handles
|
||||
(console by default; anything else if the parent chooses), and `runtime`'s fd
|
||||
table maps 0/1/2 to them. `isatty` is simply "does `status` say
|
||||
`character_device`" — no side channel needed.
|
||||
|
||||
## The pty answer: there is no pty
|
||||
|
||||
The pty exists in Unix *because the line discipline is in the kernel* — userspace
|
||||
terminal emulators need a kernel gadget to impersonate hardware. On danos the
|
||||
terminal emulator is already a userspace server, so the pair collapses:
|
||||
|
||||
**The graphical terminal application serves the VFS stream protocol itself and
|
||||
hands its own endpoints to the children it spawns as their fd 0/1/2.**
|
||||
|
||||
The terminal *is* the console service for its children — same protocol, same
|
||||
control set, same line discipline code (shared as a library with the boot
|
||||
console). No master/slave device pair, no `/dev/pts`, no new kernel object. When
|
||||
CPython arrives, the libc's `isatty`/read/write see a character device and are
|
||||
none the wiser; when xonsh eventually wants job control, that lands as control
|
||||
messages + M17 signals, still with no pty object.
|
||||
|
||||
What this costs: programs that *specifically* manipulate Unix ptys
|
||||
(`os.openpty()`, `pexpect`-style tools) have no direct equivalent — the danos
|
||||
answer is "spawn the child yourself with your own stream endpoints," which is the
|
||||
same capability with less machinery. Accepted.
|
||||
|
||||
## Milestone slicing
|
||||
|
||||
1. **pseudo-devices** — VFS honors `character_device` semantics end to end;
|
||||
`Operation.control` added; the in-memory **loopback** (plus `null`/`zero`)
|
||||
as the first device. QEMU test: one client writes, another reads — open,
|
||||
offsetless read/write, blocking read, short read, hangup on close, control
|
||||
round-trip. No hardware anywhere.
|
||||
2. **console-service** — the line-discipline library (host-tested, pure) plus
|
||||
the console composing keyboard `InputEvent`s with an in-memory output sink;
|
||||
mounted at `/device/console`. QEMU test injects key events and reads cooked
|
||||
lines and raw bytes back through the sink.
|
||||
3. **fd-inheritance** — spawn passes 0/1/2 handles; `runtime` fd table; `isatty`
|
||||
via `status`; existing binaries' stdout migrates from `debug_write` to fd 1
|
||||
(the logger keeps its own path).
|
||||
4. **terminal-as-server** — deferred to the terminal application milestone
|
||||
(Python track P3): the terminal reuses the discipline library and serves its
|
||||
children directly.
|
||||
|
||||
Steps 1–3 are exactly the shared seam that Zig self-hosting Phase 1 and Python
|
||||
Phase 1 both list; neither track repeats them.
|
||||
|
||||
## Decisions needing sign-off
|
||||
|
||||
- **No pty object; the terminal serves its children directly** (the section
|
||||
above) — the load-bearing simplification.
|
||||
- **`control` as an enumerated, typed operation** rather than an ioctl-style
|
||||
opaque pass-through.
|
||||
- **Line discipline in userspace services** (console + terminal, shared library),
|
||||
never in the kernel.
|
||||
|
||||
## Related
|
||||
|
||||
- [python-on-danos.md](python-on-danos.md) — consumes this as its Phase 1.
|
||||
- [zig-self-hosting.md](zig-self-hosting.md) — ditto ("stdio as fds").
|
||||
- [file-system-development/vfs-protocol.md](file-system-development/vfs-protocol.md) —
|
||||
the wire protocol this note extends.
|
||||
- [file-system-development/file-system-hierarchy.md](file-system-development/file-system-hierarchy.md)
|
||||
— the tree the console surfaces in.
|
||||
- [os-development/protocol-namespace.md](os-development/protocol-namespace.md) —
|
||||
supersedes this note's device-node naming: the console lands as a protocol
|
||||
(`/protocol/console`, a protocol node), not a `/dev`-style device file. The
|
||||
stream semantics designed here (line discipline, cooked/raw modes) carry over
|
||||
unchanged.
|
||||
- [device-driver-development/input.md](device-driver-development/input.md) — the
|
||||
`InputEvent` stream the console cooks.
|
||||
@@ -1,132 +0,0 @@
|
||||
# IPC: message-passing channels
|
||||
|
||||
Inter-process communication is the **backbone of a microkernel**. Once drivers and
|
||||
services run isolated in their own address spaces ([vision](../vision.md)), they can't
|
||||
just call each other — a request becomes a **message**. In a microkernel, whatever
|
||||
was a function call across a monolithic kernel is IPC, so it's a first-class
|
||||
concern, not an afterthought.
|
||||
|
||||
There are two layers, built a milestone apart:
|
||||
|
||||
- **`system/kernel/ipc.zig`** — a bounded blocking channel between *kernel threads*,
|
||||
described below. The primitive, and where the blocking discipline was worked out.
|
||||
- **`system/kernel/ipc-synchronous.zig`** — synchronous call/reply between *processes*, across
|
||||
address spaces. What user-space servers and drivers actually talk over. It's the
|
||||
second half of this document.
|
||||
|
||||
## The channel
|
||||
|
||||
The first form is a **bounded blocking channel** (`system/kernel/ipc.zig`): a fixed-size
|
||||
ring buffer of messages with a producer/consumer rendezvous, built on the
|
||||
scheduler's [wait queues](../os-development-guide/scheduling.md).
|
||||
|
||||
`Channel(T, capacity)` is generic over the message type and buffer size. It holds a
|
||||
ring buffer, a count, and two wait queues:
|
||||
|
||||
- **`send(msg)`** — if the channel is full, block on the *not-full* queue; otherwise
|
||||
write the message, bump the count, and wake a waiting receiver.
|
||||
- **`receive()`** — if the channel is empty, block on the *not-empty* queue; otherwise
|
||||
take a message, drop the count, and wake a waiting sender.
|
||||
|
||||
Neither side busy-waits: a full channel parks the sender, an empty one parks the
|
||||
receiver, and each operation wakes the other side when it makes progress possible.
|
||||
|
||||
Two details make it correct:
|
||||
|
||||
- **Recheck in a loop.** A woken task re-tests the condition (`while (full) wait`)
|
||||
rather than assuming the slot is still available — another waiter may have taken
|
||||
it first. This is the standard guard against spurious or racing wakeups.
|
||||
- **One critical section.** `send`/`receive` run under the [big kernel
|
||||
lock](../os-development-guide/smp.md) (`sync.enter` / `sync.leave`), which disables interrupts on this
|
||||
core *and* takes the kernel's one spinlock — since SMP, the interrupt flag alone
|
||||
is not atomicity, because `cli` on one core does nothing to another. So checking
|
||||
the condition and committing the block/enqueue happen atomically both with respect
|
||||
to the timer preempting mid-operation and to the other side running on another
|
||||
CPU. `waitLocked` / `wakeLocked` are the variants that assume the caller already
|
||||
holds that critical section.
|
||||
|
||||
## Verifying it
|
||||
|
||||
The `ipc` test (see [testing.md](../testing.md)) runs a producer and a consumer passing
|
||||
**100 messages through a 4-slot channel**. The small buffer means the channel goes
|
||||
full and empty over and over, so both the blocking-send and blocking-receive paths are
|
||||
exercised heavily. The messages arrive intact and in order (their sum is the
|
||||
expected `5050`), and neither task busy-waits — they block and wake each other.
|
||||
|
||||
## Endpoints: call/reply across address spaces
|
||||
|
||||
A channel connects two kernel threads sharing one address space. Real servers are
|
||||
*processes*, so the payload has to cross an address-space boundary. That's
|
||||
`system/kernel/ipc-synchronous.zig`, and its shape is L4's: a synchronous **rendezvous** at an
|
||||
`Endpoint`, with the message copied directly from the sender's pages to the receiver's
|
||||
(`copyAcross` walks both sets of page tables through the physmap — no CR3 switch, no
|
||||
bounce buffer).
|
||||
|
||||
Two syscalls carry it:
|
||||
|
||||
- **`ipc_call(h, msg, reply)`** — copy `msg` to the server, block until it replies.
|
||||
- **`ipc_reply_wait(h, reply, recv)`** — reply to the client you're still holding (if
|
||||
any), then block for the next request. One syscall, because a server's steady state
|
||||
is *always* "finish the last one, wait for the next".
|
||||
|
||||
An endpoint is reached by **handle** — a small integer index into the process's handle
|
||||
table (`Task.handles`), exactly like a file descriptor, and just as unforgeable. The
|
||||
bootstrap problem (how do you get the first handle?) is solved by a tiny name registry:
|
||||
a server calls `ipc_register(service_id, h)` under a well-known small integer, and a
|
||||
client calls `ipc_lookup(service_id)`.
|
||||
|
||||
The server never learns the client's identity beyond a **badge**, delivered alongside
|
||||
the message: the caller's task id.
|
||||
|
||||
### Interrupts are messages too
|
||||
|
||||
`notifyFromIsr` posts an *asynchronous* notification to an endpoint — no payload, no
|
||||
reply owed — and wakes whoever is blocked in `reply_wait`. Its badge has the top bit
|
||||
set (`notify_badge_bit`), which is how a driver's single event loop distinguishes "a
|
||||
client wants something" from "the hardware wants something". Notifications sit in a
|
||||
small coalescing ring on the endpoint, so an interrupt taken while the driver was busy
|
||||
elsewhere is not lost.
|
||||
|
||||
This is what makes a user-space driver possible at all, and it's the subject of
|
||||
[drivers.md](drivers.md).
|
||||
|
||||
## What's next (partly done since)
|
||||
|
||||
- **Priority inheritance** through IPC — still open: a high-priority client
|
||||
blocked on a low-priority server suffers unbounded priority inversion.
|
||||
- **Handle transfer.** *Landed as cap-passing (M13)*: `ipc_call` and
|
||||
`ipc_reply_wait` carry an optional capability alongside the bytes (`send_cap`),
|
||||
copying an endpoint or shared-memory handle into the peer's table. First user:
|
||||
[input](input.md) subscribers register by handing over their own endpoint, and
|
||||
class drivers get a private channel to one device.
|
||||
- **Asynchronous / buffered send** for the cases where a rendezvous is the wrong
|
||||
shape (logging, notifications between servers). *Landed as `ipc_send`* — a
|
||||
non-blocking post to an endpoint's bounded payload queue, delivered through
|
||||
`reply_wait` as a buffered message (badge bit `notify_message_bit`). Built for, and
|
||||
first used by, the [input service](input.md)'s keyboard-event broadcast, where a
|
||||
synchronous push would let one dead subscriber hang the fan-out. A full queue drops
|
||||
the oldest (discrete messages, not a coalescing level like the notification ring).
|
||||
- **A bounded reply** — half landed. The copy is still 256 bytes
|
||||
(`MESSAGE_MAXIMUM`) under the big kernel lock, but bulk transfer got its shared
|
||||
pages: `shared_memory_create`/`map`/`physical`, the region handle delegated as
|
||||
a capability (above). virtio-gpu's scanout surface is the first user
|
||||
([display-v2.md](display-v2.md)).
|
||||
|
||||
## Lifecycle conventions over IPC (M17)
|
||||
|
||||
Three conventions from [process-lifecycle.md](../os-development-guide/process-lifecycle.md) ride the
|
||||
notification mechanism:
|
||||
|
||||
- **Signals** arrive as notifications on the endpoint a process nominated with
|
||||
`signal_bind` (`process.bindSignals`): badge = the signal bit plus the
|
||||
coalesced pending mask (`process.signalsFrom` decodes). Statements,
|
||||
never questions; no payload, no reply.
|
||||
- **One-shot timers** (`timer_bind`, `time.timerOnce`) land as a
|
||||
timer-bit notification — the timed wait: a service arms a deadline and keeps
|
||||
serving, instead of blocking in sleep.
|
||||
- **The universal ping**: a **zero-length request is the liveness probe**,
|
||||
answered with a zero-length reply by the service harness itself
|
||||
(`service.run`). No protocol's requests start at length zero, so the
|
||||
encoding cannot collide, and a wedged service simply fails to answer — which
|
||||
is the diagnosis. Deep health ("can I reach my hardware?") stays a per-service
|
||||
protocol message.
|
||||
+8
-8
@@ -1,6 +1,6 @@
|
||||
# Device interrupts
|
||||
|
||||
CPU exceptions ([interrupts.md](../os-development-guide/interrupts.md)) are the kernel reacting to its own
|
||||
CPU exceptions ([interrupts.md](../os-development/interrupts.md)) are the kernel reacting to its own
|
||||
mistakes. **Device interrupts** are the opposite: hardware asking for attention —
|
||||
a timer firing, a key pressed, a packet arriving. They share the IDT, but differ
|
||||
in one fundamental way: an exception here is terminal (we report and halt), while a
|
||||
@@ -10,7 +10,7 @@ back — the same mechanism a scheduler will later use to preempt tasks.
|
||||
|
||||
The first device we bring up is the **timer**, because it's the simplest: it lives
|
||||
entirely on the CPU's local interrupt controller, needing no external routing.
|
||||
It's all x86_64-specific, behind the [architecture](../os-development-guide/architecture.md) boundary.
|
||||
It's all x86_64-specific, behind the [architecture](../os-development/architecture.md) boundary.
|
||||
|
||||
## The APIC, not the PIC
|
||||
|
||||
@@ -53,7 +53,7 @@ a missing PIT would hang the boot):
|
||||
|
||||
1. **CPUID leaf 0x15** — the CPU's TSC frequency directly, needing no external timer
|
||||
at all (the LAPIC is then measured against the TSC).
|
||||
2. The **HPET**, discovered via ACPI (see [discovery](../os-development-guide/discovery.md) / [acpi](../os-development-guide/acpi.md)).
|
||||
2. The **HPET**, discovered via ACPI (see [discovery](../os-development/discovery.md) / [acpi](../os-development/acpi.md)).
|
||||
3. The **ACPI PM timer** (a fixed 3.579545 MHz counter from the FADT).
|
||||
4. The **PIT** (legacy 8254, 1.193182 MHz) — last resort, and bounded so it can't hang.
|
||||
|
||||
@@ -100,7 +100,7 @@ values (a second socket, some firmware), so a thread migrating from a core readi
|
||||
check** as each application processor comes online (`checkWarpSource`, adapted from
|
||||
Linux's): the waking core and the BSP hammer a shared "highest seen" TSC under a lock,
|
||||
and if either ever reads below it, the cores' TSCs are skewed. It's pairwise because APs
|
||||
come up one at a time ([smp.md](../os-development-guide/smp.md)).
|
||||
come up one at a time ([smp.md](../os-development/smp.md)).
|
||||
|
||||
**The fallback.** When the TSC fails either test — non-invariant (a bare VM such as the
|
||||
default qemu64), or warped between cores — danos moves the monotonic clock onto the
|
||||
@@ -160,7 +160,7 @@ A device handler is a plain `fn () void` — a timer or keyboard handler doesn't
|
||||
the interrupted registers. (The stubs originally didn't save the SSE/vector
|
||||
registers, so a handler couldn't use them; `isr_common` now does an
|
||||
`fxsave`/`fxrstor` of the full SSE/x87 state around dispatch — see
|
||||
[interrupts.md](../os-development-guide/interrupts.md).)
|
||||
[interrupts.md](../os-development/interrupts.md).)
|
||||
|
||||
## Turning them on
|
||||
|
||||
@@ -168,7 +168,7 @@ Exceptions can't be masked, which is why they worked all along. Maskable device
|
||||
interrupts don't fire until the CPU's interrupt flag is set — so the final step is
|
||||
`sti` (`arch.enableInterrupts()`), after the APIC and timer are configured. From
|
||||
that instant the kernel has a heartbeat, and its idle `hlt` loop
|
||||
([halting.md](../os-development-guide/halting.md)) wakes on every tick and dozes off again.
|
||||
([halting.md](../os-development/halting.md)) wakes on every tick and dozes off again.
|
||||
|
||||
## Verifying it
|
||||
|
||||
@@ -188,13 +188,13 @@ spinning in unrelated code — is the whole mechanism working end to end.
|
||||
## Since (done elsewhere)
|
||||
|
||||
- **Preemption**: the timer handler is where the scheduler decides to switch — the
|
||||
reason a *returning* interrupt matters. See [scheduling.md](../os-development-guide/scheduling.md).
|
||||
reason a *returning* interrupt matters. See [scheduling.md](../os-development/scheduling.md).
|
||||
- **`sleep()` / timeouts** built on the calibrated clock.
|
||||
- **The I/O APIC, routed**: external device lines now reach a vector, and the
|
||||
interrupt is delivered onward to a *user-space* driver as an IPC message. See
|
||||
[drivers.md](drivers.md).
|
||||
- **Uncacheable MMIO**: device grants are mapped `PCD|PWT` (strong-uncacheable) for
|
||||
user drivers — see [paging.md](../os-development-guide/paging.md).
|
||||
user drivers — see [paging.md](../os-development/paging.md).
|
||||
|
||||
## What's next (partly done since)
|
||||
|
||||
+24
-15
@@ -10,18 +10,18 @@ mirrors them and prunes a dead reporter's children, and the `usb-report`
|
||||
scenario proves report → prune → respawn → re-report. The application surface is built (M18.3, 2026-07-13):
|
||||
`enumerate` and `subscribe` over IPC, with `device-list` as the first client —
|
||||
the manager is now the one answer to "what devices exist" for applications.
|
||||
The primitives underneath are real ([process-management.md](../os-development-guide/process-management.md):
|
||||
The primitives underneath are real ([process-management.md](../os-development/process-management.md):
|
||||
spawn/supervise/kill/exit-notification; [driver-model.md](driver-model.md): the device
|
||||
table as a capability system; [drivers.md](drivers.md): claim/map/IRQ), and the first
|
||||
per-device driver spawn works (the device manager matches the xHCI controller by PCI
|
||||
class and spawns `usb-xhci-bus` with the device id as argv[1]). This document designs
|
||||
the rest: the device manager as **the tree, the matcher, and the supervisor** — the
|
||||
policy process that turns [resilience.md](../os-development-guide/resilience.md)'s restart goal into practice
|
||||
policy process that turns [resilience.md](../os-development/resilience.md)'s restart goal into practice
|
||||
for drivers.
|
||||
|
||||
How processes stop, reload, and report their deaths is deliberately **not** in this
|
||||
document: that is the universal lifecycle every danos process speaks —
|
||||
[process-lifecycle.md](../os-development-guide/process-lifecycle.md), signals over IPC and the stable
|
||||
[process-lifecycle.md](../os-development/process-lifecycle.md), signals over IPC and the stable
|
||||
`process` interface. The device manager is that design's first serious
|
||||
customer, not its owner. Its own protocol contains nothing lifecycle-shaped; a
|
||||
driver is stopped, health-checked, and buried exactly like any other process.
|
||||
@@ -35,7 +35,7 @@ The device tree is two things fused: *information* (what exists, how it nests) a
|
||||
claims, resource containment on `device_register`, the
|
||||
`mmio_map`/`irq_bind`/`msi_bind` gates — and **cleans all of it up when a process
|
||||
dies** (settled; it is increment 1 of
|
||||
[process-lifecycle.md](../os-development-guide/process-lifecycle.md)). The three invariants in
|
||||
[process-lifecycle.md](../os-development/process-lifecycle.md)). The three invariants in
|
||||
[driver-model.md](driver-model.md) stay exactly where they are. A device manager
|
||||
that could mint MMIO mappings by its own say-so would be a second kernel, and a
|
||||
buggy one would un-earn everything the microkernel bought.
|
||||
@@ -51,7 +51,7 @@ enumeration is a **pci-bus driver**: the manager spawns it against the host brid
|
||||
like any bus reports children. ACPI becomes an **acpi service** that interprets the
|
||||
tables and reports the namespace. The manager only orchestrates and merges. Moving
|
||||
AML interpretation out of ring 0 is its own project on its own track; nothing here
|
||||
depends on when it lands. (It landed: [discovery.md](../os-development-guide/discovery.md), M19–M20.)
|
||||
depends on when it lands. (It landed: [discovery.md](../os-development/discovery.md), M19–M20.)
|
||||
|
||||
`device_register` is **idempotent on exact match**: a re-registration with an
|
||||
identical (parent, class, identity, resources) tuple returns the existing id
|
||||
@@ -82,7 +82,7 @@ one world.
|
||||
deadline means wrong binary, wrong protocol version, or wedged before main — apply
|
||||
the stop sequence and the restart policy. Everything else lifecycle-shaped
|
||||
(terminate, the common `ping` liveness call, exit reasons) arrives through
|
||||
[process-lifecycle.md](../os-development-guide/process-lifecycle.md)'s vocabulary, not this protocol.
|
||||
[process-lifecycle.md](../os-development/process-lifecycle.md)'s vocabulary, not this protocol.
|
||||
|
||||
Assignment stays argv (`usb-xhci-bus <device id>`) for now — simple, and it works.
|
||||
The step after `hello` exists is delegation: the manager claims (or is granted) the
|
||||
@@ -91,13 +91,16 @@ mechanism), replacing first-come-first-served `device_claim` with policy. Identi
|
||||
`child_added` is per-bus: PCI children carry the class triple (`pci_class`, as the
|
||||
xHCI match already uses); USB children carry the (class, subclass, protocol) triple
|
||||
from usb-ids.zig — each bus's native language, decoded by the shared ids modules.
|
||||
(Since the registry landed, `child_added` also carries a `bus` discriminator and
|
||||
the numeric `vendor`/`device`/`subsystem` ids the finer match levels need —
|
||||
see [/etc/devices.csv](devices-csv.md).)
|
||||
|
||||
## Supervision and restart
|
||||
|
||||
Every driver is spawned with the manager's exit endpoint (`spawnSupervised` — built).
|
||||
On a death notification:
|
||||
|
||||
1. **Read the reason** ([process-lifecycle.md](../os-development-guide/process-lifecycle.md) increment 2).
|
||||
1. **Read the reason** ([process-lifecycle.md](../os-development/process-lifecycle.md) increment 2).
|
||||
Clean exit → it meant to; don't restart. Fault or missed `hello` deadline →
|
||||
restart with **backoff**, and a crash-loop cap (three fast deaths → mark failed,
|
||||
stop respawning, log loudly; a later `reload` to the manager can retry).
|
||||
@@ -136,7 +139,7 @@ way.
|
||||
## Increments
|
||||
|
||||
Increments 1–4 are the lifecycle prerequisites and live in
|
||||
[process-lifecycle.md](../os-development-guide/process-lifecycle.md) (claim cleanup on death, exit reasons,
|
||||
[process-lifecycle.md](../os-development/process-lifecycle.md) (claim cleanup on death, exit reasons,
|
||||
published exit events, signals + `process`). On top of those:
|
||||
|
||||
5. **device-manager-protocol**: `hello`, supervised spawn with restart policy;
|
||||
@@ -147,7 +150,7 @@ published exit events, signals + `process`). On top of those:
|
||||
to a manager-internal seam.
|
||||
8. **Discovery migration** — DONE (M19–M20, 2026-07-13): enumeration moved to
|
||||
ring 3 as swappable per-firmware discoverers — the pci-bus driver (M19) then
|
||||
the acpi service (M20), see [discovery.md](../os-development-guide/discovery.md); of the enumerable
|
||||
the acpi service (M20), see [discovery.md](../os-development/discovery.md); of the enumerable
|
||||
devices, the kernel seeds only the host bridge and the acpi-tables node (the
|
||||
non-enumerable platform nodes — processors, interrupt controllers, the HPET,
|
||||
the loader's framebuffer — stay kernel-seeded too). Matching moved with it:
|
||||
@@ -169,9 +172,15 @@ published exit events, signals + `process`). On top of those:
|
||||
- **Manager death**: drivers survive the manager; the restarted manager re-learns
|
||||
the world (above). Checkpointing driver state with the manager is deferred until
|
||||
something demonstrates the need.
|
||||
- **Matching stays code until the third bus.** `driverFor`/`pciDriverFor` were
|
||||
honest at two bus types; the third was expected to trigger the manifest (a driver
|
||||
declares what it binds: a PCI class triple, a USB class triple, an ACPI `_HID`).
|
||||
(Since then: the third bus — USB — arrived and is matched in code too. Today's
|
||||
matchers are `pciDriverForIdentity`, `hidDriverFor`, and `usbDriverForIdentity`;
|
||||
the manifest waits until code matching actually hurts.)
|
||||
- **Matching is a registry, not code (resolved 2026-07-26).** `driverFor`/
|
||||
`pciDriverFor` were honest at two bus types; the third (USB) was matched in code
|
||||
too, and then the switch tables started to hurt — they keyed PCI matches on the
|
||||
class triple alone, so a virtio-gpu could only be matched as a generic display
|
||||
function and the driver had to re-confirm its `1AF4:1050` identity from config
|
||||
space after being spawned. The manifest the earlier note anticipated landed as a
|
||||
human-readable registry: **[/etc/devices.csv](devices-csv.md)**, parsed by the
|
||||
pure `device-registry` module and read by the manager at boot. A row binds a
|
||||
driver to a device by any of base / subclass / prog-IF / vendor / device /
|
||||
subsystem / `_HID`, most-specific match winning; it is authoritative (no
|
||||
compiled-in fallback — an unmatched device is logged, never guessed).
|
||||
`pciDriverForIdentity`, `hidDriverFor`, and `usbDriverForIdentity` are gone.
|
||||
@@ -0,0 +1,110 @@
|
||||
# /etc/devices.csv — the device registry
|
||||
|
||||
**Status: built (2026-07-26).** The device manager reads `/etc/devices.csv` at
|
||||
boot and binds every device a bus driver reports to the driver the registry
|
||||
names. It replaces the three hand-written `switch` tables that used to live in
|
||||
the manager (`pciDriverForIdentity`, `hidDriverFor`, `usbDriverForIdentity`) —
|
||||
the "manifest" [device-manager.md](device-manager.md) anticipated once code
|
||||
matching started to hurt. The parser and matcher are the pure, unit-tested
|
||||
`device-registry` module (`library/device/registry/device-registry.zig`).
|
||||
|
||||
## Why a registry
|
||||
|
||||
The switch tables keyed PCI matches on the 24-bit class/subclass/prog-IF triple
|
||||
alone. That is too coarse: a virtio-gpu is just "display / other" by class, so it
|
||||
could only be *class-matched* and the driver had to re-confirm its real
|
||||
`1AF4:1050` identity from config space **after** the manager had already spawned
|
||||
it. The registry lets a rule bind on the full identity — down to vendor, device,
|
||||
and subsystem — so the manager makes the precise decision itself, and the driver
|
||||
comes up already knowing it is the right one.
|
||||
|
||||
It is also **data, not code**: teaching the system new hardware is a line in a
|
||||
file, not an edit-and-recompile of the manager. And it is **greppable** — one
|
||||
place to read "what binds what," the same idea as Linux's `modules.alias`.
|
||||
|
||||
## The file
|
||||
|
||||
One rule per line, nine comma-separated fields; `#` starts a comment (whole-line
|
||||
or trailing); blank lines are ignored. Whitespace around a field is trimmed, so
|
||||
columns may be padded for readability.
|
||||
|
||||
```
|
||||
# bus base class prog_if vendor device subsystem hid driver
|
||||
pci, 0C, 03, 30, *, *, *, *, /system/drivers/usb-xhci-bus
|
||||
pci, 03, 00, 00, *, *, *, *, /system/drivers/display
|
||||
pci, 03, 80, *, 1AF4, 1050, *, *, /system/drivers/virtio-gpu
|
||||
usb, 03, 01, 01, *, *, *, *, /system/drivers/usb-hid-keyboard
|
||||
acpi, *, *, *, *, *, *, PNP0303, /system/drivers/ps2-bus
|
||||
```
|
||||
|
||||
| Field | Meaning | Notes |
|
||||
|---|---|---|
|
||||
| `bus` | `pci` \| `usb` \| `acpi` | which bus reported the device; picks the namespace for the id columns |
|
||||
| `base` | PCI base class / USB class | hex |
|
||||
| `class` | PCI subclass / USB subclass | hex |
|
||||
| `prog_if` | PCI prog-IF / USB protocol | hex |
|
||||
| `vendor` | PCI vendor / USB idVendor | hex |
|
||||
| `device` | PCI device / USB idProduct | hex |
|
||||
| `subsystem` | PCI subsystem, `(ssvid<<16)\|ssid` | hex; blank for usb/acpi |
|
||||
| `hid` | ACPI `_HID` (e.g. `PNP0303`) | blank for pci/usb |
|
||||
| `driver` | full ramdisk path to spawn | e.g. `/system/drivers/virtio-gpu` |
|
||||
|
||||
`*` or an empty field is a **wildcard** — it matches anything and adds nothing to
|
||||
a rule's specificity.
|
||||
|
||||
## Levels of detection: most-specific-wins
|
||||
|
||||
Several rows may match one device. The manager picks the **most specific** — the
|
||||
one that pins the finest-grained fields. Specificity weights double from the
|
||||
coarsest level so each outweighs all coarser levels combined:
|
||||
|
||||
```
|
||||
base(1) < class(2) < prog_if(4) < vendor(8) < subsystem(16) < device(32) ≈ hid(32)
|
||||
```
|
||||
|
||||
So the generic `pci, 03, 00, 00, …/display` rule and the precise
|
||||
`pci, 03, 80, *, 1AF4, 1050, …/virtio-gpu` rule coexist: the virtio card
|
||||
(vendor 1AF4, device 1050) takes the specific rule; a plain VGA adapter still
|
||||
falls to the generic one. Two rules that match a device with the *same*
|
||||
specificity are a registry authoring error — the manager logs it loudly and binds
|
||||
the first, so the shadowed rule is visible rather than silently dropped.
|
||||
|
||||
## Authoritative — no code fallback
|
||||
|
||||
There is no compiled-in default table behind the registry. A device that no row
|
||||
matches goes **unbound** and is logged; the manager never guesses. A missing or
|
||||
empty `/etc/devices.csv` therefore means nothing matches — which is loud at boot,
|
||||
not a silent half-working system.
|
||||
|
||||
## How the manager reads it
|
||||
|
||||
`/etc/devices.csv` is bundled into the initial ramdisk (`build.zig`'s `bundled`
|
||||
list). The kernel serves the initrd's `/etc` tree directly — the `fat` service is
|
||||
spawned *after* the device manager and is irrelevant to `/etc` — so the manager
|
||||
reads the file with a plain `fs.open("/etc/devices.csv")` + `read`, with no
|
||||
filesystem service running and no boot-ordering dependency. It parses the bytes
|
||||
once in `initialise`, before any bus driver can report a device to match.
|
||||
|
||||
## Feeding the matcher: the widened report
|
||||
|
||||
Finer-grained matching needs identity the old ABI threw away. Two things carry it
|
||||
now: `child_added` (and `DeviceDescriptor`) grew `vendor` / `device` /
|
||||
`subsystem` fields, filled by the PCI bus driver from config space (offsets
|
||||
0x00 and 0x2C); and each bus driver states its `bus` in the report (a `BusKind`),
|
||||
so the manager reads a PCI class triple and a USB class triple — the same 24 bits
|
||||
in different namespaces — against the right `bus` column.
|
||||
|
||||
## Adding a driver
|
||||
|
||||
(The step-by-step walkthrough with a worked example is
|
||||
[new-driver-checklist.md](new-driver-checklist.md).)
|
||||
|
||||
1. Create `system/drivers/<name>/` with the driver source plus a ~15-line
|
||||
package `build.zig` + `build.zig.zon` (copy an existing driver package,
|
||||
e.g. `system/drivers/pci-bus/`; per-driver extras go through
|
||||
`build_support.programModule`). Then bundle it at `/system/drivers/<name>`:
|
||||
one dependency + one bundled entry in the root `build.zig`, one line in the
|
||||
root `build.zig.zon`.
|
||||
2. Add a row to `etc/devices.csv` naming the identity it binds and its full path.
|
||||
|
||||
No device-manager change is required — the registry is the seam.
|
||||
+4
-3
@@ -20,9 +20,10 @@ Read [display.md](display.md) first for the *why*; this is the *what* and the *o
|
||||
|
||||
Follow [coding-standards.md](../coding-standards.md): spell out non-acronym abbreviations in
|
||||
full, kebab-case file names, no `Co-Authored-By` trailers on commits. New user binaries
|
||||
go through `addUserBinary` in [build.zig](../../build.zig) and get packed into the
|
||||
initial-ramdisk; protocols are `b.addModule("…-protocol", …)` and imported into the
|
||||
`runtime` module.
|
||||
go through build-support's shared user-binary recipe and get packed into the
|
||||
initial-ramdisk; protocols are modules exported by the `library/protocol` package.
|
||||
(This section predates the build-packages split; see
|
||||
[build-packages-plan.md](../build-packages-plan.md) for the current build shape.)
|
||||
|
||||
## How to verify along the way
|
||||
|
||||
+5
-3
@@ -18,9 +18,11 @@ lands on its own and ends in a **verifiable gate** — shaped for a `/loop` run,
|
||||
|
||||
Follow [coding-standards.md](../coding-standards.md): spell out non-acronym abbreviations,
|
||||
kebab-case file names, no `Co-Authored-By` trailers. New user binaries go through
|
||||
`addUserBinary` and get packed into the initial-ramdisk; protocols are
|
||||
`b.addModule("…-protocol", …)` imported into `runtime`; new syscalls extend
|
||||
[abi.zig](../../system/abi.zig) `SystemCall` + a `library/runtime` wrapper.
|
||||
build-support's shared user-binary recipe and get packed into the initial-ramdisk;
|
||||
protocols are modules exported by the `library/protocol` package; new syscalls extend
|
||||
[abi.zig](../../system/abi.zig) `SystemCall` + a `library/kernel` wrapper.
|
||||
(This section predates the build-packages split; see
|
||||
[build-packages-plan.md](../build-packages-plan.md) for the current build shape.)
|
||||
|
||||
## How to verify along the way
|
||||
|
||||
+1
-1
@@ -144,4 +144,4 @@ path in VMs**, where danos development happens. The framebuffer floor never goes
|
||||
- [display.md](display.md) — v1: the compositor, the GOP-vs-device split, the WC discipline.
|
||||
- [display-v2-plan.md](display-v2-plan.md) — the ordered build-out.
|
||||
- [driver-model.md](driver-model.md) — claim / `mmio_map` / MSI / capability passing (M13).
|
||||
- [resilience.md](../os-development-guide/resilience.md) — the restart machinery the hot-attach leans on.
|
||||
- [resilience.md](../os-development/resilience.md) — the restart machinery the hot-attach leans on.
|
||||
+15
-15
@@ -1,6 +1,6 @@
|
||||
# The display service: a framebuffer compositor
|
||||
|
||||
The [framebuffer](../os-development-guide/framebuffer.md) the loader hands over is a flat block of pixel
|
||||
The [framebuffer](../os-development/framebuffer.md) the loader hands over is a flat block of pixel
|
||||
memory, and the kernel's [bootstrap console](../../system/kernel/console.zig) draws text
|
||||
into it directly. That console is a stop-gap. The **display service**
|
||||
(`system/services/display/`) is the real thing: an ordinary ring-3 process that *owns*
|
||||
@@ -22,14 +22,14 @@ which one you're holding decides what you can do.
|
||||
linear framebuffer pointer and can set video modes — but only until
|
||||
`ExitBootServices`. The loader already leans on this: [`queryFramebuffer`](../../boot/efi.zig)
|
||||
reads the monitor's EDID, picks the native mode, and calls `set_mode` **before**
|
||||
exiting ([gop.md](../os-development-guide/gop.md)). Once the kernel runs, GOP is **gone** — no `set_mode`, no
|
||||
exiting ([gop.md](../os-development/gop.md)). Once the kernel runs, GOP is **gone** — no `set_mode`, no
|
||||
mode list, no EDID. What survives is the frozen snapshot in
|
||||
[`BootInformation.framebuffer`](../../system/boot-handoff.zig): `{base, width, height,
|
||||
pitch, format, refresh_hz}`, and nothing more.
|
||||
|
||||
- **The PCI class-0x03 device is the raw controller** — BARs, config space, registers,
|
||||
IO ports. It is what you actually *own* after boot. On QEMU's emulated adapter
|
||||
([`-device VGA,edid=on`](../../build.zig), the Bochs VBE/DISPI model) the `base` GOP handed
|
||||
([`-device VGA,edid=on`](../../build/qemu.zig), the Bochs VBE/DISPI model) the `base` GOP handed
|
||||
you *is* that device's linear-framebuffer BAR — the same physical memory, seen through
|
||||
a different door. On a real discrete GPU, GOP's `base` is an aperture inside the GPU's
|
||||
VRAM BAR. danos already decodes this device
|
||||
@@ -66,7 +66,7 @@ rest of the system hasn't had to face:
|
||||
[`console.zig`](../../system/kernel/console.zig). It is *not* a
|
||||
[devices-broker](../../system/kernel/devices-broker.zig) node, so
|
||||
`device.claim`/`mmio_map` cannot reach it, and there is no framebuffer
|
||||
[syscall](../os-development-guide/syscall.md). A user-space display service needs a **new mechanism just to
|
||||
[syscall](../os-development/syscall.md). A user-space display service needs a **new mechanism just to
|
||||
touch the pixels**. (See "The handoff" below — this is built.)
|
||||
|
||||
2. **danos had no cross-process shared memory.** At v1 the memory syscalls were `mmap`
|
||||
@@ -87,13 +87,13 @@ rest of the system hasn't had to face:
|
||||
│ (ResourceKind.memory = [base, height*pitch], write-combining hint,
|
||||
│ plus DisplayInfo{width, height, pitch, format, refresh_hz})
|
||||
▼
|
||||
display service (system/services/display/, ServiceId.display) ← the compositor
|
||||
display service (system/services/display/, /protocol/display) ← the compositor
|
||||
│ device.claim(display node) → mmio_map(WRITE-COMBINING) = FRONT buffer (the LFB)
|
||||
│ mmap(cacheable) a BACK buffer of the same geometry
|
||||
│ owns: an ordered LAYER STACK + a per-frame DAMAGE tracker (rect list or tile grid)
|
||||
│ loop: composite dirty layers → back buffer → present dirty rects → front
|
||||
│ backend is an INTERNAL interface: {gop-fb} at boot; {virtio-gpu} on hot-attach (v2)
|
||||
▼ reached by name (ipc_lookup); clients drive it over the display protocol
|
||||
▼ reached by name (open /protocol/display); clients drive it over the display protocol
|
||||
┌────────────────────────────────────┬──────────────────────────────────────┐
|
||||
drawing clients (v1) surface clients (deferred)
|
||||
display commands: display surfaces:
|
||||
@@ -119,7 +119,7 @@ second backend or a second monitor appears; until then it is complexity with no
|
||||
|
||||
The framebuffer crosses into user space through the machinery that already exists for
|
||||
every other device, rather than a bespoke syscall — so it inherits ownership,
|
||||
release-on-death, and re-claim-on-restart for free (the [resilience](../os-development-guide/resilience.md)
|
||||
release-on-death, and re-claim-on-restart for free (the [resilience](../os-development/resilience.md)
|
||||
story: a crashed display service returns the LFB to the kernel, and its restart
|
||||
re-claims it).
|
||||
|
||||
@@ -160,8 +160,8 @@ Two buffers, with deliberately different memory types:
|
||||
|
||||
So a frame is: compose every dirty layer into the cacheable back buffer, then **present**
|
||||
— copy the changed regions back→front in sequential, WC-friendly writes. Two details the
|
||||
[framebuffer](../os-development-guide/framebuffer.md) note already establishes carry over: step rows by `pitch`,
|
||||
not `width*4`; and handle both `rgbx` and `bgrx` [pixel formats](../os-development-guide/gop.md).
|
||||
[framebuffer](../os-development/framebuffer.md) note already establishes carry over: step rows by `pitch`,
|
||||
not `width*4`; and handle both `rgbx` and `bgrx` [pixel formats](../os-development/gop.md).
|
||||
|
||||
## Flicker vs. tearing — what double buffering does and doesn't buy
|
||||
|
||||
@@ -234,7 +234,7 @@ client module, as with every other danos service.
|
||||
|
||||
The compositor is the single owner of the framebuffer — only the main `service.run` loop
|
||||
touches the backend and the layer stack. Tracking the mouse without breaking that
|
||||
ownership is the display's first use of [threads](../os-development-guide/threading.md): the service is built
|
||||
ownership is the display's first use of [threads](../os-development/threading.md): the service is built
|
||||
multi-threaded (`addThreadedUserBinary`) and, at startup, spawns a **mouse-listener
|
||||
thread** beside the compositor loop.
|
||||
|
||||
@@ -242,7 +242,7 @@ thread** beside the compositor loop.
|
||||
(`input.subscribeMouse()`), accumulates the relative `dx`/`dy` motion into an absolute
|
||||
cursor position clamped to the screen, and hands it to the compositor. It never touches
|
||||
the compositor — so no lock guards the framebuffer. A parked `next()` leaves its core
|
||||
free to halt ([halting.md](../os-development-guide/halting.md)).
|
||||
free to halt ([halting.md](../os-development/halting.md)).
|
||||
- **The channel.** A single-slot *latest-value* cell (`CursorChannel`) guarded by a
|
||||
`Thread.Mutex`: the renderer wants where the cursor *is now*, not a replay of
|
||||
every delta, so a new position overwrites the old. The listener also **pokes** the
|
||||
@@ -254,13 +254,13 @@ thread** beside the compositor loop.
|
||||
which is just a top-z compositor layer — with the existing `configure` + `present` path
|
||||
(it damages the old and new footprints, so only those two rectangles repaint).
|
||||
|
||||
Two threading facts shape this (both in [threading.md](../os-development-guide/threading.md)). IPC **handles do
|
||||
Two threading facts shape this (both in [threading.md](../os-development/threading.md)). IPC **handles do
|
||||
not cross threads**, so the listener can't reuse the main loop's endpoint handle — it
|
||||
`ipc.lookup(.display)`s its *own* handle to the same endpoint to poke through. And a
|
||||
multi-threaded service doing concurrent IPC is why the kernel's endpoint-create / register
|
||||
/ lookup syscalls now serialize under the big kernel lock. Shared fate applies: a fault in
|
||||
the listener takes the whole display down, and the supervisor restarts the process
|
||||
([resilience.md](../os-development-guide/resilience.md)).
|
||||
([resilience.md](../os-development/resilience.md)).
|
||||
|
||||
## What v1 does not do (and why that's fine)
|
||||
|
||||
@@ -317,8 +317,8 @@ packing are additionally covered by pure host unit tests under `zig build test`.
|
||||
|
||||
## See also
|
||||
|
||||
- [framebuffer.md](../os-development-guide/framebuffer.md) — the linear framebuffer, pitch vs. width, `volatile`.
|
||||
- [gop.md](../os-development-guide/gop.md) — GOP, and why only linear RGBX/BGRX modes are paintable.
|
||||
- [framebuffer.md](../os-development/framebuffer.md) — the linear framebuffer, pitch vs. width, `volatile`.
|
||||
- [gop.md](../os-development/gop.md) — GOP, and why only linear RGBX/BGRX modes are paintable.
|
||||
- [input.md](input.md) — the sibling service; the async `ipc_send` fan-out.
|
||||
- [driver-model.md](driver-model.md) — claim / `mmio_map`, capability passing, the trust model.
|
||||
- [device-manager.md](device-manager.md) — matching and supervision (the native backend's route).
|
||||
+15
-11
@@ -34,7 +34,7 @@ plain bus driver with no controller — a USB hub — is also a real thing.
|
||||
|
||||
danos already has the right central structure. `system/kernel/devices-broker.zig` holds a table of
|
||||
`DeviceDescriptor`, each with a parent, a class, and a set of resources. Firmware discovery
|
||||
seeds it ([discovery.md](../os-development-guide/discovery.md)); `device_register` grows it.
|
||||
seeds it ([discovery.md](../os-development/discovery.md)); `device_register` grows it.
|
||||
|
||||
Three invariants make it a capability system rather than a directory:
|
||||
|
||||
@@ -138,13 +138,17 @@ a higher-level service (block ↔ filesystem, a scanout driver ↔ the composito
|
||||
private wire to its *hardware* — virtio-gpu's command set — is not that; it stays a
|
||||
driver-private file, like the virtio-pci transport beside it.
|
||||
|
||||
The build side of this has since landed: [`addUserBinary`](build.zig) injects the
|
||||
default modules — the library/kernel concern modules (`ipc`, `memory`, `process`, `time`,
|
||||
`logging`, `file-system`, `thread`, `service`), the device/service clients (`driver`,
|
||||
`block`, `display`, `input`), plus `mmio`, `xkeyboard-config`, `acpi-ids` — into every user
|
||||
binary, and per-binary extras — protocol modules, bus logic — are added with
|
||||
`programModule(exe).addImport(...)`. That's the *entire* mechanism — Zig modules
|
||||
already give you everything else.
|
||||
The build side of this has since landed: every binary owns a package whose
|
||||
~15-line `build.zig` names EXACTLY the modules its source imports — the moral
|
||||
equivalent of a C file's include list — and the shared recipe in
|
||||
[`build-support/build.zig`](../../build-support/build.zig) (`userBinary`)
|
||||
resolves each name from the library domain that exports it (kernel's concern
|
||||
modules, the device driver libraries, the service clients, the protocols). An
|
||||
undeclared `@import` is a compile error, and a domain none of the imports come
|
||||
from never appears in the binary's manifest — a keyboard driver declares
|
||||
`xkeyboard-config`; nothing else does (see
|
||||
[build-packages-plan.md](../build-packages-plan.md)). That's the *entire*
|
||||
mechanism — Zig modules already give you everything else.
|
||||
|
||||
The discipline that makes this work: **a class driver must not import a bus's *hardware*
|
||||
logic module.** `usb-hid` imports `usb` (the transfer client) and `input-protocol`, never
|
||||
@@ -199,7 +203,7 @@ class driver, the device manager, or the kernel may share them freely.
|
||||
`system_spawn(name, arguments)` loads a binary bundled in the initial-ramdisk as a
|
||||
fresh ring-3 process; `name` becomes the child's argv[0] and the optional
|
||||
NUL-separated `arguments` blob its argv[1..], delivered on a SysV entry stack
|
||||
([sysv.md](../os-development-guide/sysv.md)). This is what
|
||||
([sysv.md](../os-development/sysv.md)). This is what
|
||||
turned the device manager from "log the match" into "run the driver": the kernel now
|
||||
spawns only `init`, `init` spawns the services, and the **device-manager** discovers
|
||||
the hardware and spawns each driver ([drivers.md](drivers.md)). Ungated for now — a
|
||||
@@ -398,6 +402,6 @@ from hand-rolling `*volatile` and getting ARM wrong.
|
||||
## See also
|
||||
|
||||
- [drivers.md](drivers.md) — how to write one, concretely.
|
||||
- [discovery.md](../os-development-guide/discovery.md) / [acpi.md](../os-development-guide/acpi.md) — where the device table comes from.
|
||||
- [discovery.md](../os-development/discovery.md) / [acpi.md](../os-development/acpi.md) — where the device table comes from.
|
||||
- [ipc.md](ipc.md) — endpoints, badges, and the notification path an IRQ arrives on.
|
||||
- [resilience.md](../os-development-guide/resilience.md) — restart, the reason any of this is worth the trouble.
|
||||
- [resilience.md](../os-development/resilience.md) — restart, the reason any of this is worth the trouble.
|
||||
+5
-5
@@ -4,13 +4,13 @@ In a monolithic kernel a driver is a function call away from everything: it runs
|
||||
ring 0, dereferences any physical address, and its interrupt handler *is* the ISR. In
|
||||
danos a driver is **an ordinary ring-3 process**. It has its own address space, it
|
||||
can crash without taking the kernel with it, and — the point of this document — it
|
||||
can be restarted ([resilience](../os-development-guide/resilience.md)).
|
||||
can be restarted ([resilience](../os-development/resilience.md)).
|
||||
|
||||
That leaves three questions the kernel has to answer, because a process can't answer
|
||||
them for itself:
|
||||
|
||||
1. **What hardware exists?** → `device_enumerate`, over the device table discovery built
|
||||
([discovery](../os-development-guide/discovery.md), [acpi](../os-development-guide/acpi.md)).
|
||||
([discovery](../os-development/discovery.md), [acpi](../os-development/acpi.md)).
|
||||
2. **How do I touch its registers?** → `device_claim` + `mmio_map`: the kernel maps the
|
||||
device's physical MMIO window into your address space, and from then on it's plain
|
||||
memory. No syscall per register access.
|
||||
@@ -51,7 +51,7 @@ the optional arguments its argv[1..], on a SysV entry stack, see sysv.md). Every
|
||||
is the **driver supervisor**. It does the three steps a monolithic kernel would do in
|
||||
its probe path, entirely from ring 3:
|
||||
1. **Discover** — `device_enumerate` snapshots the device table the kernel built from
|
||||
ACPI/PCI ([discovery](../os-development-guide/discovery.md)).
|
||||
ACPI/PCI ([discovery](../os-development/discovery.md)).
|
||||
2. **Match** — for each device it looks up a driver. The match policy is code, a few
|
||||
small per-bus tables: from the boot snapshot only the PCI host bridge matches
|
||||
(→ `pci-bus`); everything else arrives later as bus reports and matches on
|
||||
@@ -293,7 +293,7 @@ the device's `io_port` resource — direct ring-3 `in`/`out` is still a #GP, so
|
||||
uncacheable, physical address exposed), **memory barriers** (`library/device/mmio`'s
|
||||
`memoryBarrier`/`readMemoryBarrier`/`writeMemoryBarrier`, imported as the `mmio` module), **fault isolation** (a ring-3 fault kills only the faulting
|
||||
process — `killCurrentProcess` — and the machine keeps running,
|
||||
[resilience](../os-development-guide/resilience.md)), and **reclaim + restart on death** (every path out of a
|
||||
[resilience](../os-development/resilience.md)), and **reclaim + restart on death** (every path out of a
|
||||
process releases its claims and IRQ/MSI bindings — `releaseAllOwnedBy`,
|
||||
`irq.releaseOwner` — and the device manager respawns the driver with backoff,
|
||||
[device-manager.md](device-manager.md)). What remains:
|
||||
@@ -394,7 +394,7 @@ Claiming and mapping is half of being a danos driver; the other half is the
|
||||
- Build on `service.run` — one replyWait loop folding protocol
|
||||
requests, signals, and notifications into callbacks. The harness answers the
|
||||
universal zero-length ping and turns `terminate` into a clean exit for you
|
||||
([process-lifecycle.md](../os-development-guide/process-lifecycle.md)).
|
||||
([process-lifecycle.md](../os-development/process-lifecycle.md)).
|
||||
- A driver spawned with an assignment (its device id as argv[1]) sends the
|
||||
versioned `hello` to the device manager inside the deadline, and a **bus**
|
||||
driver reports what it discovers with `child_added`
|
||||
+1
-1
@@ -160,5 +160,5 @@ serial line names the class received, so the log shows all three arriving on one
|
||||
## See also
|
||||
|
||||
- [ipc.md](ipc.md) — the synchronous rendezvous and the notification path `ipc_send` extends.
|
||||
- [syscall.md](../os-development-guide/syscall.md) — the system-call surface, including `ipc_send`.
|
||||
- [syscall.md](../os-development/syscall.md) — the system-call surface, including `ipc_send`.
|
||||
- [driver-model.md](driver-model.md) — class drivers, capability passing (M13), the trust model.
|
||||
@@ -0,0 +1,199 @@
|
||||
# IPC: the kernel-ipc transport
|
||||
|
||||
Inter-process communication is the **backbone of a microkernel**. Once drivers and
|
||||
services run isolated in their own address spaces ([vision](../vision.md)), they can't
|
||||
just call each other — a request becomes bytes on a wire. In a microkernel, whatever
|
||||
was a function call across a monolithic kernel is IPC, so it's a first-class
|
||||
concern, not an afterthought.
|
||||
|
||||
This document describes **one transport** — the bottom layer (L0) of the
|
||||
communication stack defined in
|
||||
[communication.md](../os-development/communication.md), which owns the model
|
||||
and the vocabulary (*protocol*, *channel*, *packet*, *signal*, *endpoint*).
|
||||
kernel-ipc is the **first** transport, not the only possible one: in
|
||||
buffer-plus-doorbell terms it is a kernel-owned mailbox with the scheduler as
|
||||
the doorbell. Its distinguishing properties, which the layers above may rely
|
||||
on where they say so:
|
||||
|
||||
- **Rendezvous.** A call is a synchronous meeting, copied sender-page to
|
||||
receiver-page — natural backpressure, no queue to size.
|
||||
- **Capability carriage.** The *only* transport that can move a handle
|
||||
between processes. Channels are therefore always established over
|
||||
kernel-ipc, and it remains every channel's control path even when bulk
|
||||
data is negotiated onto a fatter transport (a shared-memory ring).
|
||||
- **Verified source.** Every delivery carries the kernel-stamped badge — the
|
||||
identity the channel layer attaches to received packets.
|
||||
- **Bounded packets.** 256 bytes call/reply, 64 pushed — the floor every
|
||||
protocol may assume on any transport.
|
||||
|
||||
Three properties keep the networking analogy honest — kernel-ipc is
|
||||
networking-*shaped*, not TCP:
|
||||
|
||||
- **Channels over it are RPC-shaped, not streams.** Packets, call/reply,
|
||||
datagram pushes — closer to UDP plus RPC than to a byte stream. Ordering
|
||||
exists per exchange (a reply answers its call), not across a channel.
|
||||
- **Possession is the connection.** There is no handshake state in the
|
||||
kernel: holding the capability *is* having the channel. A provider's one
|
||||
endpoint terminates every client's channel at once, demultiplexed by badge
|
||||
— like every client sharing the server's listening socket, with
|
||||
per-connection state living in the provider, keyed by badge. A *private*
|
||||
channel (a dedicated endpoint pair) is built when wanted: that is exactly
|
||||
what `subscribe` does.
|
||||
- **Packets never fragment.** If it doesn't fit in a packet, it isn't a
|
||||
packet: bulk data lives in shared memory and a packet (or signal) is the
|
||||
doorbell. The display path already works this way.
|
||||
|
||||
The rest of this document is the implementation, bottom-up: the kernel-thread
|
||||
queue the blocking discipline was worked out on, then endpoints — this
|
||||
transport's termination points.
|
||||
|
||||
## The kernel-thread queue
|
||||
|
||||
The first form is a **bounded blocking queue** (`system/kernel/ipc.zig`): a
|
||||
fixed-size ring buffer of messages with a producer/consumer rendezvous, built
|
||||
on the scheduler's [wait queues](../os-development/scheduling.md). (Its type
|
||||
is still named `Channel(T, capacity)` — it predates the vocabulary above, and
|
||||
is a *queue between kernel threads in one address space*, not a channel in
|
||||
the model's sense; a rename can ride a later flag-day.)
|
||||
|
||||
`Channel(T, capacity)` is generic over the message type and buffer size. It holds a
|
||||
ring buffer, a count, and two wait queues:
|
||||
|
||||
- **`send(msg)`** — if the queue is full, block on the *not-full* queue; otherwise
|
||||
write the message, bump the count, and wake a waiting receiver.
|
||||
- **`receive()`** — if the queue is empty, block on the *not-empty* queue; otherwise
|
||||
take a message, drop the count, and wake a waiting sender.
|
||||
|
||||
Neither side busy-waits: a full queue parks the sender, an empty one parks the
|
||||
receiver, and each operation wakes the other side when it makes progress possible.
|
||||
|
||||
Two details make it correct:
|
||||
|
||||
- **Recheck in a loop.** A woken task re-tests the condition (`while (full) wait`)
|
||||
rather than assuming the slot is still available — another waiter may have taken
|
||||
it first. This is the standard guard against spurious or racing wakeups.
|
||||
- **One critical section.** `send`/`receive` run under the [big kernel
|
||||
lock](../os-development/smp.md) (`sync.enter` / `sync.leave`), which disables interrupts on this
|
||||
core *and* takes the kernel's one spinlock — since SMP, the interrupt flag alone
|
||||
is not atomicity, because `cli` on one core does nothing to another. So checking
|
||||
the condition and committing the block/enqueue happen atomically both with respect
|
||||
to the timer preempting mid-operation and to the other side running on another
|
||||
CPU. `waitLocked` / `wakeLocked` are the variants that assume the caller already
|
||||
holds that critical section.
|
||||
|
||||
### Verifying it
|
||||
|
||||
The `ipc` test (see [testing.md](../testing.md)) runs a producer and a consumer passing
|
||||
**100 messages through a 4-slot queue**. The small buffer means the queue goes
|
||||
full and empty over and over, so both the blocking-send and blocking-receive paths are
|
||||
exercised heavily. The messages arrive intact and in order (their sum is the
|
||||
expected `5050`), and neither task busy-waits — they block and wake each other.
|
||||
|
||||
## Endpoints: the termination points
|
||||
|
||||
A queue connects two kernel threads sharing one address space. Real providers are
|
||||
*processes*, so a packet has to cross an address-space boundary. That's
|
||||
`system/kernel/ipc-synchronous.zig`, and its shape is L4's: a synchronous **rendezvous** at an
|
||||
`Endpoint`, with the packet copied directly from the sender's pages to the receiver's
|
||||
(`copyAcross` walks both sets of page tables through the physmap — no CR3 switch, no
|
||||
bounce buffer).
|
||||
|
||||
Two syscalls carry the request/reply exchange:
|
||||
|
||||
- **`ipc_call(h, msg, reply)`** — copy the request packet to the provider, block
|
||||
until the reply packet comes back.
|
||||
- **`ipc_reply_wait(h, reply, recv)`** — reply to the client you're still holding (if
|
||||
any), then block for the next request. One syscall, because a provider's steady state
|
||||
is *always* "finish the last one, wait for the next".
|
||||
|
||||
An endpoint is reached by **handle** — a small integer index into the process's handle
|
||||
table (`Task.handles`), exactly like a file descriptor, and just as unforgeable.
|
||||
The provider never learns the client's identity beyond the **badge** delivered
|
||||
alongside each packet: the caller's task id, stamped by the kernel —
|
||||
unforgeable source addressing, a property a network's source field lacks.
|
||||
|
||||
The bootstrap problem — how a channel is first established — is the subject of
|
||||
[protocol-namespace.md](../os-development/protocol-namespace.md): a protocol is
|
||||
resolved by name and the channel arrives as a capability. (The mechanism it
|
||||
replaced — `ipc_register`/`ipc_lookup` under compile-time `ServiceId` integers —
|
||||
is gone: both syscalls and the enum were deleted when the registry landed, and
|
||||
their syscall numbers are left vacant.)
|
||||
|
||||
### Interrupts are signals
|
||||
|
||||
`notifyFromIsr` posts an *asynchronous* signal to an endpoint — no payload, no
|
||||
reply owed — and wakes whoever is blocked in `reply_wait`. Its badge has the top bit
|
||||
set (`notify_badge_bit`), which is how a driver's single event loop distinguishes "a
|
||||
client wants something" from "the hardware wants something". Signals sit in a
|
||||
small coalescing ring on the endpoint, so an interrupt taken while the driver was busy
|
||||
elsewhere is not lost — coalesced, never dropped, which is exactly a signal's
|
||||
contract (the *count* may collapse; the *fact* may not).
|
||||
|
||||
This is what makes a user-space driver possible at all, and it's the subject of
|
||||
[drivers.md](drivers.md).
|
||||
|
||||
## What's next (partly done since)
|
||||
|
||||
- **Priority inheritance** through IPC — still open: a high-priority client
|
||||
blocked on a low-priority provider suffers unbounded priority inversion.
|
||||
- **Handle transfer.** *Landed as cap-passing (M13)*: `ipc_call` and
|
||||
`ipc_reply_wait` carry an optional capability alongside the bytes (`send_cap`),
|
||||
copying an endpoint or shared-memory handle into the peer's table — the
|
||||
mechanism by which channels are established and private channels built. First
|
||||
user: [input](input.md) subscribers register by handing over their own
|
||||
endpoint, and class drivers get a private channel to one device.
|
||||
- **Asynchronous / buffered send** for the cases where a rendezvous is the wrong
|
||||
shape (logging, event fan-out). *Landed as `ipc_send`* — a
|
||||
non-blocking post of an event packet (≤ 64 bytes) to an endpoint's bounded
|
||||
queue, delivered through `reply_wait` (badge bit `notify_message_bit`). Built
|
||||
for, and first used by, the [input service](input.md)'s keyboard-event
|
||||
broadcast, where a synchronous push would let one dead subscriber hang the
|
||||
fan-out. A full queue drops the oldest — event packets are droppable by
|
||||
design ([protocol-namespace.md](../os-development/protocol-namespace.md)'s
|
||||
wiring section states the rule).
|
||||
- **A bounded reply** — half landed. The copy is still one packet
|
||||
(256 bytes) under the big kernel lock, but bulk transfer got its shared
|
||||
pages: `shared_memory_create`/`map`/`physical`, the region handle delegated as
|
||||
a capability (above) — the packets-never-fragment rule in practice.
|
||||
virtio-gpu's scanout surface is the first user
|
||||
([display-v2.md](display-v2.md)).
|
||||
|
||||
## Lifecycle conventions over IPC (M17)
|
||||
|
||||
Three conventions from [process-lifecycle.md](../os-development/process-lifecycle.md) ride the
|
||||
signal mechanism:
|
||||
|
||||
- **Process signals** arrive as endpoint signals on the endpoint a process
|
||||
nominated with `signal_bind` (`process.bindSignals`): badge = the signal bit
|
||||
plus the coalesced pending mask (`process.signalsFrom` decodes). Statements,
|
||||
never questions; no payload, no reply.
|
||||
- **One-shot timers** (`timer_bind`, `time.timerOnce`) land as a
|
||||
timer-bit signal — the timed wait: a service arms a deadline and keeps
|
||||
serving, instead of blocking in sleep.
|
||||
- **Kernel notifications go only to your own endpoint.** `signal_bind`,
|
||||
`timer_bind`, `process_subscribe`, `irq_bind`, `msi_bind`, and spawn's exit
|
||||
endpoint all *nominate where the kernel will speak*, and all of them refuse an
|
||||
endpoint the caller did not create (`-EPERM`; the check is `ipc.ownedBy`,
|
||||
normalized to the process, so any thread may nominate an endpoint a sibling
|
||||
created). Holding a handle is not enough, because holding a handle is cheap:
|
||||
`fs_resolve` installs a mounted backend's capability in *any* caller's table,
|
||||
so every process holds a handle to PID 1's mailbox. Without the rule, "bind
|
||||
init's endpoint, then signal yourself" is a genuine, kernel-stamped `terminate`
|
||||
badge in PID 1's queue — a shutdown a receiver has no way to disbelieve — and
|
||||
timers, which carry no identity at all, multiply any loop that re-arms on its
|
||||
own landing.
|
||||
- **A capability that arrives belongs to the turn.** The kernel installs a sent
|
||||
capability in the receiver's table whatever the message's length or kind, so a
|
||||
receive loop must dispose of one on *every* path — the ping, the notification,
|
||||
the malformed request. The service harness (`service.run`) and PID 1 both hold
|
||||
it in an `ipc.Arrival`, released by a `defer`, and a handler that means to keep
|
||||
it says `take()`: forgetting closes, keeping is explicit. The reverse
|
||||
arrangement leaks a handle-table slot per request, and thirty-two unauthorized
|
||||
zero-length pings then end a service's ability to accept any capability —
|
||||
no subscribe, no shared-memory handover — for the rest of the boot.
|
||||
- **The universal ping**: a **zero-length request is the liveness probe**,
|
||||
answered with a zero-length reply by the service harness itself
|
||||
(`service.run`). No protocol's requests start at length zero, so the
|
||||
encoding cannot collide, and a wedged service simply fails to answer — which
|
||||
is the diagnosis. Deep health ("can I reach my hardware?") stays a per-service
|
||||
protocol packet.
|
||||
@@ -0,0 +1,218 @@
|
||||
# New driver: the minimum steps
|
||||
|
||||
The shortest path from "a device shows up in the boot log" to "my process is
|
||||
running with its registers mapped". This is the checklist; the reasoning behind
|
||||
every step lives in [Writing a driver](drivers.md), the matching rules in
|
||||
[devices.csv](devices-csv.md), and interrupts in
|
||||
[device interrupts](device-interrupts.md).
|
||||
|
||||
Worked example throughout: the Intel UHD 750 iGPU, which the boot log reports as
|
||||
|
||||
```
|
||||
pci-bus: 0:2.0 bus=pci base=03 class=00 prog_if=00 vendor=8086 device=4C8A ...
|
||||
```
|
||||
|
||||
## 1. Create the source file
|
||||
|
||||
`system/drivers/<name>/<name>.zig` — kebab-case, abbreviations spelled out
|
||||
([coding standards](../coding-standards.md)). The directory name, the binary
|
||||
name, and the `devices.csv` driver path must all agree; a mismatch fails
|
||||
silently (the device-manager logs the spawn failure, nothing else happens).
|
||||
|
||||
The complete minimal driver — claims its device, logs every resource, maps the
|
||||
register window, then sleeps in the harness loop:
|
||||
|
||||
```zig
|
||||
//! /system/drivers/intel-uhd-graphics-750 — spawned by the device manager with
|
||||
//! the device-tree id as argv[1]; claims that device and no other.
|
||||
|
||||
const std = @import("std");
|
||||
const device = @import("driver");
|
||||
const ipc = @import("ipc");
|
||||
const memory = @import("memory");
|
||||
const process = @import("process");
|
||||
const service = @import("service");
|
||||
|
||||
/// No protocol yet: the kernel's IPC ceiling (MESSAGE_MAXIMUM) sizes the buffers.
|
||||
const message_maximum = 256;
|
||||
|
||||
var controller_id: u64 = 0;
|
||||
var register_base: usize = 0;
|
||||
|
||||
fn initialise(endpoint: ipc.Handle) bool {
|
||||
_ = endpoint; // needed later, for irq binding and timers
|
||||
|
||||
if (!device.claim(controller_id)) {
|
||||
std.log.err("unable to claim device {d}", .{controller_id});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fetch our own descriptor back for the device's resources.
|
||||
const buffer = memory.allocator().alloc(device.DeviceDescriptor, 64) catch return false;
|
||||
defer memory.allocator().free(buffer);
|
||||
const total = device.enumerate(buffer);
|
||||
const descriptor = for (buffer[0..@min(total, buffer.len)]) |d| {
|
||||
if (d.id == controller_id) break d;
|
||||
} else {
|
||||
std.log.err("device {d} not in the device tree", .{controller_id});
|
||||
return false;
|
||||
};
|
||||
|
||||
// Log every resource BEFORE choosing one (see step 5).
|
||||
var register_index: u64 = 0;
|
||||
for (descriptor.resources[0..@intCast(descriptor.resource_count)], 0..) |resource, index| {
|
||||
std.log.info("resource {d}: kind={d} start=0x{x} len=0x{x}", .{
|
||||
index, resource.kind, resource.start, resource.len,
|
||||
});
|
||||
// The 16 MiB window is GTTMMADR, the register BAR (this device also has
|
||||
// a 256 MiB memory BAR, GMADR — "first memory resource" would be wrong).
|
||||
if (resource.kind == @intFromEnum(device.ResourceKind.memory) and
|
||||
resource.len == 16 * 1024 * 1024) register_index = index;
|
||||
}
|
||||
if (register_index == 0) {
|
||||
std.log.err("register BAR not found", .{});
|
||||
return false;
|
||||
}
|
||||
|
||||
register_base = device.mmioMap(controller_id, register_index) orelse {
|
||||
std.log.err("mmio_map failed", .{});
|
||||
return false;
|
||||
};
|
||||
std.log.info("registers mapped at 0x{x}", .{register_base});
|
||||
return true;
|
||||
}
|
||||
|
||||
fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize {
|
||||
_ = message;
|
||||
_ = reply;
|
||||
_ = sender;
|
||||
_ = capability;
|
||||
return 0; // no protocol yet; the zero-length ping is answered by the harness
|
||||
}
|
||||
|
||||
pub fn main(init: process.Init) void {
|
||||
const argument = init.arguments.get(1) orelse {
|
||||
std.log.err("missing device id (argv[1])", .{});
|
||||
return;
|
||||
};
|
||||
controller_id = std.fmt.parseInt(u64, argument, 10) catch {
|
||||
std.log.err("malformed device id '{s}'", .{argument});
|
||||
return;
|
||||
};
|
||||
service.run(message_maximum, .{
|
||||
.init = initialise,
|
||||
.on_message = onMessage,
|
||||
// .on_notification only once an IRQ or timer is bound
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
`claim` is the capability gate: MMIO mapping, DMA grants, and IRQ binding all
|
||||
require it, and it pins the IOMMU domain to this process
|
||||
([drivers.md — claim before touch](drivers.md#the-capability-claim-before-touch)).
|
||||
|
||||
## 2. Create the build package and register it in the root build
|
||||
|
||||
The driver directory is its own build package
|
||||
([build-packages-plan.md](../build-packages-plan.md)): a ~15-line `build.zig`
|
||||
plus a `build.zig.zon` beside the source. Copy both from an existing driver —
|
||||
`system/drivers/pci-bus/` is the template — and adjust the name, root source
|
||||
file, and the import list. The list names EXACTLY the modules the driver's
|
||||
source `@import`s (the moral equivalent of its include list; an undeclared
|
||||
import is a compile error):
|
||||
|
||||
```zig
|
||||
pub fn build(b: *std.Build) void {
|
||||
const exe = build_support.userBinary(b, .{
|
||||
.name = "intel-uhd-graphics-750",
|
||||
.root_source_file = b.path("intel-uhd-graphics-750.zig"),
|
||||
.imports = &.{ "driver", "ipc", "memory", "process", "service" },
|
||||
});
|
||||
b.installArtifact(exe);
|
||||
}
|
||||
```
|
||||
|
||||
The zon declares `build-support`, `kernel` (implicit in every binary: the root
|
||||
shim lives there), and the homes of the listed imports — for the minimal
|
||||
driver above that is kernel alone plus `device` (for `driver`); add
|
||||
`protocol`, `client`, ... only when an import comes from them (again, copy
|
||||
pci-bus's zon and adjust). For the `.fingerprint` field, leave the copied
|
||||
value in place and `zig build` will reject it and suggest the fresh one to
|
||||
paste.
|
||||
|
||||
Then three one-liners in the root build register the package: the dependency
|
||||
and a row in the boot-tree array in `build.zig` (search for
|
||||
`virtio_gpu_package` to land in the right places),
|
||||
|
||||
```zig
|
||||
const intel_uhd_graphics_750_exe = b.dependency("intel-uhd-graphics-750", .{}).artifact("intel-uhd-graphics-750");
|
||||
```
|
||||
|
||||
```zig
|
||||
.{ .path = "system/drivers/intel-uhd-graphics-750", .binary = intel_uhd_graphics_750_exe.getEmittedBin() },
|
||||
```
|
||||
|
||||
and the path entry in the root `build.zig.zon`:
|
||||
|
||||
```zig
|
||||
.@"intel-uhd-graphics-750" = .{ .path = "system/drivers/intel-uhd-graphics-750" },
|
||||
```
|
||||
|
||||
Without the boot-tree row the binary never reaches the image and the
|
||||
device-manager has nothing to spawn. (The package also builds standalone:
|
||||
`cd system/drivers/intel-uhd-graphics-750 && zig build`.)
|
||||
|
||||
## 3. Add the match rule to `etc/devices.csv`
|
||||
|
||||
One row: bus, class triplet, vendor/device, driver path. **Copy the class
|
||||
triplet from the pci-bus boot log line, not from another row** — for the iGPU
|
||||
above the correct rule is
|
||||
|
||||
```
|
||||
pci, 03, 00, 00, 8086, 4C8A, *, *, /system/drivers/intel-uhd-graphics-750
|
||||
```
|
||||
|
||||
Field-by-field rules and the most-specific-wins policy: [devices.csv](devices-csv.md).
|
||||
The registry is authoritative: an unmatched device is logged unbound, never
|
||||
guessed — so a wrong nibble here means the driver simply never starts.
|
||||
|
||||
## 4. First contact: read, predict, verify
|
||||
|
||||
Before writing any register, read one whose value you can predict from state
|
||||
the firmware already programmed (for a display controller: the pipe source
|
||||
size of the live mode). Registers are volatile loads at `register_base +
|
||||
offset`, where `offset` is what the device's manual lists:
|
||||
|
||||
```zig
|
||||
fn read32(offset: usize) u32 {
|
||||
return @as(*volatile u32, @ptrFromInt(register_base + offset)).*;
|
||||
}
|
||||
```
|
||||
|
||||
A matching read proves the whole chain — CSV match, spawn, claim, BAR choice,
|
||||
mapping — with zero risk to the hardware.
|
||||
|
||||
## 5. Verify the plumbing
|
||||
|
||||
- `zig build test` still passes.
|
||||
- On the image: `/var/log/<boot-stamp>/system/services/device-manager.log`
|
||||
shows `spawned <name> for device <N>`, and
|
||||
`/var/log/<boot-stamp>/system/drivers/<name>.log` holds the resource list and
|
||||
your first read.
|
||||
- If the driver did not spawn, diagnose in this order: binary on the image
|
||||
(step 2) → CSV row matches the log line exactly (step 3) → path identical in
|
||||
both (step 1).
|
||||
|
||||
## Later, when the device needs them
|
||||
|
||||
- **Interrupts**: MSI/MSI-X via the `pci` module, delivered as notifications to
|
||||
`on_notification` — see [device interrupts](device-interrupts.md) and the
|
||||
xHCI driver's `setupMsi` (QEMU trap documented there: enable MSI-X before
|
||||
unmasking the device's own interrupt-enable bit).
|
||||
- **DMA**: grant-backed buffers, bounded by the IOMMU domain established at
|
||||
claim time ([driver model](driver-model.md)).
|
||||
- **Children**: a bus driver publishes what it finds via `device_register`
|
||||
([drivers.md — publishing children](drivers.md#publishing-children-device_register)).
|
||||
- **A protocol**: replace `message_maximum` with the protocol's own maximum and
|
||||
dispatch on the operation word in `onMessage` — every service under
|
||||
`system/services/` is an example.
|
||||
@@ -0,0 +1,124 @@
|
||||
# Dynamic libraries on danos
|
||||
|
||||
A design note and milestone plan for shared objects: building them, loading them
|
||||
with `dlopen`, and — the part that needs kernel work — actually *sharing* them
|
||||
between processes. Directional, post-P5 of
|
||||
[python-on-danos-milestones.md](python-on-danos-milestones.md); nothing on the
|
||||
CPython bring-up path depends on it.
|
||||
|
||||
## Reconciling the earlier "rejected"
|
||||
|
||||
Dynamic libraries were evaluated once before and rejected — but as an answer to a
|
||||
*different question*: whether they could claw back ReleaseSafe's measured ~2×
|
||||
code size. They cannot (the safety checks inline at every call site; no library
|
||||
scheme dedups them), and that verdict stands for that question. The reasons to
|
||||
build them now are the ones that investigation never weighed:
|
||||
|
||||
- **`ctypes` and runtime FFI** — Python calling into a danos library without
|
||||
rebuilding the interpreter. This is the piece that makes Python prototyping
|
||||
self-serve: drop a `.so` on the image, `ctypes.CDLL` it, iterate.
|
||||
- **Loadable CPython extension modules** — today every C extension means
|
||||
relinking the interpreter (`Modules/Setup`); with `dlopen`, an extension is a
|
||||
file.
|
||||
- **One interpreter image, many Python services** — a statically-linked CPython
|
||||
is tens of megabytes *per process*. A shared `libpython` mapped read-only once
|
||||
(milestone D3 below) makes Python services cheap enough to be the default way
|
||||
to prototype one.
|
||||
- **Plugin-shaped applications** — the UI toolkit and the terminal will want
|
||||
them eventually.
|
||||
|
||||
The scoping that dissolves the apparent contradiction is the **size doctrine**:
|
||||
leanness is an *operating-system* property — the kernel and system services stay
|
||||
small and statically linked, and none of them ever link the loader — while
|
||||
*applications* have their own budget and may be big. Dynamic libraries are an
|
||||
**application-layer facility**, full stop.
|
||||
|
||||
What also does **not** change: the public ABI stays the vDSO + the IPC
|
||||
protocols. Shared objects are artifacts *within* one system image, versioned by
|
||||
the build — not a new stable ABI surface for the OS.
|
||||
|
||||
## Design
|
||||
|
||||
- **Format and codegen are free.** ELF shared objects with position-independent
|
||||
code; `zig cc -fPIC -shared` against the [libdanos-c](c-library-compatibility.md)
|
||||
sysroot already emits them. The work is entirely on the loading side.
|
||||
- **The loader lives in userspace, inside the libc.** `dlopen` reads the `.so`
|
||||
through the VFS, maps its segments, applies relocations, resolves symbols
|
||||
against the process and the `DT_NEEDED` dependency graph, runs constructors,
|
||||
returns a handle. No kernel loader changes in v1 — segments land in anonymous
|
||||
`mmap` as private copies.
|
||||
- **Bind-now, always.** All relocations resolved at `dlopen` time
|
||||
(`RTLD_NOW` semantics only). Lazy PLT binding buys startup latency danos does
|
||||
not care about, at the price of a writable GOT dance and a much subtler
|
||||
loader. Not worth it; keep it out permanently.
|
||||
- **W^X from day one.** Map, relocate, then flip text pages read-execute —
|
||||
which requires memory-protection change (`mprotect`-shaped) in the danos
|
||||
`mmap` surface if it is not already there. No page is ever writable and
|
||||
executable at once.
|
||||
- **TLS in shared objects is deferred.** Thread-local storage models
|
||||
(initial-exec vs. general-dynamic) are the deep end of every dynamic linker.
|
||||
v1 refuses a `.so` with a TLS segment; revisit alongside the post-P5 pthread
|
||||
subset, which is when it could matter.
|
||||
- **Executables stay static until D4.** v1 is "a static binary that can
|
||||
`dlopen`" — no `PT_INTERP`, no program interpreter, no dynamically-linked
|
||||
`main` binaries. That keeps process startup untouched.
|
||||
|
||||
## Milestones
|
||||
|
||||
1. **D1 — dlopen in-process.** The `.so` build target; the loader in libdanos-c:
|
||||
map, relocate (`RELATIVE`/`GLOB_DAT`/`JUMP_SLOT`), resolve, constructors;
|
||||
`dlopen`/`dlsym`/`dlerror`/`dlclose`; private anonymous mappings; no TLS.
|
||||
*Test:* QEMU `dlopen-hello` — load a `.so`, call a symbol, unload, reload.
|
||||
2. **D2 — the FFI payoff.** `DT_NEEDED` dependency graphs; a **libffi port**
|
||||
(x86-64 SysV assembly is upstream; the port is its closure-allocation paths,
|
||||
which must respect W^X); CPython's `ctypes` enabled; extension modules
|
||||
loadable from file. *Test:* QEMU — a Python script `ctypes.CDLL`s a danos
|
||||
`.so` and round-trips a call; a `.so` extension module imports.
|
||||
3. **D3 — actual sharing (the kernel milestone).** Shared read-only file-backed
|
||||
mappings — a page-cache-shaped facility so N processes mapping `libpython`
|
||||
hold one physical copy. This is the memory-win milestone and the only one
|
||||
touching the kernel; design it with the existing shm machinery in view
|
||||
(the shared-fate walks already locked the relevant paths). *Test:* N Python
|
||||
services up; measure physical pages against N× the static baseline.
|
||||
4. **D4 — dynamically-linked executables** (optional, evaluate after D3):
|
||||
`PT_INTERP`, a danos program interpreter, and the spawn path teaching the
|
||||
loader about it. Only worth it if the image-size or update story demands it.
|
||||
|
||||
## Risks and gotchas
|
||||
|
||||
- **Scope creep is the failure mode.** Every dynamic linker grows toward glibc.
|
||||
The fences: bind-now only, no lazy binding ever, no TLS until pthreads demand
|
||||
it, no dlopen-from-memory, no versioned symbols. Each fence removed is a
|
||||
design discussion, not a patch.
|
||||
- **Code loading is a security event.** `dlopen` turns file bytes into executable
|
||||
code, so W^X discipline is table stakes and *what may be dlopened* is a
|
||||
capability question — the natural danos answer is that loadability follows VFS
|
||||
readability of the `.so`, and services' images are supervised like any other
|
||||
artifact. Revisit explicitly at D3 when mappings become shared.
|
||||
- **`dlclose` is where loaders go to die.** Constructors/destructors,
|
||||
dangling function pointers, re-open identity. Keep v1 semantics honest and
|
||||
simple: `dlclose` runs destructors and unmaps; holding pointers past it is
|
||||
undefined; no reference-counted deferral cleverness.
|
||||
- **The ReleaseSafe fact still applies to `.so`s** — a ReleaseSafe shared object
|
||||
carries its inlined checks like any static code; D3's sharing saves *copies*,
|
||||
not check overhead. Size expectations should be set accordingly.
|
||||
|
||||
## Decisions needing sign-off
|
||||
|
||||
- Dynamic libraries join the roadmap at all (this note exists because the
|
||||
earlier size-motivated rejection was re-opened for ABI/sharing reasons).
|
||||
- **Bind-now only; no lazy binding, permanently.**
|
||||
- **Loader in userspace libc; kernel involvement only at D3** (shared read-only
|
||||
mappings).
|
||||
- **Static executables until D4**, and D4 only on demonstrated need.
|
||||
|
||||
## Related
|
||||
|
||||
- [c-library-compatibility.md](c-library-compatibility.md) — the sysroot the
|
||||
loader ships in; its absence table gains `dlfcn.h` at D1.
|
||||
- [python-on-danos.md](python-on-danos.md) — the `ctypes` story this unlocks.
|
||||
- [python-on-danos-milestones.md](python-on-danos-milestones.md) — sequencing;
|
||||
this work is post-P5.
|
||||
- [os-development/memory-map.md](os-development/memory-map.md) /
|
||||
[os-development/paging.md](os-development/paging.md) — where W^X and shared
|
||||
mappings land.
|
||||
@@ -1,128 +0,0 @@
|
||||
# DanOS Filesystem Hierarchy Standard (DFHS)
|
||||
|
||||
Most modern Unix and Unix-like operating systems follow the FHS. DanOS has its own FHS structure which extends the unix FHS. Root path resolution is provided by the kernel-resident VFS root (`fs_resolve`, `system/kernel/vfs.zig`); mounted filesystem servers serve the subtrees they own.
|
||||
|
||||
## Directory structure
|
||||
|
||||
| Path | Description |
|
||||
|------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| / | Primary hierarchy root and root directory of the entire file system hierarchy. |
|
||||
| /bin | Essential command binaries that need to be available in single-user mode, including to bring up the system or repair it, for all users (e.g., cat, ls, cp). |
|
||||
| /boot | Boot loader files (e.g., EFI, initial-ramdisk.img ). |
|
||||
| /dev | POSIX Device files (e.g., /dev/null, /dev/disk0, /dev/tty, /dev/random). |
|
||||
| /etc | Host-specific system-wide configuration files. |
|
||||
| /home | Users' home directories, containing saved files, personal settings, etc. |
|
||||
| /lib | Libraries essential for the binaries in /bin and /sbin. eg realtime, system, ipc etc. |
|
||||
| /sbin | Essential system binaries (e.g init) |
|
||||
| /srv | Site-specific data served by this system, such as data and scripts for web servers, data offered by FTP servers, and repositories for version control systems |
|
||||
| /system | DanOS operating system files (similar idea to C:\Windows). A true representation of danos — its layout mirrors the source tree, so `/system` is what danos *is*. |
|
||||
| /system/devices | danos virtual device tree e.g. similar to /sys on linux but with danos device tree conventions (the structures in the devices module) |
|
||||
| /system/drivers | driver binaries, one sub-project each (e.g. /system/drivers/pci-bus, /system/drivers/ps2-bus) |
|
||||
| /system/services | system-service binaries — init, the FAT server, and other user-mode servers (e.g. /system/services/init, /system/services/fat) |
|
||||
| /system/kernel | the kernel image |
|
||||
| /test | Test fixtures for the QEMU integration suite. Read-only and initrd-backed like /system, and its layout likewise mirrors the source tree (the repo's test/ directory). Present on development and test images; a volume without it still boots. |
|
||||
| /test/system/services | test-fixture binaries (e.g. /test/system/services/vfs-test, /test/system/services/thread-test) — the same path in the repo source tree and on the boot volume |
|
||||
| /tmp | Directory for temporary files (see also /var/tmp). Often not preserved between system reboots and may be severely size-restricted. |
|
||||
| /usr | Secondary hierarchy for read-only user data; contains the majority of (multi-)user utilities and applications. Should be shareable and read-only. |
|
||||
| /var | Variable files: files whose content is expected to continually change during normal operation of the system, such as logs, spool files, and temporary e-mail files. |
|
||||
|
||||
## File types
|
||||
|
||||
POSIX specifies the long format of the ls command to represent the Unix file type as the first letter for an entry.
|
||||
|
||||
| type | symbol | Description |
|
||||
|-------------------|--------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| regular | - | An ordinary file holding an uninterpreted byte stream. Reads and writes are positional, and the file grows on demand (e.g., a binary in /bin, a config file in /etc). |
|
||||
| directory | d | A container mapping names to other files. It may only be modified through directory operations, never written to directly. |
|
||||
| symbolic link | l | A file whose contents are a path that is resolved in its place. The target need not exist, and may cross mount points. |
|
||||
| FIFO special | p | A named pipe: an in-order byte stream between processes, where writers block until a reader opens the other end. |
|
||||
| block special | b | A device node addressed in fixed-size blocks with the kernel free to buffer and reorder access (e.g., /dev/disk0). |
|
||||
| character special | c | A device node addressed as an unbuffered byte stream, delivered to the driver in order (e.g., /dev/tty, /dev/null). |
|
||||
| socket | s | A named endpoint for bidirectional message-passing between processes, bound to a path rather than an address. |
|
||||
|
||||
## /dev
|
||||
|
||||
`/dev` holds the names through which processes reach devices. It is deliberately not
|
||||
the device tree: the tree — every node discovered by ACPI or PCI enumeration, with its
|
||||
resources and its parent — lives under [/system/devices](#directory-structure) and is
|
||||
addressed by device id. `/dev` is the much smaller set of devices that have a driver
|
||||
willing to serve them, addressed by name.
|
||||
|
||||
A device node is not a file the VFS can read. The bytes live in a driver process
|
||||
([drivers.md](../device-driver-development-guide/drivers.md)), so opening a `/dev` name has to resolve to that driver's
|
||||
IPC endpoint, and subsequent reads and writes are calls against it. Resolve-to-endpoint
|
||||
is exactly what the kernel's `fs_resolve` already does for any mounted backend, and
|
||||
`FileStatus.kind` is the field that marks a device node; **what is not implemented today
|
||||
is `/dev` itself** — no service mounts it. (The flat eight-node ramfs this section once
|
||||
described is retired: the kernel-resident VFS root in `system/kernel/vfs.zig` serves a
|
||||
read-only initrd mount per top-level tree — `/system`, and `/test` on images that carry
|
||||
the fixtures — with real directories and node kinds, and filesystem
|
||||
backends such as the FAT server mount the rest.) The three sections below describe the
|
||||
intended shape, and are honest about which parts the kernel can already support.
|
||||
|
||||
### Character devices
|
||||
|
||||
A character device is a byte stream with no addressable position: bytes are delivered
|
||||
to the driver in the order written, and a read consumes what is there. Terminals,
|
||||
serial lines, keyboards and mice are all of this shape. These are the natural first
|
||||
device nodes in danos, because a character driver needs nothing the kernel doesn't
|
||||
already provide — it claims its device, maps its registers with `mmio_map`, and blocks
|
||||
on `replyWait` for either an interrupt or a client request. `system/drivers/ps2-bus/ps2-bus.zig`
|
||||
is already that program, minus the file-node client half.
|
||||
|
||||
The obstacle was never the file type; it is which hardware a ring-3 driver can reach.
|
||||
Direct `in`/`out` from user space is still a #GP (no TSS I/O bitmap, IOPL never raised),
|
||||
but a driver no longer needs it: **`io_read`/`io_write`** grant port access the same way
|
||||
`mmio_map` grants memory — gated by `device_claim` and the device's discovered `io_port`
|
||||
resource. So the 16550 UART at `0x3F8` and the PS/2 controller at `0x60`/`0x64` (and thus
|
||||
`/dev/ttyS0` and a keyboard node) are now writable as ordinary ring-3 drivers; the
|
||||
low-rate legacy hardware that needs port I/O is fine with a syscall per access. A
|
||||
memory-mapped device such as the framebuffer, needing no port I/O at all, remains the
|
||||
easiest first entry.
|
||||
|
||||
### Block devices
|
||||
|
||||
A block device is addressed in fixed-size blocks and, unlike a character device, the
|
||||
layer above is free to buffer, reorder, coalesce and retry requests against it. Disks
|
||||
and other persistent storage are the whole population of this class.
|
||||
|
||||
A block driver is now **writable, but not yet memory-safe.** Every storage controller
|
||||
worth naming is a bus master: it is programmed by handing it the physical address of a
|
||||
descriptor ring and left to read and write memory on its own. That ring is exactly what
|
||||
**`dma_alloc`** now provides — physically contiguous, pinned, uncacheable, with its
|
||||
physical address disclosed — and **`/lib/device/mmio`**'s barriers order the descriptor writes
|
||||
against the doorbell, and **`msi_bind`** delivers completions. So an AHCI or NVMe driver
|
||||
can be written today (the M14/M15 work in [driver-model.md](../device-driver-development-guide/driver-model.md); the earlier
|
||||
"cannot host a block driver at all" is no longer true).
|
||||
|
||||
What is *not* yet true is that it is safe. A device programmed with an arbitrary physical
|
||||
address writes to arbitrary physical memory, and page tables do not sit between a device
|
||||
and RAM — an IOMMU does. The IOMMU is now *detected* (M16), but no translation domains
|
||||
are programmed, so granting a DMA-capable device to a driver process is still equivalent
|
||||
to granting ring 0. Until per-device domains confine a driver's DMA to the buffers it
|
||||
`dma_alloc`'d, a block driver works but forfeits the isolation that motivates user-space
|
||||
drivers — enforcement is the next step, and lands with that first driver. A ramdisk over
|
||||
the initial ramdisk remains the one block-shaped thing that needs no driver process at all.
|
||||
|
||||
### Pseudo-devices
|
||||
|
||||
A pseudo-device has the interface of a device and no hardware behind it: `/dev/null`
|
||||
discarding writes and reading as end-of-file, `/dev/zero` reading as an endless run of
|
||||
zero bytes, `/dev/full` failing writes with `ENOSPC`, `/dev/random` and `/dev/urandom`
|
||||
yielding unpredictable bytes.
|
||||
|
||||
These are the only `/dev` entries danos can implement immediately, and they are the
|
||||
sensible place to start, because they are exactly the entries that need no driver
|
||||
process, no `device_claim`, no MMIO grant and no interrupt. A future pseudo-device
|
||||
service would answer them out of its own address space — `null` and `zero` are a few
|
||||
lines each in its `read` and `write` handlers — and mount itself at `/dev` the way the
|
||||
FAT server mounts `/mnt/usb`. The two pieces of structure every later device node
|
||||
depends on (and that the flat ramfs of the time lacked) exist now: directories, so that
|
||||
`/dev/null` is a path rather than a name; and a populated `FileStatus.kind`, so that a
|
||||
caller can tell a character device from a regular file.
|
||||
|
||||
`/dev/random` is the one that is not free. It needs an entropy source, and the honest
|
||||
options on this kernel are `RDRAND`/`RDSEED` where CPUID advertises them, and the HPET
|
||||
counter's low bits as a poor fallback. Neither is a seeded CSPRNG, and a `/dev/random`
|
||||
that is merely unpredictable-looking is worse than none — nothing should be keyed from
|
||||
it until it is a real one.
|
||||
@@ -0,0 +1,90 @@
|
||||
# The danos file-system hierarchy
|
||||
|
||||
danos is not unix, and its tree does not follow the unix FHS. Paths are the
|
||||
system's universal namespace — files, the device inventory, and protocol
|
||||
endpoints all live in one tree — but what a path *yields* differs by subtree:
|
||||
bytes, facts, or a connection. Root path resolution is provided by the
|
||||
kernel-resident VFS root (`fs_resolve`, `system/kernel/vfs.zig`); mounted
|
||||
backends serve the subtrees they own.
|
||||
|
||||
Naming follows the codebase conventions: kebab-case, full words, no
|
||||
abbreviations. Every top-level name says what its subtree *is*.
|
||||
|
||||
## The tree
|
||||
|
||||
| Path | What it is |
|
||||
|-------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `/` | The root of the one namespace. |
|
||||
| `/applications` | Installed applications, one directory per application — the directory is the identity, the same rule as source sub-projects. *(Planned; empty today.)* |
|
||||
| `/protocol` | The contract namespace: one protocol node per contract, grouped into directories by domain (`/protocol/display`, `/protocol/networking/ip`). Synthetic — no bytes; opening a name yields a connection to the current provider. See [protocol-namespace.md](../os-development/protocol-namespace.md). |
|
||||
| `/system` | The operating system — what danos *is*. Its program subtrees mirror the source tree exactly. |
|
||||
| `/system/kernel` | The kernel image. |
|
||||
| `/system/drivers` | Driver binaries, one per sub-project (`/system/drivers/pci-bus`, `/system/drivers/ps2-bus`). |
|
||||
| `/system/services` | System-service binaries (`/system/services/init`, `/system/services/fat`). |
|
||||
| `/system/devices` | The device inventory: every node hardware discovery found, with its resources and parent — the structures of the devices module, as a browsable virtual tree. Informational only; you *read about* hardware here and *talk to* it through `/protocol`. *(Planned; served by device-manager.)* |
|
||||
| `/system/configuration` | Machine configuration (`init.csv`, `devices.csv`). Writable, served from the boot volume. |
|
||||
| `/system/logs` | Per-boot logs: `/system/logs/<boot-stamp>/<binary-path>.log`. Writable, served from the boot volume. |
|
||||
| `/test` | Test fixtures for the QEMU integration suite. Read-only and initrd-backed like the program subtrees of `/system`, mirroring the repo's `test/` directory. Present on development and test images; a volume without it still boots. |
|
||||
| `/volumes` | Attached storage volumes, one directory per volume (`/volumes/usb`). A volume's own tree appears beneath its name. |
|
||||
|
||||
Read-only and writable halves of `/system`: the program subtrees (`kernel`,
|
||||
`drivers`, `services`) and the future `devices` are immutable at runtime —
|
||||
initrd-backed or synthetic — while `configuration` and `logs` are mutable
|
||||
machine state served by the boot-volume FAT backend. The kernel's
|
||||
reserved-prefix rule (no mount may shadow `/system`, `/test`, or `/protocol`)
|
||||
needs a carve-out for exactly these two writable subtrees; that lands with the
|
||||
path migration below.
|
||||
|
||||
Deliberately not defined yet: a temporary-files location and per-application
|
||||
mutable storage. Both belong to the `/applications` design and will be
|
||||
specified there, not guessed at here.
|
||||
|
||||
## Node kinds
|
||||
|
||||
What a path resolves to. These fill `FileStatus.kind` and
|
||||
`DirectoryEntry.kind` in the [vfs protocol](vfs-protocol.md)
|
||||
(`library/protocol/vfs/vfs-protocol.zig`); enum values are append-only.
|
||||
|
||||
| Kind | Meaning |
|
||||
|--------------------|-------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `regular` | An ordinary file: an uninterpreted byte stream, positional reads and writes, grows on demand. |
|
||||
| `directory` | A container mapping names to nodes; modified only through directory operations. |
|
||||
| `character_device` | A node whose read/write have **stream semantics**: unseekable, reads block until bytes exist, size is meaningless. The console and every tty-shaped node ([character-devices-and-tty.md](../character-devices-and-tty.md)); what a POSIX layer's `isatty` detects. |
|
||||
| `block_device` | A node addressed in fixed-size sectors — a raw volume. Reserved: recognized, nothing serves one yet. |
|
||||
| `symbolic_link` | Reserved: a recognized value, not implemented by any backend. |
|
||||
| `fifo` | Reserved for the future pipe object (wanted by the POSIX compatibility layer); not implemented. |
|
||||
| `protocol` | A node naming a contract: `open` yields an IPC connection (an endpoint capability) instead of a file id — the kind of every leaf under `/protocol`. *(Being added; see protocol-namespace.md.)* |
|
||||
|
||||
Note the layering: `protocol` says what *opening the name* does (you get a
|
||||
conversation); `character_device`/`block_device` say what *read and write
|
||||
mean* on a node a provider serves you. The two compose — `/protocol/console`
|
||||
is a protocol node in the registry, and the node opened over that connection
|
||||
reports `character_device`, which is what gives it stream semantics. Only
|
||||
`socket` is retired (its value stays reserved for wire stability): a named
|
||||
rendezvous point is exactly what a protocol node is.
|
||||
|
||||
## What is deliberately absent
|
||||
|
||||
There is no `/bin`, `/boot`, `/dev`, `/etc`, `/home`, `/lib`, `/mnt`, `/sbin`,
|
||||
`/srv`, `/tmp`, `/usr`, or `/var`. These encode unix history — the
|
||||
binary/library split of small disks, configuration-as-scattered-text, devices
|
||||
as magic files — that danos does not carry. A POSIX compatibility layer (the
|
||||
Python track's mini-libc) may *present* whichever of these its programs
|
||||
expect, mapped onto the real tree; the tree itself stays danos-native.
|
||||
|
||||
## Migration
|
||||
|
||||
The tree above is the specification; some code still writes the unix paths it
|
||||
replaced. The flag-day converting them:
|
||||
|
||||
| Today (in code) | Becomes | Where |
|
||||
|------------------------------------------|-------------------------------------------|-----------------------------------------------------------------|
|
||||
| `/etc/init.csv` | `/system/configuration/init.csv` | `system/services/init/init.zig` |
|
||||
| `/etc/devices.csv` | `/system/configuration/devices.csv` | `system/services/device-manager/device-manager.zig` |
|
||||
| `/var/log/...` | `/system/logs/...` | `system/services/logger/logger.zig`, the FAT server's `/var` mount |
|
||||
| `/mnt/usb` | `/volumes/usb` | `system/services/fat/fat.zig`, the fat/vfs tests |
|
||||
| `ServiceId` lookup | resolve + open under `/protocol` | every service and client; [protocol-namespace.md](../os-development/protocol-namespace.md) |
|
||||
|
||||
The boot-image builder and the on-volume directory layout move in the same
|
||||
change, so a freshly written image and the paths the services expect never
|
||||
disagree.
|
||||
@@ -9,7 +9,7 @@
|
||||
> backend, unchanged. The Zig source of truth is `library/protocol/vfs/vfs-protocol.zig`
|
||||
> (the `vfs-protocol` module), whose unit test pins a sample of the sizes
|
||||
> and values below. This page is the **language-neutral wire specification**
|
||||
> of that contract — what a Rust or C client implements ([vdso.md](../os-development-guide/vdso.md)
|
||||
> of that contract — what a Rust or C client implements ([vdso.md](../os-development/vdso.md)
|
||||
> explains why the IPC protocols, not the syscall numbers, are danos's
|
||||
> public ABI).
|
||||
|
||||
@@ -149,8 +149,8 @@ Bitwise OR in `Request.flags`, meaningful for `open` only:
|
||||
|
||||
## NodeKind
|
||||
|
||||
Aligned to the FSH file-type table
|
||||
(docs/danos-file-system-hierarchy-FSH.md):
|
||||
Aligned to the node-kind table in the file-system hierarchy
|
||||
(docs/file-system-development/file-system-hierarchy.md):
|
||||
|
||||
| value | kind |
|
||||
|------:|------|
|
||||
@@ -161,9 +161,25 @@ Aligned to the FSH file-type table
|
||||
| 4 | symbolic link |
|
||||
| 5 | fifo |
|
||||
| 6 | socket |
|
||||
| 7 | protocol |
|
||||
|
||||
Clients should map unknown values to *regular* rather than reject — the
|
||||
table can grow.
|
||||
table can grow. Kind 6 (`socket`) keeps its wire value but is retired from
|
||||
the design — a named rendezvous point is exactly what a `protocol` node is,
|
||||
landed as value 7 with the protocol namespace
|
||||
(docs/os-development/protocol-namespace.md). `character_device` (stream
|
||||
semantics — the tty/console shape) and `block_device` (raw sector-addressed
|
||||
volumes, reserved) remain part of the design.
|
||||
|
||||
## An open reply may carry a capability
|
||||
|
||||
`open` rides `ipc_call`, whose reply direction can hand back an endpoint
|
||||
capability alongside the `Reply` header. A file backend never uses it — FAT
|
||||
answers with a node id and nothing else — but a **synthetic** backend does:
|
||||
opening a `protocol` node returns the provider's endpoint, and possession of
|
||||
that endpoint *is* the channel. The convention is per-backend, not
|
||||
per-operation, so a client that opens an ordinary file simply receives no
|
||||
capability, exactly as before.
|
||||
|
||||
## Lifetimes and trust
|
||||
|
||||
@@ -183,8 +199,8 @@ What a non-Zig implementation may rely on, and what it must not:
|
||||
- Operation values, flag bits, `NodeKind` values, and struct layouts are
|
||||
**append-only and frozen once shipped**. The unit test in
|
||||
`library/protocol/vfs/vfs-protocol.zig` pins a sample of them (the `DirectoryEntry`
|
||||
size, `NodeKind` 0–1, `Operation` values 0, 4 and 5); this page is the
|
||||
full record of the frozen values.
|
||||
size, `NodeKind` 0–1 and 6–7, `Operation` values 0, 4 and 5); this page is
|
||||
the full record of the frozen values.
|
||||
- The 256-byte message ceiling is a property of the current IPC transport,
|
||||
not a promise; clients should read `maximum_payload`-shaped limits from the
|
||||
reply lengths they actually get (loop-until-done), not hard-code 224.
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# OS Developer Guide
|
||||
|
||||
This document is for those who need to understand the architectural decisions behind the OS.
|
||||
|
||||
## Written in Zig?
|
||||
|
||||
The os was initially written in zig because it has excellent support for EFI. With zig, we could forgo using a third party bootloader, reducing the time to boot up the kernel. Following the "Zen of Zig", helped to produce the most readable codebase for an operating system ever created. So those, new to OS development could quickly get up to speed.
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# OS Development
|
||||
|
||||
This document explains the architectural decisions behind the operating system.
|
||||
|
||||
## Written in Zig?
|
||||
|
||||
The OS is written in Zig because it has excellent EFI support, so the OS boots quickly without a third-party bootloader.
|
||||
|
||||
Zig comes batteries included for systems work — cross-compilation, a build system, and a test runner are all part of the toolchain. Building with `-Doptimize=ReleaseSafe` keeps runtime safety checks on in the shipped kernel, which removes entire classes of bugs. The built-in test suite, combined with a QEMU integration harness, means every feature is proven to work, before it is shipped.
|
||||
|
||||
The codebase of the OS prioritizes readability. The aim is a codebase where someone new to OS development can find their way around without a guide.
|
||||
|
||||
## A microkernel?
|
||||
|
||||
The kernel is a thin layer: it schedules processes and manages memory. Everything else — drivers, file systems, the display — runs in user space as separate, isolated processes.
|
||||
|
||||
The payoff is resilience. When a driver crashes, it doesn't take the OS down with it; it gets restarted. That makes this an ideal environment for *developing* an operating system, because a buggy driver is an ordinary bug: patch it, restart the service, and keep going.
|
||||
|
||||
There is a security benefit too. Processes are isolated and talk over Inter-Process Communication (IPC) channels, so compromising one service doesn't hand an attacker the whole machine. Vulnerabilities tend to stay contained in the process they started in.
|
||||
|
||||
Other operating systems choose to pack all of these duties into one binary as a Monolithic kernel, mostly for performance: a function call inside the kernel is faster than passing a message between isolated processes. That cost is real — an IPC round-trip is a few microseconds where a function call is nanoseconds — but it is also workload-shaped. Compute-bound programs don't notice it at all. For bulk data like file contents and pixels, the design moves data through shared memory and DMA so it is copied once, the same as a monolithic kernel; only small control messages cross the IPC boundary. What remains is the per-message cost on chatty paths, and the scheduler and memory management are designed to keep that small.
|
||||
|
||||
## Private ABI
|
||||
|
||||
The syscall layer is private. The numbers and structures in `abi.zig` are an internal detail shared between the kernel and the system's own libraries, and they are free to change between builds.
|
||||
|
||||
The public boundary sits one level up: the [vDSO](vdso.md) that programs call into, and the documented IPC protocols such as the [VFS protocol](../file-system-development/vfs-protocol.md). Programs that stick to those interfaces keep working while the kernel rearranges itself underneath. This is the opposite of the Linux approach, where raw syscall numbers are frozen forever; here, stability is promised at the library and protocol level, and nowhere below it.
|
||||
|
||||
## Steal the best bits and dump the legacy
|
||||
|
||||
The OS is Unix-like, but selectively. It borrows the ideas that have aged well — everything is a file, small services composed over clean interfaces — and skips the parts of POSIX that have caused decades of headaches.
|
||||
|
||||
Some concrete choices:
|
||||
|
||||
- **`spawn`, not `fork`.** Creating a process starts a fresh program and returns the child's id. There is no clone-the-whole-address-space-then-immediately-throw-it-away dance, and none of the subtle state-inheritance bugs that come with it.
|
||||
- **Time is a syscall.** The kernel owns the clock and timers directly. There is no time daemon to keep alive and no ambiguity about where the truth lives.
|
||||
- **Lifecycle events arrive as messages.** A supervisor learns that a child exited through an IPC message on an endpoint it already owns — delivered like any other message, not as an interrupt that can fire between any two instructions.
|
||||
|
||||
The test for keeping an idea is simple: does it still pull its weight, or is it only there because it was there in 1979?
|
||||
@@ -75,7 +75,7 @@ There are really two independent questions, and it's worth not conflating them:
|
||||
- **`system/kernel/architecture/x86_64/paging.zig`** — the kernel's page tables and address-space
|
||||
management (see [paging.md](paging.md)).
|
||||
- **`system/kernel/architecture/x86_64/apic.zig`** / **`ioapic.zig`** — the Local APIC, its timer,
|
||||
and the I/O APIC for device interrupts (see [device-interrupts.md](../device-driver-development-guide/device-interrupts.md)).
|
||||
and the I/O APIC for device interrupts (see [device-interrupts.md](../device-driver-development/device-interrupts.md)).
|
||||
- **`system/kernel/architecture/x86_64/serial.zig`** / **`io.zig`** — the COM1 UART (the kernel's
|
||||
machine-readable log channel, see [testing.md](../testing.md)) and the shared port-I/O + MSR primitives.
|
||||
- **`system/kernel/architecture/x86_64/smp.zig`** / **`per-cpu.zig`** — application-processor bring-up
|
||||
@@ -0,0 +1,120 @@
|
||||
# Communication: the four layers
|
||||
|
||||
*Design, agreed 2026-07-31. The model document — the vocabulary and layering
|
||||
every other communication document speaks.*
|
||||
|
||||
danos separates **what is said** from **how the bytes move**, so that the
|
||||
mechanism is replaceable. The shape is a network stack's, cut into four
|
||||
layers; a program only ever touches the top two.
|
||||
|
||||
```
|
||||
L3 namespace /protocol/... names establishment points protocol-namespace.md
|
||||
L2 protocol the language: packet schemas, verbs, targets the envelope, library/protocol/*
|
||||
L1 channel two ends exchanging packets and signals the client library's Channel
|
||||
L0 transport a buffer + a doorbell: moves the bytes ipc.md (kernel-ipc), later shm-ring, …
|
||||
```
|
||||
|
||||
## Vocabulary
|
||||
|
||||
| Term | Meaning |
|
||||
|---|---|
|
||||
| **protocol** | The language: which packets exist, what their fields mean, which verbs a provider answers. Defined transport-independently in a `library/protocol/*` module. |
|
||||
| **channel** | An open conversation between two processes, speaking one protocol. Established by opening a `/protocol/...` name; both ends can send and receive. |
|
||||
| **packet** | The unit a protocol transmits: a bounded, atomic header+payload. Never fragmented — if it doesn't fit, it isn't a packet; bulk data rides shared memory with a packet as the doorbell. |
|
||||
| **signal** | A payload-less poke below the packet layer: "something happened, come look." Coalescing — the count may collapse, the fact may not. |
|
||||
| **transport** | What moves the bytes of one channel: a buffer plus a doorbell. Chosen (and upgradable) at establishment, invisible above L1. |
|
||||
| **endpoint** | A termination point where a transport delivers. The kernel-ipc transport's endpoint is its kernel mailbox object. |
|
||||
|
||||
## Addressing: parties by channel, objects by target
|
||||
|
||||
There are no network-style addresses in a packet. The two questions addresses
|
||||
answer are answered at different layers:
|
||||
|
||||
- **Who am I talking to?** The **channel**, decided once at establishment.
|
||||
Opening `/protocol/input` yields a channel; every packet sent on it goes to
|
||||
the peer. Nothing to route per-packet — like TCP, where no HTTP request
|
||||
carries the server's IP.
|
||||
- **Who sent this?** Attached to every received packet **by the channel
|
||||
layer**, from identity the transport can verify — under kernel-ipc, the
|
||||
kernel-stamped badge. The sender never writes a source field, which is what
|
||||
makes source unforgeable (the property a network's spoofable source header
|
||||
lacks).
|
||||
- **Which of your things?** The packet's **`target`** field: *object*
|
||||
addressing within the already-chosen peer — the vfs protocol's node id, the
|
||||
display protocol's layer id, a block volume. `target = 0` addresses the
|
||||
provider itself; a protocol without objects never uses it.
|
||||
|
||||
`target` is how instance multiplicity stays out of the namespace. Ten USB
|
||||
sticks and the namespace still holds exactly one name, `/protocol/block`: a
|
||||
channel to the provider, `enumerate` lists the current volumes as targets, a
|
||||
`targets_changed` signal announces hotplug, and a read names its volume in
|
||||
`target`. The unix `/dev/sda`,`/dev/sdb` problem is dissolved, not renamed.
|
||||
|
||||
If a future transport genuinely routes between machines, *it* carries real
|
||||
source/destination addressing internally at L0 — the way IP runs under TCP —
|
||||
and none of it surfaces into the packet header. Protocols stay ignorant of
|
||||
distance.
|
||||
|
||||
## The transport (L0): a buffer and a doorbell
|
||||
|
||||
Strip any transport to its skeleton and the same two parts remain:
|
||||
|
||||
| Transport | Buffer | Doorbell | Status |
|
||||
|---|---|---|---|
|
||||
| **kernel-ipc** | kernel-owned mailbox (the `Endpoint`) | the scheduler (rendezvous wake) | the first transport — [ipc.md](../device-driver-development/ipc.md) |
|
||||
| **shm-ring** | user-owned shared-memory ring | a signal | exists ad hoc (display bulk); to be formalized — the unlock for the 256-byte ceiling |
|
||||
| network | NIC queue | an interrupt | someday, when danos networks |
|
||||
|
||||
Transports differ in their **properties**, which the channel layer exposes and
|
||||
the protocol layer may depend on:
|
||||
|
||||
- **packet ceiling** — kernel-ipc: 256 bytes request/reply, 64 pushed. An
|
||||
shm-ring's ceiling is its slot size. Kernel-ipc's 256 is the *floor* every
|
||||
protocol may assume everywhere.
|
||||
- **synchrony** — kernel-ipc's call is a rendezvous: natural backpressure, no
|
||||
queue to size. An asynchronous transport buffers, so a channel over one
|
||||
needs explicit flow control. Backpressure is a *transport property*, not a
|
||||
channel guarantee — protocols that rely on it say so.
|
||||
- **droppability** — pushed event packets may drop when a ring fills;
|
||||
request/reply may not.
|
||||
- **capability carriage** — **only kernel-ipc can move a capability.**
|
||||
Handles are kernel objects; a user-space ring cannot transfer one. So
|
||||
kernel-ipc is always the *establishment and control* transport — channels
|
||||
are born on it, capabilities ride it — even when a channel's data is
|
||||
negotiated onto something fatter.
|
||||
|
||||
That negotiation is the upgrade path: a channel starts on kernel-ipc; the
|
||||
protocol's handshake may then delegate a shared-memory region (as a
|
||||
capability, over kernel-ipc) and move its bulk traffic there. The display
|
||||
path already does exactly this by hand; formalizing it in the channel layer
|
||||
makes it every protocol's option.
|
||||
|
||||
## The channel (L1)
|
||||
|
||||
A channel has two ends, and **the ends are peers**: each may send packets,
|
||||
each may receive, each may signal. Request/reply is a *pattern* over the
|
||||
channel — a send with a correlated receive, which the kernel-ipc transport
|
||||
happens to accelerate as a single rendezvous — not the definition of it. The
|
||||
event stream (subscribe, then pushes) and the change signal (poke, then
|
||||
re-read) are the other two patterns; all three are catalogued in
|
||||
[protocol-namespace.md](protocol-namespace.md)'s wiring section.
|
||||
|
||||
The channel layer's obligations: deliver packets whole, attach the verified
|
||||
source to every receive, expose the transport's properties, and hide the
|
||||
transport's mechanics. The client library's `Channel` type is this layer made
|
||||
concrete — a program holds channels that speak protocols and never touches a
|
||||
raw handle.
|
||||
|
||||
## The protocol (L2) and the namespace (L3)
|
||||
|
||||
A protocol defines its packets through the envelope — every packet begins
|
||||
`{operation, target}`, reserved verbs (`describe`, `enumerate`, `subscribe`,
|
||||
`unsubscribe`) mean the same thing in every protocol, and `Define` checks
|
||||
every packet against the transport floor at compile time. The full treatment,
|
||||
including how names are granted, resolved, and restricted per process, is
|
||||
[protocol-namespace.md](protocol-namespace.md).
|
||||
|
||||
Establishment points are named by contract — `/protocol/display`, never
|
||||
`/protocol/ipc-1` — because the name must outlive the mechanism: a
|
||||
transport named in the namespace could never be swapped, which would defeat
|
||||
this document's premise.
|
||||
@@ -132,7 +132,7 @@ when*:
|
||||
- **User-space enumeration: a device-manager server.** Everything else — PCI devices,
|
||||
peripherals — is parsed (or queried from the kernel's parse) by a privileged
|
||||
user-space server that hands each driver process its MMIO regions and IRQ rights
|
||||
over [IPC](../device-driver-development-guide/ipc.md). Combined with **interrupts-as-messages** (an IRQ delivered to a
|
||||
over [IPC](../device-driver-development/ipc.md). Combined with **interrupts-as-messages** (an IRQ delivered to a
|
||||
driver as a message on a channel — a natural extension of the wait queues and
|
||||
channels already built), that's what makes drivers genuinely isolated.
|
||||
|
||||
@@ -144,7 +144,7 @@ slice is unavoidably in-kernel.
|
||||
On ARMv8 the generic timer exposes its frequency directly via the `CNTFRQ` register —
|
||||
no calibration needed. That's cleaner than the x86 side, where we measure the LAPIC
|
||||
and TSC against the PIT because nothing tells us their frequency (see
|
||||
[device-interrupts.md](../device-driver-development-guide/device-interrupts.md)). Discovery on ARM hands you more for
|
||||
[device-interrupts.md](../device-driver-development/device-interrupts.md)). Discovery on ARM hands you more for
|
||||
free; discovery on x86 is partly about *finding* what ARM just tells you.
|
||||
|
||||
## Suggested ordering
|
||||
@@ -166,9 +166,9 @@ free; discovery on x86 is partly about *finding* what ARM just tells you.
|
||||
- [arm.md](arm.md) — the aarch64 target that forces genuine discovery (DTB, GIC).
|
||||
- [memory-map.md](memory-map.md) — the same loader-captures / kernel-consumes seam,
|
||||
and the note about grabbing the RSDP before `ExitBootServices`.
|
||||
- [device-interrupts.md](../device-driver-development-guide/device-interrupts.md) — the LAPIC/timer bring-up that
|
||||
- [device-interrupts.md](../device-driver-development/device-interrupts.md) — the LAPIC/timer bring-up that
|
||||
discovery will eventually feed (IOAPIC, real IRQ routing).
|
||||
- [ipc.md](../device-driver-development-guide/ipc.md) — the channels that interrupts-as-messages and the device manager
|
||||
- [ipc.md](../device-driver-development/ipc.md) — the channels that interrupts-as-messages and the device manager
|
||||
will ride on.
|
||||
- [vision.md](../vision.md) — why drivers belong in isolated user space at all.
|
||||
|
||||
@@ -177,7 +177,7 @@ free; discovery on x86 is partly about *finding* what ARM just tells you.
|
||||
The kernel now seeds only the `pci_host_bridge` node (ECAM window, MMIO
|
||||
apertures derived from the memory map's holes, bus range, and the 16-bit I/O
|
||||
window). The per-function walk moved to the ring-3 `pci-bus` driver
|
||||
([device-manager.md](../device-driver-development-guide/device-manager.md)): it claims the bridge, repeats the
|
||||
([device-manager.md](../device-driver-development/device-manager.md)): it claims the bridge, repeats the
|
||||
ECAM scan through its mmio grant, and `device_register`s what it finds, which
|
||||
the device manager mirrors and matches. The ACPI namespace walk follows in M20;
|
||||
the static tables (MADT, HPET, MCFG, FADT + `\\_S5`) stay kernel-side.
|
||||
@@ -190,7 +190,7 @@ for the host bridge, FADT); at this point it also still built the AML namespace
|
||||
but only to read the `\\_S5` sleep type for poweroff. (That remnant is gone too:
|
||||
the kernel now runs no AML at all — soft-off belongs to the acpi service, and the
|
||||
kernel keeps only the AML-free reboot path.) Device discovery is the ring-3 **acpi
|
||||
service** ([device-manager.md](../device-driver-development-guide/device-manager.md)): it claims the `acpi-tables`
|
||||
service** ([device-manager.md](../device-driver-development/device-manager.md)): it claims the `acpi-tables`
|
||||
node the kernel publishes (the AML blobs, a broad io_port grant, the SCI),
|
||||
re-parses the same blobs with the shared AML module, evaluates `_STA`/`_CRS`,
|
||||
and registers + reports each `_HID` device — the device manager matches drivers
|
||||
@@ -206,7 +206,7 @@ ring 0.)
|
||||
Moving PCI and ACPI enumeration out of ring 0 was not just a relocation — it
|
||||
made discovery **firmware-neutral by construction**, which is the whole reason
|
||||
to do it before the second architecture rather than after. Everything at and
|
||||
above the [device-manager](../device-driver-development-guide/device-manager.md) protocol — descriptors,
|
||||
above the [device-manager](../device-driver-development/device-manager.md) protocol — descriptors,
|
||||
containment, reports, matching, supervision — is generic and may never become
|
||||
x86-specific. Discovery is the single firmware-specific piece, and it is
|
||||
isolated as **one swappable process per firmware**:
|
||||
@@ -231,8 +231,8 @@ Two consequences of neutrality bind on later work:
|
||||
|
||||
- **Cross-firmware surfaces are named by domain, not firmware.** System power is
|
||||
a [`power`](power.md) protocol, not an "ACPI events" protocol: on x86 the acpi
|
||||
service registers it, on ARM a PSCI/mailbox service registers the same
|
||||
`ServiceId.power`, and subscribers never learn the difference.
|
||||
service binds it, on ARM a PSCI/mailbox service binds the same
|
||||
`/protocol/power`, and subscribers never learn the difference.
|
||||
- **Identity must widen before the fdt service exists.** `DeviceDescriptor`'s
|
||||
8-byte `hid` holds an EISA id but cannot hold an FDT `compatible` string
|
||||
(`"brcm,bcm2835-aux-uart"`); the identity field grows before the ARM path can
|
||||
@@ -24,8 +24,9 @@ EFI/BOOT/BOOTX64.efi <- the "removable media" default for x86-64
|
||||
```
|
||||
|
||||
The boot volume is **FHS-shaped** (see the repository-layout note in
|
||||
[README.md](../README.md)): `build.zig` installs `boot/efi.zig` (built for the `uefi`
|
||||
target) at `EFI/BOOT/BOOTX64.efi` — the one path UEFI firmware fixes — and lays
|
||||
[README.md](../README.md)): the root `build.zig` compiles `boot/efi.zig` (built
|
||||
for the `uefi` target) and `build/images.zig` places it at
|
||||
`EFI/BOOT/BOOTX64.efi` — the one path UEFI firmware fixes — and lays
|
||||
the rest out by FHS path: the kernel at `system/kernel`, init at
|
||||
`system/services/init`, the pre-packed boot capsule at `boot/system.img`
|
||||
([system-image.md](system-image.md)).
|
||||
@@ -136,7 +136,7 @@ Both items originally deferred here have landed:
|
||||
- **The IO-APIC**: [ioapic.zig](../../system/kernel/architecture/x86_64/ioapic.zig)
|
||||
routes external device lines onto vectors — discovered via ACPI's MADT, every
|
||||
input masked at init, lines unmasked one at a time as user-space drivers bind
|
||||
them (see [device-interrupts.md](../device-driver-development-guide/device-interrupts.md)). The keyboard followed
|
||||
them (see [device-interrupts.md](../device-driver-development/device-interrupts.md)). The keyboard followed
|
||||
exactly as predicted: the PS/2 bus driver (`system/drivers/ps2-bus/`) claims
|
||||
the 8042 controller and binds its IRQ 1 (and the aux mouse's IRQ 12) through
|
||||
this routing. USB HID keyboards arrive over xHCI instead, which interrupts via
|
||||
@@ -138,8 +138,8 @@ Four tests (see [testing.md](../testing.md)) pin down the guarantees:
|
||||
processes own the low half.
|
||||
- **Per-address-space tables** — done: each user process gets its own root with
|
||||
the kernel half shared, and refcounted shared-memory mappings exist
|
||||
([ipc.md](../device-driver-development-guide/ipc.md)). Copy-on-write remains unbuilt — nothing has needed it yet.
|
||||
([ipc.md](../device-driver-development/ipc.md)). Copy-on-write remains unbuilt — nothing has needed it yet.
|
||||
- **Uncacheable MMIO** — half done: user-space device and DMA mappings are
|
||||
strong-uncacheable and the framebuffer is write-combining via the PAT, but the
|
||||
kernel's own `mapMmio` path is still writeback — the LAPIC included (see
|
||||
[device-interrupts.md](../device-driver-development-guide/device-interrupts.md)).
|
||||
[device-interrupts.md](../device-driver-development/device-interrupts.md)).
|
||||
@@ -7,7 +7,7 @@ them owns the hardware that reported the event, and the reporter should not know
|
||||
who is listening. So system power is a **service**: an event source **publishes**
|
||||
button/lid/battery/AC events, interested processes **subscribe**, and one
|
||||
privileged caller — init — can ask it to power the machine off. It is the same
|
||||
publish/subscribe shape as the [input service](../device-driver-development-guide/input.md), applied to power.
|
||||
publish/subscribe shape as the [input service](../device-driver-development/input.md), applied to power.
|
||||
|
||||
## Why a service, and why it is named for the domain, not the firmware
|
||||
|
||||
@@ -15,9 +15,9 @@ Where the events come from is firmware-specific — on x86 they ride the ACPI SC
|
||||
([acpi.md](acpi.md)); on a Raspberry Pi they would come from PSCI or a mailbox.
|
||||
What subscribers want is not: *the lid closed* means the same thing regardless of
|
||||
who noticed. So the surface is **domain-named**. There is a `power-protocol`
|
||||
module and a well-known `ServiceId.power = 5`; on x86 the **acpi service**
|
||||
registers it, and on ARM a PSCI/mailbox service will register the *same* id.
|
||||
Subscribers call `ipc.lookup(.power)` and never learn which firmware they
|
||||
module and a contract named `/protocol/power`; on x86 the **acpi service**
|
||||
binds it, and on ARM a PSCI/mailbox service will bind the *same* name.
|
||||
Subscribers open `/protocol/power` and never learn which firmware they
|
||||
are on — the neutrality the whole [discovery](discovery.md) migration exists to
|
||||
preserve, carried one layer up into a running-system surface.
|
||||
|
||||
@@ -126,5 +126,5 @@ until laptop sleep), and thermal zones.
|
||||
firmware neutrality that makes a PSCI backend drop-in on ARM.
|
||||
- [process-lifecycle.md](process-lifecycle.md) — the stop sequence
|
||||
(`terminate → deadline → kill`) and signals init composes into shutdown.
|
||||
- [device-manager.md](../device-driver-development-guide/device-manager.md) — the supervision model init mirrors for
|
||||
- [device-manager.md](../device-driver-development/device-manager.md) — the supervision model init mirrors for
|
||||
its own children.
|
||||
+3
-3
@@ -9,7 +9,7 @@ the layer above them — the standard vocabulary a danos process speaks about it
|
||||
life, and the stable `process` interface that carries it. Nothing here is
|
||||
device- or driver-specific: a driver, the VFS, and a user application all stop,
|
||||
reload, and die the same way. The device manager is simply this design's first
|
||||
serious customer ([device-manager.md](../device-driver-development-guide/device-manager.md)).
|
||||
serious customer ([device-manager.md](../device-driver-development/device-manager.md)).
|
||||
|
||||
**"POSIX" in this document means the concepts, never the letter of the standard.**
|
||||
danos borrows the ideas and the hard-won lessons (what SIGTERM *means*, why SIGPIPE
|
||||
@@ -172,7 +172,7 @@ zombie state or privileged snooping:
|
||||
the server's reply with `-EPEER`; a server that dies fails its waiting clients
|
||||
the same way. This covers the *synchronous* case only.
|
||||
3. **The subscribers** — the new piece, and it is the input service's
|
||||
publish/subscribe shape ([input.md](../device-driver-development-guide/input.md)) applied to exits. A stateful
|
||||
publish/subscribe shape ([input.md](../device-driver-development/input.md)) applied to exits. A stateful
|
||||
service accumulates per-client state across many requests: a filesystem server
|
||||
(FAT today) holds a dead client's open file handles, the input service holds
|
||||
its subscriptions, a future network stack holds its sockets. None of these
|
||||
@@ -320,7 +320,7 @@ get POSIX; danos-native programs never pay for it.
|
||||
`process` grows the interface above; the service harness handles
|
||||
`terminate` and answers the common `ping`; `stop()` for supervisors.
|
||||
|
||||
[device-manager.md](../device-driver-development-guide/device-manager.md) builds directly on all four.
|
||||
[device-manager.md](../device-driver-development/device-manager.md) builds directly on all four.
|
||||
|
||||
## Settled questions (2026-07-12)
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
# The protocol namespace
|
||||
|
||||
*Design, agreed 2026-07-31. Supersedes the `ServiceId` registry. P1–P3 of the
|
||||
migration plan at the end have landed (the envelope, the registry and the
|
||||
`ServiceId` flag-day, and restriction stage one); P4 and P5 are the remaining
|
||||
work list.*
|
||||
|
||||
How a program finds, connects to, and is restricted from the things it talks to.
|
||||
Three ideas, kept deliberately separate:
|
||||
|
||||
1. **Naming** — a path under `/protocol` names a *contract*, not a service.
|
||||
2. **Access** — resolving that path yields an endpoint *capability*; what a process
|
||||
cannot resolve, it cannot reach.
|
||||
3. **Transport** — unchanged: packets over channels, moved by whichever
|
||||
transport the channel rides (kernel-ipc first).
|
||||
This document is layers **L3** (the namespace) and **L2** (the protocol
|
||||
and its envelope) of the communication stack;
|
||||
[communication.md](communication.md) owns the model and the vocabulary
|
||||
(*protocol* the language, *channel* the conversation, *packet* the
|
||||
transmitted unit, *signal* the payload-less poke, *transport* the
|
||||
replaceable mechanism), and
|
||||
[ipc.md](../device-driver-development/ipc.md) is the first transport.
|
||||
|
||||
## Why ServiceId has to go
|
||||
|
||||
Today a service calls `ipc_register(service_id, endpoint)` and a client calls
|
||||
`ipc_lookup(service_id)`, where `ServiceId` is a compile-time enum in `abi.zig`
|
||||
backed by a flat 16-slot table in the kernel. Three defects, in rising order:
|
||||
|
||||
- **Static.** The id space is baked into the ABI at compile time. A third-party
|
||||
program can never introduce a service; the one place danos is *less* dynamic
|
||||
than its own design.
|
||||
- **Ungated.** `ipc_register` is callable by any process and *replaces* an
|
||||
existing registration. Any process can hijack `.fat` or `.display` and
|
||||
impersonate it. `ipc_lookup` is equally ambient.
|
||||
- **Unrestrictable.** Because lookup is a syscall available to everyone, there is
|
||||
no point at which "this process may not talk to the display" can be enforced.
|
||||
Any future file-access restriction would be bypassable by speaking to the FAT
|
||||
server directly.
|
||||
|
||||
## Naming: contracts, not services
|
||||
|
||||
`/protocol/<name>` names a protocol — the contract a conversation follows — and
|
||||
resolving it connects you to whatever process currently provides that contract.
|
||||
The client never cared *which* binary answers; it cares that its messages are
|
||||
understood. Naming the contract makes that explicit, and buys:
|
||||
|
||||
- **Swappable providers.** Replace the display server; `/protocol/display`
|
||||
routes to the new one; clients notice nothing.
|
||||
- **Test fakes.** Spawn a program whose namespace wires `/protocol/display` to a
|
||||
mock. The name promises the protocol; the mock speaks it.
|
||||
- **One vocabulary.** The names mirror `library/protocol/`: a program imports
|
||||
the `display-protocol` module, then opens `/protocol/display`. What you
|
||||
compiled against and what you ask the namespace for are the same word.
|
||||
|
||||
A leaf names one contract — kebab-case, full words, matching the
|
||||
`library/protocol/` module that defines its wire format — and related
|
||||
contracts group into directories: `/protocol/networking/ip`,
|
||||
`/protocol/networking/bluetooth`. Directories organize *contracts only*;
|
||||
they never encode addressing (see below), so a directory appears because a
|
||||
domain has several contracts, never because hardware multiplied. The module
|
||||
tree mirrors the namespace (`library/protocol/networking/ip` ↔
|
||||
`/protocol/networking/ip`), and registrar grants scope naturally to subtrees
|
||||
— an application installed at `/applications/foo` can be granted
|
||||
`/protocol/applications/foo/...` and nothing above it. `/protocol` is
|
||||
top level, beside `/system` and `/applications`, because the boundary it names
|
||||
is spoken on both sides: applications talk to protocols as much as the OS does
|
||||
(see [file-system-hierarchy.md](../file-system-development/file-system-hierarchy.md)).
|
||||
|
||||
**Addressing lives inside the protocol, never in the path.** Which volume, which
|
||||
layer, which input device — that is a destination field in the messages, the way
|
||||
TCP carries a destination address, and the way danos protocols already work (the
|
||||
display protocol multiplexes layer ids; the vfs protocol addresses node ids).
|
||||
The namespace answers exactly one question — *may this process speak this
|
||||
protocol at all* — so `/protocol/block` is one name no matter how many disks are
|
||||
attached. The source address is never in the message either: it is the IPC
|
||||
badge, stamped by the kernel per message, unforgeable — a property TCP's source
|
||||
address does not have.
|
||||
|
||||
`/system/devices` (the device inventory) stays purely informational: facts for
|
||||
diagnosis, never a routing mechanism. Unix conflated the two in `/dev`; danos
|
||||
does not. You *read about* hardware in `/system/devices`; you *talk to* it
|
||||
through `/protocol`.
|
||||
|
||||
## Resolution: a protocol node in the VFS
|
||||
|
||||
The kernel VFS router already does the hard part: `fs_resolve` matches a mount
|
||||
prefix and installs the backend's endpoint capability in the caller's handle
|
||||
table. The registry is just a backend mounted at `/protocol` — ring 3, like FAT.
|
||||
Connecting is a normal vfs-protocol `open` with one twist in the reply:
|
||||
|
||||
```
|
||||
client kernel router registry backend
|
||||
│ fs_resolve("/protocol/display") │
|
||||
│──────────────────────────▶│ prefix match: /protocol │
|
||||
│◀── registry endpoint ─────│ (capability installed) │
|
||||
│ vfs open("display") ──────────────────────────────────────▶│
|
||||
│◀───────────────── Reply + capability = provider endpoint ──│
|
||||
│ ipc_call(provider, display-protocol messages...) │
|
||||
```
|
||||
|
||||
Both capability moves use machinery the kernel already has: request-direction
|
||||
and reply-direction `send_cap` on `call`/`replyWait`. The vfs protocol needs two
|
||||
additions, both append-only:
|
||||
|
||||
- `NodeKind.protocol` — a node that names a contract; its `open` establishes
|
||||
a **channel** (delivered as an endpoint capability) instead of returning a
|
||||
file id. The node is the protocol, the channel is the conversation, and the
|
||||
addressing inside the packets decides where within the provider each one
|
||||
lands. `readdir` over `/protocol` lists protocol nodes like any others, so
|
||||
the tree stays browsable for diagnosis.
|
||||
- The convention that an `open` reply may carry a capability. File backends
|
||||
(FAT) never use it; synthetic backends (the registry, later the device
|
||||
inventory) do.
|
||||
|
||||
The path lookup happens once, at connect time. The hot path — `ipc_call` on the
|
||||
cached endpoint — is untouched. A provider crash turns the cached endpoint dead
|
||||
(`-EPEER`), and the client's recovery is to re-resolve: the restart story falls
|
||||
out of the naming layer for free.
|
||||
|
||||
## Registration: the registrar, held by init
|
||||
|
||||
The registry backend is **init**. It is already PID 1, already spawns every
|
||||
service from its manifest, and already holds the supervision link to each — it
|
||||
is the process that *knows* which binary is which. (If init grows
|
||||
uncomfortable, the same design lifts into a dedicated registry service that
|
||||
init spawns first and delegates to; nothing below changes.)
|
||||
|
||||
- **Binding.** A service creates its endpoint and sends the registry a `bind`
|
||||
request with the protocol name as payload and the endpoint attached as the
|
||||
call's capability.
|
||||
- **Authorization.** Init's manifest gains a column: the protocols each spawned
|
||||
binary may bind. A `bind` from any process not granted that name is refused
|
||||
(`-EPERM`) — the badge identifies the caller, the supervision records map
|
||||
badge to binary. This is the registrar authority; it never leaves init.
|
||||
- **Collision is an error.** A name already bound refuses a second bind — never
|
||||
last-writer-wins. When a provider dies, init (its supervisor) unbinds its
|
||||
names; the restarted instance binds again.
|
||||
- **Provenance.** The registry records name → task id → binary path, so a
|
||||
diagnostic listing answers "who serves this?" at a glance:
|
||||
|
||||
```
|
||||
/protocol/display pid 12 /system/services/display
|
||||
/protocol/input pid 7 /system/services/input
|
||||
```
|
||||
|
||||
`ipc_register` and `ipc_lookup` retire; the `ServiceId` enum leaves `abi.zig`.
|
||||
The kernel keeps one residual rule: `/protocol` becomes a reserved prefix like
|
||||
`/system` — `fs_mount` refuses to shadow it, and init's boot-time mount is the
|
||||
only one it will ever hold. (Full gating of `fs_mount` is a separate item on
|
||||
the security track; the reserved prefix closes the hole for this namespace
|
||||
without waiting for it.)
|
||||
|
||||
## Restriction: per-process namespaces, not ACLs
|
||||
|
||||
danos has no users and no principals, deliberately. Restriction is therefore
|
||||
**delegation**: what a process may open is decided by whoever spawned it, and
|
||||
enforcement is absence — a protocol you cannot resolve does not exist for you.
|
||||
"Permission denied" and "not found" are the same answer, which is the same
|
||||
discipline the device layer already follows: the claim is the capability; here,
|
||||
the resolvable name is the capability.
|
||||
|
||||
Two stages, deliberately ordered so the useful half lands first:
|
||||
|
||||
**Stage one — the registry filters by badge.** Init is both the spawner and the
|
||||
registry, so its manifest already knows which binary may *open* which protocols
|
||||
(a second manifest column, beside the bind grants). An `open` from a process
|
||||
whose binary is not granted that protocol is refused. No new kernel mechanism
|
||||
at all; the display driver's view can be narrowed to nothing, a future
|
||||
downloaded application's to `display` and `input`, today.
|
||||
|
||||
**Stage two — spawn passes the namespace.** `spawn` gains an initial
|
||||
capability: the child's connection to *its* registry view, chosen by the
|
||||
spawner. A newly spawned process starts with an empty handle table and this one
|
||||
handle — its world is whatever its parent wired in. This removes the last
|
||||
ambient reach (`fs_resolve` finding `/protocol` globally), lets any supervisor
|
||||
— not just init — narrow or fake a child's view (an application launcher
|
||||
granting an app only what its manifest declares; a test harness substituting
|
||||
every provider), and composes down the supervision tree. Stage one's manifest
|
||||
column becomes the *content* of the view init builds, so nothing is thrown
|
||||
away.
|
||||
|
||||
### A worked example: the microphone prompt
|
||||
|
||||
The scenario stage two exists for: an application opens
|
||||
`/protocol/audio-input`, and the user should be asked. The supervisor is an
|
||||
ordinary user process — an application launcher — and the flow needs no new
|
||||
security concepts:
|
||||
|
||||
1. The launcher spawned the app with a namespace channel that terminates at
|
||||
**the launcher itself**. The app's whole world is a conversation with its
|
||||
supervisor.
|
||||
2. The app's `open("audio-input")` packet lands in the launcher,
|
||||
badge-stamped. The launcher spawned the app, so badge → binary path
|
||||
(`/applications/foo`) is its own supervision record — "remember my choice"
|
||||
needs no identity system.
|
||||
3. Grant unknown → the launcher parks the request and shows a prompt (it is a
|
||||
user process with display access; init never does UI). Blocking an open on
|
||||
a human is architecturally fine: opens are connect-time, never hot-path.
|
||||
4. **Yes** → the launcher opens `/protocol/audio-input` in *its own*
|
||||
namespace and attaches the resulting channel to the parked reply. The app
|
||||
cannot tell a prompt happened — a consented open is indistinguishable from
|
||||
a direct one, merely slower.
|
||||
5. **No** → refuse the open, indistinguishable from "no such protocol" — or
|
||||
hand the app a **fake**: a silence-generating provider. The test-fake
|
||||
mechanism doubles as a privacy feature.
|
||||
|
||||
The capability discipline holds throughout: the launcher can only grant what
|
||||
it holds — if init never gave the launcher `audio-input`, no prompt can
|
||||
conjure it. Consent is delegation flowing down the supervision tree, never a
|
||||
global ACL edit. And the provider still sees the app's badge on every packet,
|
||||
so a coarser second check at the audio service remains possible.
|
||||
|
||||
Two mechanical requirements this scenario pins on stage two:
|
||||
|
||||
- **Parked replies.** A prompt takes seconds, and the service loop holds one
|
||||
outstanding reply today — the launcher must park request A, keep serving B
|
||||
and C, and reply to A later (by badge). The kernel already tracks owed
|
||||
replies (that is how death delivers `-EPEER`); multiple parked replies is
|
||||
the extension, in the harness and, if needed, the kernel.
|
||||
- **Granted channels are dedicated, hence revocable.** Once the app holds a
|
||||
channel capability, nobody reaches into its handle table — so a
|
||||
prompt-granted channel must be one that can be *killed*: a dedicated
|
||||
endpoint pair (or per-client session at the provider) whose death turns
|
||||
the app's capability into `-EPEER`. Revoking microphone access is then
|
||||
killing that channel, using machinery that already exists.
|
||||
|
||||
One adjacent problem, named and deferred: **trusted UI**. The prompt is only
|
||||
meaningful if the app cannot draw a convincing fake or overlay the real one —
|
||||
a display-layer question (a reserved surface for the supervisor chain), owned
|
||||
by the display track, not this one.
|
||||
|
||||
Fine-grained restriction *within* a protocol (this process may use volume A but
|
||||
not volume B) is not the namespace's job. The capability-shaped answer, when it
|
||||
is needed: the supervisor pre-opens a connection scoped to one target and passes
|
||||
that connection to the child, which never opens `/protocol/block` at all.
|
||||
Delegation again, not ACLs.
|
||||
|
||||
## The envelope: one addressing scheme for every protocol
|
||||
|
||||
Every protocol module today hand-rolls its `Request`/`Reply` with an
|
||||
`operation` first field. That convention becomes a library, so addressing is
|
||||
uniform and the rules are enforced by construction rather than by review. New
|
||||
module: **`library/protocol/envelope`** (the one protocol-layer module that is
|
||||
not itself a protocol).
|
||||
|
||||
```zig
|
||||
/// Every packet a danos protocol transmits begins with this header.
|
||||
pub const Header = extern struct {
|
||||
operation: u32, // the verb; values 0..15 are reserved universal verbs
|
||||
_padding: u32 = 0,
|
||||
/// Object addressing, never party addressing: which of the peer's
|
||||
/// objects this packet operates on — a volume, layer, node, device.
|
||||
/// 0 addresses the provider itself. Parties are addressed by the
|
||||
/// channel; the protocol defines target's meaning; the field's place
|
||||
/// and width are universal.
|
||||
target: u64 = 0,
|
||||
};
|
||||
|
||||
/// Reserved verbs, answered by every provider.
|
||||
pub const operation_describe: u32 = 0; // -> protocol name, version, target kinds
|
||||
pub const operation_enumerate: u32 = 1; // -> the current targets, one per reply page
|
||||
pub const operation_subscribe: u32 = 2; // capability = the subscriber's endpoint
|
||||
pub const operation_unsubscribe: u32 = 3;
|
||||
pub const first_protocol_operation: u32 = 16;
|
||||
|
||||
/// Every reply begins with this.
|
||||
pub const Status = extern struct {
|
||||
status: i32, // 0 or a negative errno
|
||||
_padding: u32 = 0,
|
||||
len: u32 = 0, // payload bytes following the header
|
||||
_padding2: u32 = 0,
|
||||
};
|
||||
```
|
||||
|
||||
A protocol is then *defined through* the envelope, not beside it:
|
||||
|
||||
```zig
|
||||
pub const Protocol = envelope.Define(.{
|
||||
.name = "display",
|
||||
.version = 1,
|
||||
.operations = &.{
|
||||
.{ .name = "configure_layer", .request = ConfigureLayer, .reply = void },
|
||||
.{ .name = "blit", .request = Blit, .reply = void },
|
||||
...
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`Define` is comptime and is where the enforcement lives:
|
||||
|
||||
- verbs are numbered automatically from `first_protocol_operation`, so no
|
||||
protocol can collide with the reserved range;
|
||||
- every packet is size-checked at compile time against the kernel-ipc floor
|
||||
— `packet_maximum` (256) for request/reply, `post_maximum` (64) for event
|
||||
packets. Ceilings are transport properties
|
||||
([communication.md](communication.md)); the floor is what every protocol
|
||||
may assume on any transport. The errors that today surface as runtime
|
||||
truncation become compile errors, and packets-never-fragment is enforced
|
||||
at the source;
|
||||
- the generated type carries encode/decode helpers and a provider-side dispatch
|
||||
table, so a provider answers `describe` automatically and unknown operations
|
||||
with `-ENOSYS` uniformly;
|
||||
- the service harness (`library/kernel/service.zig`) accepts the generated
|
||||
dispatch type, which is what makes the envelope *enforced*: a protocol that
|
||||
bypasses `Define` does not plug into the harness.
|
||||
|
||||
Universal conventions that ride on the reserved verbs:
|
||||
|
||||
- **`describe`** is the version handshake. Version lives in the handshake, not
|
||||
in every message — the 256-byte budget is too small to spend per call.
|
||||
- **`enumerate`** is how multi-target protocols expose their targets, and the
|
||||
standard `targets_changed` notification (a notify bit) tells subscribers to
|
||||
re-enumerate — arrival and removal of volumes, layers, devices all take the
|
||||
same shape. Hotplug fits the notification ring far better than a filesystem
|
||||
tree ever did.
|
||||
- **Source is the badge.** No protocol defines a "sender" field; the kernel's
|
||||
per-message badge is the only source identity, and providers key per-client
|
||||
state on it.
|
||||
|
||||
### Paths resolve once; integers do the work
|
||||
|
||||
A rule the envelope makes official: **a path appears in a conversation at most
|
||||
once — at resolve or open — and everything after it addresses integers.** The
|
||||
namespace resolves `/protocol/display` to an endpoint; a backend's `open`
|
||||
resolves a path payload to a node id; from then on every packet carries the
|
||||
integer in `target`. Integers compare in one instruction and fit the fixed
|
||||
header, and the 256-byte message budget never re-carries path strings on the
|
||||
hot path. This is already the system's shape — vfs node ids, display layer ids
|
||||
— and the envelope pins it as the required shape for every protocol.
|
||||
|
||||
Two integer identities, not to be confused:
|
||||
|
||||
- **An open handle** — what vfs `open` returns today: transient, meaningful
|
||||
only within one client's session with one provider, swept when the client
|
||||
exits. Cheap, and all a protocol usually needs. Handles must be **scoped per
|
||||
client** — validated against the badge, or drawn from a per-client id
|
||||
namespace. (Today the FAT server's node ids are guessable small integers
|
||||
honoured across clients; that hole closes with this rule.)
|
||||
- **A persistent node identity** — a unix inode number, stable across opens
|
||||
and renames. danos deliberately does not promise this, because FAT cannot
|
||||
deliver it: a FAT file's identity is its directory entry, and rename or
|
||||
truncation moves every candidate anchor. If a future filesystem or a cache
|
||||
layer needs stable identity, that is the backend's promise to make, never
|
||||
the protocol's assumption.
|
||||
|
||||
The five existing protocol modules (`vfs`, `display`, `input`, `power`,
|
||||
`block`, plus `scanout`, `usb-transfer`, `device-manager`) rebase onto the
|
||||
envelope during the migration flag-day. `input-protocol`'s subscribe/publish
|
||||
split and `vfs-protocol`'s node addressing both map cleanly (`node` and layer
|
||||
ids become `target`).
|
||||
|
||||
## Wiring: how conversations flow
|
||||
|
||||
The patterns below are channel-layer (L1) shapes; the delivery mechanics are
|
||||
the kernel-ipc transport's, described here because it is the transport every
|
||||
channel starts on. Kernel-ipc provides exactly three delivery shapes, and
|
||||
every one is unicast. An endpoint is a mailbox owned by one process — its
|
||||
creator receives; anyone holding its capability sends into it. That direction
|
||||
never reverses:
|
||||
|
||||
1. **Synchronous call** — request/reply. The kernel parks the caller and
|
||||
`replyWait` delivers the reply straight back, so the provider answers
|
||||
without holding any capability to the client. Badge-stamped, blocking, and
|
||||
the *only* shape that carries capabilities (in the request, and in the
|
||||
reply — which is how a reverse path is bootstrapped).
|
||||
2. **Asynchronous send** — an event packet pushed into the receiver's post
|
||||
ring, at most `post_maximum` (64) bytes, no reply owed, never blocks the
|
||||
sender. Strictly one-way: to be pushed to, you must first hand the pusher
|
||||
your endpoint.
|
||||
3. **Signals** — payload-less notification bits, below the packet layer,
|
||||
coalescing: "something changed, come look."
|
||||
|
||||
A bidirectional link is therefore always **a pair of endpoints**, one per
|
||||
direction, each delivered by cap-passing. Three conversation patterns are
|
||||
built from these, and the envelope names all three:
|
||||
|
||||
- **Request/response** — the synchronous call. The default, and the only
|
||||
place capabilities move.
|
||||
- **Event stream** — `subscribe` (a synchronous call whose attached
|
||||
capability is the subscriber's own endpoint), after which the provider
|
||||
pushes events asynchronously; `unsubscribe` or subscriber exit ends it.
|
||||
Listened-to, not blocked-on.
|
||||
- **Change signal** — a signal plus re-read: `targets_changed` →
|
||||
`enumerate`. For state whose truth lives with the provider.
|
||||
|
||||
**Broadcast is a provider pattern, never a kernel primitive.** The kernel
|
||||
does not know subscriber sets — a service does. The input service is the
|
||||
model: sources *publish* (a unicast call to the service), the service
|
||||
*broadcasts* (a fan-out loop of asynchronous sends over its subscriber list,
|
||||
so one dead subscriber can never stall the rest). One fan-out point per event
|
||||
domain, owned by the service that defines the event.
|
||||
|
||||
The harness owns the machinery: the subscriber table, the dead-subscriber
|
||||
sweep (via process-exit notifications), and the fan-out loop — all written by
|
||||
hand in `input.zig` today, lifted into the service harness so every protocol
|
||||
gets identical semantics. `Define` declares a protocol's events (`.events`),
|
||||
and each event type is checked against `post_maximum` at compile time,
|
||||
generalizing the assert `input-protocol` already carries.
|
||||
|
||||
**Event packets are droppable.** A slow subscriber's ring fills, and the
|
||||
provider must not block on it — so an event stream is a hint or a coalescing
|
||||
signal, never a ledger. Anything that must not be lost is either re-readable
|
||||
state (the change-signal pattern) or bulk data in shared memory with a
|
||||
packet as the doorbell, which is how the display path already works — the
|
||||
packets-never-fragment rule and this one are the same rule seen from two
|
||||
sides.
|
||||
|
||||
**Source direction (open point).** Today event sources are *clients*: an
|
||||
input driver resolves `/protocol/input` and delivers each event as a
|
||||
synchronous `publish` call — one capability, obtained by resolution, covers
|
||||
everything, and the badge tells the service exactly who each event came from.
|
||||
The inversion — the service subscribing to each driver — would require every
|
||||
driver to be individually discoverable and its endpoint ferried to the
|
||||
service, machinery whose payoff (the service choosing its sources) the
|
||||
namespace already provides more cheaply: only a process granted open on
|
||||
`/protocol/input` can publish into it. Sources stay clients for now;
|
||||
revisited at restriction stage two, when a supervisor can wire capabilities
|
||||
at spawn time.
|
||||
|
||||
## What this deliberately does not solve
|
||||
|
||||
The wider security track, for which this namespace is the foundation, not the
|
||||
whole:
|
||||
|
||||
- **File access restriction** — the point of the exercise. The same stage-two
|
||||
namespace mechanism extends from protocol names to file paths: the spawner
|
||||
decides which subtrees resolve. Designed separately once this lands.
|
||||
- `fs_mount` gating beyond the reserved prefixes; `system_spawn` gating;
|
||||
`klog_read` being world-readable; backends checking the badge on per-node
|
||||
operations (the FAT server honours node ids across clients today).
|
||||
- Kernel hardening items already noted in-tree: SMEP/SMAP and SYSRET
|
||||
canonical-RIP, now designed in [smep-smap.md](smep-smap.md).
|
||||
- Pipes/FIFOs for the POSIX layer — a byte-stream object *beside* message IPC,
|
||||
wanted by the Python track, unrelated to naming.
|
||||
- **Trusted UI** — a permission prompt an application cannot fake or overlay
|
||||
(see the microphone example). A display-track concern: the supervisor chain
|
||||
needs a reserved surface.
|
||||
|
||||
## Migration plan
|
||||
|
||||
Flag-day per phase, in the style of the DMA-capability conversion — no
|
||||
dual-stack periods, the QEMU suite green at each phase boundary.
|
||||
|
||||
**P1 — mechanics, no behavior change.** The `envelope` module with its comptime
|
||||
`Define`, unit tests; `NodeKind.protocol` and the open-reply-capability
|
||||
convention in `vfs-protocol`; existing protocols untouched.
|
||||
|
||||
**P2 — the registry.** Init serves `/protocol` (bind with manifest
|
||||
authorization, collision refusal, unbind on provider death, provenance);
|
||||
kernel reserves the `/protocol` prefix; every service converts from
|
||||
`ipc_register` to `bind`, every client from `ipc_lookup` to resolve-and-open;
|
||||
`ServiceId`, `ipc_register`, `ipc_lookup` deleted. Tests: unauthorized bind
|
||||
refused, collision refused, provider restart re-binds and a client re-resolves.
|
||||
|
||||
**P3 — restriction, stage one.** The open-grant column in init's manifest;
|
||||
registry refuses ungranted opens. Test: a fixture process denied a protocol its
|
||||
neighbour is granted.
|
||||
|
||||
**P4 — protocol rebase.** Existing protocol modules re-expressed through
|
||||
`Define`; providers move onto the generated dispatch; `describe`/`enumerate`
|
||||
answered everywhere; the conformance test fixture exercises the reserved verbs
|
||||
against every registered provider.
|
||||
|
||||
**P5 — restriction, stage two.** Spawn's initial capability; namespace views
|
||||
built by the spawner; ambient resolution of `/protocol` retired. Includes the
|
||||
two requirements the microphone example pins: **parked replies** (a
|
||||
supervisor parks an open, keeps serving, replies later by badge) and
|
||||
**dedicated, killable granted channels** (revocation = channel death →
|
||||
`-EPEER`). Scoped separately — it touches `spawn`, the loader contract, and
|
||||
every supervisor — and lands together with the file-path half of namespacing.
|
||||
|
||||
The unix-path migration ([file-system-hierarchy.md](../file-system-development/file-system-hierarchy.md#migration))
|
||||
is independent of P1–P5 and can land before or after.
|
||||
@@ -5,7 +5,7 @@ isolation; fault → kill the process → keep the core (`onException`; the
|
||||
`fault-recovery` test); the supervisor notification **with exit reasons**
|
||||
([process-lifecycle.md](process-lifecycle.md) — clean exit, fault class, or
|
||||
killed, recorded before the notice posts); and the **restart policy itself**
|
||||
([device-manager.md](../device-driver-development-guide/device-manager.md)): the device manager supervises every
|
||||
([device-manager.md](../device-driver-development/device-manager.md)): the device manager supervises every
|
||||
driver, restarts crashes with backoff, caps crash loops, and re-claims work
|
||||
because the kernel releases a dead process's claims. The `driver-restart` and
|
||||
`usb-report` scenarios prove kill → release → respawn → re-claim → re-report
|
||||
@@ -81,7 +81,7 @@ Detecting and killing is the easy half. The genuinely tricky questions are about
|
||||
- **In-flight IPC**: messages sent to the dead component, or replies its clients are
|
||||
blocked waiting for. The channel has to break cleanly and unblock the waiters with
|
||||
an error rather than hang them forever (a design constraint that reaches back into
|
||||
[ipc.md](../device-driver-development-guide/ipc.md) — channels need a "peer died" outcome).
|
||||
[ipc.md](../device-driver-development/ipc.md) — channels need a "peer died" outcome).
|
||||
- **Clients**: how does a client discover the service it was talking to is gone and
|
||||
has been replaced? Options: capability revocation makes stale handles fail; or a
|
||||
**name server** re-binds clients to the new instance; or clients retry through a
|
||||
@@ -159,7 +159,7 @@ real-time work without owing anyone a timing *guarantee*.
|
||||
- [vision.md](../vision.md) — the goals this serves (learning by doing; resilience over
|
||||
hard real-time).
|
||||
- [scheduling.md](scheduling.md) — preemption, which makes runaway components killable.
|
||||
- [ipc.md](../device-driver-development-guide/ipc.md) — channels that need a "peer died" outcome for clean restart.
|
||||
- [ipc.md](../device-driver-development/ipc.md) — channels that need a "peer died" outcome for clean restart.
|
||||
- [interrupts.md](interrupts.md) — fault reporting that user mode turns into "kill and
|
||||
restart" instead of "halt".
|
||||
- [smp.md](smp.md) — the real-time-vs-resilience fork, in the SMP context.
|
||||
@@ -37,7 +37,7 @@ down a return address pointing at `task_trampoline` and zeroed callee-saved slot
|
||||
`schedule()` — pick the best task and switch — runs from two places:
|
||||
|
||||
- **`yield()`** — a task voluntarily gives up the CPU.
|
||||
- **`tick()`** — the 1000 Hz [timer](../device-driver-development-guide/device-interrupts.md) preempts the running
|
||||
- **`tick()`** — the 1000 Hz [timer](../device-driver-development/device-interrupts.md) preempts the running
|
||||
task. This is what lets a task that never yields still share the CPU.
|
||||
|
||||
The subtlety in mixing them is the **interrupt flag (IF)**. The rule: `switch_context`
|
||||
@@ -97,7 +97,7 @@ marks the task blocked with a wake deadline and switches away. On every tick the
|
||||
timer wakes any task whose deadline has passed (a bounded scan, so it stays
|
||||
deterministic), which makes it ready again; the scheduler then runs it when its
|
||||
priority comes up. `sleep` measures its deadline on the [calibrated
|
||||
clock](../device-driver-development-guide/device-interrupts.md), so it's real time.
|
||||
clock](../device-driver-development/device-interrupts.md), so it's real time.
|
||||
|
||||
When *every* task is blocked, something still has to run — so there's an **idle
|
||||
task** at the lowest priority that just `hlt`s until the next interrupt (see
|
||||
@@ -111,7 +111,7 @@ The other form of blocking is waiting for an **event** rather than a duration. A
|
||||
the caller on it, `wake(wq)` moves the highest-priority waiter back to ready
|
||||
(preempting if it now outranks the running task). A task links into a wait queue
|
||||
through the same field the ready queues use — it's in exactly one queue at a time.
|
||||
These are the primitives locks, semaphores and [IPC](../device-driver-development-guide/ipc.md) are built on.
|
||||
These are the primitives locks, semaphores and [IPC](../device-driver-development/ipc.md) are built on.
|
||||
|
||||
Blocking safely needs **composable critical sections**. A blanket `cli`/`sti` pair
|
||||
doesn't nest: an IPC channel that `cli`s and then calls `wait` would have `wait`'s
|
||||
+1
-1
@@ -307,4 +307,4 @@ refcount, and no group-kill special case is needed at all.
|
||||
Then update [threading.md](threading.md) (the shared-fate gap note),
|
||||
[process-lifecycle.md](process-lifecycle.md),
|
||||
[process-management.md](process-management.md), and
|
||||
[ipc.md](../device-driver-development-guide/ipc.md)/[drivers.md](../device-driver-development-guide/drivers.md) mentions.
|
||||
[ipc.md](../device-driver-development/ipc.md)/[drivers.md](../device-driver-development/drivers.md) mentions.
|
||||
@@ -0,0 +1,143 @@
|
||||
# SMEP and SMAP — supervisor-mode hardening
|
||||
|
||||
*Design, 2026-07-31. Not yet implemented. Companion to
|
||||
[protocol-namespace.md](protocol-namespace.md) on the security track — this is
|
||||
the hardware half; that is the namespace half.*
|
||||
|
||||
Two CR4 bits that make the CPU refuse the two things a kernel should never do
|
||||
with user memory:
|
||||
|
||||
- **SMEP** (Supervisor Mode Execution Prevention, CR4 bit 20): instruction
|
||||
fetch in ring 0 from a page whose U/S bit says *user* → #PF. Kills the
|
||||
classic ret2usr exploit shape — a kernel bug that redirects control flow
|
||||
can no longer land in attacker-prepared user code.
|
||||
- **SMAP** (Supervisor Mode Access Prevention, CR4 bit 21): data read/write
|
||||
in ring 0 to a user page → #PF, unless `EFLAGS.AC` is set. `stac`/`clac`
|
||||
open and close deliberate access windows; danos's design needs no windows
|
||||
at all (below).
|
||||
|
||||
Detection is CPUID leaf 7, subleaf 0, EBX bit 7 (SMEP) and bit 20 (SMAP).
|
||||
Both bits are per-core state: the BSP and every AP must set them.
|
||||
|
||||
## Why, in danos terms
|
||||
|
||||
Every syscall argument is an attacker-controlled integer, and several take
|
||||
pointers. A kernel bug that dereferences a crafted pointer reads, writes, or
|
||||
executes memory of the attacker's choosing — the exact bug class the
|
||||
isolation tracks exist to prevent. SMEP/SMAP turn that class from "silent
|
||||
compromise" into "immediate, attributable #PF with a kernel RIP in the log."
|
||||
|
||||
The second benefit matters as much as the first: **SMAP is a permanent
|
||||
tripwire.** Once it is on, any *future* syscall that touches user memory
|
||||
directly — instead of going through the checked copy layer — faults the
|
||||
first time the QEMU suite runs it. The discipline stops depending on review.
|
||||
|
||||
## Where danos already stands
|
||||
|
||||
The design is closer than it looks, because the IPC layer was built right:
|
||||
|
||||
- **The copy layer is already SMAP-proof.** `copyAcross` and `copyFromUser`
|
||||
(`system/kernel/ipc-synchronous.zig:305,333`) never dereference a user
|
||||
virtual address: they walk the page tables and move bytes through the
|
||||
physmap — kernel mappings throughout. SMAP cannot object.
|
||||
- **Syscall entry already clears AC.** `SFMASK = 0x4_0700` clears IF, TF,
|
||||
DF, **AC** on every `syscall`
|
||||
(`system/kernel/architecture/x86_64/per-cpu.zig:76`). The syscall path is
|
||||
SMAP-clean from day one.
|
||||
- **The interrupt path is not.** Hardware does *not* clear AC on IDT
|
||||
delivery, and ring 3 can set AC with `popfq` — so a hostile process could
|
||||
take an interrupt with AC=1 and have the handler run with SMAP suspended.
|
||||
`isr_common` (`system/kernel/architecture/x86_64/isr.s:366`) needs a
|
||||
`clac` beside its `swapgs`.
|
||||
- **CR4 today:** the BSP inherits firmware CR4 (no kernel write anywhere);
|
||||
APs set PAE/OSFXSR/OSXMMEXCPT in `trampoline.s:62-68`. Neither path sets
|
||||
SMEP/SMAP yet, and both must.
|
||||
- **The stragglers.** Nine syscalls still dereference user pointers raw
|
||||
after a bounds check — every one is a SMAP #PF waiting to happen, and
|
||||
every one is *already* a latent kernel fault today (an unmapped-but-in-
|
||||
range user page oopses the kernel instead of failing the call). The
|
||||
verified sweep of `system/kernel/process.zig` (2026-07-31; a
|
||||
whole-kernel `@ptrFromInt` audit found no user-address dereference
|
||||
outside this file):
|
||||
|
||||
| Syscall | Raw access | Direction |
|
||||
|---|---|---|
|
||||
| `system_spawn` | name + argument blob (`:972`, `:980`) | read |
|
||||
| `fs_resolve` | path in (`:1780`), result out (`:1797`) | read + write |
|
||||
| `fs_mount` | prefix + rewrite strings (`:1864`, `:1865`) | read |
|
||||
| `fs_unmount` | prefix string (`:1883`) | read |
|
||||
| `fs_node` | read buffer out (`:1820`) | write |
|
||||
| `debug_write` | message bytes (`:1700`; read twice — memcpy `:1710` and `log.append` `:1717`) | read |
|
||||
| `klog_read` | log bytes out (`:1741`) | write |
|
||||
| `klog_status` | status struct out (`:1758`) | write |
|
||||
| `process_enumerate` | descriptor array out (`:1132`) | write |
|
||||
| `device_enumerate` | descriptor array out (`:388`) | write |
|
||||
|
||||
For the write-direction rows the `@ptrFromInt` is in process.zig but the
|
||||
stores happen in callees (`scheduler.enumerate`
|
||||
`system/kernel/scheduler.zig:1209`, `devices_broker.enumerate`
|
||||
`devices-broker.zig:136`, `log.readAt` `log.zig:209`, the vfs node calls
|
||||
`vfs.zig:257/269/289`) — converting them means bounce buffers plus
|
||||
`copyToUser` around those calls, not just editing the process.zig lines.
|
||||
(Some paths already do it right — the futex word and the device-register
|
||||
descriptor go through `copyFromUser` (`:1087`, `:924`). The write
|
||||
direction has no public helper yet, but the mechanism exists:
|
||||
`copyAcross` with a kernel source is exactly how IPC replies reach user
|
||||
buffers, so `copyToUser` is a mechanical mirror.)
|
||||
|
||||
- **One known gap inside the copy layer itself:** the walk checks presence,
|
||||
not the leaf U/S and writable bits (`ipc-synchronous.zig:20-22` flags
|
||||
this). Today that is nearly moot — the user half contains only mappings
|
||||
the kernel itself created for that process — but it must close before
|
||||
shared or copy-on-write mappings exist, and closing it is part of making
|
||||
the copy layer the single trusted door.
|
||||
|
||||
## The plan
|
||||
|
||||
**H1 — copy discipline (the real work).** A `user-memory` kernel module:
|
||||
`copyFromUser` / `copyToUser` (the missing write direction) via the physmap
|
||||
walk, with U/S and writable leaf checks closing the in-tree TODO. Convert
|
||||
the nine stragglers. This fixes the latent unmapped-page kernel fault on
|
||||
its own — it is worth doing even if SMEP/SMAP never shipped. QEMU suite
|
||||
green; no behavior change visible to correct programs.
|
||||
|
||||
**H2 — SMEP.** A leaf-7 feature probe (the kernel has per-leaf `cpuid`
|
||||
helpers in `apic.zig` to generalize); set CR4.SMEP during per-CPU bring-up
|
||||
on BSP and APs — prefer the Zig-side per-CPU init over the trampoline
|
||||
assembly, so one code path covers every core and the trampoline stays
|
||||
minimal. Audit first that ring 0 never executes user-mapped pages: kernel
|
||||
text lives in the kernel half, `jump_to_user` is kernel code, and the AP
|
||||
trampoline page is kernel-mapped — expected clean, verify before flipping.
|
||||
|
||||
**H3 — SMAP.** Add `clac` at `isr_common` entry. `clac` is #UD on CPUs
|
||||
without SMAP, so the instruction is a 3-byte NOP in the image, patched to
|
||||
`clac` at boot when CPUID advertises SMAP (one-time patch beats a
|
||||
conditional branch in the hottest path in the kernel). Then set CR4.SMAP in
|
||||
the same per-CPU init. From this point the whole QEMU suite doubles as the
|
||||
enforcement test: any missed raw dereference is a vector-14 with a kernel
|
||||
RIP and a user CR2 — loud and attributable.
|
||||
|
||||
**H4 — keep it honest.** A line in the coding standards: kernel code
|
||||
touches user memory only through `user-memory`; there is no `stac` anywhere
|
||||
in the tree, and a PR that adds one is wrong by definition. SMAP enforces
|
||||
the rule mechanically at test time.
|
||||
|
||||
Feature-gating follows the timekeeping rule (work on any VM, real Intel,
|
||||
real AMD): both bits are probed, absence is logged and tolerated — like the
|
||||
IOMMU's fail-open, the machine still boots, just unhardened. QEMU: TCG
|
||||
implements both; KVM inherits the host (Intel Ivy Bridge+ for SMEP,
|
||||
Broadwell+ for SMAP; AMD Zen+ for both). The test images should run with
|
||||
`-cpu max` so the suite always exercises the enabled paths.
|
||||
|
||||
## Adjacent, deliberately separate
|
||||
|
||||
- **SYSRET canonical-RIP hardening** (`isr.s:192-194` documents it): a
|
||||
non-canonical return RIP makes `sysretq` #GP *in ring 0* on Intel. Same
|
||||
hardening bucket, independent fix (validate RCX before `sysretq`, fall
|
||||
back to `iretq`), should ride the same branch as H2/H3 but is not
|
||||
SMEP/SMAP.
|
||||
- **KPTI / Meltdown-class leaks are out of scope.** SMEP/SMAP police
|
||||
architectural accesses, not speculative ones. danos runs one kernel
|
||||
mapping in every address space and accepts that on affected hardware;
|
||||
revisit only if the threat model ever includes hostile native code on
|
||||
shared machines.
|
||||
@@ -270,6 +270,6 @@ next lands.
|
||||
|
||||
- [scheduling.md](scheduling.md) — the single-core scheduler SMP would extend.
|
||||
- [discovery.md](discovery.md) — enumerating cores is a device-discovery problem.
|
||||
- [ipc.md](../device-driver-development-guide/ipc.md) — the message passing cross-core coordination rides on.
|
||||
- [ipc.md](../device-driver-development/ipc.md) — the message passing cross-core coordination rides on.
|
||||
- [vision.md](../vision.md) — the goals question (real-time vs resilience) this note
|
||||
keeps bumping into.
|
||||
@@ -51,7 +51,7 @@ Everything else---including`read()`,`write()`,`malloc()`, and`fork()`---will run
|
||||
3. **`Yield()`/`Thread_Ctrl()`**
|
||||
- **What it does:**Allows a thread to voluntarily give up its CPU time slice, or allows a root task to spawn/kill threads.
|
||||
4. **`ipc_send(endpoint, message_buffer)`(Asynchronous Send)**
|
||||
- **What it does:**Posts a small payload to an endpoint's bounded queue and returns *without* blocking — no rendezvous, no reply. The receiver picks it up through the same `IPC_ReplyWait`, as a buffered message. It is the async counterpart of `IPC_Call`, for one-to-many broadcasts where a synchronous rendezvous would let one dead or slow receiver hang the sender. The [input service](../device-driver-development-guide/input.md) — keyboard-event fan-out — is its first user. A full queue drops the oldest message (a buffered message is discrete data, unlike a coalescing interrupt notification).
|
||||
- **What it does:**Posts a small payload to an endpoint's bounded queue and returns *without* blocking — no rendezvous, no reply. The receiver picks it up through the same `IPC_ReplyWait`, as a buffered message. It is the async counterpart of `IPC_Call`, for one-to-many broadcasts where a synchronous rendezvous would let one dead or slow receiver hang the sender. The [input service](../device-driver-development/input.md) — keyboard-event fan-out — is its first user. A full queue drops the oldest message (a buffered message is discrete data, unlike a coalescing interrupt notification).
|
||||
|
||||
* * * * *
|
||||
|
||||
@@ -11,7 +11,7 @@ sequential pass and hands the bytes to the kernel unmodified.
|
||||
|
||||
The capsule is a *performance artifact*, not a source of truth. The boot
|
||||
volume's `/system` and `/test` file trees remain the canonical layout (see
|
||||
[danos-file-system-hierarchy-FSH.md](../file-system-development/danos-file-system-hierarchy-FSH.md));
|
||||
[file-system-hierarchy.md](../file-system-development/file-system-hierarchy.md));
|
||||
the capsule is a pre-baked snapshot of the same binaries, derived from the same
|
||||
build graph, so the running system is identical whether the loader read the
|
||||
capsule or walked the tree.
|
||||
@@ -36,11 +36,11 @@ so it need be no fancier. Little-endian throughout:
|
||||
|
||||
```
|
||||
Header magic: u32 = "DNR2" (0x32524E44), count: u32
|
||||
Entry × count name: [64]u8 (NUL-padded FHS path), offset: u64, len: u64
|
||||
Entry × count name: [64]u8 (NUL-padded hierarchy path), offset: u64, len: u64
|
||||
blobs... each entry's file bytes, at its offset within the image
|
||||
```
|
||||
|
||||
- **Names are full FHS paths** (`/system/services/init`), not basenames — that
|
||||
- **Names are full hierarchy paths** (`/system/services/init`), not basenames — that
|
||||
is what "v2" means. The 64-byte capacity matches `abi.maximum_process_name`,
|
||||
so a task named after its binary path is never truncated. Paths longer than
|
||||
63 bytes are a build error (`pack-system-image.py` rejects them).
|
||||
@@ -54,14 +54,14 @@ blobs... each entry's file bytes, at its offset within the image
|
||||
|
||||
## How it is built
|
||||
|
||||
`build.zig` maintains one `bundled` list — every user binary and its FHS home.
|
||||
`build.zig` maintains one `bundled` list — every user binary and its hierarchy home.
|
||||
Three artifacts are derived from that same list, in the same build graph, so
|
||||
they cannot drift apart:
|
||||
|
||||
1. **The tree**: each binary installed at its FHS path (`zig-out/system/...`
|
||||
1. **The tree**: each binary installed at its hierarchy path (`zig-out/system/...`
|
||||
and `zig-out/test/...`, mirrored onto the FAT boot volume by
|
||||
`tools/make-fat-image.py`).
|
||||
2. **The manifest** (`system/manifest`): the FHS path of every bundled binary,
|
||||
2. **The manifest** (`system/manifest`): the hierarchy path of every bundled binary,
|
||||
one per line — the loader's per-file fallback input.
|
||||
3. **The capsule**: `tools/pack-system-image.py` packs the same binaries into
|
||||
the v2 container, installed at `zig-out/boot/system.img` and placed on the
|
||||
@@ -103,7 +103,7 @@ the kernel (`kernel.zig`) then publishes the same bytes twice, to two
|
||||
consumers:
|
||||
|
||||
- **The process layer** (`process.zig`): `system_spawn` looks binaries up in
|
||||
the ramdisk via `Reader.find` — exact FHS path, or unique basename for
|
||||
the ramdisk via `Reader.find` — exact hierarchy path, or unique basename for
|
||||
pre-path callers — and loads them as fresh ring-3 processes. The stored path
|
||||
becomes the task's name.
|
||||
- **The VFS root** (`vfs.zig`, `setInitialRamdisk`): the image is mounted as
|
||||
@@ -111,7 +111,7 @@ consumers:
|
||||
paths, so `/system` and, when the fixtures are bundled, `/test`. Directory
|
||||
nodes are derived from the entry paths (the unique parents), so the trees
|
||||
are listable and their files readable over the normal VFS protocol — the
|
||||
FHS boot tree every process sees comes straight out of the capsule bytes.
|
||||
boot tree every process sees comes straight out of the capsule bytes.
|
||||
|
||||
The image is never copied after the handoff and never mutated: the initrd is
|
||||
immutable, which is what makes the VFS's node serving lock-free.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
The ordered, checkpointable build-out for [threading.md](threading.md). Each milestone
|
||||
lands on its own and ends in a **verifiable gate** — shaped for a `/loop` run, like
|
||||
[display-v2-plan.md](../device-driver-development-guide/display-v2-plan.md). Read threading.md first for the *why*.
|
||||
[display-v2-plan.md](../device-driver-development/display-v2-plan.md). Read threading.md first for the *why*.
|
||||
|
||||
## Locked decisions (do not relitigate)
|
||||
|
||||
@@ -22,11 +22,12 @@ lands on its own and ends in a **verifiable gate** — shaped for a `/loop` run,
|
||||
## Conventions
|
||||
|
||||
Follow [coding-standards.md](../coding-standards.md): spell out non-acronym abbreviations,
|
||||
kebab-case file names, no `Co-Authored-By` trailers. New user binaries go through
|
||||
`addUserBinary` (with the new `threaded` flag where a binary spawns threads) and get
|
||||
packed into the initial-ramdisk; new syscalls extend [abi.zig](../../system/abi.zig)
|
||||
`SystemCall` + a `library/runtime` wrapper; test services live beside the code they
|
||||
exercise and register a `ServiceId` if they must be looked up.
|
||||
kebab-case file names, no `Co-Authored-By` trailers. New user binaries are
|
||||
packages whose build.zig calls `build_support.userBinary` (with `.threaded =
|
||||
true` where a binary spawns threads) and get packed into the initial-ramdisk;
|
||||
new syscalls extend [abi.zig](../../system/abi.zig) `SystemCall` + a
|
||||
`library/kernel` wrapper; test services live beside the code they exercise and
|
||||
bind a `/protocol/test/...` name if they must be reachable.
|
||||
|
||||
## How to verify along the way
|
||||
|
||||
@@ -459,7 +460,7 @@ clean.
|
||||
## Deferred (explicitly not in this plan)
|
||||
|
||||
- **Cross-process shared-memory futex** — the `(address_space, virtual_address)` key can become a
|
||||
physical-address key so two processes share a futex through a [shared-memory](../device-driver-development-guide/display-v2.md)
|
||||
physical-address key so two processes share a futex through a [shared-memory](../device-driver-development/display-v2.md)
|
||||
region. Not needed for intra-process threads.
|
||||
- **Per-thread priorities / affinity distinct from the process** — threads inherit the
|
||||
process priority ([scheduling.md](scheduling.md)); revisit only if it earns its keep.
|
||||
@@ -36,7 +36,7 @@ implementation underneath, not the API above.
|
||||
[Why not literal std.Thread](#why-not-literal-stdthread).
|
||||
- **Threads are a narrow, opt-in capability — not the default concurrency tool.** The
|
||||
default for resilience stays **process + IPC** ([resilience.md](resilience.md),
|
||||
[ipc.md](../device-driver-development-guide/ipc.md)). See [Where threads fit](#where-threads-fit-the-resilience-tension).
|
||||
[ipc.md](../device-driver-development/ipc.md)). See [Where threads fit](#where-threads-fit-the-resilience-tension).
|
||||
- **Blocking synchronization is futex-backed, never spin-backed.** Waiters sleep in
|
||||
the kernel so an idle core still halts ([halting.md](halting.md)).
|
||||
- **Per-binary opt-in to multi-threaded codegen.** Only a service that asks for
|
||||
@@ -61,9 +61,10 @@ runtime — rebuilt in lockstep — knows the mapping.
|
||||
backend would either bake danos syscall numbers into std (breaking ABI privacy and
|
||||
renumbering) or fork std to route back through the runtime — a permanent rebase
|
||||
cost that buys nothing the native type doesn't.
|
||||
2. **Our user binaries are built `single_threaded = true`** ([build.zig](../../build.zig)
|
||||
`addUserBinary`), which compiles threading out entirely and makes atomics and TLS
|
||||
single-threaded. Threads need this flipped per binary regardless.
|
||||
2. **Our user binaries are built `single_threaded = true`** (the shared recipe in
|
||||
[build-support/build.zig](../../build-support/build.zig)), which compiles threading
|
||||
out entirely and makes atomics and TLS single-threaded. Threads need this flipped
|
||||
per binary regardless.
|
||||
|
||||
So we take the *shape* of `std.Thread`, not the *type*. The cost of replicating the
|
||||
surface (spawn/join/Mutex/Condition) is small; the cost of the std type is the ABI
|
||||
@@ -214,7 +215,7 @@ Keying: threads share an address space, so a **virtual address within that addre
|
||||
identifies a futex uniquely; the kernel keys its wait queue by `(address_space_root, virtual_address)`.
|
||||
Keying by the **physical** address instead (translate `virtual_address -> physical_address` on entry) is a
|
||||
deliberate forward door: it lets two *processes* share a futex through an
|
||||
[shared-memory](../device-driver-development-guide/display-v2.md) region later, without changing the API. We start with the
|
||||
[shared-memory](../device-driver-development/display-v2.md) region later, without changing the API. We start with the
|
||||
private-per-address-space key and note the physical-key upgrade.
|
||||
|
||||
No spinning: a contended lock parks the task in the kernel and the core is free to run
|
||||
@@ -236,9 +237,10 @@ see the intro). Two scoped pieces, as built:
|
||||
|
||||
### Build: multi-threaded codegen, opt-in
|
||||
|
||||
A binary opts in by being added with `addThreadedUserBinary` — as `addUserBinary`,
|
||||
but the shared implementation builds it `single_threaded = false` — so atomics and
|
||||
(later) TLS are real. Threads and atomics are unsound in a `single_threaded` image,
|
||||
A binary opts in with `.threaded = true` in its package's
|
||||
`build_support.userBinary` call — the shared recipe in build-support then builds it
|
||||
`single_threaded = false` — so atomics
|
||||
and (later) TLS are real. Threads and atomics are unsound in a `single_threaded` image,
|
||||
so a binary must opt in **before** it may call `Thread.spawn`. Everyone else
|
||||
stays single-threaded and lean.
|
||||
|
||||
@@ -264,17 +266,19 @@ stays single-threaded and lean.
|
||||
**process**, which respawns its threads from a known-good state — restart
|
||||
granularity stays the process. The leader's recorded exit reason carries the fault
|
||||
class even when a worker faulted, so restart policy is unchanged.
|
||||
- **IPC — two consequences threads forced ([ipc.md](../device-driver-development-guide/ipc.md)):**
|
||||
- **IPC — two consequences threads forced ([ipc.md](../device-driver-development/ipc.md)):**
|
||||
- *Handles do not cross threads.* The handle table lives on the `Task`
|
||||
([scheduler.zig](../../system/kernel/scheduler.zig)), so a handle number is meaningful
|
||||
only to the thread that created it — thread A's endpoint handle `3` is not thread B's.
|
||||
A thread that needs to reach an endpoint another thread owns looks it up
|
||||
(`ipc.lookup(service)`) to install its **own** handle to the same underlying endpoint.
|
||||
This is how the display's mouse-listener thread reaches the compositor loop's endpoint
|
||||
to poke it awake (docs/display.md).
|
||||
A thread that needs to reach an endpoint another thread owns opens the name
|
||||
(`channel.openEndpoint("display")`) to install its **own** handle to the same
|
||||
underlying endpoint — an ordinary client open, with no special mechanism for the
|
||||
fact that the provider happens to be this process. This is how the display's
|
||||
mouse-listener thread reaches the compositor loop's endpoint to poke it awake
|
||||
(docs/display.md).
|
||||
- *IPC syscalls that touch shared kernel state now serialize under the big kernel lock.*
|
||||
`create_ipc_endpoint`/`ipc_register`/`ipc_lookup` allocate from the kernel heap and
|
||||
mutate the global service registry, endpoint refcounts, and handle tables. Those paths
|
||||
`create_ipc_endpoint` allocates from the kernel heap and
|
||||
mutates endpoint refcounts and handle tables. Those paths
|
||||
were unlocked because a single-threaded process could not race itself; a multi-threaded
|
||||
one can, from two cores at once. They now take `sync.enter()` like `call`/`reply_wait`/
|
||||
`send` already did — the kernel heap has no lock of its own (heap.zig: "every kernel
|
||||
@@ -284,7 +288,7 @@ stays single-threaded and lean.
|
||||
|
||||
The ordered, `/loop`-runnable milestones live in
|
||||
**[threading-plan.md](threading-plan.md)** (shaped like
|
||||
[display-v2-plan.md](../device-driver-development-guide/display-v2-plan.md)): every milestone lands on its own and ends in
|
||||
[display-v2-plan.md](../device-driver-development/display-v2-plan.md)): every milestone lands on its own and ends in
|
||||
a verifiable gate (`python3 test/qemu_test.py <case>`, asserting serial markers;
|
||||
`zig build test` for host unit tests). The stages below are the shape it expands.
|
||||
|
||||
@@ -343,7 +347,7 @@ the later self-hosting lift cheap.
|
||||
- [scheduling.md](scheduling.md), [smp.md](smp.md) — the task model these threads join.
|
||||
- [resilience.md](resilience.md), [vision.md](../vision.md) — why isolation is the default
|
||||
and threads are the exception.
|
||||
- [syscall.md](syscall.md), [ipc.md](../device-driver-development-guide/ipc.md) — the private ABI and the messaging model
|
||||
- [syscall.md](syscall.md), [ipc.md](../device-driver-development/ipc.md) — the private ABI and the messaging model
|
||||
threads sit beside.
|
||||
- [halting.md](halting.md) — the idle/halt property futex-backed blocking preserves.
|
||||
- [zig-self-hosting.md](../zig-self-hosting.md) — the target this bends toward.
|
||||
@@ -7,7 +7,7 @@ Two different needs hide under the word "timer", and danos keeps them apart:
|
||||
|
||||
Both are answered by the **kernel**, because the kernel already owns a timer: it has
|
||||
to, to preempt tasks. The LAPIC heartbeat and the calibrated TSC that back all of this
|
||||
are built in [device-interrupts.md](../device-driver-development-guide/device-interrupts.md); the scheduler's blocking and
|
||||
are built in [device-interrupts.md](../device-driver-development/device-interrupts.md); the scheduler's blocking and
|
||||
wait queues are in [scheduling.md](scheduling.md). This page is about the surface a
|
||||
ring-3 program actually uses, and one deliberate absence: **there is no user-space time
|
||||
service.**
|
||||
@@ -32,11 +32,11 @@ danos checks both — the invariant-TSC CPUID bit (`0x80000007` EDX[8], set on I
|
||||
AMD), and a cross-core "warp" check as the cores come up — and falls back to the HPET
|
||||
counter when either fails. So `now()` stays accurate on a real Intel box, a real AMD box,
|
||||
and inside a VM alike; only the source behind it differs. The mechanism is in
|
||||
[device-interrupts.md](../device-driver-development-guide/device-interrupts.md).
|
||||
[device-interrupts.md](../device-driver-development/device-interrupts.md).
|
||||
|
||||
So the timer hardware lives in the kernel, and there is **no `hpet` driver and no time
|
||||
server** to consume. (An earlier HPET driver existed only to *demonstrate* the driver
|
||||
model; that role now lives in [drivers.md](../device-driver-development-guide/drivers.md), as documentation.) The one place
|
||||
model; that role now lives in [drivers.md](../device-driver-development/drivers.md), as documentation.) The one place
|
||||
a user-space time service *is* justified — **wall-clock / calendar time** — is discussed
|
||||
at the end; it is deliberately not built yet.
|
||||
|
||||
@@ -55,7 +55,7 @@ Time and waiting are three entries in the small syscall table ([syscall.md](sysc
|
||||
service can keep answering messages on the same endpoint while a deadline is pending.
|
||||
This is the timed wait that stop-sequence escalation, hello deadlines, and restart
|
||||
backoff are built from ([process-lifecycle.md](process-lifecycle.md),
|
||||
[device-manager.md](../device-driver-development-guide/device-manager.md)).
|
||||
[device-manager.md](../device-driver-development/device-manager.md)).
|
||||
|
||||
The kernel's own scheduling timer (the LAPIC, vector 32) is never exposed to user space;
|
||||
programs read the TSC through `clock` and get timed wakeups through `sleep`/`timer_bind`,
|
||||
@@ -137,15 +137,15 @@ Grouped as `abi.zig` groups them:
|
||||
| process | `danos_exit`, `danos_yield`, `danos_sleep`, `danos_spawn`, `danos_process_enumerate`, `danos_process_kill`, `danos_process_exit_reason`, `danos_process_subscribe`, `danos_process_signal`, `danos_signal_bind` |
|
||||
| threads | `danos_thread_spawn`, `danos_thread_exit`, `danos_current_core`, `danos_futex_wait`, `danos_futex_wake`, `danos_thread_self`, `danos_thread_join`, `danos_set_thread_pointer` |
|
||||
| memory | `danos_mmap`, `danos_munmap`, `danos_dma_alloc`, `danos_dma_free`, `danos_shared_memory_create`, `danos_shared_memory_map`, `danos_shared_memory_physical` |
|
||||
| ipc | `danos_endpoint_create`, `danos_ipc_register`, `danos_ipc_lookup`, `danos_ipc_call`, `danos_ipc_reply_wait`, `danos_ipc_send` |
|
||||
| ipc | `danos_endpoint_create`, `danos_ipc_call`, `danos_ipc_reply_wait`, `danos_ipc_send` (naming is not a syscall: a provider binds its contract at the registry and a client resolves `/protocol/<name>` — see [protocol-namespace.md](protocol-namespace.md)) |
|
||||
| devices | `danos_device_enumerate`, `danos_device_claim`, `danos_device_register`, `danos_mmio_map`, `danos_irq_bind`, `danos_irq_ack`, `danos_msi_bind`, `danos_io_read`, `danos_io_write` |
|
||||
| time | `danos_clock`, `danos_wall_clock`, `danos_timer_bind` |
|
||||
| diagnostics | `danos_debug_write` (leveled, kernel-stamped records), `danos_klog_read`, `danos_klog_status` |
|
||||
| filesystem naming | `danos_fs_resolve`, `danos_fs_node`, `danos_fs_mount`, `danos_fs_unmount` (naming only — file DATA still crosses the vfs-protocol IPC, see below) |
|
||||
|
||||
The constants that ride alongside the calls — mmap protection bits, DMA
|
||||
flags, notification badge bits, `ExitReason`, `Signal`, well-known service
|
||||
ids, `page_size`, the IPC message maximum — move to the public header too:
|
||||
flags, notification badge bits, `ExitReason`, `Signal`, `page_size`, the IPC
|
||||
message maximum — move to the public header too:
|
||||
they are wire values a Rust program needs verbatim. What stays private in
|
||||
`abi.zig` is exactly the thing the vDSO exists to hide: the `SystemCall`
|
||||
numbers and the trap convention.
|
||||
@@ -0,0 +1,192 @@
|
||||
# Python on danos: the milestone plan
|
||||
|
||||
The execution plan for [python-on-danos.md](python-on-danos.md). That note holds
|
||||
the *why* and the design decisions; this one slices the work into milestones with
|
||||
concrete deliverables, tests, and exit criteria. Milestones are numbered **P0–P5**
|
||||
(track-local — the global M-series stays with the driver/lifecycle tracks).
|
||||
|
||||
Dependencies at a glance:
|
||||
|
||||
```
|
||||
P0 toolchain + mini-libc ──┐
|
||||
P1 streams + console + seam ─┴─→ P2 CPython minimal ─→ P3 terminal + REPL
|
||||
│ │
|
||||
└─→ P4 danos module │
|
||||
+ Python service│
|
||||
P5 process control + shell ←─────────────────────────────────┘
|
||||
```
|
||||
|
||||
P0 and P1 are independent of each other and can proceed in parallel. P1 is shared
|
||||
work — it is also Zig self-hosting Phase 1 and the first three slices of
|
||||
[character-devices-and-tty.md](character-devices-and-tty.md).
|
||||
|
||||
## P0 — Toolchain + the C library compatibility layer
|
||||
|
||||
**Goal:** a C hello-world, cross-compiled on the host with `zig cc`, runs on danos.
|
||||
|
||||
Design and slicing live in
|
||||
[c-library-compatibility.md](c-library-compatibility.md): the **libdanos-c**
|
||||
sysroot (hand-written danos-native headers + `libc.a`) as a `library/c/` build
|
||||
package — pure computation (string, libm, `strtod`, the printf/scanf engines)
|
||||
lifted from a vendored, pinned musl subtree; the OS plumbing written in Zig over
|
||||
the `runtime` surface (re-targeting `runtime.os` when the Zig track authors it);
|
||||
`malloc` over danos `mmap`; a `crt0` bridging the danos entry shim to C `main`.
|
||||
Driven by `zig cc -target x86_64-freestanding-none -isystem` (the triple becomes
|
||||
`x86_64-danos` if the Zig fork lands first; nothing else changes).
|
||||
|
||||
Its five slices (sysroot-skeleton, fd-plumbing, malloc, stdio,
|
||||
mathematics-and-time) carry their own tests — host-side oracle suites for the
|
||||
computation layer, QEMU cases (`c-hello`, `c-file-io`, `c-stdio`, `c-time`) for
|
||||
the plumbing.
|
||||
|
||||
**Exit:** `c-hello` and `c-file-io` green in the QEMU suite; host computation
|
||||
tests green.
|
||||
|
||||
## P1 — Stream nodes, console, and the seam pieces
|
||||
|
||||
**Goal:** the shared Phase-1 surface exists: byte-stream stdio, cwd, environment,
|
||||
entropy. Design and slicing live in
|
||||
[character-devices-and-tty.md](character-devices-and-tty.md); this milestone is
|
||||
its slices 1–3 plus three small seam pieces:
|
||||
|
||||
- **cwd/chdir** — per-process current directory used by path resolution (the
|
||||
kernel already anchors a VFS root per `fs_resolve`; the cwd is the same idea,
|
||||
process-scoped, with `getcwd`/`chdir` exposed through `runtime` and the libc).
|
||||
- **Environment** — spawn carries an environment block; the SysV entry stack's
|
||||
`envp` slot ([sysv.md](os-development/sysv.md)) stops being empty; `getenv`
|
||||
reads it. An empty block stays valid.
|
||||
- **Entropy** — a kernel `entropy` syscall (RDSEED/RDRAND with a jitter fallback,
|
||||
mirroring the TSC-reliability posture of not trusting one CPU feature blindly);
|
||||
the libc exposes `getentropy`.
|
||||
|
||||
- **Tests.** QEMU: the character-device tests from the tty note (offsetless
|
||||
read/write, blocking read, cooked/raw control round-trip), plus `cwd-basics`
|
||||
(chdir + relative open), `env-roundtrip` (spawn with env, child reads it),
|
||||
`entropy-sane` (nonzero, changing, correct length).
|
||||
|
||||
**Exit:** a C program reads a cooked line from fd 0 and echoes it to fd 1 —
|
||||
injected key events in, bytes read back through the console's in-memory sink,
|
||||
all under QEMU with no hardware involved — and `getcwd`/`getenv`/`getentropy`
|
||||
return real answers.
|
||||
|
||||
## P2 — CPython, minimal configuration
|
||||
|
||||
**Goal:** `python -c 'print(2**100)'` runs on danos under QEMU.
|
||||
|
||||
- Pin **CPython 3.13.x**; vendor as `third-party/cpython/` or fetch via the build
|
||||
(decide with the build-packages conventions).
|
||||
- Host build-Python of the same version (`--with-build-python`).
|
||||
- `config.site` cache for the cross answers; `config.sub` patch so
|
||||
`x86_64-unknown-danos` parses; a small `configure`/`pyconfig` patch set kept as
|
||||
rebasable diffs, WASI-style.
|
||||
- `--disable-shared`; static `Modules/Setup`: `posix errno _io _codecs _weakref
|
||||
time math _stat _collections itertools _functools _locale _sre` plus what the
|
||||
interpreter core insists on; threadless build (WASI precedent).
|
||||
- `Lib/` on the FAT image under the hierarchy (e.g. `/system/python/lib`);
|
||||
`PYTHONHOME` set accordingly; `.pyc` written with **checked-hash
|
||||
invalidation** (FAT's 2-second mtime granularity makes mtime-based validation
|
||||
lie during fast edit-run cycles).
|
||||
- `PYTHONHASHSEED` pinned only if P1's entropy slipped — otherwise real
|
||||
hash randomization from day one.
|
||||
- **Tests.** QEMU: `python-expr` (the exit criterion), `python-file` (run a
|
||||
script from FAT, write a file, read it back), then a curated slice of CPython's
|
||||
own suite (`test_int`, `test_float`, `test_io`, `test_dict`) as a
|
||||
longer-running target — the suite is the porting harness.
|
||||
|
||||
**Exit:** the four QEMU cases green; the CPython test slice green or with a
|
||||
short, documented skip list.
|
||||
|
||||
## P3 — Terminal + REPL: the first real application
|
||||
|
||||
**Goal:** an interactive `python` REPL in a graphical danos terminal — the
|
||||
milestone demo for the OS.
|
||||
|
||||
- Depends on the display track's font rendering (its stated next step) — until
|
||||
that lands, the REPL is exercised end-to-end through the pseudo-device
|
||||
harness from P1+P2 (scripted input in, output read back), so P2's exit is
|
||||
never blocked on graphics; the graphical terminal is the *interactive* debut.
|
||||
- The terminal application: draws with the UI toolkit / display client, consumes
|
||||
keyboard `InputEvent`s, and — per the tty note's load-bearing decision —
|
||||
**serves the VFS stream protocol itself** to its children, reusing the console's
|
||||
line-discipline library. Spawns `python` with its endpoints as fd 0/1/2.
|
||||
- Raw mode + the control set give the REPL line editing; window-size control
|
||||
gives it wrapping.
|
||||
- **Tests.** QEMU: scripted terminal session (inject key events, assert rendered
|
||||
or captured output). Real-hardware smoke on the Intel box joins the existing
|
||||
checklist.
|
||||
|
||||
**Exit:** typing `2+2` into the terminal on the QEMU GPU target prints `4`.
|
||||
|
||||
## P4 — The `danos` extension module + a Python service
|
||||
|
||||
**Goal:** Python can speak danos: IPC, capabilities, spawn.
|
||||
|
||||
- The `danos` module, **written in Zig against `Python.h`**, statically linked
|
||||
via `Modules/Setup`: endpoints (create/send/receive), capability passing,
|
||||
spawn + exit-notification, and the service bootstrap (announce, supervision
|
||||
handshake) — the same surface Zig services use, re-exposed.
|
||||
- UI-toolkit bindings as a second module once the toolkit's API settles.
|
||||
- Prototype **one real service in Python** — policy-shaped, not data-plane
|
||||
(candidates: hot-plug policy, a settings service) — speaking an existing wire
|
||||
protocol, supervised by the device manager like any service.
|
||||
- **Tests.** QEMU: `python-ipc-echo` (Python service echoes over an endpoint, a
|
||||
Zig client asserts), plus the prototype service's own protocol test.
|
||||
|
||||
**Exit:** a Python process runs as a supervised danos service exchanging IPC
|
||||
with Zig peers.
|
||||
|
||||
## P5 — Process control, then the shell
|
||||
|
||||
**Goal:** danos can spawn arbitrary programs with arguments and pipes; a small
|
||||
Python shell uses it.
|
||||
|
||||
The kernel/VFS cluster a shell forces (any shell, any language):
|
||||
|
||||
- **exec-of-path** — spawn an arbitrary VFS path, not a named ramdisk binary;
|
||||
- **argv/envp** — carried through spawn onto the child's entry stack (env from
|
||||
P1, argv new);
|
||||
- **numeric exit status** — extend the exit record beyond the categorical
|
||||
`ExitReason` (the gotcha the Zig roadmap flagged: `WEXITSTATUS` must be real);
|
||||
- **fd inheritance + pipes** — a kernel or service pipe (a character device by
|
||||
the tty note's definition) and spawn-time fd mapping.
|
||||
|
||||
Then, in order: `subprocess` enabled in CPython (maps onto spawn + the
|
||||
exit-notification endpoint — no fork, Windows-style); a **small Python shell** (a
|
||||
few hundred lines over `subprocess` + the console: prompt, argv parsing, pipes,
|
||||
cwd) as the forcing function that reveals what job control actually needs.
|
||||
|
||||
**Explicitly deferred past P5:** the pthread subset over `thread_spawn`/futex,
|
||||
signals-in-libc via M17, termios job control (Ctrl-C to foreground child), and
|
||||
**xonsh** — which wants all three and is the arc's endpoint, not a milestone.
|
||||
|
||||
**Tests.** QEMU: `spawn-argv-exit` (child echoes argv, exits 42, parent sees
|
||||
42), `pipe-through` (parent → child → parent), `python-subprocess`, and a
|
||||
scripted shell session.
|
||||
|
||||
**Exit:** the Python shell runs `program | program` typed at the terminal and
|
||||
reports the exit status.
|
||||
|
||||
## Post-P5 outlook
|
||||
|
||||
Two tracks continue past this plan, each with its own design doc rather than a
|
||||
P-number here:
|
||||
|
||||
- **Dynamic libraries** ([dynamic-libraries.md](dynamic-libraries.md), D1–D4) —
|
||||
an application-layer facility (the OS stays static and lean): `dlopen` in the
|
||||
libc, then libffi + `ctypes` + loadable extension modules, then shared
|
||||
read-only mappings so N Python services hold one physical `libpython`.
|
||||
- **The full C compatibility layer**
|
||||
([c-library-compatibility.md](c-library-compatibility.md), stages 2–3) — the
|
||||
standing rule that every system capability ships with its C spelling, draining
|
||||
the absence table toward "portable C builds on danos"; `fork` is the one
|
||||
permanent exception.
|
||||
|
||||
## Related
|
||||
|
||||
- [python-on-danos.md](python-on-danos.md) — the design note this executes.
|
||||
- [c-library-compatibility.md](c-library-compatibility.md) — P0's design.
|
||||
- [character-devices-and-tty.md](character-devices-and-tty.md) — P1's design.
|
||||
- [zig-self-hosting.md](zig-self-hosting.md) — shares P1; its fork makes P0's
|
||||
triple prettier but gates nothing here.
|
||||
- [os-development/process-management.md](os-development/process-management.md) —
|
||||
the spawn/exit surface P5 extends.
|
||||
@@ -0,0 +1,261 @@
|
||||
# Python on danos: the CPython milestone
|
||||
|
||||
A design note (not built yet) on bringing **CPython** to danos, compiled with the Zig
|
||||
toolchain (`zig cc`). Like [zig-self-hosting.md](zig-self-hosting.md), it is
|
||||
forward-looking: it sets a direction and the decisions that follow from it.
|
||||
|
||||
## Why Python, and why now
|
||||
|
||||
The Zig self-hosting road is gated on a compiler fork and a long std-library seam.
|
||||
Python is the **stop-gap that removes the wait**: a working CPython gives danos a way
|
||||
to write programs — services, tools, application prototypes — *without* the Zig
|
||||
compiler being self-hosted, and it brings the pure-Python package ecosystem along as
|
||||
a bonus. The intended division of labour:
|
||||
|
||||
- **Zig** — the kernel, drivers, and anything on a data plane (interrupt paths,
|
||||
DMA rings, block I/O). Unchanged.
|
||||
- **Python** — the control plane and the prototyping surface: services that are
|
||||
event loops over IPC, policy logic that changes often, application experiments,
|
||||
and eventually the shell.
|
||||
|
||||
Python is also the scripting language for the terminal-and-shell arc: the first
|
||||
real danos application is planned as a terminal, a terminal wants a shell, a shell
|
||||
wants a scripting language — and [xonsh](https://xon.sh) (a shell written in
|
||||
Python) marks where that road can end.
|
||||
|
||||
### Non-goals
|
||||
|
||||
- **No drivers in Python.** Interrupt handling, ring management, and DMA stay in
|
||||
Zig. Python may *supervise and configure* drivers; it does not sit in their hot
|
||||
paths (interpreter overhead and garbage-collection pauses in an interrupt path
|
||||
are disqualifying).
|
||||
- **No dynamic loading during bring-up, no `pip`.** The whole arc here ships
|
||||
statically linked. Dynamic libraries are a real *later* milestone
|
||||
([dynamic-libraries.md](dynamic-libraries.md)) — an application-layer
|
||||
facility that unlocks `ctypes` and loadable extension modules; the operating
|
||||
system itself stays static and lean regardless (the size doctrine below).
|
||||
`pip` stays out either way until a networking track exists.
|
||||
- **No fork.** `os.fork` will not exist. This costs almost nothing (see "The
|
||||
spawn model fits").
|
||||
|
||||
## The realization that shapes everything: the compiler is not the obstacle
|
||||
|
||||
`zig cc` is a full Clang-based C cross-compiler, and CPython is portable C with
|
||||
official precedent for stranger targets than danos — the WASI port is upstream
|
||||
tier-2, and it runs **without fork, without dynamic loading, and without working
|
||||
threads**. Every "CPython can't possibly run there" objection has already been
|
||||
answered upstream by a target *more* constrained than danos.
|
||||
|
||||
What CPython actually needs is a **C environment**: headers and a `libc.a`. danos
|
||||
has neither — and that is the whole project. In the language of the Zig roadmap's
|
||||
three doors, this is the **door-2-shaped work** (the deferred "musl door"), not the
|
||||
`std.os.danos` seam: CPython never touches Zig's std.
|
||||
|
||||
### The same surface, a third time
|
||||
|
||||
The Zig roadmap observed that door 1 (`std.os.danos`) and door 2 (a libc) implement
|
||||
the *same* ~30 danos-facing operations at different layers. CPython consumes that
|
||||
identical surface through C spellings. So nothing here is throwaway: the
|
||||
danos-native operations backing `runtime.os` are the same ones the libc bottoms out
|
||||
in, and the gaps this track must close (stdio byte streams, cwd, environment,
|
||||
entropy) are **exactly the Phase-1 gaps the Zig roadmap already lists**. The two
|
||||
tracks share a road until Python forks off at "build the libc."
|
||||
|
||||
## Where danos stands: coverage vs. the gaps
|
||||
|
||||
Judged against the minimal CPython configuration (static, WASI-like):
|
||||
|
||||
| CPython need | danos today | Gap |
|
||||
|--------------|-------------|-----|
|
||||
| open/read/write/close/lseek, readdir | VFS + FAT via `runtime.fs` | none — wrap in C |
|
||||
| mkdir / unlink / rename / truncate | done (self-hosting Phase 2) | none |
|
||||
| stat with mtime | done (`wall_clock` + FAT mtime) | none |
|
||||
| mmap/munmap (object allocator) | native syscalls | none |
|
||||
| monotonic + wall clock | `clock` + `wall_clock` syscalls | none |
|
||||
| a place for `Lib/` | FAT boot image | none — better than WASI has it |
|
||||
| fork / exec | not needed (subprocess disabled at first) | — |
|
||||
| dynamic loading | not needed (static extension modules) | — |
|
||||
| getcwd / chdir | — | **missing** (shared with Zig Phase 1) |
|
||||
| environment variables | `Init` has no env | **missing** (can start empty) |
|
||||
| entropy | — | **missing** (hash seed; `PYTHONHASHSEED` pins it meanwhile) |
|
||||
| byte-stream stdin/stdout (fd 0/1/2) | `debug_write` out; structured `InputEvent` in | **missing** (shared with Zig Phase 1; the REPL needs it) |
|
||||
| signals | — | stubs suffice (WASI precedent); M17 signals-over-IPC maps on later |
|
||||
| threads | native `thread_spawn`/futex | build threadless first; a pthread subset later (xonsh needs it) |
|
||||
|
||||
The clustering repeats the Zig roadmap's: **files, memory, and time are done; the
|
||||
work is the C packaging plus the small seam pieces** (tty bytes, cwd, env, entropy).
|
||||
|
||||
## The libc decision: hand-rolled in Zig, computation lifted from musl
|
||||
|
||||
Two viable shapes were considered:
|
||||
|
||||
| Option | What it is | Verdict |
|
||||
|--------|-----------|---------|
|
||||
| **Mini-libc in Zig** | C-ABI-exporting Zig library over `runtime.os`/`runtime.fs`, shipped as headers + `libc.a`. | **Take this.** Reuses the danos-native surface directly; no Linux assumptions to fight. |
|
||||
| **Port musl** | Full musl with a danos syscall backend. | Defer, again. musl assumes Linux syscall semantics in places; heavier than the need. |
|
||||
|
||||
The trick that makes the mini-libc tractable: musl's `string/`, `math/` (libm —
|
||||
CPython needs essentially all of it), and number-conversion layers are **pure
|
||||
computation with no syscalls**. Lift those wholesale (MIT-licensed, designed to
|
||||
compile standalone) and hand-write only:
|
||||
|
||||
- the OS-facing bottom: fds, `mmap`, clocks, `exit`, `getcwd` — thin C-ABI wrappers
|
||||
over `runtime.os`;
|
||||
- a `FILE*` stdio layer (buffered, over the fd layer);
|
||||
- `malloc` over danos `mmap` (a simple allocator is fine; CPython does its own
|
||||
small-object arena management above it);
|
||||
- the headers (`stdio.h`, `stdlib.h`, `string.h`, `math.h`, `errno.h`, …).
|
||||
|
||||
Estimate: **100–150 functions**, of which the hard 40% (libm, string, printf/strtod
|
||||
cores) are lifted, not written. Correctness hot spots are `strtod`/`dtoa` (Python's
|
||||
float repr round-trips through them) — another reason to lift musl's, not improvise.
|
||||
|
||||
## C interop: static extension modules, not ctypes
|
||||
|
||||
"Python can interface with C libraries" is true on danos with one important
|
||||
correction: **`ctypes` does not work at first** — it is built on `dlopen` + libffi,
|
||||
both of which arrive only with the [dynamic-libraries](dynamic-libraries.md)
|
||||
milestone (D2). Until then the interop story is the other, older one:
|
||||
|
||||
- **Extension modules statically linked into the interpreter** via CPython's
|
||||
`Modules/Setup` mechanism (the standard route for embedded/static builds).
|
||||
- **Zig speaks C ABI natively**, so danos extension modules are written in Zig
|
||||
against `Python.h` — no C required. Two modules are planned from the start:
|
||||
- **`danos`** — the system module: endpoints, send/receive, capability passing,
|
||||
spawn, exit notification. This is what makes a Python *service* possible: an
|
||||
event loop over IPC, speaking the same wire protocols as Zig services.
|
||||
- **UI toolkit bindings** — the in-progress danos UI toolkit exposed to Python,
|
||||
so application prototypes drive real windows.
|
||||
|
||||
The package story follows: **pure-Python packages work** (unpack into
|
||||
`Lib/site-packages` on the FAT image); packages with C extensions must be
|
||||
cross-compiled and baked into the interpreter — a curated set chosen per image,
|
||||
not `pip install`. That is the honest shape of the stop-gap.
|
||||
|
||||
## The roadmap
|
||||
|
||||
### Phase 0 — Toolchain + libc bring-up
|
||||
|
||||
`zig cc -target x86_64-freestanding-none` plus `-isystem` the danos headers and the
|
||||
mini-libc archive. No compiler fork required — this track deliberately avoids the
|
||||
Zig roadmap's Phase-0 gate (if the fork lands first, the triple becomes a clean
|
||||
`x86_64-danos`; nothing else changes). Exit criterion: a **hello-world C program**
|
||||
compiles on the host and runs on danos, printing via the libc's `write`.
|
||||
|
||||
### Phase 1 — The shared seam pieces
|
||||
|
||||
The same list as Zig self-hosting Phase 1, closed once for both tracks:
|
||||
|
||||
- fd 0/1/2 as console **byte** streams (output exists as `debug_write`; input is a
|
||||
new small thing — cooked line input first, raw mode when the REPL wants editing);
|
||||
- `getcwd`/`chdir`;
|
||||
- environment variables (an empty block is a valid start);
|
||||
- an entropy syscall or service (until then, builds pin `PYTHONHASHSEED`).
|
||||
|
||||
### Phase 2 — Cross-compile CPython, minimal configuration
|
||||
|
||||
Pin one CPython release (3.13 — strongest WASI-era cross-compile support). The
|
||||
mechanics are well-trodden upstream since 3.11:
|
||||
|
||||
- a same-version **build-Python on the host** (`--with-build-python`);
|
||||
- a `config.site` cache answering what configure cannot probe cross
|
||||
(`ac_cv_file__dev_ptmx=no` and friends);
|
||||
- a `config.sub` patch so `x86_64-unknown-danos` parses;
|
||||
- `--disable-shared`, static `Modules/Setup` with a minimal module set
|
||||
(`posix`, `errno`, `_io`, `_codecs`, `time`, `math`, …);
|
||||
- `Lib/` shipped on the FAT image; `PYTHONHOME` pointed at it.
|
||||
|
||||
Exit criterion: `python -c 'print(2**100)'` runs on danos under QEMU.
|
||||
|
||||
### Phase 3 — Terminal + REPL: the first real application
|
||||
|
||||
Depends on the display track's font rendering (already its stated next step) and
|
||||
Phase 1's tty. A terminal emulator drawing a `python` REPL is the milestone demo:
|
||||
interactive, self-evidently real, and it needs **zero** process-control machinery.
|
||||
|
||||
### Phase 4 — The `danos` module and Python services
|
||||
|
||||
Write the `danos` extension module and the UI-toolkit bindings; prototype one real
|
||||
service in Python (a policy-shaped one — e.g. hot-plug policy or a settings
|
||||
service) speaking the existing IPC protocols. This is the payoff phase for
|
||||
"prototyping a service or application."
|
||||
|
||||
### Phase 5 — Process control, then the shell
|
||||
|
||||
The shell — any shell, in any language — forces the surface danos has deferred so
|
||||
far: **exec-of-path, argv/envp passing, numeric exit status (`WEXITSTATUS`, not the
|
||||
categorical `ExitReason`), fd inheritance, and pipes.** That is a kernel/VFS
|
||||
milestone cluster of its own. Then, in order:
|
||||
|
||||
1. `subprocess` enabled in CPython (maps onto danos spawn — see below);
|
||||
2. a **small Python shell** (a few hundred lines over `subprocess` + line input, no
|
||||
job control) — the forcing function that reveals which process-control pieces
|
||||
actually matter;
|
||||
3. **explicitly deferred:** a pthread subset over `thread_spawn`/futex
|
||||
(create/join/mutex/condition/thread-locals), signals via M17 signals-over-IPC,
|
||||
termios job control — and then **xonsh**, which wants all three.
|
||||
|
||||
### The spawn model fits
|
||||
|
||||
One genuinely good alignment: **CPython does not need fork.** `subprocess` maps
|
||||
cleanly onto a posix_spawn-style model — exactly what danos has — and the existing
|
||||
exit-notification-via-endpoint is a *better* fit for `Popen.wait` than Unix's
|
||||
`wait` semantics. `os.fork` simply won't exist, as on Windows, and almost nothing
|
||||
in practice cares.
|
||||
|
||||
## Risks and gotchas
|
||||
|
||||
- **Binary size — and the size doctrine that makes it acceptable.** danos's
|
||||
leanness mandate applies to the **operating system**: the kernel and the system
|
||||
services stay small (the kernel is measured in kilobytes, not megabytes), and
|
||||
nothing in this track changes that — Python never enters the OS layer. An
|
||||
**application** budget is different: a statically-linked CPython with its
|
||||
module set will be tens of megabytes in ReleaseSafe (the measured ~2×
|
||||
safety-check factor compounds it), and that is *allowed* — applications live
|
||||
on the FAT image, not in the kernel's world. It still shapes the image, and it
|
||||
means every Python service shares one interpreter binary + per-service
|
||||
scripts, so the spawn model needs **argv** before "run this .py" works at all.
|
||||
- **FAT mtime granularity is 2 seconds.** CPython's `.pyc` cache validation is
|
||||
mtime-based by default; a rapid edit-run cycle can see stale bytecode. Use
|
||||
hash-based `.pyc` invalidation (PEP 552, `--invalidation-mode checked-hash` at
|
||||
freeze time) or accept the quirk during bring-up.
|
||||
- **FAT name lookups are case-insensitive.** Long file names preserve case but
|
||||
match insensitively — the same world Python inhabits on Windows/macOS, so
|
||||
importlib copes, but two modules differing only by case cannot coexist on the
|
||||
image.
|
||||
- **`strtod`/float repr correctness.** Python's float round-tripping is exacting;
|
||||
lift musl's conversions rather than writing them, and run CPython's float tests
|
||||
early.
|
||||
- **Threadless build is load-bearing, initially.** Like WASI, the first builds have
|
||||
no working `threading`. The escape hatch is real (danos has native threads and
|
||||
futexes; a pthread subset is Phase-5 work) but keep the configuration honestly
|
||||
single-threaded until then.
|
||||
- **The test suite is the porting harness.** CPython ships its own conformance
|
||||
suite; getting `test_builtin`, `test_int`, `test_float`, `test_io` running on
|
||||
danos early converts "it seems to work" into a checklist. Budget image space for
|
||||
the test `Lib/` tree during bring-up.
|
||||
- **Entropy before exposure.** `PYTHONHASHSEED=0` is fine for bring-up and wrong
|
||||
forever; hash randomization exists because attacker-controlled dict keys are a
|
||||
denial-of-service vector. Land the entropy source before any Python service
|
||||
parses external input.
|
||||
|
||||
## Related
|
||||
|
||||
- [python-on-danos-milestones.md](python-on-danos-milestones.md) — the execution
|
||||
plan (P0–P5) for this note.
|
||||
- [c-library-compatibility.md](c-library-compatibility.md) — the mini-libc
|
||||
(libdanos-c) design behind Phase 0.
|
||||
- [character-devices-and-tty.md](character-devices-and-tty.md) — the stream-node /
|
||||
console / no-pty design behind Phase 1.
|
||||
- [zig-self-hosting.md](zig-self-hosting.md) — the sibling track; shares Phase 1,
|
||||
diverges at the libc.
|
||||
- [os-development/syscall.md](os-development/syscall.md) — the kernel ABI the
|
||||
mini-libc bottoms out in.
|
||||
- [os-development/vdso.md](os-development/vdso.md) — the public ABI boundary the
|
||||
`danos` extension module wraps.
|
||||
- [os-development/sysv.md](os-development/sysv.md) — the entry stack (argv/envp)
|
||||
the spawn-argv work extends.
|
||||
- [device-driver-development/ipc.md](device-driver-development/ipc.md) — the IPC
|
||||
surface Python services speak.
|
||||
- [file-system-development/file-system-hierarchy.md](file-system-development/file-system-hierarchy.md)
|
||||
— where `Lib/` and `site-packages` land on the image.
|
||||
@@ -0,0 +1,481 @@
|
||||
# Security track execution plan: paths, protocol namespace, SMEP/SMAP
|
||||
|
||||
The design is settled in
|
||||
[communication.md](os-development/communication.md),
|
||||
[protocol-namespace.md](os-development/protocol-namespace.md),
|
||||
[file-system-hierarchy.md](file-system-development/file-system-hierarchy.md),
|
||||
and [smep-smap.md](os-development/smep-smap.md). This file is the build order
|
||||
— one phase at a time, each phase green before the next starts. Delete or
|
||||
archive this file when the last milestone lands.
|
||||
|
||||
**Context a fresh session should read first:** the four design docs above,
|
||||
then this plan's *Settled decisions* section — those decisions came out of a
|
||||
full-code grounding pass (2026-07-31) and must not be re-derived or reopened.
|
||||
|
||||
**Definition of green, every phase:** `zig build` clean, `zig build test`
|
||||
clean, `python3 test/qemu_test.py` passes (existing scenarios plus the
|
||||
phase's new ones — record the suite count in the checkbox), and the relevant
|
||||
design doc's status/known-gap lines updated in the same commit. Commit per
|
||||
green phase, style `area: lower-case declarative summary`, **no co-author
|
||||
trailers**. On a suite failure, read
|
||||
`zig-out/qemu-test/<case>-failed-serial.log` before changing anything.
|
||||
|
||||
**Workflow:** work in a dedicated git worktree on feature branches cut from
|
||||
`main` (one branch per milestone group as marked below); when a group's
|
||||
phases are all green, merge to `main` and push. The loop marks a phase `[x]`
|
||||
in the same commit that lands it.
|
||||
|
||||
**Numbering note:** milestones use the design docs' own names (PM, H1–H3,
|
||||
HS, P1–P4) — the M-number sequence is left alone (M19–M22 are reserved by
|
||||
the logging/USB-lifecycle track).
|
||||
|
||||
## Status
|
||||
|
||||
**Live state — updated on `main` after every phase, so this file read from a
|
||||
plain `main` checkout always tells the truth about where the work is.**
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Working on | **P4a** — protocol rebase onto `envelope.Define` (next) |
|
||||
| Branch carrying it | `feat/security-group-2` (pushed to origin) |
|
||||
| On `main` | Phase 0, PM, H1, P1, P2, P3 (group 2 merged) |
|
||||
| Awaiting merge | nothing — group 2 is on `main` |
|
||||
| Suite | 109 cases, all passing |
|
||||
| Last updated | 2026-08-01 |
|
||||
|
||||
A checkbox below means the phase met its definition of green and was
|
||||
committed — on the branch named above, which reaches `main` at the next
|
||||
group boundary.
|
||||
|
||||
- [x] **Phase 0** — baseline: suite green on `main` (106/106, 2026-07-31; `zig build` + `zig build test` clean at 9a32380), plan committed
|
||||
- [x] **PM** — path-migration flag-day (`/etc`→`/system/configuration`, `/var/log`→`/system/logs`, `/mnt/usb`→`/volumes/usb`; vfs carve-out for the two writable `/system` subtrees, FAT's `/var` mount split in two; suite 106/106)
|
||||
- [x] **H1** — the `user-memory` module; nine stragglers converted; leaf U/S+W checks (plus physmap-coverage confirmation, so an `mmio_map`'d buffer cannot fault ring 0 — this also closes the same hazard on the IPC path; `fs_resolve`'s out-capacity bound made overflow-safe; suite 107/107)
|
||||
- [x] **merge** group 1 → main, push (f3bc23c, 2026-07-31)
|
||||
- [x] **P1** — envelope module + `Define`; vfs `NodeKind.protocol` + open-reply-capability; client `Channel` (mechanics only, nothing converted; suite unchanged at 107)
|
||||
- [x] **P2** — registry in init; `/protocol` reserved; ServiceId flag-day (11 binds, 17 lookups; `protocol.csv` grants, chain-attested identity, dead-owner rebind; the kernel's endpoint-death sweep generalized off the retired registry; suite 108/108). Three adversarial review rounds closed six defects a green suite had missed: a forged power event could shut the machine down; the ping path leaked a capability per call, first in init and then in the shared harness; supervisor attestation by name was defeated by a laundering deputy; and the kernel let any handle-holder bind signals, timers, exits and IRQs to an endpoint it did not own.
|
||||
- [x] **P3** — open grants: `protocol.csv` enforcement, denial test. `onOpen`
|
||||
consults the manifest with the same chain-attested identity a bind uses, and a
|
||||
refused caller gets the *same* answer as one naming a contract nobody bound —
|
||||
`-ENOENT`, no capability, the same reply bytes, no log line, and both questions
|
||||
asked on every open so there is nothing to time. Twenty-seven `open` rows cover
|
||||
the whole live client set. One wrinkle the plan had not foreseen: the driver
|
||||
tree is three deep (device manager → PS/2 bus → keyboard/mouse) and attestation
|
||||
is one hop, so a legitimate grandchild read exactly like a laundering deputy;
|
||||
the manifest gained a third permission, `supervise`, which names an authorized
|
||||
supervising task per contract and is deliberately **open-only**, leaving P2's
|
||||
bind attestation and every refusal it makes untouched (suite 109/109)
|
||||
- [x] **merge** group 2 → main, push
|
||||
- [ ] **P4a** — clean protocols rebased onto `Define` (vfs, block, display, scanout, input)
|
||||
- [ ] **P4b** — misfit protocols rebased (device-manager, power, usb-transfer)
|
||||
- [ ] **P4c** — harness subscriber lift + badge-scoped per-client integers
|
||||
- [ ] **merge** group 3 → main, push
|
||||
- [ ] **H2** — SMEP on every core
|
||||
- [ ] **HS** — SYSRET canonical-RIP guard
|
||||
- [ ] **H3** — SMAP + boot-patched `clac`; `-cpu max` in the harness; negative tests
|
||||
- [ ] **merge** group 4 → main, push
|
||||
|
||||
---
|
||||
|
||||
## Settled decisions (grounding pass, 2026-07-31 — do not reopen)
|
||||
|
||||
These resolve every open wrinkle the code inventory surfaced. Where one
|
||||
amends a design doc, the amendment lands in the same commit as the phase
|
||||
that implements it.
|
||||
|
||||
1. **Every packet — request, reply, and event — begins with the envelope
|
||||
`Header`, exactly as the design says; the header is FOLDED, never
|
||||
stacked.** It absorbs each protocol's existing operation/id fields
|
||||
rather than sitting on top of them, so the two apparent 64-byte-limit
|
||||
offenders fit: `ChildAdded` re-lays to 60 bytes (its packed operation
|
||||
byte and `device_id` become `Header.operation`/`.target`);
|
||||
`InterruptReport` puts `device_token` in `Header.target` and trims
|
||||
inline data 48 → 40 bytes (largest real report today is 8). A
|
||||
headerless-events variant was considered and REJECTED (2026-07-31): it
|
||||
re-invents per-protocol mini-headers and breaks uniform tooling. No
|
||||
design-doc amendment; `Define`'s event check stays ≤ 64 *including*
|
||||
the header.
|
||||
2. **Bind/open authorization is chain-attested identity: the
|
||||
kernel-stamped binary name PLUS the supervision chain**, both read from
|
||||
the kernel's process records (`ProcessDescriptor` carries `name` and
|
||||
`supervisor`; init walks the chain with `process_enumerate` — no new
|
||||
protocol). A grant row names the binary *and* the supervisor expected
|
||||
in its chain, so a malicious process re-spawning a granted binary
|
||||
(ungated `spawn`, hostile argv — the confused deputy) is refused: its
|
||||
chain roots at the attacker, not at init or device-manager. Name alone
|
||||
is NOT sufficient — that was considered and rejected (2026-07-31).
|
||||
Pure delegation (device-manager forwarding driver binds as
|
||||
capabilities — "option B") is deliberately deferred to P5, whose
|
||||
spawner-wired namespaces subsume it. Amends protocol-namespace.md's
|
||||
"Authorization" bullet in P2.
|
||||
3. **Grants live in a new manifest, `/system/configuration/protocol.csv`**
|
||||
(rows: `binary-path, supervisor, bind|open, protocol-name`, where
|
||||
`supervisor` is the binary expected in the caller's supervision chain —
|
||||
`init` for init's own children, `kernel` for harness-spawned fixtures),
|
||||
not in extra init.csv columns — today every post-path init.csv field is
|
||||
argv, and overloading that is ambiguous. init parses both files.
|
||||
*(P2 spelling: the supervisor column carries the binary exactly as the
|
||||
kernel stamped it, so init's own children say `/system/services/init` and
|
||||
the drivers say `/system/services/device-manager`; `kernel` stays a bare
|
||||
word because a kernel task has no binary. A trailing `*` on any field
|
||||
matches a subtree, which is how decision 4's `/test/` rule is expressed.)*
|
||||
*(Clarification, 2026-08-01: the supervisor column names **the authorized
|
||||
supervising task, matched by identity** — the binary is how the row spells
|
||||
it, but init checks the task id. `kernel` is satisfied only by supervisor
|
||||
id 0 (which only the kernel confers — user `system_spawn` always stamps the
|
||||
caller); init's own path only by this init's task id; any other path only by
|
||||
a task init spawned itself or one the kernel spawned. Matching the supervisor
|
||||
by *name* alone is defeated by a laundering deputy — an attacker runs its own
|
||||
instance of `/system/services/init`, has that spawn `/system/services/input`,
|
||||
and both stamped names satisfy the row while the chain is entirely the
|
||||
attacker's. Walking to the root of the chain does not fix it either, since
|
||||
the laundered chain still roots at the real PID 1.)*
|
||||
*(P3 amendment: a third permission, `supervise`, joins `bind|open`. One-hop
|
||||
attestation cannot express the one three-deep chain in the tree — the device
|
||||
manager starts the PS/2 bus, and the bus starts the keyboard and mouse
|
||||
drivers — and nothing structural tells that chain apart from the laundering
|
||||
deputy, since both are a granted binary spawned by a granted binary. Only
|
||||
policy can: a `supervise` row names the authorized supervising task the way
|
||||
every other row names a claimant (binary, its own supervisor, the contract it
|
||||
concerns), and an `open` row may then name that task in its supervisor
|
||||
column. The delegate is itself attested the ordinary strict way, so the chain
|
||||
still anchors in init or the kernel one hop above it and the recursion stops
|
||||
there. It is **open-only** on purpose — a delegate may vouch for what its
|
||||
children *reach*, never for what they *claim* — so the bind path is
|
||||
byte-for-byte P2's and the laundering-deputy refusal is untouched.)*
|
||||
4. **Test fixtures bind under `/protocol/test/...`**, granted to any
|
||||
binary whose path starts `/test/` — the subtree-scoping rule from the
|
||||
design doc, dogfooded. `shared_memory_test` (the borrowed-ServiceId
|
||||
hack) becomes `/protocol/test/shared-memory`; process-test's child gets
|
||||
`/protocol/test/process`.
|
||||
5. **Rebind after provider death:** a `bind` hitting an existing binding
|
||||
succeeds only if the current owner process is dead (init checks
|
||||
liveness); otherwise `-EBUSY`. Init also unbinds in `restartChild`
|
||||
before respawning its own children. This preserves collision-refusal
|
||||
while making restart work for providers init does not supervise.
|
||||
6. **Cross-thread service access** (the display mouse-listener's
|
||||
per-thread self-lookup, `display.zig:512`): threads resolve and open
|
||||
`/protocol/<name>` like any client — once, at thread startup. No
|
||||
special mechanism.
|
||||
7. **The envelope module is `library/protocol/envelope/envelope.zig`**
|
||||
(module name `envelope`) — the one protocol-package module not ending
|
||||
in `-protocol`, because it is not a protocol. Wired as a new
|
||||
`addModule` row in `library/protocol/build.zig` with its host tests in
|
||||
that package's test step.
|
||||
8. **The QEMU harness gains `-cpu max`** (in `qemu_args`,
|
||||
`test/qemu_test.py:66-83`) so TCG exposes SMEP/SMAP — without it the
|
||||
enabled paths never execute in CI. Landed in H2 so the flag soaks
|
||||
before H3 depends on it.
|
||||
9. **Scenario fixtures that need the registry are init-driven.** Kernel
|
||||
test cases that today spawn providers directly (shared-memory,
|
||||
process-test) either spawn init first or move to init.csv-driven
|
||||
scenario boots — resolved per-case in P2 with the suite as the
|
||||
arbiter.
|
||||
*(P2 resolution: init gained a `registry` argv role — it mounts
|
||||
`/protocol`, reads the grants, and starts no services — and each affected
|
||||
case calls `spawnRegistry(rd)` before its own providers. Every case keeps
|
||||
its own spawn set, so no scenario had to be re-shaped.)*
|
||||
10. **The capsule-staleness caveat is documented, not fixed.** On-volume
|
||||
edits to `/system/configuration/*.csv` do not reach the initrd copy
|
||||
the loader boots (capsule shadows tree). Same drift exists today with
|
||||
`/etc`; PM adds the note to file-system-hierarchy.md and moves on.
|
||||
|
||||
---
|
||||
|
||||
## PM — path-migration flag-day
|
||||
|
||||
One commit, everything moves together. The authoritative site inventory is
|
||||
the grounding pass; the checklist order:
|
||||
|
||||
1. Move repo `etc/` → `configuration/` sources; fix the three CSVs'
|
||||
self-referencing headers (`etc/init.csv:1,12`, `etc/devices.csv:1`,
|
||||
`etc/init-diagnose.csv:1`).
|
||||
2. `build.zig:309-311`: bundled entries `etc/...` →
|
||||
`system/configuration/...` (this alone re-shapes the image, manifest,
|
||||
and capsule — `tools/make-fat-image.py` and the EFI loader need
|
||||
nothing; the tree-walk fallback even starts picking the CSVs up, a
|
||||
bonus fix).
|
||||
3. `system/kernel/vfs.zig` `mountBackend` (`:332-340`): allow exactly
|
||||
`/system/configuration` and `/system/logs` as backend prefixes beneath
|
||||
the initrd `/system` mount; keep refusing everything else under
|
||||
`/system` and `/test`.
|
||||
4. `system/services/fat/fat.zig`: `mount_point` → `/volumes/usb` (`:25`);
|
||||
replace the `/var` mount (`:155`) with two `mountRewritten` calls for
|
||||
`/system/configuration` and `/system/logs`; update the mount log lines
|
||||
(the harness matches them).
|
||||
5. `system/services/init/init.zig:76` and
|
||||
`system/services/device-manager/device-manager.zig:48`: open the new
|
||||
CSV paths; update the message strings (`init.zig:77,92`,
|
||||
`device-manager.zig:49,61-63,454`).
|
||||
6. `system/services/logger/logger.zig:44`: `base = "/system/logs"`
|
||||
(buffers derive from `base.len` comptime — nothing else changes).
|
||||
7. `system/kernel/tests.zig:2808-2810`: exclude `/system/configuration/`
|
||||
from the spawn-everything sweep (the CSVs are not programs).
|
||||
8. Tests: `fat-test.zig` and `vfs-test.zig` `/mnt/usb` literals →
|
||||
`/volumes/usb`; harness regexes `test/qemu_test.py:175,211,632,717`.
|
||||
9. Comment sweep (init, device-manager, logger, fat, engine, vfs, abi,
|
||||
file-system, csv, device, protocol/device-manager, drivers, acpi,
|
||||
build.zig — full list in the grounding inventory); delete vestigial
|
||||
repo `var/`.
|
||||
|
||||
**Test:** no new case — the existing 106 are the test, since fat/logger/
|
||||
init/device-manager scenarios all assert the new paths through their
|
||||
regexes. Suite stays 106.
|
||||
|
||||
## H1 — user-memory copy discipline
|
||||
|
||||
New kernel module `system/kernel/user-memory.zig`:
|
||||
|
||||
- `copyFromUser` moves from ipc-synchronous.zig (which re-exports or
|
||||
imports it); new `copyToUser(user_as, user_va, source) bool` — the
|
||||
mechanical mirror (kernel-source `copyAcross` already does this for IPC
|
||||
replies at `ipc-synchronous.zig:431,460`).
|
||||
- The page walk gains leaf U/S and writable checks: `paging.translateIn`
|
||||
(`architecture/x86_64/paging.zig:513-525`) tests only `present` today —
|
||||
add a flags-accumulating variant (2 MiB leaves included); reads require
|
||||
U/S, writes require U/S+W. Closes the TODO at
|
||||
`ipc-synchronous.zig:20-22`.
|
||||
- Convert the nine stragglers (table in smep-smap.md). Read direction is
|
||||
local to `process.zig`; the write direction restructures callees with
|
||||
kernel bounce buffers: `scheduler.enumerate` (`scheduler.zig:1209`),
|
||||
`devices_broker.enumerate` (`devices-broker.zig:136`), `log.readAt`
|
||||
(`log.zig:209`), and the `fs_node` flows through
|
||||
`vfs.nodeRead/nodeStatus/nodeReaddir` (`vfs.zig:257/269/289`).
|
||||
|
||||
**Test:** kernel unit coverage in `system/kernel/tests.zig` for
|
||||
`copyToUser` bounds/permission refusals; one new QEMU case `user-memory` —
|
||||
a fixture passes an unmapped-but-in-range buffer to `klog_read`,
|
||||
`process_enumerate`, and `fs_resolve` and asserts `-EFAULT` returns with
|
||||
the system still alive (today each would oops the kernel). Suite 107.
|
||||
|
||||
## P1 — envelope, vfs additions, Channel
|
||||
|
||||
- `library/protocol/envelope/envelope.zig`: `Header` {operation:u32, pad,
|
||||
target:u64}, `Status`, reserved verbs (describe=0, enumerate=1,
|
||||
subscribe=2, unsubscribe=3, protocol verbs from 16), `packet_maximum`
|
||||
= 256 / `post_maximum` = 64 (the floor constants protocols compile
|
||||
against — nothing exports them today), and comptime
|
||||
`Define(.{name, version, operations, events})` generating request/reply
|
||||
types, encode/decode, a provider dispatch table (automatic `describe`,
|
||||
`-ENOSYS` for unknown verbs), and compile-time size checks:
|
||||
request/reply ≤ 256, each `.events` entry ≤ 64 *including* its Header
|
||||
(decision 1). Host unit tests in the protocol package's test step.
|
||||
- `library/protocol/vfs/vfs-protocol.zig`: `NodeKind.protocol = 7`; the
|
||||
open-reply-may-carry-capability convention documented in the module.
|
||||
Rewrite the value-pinning unit test (`:108-117`) to pin the *new*
|
||||
stable values.
|
||||
- `library/kernel/file-system.zig` + a new `Channel` type in
|
||||
`library/kernel` (or `library/client`): `open("/protocol/<name>")` →
|
||||
resolve, vfs open, receive the reply capability → a `Channel` wrapping
|
||||
the handle with `call`/typed helpers. Nothing uses it yet — P2 converts
|
||||
the world.
|
||||
- Docs: vfs-protocol.md's NodeKind table gains value 7 (no
|
||||
protocol-namespace.md amendment — decision 1 conforms to it as written).
|
||||
|
||||
**Test:** host unit tests only (envelope round-trips, size-check compile
|
||||
errors via `error` tests, Channel plumbing against a mock). Suite stays
|
||||
107.
|
||||
|
||||
## P2 — the registry; ServiceId flag-day
|
||||
|
||||
The single biggest phase; one branch, may be several commits, green at the
|
||||
end of each.
|
||||
|
||||
- **init as registry backend** (`system/services/init/init.zig`): a second
|
||||
endpoint (the supervision endpoint's reply-empty loop is unsuitable for
|
||||
a vfs backend); serve vfs `open`/`readdir` over `/protocol` plus the
|
||||
`bind` operation (name payload + capability). Mount `/protocol` before
|
||||
spawning children. Parse `/system/configuration/protocol.csv`
|
||||
(decision 3). Authorization by chain-attested identity (decision 2):
|
||||
badge → kernel process records → binary name **and** supervision chain
|
||||
(walk `supervisor` links) checked against the grant row's expected
|
||||
supervisor. Unbind on child death in `restartChild`; dead-owner rebind
|
||||
rule (decision 5).
|
||||
Provenance: readdir/diagnostics show name → pid → binary path.
|
||||
- **Kernel:** reserve `/protocol` — `mountBackend` refuses mounts at or
|
||||
under it once bound, `installMount`'s remount-replace path refuses it,
|
||||
and `fs_unmount` refuses it (`vfs.zig:164-181,332-351`,
|
||||
`process.zig:1879-1889`). First mount wins (init is PID 1).
|
||||
- **Harness:** `library/kernel/service.zig` `Callbacks.service:
|
||||
?abi.ServiceId` becomes a protocol name; the register call (`:49-51`)
|
||||
becomes bind-with-retry via the registry.
|
||||
- **Flag-day conversion** — all 11 registration sites and 17 lookup sites
|
||||
from the grounding inventory: providers (input:123, ps2-bus:223,
|
||||
device-manager:569, acpi:193, usb-xhci-bus:676, usb-storage:205,
|
||||
fat:307, display:699, virtio-gpu:550, shared-memory-server:43,
|
||||
process-test:130 → `/protocol/test/...` per decision 4); clients
|
||||
(input-client:53, display-client:28, driver.zig:173, usb.zig:139,
|
||||
block.zig:72+87, ps2-bus keyboard:35 + mouse:34, virtio-gpu:478,
|
||||
display:314+512 (decision 6), acpi:212, init:218+245 — init
|
||||
short-circuits its own registry, shared-memory-client:22,
|
||||
process-test:85, device-list:22, crash-test:32). Retry loops keep their
|
||||
cadence, wrapping resolve+open instead of lookup.
|
||||
- **Delete:** `abi.zig:36-37` (syscall ids — leave holes),
|
||||
`abi.zig:287-303` (enum), `process.zig:223-224,314-343`,
|
||||
`ipc-synchronous.zig:41-43,646-664` and the registry sweep in
|
||||
`:121-140`; the wrappers `library/kernel/ipc.zig:33-35,47-50`; comment
|
||||
sweep (irq.zig:50, tests.zig:3744, vdso.md's syscall table, the docs
|
||||
list in the inventory).
|
||||
- Kernel-spawned test scenarios made init-driven where they need the
|
||||
registry (decision 9).
|
||||
|
||||
**Test:** new QEMU case `protocol-registry`: a fixture asserts (a) bind of
|
||||
an ungranted name → `-EPERM`, (b) bind collision with a live owner →
|
||||
`-EBUSY`, (c) provider kill → re-resolve reaches the restarted instance.
|
||||
Every existing scenario doubles as conversion proof. Suite 108.
|
||||
|
||||
## P3 — open grants (restriction stage one)
|
||||
|
||||
- `protocol.csv` `open` rows enforced in the registry's `open` handler,
|
||||
same name-based identity as bind. Default rows grant what today's
|
||||
clients need (from the P2 conversion table); a deliberate hole for the
|
||||
test fixture.
|
||||
- Docs: protocol-namespace.md stage-one section gets its "landed" line.
|
||||
|
||||
**Test:** new QEMU case `protocol-denied`: a fixture granted
|
||||
`/protocol/test/shared-memory` but not `/protocol/display` asserts open of
|
||||
the first succeeds and the second fails identically to not-found. Suite
|
||||
109.
|
||||
|
||||
*Landed. Four things the plan did not foresee, recorded because P4 and P5
|
||||
inherit them:*
|
||||
|
||||
- *`supervise` — decision 3's amendment. The PS/2 keyboard and mouse drivers
|
||||
are started by the PS/2 bus driver, which the device manager started: the
|
||||
tree's one three-deep chain, and one hop deeper than attestation reaches.
|
||||
Nothing structural separates it from the laundering deputy, so the manifest
|
||||
says which delegate is authorized, per contract. Open-only, so P2's bind
|
||||
attestation is unchanged.*
|
||||
- *Indistinguishability is a claim about work, not only about bytes. `onOpen`
|
||||
refreshes the process table, identifies the caller, scans the grants and
|
||||
scans the bindings on **every** open and forms one verdict at the end; and
|
||||
it logs nothing on any branch, because `klog_read` is ungated (a line
|
||||
written on one branch is a line the refused caller can read) and a serial
|
||||
line is milliseconds it could time. The operator's diagnosis is the pair the
|
||||
namespace publishes anyway: `readdir /protocol` for what is bound, the
|
||||
manifest for who may reach it.*
|
||||
- *The fixture is `protocol-denied-test`, and its scenario boots the **input
|
||||
service** so the forbidden name is genuinely bound — the fixture reads the
|
||||
namespace listing to prove it before asking for it. Without a live provider
|
||||
the case would be comparing two boot races and asserting nothing.*
|
||||
- *Two channels stay open by design, named rather than papered over: `readdir`
|
||||
over `/protocol` lists every bound name to anyone (deliberate — the tree is
|
||||
diagnosable), and `/system/configuration/protocol.csv` is world-readable on
|
||||
the `/system` mount. Stage one hides neither the set of contracts nor the
|
||||
policy; what it removes is the **oracle in the reply**, which is what stage
|
||||
two's parked and faked opens depend on.*
|
||||
|
||||
## P4a — clean protocols onto Define
|
||||
|
||||
vfs, block, display, scanout, input — the modules whose shapes map
|
||||
directly (grounding inventory §1,3,4,6,8):
|
||||
|
||||
- vfs: `node` → `target`; `Reply.node` (open's result) moves to reply
|
||||
payload — `library/kernel/file-system.zig` decoders change; readdir
|
||||
stays a protocol verb.
|
||||
- block: pure renumber; `attach`'s DMA cap rides the call as today.
|
||||
- display: the overloaded 40-byte `Request` becomes per-operation structs
|
||||
(attach_scanout's field abuse dies); `layer` → `target`; blit payload
|
||||
grows to 224 bytes.
|
||||
- scanout: renumber; drop its bogus `message_maximum=64` (sync floor is
|
||||
256); fix virtio-gpu's hard-coded `service.run(256, …)` to the
|
||||
generated constant.
|
||||
- input: subscribe merges into reserved subscribe; publish renumbers;
|
||||
the event re-lays onto the Header folded (operation = event kind,
|
||||
target = 0; 16 + 28-byte payload = 44 ≤ 64); **input moves onto the
|
||||
service harness** (it is the last hand-rolled loop, no ping/terminate
|
||||
compliance today).
|
||||
|
||||
**Test:** new QEMU case `protocol-conformance`: a fixture opens every
|
||||
registered protocol and asserts `describe` answers (name, version) and an
|
||||
unknown verb returns `-ENOSYS`. Existing input/display/fat scenarios prove
|
||||
the rebase. Suite 110.
|
||||
|
||||
## P4b — misfit protocols onto Define
|
||||
|
||||
device-manager, power, usb-transfer (inventory §2,5,7 — the u8-operation
|
||||
re-layouts and raw-offset readers):
|
||||
|
||||
- device-manager: u8 operations → Header; its enumerate=4/subscribe=5
|
||||
merge into the reserved verbs; `ChildAdded` splits its dual role —
|
||||
request struct and event, both Header-first (folded to 60 B ≤ 64);
|
||||
`ChildRemoved`'s (parent, bus_address) addressing stays payload.
|
||||
- power: u8 operations → Header; subscribe merges; **init's raw
|
||||
byte-offset event parsing (`init.zig:171-173`) and acpi's
|
||||
`message[0]` dispatch (`acpi.zig:435-467`) are rewritten against the
|
||||
generated types** — the two silent-breakage sites, called out so the
|
||||
loop treats them as first-class conversions, not collateral.
|
||||
- usb-transfer: `device_token` → `target` (already layout-identical);
|
||||
`InterruptReport` re-lays onto the Header (`device_token` → `target`,
|
||||
inline data trimmed 48 → 40 — largest real report is 8); control/bulk
|
||||
budgets re-verified by `Define` (Status absorbs `actual_length`).
|
||||
|
||||
**Test:** existing scenarios are the proof (device hot-add, power button,
|
||||
USB storage/HID all exercise these wires); the conformance case now covers
|
||||
three more providers. Suite 110.
|
||||
|
||||
## P4c — harness subscriber lift + badge scoping
|
||||
|
||||
- `library/kernel/service.zig` grows the subscriber table, exit-
|
||||
notification sweep, and fan-out loop declared via `Define(.events)`;
|
||||
input (:33-116), acpi (:67-68,393-406), and device-manager (:155-166)
|
||||
delete their hand-rolled variants. One sweep idiom: exit notifications
|
||||
(fat's pattern), replacing input's process-list polling and acpi's
|
||||
none-at-all.
|
||||
- Badge-scoped per-client integers (the guessable-id holes): fat node ids
|
||||
gain owner checks on every operation (`fat.zig:72-76`), xhci device
|
||||
tokens validate sender and sweep on exit (`usb-xhci-bus.zig:66-88,479`),
|
||||
display layers gain an owner field.
|
||||
|
||||
**Test:** extend the fat scenario: a second fixture guesses the first's
|
||||
node id and asserts refusal; kernel-side unit test for the harness sweep.
|
||||
Suite 111.
|
||||
|
||||
## H2 — SMEP
|
||||
|
||||
- Generalize the cpuid helper (`apic.zig:351-365`, private, subleaf-0) to
|
||||
a shared probe; gate on `cpuid(0).eax >= 7`.
|
||||
- Set CR4 bit 20 in `per-cpu.zig:initSystemCall` (or a sibling called
|
||||
from both `cpu.zig:148` and `smp.zig:181` — the one path both BSP and
|
||||
every AP already execute). Log enabled/absent (fail-open, IOMMU style).
|
||||
- Harness: add `-cpu max` to `qemu_args` (decision 8).
|
||||
|
||||
**Test:** new QEMU case `fault-smep` mirroring the `fault-*` injector
|
||||
pattern (`tests.zig:3906-3938`): ring-0 call through a pointer into a
|
||||
user-mapped page; expect `page fault (vector 14)` + `error code : 0x11` +
|
||||
kernel-half IP, machine reports the exception (deliberate-exception cases
|
||||
put the text in `expect`, per `qemu_test.py:189`). Suite 112.
|
||||
|
||||
## HS — SYSRET canonical-RIP guard
|
||||
|
||||
- `isr.s` syscall exit (`:256`): validate RCX canonicality before
|
||||
`sysretq`; non-canonical → `iretq` fallback (or kill), per the hazard
|
||||
note at `isr.s:192-194`.
|
||||
|
||||
**Test:** kernel unit case driving a thread whose return RIP is forged
|
||||
non-canonical via the syscall path if constructible cheaply; otherwise the
|
||||
review-level proof plus the existing fault cases regression. Suite 112.
|
||||
|
||||
## H3 — SMAP
|
||||
|
||||
- `clac` patch site at `isr_common` (`isr.s:367`, before the CPL test —
|
||||
ring-0 nesting inherits AC too): assemble a 3-byte NOP, patch to `clac`
|
||||
at boot through the physmap (the `process.zig:1990-1995` /
|
||||
`smp.zig:79-111` precedent), BSP-only before AP bring-up.
|
||||
- Set CR4 bit 21 in the same per-CPU init as SMEP.
|
||||
- Coding standards: kernel code touches user memory only through
|
||||
`user-memory`; no `stac` anywhere, ever.
|
||||
|
||||
**Test:** new QEMU case `fault-smap`: ring-0 deliberate read of a mapped
|
||||
user page; expect vector 14 + `error code : 0x1` + kernel IP. And the
|
||||
whole suite becomes the tripwire — any missed straggler now fails loudly.
|
||||
Suite 113.
|
||||
|
||||
---
|
||||
|
||||
**Explicitly out of scope** (own tracks, after this plan): P5 restriction
|
||||
stage two (spawn's initial capability, namespace views, parked replies,
|
||||
dedicated killable channels — needs a design session on the spawn
|
||||
contract), file-path namespacing, trusted UI (display track), pipes/FIFOs
|
||||
(Python track), `/applications` and its storage, `fs_mount`/`spawn`/
|
||||
`klog_read` gating beyond the `/protocol` reserved prefix, KPTI, IPC
|
||||
priority inheritance.
|
||||
@@ -95,9 +95,9 @@ hypervisor configured for UEFI firmware and an xHCI USB controller.
|
||||
|
||||
| Requirement | Detail | Source |
|
||||
|---|---|---|
|
||||
| **x86-64, 64-bit only** | Kernel and loader are built exclusively for `x86_64`; the loader rejects any non-x86-64 kernel ELF (`error.WrongArchitecture`). | `build.zig:481`, `boot/efi.zig:622` |
|
||||
| **x86-64, 64-bit only** | Kernel and loader are built exclusively for `x86_64`; the loader rejects any non-x86-64 kernel ELF (`error.WrongArchitecture`). | `build-support/build.zig` (`freestandingTarget`), `boot/efi.zig:622` |
|
||||
| **Long mode + PAE + NX** | AP trampoline sets `CR4.PAE`, `EFER.LME`, `EFER.NXE`; NX is used in kernel page-table entries. | `system/kernel/architecture/x86_64/trampoline.s:62` |
|
||||
| **SSE / SSE2** | Baseline: the compiler emits SSE for ordinary struct copies. Trampoline enables `CR4.OSFXSR` + `OSXMMEXCPT` and clears `CR0.EM`. | `build.zig:477`, `trampoline.s:62` |
|
||||
| **SSE / SSE2** | Baseline: the compiler emits SSE for ordinary struct copies. Trampoline enables `CR4.OSFXSR` + `OSXMMEXCPT` and clears `CR0.EM`. | `build-support/build.zig` (`freestandingTarget`), `trampoline.s:62` |
|
||||
| **`syscall` / `sysret`** | Primary user↔kernel entry path. `EFER.SCE` enabled; `STAR`/`LSTAR`/`SFMASK` programmed per core. (`int 0x80` exists as a parallel gate.) | `architecture/x86_64/per-cpu.zig:71`, `isr.s:196` |
|
||||
| **Local APIC (xAPIC)** | LAPIC accessed via MMIO at `0xFEE00000`. LAPIC ID read as a `u8` — classic xAPIC. **x2APIC is not supported** (no MSR path). | `apic.zig:67`, `apic.zig:646` |
|
||||
| **CPUID + RDTSC** | CPUID leaf `0x15` for TSC frequency; RDTSC is the monotonic clock. | `apic.zig:333`, `apic.zig:113` |
|
||||
@@ -108,7 +108,7 @@ hypervisor configured for UEFI firmware and an xHCI USB controller.
|
||||
- **UEFI only.** A custom UEFI application loader is installed to
|
||||
`\EFI\BOOT\BOOTX64.efi`. There is **no BIOS, multiboot, or limine** path. The
|
||||
loader tolerates UEFI Class-3 machines with no legacy PIC/PIT.
|
||||
(`build.zig:246`, `boot/efi.zig`)
|
||||
(`build/images.zig` — the EFI/BOOT install — and `boot/efi.zig`)
|
||||
- **ACPI is the hardware-discovery mechanism.** The RSDP is taken from the UEFI
|
||||
configuration table (ACPI 2.0 GUID preferred, 1.0 fallback). Without a valid
|
||||
RSDP there is **no device discovery** — no SMP, no IOAPIC routing, no PCI/USB.
|
||||
@@ -118,7 +118,7 @@ hypervisor configured for UEFI firmware and an xHCI USB controller.
|
||||
(`system/kernel/acpi.zig:3`)
|
||||
- The loader reads `/system/kernel` off the FAT boot volume, then loads user
|
||||
space: a prebuilt `boot\system.img` capsule
|
||||
([system-image.md](os-development-guide/system-image.md)) when present, otherwise it walks
|
||||
([system-image.md](os-development/system-image.md)) when present, otherwise it walks
|
||||
the volume's `/system` and optional `/test` trees (init included) into the
|
||||
initial ramdisk. The kernel can boot "kernel-only" without either.
|
||||
(`efi.zig:16`, `efi.zig:68`)
|
||||
|
||||
+5
-3
@@ -12,7 +12,9 @@ There are two layers:
|
||||
`system/abi.zig`, `library/device/model/device-abi.zig`) now spans ~26 modules:
|
||||
protocol and on-wire definitions (VFS, USB, virtio-gpu), the FAT engine, the
|
||||
display compositor, PS/2 and HID decoding, the kernel log ring, and the
|
||||
runtime's `time`/`thread` — the full list is the test step in `build.zig`.
|
||||
runtime's `time`/`thread` — the list is distributed across the library-domain
|
||||
and binary packages' own `test` steps, which the root `zig build test`
|
||||
aggregates (docs/build-packages-plan.md).
|
||||
These compile for the host and run natively.
|
||||
- **QEMU integration tests** (`python3 test/qemu_test.py`) — boot the real kernel
|
||||
and check its behaviour. This is the interesting part.
|
||||
@@ -27,7 +29,7 @@ boot log, memory summary, exception reports — appears on serial as plain text.
|
||||
|
||||
QEMU captures that with `-serial file:serial.log`, giving a machine-readable
|
||||
transcript. Serial is per-architecture (x86 uses port I/O; an ARM board uses a
|
||||
memory-mapped UART), so it lives behind the [architecture](os-development-guide/architecture.md) boundary — and adding
|
||||
memory-mapped UART), so it lives behind the [architecture](os-development/architecture.md) boundary — and adding
|
||||
a new architecture's UART is what makes the same tests run there.
|
||||
|
||||
The serial log sink is **compiled in only under `-Dserial`** (off by default).
|
||||
@@ -88,7 +90,7 @@ table in `test/qemu_test.py`):
|
||||
| `fault-recovery` | a ring-3 process that faults is killed and reaped while init keeps heartbeating — the OS survives | `DANOS-TEST-RESULT: PASS` |
|
||||
|
||||
The faulting cases don't print a result line — they deliberately raise a CPU
|
||||
exception, and the harness asserts on the [exception report](os-development-guide/interrupts.md) the
|
||||
exception, and the harness asserts on the [exception report](os-development/interrupts.md) the
|
||||
handler prints (which also reaches serial). This reuses the real fault path as the
|
||||
test oracle: if the IDT/TSS weren't wired up, `fault-df` would triple-fault and the
|
||||
marker would never appear.
|
||||
|
||||
@@ -108,7 +108,7 @@ localised (below).
|
||||
## The architecture decision: `runtime.os` + `runtime.fs`, and retire `posix`
|
||||
|
||||
danos already has the right split ([the private-ABI boundary](../README.md)): the
|
||||
kernel exposes a minimal syscall ABI ([syscall.md](os-development-guide/syscall.md)); the **`runtime`**
|
||||
kernel exposes a minimal syscall ABI ([syscall.md](os-development/syscall.md)); the **`runtime`**
|
||||
library is the stable, danos-native application ABI. What this roadmap adds:
|
||||
|
||||
- **`runtime.os` — the seam.** A C-ABI-shaped module of the ~30 operations
|
||||
@@ -165,7 +165,7 @@ What the seam needs, and what danos already provides:
|
||||
| mmap / munmap | native syscalls ([abi.zig](../system/abi.zig)) | none |
|
||||
| page allocator | over `mmap`, via `root.os.heap.page_allocator` override | ~30-line hook |
|
||||
| monotonic clock | `clock` syscall | none |
|
||||
| args / argv | SysV entry stack ([sysv.md](os-development-guide/sysv.md)), `runtime.process.Init` | none |
|
||||
| args / argv | SysV entry stack ([sysv.md](os-development/sysv.md)), `runtime.process.Init` | none |
|
||||
| stdout / stderr | `debug_write` today | wire fd 1/2 to a console **byte** stream |
|
||||
| mkdir / unlink / rename / truncate | done — engine + VFS + `runtime.fs` (Phase 2) | — |
|
||||
| stat fields | `{size, kind, mtime}` | **mode / inode** still missing (cache validity) |
|
||||
@@ -209,7 +209,7 @@ build); point danos's `build.zig`/CI at the resulting binary. Four localised pat
|
||||
plan9/serenity;
|
||||
- add `danos` to the freestanding/other **no-op `_start` list** in `std`'s `start.zig`,
|
||||
so std does *not* emit its own System-V `_start` — danos keeps owning the entry shim
|
||||
and `Init`/argv construction it already builds ([sysv.md](os-development-guide/sysv.md));
|
||||
and `Init`/argv construction it already builds ([sysv.md](os-development/sysv.md));
|
||||
- wire the `system` selector `.danos => std.os.danos` in `std.posix`;
|
||||
- add `std/os/danos.zig` — **the seam itself**, promoted near-verbatim from the
|
||||
`runtime.os` developed first in Phase 1 (against the stock toolchain, so the fork is
|
||||
@@ -344,10 +344,10 @@ Two current decisions fall out of this roadmap:
|
||||
## Related
|
||||
|
||||
- [vision.md](vision.md) — the north star this serves.
|
||||
- [syscall.md](os-development-guide/syscall.md) — the kernel↔runtime ABI `runtime.os` is built on.
|
||||
- [sysv.md](os-development-guide/sysv.md) — the entry stack (`argc/argv/envp/auxv`) danos already constructs.
|
||||
- [ipc.md](device-driver-development-guide/ipc.md) — the IPC the VFS/FAT operations travel over.
|
||||
- [danos-file-system-hierarchy-FSH.md](file-system-development/danos-file-system-hierarchy-FSH.md) — the
|
||||
- [syscall.md](os-development/syscall.md) — the kernel↔runtime ABI `runtime.os` is built on.
|
||||
- [sysv.md](os-development/sysv.md) — the entry stack (`argc/argv/envp/auxv`) danos already constructs.
|
||||
- [ipc.md](device-driver-development/ipc.md) — the IPC the VFS/FAT operations travel over.
|
||||
- [file-system-hierarchy.md](file-system-development/file-system-hierarchy.md) — the
|
||||
filesystem layout the file surface serves.
|
||||
- [coding-standards.md](coding-standards.md) — danos naming (why the compat spellings
|
||||
are confined, and now retired).
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//! The "client" library domain (library/client): userspace-service clients —
|
||||
//! they talk to services over IPC, not to the kernel. Client modules end in
|
||||
//! `-client` the way wire protocols end in `-protocol`, so a service, its
|
||||
//! protocol, and its client never share a name (`display` the service,
|
||||
//! `display-protocol` the wire contract, `display-client` a program's view).
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
pub fn build(b: *std.Build) void {
|
||||
const kernel = b.dependency("kernel", .{});
|
||||
const protocol = b.dependency("protocol", .{});
|
||||
|
||||
const ipc = kernel.module("ipc");
|
||||
const time = kernel.module("time");
|
||||
// Every client reaches its service by name now: resolve `/protocol/<name>`,
|
||||
// open it, and take the provider's endpoint out of the reply
|
||||
// (docs/os-development/protocol-namespace.md).
|
||||
const channel = kernel.module("channel");
|
||||
|
||||
_ = b.addModule("display-client", .{
|
||||
.root_source_file = b.path("display/display-client.zig"),
|
||||
.imports = &.{
|
||||
.{ .name = "channel", .module = channel },
|
||||
.{ .name = "ipc", .module = ipc },
|
||||
.{ .name = "time", .module = time },
|
||||
.{ .name = "display-protocol", .module = protocol.module("display-protocol") },
|
||||
},
|
||||
});
|
||||
_ = b.addModule("input-client", .{
|
||||
.root_source_file = b.path("input/input-client.zig"),
|
||||
.imports = &.{
|
||||
.{ .name = "channel", .module = channel },
|
||||
.{ .name = "ipc", .module = ipc },
|
||||
.{ .name = "time", .module = time },
|
||||
.{ .name = "input-protocol", .module = protocol.module("input-protocol") },
|
||||
},
|
||||
});
|
||||
|
||||
// Standalone `zig build test`, kept for uniformity across the domains (the
|
||||
// root aggregate depends on every domain's test step). The clients have no
|
||||
// host-runnable unit tests yet — they are thin IPC conversation wrappers —
|
||||
// so the step is empty until one grows some.
|
||||
_ = b.step("test", "Run the client unit tests (none yet)");
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
.{
|
||||
.name = .client,
|
||||
.version = "0.0.0",
|
||||
.fingerprint = 0xc74404553e73d4ff, // Changing this has security and trust implications.
|
||||
.minimum_zig_version = "0.16.0",
|
||||
.dependencies = .{
|
||||
// The clients converse over ipc with time-bounded waits.
|
||||
.kernel = .{ .path = "../kernel" },
|
||||
// Each client speaks its service's wire protocol.
|
||||
.protocol = .{ .path = "../protocol" },
|
||||
},
|
||||
.paths = .{""},
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
//! User-space display client: talk to the display service (query the mode, and — from D3
|
||||
//! — create layers, draw, and present) without hand-rolling the IPC. The `runtime.block`
|
||||
//! shape: a cached `.display` lookup with a boot-race retry, then extern-struct request/
|
||||
//! shape: a cached `/protocol/display` open with a boot-race retry, then extern-struct request/
|
||||
//! reply marshalling. See system/services/display/ and docs/display.md.
|
||||
|
||||
const std = @import("std");
|
||||
const channel = @import("channel");
|
||||
const ipc = @import("ipc");
|
||||
const time = @import("time");
|
||||
const display_protocol = @import("display-protocol");
|
||||
@@ -19,13 +20,13 @@ pub const Info = struct {
|
||||
/// The service endpoint, looked up once and cached.
|
||||
var handle: ?ipc.Handle = null;
|
||||
|
||||
/// Look up the display service, retrying while it comes up (a client races its
|
||||
/// registration at boot). Returns the endpoint, or null if it never appears.
|
||||
/// Open `/protocol/display`, retrying while it comes up (a client races the
|
||||
/// service's bind at boot). Returns the endpoint, or null if it never appears.
|
||||
fn service() ?ipc.Handle {
|
||||
if (handle) |h| return h;
|
||||
var attempts: usize = 0;
|
||||
while (attempts < 100) : (attempts += 1) {
|
||||
if (ipc.lookup(.display)) |h| {
|
||||
if (channel.openEndpoint("display")) |h| {
|
||||
handle = h;
|
||||
return h;
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
//! }
|
||||
|
||||
const std = @import("std");
|
||||
const abi = @import("abi");
|
||||
const channel = @import("channel");
|
||||
const ipc = @import("ipc");
|
||||
const time = @import("time");
|
||||
const input_protocol = @import("input-protocol");
|
||||
@@ -44,13 +44,13 @@ pub const device_mouse = input_protocol.device_mouse;
|
||||
pub const device_joystick = input_protocol.device_joystick;
|
||||
pub const device_all = input_protocol.device_all;
|
||||
|
||||
/// Look up the input service, retrying while it is still coming up. Both a subscriber and
|
||||
/// a source race the service's registration at boot, so both wait for it here rather than
|
||||
/// failing. Returns the service endpoint handle, or null if it never appears.
|
||||
/// Open `/protocol/input`, retrying while it is still coming up. Both a subscriber and
|
||||
/// a source race the service's bind at boot, so both wait for it here rather than
|
||||
/// failing. Returns the provider's endpoint handle, or null if it never appears.
|
||||
fn lookupService() ?ipc.Handle {
|
||||
var attempts: usize = 0;
|
||||
while (attempts < 100) : (attempts += 1) {
|
||||
if (ipc.lookup(.input)) |handle| return handle;
|
||||
if (channel.openEndpoint("input")) |handle| return handle;
|
||||
time.sleepMillis(50);
|
||||
}
|
||||
return null;
|
||||
@@ -0,0 +1,20 @@
|
||||
//! The "csv" library domain: shared CSV helpers (comment stripping, field
|
||||
//! iteration) for the /system/configuration/*.csv config files — the device registry and the
|
||||
//! init service list both parse them.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
pub fn build(b: *std.Build) void {
|
||||
_ = b.addModule("csv", .{ .root_source_file = b.path("csv.zig") });
|
||||
|
||||
// Standalone `zig build test` for this domain alone; the root build keeps
|
||||
// its aggregate test step.
|
||||
const test_step = b.step("test", "Run the csv unit tests");
|
||||
const csv_tests = b.addTest(.{
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("csv.zig"),
|
||||
.target = b.resolveTargetQuery(.{}),
|
||||
}),
|
||||
});
|
||||
test_step.dependOn(&b.addRunArtifact(csv_tests).step);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.{
|
||||
.name = .csv,
|
||||
.version = "0.0.0",
|
||||
.fingerprint = 0x8a4525791f4e5b6, // Changing this has security and trust implications.
|
||||
.minimum_zig_version = "0.16.0",
|
||||
.dependencies = .{},
|
||||
.paths = .{""},
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Minimal CSV helpers shared by the `/system/configuration/*.csv` config files — the device
|
||||
//! registry (`/system/configuration/devices.csv`) and the init service list (`/system/configuration/init.csv`).
|
||||
//! Freestanding, no allocator: returned fields are slices into the source line,
|
||||
//! so the source must outlive them. `#` starts a comment (whole-line or trailing);
|
||||
//! whitespace around a field is trimmed, so columns may be padded for alignment.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
/// Strip a trailing `#` comment and surrounding whitespace from one raw line.
|
||||
/// A blank or comment-only line returns "" (length 0) — the caller's skip signal.
|
||||
pub fn stripComment(raw: []const u8) []const u8 {
|
||||
const body = if (std.mem.indexOfScalar(u8, raw, '#')) |hash| raw[0..hash] else raw;
|
||||
return std.mem.trim(u8, body, " \t\r\n");
|
||||
}
|
||||
|
||||
/// Iterate the comma-separated fields of a line body, each trimmed of spaces and
|
||||
/// tabs. Build it from a `stripComment`ed body.
|
||||
pub const Fields = struct {
|
||||
inner: std.mem.SplitIterator(u8, .scalar),
|
||||
|
||||
/// The next field, trimmed, or null when the row is exhausted.
|
||||
pub fn next(self: *Fields) ?[]const u8 {
|
||||
const field = self.inner.next() orelse return null;
|
||||
return std.mem.trim(u8, field, " \t");
|
||||
}
|
||||
};
|
||||
|
||||
pub fn fields(body: []const u8) Fields {
|
||||
return .{ .inner = std.mem.splitScalar(u8, body, ',') };
|
||||
}
|
||||
|
||||
// --- tests -------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "stripComment trims and drops comments" {
|
||||
try testing.expectEqualStrings("a, b", stripComment(" a, b # trailing\r\n"));
|
||||
try testing.expectEqualStrings("", stripComment(" # whole-line comment"));
|
||||
try testing.expectEqualStrings("", stripComment(" \t "));
|
||||
try testing.expectEqualStrings("x", stripComment("x"));
|
||||
}
|
||||
|
||||
test "fields splits and trims each column" {
|
||||
var it = fields(stripComment("pci, 03 , 80 , /system/drivers/x # note"));
|
||||
try testing.expectEqualStrings("pci", it.next().?);
|
||||
try testing.expectEqualStrings("03", it.next().?);
|
||||
try testing.expectEqualStrings("80", it.next().?);
|
||||
try testing.expectEqualStrings("/system/drivers/x", it.next().?);
|
||||
try testing.expect(it.next() == null);
|
||||
}
|
||||
|
||||
test "a single field yields one column then null" {
|
||||
var it = fields(stripComment("/system/services/input"));
|
||||
try testing.expectEqualStrings("/system/services/input", it.next().?);
|
||||
try testing.expect(it.next() == null);
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
//! limit — the same handoff usb-storage uses toward the controller.
|
||||
|
||||
const std = @import("std");
|
||||
const channel = @import("channel");
|
||||
const ipc = @import("ipc");
|
||||
const time = @import("time");
|
||||
const block_protocol = @import("block-protocol");
|
||||
@@ -28,6 +29,18 @@ pub const Device = struct {
|
||||
return .{ .block_size = result.block_size, .block_count = result.block_count };
|
||||
}
|
||||
|
||||
/// Hand the block server a DMA-region capability (`handle` — from a `shareable`
|
||||
/// dma_alloc) so it forwards it to the controller and the buffer's physical
|
||||
/// addresses become reachable by the device. Call once per buffer before naming it
|
||||
/// in `read`/`write`. Harmless success when no IOMMU is enforcing.
|
||||
pub fn attach(self: Device, handle: ipc.Handle) bool {
|
||||
var request = block_protocol.Request{ .operation = @intFromEnum(block_protocol.Operation.attach), .lba = 0, .count = 0, .physical = 0 };
|
||||
var reply: [block_protocol.reply_size]u8 = undefined;
|
||||
const result = ipc.callCap(self.endpoint, std.mem.asBytes(&request), &reply, handle) catch return false;
|
||||
if (result.len < block_protocol.reply_size) return false;
|
||||
return std.mem.bytesToValue(block_protocol.Reply, reply[0..block_protocol.reply_size]).status == 0;
|
||||
}
|
||||
|
||||
/// Read `count` blocks starting at `lba` into the DMA buffer at `physical`.
|
||||
pub fn read(self: Device, lba: u64, count: u32, physical: u64) bool {
|
||||
return self.transfer(.read, lba, count, physical);
|
||||
@@ -54,14 +67,14 @@ pub const Device = struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// One lookup attempt, no waiting — for a server that retries on its own
|
||||
/// One open attempt, no waiting — for a server that retries on its own
|
||||
/// timer (the fat service) instead of blocking its harness in here.
|
||||
pub fn tryOpen() ?Device {
|
||||
if (ipc.lookup(.block)) |handle| return .{ .endpoint = handle };
|
||||
if (channel.openEndpoint("block")) |handle| return .{ .endpoint = handle };
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Look up the block device, retrying generously while the USB storage chain
|
||||
/// Open `/protocol/block`, retrying generously while the USB storage chain
|
||||
/// (controller reset, enumeration, mass-storage bring-up) comes up.
|
||||
pub fn open() ?Device {
|
||||
// Patient: the whole USB storage chain (firmware discovery, xHCI reset and
|
||||
@@ -72,7 +85,7 @@ pub fn open() ?Device {
|
||||
// completed at ~24 s); a machine whose stick genuinely failed setup should
|
||||
// not sit a further minute pretending otherwise.
|
||||
while (attempts < 600) : (attempts += 1) {
|
||||
if (ipc.lookup(.block)) |handle| return .{ .endpoint = handle };
|
||||
if (channel.openEndpoint("block")) |handle| return .{ .endpoint = handle };
|
||||
time.sleepMillis(50);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
//! The "device" library domain (library/device): what a driver author imports.
|
||||
//! The flat reference data (device-abi, pci-class, acpi-ids, usb-abi, usb-ids),
|
||||
//! typed MMIO access, the driver-side client libraries (driver, pci, usb,
|
||||
//! block), the AML interpreter, and the data-driven device registry.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
pub fn build(b: *std.Build) void {
|
||||
const kernel = b.dependency("kernel", .{});
|
||||
const protocol = b.dependency("protocol", .{});
|
||||
const csv = b.dependency("csv", .{});
|
||||
|
||||
const abi = kernel.module("abi");
|
||||
const system_call = kernel.module("system-call");
|
||||
const ipc = kernel.module("ipc");
|
||||
const time = kernel.module("time");
|
||||
// A driver finds the bus it attaches to by name — `/protocol/device-manager`,
|
||||
// `/protocol/usb-transfer`, `/protocol/block`
|
||||
// (docs/os-development/protocol-namespace.md).
|
||||
const channel = kernel.module("channel");
|
||||
|
||||
// The devices sub-project's public interface (the flat wire types),
|
||||
// importable by user space, unlike the kernel-internal device model it
|
||||
// also feeds (system/kernel/device-model.zig).
|
||||
const device_abi = b.addModule("device-abi", .{
|
||||
.root_source_file = b.path("model/device-abi.zig"),
|
||||
});
|
||||
// PCI class-code decoding (class/subclass/prog-IF -> names). Pure reference
|
||||
// data, shared by kernel discovery and any user-space PCI tool.
|
||||
const pci_class = b.addModule("pci-class", .{
|
||||
.root_source_file = b.path("pci/pci-class.zig"),
|
||||
});
|
||||
// ACPI/PnP hardware-ID (_HID) names — the flat analog of pci-class.
|
||||
_ = b.addModule("acpi-ids", .{
|
||||
.root_source_file = b.path("acpi/acpi-ids.zig"),
|
||||
});
|
||||
// The AML interpreter, a build module so the ring-3 acpi service can run
|
||||
// the same parser the kernel does (docs/discovery.md). Pure Zig, no kernel
|
||||
// imports — one source, two builds.
|
||||
_ = b.addModule("aml", .{
|
||||
.root_source_file = b.path("acpi/aml/aml.zig"),
|
||||
});
|
||||
// The USB device-framework wire ABI (chapter-9 set-up packets, standard +
|
||||
// class requests, descriptors) and the USB class-code taxonomy.
|
||||
const usb_abi = b.addModule("usb-abi", .{
|
||||
.root_source_file = b.path("usb/usb-abi.zig"),
|
||||
});
|
||||
const usb_ids = b.addModule("usb-ids", .{
|
||||
.root_source_file = b.path("usb/usb-ids.zig"),
|
||||
});
|
||||
// Typed volatile MMIO register access + memory-ordering barriers, for
|
||||
// drivers on top of an mmio_map grant. Depends only on `builtin`.
|
||||
const mmio = b.addModule("mmio", .{
|
||||
.root_source_file = b.path("mmio/mmio.zig"),
|
||||
});
|
||||
// The driver author's interface: device access + the device-manager hello
|
||||
// handshake, folded together.
|
||||
const driver = b.addModule("driver", .{
|
||||
.root_source_file = b.path("driver/driver.zig"),
|
||||
.imports = &.{
|
||||
.{ .name = "abi", .module = abi },
|
||||
.{ .name = "channel", .module = channel },
|
||||
.{ .name = "device-abi", .module = device_abi },
|
||||
.{ .name = "system-call", .module = system_call },
|
||||
.{ .name = "ipc", .module = ipc },
|
||||
.{ .name = "time", .module = time },
|
||||
.{ .name = "device-manager-protocol", .module = protocol.module("device-manager-protocol") },
|
||||
},
|
||||
});
|
||||
// A device driver's view of its claimed PCI function: config-space header
|
||||
// fields, BAR decode + map, capability walks (legacy + extended), MSI/MSI-X
|
||||
// programming, power state, and function-level reset — the generic PCI
|
||||
// mechanics every leaf PCI driver used to re-derive inline.
|
||||
_ = b.addModule("pci", .{
|
||||
.root_source_file = b.path("pci/pci.zig"),
|
||||
.imports = &.{
|
||||
.{ .name = "driver", .module = driver },
|
||||
.{ .name = "mmio", .module = mmio },
|
||||
.{ .name = "pci-class", .module = pci_class },
|
||||
.{ .name = "time", .module = time },
|
||||
},
|
||||
});
|
||||
// The USB class-driver transfer client: open a device on the xHCI bus and
|
||||
// drive it (control / interrupt / bulk). Re-exports usb-abi / usb-ids as
|
||||
// usb.abi / usb.ids for a single USB import.
|
||||
_ = b.addModule("usb", .{
|
||||
.root_source_file = b.path("usb/usb.zig"),
|
||||
.imports = &.{
|
||||
.{ .name = "channel", .module = channel },
|
||||
.{ .name = "ipc", .module = ipc },
|
||||
.{ .name = "time", .module = time },
|
||||
.{ .name = "usb-transfer-protocol", .module = protocol.module("usb-transfer-protocol") },
|
||||
.{ .name = "usb-abi", .module = usb_abi },
|
||||
.{ .name = "usb-ids", .module = usb_ids },
|
||||
},
|
||||
});
|
||||
// The block-device client — a device type, so it lives here.
|
||||
_ = b.addModule("block", .{
|
||||
.root_source_file = b.path("block/block.zig"),
|
||||
.imports = &.{
|
||||
.{ .name = "channel", .module = channel },
|
||||
.{ .name = "ipc", .module = ipc },
|
||||
.{ .name = "time", .module = time },
|
||||
.{ .name = "block-protocol", .module = protocol.module("block-protocol") },
|
||||
},
|
||||
});
|
||||
// The device registry: parse /system/configuration/devices.csv into match rules and bind a
|
||||
// reported device to a driver. Pure logic (no hardware, no syscalls), so it
|
||||
// unit-tests on the host; the device manager imports it.
|
||||
_ = b.addModule("device-registry", .{
|
||||
.root_source_file = b.path("registry/device-registry.zig"),
|
||||
.imports = &.{.{ .name = "csv", .module = csv.module("csv") }},
|
||||
});
|
||||
|
||||
// Standalone `zig build test` for this domain alone; the root build keeps
|
||||
// its aggregate test step.
|
||||
const test_step = b.step("test", "Run the device library unit tests");
|
||||
for ([_][]const u8{
|
||||
"model/device-abi.zig", // wire-type sizes
|
||||
"pci/pci-class.zig", // class/subclass/prog-IF name decoding
|
||||
"acpi/acpi-ids.zig", // _HID name decoding
|
||||
"acpi/aml/aml.zig", // AML parse + interpret, incl. Notify dispatch
|
||||
"usb/usb-abi.zig", // wire sizes + bit packings + set-up packet encodings
|
||||
"usb/usb-ids.zig", // class/subclass/protocol code assignments
|
||||
"mmio/mmio.zig", // barriers assemble + registers round-trip
|
||||
}) |root| {
|
||||
const device_tests = b.addTest(.{
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path(root),
|
||||
.target = b.resolveTargetQuery(.{}),
|
||||
}),
|
||||
});
|
||||
test_step.dependOn(&b.addRunArtifact(device_tests).step);
|
||||
}
|
||||
// The registry needs its csv import wired, so it doesn't fit the loop.
|
||||
const registry_tests = b.addTest(.{
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("registry/device-registry.zig"),
|
||||
.target = b.resolveTargetQuery(.{}),
|
||||
.imports = &.{.{ .name = "csv", .module = csv.module("csv") }},
|
||||
}),
|
||||
});
|
||||
test_step.dependOn(&b.addRunArtifact(registry_tests).step);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.{
|
||||
.name = .device,
|
||||
.version = "0.0.0",
|
||||
.fingerprint = 0x92fb68eace23a4f, // Changing this has security and trust implications.
|
||||
.minimum_zig_version = "0.16.0",
|
||||
.dependencies = .{
|
||||
// driver/block/usb/pci build on the kernel library's concern modules.
|
||||
.kernel = .{ .path = "../kernel" },
|
||||
// driver speaks device-manager-protocol; block/usb their transfer protocols.
|
||||
.protocol = .{ .path = "../protocol" },
|
||||
// device-registry parses /system/configuration/devices.csv with the shared csv helpers.
|
||||
.csv = .{ .path = "../csv" },
|
||||
},
|
||||
.paths = .{""},
|
||||
}
|
||||
@@ -7,6 +7,7 @@ const std = @import("std");
|
||||
const abi = @import("abi");
|
||||
const device_abi = @import("device-abi");
|
||||
const sc = @import("system-call");
|
||||
const channel = @import("channel");
|
||||
const ipc = @import("ipc");
|
||||
const time = @import("time");
|
||||
const device_manager_protocol = @import("device-manager-protocol");
|
||||
@@ -99,6 +100,27 @@ pub fn msiBind(device_id: u64, endpoint: usize) ?Msi {
|
||||
return .{ .address = rax, .data = @intCast(rdx) };
|
||||
}
|
||||
|
||||
/// Map a delegated DMA-region (or shared-memory) capability into a claimed device's
|
||||
/// IOMMU domain, so the device may DMA to that buffer. The caller must own `device_id`
|
||||
/// and hold `handle` (received over IPC or from its own `dma.alloc(.. | shareable)`).
|
||||
/// Idempotent. Returns true on success (and trivially when no IOMMU is present).
|
||||
pub fn dmaBind(device_id: u64, handle: usize) bool {
|
||||
return !failed(sc.systemCall2(.dma_bind, device_id, handle));
|
||||
}
|
||||
|
||||
/// Unmap a previously `dmaBind`'d buffer from the device's domain.
|
||||
pub fn dmaUnbind(device_id: u64, handle: usize) bool {
|
||||
return !failed(sc.systemCall2(.dma_unbind, device_id, handle));
|
||||
}
|
||||
|
||||
/// Drain and log any pending IOMMU translation faults, returning the count seen. A
|
||||
/// diagnostic: a driver that suspects its device attempted an out-of-domain DMA (or a
|
||||
/// test proving enforcement) forces the hardware's fault records to the log now. Returns
|
||||
/// 0 when no IOMMU is present.
|
||||
pub fn iommuFaultDrain() usize {
|
||||
return sc.systemCall0(.iommu_fault_drain);
|
||||
}
|
||||
|
||||
/// Read `width` bytes (1, 2, or 4) from a port in a claimed device's `io_port`
|
||||
/// resource, at byte `offset` within it. Ring 3 has no direct `in`/`out`, so a legacy
|
||||
/// driver (PS/2, 16550 UART) reaches its ports through this claim-gated call — each
|
||||
@@ -149,7 +171,7 @@ const lookup_pause_ms: u64 = 20;
|
||||
pub fn hello(role: Role, device_id: u64) ?ipc.Handle {
|
||||
var attempts: u32 = 0;
|
||||
const manager = while (attempts < lookup_attempts) : (attempts += 1) {
|
||||
if (ipc.lookup(.device_manager)) |handle| break handle;
|
||||
if (channel.openEndpoint("device-manager")) |handle| break handle;
|
||||
time.sleepMillis(lookup_pause_ms);
|
||||
} else {
|
||||
std.log.info("no device manager to hello", .{});
|
||||
|
||||
@@ -125,6 +125,15 @@ pub const DeviceDescriptor = extern struct {
|
||||
// `pci_device` is (an xHCI controller, an AHCI controller) — decode the triple into
|
||||
// names with the pci-class module.
|
||||
pci_class: u64,
|
||||
// Numeric identity beyond the class triple, mirrored in the bus report's
|
||||
// ChildAdded so /system/configuration/devices.csv can bind on it: `vendor`/`device` are the PCI
|
||||
// vendor/device (or USB idVendor/idProduct), `subsystem` is the PCI subsystem id
|
||||
// packed `(subsystem_vendor << 16) | subsystem_device`. Zero where the bus has no
|
||||
// such concept. Defaulted so existing descriptor literals keep compiling and lay
|
||||
// out identically until they choose to set them.
|
||||
vendor: u16 = 0,
|
||||
device: u16 = 0,
|
||||
subsystem: u32 = 0,
|
||||
hid_len: u64,
|
||||
resource_count: u64,
|
||||
hid: [8]u8,
|
||||
|
||||
@@ -52,16 +52,134 @@ pub const config_vendor_id: usize = 0x00;
|
||||
pub const config_device_id: usize = 0x02;
|
||||
pub const config_command: usize = 0x04;
|
||||
pub const config_status: usize = 0x06;
|
||||
pub const config_capabilities_pointer: usize = 0x34;
|
||||
pub const config_revision_id: usize = 0x08;
|
||||
pub const config_class_code: usize = 0x09; // 3 bytes: prog-IF 0x09, subclass 0x0A, base class 0x0B
|
||||
pub const config_bar0: usize = 0x10; // BAR0; BAR n is at config_bar0 + n*4
|
||||
pub const config_subsystem_vendor_id: usize = 0x2C;
|
||||
pub const config_subsystem_id: usize = 0x2E;
|
||||
pub const config_expansion_rom: usize = 0x30;
|
||||
pub const config_capabilities_pointer: usize = 0x34;
|
||||
pub const config_interrupt_line: usize = 0x3C;
|
||||
pub const config_interrupt_pin: usize = 0x3D; // 0 = none, 1..4 = INTA..INTD
|
||||
|
||||
/// Command register: Memory-Space enable (bit 1) | Bus-Master enable (bit 2).
|
||||
pub const command_memory_and_bus_master: u16 = 0x06;
|
||||
/// Command register bits.
|
||||
pub const command_io_space: u16 = 0x0001; // bit 0: I/O-space decode enable
|
||||
pub const command_memory_space: u16 = 0x0002; // bit 1: memory-space decode enable
|
||||
pub const command_bus_master: u16 = 0x0004; // bit 2: bus-master (DMA) enable
|
||||
pub const command_interrupt_disable: u16 = 0x0400; // bit 10: suppress legacy INTx (MSI/MSI-X unaffected)
|
||||
/// The pair a bus-mastering driver enables together: decode my BARs, let me DMA.
|
||||
pub const command_memory_and_bus_master: u16 = command_memory_space | command_bus_master;
|
||||
|
||||
/// Status register bit 3: legacy INTx is asserted (upstream of the command bit-10 gate).
|
||||
pub const status_interrupt: u16 = 0x0008;
|
||||
/// Status register bit 4: a capability list is present at config_capabilities_pointer.
|
||||
pub const status_capabilities_list: u16 = 0x10;
|
||||
/// Capability pointers are dword-aligned; the low two bits are reserved.
|
||||
pub const capability_pointer_mask: u8 = 0xFC;
|
||||
|
||||
/// Capability IDs — the first byte of each entry in the legacy capability list.
|
||||
/// Non-exhaustive: hardware may report IDs not named here.
|
||||
pub const CapabilityId = enum(u8) {
|
||||
power_management = 0x01,
|
||||
msi = 0x05,
|
||||
vendor_specific = 0x09,
|
||||
pci_express = 0x10,
|
||||
msix = 0x11,
|
||||
_,
|
||||
};
|
||||
|
||||
/// MSI capability (id 0x05) register layout. Offsets are relative to the capability
|
||||
/// header; whether the address is one or two dwords (and therefore where the data word
|
||||
/// sits) depends on `control_64bit_capable`.
|
||||
pub const msi = struct {
|
||||
pub const control: usize = 0x02; // u16 Message Control
|
||||
pub const control_enable: u16 = 0x0001;
|
||||
pub const control_multiple_message_capable_mask: u16 = 0x000E; // bits 3:1, log2(vectors requested)
|
||||
pub const control_multiple_message_enable_mask: u16 = 0x0070; // bits 6:4, log2(vectors granted)
|
||||
pub const control_64bit_capable: u16 = 0x0080; // bit 7: address is 64-bit (layout shifts)
|
||||
pub const control_per_vector_masking: u16 = 0x0100; // bit 8
|
||||
pub const address: usize = 0x04; // u32 low address dword (both layouts)
|
||||
pub const address_high: usize = 0x08; // u32, present only when 64-bit capable
|
||||
pub const data_32: usize = 0x08; // u16 message data, 32-bit layout
|
||||
pub const data_64: usize = 0x0C; // u16 message data, 64-bit layout
|
||||
pub const mask_bits_32: usize = 0x0C; // u32, only with per-vector masking
|
||||
pub const mask_bits_64: usize = 0x10;
|
||||
};
|
||||
|
||||
/// MSI-X capability (id 0x11) register layout, plus the 16-byte vector table entry that
|
||||
/// lives in BAR space (not configuration space) at the decoded (BIR, offset).
|
||||
pub const msix = struct {
|
||||
pub const control: usize = 0x02; // u16 Message Control
|
||||
pub const control_table_size_mask: u16 = 0x07FF; // bits 10:0, encoded as N-1
|
||||
pub const control_function_mask: u16 = 0x4000; // bit 14: mask every vector
|
||||
pub const control_enable: u16 = 0x8000; // bit 15
|
||||
pub const table_offset_word: usize = 0x04; // u32: BIR in bits 2:0, table offset in bits 31:3
|
||||
pub const pba_offset_word: usize = 0x08; // u32: same encoding, pending-bit array
|
||||
pub const bir_mask: u32 = 0x0000_0007;
|
||||
pub const offset_mask: u32 = 0xFFFF_FFF8;
|
||||
pub const entry_size: usize = 16; // table entry stride; offsets within an entry:
|
||||
pub const entry_address: usize = 0x0; // u32 low
|
||||
pub const entry_address_high: usize = 0x4; // u32 high
|
||||
pub const entry_data: usize = 0x8; // u32
|
||||
pub const entry_vector_control: usize = 0xC; // u32
|
||||
pub const entry_vector_control_masked: u32 = 0x1; // bit 0; entries reset to masked
|
||||
|
||||
/// Where the table (or pending-bit array) lives, decoded from its offset/BIR dword.
|
||||
pub const TableLocation = struct { bar: u8, offset: u32 };
|
||||
pub fn tableLocation(word: u32) TableLocation {
|
||||
return .{ .bar = @intCast(word & bir_mask), .offset = word & offset_mask };
|
||||
}
|
||||
/// Number of table entries (the control field encodes N-1).
|
||||
pub fn tableSize(control_value: u16) u16 {
|
||||
return (control_value & control_table_size_mask) + 1;
|
||||
}
|
||||
};
|
||||
|
||||
/// Power-management capability (id 0x01) register layout.
|
||||
pub const power_management = struct {
|
||||
pub const capabilities: usize = 0x02; // u16 PMC (read-only: version, D-state support)
|
||||
pub const control_status: usize = 0x04; // u16 PMCSR
|
||||
pub const control_status_power_state_mask: u16 = 0x0003; // bits 1:0
|
||||
pub const power_state_d0: u16 = 0x0;
|
||||
pub const power_state_d3_hot: u16 = 0x3;
|
||||
pub const control_status_pme_enable: u16 = 0x0100; // bit 8: plain RW — preserve on writes
|
||||
pub const control_status_pme_status: u16 = 0x8000; // bit 15: RW1C — write 0 or you clear it
|
||||
};
|
||||
|
||||
/// PCI Express capability (id 0x10) register layout — the slice function-level reset
|
||||
/// needs; the full capability is much larger.
|
||||
pub const pci_express = struct {
|
||||
pub const capabilities: usize = 0x02; // u16 PCIe Capabilities register
|
||||
pub const device_capabilities: usize = 0x04; // u32
|
||||
pub const device_capabilities_flr: u32 = 1 << 28; // Function Level Reset supported
|
||||
pub const device_control: usize = 0x08; // u16
|
||||
pub const device_control_initiate_flr: u16 = 1 << 15;
|
||||
pub const device_status: usize = 0x0A; // u16
|
||||
pub const device_status_transactions_pending: u16 = 1 << 5;
|
||||
};
|
||||
|
||||
/// Extended (PCI Express) capabilities start here in the 4 KiB configuration space; a
|
||||
/// conventional-PCI function has nothing there (the space reads as all-ones).
|
||||
pub const extended_capability_start: usize = 0x100;
|
||||
/// Extended-capability next pointers are dword-aligned within the 4 KiB space.
|
||||
pub const extended_capability_pointer_mask: u16 = 0xFFC;
|
||||
|
||||
/// The 32-bit header at the start of each extended capability: ID in bits 15:0,
|
||||
/// version in 19:16, next offset in 31:20 (0 = end of list).
|
||||
pub const ExtendedCapabilityHeader = struct {
|
||||
id: u16,
|
||||
version: u4,
|
||||
next: u16,
|
||||
|
||||
pub fn decode(word: u32) ExtendedCapabilityHeader {
|
||||
return .{
|
||||
.id = @truncate(word),
|
||||
.version = @truncate(word >> 16),
|
||||
.next = @intCast((word >> 20) & extended_capability_pointer_mask),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// BAR bit layout: bit 0 selects I/O (1) vs memory (0) space; for a memory BAR, bits 2:1
|
||||
/// give the type (00 = 32-bit, 10 = 64-bit spanning the next BAR), and the base address is
|
||||
/// the dword with the low 4 flag bits masked off.
|
||||
@@ -595,3 +713,37 @@ test "named parts pack to the raw triple" {
|
||||
};
|
||||
try std.testing.expectEqual(@as(u24, 0x0C_03_30), xhci.pack());
|
||||
}
|
||||
|
||||
test "MSI-X table word decodes to BIR and offset" {
|
||||
const eq = std.testing.expectEqual;
|
||||
// BIR 3, table at 0x2000 within that BAR.
|
||||
try eq(msix.TableLocation{ .bar = 3, .offset = 0x2000 }, msix.tableLocation(0x0000_2003));
|
||||
// BIR 0, offset 0 — the degenerate-but-common "table at BAR start" case.
|
||||
try eq(msix.TableLocation{ .bar = 0, .offset = 0 }, msix.tableLocation(0));
|
||||
// Table size encodes N-1 in bits 10:0; enable/function-mask bits must not leak in.
|
||||
try eq(@as(u16, 11), msix.tableSize(msix.control_enable | 0x000A));
|
||||
try eq(@as(u16, 1), msix.tableSize(0));
|
||||
try eq(@as(u16, 2048), msix.tableSize(msix.control_table_size_mask));
|
||||
}
|
||||
|
||||
test "extended capability header unpacks id, version, next" {
|
||||
const eq = std.testing.expectEqual;
|
||||
// AER (id 0x0001), version 1, next capability at 0x140.
|
||||
const aer = ExtendedCapabilityHeader.decode(0x1401_0001);
|
||||
try eq(@as(u16, 0x0001), aer.id);
|
||||
try eq(@as(u4, 1), aer.version);
|
||||
try eq(@as(u16, 0x140), aer.next);
|
||||
// A zero header is the "nothing here" terminator.
|
||||
const none = ExtendedCapabilityHeader.decode(0);
|
||||
try eq(@as(u16, 0), none.id);
|
||||
try eq(@as(u16, 0), none.next);
|
||||
}
|
||||
|
||||
test "command bits and capability ids compose" {
|
||||
const eq = std.testing.expectEqual;
|
||||
try eq(command_memory_space | command_bus_master, command_memory_and_bus_master);
|
||||
try eq(@as(u8, 0x05), @intFromEnum(CapabilityId.msi));
|
||||
try eq(@as(u8, 0x11), @intFromEnum(CapabilityId.msix));
|
||||
try eq(@as(u8, 0x01), @intFromEnum(CapabilityId.power_management));
|
||||
try eq(@as(u8, 0x10), @intFromEnum(CapabilityId.pci_express));
|
||||
}
|
||||
|
||||
+283
-7
@@ -1,7 +1,8 @@
|
||||
//! library/device/pci/pci.zig — a device driver's view of the ONE PCI function it has
|
||||
//! claimed. Config space is mapped as resource 0; this gives header-field accessors, BAR
|
||||
//! decode + map, and a capability-list iterator, so a driver never re-derives the
|
||||
//! config-space layout by hand.
|
||||
//! claimed. Config space is mapped as resource 0 (a full 4 KiB ECAM page); this gives
|
||||
//! header-field accessors, BAR decode + map, capability walks (legacy and extended),
|
||||
//! MSI/MSI-X programming, power-state handling, and function-level reset, so a driver
|
||||
//! never re-derives the config-space layout by hand.
|
||||
//!
|
||||
//! This is the *device-owned* view: read my own function's live config, map my own BARs.
|
||||
//! The bus enumerator's view — probing arbitrary, not-yet-claimed functions and sizing
|
||||
@@ -13,6 +14,18 @@ const std = @import("std");
|
||||
const mmio = @import("mmio");
|
||||
const pci_class = @import("pci-class");
|
||||
const device = @import("driver");
|
||||
const time = @import("time");
|
||||
|
||||
/// Spec recovery time after a D3hot -> D0 transition.
|
||||
const d0_recovery_millis: u64 = 10;
|
||||
/// How long to wait for in-flight transactions to drain before a function-level reset
|
||||
/// (then reset anyway — resetting a stuck function is the point of FLR).
|
||||
const flr_pending_timeout_millis: u64 = 100;
|
||||
/// The spec's maximum FLR completion time.
|
||||
const flr_settle_millis: u64 = 100;
|
||||
/// How long to wait for the function to become readable again after an FLR.
|
||||
const flr_ready_timeout_millis: u64 = 1000;
|
||||
const flr_poll_interval_millis: u64 = 10;
|
||||
|
||||
/// A claimed PCI function whose configuration space is mapped (resource 0). `descriptor`
|
||||
/// must outlive the Function — the driver's `device.enumerate` buffer does, for the whole
|
||||
@@ -42,12 +55,61 @@ pub const Function = struct {
|
||||
pub fn status(self: *const Function) u16 {
|
||||
return mmio.readRegister(u16, self.config + pci_class.config_status);
|
||||
}
|
||||
pub fn revisionId(self: *const Function) u8 {
|
||||
return mmio.readRegister(u8, self.config + pci_class.config_revision_id);
|
||||
}
|
||||
/// Subsystem vendor ID (config 0x2C) — with `subsystemId`, the standard key for
|
||||
/// board-level quirk matching.
|
||||
pub fn subsystemVendorId(self: *const Function) u16 {
|
||||
return mmio.readRegister(u16, self.config + pci_class.config_subsystem_vendor_id);
|
||||
}
|
||||
pub fn subsystemId(self: *const Function) u16 {
|
||||
return mmio.readRegister(u16, self.config + pci_class.config_subsystem_id);
|
||||
}
|
||||
/// Interrupt pin (config 0x3D): 0 = none, 1..4 = INTA..INTD.
|
||||
pub fn interruptPin(self: *const Function) u8 {
|
||||
return mmio.readRegister(u8, self.config + pci_class.config_interrupt_pin);
|
||||
}
|
||||
/// The live class-code triple (config 0x09..0x0B), same shape discovery records.
|
||||
pub fn classCode(self: *const Function) pci_class.ClassCode {
|
||||
return .{
|
||||
.prog_if = mmio.readRegister(u8, self.config + pci_class.config_class_code),
|
||||
.subclass = mmio.readRegister(u8, self.config + pci_class.config_class_code + 1),
|
||||
.base = mmio.readRegister(u8, self.config + pci_class.config_class_code + 2),
|
||||
};
|
||||
}
|
||||
|
||||
/// Set Memory-Space + Bus-Master enable in the command register. Firmware often leaves
|
||||
/// a secondary display's decode off; a bus-mastering device must enable both.
|
||||
pub fn enableMemoryAndBusMaster(self: *const Function) void {
|
||||
fn commandSetBits(self: *const Function, bits: u16) void {
|
||||
const at = self.config + pci_class.config_command;
|
||||
mmio.writeRegister(u16, at, mmio.readRegister(u16, at) | pci_class.command_memory_and_bus_master);
|
||||
mmio.writeRegister(u16, at, mmio.readRegister(u16, at) | bits);
|
||||
}
|
||||
fn commandClearBits(self: *const Function, bits: u16) void {
|
||||
const at = self.config + pci_class.config_command;
|
||||
mmio.writeRegister(u16, at, mmio.readRegister(u16, at) & ~bits);
|
||||
}
|
||||
|
||||
/// Set Memory-Space + Bus-Master Enable in the command register. Firmware only enables
|
||||
/// memory decode on devices it used at boot; any other device has dead BARs until its
|
||||
/// driver sets it. Bus mastering is separately required for the device to do DMA.
|
||||
pub fn enableMemoryAndBusMaster(self: *const Function) void {
|
||||
self.commandSetBits(pci_class.command_memory_and_bus_master);
|
||||
}
|
||||
|
||||
/// Clear Bus-Master Enable — stop the device initiating DMA. The quiesce half of a
|
||||
/// driver's shutdown (or a supervisor restart): after this the device can no longer
|
||||
/// write memory the process is about to stop owning.
|
||||
pub fn disableBusMaster(self: *const Function) void {
|
||||
self.commandClearBits(pci_class.command_bus_master);
|
||||
}
|
||||
|
||||
/// Set command bit 10: suppress legacy INTx assertion. MSI/MSI-X are unaffected —
|
||||
/// set this when enabling either, so the device cannot also raise the shared pin.
|
||||
pub fn setInterruptDisable(self: *const Function) void {
|
||||
self.commandSetBits(pci_class.command_interrupt_disable);
|
||||
}
|
||||
/// Clear command bit 10, re-allowing legacy INTx assertion.
|
||||
pub fn clearInterruptDisable(self: *const Function) void {
|
||||
self.commandClearBits(pci_class.command_interrupt_disable);
|
||||
}
|
||||
|
||||
/// Decode BAR `bar` (0..5) and map it: read the BAR register, reject I/O-space BARs,
|
||||
@@ -86,6 +148,129 @@ pub const Function = struct {
|
||||
0;
|
||||
return .{ .config = self.config, .cursor = first };
|
||||
}
|
||||
|
||||
/// First capability with `id`, or null.
|
||||
pub fn findCapability(self: *const Function, id: pci_class.CapabilityId) ?Capability {
|
||||
var walk = self.capabilities();
|
||||
while (walk.next()) |capability| {
|
||||
if (capability.id == @intFromEnum(id)) return capability;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Program the MSI capability with the kernel's `msi_bind` result and enable it —
|
||||
/// one vector (multiple-message-enable 0, matching the kernel's single-vector
|
||||
/// grant), INTx suppressed. false if the function has no MSI capability.
|
||||
pub fn programMsi(self: *const Function, message: device.Msi) bool {
|
||||
const cap = self.findCapability(.msi) orelse return false;
|
||||
const control_at = cap.offset + pci_class.msi.control;
|
||||
const control = mmio.readRegister(u16, control_at);
|
||||
// Program the registers while the capability is disabled.
|
||||
mmio.writeRegister(u16, control_at, control & ~pci_class.msi.control_enable);
|
||||
mmio.writeRegister(u32, cap.offset + pci_class.msi.address, @truncate(message.address));
|
||||
const data_offset = if (control & pci_class.msi.control_64bit_capable != 0) offset: {
|
||||
mmio.writeRegister(u32, cap.offset + pci_class.msi.address_high, @intCast(message.address >> 32));
|
||||
break :offset pci_class.msi.data_64;
|
||||
} else pci_class.msi.data_32;
|
||||
// Message data is a 16-bit register in both layouts.
|
||||
mmio.writeRegister(u16, cap.offset + data_offset, @truncate(message.data));
|
||||
mmio.writeRegister(u16, control_at, (control & ~pci_class.msi.control_multiple_message_enable_mask) | pci_class.msi.control_enable);
|
||||
self.setInterruptDisable();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Clear the MSI enable bit. No-op if the function has no MSI capability.
|
||||
pub fn disableMsi(self: *const Function) void {
|
||||
const cap = self.findCapability(.msi) orelse return;
|
||||
const control_at = cap.offset + pci_class.msi.control;
|
||||
mmio.writeRegister(u16, control_at, mmio.readRegister(u16, control_at) & ~pci_class.msi.control_enable);
|
||||
}
|
||||
|
||||
/// The function's MSI-X capability with its vector table mapped: the table's BIR is
|
||||
/// resolved through `mapBar` (a free cache hit when it is a BAR the driver already
|
||||
/// mapped). null if the capability is absent or the table's BAR cannot be mapped.
|
||||
pub fn msix(self: *Function) ?MsiX {
|
||||
const cap = self.findCapability(.msix) orelse return null;
|
||||
const control = mmio.readRegister(u16, cap.offset + pci_class.msix.control);
|
||||
const word = mmio.readRegister(u32, cap.offset + pci_class.msix.table_offset_word);
|
||||
const location = pci_class.msix.tableLocation(word);
|
||||
const bar_base = self.mapBar(location.bar) orelse return null;
|
||||
return .{
|
||||
.capability = cap.offset,
|
||||
.table = bar_base + location.offset,
|
||||
.entry_count = pci_class.msix.tableSize(control),
|
||||
};
|
||||
}
|
||||
|
||||
/// Bring the function to D0. Firmware can leave a non-boot device in D3hot, where
|
||||
/// its BARs and MSI registers do not decode; call this before touching either. No
|
||||
/// power-management capability means the function is always at D0: nothing to do.
|
||||
/// Preserves PME-Enable and never clears the write-1-to-clear PME-Status bit.
|
||||
pub fn ensurePowerStateD0(self: *const Function) void {
|
||||
const cap = self.findCapability(.power_management) orelse return;
|
||||
const at = cap.offset + pci_class.power_management.control_status;
|
||||
const pmcsr = mmio.readRegister(u16, at);
|
||||
if (pmcsr & pci_class.power_management.control_status_power_state_mask == pci_class.power_management.power_state_d0) return;
|
||||
// PME-Status is RW1C: echoing a read 1 back would clear it, so write it as 0.
|
||||
mmio.writeRegister(u16, at, (pmcsr & ~pci_class.power_management.control_status_power_state_mask & ~pci_class.power_management.control_status_pme_status) | pci_class.power_management.power_state_d0);
|
||||
time.sleepMillis(d0_recovery_millis);
|
||||
}
|
||||
|
||||
/// Function Level Reset via the PCI Express capability: return the hardware to a
|
||||
/// known state (a supervisor re-claiming a device after its driver died, or a driver
|
||||
/// recovering a wedged function). The six BAR dwords are saved and restored — FLR
|
||||
/// clears them, and the bus enumerator's assignment must survive for the descriptor
|
||||
/// correlation and `mapBar` cache to stay valid. Everything else is reset: command
|
||||
/// enables and MSI/MSI-X programming are gone, so the caller re-runs its whole
|
||||
/// bring-up afterwards. false if the function has no PCI Express capability, does
|
||||
/// not advertise FLR (conventional-PCI Advanced Features FLR is a possible
|
||||
/// follow-up), or never became readable again. Blocks for at least 100 ms.
|
||||
pub fn functionLevelReset(self: *const Function) bool {
|
||||
const cap = self.findCapability(.pci_express) orelse return false;
|
||||
const device_capabilities = mmio.readRegister(u32, cap.offset + pci_class.pci_express.device_capabilities);
|
||||
if (device_capabilities & pci_class.pci_express.device_capabilities_flr == 0) return false;
|
||||
|
||||
// Stop new DMA, then give in-flight transactions a bounded chance to drain —
|
||||
// and reset anyway on timeout, since resetting a stuck function is the point.
|
||||
self.disableBusMaster();
|
||||
var waited: u64 = 0;
|
||||
while (mmio.readRegister(u16, cap.offset + pci_class.pci_express.device_status) & pci_class.pci_express.device_status_transactions_pending != 0) {
|
||||
if (waited >= flr_pending_timeout_millis) break;
|
||||
time.sleepMillis(flr_poll_interval_millis);
|
||||
waited += flr_poll_interval_millis;
|
||||
}
|
||||
|
||||
var bars: [6]u32 = undefined;
|
||||
for (&bars, 0..) |*bar, index| bar.* = mmio.readRegister(u32, self.config + pci_class.config_bar0 + index * 4);
|
||||
|
||||
const control_at = cap.offset + pci_class.pci_express.device_control;
|
||||
mmio.writeRegister(u16, control_at, mmio.readRegister(u16, control_at) | pci_class.pci_express.device_control_initiate_flr);
|
||||
time.sleepMillis(flr_settle_millis);
|
||||
|
||||
waited = 0;
|
||||
while (self.vendorId() == 0xFFFF) {
|
||||
if (waited >= flr_ready_timeout_millis) return false;
|
||||
time.sleepMillis(flr_poll_interval_millis);
|
||||
waited += flr_poll_interval_millis;
|
||||
}
|
||||
for (bars, 0..) |bar, index| mmio.writeRegister(u32, self.config + pci_class.config_bar0 + index * 4, bar);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Iterate the extended (PCI Express) capability list at 0x100.. in the 4 KiB ECAM
|
||||
/// page. Empty on a conventional-PCI function (the space reads as all-ones).
|
||||
pub fn extendedCapabilities(self: *const Function) ExtendedCapabilityIterator {
|
||||
return .{ .config = self.config };
|
||||
}
|
||||
|
||||
/// First extended capability with `id`, or null.
|
||||
pub fn findExtendedCapability(self: *const Function, id: u16) ?ExtendedCapability {
|
||||
var walk = self.extendedCapabilities();
|
||||
while (walk.next()) |capability| {
|
||||
if (capability.id == id) return capability;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/// One capability header. `offset` is the ABSOLUTE virtual address of the header, so the
|
||||
@@ -106,3 +291,94 @@ pub const CapabilityIterator = struct {
|
||||
return .{ .id = id, .offset = at };
|
||||
}
|
||||
};
|
||||
|
||||
/// A resolved MSI-X capability from `Function.msix`: `capability` is the absolute
|
||||
/// virtual address of the config-space header, `table` of vector-table entry 0 (in BAR
|
||||
/// space — table writes are MMIO, not config space). Entries reset masked; bring-up
|
||||
/// order is programEntry per vector, unmaskEntry per used vector, `enable`, then
|
||||
/// `Function.setInterruptDisable`.
|
||||
pub const MsiX = struct {
|
||||
capability: usize,
|
||||
table: usize,
|
||||
entry_count: u16,
|
||||
|
||||
/// Write `message` into table entry `entry`, leaving the entry masked (its reset
|
||||
/// state) — the spec requires masking while address/data change. false if `entry`
|
||||
/// is out of range.
|
||||
pub fn programEntry(self: *const MsiX, entry: u16, message: device.Msi) bool {
|
||||
if (entry >= self.entry_count) return false;
|
||||
const at = self.table + @as(usize, entry) * pci_class.msix.entry_size;
|
||||
mmio.writeRegister(u32, at + pci_class.msix.entry_vector_control, pci_class.msix.entry_vector_control_masked);
|
||||
mmio.writeRegister(u32, at + pci_class.msix.entry_address, @truncate(message.address));
|
||||
mmio.writeRegister(u32, at + pci_class.msix.entry_address_high, @intCast(message.address >> 32));
|
||||
mmio.writeRegister(u32, at + pci_class.msix.entry_data, message.data);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Set the entry's vector-control mask bit — its interrupt is held off (pended in
|
||||
/// the PBA, not lost). false if `entry` is out of range.
|
||||
pub fn maskEntry(self: *const MsiX, entry: u16) bool {
|
||||
return self.writeEntryMask(entry, true);
|
||||
}
|
||||
/// Clear the entry's vector-control mask bit. false if `entry` is out of range.
|
||||
pub fn unmaskEntry(self: *const MsiX, entry: u16) bool {
|
||||
return self.writeEntryMask(entry, false);
|
||||
}
|
||||
fn writeEntryMask(self: *const MsiX, entry: u16, masked: bool) bool {
|
||||
if (entry >= self.entry_count) return false;
|
||||
const at = self.table + @as(usize, entry) * pci_class.msix.entry_size + pci_class.msix.entry_vector_control;
|
||||
const control = mmio.readRegister(u32, at);
|
||||
mmio.writeRegister(u32, at, if (masked)
|
||||
control | pci_class.msix.entry_vector_control_masked
|
||||
else
|
||||
control & ~pci_class.msix.entry_vector_control_masked);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Set the function-mask control bit: every vector masked regardless of entry bits.
|
||||
pub fn setFunctionMask(self: *const MsiX) void {
|
||||
self.writeControl(pci_class.msix.control_function_mask, true);
|
||||
}
|
||||
/// Clear the function-mask control bit.
|
||||
pub fn clearFunctionMask(self: *const MsiX) void {
|
||||
self.writeControl(pci_class.msix.control_function_mask, false);
|
||||
}
|
||||
/// Set MSI-X Enable. The caller also calls `Function.setInterruptDisable` (INTx off).
|
||||
pub fn enable(self: *const MsiX) void {
|
||||
self.writeControl(pci_class.msix.control_enable, true);
|
||||
}
|
||||
/// Clear MSI-X Enable.
|
||||
pub fn disable(self: *const MsiX) void {
|
||||
self.writeControl(pci_class.msix.control_enable, false);
|
||||
}
|
||||
fn writeControl(self: *const MsiX, bit: u16, set: bool) void {
|
||||
const at = self.capability + pci_class.msix.control;
|
||||
const control = mmio.readRegister(u16, at);
|
||||
mmio.writeRegister(u16, at, if (set) control | bit else control & ~bit);
|
||||
}
|
||||
};
|
||||
|
||||
/// One extended capability. `offset` is the ABSOLUTE virtual address of its header,
|
||||
/// like `Capability.offset`.
|
||||
pub const ExtendedCapability = struct { id: u16, version: u4, offset: usize };
|
||||
|
||||
pub const ExtendedCapabilityIterator = struct {
|
||||
config: usize,
|
||||
cursor: u16 = @intCast(pci_class.extended_capability_start),
|
||||
guard: u32 = 0, // bounds a malformed chain (480 = the 0xF00-byte space / 8-byte minimum spacing)
|
||||
|
||||
pub fn next(self: *ExtendedCapabilityIterator) ?ExtendedCapability {
|
||||
if (self.cursor == 0 or self.guard >= 480) return null;
|
||||
self.guard += 1;
|
||||
const at = self.config + self.cursor;
|
||||
const header = pci_class.ExtendedCapabilityHeader.decode(mmio.readRegister(u32, at));
|
||||
// Id 0 marks an empty list; all-ones is a conventional-PCI function (no
|
||||
// extended space — reads come back as FFs).
|
||||
if (header.id == 0 or header.id == 0xFFFF) return null;
|
||||
// A next pointer below 0x100 would walk into the legacy header; treat it as the
|
||||
// terminator it must be (0 is the normal one). The 0xFFC decode mask already
|
||||
// keeps `config + cursor + 4` inside the 4 KiB page.
|
||||
self.cursor = if (header.next >= pci_class.extended_capability_start) header.next else 0;
|
||||
return .{ .id = header.id, .version = header.version, .offset = at };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
//! The device registry: parse `/system/configuration/devices.csv` into match rules and bind a
|
||||
//! reported device to a driver. This is the data-driven replacement for the
|
||||
//! device manager's three hand-written `switch` tables (`pciDriverForIdentity`,
|
||||
//! `hidDriverFor`, `usbDriverForIdentity`); the registry is now **authoritative**
|
||||
//! — a device that no row matches goes unbound (logged), never guessed.
|
||||
//!
|
||||
//! Pure logic: no hardware access, no syscalls, no allocator. `parse` fills a
|
||||
//! caller-provided `[]Rule` whose string fields (`hid`, `driver`) are slices
|
||||
//! *into the CSV source*, so the source buffer must outlive the rules (the
|
||||
//! manager holds it in a static buffer for the life of the process — zero-copy).
|
||||
//! That keeps this module freestanding and unit-testable with plain `zig test`.
|
||||
//!
|
||||
//! The file format (docs/device-driver-development/device-manager.md, and the
|
||||
//! `/system/configuration/devices.csv` header itself): one rule per line, nine comma-separated
|
||||
//! fields, `#` starts a comment (whole-line or trailing), blank lines ignored.
|
||||
//!
|
||||
//! bus, base, class, prog_if, vendor, device, subsystem, hid, driver
|
||||
//!
|
||||
//! `bus` is `pci`/`usb`/`acpi`; the numeric fields are hex (with or without a
|
||||
//! `0x` prefix); `*` or an empty field is a wildcard (matches anything). For PCI
|
||||
//! the class triple is base/subclass/prog-IF; for USB it is class/subclass/
|
||||
//! protocol with vendor/device the idVendor/idProduct; ACPI matches on `hid`
|
||||
//! (e.g. "PNP0303") with the triple left blank. `driver` is a full ramdisk path.
|
||||
|
||||
const std = @import("std");
|
||||
const csv = @import("csv");
|
||||
|
||||
/// Which bus a rule or a reported device belongs to. `unknown` is what an
|
||||
/// unrecognised `bus` token parses to — such a rule never matches (its bus
|
||||
/// equals no real device's), so a typo fails safe rather than binding wrongly.
|
||||
pub const Bus = enum {
|
||||
pci,
|
||||
usb,
|
||||
acpi,
|
||||
unknown,
|
||||
|
||||
pub fn fromToken(token: []const u8) Bus {
|
||||
if (std.mem.eql(u8, token, "pci")) return .pci;
|
||||
if (std.mem.eql(u8, token, "usb")) return .usb;
|
||||
if (std.mem.eql(u8, token, "acpi")) return .acpi;
|
||||
return .unknown;
|
||||
}
|
||||
};
|
||||
|
||||
/// A reported device's full identity, as the manager assembles it from a
|
||||
/// `child_added`: the bus-native class triple plus the numeric ids the widened
|
||||
/// ABI now carries, or the ACPI `_HID` string. Fields a given bus does not have
|
||||
/// are zero / empty (a PCI function has no `hid`; an ACPI device has no vendor).
|
||||
pub const Identity = struct {
|
||||
bus: Bus,
|
||||
base: u8 = 0,
|
||||
subclass: u8 = 0,
|
||||
prog_if: u8 = 0,
|
||||
vendor: u16 = 0,
|
||||
device: u16 = 0,
|
||||
subsystem: u32 = 0,
|
||||
hid: []const u8 = "",
|
||||
};
|
||||
|
||||
/// One parsed registry row. A `null` field is a wildcard — it matches any value
|
||||
/// and contributes nothing to specificity. String fields point into the CSV
|
||||
/// source that was parsed (see the module doc).
|
||||
pub const Rule = struct {
|
||||
bus: Bus,
|
||||
base: ?u8 = null,
|
||||
subclass: ?u8 = null,
|
||||
prog_if: ?u8 = null,
|
||||
vendor: ?u16 = null,
|
||||
device: ?u16 = null,
|
||||
subsystem: ?u32 = null,
|
||||
hid: ?[]const u8 = null,
|
||||
driver: []const u8,
|
||||
};
|
||||
|
||||
/// Specificity weights: how much each pinned field counts toward "most specific
|
||||
/// wins". Doubling from the coarsest (`base`) so that each level outweighs *all*
|
||||
/// coarser levels combined (1+2+4+8+16 = 31 < 32) — a rule that pins `device`
|
||||
/// always beats any rule that does not, no matter how many coarse fields the
|
||||
/// latter pins. `hid` and `device` share the top tier (the user's "hid and
|
||||
/// device weigh heaviest"); they never co-occur, since `hid` is ACPI-only and
|
||||
/// `device` is a PCI/USB numeric id.
|
||||
const weight_base: u32 = 1;
|
||||
const weight_subclass: u32 = 2;
|
||||
const weight_prog_if: u32 = 4;
|
||||
const weight_vendor: u32 = 8;
|
||||
const weight_subsystem: u32 = 16;
|
||||
const weight_device: u32 = 32;
|
||||
const weight_hid: u32 = 32;
|
||||
|
||||
/// The outcome of `matchDriver`: the winning rule's driver path, its specificity,
|
||||
/// and whether another rule tied it at that specificity. `ambiguous` is a
|
||||
/// registry authoring error (two equally-specific rules claiming one device); the
|
||||
/// manager logs it loudly and binds the first, so a shadowed rule is visible
|
||||
/// rather than silently dropped.
|
||||
pub const Match = struct {
|
||||
driver: []const u8,
|
||||
specificity: u32,
|
||||
ambiguous: bool,
|
||||
};
|
||||
|
||||
/// Whether `rule` matches `id`: same bus, and every pinned (non-wildcard) field
|
||||
/// equal. `hid` compares as a string; the rest as integers.
|
||||
fn matches(rule: Rule, id: Identity) bool {
|
||||
if (rule.bus != id.bus) return false;
|
||||
if (rule.base) |b| if (b != id.base) return false;
|
||||
if (rule.subclass) |s| if (s != id.subclass) return false;
|
||||
if (rule.prog_if) |p| if (p != id.prog_if) return false;
|
||||
if (rule.vendor) |v| if (v != id.vendor) return false;
|
||||
if (rule.device) |d| if (d != id.device) return false;
|
||||
if (rule.subsystem) |s| if (s != id.subsystem) return false;
|
||||
if (rule.hid) |h| if (!std.mem.eql(u8, h, id.hid)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The specificity score of a rule — the sum of the weights of its pinned fields.
|
||||
fn specificity(rule: Rule) u32 {
|
||||
var score: u32 = 0;
|
||||
if (rule.base != null) score += weight_base;
|
||||
if (rule.subclass != null) score += weight_subclass;
|
||||
if (rule.prog_if != null) score += weight_prog_if;
|
||||
if (rule.vendor != null) score += weight_vendor;
|
||||
if (rule.device != null) score += weight_device;
|
||||
if (rule.subsystem != null) score += weight_subsystem;
|
||||
if (rule.hid != null) score += weight_hid;
|
||||
return score;
|
||||
}
|
||||
|
||||
/// Bind a reported device to a driver: of every rule that matches `id`, return
|
||||
/// the most specific. `null` when nothing matches (the device goes unbound —
|
||||
/// the authoritative registry does not guess). On an exact specificity tie the
|
||||
/// first such rule in file order wins and `ambiguous` is set.
|
||||
pub fn matchDriver(rules: []const Rule, id: Identity) ?Match {
|
||||
var best: ?Match = null;
|
||||
for (rules) |rule| {
|
||||
if (!matches(rule, id)) continue;
|
||||
const score = specificity(rule);
|
||||
if (best) |current| {
|
||||
if (score > current.specificity) {
|
||||
best = .{ .driver = rule.driver, .specificity = score, .ambiguous = false };
|
||||
} else if (score == current.specificity) {
|
||||
// Two equally-specific rules claim this device — keep the first,
|
||||
// flag the ambiguity for the manager to log.
|
||||
best.?.ambiguous = true;
|
||||
}
|
||||
} else {
|
||||
best = .{ .driver = rule.driver, .specificity = score, .ambiguous = false };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// --- parsing -----------------------------------------------------------------
|
||||
|
||||
/// What one CSV line parsed to. `malformed` is a non-comment, non-blank line the
|
||||
/// parser could not read (wrong field count, unparsable number, empty driver) —
|
||||
/// the manager counts these and logs, so a broken registry is loud, not silent.
|
||||
const Line = union(enum) {
|
||||
rule: Rule,
|
||||
ignorable, // blank or comment
|
||||
malformed,
|
||||
};
|
||||
|
||||
/// The result of `parse`: how many rules landed in the caller's buffer, and how
|
||||
/// many non-ignorable lines were malformed (for the manager to log). `truncated`
|
||||
/// is set if there were more valid rules than the buffer could hold.
|
||||
pub const ParseResult = struct {
|
||||
count: usize,
|
||||
malformed: usize,
|
||||
truncated: bool,
|
||||
};
|
||||
|
||||
/// Parse one hex field into `T`, honouring `*`/empty as a wildcard (`null`) and
|
||||
/// an optional `0x` prefix. Returns an error only for a genuinely unparsable
|
||||
/// non-wildcard token, so the caller can mark the whole line malformed.
|
||||
fn parseHexField(comptime T: type, field: []const u8) !?T {
|
||||
const token = std.mem.trim(u8, field, " \t");
|
||||
if (token.len == 0 or std.mem.eql(u8, token, "*")) return null;
|
||||
const digits = if (std.mem.startsWith(u8, token, "0x") or std.mem.startsWith(u8, token, "0X"))
|
||||
token[2..]
|
||||
else
|
||||
token;
|
||||
return try std.fmt.parseInt(T, digits, 16);
|
||||
}
|
||||
|
||||
/// Parse a wildcard-or-string field (the `hid` column): `*`/empty → wildcard.
|
||||
fn parseStringField(field: []const u8) ?[]const u8 {
|
||||
const token = std.mem.trim(u8, field, " \t");
|
||||
if (token.len == 0 or std.mem.eql(u8, token, "*")) return null;
|
||||
return token;
|
||||
}
|
||||
|
||||
/// Classify and (if a rule) parse one line. Split out from `parse` so it can be
|
||||
/// unit-tested directly. `line` is the raw line including no newline.
|
||||
fn parseLine(line: []const u8) Line {
|
||||
const body = csv.stripComment(line);
|
||||
if (body.len == 0) return .ignorable;
|
||||
|
||||
// Nine comma-separated fields (csv.fields trims each): bus, base, class,
|
||||
// prog_if, vendor, device, subsystem, hid, driver.
|
||||
var cols: [9][]const u8 = undefined;
|
||||
var count: usize = 0;
|
||||
var it = csv.fields(body);
|
||||
while (it.next()) |field| {
|
||||
if (count >= cols.len) return .malformed; // too many columns
|
||||
cols[count] = field;
|
||||
count += 1;
|
||||
}
|
||||
if (count != cols.len) return .malformed; // too few columns
|
||||
|
||||
const bus = Bus.fromToken(cols[0]);
|
||||
if (bus == .unknown) return .malformed;
|
||||
|
||||
const driver = cols[8];
|
||||
if (driver.len == 0) return .malformed;
|
||||
|
||||
return .{ .rule = .{
|
||||
.bus = bus,
|
||||
.base = parseHexField(u8, cols[1]) catch return .malformed,
|
||||
.subclass = parseHexField(u8, cols[2]) catch return .malformed,
|
||||
.prog_if = parseHexField(u8, cols[3]) catch return .malformed,
|
||||
.vendor = parseHexField(u16, cols[4]) catch return .malformed,
|
||||
.device = parseHexField(u16, cols[5]) catch return .malformed,
|
||||
.subsystem = parseHexField(u32, cols[6]) catch return .malformed,
|
||||
.hid = parseStringField(cols[7]),
|
||||
.driver = driver,
|
||||
} };
|
||||
}
|
||||
|
||||
/// Parse a whole `/system/configuration/devices.csv` into `out_rules`. The string fields of the
|
||||
/// returned rules point into `source`, which must outlive them.
|
||||
pub fn parse(source: []const u8, out_rules: []Rule) ParseResult {
|
||||
var result: ParseResult = .{ .count = 0, .malformed = 0, .truncated = false };
|
||||
var lines = std.mem.splitScalar(u8, source, '\n');
|
||||
while (lines.next()) |line| {
|
||||
switch (parseLine(line)) {
|
||||
.ignorable => {},
|
||||
.malformed => result.malformed += 1,
|
||||
.rule => |rule| {
|
||||
if (result.count >= out_rules.len) {
|
||||
result.truncated = true;
|
||||
continue;
|
||||
}
|
||||
out_rules[result.count] = rule;
|
||||
result.count += 1;
|
||||
},
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- tests -------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
// The worked example from the design: a specific virtio-gpu rule (pins vendor +
|
||||
// device) and a generic display rule (class only) both match the virtio card;
|
||||
// the specific one must win. And a plain VGA adapter still falls to the generic
|
||||
// rule. This is the whole point of widening the ABI to carry vendor/device.
|
||||
test "virtio device rule beats the generic display rule" {
|
||||
const text =
|
||||
\\# bus, base, class, prog_if, vendor, device, subsystem, hid, driver
|
||||
\\pci, 03, 00, 00, *, *, *, *, /system/drivers/display
|
||||
\\pci, 03, 80, *, 1AF4, 1050, *, *, /system/drivers/virtio-gpu
|
||||
;
|
||||
var rules: [8]Rule = undefined;
|
||||
const parsed = parse(text, &rules);
|
||||
try testing.expectEqual(@as(usize, 2), parsed.count);
|
||||
try testing.expectEqual(@as(usize, 0), parsed.malformed);
|
||||
|
||||
// The virtio-gpu function: display / other, vendor 1AF4 device 1050.
|
||||
const virtio = matchDriver(rules[0..parsed.count], .{
|
||||
.bus = .pci, .base = 0x03, .subclass = 0x80, .prog_if = 0x00,
|
||||
.vendor = 0x1AF4, .device = 0x1050,
|
||||
}).?;
|
||||
try testing.expect(!virtio.ambiguous);
|
||||
try testing.expectEqualStrings("/system/drivers/virtio-gpu", virtio.driver);
|
||||
|
||||
// A plain VGA adapter (display / VGA) still binds the generic display driver.
|
||||
const vga = matchDriver(rules[0..parsed.count], .{
|
||||
.bus = .pci, .base = 0x03, .subclass = 0x00, .prog_if = 0x00,
|
||||
.vendor = 0x1234, .device = 0x1111,
|
||||
}).?;
|
||||
try testing.expectEqualStrings("/system/drivers/display", vga.driver);
|
||||
}
|
||||
|
||||
test "no matching row leaves the device unbound" {
|
||||
const text = "pci, 0C, 03, 30, *, *, *, *, /system/drivers/usb-xhci-bus\n";
|
||||
var rules: [8]Rule = undefined;
|
||||
const parsed = parse(text, &rules);
|
||||
try testing.expectEqual(@as(usize, 1), parsed.count);
|
||||
|
||||
// An AHCI controller (mass storage / SATA / AHCI) has no row — unbound.
|
||||
const unmatched = matchDriver(rules[0..parsed.count], .{
|
||||
.bus = .pci, .base = 0x01, .subclass = 0x06, .prog_if = 0x01,
|
||||
});
|
||||
try testing.expect(unmatched == null);
|
||||
}
|
||||
|
||||
test "acpi rows match on hid" {
|
||||
const text =
|
||||
\\acpi, *, *, *, *, *, *, PNP0303, /system/drivers/ps2-bus
|
||||
\\acpi, *, *, *, *, *, *, PNP0F13, /system/drivers/ps2-bus
|
||||
;
|
||||
var rules: [8]Rule = undefined;
|
||||
const parsed = parse(text, &rules);
|
||||
try testing.expectEqual(@as(usize, 2), parsed.count);
|
||||
|
||||
const keyboard = matchDriver(rules[0..parsed.count], .{ .bus = .acpi, .hid = "PNP0303" }).?;
|
||||
try testing.expectEqualStrings("/system/drivers/ps2-bus", keyboard.driver);
|
||||
const nothing = matchDriver(rules[0..parsed.count], .{ .bus = .acpi, .hid = "PNP0A03" });
|
||||
try testing.expect(nothing == null);
|
||||
}
|
||||
|
||||
test "equally specific rules flag ambiguity" {
|
||||
const text =
|
||||
\\pci, 03, 00, 00, *, *, *, *, /system/drivers/display-a
|
||||
\\pci, 03, 00, 00, *, *, *, *, /system/drivers/display-b
|
||||
;
|
||||
var rules: [8]Rule = undefined;
|
||||
const parsed = parse(text, &rules);
|
||||
const hit = matchDriver(rules[0..parsed.count], .{
|
||||
.bus = .pci, .base = 0x03, .subclass = 0x00, .prog_if = 0x00,
|
||||
}).?;
|
||||
try testing.expect(hit.ambiguous);
|
||||
try testing.expectEqualStrings("/system/drivers/display-a", hit.driver); // first wins
|
||||
}
|
||||
|
||||
test "comments, blanks, and malformed lines" {
|
||||
const text =
|
||||
\\# a header comment
|
||||
\\
|
||||
\\pci, 0C, 03, 30, *, *, *, *, /system/drivers/usb-xhci-bus # trailing comment
|
||||
\\pci, ZZ, 03, 30, *, *, *, *, /system/drivers/broken
|
||||
\\pci, 03, 00, 00, *, *, *, *,
|
||||
\\bogus-bus, *, *, *, *, *, *, *, /system/drivers/x
|
||||
;
|
||||
var rules: [8]Rule = undefined;
|
||||
const parsed = parse(text, &rules);
|
||||
try testing.expectEqual(@as(usize, 1), parsed.count); // only the xhci row is valid
|
||||
try testing.expectEqual(@as(usize, 3), parsed.malformed); // bad hex, empty driver, bad bus
|
||||
try testing.expectEqualStrings("/system/drivers/usb-xhci-bus", rules[0].driver);
|
||||
try testing.expect(rules[0].hid == null); // trailing comment stripped, hid still wildcard
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
//! the service harness drops buffered-message payloads — see service.zig).
|
||||
|
||||
const std = @import("std");
|
||||
const channel = @import("channel");
|
||||
const ipc = @import("ipc");
|
||||
const time = @import("time");
|
||||
const usb_transfer_protocol = @import("usb-transfer-protocol");
|
||||
@@ -99,6 +100,19 @@ pub const Device = struct {
|
||||
return std.mem.bytesToValue(usb_transfer_protocol.InterruptSubscribeReply, reply[0..@sizeOf(usb_transfer_protocol.InterruptSubscribeReply)]).status == 0;
|
||||
}
|
||||
|
||||
/// Hand the controller a DMA-region capability (`handle` — from a `shareable`
|
||||
/// dma_alloc, or forwarded from another process) so it binds that buffer into its
|
||||
/// IOMMU domain. Must be called for every buffer whose physical address this device
|
||||
/// will name in a `bulk` transfer, before the transfer. Harmless (and a no-op
|
||||
/// success) when no IOMMU is enforcing. Returns false on failure.
|
||||
pub fn attachDma(self: *Device, handle: ipc.Handle) bool {
|
||||
var request = usb_transfer_protocol.DmaAttachRequest{ .device_token = self.token };
|
||||
var reply: [@sizeOf(usb_transfer_protocol.DmaAttachReply)]u8 = undefined;
|
||||
const result = ipc.callCap(self.bus, std.mem.asBytes(&request), &reply, handle) catch return false;
|
||||
if (result.len < @sizeOf(usb_transfer_protocol.DmaAttachReply)) return false;
|
||||
return std.mem.bytesToValue(usb_transfer_protocol.DmaAttachReply, reply[0..@sizeOf(usb_transfer_protocol.DmaAttachReply)]).status == 0;
|
||||
}
|
||||
|
||||
/// One bulk transfer (IN or OUT per `endpoint_address`'s direction bit) to or
|
||||
/// from the caller's own DMA buffer at `physical`. Returns the bytes moved.
|
||||
pub fn bulk(self: *Device, endpoint_address: u8, physical: u64, length: u32) ?u32 {
|
||||
@@ -117,13 +131,15 @@ pub const Device = struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// Look up the USB bus and open the device with the assigned id, handing over a
|
||||
/// freshly created endpoint for asynchronous interrupt reports. Retries while the
|
||||
/// bus is still coming up (a class driver races the bus driver at boot).
|
||||
/// Open `/protocol/usb-transfer` and, on that channel, open the device with the
|
||||
/// assigned id, handing over a freshly created endpoint for asynchronous interrupt
|
||||
/// reports. Retries while the bus is still coming up (a class driver races the bus
|
||||
/// driver at boot). Two opens, deliberately: the first names the contract, the
|
||||
/// second names an object within it.
|
||||
pub fn open(device_id: u64) ?Device {
|
||||
var attempts: usize = 0;
|
||||
const bus = while (attempts < 100) : (attempts += 1) {
|
||||
if (ipc.lookup(.usb_bus)) |handle| break handle;
|
||||
if (channel.openEndpoint("usb-transfer")) |handle| break handle;
|
||||
time.sleepMillis(20);
|
||||
} else return null;
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! The "kernel" library domain (library/kernel): the userspace private-ABI
|
||||
//! library (kernel32-style), split by concern into directly-importable
|
||||
//! modules. The graph is a DAG: memory depends on thread (heap needs
|
||||
//! Thread.Mutex), and thread does its own raw mmap so there is no cycle.
|
||||
//!
|
||||
//! This package also exports `abi` — the kernel <-> user contract (SystemCall
|
||||
//! numbers, mmap prot flags, page_size). Its source lives with the kernel in
|
||||
//! system/abi.zig, outside this directory, but userspace's one view of it is
|
||||
//! exported here so every consumer names the same module instance. Reaching
|
||||
//! outside the package root means this package is valid only as an in-repo
|
||||
//! path dependency (never fetchable by hash) — fine, since path dependencies
|
||||
//! are the only way danos packages are consumed.
|
||||
//!
|
||||
//! The root shim (root.zig) and the user link script (user.ld) are plain
|
||||
//! files, not modules; build-support reaches them through this package's
|
||||
//! directory (Dependency.path).
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
pub fn build(b: *std.Build) void {
|
||||
const protocol = b.dependency("protocol", .{});
|
||||
|
||||
const abi = b.addModule("abi", .{
|
||||
.root_source_file = b.path("../../system/abi.zig"),
|
||||
});
|
||||
const system_call = b.addModule("system-call", .{
|
||||
.root_source_file = b.path("system-call.zig"),
|
||||
.imports = &.{.{ .name = "abi", .module = abi }},
|
||||
});
|
||||
const ipc = b.addModule("ipc", .{
|
||||
.root_source_file = b.path("ipc.zig"),
|
||||
.imports = &.{ .{ .name = "abi", .module = abi }, .{ .name = "system-call", .module = system_call } },
|
||||
});
|
||||
const time = b.addModule("time", .{
|
||||
.root_source_file = b.path("time.zig"),
|
||||
.imports = &.{.{ .name = "system-call", .module = system_call }},
|
||||
});
|
||||
const thread = b.addModule("thread", .{
|
||||
.root_source_file = b.path("thread.zig"),
|
||||
.imports = &.{ .{ .name = "abi", .module = abi }, .{ .name = "system-call", .module = system_call } },
|
||||
});
|
||||
const logging = b.addModule("logging", .{
|
||||
.root_source_file = b.path("logging.zig"),
|
||||
.imports = &.{ .{ .name = "abi", .module = abi }, .{ .name = "system-call", .module = system_call } },
|
||||
});
|
||||
const process = b.addModule("process", .{
|
||||
.root_source_file = b.path("process.zig"),
|
||||
.imports = &.{
|
||||
.{ .name = "abi", .module = abi },
|
||||
.{ .name = "system-call", .module = system_call },
|
||||
.{ .name = "ipc", .module = ipc },
|
||||
.{ .name = "time", .module = time },
|
||||
},
|
||||
});
|
||||
const file_system = b.addModule("file-system", .{
|
||||
.root_source_file = b.path("file-system.zig"),
|
||||
.imports = &.{
|
||||
.{ .name = "abi", .module = abi },
|
||||
.{ .name = "system-call", .module = system_call },
|
||||
.{ .name = "ipc", .module = ipc },
|
||||
.{ .name = "vfs-protocol", .module = protocol.module("vfs-protocol") },
|
||||
},
|
||||
});
|
||||
// The channel is the L1 concept made concrete (docs/os-development/communication.md):
|
||||
// it needs the namespace (file-system, to resolve a /protocol name) and the
|
||||
// transport (ipc) both, which is why it lives here rather than in a protocol
|
||||
// module — those import nothing.
|
||||
const channel = b.addModule("channel", .{
|
||||
.root_source_file = b.path("channel.zig"),
|
||||
.imports = &.{
|
||||
.{ .name = "ipc", .module = ipc },
|
||||
.{ .name = "time", .module = time },
|
||||
.{ .name = "file-system", .module = file_system },
|
||||
.{ .name = "vfs-protocol", .module = protocol.module("vfs-protocol") },
|
||||
.{ .name = "envelope", .module = protocol.module("envelope") },
|
||||
},
|
||||
});
|
||||
_ = b.addModule("memory", .{
|
||||
.root_source_file = b.path("memory/memory.zig"),
|
||||
.imports = &.{
|
||||
.{ .name = "abi", .module = abi },
|
||||
.{ .name = "system-call", .module = system_call },
|
||||
.{ .name = "ipc", .module = ipc },
|
||||
.{ .name = "thread", .module = thread },
|
||||
},
|
||||
});
|
||||
// The harness binds the service's contract name at startup, which is a
|
||||
// conversation with the registry — hence channel (and time, for the patience
|
||||
// a provider that beat init to the mount needs).
|
||||
_ = b.addModule("service", .{
|
||||
.root_source_file = b.path("service.zig"),
|
||||
.imports = &.{
|
||||
.{ .name = "channel", .module = channel },
|
||||
.{ .name = "ipc", .module = ipc },
|
||||
.{ .name = "process", .module = process },
|
||||
},
|
||||
});
|
||||
_ = b.addModule("start", .{
|
||||
.root_source_file = b.path("start.zig"),
|
||||
.imports = &.{ .{ .name = "process", .module = process }, .{ .name = "logging", .module = logging } },
|
||||
});
|
||||
|
||||
// Standalone `zig build test` for this domain alone; the root build keeps
|
||||
// its aggregate test step. time and thread pull in the syscall wrappers,
|
||||
// which need the `abi` module; their danos seams fall back to host
|
||||
// primitives off the danos target, so they run with real host threads.
|
||||
const test_step = b.step("test", "Run the kernel library unit tests");
|
||||
for ([_][]const u8{
|
||||
"time.zig", // Instant/Duration arithmetic
|
||||
"thread.zig", // Mutex/Condition/RwLock/WaitGroup state machines
|
||||
}) |root| {
|
||||
const kernel_tests = b.addTest(.{
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path(root),
|
||||
.target = b.resolveTargetQuery(.{}),
|
||||
.imports = &.{.{ .name = "abi", .module = abi }},
|
||||
}),
|
||||
});
|
||||
test_step.dependOn(&b.addRunArtifact(kernel_tests).step);
|
||||
}
|
||||
|
||||
// channel needs its whole import set to compile at all; only its framing is
|
||||
// host-runnable (the syscall seams are x86_64-only, and unreferenced from
|
||||
// the tests), so that is what it tests.
|
||||
const channel_tests = b.addTest(.{
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("channel.zig"),
|
||||
.target = b.resolveTargetQuery(.{}),
|
||||
.imports = &.{
|
||||
.{ .name = "ipc", .module = ipc },
|
||||
.{ .name = "time", .module = time },
|
||||
.{ .name = "file-system", .module = file_system },
|
||||
.{ .name = "vfs-protocol", .module = protocol.module("vfs-protocol") },
|
||||
.{ .name = "envelope", .module = protocol.module("envelope") },
|
||||
},
|
||||
}),
|
||||
});
|
||||
test_step.dependOn(&b.addRunArtifact(channel_tests).step);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
.{
|
||||
.name = .kernel,
|
||||
.version = "0.0.0",
|
||||
.fingerprint = 0x5dd29aab36503453, // Changing this has security and trust implications.
|
||||
.minimum_zig_version = "0.16.0",
|
||||
.dependencies = .{
|
||||
// file-system speaks the VFS wire protocol.
|
||||
.protocol = .{ .path = "../protocol" },
|
||||
},
|
||||
.paths = .{""},
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
//! `Channel` — layer L1 of the communication stack
|
||||
//! (docs/os-development/communication.md) made concrete. A program holds a
|
||||
//! channel that speaks a protocol; it does not hold a raw handle and marshal
|
||||
//! bytes at one. The channel is the answer to "who am I talking to", decided
|
||||
//! once at establishment, so nothing after that ever routes a party again:
|
||||
//! every packet's `target` addresses an *object* within the peer already chosen.
|
||||
//!
|
||||
//! **Possession of the Channel is the connection.** There is no connect step, no
|
||||
//! session id, no reconnect handshake — the endpoint capability inside is the
|
||||
//! whole of the relationship, and it cannot be forged, only handed over. Which
|
||||
//! also means a channel is a resource: `close` it, or it occupies a handle-table
|
||||
//! slot for the life of the process.
|
||||
//!
|
||||
//! **A dead provider surfaces as `-EPEER`, and the recovery is to re-open.**
|
||||
//! When the process on the other end exits, the kernel fails calls on its
|
||||
//! endpoint rather than blocking forever; `call` returns null. The client does
|
||||
//! not repair the channel — it discards it and opens the name again, which
|
||||
//! reaches whatever instance the registry now points at. The restart story
|
||||
//! falls out of the naming layer for free; no protocol needs a reconnect verb.
|
||||
//!
|
||||
//! `open` resolves a `/protocol/<name>` path through the kernel VFS router and
|
||||
//! takes the provider's endpoint from the open reply's capability. The registry
|
||||
//! answering it is init, PID 1, which mounts `/protocol` before it spawns anyone
|
||||
//! (docs/os-development/protocol-namespace.md); `bind` below is the other half —
|
||||
//! how a provider claims the name in the first place.
|
||||
|
||||
const std = @import("std");
|
||||
const ipc = @import("ipc");
|
||||
const time = @import("time");
|
||||
const file_system = @import("file-system");
|
||||
const vfs_protocol = @import("vfs-protocol");
|
||||
const envelope = @import("envelope");
|
||||
|
||||
/// Longest `/protocol/...` path this client marshals. The registry's names are
|
||||
/// short by construction (a contract leaf, not a file path), and the buffer is
|
||||
/// on the stack of whoever opens.
|
||||
pub const path_maximum: usize = 224;
|
||||
|
||||
/// Where the protocol namespace is rooted — the one path prefix in the system
|
||||
/// that names contracts rather than files. Spelled once, here, so no caller
|
||||
/// builds it by hand (docs/file-system-development/file-system-hierarchy.md).
|
||||
pub const root: []const u8 = "/protocol";
|
||||
|
||||
/// Longest contract name — the part after `/protocol/`. Short by construction:
|
||||
/// a leaf like `display`, or a subtree leaf like `test/shared-memory`.
|
||||
pub const name_maximum: usize = 64;
|
||||
|
||||
/// What a `call` came back with: the provider's status, the reply payload (the
|
||||
/// bytes after the `Status`, in the caller's own buffer), and any capability the
|
||||
/// reply carried.
|
||||
pub const Response = struct {
|
||||
status: envelope.Status,
|
||||
payload: []u8,
|
||||
capability: ?ipc.Handle,
|
||||
|
||||
/// Whether the provider answered success. A negative status is its refusal
|
||||
/// (`-ENOSYS` for a verb it does not implement, and so on).
|
||||
pub fn succeeded(self: Response) bool {
|
||||
return self.status.status == 0;
|
||||
}
|
||||
};
|
||||
|
||||
/// An open conversation with one provider, speaking one protocol.
|
||||
pub const Channel = struct {
|
||||
/// The provider's endpoint. Sending into it is the only thing this handle
|
||||
/// can do — an endpoint is a mailbox owned by its creator, and that
|
||||
/// direction never reverses.
|
||||
endpoint: ipc.Handle,
|
||||
|
||||
/// Adopt an endpoint that arrived some other way — a capability delivered
|
||||
/// in a reply, or one a supervisor wired in at spawn time (P5). The channel
|
||||
/// takes ownership of the handle.
|
||||
pub fn adopt(endpoint: ipc.Handle) Channel {
|
||||
return .{ .endpoint = endpoint };
|
||||
}
|
||||
|
||||
/// Establish a channel by name: resolve `/protocol/<name>` to the registry
|
||||
/// backend, `open` the contract there, and take the provider's endpoint from
|
||||
/// the reply's capability. Null if the path does not resolve, the registry
|
||||
/// refuses (an ungranted name is refused *as* not-found), or the reply
|
||||
/// carries no capability.
|
||||
///
|
||||
/// The path is spoken exactly once, here. Everything afterwards is integers
|
||||
/// in the packet header.
|
||||
pub fn open(path: []const u8) ?Channel {
|
||||
return .{ .endpoint = openPath(path) orelse return null };
|
||||
}
|
||||
|
||||
/// Establish a channel by contract name — `open` with `/protocol/` supplied,
|
||||
/// which is how every caller in the system spells it.
|
||||
pub fn connect(name: []const u8) ?Channel {
|
||||
return .{ .endpoint = openEndpoint(name) orelse return null };
|
||||
}
|
||||
|
||||
/// Send one request packet and block for the reply: `[Header][request]` out,
|
||||
/// `[Status][reply]` back. `request` is the bytes *after* the header — the
|
||||
/// protocol's fixed part plus any tail — because the header is this call's
|
||||
/// to lay down. The reply's payload lands in `into`.
|
||||
///
|
||||
/// Null means the transport failed, which today means one of: a dead
|
||||
/// provider (`-EPEER` — discard this channel and `open` the name again), an
|
||||
/// oversized packet, or a bad handle. A provider that answered *and refused*
|
||||
/// is not a failure here: it comes back with a negative `Response.status`.
|
||||
pub fn call(self: Channel, header: envelope.Header, request: []const u8, into: []u8) ?Response {
|
||||
return self.callCapability(header, request, into, null);
|
||||
}
|
||||
|
||||
/// As `call`, handing the provider a capability with the request — the only
|
||||
/// direction-crossing move kernel-ipc offers, and how `subscribe` delivers
|
||||
/// the subscriber's own endpoint.
|
||||
pub fn callCapability(
|
||||
self: Channel,
|
||||
header: envelope.Header,
|
||||
request: []const u8,
|
||||
into: []u8,
|
||||
capability: ?ipc.Handle,
|
||||
) ?Response {
|
||||
var packet: [envelope.packet_maximum]u8 = undefined;
|
||||
const framed = frame(header, request, &packet) orelse return null;
|
||||
|
||||
var reply: [envelope.packet_maximum]u8 = undefined;
|
||||
const answer = ipc.callCap(self.endpoint, framed, &reply, capability) catch return null;
|
||||
const status = envelope.statusOf(reply[0..answer.len]) orelse return null;
|
||||
const available = @min(answer.len - envelope.prefix_size, @as(usize, status.len));
|
||||
const taken = @min(available, into.len);
|
||||
@memcpy(into[0..taken], reply[envelope.prefix_size..][0..taken]);
|
||||
return .{ .status = status, .payload = into[0..taken], .capability = answer.cap };
|
||||
}
|
||||
|
||||
/// Push one event packet and return immediately — no reply owed, and a slow
|
||||
/// or dead peer can never stall the sender. Bounded by `post_maximum`: an
|
||||
/// event that does not fit is refused here rather than split, because a
|
||||
/// packet is never fragmented.
|
||||
pub fn send(self: Channel, header: envelope.Header, payload: []const u8) bool {
|
||||
var packet: [envelope.post_maximum]u8 = undefined;
|
||||
const framed = frame(header, payload, &packet) orelse return false;
|
||||
return ipc.send(self.endpoint, framed);
|
||||
}
|
||||
|
||||
/// Ask the provider what it is: the reserved `describe` verb, answered by
|
||||
/// every protocol built through `envelope.Define`. The name and version come
|
||||
/// back in `into`, which the returned `Described` borrows.
|
||||
pub fn describe(self: Channel, into: []u8) ?envelope.Described {
|
||||
var request: [envelope.packet_maximum]u8 = undefined;
|
||||
const packet = envelope.encodeDescribe(&request) orelse return null;
|
||||
|
||||
var reply: [envelope.packet_maximum]u8 = undefined;
|
||||
const answer = ipc.callCap(self.endpoint, packet, &reply, null) catch return null;
|
||||
const taken = @min(answer.len, into.len);
|
||||
@memcpy(into[0..taken], reply[0..taken]);
|
||||
return envelope.decodeDescribe(into[0..taken]);
|
||||
}
|
||||
|
||||
/// Drop the provider's endpoint and free the handle-table slot. The
|
||||
/// conversation is over the moment the capability is gone — there is nothing
|
||||
/// else holding it open.
|
||||
pub fn close(self: Channel) void {
|
||||
_ = ipc.close(self.endpoint);
|
||||
}
|
||||
};
|
||||
|
||||
// --- the namespace: resolving, opening, and claiming a contract name ---------
|
||||
|
||||
/// Where a `/protocol/...` path routed: the registry's endpoint, plus the path
|
||||
/// rewritten mount-relative (`/display` for `/protocol/display`). The handle is
|
||||
/// deduplicated by the kernel across resolves and shared with every other user
|
||||
/// of that mount, so it is never ours to close.
|
||||
const Registry = struct {
|
||||
handle: ipc.Handle,
|
||||
relative: [path_maximum]u8,
|
||||
relative_len: usize,
|
||||
|
||||
fn path(self: *const Registry) []const u8 {
|
||||
return self.relative[0..self.relative_len];
|
||||
}
|
||||
};
|
||||
|
||||
/// Route `path` to whatever backend serves it. Null when nothing is mounted
|
||||
/// there — under `/protocol` that means the registry is not up yet, which is a
|
||||
/// *retry*, not a refusal. A kernel-served route (the read-only `/system` tree)
|
||||
/// is the wrong path, not a channel, and is refused here.
|
||||
fn reach(path: []const u8) ?Registry {
|
||||
var out: Registry = .{ .handle = 0, .relative = undefined, .relative_len = 0 };
|
||||
const route = file_system.fsResolve(path, 0, &out.relative) orelse return null;
|
||||
switch (route) {
|
||||
.kernel => return null,
|
||||
.backend => |b| {
|
||||
out.handle = b.handle;
|
||||
out.relative_len = b.path_len;
|
||||
return out;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// One vfs-protocol round trip at a backend: fixed header, inline payload, and
|
||||
/// an optional capability in each direction.
|
||||
fn transact(
|
||||
handle: ipc.Handle,
|
||||
operation: vfs_protocol.Operation,
|
||||
payload: []const u8,
|
||||
send_capability: ?ipc.Handle,
|
||||
) ?struct { reply: vfs_protocol.Reply, capability: ?ipc.Handle } {
|
||||
var request: [vfs_protocol.message_maximum]u8 = undefined;
|
||||
if (vfs_protocol.request_size + payload.len > request.len) return null;
|
||||
const header = vfs_protocol.Request{
|
||||
.operation = operation,
|
||||
.node = 0,
|
||||
.offset = 0,
|
||||
.len = @intCast(payload.len),
|
||||
.flags = 0,
|
||||
};
|
||||
@memcpy(request[0..vfs_protocol.request_size], std.mem.asBytes(&header));
|
||||
@memcpy(request[vfs_protocol.request_size..][0..payload.len], payload);
|
||||
|
||||
var reply: [vfs_protocol.message_maximum]u8 = undefined;
|
||||
const answer = ipc.callCap(handle, request[0 .. vfs_protocol.request_size + payload.len], &reply, send_capability) catch return null;
|
||||
if (answer.len < vfs_protocol.reply_size) return null;
|
||||
return .{
|
||||
.reply = std.mem.bytesToValue(vfs_protocol.Reply, reply[0..vfs_protocol.reply_size]),
|
||||
.capability = answer.cap,
|
||||
};
|
||||
}
|
||||
|
||||
/// Resolve an absolute `/protocol/...` path and take the provider's endpoint out
|
||||
/// of the open reply's capability.
|
||||
fn openPath(path: []const u8) ?ipc.Handle {
|
||||
const registry = reach(path) orelse return null;
|
||||
const answered = transact(registry.handle, .open, registry.path(), null) orelse return null;
|
||||
if (answered.reply.status != 0) return null;
|
||||
// The capability *is* the channel — an open that succeeds without one was
|
||||
// answered by a file backend, which does not speak protocols.
|
||||
return answered.capability;
|
||||
}
|
||||
|
||||
/// The provider's raw endpoint behind `/protocol/<name>`. The transitional form,
|
||||
/// for the clients that still marshal their protocol's bytes by hand; P4 moves
|
||||
/// them onto `Channel` proper and this shrinks back to `connect`.
|
||||
///
|
||||
/// Null covers both "no such contract" and "you may not have it" — deliberately
|
||||
/// the same answer (protocol-namespace.md: enforcement is absence), and also
|
||||
/// "the registry is not mounted yet", which is why every caller retries.
|
||||
pub fn openEndpoint(name: []const u8) ?ipc.Handle {
|
||||
var path: [path_maximum]u8 = undefined;
|
||||
const full = join(name, &path) orelse return null;
|
||||
return openPath(full);
|
||||
}
|
||||
|
||||
/// Claim `/protocol/<name>` for `endpoint`: the registry records the name
|
||||
/// against this process and hands the endpoint to whoever opens it afterwards.
|
||||
/// The endpoint rides the call as its capability, the one direction-crossing
|
||||
/// move kernel-ipc offers.
|
||||
///
|
||||
/// Three-valued on purpose. **Null** is "the registry could not be reached" —
|
||||
/// it is not mounted yet, which happens when a provider starts before init has
|
||||
/// finished coming up, and the answer is to retry. A **value** is the registry's
|
||||
/// verdict and is final: 0 bound, `-EPERM` this binary is not granted that name,
|
||||
/// `-EBUSY` a live provider already holds it.
|
||||
pub fn bind(name: []const u8, endpoint: ipc.Handle) ?i32 {
|
||||
const registry = reach(root) orelse return null;
|
||||
const answered = transact(registry.handle, .bind, name, endpoint) orelse return null;
|
||||
return answered.reply.status;
|
||||
}
|
||||
|
||||
/// How long a provider keeps offering itself before giving up. The registry is
|
||||
/// init, which mounts `/protocol` before it spawns anyone, so in a normal boot
|
||||
/// the first try lands; a provider the kernel test harness starts may well beat
|
||||
/// init to the mount, which is what the patience is for. Four seconds of 20 ms
|
||||
/// tries — the same cadence every client in the tree spends finding a service.
|
||||
const bind_attempts: u32 = 200;
|
||||
const bind_retry_ms: u64 = 20;
|
||||
|
||||
/// `bind`, waiting out a registry that is not mounted yet. Only unreachability
|
||||
/// is retried: a registry that *answered* has decided, and asking again cannot
|
||||
/// change its mind. True when the name is ours.
|
||||
pub fn bindPatiently(name: []const u8, endpoint: ipc.Handle) bool {
|
||||
var attempt: u32 = 0;
|
||||
while (attempt < bind_attempts) : (attempt += 1) {
|
||||
if (bind(name, endpoint)) |status| return status == 0;
|
||||
time.sleepMillis(bind_retry_ms);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// `/protocol/` + `name`, in the caller's buffer. Null if the name is empty or
|
||||
/// longer than the namespace admits.
|
||||
fn join(name: []const u8, buffer: []u8) ?[]u8 {
|
||||
if (name.len == 0 or name.len > name_maximum) return null;
|
||||
const total = root.len + 1 + name.len;
|
||||
if (total > buffer.len) return null;
|
||||
@memcpy(buffer[0..root.len], root);
|
||||
buffer[root.len] = '/';
|
||||
@memcpy(buffer[root.len + 1 ..][0..name.len], name);
|
||||
return buffer[0..total];
|
||||
}
|
||||
|
||||
/// Lay a packet down: the folded header first, then the protocol's bytes. Null
|
||||
/// when it would not fit the buffer — the same rule as `envelope`'s framing,
|
||||
/// applied where the buffer is the transport's, not the protocol's.
|
||||
fn frame(header: envelope.Header, body: []const u8, buffer: []u8) ?[]u8 {
|
||||
const total = envelope.prefix_size + body.len;
|
||||
if (total > buffer.len) return null;
|
||||
@memcpy(buffer[0..envelope.prefix_size], std.mem.asBytes(&header));
|
||||
@memcpy(buffer[envelope.prefix_size..][0..body.len], body);
|
||||
return buffer[0..total];
|
||||
}
|
||||
|
||||
// --- tests ------------------------------------------------------------------
|
||||
//
|
||||
// The syscall half cannot run on the host, and there is no registry to reach
|
||||
// until P2 — so what is testable here is the framing, which is the part with
|
||||
// arithmetic in it.
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "a framed packet is the header followed by the protocol's bytes" {
|
||||
var buffer: [envelope.packet_maximum]u8 = undefined;
|
||||
const header = envelope.Header{ .operation = envelope.first_protocol_operation, .target = 9 };
|
||||
const packet = frame(header, "body", &buffer).?;
|
||||
|
||||
try testing.expectEqual(envelope.prefix_size + "body".len, packet.len);
|
||||
const decoded = envelope.headerOf(packet).?;
|
||||
try testing.expectEqual(envelope.first_protocol_operation, decoded.operation);
|
||||
try testing.expectEqual(@as(u64, 9), decoded.target);
|
||||
try testing.expectEqualStrings("body", packet[envelope.prefix_size..]);
|
||||
}
|
||||
|
||||
test "a contract name joins the namespace root exactly once" {
|
||||
var buffer: [path_maximum]u8 = undefined;
|
||||
try testing.expectEqualStrings("/protocol/display", join("display", &buffer).?);
|
||||
try testing.expectEqualStrings("/protocol/test/shared-memory", join("test/shared-memory", &buffer).?);
|
||||
try testing.expect(join("", &buffer) == null);
|
||||
try testing.expect(join("x" ** (name_maximum + 1), &buffer) == null);
|
||||
}
|
||||
|
||||
test "framing refuses a packet that would not fit rather than truncating it" {
|
||||
var post: [envelope.post_maximum]u8 = undefined;
|
||||
const header = envelope.Header{ .operation = envelope.first_protocol_operation };
|
||||
const body = [_]u8{0} ** (envelope.post_maximum - envelope.prefix_size);
|
||||
const one_too_many = body ++ [_]u8{0};
|
||||
|
||||
try testing.expect(frame(header, &body, &post) != null);
|
||||
try testing.expect(frame(header, &one_too_many, &post) == null);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ fn kindFromWire(value: u32) Kind {
|
||||
@intFromEnum(Kind.symbolic_link) => .symbolic_link,
|
||||
@intFromEnum(Kind.fifo) => .fifo,
|
||||
@intFromEnum(Kind.socket) => .socket,
|
||||
@intFromEnum(Kind.protocol) => .protocol,
|
||||
else => .regular,
|
||||
};
|
||||
}
|
||||
@@ -305,7 +306,7 @@ pub fn makePath(path: []const u8) bool {
|
||||
while (end < path.len and path[end] != '/') end += 1;
|
||||
const prefix = path[0..end];
|
||||
if (prefix.len == 0 or (prefix.len == 1 and prefix[0] == '/')) continue;
|
||||
// Best-effort per prefix: components at or above a mount point ("/mnt")
|
||||
// Best-effort per prefix: components at or above a mount point ("/volumes")
|
||||
// are router names, not filesystem nodes — they neither exist as nodes
|
||||
// nor accept mkdir, and that is fine. Only the final verdict counts.
|
||||
if (!exists(prefix)) _ = makeDirectory(prefix);
|
||||
@@ -348,8 +349,9 @@ pub fn mount(target: []const u8, backend: ipc.Handle) bool {
|
||||
}
|
||||
|
||||
/// As `mount`, with a backend-side rewrite prefix: a path under `target` reaches
|
||||
/// the backend as `rewrite` + the mount-relative tail. How one volume serves two
|
||||
/// mounts ("/mnt/usb" from its root, "/var" from its /var subtree).
|
||||
/// the backend as `rewrite` + the mount-relative tail. How one volume serves
|
||||
/// several mounts ("/volumes/usb" from its root, "/system/logs" from its
|
||||
/// /system/logs subtree).
|
||||
pub fn mountRewritten(target: []const u8, backend: ipc.Handle, rewrite: []const u8) bool {
|
||||
return fsMount(target, backend, rewrite);
|
||||
}
|
||||
|
||||
+60
-9
@@ -29,16 +29,17 @@ pub fn createIpcEndpoint() ?Handle {
|
||||
return if (failed(r)) null else r;
|
||||
}
|
||||
|
||||
/// Publish endpoint `h` under a well-known service id so other processes find it.
|
||||
pub fn register(id: abi.ServiceId, h: Handle) bool {
|
||||
return !failed(sc.systemCall2(.ipc_register, @intFromEnum(id), h));
|
||||
}
|
||||
// `register`/`lookup` lived here — the two wrappers over the flat ServiceId
|
||||
// registry. Naming is not a system call any more: a provider binds its contract
|
||||
// name at the registry and a client resolves and opens `/protocol/<name>`, both
|
||||
// through `channel` (docs/os-development/protocol-namespace.md).
|
||||
|
||||
/// Find the endpoint published under `id`, installing a handle to it in this
|
||||
/// process.
|
||||
pub fn lookup(id: abi.ServiceId) ?Handle {
|
||||
const r = sc.systemCall1(.ipc_lookup, @intFromEnum(id));
|
||||
return if (failed(r)) null else r;
|
||||
/// Drop a capability handle (endpoint, shared-memory, or DMA-region) and free its table
|
||||
/// slot. A forwarding hop closes a cap it passed on; a binder closes a DMA-region cap
|
||||
/// once the binding holds its own reference — the 32-slot table is otherwise consumed by
|
||||
/// repeated cap-passing.
|
||||
pub fn close(h: Handle) bool {
|
||||
return !failed(sc.systemCall1(.handle_close, h));
|
||||
}
|
||||
|
||||
pub const CallError = error{Failed};
|
||||
@@ -170,6 +171,56 @@ pub const Received = struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// A capability that arrived with one turn of a receive loop, and the ownership
|
||||
/// rule for it: **the turn owns it until a handler takes it, and closes whatever
|
||||
/// is left.**
|
||||
///
|
||||
/// The kernel installs a sent capability in the receiver's handle table whenever
|
||||
/// the caller attached one, *independent of the message's length or kind*
|
||||
/// (system/kernel/ipc-synchronous.zig `replyWait`), so every path out of a loop
|
||||
/// has to dispose of one — including the paths that never look at the message.
|
||||
/// The table is thirty-two slots, and `ipc_call` does not dedupe, so a client
|
||||
/// looping on `callCap(server, &.{}, endpoint)` spends one slot per call: about
|
||||
/// thirty-two zero-length pings and the service can never accept another
|
||||
/// capability, which means no subscribe and no shared-memory handover, for the
|
||||
/// rest of the boot. It is unauthenticated and it is two lines to write.
|
||||
///
|
||||
/// So ownership is structural rather than a close per branch — the per-branch
|
||||
/// version has already failed twice in this tree, in PID 1's ping path and in
|
||||
/// every `service.run` callback that simply ignored its capability argument.
|
||||
/// Written this way, forgetting **closes**, and *keeping* a capability is the
|
||||
/// thing a handler has to say out loud:
|
||||
///
|
||||
/// ```zig
|
||||
/// var arrived: ipc.Arrival = .{ .handle = got.cap };
|
||||
/// defer arrived.release(); // every exit path, including `continue`
|
||||
/// ...
|
||||
/// const kept = arrived.take().?; // claimed: mine to hold or close
|
||||
/// ```
|
||||
pub const Arrival = struct {
|
||||
handle: ?Handle = null,
|
||||
|
||||
/// Look without claiming — a handler that may still refuse wants no close of
|
||||
/// its own on the refusal paths.
|
||||
pub fn peek(self: *const Arrival) ?Handle {
|
||||
return self.handle;
|
||||
}
|
||||
|
||||
/// Claim ownership: from here the capability is the taker's to keep or close,
|
||||
/// and the turn will not touch it.
|
||||
pub fn take(self: *Arrival) ?Handle {
|
||||
defer self.handle = null;
|
||||
return self.handle;
|
||||
}
|
||||
|
||||
/// Close whatever nobody claimed. Idempotent, so it is safe as a `defer` next
|
||||
/// to any number of `take`s.
|
||||
pub fn release(self: *Arrival) void {
|
||||
if (self.handle) |handle| _ = close(handle);
|
||||
self.handle = null;
|
||||
}
|
||||
};
|
||||
|
||||
/// Server side of IPC_ReplyWait: deliver `reply` to the client last received (if any,
|
||||
/// optionally handing it `send_cap`), then block until the next request arrives in
|
||||
/// `receive`. Returns its length, the sender badge, and any capability the request
|
||||
|
||||
@@ -12,12 +12,18 @@ const sc = @import("system-call");
|
||||
pub const coherent: usize = abi.dma_coherent;
|
||||
pub const write_combining: usize = abi.dma_write_combining;
|
||||
pub const below_4g: usize = abi.dma_below_4g;
|
||||
/// Ask for a capability handle (in `Region.handle`) so the buffer can be delegated to
|
||||
/// another driver and bound into a device's IOMMU domain (`driver.dmaBind`). A driver's
|
||||
/// private rings don't need it; a buffer whose physical address crosses IPC does.
|
||||
pub const shareable: usize = abi.dma_shareable;
|
||||
|
||||
/// A DMA allocation: the `virtual` address the CPU touches, and the `physical` address
|
||||
/// to program into the device's descriptor-ring / base registers.
|
||||
/// A DMA allocation: the `virtual` address the CPU touches, the `physical` address to
|
||||
/// program into the device's registers, and — when `shareable` was requested — a
|
||||
/// capability `handle` naming the region for delegation (null otherwise).
|
||||
pub const Region = struct {
|
||||
virtual: usize,
|
||||
physical: usize,
|
||||
handle: ?usize = null,
|
||||
};
|
||||
|
||||
inline fn failed(r: usize) bool {
|
||||
@@ -25,21 +31,23 @@ inline fn failed(r: usize) bool {
|
||||
}
|
||||
|
||||
/// Allocate `len` bytes of DMA-capable memory with `flags` (e.g. `coherent`, or
|
||||
/// `coherent | below_4g`). Returns the virtual/physical pair, or null on failure. Two
|
||||
/// return values — the virtual address in rax, the physical address in rdx — so it
|
||||
/// needs a hand-written stub.
|
||||
/// `coherent | shareable`). Returns virtual/physical (and a handle when `shareable`), or
|
||||
/// null on failure. Three return values — virtual in rax, physical in rdx, handle in r8
|
||||
/// — so it needs a hand-written stub.
|
||||
pub fn alloc(len: usize, flags: usize) ?Region {
|
||||
var rax: usize = undefined;
|
||||
var rdx: usize = undefined; // out: physical address
|
||||
var r8: usize = undefined; // out: capability handle (abi.no_cap unless shareable)
|
||||
asm volatile ("syscall"
|
||||
: [rax] "={rax}" (rax),
|
||||
[rdx] "={rdx}" (rdx),
|
||||
[r8] "={r8}" (r8),
|
||||
: [n] "{rax}" (@intFromEnum(abi.SystemCall.dma_alloc)),
|
||||
[a0] "{rdi}" (len),
|
||||
[a1] "{rsi}" (flags),
|
||||
: .{ .rcx = true, .r11 = true, .memory = true });
|
||||
if (failed(rax)) return null;
|
||||
return .{ .virtual = rax, .physical = rdx };
|
||||
return .{ .virtual = rax, .physical = rdx, .handle = if (r8 == abi.no_cap) null else r8 };
|
||||
}
|
||||
|
||||
/// Release a region from a prior `alloc` (`virtual` and the same `len`).
|
||||
|
||||
@@ -38,6 +38,7 @@ pub const DmaRegion = dma.Region;
|
||||
pub const dma_coherent = dma.coherent;
|
||||
pub const dma_write_combining = dma.write_combining;
|
||||
pub const dma_below_4g = dma.below_4g;
|
||||
pub const dma_shareable = dma.shareable;
|
||||
pub const dmaAlloc = dma.alloc;
|
||||
pub const dmaFree = dma.free;
|
||||
|
||||
|
||||
@@ -142,6 +142,18 @@ pub fn subscribeExits(endpoint: usize) bool {
|
||||
/// snapshot buffer without importing `abi` itself.
|
||||
pub const ProcessDescriptor = abi.ProcessDescriptor;
|
||||
|
||||
/// The calling task's own kernel id — its row in the process table, and the value
|
||||
/// every other process sees as this one's `supervisor` after it spawns them. For a
|
||||
/// single-threaded program that is its process id; in a threaded one it is the
|
||||
/// calling thread's id (`Thread.getCurrentId` is the same system call, named for
|
||||
/// the threading vocabulary). Ids are monotonic and never reused
|
||||
/// (system/kernel/process.zig), which is what makes comparing one an identity
|
||||
/// test where comparing a *name* is only a resemblance test — the registrar in
|
||||
/// init leans on exactly that.
|
||||
pub fn taskId() u32 {
|
||||
return @intCast(sc.systemCall0(.thread_self));
|
||||
}
|
||||
|
||||
/// Give up the rest of this quantum.
|
||||
pub fn yield() void {
|
||||
_ = sc.systemCall0(.yield);
|
||||
|
||||
+50
-16
@@ -6,13 +6,19 @@
|
||||
//! loop chose, never on a hijacked stack — the whole reason signals are
|
||||
//! messages.
|
||||
//!
|
||||
//! One rule a service author does have to know, and it is stated on
|
||||
//! `Callbacks.on_message`: **a capability that arrives belongs to the turn** —
|
||||
//! the loop closes it unless the callback claims it with `take()`. Forgetting is
|
||||
//! therefore safe, and keeping is explicit; the opposite arrangement quietly
|
||||
//! spends a handle-table slot per request.
|
||||
//!
|
||||
//! The liveness probe: a **zero-length request is the universal ping**, answered
|
||||
//! with a zero-length reply by the harness itself. No protocol's requests start
|
||||
//! at length zero, so the encoding cannot collide, and there is nothing for a
|
||||
//! service author to implement — a wedged service simply fails to answer, which
|
||||
//! is the diagnosis (see docs/ipc.md).
|
||||
|
||||
const abi = @import("abi");
|
||||
const channel = @import("channel");
|
||||
const ipc = @import("ipc");
|
||||
const process = @import("process");
|
||||
|
||||
@@ -22,10 +28,22 @@ pub const Callbacks = struct {
|
||||
/// Return false to abort startup (the process exits).
|
||||
init: ?*const fn (endpoint: ipc.Handle) bool = null,
|
||||
/// One protocol request from `sender` (a task id): write the reply into
|
||||
/// `reply`, return its length. `capability` is the handle the request
|
||||
/// carried, if any (M13 cap passing — how a subscriber hands over its
|
||||
/// endpoint). The zero-length ping never reaches this.
|
||||
on_message: *const fn (message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize,
|
||||
/// `reply`, return its length. The zero-length ping never reaches this.
|
||||
///
|
||||
/// `arrived` is the capability the request carried (M13 cap passing — how a
|
||||
/// subscriber hands over its endpoint), and it comes with **an ownership
|
||||
/// rule: the turn owns it, and a handler that wants to keep it must say so
|
||||
/// with `take()`.** Whatever is left when this returns, the loop closes.
|
||||
/// `peek()` reads it without claiming, which is what a handler that may
|
||||
/// still refuse wants — no close of its own on the refusal paths.
|
||||
///
|
||||
/// The rule is stated here, in the contract, because the alternative has
|
||||
/// failed in practice: an implementation that simply ignored a `?ipc.Handle`
|
||||
/// argument leaked a handle table slot per request, and every operation
|
||||
/// except a subscribe ignores it. Thirty-two such requests — zero-length
|
||||
/// pings will do, and they need no authorization — and the service can never
|
||||
/// accept another capability for the rest of the boot. See `ipc.Arrival`.
|
||||
on_message: *const fn (message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize,
|
||||
/// A notification that is not a signal — a subscribed exit event, a bound
|
||||
/// IRQ, a timer landing. The raw badge; decode with the ipc helpers.
|
||||
on_notification: ?*const fn (badge: u64) void = null,
|
||||
@@ -35,19 +53,25 @@ pub const Callbacks = struct {
|
||||
/// the return itself — never put *necessary* work here (iron rule 1: a kill
|
||||
/// arrives with no warning; this is for graceful extras only).
|
||||
on_terminate: ?*const fn () void = null,
|
||||
/// Publish the endpoint under a well-known service id at startup.
|
||||
service: ?abi.ServiceId = null,
|
||||
/// The contract this service provides: a name under `/protocol`, mirroring
|
||||
/// the `library/protocol/` module that defines the wire format — a program
|
||||
/// imports `display-protocol` and the provider binds `"display"`
|
||||
/// (docs/os-development/protocol-namespace.md). Bound at startup, before
|
||||
/// `init` runs, so the service is reachable the moment it serves. A refusal
|
||||
/// (not granted, or a live provider already holds the name) aborts startup.
|
||||
service: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
/// Run the service: create and (optionally) register the endpoint, bind signals
|
||||
/// to it, call `init`, then serve until `terminate` arrives — at which point the
|
||||
/// loop returns and main's return is the clean exit the supervisor reads as
|
||||
/// `ExitReason.exited`. `maximum_message` sizes the receive and reply buffers
|
||||
/// (a service passes its protocol's message maximum).
|
||||
/// Run the service: create the endpoint, bind it under the service's contract
|
||||
/// name (if it has one), bind signals to it, call `init`, then serve until
|
||||
/// `terminate` arrives — at which point the loop returns and main's return is
|
||||
/// the clean exit the supervisor reads as `ExitReason.exited`.
|
||||
/// `maximum_message` sizes the receive and reply buffers (a service passes its
|
||||
/// protocol's message maximum).
|
||||
pub fn run(comptime maximum_message: usize, callbacks: Callbacks) void {
|
||||
const endpoint = ipc.createIpcEndpoint() orelse return;
|
||||
if (callbacks.service) |id| {
|
||||
if (!ipc.register(id, endpoint)) return;
|
||||
if (callbacks.service) |name| {
|
||||
if (!channel.bindPatiently(name, endpoint)) return;
|
||||
}
|
||||
_ = process.bindSignals(endpoint);
|
||||
if (callbacks.init) |initialise| {
|
||||
@@ -59,6 +83,16 @@ pub fn run(comptime maximum_message: usize, callbacks: Callbacks) void {
|
||||
var receive: [maximum_message]u8 = undefined;
|
||||
while (true) {
|
||||
const got = ipc.replyWait(endpoint, reply_buffer[0..reply_len], &receive, null);
|
||||
// Whatever capability came with this turn is the turn's, and the turn
|
||||
// closes it unless a callback claims it (`ipc.Arrival`). Structural
|
||||
// rather than a close per branch, because the branches are exactly what
|
||||
// gets forgotten: the ping's `continue` below, and every `on_message`
|
||||
// that has no use for a capability — which is every operation but a
|
||||
// subscribe. A `defer` in a loop body runs on `continue` and on the
|
||||
// `return` that ends the loop, so this covers all four exits.
|
||||
var arrived: ipc.Arrival = .{ .handle = got.cap };
|
||||
defer arrived.release();
|
||||
|
||||
if (got.isNotification()) {
|
||||
reply_len = 0; // nothing owed for a notification
|
||||
if (process.signalsFrom(got.badge)) |signals| {
|
||||
@@ -76,8 +110,8 @@ pub fn run(comptime maximum_message: usize, callbacks: Callbacks) void {
|
||||
}
|
||||
if (got.len == 0) {
|
||||
reply_len = 0; // the universal ping: a zero-length reply, from the harness
|
||||
continue;
|
||||
continue; // any capability it carried goes out through the turn's `defer`
|
||||
}
|
||||
reply_len = callbacks.on_message(receive[0..got.len], &reply_buffer, got.senderTaskId(), got.cap);
|
||||
reply_len = callbacks.on_message(receive[0..got.len], &reply_buffer, got.senderTaskId(), &arrived);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
//! buffer**, named by its physical address — the same physical-address handoff
|
||||
//! usb-storage already uses toward the controller, one layer up. So a 512-byte
|
||||
//! sector never has to cross the 256-byte IPC boundary; only the small request /
|
||||
//! reply headers do. (Safe while the IOMMU is unenforced; see docs/driver-model.md.)
|
||||
//! reply headers do. Under an enforcing IOMMU the buffer's physical addresses are
|
||||
//! only reachable by the device once the filesystem has `attach`ed the buffer's
|
||||
//! capability (the block server forwards it to the controller); see docs/driver-model.md.
|
||||
|
||||
pub const Operation = enum(u32) {
|
||||
/// geometry() -> { block_size, block_count }
|
||||
@@ -20,6 +22,11 @@ pub const Operation = enum(u32) {
|
||||
/// A filesystem calls this to make prior writes durable — e.g. before power-off,
|
||||
/// so a shutdown-time write isn't lost in the USB flash controller's cache.
|
||||
flush = 3,
|
||||
/// attach(): the caller's DMA-region capability rides the call's cap slot; the
|
||||
/// block server forwards it to the controller so the buffer's physical addresses
|
||||
/// (named in later read/write) are reachable by the device under an enforcing
|
||||
/// IOMMU. Call once per buffer before using it in a transfer.
|
||||
attach = 4,
|
||||
};
|
||||
|
||||
pub const Request = extern struct {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
//! The "protocol" library domain: the wire protocols — each service's public
|
||||
//! interface, exposed as its own module (docs/driver-model.md). Both sides of
|
||||
//! every conversation depend on the contract by name; neither reaches into the
|
||||
//! other's files. Pure flat wire types: no protocol module imports anything.
|
||||
//!
|
||||
//! One module here is not a protocol but the shape the others are written in:
|
||||
//!
|
||||
//! envelope : the packet prefix + comptime Define (docs/os-development/protocol-namespace.md)
|
||||
//!
|
||||
//! vfs-protocol : the VFS server <-> the file layer (unistd/stdio)
|
||||
//! input-protocol : the input fan-out service <-> sources + subscribers
|
||||
//! block-protocol : a filesystem <-> a block driver (usb-storage)
|
||||
//! usb-transfer-protocol : a USB class driver <-> the xHCI bus driver
|
||||
//! device-manager-protocol : the device manager <-> drivers + discovery
|
||||
//! display-protocol : the compositor's client-facing surface
|
||||
//! scanout-protocol : the compositor -> a native scanout driver (docs/display-v2.md)
|
||||
//! power-protocol : system power's domain-named surface (docs/power.md)
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
pub fn build(b: *std.Build) void {
|
||||
for ([_]struct { name: []const u8, root: []const u8 }{
|
||||
// Not a protocol, hence not `-protocol`: the envelope is what a
|
||||
// protocol is defined *through*.
|
||||
.{ .name = "envelope", .root = "envelope/envelope.zig" },
|
||||
.{ .name = "vfs-protocol", .root = "vfs/vfs-protocol.zig" },
|
||||
.{ .name = "input-protocol", .root = "input/input-protocol.zig" },
|
||||
.{ .name = "block-protocol", .root = "block/block-protocol.zig" },
|
||||
.{ .name = "usb-transfer-protocol", .root = "usb-transfer/usb-transfer-protocol.zig" },
|
||||
.{ .name = "device-manager-protocol", .root = "device-manager/device-manager-protocol.zig" },
|
||||
.{ .name = "display-protocol", .root = "display/display-protocol.zig" },
|
||||
.{ .name = "scanout-protocol", .root = "scanout/scanout-protocol.zig" },
|
||||
.{ .name = "power-protocol", .root = "power/power-protocol.zig" },
|
||||
}) |protocol| {
|
||||
_ = b.addModule(protocol.name, .{ .root_source_file = b.path(protocol.root) });
|
||||
}
|
||||
|
||||
// Standalone `zig build test` for this domain alone; the root build keeps
|
||||
// its aggregate test step.
|
||||
const test_step = b.step("test", "Run the protocol unit tests");
|
||||
for ([_][]const u8{
|
||||
"envelope/envelope.zig", // framing round trips, verb numbering, dispatch, the floors
|
||||
"vfs/vfs-protocol.zig", // NodeKind / DirectoryEntry sizes + op values
|
||||
"display/display-protocol.zig", // pack(): native pixel encoding per format
|
||||
}) |root| {
|
||||
const protocol_tests = b.addTest(.{
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path(root),
|
||||
.target = b.resolveTargetQuery(.{}),
|
||||
}),
|
||||
});
|
||||
test_step.dependOn(&b.addRunArtifact(protocol_tests).step);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.{
|
||||
.name = .protocol,
|
||||
.version = "0.0.0",
|
||||
.fingerprint = 0xc8c0bc4c4d551283, // Changing this has security and trust implications.
|
||||
.minimum_zig_version = "0.16.0",
|
||||
.dependencies = .{},
|
||||
.paths = .{""},
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user