Author SHA1 Message Date
Daniel Samson 3fc2d5b083 display: finalize v1 — docs + plan to DONE (D5)
The three integration cases (display, display-service, display-demo) plus the
pure host tests all pass; the default build is clean. Update docs/display.md's
"Verifying it" to name the real cases, and mark docs/display-plan.md D1-D5 done.

The display service v1 is complete: a framebuffer compositor that owns the
write-combining framebuffer, composites a z-ordered layer stack into a cacheable
back buffer, presents only the damaged region, and is driven over IPC by the
runtime.display client — proven end to end by the display-demo process. Deferred
by design (docs/display.md): runtime mode-setting and true vsync.
2026-07-14 01:58:18 +01:00
Daniel Samson 105203b447 display: layer client API + the display-demo client (D4)
Drive the compositor from a separate process, proving the pipeline end to end.

- runtime.display: a Layer handle (fill / blitTile / configure / damage /
  destroy), createLayer, and a color(r,g,b) helper that caches the mode and
  packs via protocol.pack. Coordinates are signed over the u32 wire fields
  (@bitCast both ways), so a layer may sit or move partly off-screen.
- system/services/display-demo: the input-source analog for the compositor — a
  full-screen wallpaper, a rectangle it slides back and forth (moved by
  configure each frame, so the damage-driven present repaints old + new), and a
  cursor. Presents in a loop paced by runtime.time. Wired into build + initrd.

Fix this surfaced: protocol.message_maximum was 4096, but the kernel caps every
IPC message at MESSAGE_MAXIMUM = 256, so replyWait rejected the oversized receive
buffer with -E2BIG and the serve loop had been spinning since D2 (invisibly, as
those gates matched init-time heartbeats). Set it to 256; blit_tile is now
explicitly a small-tile path (larger bitmaps are the deferred shm surface).

Gate: `python3 test/qemu_test.py display-demo` — the demo drives frames of
motion through the layer client API and logs `display-demo: ok`. Regression:
zig build test, display (D1), display-service (D2/D3), and default zig build.
2026-07-14 01:56:37 +01:00
Daniel Samson f9cf0007c5 display: layer stack + damage-driven compositor (D3)
Turn the service into a real compositor. A layer is a server-owned surface (its
own mmap'd cacheable buffer) with a screen position, z-order, and visibility.
Clients create layers, draw into them by command, mark damage, and present; the
compositor repaints only the damaged region — clear to the wallpaper, paint the
visible layers bottom-to-top (z-sorted), flush that rectangle back -> front (WC).

- compositor.zig: the pure, host-tested core — Rect (intersect/unite), Surface,
  fillRect, composite (opaque, clipped to a damage rect), blitTile (unaligned-
  safe read of a client tile). No syscall/runtime dependency.
- protocol.zig: pack(format, r, g, b) — native pixel encoding for rgbx/bgrx, the
  shared colour vocabulary of client and server. Host-tested.
- display.zig: the layer table + create/configure/destroy/fill_rect/blit_tile/
  damage/present ops wired onto the compositor, plus a damage-accumulating present.
- Startup self-check: two overlapping layers composited on the real framebuffer,
  read back to confirm the overlap shows the top layer and outside shows the
  bottom — logs `display: compositor self-check ok`.

Gate: `zig build test` green (compositor + pack), and the display-service case's
self-check passes on hardware. Both new pure modules added to the test loop.
2026-07-14 01:39:04 +01:00
Daniel Samson 69b018cc32 display: the compositor service — claim, double-buffer, present (D2)
Stand up /system/services/display: a ring-3 process that claims the framebuffer
D1 seeded, maps it write-combining as the front buffer, allocates a cacheable
back buffer, and presents composed frames. The GUI track's compositor, reached
by name over ServiceId.display (= 9).

- protocol.zig: the display wire protocol (info/create_layer/configure_layer/
  destroy_layer/fill_rect/blit_tile/damage/present). `info` and a whole-screen
  `present` are live; the layer ops fail-stub until D3.
- display.zig: enumerate -> claim -> mmio_map(WC) the LFB, mmap a cacheable back
  buffer, clear it and present it (proving the double-buffer path), then serve.
- runtime.display + barrel exports (display, display_protocol): a cached
  `.display` client with info()/present(), the runtime.block shape.
- init spawns "display" in boot_services; build.zig wires the protocol onto the
  runtime, builds the exe, packs it into the initial-ramdisk, installs it.

mmap fix the back buffer forced: systemMmap was capped at 256 pages (1 MiB) by a
fixed kernel-stack scratch array. Rewrote it to map page-by-page with rollback
(no array) and raised the cap to 8192 pages (32 MiB) — enough for a 4K back
buffer. A real limitation met.

Gate: `python3 test/qemu_test.py display-service` matches the service's own
serial heartbeats (display: online WxH / presented frame 0), printed only after
the full claim -> WC-map -> back-buffer -> present chain. Regression-checked
usermem, heap, init, and D1's display.
2026-07-14 01:26:25 +01:00
Daniel Samson cd812cc00e display: framebuffer handoff primitive + service design (D1)
Kick off the display service track (docs/display.md, docs/display-plan.md): a
user-space compositor that owns the framebuffer. GOP and the PCI display device
are two views of one controller; GOP dies at ExitBootServices, so the portable
base is the boot-handoff linear framebuffer.

D1 makes that framebuffer reachable from user space over the existing device
claim/mmio_map path rather than a bespoke syscall:

- device-abi: a `display` DeviceClass, a DisplayInfo{w,h,pitch,format} on the
  descriptor, and a flags field on resources with a write-combining bit.
- devices-broker: seedDisplay() publishes the loader's framebuffer as a
  root-level `display` node (one WC-flagged memory resource); kmain seeds it
  after discovery. displayDevice()/displayClaimed() track the claim.
- paging/mmio_map: mapUserDeviceInto gains a write_combining bool — a WC-flagged
  resource maps through PAT entry 4 instead of strong-uncacheable (an
  uncacheable framebuffer blit is glacial).
- console: falls silent while a display service holds the framebuffer, and is
  forced back on by the panic/exception paths.

Gate: the `display` kernel test asserts the seeded node's shape and that the
claim + mmio_map leaf is genuinely write-combining (PAT bit set, PCD/PWT clear).
Regression-checked discovery/ioport/claim-release/supervision/device-list/
device-manager with the +1 device in the table.
2026-07-14 00:54:56 +01:00
Daniel Samson f157a93c9c fat/usb-storage: flush the device cache on close so writes survive power-off
init writes /mnt/usb/DANOS.LOG at shutdown and then enters S5 — but the write sat in the USB flash controller's write cache and was lost when power was cut, because nothing issued SCSI SYNCHRONIZE CACHE. Invisible in QEMU (its backing file commits immediately); real on hardware. The boot-time flush survived only because the machine kept running afterward and the cache drained on its own.

- block protocol gains a flush op; usb-storage serves it with SYNCHRONIZE CACHE (10); runtime.block gains Device.flush()
- the FAT server tracks whether blocks were written and, on a file close, commits the device cache (durable-on-close — the right default for removable media, and it makes init's existing shutdown close() persist the log before S5, no init/VFS change needed)
- verified the SYNCHRONIZE CACHE actually reaches the driver on each dirty close; usb-storage/fat-mount/mutations/rename/mtime/log-flush all green
2026-07-13 23:29:15 +01:00
Daniel Samson 88644e57d6 acpi: stop parsing AML in the kernel; power management is userspace's
Kernel discovery interpreted the whole DSDT/SSDTs (~0.5 MB -> ~9700 nodes) solely to extract the _S5 sleep type for a kernel-side soft-off — ~1-2s of work on the single-core, pre-scheduler critical path, with nothing to overlap it. The ring-3 acpi service already parses the same blobs and owns S5 end-to-end (reads pm1a_cnt from the FADT, writes SLP_TYP itself; orderly-shutdown tests it). So drop the kernel parse entirely.

- all *static*-table parsing stays (MADT/HPET/FADT/MCFG): CPUs, timers, PCIe, and the power register map are still detected from ACPI, not legacy/compat addresses (timer still calibrates via HPET/PM-timer/CPUID, PIT only as last resort)
- kernel keeps reboot (FADT reset register + legacy fallbacks — no AML); soft-off is userspace-only now
- removed PowerInformation.s5/s3, the kernel AML namespace, aml_stats, amlDeviceCount, and the aml import; power.shutdown/enable/sleepS3 gone
- tests: poweroff case retired (S5 covered by orderly-shutdown); discovery drops its S5/AML checks; acpi-parse self-verifies against a device-count floor since there's no kernel count to match
2026-07-13 23:09:30 +01:00
Daniel Samson 10c11d1806 paging: map the framebuffer write-combining, and clear it after paging
The console's one-time full-screen clear was millions of individual uncached word-writes to the GPU BAR: fine in QEMU, but on real hardware firmware MTRRs force that region uncacheable, so the clear crawls. Program PAT entry 4 to write-combining (setupPat, on the BSP and every AP) and map the framebuffer window with the PAT bit, so those writes batch into bursts.

The clear ran on the *loader's* uncached mapping because console.init happened before enablePaging. Move it to just after paging, so it uses the kernel's write-combining mapping instead. The cost: on-screen output is absent before paging (an early panic still lands in the serial/RAM log). Verified: 11 QEMU cases (incl. SMP AP-PAT, USB/FAT/ACPI) plus a screendump showing the framebuffer cleared to black with the status text rendered correctly.
2026-07-13 22:40:39 +01:00
Daniel Samson c4595700ba paging: map the physmap with 2 MiB huge pages
The physmap (the kernel's permanent window onto all physical RAM) was built one 4 KiB page at a time: on a 64 GiB machine that is 16.7M mapPage calls and ~128 MiB of page tables (the bulk of the 'Kernel footprint' line). Map the 2 MiB-aligned interior with huge PD leaves instead — one entry per 2 MiB, no PT beneath — and only the unaligned head/tail with 4 KiB. 512x fewer entries and table frames; footprint at 8 GiB drops from ~16 MiB of tables to ~1 MiB total, and it scales.

translateIn/isExecutable now stop at a 2 MiB leaf, and descend() panics rather than walking through one (a 4 KiB map inside a huge page would corrupt RAM; the physmap and 4 KiB regions live in disjoint PML4 slots, so it can't happen — the guard just makes a bug loud). Verified: 20 QEMU cases (vmm/heap/wx/usermem/dma/ipc/smp/usb/fat/acpi/faults) green.
2026-07-13 22:22:40 +01:00
Daniel Samson 28b4dabbaa serial: make the log sink a build option, off by default
Serial is now a QEMU/dev aid, not a real-hardware necessity: a legacy-free board often has no live COM1, and the boot log is kept in RAM (klog) and flushed to disk. So the serial sink is compiled in only under -Dserial (default false).

- kernel.zig gates serialInit + the log sink on build_options.serial
- boot/efi.zig gates its EFI: progress breadcrumbs (con_out) via progress(); fatal-error messages stay always-on so a failed boot still explains itself
- run-x86-64 boots a serial-enabled image variant (factored addKernel/addBootImage helpers) so a dev boot always captures serial0, without baking serial into the flashable image
- test/qemu_test.py builds -Dserial=true (it asserts on serial markers)
- the loopback probe stays as a real-HW safety net for -Dserial images
2026-07-13 22:05:08 +01:00
Daniel Samson 4cb4f2a80f serial: skip a dead COM1 via a loopback probe (fixes ~57s real-HW boot)
On a legacy-free board COM1 is decoded but has no live UART: its LSR reads 0x00, so the transmit-holding-empty bit never sets and writeByte spun its full 100k-iteration guard on every logged byte (~15ms/byte x ~3.7k bytes to 'initialised' ~= 57s). QEMU always has a working UART, so this only bit real hardware; the boot log survived via log.ramSink.

- probe() loopback-tests the UART in init(); write() is a no-op when absent, so a dead port costs nothing per byte
- guard cut 100k -> 5k (backstop for a live-but-stalled UART only)
- serialPresent() + a boot line making the absent-UART case visible
2026-07-13 21:15:56 +01:00
34 changed files with 2206 additions and 315 deletions
+12 -3
View File
@@ -2,6 +2,7 @@ const std = @import("std");
const uefi = std.os.uefi; const uefi = std.os.uefi;
const elf = std.elf; const elf = std.elf;
const boot_handoff = @import("boot-handoff"); const boot_handoff = @import("boot-handoff");
const build_options = @import("build_options");
const BootInformation = boot_handoff.BootInformation; const BootInformation = boot_handoff.BootInformation;
const GraphicsOutput = uefi.protocol.GraphicsOutput; const GraphicsOutput = uefi.protocol.GraphicsOutput;
const EdidActive = uefi.protocol.edid.Active; const EdidActive = uefi.protocol.edid.Active;
@@ -84,7 +85,7 @@ fn boot() !noreturn {
// the map and exiting would invalidate the map key. // the map and exiting would invalidate the map key.
const cr3 = try buildBootstrapTables(bs, &boot_information); const cr3 = try buildBootstrapTables(bs, &boot_information);
log("EFI: kernel loaded, exiting boot services\r\n"); progress("EFI: kernel loaded, exiting boot services\r\n");
boot_information.memory_map = try exitBootServices(bs); boot_information.memory_map = try exitBootServices(bs);
// Switch onto our tables and jump to the kernel in one uninterruptible step. // Switch onto our tables and jump to the kernel in one uninterruptible step.
@@ -395,7 +396,7 @@ fn loadInit(bs: *uefi.tables.BootServices, boot_information: *BootInformation) !
const image = try loadFile(bs, init_file_name); const image = try loadFile(bs, init_file_name);
boot_information.init_base = @intFromPtr(image.ptr); boot_information.init_base = @intFromPtr(image.ptr);
boot_information.init_len = image.len; boot_information.init_len = image.len;
log("EFI: /system/services/init loaded\r\n"); progress("EFI: /system/services/init loaded\r\n");
} }
/// Ferry the initial_ramdisk (the VFS server + drivers) to the kernel, same as init. /// Ferry the initial_ramdisk (the VFS server + drivers) to the kernel, same as init.
@@ -403,7 +404,7 @@ fn loadInitialRamdisk(bs: *uefi.tables.BootServices, boot_information: *BootInfo
const image = try loadFile(bs, initial_ramdisk_file_name); const image = try loadFile(bs, initial_ramdisk_file_name);
boot_information.initial_ramdisk_base = @intFromPtr(image.ptr); boot_information.initial_ramdisk_base = @intFromPtr(image.ptr);
boot_information.initial_ramdisk_len = image.len; boot_information.initial_ramdisk_len = image.len;
log("EFI: initial_ramdisk loaded\r\n"); progress("EFI: initial_ramdisk loaded\r\n");
} }
/// Validate the ELF, copy every PT_LOAD segment to its physical address, and /// Validate the ELF, copy every PT_LOAD segment to its physical address, and
@@ -561,6 +562,14 @@ fn log(comptime message: []const u8) void {
_ = out.outputString(std.unicode.utf8ToUtf16LeStringLiteral(message)) catch {}; _ = out.outputString(std.unicode.utf8ToUtf16LeStringLiteral(message)) catch {};
} }
/// A boot-progress breadcrumb: like `log`, but compiled out unless `-Dserial`
/// (off by default), so a real-hardware boot stays silent. Fatal errors use
/// `log` directly and always show, so a failed boot still explains itself.
fn progress(comptime message: []const u8) void {
if (!build_options.serial) return;
log(message);
}
/// Write a runtime ASCII byte string (e.g. an @errorName) by widening to UTF-16. /// Write a runtime ASCII byte string (e.g. an @errorName) by widening to UTF-16.
fn logBytes(bytes: []const u8) void { fn logBytes(bytes: []const u8) void {
const out = uefi.system_table.con_out orelse return; const out = uefi.system_table.con_out orelse return;
+154 -53
View File
@@ -96,6 +96,105 @@ fn addUserBinary(
return exe; return exe;
} }
/// The modules the kernel imports, gathered once so both kernel variants (the
/// installed one and the serial-enabled one `run-x86-64` boots) are built from
/// the same set. `build_options` is *not* here — it carries `serial`/`test_case`,
/// which differ per variant, so `addKernel` builds it fresh each time.
const KernelModules = struct {
boot_handoff: *std.Build.Module,
abi: *std.Build.Module,
device_abi: *std.Build.Module,
architecture: *std.Build.Module,
platform: *std.Build.Module,
parameters: *std.Build.Module,
initial_ramdisk: *std.Build.Module,
};
/// Build the freestanding x86_64 kernel ELF. Factored so we can build it twice
/// from one recipe: the installed/flashable image (serial off by default) and the
/// serial-enabled variant `run-x86-64` boots — they differ only in the `serial`
/// build option baked into `build_options`.
fn addKernel(
b: *std.Build,
kernel_target: std.Build.ResolvedTarget,
optimize: std.builtin.OptimizeMode,
modules: KernelModules,
test_case: ?[]const u8,
serial: bool,
) *std.Build.Step.Compile {
// Compile-time configuration the kernel reads as `@import("build_options")`:
// the QEMU harness's -Dtest-case, and whether the serial log sink is compiled
// in (see the -Dserial option). Built per variant since `serial` differs.
const build_options = b.addOptions();
build_options.addOption(?[]const u8, "test_case", test_case);
build_options.addOption(bool, "serial", serial);
const build_options_module = build_options.createModule();
const exe = b.addExecutable(.{
.name = "kernel",
.root_module = b.createModule(.{
.root_source_file = b.path("system/kernel/kernel.zig"),
.target = kernel_target,
.optimize = optimize,
.code_model = .kernel, // kernel runs in the top 2 GiB (higher half)
.red_zone = false, // interrupts would corrupt the SystemV red zone
.single_threaded = false, // SMP: the big kernel lock's atomics must be real across cores
.sanitize_c = .off, // the UBSan runtime needs f128/SSE support we don't provide
.stack_check = false, // stack-probe calls have no runtime to land in
.stack_protector = false,
.imports = &.{
.{ .name = "boot-handoff", .module = modules.boot_handoff },
.{ .name = "abi", .module = modules.abi },
.{ .name = "device-abi", .module = modules.device_abi },
.{ .name = "architecture", .module = modules.architecture },
.{ .name = "platform", .module = modules.platform },
.{ .name = "parameters", .module = modules.parameters },
.{ .name = "build_options", .module = build_options_module },
.{ .name = "initial-ramdisk", .module = modules.initial_ramdisk },
},
}),
});
exe.setLinkerScript(b.path("system/kernel/architecture/x86_64/linker.ld"));
exe.entry = .{ .symbol_name = "_start" };
// The self-hosted linker ignores parts of the linker script (PHDRS,
// /DISCARD/, AT(), section order); the higher-half layout depends on the
// script being authoritative, so pin the kernel to LLVM + LLD.
exe.use_llvm = true;
exe.use_lld = true;
// Higher-half virtual base (matches KERNEL_VIRT_BASE in linker.ld); the
// linker's AT() clauses give each segment a low physical load address
// (.text at 1 MiB), which the loader allocates and copies into.
exe.image_base = 0xFFFFFFFF80100000;
return exe;
}
/// Assemble the bootable FAT32 image (the in-repo Python builder) holding what
/// the firmware and loader need off the ESP: the EFI stub, `kernel`, `init`, and
/// the initial-ramdisk. Factored so the serial-enabled `run-x86-64` variant can
/// bundle its own kernel while sharing the (serial-independent) loader, init, and
/// ramdisk. Returns the image's LazyPath.
fn addBootImage(
b: *std.Build,
kernel_bin: std.Build.LazyPath,
efi_bin: std.Build.LazyPath,
init_bin: std.Build.LazyPath,
initial_ramdisk_img: std.Build.LazyPath,
) 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/services/init");
mk_fat.addFileArg(init_bin);
mk_fat.addArg("boot/initial-ramdisk.img");
mk_fat.addFileArg(initial_ramdisk_img);
return fat_image;
}
pub fn build(b: *std.Build) void { pub fn build(b: *std.Build) void {
ensureZigVersion(); ensureZigVersion();
@@ -249,6 +348,13 @@ pub fn build(b: *std.Build) void {
// The block protocol, so runtime.block (the block-device client) can speak it. // The block protocol, so runtime.block (the block-device client) can speak it.
runtime_module.addImport("block-protocol", block_protocol_module); runtime_module.addImport("block-protocol", block_protocol_module);
// The display protocol, so runtime.display (the compositor client) and the display
// service both speak it through the runtime, like the other protocol modules.
const display_protocol_module = b.addModule("display-protocol", .{
.root_source_file = b.path("system/services/display/protocol.zig"),
});
runtime_module.addImport("display-protocol", display_protocol_module);
// The power protocol: system power's domain-named surface (docs/power.md). // The power protocol: system power's domain-named surface (docs/power.md).
const power_protocol_module = b.addModule("power-protocol", .{ const power_protocol_module = b.addModule("power-protocol", .{
.root_source_file = b.path("system/services/power/protocol.zig"), .root_source_file = b.path("system/services/power/protocol.zig"),
@@ -285,9 +391,12 @@ pub fn build(b: *std.Build) void {
// Compile-time configuration the kernel reads as `@import("build_options")`. The // Compile-time configuration the kernel reads as `@import("build_options")`. The
// QEMU test harness sets -Dtest-case=<name> to run one self-test at boot. // QEMU test harness sets -Dtest-case=<name> to run one self-test at boot.
const test_case = b.option([]const u8, "test-case", "Kernel self-test case to run at boot (see system/kernel/tests.zig)"); const test_case = b.option([]const u8, "test-case", "Kernel self-test case to run at boot (see system/kernel/tests.zig)");
const build_options = b.addOptions(); // The serial-console log sink. Off by default: a real machine often has no
build_options.addOption(?[]const u8, "test_case", test_case); // working legacy COM1, and the boot log is kept in RAM (klog) and flushed to
const build_options_module = build_options.createModule(); // disk instead — serial is now only a QEMU convenience. `run-x86-64` and the
// QEMU test harness (test/qemu_test.py, which asserts on serial markers) turn
// it on; a flashable `zig build` image leaves it out. See serial.zig.
const serial = b.option(bool, "serial", "Compile the serial-console log sink into the kernel (default: off; run-x86-64 and the test harness enable it)") orelse false;
// --- Kernel: freestanding x86_64 ELF, jumped to by the bootloader --- // --- Kernel: freestanding x86_64 ELF, jumped to by the bootloader ---
// SSE2 is part of the x86_64 baseline and UEFI leaves it enabled at handoff, // SSE2 is part of the x86_64 baseline and UEFI leaves it enabled at handoff,
@@ -299,41 +408,17 @@ pub fn build(b: *std.Build) void {
.abi = .none, .abi = .none,
}); });
const exe = b.addExecutable(.{ const kernel_modules = KernelModules{
.name = "kernel", .boot_handoff = boot_handoff_module,
.root_module = b.createModule(.{ .abi = abi_module,
.root_source_file = b.path("system/kernel/kernel.zig"), .device_abi = device_abi_module,
.target = kernel_target, .architecture = architecture_module,
.optimize = optimize, .platform = platform_module,
.code_model = .kernel, // kernel runs in the top 2 GiB (higher half) .parameters = parameters_module,
.red_zone = false, // interrupts would corrupt the SystemV red zone .initial_ramdisk = initial_ramdisk_module,
.single_threaded = false, // SMP: the big kernel lock's atomics must be real across cores };
.sanitize_c = .off, // the UBSan runtime needs f128/SSE support we don't provide // The installed/flashable kernel: serial follows -Dserial (off by default).
.stack_check = false, // stack-probe calls have no runtime to land in const exe = addKernel(b, kernel_target, optimize, kernel_modules, test_case, serial);
.stack_protector = false,
.imports = &.{
.{ .name = "boot-handoff", .module = boot_handoff_module },
.{ .name = "abi", .module = abi_module },
.{ .name = "device-abi", .module = device_abi_module },
.{ .name = "architecture", .module = architecture_module },
.{ .name = "platform", .module = platform_module },
.{ .name = "parameters", .module = parameters_module },
.{ .name = "build_options", .module = build_options_module },
.{ .name = "initial-ramdisk", .module = initial_ramdisk_module },
},
}),
});
exe.setLinkerScript(b.path("system/kernel/architecture/x86_64/linker.ld"));
exe.entry = .{ .symbol_name = "_start" };
// The self-hosted linker ignores parts of the linker script (PHDRS,
// /DISCARD/, AT(), section order); the higher-half layout depends on the
// script being authoritative, so pin the kernel to LLVM + LLD.
exe.use_llvm = true;
exe.use_lld = true;
// Higher-half virtual base (matches KERNEL_VIRT_BASE in linker.ld); the
// linker's AT() clauses give each segment a low physical load address
// (.text at 1 MiB), which the loader allocates and copies into.
exe.image_base = 0xFFFFFFFF80100000;
// Everything installs into a FHS-shaped zig-out: it IS the danos filesystem *and* // 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 // the boot volume. Each binary lands at its addressed, leaf-collapsed path — the
@@ -381,6 +466,8 @@ pub fn build(b: *std.Build) void {
// The FAT filesystem server: mounts the block device and serves it into the VFS // The FAT filesystem server: mounts the block device and serves it into the VFS
// at /mnt/usb. Its engine (engine.zig / on-disk.zig) is imported relatively. // at /mnt/usb. Its engine (engine.zig / on-disk.zig) is imported relatively.
const fat_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "fat", "system/services/fat/fat.zig"); const fat_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "fat", "system/services/fat/fat.zig");
const display_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "display", "system/services/display/display.zig");
const display_demo_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "display-demo", "system/services/display-demo/display-demo.zig");
const fat_test_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "fat-test", "system/services/fat/fat-test.zig"); const fat_test_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "fat-test", "system/services/fat/fat-test.zig");
const pci_bus_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "pci-bus", "system/drivers/pci-bus/pci-bus.zig"); const pci_bus_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "pci-bus", "system/drivers/pci-bus/pci-bus.zig");
// The PCI bus driver decodes each function's class triple to human names in its // The PCI bus driver decodes each function's class triple to human names in its
@@ -448,6 +535,10 @@ pub fn build(b: *std.Build) void {
mk_run.addFileArg(fat_exe.getEmittedBin()); mk_run.addFileArg(fat_exe.getEmittedBin());
mk_run.addArg("fat-test"); mk_run.addArg("fat-test");
mk_run.addFileArg(fat_test_exe.getEmittedBin()); mk_run.addFileArg(fat_test_exe.getEmittedBin());
mk_run.addArg("display");
mk_run.addFileArg(display_exe.getEmittedBin());
mk_run.addArg("display-demo");
mk_run.addFileArg(display_demo_exe.getEmittedBin());
mk_run.addArg("pci-bus"); mk_run.addArg("pci-bus");
mk_run.addFileArg(pci_bus_exe.getEmittedBin()); mk_run.addFileArg(pci_bus_exe.getEmittedBin());
mk_run.addArg("crash-test"); mk_run.addArg("crash-test");
@@ -485,6 +576,7 @@ pub fn build(b: *std.Build) void {
.{ usb_hid_mouse_exe, "system/drivers" }, .{ usb_hid_mouse_exe, "system/drivers" },
.{ usb_storage_exe, "system/drivers" }, .{ usb_storage_exe, "system/drivers" },
.{ fat_exe, "system/services" }, .{ fat_exe, "system/services" },
.{ display_exe, "system/services" },
.{ log_flush_exe, "system/services" }, .{ log_flush_exe, "system/services" },
}) |entry| { }) |entry| {
const step = b.addInstallArtifact(entry[0], .{ .dest_dir = .{ .override = .{ .custom = entry[1] } } }); const step = b.addInstallArtifact(entry[0], .{ .dest_dir = .{ .override = .{ .custom = entry[1] } } });
@@ -498,6 +590,13 @@ pub fn build(b: *std.Build) void {
// Boot methods live in boot/, one per way of getting the kernel running. // Boot methods live in boot/, one per way of getting the kernel running.
// Each is its own binary/entry (a loader is built for its own target); today // Each is its own binary/entry (a loader is built for its own target); today
// that's UEFI for x86-64, with room for e.g. a device-tree path for the Pis. // that's UEFI for x86-64, with room for e.g. a device-tree path for the Pis.
// The loader reads -Dserial too, so its boot-progress breadcrumbs (con_out,
// which firmware may mirror to a serial console) are silenced by default — a
// real-hardware boot stays quiet. Fatal-error messages ignore this and always
// show, so a failed boot still explains itself on screen. See boot/efi.zig.
const loader_options = b.addOptions();
loader_options.addOption(bool, "serial", serial);
const loader_options_module = loader_options.createModule();
const efiexe = b.addExecutable(.{ const efiexe = b.addExecutable(.{
.name = "BOOTX64", .name = "BOOTX64",
.root_module = b.createModule(.{ .root_module = b.createModule(.{
@@ -510,6 +609,7 @@ pub fn build(b: *std.Build) void {
.imports = &.{ .imports = &.{
// The bootloader speaks only the handoff contract — never the user ABI. // The bootloader speaks only the handoff contract — never the user ABI.
.{ .name = "boot-handoff", .module = boot_handoff_module }, .{ .name = "boot-handoff", .module = boot_handoff_module },
.{ .name = "build_options", .module = loader_options_module },
}, },
}), }),
}); });
@@ -525,21 +625,17 @@ pub fn build(b: *std.Build) void {
// stub, the kernel, init, and the initial-ramdisk. QEMU presents this image as // stub, the kernel, init, and the initial-ramdisk. QEMU presents this image as
// a USB mass-storage device the guest boots from (see run-x86-64 and the test // 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 /mnt/usb. // harness), and the danos fat driver mounts the same image at /mnt/usb.
const mk_fat = b.addSystemCommand(&.{"python3"}); const fat_image = addBootImage(b, exe.getEmittedBin(), efiexe.getEmittedBin(), init_exe.getEmittedBin(), initial_ramdisk_img);
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(efiexe.getEmittedBin());
mk_fat.addArg("system/kernel");
mk_fat.addFileArg(exe.getEmittedBin());
mk_fat.addArg("system/services/init");
mk_fat.addFileArg(init_exe.getEmittedBin());
mk_fat.addArg("boot/initial-ramdisk.img");
mk_fat.addFileArg(initial_ramdisk_img);
const fat_image_install = b.addInstallFile(fat_image, "danos-usb.img"); const fat_image_install = b.addInstallFile(fat_image, "danos-usb.img");
b.getInstallStep().dependOn(&fat_image_install.step); 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 exe_serial = addKernel(b, kernel_target, optimize, kernel_modules, test_case, true);
const fat_image_serial = addBootImage(b, exe_serial.getEmittedBin(), efiexe.getEmittedBin(), init_exe.getEmittedBin(), initial_ramdisk_img);
// `zig build check-fat-image` — validate the produced image is a real FAT32 // `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). // with the EFI stub present (the builder's own --verify, no external tools).
const check_fat = b.addSystemCommand(&.{"python3"}); const check_fat = b.addSystemCommand(&.{"python3"});
@@ -611,8 +707,9 @@ pub fn build(b: *std.Build) void {
run_efi.addPrefixedFileArg("if=pflash,format=raw,file=", vars_out); 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 // 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 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.addArg("-drive");
run_efi.addPrefixedFileArg("if=none,id=bootusb,format=raw,file=", fat_image); run_efi.addPrefixedFileArg("if=none,id=bootusb,format=raw,file=", fat_image_serial);
run_efi.addArgs(&.{ run_efi.addArgs(&.{
"-device", "-device",
"usb-storage,bus=xhci.0,drive=bootusb,removable=on,bootindex=0", "usb-storage,bus=xhci.0,drive=bootusb,removable=on,bootindex=0",
@@ -634,8 +731,10 @@ pub fn build(b: *std.Build) void {
const make_log_dir = b.addSystemCommand(&.{ "mkdir", "-p", log_dir }); const make_log_dir = b.addSystemCommand(&.{ "mkdir", "-p", log_dir });
const serial_log = b.fmt("{s}/run-x86-64-serial0-{s}.log", .{ log_dir, timestamp(b) }); 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}) }); run_efi.addArgs(&.{ "-serial", b.fmt("file:{s}", .{serial_log}) });
// The whole FHS zig-out must be installed (and the scratch dir created) before we mount it. // We boot the self-contained `fat_image_serial` (added as a file arg above, so
run_efi.step.dependOn(b.getInstallStep()); // 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); 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"); 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");
@@ -674,6 +773,8 @@ pub fn build(b: *std.Build) void {
"system/services/vfs/protocol.zig", // NodeKind / DirectoryEntry sizes + op values "system/services/vfs/protocol.zig", // NodeKind / DirectoryEntry sizes + op values
"system/services/fat/on-disk.zig", // FAT on-disk struct sizes + type detection "system/services/fat/on-disk.zig", // FAT on-disk struct sizes + type detection
"system/services/fat/engine.zig", // FAT read/write over a RAM-backed image "system/services/fat/engine.zig", // FAT read/write over a RAM-backed image
"system/services/display/compositor.zig", // Rect math + fill/composite/blit-tile
"system/services/display/protocol.zig", // pack(): native pixel encoding per format
}) |root| { }) |root| {
const mod_tests = b.addTest(.{ const mod_tests = b.addTest(.{
.root_module = b.createModule(.{ .root_module = b.createModule(.{
+6 -1
View File
@@ -69,7 +69,12 @@ rather than restate it. Roughly in the order things happen at runtime:
mouse, joystick): why a synchronous rendezvous can't fan out to many listeners, the 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 asynchronous `ipc_send` primitive built to fix it, and the per-device subscribe/publish
service layered on top. service layered on top.
19. **[halting.md](halting.md) — halting.** Why a kernel can't just "exit", and 19. **[display.md](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](display-plan.md).
20. **[halting.md](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. how `while (true) hlt` parks the CPU safely once there's nothing left to do.
Start with the north star: Start with the north star:
+181
View File
@@ -0,0 +1,181 @@
# Display service — build plan (v1: the dumb-framebuffer compositor)
The ordered, checkpointable build-out for [display.md](display.md). Each milestone is
small, lands on its own, and ends in a **verifiable gate** — shaped for a `/loop` run.
Read [display.md](display.md) first for the *why*; this is the *what* and the *order*.
## Locked decisions (do not relitigate)
- **Handoff = device node + write-combining `mmio_map`.** The kernel seeds a synthetic
`display0` node from `BootInformation.framebuffer`; the service claims + WC-maps it.
(Not a bespoke `framebuffer_map` syscall — the device route inherits ownership,
release-on-death, and re-claim-on-restart.)
- **v1 = the full compositor pipeline on the dumb framebuffer.** One `display` service
owns the LFB + a cacheable back buffer + a layer stack; double-buffer + damage-driven
present; clients draw via server-side commands. **No** runtime mode-setting, **no**
shared-memory surfaces — both deferred (see display.md, "What v1 does not do").
## Conventions
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.
## How to verify along the way
- `zig build test` — host unit tests (compositor math: layer clipping, damage merge,
pitch/format blits are all host-testable with a fake framebuffer).
- `python3 test/qemu_test.py <case>` — boots the real kernel in QEMU; assert on the
serial log ([tests.zig](../system/kernel/tests.zig) is the registry).
- The `run-efi` target renders to QEMU's display (`-device VGA,edid=on,xres=1280,yres=720`)
— a screenshot confirms pixels for the milestones whose gate is visual.
---
## D1 — The handoff primitive (kernel) ✅
Make the boot framebuffer reachable and mappable **write-combining** from user space.
- [x] [device-abi.zig](../system/devices/device-abi.zig): added `DeviceClass.display`; a
`DisplayInfo{ width, height, pitch, format }` carried on the descriptor; a
`flags` field on `ResourceDescriptor` + `resource_flag_write_combining`.
- [x] [devices-broker.zig](../system/kernel/devices-broker.zig): `seedDisplay(base, w, h,
pitch, format)` publishes a root-level `display` node with one WC-flagged `memory`
resource `[base, height*pitch]` + the `DisplayInfo`; `displayDevice()` /
`displayClaimed()`. Seeded from `kmain` after `devices_broker.init`.
- [x] [process.zig](../system/kernel/process.zig) `systemMmioMap` + paging
(`mapUserDeviceInto` gains a `write_combining` bool): a resource's WC flag maps it
through the WC PAT slot (`setupPat`) instead of strong-uncacheable.
- [x] [console.zig](../system/kernel/console.zig): `setSuppressed` quiesces `write` while
the display device is claimed (driven from `systemDeviceClaim` / release); the
terminal panic + exception paths clear it first so a dying machine still draws.
**Gate (met, automated):** the `display` kernel test (`python3 test/qemu_test.py display`,
`displayTest` in [tests.zig](../system/kernel/tests.zig)) asserts the seeded node's shape
and geometry, then walks the real claim + `mmio_map` path into a throwaway address space
and verifies the leaf is **write-combining** (PAT entry 4: PAT bit set, PCD/PWT clear) —
with an uncacheable-still-uncacheable regression guard. Chosen over the original
screenshot-of-a-fill gate because it proves the *actual* WC property headlessly; the
visible fill folds into D2's gate (the service clears the screen through the back buffer).
Regression-checked: `discovery`, `ioport`, `claim-release`, `supervision`, `device-list`,
`device-manager` all still pass with the +1 device in the table.
## D2 — Service skeleton, protocol, runtime module ✅
Stand up the named service and the double-buffer, no layers yet.
- [x] `system/services/display/protocol.zig`: `Operation{ info, create_layer,
configure_layer, destroy_layer, fill_rect, blit_tile, damage, present }`; `extern`
`Request`/`Reply`; size + `maximum_payload` consts. (Model: block/protocol.zig.)
- [x] [abi.zig](../system/abi.zig): `ServiceId.display = 9`.
- [x] `system/services/display/display.zig`: `main` → enumerate + claim + WC-map the LFB
(front) → `mmap` a cacheable back buffer of `height*pitch` → `runtime.service.run`.
`info` and a whole-screen `present` (back → front) are live; layer ops fail-stub
until D3. Init clears the back buffer and presents it — the double-buffer path.
- [x] [library/runtime/display.zig](../library/runtime/runtime.zig) (+ barrel export of
`display` and `display_protocol`): `info()` and `present()`, cached `.display`
lookup with retry (model: block.zig).
- [x] [init.zig](../system/services/init/init.zig): `"display"` added to `boot_services`.
- [x] [build.zig](../build.zig): `display-protocol` module on the runtime; `display` exe
via `addUserBinary`; packed into the initial-ramdisk; installed to
`/system/services/display`.
- [x] **Kernel fix the back buffer surfaced:** `mmap` was capped at 256 pages (1 MiB) by
a fixed kernel-stack `frames` array. Rewrote `systemMmap` to map page-by-page with
rollback (no scratch array) and raised the cap to 8192 pages (32 MiB) — enough for a
4K back buffer. A real limitation met, exactly the kind this project chases.
**Gate (met, automated):** `python3 test/qemu_test.py display-service` spawns the
compositor and matches its own serial heartbeats — `display: online {w}x{h} pitch …`
followed by `display: presented frame 0` — which it prints only after the whole
claim → WC-map → back-buffer → clear → present chain succeeds (matched on serial like the
fault cases, since a lone blocking service can't reschedule the in-kernel test context to
poll). Regression-checked: `usermem`, `heap` (the `mmap` rewrite), `init` (the boot-list
addition), and D1's `display` all still pass.
## D3 — Layer stack + compositor + damage present ✅
The heart: composite an ordered layer stack, present only what changed.
- [x] A layer table (16 slots): each `Layer` = position, z, visible, a server-owned
`mmap`'d surface (freed on `destroy_layer`). `damage` accumulates the dirty screen
region since the last present.
- [x] `create_layer` / `configure_layer` (damages old + new footprints) / `destroy_layer`,
`fill_rect`, `blit_tile` (reads the inline tile from the IPC payload, unaligned-safe),
`damage`, `present`.
- [x] Pure, host-tested [compositor.zig](../system/services/display/compositor.zig): `Rect`
(intersect/unite), `Surface`, `fillRect`, `composite` (opaque, clipped to a damage
rect), `blitTile`. `present` clears the damaged region to the wallpaper, paints the
visible layers bottom-to-top (z-sorted), and flushes just that rect back → front (WC).
Colour packing (rgbx/bgrx) is `protocol.pack`, also host-tested.
- [x] Host tests (`zig build test`, green): rect intersect/unite, `fillRect` clipping +
`stride > width` padding, `composite` overlap-shows-top + damage clipping, `blitTile`
unaligned read + clipping, and `pack` for both pixel formats.
**Gate (met):** `zig build test` green for the compositor + pack unit tests, **and** the
`display-service` case's startup self-check composites two overlapping layers on the real
framebuffer and reads back the composited pixels — overlap = top layer, outside = bottom
layer — logging `display: compositor self-check ok` (matched by the harness).
## D4 — Client API + the demo client ✅
Prove the pipeline end-to-end from a separate process.
- [x] Finished [runtime/display.zig](../library/runtime/runtime.zig): a `Layer` handle with
`fill` / `blitTile` (inline tile) / `configure` (move/restack/show) / `damage` /
`destroy`, `createLayer`, and a `color(r,g,b)` helper (caches the mode, packs via
`protocol.pack`). Coordinates are signed over the wire (`@bitCast` both ways).
- [x] `system/services/display-demo/`: a hardware-free client (the `input-source` analog)
— a full-screen wallpaper layer, a rectangle that slides back and forth (moved by
`configure` each frame, so the compositor repaints old + new), and a cursor layer;
presents in a loop paced by `runtime.time`. Wired into build + initial-ramdisk.
- [x] **Bug this surfaced:** `protocol.message_maximum` was 4096, but the kernel caps
every IPC message at `MESSAGE_MAXIMUM` = 256 — so `replyWait` rejected the oversized
receive buffer with `-E2BIG` and the serve loop had been *spinning* since D2 (unseen,
as D2/D3 matched init-time heartbeats). Set it to 256; `blit_tile` is now explicitly
a small-tile path (≤ 54 px inline), larger bitmaps being the deferred shm surface.
**Gate (met):** `python3 test/qemu_test.py display-demo` spawns the service + `display-demo`;
the demo drives a run of frames of motion through the layer client API and logs
`display-demo: ok` (the visible motion is a screenshot via `zig build run-x86-64`).
Regression-checked: `zig build test`, `display` (D1), and `display-service` (D2/D3) all
still pass, and the default `zig build` is clean.
## D5 — Test cases + docs ✅
- [x] The three integration cases exist and pass: `display` (D1 handoff, kernel),
`display-service` (D2/D3 compositor + self-check), and `display-demo` (D4 full
pipeline: spawn `display` + `display-demo`, match `display-demo: ok`) —
[tests.zig](../system/kernel/tests.zig) + [qemu_test.py](../test/qemu_test.py). Plus
the pure host tests (`zig build test`).
- [x] [display.md](display.md) updated to the built state (the "Verifying it" section names
the real cases); [README index](README.md) entry present (#19); the `display-track`
memory marked DONE with the commits.
**Gate (met):** `python3 test/qemu_test.py display display-service display-demo` all pass,
`zig build test` is green, and the default `zig build` is clean.
---
## v1 status: complete
D1–D5 done. The display service is a working framebuffer compositor: it owns the
framebuffer (write-combining), composites a z-ordered layer stack into a cacheable back
buffer, presents only the damaged region, and is driven over IPC by the `runtime.display`
client — proven end-to-end by a separate demo process. Two limitations are deliberate and
documented (docs/display.md): no runtime mode-setting (native backend) and no true vsync
(no vblank on a dumb framebuffer). Next steps are the Deferred items below.
---
## Deferred (explicitly not in this plan)
- **Shared-memory surfaces** — generalize M13 capability passing to memory objects
(`shm_create`/`shm_map`), so bitmap clients hand the compositor a rendered surface
instead of drawing commands. The compositor's layer model already anticipates it.
- **Native backend (Bochs DISPI, then virtio-gpu)** — behind the same internal backend
interface as the dumb framebuffer: EDID mode list + runtime resolution/bpp change +
(eventually) a vblank/flip path for true vsync.
- **Driver/compositor process split** — only when a second backend or a second head makes
the abstraction pay for itself.
+264
View File
@@ -0,0 +1,264 @@
# The display service: a framebuffer compositor
The [framebuffer](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*
the framebuffer, composes a stack of **layers** into an off-screen back buffer, and
**presents** finished frames to the screen — the display half of the GUI track
([vision.md](vision.md)), the sibling of the [input service](input.md).
This note is the architecture and the reasoning behind it. The concrete build order
lives in [display-plan.md](display-plan.md).
## First, a distinction that shapes everything: GOP vs. the PCI device
It is tempting to think "the GOP framebuffer" and "the VGA-compatible display
controller in the PCIe tree" are two different things. They are not — they are **two
interfaces to the same silicon, at different times and different levels**, and knowing
which one you're holding decides what you can do.
- **GOP is firmware's *temporary* driver** for the display controller. It gives you a
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](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}`, 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
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
([pci-class.zig](../system/devices/pci-class.zig) has the full `display` namespace, and
`pci-bus` already reports it to the [device manager](device-manager.md) with its class
triple) — but nothing binds it yet.
What that difference costs you, concretely:
| You want to… | Dumb GOP framebuffer (boot handoff) | Native device driver (PCI 0x03) |
|-------------------------------------------|-------------------------------------|------------------------------------------|
| **Report** the current mode | ✅ from the handoff | ✅ |
| **Change resolution / bpp at runtime** | ❌ GOP is gone | ✅ program DISPI regs / virtio-gpu queue |
| **Re-read EDID, enumerate monitor modes** | ❌ | ✅ the device exposes an EDID block |
| **Refresh rate** | ❌ (virtual anyway) | only a real KMS driver — far future |
| **vblank / tear-free present** | ❌ no vblank signal | ✅ vblank IRQ + page-flip (real GPUs) |
| **Works on the Pi (no PCI VGA)** | ✅ VideoCore hands a simple FB | ✗ per-device |
The lesson: the **portable base for the whole GUI stack is the GOP / boot-handoff linear
framebuffer**. Runtime mode-setting is a *per-device upgrade* layered on top — and on
the Raspberry Pis there is no PCI VGA at all, so the neutral framebuffer is the only
thing all three target machines share. That is why the display service is built on the
dumb framebuffer first, with the native backend as an optional module behind the same
interface.
## Two constraints this service exists to meet
Like the input service — which existed partly to motivate the asynchronous
[`ipc_send`](ipc.md) primitive — the display service runs straight into two limits the
rest of the system hasn't had to face:
1. **The framebuffer is kernel-only today.** It arrives through the boot handoff, is
mapped into the kernel's physmap, and is touched only by
[`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](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 has no cross-process shared memory.** The memory syscalls are `mmap`
(private, zeroed), `mmio_map` (a *claimed device's* MMIO), and `dma_alloc` (new
pinned physical). The block driver's "pass a buffer by physical address" trick
([block/protocol.zig](../system/services/block/protocol.zig)) works *only because its
consumer is DMA hardware*. A compositor that CPU-reads and blends client layers can't
use it — it would have to *map* another process's memory, which nothing allows. This
is deferred (see "What v1 does not do"), because v1 sidesteps it entirely.
## Architecture
```
kernel ── owns the boot framebuffer; bootstrap console only
│ seeds a "display0" device node from BootInformation.framebuffer
│ (ResourceKind.memory = [base, height*pitch], write-combining hint,
│ plus DisplayInfo{width, height, pitch, format})
▼
display service (system/services/display/, ServiceId.display) ← the compositor
│ device.claim(display0) → 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 list
│ loop: composite dirty layers → back buffer → present dirty rects → front
│ backend is an INTERNAL interface: {gop-fb} today; {bochs-dispi, virtio-gpu} later
▼ reached by name (ipc_lookup); clients drive it over the display protocol
┌────────────────────────────────────┬──────────────────────────────────────┐
drawing clients (v1) surface clients (deferred)
runtime.display commands: runtime.display surfaces:
create_layer / configure_layer shm_create → pass as a capability →
fill_rect / blit_tile / damage the compositor maps & composites the
present client-rendered bitmap directly
```
The bring-up sequence mirrors a hardware driver's — it is the
[`usb-xhci-bus` `initialise`](../system/drivers/usb-xhci-bus/usb-xhci-bus.zig) shape
(claim → `mmio_map` → run loop) — and the request/reply service shell is the
[FAT](../system/services/fat/fat.zig) / [input](../system/services/input/input.zig) shape
([`runtime.service.run`](../library/runtime/service.zig) with a `protocol.zig` of
`extern struct` messages and an `Operation` tag).
**One process, for now.** v1 is a *single* service that both owns the framebuffer and
composites — it does not split a "framebuffer driver" from a "compositor" the way input
splits `ps2-bus` from the input service. The backend (dumb FB vs. a native GPU) is an
*internal* interface, not a process boundary. That boundary earns its keep only when a
second backend or a second monitor appears; until then it is complexity with no payoff.
## The handoff: a device node + a write-combining map
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](resilience.md)
story: a crashed display service returns the LFB to the kernel, and its restart
re-claims it).
- The kernel seeds a synthetic **`display0`** node into the
[devices-broker](../system/kernel/devices-broker.zig) at init, from
`BootInformation.framebuffer`: one `ResourceKind.memory` resource spanning
`[base, height*pitch]`, tagged **write-combining**, plus a small
`DisplayInfo{width, height, pitch, format}` (the memory resource says *where* and *how
big*; `DisplayInfo` says how to *interpret* the bytes).
- The service `device.claim`s it and `mmio_map`s the resource. The map is
**write-combining**, not the strong-uncacheable that `mmio_map` uses for register
MMIO. The kernel already programs a WC PAT slot for its own console
([`setupPat`](../system/kernel/architecture/x86_64/paging.zig)); this reaches it from
the user mapping path. **This matters:** an uncacheable framebuffer makes the
back→front blit unusably slow.
- On `claim`, the kernel's bootstrap console goes quiet, so the two never fight over the
LFB. A panic is the one exception — by then the service is likely dead anyway, and a
panic on screen wins.
The display service is a **named boot service**: `init` spawns it by name alongside
`vfs`/`input`/`device-manager` ([init.zig](../system/services/init/init.zig)), and it
self-discovers `display0` with `device.enumerate`. The [device manager](device-manager.md)
matching path (PCI class 0x03 → a driver) is reserved for the future *native* backend, not
this singleton synthetic node.
## Double buffering and the write-combining discipline
Two buffers, with deliberately different memory types:
- The **front buffer** is the LFB — **write-combining**: fast to *write*, slow to
*read*. The rule is therefore **never read the front buffer**. Only ever stream into
it, sequentially.
- The **back buffer** is ordinary **cacheable** RAM (`mmap`), the same geometry. All
compositing happens here, where reads and read-modify-write blends are cheap.
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](framebuffer.md) note already establishes carry over: step rows by `pitch`,
not `width*4`; and handle both `rgbx` and `bgrx` [pixel formats](gop.md).
## Flicker vs. tearing — what double buffering does and doesn't buy
These are two different artifacts, and the dumb framebuffer fixes exactly one of them:
- **Flicker** is the user seeing intermediate, half-drawn states (a clear-then-redraw
flash). Double buffering **eliminates it completely** — the screen only ever receives
whole, finished frames.
- **Tearing** is a present landing while the display's scanout beam is mid-frame, so the
top of the screen shows the new frame and the bottom the old. Avoiding it requires
presenting during the vertical blank (**vsync**) — which needs a vblank signal. **A
dumb GOP framebuffer has no vblank.**
So v1 is **flicker-free**, and it *minimizes* the tear window by presenting only damaged
rectangles (less to copy → a smaller window in which the beam can catch a half-updated
frame), but it is **not tear-free**. Genuine vsync waits for a backend with a vblank IRQ
or a flush/flip path — a native-device capability, not something the firmware
framebuffer can offer. Stated plainly here so the limitation is understood, not
discovered.
## Layers and the client protocol
The compositor holds an **ordered stack of layers**. Each layer has a rectangle, a
z-order, a visibility flag, and a surface. Presenting walks the stack bottom-to-top,
painting each dirty layer into the back buffer, then flushes the damage to the front.
In v1 the surfaces are **server-owned**, and clients draw into them with a small
immediate-mode command protocol — essentially the model early X used, and enough for a
shell, a terminal, a cursor, and a wallpaper:
| Operation | Meaning |
|--------------------|---------------------------------------------------------------|
| `info` | report `{width, height, pitch, format}` of the display |
| `create_layer` | allocate a server-owned surface, return a layer handle |
| `configure_layer` | set a layer's rect, z-order, visibility |
| `destroy_layer` | release a layer |
| `fill_rect` | fill a rectangle of a layer with a colour |
| `blit_tile` | copy a small client-supplied pixel tile into a layer (inline) |
| `damage` | mark a region of a layer dirty |
| `present` | composite dirty layers and flush to the screen |
Text is intentionally *not* an operation — a client renders glyphs by blitting tiles
(the [PSF font](../system/kernel/font.psf) path the console already uses can move into a
client). Keeping the protocol to rectangles and tiles keeps the compositor small and the
policy in the client.
## `runtime.display`
Clients speak the protocol through a new [`library/runtime/display.zig`](../library/runtime/runtime.zig),
the [`runtime.block`](../library/runtime/block.zig) shape (a cached `.display` lookup
with a boot-race retry): `display.info()`, a `Layer` handle with `fill` / `blitTile` /
`damage`, and `present()`. Application code never issues the raw syscalls — it calls the
runtime, as with every other danos service.
## What v1 does not do (and why that's fine)
Two capabilities are deliberately out of the first cut. Neither reshapes anything above;
both are clean additions behind the interfaces v1 establishes.
- **Client-rendered surfaces (shared memory).** The fast path for a bitmap-heavy app is
to render into its *own* buffer and hand the compositor a *reference*, not a stream of
commands. That needs the missing cross-process shared-memory primitive — best built as
the natural generalization of the existing M13 [capability passing](driver-model.md)
from *endpoints* to *memory objects* (`shm_create(len) → {cap, vaddr}`, pass `cap` on
an `ipc_call`, receiver `shm_map(cap) → vaddr`). v1 avoids it because server-owned
surfaces already prove the whole pipeline.
- **Runtime mode-setting (a native backend).** Detecting the EDID mode list and changing
resolution / bpp at runtime needs the raw PCI device. The first native backend is
Bochs DISPI — the register interface QEMU's `-device VGA` exposes — behind the same
internal backend interface the dumb framebuffer sits behind. Refresh-rate and colour
management (a gamma LUT) are real-GPU-KMS territory, far beyond this.
## Verifying it
Three QEMU test cases ([tests.zig](../system/kernel/tests.zig), `python3
test/qemu_test.py <case>`), each layering on the last:
- **`display`** — the kernel handoff: the seeded `display` device is shaped correctly and
the claim → `mmio_map` leaf is genuinely **write-combining** (PAT entry 4), asserted at
the page-table level.
- **`display-service`** — the compositor comes up: it claims the framebuffer, allocates
the cacheable back buffer, presents a cleared frame through the double-buffer path
(`display: online … / presented frame 0`), and a startup **self-check** composites two
overlapping layers on the real framebuffer and reads them back — overlap = the top
layer — logging `display: compositor self-check ok`.
- **`display-demo`** — the full pipeline from a separate process: the hardware-free
[`display-demo`](../system/services/display-demo/) client (the
[`input-source`](../system/services/input-source/) analog) drives layers — a wallpaper, a
sliding rectangle, a cursor — through the layer client API and heartbeats
`display-demo: ok`, proving a frame travelled client → compositor → screen, exactly as
the [input test](input.md) proves an event travels source → service → subscriber. The
visible motion itself is a screenshot away via `zig build run-x86-64`.
The compositor's pixel math (rectangle clipping, fill, composite, tile blit) and colour
packing are additionally covered by pure host unit tests under `zig build test`.
## See also
- [framebuffer.md](framebuffer.md) — the linear framebuffer, pitch vs. width, `volatile`.
- [gop.md](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).
- [display-plan.md](display-plan.md) — the ordered build-out.
+9
View File
@@ -27,6 +27,15 @@ transcript. Serial is per-architecture (x86 uses port I/O; an ARM board uses a
memory-mapped UART), so it lives behind the [arch](arch.md) boundary — and adding memory-mapped UART), so it lives behind the [arch](arch.md) boundary — and adding
a new architecture's UART is what makes the same tests run there. 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).
A real machine often has no live legacy COM1 — writing to a dead one is slow —
and the boot log is kept in a RAM buffer (`klog`) and flushed to disk instead,
so serial is now purely a QEMU/dev aid. The harness (`test/qemu_test.py`) builds
every case with `-Dserial=true`, and `zig build run-x86-64` boots a serial-enabled
image variant, so both get the transcript; a flashable `zig build` image leaves
serial out. (Even with `-Dserial`, a loopback probe disables a dead port at boot,
so a serial-enabled image is still safe on real hardware.)
## In-kernel test cases ## In-kernel test cases
Building with `-Dtest-case=<name>` makes the kernel, after normal bring-up, run one Building with `-Dtest-case=<name>` makes the kernel, after normal bring-up, run one
+7
View File
@@ -38,6 +38,13 @@ pub const Device = struct {
return self.transfer(.write, lba, count, physical); return self.transfer(.write, lba, count, physical);
} }
/// Commit any device write cache to stable media (SCSI SYNCHRONIZE CACHE), so
/// prior writes survive a power-off. A filesystem calls this before the machine
/// goes down; no data transfer, so the buffer arguments are unused.
pub fn flush(self: Device) bool {
return self.transfer(.flush, 0, 0, 0);
}
fn transfer(self: Device, operation: protocol.Operation, lba: u64, count: u32, physical: u64) bool { fn transfer(self: Device, operation: protocol.Operation, lba: u64, count: u32, physical: u64) bool {
var request = protocol.Request{ .operation = @intFromEnum(operation), .lba = lba, .count = count, .physical = physical }; var request = protocol.Request{ .operation = @intFromEnum(operation), .lba = lba, .count = count, .physical = physical };
var reply: [protocol.reply_size]u8 = undefined; var reply: [protocol.reply_size]u8 = undefined;
+171
View File
@@ -0,0 +1,171 @@
//! 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/
//! reply marshalling. See system/services/display/ and docs/display.md.
const std = @import("std");
const ipc = @import("ipc.zig");
const system = @import("system.zig");
const protocol = @import("display-protocol");
/// The display's current mode, as `info()` reports it.
pub const Info = struct {
width: u32,
height: u32,
pitch: u32, // bytes per row (may exceed width*4; see docs/framebuffer.md)
format: u32, // a device-abi DisplayFormat value (0 = rgbx, 1 = bgrx)
};
/// 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.
fn service() ?ipc.Handle {
if (handle) |h| return h;
var attempts: usize = 0;
while (attempts < 100) : (attempts += 1) {
if (ipc.lookup(.display)) |h| {
handle = h;
return h;
}
system.sleep(50);
}
return null;
}
/// Send one request, receive its reply; true on a zero status. `out` receives the reply
/// so callers can read `info`/`layer` fields on success.
fn transact(request: protocol.Request, out: *protocol.Reply) bool {
const h = service() orelse return false;
var req = request;
var reply: [protocol.reply_size]u8 = undefined;
const len = ipc.call(h, std.mem.asBytes(&req), &reply) catch return false;
if (len < protocol.reply_size) return false;
out.* = std.mem.bytesToValue(protocol.Reply, reply[0..protocol.reply_size]);
return out.status == 0;
}
/// The display's current mode, or null if the service never came up.
pub fn info() ?Info {
var reply: protocol.Reply = undefined;
if (!transact(.{ .operation = @intFromEnum(protocol.Operation.info) }, &reply)) return null;
return .{ .width = reply.width, .height = reply.height, .pitch = reply.pitch, .format = reply.format };
}
/// Composite the dirty layers and flush the frame to the screen.
pub fn present() bool {
var reply: protocol.Reply = undefined;
return transact(.{ .operation = @intFromEnum(protocol.Operation.present) }, &reply);
}
/// The mode, cached after the first `info()` so `color()` doesn't round-trip per pixel.
var mode: ?Info = null;
fn cachedInfo() ?Info {
if (mode) |m| return m;
const i = info() orelse return null;
mode = i;
return i;
}
/// The native pixel value for an 8-bit-per-channel colour, in the display's format. A
/// client packs colours through this so it never has to know the byte order itself.
pub fn color(r: u8, g: u8, b: u8) u32 {
const format = if (cachedInfo()) |i| i.format else 0;
return protocol.pack(format, r, g, b);
}
/// A handle to a server-owned layer: a positioned, z-ordered surface the client draws
/// into by command. Create with `createLayer`; drawing and moves take effect on the next
/// `present`. Coordinates are signed (a layer may sit partly off-screen).
pub const Layer = struct {
id: u32,
/// Fill a rectangle of this layer (layer-local coordinates) with a native `colour`.
pub fn fill(self: Layer, x: i32, y: i32, w: u32, h: u32, colour: u32) bool {
var reply: protocol.Reply = undefined;
return transact(.{
.operation = @intFromEnum(protocol.Operation.fill_rect),
.layer = self.id,
.x = @bitCast(x),
.y = @bitCast(y),
.width = w,
.height = h,
.colour = colour,
}, &reply);
}
/// Copy a `w`×`h` tile of native pixels (row-major, little-endian bytes) into this
/// layer at (`x`, `y`). The tile rides inline in the request, so `w*h*4` must fit
/// `protocol.maximum_payload`.
pub fn blitTile(self: Layer, x: i32, y: i32, w: u32, h: u32, pixels: []const u8) bool {
var request = protocol.Request{
.operation = @intFromEnum(protocol.Operation.blit_tile),
.layer = self.id,
.x = @bitCast(x),
.y = @bitCast(y),
.width = w,
.height = h,
};
const header = std.mem.asBytes(&request);
if (header.len + pixels.len > protocol.message_maximum) return false;
var buffer: [protocol.message_maximum]u8 = undefined;
@memcpy(buffer[0..header.len], header);
@memcpy(buffer[header.len..][0..pixels.len], pixels);
const h_svc = service() orelse return false;
var reply: [protocol.reply_size]u8 = undefined;
const len = ipc.call(h_svc, buffer[0 .. header.len + pixels.len], &reply) catch return false;
if (len < protocol.reply_size) return false;
return std.mem.bytesToValue(protocol.Reply, reply[0..protocol.reply_size]).status == 0;
}
/// Move / restack / show or hide the layer.
pub fn configure(self: Layer, x: i32, y: i32, z: u32, visible: bool) bool {
var reply: protocol.Reply = undefined;
return transact(.{
.operation = @intFromEnum(protocol.Operation.configure_layer),
.layer = self.id,
.x = @bitCast(x),
.y = @bitCast(y),
.z = z,
.visible = if (visible) 1 else 0,
}, &reply);
}
/// Mark a rectangle of this layer (layer-local) dirty for the next present — for when
/// the layer's pixels changed without a drawing call the compositor already tracked.
pub fn damage(self: Layer, x: i32, y: i32, w: u32, h: u32) bool {
var reply: protocol.Reply = undefined;
return transact(.{
.operation = @intFromEnum(protocol.Operation.damage),
.layer = self.id,
.x = @bitCast(x),
.y = @bitCast(y),
.width = w,
.height = h,
}, &reply);
}
/// Release the layer and its surface.
pub fn destroy(self: Layer) bool {
var reply: protocol.Reply = undefined;
return transact(.{ .operation = @intFromEnum(protocol.Operation.destroy_layer), .layer = self.id }, &reply);
}
};
/// Create a server-owned layer of `w`×`h` pixels at screen (`x`, `y`) with stacking order
/// `z` (higher is nearer the front), initially visible. Returns a handle, or null.
pub fn createLayer(x: i32, y: i32, w: u32, h: u32, z: u32) ?Layer {
var reply: protocol.Reply = undefined;
if (!transact(.{
.operation = @intFromEnum(protocol.Operation.create_layer),
.x = @bitCast(x),
.y = @bitCast(y),
.width = w,
.height = h,
.z = z,
.visible = 1,
}, &reply)) return null;
return .{ .id = reply.layer };
}
+6
View File
@@ -46,6 +46,12 @@ pub const usb = @import("usb.zig");
/// usb-storage). See library/runtime/block.zig. /// usb-storage). See library/runtime/block.zig.
pub const block = @import("block.zig"); pub const block = @import("block.zig");
/// Display-service client: query the mode, and (from D3) create layers, draw, and
/// present frames. See library/runtime/display.zig and system/services/display/.
pub const display = @import("display.zig");
/// The display wire protocol (shared with the display service and its clients).
pub const display_protocol = @import("display-protocol");
/// The danos-native file API (open/read/write/list over the user-space VFS) — the /// The danos-native file API (open/read/write/list over the user-space VFS) — the
/// layer danos programs use directly, and where the operations that later become /// layer danos programs use directly, and where the operations that later become
/// `std.os.danos` are staged. See docs/zig-self-hosting.md. /// `std.os.danos` are staged. See docs/zig-self-hosting.md.
+1
View File
@@ -183,6 +183,7 @@ pub const ServiceId = enum(u32) {
usb_bus = 6, // the xHCI host-controller driver's transfer endpoint; USB class drivers look it up and `callCap`-open their device to get a private per-device transfer channel (docs/driver-model.md) usb_bus = 6, // the xHCI host-controller driver's transfer endpoint; USB class drivers look it up and `callCap`-open their device to get a private per-device transfer channel (docs/driver-model.md)
block = 7, // a block-device driver (USB mass storage today): read/write of fixed-size blocks, the storage a filesystem sits on block = 7, // a block-device driver (USB mass storage today): read/write of fixed-size blocks, the storage a filesystem sits on
fat = 8, // the FAT filesystem server; the VFS mounts it and forwards paths under its mount point (/mnt/usb) to it fat = 8, // the FAT filesystem server; the VFS mounts it and forwards paths under its mount point (/mnt/usb) to it
display = 9, // the display service: owns the framebuffer, composites a layer stack, presents frames (docs/display.md)
_, _,
}; };
+28 -62
View File
@@ -3,11 +3,13 @@
//! Walks the ACPI tables the firmware left in memory (starting from the RSDP the //! Walks the ACPI tables the firmware left in memory (starting from the RSDP the
//! bootloader handed us) and translates the static tables into the generic //! bootloader handed us) and translates the static tables into the generic
//! `device` model, so the kernel enumerates hardware without knowing ACPI is the //! `device` model, so the kernel enumerates hardware without knowing ACPI is the
//! source. This is deliberately the *static-table* path: MADT (CPUs / interrupt //! source. This is deliberately the *static-table* path, and **only** that: MADT
//! controllers), MCFG (PCIe ECAM -> PCI enumeration), HPET (timer), and FADT //! (CPUs / interrupt controllers), MCFG (PCIe ECAM -> PCI enumeration), HPET
//! (power register map). The DSDT/SSDT bytecode is handed to the `aml` submodule //! (timer), and FADT (power register map). The DSDT/SSDT bytecode is *not*
//! only to extract the sleep-state (`_Sx`) values for power management; full AML namespace //! interpreted here — the kernel collects the blobs and publishes them on the
//! interpretation is a separate, larger subproject. //! acpi-tables node for the ring-3 acpi service to parse (device enumeration and
//! soft-off). Keeping the ~0.5 MB AML interpretation out of kernel init keeps it
//! off the single-core critical path (nothing else runs alongside it there).
//! //!
//! ACPI tables live in `.acpi_tables` / `.acpi_nvs` memory, which the kernel //! ACPI tables live in `.acpi_tables` / `.acpi_nvs` memory, which the kernel
//! identity-maps, so table addresses are dereferenced directly. PCIe ECAM is MMIO //! identity-maps, so table addresses are dereferenced directly. PCIe ECAM is MMIO
@@ -19,7 +21,6 @@ const boot_handoff = @import("boot-handoff");
const abi = @import("abi"); const abi = @import("abi");
const parameters = @import("parameters"); const parameters = @import("parameters");
const device_model = @import("device-model.zig"); const device_model = @import("device-model.zig");
const aml = @import("aml/aml.zig");
const DeviceTree = device_model.DeviceTree; const DeviceTree = device_model.DeviceTree;
const Hal = device_model.Hal; const Hal = device_model.Hal;
@@ -37,8 +38,11 @@ pub const RegisterAccess = struct {
} }
}; };
/// Everything the power subsystem needs, extracted from the FADT and the AML /// The power register map, extracted from the FADT during discovery. Populated by
/// sleep packages during discovery. Populated by `discover`, read by `power`. /// `discover`, read by `power` (kernel reboot). The **sleep-state (`_Sx`) values
/// live in AML**, which the kernel no longer parses — soft-off (S5) is owned by the
/// ring-3 acpi service (it re-parses the blobs on the published acpi-tables node and
/// writes the PM1 control register itself). So this holds only the FADT scalars.
pub const PowerInformation = struct { pub const PowerInformation = struct {
/// The System Control Interrupt's GSI (FADT SCI_INT) — the line ACPI events /// The System Control Interrupt's GSI (FADT SCI_INT) — the line ACPI events
/// (power button, GPEs) arrive on. Published to the acpi service for M21. /// (power button, GPEs) arrive on. Published to the acpi service for M21.
@@ -54,10 +58,6 @@ pub const PowerInformation = struct {
reset: RegisterAccess = .{}, reset: RegisterAccess = .{},
reset_value: u8 = 0, reset_value: u8 = 0,
reset_supported: bool = false, reset_supported: bool = false,
/// SLP_TYP values for S5 (soft off) and S3 (suspend), from the AML sleep-state (`_Sx`)
/// packages.
s5: ?aml.SleepType = null,
s3: ?aml.SleepType = null,
}; };
/// Filled in by `discover`; the power service reads it to reboot/shutdown. /// Filled in by `discover`; the power service reads it to reboot/shutdown.
@@ -141,19 +141,6 @@ const maximum_cpus = parameters.maximum_cpus;
/// Filled in by `discover` (from the MADT); SMP bring-up reads it to wake the APs. /// Filled in by `discover` (from the MADT); SMP bring-up reads it to wake the APs.
pub var cpu_information: CpuInformation = .{}; pub var cpu_information: CpuInformation = .{};
/// Integrity/diagnostics for the AML parse. `consumed == total` means the parser
/// walked every byte of the DSDT/SSDTs without desyncing.
pub const AmlStats = struct {
nodes: usize = 0,
consumed: usize = 0,
total: usize = 0,
};
pub var aml_stats: AmlStats = .{};
/// The ACPI namespace built from the DSDT/SSDTs, kept for sleep-state (`_Sx`) lookup now and
/// device enumeration later. Null until `discover` runs successfully.
pub var namespace: ?aml.Namespace = null;
/// Physical address of the DSDT the FADT points at, or 0. /// Physical address of the DSDT the FADT points at, or 0.
pub var dsdt_physical: u64 = 0; pub var dsdt_physical: u64 = 0;
@@ -165,8 +152,9 @@ var fadt_physical: u64 = 0;
var fadt_length: u64 = 0; var fadt_length: u64 = 0;
// AML blocks (DSDT + any SSDTs) collected during the table walk, as physical // AML blocks (DSDT + any SSDTs) collected during the table walk, as physical
// address + length of each table's post-header bytecode. Scanned after the walk // address + length of each table's post-header bytecode. The kernel does not
// for the sleep-state (`_Sx`) packages. // interpret them — it publishes them on the acpi-tables node for the ring-3 acpi
// service to parse (device enumeration + soft-off). See publishAcpiTablesNode.
var aml_block_physical: [32]u64 = undefined; var aml_block_physical: [32]u64 = undefined;
var aml_block_len: [32]usize = undefined; var aml_block_len: [32]usize = undefined;
var aml_block_count: usize = 0; var aml_block_count: usize = 0;
@@ -373,7 +361,7 @@ const Hpet = extern struct {
/// Discover hardware from the ACPI tables rooted at `rsdp_physical` and populate /// Discover hardware from the ACPI tables rooted at `rsdp_physical` and populate
/// `device_tree`. `hal` provides MMIO mapping (for PCIe ECAM) and port I/O. Also parses the /// `device_tree`. `hal` provides MMIO mapping (for PCIe ECAM) and port I/O. Also parses the
/// FADT and the AML sleep-state (`_Sx`) packages into `power_information` for the power service. /// FADT into `power_information`, and publishes the AML blobs for the ring-3 acpi service.
pub fn discover(rsdp_physical: u64, memory_regions: []const boot_handoff.MemoryRegion, device_tree: *DeviceTree, hal: Hal) !void { pub fn discover(rsdp_physical: u64, memory_regions: []const boot_handoff.MemoryRegion, device_tree: *DeviceTree, hal: Hal) !void {
if (rsdp_physical == 0) return error.NoRsdp; if (rsdp_physical == 0) return error.NoRsdp;
boot_memory_regions = memory_regions; boot_memory_regions = memory_regions;
@@ -383,8 +371,6 @@ pub fn discover(rsdp_physical: u64, memory_regions: []const boot_handoff.MemoryR
fadt_physical = 0; fadt_physical = 0;
fadt_length = 0; fadt_length = 0;
platform_information = .{}; platform_information = .{};
aml_stats = .{};
namespace = null;
dsdt_physical = 0; dsdt_physical = 0;
aml_block_count = 0; aml_block_count = 0;
@@ -401,34 +387,20 @@ pub fn discover(rsdp_physical: u64, memory_regions: []const boot_handoff.MemoryR
try walkRoot(u32, rsdp.root_system_description_table_address, device_tree, hal); try walkRoot(u32, rsdp.root_system_description_table_address, device_tree, hal);
} }
// Now that the DSDT and any SSDTs are collected, build the AML namespace and // The kernel does **not** interpret the DSDT/SSDTs. Static-table discovery
// read the sleep types from it. // above (MADT/HPET/FADT/MCFG) is all the kernel needs — CPUs, timers, PCIe,
var blocks: [aml_block_physical.len][]const u8 = undefined; // and the power register map. The AML bytecode (device enumeration and the
for (0..aml_block_count) |i| { // sleep-state `_Sx` values for soft-off) is entirely the ring-3 acpi service's
blocks[i] = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(aml_block_physical[i])))[0..aml_block_len[i]]; // job: it claims the acpi-tables node published below, parses the same blobs,
} // and both registers the `_HID` devices and owns S5. Not parsing ~0.5 MB of
const active = blocks[0..aml_block_count]; // AML in the kernel keeps boot latency off the critical, single-core path.
if (aml.parse(device_tree.allocator, active)) |pr| {
namespace = pr.namespace;
aml_stats = .{ .nodes = namespace.?.nodeCount(), .consumed = pr.consumed, .total = pr.total };
power_information.s5 = aml.sleepState(&namespace.?, 5);
power_information.s3 = aml.sleepState(&namespace.?, 3);
// The namespace's Device objects are no longer folded into the kernel
// tree (M20.3): the ring-3 acpi service claims the acpi-tables node
// (published below), re-parses the same blobs, and registers + reports
// the _HID devices itself. The kernel keeps the namespace only for the
// \_S5 sleep type above.
} else |_| {
// AML parse failed (e.g. out of memory); power stays best-effort with
// whatever the FADT alone provided.
}
// Publish the acpi-tables node (docs/discovery.md): the AML blobs as // Publish the acpi-tables node (docs/discovery.md): the AML blobs as
// memory resources for the acpi service to map and parse in ring 3, a broad // memory resources for the acpi service to map and parse in ring 3, a broad
// io_port grant for the OperationRegion access its interpreter needs, and // io_port grant for the OperationRegion access its interpreter needs, and
// the SCI for the events track (M21). Exactly one node, one trusted // the SCI for the events track (M21). Exactly one node, one trusted
// claimant. Kept even when the kernel-side device building (above) retires // claimant — the sole path by which AML (devices + soft-off) reaches ring 3,
// in M20.3 — the kernel still owns the *static* tables and \_S5. // now that the kernel keeps only the *static* tables for itself.
publishAcpiTablesNode(device_tree) catch {}; publishAcpiTablesNode(device_tree) catch {};
} }
@@ -461,12 +433,6 @@ fn publishAcpiTablesNode(device_tree: *DeviceTree) !void {
if (fadt_physical != 0) _ = node.addResource(.memory, fadt_physical, fadt_length); if (fadt_physical != 0) _ = node.addResource(.memory, fadt_physical, fadt_length);
} }
/// The number of Device objects in the namespace built during discovery, or 0.
pub fn amlDeviceCount() usize {
if (namespace) |*ns| return aml.deviceCount(ns);
return 0;
}
/// Walk the RSDT (Entry = u32) or XSDT (Entry = u64): validate it, then dispatch /// Walk the RSDT (Entry = u32) or XSDT (Entry = u64): validate it, then dispatch
/// each SDT it points at. A bad individual table is skipped, not fatal. /// each SDT it points at. A bad individual table is skipped, not fatal.
fn walkRoot(comptime Entry: type, root_physical: u64, device_tree: *DeviceTree, hal: Hal) !void { fn walkRoot(comptime Entry: type, root_physical: u64, device_tree: *DeviceTree, hal: Hal) !void {
@@ -502,7 +468,7 @@ fn handleTable(device_tree: *DeviceTree, hal: Hal, sdt_physical: u64) !void {
} else if (std.mem.eql(u8, &sig, &DMAR)) { } else if (std.mem.eql(u8, &sig, &DMAR)) {
parseDmar(hal, header); parseDmar(hal, header);
} else if (std.mem.eql(u8, &sig, &SSDT)) { } else if (std.mem.eql(u8, &sig, &SSDT)) {
// Secondary namespace bytecode — collect for the sleep-state (`_Sx`) scan. // Secondary namespace bytecode — collect it to publish for the ring-3 parse.
addAmlBlock(sdt_physical); addAmlBlock(sdt_physical);
} }
// Any other signature is recognised but left opaque for now. // Any other signature is recognised but left opaque for now.
@@ -740,8 +706,8 @@ const fadt_x_pm_tmr_blk = 208; // GAS
const flag_reset_register_supported = 1 << 10; const flag_reset_register_supported = 1 << 10;
const flag_tmr_value_ext = 1 << 8; // PM timer counter is 32-bit (else 24-bit) const flag_tmr_value_ext = 1 << 8; // PM timer counter is 32-bit (else 24-bit)
/// FADT -> the power register map (into `power_information`) and the DSDT address, which /// FADT -> the power register map (into `power_information`) and the DSDT address,
/// is queued for the AML sleep-state (`_Sx`) scan. No AML interpretation happens here. /// whose bytecode is collected for the ring-3 parse. No AML interpretation here.
fn parseFadt(header: *const SystemDescriptorTableHeader) void { fn parseFadt(header: *const SystemDescriptorTableHeader) void {
const base: [*]align(1) const u8 = @ptrCast(header); const base: [*]align(1) const u8 = @ptrCast(header);
const len: usize = header.length; const len: usize = header.length;
+41
View File
@@ -38,6 +38,13 @@ pub const DeviceClass = enum(u32) {
/// resources; the (class, subclass, protocol) triple that says what it is /// resources; the (class, subclass, protocol) triple that says what it is
/// travels in the bus report's identity, not here. /// travels in the bus report's identity, not here.
usb_device, usb_device,
/// A scanout framebuffer: a linear region of pixel memory the display service
/// claims and maps. Unlike the other classes this one is not firmware-discovered
/// — the kernel seeds it from the loader's [[boot-handoff]] framebuffer
/// (`devices_broker.seedDisplay`). Its one `memory` resource is the framebuffer,
/// flagged write-combining; the geometry to interpret it travels in
/// `DeviceDescriptor.display`.
display,
unknown, unknown,
}; };
@@ -59,10 +66,39 @@ pub const ResourceDescriptor = extern struct {
kind: u64, // a ResourceKind value kind: u64, // a ResourceKind value
start: u64, start: u64,
len: u64, len: u64,
/// A bitmask of `resource_flag_*` hints. Zero for a plain register/RAM window;
/// the kernel reads it when it maps the resource. Defaulted so every existing
/// literal (which never set flags) keeps compiling and lays out identically.
flags: u64 = 0,
}; };
/// `ResourceDescriptor.flags`: map this `memory` resource **write-combining** rather
/// than strong-uncacheable — for a framebuffer, where batched bursts to pixel memory
/// are the whole point (an uncacheable framebuffer blit is glacial). See
/// `mmio_map` (system/kernel/process.zig) and `setupPat` (…/x86_64/paging.zig).
pub const resource_flag_write_combining: u64 = 1 << 0;
pub const maximum_device_resources = 8; pub const maximum_device_resources = 8;
/// The byte order of a display's pixels — mirrors the loader's `PixelFormat`
/// ([[boot-handoff]]) with the same numeric values, but lives here so user space
/// (which must never import the loader↔kernel handoff) can name it. Only the two
/// linear 32-bpp layouts a console can paint into exist; see docs/gop.md.
pub const DisplayFormat = enum(u32) {
rgbx = 0, // byte 0 = Red, 1 = Green, 2 = Blue, 3 = reserved
bgrx = 1, // byte 0 = Blue, 1 = Green, 2 = Red, 3 = reserved
};
/// The geometry of a `display` device's framebuffer, carried in its descriptor so a
/// claiming driver knows how to interpret the pixel bytes its `memory` resource maps.
/// `pitch` is bytes per row (may exceed `width * 4`; see docs/framebuffer.md).
pub const DisplayInfo = extern struct {
width: u32 = 0, // visible pixels per row
height: u32 = 0, // visible rows
pitch: u32 = 0, // bytes from one row's start to the next
format: u32 = 0, // a DisplayFormat value
};
/// `DeviceDescriptor.parent` for a device with no parent — a root of the device tree. /// `DeviceDescriptor.parent` for a device with no parent — a root of the device tree.
pub const no_parent: u64 = ~@as(u64, 0); pub const no_parent: u64 = ~@as(u64, 0);
@@ -92,4 +128,9 @@ pub const DeviceDescriptor = extern struct {
resource_count: u64, resource_count: u64,
hid: [8]u8, hid: [8]u8,
resources: [maximum_device_resources]ResourceDescriptor, resources: [maximum_device_resources]ResourceDescriptor,
// Framebuffer geometry, meaningful only when `class` is `DeviceClass.display`
// (zeroed otherwise). Kept here — a class-specific field on the shared descriptor —
// the same way `pci_class` is meaningful only for `pci_device` and `hid` only for
// `acpi_device`.
display: DisplayInfo = .{},
}; };
+5 -20
View File
@@ -22,13 +22,14 @@ pub const Resource = device_model.Resource;
pub const ResourceKind = device_model.ResourceKind; pub const ResourceKind = device_model.ResourceKind;
pub const Hal = device_model.Hal; pub const Hal = device_model.Hal;
pub const PowerInformation = acpi.PowerInformation; pub const PowerInformation = acpi.PowerInformation;
pub const AmlStats = acpi.AmlStats;
pub const PlatformInformation = acpi.PlatformInformation; pub const PlatformInformation = acpi.PlatformInformation;
pub const RegisterAccess = acpi.RegisterAccess; pub const RegisterAccess = acpi.RegisterAccess;
pub const IsoEntry = acpi.IsoEntry; pub const IsoEntry = acpi.IsoEntry;
pub const Cpu = acpi.Cpu; pub const Cpu = acpi.Cpu;
/// The register map + sleep types discovery extracted, for logging/diagnostics. /// The FADT power register map discovery extracted (PM1 control, reset register),
/// for kernel reboot and diagnostics. Sleep-state values are userspace's (S5 is
/// owned by the ring-3 acpi service), so they are not here.
pub fn powerInformation() PowerInformation { pub fn powerInformation() PowerInformation {
return acpi.power_information; return acpi.power_information;
} }
@@ -39,18 +40,6 @@ pub fn platformInformation() PlatformInformation {
return acpi.platform_information; return acpi.platform_information;
} }
/// AML parse integrity/diagnostics (namespace node count, bytes consumed).
/// The number of Device objects in the kernel's own AML namespace, or 0 if the
/// parse produced none — the `acpi-parse` test compares the ring-3 service's
/// count against this.
pub fn amlDeviceCount() usize {
return acpi.amlDeviceCount();
}
pub fn amlStats() AmlStats {
return acpi.aml_stats;
}
/// The usable logical processors discovered during enumeration — one entry per /// The usable logical processors discovered during enumeration — one entry per
/// core danos may schedule on, each carrying the Local APIC ID an SMP wake targets. /// core danos may schedule on, each carrying the Local APIC ID an SMP wake targets.
/// `len` is the hardware's degree of parallelism: how many tasks *could* run at the /// `len` is the hardware's degree of parallelism: how many tasks *could* run at the
@@ -92,12 +81,8 @@ pub fn discover(
} }
/// Restart the machine. Never returns on success; returns only if no reset method /// Restart the machine. Never returns on success; returns only if no reset method
/// worked (extremely unlikely). Backend-agnostic entry the kernel calls. /// worked (extremely unlikely). Backend-agnostic entry the kernel calls. Soft-off
/// (S5) is not a kernel operation — the ring-3 acpi service owns it (docs/power.md).
pub fn reboot(hal: Hal) void { pub fn reboot(hal: Hal) void {
power.reboot(hal); power.reboot(hal);
} }
/// Power the machine off (ACPI S5). Never returns on success.
pub fn shutdown(hal: Hal) void {
power.shutdown(hal);
}
+10 -63
View File
@@ -1,34 +1,17 @@
//! Machine power control: enter ACPI mode, reboot, and power off (ACPI S5). //! Machine reboot: restart via the FADT reset register, with legacy fallbacks.
//! //!
//! Built entirely on the register map `acpi` extracted from the FADT plus the //! Built on the register map `acpi` extracted from the FADT, driven through the
//! sleep-state (`_Sx`) types the AML submodule pulled from the DSDT, driven through the //! injected `Hal` (port I/O and MMIO). Soft-off (ACPI S5) and suspend (S3) are
//! injected `Hal` (port I/O and MMIO). Nothing here is x86-specific beyond the //! **not** here: they need the AML sleep-state (`_Sx`) values, which the kernel no
//! well-known legacy reset fallbacks, which are guarded behind the ACPI methods. //! longer parses — the ring-3 acpi service owns power management (it re-parses the
//! //! blobs and writes the PM1 control register itself). See docs/power.md. Reboot
//! S3 (suspend-to-RAM) is stubbed: it needs a wake trampoline and device //! stays in the kernel because it needs no AML — only the FADT reset register and
//! re-initialisation, a milestone of its own. //! the well-known legacy fallbacks — so it survives as a last-resort restart.
const acpi = @import("acpi.zig"); const acpi = @import("acpi.zig");
const device_model = @import("device-model.zig"); const device_model = @import("device-model.zig");
const Hal = device_model.Hal; const Hal = device_model.Hal;
const slp_en: u32 = 1 << 13; // SLP_EN: writing 1 triggers the sleep transition
const sci_en: u32 = 1 << 0; // SCI_EN in PM1 control: set once ACPI mode is active
/// Switch the platform into ACPI mode if it isn't already, so the PM1 control
/// register is live. A no-op when the firmware exposes no SMI command port (ACPI
/// already enabled, as under QEMU/OVMF) — we still verify SCI_EN first.
pub fn enable(hal: Hal) void {
const pi = acpi.power_information;
if (!pi.pm1a_cnt.present()) return;
if (readRegister(hal, pi.pm1a_cnt) & sci_en != 0) return; // already in ACPI mode
if (pi.smi_cmd == 0 or pi.acpi_enable == 0) return; // no way to switch; assume fine
hal.pioWrite(1, pi.smi_cmd, pi.acpi_enable);
var spins: usize = 0;
while (readRegister(hal, pi.pm1a_cnt) & sci_en == 0 and spins < 1_000_000) : (spins += 1) {}
}
/// Restart the machine. Tries the ACPI reset register first, then the two legacy /// Restart the machine. Tries the ACPI reset register first, then the two legacy
/// fallbacks. Returns only if every method failed (very unlikely). /// fallbacks. Returns only if every method failed (very unlikely).
pub fn reboot(hal: Hal) void { pub fn reboot(hal: Hal) void {
@@ -48,42 +31,6 @@ pub fn reboot(hal: Hal) void {
delay(); delay();
} }
/// Power the machine off via ACPI S5. Requires the soft-off (`_S5`) sleep type; if
/// it wasn't found in the AML, there is nothing safe to do and this returns.
pub fn shutdown(hal: Hal) void {
enable(hal);
const pi = acpi.power_information;
const s5 = pi.s5 orelse return;
if (pi.pm1a_cnt.present()) {
writeRegister(hal, pi.pm1a_cnt, sleepValue(s5.slp_typ_a));
}
if (pi.pm1b_cnt.present()) {
writeRegister(hal, pi.pm1b_cnt, sleepValue(s5.slp_typ_b));
}
delay();
}
/// S3 suspend-to-RAM — not implemented (needs a wake path + device re-init).
pub fn sleepS3(hal: Hal) error{Unsupported}!void {
_ = hal;
return error.Unsupported;
}
/// The PM1 control write that requests sleep type `slp_typ`: SLP_TYP in bits
/// [12:10], SLP_EN in bit 13.
fn sleepValue(slp_typ: u8) u32 {
return (@as(u32, slp_typ & 0x7) << 10) | slp_en;
}
fn readRegister(hal: Hal, register: acpi.RegisterAccess) u32 {
if (register.mmio) {
const p: *align(1) volatile u32 = @ptrFromInt(hal.mapMmio(register.address, 4, true));
return p.*;
}
return hal.pioRead(register.width, @intCast(register.address));
}
fn writeRegister(hal: Hal, register: acpi.RegisterAccess, value: u32) void { fn writeRegister(hal: Hal, register: acpi.RegisterAccess, value: u32) void {
if (register.mmio) { if (register.mmio) {
const p: *align(1) volatile u32 = @ptrFromInt(hal.mapMmio(register.address, 4, true)); const p: *align(1) volatile u32 = @ptrFromInt(hal.mapMmio(register.address, 4, true));
@@ -93,8 +40,8 @@ fn writeRegister(hal: Hal, register: acpi.RegisterAccess, value: u32) void {
} }
} }
/// A short busy-wait so a reset/power-off takes effect before we fall through to /// A short busy-wait so a reset takes effect before we fall through to the next
/// the next method. The empty asm is an architecture-neutral barrier that keeps the loop /// method. The empty asm is an architecture-neutral barrier that keeps the loop
/// from being optimised away. /// from being optimised away.
fn delay() void { fn delay() void {
var i: usize = 0; var i: usize = 0;
+11
View File
@@ -15,6 +15,7 @@ const op_inquiry: u8 = 0x12;
const op_read_capacity_10: u8 = 0x25; const op_read_capacity_10: u8 = 0x25;
const op_read_10: u8 = 0x28; const op_read_10: u8 = 0x28;
const op_write_10: u8 = 0x2A; const op_write_10: u8 = 0x2A;
const op_synchronize_cache_10: u8 = 0x35;
/// INQUIRY: standard device data (36 bytes: peripheral type, removable, vendor /// INQUIRY: standard device data (36 bytes: peripheral type, removable, vendor
/// and product strings). /// and product strings).
@@ -57,6 +58,16 @@ pub fn write10(lba: u32, blocks: u16) [10]u8 {
return cdb; return cdb;
} }
/// SYNCHRONIZE CACHE(10): commit the device's write cache to stable media. LBA 0
/// and block count 0 mean "the whole medium". No data stage. Without this a write
/// can sit in the USB flash controller's cache and be lost if power is cut right
/// after — which is exactly what a shutdown-time log flush hits on real hardware.
pub fn synchronizeCache10() [10]u8 {
var cdb = [_]u8{0} ** 10;
cdb[0] = op_synchronize_cache_10;
return cdb;
}
/// Decode an 8-byte READ CAPACITY(10) reply. /// Decode an 8-byte READ CAPACITY(10) reply.
pub fn parseCapacity(bytes: [8]u8) struct { last_lba: u32, block_size: u32 } { pub fn parseCapacity(bytes: [8]u8) struct { last_lba: u32, block_size: u32 } {
return .{ return .{
@@ -147,6 +147,14 @@ fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?runtime
const ok = transact(&cdb, false, request.physical, request.count * block_size); const ok = transact(&cdb, false, request.physical, request.count * block_size);
return writeReply(reply, .{ .status = if (ok) 0 else -1, .block_size = block_size, .block_count = if (ok) request.count else 0 }); return writeReply(reply, .{ .status = if (ok) 0 else -1, .block_size = block_size, .block_count = if (ok) request.count else 0 });
}, },
@intFromEnum(block_protocol.Operation.flush) => {
// SYNCHRONIZE CACHE: commit the device's write cache to flash. No data
// stage. Makes prior writes durable before a caller (init at shutdown)
// cuts power. A device without a volatile cache reports success anyway.
const cdb = scsi.synchronizeCache10();
const ok = transact(&cdb, false, 0, 0);
return writeReply(reply, .{ .status = if (ok) 0 else -1, .block_size = block_size, .block_count = 0 });
},
else => return 0, else => return 0,
} }
} }
+18 -4
View File
@@ -106,6 +106,12 @@ pub fn serialWrite(bytes: []const u8) void {
serial.write(bytes); serial.write(bytes);
} }
/// Whether a working UART was detected (loopback probe). When false the serial
/// sink is silently inert — a dead legacy COM1 costs nothing per byte.
pub fn serialPresent() bool {
return serial.present();
}
/// Emit a one-byte progress checkpoint to whatever hardware debug sink the /// Emit a one-byte progress checkpoint to whatever hardware debug sink the
/// platform has — here the POST diagnostic port (0x80), which a POST card or BMC /// platform has — here the POST diagnostic port (0x80), which a POST card or BMC
/// displays. The last-resort progress signal when there's no text output at all. /// displays. The last-resort progress signal when there's no text output at all.
@@ -162,10 +168,18 @@ pub fn mapUserPageInto(root: u64, virtual: u64, physical: u64, writable: bool, e
paging.mapUserInto(root, virtual, physical, writable, executable); paging.mapUserInto(root, virtual, physical, writable, executable);
} }
/// Map a device MMIO window into address space `root`: strong-uncacheable, RW+NX, /// Map a device MMIO window into address space `root`: RW+NX, and marked so teardown
/// and marked so teardown won't free the MMIO frames as RAM. For IO passthrough. /// won't free the MMIO frames as RAM. `write_combining` picks the cache type —
pub fn mapUserDeviceInto(root: u64, virtual: u64, physical: u64, len: u64) void { /// false = strong-uncacheable (registers), true = write-combining (a framebuffer).
paging.mapUserDeviceInto(root, virtual, physical, len); /// For IO passthrough.
pub fn mapUserDeviceInto(root: u64, virtual: u64, physical: u64, len: u64, write_combining: bool) void {
paging.mapUserDeviceInto(root, virtual, physical, len, write_combining);
}
/// Is the user leaf mapping `virtual` in address space `root` write-combining? Null if
/// unmapped. For tests verifying the framebuffer map's cache type.
pub fn userLeafIsWriteCombining(root: u64, virtual: u64) ?bool {
return paging.leafIsWriteCombining(root, virtual);
} }
/// Map coherent DMA RAM into address space `root`: strong-uncacheable, RW+NX, but /// Map coherent DMA RAM into address space `root`: strong-uncacheable, RW+NX, but
+118 -18
View File
@@ -7,8 +7,13 @@
//! unmapped as a null guard. It also exposes map/unmap for on-demand mapping, //! unmapped as a null guard. It also exposes map/unmap for on-demand mapping,
//! which the kernel heap will build on. //! which the kernel heap will build on.
//! //!
//! Everything is 4 KiB pages — precise and simple; the extra table memory is //! The physmap (the permanent window onto all physical RAM) is built with 2 MiB
//! negligible against available RAM. //! huge pages wherever the range is 2 MiB-aligned, falling back to 4 KiB for the
//! unaligned edges. On a big machine that is the difference between ~16.7M page-
//! table entries (128 MiB of tables) and ~32K — it makes both the build and the
//! footprint scale sanely with RAM. Everything else (kernel segments, heap, user
//! space, on-demand MMIO) stays 4 KiB: precise, and the table memory is
//! negligible there.
const boot_handoff = @import("boot-handoff"); const boot_handoff = @import("boot-handoff");
const abi = @import("abi"); const abi = @import("abi");
@@ -22,10 +27,22 @@ const writable: u64 = 1 << 1;
const user: u64 = 1 << 2; // U/S: accessible from ring 3 (must be set at every level) const user: u64 = 1 << 2; // U/S: accessible from ring 3 (must be set at every level)
const pwt: u64 = 1 << 3; // page write-through const pwt: u64 = 1 << 3; // page write-through
const pcd: u64 = 1 << 4; // page cache disable (with PWT: strong-uncacheable under the default PAT) const pcd: u64 = 1 << 4; // page cache disable (with PWT: strong-uncacheable under the default PAT)
const page_size_bit: u64 = 1 << 7; // PS: this PDPT/PD entry is a 1 GiB/2 MiB leaf, not a pointer to the next table
const device_grant: u64 = 1 << 9; // available bit: this leaf maps device MMIO, not RAM — do not reclaim const device_grant: u64 = 1 << 9; // available bit: this leaf maps device MMIO, not RAM — do not reclaim
const no_execute: u64 = 1 << 63; const no_execute: u64 = 1 << 63;
const address_mask: u64 = 0x000F_FFFF_FFFF_F000; const address_mask: u64 = 0x000F_FFFF_FFFF_F000;
// The PAT-index bit. In a 4 KiB PTE it is bit 7; in a huge leaf (2 MiB PDE / 1 GiB
// PDPTE) bit 7 is PS, so the PAT bit moves to bit 12. With PCD=PWT=0 this selects
// PAT entry 4, which `setupPat` programs to write-combining (see mapRangePhysmap).
const pte_pat: u64 = 1 << 7;
const huge_pat: u64 = 1 << 12;
const ia32_pat: u32 = 0x277;
/// The physmap's page size for 2 MiB-aligned RAM: one PD leaf covers this instead
/// of 512 PT entries. 4 KiB pages fill the unaligned edges (see mapRangePhysmap).
const huge_page_size: u64 = 2 << 20; // 2 MiB
// ELF segment flags (p_flags). // ELF segment flags (p_flags).
const pf_x: u32 = 1; const pf_x: u32 = 1;
const pf_w: u32 = 2; const pf_w: u32 = 2;
@@ -74,7 +91,15 @@ fn allocTable() u64 {
/// entries are writable and executable so the leaf's bits govern (a page is /// entries are writable and executable so the leaf's bits govern (a page is
/// writable only if every level is; non-executable if any level is). /// writable only if every level is; non-executable if any level is).
fn descend(entry: *u64) u64 { fn descend(entry: *u64) u64 {
if (entry.* & present != 0) return entry.* & address_mask; if (entry.* & present != 0) {
// A present-but-huge entry is a leaf, not a table: descending would read
// its 2 MiB/1 GiB data frame as a page table and corrupt RAM. This only
// fires on a bug — a 4 KiB map landing inside a physmap huge page — and a
// loud panic beats silent corruption. (The physmap and the 4 KiB regions
// live in disjoint PML4 slots, so it should never happen.)
if (entry.* & page_size_bit != 0) @panic("paging: descend through a huge-page leaf");
return entry.* & address_mask;
}
const frame = allocTable(); const frame = allocTable();
entry.* = frame | present | writable; entry.* = frame | present | writable;
return frame; return frame;
@@ -96,15 +121,39 @@ fn mapPage(pml4: u64, virtual: u64, physical: u64, flags: u64) void {
tableAt(pt)[(virtual >> 12) & 0x1FF] = (physical & address_mask) | flags | present; tableAt(pt)[(virtual >> 12) & 0x1FF] = (physical & address_mask) | flags | present;
} }
/// Map one 2 MiB huge page `virtual` -> `physical` with `flags` — a leaf at the PD
/// level (PS bit set), with no PT beneath it. Both addresses must be 2 MiB-aligned.
/// One of these replaces 512 `mapPage`s (and the PT frame they'd need).
fn mapHugePage(pml4: u64, virtual: u64, physical: u64, flags: u64) void {
const pml4e = &tableAt(pml4)[(virtual >> 39) & 0x1FF];
if (init_done and (virtual >> 63) == 1 and pml4e.* & present == 0)
@panic("paging: new higher-half PML4 entry after init");
const pdpt = descend(pml4e);
const pdpte = &tableAt(pdpt)[(virtual >> 30) & 0x1FF];
const pd = descend(pdpte);
tableAt(pd)[(virtual >> 21) & 0x1FF] = (physical & address_mask) | flags | present | page_size_bit;
}
/// Map [physical_base, physical_base+len) into the physmap (at physicalToVirtual(physical)) with /// Map [physical_base, physical_base+len) into the physmap (at physicalToVirtual(physical)) with
/// `flags`, rounded out to whole pages. This is how the kernel keeps a permanent /// `flags`, rounded out to whole pages. This is how the kernel keeps a permanent
/// window onto physical memory once the low identity map goes away. /// window onto physical memory once the low identity map goes away. The 2 MiB-
fn mapRangePhysmap(pml4: u64, physical_base: u64, len: u64, flags: u64) void { /// aligned interior is mapped with huge pages; the unaligned head/tail with 4 KiB.
/// `write_combining` selects the WC memory type (setupPat's PAT entry 4) via the
/// PAT bit — bit 7 in a 4 KiB PTE, bit 12 in a huge leaf — for the framebuffer.
fn mapRangePhysmap(pml4: u64, physical_base: u64, len: u64, flags: u64, write_combining: bool) void {
const pte_flags = if (write_combining) flags | pte_pat else flags;
const huge_flags = if (write_combining) flags | huge_pat else flags;
var address = physical_base & ~@as(u64, page_size - 1); var address = physical_base & ~@as(u64, page_size - 1);
const end = physical_base + len; const end = physical_base + len;
while (address < end) : (address += page_size) { // Head: 4 KiB pages up to the next 2 MiB boundary.
mapPage(pml4, boot_handoff.physicalToVirtual(address), address, flags); while (address < end and address & (huge_page_size - 1) != 0) : (address += page_size)
} mapPage(pml4, boot_handoff.physicalToVirtual(address), address, pte_flags);
// Interior: 2 MiB huge pages while a whole one still fits.
while (address + huge_page_size <= end) : (address += huge_page_size)
mapHugePage(pml4, boot_handoff.physicalToVirtual(address), address, huge_flags);
// Tail: 4 KiB pages for whatever is left.
while (address < end) : (address += page_size)
mapPage(pml4, boot_handoff.physicalToVirtual(address), address, pte_flags);
} }
fn regions(mm: boot_handoff.MemoryMap) []const boot_handoff.MemoryRegion { fn regions(mm: boot_handoff.MemoryMap) []const boot_handoff.MemoryRegion {
@@ -118,11 +167,26 @@ fn enableNx() void {
io.wrmsr(efer_msr, io.rdmsr(efer_msr) | (1 << 11)); io.wrmsr(efer_msr, io.rdmsr(efer_msr) | (1 << 11));
} }
/// Program this core's PAT so entry 4 (selected by the PAT bit with PCD=PWT=0) is
/// **write-combining**, leaving the other seven at their reset types. Nothing else
/// in danos sets the PAT bit, so this changes no existing mapping — it only gives
/// the framebuffer a write-combining type, which turns its full-screen clear from
/// glacial (uncached writes to a GPU BAR, the real-hardware default via MTRRs) into
/// a batched burst. Must run on **every** core (PAT is per-logical-processor) — the
/// framebuffer mapping lives in the shared kernel half, so a core with the reset
/// PAT would see it as write-back and alias. Called from `init` (BSP) and each AP.
pub fn setupPat() void {
// Reset PAT is PA0=WB PA1=WT PA2=UC- PA3=UC PA4=WB PA5=WT PA6=UC- PA7=UC; flip
// PA4 from WB (0x06) to WC (0x01). Type codes: UC=0 WC=1 WT=4 WP=5 WB=6 UC-=7.
io.wrmsr(ia32_pat, 0x0007_0401_0007_0406);
}
/// Build the address space and switch onto it. /// Build the address space and switch onto it.
pub fn init(allocFrame: *const fn () ?u64, freeFrame: *const fn (u64) void, boot_information: *const boot_handoff.BootInformation) void { pub fn init(allocFrame: *const fn () ?u64, freeFrame: *const fn (u64) void, boot_information: *const boot_handoff.BootInformation) void {
alloc_frame = allocFrame; alloc_frame = allocFrame;
free_frame = freeFrame; free_frame = freeFrame;
enableNx(); enableNx();
setupPat(); // BSP: PAT entry 4 = write-combining, for the framebuffer window
const pml4 = allocTable(); const pml4 = allocTable();
// 1. All RAM in the physmap (physicalToVirtual(physical)) RW + NX. No identity/low-half // 1. All RAM in the physmap (physicalToVirtual(physical)) RW + NX. No identity/low-half
@@ -130,13 +194,15 @@ pub fn init(allocFrame: *const fn () ?u64, freeFrame: *const fn (u64) void, boot
// mapped on demand (mapMmio) or explicitly below. // mapped on demand (mapMmio) or explicitly below.
for (regions(boot_information.memory_map)) |r| { for (regions(boot_information.memory_map)) |r| {
if (r.kind == .mmio) continue; if (r.kind == .mmio) continue;
mapRangePhysmap(pml4, r.base, r.pages * page_size, present | writable | no_execute); mapRangePhysmap(pml4, r.base, r.pages * page_size, present | writable | no_execute, false);
} }
// 2. Physmap windows for the framebuffer and the Local APIC (device memory // 2. Physmap windows for the framebuffer and the Local APIC (device memory
// the kernel touches directly), RW + NX. // the kernel touches directly), RW + NX. The framebuffer is **write-
// combining** (see setupPat) so the console's full-screen clear is a burst,
// not millions of uncached single-word writes.
const fb = boot_information.framebuffer; const fb = boot_information.framebuffer;
mapRangePhysmap(pml4, fb.base, @as(u64, fb.height) * fb.pitch, present | writable | no_execute); mapRangePhysmap(pml4, fb.base, @as(u64, fb.height) * fb.pitch, present | writable | no_execute, true);
mapPage(pml4, boot_handoff.physicalToVirtual(0xFEE00000), 0xFEE00000, present | writable | no_execute); mapPage(pml4, boot_handoff.physicalToVirtual(0xFEE00000), 0xFEE00000, present | writable | no_execute);
// 3. The kernel's own segments at their higher-half link addresses, mapped // 3. The kernel's own segments at their higher-half link addresses, mapped
@@ -254,8 +320,12 @@ pub fn mapUserInto(pml4: u64, virtual: u64, physical: u64, writable_page: bool,
/// RAM allocator (`freeSubtree`). RW + NX; the caller places `virtual` in a /// RAM allocator (`freeSubtree`). RW + NX; the caller places `virtual` in a
/// user-exclusive range (PML4[225]). Both `virtual` and `physical` are page-aligned by /// user-exclusive range (PML4[225]). Both `virtual` and `physical` are page-aligned by
/// the caller; a sub-page `physical` offset is the caller's to re-apply. /// the caller; a sub-page `physical` offset is the caller's to re-apply.
pub fn mapUserDeviceInto(pml4: u64, virtual: u64, physical: u64, len: u64) void { pub fn mapUserDeviceInto(pml4: u64, virtual: u64, physical: u64, len: u64, write_combining: bool) void {
const flags: u64 = present | user | writable | no_execute | pcd | pwt | device_grant; // Registers are strong-uncacheable (PCD|PWT). A framebuffer instead wants
// write-combining — the PAT bit (bit 7 in a 4 KiB PTE) with PCD=PWT=0 selects PAT
// entry 4, which `setupPat` programs to WC — so pixel writes batch into bursts.
const cache: u64 = if (write_combining) pte_pat else (pcd | pwt);
const flags: u64 = present | user | writable | no_execute | device_grant | cache;
const first = physical & ~@as(u64, page_size - 1); const first = physical & ~@as(u64, page_size - 1);
const last = (physical + (if (len == 0) 1 else len) - 1) & ~@as(u64, page_size - 1); const last = (physical + (if (len == 0) 1 else len) - 1) & ~@as(u64, page_size - 1);
var off: u64 = 0; var off: u64 = 0;
@@ -296,6 +366,33 @@ pub fn mapUserDmaInto(pml4: u64, virtual: u64, physical: u64, len: u64) void {
} }
} }
/// The raw leaf entry mapping `virtual` in the address space rooted at `pml4`, or null
/// if any level of the walk is absent. **Read-only** — never allocates or descends into
/// a missing table (unlike the `map*` paths' `descendUser`). Stops at the first huge
/// leaf. For tests and introspection that need a page's actual flag bits.
pub fn leafEntryOf(pml4: u64, virtual: u64) ?u64 {
const l4 = tableAt(pml4)[(virtual >> 39) & 0x1FF];
if (l4 & present == 0) return null;
const l3 = tableAt(l4 & address_mask)[(virtual >> 30) & 0x1FF];
if (l3 & present == 0) return null;
if (l3 & page_size_bit != 0) return l3; // 1 GiB leaf
const l2 = tableAt(l3 & address_mask)[(virtual >> 21) & 0x1FF];
if (l2 & present == 0) return null;
if (l2 & page_size_bit != 0) return l2; // 2 MiB leaf
const l1 = tableAt(l2 & address_mask)[(virtual >> 12) & 0x1FF];
if (l1 & present == 0) return null;
return l1;
}
/// Is the 4 KiB leaf mapping `virtual` write-combining — the PAT bit set with PCD and
/// PWT clear, which `setupPat` makes PAT entry 4 (WC)? Null if unmapped. The device
/// mapping path (`mapUserDeviceInto`) always uses 4 KiB leaves, so bit 7 (`pte_pat`)
/// is the PAT selector in play.
pub fn leafIsWriteCombining(pml4: u64, virtual: u64) ?bool {
const e = leafEntryOf(pml4, virtual) orelse return null;
return (e & pte_pat != 0) and (e & pcd == 0) and (e & pwt == 0);
}
/// Create a new address space: a fresh PML4 with an empty user half and the /// Create a new address space: a fresh PML4 with an empty user half and the
/// kernel's higher half shared in (copying PML4[256..512), whose entries point /// kernel's higher half shared in (copying PML4[256..512), whose entries point
/// at the kernel's PDPTs — pre-created at init and never restaled, so growth in /// at the kernel's PDPTs — pre-created at init and never restaled, so growth in
@@ -338,8 +435,8 @@ fn freeSubtree(physical: u64, level: u32) void {
} }
/// Whether `virtual` is currently mapped **executable** — present with the NX bit /// Whether `virtual` is currently mapped **executable** — present with the NX bit
/// clear. Walks the 4-level tables (all danos mappings are 4 KiB, so no huge-page /// clear. Walks the 4-level tables, stopping at a 2 MiB huge-page leaf (the physmap
/// case). Returns false if unmapped. Used for W^X checks in tests. /// uses them). Returns false if unmapped. Used for W^X checks in tests.
pub fn isExecutable(virtual: u64) bool { pub fn isExecutable(virtual: u64) bool {
const pml4e = tableAt(kernel_pml4)[(virtual >> 39) & 0x1FF]; const pml4e = tableAt(kernel_pml4)[(virtual >> 39) & 0x1FF];
if (pml4e & present == 0) return false; if (pml4e & present == 0) return false;
@@ -347,6 +444,7 @@ pub fn isExecutable(virtual: u64) bool {
if (pdpte & present == 0) return false; if (pdpte & present == 0) return false;
const pde = tableAt(pdpte & address_mask)[(virtual >> 21) & 0x1FF]; const pde = tableAt(pdpte & address_mask)[(virtual >> 21) & 0x1FF];
if (pde & present == 0) return false; if (pde & present == 0) return false;
if (pde & page_size_bit != 0) return pde & no_execute == 0; // 2 MiB huge leaf
const pte = tableAt(pde & address_mask)[(virtual >> 12) & 0x1FF]; const pte = tableAt(pde & address_mask)[(virtual >> 12) & 0x1FF];
if (pte & present == 0) return false; if (pte & present == 0) return false;
return pte & no_execute == 0; return pte & no_execute == 0;
@@ -385,9 +483,9 @@ pub fn unmapInto(pml4: u64, virtual: u64) void {
/// Resolve a virtual address to a physical one in the address space rooted at /// Resolve a virtual address to a physical one in the address space rooted at
/// `pml4`, walking the tables through the physmap (CR3-independent — works for /// `pml4`, walking the tables through the physmap (CR3-independent — works for
/// any address space, not just the live one). Returns null if `virtual` is not /// any address space, not just the live one). Returns null if `virtual` is not
/// mapped at any level. All danos mappings are 4 KiB, so there is no huge-page /// mapped at any level. Stops at a 2 MiB huge-page leaf (the physmap uses them),
/// case. The foundation for cross-address-space copies and for munmap (which /// resolving the offset within it. The foundation for cross-address-space copies
/// needs the frame behind a user vaddr to free it). /// and for munmap (which needs the frame behind a user vaddr to free it).
pub fn translateIn(pml4: u64, virtual: u64) ?u64 { pub fn translateIn(pml4: u64, virtual: u64) ?u64 {
const pml4e = tableAt(pml4)[(virtual >> 39) & 0x1FF]; const pml4e = tableAt(pml4)[(virtual >> 39) & 0x1FF];
if (pml4e & present == 0) return null; if (pml4e & present == 0) return null;
@@ -395,6 +493,8 @@ pub fn translateIn(pml4: u64, virtual: u64) ?u64 {
if (pdpte & present == 0) return null; if (pdpte & present == 0) return null;
const pde = tableAt(pdpte & address_mask)[(virtual >> 21) & 0x1FF]; const pde = tableAt(pdpte & address_mask)[(virtual >> 21) & 0x1FF];
if (pde & present == 0) return null; if (pde & present == 0) return null;
if (pde & page_size_bit != 0) // 2 MiB huge leaf: frame base is bits 51:21
return (pde & address_mask & ~@as(u64, huge_page_size - 1)) | (virtual & (huge_page_size - 1));
const pte = tableAt(pde & address_mask)[(virtual >> 12) & 0x1FF]; const pte = tableAt(pde & address_mask)[(virtual >> 12) & 0x1FF];
if (pte & present == 0) return null; if (pte & present == 0) return null;
return (pte & address_mask) | (virtual & (page_size - 1)); return (pte & address_mask) | (virtual & (page_size - 1));
+42 -4
View File
@@ -17,6 +17,12 @@ const Access = enum { port, mmio };
var access: Access = .port; var access: Access = .port;
var base: u64 = 0x3F8; // COM1 var base: u64 = 0x3F8; // COM1
/// Whether `init`/`reconfigure` found a *working* UART at `base`. False on a
/// legacy-free machine whose COM1 is decoded but dead: writing to it is then a
/// no-op, so `write` never spins waiting for a transmit register that will never
/// drain. Cleared until proven by the loopback probe.
var uart_present: bool = false;
fn portOut(p: u16, value: u8) void { fn portOut(p: u16, value: u8) void {
asm volatile ("outb %[value], %[p]" asm volatile ("outb %[value], %[p]"
: :
@@ -57,6 +63,34 @@ pub fn init() void {
setRegister(3, 0x03); // 8 bits, no parity, one stop bit; DLAB off setRegister(3, 0x03); // 8 bits, no parity, one stop bit; DLAB off
setRegister(2, 0xC7); // enable + clear FIFO, 14-byte threshold setRegister(2, 0xC7); // enable + clear FIFO, 14-byte threshold
setRegister(4, 0x0B); // RTS/DSR set setRegister(4, 0x0B); // RTS/DSR set
uart_present = probe();
}
/// Detect a *working* UART by internal loopback: route the transmitter back to
/// the receiver (MCR bit 4), send a byte, and check it comes back. A port that is
/// merely decoded but has nothing behind it (the common case on a legacy-free
/// board that still answers I/O at 0x3F8) never echoes, so this returns false.
///
/// This matters for speed, not just correctness: a dead UART's line-status
/// register reads back 0x00, so its transmit-holding-empty bit never sets, and
/// `writeByte` would otherwise spin its full guard — tens of milliseconds — on
/// *every* logged byte. On real hardware that alone can add ~a minute to boot.
fn probe() bool {
const saved_mcr = register(4);
setRegister(4, 0x1E); // MCR: LOOP | OUT2 | OUT1 | RTS — internal loopback
setRegister(0, 0xAE); // push a distinctive byte into the loopback path
var guard: u32 = 0;
while (register(5) & 0x01 == 0 and guard < 10_000) : (guard += 1) {} // await Data Ready
const echo = register(0);
setRegister(4, saved_mcr); // restore the modem-control lines
return echo == 0xAE;
}
/// Whether a working UART was detected (see `probe`). The log sink stays
/// registered regardless — it simply does nothing until this is true — so a UART
/// that only `reconfigure` discovers (via SPCR) still starts logging.
pub fn present() bool {
return uart_present;
} }
/// Point the console at the UART ACPI's SPCR table names (MMIO or I/O port) and /// Point the console at the UART ACPI's SPCR table names (MMIO or I/O port) and
@@ -70,15 +104,19 @@ pub fn reconfigure(is_mmio: bool, address: u64) void {
} }
fn writeByte(c: u8) void { fn writeByte(c: u8) void {
// Wait for the transmit-holding register to empty — but bounded, so an absent // Wait for the transmit-holding register to empty. `write` only reaches here
// UART (whose line-status register reads back as 0x00) can't hang the kernel. // for a UART the loopback probe proved live, so this bounds a momentary stall
// (e.g. deasserted flow control), not an absent port: ~5000 legacy-port reads
// is a few ms — comfortably longer than one 38400-baud byte-time (~260 µs).
var guard: u32 = 0; var guard: u32 = 0;
while (register(5) & 0x20 == 0 and guard < 100_000) : (guard += 1) {} while (register(5) & 0x20 == 0 and guard < 5_000) : (guard += 1) {}
setRegister(0, c); setRegister(0, c);
} }
/// Write bytes, translating LF to CRLF so terminals and logs line up. /// Write bytes, translating LF to CRLF so terminals and logs line up. A no-op
/// when no working UART was detected, so a dead COM1 costs nothing per byte.
pub fn write(bytes: []const u8) void { pub fn write(bytes: []const u8) void {
if (!uart_present) return;
for (bytes) |c| { for (bytes) |c| {
if (c == '\n') writeByte('\r'); if (c == '\n') writeByte('\r');
writeByte(c); writeByte(c);
@@ -173,6 +173,7 @@ fn delayMicros(us: u64) void {
/// signals the BSP, then jumps to the generic scheduler entry. Never returns. /// signals the BSP, then jumps to the generic scheduler entry. Never returns.
fn apEntry(percpu: usize) callconv(.c) noreturn { fn apEntry(percpu: usize) callconv(.c) noreturn {
const cpu = boot_index; const cpu = boot_index;
paging.setupPat(); // this core's PAT: entry 4 = write-combining, to match the BSP
gdt.loadOnThisCpu(cpu); // this core's GDT (with its own TSS slot) gdt.loadOnThisCpu(cpu); // this core's GDT (with its own TSS slot)
tss.setupThisCpu(cpu); // this core's TSS + IST stack, loaded into TR tss.setupThisCpu(cpu); // this core's TSS + IST stack, loaded into TR
idt.loadOnThisCpu(); // the shared IDT idt.loadOnThisCpu(); // the shared IDT
+16 -3
View File
@@ -20,6 +20,12 @@ const boot_handoff = @import("boot-handoff");
var con: Console = undefined; var con: Console = undefined;
var con_present: bool = false; var con_present: bool = false;
/// Set while a user-space display service owns the framebuffer: `write` falls silent so
/// the kernel doesn't paint over the compositor. Driven by the display device's
/// claim/release (system/kernel/process.zig). The terminal panic/exception paths clear
/// it first (`setSuppressed(false)`) — a dying machine's message wins over any display.
var suppressed: bool = false;
/// Set up the console over `fb`, or mark it absent if there's no usable /// Set up the console over `fb`, or mark it absent if there's no usable
/// framebuffer. Clears the screen when present. /// framebuffer. Clears the screen when present.
pub fn init(fb: boot_handoff.Framebuffer) void { pub fn init(fb: boot_handoff.Framebuffer) void {
@@ -41,13 +47,20 @@ pub fn present() bool {
return con_present; return con_present;
} }
/// Output sink: draw `bytes` on screen. A no-op when no framebuffer is present, /// Output sink: draw `bytes` on screen. A no-op when no framebuffer is present, or
/// so it's always safe to call. /// while a display service owns the screen (`suppressed`), so it's always safe to call.
pub fn write(bytes: []const u8) void { pub fn write(bytes: []const u8) void {
if (!con_present) return; if (!con_present or suppressed) return;
for (bytes) |c| con.putChar(c); for (bytes) |c| con.putChar(c);
} }
/// Quiesce (or resume) the bootstrap console. Set true when a display service claims the
/// framebuffer; set false when that claim is released, or by the panic path to force a
/// last message onto a screen a (now-irrelevant) service was holding.
pub fn setSuppressed(value: bool) void {
suppressed = value;
}
/// The console font, embedded at compile time. cp850-8x16, PSF2 format: /// The console font, embedded at compile time. cp850-8x16, PSF2 format:
/// a 32-byte header, then 256 glyphs of 16 bytes each (one byte per 8-pixel /// a 32-byte header, then 256 glyphs of 16 bytes each (one byte per 8-pixel
/// row). We index glyphs straight by byte value, so ASCII maps 1:1. /// row). We index glyphs straight by byte value, so ASCII maps 1:1.
+50
View File
@@ -36,6 +36,11 @@ var devices: [maximum_devices]device_abi.DeviceDescriptor = undefined;
var claimed: [maximum_devices]?u32 = .{null} ** maximum_devices; // owner task id, or null var claimed: [maximum_devices]?u32 = .{null} ** maximum_devices; // owner task id, or null
var count: usize = 0; var count: usize = 0;
/// The id of the seeded framebuffer node (`seedDisplay`), or null when the machine
/// handed over no framebuffer. Lets the process layer recognise the display claim
/// (to quiesce the bootstrap console) without threading the id through every caller.
var display_device: ?u64 = null;
/// Devices discovery found but the table had no room for. Non-zero means the machine /// Devices discovery found but the table had no room for. Non-zero means the machine
/// is bigger than `maximum_devices` and some hardware is simply invisible to drivers — /// is bigger than `maximum_devices` and some hardware is simply invisible to drivers —
/// which would otherwise be an entirely silent failure. Logged at boot. /// which would otherwise be an entirely silent failure. Logged at boot.
@@ -45,10 +50,55 @@ pub var dropped: usize = 0;
pub fn init(device_tree: *const platform.DeviceTree) void { pub fn init(device_tree: *const platform.DeviceTree) void {
count = 0; count = 0;
dropped = 0; dropped = 0;
display_device = null;
for (&claimed) |*c| c.* = null; for (&claimed) |*c| c.* = null;
walk(device_tree.root, device_abi.no_parent); walk(device_tree.root, device_abi.no_parent);
} }
/// Publish the loader's framebuffer as a `display` device — a root-level node with one
/// write-combining `memory` resource over the linear framebuffer and its geometry in
/// `.display`. The framebuffer is *not* firmware-discovered (it rides the
/// [[boot-handoff]], not the device tree), so it is seeded explicitly, after `init`.
/// Returns the new device id, or null when there is no framebuffer (headless) or the
/// table is full. Idempotent-ish: only ever call once per boot.
pub fn seedDisplay(base: u64, width: u32, height: u32, pitch: u32, format: u32) ?u64 {
if (base == 0 or width == 0 or height == 0) return null; // headless
if (count >= maximum_devices) {
dropped += 1;
return null;
}
var d = std.mem.zeroes(device_abi.DeviceDescriptor);
d.id = count;
d.parent = device_abi.no_parent;
d.class = @intFromEnum(device_abi.DeviceClass.display);
d.pci_class = device_abi.no_pci_class;
d.resource_count = 1;
d.resources[0] = .{
.kind = @intFromEnum(device_abi.ResourceKind.memory),
.start = base,
.len = @as(u64, height) * pitch,
.flags = device_abi.resource_flag_write_combining,
};
d.display = .{ .width = width, .height = height, .pitch = pitch, .format = format };
devices[count] = d;
display_device = d.id;
count += 1;
return d.id;
}
/// The id of the seeded framebuffer device, or null when none was seeded.
pub fn displayDevice() ?u64 {
return display_device;
}
/// Whether the framebuffer device is currently claimed by some process. The bootstrap
/// console uses this (via the process layer) to fall silent while a display service
/// owns the screen, and to resume if that service dies and its claim is released.
pub fn displayClaimed() bool {
const id = display_device orelse return false;
return ownerOf(id) != null;
}
/// Record `node` (unless it's the synthetic root) and recurse, threading the id we /// Record `node` (unless it's the synthetic root) and recurse, threading the id we
/// assigned it down to its children as their parent. /// assigned it down to its children as their parent.
fn walk(node: *platform.Device, parent_id: u64) void { fn walk(node: *platform.Device, parent_id: u64) void {
+40 -14
View File
@@ -60,8 +60,16 @@ fn kmain(boot_information: *const BootInformation) noreturn {
// file on a ramdisk/USB/SSD), so a message survives as long as any is present. // file on a ramdisk/USB/SSD), so a message survives as long as any is present.
// A headless, serial-less machine still boots correctly — it just goes quiet, // A headless, serial-less machine still boots correctly — it just goes quiet,
// with port-0x80 checkpoints as the only progress signal. // with port-0x80 checkpoints as the only progress signal.
//
// Serial is compiled in only under -Dserial (build.zig): a real machine often
// has no live legacy COM1, and the log survives in the RAM buffer (below) and
// is flushed to disk — so serial is now a QEMU/dev convenience the flashable
// image leaves out. When it *is* built in, `serialInit`'s loopback probe still
// guards against a dead port (so a -Dserial image is safe on real hardware).
if (build_options.serial) {
architecture.serialInit(); architecture.serialInit();
log.addSink(architecture.serialWrite); log.addSink(architecture.serialWrite);
}
if (architecture.debugconPresent()) log.addSink(architecture.debugconWrite); if (architecture.debugconPresent()) log.addSink(architecture.debugconWrite);
// Retain the whole stream in a RAM buffer too, so a user program can later // Retain the whole stream in a RAM buffer too, so a user program can later
// read it back (klog_read) and persist the boot log to disk — the only way to // read it back (klog_read) and persist the boot log to disk — the only way to
@@ -72,8 +80,14 @@ fn kmain(boot_information: *const BootInformation) noreturn {
// surface — a bootstrap text console today, a graphics device driver later — so // surface — a bootstrap text console today, a graphics device driver later — so
// we never assume the OS is text-based. Only a few user-facing status lines // we never assume the OS is text-based. Only a few user-facing status lines
// (via `status`) and panics are mirrored to it; the verbose log stays out. // (via `status`) and panics are mirrored to it; the verbose log stays out.
//
// The console is brought up *after* paging (below), not here: its one-time
// full-screen clear then runs on the kernel's **write-combining** mapping of the
// framebuffer instead of the loader's uncached one — a fast burst rather than
// millions of uncached writes on real hardware. Until then, on-screen output is
// absent (an early panic still lands in the serial/RAM log); the trade is worth
// a near-instant boot. `console.write` is a safe no-op while the console is down.
const fb = boot_information.framebuffer; const fb = boot_information.framebuffer;
console.init(fb);
log.checkpoint(cp_entry); log.checkpoint(cp_entry);
@@ -83,10 +97,10 @@ fn kmain(boot_information: *const BootInformation) noreturn {
architecture.init(); architecture.init();
status("/system/kernel: initialising kernel...\n"); status("/system/kernel: initialising kernel...\n");
log.write(if (console.present()) if (build_options.serial) log.write(if (architecture.serialPresent())
"/system/kernel: framebuffer console online (bootstrap; graphics driver later)\n" "/system/kernel: serial console online (COM1)\n"
else else
"/system/kernel: no framebuffer (headless) -> logging to serial/debugcon only\n"); "/system/kernel: no serial UART (COM1 absent) -> log kept in RAM/debugcon\n");
log.write("/system/kernel: cpu tables online (GDT, IDT, TSS)\n"); log.write("/system/kernel: cpu tables online (GDT, IDT, TSS)\n");
log.print(" resolution : {d}x{d}\n", .{ fb.width, fb.height }); log.print(" resolution : {d}x{d}\n", .{ fb.width, fb.height });
log.print(" pitch : {d} bytes\n", .{fb.pitch}); log.print(" pitch : {d} bytes\n", .{fb.pitch});
@@ -142,6 +156,15 @@ fn kmain(boot_information: *const BootInformation) noreturn {
log.print(" page tables: root = 0x{x:0>16}\n", .{architecture.activePageTable()}); log.print(" page tables: root = 0x{x:0>16}\n", .{architecture.activePageTable()});
log.print(" kernel segs: {d} (mapped with W^X permissions)\n", .{boot_information.kernel_segment_count}); log.print(" kernel segs: {d} (mapped with W^X permissions)\n", .{boot_information.kernel_segment_count});
// Now on our own tables, the framebuffer window is write-combining: bring up
// the on-screen console and clear it (a fast burst here, not the loader's
// uncached crawl). From here `status` reaches the screen as well as the log.
console.init(fb);
log.write(if (console.present())
"/system/kernel: framebuffer console online (bootstrap; graphics driver later)\n"
else
"/system/kernel: no framebuffer (headless) -> logging to serial/debugcon only\n");
// Bring up the kernel heap (dynamic allocation), built on the VMM. // Bring up the kernel heap (dynamic allocation), built on the VMM.
heap.init(); heap.init();
log.checkpoint(cp_heap); log.checkpoint(cp_heap);
@@ -172,25 +195,24 @@ fn kmain(boot_information: *const BootInformation) noreturn {
log.print("/system/kernel: WARNING {d} device(s) dropped — table full\n", .{devices_broker.dropped}); log.print("/system/kernel: WARNING {d} device(s) dropped — table full\n", .{devices_broker.dropped});
} }
// Publish the loader's framebuffer as a claimable `display` device, so a
// user-space display service can take it over the same claim + mmio_map path as
// any other hardware (it is not firmware-discovered; it rides the boot handoff).
if (devices_broker.seedDisplay(fb.base, fb.width, fb.height, fb.pitch, @intFromEnum(fb.format))) |display_id| {
log.print("/system/kernel: framebuffer device {d} seeded ({d}x{d}, pitch {d}, write-combining)\n", .{ display_id, fb.width, fb.height, fb.pitch });
}
// Install the device-IRQ trampolines, so a driver's irq_bind has vectors to // Install the device-IRQ trampolines, so a driver's irq_bind has vectors to
// land on. Every line stays masked until something binds it (ioapic.init). // land on. Every line stays masked until something binds it (ioapic.init).
irq.init(); irq.init();
// Power register map extracted from the FADT + AML, for confidence it parsed. // Power register map, from the FADT (the SLP_TYP sleep values live in AML,
// which the kernel doesn't parse — the ring-3 acpi service owns soft-off).
const pw = platform.powerInformation(); const pw = platform.powerInformation();
log.write("/system/kernel: power\n"); log.write("/system/kernel: power\n");
log.print(" pm1a_cnt : {s} 0x{x} (width {d})\n", .{ if (pw.pm1a_cnt.mmio) "mmio" else "io", pw.pm1a_cnt.address, pw.pm1a_cnt.width }); log.print(" pm1a_cnt : {s} 0x{x} (width {d})\n", .{ if (pw.pm1a_cnt.mmio) "mmio" else "io", pw.pm1a_cnt.address, pw.pm1a_cnt.width });
if (pw.s5) |s| {
log.print(" S5 slp_typ : a={d} b={d}\n", .{ s.slp_typ_a, s.slp_typ_b });
} else {
log.write(" S5 slp_typ : (not found)\n");
}
log.print(" reset : supported={} {s} 0x{x} val 0x{x}\n", .{ pw.reset_supported, if (pw.reset.mmio) "mmio" else "io", pw.reset.address, pw.reset_value }); log.print(" reset : supported={} {s} 0x{x} val 0x{x}\n", .{ pw.reset_supported, if (pw.reset.mmio) "mmio" else "io", pw.reset.address, pw.reset_value });
// AML namespace parse integrity: consumed should equal total.
const am = platform.amlStats();
log.print(" aml : {d} namespace nodes, parsed {d}/{d} bytes\n", .{ am.nodes, am.consumed, am.total });
// Feed the architecture layer the discovered addresses/facts so it makes no legacy // Feed the architecture layer the discovered addresses/facts so it makes no legacy
// assumptions — the point of all this on UEFI Class 3 firmware. MMIO bases // assumptions — the point of all this on UEFI Class 3 firmware. MMIO bases
// (HPET, I/O APIC) come from the device tree; scalar facts from ACPI. // (HPET, I/O APIC) come from the device tree; scalar facts from ACPI.
@@ -464,6 +486,9 @@ fn onException(state: *const architecture.CpuState) noreturn {
} }
log.checkpoint(cp_exception); log.checkpoint(cp_exception);
// The machine is going down: force the console back on even if a display service
// was holding the framebuffer, so the exception actually reaches the screen.
console.setSuppressed(false);
const core = scheduler.currentCpuIndex(); const core = scheduler.currentCpuIndex();
// A fault is user-facing enough to paint on screen too (via statusPrint), on // A fault is user-facing enough to paint on screen too (via statusPrint), on
// top of the diagnostic log. // top of the diagnostic log.
@@ -486,6 +511,7 @@ pub const panic = std.debug.FullPanic(struct {
_ = first_trace_address; _ = first_trace_address;
log.checkpoint(cp_panic); log.checkpoint(cp_panic);
log.recordPanic(message); log.recordPanic(message);
console.setSuppressed(false); // a panic outranks any display service holding the screen
status("\nKERNEL PANIC: "); status("\nKERNEL PANIC: ");
status(message); status(message);
status("\n"); status("\n");
+40 -19
View File
@@ -28,6 +28,7 @@ const parameters = @import("parameters");
const architecture = @import("architecture"); const architecture = @import("architecture");
const pmm = @import("pmm.zig"); const pmm = @import("pmm.zig");
const scheduler = @import("scheduler.zig"); const scheduler = @import("scheduler.zig");
const console = @import("console.zig");
const sync = @import("sync.zig"); const sync = @import("sync.zig");
const ipc = @import("ipc-synchronous.zig"); const ipc = @import("ipc-synchronous.zig");
const devices_broker = @import("devices-broker.zig"); const devices_broker = @import("devices-broker.zig");
@@ -80,9 +81,11 @@ pub const device_arena_end: u64 = device_arena_base + (4 << 30);
pub const dma_arena_base: u64 = 0x0000_7200_0000_0000; pub const dma_arena_base: u64 = 0x0000_7200_0000_0000;
pub const dma_arena_end: u64 = dma_arena_base + (256 << 20); // 256 MiB per process pub const dma_arena_end: u64 = dma_arena_base + (256 << 20); // 256 MiB per process
/// Largest single `mmap` grant, in pages (1 MiB). The user heap grows in small /// Largest single `mmap` grant, in pages (32 MiB). Big enough for a display service's
/// chunks, so this bound is generous; it also caps the frame scratch array below. /// back buffer at up to 4K (3840x2160x4 ≈ 8100 pages); the user heap otherwise grows in
const maximum_mmap_pages = 256; /// small chunks. `systemMmap` maps page by page with rollback, so this is only a sanity
/// bound (and an overflow guard on the page count), not the size of any scratch array.
const maximum_mmap_pages = 8192;
/// Ceiling on a process's argv entries, including argv[0]. Arguments are spawn /// Ceiling on a process's argv entries, including argv[0]. Arguments are spawn
/// parameters ("you are the driver for device 12"), not bulk data — IPC carries /// parameters ("you are the driver for device 12"), not bulk data — IPC carries
@@ -301,12 +304,19 @@ fn systemDeviceEnumerate(state: *architecture.CpuState) void {
/// device_claim(id) -> 0/-1: take exclusive ownership of a device for this process. /// device_claim(id) -> 0/-1: take exclusive ownership of a device for this process.
fn systemDeviceClaim(state: *architecture.CpuState) void { fn systemDeviceClaim(state: *architecture.CpuState) void {
const device_id = architecture.systemCallArg(state, 0);
const claim_flags = sync.enter(); const claim_flags = sync.enter();
defer sync.leave(claim_flags); defer sync.leave(claim_flags);
if (devices_broker.claim(architecture.systemCallArg(state, 0), scheduler.current().id)) if (devices_broker.claim(device_id, scheduler.current().id)) {
architecture.setSystemCallResult(state, 0) // A display service just took the framebuffer — quiesce the bootstrap console
else // so the kernel and the service don't scribble over each other's pixels. The
fail(state); // claim releases (and the console resumes) automatically if the service dies;
// see releaseTaskResourcesLocked.
if (devices_broker.displayDevice()) |display_id| {
if (device_id == display_id) console.setSuppressed(true);
}
architecture.setSystemCallResult(state, 0);
} else fail(state);
} }
/// mmio_map(device_id, resource_index) -> vaddr: map a claimed device's MMIO window into /// mmio_map(device_id, resource_index) -> vaddr: map a claimed device's MMIO window into
@@ -341,7 +351,10 @@ fn systemMmioMap(state: *architecture.CpuState) void {
const base_v = t.device_map_next; const base_v = t.device_map_next;
if (base_v + pages * page_size > device_arena_end) return fail(state); if (base_v + pages * page_size > device_arena_end) return fail(state);
architecture.mapUserDeviceInto(t.aspace, base_v, r.start, r.len); // A framebuffer resource asks (via its flag) to be mapped write-combining rather
// than the strong-uncacheable default that register MMIO needs.
const write_combining = (r.flags & device_abi.resource_flag_write_combining) != 0;
architecture.mapUserDeviceInto(t.aspace, base_v, r.start, r.len, write_combining);
t.device_map_next = base_v + pages * page_size; t.device_map_next = base_v + pages * page_size;
architecture.setSystemCallResult(state, base_v + (r.start & (page_size - 1))); // register base architecture.setSystemCallResult(state, base_v + (r.start & (page_size - 1))); // register base
} }
@@ -601,6 +614,9 @@ fn releaseTaskResourcesLocked(t: *scheduler.Task) void {
recordExitLocked(t); recordExitLocked(t);
irq.releaseOwner(t.id); irq.releaseOwner(t.id);
devices_broker.releaseAllOwnedBy(t.id); devices_broker.releaseAllOwnedBy(t.id);
// If that dropped the framebuffer claim (this task was the display service), let the
// bootstrap console draw again — the screen is nobody's now, so panics/status land.
if (!devices_broker.displayClaimed()) console.setSuppressed(false);
// The dying task's signal endpoint and one-shot timers go with it. // The dying task's signal endpoint and one-shot timers go with it.
if (t.signal_endpoint) |raw| { if (t.signal_endpoint) |raw| {
ipc.dropRef(@ptrCast(@alignCast(raw))); ipc.dropRef(@ptrCast(@alignCast(raw)));
@@ -1024,21 +1040,26 @@ fn systemMmap(state: *architecture.CpuState) void {
const base = t.heap_next; const base = t.heap_next;
if (base + pages * page_size > heap_arena_end) return fail(state); // arena exhausted if (base + pages * page_size > heap_arena_end) return fail(state); // arena exhausted
// Reserve all frames up front so a mid-way exhaustion rolls back cleanly // Map page by page. On mid-way frame exhaustion, roll back the pages already mapped
// (no partially-mapped grant leaks into the address space). // (unmap + free) so no partial grant leaks into the address space — the same
var frames: [maximum_mmap_pages]u64 = undefined; // all-or-nothing guarantee as before, but without a fixed scratch array, so the
var got: usize = 0; // per-call size can be a multi-MiB framebuffer.
while (got < pages) : (got += 1) { var mapped: usize = 0;
frames[got] = pmm.alloc() orelse { while (mapped < pages) : (mapped += 1) {
for (frames[0..got]) |f| pmm.free(f); const frame = pmm.alloc() orelse {
var i: usize = 0;
while (i < mapped) : (i += 1) {
const va = base + i * page_size;
if (architecture.translate(t.aspace, va)) |physical| {
architecture.unmapUserPageInto(t.aspace, va);
pmm.free(physical);
}
}
return fail(state); return fail(state);
}; };
}
for (frames[0..pages], 0..) |frame, i| {
const destination: [*]u8 = @ptrFromInt(boot_handoff.physicalToVirtual(frame)); const destination: [*]u8 = @ptrFromInt(boot_handoff.physicalToVirtual(frame));
@memset(destination[0..page_size], 0); // hand out zeroed memory @memset(destination[0..page_size], 0); // hand out zeroed memory
architecture.mapUserPageInto(t.aspace, base + i * page_size, frame, true, false); // RW + NX architecture.mapUserPageInto(t.aspace, base + mapped * page_size, frame, true, false); // RW + NX
} }
t.heap_next = base + pages * page_size; t.heap_next = base + pages * page_size;
architecture.setSystemCallResult(state, base); architecture.setSystemCallResult(state, base);
+166 -36
View File
@@ -95,6 +95,12 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void {
iommuTest(); iommuTest();
} else if (eql(case, "ioport")) { } else if (eql(case, "ioport")) {
ioPortTest(); ioPortTest();
} else if (eql(case, "display")) {
displayTest(boot_information);
} else if (eql(case, "display-service")) {
displayServiceTest(boot_information);
} else if (eql(case, "display-demo")) {
displayDemoTest(boot_information);
} else if (eql(case, "clock")) { } else if (eql(case, "clock")) {
clockTest(); clockTest();
} else if (eql(case, "smp")) { } else if (eql(case, "smp")) {
@@ -181,10 +187,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void {
containmentTest(); containmentTest();
} else if (eql(case, "device-manager")) { } else if (eql(case, "device-manager")) {
deviceManagerTest(boot_information); deviceManagerTest(boot_information);
} else if (eql(case, "poweroff")) {
powerTest(.off);
} else if (eql(case, "reboot")) { } else if (eql(case, "reboot")) {
powerTest(.reboot); rebootTest();
} else { } else {
log("DANOS-TEST-RESULT: FAIL (unknown case '{s}')\n", .{case}); log("DANOS-TEST-RESULT: FAIL (unknown case '{s}')\n", .{case});
} }
@@ -201,15 +205,14 @@ fn platformHal() platform.Hal {
/// Drive an ACPI power transition. On success the machine powers off or resets, /// Drive an ACPI power transition. On success the machine powers off or resets,
/// so QEMU exits — the harness observes the process exit. If control returns, the /// so QEMU exits — the harness observes the process exit. If control returns, the
/// transition failed and we emit a FAIL result. /// transition failed and we emit a FAIL result.
fn powerTest(comptime action: enum { off, reboot }) void { // Soft-off (S5) is no longer a kernel operation — the ring-3 acpi service owns it
const name = if (action == .off) "poweroff" else "reboot"; // (exercised end-to-end by `orderly-shutdown`). Reboot stays in the kernel (FADT
log("DANOS-TEST-BEGIN: {s}\n", .{name}); // reset register, no AML), so it keeps its own case.
fn rebootTest() void {
log("DANOS-TEST-BEGIN: reboot\n", .{});
const hal = platformHal(); const hal = platformHal();
log("DANOS-POWER: attempting {s}\n", .{name}); log("DANOS-POWER: attempting reboot\n", .{});
switch (action) { platform.reboot(hal);
.off => platform.shutdown(hal),
.reboot => platform.reboot(hal),
}
check("power transition took effect", false); check("power transition took effect", false);
result(); result();
} }
@@ -276,22 +279,19 @@ fn timer() void {
} }
/// Verify device discovery populated the platform facts the rest of the kernel /// Verify device discovery populated the platform facts the rest of the kernel
/// depends on — the results ACPI parsing stashed in globals at boot. These are /// depends on — the results the static ACPI tables stashed in globals at boot.
/// stable for the QEMU q35 + OVMF machine the harness runs, and span the tables: /// These are stable for the QEMU q35 + OVMF machine the harness runs, and span the
/// MADT (LAPIC base, CPU count), FADT (PM/reset registers), and the AML parse /// tables: MADT (LAPIC base, CPU count) and FADT (PM/reset registers). The kernel
/// (the sleep type, plus the integrity check that every byte was consumed). /// no longer interprets AML — sleep types are the ring-3 acpi service's concern.
fn discoveryTest() void { fn discoveryTest() void {
log("DANOS-TEST-BEGIN: discovery\n", .{}); log("DANOS-TEST-BEGIN: discovery\n", .{});
const pinfo = platform.platformInformation(); const pinfo = platform.platformInformation();
const pw = platform.powerInformation(); const pw = platform.powerInformation();
const am = platform.amlStats();
check("LAPIC base discovered (MADT)", pinfo.lapic_base == 0xFEE00000); check("LAPIC base discovered (MADT)", pinfo.lapic_base == 0xFEE00000);
check("ACPI PM timer found (FADT)", pinfo.pm_timer.present()); check("ACPI PM timer found (FADT)", pinfo.pm_timer.present());
check("PM1a control register found (FADT)", pw.pm1a_cnt.present()); check("PM1a control register found (FADT)", pw.pm1a_cnt.present());
check("reset register supported (FADT)", pw.reset_supported); check("reset register supported (FADT)", pw.reset_supported);
check("S5 sleep type found (AML)", pw.s5 != null);
check("AML parsed completely (consumed == total)", am.total > 0 and am.consumed == am.total);
check("at least one CPU enumerated (MADT)", platform.cpus().len >= 1); check("at least one CPU enumerated (MADT)", platform.cpus().len >= 1);
// M15: every PCI function now carries its own 4 KiB ECAM configuration space as // M15: every PCI function now carries its own 4 KiB ECAM configuration space as
@@ -2095,11 +2095,11 @@ fn acpiReportTest(boot_information: *const BootInformation) void {
result(); result();
} }
/// M20.1: the ring-3 AML parse agrees with the kernel's. The manager spawns /// M20.1: the ring-3 AML parse works. The manager spawns the discovery service
/// the discovery service (the acpi build variant); it claims the acpi-tables /// (the acpi build variant); it claims the acpi-tables node, maps the blobs,
/// node, maps the blobs, parses them, and logs its Device count — which must /// parses them, and self-verifies it found at least a floor of Device objects,
/// equal what the kernel's own parse produced (the equivalence that licenses /// printing "acpi-parse: ok". The kernel no longer parses AML, so there is no
/// retiring the kernel's device build in M20.3). /// kernel count to compare against — the ring-3 parse is now the only one.
fn acpiParseTest(boot_information: *const BootInformation) void { fn acpiParseTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: acpi-parse\n", .{}); log("DANOS-TEST-BEGIN: acpi-parse\n", .{});
if (boot_information.initial_ramdisk_len == 0) { if (boot_information.initial_ramdisk_len == 0) {
@@ -2114,23 +2114,18 @@ fn acpiParseTest(boot_information: *const BootInformation) void {
return; return;
}; };
// The kernel's own count, from the namespace it already built for \_S5. // Spawn the discovery service directly with a device-count *floor* as argv:
const kernel_devices = platform.amlDeviceCount(); // it parses the blobs in ring 3 and self-verifies it found at least that many
check("the kernel namespace has devices to compare against", kernel_devices >= 1); // Device objects, printing "acpi-parse: ok". A floor of 1 just proves the
// parser ran and produced a namespace (the QEMU q35 DSDT has dozens). The
// Spawn the discovery service directly with that count as argv: it parses // marker is deterministic — no racing the shared serial buffer.
// the same blobs in ring 3 and self-verifies, printing "acpi-parse: ok" iff
// the counts match. The harness's expect regex is that marker — deterministic,
// no racing the shared serial buffer.
process.setInitialRamdisk(image); process.setInitialRamdisk(image);
var count_text: [16]u8 = undefined;
const count_arg = std.fmt.bufPrint(&count_text, "{d}", .{kernel_devices}) catch "0";
var spawned = false; var spawned = false;
var i: u32 = 0; var i: u32 = 0;
while (i < rd.count) : (i += 1) { while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue; const item = rd.entry(i) orelse continue;
if (!eql(item.name, "discovery")) continue; if (!eql(item.name, "discovery")) continue;
_ = process.spawnProcessSupervised(item.blob, 4, &.{ "discovery", count_arg }, scheduler.currentId(), null) catch 0; _ = process.spawnProcessSupervised(item.blob, 4, &.{ "discovery", "1" }, scheduler.currentId(), null) catch 0;
spawned = true; spawned = true;
break; break;
} }
@@ -2316,6 +2311,72 @@ fn inputTest(boot_information: *const BootInformation) void {
result(); result();
} }
/// D2 — the display service comes up. Spawn it from the initial_ramdisk; it claims the
/// framebuffer the kernel seeded (D1), maps it write-combining, allocates a cacheable
/// back buffer, and proves the double-buffer path by clearing that buffer and presenting
/// it. Its `display: online WxH` + `display: presented frame 0` heartbeats are the
/// markers — seeing them proves a user-space compositor took the framebuffer and pushed
/// a whole composed frame to the screen, without ever drawing straight to the LFB.
fn displayServiceTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: display-service\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
// Spawn the compositor and hand it the core. Its own serial heartbeats — `display:
// online WxH` and `display: presented frame 0` — are what the harness matches (it
// reads serial directly, like the fault cases). We don't poll for them in-kernel: a
// single service that comes up and blocks doesn't reschedule this bring-up context
// (there is no other runnable task to bounce control back through), so the honest
// observation point is the service's output itself, not a check() proxy here.
if (!spawnNamed(rd, "display")) {
log("display-service: could not spawn the display service\n", .{});
result();
return;
}
scheduler.setPriority(1); // below the service, so it runs and comes up first
while (true) scheduler.yield();
}
/// D4 — a separate process drives the compositor. Spawn the display service and the
/// hardware-free `display-demo` client, which creates a wallpaper, a moving rectangle,
/// and a cursor and presents a run of frames. Its `display-demo: ok` heartbeat — printed
/// only after it drove frames of motion through the layer client API and the compositor —
/// is the harness's marker (the visible motion itself is a screenshot away via run-x86-64).
/// The demo keeps presenting, so unlike a lone blocking service the scheduler stays busy;
/// we still match on serial rather than poll, for consistency.
fn displayDemoTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: display-demo\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
if (!spawnNamed(rd, "display")) {
log("display-demo: could not spawn the display service\n", .{});
result();
return;
}
_ = spawnNamed(rd, "display-demo");
scheduler.setPriority(1); // below the service + demo, so they run
while (true) scheduler.yield();
}
/// Process arguments, end to end: spawn args-echo bare (its argv[0] is the /// Process arguments, end to end: spawn args-echo bare (its argv[0] is the
/// initial-ramdisk name). Instance 1 sees argc == 1 and respawns itself through /// initial-ramdisk name). Instance 1 sees argc == 1 and respawns itself through
/// `system_spawn` with the extra arguments "alpha beta-42" — the syscall argument /// `system_spawn` with the extra arguments "alpha beta-42" — the syscall argument
@@ -2638,8 +2699,8 @@ fn ioPassTest() void {
result(); result();
return; return;
}; };
// Map it the way mmio_map does (device grant), then tear the space down. // Map it the way mmio_map does (device grant, strong-uncacheable), then tear the space down.
architecture.mapUserDeviceInto(aspace, process.device_arena_base, frame, abi.page_size); architecture.mapUserDeviceInto(aspace, process.device_arena_base, frame, abi.page_size, false);
architecture.destroyAddressSpace(aspace); architecture.destroyAddressSpace(aspace);
// The page tables were reclaimed; the device-granted frame must not have been. // The page tables were reclaimed; the device-granted frame must not have been.
@@ -2649,6 +2710,75 @@ fn ioPassTest() void {
result(); result();
} }
/// D1 — the framebuffer handoff primitive. The kernel seeds the loader's framebuffer as
/// a claimable `display` device with a write-combining `memory` resource; a display
/// service reaches it over the ordinary claim + mmio_map path. Prove the whole chain:
/// the node is present and correctly shaped, it maps, and — the point of D1 — the
/// mapping is genuinely write-combining, not the strong-uncacheable default that would
/// make a framebuffer blit glacial.
fn displayTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: display\n", .{});
const fb = boot_information.framebuffer;
if (!fb.present()) {
// Headless: nothing to seed. Not a failure of the mechanism, so pass cleanly.
log("display: no framebuffer (headless); skipping\n", .{});
result();
return;
}
// The kernel seeded a display device in kmain, right after devices_broker.init.
const display_id = devices_broker.displayDevice() orelse {
check("a framebuffer display device was seeded", false);
result();
return;
};
var buffer: [64]device_abi.DeviceDescriptor = undefined;
const n = @min(devices_broker.enumerate(&buffer), buffer.len);
check("the seeded display id is enumerable", display_id < n);
if (display_id >= n) {
result();
return;
}
const d = buffer[@intCast(display_id)];
check("the node is class display", d.class == @intFromEnum(device_abi.DeviceClass.display));
check("it carries the framebuffer geometry", d.display.width == fb.width and d.display.height == fb.height and d.display.pitch == fb.pitch);
check("it has exactly one resource", d.resource_count == 1);
const r = d.resources[0];
check("that resource is a memory window", r.kind == @intFromEnum(device_abi.ResourceKind.memory));
check("it spans the whole framebuffer", r.start == fb.base and r.len == @as(u64, fb.height) * fb.pitch);
check("it is flagged write-combining", (r.flags & device_abi.resource_flag_write_combining) != 0);
// Walk the real claim + map path a display service would, into a throwaway address
// space, and confirm the leaf's cache type. We never run this space (no CR3 load) —
// we only read back the page-table entries — so aliasing the same physical page at
// two cache types below is inert.
const aspace = architecture.createAddressSpace() orelse {
check("created a fresh address space", false);
result();
return;
};
defer architecture.destroyAddressSpace(aspace);
const page_base = fb.base & ~@as(u64, abi.page_size - 1);
architecture.mapUserDeviceInto(aspace, process.device_arena_base, page_base, abi.page_size, true);
check(
"the framebuffer maps write-combining (PAT entry 4: PAT bit set, PCD/PWT clear)",
architecture.userLeafIsWriteCombining(aspace, process.device_arena_base) == true,
);
// Regression guard: the strong-uncacheable default is still that, so WC is a real
// choice the flag makes, not the only behaviour.
architecture.mapUserDeviceInto(aspace, process.device_arena_base + abi.page_size, page_base, abi.page_size, false);
check(
"a register window still maps strong-uncacheable",
architecture.userLeafIsWriteCombining(aspace, process.device_arena_base + abi.page_size) == false,
);
log("display: mapped {d}x{d} pitch {d} (write-combining)\n", .{ fb.width, fb.height, fb.pitch });
result();
}
fn faultInvalidOpcode() void { fn faultInvalidOpcode() void {
log("DANOS-TEST-BEGIN: fault-ud\n", .{}); log("DANOS-TEST-BEGIN: fault-ud\n", .{});
asm volatile ("ud2"); asm volatile ("ud2");
+8 -6
View File
@@ -103,9 +103,11 @@ fn findTablesNode(buffer: []device.DeviceDescriptor) ?device.DeviceDescriptor {
} }
pub fn main(init: runtime.process.Init) void { pub fn main(init: runtime.process.Init) void {
// When the acpi-parse scenario spawns this directly, argv[1] is the kernel's // When the acpi-parse scenario spawns this directly, argv[1] is a device-count
// own device count to self-verify against — deterministic, no log-scraping. // *floor* to self-verify against. The kernel no longer parses AML, so there is
const expected: ?usize = if (init.arguments.get(1)) |a| (std.fmt.parseInt(usize, a, 10) catch null) else null; // no exact count to match — proving the ring-3 parse found at least a floor of
// devices is the check. Deterministic, no log-scraping.
const floor: ?usize = if (init.arguments.get(1)) |a| (std.fmt.parseInt(usize, a, 10) catch null) else null;
const buffer = runtime.allocator().alloc(device.DeviceDescriptor, 64) catch { const buffer = runtime.allocator().alloc(device.DeviceDescriptor, 64) catch {
_ = runtime.system.write("/system/services/acpi: out of memory\n"); _ = runtime.system.write("/system/services/acpi: out of memory\n");
@@ -162,11 +164,11 @@ pub fn main(init: runtime.process.Init) void {
var namespace = result.namespace; var namespace = result.namespace;
const devices = aml.deviceCount(&namespace); const devices = aml.deviceCount(&namespace);
writeLine("/system/services/acpi: parsed {d} AML blob(s), {d} namespace devices\n", .{ block_count, devices }); writeLine("/system/services/acpi: parsed {d} AML blob(s), {d} namespace devices\n", .{ block_count, devices });
if (expected) |want| { if (floor) |minimum| {
if (devices == want) { if (devices >= minimum) {
_ = runtime.system.write("acpi-parse: ok\n"); _ = runtime.system.write("acpi-parse: ok\n");
} else { } else {
writeLine("acpi-parse: mismatch (ring-3 {d} vs kernel {d})\n", .{ devices, want }); writeLine("acpi-parse: too few (ring-3 {d} < floor {d})\n", .{ devices, minimum });
} }
// Self-verify mode is standalone (no manager); stop before reporting. // Self-verify mode is standalone (no manager); stop before reporting.
while (true) runtime.system.sleep(1000); while (true) runtime.system.sleep(1000);
+4
View File
@@ -16,6 +16,10 @@ pub const Operation = enum(u32) {
read = 1, read = 1,
/// write(lba, count, physical): write `count` blocks at `lba` from the buffer /// write(lba, count, physical): write `count` blocks at `lba` from the buffer
write = 2, write = 2,
/// flush(): commit any device write cache to stable media (no data transfer).
/// 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,
}; };
pub const Request = extern struct { pub const Request = extern struct {
@@ -0,0 +1,66 @@
//! system/services/display-demo — a hardware-free client of the display service, the
//! `input-source` analog for the compositor. It creates a wallpaper, a rectangle it moves
//! each frame, and a small cursor, then drives the compositor in a present loop — proof
//! that a *separate process* can compose a moving scene through the display service over
//! IPC, exercising the layer client API and damage-driven present end to end
//! (docs/display.md). It logs `display-demo: ok` once it has driven a run of frames.
const runtime = @import("runtime");
const display = runtime.display;
const system = runtime.system;
const time = runtime.time;
pub fn main() void {
const mode = display.info() orelse {
_ = system.write("display-demo: no display service\n");
return;
};
// A full-screen wallpaper under everything.
const wallpaper = display.createLayer(0, 0, mode.width, mode.height, 0) orelse return createFailed();
_ = wallpaper.fill(0, 0, mode.width, mode.height, display.color(0x10, 0x18, 0x28));
// A rectangle that slides back and forth.
const box_w: u32 = 140;
const box_h: u32 = 100;
const box_y: i32 = 200;
const box = display.createLayer(0, box_y, box_w, box_h, 1) orelse return createFailed();
_ = box.fill(0, 0, box_w, box_h, display.color(0xE0, 0x60, 0x40));
// A little cursor on top.
const cursor = display.createLayer(40, 40, 12, 12, 2) orelse return createFailed();
_ = cursor.fill(0, 0, 12, 12, display.color(0xF0, 0xF0, 0xF0));
_ = display.present();
_ = system.write("display-demo: scene up; animating\n");
const span: i32 = @as(i32, @intCast(mode.width)) - @as(i32, @intCast(box_w));
var x: i32 = 0;
var dx: i32 = 8;
var frame: u32 = 0;
while (true) : (frame += 1) {
x += dx;
if (x <= 0) {
x = 0;
dx = -dx;
} else if (x >= span) {
x = span;
dx = -dx;
}
_ = box.configure(x, box_y, 1, true); // move it; the compositor repaints old + new
_ = display.present();
// A run of frames drawn through the compositor is the automated proof (the visible
// motion is a screenshot away via `zig build run-x86-64`).
if (frame == 20) _ = system.write("display-demo: ok\n");
time.sleep(time.Duration.fromMillis(30));
}
}
fn createFailed() void {
_ = system.write("display-demo: create failed\n");
}
pub const panic = runtime.panic;
comptime {
_ = &runtime.start._start;
}
+192
View File
@@ -0,0 +1,192 @@
//! The compositor's pure core: rectangle math and the three blitting primitives the
//! display service composes frames from — fill a rectangle of a surface, composite one
//! surface onto another clipped to a damage rectangle, and copy a client-supplied pixel
//! tile in. Deliberately free of any syscall or `runtime` dependency (it takes plain
//! pixel pointers), so it is host-tested under `zig build test`. The service
//! (system/services/display/display.zig) wires real mmap'd surfaces and the framebuffer
//! to it. Pixels are opaque native 32-bit values — v1 layers don't alpha-blend, and
//! channel order (rgbx/bgrx) is the caller's concern (see protocol.pack).
const std = @import("std");
/// An axis-aligned rectangle in pixels. Signed, so a surface partly off-screen (a layer
/// dragged past an edge) clips with plain arithmetic. Half-open: covers [x, x+w) × [y, y+h).
pub const Rect = struct {
x: i32,
y: i32,
w: i32,
h: i32,
pub const empty = Rect{ .x = 0, .y = 0, .w = 0, .h = 0 };
pub fn init(x: i32, y: i32, w: i32, h: i32) Rect {
return .{ .x = x, .y = y, .w = w, .h = h };
}
pub fn isEmpty(r: Rect) bool {
return r.w <= 0 or r.h <= 0;
}
pub fn right(r: Rect) i32 {
return r.x + r.w;
}
pub fn bottom(r: Rect) i32 {
return r.y + r.h;
}
/// The overlap of two rectangles, or an empty rectangle if they don't touch.
pub fn intersect(a: Rect, b: Rect) Rect {
const x0 = @max(a.x, b.x);
const y0 = @max(a.y, b.y);
const x1 = @min(a.right(), b.right());
const y1 = @min(a.bottom(), b.bottom());
return .{ .x = x0, .y = y0, .w = x1 - x0, .h = y1 - y0 };
}
/// The bounding box of two rectangles. An empty operand contributes nothing (returns
/// the other), so folding damage rectangles with `unite` from `empty` yields their
/// bounding box.
pub fn unite(a: Rect, b: Rect) Rect {
if (a.isEmpty()) return b;
if (b.isEmpty()) return a;
const x0 = @min(a.x, b.x);
const y0 = @min(a.y, b.y);
const x1 = @max(a.right(), b.right());
const y1 = @max(a.bottom(), b.bottom());
return .{ .x = x0, .y = y0, .w = x1 - x0, .h = y1 - y0 };
}
};
/// A block of 32-bit pixels: `pixels` addressed row-major with `stride` pixels between
/// row starts (≥ width — the framebuffer's stride is pitch/4, a layer's is its width).
pub const Surface = struct {
pixels: [*]u32,
stride: u32, // pixels per row
width: u32,
height: u32,
pub fn bounds(s: Surface) Rect {
return .{ .x = 0, .y = 0, .w = @intCast(s.width), .h = @intCast(s.height) };
}
inline fn row(s: Surface, y: u32) [*]u32 {
return s.pixels + @as(usize, y) * s.stride;
}
};
/// Fill `rect` of `s` with the native pixel `colour`, clipped to `s`'s bounds.
pub fn fillRect(s: Surface, rect: Rect, colour: u32) void {
const c = rect.intersect(s.bounds());
if (c.isEmpty()) return;
var y: i32 = c.y;
while (y < c.bottom()) : (y += 1) {
const r = s.row(@intCast(y));
var x: i32 = c.x;
while (x < c.right()) : (x += 1) r[@intCast(x)] = colour;
}
}
/// Composite the whole of `layer` onto `dst` with the layer's top-left at (`dx`, `dy`),
/// painting only the pixels that fall inside `clip` (a `dst`-space rectangle) and inside
/// `dst`. Opaque copy. This is the primitive `present` repeats over the visible layer
/// stack, bottom to top, for each damaged region.
pub fn composite(dst: Surface, dx: i32, dy: i32, layer: Surface, clip: Rect) void {
const on_screen = Rect{ .x = dx, .y = dy, .w = @intCast(layer.width), .h = @intCast(layer.height) };
const region = on_screen.intersect(clip).intersect(dst.bounds());
if (region.isEmpty()) return;
var y: i32 = region.y;
while (y < region.bottom()) : (y += 1) {
const src = layer.row(@intCast(y - dy));
const d = dst.row(@intCast(y));
var x: i32 = region.x;
while (x < region.right()) : (x += 1) {
d[@intCast(x)] = src[@intCast(x - dx)];
}
}
}
/// Copy a `w`×`h` tile of native pixels from `src` (raw little-endian bytes, row-major,
/// tightly packed) into `dst` at (`dx`, `dy`), clipped to `dst`'s bounds. `src` is read
/// with `readInt` because it comes straight out of an IPC message buffer and carries no
/// alignment guarantee. Returns without touching anything if `src` is short.
pub fn blitTile(dst: Surface, dx: i32, dy: i32, src: []const u8, w: u32, h: u32) void {
if (src.len < @as(usize, w) * h * 4) return;
var ty: u32 = 0;
while (ty < h) : (ty += 1) {
const yy = dy + @as(i32, @intCast(ty));
if (yy < 0 or yy >= dst.height) continue;
const drow = dst.row(@intCast(yy));
var tx: u32 = 0;
while (tx < w) : (tx += 1) {
const xx = dx + @as(i32, @intCast(tx));
if (xx < 0 or xx >= dst.width) continue;
const off = (@as(usize, ty) * w + tx) * 4;
drow[@intCast(xx)] = std.mem.readInt(u32, src[off..][0..4], .little);
}
}
}
// --- tests ------------------------------------------------------------------
test "rect intersect: overlap and disjoint" {
try std.testing.expectEqual(Rect.init(5, 5, 5, 5), Rect.init(0, 0, 10, 10).intersect(Rect.init(5, 5, 10, 10)));
try std.testing.expect(Rect.init(0, 0, 10, 10).intersect(Rect.init(20, 20, 5, 5)).isEmpty());
}
test "rect unite: bounding box, empty is identity" {
const a = Rect.init(2, 2, 4, 4);
try std.testing.expectEqual(Rect.init(2, 1, 10, 5), a.unite(Rect.init(10, 1, 2, 2)));
try std.testing.expectEqual(a, a.unite(Rect.empty));
try std.testing.expectEqual(a, Rect.empty.unite(a));
}
test "fillRect clips to surface and honours stride padding" {
// A 4×3 surface inside a 6-wide allocation (stride 6 > width 4), like pitch padding.
var mem = [_]u32{0} ** (6 * 3);
const s = Surface{ .pixels = &mem, .stride = 6, .width = 4, .height = 3 };
fillRect(s, Rect.init(-1, -1, 3, 3), 0xAB); // straddles the top-left corner
try std.testing.expectEqual(@as(u32, 0xAB), mem[0 * 6 + 0]);
try std.testing.expectEqual(@as(u32, 0xAB), mem[1 * 6 + 1]);
try std.testing.expectEqual(@as(u32, 0), mem[0 * 6 + 2]); // beyond the 2-wide fill
try std.testing.expectEqual(@as(u32, 0), mem[2 * 6 + 0]); // row 2 untouched
try std.testing.expectEqual(@as(u32, 0), mem[0 * 6 + 4]); // stride padding untouched
}
test "composite: overlap shows the top layer, clipped to damage" {
var back = [_]u32{0} ** (8 * 8);
const dst = Surface{ .pixels = &back, .stride = 8, .width = 8, .height = 8 };
var lo = [_]u32{0x11} ** (4 * 4);
var hi = [_]u32{0x22} ** (4 * 4);
const low = Surface{ .pixels = &lo, .stride = 4, .width = 4, .height = 4 };
const high = Surface{ .pixels = &hi, .stride = 4, .width = 4, .height = 4 };
composite(dst, 0, 0, low, dst.bounds()); // bottom at (0,0)
composite(dst, 2, 2, high, dst.bounds()); // top overlaps at (2,2)
try std.testing.expectEqual(@as(u32, 0x11), back[0 * 8 + 0]); // bottom-only
try std.testing.expectEqual(@as(u32, 0x22), back[3 * 8 + 3]); // overlap → top wins
try std.testing.expectEqual(@as(u32, 0x22), back[5 * 8 + 5]); // top-only
try std.testing.expectEqual(@as(u32, 0), back[7 * 8 + 7]); // neither
}
test "composite honours the damage rectangle" {
var back = [_]u32{0} ** (8 * 8);
const dst = Surface{ .pixels = &back, .stride = 8, .width = 8, .height = 8 };
var fill = [_]u32{0x33} ** (8 * 8);
const layer = Surface{ .pixels = &fill, .stride = 8, .width = 8, .height = 8 };
composite(dst, 0, 0, layer, Rect.init(2, 2, 2, 2)); // only this damage region
try std.testing.expectEqual(@as(u32, 0x33), back[2 * 8 + 2]);
try std.testing.expectEqual(@as(u32, 0x33), back[3 * 8 + 3]);
try std.testing.expectEqual(@as(u32, 0), back[1 * 8 + 1]); // outside damage
try std.testing.expectEqual(@as(u32, 0), back[4 * 8 + 4]); // outside damage
}
test "blitTile copies a packed tile, clipping and reading unaligned bytes" {
var back = [_]u32{0} ** (4 * 4);
const dst = Surface{ .pixels = &back, .stride = 4, .width = 4, .height = 4 };
// A 2×2 tile in a byte buffer offset by one byte, so reads are unaligned.
var raw = [_]u8{0} ** (1 + 2 * 2 * 4);
const tile = raw[1..];
for (0..4) |i| std.mem.writeInt(u32, tile[i * 4 ..][0..4], @intCast(0xA0 + i), .little);
blitTile(dst, 3, 3, tile, 2, 2); // bottom-right corner; only (3,3) lands on-surface
try std.testing.expectEqual(@as(u32, 0xA0), back[3 * 4 + 3]);
try std.testing.expectEqual(@as(u32, 0), back[0]); // nothing else touched
}
+397
View File
@@ -0,0 +1,397 @@
//! /system/services/display — the display service (docs/display.md). A ring-3 process
//! that claims the framebuffer the kernel seeded (docs/display-plan.md D1), owns it as a
//! **write-combining front buffer**, composites an ordered stack of **layers** into a
//! **cacheable back buffer**, and presents finished frames — the GUI track's compositor,
//! the sibling of the input service. Reached by name over `ServiceId.display`.
//!
//! A layer is a server-owned surface (its own cacheable buffer) with a screen position,
//! z-order, and visibility. Clients create layers and draw into them by command
//! (`fill_rect`, `blit_tile`), mark `damage`, and ask for a `present`; the compositor
//! repaints only the damaged region — clear it, paint the visible layers bottom-to-top,
//! flush it to the screen. The pixel math lives in the pure, host-tested
//! [compositor.zig](compositor.zig); this file wires real surfaces and the framebuffer to
//! it. Shared-memory client surfaces are a later milestone (docs/display.md).
const std = @import("std");
const runtime = @import("runtime");
const compositor = @import("compositor.zig");
const protocol = runtime.display_protocol;
const ipc = runtime.ipc;
const system = runtime.system;
const device = runtime.device;
const Rect = compositor.Rect;
const Surface = compositor.Surface;
/// The claimed framebuffer and its off-screen twin. The front buffer is the LFB —
/// write-combining, so it is **only ever written**, never read; all compositing happens
/// in the cacheable back buffer, which is then streamed to the front (docs/display.md).
const Display = struct {
device_id: u64,
front: [*]volatile u8, // the LFB (write-combining)
back: [*]u8, // cacheable, same geometry
width: u32,
height: u32,
pitch: u32, // bytes per row (shared by both buffers)
format: u32, // a device-abi DisplayFormat value
frames: u64 = 0,
};
var display: Display = undefined;
/// The wallpaper the compositor clears damaged regions to before painting layers.
var background: u32 = 0;
/// The layer stack. A fixed table (a compositor has few top-level surfaces during
/// bring-up); each used slot owns an mmap'd surface. `damage` accumulates the dirty
/// screen region since the last `present`, so a present touches only what changed.
const maximum_layers = 16;
const Layer = struct {
used: bool = false,
x: i32 = 0,
y: i32 = 0,
z: u32 = 0,
visible: bool = false,
surface: Surface = undefined,
surface_len: usize = 0, // for munmap on destroy
};
var layers: [maximum_layers]Layer = [_]Layer{.{}} ** maximum_layers;
var damage: Rect = Rect.empty;
/// Enumeration buffer kept off the stack — a `DeviceDescriptor` is large, and this
/// service only ever needs one scan.
var device_table: [64]device.DeviceDescriptor = undefined;
// --- geometry helpers -------------------------------------------------------
fn screenRect() Rect {
return .{ .x = 0, .y = 0, .w = @intCast(display.width), .h = @intCast(display.height) };
}
fn backSurface() Surface {
return .{
.pixels = @ptrCast(@alignCast(display.back)),
.stride = display.pitch / 4, // pitch is bytes; a 32-bpp row is pitch/4 pixels
.width = display.width,
.height = display.height,
};
}
fn layerScreenRect(l: *const Layer) Rect {
return .{ .x = l.x, .y = l.y, .w = @intCast(l.surface.width), .h = @intCast(l.surface.height) };
}
/// Add `r` (screen coordinates) to the pending damage, clipped to the screen.
fn addDamage(r: Rect) void {
damage = damage.unite(r.intersect(screenRect()));
}
// --- layer operations (called from onMessage and the self-check) ------------
fn freeLayer() ?u32 {
for (&layers, 0..) |*l, i| {
if (!l.used) return @intCast(i);
}
return null;
}
/// A used layer by id, or null if the id is out of range or free.
fn layerAt(id: u32) ?*Layer {
if (id >= maximum_layers or !layers[id].used) return null;
return &layers[id];
}
fn createLayer(x: i32, y: i32, w: u32, h: u32, z: u32, visible: bool) ?u32 {
if (w == 0 or h == 0) return null;
const slot = freeLayer() orelse return null;
const len = @as(usize, w) * h * 4;
const base = system.mmap(len, system.PROT_READ | system.PROT_WRITE);
if (system.mmapFailed(base)) return null;
layers[slot] = .{
.used = true,
.x = x,
.y = y,
.z = z,
.visible = visible,
.surface = .{ .pixels = @ptrFromInt(base), .stride = w, .width = w, .height = h },
.surface_len = len,
};
return slot;
}
fn fillLayer(id: u32, local: Rect, colour: u32) bool {
const l = layerAt(id) orelse return false;
compositor.fillRect(l.surface, local, colour);
// Damage in screen space = the fill, translated by the layer origin, within the layer.
const screen = Rect{ .x = l.x + local.x, .y = l.y + local.y, .w = local.w, .h = local.h };
addDamage(screen.intersect(layerScreenRect(l)));
return true;
}
fn blitLayer(id: u32, x: i32, y: i32, w: u32, h: u32, pixels: []const u8) bool {
const l = layerAt(id) orelse return false;
compositor.blitTile(l.surface, x, y, pixels, w, h);
const screen = Rect{ .x = l.x + x, .y = l.y + y, .w = @intCast(w), .h = @intCast(h) };
addDamage(screen.intersect(layerScreenRect(l)));
return true;
}
fn configureLayer(id: u32, x: i32, y: i32, z: u32, visible: bool) bool {
const l = layerAt(id) orelse return false;
addDamage(layerScreenRect(l)); // the old footprint must repaint
l.x = x;
l.y = y;
l.z = z;
l.visible = visible;
addDamage(layerScreenRect(l)); // and the new one
return true;
}
fn destroyLayer(id: u32) bool {
const l = layerAt(id) orelse return false;
addDamage(layerScreenRect(l));
_ = system.munmap(@intFromPtr(l.surface.pixels), l.surface_len);
l.* = .{};
return true;
}
// --- compositing + present --------------------------------------------------
/// Repaint the damaged region `clip` of the back buffer: clear it to the background, then
/// paint every visible layer that overlaps it, bottom to top (ascending z).
fn compositeInto(clip: Rect) void {
const back = backSurface();
compositor.fillRect(back, clip, background);
// z-order the used, visible layers (n ≤ 16; a plain insertion sort of indices).
var order: [maximum_layers]u32 = undefined;
var n: usize = 0;
for (layers, 0..) |l, i| {
if (l.used and l.visible) {
order[n] = @intCast(i);
n += 1;
}
}
var a: usize = 1;
while (a < n) : (a += 1) {
const key = order[a];
var b: usize = a;
while (b > 0 and layers[order[b - 1]].z > layers[key].z) : (b -= 1) order[b] = order[b - 1];
order[b] = key;
}
for (order[0..n]) |i| {
const l = layers[i];
compositor.composite(back, l.x, l.y, l.surface, clip);
}
}
/// Stream the damaged rectangle from the cacheable back buffer to the write-combining
/// front buffer, row by row (sequential writes — what WC memory wants; we never read the
/// front buffer). Only the visible width of each row is touched.
fn flushRect(rect: Rect) void {
const c = rect.intersect(screenRect());
if (c.isEmpty()) return;
var y: i32 = c.y;
while (y < c.bottom()) : (y += 1) {
const off = @as(usize, @intCast(y)) * display.pitch;
const src: [*]const u32 = @ptrCast(@alignCast(display.back + off));
const dst: [*]volatile u32 = @ptrCast(@alignCast(display.front + off));
var x: i32 = c.x;
while (x < c.right()) : (x += 1) dst[@intCast(x)] = src[@intCast(x)];
}
}
/// Composite and flush the accumulated damage, then clear it. A no-op when nothing is
/// dirty. The frame counter advances regardless, so callers can name frames.
fn present() void {
const dirty = damage.intersect(screenRect());
if (!dirty.isEmpty()) {
compositeInto(dirty);
flushRect(dirty);
}
damage = Rect.empty;
display.frames += 1;
}
// --- startup self-check -----------------------------------------------------
/// Prove the compositor wiring on the real framebuffer: two overlapping opaque layers,
/// composited, must show the top layer in the overlap and the bottom layer outside it.
/// Exercises the whole path — mmap surfaces, the z-sort, damage, composite into the back
/// buffer — and reads the composited result back. Cleans up after itself.
fn selfCheck() void {
const red = protocol.pack(display.format, 0xC0, 0x20, 0x20);
const green = protocol.pack(display.format, 0x20, 0xC0, 0x20);
const bottom = createLayer(100, 100, 80, 80, 0, true) orelse return fail_check("create");
const top = createLayer(140, 140, 80, 80, 1, true) orelse return fail_check("create");
_ = fillLayer(bottom, Rect.init(0, 0, 80, 80), red);
_ = fillLayer(top, Rect.init(0, 0, 80, 80), green);
present();
const back = backSurface();
const overlap = back.pixels[@as(usize, 150) * back.stride + 150]; // in both layers → top
const bottom_only = back.pixels[@as(usize, 110) * back.stride + 110]; // bottom only
_ = destroyLayer(top);
_ = destroyLayer(bottom);
present(); // repaint the self-check region back to the background
if (overlap == green and bottom_only == red) {
_ = system.write("display: compositor self-check ok\n");
} else {
_ = system.write("display: compositor self-check FAILED\n");
}
}
fn fail_check(_: []const u8) void {
_ = system.write("display: compositor self-check FAILED (setup)\n");
}
// --- service ----------------------------------------------------------------
/// The framebuffer node the kernel seeded (`DeviceClass.display`), or null if none.
fn findDisplay() ?device.DeviceDescriptor {
const total = device.enumerate(&device_table);
const n = @min(total, device_table.len);
for (device_table[0..n]) |d| {
if (d.class == @intFromEnum(device.DeviceClass.display)) return d;
}
return null;
}
fn initialise(endpoint: ipc.Handle) bool {
_ = endpoint;
// Find the framebuffer, retrying while device discovery catches up with our spawn.
var tries: u32 = 0;
const found = while (tries < 100) : (tries += 1) {
if (findDisplay()) |d| break d;
system.sleep(50);
} else {
_ = system.write("display: no framebuffer device (headless?)\n");
return false; // clean exit: nothing to drive
};
if (!device.claim(found.id)) {
_ = system.write("display: could not claim the framebuffer\n");
return false;
}
// Resource 0 is the framebuffer memory window; the kernel maps it write-combining
// because the resource carries that flag (docs/display-plan.md D1).
const front_base = device.mmioMap(found.id, 0) orelse {
_ = system.write("display: could not map the framebuffer\n");
return false;
};
const geometry = found.display;
const size = @as(usize, geometry.height) * geometry.pitch;
const back_base = system.mmap(size, system.PROT_READ | system.PROT_WRITE);
if (system.mmapFailed(back_base)) {
_ = system.write("display: could not allocate the back buffer\n");
return false;
}
display = .{
.device_id = found.id,
.front = @ptrFromInt(front_base),
.back = @ptrFromInt(back_base),
.width = geometry.width,
.height = geometry.height,
.pitch = geometry.pitch,
.format = geometry.format,
};
background = protocol.pack(display.format, 0x20, 0x30, 0x48); // a dark slate wallpaper
// Clear the whole screen through the back buffer → present path (double buffering:
// no direct-to-LFB drawing).
addDamage(screenRect());
present();
var line: [96]u8 = undefined;
_ = system.write(std.fmt.bufPrint(&line, "display: online {d}x{d} pitch {d} format {d}\n", .{
display.width, display.height, display.pitch, display.format,
}) catch "display: online\n");
_ = system.write("display: presented frame 0\n");
selfCheck();
return true;
}
fn writeReply(reply: []u8, value: protocol.Reply) usize {
const bytes = std.mem.asBytes(&value);
@memcpy(reply[0..bytes.len], bytes);
return bytes.len;
}
fn ok(reply: []u8) usize {
return writeReply(reply, .{ .status = 0 });
}
fn fail(reply: []u8) usize {
return writeReply(reply, .{ .status = -1 });
}
fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize {
_ = sender;
_ = capability;
if (message.len < protocol.request_size) return fail(reply);
const request = std.mem.bytesToValue(protocol.Request, message[0..protocol.request_size]);
const payload = message[protocol.request_size..];
// Switch on the raw operation value — an out-of-range one must fail cleanly, not
// panic an `@enumFromInt`.
switch (request.operation) {
@intFromEnum(protocol.Operation.info) => return writeReply(reply, .{
.status = 0,
.width = display.width,
.height = display.height,
.pitch = display.pitch,
.format = display.format,
}),
@intFromEnum(protocol.Operation.create_layer) => {
// x/y are signed coordinates carried in the u32 wire fields — reinterpret the
// bits (@bitCast), don't range-check (@intCast) which a negative would fail.
const slot = createLayer(@bitCast(request.x), @bitCast(request.y), request.width, request.height, request.z, request.visible != 0) orelse return fail(reply);
return writeReply(reply, .{ .status = 0, .layer = slot });
},
@intFromEnum(protocol.Operation.configure_layer) => {
return if (configureLayer(request.layer, @bitCast(request.x), @bitCast(request.y), request.z, request.visible != 0)) ok(reply) else fail(reply);
},
@intFromEnum(protocol.Operation.destroy_layer) => {
return if (destroyLayer(request.layer)) ok(reply) else fail(reply);
},
@intFromEnum(protocol.Operation.fill_rect) => {
const local = Rect.init(@bitCast(request.x), @bitCast(request.y), @intCast(request.width), @intCast(request.height));
return if (fillLayer(request.layer, local, request.colour)) ok(reply) else fail(reply);
},
@intFromEnum(protocol.Operation.blit_tile) => {
return if (blitLayer(request.layer, @bitCast(request.x), @bitCast(request.y), request.width, request.height, payload)) ok(reply) else fail(reply);
},
@intFromEnum(protocol.Operation.damage) => {
const l = layerAt(request.layer) orelse return fail(reply);
const screen = Rect{ .x = l.x + @as(i32, @bitCast(request.x)), .y = l.y + @as(i32, @bitCast(request.y)), .w = @intCast(request.width), .h = @intCast(request.height) };
addDamage(screen.intersect(layerScreenRect(l)));
return ok(reply);
},
@intFromEnum(protocol.Operation.present) => {
present();
return ok(reply);
},
else => return fail(reply),
}
}
pub fn main() void {
runtime.service.run(protocol.message_maximum, .{
.service = .display,
.init = initialise,
.on_message = onMessage,
});
}
pub const panic = runtime.panic;
comptime {
_ = &runtime.start._start;
}
+90
View File
@@ -0,0 +1,90 @@
//! The display wire protocol — what a client says to the display service over its
//! well-known `.display` endpoint. extern-struct messages with an `Operation` tag, the
//! same shape as block/vfs/input protocols. The compositor owns the framebuffer and an
//! ordered stack of **layers**; a client creates layers, draws into them with these
//! operations, marks damage, and asks for a `present`. v1 surfaces are server-owned (a
//! client draws by command); shared-memory surfaces are a later milestone (docs/display.md).
const std = @import("std");
pub const Operation = enum(u32) {
/// info() -> { width, height, pitch, format }: the display's current mode.
info = 0,
/// create_layer(x, y, width, height, z) -> { layer }: a new server-owned surface.
create_layer = 1,
/// configure_layer(layer, x, y, z, visible): move, restack, show, or hide a layer.
configure_layer = 2,
/// destroy_layer(layer): release a layer.
destroy_layer = 3,
/// fill_rect(layer, x, y, width, height, colour): fill a rectangle of a layer.
fill_rect = 4,
/// blit_tile(layer, x, y, width, height, <inline pixels>): copy a small pixel tile in.
blit_tile = 5,
/// damage(layer, x, y, width, height): mark a region dirty for the next present.
damage = 6,
/// present(): composite the dirty layers and flush to the screen.
present = 7,
};
/// The fixed request header. A `blit_tile`'s pixel payload (width*height 32-bit pixels)
/// follows this header inline in the same message, up to `maximum_payload`.
pub const Request = extern struct {
operation: u32,
layer: u32 = 0, // create/configure/destroy/fill/blit/damage: the target layer
x: u32 = 0,
y: u32 = 0,
width: u32 = 0,
height: u32 = 0,
z: u32 = 0, // create_layer / configure_layer: stacking order (higher = in front)
colour: u32 = 0, // fill_rect: the fill colour (native pixel value)
visible: u32 = 1, // configure_layer: 0 hides the layer
reserved: u32 = 0,
};
pub const Reply = extern struct {
status: i32, // 0 on success, negative on failure
reserved: u32 = 0,
// info():
width: u32 = 0,
height: u32 = 0,
pitch: u32 = 0,
format: u32 = 0, // a device-abi DisplayFormat value (0 = rgbx, 1 = bgrx)
// create_layer():
layer: u32 = 0,
reserved2: u32 = 0,
};
/// The IPC message size — the kernel caps every message at `MESSAGE_MAXIMUM` (256 bytes,
/// system/kernel/ipc-synchronous.zig), so this matches it (a larger receive/reply buffer
/// is rejected with -E2BIG). A `blit_tile` therefore carries only a *small* tile inline —
/// `maximum_payload` bytes = up to 54 pixels, enough for a cursor or small sprite; larger
/// bitmaps are the deferred shared-memory surface path (docs/display.md).
pub const message_maximum: usize = 256;
pub const request_size: usize = @sizeOf(Request);
pub const reply_size: usize = @sizeOf(Reply);
pub const maximum_payload: usize = message_maximum - request_size;
/// Pack an 8-bit-per-channel colour into the display's native 32-bit pixel for `format`
/// (a device-abi `DisplayFormat`: 0 = rgbx, 1 = bgrx). Shared so a `colour` in a
/// `fill_rect` request means the same thing to the client that sends it and the
/// compositor that paints it. Little-endian memory, reserved byte 0: rgbx puts red in
/// the low byte, bgrx puts blue there.
pub fn pack(format: u32, r: u8, g: u8, b: u8) u32 {
const rr: u32 = r;
const gg: u32 = g;
const bb: u32 = b;
return switch (format) {
1 => bb | (gg << 8) | (rr << 16), // bgrx
else => rr | (gg << 8) | (bb << 16), // rgbx
};
}
test "pack encodes native byte order for rgbx and bgrx" {
// rgbx: red in the low byte, blue in byte 2.
try std.testing.expectEqual(@as(u32, 0x0000_00AA), pack(0, 0xAA, 0, 0));
try std.testing.expectEqual(@as(u32, 0x00AA_0000), pack(0, 0, 0, 0xAA));
// bgrx: blue in the low byte, red in byte 2.
try std.testing.expectEqual(@as(u32, 0x0000_00AA), pack(1, 0, 0, 0xAA));
try std.testing.expectEqual(@as(u32, 0x00AA_0000), pack(1, 0xAA, 0, 0));
try std.testing.expectEqual(@as(u32, 0x0000_3020), pack(0, 0x20, 0x30, 0)); // green in byte 1
}
+14 -1
View File
@@ -40,11 +40,16 @@ const IpcBlock = struct {
const self: *IpcBlock = @ptrCast(@alignCast(context)); const self: *IpcBlock = @ptrCast(@alignCast(context));
const destination: [*]u8 = @ptrFromInt(self.bounce.virtual); const destination: [*]u8 = @ptrFromInt(self.bounce.virtual);
@memcpy(destination[0..512], buffer[0..512]); @memcpy(destination[0..512], buffer[0..512]);
return self.device.write(lba, 1, self.bounce.physical); if (!self.device.write(lba, 1, self.bounce.physical)) return false;
device_dirty = true; // a block reached the device; a close will flush it
return true;
} }
}; };
var ipc_block: IpcBlock = undefined; var ipc_block: IpcBlock = undefined;
// Set whenever a block is written, cleared when the device cache is flushed on a
// file close — so writes are committed to stable media before a power-off.
var device_dirty: bool = false;
var filesystem: engine.FileSystem = undefined; var filesystem: engine.FileSystem = undefined;
// Open handles the VFS holds against this backend: each maps a node id to a // Open handles the VFS holds against this backend: each maps a node id to a
@@ -192,6 +197,14 @@ fn onMessage(message: []const u8, out: []u8, sender: u32, capability: ?runtime.i
}, },
.close => { .close => {
if (openAt(request.node)) |o| o.used = false; if (openAt(request.node)) |o| o.used = false;
// Durable-on-close: if any block reached the device since the last
// flush, commit its cache to stable media now (best-effort). This is
// what makes init's shutdown log flush survive a real power-off, and is
// the right default for removable media the user may unplug.
if (device_dirty) {
_ = ipc_block.device.flush();
device_dirty = false;
}
return writeReply(out, .{ .status = 0 }, &.{}); return writeReply(out, .{ .status = 0 }, &.{});
}, },
.mkdir => { .mkdir => {
+1 -1
View File
@@ -30,7 +30,7 @@ const log_path = "/mnt/usb/DANOS.LOG";
/// microkernel keeps such choices in user space, not the kernel. Drivers are absent /// microkernel keeps such choices in user space, not the kernel. Drivers are absent
/// on purpose: the device manager owns those. (A future init reads this from a /// on purpose: the device manager owns those. (A future init reads this from a
/// manifest under /system/services instead of a hardcoded list.) /// manifest under /system/services instead of a hardcoded list.)
const boot_services = [_][]const u8{ "vfs", "input", "device-manager", "fat" }; const boot_services = [_][]const u8{ "vfs", "input", "device-manager", "fat", "display" };
var children: [boot_services.len]u32 = .{0} ** boot_services.len; var children: [boot_services.len]u32 = .{0} ** boot_services.len;
var child_count: usize = 0; var child_count: usize = 0;
+27 -5
View File
@@ -165,6 +165,26 @@ CASES = [
{"name": "ioport", {"name": "ioport",
"expect": r"DANOS-TEST-RESULT: PASS", "expect": r"DANOS-TEST-RESULT: PASS",
"fail": r"DANOS-TEST-RESULT: FAIL"}, "fail": r"DANOS-TEST-RESULT: FAIL"},
# Display handoff (D1): the kernel seeds the loader's framebuffer as a claimable
# `display` device with a write-combining memory resource; the claim + mmio_map path
# maps it, and the leaf is genuinely write-combining (PAT entry 4), not the UC default.
{"name": "display",
"expect": r"DANOS-TEST-RESULT: PASS",
"fail": r"DANOS-TEST-RESULT: FAIL"},
# Display service (D2/D3): the user-space compositor claims the framebuffer, allocates
# a cacheable back buffer, clears it, and presents that composed frame (double-buffer
# path); then a startup self-check composites two overlapping layers and confirms the
# overlap shows the top layer (D3). Matched on the service's own heartbeats.
{"name": "display-service",
"expect": r"display: online \d+x\d+ pitch \d+[\s\S]*display: presented frame 0[\s\S]*display: compositor self-check ok",
"fail": r"display: could not|self-check FAILED|CPU EXCEPTION|KERNEL PANIC"},
# Display demo (D4): a separate process (display-demo) drives the compositor over the
# layer client API — wallpaper + a moving rectangle + a cursor, presented in a loop.
# `display-demo: ok` is printed only after it drove a run of frames of motion through
# the service (the visible motion is a screenshot via `zig build run-x86-64`).
{"name": "display-demo",
"expect": r"display-demo: scene up[\s\S]*display-demo: ok",
"fail": r"display-demo: (no display|create failed)|display: could not|CPU EXCEPTION|KERNEL PANIC"},
# Monotonic clock (clock() syscall source): calibrated, advancing, never backwards. # Monotonic clock (clock() syscall source): calibrated, advancing, never backwards.
{"name": "clock", {"name": "clock",
"expect": r"DANOS-TEST-RESULT: PASS", "expect": r"DANOS-TEST-RESULT: PASS",
@@ -365,7 +385,7 @@ CASES = [
"smp": 4, "smp": 4,
"timeout": 60, "timeout": 60,
"expect": r"acpi-parse: ok", "expect": r"acpi-parse: ok",
"fail": r"acpi-parse: mismatch|DANOS-TEST-RESULT: FAIL"}, "fail": r"acpi-parse: too few|DANOS-TEST-RESULT: FAIL"},
# M20.3: the flip — ps2-bus now comes up from the acpi service's report, not # M20.3: the flip — ps2-bus now comes up from the acpi service's report, not
# a kernel-built node. Ordered: report -> spawn -> the driver attaches its # a kernel-built node. Ordered: report -> spawn -> the driver attaches its
# keyboard, proving discovery runs entirely in ring 3 (docs/discovery.md). # keyboard, proving discovery runs entirely in ring 3 (docs/discovery.md).
@@ -492,9 +512,8 @@ CASES = [
"fail": r"DANOS-TEST-RESULT: FAIL"}, "fail": r"DANOS-TEST-RESULT: FAIL"},
# The ACPI power path succeeds by QEMU *exiting* (S5 off / reset), so match the # The ACPI power path succeeds by QEMU *exiting* (S5 off / reset), so match the
# pre-transition marker; the FAIL line only appears if the transition didn't take. # pre-transition marker; the FAIL line only appears if the transition didn't take.
{"name": "poweroff", # Soft-off (S5) is owned by the ring-3 acpi service now (see orderly-shutdown);
"expect": r"DANOS-POWER: attempting poweroff", # the kernel keeps only reboot (FADT reset register, no AML).
"fail": r"DANOS-TEST-RESULT: FAIL"},
{"name": "reboot", {"name": "reboot",
"expect": r"DANOS-POWER: attempting reboot", "expect": r"DANOS-POWER: attempting reboot",
"fail": r"DANOS-TEST-RESULT: FAIL"}, "fail": r"DANOS-TEST-RESULT: FAIL"},
@@ -504,7 +523,10 @@ TIMEOUT = 30 # seconds per case
def build(arch, case): def build(arch, case):
cmd = ["zig", "build", f"-Dtest-case={case}"] + arch["zig_flags"] # -Dserial: the harness asserts on markers the kernel writes to serial0, so the
# serial log sink must be compiled in. It is off by default (a flashed real-
# hardware image keeps its log in RAM instead; see build.zig / serial.zig).
cmd = ["zig", "build", f"-Dtest-case={case}", "-Dserial=true"] + arch["zig_flags"]
r = subprocess.run(cmd, cwd=REPO, capture_output=True, text=True) r = subprocess.run(cmd, cwd=REPO, capture_output=True, text=True)
if r.returncode != 0: if r.returncode != 0:
return r.stderr.strip() or r.stdout.strip() return r.stderr.strip() or r.stdout.strip()