diff --git a/build.zig b/build.zig index 3021d44..038fa16 100644 --- a/build.zig +++ b/build.zig @@ -788,8 +788,8 @@ pub fn build(b: *std.Build) void { "/usr/local/share/qemu/edk2-i386-vars.fd", // macOS Homebrew (Intel) }); - // The FHS zig-out (installed above) *is* the boot volume — no separate ESP to - // assemble. QEMU presents it to the guest as a FAT drive below. + // The guest boots the self-contained FAT image (attached as USB storage below), + // not the installed FHS zig-out — see the run step's drive/device flags. // The firmware needs to write NVRAM, so give it a writable copy of the vars. const vars_copy = b.addSystemCommand(&.{ "cp", "-f", ovmf_vars }); diff --git a/docs/README.md b/docs/README.md index 6beed55..11d0362 100644 --- a/docs/README.md +++ b/docs/README.md @@ -54,27 +54,32 @@ rather than restate it. Roughly in the order things happen at runtime: three primitives it proposed are long since built (M13 capability passing, M14 DMA + barriers, M15 MSI), and the driver *contract* on top of them — hello, supervision, restart — is built too (device-manager.md, M18). -16. **[process-management.md](process-management.md) — process management.** The +16. **[usb-hub.md](usb-hub.md) — USB hubs.** Built (M22): why hub topology is handled + *inside* the `usb-xhci-bus` driver rather than a separate hub class driver — a + device behind a hub is reached by the **controller**, programmed with a route + string in its slot context — plus the compound-hub reality (a USB 3.0 hub is + physically two hubs) and detection via the hub's status-change interrupt endpoint. +17. **[process-management.md](process-management.md) — process management.** The microkernel's `ps`/`kill`/SIGCHLD: enumerate as a table snapshot, the supervision link as the kill authority, and child-exit notifications over the same endpoints IRQs arrive on. -17. **[process-lifecycle.md](process-lifecycle.md) — the process lifecycle.** Built +18. **[process-lifecycle.md](process-lifecycle.md) — the process lifecycle.** Built (M17): signals over IPC as the one lifecycle vocabulary every process speaks — the POSIX.1-1990 words with message delivery instead of stack hijack, the stable `runtime.process` interface, exit reasons, published exit events any stateful service can subscribe to (the VFS releasing dead clients' handles), and the two iron rules (cleanup is the kernel's job; kill is not a signal). -18. **[device-manager.md](device-manager.md) — the device manager.** Built (M18, +19. **[device-manager.md](device-manager.md) — the device manager.** Built (M18, through the app surface): the tree, the matcher, and the supervisor. Tree structure lives in the manager, authority stays in the kernel; bus drivers report what they see; drivers are restarted through the lifecycle vocabulary — the plan that turns [resilience.md](resilience.md)'s restart goal into increments. -19. **[input.md](input.md) — the input module.** Broadcasting input events (keyboard, +20. **[input.md](input.md) — the input module.** Broadcasting input events (keyboard, mouse, joystick): why a synchronous rendezvous can't fan out to many listeners, the asynchronous `ipc_send` primitive built to fix it, and the per-device subscribe/publish service layered on top. -20. **[display.md](display.md) — the display service.** The display half of the GUI +21. **[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 @@ -85,7 +90,7 @@ rather than restate it. Roughly in the order things happen at runtime: further out, two research snapshots survey what a *native* driver for real GPU silicon would take as another `.scanout` backend: [nvidia-gpus.md](nvidia-gpus.md) (RTX 3060 / Ampere) and [intel-igpu.md](intel-igpu.md) (Intel iGPU). -21. **[halting.md](halting.md) — halting.** Why a kernel can't just "exit", and +22. **[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. Start with the north star: @@ -139,7 +144,7 @@ Cutting across all of these: hardware exists via ACPI (x86) or device tree (ARM) behind one neutral device model — when to build it, and how to keep it architecture-agnostic. - **[acpi.md](acpi.md) — finding the ACPI tables.** The concrete x86 locator chain: - how the loader captures the **RSDP**, hands its physical address across in `BootInfo`, + how the loader captures the **RSDP**, hands its physical address across in `BootInformation`, and how the platform derives the **RSDT/XSDT** from it and walks the SDTs — plus the live event side (the SCI, the power button, GPE/Notify) the ring-3 acpi service runs. - **[power.md](power.md) — the power service.** System power as a domain-named @@ -210,7 +215,7 @@ addressed as **`system/services/init`** — the repeated leaf resolves away: | `library/runtime/runtime.zig` | `library/runtime` (the `runtime` module) | In **source**, a sub-project is a directory so it can hold many files — the entry is -`init/init.zig`, beside it `vfs/vfs-test.zig`, `vfs/protocol.zig`, and so on. When +`fat/fat.zig`, beside it `fat/engine.zig`, `fat/on-disk.zig`, and so on. When **addressed or installed**, that collapses to the single canonical path: the `init` binary installs to `/system/services/init` (a file at that path), not `/system/services/init/init`. The repeated leaf exists only in source; the directory is @@ -222,24 +227,28 @@ A sub-project's extra files are reached through the module, never as separate pa system/ → /system danos's own internals (the self-representation) boot-handoff.zig the loader↔kernel contract (the `boot-handoff` module) abi.zig the private kernel↔runtime syscall ABI (the `abi` module) - parameters.zig initial-ramdisk.zig shared contracts - kernel/ IPC, memory, scheduling, the private syscall dispatch + parameters.zig initial-ramdisk.zig vfs-protocol.zig shared contracts + kernel/ IPC, memory, scheduling, the VFS root, the private syscall dispatch architecture/x86_64/ the `architecture` module (never named by generic code) devices/ the device model /system/devices reflects (+ aml/) device-abi.zig the device wire types (the `device-abi` module) - drivers/ hpet/ bus/ one sub-project per driver → /system/drivers - services/ init/ vfs/ device-manager/ system servers → /system/services (vfs/ holds - vfs.zig, vfs-test.zig, protocol.zig) + drivers/ pci-bus/ ps2-bus/ usb-xhci-bus/ one sub-project per driver → /system/drivers + services/ init/ fat/ device-manager/ system servers → /system/services (fat/ holds + fat.zig, engine.zig, on-disk.zig) library/ → /lib libraries, one sub-directory each runtime/ the danos-native runtime + file API (fs) — the stable application ABI boot/ → /boot the loaders tools/ test/ host-side build + QEMU test harness ``` -A sub-project exposes its **public interface as a module**: `system/services/vfs/` owns -the VFS wire protocol (`protocol.zig`, the `vfs-protocol` module), which the runtime's -file API (`runtime.fs`) imports by name. `usb`/`block` drivers expose their protocols the -same way. +A sub-project exposes its **public interface as a module**: the `usb-xhci-bus` driver +owns the USB transfer protocol (`usb-transfer-protocol.zig`, the `usb-transfer-protocol` +module), which `runtime.usb` imports by name — the USB class drivers reach the +protocol through that wrapper; `block` exposes its protocol (`block-protocol`) the +same way. The VFS wire protocol is the one that outgrew its +sub-project: the VFS root moved into the kernel (`system/kernel/vfs.zig`), so the +protocol lives as a shared contract at `system/vfs-protocol.zig` (the `vfs-protocol` +module), which the runtime's file API (`runtime.fs`) imports by name. There is **no POSIX/C compatibility layer today**: danos programs do file I/O through the danos-native `runtime.fs` (open/read/write/list over the VFS). A hand-rolled POSIX shim @@ -254,7 +263,7 @@ exception in [coding-standards.md](coding-standards.md) applies to that seam. |------|------| | Boot methods (one per way of booting the kernel) | `boot/` — `efi.zig` (UEFI) → `BOOTX64.efi` | | Kernel entry, panic, bring-up | `system/kernel/kernel.zig` | -| Loader↔kernel handoff (`BootInfo`, `Framebuffer`, `MemoryMap`, VM layout) | `system/boot-handoff.zig` | +| Loader↔kernel handoff (`BootInformation`, `Framebuffer`, `MemoryMap`, VM layout) | `system/boot-handoff.zig` | | Private kernel↔runtime syscall ABI (`SystemCall`, mmap prot flags, `page_size`) — the runtime speaks it, not apps | `system/abi.zig` | | Device wire types (`DeviceDescriptor`, `DeviceClass`, …) | `system/devices/device-abi.zig` | | Physical frame allocator | `system/kernel/pmm.zig` | @@ -264,6 +273,7 @@ exception in [coding-standards.md](coding-standards.md) applies to that seam. | IPC channels between kernel threads (message passing) | `system/kernel/ipc.zig` | | IPC endpoints: cross-address-space call/reply, handles, notifications | `system/kernel/ipc-synchronous.zig` | | User processes: ELF loading, address spaces, the syscall table | `system/kernel/process.zig` | +| VFS root: mount table + kernel-served nodes (`fs_resolve`/`fs_node`); wire protocol in `system/vfs-protocol.zig` | `system/kernel/vfs.zig` | | Device tree + claim capability + `device_register` containment | `system/kernel/devices-broker.zig` | | IRQ-as-IPC: routing a device interrupt to a driver's endpoint | `system/kernel/irq.zig` | | Hardware discovery (ACPI/device tree) behind one neutral device model | `system/devices/` | @@ -271,7 +281,7 @@ exception in [coding-standards.md](coding-standards.md) applies to that seam. | In-kernel test cases | `system/kernel/tests.zig` | | Arch-specific kernel code (`halt`, GDT/IDT/TSS, exception + interrupt stubs, page tables, APIC/IO-APIC/timer, serial, linker script) | `system/kernel/architecture/x86_64/` | | danos-native runtime (`runtime`): syscall wrappers, heap, IPC, device access, the file API (`fs`) — the stable application ABI | `library/runtime/` | -| System services (init, the VFS server + `protocol`, the device-manager) | `system/services/` | +| System services (init, the `fat` filesystem, the device-manager) | `system/services/` | | Device drivers, one sub-project each (`pci-bus`, `ps2-bus`, `usb-xhci-bus` bus drivers) | `system/drivers/` | | Build + `run-x86-64` (QEMU/OVMF) + `release-x86-64` (the flashable ISO) | `build.zig` | | QEMU integration test harness | `test/qemu_test.py` | diff --git a/docs/acpi.md b/docs/acpi.md index c43d3ae..588937b 100644 --- a/docs/acpi.md +++ b/docs/acpi.md @@ -16,11 +16,11 @@ the RSDT's address is a field *inside* the RSDP. The platform follows that point UEFI configuration table │ the loader reads the RSDP's physical address ▼ -BootInfo.acpi_rsdp (u64, in the loader↔kernel handoff) system/boot-handoff.zig - │ the kernel forwards the whole BootInfo +BootInformation.acpi_rsdp (u64, in the loader↔kernel handoff) system/boot-handoff.zig + │ the kernel forwards the whole BootInformation ▼ -platform.discover(boot_info, …) system/devices/platform.zig - │ reads boot_info.acpi_rsdp, hands it to the ACPI backend +platform.discover(boot_information, …) system/devices/platform.zig + │ reads boot_information.acpi_rsdp, hands it to the ACPI backend ▼ acpi.discover(rsdp_phys, …) system/devices/acpi.zig │ dereferences the RSDP, reads the pointer it contains @@ -40,11 +40,12 @@ still up. `acpiRootSystemDescriptorPointer()` in `boot/efi.zig` walks the UEFI "grab it before `ExitBootServices`" pattern as the [framebuffer](framebuffer.md) and the [memory map](memory-map.md). -## Step 2 — the handoff: a physical address in `BootInfo` +## Step 2 — the handoff: a physical address in `BootInformation` The loader can't just call the device module: the bootloader binary and the kernel binary are compiled separately, and **the loader isn't linked against the `platform` -module at all** (it imports only the `boot-handoff` contract). So instead of a call, it +module at all** (it imports only the `boot-handoff` contract and the +`initial-ramdisk` module). So instead of a call, it deposits a value in the handoff struct: ```zig @@ -56,13 +57,14 @@ Two things about what crosses the boundary: - **It's a *physical* address, not a Zig pointer.** The loader and kernel don't share an address space at the moment of the jump, so a raw `u64` physical address is the - only thing that survives the handoff. `BootInfo.acpi_rsdp` is `0` when the firmware + only thing that survives the handoff. `BootInformation.acpi_rsdp` is `0` when the firmware exposed no ACPI (e.g. a future device-tree machine, which would fill a different field instead — the kernel never learns which firmware booted it). -- **The kernel can dereference it because it identity-maps ACPI memory.** The RSDP - lives in ACPI-reclaim memory, which [paging.zig](paging.md) identity-maps along with - the rest of RAM, so by the time discovery runs `@ptrFromInt(rsdp_phys)` is a valid - pointer. +- **The kernel can dereference it through the physmap.** The RSDP lives in + ACPI-reclaim memory, which [paging.zig](paging.md) maps — along with the rest of + RAM — into the higher-half **physmap** (there is no identity mapping; the low half + belongs to user space). So by the time discovery runs, + `@ptrFromInt(physicalToVirtual(rsdp_phys))` is a valid pointer. This is the concrete form of the "capture the description pointer" step sketched in [discovery.md](discovery.md) — a plain `acpi_rsdp: u64` rather than a tagged handle, @@ -75,13 +77,13 @@ and then reads the root-table pointer *out of it*. Which pointer depends on the version, because the RSDP carries **both**: ```zig -const rsdp: *const RootSystemDescriptionPointer = @ptrFromInt(rsdp_phys); +const rsdp: *const RootSystemDescriptionPointer = @ptrFromInt(physicalToVirtual(rsdp_phys)); if (!std.mem.eql(u8, &rsdp.signature, "RSD PTR ")) return error.BadRsdpSignature; -if (!checksumOk(@ptrFromInt(rsdp_phys), 20)) return error.BadRsdpChecksum; +if (!checksumOk(@ptrFromInt(physicalToVirtual(rsdp_phys)), 20)) return error.BadRsdpChecksum; if (rsdp.revision >= 2) { // ACPI 2.0+: use the 64-bit XSDT pointer (the 32-bit RSDT is deprecated) - const xsdp: *const ExtendedSystemDescriptorPointer = @ptrFromInt(rsdp_phys); + const xsdp: *const ExtendedSystemDescriptorPointer = @ptrFromInt(physicalToVirtual(rsdp_phys)); try walkRoot(u64, xsdp.extended_system_descriptor_table_address, …); } else { // ACPI 1.0: use the 32-bit RSDT pointer @@ -120,13 +122,15 @@ namespace and the port grant. **The kernel hands the service what it needs and no more.** Reading PM1 event blocks and GPE blocks requires the FADT, which the kernel already parses for its -own `\_S5` poweroff. Rather than re-parse, the kernel appends the **FADT as one +own power register map (feeding reboot), the PM timer, and the SCI line — the +kernel itself has no S5/poweroff path. Rather than re-parse, the kernel appends the **FADT as one more memory resource** on the `acpi-tables` node; the service tells it apart from the AML blob resources by signature — the FADT keeps its intact `"FACP"` header, while the blob resources are header-stripped bytecode that starts with no signature. The kernel's own FADT parse is untouched; the service reads the -PM1 *event* blocks (which the kernel never parsed — it only needs PM1 *control* -for `\_S5`) and the GPE0/GPE1 blocks straight from its copy. The **SCI itself** +PM1 *event* blocks (which the kernel never parsed — it extracts only the PM1 +*control* register, and it is the service, not the kernel, that writes it for +`\_S5`) and the GPE0/GPE1 blocks straight from its copy. The **SCI itself** arrives as the node's one `len == 1` irq resource (distinct from the broad `[0, 256)` window that covers children's legacy lines), which is how the service finds the line to `irq_bind`. @@ -141,8 +145,9 @@ some firmwares boot with it already set), sets `PWRBTN_EN`, and on each SCI: service evaluates its `\_GPE._L%02X` (level) or `_E%02X` (edge) handler method, drains the **Notify** queue that method produced, maps each notified device to an event (battery, AC, lid, or a generic `notify` with its code), - and clears the status bit. A missing handler method is clear-and-log, not an - error. Making GPEs work required teaching the interpreter one opcode it never + and clears the status bit. A missing handler method is not an error: the + status bit is cleared and the event silently dropped. Making GPEs work + required teaching the interpreter one opcode it never handled — `Notify` (`0x86`) — which it now folds into a bounded queue drained per evaluation; everything else a handler needs (field access, control flow, method calls) was already proven by the ring-3 `_STA`/`_CRS` work. @@ -152,8 +157,9 @@ so GPE/Notify correctness is proven by **host unit tests** — hand-encoded AML with a `Notify` inside a method body, run under `zig build test`. The QEMU `power-button` scenario proves the fixed-event path end to end: a QMP `system_powerdown` injects a real ACPI power-button press, and the service's SCI -handler must log it. Battery/AC/lid and the embedded controller's `_Qxx` queries -are interface-complete but validated on real hardware later. +handler must log it. Battery/AC/lid mapping is interface-complete but validated +on real hardware later; the embedded controller's `_Qxx` queries are out of +scope. The service surface these events are *published on* — subscription, the event vocabulary, and orderly shutdown — is the power service, [power.md](power.md). diff --git a/docs/architecture.md b/docs/architecture.md index 8702ff8..1398f5e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -61,7 +61,7 @@ There are really two independent questions, and it's worth not conflating them: `BOOTX64.efi`, a distinct executable from the kernel ELF. On a Pi there is no separate loader at all — the firmware jumps straight into the kernel with a device-tree pointer, so that entry work would live in the AArch64 architecture code. - Either path converges on the same neutral [`BootInfo`](memory-map.md). + Either path converges on the same neutral [`BootInformation`](memory-map.md). ## Current x86_64 contents diff --git a/docs/arm.md b/docs/arm.md index db0df85..797e7ca 100644 --- a/docs/arm.md +++ b/docs/arm.md @@ -18,7 +18,7 @@ matters for understanding why. This page maps the landscape so the new ISA. They are as different from each other as either is from x86-64: separate registers, -page-table formats, and calling conventions. Each needs its own `system/kernel/arch//`. +page-table formats, and calling conventions. Each needs its own `system/kernel/architecture//`. ## The Raspberry Pi models @@ -57,9 +57,9 @@ the DTB/ACPI tells you what devices exist. ## What danos needs, layer by layer -- **One CPU arch module: `system/kernel/arch/aarch64/`** — covering the Zero 2 W and Pi 3-5, +- **One CPU arch module: `system/kernel/architecture/aarch64/`** — covering the Zero 2 W and Pi 3-5, providing the same `arch` interface as x86_64: `halt`, context switch, - interrupt/exception vectors, page tables, a UART, a timer. No `system/kernel/arch/arm/` is + interrupt/exception vectors, page tables, a UART, a timer. No `system/kernel/architecture/arm/` is planned (see the decision above), so there's a single ARM backend to write. - **A device-tree boot path.** Since stock Pis boot via DTB, danos needs an entry that parses the DTB's `/memory` and `/reserved-memory` into the neutral @@ -67,11 +67,16 @@ the DTB/ACPI tells you what devices exist. from a different source. This is where keeping boot-protocol knowledge on the loader side (as we did for the UEFI memory-map classification) pays off. - **The UEFI loader mostly carries over.** `boot/efi.zig` is largely - boot-*protocol* code (`std.os.uefi` protocol calls), not x86 code. Its only truly - x86-specific bits are the ELF machine check (`.X86_64`) and the SysV calling - convention for the kernel jump. So an `aarch64`-UEFI target (QEMU `virt` + AAVMF) - can reuse it — which makes **aarch64-UEFI the easiest second target**, easier than - the device-tree Pi. + boot-*protocol* code (`std.os.uefi` protocol calls), not x86 code. Its truly + x86-specific bits are the ELF machine check (`.X86_64`), the SysV calling + convention for the kernel jump, the `hlt` park on failure — and, the substantial + one, the bootstrap page tables: `buildBootstrapTables` builds x86-64 4-level + tables (PML4/PDPT/PD index shifts, x86 PTE bits, 2 MiB leaves) and hands the + kernel a CR3. Page-table formats are per-architecture (see above), so an + `aarch64` loader keeps the protocol code but rewrites that builder in the + aarch64 translation-table format. Even so, an `aarch64`-UEFI target (QEMU + `virt` + AAVMF) reuses most of it — which makes **aarch64-UEFI the easiest + second target**, easier than the device-tree Pi. ## Pi hardware quirks (for when we port) diff --git a/docs/coding-standards.md b/docs/coding-standards.md index 7015eb8..d4c7797 100644 --- a/docs/coding-standards.md +++ b/docs/coding-standards.md @@ -5,8 +5,8 @@ Conventions for danos source. The overriding one, from which most of the rest fo > **Names are spelled out in full. An identifier is not abbreviated unless the > abbreviation is an acronym.** -`interruptDispatch`, not `intDisp`. `message_len`, not `message_len` (`msg` expands, `len` -is a Zig idiom — see the exceptions). `devices_broker`, not `devices_broker`. `scheduler`, not +`interruptDispatch`, not `intDisp`. `message_len`, not `msg_len` (`msg` expands, `len` +is a Zig idiom — see the exceptions). `devices_broker`, not `dev_broker`. `scheduler`, not `sched`. The cost of a longer name is paid once, at the keyboard; the cost of a cryptic one is paid every time the code is read, by everyone who reads it. In a microkernel whose whole argument is that a human can hold each piece in their head, @@ -98,8 +98,8 @@ Three, and only three. That's all — no Unix-abbreviation exception. The source directories are full words (`system`, `library`, not `src`/`lib`), and there is no daemon `d` suffix: a driver lives in `system/drivers/` and a service in `system/services/`, so the *location* -already says what it is. Encoding the role in the name too (`busd`, `vfsd`) is -redundant — the program is just `ps2-bus`, `vfs`. Don't put in a name what its directory +already says what it is. Encoding the role in the name too (`busd`, `fatd`) is +redundant — the program is just `ps2-bus`, `fat`. Don't put in a name what its directory already tells you. ## A note on collisions diff --git a/docs/danos-file-system-hierarchy-FSH.md b/docs/danos-file-system-hierarchy-FSH.md index c79bba9..d4b1b43 100644 --- a/docs/danos-file-system-hierarchy-FSH.md +++ b/docs/danos-file-system-hierarchy-FSH.md @@ -1,6 +1,6 @@ # DanOS Filesystem Hierarchy Standard (DFHS) -Most modern Unix and Unix-like operating systems follow the FHS. DanOS has its own FHS structure which extends the unix FHS. This is provided by virtual file system driver (VFS). +Most modern Unix and Unix-like operating systems follow the FHS. DanOS has its own FHS structure which extends the unix FHS. Root path resolution is provided by the kernel-resident VFS root (`fs_resolve`, `system/kernel/vfs.zig`); mounted filesystem servers serve the subtrees they own. ## Directory structure @@ -18,7 +18,7 @@ Most modern Unix and Unix-like operating systems follow the FHS. DanOS has its o | /system | DanOS operating system files (similar idea to C:\Windows). A true representation of danos — its layout mirrors the source tree, so `/system` is what danos *is*. | | /system/devices | danos virtual device tree e.g. similar to /sys on linux but with danos device tree conventions (the structures in the devices module) | | /system/drivers | driver binaries, one sub-project each (e.g. /system/drivers/pci-bus, /system/drivers/ps2-bus) | -| /system/services | system-service binaries — the VFS server, init, and other user-mode servers (e.g. /system/services/vfs, /system/services/init) | +| /system/services | system-service binaries — init, the FAT server, and other user-mode servers (e.g. /system/services/init, /system/services/fat) | | /system/kernel | the kernel image | | /tmp | Directory for temporary files (see also /var/tmp). Often not preserved between system reboots and may be severely size-restricted. | | /usr | Secondary hierarchy for read-only user data; contains the majority of (multi-)user utilities and applications. Should be shareable and read-only. | @@ -48,10 +48,13 @@ willing to serve them, addressed by name. A device node is not a file the VFS can read. The bytes live in a driver process ([drivers.md](drivers.md)), so opening a `/dev` name has to resolve to that driver's -IPC endpoint, and subsequent reads and writes are calls against it. This is what -`system/services/vfs/vfs.zig` reserves for M10 and what the `Stat.kind` field is for; **none of it is -implemented today.** The current VFS is a flat, in-memory ramfs of eight nodes, with no -directories at all and `kind` hardcoded to zero. The three sections below describe the +IPC endpoint, and subsequent reads and writes are calls against it. Resolve-to-endpoint +is exactly what the kernel's `fs_resolve` already does for any mounted backend, and +`FileStatus.kind` is the field that marks a device node; **what is not implemented today +is `/dev` itself** — no service mounts it. (The flat eight-node ramfs this section once +described is retired: the kernel-resident VFS root in `system/kernel/vfs.zig` serves the +read-only `/system` initrd mount with real directories and node kinds, and filesystem +backends such as the FAT server mount the rest.) The three sections below describe the intended shape, and are honest about which parts the kernel can already support. ### Character devices @@ -107,12 +110,13 @@ yielding unpredictable bytes. These are the only `/dev` entries danos can implement immediately, and they are the sensible place to start, because they are exactly the entries that need no driver -process, no `device_claim`, no MMIO grant and no interrupt. The VFS server answers them -out of its own address space — `null` and `zero` are a few lines each in -`system/services/vfs/vfs.zig`'s `read` and `write` handlers. Doing so forces the two pieces of -structure that every later device node depends on and that the flat ramfs currently -lacks: a directory, so that `/dev/null` is a path rather than a name; and a populated -`Stat.kind`, so that a caller can tell a character device from a regular file. +process, no `device_claim`, no MMIO grant and no interrupt. A future pseudo-device +service would answer them out of its own address space — `null` and `zero` are a few +lines each in its `read` and `write` handlers — and mount itself at `/dev` the way the +FAT server mounts `/mnt/usb`. The two pieces of structure every later device node +depends on (and that the flat ramfs of the time lacked) exist now: directories, so that +`/dev/null` is a path rather than a name; and a populated `FileStatus.kind`, so that a +caller can tell a character device from a regular file. `/dev/random` is the one that is not free. It needs an entropy source, and the honest options on this kernel are `RDRAND`/`RDSEED` where CPUID advertises them, and the HPET diff --git a/docs/device-interrupts.md b/docs/device-interrupts.md index b6f3e81..716eb81 100644 --- a/docs/device-interrupts.md +++ b/docs/device-interrupts.md @@ -87,7 +87,9 @@ because they decide whether we read time with a cheap `rdtsc` or fall back to th frequency scaling — useless as wall time. Modern CPUs (all of danos's targets) provide an **invariant TSC**: a constant rate across P/C-states that never stops. The guarantee is a CPUID bit — leaf `0x80000007`, EDX bit 8 — on both Intel *and* AMD. danos reads it in -`calibrate`, and a TSC that doesn't advertise it is not used as the clocksource. AMD is +`calibrate`, and a TSC that doesn't advertise it is demoted to the HPET clocksource — +provided a usable HPET exists (64-bit; a 32-bit one wraps too fast to stay monotonic). +With no such fallback the TSC stays, there being nothing steadier to switch to. AMD is why this matters in practice: it doesn't populate the Intel leaf `0x15` that enumerates the TSC *frequency*, so danos already measures AMD's rate against the HPET — but a measured frequency without the invariance guarantee is not enough. @@ -127,6 +129,10 @@ if (state.vector < 32) { // else: spurious/unhandled — deliberately no EOI ``` +(A third branch has since joined for user mode, elided here: +`state.vector == system_call_vector` (128) hands the trap frame to the ring-3 +syscall handler.) + Two things make device interrupts *return* where exceptions don't: 1. **The handler returns.** The timer handler just bumps a tick counter. Control @@ -151,8 +157,10 @@ needs, so only the handler can sequence it. See [drivers.md](drivers.md), where device is quieted by a driver in ring 3, long after the ISR has returned. A device handler is a plain `fn () void` — a timer or keyboard handler doesn't need -the interrupted registers. (Note: the stubs don't save the SSE/vector registers, so -a handler must not use them; ours don't.) +the interrupted registers. (The stubs originally didn't save the SSE/vector +registers, so a handler couldn't use them; `isr_common` now does an +`fxsave`/`fxrstor` of the full SSE/x87 state around dispatch — see +[interrupts.md](interrupts.md).) ## Turning them on diff --git a/docs/device-manager.md b/docs/device-manager.md index 70ace1c..8fce9ae 100644 --- a/docs/device-manager.md +++ b/docs/device-manager.md @@ -73,8 +73,8 @@ one world. | Direction | Message | Purpose | |---|---|---| | driver → manager | `hello { version, role, device_id }` | confirms the argv assignment, starts the deadline clock | -| bus → manager | `child_added { parent, identity, resources }` | one node the bus discovered | -| bus → manager | `child_removed { id }` | unplug, or the bus lost it | +| bus → manager | `child_added { parent, bus_address, identity, device_id, hid }` | one node the bus discovered | +| bus → manager | `child_removed { parent, bus_address }` | unplug, or the bus lost it | | app → manager | `enumerate` | snapshot of the tree (read-only) | | app → manager | `subscribe` | receive published add/remove events | @@ -147,8 +147,10 @@ published exit events, signals + `runtime.process`). On top of those: to a manager-internal seam. 8. **Discovery migration** — DONE (M19–M20, 2026-07-13): enumeration moved to ring 3 as swappable per-firmware discoverers — the pci-bus driver (M19) then - the acpi service (M20), see [discovery.md](discovery.md); the kernel seeds - only the host bridge and the acpi-tables node. Matching moved with it: + the acpi service (M20), see [discovery.md](discovery.md); of the enumerable + devices, the kernel seeds only the host bridge and the acpi-tables node (the + non-enumerable platform nodes — processors, interrupt controllers, the HPET, + the loader's framebuffer — stay kernel-seeded too). Matching moved with it: `child_added` grew a `device_id` (the kernel-registered id, `no_device` for unregistered leaves like USB ports) and a firmware `hid`, and the manager now matches drivers from those **reports** rather than its boot-time snapshot. The @@ -167,6 +169,9 @@ published exit events, signals + `runtime.process`). On top of those: - **Manager death**: drivers survive the manager; the restarted manager re-learns the world (above). Checkpointing driver state with the manager is deferred until something demonstrates the need. -- **Matching stays code until the third bus.** `driverFor`/`pciDriverFor` are - honest at two bus types; the third triggers the manifest (a driver declares what - it binds: a PCI class triple, a USB class triple, an ACPI `_HID`). +- **Matching stays code until the third bus.** `driverFor`/`pciDriverFor` were + honest at two bus types; the third was expected to trigger the manifest (a driver + declares what it binds: a PCI class triple, a USB class triple, an ACPI `_HID`). + (Since then: the third bus — USB — arrived and is matched in code too. Today's + matchers are `pciDriverForIdentity`, `hidDriverFor`, and `usbDriverForIdentity`; + the manifest waits until code matching actually hurts.) diff --git a/docs/discovery.md b/docs/discovery.md index 554f75b..09ed213 100644 --- a/docs/discovery.md +++ b/docs/discovery.md @@ -7,19 +7,23 @@ tree** (DTB). This note is a design plan, not built yet: *when* danos should tac it, and *how* to keep it architecture-agnostic — the same discipline the [memory map](memory-map.md) and [architecture split](architecture.md) already follow. -## What the kernel assumes today +## What the kernel assumed when this plan was written -Right now danos discovers almost nothing — it coasts on legacy PC fixtures that are -guaranteed to exist under QEMU + UEFI: +At the time danos discovered almost nothing — it coasted on legacy PC fixtures that +are guaranteed to exist under QEMU + UEFI: -- `arch/x86_64/apic.zig` assumes the **Local APIC** at the default `0xFEE0_0000` and - calibrates its timer against the **PIT** (the legacy 8254). -- `arch/x86_64/serial.zig` hardcodes **COM1** at I/O port `0x3F8`. +- `system/kernel/architecture/x86_64/apic.zig` assumed the **Local APIC** at the + default `0xFEE0_0000` and calibrated its timer against the **PIT** (the legacy + 8254). (Today the PIT is the *last-resort* reference: calibration prefers the + CPUID-reported TSC frequency, then the HPET, then the ACPI PM timer.) +- `system/kernel/architecture/x86_64/serial.zig` hardcoded **COM1** at I/O port + `0x3F8`. (Today `0x3F8` is only the default: the kernel loopback-probes the UART + and parses ACPI's SPCR table to target the firmware's actual debug port.) - The framebuffer and memory map come from **UEFI** — that *is* discovery, just done by the firmware and handed over, not read from ACPI. -This works only because PC-compatible hardware promises those legacy pieces exist at -those addresses. It is a crutch, and it does not travel. +This worked only because PC-compatible hardware promises those legacy pieces exist at +those addresses. It was a crutch, and it did not travel. ## The forcing functions: when to build it @@ -53,8 +57,8 @@ it isn't really agnostic. ## The one cheap step to take sooner -Have the **loader capture the description pointer** into `BootInfo` — a neutral -handle, no parsing: +Have the **loader capture the description pointer** into `BootInformation` — a +neutral handle, no parsing: ```zig pub const HardwareInfo = extern struct { @@ -146,7 +150,7 @@ free; discovery on x86 is partly about *finding* what ARM just tells you. ## Suggested ordering 1. **Now (cheap):** plumb the neutral `HardwareInfo` pointer through the loader into - `BootInfo`. No parser yet. + `BootInformation`. No parser yet. 2. **Next milestone unchanged:** user mode + address-space isolation — needs no discovery. 3. **With the aarch64 port:** build the agnostic discovery layer, **DTB first** (the @@ -182,15 +186,20 @@ the static tables (MADT, HPET, MCFG, FADT + `\\_S5`) stay kernel-side. The kernel no longer folds the AML namespace's Device objects into the device tree. It still parses the *static* tables (MADT for SMP, HPET for the tick, MCFG -for the host bridge, FADT) and still builds the AML namespace — but only to read -the `\\_S5` sleep type for poweroff. Device discovery is the ring-3 **acpi +for the host bridge, FADT); at this point it also still built the AML namespace — +but only to read the `\\_S5` sleep type for poweroff. (That remnant is gone too: +the kernel now runs no AML at all — soft-off belongs to the acpi service, and the +kernel keeps only the AML-free reboot path.) Device discovery is the ring-3 **acpi service** ([device-manager.md](device-manager.md)): it claims the `acpi-tables` node the kernel publishes (the AML blobs, a broad io_port grant, the SCI), re-parses the same blobs with the shared AML module, evaluates `_STA`/`_CRS`, and registers + reports each `_HID` device — the device manager matches drivers (ps2-bus) from those reports. With M19's pci-bus driver, discovery now runs -entirely in user space; the kernel seeds only the host bridge and the -acpi-tables node. +entirely in user space, anchored on two kernel-seeded nodes: the host bridge and +the acpi-tables node. (The kernel's static-table parse also seeds the processor, +interrupt-controller, and HPET timer nodes, and it publishes the boot +framebuffer as a claimable display node — but no *enumeration* happens in +ring 0.) ## Discovery is a swappable process per firmware (M19–M20) @@ -231,11 +240,14 @@ Two consequences of neutrality bind on later work: Two supporting decisions keep the kernel's remaining slice honest: -- **The AML interpreter is a shared build module**, compiled into both the - kernel and the acpi service — one source, two builds, no fork. The kernel - links it for the `\_S5` poweroff evaluation, the service links it for - everything else, and the `acpi-parse` test asserts the two produce the same - device count across the ring-3 move. +- **The AML interpreter is a single build module** + (`system/devices/aml/aml.zig`) — one source, no fork. During the ring-3 move + it was compiled into both the kernel (which linked it just for the `\_S5` + poweroff evaluation) and the acpi service, with the `acpi-parse` test + asserting the two produce the same device count. Since soft-off followed + discovery out of the kernel, only the acpi service links the module — the + kernel runs no AML — and the test now asserts a device-count *floor* for the + ring-3 parse instead, there being no kernel count left to equal. - **Bridge apertures come from the firmware memory map, not AML.** Registered PCI functions carry BAR resources, and `device_register` containment demands the bridge own windows that cover them. Those apertures are derived diff --git a/docs/display-plan.md b/docs/display-plan.md index 8729e0b..b8195c5 100644 --- a/docs/display-plan.md +++ b/docs/display-plan.md @@ -7,7 +7,8 @@ Read [display.md](display.md) first for the *why*; this is the *what* and the *o ## 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. + display node (found by class, not name) 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 diff --git a/docs/display-v2.md b/docs/display-v2.md index 8061a55..3a226e7 100644 --- a/docs/display-v2.md +++ b/docs/display-v2.md @@ -29,7 +29,7 @@ The compositor itself (layers, back buffer, damage) does not change. Only the la │ └─ VirtioGpuBackend talks to a virtio-gpu driver process over a `scanout` service: present via a shared resource + fenced flush, - EDID mode list, runtime mode-set. + a fixed mode list, runtime mode-set, EDID refresh rate. ``` A **backend** is a small interface the compositor calls: @@ -53,10 +53,12 @@ manager brings it up after boot), and because danos is meant to be resilient: blank screen while drivers load — the exact v1 behaviour. 2. **Upgrade on announce.** When the virtio-gpu driver has claimed its device and set up a scanout, it **announces itself to the display service** (a `push`: the driver looks up - `.display` and sends an *attach-scanout* message carrying its `scanout` endpoint as a - capability). The compositor switches to `VirtioGpuBackend` and re-presents the current - frame full-screen. Push beats polling — the compositor doesn't know a priori which - driver, if any, exists, and danos has no service-registration pub/sub. + `.display` and sends an *attach-scanout* message carrying the shared scanout **surface** + as a capability; the compositor maps it and reaches the driver's present/mode channel by + looking up the registered `scanout` service). The compositor switches to + `VirtioGpuBackend` and re-presents the current frame full-screen. Push beats polling — + the compositor doesn't know a priori which driver, if any, exists, and danos has no + service-registration pub/sub. 3. **Native is restartable, not fallback-on-crash.** Once a native driver has reprogrammed the device, the firmware's GOP framebuffer is **stale** — "native → GOP" is not a clean fall-back. So a native driver that **crashes** is *restarted* by its supervisor (the @@ -64,9 +66,10 @@ manager brings it up after boot), and because danos is meant to be resilient: (native → native). The screen freezes on the last frame during the gap — acceptable. 4. **GOP is the floor for "no driver was ever there."** On a real GPU (NVIDIA/AMD/Intel) the class-0x03 device matches nothing in the driver table, no `scanout` is ever - announced, and the compositor stays on GOP forever — no special-casing. Only if a - native driver *permanently* gives up (crash-loop cap) does the compositor attempt GOP - again, and even then only if the LFB is still mappable. + announced, and the compositor stays on GOP forever — no special-casing. If a native + driver *permanently* gives up (the device manager's crash-loop cap), nothing tells the + compositor and there is no path back to GOP — the screen stays frozen on the last + frame. A GOP revert (sensible only while the LFB is still mappable) is not built. ## The shared-memory primitive this needs @@ -91,11 +94,15 @@ reference instead of drawing by command). One piece of kernel work, two features A new ring-3 driver process (the topology v1 anticipated — "split the driver from the compositor when a second backend arrives"). It claims the virtio-gpu PCI function, and: -- sets up the **virtqueues** (control + cursor) and the device's config space, +- sets up the **control virtqueue** (queue 0 — the only queue it uses; no cursor queue) + and the device's config space, - creates a **2D scanout resource** backed by a shared-memory region, `attach_backing`s it, - `set_scanout`s it to a CRTC, and `resource_flush`es damaged rectangles, -- reads **EDID** (the `GET_EDID` control command) for the mode list, and `set_scanout` - at a chosen mode for **runtime mode-setting**, + `set_scanout`s it to a CRTC, and on each present transfers and `resource_flush`es the + full current-mode rectangle (damage-narrowed flushes are a later refinement), +- reads **EDID** (the `GET_EDID` control command) to log the monitor's preferred timing + and derive the refresh rate it announces (the compositor's frame-clock seed); the mode + list it offers is a fixed pair — 640×480 and 800×600 — and `set_scanout` at a chosen + mode gives **runtime mode-setting**, - registers a `scanout` service and announces to the display service. Its `resource_flush` is the real **present** — and gives a **fenced, tear-free** path a @@ -111,8 +118,9 @@ the existing IRQ-as-IPC path) or the compositor's own frame clock. ## What v2 unlocks — and its honest scope -Behind the abstraction, a native backend gives runtime **mode-setting** (resolution / -refresh / bpp), **EDID** enumeration, and **fenced presents**. But only on devices we have a driver +Behind the abstraction, a native backend gives runtime **mode-setting** (resolution only — +the scanout protocol carries neither refresh nor bpp), an **EDID**-derived refresh rate, and +**fenced presents**. But only on devices we have a driver for — realistically **VMs** (virtio-gpu, and later maybe Bochs DISPI). Real discrete GPUs need per-vendor KMS-class drivers that aren't getting written, so they **stay on GOP** — which is genuinely fine (v1 on the NVIDIA box is smooth). So v2's real value is twofold: diff --git a/docs/display.md b/docs/display.md index e6b04dc..596b903 100644 --- a/docs/display.md +++ b/docs/display.md @@ -25,7 +25,7 @@ which one you're holding decides what you can do. 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. + pitch, format, refresh_hz}`, and nothing more. - **The PCI class-0x03 device is the raw controller** — BARs, config space, registers, IO ports. It is what you actually *own* after boot. On QEMU's emulated adapter @@ -69,28 +69,30 @@ rest of the system hasn't had to face: [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` +2. **danos had no cross-process shared memory.** At v1 the memory syscalls were `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. + use it — it would have to *map* another process's memory, which nothing allowed. v1 + sidesteps it entirely (see "What v1 does not do"); v2 has since built the primitive + (`shared_memory_create` / `shared_memory_map` / `shared_memory_physical` — + [display-v2.md](display-v2.md)). ## Architecture ``` kernel ── owns the boot framebuffer; bootstrap console only - │ seeds a "display0" device node from BootInformation.framebuffer + │ seeds a display-class device node from BootInformation.framebuffer │ (ResourceKind.memory = [base, height*pitch], write-combining hint, - │ plus DisplayInfo{width, height, pitch, format}) + │ plus DisplayInfo{width, height, pitch, format, refresh_hz}) ▼ display service (system/services/display/, ServiceId.display) ← the compositor - │ device.claim(display0) → mmio_map(WRITE-COMBINING) = FRONT buffer (the LFB) + │ device.claim(display node) → mmio_map(WRITE-COMBINING) = FRONT buffer (the LFB) │ mmap(cacheable) a BACK buffer of the same geometry - │ owns: an ordered LAYER STACK + a per-frame DAMAGE list + │ owns: an ordered LAYER STACK + a per-frame DAMAGE tracker (rect list or tile grid) │ loop: composite dirty layers → back buffer → present dirty rects → front - │ backend is an INTERNAL interface: {gop-fb} today; {bochs-dispi, virtio-gpu} later + │ backend is an INTERNAL interface: {gop-fb} at boot; {virtio-gpu} on hot-attach (v2) ▼ reached by name (ipc_lookup); clients drive it over the display protocol ┌────────────────────────────────────┬──────────────────────────────────────┐ drawing clients (v1) surface clients (deferred) @@ -121,12 +123,15 @@ release-on-death, and re-claim-on-restart for free (the [resilience](resilience. 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 +- The kernel seeds a synthetic **display-class** node into the + [devices-broker](../system/kernel/devices-broker.zig) at init (`seedDisplay`), 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). + `DisplayInfo{width, height, pitch, format, refresh_hz}` (the memory resource says *where* + and *how big*; `DisplayInfo` says how to *interpret* the bytes — and `refresh_hz`, the + panel refresh the loader computed from EDID before `ExitBootServices`, seeds the + compositor's frame clock). The node carries no name or index; it is identified purely by + its `display` device class. - 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 @@ -138,8 +143,8 @@ re-claims it). 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) +`input`/`device-manager`/`fat` ([init.zig](../system/services/init/init.zig)), and it +self-discovers the display node with `device.enumerate` (matching on `DeviceClass.display`). 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. @@ -182,6 +187,12 @@ discovered. 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. +Damage is tracked by one of two interchangeable trackers behind a compile-time +`damage_mode` A/B switch ([display.zig](../system/services/display/display.zig)): a +free-form dirty-rectangle **list** (tight bounds, heuristic merging) or a fixed 64-px +**tile grid** (exact O(1) merging, tile-quantized repaints) — the grid is the default; +[compositor.zig](../system/services/display/compositor.zig) has both, with the trade-off +discussion. 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 @@ -258,15 +269,16 @@ 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) + commands. That needs a cross-process shared-memory primitive — the natural + generalization of the existing M13 [capability passing](driver-model.md) from *endpoints* to *memory objects* (`shared_memory_create(len) → {cap, virtual_address}`, pass `cap` on an `ipc_call`, receiver `shared_memory_map(cap) → virtual_address`). v1 avoids it because server-owned - surfaces already prove the whole pipeline. + surfaces already prove the whole pipeline; v2 has since built exactly that primitive + ([display-v2.md](display-v2.md)) — the client-surface path on top of it is still open. - **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 + resolution / bpp at runtime needs the raw PCI device. The first native backend — since + built by v2 ([display-v2.md](display-v2.md)) — is virtio-gpu, 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. diff --git a/docs/driver-model.md b/docs/driver-model.md index 23e1946..3f1030b 100644 --- a/docs/driver-model.md +++ b/docs/driver-model.md @@ -33,7 +33,7 @@ plain bus driver with no controller — a USB hub — is also a real thing. ## The device table is the spine danos already has the right central structure. `system/kernel/devices-broker.zig` holds a table of -`DeviceDesc`, each with a parent, a class, and a set of resources. Firmware discovery +`DeviceDescriptor`, each with a parent, a class, and a set of resources. Firmware discovery seeds it ([discovery.md](discovery.md)); `device_register` grows it. Three invariants make it a capability system rather than a directory: @@ -67,7 +67,7 @@ const base = dev.mmioMap(bus.id, 0).?; // 2. enumerate it — from the har const n = ((cap.* >> 8) & 0x1F) + 1; // GENERAL_CAP says how many children for (0..n) |i| { // 3. publish each child - var child = std.mem.zeroes(dev.DeviceDesc); + var child = std.mem.zeroes(dev.DeviceDescriptor); child.class = @intFromEnum(dev.DeviceClass.timer); child.resource_count = 1; child.resources[0] = .{ .kind = memory, @@ -96,8 +96,11 @@ A "family" is two modules, not one: *whatever* published its device. This is the part that makes class drivers portable. danos already has one of each: `library/runtime/device.zig` is a logic module, -[`system/services/vfs/protocol.zig`](system/services/vfs/protocol.zig) is a protocol module shared by `system/services/vfs/vfs.zig` -and its clients. The pattern generalises directly: +[`system/vfs-protocol.zig`](system/vfs-protocol.zig) is a protocol module shared by the +mount backends (today the fat server) and their clients. (The user-space VFS server it +was originally written against has since retired — path routing moved into the kernel, +`system/kernel/vfs.zig`'s `fs_resolve` — but the protocol module outlived it, which is +rather the point.) The pattern generalises directly: ``` library/ @@ -107,7 +110,7 @@ library/ pci/ module "pci" — ECAM, BAR decode, capability walk usb/ module "usb" — descriptors, control transfers, hubs proto/ - vfs/ module "vfs-protocol" (today: system/services/vfs/protocol.zig) + vfs/ module "vfs-protocol" (today: system/vfs-protocol.zig) block/ module "block-protocol" hid/ module "hid-protocol" @@ -117,10 +120,11 @@ system/drivers/ one sub-project each → /system/drivers (no `d` suffix) block/ class driver imports runtime, block-protocol ``` -The only build change needed: [`addUserBinary`](build.zig) currently takes exactly one -module (`rt_mod`) and injects it. It should take a slice of modules. That's a -five-line change, and it's the *entire* mechanism — Zig modules already give you -everything else. +The build side of this has since landed: [`addUserBinary`](build.zig) injects the +default modules (`runtime`, `mmio`, `xkeyboard-config`, `acpi-ids`) into every user +binary, and per-binary extras — protocol modules, bus logic — are added with +`programModule(exe).addImport(...)`. That's the *entire* mechanism — Zig modules +already give you everything else. The discipline that makes this work: **a class driver must not import a bus's logic module.** `usbhid` imports `proto.hid` and `usb` (for descriptor types), never `pci`. @@ -132,20 +136,24 @@ If a class driver needs `mmio`, it has become an HCD and should be one. grants, `device_grant` teardown. - **M11** — `irq_bind` / `irq_ack`. IRQ delivered as an IPC notification; mask before EOI; `irq_ack` is the unmask. -- **M12** — `parent` in `DeviceDesc`, `device_register` with resource containment. +- **M12** — `parent` in `DeviceDescriptor`, `device_register` with resource containment. - **M13** — capability passing. `ipc_call` / `ipc_reply_wait` grew a `send_cap` argument and a `received_cap` return (r8): an endpoint travels with a message, installed into the receiver's handle table (shared, refcount-bumped — a copy, not a move). A full table fails `-ENOSPC` and does not half-deliver. This is the "open" primitive — a bus driver mints a per-device endpoint and hands it to a class driver. The runtime exposes - `callCap` and `replyWait(..., send_cap)`; no class driver consumes it yet. + `callCap` and `replyWait(..., send_cap)`, and class drivers consume them now: the + PS/2 keyboard and mouse drivers attach to ps2-bus this way, and `runtime.usb` / + `runtime.input` open their per-device and subscription channels with `callCap`. - **M14** — DMA memory + the memory-ordering layer. `/lib/mmio` gives drivers typed volatile access and `mb`/`rmb`/`wmb` (per-arch); `dma_alloc`/`dma_free` grant physically-contiguous, pinned, uncacheable, reclaim-on-teardown buffers with the physical address exposed (`pmm.allocContiguous`, a DMA arena, `mapUserDmaInto`). `dma_below_4g` caps the address for legacy engines; `dma_write_combining` is accepted - but falls back to coherent until PAT is programmed. The bus drivers use `/lib/mmio`; - no DMA driver consumes `dma_alloc` yet. + but falls back to coherent until PAT is programmed. The bus drivers use `/lib/mmio`, + and `dma_alloc` has real consumers now: the xHCI driver's rings and contexts, + usb-storage's command/status wrappers, virtio-gpu's virtqueue, and the fat + service's bounce buffer. - **M15** — interrupts for PCI devices, the MSI half. Discovery now gives every PCI function its 4 KiB ECAM config space as resource 0 (unblocking the capability walk with no new syscall), and `msi_bind(device_id, endpoint) -> address, data` allocates a @@ -175,8 +183,11 @@ If a class driver needs `mmio`, it has become an HCD and should be one. the hardware and spawns each driver ([drivers.md](drivers.md)). Ungated for now — a spawn capability is future work. -So: **bus drivers work now, and they're started by the device manager, not the kernel.** -HCDs and class drivers do not work yet. Here is exactly why, and exactly what would fix it. +So: **all three shapes work now, and they're started by the device manager, not the +kernel.** The xHCI driver is the HCD-and-bus proof; the USB HID/storage and PS/2 class +drivers reach their devices purely over IPC. What follows are the original design notes +for the primitives that unblocked each shape — exactly why each was the blocker, and +exactly what fixed it. --- @@ -302,8 +313,10 @@ barrier, or per-arch inline asm — which is what `library/mmio.zig` should hide rather than an out-struct. The rest of this section is the original design note.* **The blocker, and it's a hard one.** No PCI device can take an interrupt today. -[`addBars`](system/devices/acpi.zig) records `.memory` and `.io_port` BARs and never an -`.irq`; there is no `_PRT` parsing anywhere in the tree. The HPET is the one exception — +`addBars` (then in the kernel's ACPI discovery; BAR decode has since moved to the +ring-3 pci-bus driver, `system/drivers/pci-bus/pci-bus.zig`) records `.memory` and +`.io_port` BARs and never an `.irq`; there is no `_PRT` parsing anywhere in the tree. +The HPET is the one exception — it advertises its own interrupt routing in its own registers, a privilege no ordinary device has. diff --git a/docs/drivers.md b/docs/drivers.md index 5879b7a..dfb2575 100644 --- a/docs/drivers.md +++ b/docs/drivers.md @@ -31,8 +31,8 @@ kernel ──spawns──► init (PID 1) ──spawns──► device-manag | | | spawns only init, the service supervisor: the driver supervisor: enumerates publishes the starts the system /system/devices, matches each device - initial-ramdisk services (vfs, the to a driver, and system_spawn's it - so user space can device-manager). Its + initial-ramdisk services (device-manager, to a driver, and system_spawn's it + so user space can fat, logger, ...). Its system_spawn from it list is init policy. ``` @@ -42,15 +42,22 @@ in the initial-ramdisk as a fresh ring-3 process — `name` becoming its argv[0] the optional arguments its argv[1..], on a SysV entry stack, see sysv.md). Everything else is a user-space decision: - **init** ([system/services/init](system/services/init/init.zig)) is the **service - supervisor**. It spawns the system services danos brings up at boot — today `vfs` and - the `device-manager` — from a small list. Drivers are deliberately *not* its job. + supervisor**. It spawns the system services danos brings up at boot — today `input`, + the `device-manager`, `fat`, `display`, `display-demo`, and the `logger` — from a + small list. Drivers are deliberately *not* its job. (An earlier draft listed a `vfs` + service here; that service is retired — the router moved into the kernel as + `fs_resolve`.) - **device-manager** ([system/services/device-manager](system/services/device-manager/device-manager.zig)) is the **driver supervisor**. It does the three steps a monolithic kernel would do in its probe path, entirely from ring 3: 1. **Discover** — `device_enumerate` snapshots the device table the kernel built from ACPI/PCI ([discovery](discovery.md)). - 2. **Match** — for each device it looks up a driver by `DeviceClass`. The match policy - is a table (`driverFor`): today a static `timer → hpet` map; a fuller system reads + 2. **Match** — for each device it looks up a driver. The match policy is code, a few + small per-bus tables: from the boot snapshot only the PCI host bridge matches + (→ `pci-bus`); everything else arrives later as bus reports and matches on + identity — `pciDriverForIdentity` (xHCI → `usb-xhci-bus`, virtio-gpu → + `virtio-gpu`), `hidDriverFor` (PNP0303/PNP0F13 → `ps2-bus`), and + `usbDriverForIdentity` (USB keyboard, mouse, storage). A fuller system reads what each driver *binds* (a manifest under `/system/drivers`, or the driver describing its own match). 3. **Spawn** — `system_spawn(driver_name, arguments)` starts the matched driver (the @@ -84,7 +91,7 @@ names a device by id and a resource by index. That indirection is the entire sec model. If `mmio_map` took a physical address, any process could map the kernel's memory; if `irq_bind` took a GSI, any process could bind the keyboard's line and silently intercept it. Instead the kernel checks two things (`process.ownedGsi`, and -the same check at the top of `sysMmioMap`): +the same check at the top of `systemMmioMap`): - `devices_broker.ownerOf(dev_id) == me` — you claimed it, and claims are exclusive - the resource at `res_idx` is of the right *kind* — `memory` for `mmio_map`, `irq` @@ -95,11 +102,15 @@ The claim is the capability. Everything else follows from it. ## Registers: `mmio_map` `mmio_map` walks the caller's page tables and installs the device's physical frames -with `present | user | writable | nx | pcd | pwt` -(`arch/x86_64/paging.zig:mapUserDeviceInto`). Two of those bits are load-bearing: +with `present | user | writable | nx | device_grant` plus a cache mode +(`system/kernel/architecture/x86_64/paging.zig:mapUserDeviceInto`). Two of those bits +are load-bearing: -- **`pcd | pwt`** — strong-uncacheable. A device register is not memory; a cached read - would return a stale value and a write might never leave the CPU. +- **`pcd | pwt`** — strong-uncacheable, the default cache mode. A device register is + not memory; a cached read would return a stale value and a write might never leave + the CPU. The one exception: a resource flagged write-combining + (`resource_flag_write_combining` — today the kernel-seeded display framebuffer) + gets the PAT bit instead, so pixel writes batch into bursts. - **`device_grant`** (bit 9, one of the PTE's available bits) — marks the leaf as MMIO rather than RAM, so `freeSubtree` skips `pmm.free` on it when the address space is destroyed. Without this, killing a driver would hand the HPET's registers back to @@ -143,7 +154,7 @@ interrupt fires exactly once, ever; call it before the device is quiet and you g interrupt storm. That single fact explains why `irq_bind` and `irq_ack` are two syscalls and not one. -This is also why `interruptDispatch` (`arch/x86_64/idt.zig`) no longer issues the EOI +This is also why `interruptDispatch` (`system/kernel/architecture/x86_64/idt.zig`) no longer issues the EOI itself. It used to, before running the handler — correct for the LAPIC timer, and impossible for a routed device line. Each handler now owns its EOI, because only the handler knows which discipline its source needs. @@ -234,10 +245,10 @@ static capability. A device that *contains other devices* — a PCI bridge, a USB hub, or the HPET's block of comparators — needs a driver that enumerates it and tells the kernel what it found. That's `device_register`, and it makes the device table a tree rather than a list -(`DeviceDesc.parent`). +(`DeviceDescriptor.parent`). ```zig -var child = std.mem.zeroes(dev.DeviceDesc); +var child = std.mem.zeroes(dev.DeviceDescriptor); child.class = @intFromEnum(dev.DeviceClass.timer); child.resource_count = 1; child.resources[0] = .{ .kind = memory, .start = bus_base + 0x100, .len = 0x20 }; @@ -248,10 +259,12 @@ The child is left **unclaimed**, which is the whole point: another process claim `mmio_map`s it, and sees only that 0x20-byte window. The rule the kernel enforces is **containment**: every resource of a child must lie -inside a resource of the same kind on its parent. Ranges must nest; an IRQ must match -exactly. This isn't bureaucracy — a `DeviceDesc` is a licence to map physical memory, so -without containment `device_register` would be a syscall for mapping any page you like. A -bus driver may only ever subdivide what it already owns. +inside a resource of the same kind on its parent. Ranges must nest; a child's IRQ — +still exactly one line — must fall within the parent's IRQ range (a length-1 parent +range is the old exact-match rule). This isn't bureaucracy — a `DeviceDescriptor` is a +licence to map physical memory, so without containment `device_register` would be a +syscall for mapping any page you like. A bus driver may only ever subdivide what it +already owns. A device with **no resources** is legal and common. A USB device is reached through its controller, not by MMIO, so it gets `resource_count = 0`. @@ -277,8 +290,13 @@ Several things this list used to warn about are now available (see [driver-model.md](driver-model.md)): **port I/O** (`io_read`/`io_write`, claim-gated by the device's `io_port` resource — direct ring-3 `in`/`out` is still a #GP, so a PS/2 or 16550 driver goes through these), **DMA memory** (`dma_alloc`: contiguous, pinned, -uncacheable, physical address exposed), and **memory barriers** (`/lib/mmio`'s -`mb`/`rmb`/`wmb`). What remains: +uncacheable, physical address exposed), **memory barriers** (`library/mmio`'s +`mb`/`rmb`/`wmb`, imported as the `mmio` module), **fault isolation** (a ring-3 fault kills only the faulting +process — `killCurrentProcess` — and the machine keeps running, +[resilience](resilience.md)), and **reclaim + restart on death** (every path out of a +process releases its claims and IRQ/MSI bindings — `releaseAllOwnedBy`, +`irq.releaseOwner` — and the device manager respawns the driver with backoff, +[device-manager.md](device-manager.md)). What remains: - **Page granularity.** `mmio_map` rounds to 4 KiB. Two devices sharing a page means granting one grants the other. A `device_register`ed child's *resource* can be narrower @@ -289,8 +307,9 @@ uncacheable, physical address exposed), and **memory barriers** (`/lib/mmio`'s are programmed, so `device_claim` on a DMA-capable device is still effectively equivalent to granting ring 0. This is the largest gap between the design's promise and what it delivers; enforcement lands with the first DMA driver. -- **No `dev_release`.** A claim is never dropped (only IRQ/MSI bindings are, on exit), so - a device stays owned for the life of its driver — which blocks restart. +- **No voluntary `dev_release`.** A *live* driver can't drop a claim — only exit + releases it (any path out of a process runs `releaseAllOwnedBy`) — so handing a + device between running drivers still means exiting. - **One endpoint per GSI**, so shared legacy PCI INTx lines can't be split between two drivers. MSI/MSI-X — one vector per device, edge-triggered, unshared — is the real answer, and QEMU's HPET doesn't offer it (`Tn_FSB_INT_DEL_CAP = 0`). @@ -303,13 +322,6 @@ uncacheable, physical address exposed), and **memory barriers** (`/lib/mmio`'s the ISR until `irq_ack`, so at most one badge is ever outstanding. Bind nine devices to one endpoint, though, and a dropped badge leaves that line masked with nobody left to ack it. -- **A faulting driver still kills the machine.** There is no per-process kill path: a - ring-3 page fault halts the kernel, so `releaseIrqs` runs only on a voluntary - `exit`. Fault isolation is the whole premise ([vision](vision.md)) and it is - [not built yet](resilience.md). -- **A dead driver's device is not reclaimed.** `releaseIrqs` unbinds and masks the - line on exit, but the claim is never released — restart is - [not built](resilience.md). - **On real hardware, the mask/EOI cycle may need a remote-IRR flush.** Masking a level-triggered redirection entry with remote-IRR set doesn't clear it on some chipsets, and the line never fires again. QEMU clears it on EOI regardless, so the diff --git a/docs/efi.md b/docs/efi.md index 4762f5a..eb3206b 100644 --- a/docs/efi.md +++ b/docs/efi.md @@ -23,14 +23,19 @@ Partition (ESP)** and running a file at a well-known fallback path: EFI/BOOT/BOOTX64.efi <- the "removable media" default for x86-64 ``` -The boot volume is the **FHS-shaped `zig-out`** itself (see the repository-layout note -in [README.md](README.md)): `build.zig` installs `boot/efi.zig` (built for the `uefi` -target) to `zig-out/EFI/BOOT/BOOTX64.efi` — the one path UEFI firmware fixes — and lays -the rest out by FHS path: the kernel at `zig-out/system/kernel`, init at -`zig-out/system/services/init`, the initial-ramdisk at `zig-out/boot/`. The -`run-x86-64` step points QEMU at OVMF (UEFI firmware for virtual machines) and presents -`zig-out` to the guest as a FAT drive. The firmware finds `BOOTX64.efi` and runs it — -that's our `main()`, which then loads the kernel and init from their FHS paths. +The boot volume is **FHS-shaped** (see the repository-layout note in +[README.md](README.md)): `build.zig` installs `boot/efi.zig` (built for the `uefi` +target) at `EFI/BOOT/BOOTX64.efi` — the one path UEFI firmware fixes — and lays +the rest out by FHS path: the kernel at `system/kernel`, init at +`system/services/init`, the pre-packed boot capsule at `boot/system.img`. +`zig-out` mirrors that tree, but what a machine actually boots is the +self-contained FAT32 image `tools/make-fat-image.py` builds from the same files +(`danos-usb.img`). The `run-x86-64` step points QEMU at OVMF (UEFI firmware for +virtual machines) and attaches that image (its serial-logging twin, built the +same way) as a USB mass-storage device on the xHCI bus — the guest never sees +`zig-out`. The firmware finds `BOOTX64.efi` on the image and runs it — that's +our `main()`, which then loads the kernel and the system binaries from their +FHS paths. ## Boot services: the firmware's API @@ -56,7 +61,15 @@ The comment in `boot()` says exactly this: ## What our loader actually does -`boot()` runs four steps in order: +The four milestones below are the spine of `boot()`. Along the way it also +captures the **ACPI RSDP** from the UEFI configuration table (while boot +services are still up), loads the system binaries into an in-RAM +**initial ramdisk** (`loadSystemTree` — normally a single read of the pre-packed +`boot\system.img` capsule, which already *is* the ramdisk wire format; it falls +back to opening each manifest-listed path, and walks the `/system` tree only as +a last resort for hand-assembled sticks. Best-effort either way — a kernel-only +volume still boots), and builds the **bootstrap page tables** the kernel starts +life on (`buildBootstrapTables`), all before the jump: ### 1. Query the framebuffer (`queryFramebuffer`) @@ -91,16 +104,20 @@ All of this *must* happen now, because after exit there's no GOP to ask. (See may return short, so we loop.) - Parse the ELF: validate the `\x7fELF` magic and the `x86_64` machine type, then walk the program headers. For every `PT_LOAD` segment we: - - reserve the exact physical pages it's linked at (`p_paddr`) via + - reserve the exact physical pages it asks to be loaded at (`p_paddr`) via `allocatePages`, - `@memcpy` the file-backed bytes to that address, - `@memset` the `.bss` tail (the part where `p_memsz > p_filesz`) to zero. -The kernel is linked to load at physical `0x100000` (1 MiB) — set by -`exe.image_base` in `build.zig` and the linker script. UEFI identity-maps memory, -so the physical address the ELF asks for is the address it actually runs at. If a -segment's `p_paddr` collided with firmware-reserved memory, `allocatePages` would -fail and we'd need to move `image_base`. +The kernel is linked to *run* in the higher half (virtual base +`0xFFFFFFFF80000000`; `exe.image_base` in `build.zig` is the *virtual* address +`0xFFFFFFFF80100000`) but is *loaded* low: the linker script's `AT()` clauses +give every segment a low physical load address (`p_paddr`, with `.text` at +`0x100000`, 1 MiB), which is what the loader allocates and copies into. The +bootstrap page tables built before the jump map the high link addresses onto +those low physical pages. If a segment's `p_paddr` collided with +firmware-reserved memory, `allocatePages` would fail and we'd need to move the +load addresses. `loadElf` returns `e_entry`, the kernel's entry-point address. @@ -124,13 +141,12 @@ entirely ours. ### 4. Jump to the kernel ```zig -const kernel: *const fn (*const BootInfo) callconv(boot_handoff.kernel_abi) noreturn = - @ptrFromInt(entry); -kernel(&boot_info); +handoff(cr3, entry, &boot_information); ``` -We cast the entry address to a function pointer and call it, passing a pointer to -the `BootInfo` we filled in. This never returns. +`handoff` is a single inline-asm block — `cli`, load the bootstrap page tables' +`cr3`, place the `boot_information` pointer in RDI, then `callq *entry` — so +nothing runs between the CR3 load and the jump. This never returns. ## The ABI subtlety: RCX vs RDI @@ -138,14 +154,15 @@ There's a deliberate detail worth calling out. A UEFI binary is compiled with th **Microsoft x64** calling convention (first argument in register **RCX**). Our kernel is freestanding and uses the **SysV AMD64** convention (first argument in **RDI**). If we let each side use its target's default, the loader would place -`boot_info` in RCX while the kernel looked for it in RDI — and the kernel would -read garbage. +`boot_information` in RCX while the kernel looked for it in RDI — and the kernel +would read garbage. -So both sides pin the convention explicitly to SysV via the shared -`boot_handoff.kernel_abi` (defined in `system/boot-handoff.zig`). The loader's -function-pointer type and the kernel's `_start` both reference it, so the pointer lands -in the register the kernel expects. This is the whole reason `kernel_abi` lives in the -shared `boot-handoff` module: it's a contract both binaries must agree on. See +So the convention is pinned explicitly to SysV via the shared +`boot_handoff.kernel_abi` (defined in `system/boot-handoff.zig`). The kernel's +`kmainEntry` declares `callconv(kernel_abi)`; the loader honours the same contract +by loading RDI by hand in `handoff`'s inline asm rather than trusting its own +Microsoft-x64 default. `kernel_abi` lives in the shared `boot-handoff` module +because it's a contract both binaries must agree on. See [sysv.md](sysv.md) for what "SysV" means and where else it shows up. ## The handoff contract @@ -156,13 +173,16 @@ what `system/boot-handoff.zig` provides — imported by both as the `boot-handof It is *only* the handoff: the kernel↔user ABI (`system/abi.zig`) and the device types (`system/devices/device-abi.zig`) are separate contracts the bootloader never sees. -- `BootInfo` — the top-level struct passed to the kernel (currently just the - framebuffer; this is where future handoff data like the memory map will go). -- `Framebuffer`, `PixelFormat`, `kernel_abi` — the shared field layouts and the - calling convention. +- `BootInformation` — the top-level struct passed to the kernel: the + framebuffer, the memory map, the kernel's own `PT_LOAD` segments + (`kernel_segments` + `kernel_segment_count`, so the kernel can re-map itself + with correct permissions), the ACPI RSDP address, and the initial-ramdisk + base/length. +- `Framebuffer`, `PixelFormat`, `MemoryMap`/`MemoryRegion`, `KernelSegment`, + `kernel_abi` — the shared field layouts and the calling convention. -Both are `extern struct`, giving them a stable, C-compatible layout so the bytes -the loader writes are the bytes the kernel reads. +The structs are `extern struct`, giving them a stable, C-compatible layout so the +bytes the loader writes are the bytes the kernel reads. ## The whole flow at a glance @@ -170,15 +190,20 @@ the loader writes are the bytes the kernel reads. power on -> UEFI firmware initialises hardware -> finds EFI/BOOT/BOOTX64.efi on the FHS volume, runs it (our efi.zig main) - -> grab boot services + -> grab boot services (+ the ACPI RSDP from the configuration table) -> queryFramebuffer (via GOP: EDID native res, setMode, describe fb) - -> loadKernel (read system/kernel ELF, load PT_LOAD segments to 0x100000) + -> loadKernel (read system/kernel ELF, load PT_LOAD segments low, .text at 0x100000) + -> loadSystemTree (read the boot\system.img capsule as the in-RAM initial ramdisk; + fallbacks: manifest-listed paths, then a /system tree walk) + -> buildBootstrapTables (identity + physmap + higher-half kernel mappings) -> exitBootServices (retry until the memory-map key holds) - -> jump to e_entry, boot_info pointer in RDI - -> kernel _start (system/kernel/kernel.zig: framebuffer console, then halt) + -> handoff: load bootstrap CR3, jump to e_entry, boot_information pointer in RDI + -> kernel _start (architecture/x86_64/isr.s: switch to a kernel-owned stack, + call kmainEntry -> paging, heap, device discovery, + scheduler, SMP, user space) ``` Bottom line: **UEFI's job is to give us a CPU, memory, and a framebuffer, then disappear.** `boot/efi.zig` is the thin bridge that collects those gifts into a -`BootInfo`, tears down the firmware, and jumps into the kernel — after which we're -on our own. +`BootInformation`, tears down the firmware, and jumps into the kernel — after +which we're on our own. diff --git a/docs/frame-allocator.md b/docs/frame-allocator.md index eb1af48..219bf85 100644 --- a/docs/frame-allocator.md +++ b/docs/frame-allocator.md @@ -8,7 +8,7 @@ natural unit because that's the granularity the CPU's paging hardware maps — a it is the primitive everything above it stands on: page tables, the kernel heap, per-process memory all ultimately ask the frame allocator for pages. -It's **generic kernel code**: it operates on the neutral `system.MemoryRegion` +It's **generic kernel code**: it operates on the neutral `boot_handoff.MemoryRegion` array, so there's no UEFI in it and nothing architecture-specific beyond the 4 KiB page. (Contrast [architecture.md](architecture.md), which is where CPU-specific code lives.) @@ -38,16 +38,18 @@ and a `next_hint` marking where the next allocation scan should start. ### init(map) — building it from the memory map -1. **Size it.** Find the highest address across all `usable` regions; - `total_frames = highest / page_size`. Reserved and MMIO spans above that - (remember the ~12 GiB of MMIO from [memory-map.md](memory-map.md)) sit *outside* - the bitmap and are simply never allocatable. +1. **Size it.** Find the highest address across all RAM regions — every kind + *except* `mmio` — so reserved and ACPI spans sit *inside* the bitmap, marked + used but trackable (e.g. so the boot buffers can be freed later); + `total_frames = highest / page_size`. Only MMIO (device address space — + remember the ~12 GiB of it from [memory-map.md](memory-map.md)) sits outside + the bitmap and is simply never allocatable. 2. **Place it (the bootstrap).** The bitmap needs storage before an allocator exists — a chicken-and-egg. Solution: pick the first `usable` region big enough - to hold the bitmap and put it there, addressing it directly as a pointer. That - last part relies on the firmware's **identity mapping** still being in effect - (physical address == virtual address), which holds until the kernel installs - its own page tables. + to hold the bitmap and put it there, addressing it through the **physmap** + (`boot_handoff.physicalToVirtual`). The loader's bootstrap page tables already + provide the physmap and the kernel's own tables keep it, so the pointer stays + valid across the paging switch. 3. **Mark, then free.** Set the whole bitmap to `used` (`0xff`), then walk the `usable` regions clearing their bits. Doing it in that direction means every gap, reserved span, and hole is unallocatable *by default* — we only ever hand @@ -73,10 +75,13 @@ free, or one out of range) are ignored rather than corrupting the used count. - **Generic walk.** Because `MemoryRegion` is danos's own type, the map is a plain slice — none of the variable descriptor-stride from the raw UEFI map. -- **Identity mapping assumption.** Placing the bitmap by physical address only - works while the firmware's identity map is live. When danos sets up its own - paging, the bitmap (and any other physical pointer) will need an explicit - mapping. This is a deliberate, documented dependency of this stage. +- **Physmap addressing.** The bitmap (and the region array) is reached through + the physmap via `boot_handoff.physicalToVirtual`, which both the loader's + bootstrap tables and the kernel's own tables provide — no remapping is needed + when danos switches to its own paging. The one invariant: the bitmap must sit + under the bootstrap physmap's reach (4 GiB), which holds because the placement + scan (step 2 above) runs from the lowest usable region up and takes the first + one big enough — on the supported configurations that lands well under 4 GiB. - **Frame 0 is reserved** so `0` stays a safe "none" sentinel — and the bitmap is never placed there. (An early bug did exactly that: a `usable` region at physical address 0 collided with a `0`-means-not-found sentinel and tripped a panic. The @@ -90,12 +95,15 @@ free, or one out of range) are ignored rather than corrupting the used count. `kmain` brings the allocator up and self-tests it. Booted in QEMU with 128 MiB: ``` -danos: frame allocator online - free frames: 19751 (77 MiB) <- matches the map's 77 MiB usable - alloc x3 : 0x2000 0x3000 0x4000 <- frame 0 reserved, bitmap at 0x1000, so allocs start at 0x2000 - after free : 19751 frames free <- three freed, count restored +/system/kernel: frame allocator online + free frames: 30520 (119 MiB) <- matches the map's 119 MiB usable + alloc x3 : 0x3000 0x4000 0x5000 <- frame 0 reserved, bitmap at 0x1000, AP trampoline at 0x2000 + after free : 30520 frames free <- three freed, count restored ``` +(The AP-trampoline page is claimed with `allocBelow` right after `init`, before +the demo allocations — hence they start at 0x3000.) + The `free frames` MiB agreeing with the memory map's `usable RAM`, the three distinct consecutive addresses, and the count returning to its start after freeing are the three signals that init, alloc and free are all correct. @@ -103,7 +111,7 @@ are the three signals that init, alloc and free are all correct. ## Boot-services memory comes pre-reclaimed The UEFI boot-services memory (~44 MiB) is defunct and free once -`ExitBootServices` runs, taking usable RAM from ~76 MiB up to ~121 MiB. The frame +`ExitBootServices` runs, taking usable RAM from ~76 MiB up to ~119 MiB. The frame allocator does **nothing special** to get it: the loader already classified it as `usable` (see [memory-map.md](memory-map.md)), so it's just part of the `usable` regions `init` frees. Keeping that boot-protocol knowledge on the loader side is diff --git a/docs/framebuffer.md b/docs/framebuffer.md index e073d44..7f2a98f 100644 --- a/docs/framebuffer.md +++ b/docs/framebuffer.md @@ -89,11 +89,13 @@ it. ## Two subtleties worth noting -1. **`width` vs `pitch` in the loops.** In `fillRow`/`copyRow` we iterate `x` - up to `self.fb.width` — the *visible* count — but jump between rows with +1. **`width` vs `pitch` in the loops.** In `fillRow` we iterate `x` up to + `self.fb.width` — the *visible* count — but jump between rows with `pitch`. That's the correct pairing: touch only real pixels, but skip the full stride (including padding) to reach the next row. We never write into - the padding, which is right. + the padding, which is right. (A `copyRow` used to sit alongside it; it's + gone — `scroll` was since rewritten as a writes-only screen clear, because + reading VRAM back is uncached-slow on real hardware.) 2. **`volatile`.** The pointer is `volatile` because this memory is special — it's watched by the display hardware. `volatile` tells the compiler *"don't diff --git a/docs/halting.md b/docs/halting.md index 9592973..2995717 100644 --- a/docs/halting.md +++ b/docs/halting.md @@ -17,7 +17,7 @@ safely, until the machine is reset or powered off. Everything comes down to one x86 instruction. It's CPU-specific, so it lives in the arch module, `system/kernel/architecture/x86_64/cpu.zig` (see [architecture.md](architecture.md)), and the -generic kernel calls it as `arch.halt()`: +generic kernel calls it as `architecture.halt()`: ```zig /// Park the core forever. `hlt` drops it into a low-power idle until the next @@ -56,10 +56,10 @@ door: every time an interrupt wakes the core, the loop immediately runs `hlt` again and it goes back to sleep. The net effect is a permanent halt that still sleeps between the interrupts it can't prevent. -(At this stage danos hasn't set up an interrupt descriptor table, so most -interrupts aren't even something we handle — but non-maskable interrupts and -system-management interrupts can still wake a halted core regardless. The loop -makes us robust to all of them.) +(danos handles plenty of interrupts through its interrupt descriptor table — +the timer tick waking a halted idle core is exactly how scheduling works — and +non-maskable and system-management interrupts can wake a halted core regardless +of what we handle. The loop makes the halt robust to all of them.) ## The `asm volatile` part @@ -79,32 +79,40 @@ treats the call: - Code *after* a `noreturn` call is unreachable, so the compiler needn't emit a return sequence, and won't warn about "missing return value" in the callers. -- It lets `kmain` and `_start` themselves be `noreturn`, which is the honest - signature for a kernel entry point — the bootloader jumps in and nothing ever - jumps back out. +- It lets `kmain` and the exported entry shim `kmainEntry` themselves be + `noreturn`, which is the honest signature for a kernel entry point — the + bootloader jumps in and nothing ever jumps back out. -You can see the chain in `system/kernel/kernel.zig`: `_start` is `noreturn`, it calls -`kmain` which is `noreturn`, which ends by calling `arch.halt()` which is -`noreturn`. The "never returns" property is threaded all the way down. +You can see the chain in the code: `_start` (an assembly stub in +`system/kernel/architecture/x86_64/isr.s`) installs a kernel-owned stack and calls +`kmainEntry` in `system/kernel/kernel.zig`, which is `noreturn`; it calls `kmain`, +also `noreturn`, which ends by calling `architecture.halt()`, again `noreturn`. +The "never returns" property is threaded all the way down. ## Where danos halts There are three halt sites, and they're all the same idea: -1. **Normal end of kernel work** — `kmain` prints its status, then calls - `arch.halt()`: +1. **The BSP's idle loop** — `kmain` no longer runs out of work: it spawns + `/system/services/init` as PID 1, drops itself to priority 0, and ends as the + bootstrap core's idle task — still by calling `architecture.halt()`: ```zig - con.write("\nkernel initialised; nothing left to do, halting.\n"); - arch.halt(); + scheduler.setPriority(0); + status("\n/system/kernel: kernel idle; user space is running.\n"); + architecture.halt(); ``` - There's genuinely nothing more to do yet, so the kernel parks itself. + The timer keeps preempting the idle context into init and whatever else is + ready; between those interrupts, the halt loop is exactly the low-power park + described above. 2. **Kernel panic** — the freestanding panic handler has no OS to report to, so - it prints the message in red (if the console is up) and halts via the same - `arch.halt()`. A panic is unrecoverable here, so stopping the machine — rather - than limping on with corrupted state — is the safe response. + it prints the message to the diagnostic log and the on-screen console — + forcing the console back on even if a display service had it suppressed — and + halts via the same `architecture.halt()`. A panic is unrecoverable here, so + stopping the machine — rather than limping on with corrupted state — is the + safe response. 3. **Bootloader failure** — in `boot/efi.zig`, if `boot()` fails *before* handing off to the kernel, `main` logs the error and parks the machine with the same @@ -112,14 +120,14 @@ There are three halt sites, and they're all the same idea: ```zig boot() catch |err| { - log("\r\ndanos: boot failed: "); + log("\r\nEFI: boot failed: "); logBytes(@errorName(err)); log("\r\n"); while (true) asm volatile ("hlt"); }; ``` - (Here it's an inline loop rather than `arch.halt()` because that lives in the + (Here it's an inline loop rather than `architecture.halt()` because that lives in the kernel's arch module, and the loader is a separate binary from the kernel.) ## Summary @@ -132,5 +140,5 @@ There are three halt sites, and they're all the same idea: otherwise wake the core and let execution continue. - **`asm volatile`** emits the instruction and forbids the compiler from removing it; **`noreturn`** encodes "control never comes back" into the type system. -- danos halts on normal completion, on a kernel panic, and on a bootloader error - — the same "stop safely and stay stopped" in all three. +- danos halts in the kernel's idle loop, on a kernel panic, and on a bootloader + error — the same "park the core safely" in all three. diff --git a/docs/heap.md b/docs/heap.md index 909bf64..03c541d 100644 --- a/docs/heap.md +++ b/docs/heap.md @@ -8,7 +8,7 @@ the thing that unlocks dynamic data structures — lists, hash maps, driver stat eventually a process table. It's generic kernel code (`system/kernel/heap.zig`): the allocator logic is -architecture-neutral, using `arch.mapPage` and the frame allocator underneath. +architecture-neutral, using `architecture.mapPage` and the frame allocator underneath. ## A growable free-list allocator @@ -30,9 +30,10 @@ Allocations are 16-byte aligned; larger alignments aren't supported yet (the ## Growing on demand The heap lives in the **higher half** of the address space (virtual base -`0xFFFF_8000_0000_0000`) — unmapped, well clear of the identity-mapped low half, -and leaving the low half free for a future user address space. (That base is -x86_64-canonical; another architecture would pick its own.) +`0xFFFF_8000_0000_0000`) — unmapped, well clear of the low half, which belongs +to user space (unmapped in the kernel's own tables; per-process user address +spaces now map into it). (That base is x86_64-canonical; another architecture +would pick its own.) When the free list can't satisfy a request, `grow` extends the mapped region: it pulls fresh frames from the [frame allocator](frame-allocator.md) and `map`s each diff --git a/docs/input.md b/docs/input.md index 767c303..1bad896 100644 --- a/docs/input.md +++ b/docs/input.md @@ -5,7 +5,7 @@ window server, a logger. None of them owns the hardware, and the driver should n who is listening. So between the drivers and the listeners sits the **input service** (`system/services/input/`): drivers **publish** events to it, programs **subscribe**, and it fans each event out to every interested subscriber. It is an ordinary ring-3 process -reached over IPC, like the [VFS server](../system/services/vfs/vfs.zig) — no kernel knows +reached over IPC, like the [FAT server](../system/services/fat/fat.zig) — no kernel knows what a key is. ## One service, several device classes @@ -43,10 +43,13 @@ consequences decide the whole design: 2. **A synchronous push can hang the whole service.** If the service delivered with `ipc_call`, it would block until each subscriber replied. `ipc_call` has no timeout, and - the kernel does **not** wake a caller parked on a *dead* peer's endpoint (it only fails a - peer that was mid-reply — see [process.zig](../system/kernel/process.zig) - `releaseTaskResourcesLocked`). One subscriber that exits mid-delivery would wedge input - for everyone. That is the opposite of the resilience the microkernel is for. + a subscriber's endpoint is an *unregistered* capability the kernel's death path cannot + reach (since display v2's V6, `killOwnedEndpointsLocked` in + [ipc-synchronous.zig](../system/kernel/ipc-synchronous.zig) marks a dead owner's + *registered* endpoints dead and wakes parked callers with `-EPEER` — but unregistered + ones just drop with the task's handle table). One subscriber that exits mid-delivery + would wedge input for everyone. That is the opposite of the resilience the microkernel + is for. The fix is the asynchronous send that [ipc.md](ipc.md) had already earmarked as future work ("asynchronous / buffered send … for notifications between servers"): diff --git a/docs/interrupts.md b/docs/interrupts.md index b0558de..ece391f 100644 --- a/docs/interrupts.md +++ b/docs/interrupts.md @@ -9,8 +9,10 @@ reboot is miserable. This is the machinery that catches those faults and prints what happened instead. It's all x86_64-specific, so it lives behind the [architecture](architecture.md) boundary in -`system/kernel/architecture/x86_64/`. Only the 32 CPU-defined exception vectors are wired up so far; -device interrupts (timer, keyboard, via the APIC) come later, on the same IDT. +`system/kernel/architecture/x86_64/`. Only the 32 CPU-defined exception vectors were wired up at +this stage; device interrupts (timer, keyboard, via the APIC) came later, on the +same IDT — today it installs 48 gates (vectors 0-47) plus the ring-3 syscall gate +at vector 128. ## First the GDT @@ -20,9 +22,11 @@ IDT gate names a code-segment *selector* that must resolve in the current GDT. T firmware left a GDT in place, but we don't control it, so we install our own with known selectors: `0x08` kernel code, `0x10` kernel data. -`system/kernel/architecture/x86_64/gdt.zig` holds three flat descriptors — a required null entry, -plus code and data — where the only bits that matter in long mode are the access -byte and the code segment's long-mode (`L`) flag. Loading it (`gdt_flush` in +`system/kernel/architecture/x86_64/gdt.zig` held three flat descriptors at this point — a required +null entry, plus code and data — where the only bits that matter in long mode are +the access byte and the code segment's long-mode (`L`) flag. (The table has since +grown to seven entries: ring-3 user data and user code descriptors arrived with +user mode, and the TSS descriptor — below — spans two slots.) Loading it (`gdt_flush` in `isr.s`) does two things: `lgdt`, then reload the segment registers. The data registers take a plain `mov`, but **CS can't** — so we reload it with a far return, pushing the new selector and a return address and letting `lretq` pop them @@ -34,7 +38,8 @@ The **Interrupt Descriptor Table** maps each of 256 vectors to a handler. Each entry is a 16-byte *gate* holding the handler's address (split across three fields, a quirk of the format), the code selector (`0x08`), and flags: `0x8E` means present, ring 0, 64-bit interrupt gate. `system/kernel/architecture/x86_64/idt.zig` builds the -table, points the first 32 vectors at their stubs, and loads it with `lidt` +table, points the first 32 vectors at their stubs (since grown to gates 0-47, +plus the ring-3 syscall gate at vector 128), and loads it with `lidt` (`idt_flush`). ## The TSS and the double-fault stack @@ -52,9 +57,11 @@ third time and **triple-fault** — an instant reset. So the #DF gate is pointed **IST1**, a small dedicated stack (`system/kernel/architecture/x86_64/tss.zig`) that's always valid. Bringing it up: fill in the TSS's IST1 pointer, publish the TSS through a -descriptor in the GDT (`gdt.setTss`), and load it into the task register with +descriptor in the GDT (`gdt.setTssFor`), and load it into the task register with `ltr`. The TSS descriptor is a 16-byte system descriptor spanning two GDT slots, -which is why the GDT grew from three entries to five. +which is why the GDT grew from three entries to five (and later to seven, when +user mode slotted the ring-3 user data/user code descriptors between the kernel +data entry and the TSS pair). ## The stubs and the trap frame @@ -74,12 +81,12 @@ push order in `isr.s`; the two must stay in sync. The stubs and table-loads are real assembly rather than Zig inline asm because they need things inline asm on this toolchain can't express: cross-symbol `jmp`/`call` (a stub jumping to `isr_common`, which calls the exported -`exceptionHandler`), and the `lgdt`/`lidt` memory operands (which LLVM rejects +`interruptDispatch`), and the `lgdt`/`lidt` memory operands (which LLVM rejects inline). `build.zig` adds `isr.s` to the arch module. ## Reporting a fault -`isr_common` calls `exceptionHandler`, which forwards to a swappable `on_fault` +`isr_common` calls `interruptDispatch`, which forwards to a swappable `on_fault` hook. The generic kernel installs a reporter (`onException` in `kernel.zig`) that prints the exception name and vector, the error code, the faulting RIP and RSP, and — for a page fault (#PF, vector 14) — the faulting address from diff --git a/docs/ipc.md b/docs/ipc.md index 0a99afc..3648d4d 100644 --- a/docs/ipc.md +++ b/docs/ipc.md @@ -25,7 +25,7 @@ ring buffer, a count, and two wait queues: - **`send(msg)`** — if the channel is full, block on the *not-full* queue; otherwise write the message, bump the count, and wake a waiting receiver. -- **`recv()`** — if the channel is empty, block on the *not-empty* queue; otherwise +- **`receive()`** — if the channel is empty, block on the *not-empty* queue; otherwise take a message, drop the count, and wake a waiting sender. Neither side busy-waits: a full channel parks the sender, an empty one parks the @@ -36,17 +36,20 @@ Two details make it correct: - **Recheck in a loop.** A woken task re-tests the condition (`while (full) wait`) rather than assuming the slot is still available — another waiter may have taken it first. This is the standard guard against spurious or racing wakeups. -- **One critical section.** `send`/`recv` run under `saveInterrupts` / - `restoreInterrupts` (the composable form, see [scheduling.md](scheduling.md)), so - checking the condition and committing the block/enqueue happen atomically with - respect to the timer preempting mid-operation. `waitLocked` / `wakeLocked` are the - variants that assume the caller already holds that critical section. +- **One critical section.** `send`/`receive` run under the [big kernel + lock](smp.md) (`sync.enter` / `sync.leave`), which disables interrupts on this + core *and* takes the kernel's one spinlock — since SMP, the interrupt flag alone + is not atomicity, because `cli` on one core does nothing to another. So checking + the condition and committing the block/enqueue happen atomically both with respect + to the timer preempting mid-operation and to the other side running on another + CPU. `waitLocked` / `wakeLocked` are the variants that assume the caller already + holds that critical section. ## Verifying it The `ipc` test (see [testing.md](testing.md)) runs a producer and a consumer passing **100 messages through a 4-slot channel**. The small buffer means the channel goes -full and empty over and over, so both the blocking-send and blocking-recv paths are +full and empty over and over, so both the blocking-send and blocking-receive paths are exercised heavily. The messages arrive intact and in order (their sum is the expected `5050`), and neither task busy-waits — they block and wake each other. diff --git a/docs/logging.md b/docs/logging.md index 88c72a9..8487306 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -5,8 +5,11 @@ and every service must run correctly with zero output channels. On top of that rule, danos has **per-process logging** — every process's output is attributed by the kernel and lands in its own file on the flash volume, which is what makes a headless real machine (no serial port) debuggable. The display (the -framebuffer surface) is a separate concern and deliberately not a log sink; -`main.zig` mirrors a few user-facing status lines and panics to it explicitly. +framebuffer surface) is a separate concern with one bootstrap exception: the +kernel's framebuffer console (`system/kernel/console.zig`) joins the log sinks +at boot, so the whole transcript shows on screen until the display service +claims the framebuffer and silences it; after that only panic/fatal messages +are mirrored to it explicitly (`system/kernel/kernel.zig`). ## The pipeline @@ -37,7 +40,8 @@ kernel log.print ─┘ │ 8-byte aligned. 4. **Render.** Registered sinks (serial under `-Dserial`, the 0xE9 debug - console) get a live transcript: kernel/raw output verbatim, leveled records + console, and the framebuffer console until the display service claims the + screen) get a live transcript: kernel/raw output verbatim, leveled records as `: message` — one composed write per line, under the log's own spinlock (never the big kernel lock; panic paths try-acquire with a bound and fall back to sinks-only). Sinks are best-effort and self-guarding; diff --git a/docs/memory-map.md b/docs/memory-map.md index e407e50..40b0522 100644 --- a/docs/memory-map.md +++ b/docs/memory-map.md @@ -57,22 +57,27 @@ itself, `@sizeOf` is authoritative: the kernel walks a plain `[]MemoryRegion` wi no variable-stride subtlety (that stride problem is a UEFI-ism, and it stays in the loader). -`BootInfo` carries it alongside the framebuffer: +`BootInformation` carries it alongside the framebuffer (trimmed here to the +fields this page is about — the full struct has since grown the kernel's +PT_LOAD segments, the ACPI RSDP, and the initial-ramdisk span): ```zig -pub const BootInfo = extern struct { +pub const BootInformation = extern struct { framebuffer: Framebuffer, memory_map: MemoryMap, + // ...kernel_segments, acpi_rsdp, initial_ramdisk_base/len }; ``` ## The loader side (UEFI) -Two functions in `boot/efi.zig`, called from `exitBootServices`: +Two functions in `boot/efi.zig`: `exitBootServices` calls `convertMemoryMap`, +which runs `classify` on each descriptor: - **`classify`** maps each UEFI descriptor to a `MemoryKind`: `conventional_memory` **and** `boot_services_code`/`boot_services_data → usable`; - `acpi_reclaim_memory → acpi_tables`; `acpi_memory_nvs → acpi_nvs`; **everything + `acpi_reclaim_memory → acpi_tables`; `acpi_memory_nvs → acpi_nvs`; + `memory_mapped_io`/`memory_mapped_io_port_space → mmio`; **everything else → reserved** (the safe default). Our own `loader_data` — the kernel image and these buffers — falls into `reserved`. @@ -120,18 +125,25 @@ for the conversion.) The kernel receives a plain array and reads it with zero UEFI knowledge: ```zig -const mm = boot_info.memory_map; -const regions = @as([*]const system.MemoryRegion, @ptrFromInt(mm.regions))[0..mm.len]; +const mm = boot_information.memory_map; +const regions = @as( + [*]const boot_handoff.MemoryRegion, + @ptrFromInt(boot_handoff.physicalToVirtual(mm.regions)), +)[0..mm.len]; for (regions) |r| { if (r.kind == .usable) usable_pages += r.pages; } ``` +(`mm.regions` is a physical address, so it's dereferenced through the physmap — +`physicalToVirtual` — since the kernel no longer runs under the loader's +identity map.) + `kmain` summarises the map to prove the handoff works. Booted in QEMU with 128 MiB, it reports: ``` -danos: physical memory +/system/kernel: physical memory total RAM : 0.12 GiB (127 MiB) - RAM the firmware reported usable : 121 MiB - free RAM (incl. reclaimed boot-services memory) reserved : 6 MiB - kernel image, boot stack, ACPI, runtime services diff --git a/docs/paging.md b/docs/paging.md index 99fee0e..efc2fe8 100644 --- a/docs/paging.md +++ b/docs/paging.md @@ -14,8 +14,12 @@ behind the [architecture](architecture.md) boundary in `system/kernel/architectu x86_64 uses **4 levels**: PML4 → PDPT → PD → PT, each a 512-entry table, with 9 bits of the virtual address indexing each level and the low 12 bits the offset into the final 4 KiB page. Each entry holds a physical address plus flag bits — -present, writable, and (bit 63) **no-execute**. danos maps everything with 4 KiB -pages: precise, and the extra table memory is negligible against available RAM. +present, writable, and (bit 63) **no-execute**. danos maps nearly everything with +4 KiB pages: precise, and the extra table memory is negligible against available +RAM. (The physmap has since become the one exception: 2 MiB-aligned RAM there is +mapped with **2 MiB huge pages** — a PS-bit leaf at the PD level — with 4 KiB +pages filling the unaligned edges, so the table footprint scales sanely with big +RAM. Kernel segments, heap, user space, and on-demand MMIO stay 4 KiB.) ## Higher half: the address-space layout @@ -27,7 +31,7 @@ address to the low load address in its bootstrap tables and jumps in). The entir alongside a **physmap** — a straight window onto all of physical memory at `physmap_base + phys`. Wherever the kernel needs to touch a physical address (a page-table frame, an ACPI table, a device register), it adds that constant: -`system.physToVirt(phys)`. The layout constants live in `system/boot-handoff.zig`: +`boot_handoff.physicalToVirtual(phys)`. The layout constants live in `system/boot-handoff.zig`: | region | virtual base | PML4 slot | |--------|--------------|-----------| @@ -46,7 +50,7 @@ builds its own precise tables below and abandons them. Because both use the same The address space is built in four passes (`init`): 1. **All RAM in the physmap, RW + NX.** Every non-MMIO region from the - [memory map](memory-map.md) is mapped at `physToVirt(phys)`, read-write and + [memory map](memory-map.md) is mapped at `physicalToVirtual(phys)`, read-write and *non-executable*. There is **no low/identity mapping** — the low half is user space. (Frames the kernel touches while still building these tables are reached through the loader's bootstrap physmap, which covers the low 4 GiB; both the @@ -72,7 +76,7 @@ Blanket RW+NX is fine for data but wrong for the kernel's own code, which must b executable — and its code must *not* be writable (W^X: no page is both). We get the right permissions per region straight from the kernel ELF: the **loader already parses the program headers**, so `efi.zig` records each `PT_LOAD` segment's -address, size and R/W/X flags into `BootInfo`. Pass 3 re-maps those ranges with +address, size and R/W/X flags into `BootInformation`. Pass 3 re-maps those ranges with flags derived from the ELF flags: | segment | ELF flags | mapped as | @@ -97,9 +101,10 @@ real memory — turning a whole class of silent bugs into an immediate, located ## Switching on, and the on-demand API Loading the PML4's physical address into **CR3** switches address spaces and -flushes the TLB in one step. This works because the firmware's identity map is -still active *while we build*, so freshly allocated table frames are reachable by -physical address; afterwards they're covered by pass 1. +flushes the TLB in one step. This works because *while we build* the kernel is +still running on the loader's bootstrap tables, whose 4 GiB physmap makes freshly +allocated table frames reachable at the same `physmap_base + phys` addresses; +afterwards they're covered by pass 1. `init` keeps the PML4 and the frame allocator around and exposes `map(virt, phys, writable)` / `unmap(virt)` (with `invlpg` TLB invalidation) — the primitive the diff --git a/docs/power.md b/docs/power.md index 24c6929..b7cf406 100644 --- a/docs/power.md +++ b/docs/power.md @@ -83,16 +83,18 @@ On a `power_button` event or a `terminate` signal, init: 3. requests `.power` `shutdown`. The service then enters **S5** (soft off) by writing `SLP_TYP | SLP_EN` to the -PM1 control register(s) from ring 3, mirroring the kernel's own -`system/devices/power.zig` `sleepValue`. If the write returns instead of powering -the machine off, it logs loudly so a test fails rather than hangs. +PM1 control register(s) from ring 3, with the `SLP_TYP` values taken from its own +AML parse of the `_S5` object — the kernel has no S5 path of its own. If the +write returns instead of powering the machine off, it logs loudly so a test +fails rather than hangs. **No new system call was needed for S5.** The broad io_port grant on the `acpi-tables` node ([discovery.md](discovery.md)) already put the PM1 control ports in the acpi service's hands, so writing S5 from ring 3 is something it could physically already do; formalizing it as a protocol operation added a -contract, not authority. The kernel keeps `power.zig` for its own test paths and -panic-time poweroff, where no user space is available to ask. +contract, not authority. The kernel's `system/devices/power.zig` keeps only +**reboot** (the FADT reset register plus the legacy fallbacks, which need no AML); +it has no poweroff path at all — S5 is not a kernel operation. ## Verifying it diff --git a/docs/process-lifecycle.md b/docs/process-lifecycle.md index 811ca97..9190a51 100644 --- a/docs/process-lifecycle.md +++ b/docs/process-lifecycle.md @@ -55,10 +55,14 @@ pattern reused. Signals are the same pattern reused a third time. - **`process_signal(id, signal)`** — posts the signal as an asynchronous notification to the target's bound endpoint: badge = `notify_badge_bit | notify_signal_bit | pending signals`. Non-blocking for the sender, always. -- **Pending signals coalesce** in a per-process bitmask until the target next waits - — exactly like interrupt notifications, and exactly POSIX's own semantics for - non-realtime signals (two pending SIGTERMs are one SIGTERM). The bitmask *is* the - design: signals carry no payload. Anything with a payload is a protocol message. +- **Pending signals coalesce** in a per-process bitmask while the target has no + signal endpoint bound, and the whole mask arrives as one notification at bind — + POSIX's own semantics for non-realtime signals (two pending SIGTERMs are one + SIGTERM). Once bound, each `process_signal` flushes the mask straight into the + endpoint's notification ring, so a busy receiver drains separate posts as + separate notifications — harmless, because the badge is a set of bits, never a + count. The bitmask *is* the design: signals carry no payload. Anything with a + payload is a protocol message. - **Authority**: the supervisor may signal its children — the same link that is already the kill authority. A process may signal itself. Anything broader waits for transferable process handles. @@ -85,7 +89,7 @@ defense below the table. | SIGUSR1, SIGUSR2 | signals `user_1`, `user_2` | service-defined | | SIGCHLD | **already exists** — the exit notification | the badge carries the child id, dodging the classic coalescing bug (Unix code must loop `waitpid`) | | SIGKILL | `process_kill` — kernel mechanism | its definition is "cannot be handled"; it was never really a signal | -| SIGABRT | exit reason `abort` | `abort()` is synchronous self-termination, not an event | +| SIGABRT | exit reason `aborted` | synchronous self-termination is an exit, not an event; recorded for any nonzero exit code | | SIGSEGV, SIGILL, SIGFPE | exit reasons, **never delivered** | see below | | SIGPIPE | **an error return**, not a signal | see below | | SIGSTOP, SIGTSTP, SIGTTIN, SIGTTOU, SIGCONT | deferred | job control needs terminals, sessions, and process groups; stop/continue is scheduler territory | @@ -135,18 +139,21 @@ deadline. 1. **Cleanup is the kernel's job.** A process can die with no warning — fault, kill, power. Correctness must never depend on a `terminate` handler running. On - any death the kernel releases the address space, IPC handles, IRQ bindings, and - owed replies (built), and must also release **device, I/O-port, and interrupt - claims and MSI vectors** (the known gap in - [process-management.md](process-management.md); increment 1). A signal handler is + any death the kernel releases the address space, IPC handles, IRQ bindings, + owed replies, and **device, I/O-port, and interrupt claims and MSI vectors** — + the last of these was once the known gap in + [process-management.md](process-management.md), closed by increment 1 + (`releaseTaskResourcesLocked`, on every death path). A signal handler is for *graceful* work — flushing, deregistering, saving — never for *necessary* work. 2. **Kill is not a signal, and exit reasons are load-bearing.** The standard stop sequence is *terminate → deadline → `process_kill`*; the unhandleable kill stays a kernel mechanism. And a supervisor deciding whether to restart must know *how* the child died: clean exit (meant to — don't restart), fault (restart with - backoff), killed (the supervisor did it). The exit notification today carries - only the id; it grows a reason. Restart policy cannot be written without it. + backoff), killed (the supervisor did it). The exit notification carries only the + id; the reason is recorded before the notification posts and read with the + supervisor-gated `process_exit_reason` query. Restart policy cannot be + written without it. ## Who learns of a death @@ -154,7 +161,8 @@ A death has three audiences, and conflating them is how systems end up with eith zombie state or privileged snooping: 1. **The supervisor** — gets the exit notification on the endpoint it gave at spawn - (built), which grows the `ExitReason` (increment 2). The supervisor is the only + (built), then reads the `ExitReason` with the `process_exit_reason` query + (increment 2). The supervisor is the only audience that needs the *reason*, because it is the only one deciding whether to restart. 2. **The peer owed a reply** — already built: a client that dies mid-request fails @@ -162,10 +170,11 @@ zombie state or privileged snooping: the same way. This covers the *synchronous* case only. 3. **The subscribers** — the new piece, and it is the input service's publish/subscribe shape ([input.md](input.md)) applied to exits. A stateful - service accumulates per-client state across many requests: the VFS holds a dead - client's open file handles, the input service holds its subscriptions, a future - network stack holds its sockets. None of these are the client's supervisor, and - none learn anything from a failed reply if the client simply never calls again. + service accumulates per-client state across many requests: a filesystem server + (FAT today) holds a dead client's open file handles, the input service holds + its subscriptions, a future network stack holds its sockets. None of these + are the client's supervisor, and none learn anything from a failed reply if + the client simply never calls again. So the kernel **publishes every exit** to whoever subscribed: `process_subscribe(endpoint)` adds a subscriber, and each death posts a notification to every subscriber (badge = `notify_exit_bit | process id` — the @@ -180,7 +189,8 @@ zombie state or privileged snooping: non-blocking coalescing notification as everything else — a dying process never waits on its mourners. Subscribing is ungated, like `process_enumerate`: what is running (and dying) is not a secret between cooperating processes. Subscribers - do not receive the exit reason — the VFS does not care *why* the client died. + do not receive the exit reason — the filesystem server does not care *why* + the client died. This is the service-side mirror of iron rule 1: **a service must never depend on its clients cleaning up after themselves.** Handle release on client death is the @@ -221,7 +231,6 @@ pub const Signal = enum(u5) { pub const SignalSet = struct { pending: u32, pub fn has(set: SignalSet, signal: Signal) bool { ... } - pub fn iterate(set: SignalSet) Iterator { ... } }; /// Nominate `endpoint` as this process's signal endpoint (signal_bind). The @@ -231,14 +240,15 @@ pub fn bindSignals(endpoint: usize) bool { ... } /// Decode a received badge into signals, or null if the badge is not a signal /// notification (mirrors ipc.Received.isChildExit). -pub fn signalsFrom(badge: usize) ?SignalSet { ... } +pub fn signalsFrom(badge: u64) ?SignalSet { ... } /// Send `signal` to process `id`. Supervisor-gated, like kill; non-blocking. pub fn sendSignal(id: u32, signal: Signal) bool { ... } /// The standard stop sequence: terminate, wait up to `deadline_ms` for the exit -/// notification, then process_kill. The one call a supervisor needs. -pub fn stop(id: u32, deadline_ms: u64) void { ... } +/// notification on `exit_endpoint` (the endpoint the child was spawned with), +/// then process_kill. The one call a supervisor needs. +pub fn stop(id: u32, deadline_ms: u64, exit_endpoint: usize) void { ... } /// Subscribe `endpoint` to published exit events (process_subscribe). Every /// process death posts an asynchronous notification: badge = notify_exit_bit | @@ -252,7 +262,7 @@ pub fn subscribeExits(endpoint: usize) bool { ... } /// it first, so the two never race). What restart policy reads. (Built in M17.2.) pub const ExitReason = enum(u8) { exited, // returned from main / clean exit - aborted, // abort() — deliberate self-termination (SIGABRT's ghost; reserved) + aborted, // deliberate failure exit — any nonzero exit code (SIGABRT's ghost) segmentation_fault, // SIGSEGV's ghost illegal_instruction, // SIGILL's ghost arithmetic_fault, // SIGFPE's ghost @@ -299,8 +309,10 @@ get POSIX; danos-native programs never pay for it. kill a claiming driver, spawn it again, the claim succeeds. 2. **Exit reason in the death notification** (`ExitReason` above). 3. **Exit events**: `process_subscribe` in the kernel (bounded subscriber table, - publishes on every death), `runtime.process.subscribeExits`; the VFS becomes the - first subscriber — releasing a dead client's handles is its proof test. + publishes on every death), `runtime.process.subscribeExits`; the userspace VFS + router was the first subscriber — releasing a dead client's handles was its + proof test — and the FAT server inherited the role when the router moved into + the kernel (clients now hold the filesystem server's node ids directly). 4. **Signals**: `signal_bind` + `process_signal` + the pending mask in the kernel; `runtime.process` grows the interface above; the service harness handles `terminate` and answers the common `ping`; `stop()` for supervisors. diff --git a/docs/process-management.md b/docs/process-management.md index 3c31024..e52c07f 100644 --- a/docs/process-management.md +++ b/docs/process-management.md @@ -12,13 +12,15 @@ made the file tree the whole interface (`echo kill > /proc/n/ctl`). Microkernels mostly abandon ambient PIDs: Minix and QNX route everything through a user-space process-manager server, and Fuchsia/seL4 control processes only through handles. -danos rules out `/proc` **as the primitive**: here a `/proc` would be served by -the VFS server — a user process — which would put the VFS in the path of process -control. If the VFS (or anything under it) hangs, nothing could be listed or -killed, *including the hung VFS*. The control plane for processes must not -depend on a process. So the primitives are kernel system calls; a read-only -`/proc` rendering can be layered on later, and a POSIX-style process-manager -server can be built *from* these primitives when one is needed. +danos rules out `/proc` **as the primitive**: the path router lives in the +kernel (`fs_resolve`), but what is mounted under a path is served by a +user-process filesystem server (the way FAT serves `/mnt/usb`) — a `/proc` +would be one more such server, which would put a user process in the path of +process control. If that server (or anything under it) hangs, nothing could be +listed or killed, *including the hung server*. The control plane for processes +must not depend on a process. So the primitives are kernel system calls; a +read-only `/proc` rendering can be layered on later, and a POSIX-style +process-manager server can be built *from* these primitives when one is needed. ## The three primitives @@ -100,7 +102,11 @@ the architecture layer calls up into `tick`. (`releaseTaskResourcesLocked`), so a restarted driver can claim its hardware again — the cleanup half of [process-lifecycle.md](process-lifecycle.md)'s iron rule 1. The `claim-release` test proves the kill → release → re-claim cycle. -- Kernel stacks of dead tasks are leaked, as on every exit path (no reaper yet). +- ~~Kernel stacks of dead tasks are leaked~~ Closed (threading-plan M8): a task + exiting on its own core queues on the core's reap list in `.reaping` state, and + the next switch away (or tick) frees its kernel stack; one killed while off-CPU + has its stack freed synchronously by the reap itself. Both paths are accounted + by `live_stack_bytes`, which returns to baseline when no extra tasks are live. - ~~There is no exit status in the notification~~ Closed (M17.2): the kernel records how every process ends — exited, a fault class, or killed — before it posts the exit notification, and the supervisor reads it with diff --git a/docs/scheduling.md b/docs/scheduling.md index 25f4013..2551f5d 100644 --- a/docs/scheduling.md +++ b/docs/scheduling.md @@ -6,7 +6,7 @@ ready task always runs, and tasks at the same priority take turns. That model is chosen for [real-time](vision.md) — it's predictable (you can reason about which task runs when) and its decisions are O(1), unlike a fair-share scheduler. -The scheduler proper (`system/kernel/sched.zig`) is generic; the context switch and new-task +The scheduler proper (`system/kernel/scheduler.zig`) is generic; the context switch and new-task stack setup are architecture-specific (`system/kernel/architecture/x86_64/`, see [architecture](architecture.md)). ## Tasks diff --git a/docs/smp.md b/docs/smp.md index d188e15..ebc420f 100644 --- a/docs/smp.md +++ b/docs/smp.md @@ -1,10 +1,11 @@ # SMP: multiple cores, the microkernel way -A design/research note, not built yet. danos runs on **one core** today (see -[scheduling.md](scheduling.md)); this maps how microkernels — especially the L4 -family and seL4 — handle **symmetric multiprocessing (SMP)**, so the eventual port -has a plan and a reading list. It also flags where those choices depend on whether -danos is chasing **real-time** or **resilience** (see the note at the end). +A design/research note that predates the build — danos now runs on **multiple +cores** by default (see [Implementation status](#implementation-status) and +[scheduling.md](scheduling.md)). This maps how microkernels — especially the L4 +family and seL4 — handle **symmetric multiprocessing (SMP)**, the plan and +reading list the port followed. It also flags where those choices depend on +whether danos is chasing **real-time** or **resilience** (see the note at the end). ## First, the vocabulary @@ -158,11 +159,15 @@ next lands. `ipc.zig` run every critical section under it. Uncontended on one core, so behaviour is identical to the old interrupt-flag model. - **Per-CPU state** — a `PerCpu` struct (running task, idle task, APIC id) per core, - its pointer kept in the x86 **GS base** (`IA32_GS_BASE`; no `swapgs`, since there's - no user mode yet). The old global `current` is now `thisCpu().current`. The ready + its pointer kept in the x86 **GS base** (`IA32_GS_BASE`). No `swapgs` was needed at + the time — there was no user mode yet; with ring 3 in place, every ring transition + now swaps it against the user's own GS base under the `swapgs` discipline (see + `system/kernel/architecture/x86_64/per-cpu.zig`), and each AP enables the fast + system-call path (`initSystemCall`) for itself at bring-up. The old global + `current` is now `thisCpu().current`. The ready queues stay **global** under the lock — work-conserving, so any idle core will pull the highest-priority ready task; per-core queues are a later optimisation. -- **AP wake to long mode** — `arch.startSecondary` drives INIT–SIPI–SIPI (via the +- **AP wake to long mode** — `architecture.startSecondary` drives INIT–SIPI–SIPI (via the LAPIC ICR) to wake each parked core one at a time. A woken core starts in 16-bit real mode at a low page and runs the [trampoline](../system/kernel/architecture/x86_64/trampoline.s) up through protected mode into 64-bit long mode, then lands in `smp.zig:apEntry`, @@ -203,8 +208,9 @@ next lands. [scheduling.md](scheduling.md#affinity-pinning-a-task-to-a-core)). The `affinity` test confirms a pinned task never migrates. This is the mechanism the fault-on-AP test rides on, and the *explicit-affinity* real-time-predictable model. -- **Right-sized footprint** — the per-CPU ceiling (`system.max_cpus`, one constant - shared by discovery, the scheduler, and the per-core GDT/TSS) is generous (128), but +- **Right-sized footprint** — the per-CPU ceiling (`parameters.maximum_cpus`, one + constant shared by discovery, the scheduler, and the per-core GDT/TSS) is generous + (128), but the *large* per-core resources — the kernel and IST (double-fault) stacks — are **heap-allocated at bring-up**, only for cores that actually come online. Only the BSP's IST stack is static, because it must exist before the frame allocator does. @@ -217,7 +223,7 @@ next lands. life, but kept **inert between wakes**: zeroed and non-executable, armed (blob copied in, page made executable) only for the moment a core is actually climbing, then disarmed again. So there's never a dormant executable page, and a core can be - (re)woken at any time — `arch.startSecondary` is one self-contained attempt (arm → + (re)woken at any time — `architecture.startSecondary` is one self-contained attempt (arm → INIT–SIPI–SIPI → disarm), and its `INIT` resets a wedged core, so retrying just works. Boot retries a non-responding core up to three times; the same primitive is the groundwork a future **power manager** would drive to bring cores up (and, diff --git a/docs/system-requirements.md b/docs/system-requirements.md index 9a5e719..034e649 100644 --- a/docs/system-requirements.md +++ b/docs/system-requirements.md @@ -81,10 +81,11 @@ they are guidance, not a tested-hardware list.) - **Firmware must be UEFI.** Many 2011-era machines could do either UEFI or legacy BIOS — danos needs it set to UEFI. There is no BIOS boot path. -- **Input is PS/2 only, for now.** danos does not yet support USB - keyboards/mice. This is fine on most **laptops** (their built-in keyboards are - wired to a PS/2-style i8042 controller) but means a **desktop with only USB - ports** currently has no usable keyboard. USB HID input is planned. +- **Internal disks are invisible.** The only block-device driver is USB mass + storage (`usb-storage`) — there is no AHCI / NVMe / IDE driver — so danos + boots from and stores to a **USB stick**, not the machine's internal + SATA/NVMe disk. Keyboards and mice are fine either way: both PS/2 (most + laptops' built-in keyboards) and USB HID work. Virtual machines are the easiest way to meet every requirement: QEMU (with OVMF/ UEFI, a `qemu-xhci` controller, and the default Q35 machine type), or any @@ -94,40 +95,41 @@ hypervisor configured for UEFI firmware and an xHCI USB controller. | Requirement | Detail | Source | |---|---|---| -| **x86-64, 64-bit only** | Kernel and loader are built exclusively for `x86_64`; the loader rejects any non-x86-64 kernel ELF (`error.WrongArchitecture`). | `build.zig:285`, `boot/efi.zig:418` | +| **x86-64, 64-bit only** | Kernel and loader are built exclusively for `x86_64`; the loader rejects any non-x86-64 kernel ELF (`error.WrongArchitecture`). | `build.zig:481`, `boot/efi.zig:622` | | **Long mode + PAE + NX** | AP trampoline sets `CR4.PAE`, `EFER.LME`, `EFER.NXE`; NX is used in kernel page-table entries. | `system/kernel/architecture/x86_64/trampoline.s:62` | -| **SSE / SSE2** | Baseline: the compiler emits SSE for ordinary struct copies. Trampoline enables `CR4.OSFXSR` + `OSXMMEXCPT` and clears `CR0.EM`. | `build.zig:282`, `trampoline.s:62` | -| **`syscall` / `sysret`** | Primary user↔kernel entry path. `EFER.SCE` enabled; `STAR`/`LSTAR`/`SFMASK` programmed per core. (`int 0x80` exists as a parallel gate.) | `architecture/x86_64/per-cpu.zig:59`, `isr.s:169` | -| **Local APIC (xAPIC)** | LAPIC accessed via MMIO at `0xFEE00000`. LAPIC ID read as a `u8` — classic xAPIC. **x2APIC is not supported** (no MSR path). | `apic.zig:62`, `apic.zig:414` | -| **CPUID + RDTSC** | CPUID leaf `0x15` for TSC frequency; RDTSC is the monotonic clock. | `apic.zig:279`, `apic.zig:84` | -| **SMP (optional)** | Multi-core supported via INIT–SIPI–SIPI; ceiling `maximum_cpus = 128`. Single core is fine. Cores beyond the ceiling are parked. | `system/parameters.zig:16`, `apic.zig:144` | +| **SSE / SSE2** | Baseline: the compiler emits SSE for ordinary struct copies. Trampoline enables `CR4.OSFXSR` + `OSXMMEXCPT` and clears `CR0.EM`. | `build.zig:477`, `trampoline.s:62` | +| **`syscall` / `sysret`** | Primary user↔kernel entry path. `EFER.SCE` enabled; `STAR`/`LSTAR`/`SFMASK` programmed per core. (`int 0x80` exists as a parallel gate.) | `architecture/x86_64/per-cpu.zig:71`, `isr.s:196` | +| **Local APIC (xAPIC)** | LAPIC accessed via MMIO at `0xFEE00000`. LAPIC ID read as a `u8` — classic xAPIC. **x2APIC is not supported** (no MSR path). | `apic.zig:67`, `apic.zig:646` | +| **CPUID + RDTSC** | CPUID leaf `0x15` for TSC frequency; RDTSC is the monotonic clock. | `apic.zig:333`, `apic.zig:113` | +| **SMP (optional)** | Multi-core supported via INIT–SIPI–SIPI; ceiling `maximum_cpus = 128`. Single core is fine. Cores beyond the ceiling are parked. | `system/parameters.zig:16`, `apic.zig:173` | ## Firmware / boot - **UEFI only.** A custom UEFI application loader is installed to `\EFI\BOOT\BOOTX64.efi`. There is **no BIOS, multiboot, or limine** path. The loader tolerates UEFI Class-3 machines with no legacy PIC/PIT. - (`build.zig:464`, `boot/efi.zig`) + (`build.zig:246`, `boot/efi.zig`) - **ACPI is the hardware-discovery mechanism.** The RSDP is taken from the UEFI configuration table (ACPI 2.0 GUID preferred, 1.0 fallback). Without a valid RSDP there is **no device discovery** — no SMP, no IOAPIC routing, no PCI/USB. - (`efi.zig:578`, `boot-handoff.zig:144`) + (`efi.zig:790`, `boot-handoff.zig:149`) - **Required ACPI tables:** MADT (interrupt topology), MCFG (PCIe ECAM base), FADT (power / PM timer). Optionally consumed: HPET, DMAR, SPCR. (`system/devices/acpi.zig:3`) -- The loader reads `/system/kernel`, `/system/services/init`, and - `/boot/initial-ramdisk.img` off the FAT boot volume. The kernel can boot - "kernel-only" without init or the ramdisk. (`efi.zig:14`, `efi.zig:66`) +- The loader reads `/system/kernel` off the FAT boot volume, then loads user + space: a prebuilt `boot\system.img` capsule when present, otherwise it walks + the volume's `/system` tree (init included) into the initial ramdisk. The + kernel can boot "kernel-only" without either. (`efi.zig:16`, `efi.zig:68`) ## Interrupt controller - **Local APIC + I/O APIC required.** I/O APIC base, GSI base, and MADT - interrupt-source overrides come from ACPI. (`cpu.zig:365`, `apic.zig:119`) + interrupt-source overrides come from ACPI. (`cpu.zig:388`, `ioapic.zig:35`) - **MSI supported** — edge-triggered, keyed by vector, no I/O APIC mask cycle. Vector window 33–46, timer on 32, spurious on 47. (`system/kernel/irq.zig:70`, - `cpu.zig:397`) + `cpu.zig:427`) - The legacy 8259 PIC is remapped and masked **only if present** (MADT - `PCAT_COMPAT`); it is not required. (`apic.zig:103`) + `PCAT_COMPAT`); it is not required. (`apic.zig:149`) ## PCI / PCIe @@ -135,46 +137,47 @@ hypervisor configured for UEFI firmware and an xHCI USB controller. bridge's ECAM window (1 MiB config space per bus) and computes config addresses directly. **There is no legacy CF8/CFC port-IO config path** — the driver bails if the bridge exposes no ECAM window. The ECAM base comes from - the ACPI MCFG table. (`system/drivers/pci-bus/pci-bus.zig:41`, `acpi.zig:6`) + the ACPI MCFG table. (`system/drivers/pci-bus/pci-bus.zig:86`, `acpi.zig:7`) ## USB -- **xHCI only.** The sole USB driver is `usb-xhci-bus`, and the device manager - binds it strictly to PCI prog-IF `0x30` (xHCI). UHCI / OHCI / EHCI exist only - as report strings with no driver behind them — **USB 1.x/2.0-only controllers - are not supported.** (`system/drivers/usb-xhci-bus/`, - `system/services/device-manager/device-manager.zig:34`) -- USB input (keyboard/mouse over HID) is future work; the current input stack is - PS/2. See [Buses & devices](#buses--devices). +- **xHCI only.** The sole USB *host-controller* driver is `usb-xhci-bus`, and + the device manager binds it strictly to PCI prog-IF `0x30` (xHCI). UHCI / + OHCI / EHCI exist only as report strings with no driver behind them — **USB + 1.x/2.0-only controllers are not supported.** (`system/drivers/usb-xhci-bus/`, + `system/services/device-manager/device-manager.zig:30`) +- USB class drivers ride on top of it: HID keyboard + mouse (`usb-hid`, feeding + the same input service as PS/2) and mass storage (`usb-storage`). See + [Buses & devices](#buses--devices). ## Timers Calibration prefers, in order: (1) CPUID leaf `0x15` TSC frequency, (2) HPET, (3) ACPI PM timer (3.579545 MHz, from FADT), (4) legacy PIT. Any one suffices — HPET/PM-timer/PIT are optional fallbacks when CPUID `0x15` is absent. -(`apic.zig:180`) +(`apic.zig:209`) - **TSC** — monotonic high-resolution clock. - **LAPIC timer** — scheduler heartbeat, periodic at `timer_hz = 1000 Hz`. - (`parameters.zig:39`) + (`parameters.zig:42`) ## Memory **Target: 128 MiB RAM.** The system uses 4 KiB pages and a bitmap physical-frame allocator built from the firmware memory map. There is no hardcoded minimum-RAM constant — the allocator only panics if there is no usable region, or none large -enough to hold its own bitmap. (`system/kernel/pmm.zig:13`, `pmm.zig:77`) +enough to hold its own bitmap. (`system/kernel/pmm.zig:15`, `pmm.zig:77`) Where the budget goes: | Consumer | Size | Source | |---|---|---| -| Kernel heap (cap, grown one page at a time) | up to **64 MiB** | `system/kernel/heap.zig:26` | -| Kernel stack, per CPU | 16 KiB | `parameters.zig:26` | -| IST stack, per CPU | 16 KiB | `parameters.zig:36` | -| User stack, per task | 8 pages / 32 KiB | `parameters.zig:32` | -| Max concurrent tasks | 32 | `parameters.zig:23` | -| Boot page-table pool | 64 frames / 256 KiB | `efi.zig:299` | +| Kernel heap (cap, grown one page at a time) | up to **64 MiB** | `system/kernel/heap.zig:28` | +| Kernel stack, per CPU | 16 KiB | `parameters.zig:29` | +| IST stack, per CPU | 16 KiB | `parameters.zig:39` | +| User stack, per task | 8 pages / 32 KiB | `parameters.zig:35` | +| Max concurrent tasks | 48 | `parameters.zig:26` | +| Boot page-table pool | 64 frames / 256 KiB | `efi.zig:307` | The 64 MiB heap cap plus kernel image, per-CPU stacks, task stacks, the frame bitmap, and DMA-contiguous allocations fit comfortably within 128 MiB on a @@ -184,9 +187,9 @@ ceiling) add per-CPU stack overhead and push toward more RAM. **Note on the 4 GiB physmap:** the loader identity-maps and physmaps the low 4 GiB of address space with 2 MiB leaves. This is *virtual address* reach, not a RAM requirement — RAM above 4 GiB simply needs an extra mapping window and is not -needed to boot. (`efi.zig:305`) +needed to boot. (`efi.zig:313`) -Virtual-memory layout (`boot-handoff.zig:47`): +Virtual-memory layout (`boot-handoff.zig:58`): | Region | Base | |---|---| @@ -201,16 +204,20 @@ Buses with real drivers today: - **PCIe** via ECAM (`pci-bus`) - **xHCI USB** (`usb-xhci-bus`) -- **PS/2** keyboard + mouse (`ps2-bus`) — the current input stack +- **PS/2** keyboard + mouse (`ps2-bus`) +- **USB HID** keyboard + mouse (`usb-hid`) — PS/2 and USB HID feed the same + input service - **Serial UART** (16550/16450), configured from the ACPI SPCR table -**No storage driver exists yet.** AHCI / NVMe / IDE are named for reporting only; -there is no block-device driver. Persistent storage is future work. +**Storage is USB mass storage only.** The block-device driver is `usb-storage` +(bulk-only transport + SCSI), and the FAT server reads and writes it — +create / truncate / mkdir / unlink / rename, with mtime. AHCI / NVMe / IDE are +named for reporting only; internal SATA / NVMe / IDE disks have no driver. ## IOMMU **Detection only; enforcement deferred.** The ACPI DMAR table is parsed for the -first VT-d DRHD unit and its capabilities are exposed via `PlatformInfo` +first VT-d DRHD unit and its capabilities are exposed via `PlatformInformation` (`iommu_present`, `iommu_base`, `iommu_version`). No DMA-remapping tables are programmed and no translation is enforced. An IOMMU is therefore **not required** and does not currently constrain devices. (`system/devices/acpi.zig:96`) @@ -223,5 +230,5 @@ and does not currently constrain devices. (`system/devices/acpi.zig:96`) - Legacy port-IO (CF8/CFC) PCI configuration - Non-xHCI USB (UHCI / OHCI / EHCI) - Machines without ACPI (no device discovery) -- Persistent storage (no AHCI / NVMe / IDE driver yet) -- USB HID input (PS/2 only for now) +- Internal-disk storage (no AHCI / NVMe / IDE driver — persistent storage means + USB mass storage) diff --git a/docs/sysv.md b/docs/sysv.md index 2f2d377..20f9d34 100644 --- a/docs/sysv.md +++ b/docs/sysv.md @@ -56,7 +56,7 @@ danos's two binaries default to different conventions: Microsoft x64 (first argument → RCX). - The kernel is freestanding, so its convention is SysV (first argument → RDI). -When the loader jumps to the kernel passing the `BootInfo` pointer, both sides have +When the loader jumps to the kernel passing the `BootInformation` pointer, both sides have to agree *which register that pointer lands in*. Left to their defaults, the loader would place it in RCX while the kernel looked in RDI — and the kernel would read garbage. So both sides reference the same `system.kernel_abi` (SysV): the loader's diff --git a/docs/testing.md b/docs/testing.md index 8d1a6e7..de8780a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -7,10 +7,13 @@ without a human staring at the screen. There are two layers: -- **Host unit tests** (`zig build test`) — for pure, platform-independent logic in - the shared contracts (`system/boot-handoff.zig`, `system/abi.zig`, - `system/devices/device-abi.zig`), which also compile-checks the three-way split - stays self-consistent. These compile for the host and run natively. +- **Host unit tests** (`zig build test`) — for pure, platform-independent logic. + What began as the three shared contracts (`system/boot-handoff.zig`, + `system/abi.zig`, `system/devices/device-abi.zig`) now spans ~26 modules: + protocol and on-wire definitions (VFS, USB, virtio-gpu), the FAT engine, the + display compositor, PS/2 and HID decoding, the kernel log ring, and the + runtime's `time`/`thread` — the full list is the test step in `build.zig`. + These compile for the host and run natively. - **QEMU integration tests** (`python3 test/qemu_test.py`) — boot the real kernel and check its behaviour. This is the interesting part. @@ -18,9 +21,9 @@ There are two layers: The framebuffer console draws pixels, which a test can't read without screen-scraping. So the kernel also writes everything to a **serial port** -(`system/kernel/architecture/x86_64/serial.zig`, a 16550 UART on COM1). `Console.write` mirrors every -byte to it, so all kernel output — boot log, memory summary, exception reports — -appears on serial as plain text. +(`system/kernel/architecture/x86_64/serial.zig`, a 16550 UART on COM1). The diagnostic log fans out +to registered sinks, and the serial UART is one of them, so all kernel output — +boot log, memory summary, exception reports — appears on serial as plain text. QEMU captures that with `-serial file:serial.log`, giving a machine-readable transcript. Serial is per-architecture (x86 uses port I/O; an ARM board uses a @@ -51,13 +54,18 @@ DANOS-TEST-RESULT: PASS (6 passed, 0 failed) DANOS-TEST-DONE ``` -Current cases: +The core kernel cases (the suite has since grown far beyond this table — SMP, +threads, processes, display, USB, FAT and more; the full list is the `CASES` +table in `test/qemu_test.py`): | Case | What it checks | How the harness confirms it | |------|----------------|-----------------------------| | `smoke` | memory map has usable RAM; frame alloc/free; paging active | `DANOS-TEST-RESULT: PASS` | +| `discovery` | ACPI discovery populated the platform facts: MADT (LAPIC base, CPU count) and FADT (PM/reset registers) | `DANOS-TEST-RESULT: PASS` | +| `wx` | W^X audit: kernel code is executable; rodata, data, heap, and stack are NX | `DANOS-TEST-RESULT: PASS` | | `timer` | device interrupts fire and return (tick count advances) | `DANOS-TEST-RESULT: PASS` | | `clock` | LAPIC + TSC calibrated; monotonic uptime advances; `nanos()` has sub-ms resolution | `DANOS-TEST-RESULT: PASS` | +| `wall-clock` | the CMOS RTC read at boot yields a plausible current epoch | `DANOS-TEST-RESULT: PASS` | | `vmm` | on-demand `map` works: a mapped page is writable and reads back | `DANOS-TEST-RESULT: PASS` | | `heap` | kernel heap: alloc/free, block reuse, growth, and a std container on it | `DANOS-TEST-RESULT: PASS` | | `sched` | preemption: three non-yielding tasks all make progress | `DANOS-TEST-RESULT: PASS` | @@ -65,11 +73,19 @@ Current cases: | `sleep` | a task blocks for ~50 ms (real block, not a busy-wait) | `DANOS-TEST-RESULT: PASS` | | `event` | a task blocks on a wait queue and is woken (preempting) | `DANOS-TEST-RESULT: PASS` | | `ipc` | producer/consumer pass 100 messages through a 4-slot channel intact | `DANOS-TEST-RESULT: PASS` | +| `ipc-call` | synchronous IPC: client and server ping-pong 100 calls through one endpoint (rendezvous, reply routing, cross-copy) | `DANOS-TEST-RESULT: PASS` | +| `ipc-cap` | capability passing: endpoints handed over in a call and its reply arrive as the same object, shared not moved | `DANOS-TEST-RESULT: PASS` | +| `dma` | DMA memory: contiguous frame allocation, below-4G cap, coherent mapping, reclaim on teardown | `DANOS-TEST-RESULT: PASS` | +| `msi` | an MSI vector is allocated and delivered as an endpoint notification (a self-IPI stands in for the device write) | `DANOS-TEST-RESULT: PASS` | +| `iommu` | the VT-d unit is found in the DMAR table and its registers read back (detection only; boots with an emulated IOMMU) | `DANOS-TEST-RESULT: PASS` | +| `ioport` | port I/O grants: an `io_port` resource admits in-range reads, refuses out-of-range/unclaimed | `DANOS-TEST-RESULT: PASS` | | `fault-ud` | invalid-opcode exception is caught | serial shows `invalid opcode (vector 6)` | | `fault-pf` | page fault caught with CR2 | `page fault (vector 14)` | | `fault-df` | double fault caught on IST1 (not a triple-fault reset) | `double fault (vector 8)` | +| `fault-ap-df` | a double fault pinned to an application processor is caught by that core's own TSS/IST (boots with `-smp 4`) | `core N: double fault (vector 8)`, N ≥ 1 | | `fault-nx` | executing a data page (NX) faults | `page fault (vector 14)` | | `fault-null` | dereferencing the unmapped page 0 faults | `page fault (vector 14)` | +| `fault-recovery` | a ring-3 process that faults is killed and reaped while init keeps heartbeating — the OS survives | `DANOS-TEST-RESULT: PASS` | The faulting cases don't print a result line — they deliberately raise a CPU exception, and the harness asserts on the [exception report](interrupts.md) the @@ -81,8 +97,10 @@ marker would never appear. `test/qemu_test.py` ties it together. For each case it: -1. builds the kernel with `-Dtest-case=`, -2. assembles a fresh EFI System Partition from the built binaries, +1. builds the kernel with `-Dtest-case=` (the build produces the bootable + FAT32 USB image, `zig-out/danos-usb.img`), +2. copies that image to a fresh per-run boot volume, so the guest's mutations + don't dirty the build artifact, 3. boots it headless in QEMU with serial captured to a file and `-no-reboot` (so a triple fault exits rather than looping), 4. polls the serial log until the case's expected regex appears (**pass**), a @@ -118,8 +136,8 @@ firmware, boot method, serial device). The cases are architecture-neutral — So bringing up a second architecture — an AArch64 Raspberry Pi is the motivating one — means: -1. implement `system/kernel/arch/aarch64/` (CPU ops, its UART, exception vectors, page - tables) behind the same `arch` interface, +1. implement `system/kernel/architecture/aarch64/` (CPU ops, its UART, exception vectors, page + tables) behind the same `architecture` interface, 2. add an `aarch64` entry to `ARCHES` with its `qemu-system-aarch64` invocation, and the *same* `smoke` / `fault-*` cases run against it: `python3 test/qemu_test.py diff --git a/docs/threading-plan.md b/docs/threading-plan.md index bc53c42..572f15d 100644 --- a/docs/threading-plan.md +++ b/docs/threading-plan.md @@ -112,7 +112,7 @@ task's exit; make destruction happen on the **last** exit. (`address_space_refs`): `retainAddressSpace` takes a reference in `spawnUserLocked` (on the success path, after the slot + stack are secured), all under the big kernel lock. - [x] Both task-teardown paths ([scheduler.zig](../system/kernel/scheduler.zig): - `exitUserLocked` and `destroyTaskLocked`) call `releaseAspace`, which decrements + `exitUserLocked` and `destroyTaskLocked`) call `releaseAddressSpace`, which decrements and only `destroyAddressSpace`s at **zero**; an unretained space (hand-built test spaces) is destroyed directly, preserving prior behaviour. - [x] `-Dtest-case=address-space-refcount`: spawn and reap several ring-3 processes in sequence @@ -134,9 +134,10 @@ Spawn only — no join yet. Prove a second task executes in the **caller's** add space and exits cleanly. - [x] [abi.zig](../system/abi.zig): `thread_spawn = 37`, `thread_exit = 38`. Handlers in - process.zig; `thread_spawn` calls `scheduler.spawnThread` (shares the caller's + process.zig; `thread_spawn` calls `scheduler.spawnThread` (today, after M3, the + handler goes `spawnThreadSupervised` → `scheduler.spawnUserLocked`; shares the caller's address space, `retainAddressSpace`); `thread_exit` ends the task like a process `exit(0)` - (`terminateCurrent` → `releaseAspace`). The closure pointer is delivered in the new + (`terminateCurrent` → `releaseAddressSpace`). The closure pointer is delivered in the new thread's **rdi** via a new `jump_to_user_arg` asm path (`t.user_arg`, 0 for a process) — no naked runtime asm. - [x] `library/runtime/thread.zig` (barrel-exported as `runtime.Thread`): `spawn` maps a @@ -379,8 +380,9 @@ to the std shape and drop M3's per-thread exit endpoint: it enters the kernel to exit — so waking at *exit* time (not reap time) is safe, and no reaper/address-space juggling or user-memory write is needed. This is equally std-shaped (like `pthread_join`) and much simpler/safer than the planned - reaper-written completion word. `thread_spawn` no longer takes an exit endpoint (the - runtime passes `no_cap`); the per-thread IPC endpoint is gone. + reaper-written completion word. The runtime no longer passes `thread_spawn` an exit + endpoint (it passes `no_cap`; the kernel's 4th `exit_endpoint` arg remains and is + still honored); the runtime's per-thread IPC endpoint is gone. - [x] `thread-join` passes on the new path, and its join mode now runs **40 spawn+join cycles** — under the old per-thread-endpoint scheme those leaked handles would exhaust the 16-slot handle table; here they all succeed, proving join is endpoint-free. diff --git a/docs/threading.md b/docs/threading.md index 29cd0e5..0a8a43b 100644 --- a/docs/threading.md +++ b/docs/threading.md @@ -81,9 +81,11 @@ address space. Threads deliberately remove that boundary *within* a process: - Threads share one address space, so one thread's stray write corrupts them all — there is no isolation **between** threads. -- Threads share fate: a fault in any thread, or a "kill the process" decision, takes - down **all** of them. Restartability lives at the process level, not the thread - level. +- Threads share fate — by contract: a fault in any thread, or a "kill the process" + decision, takes down **all** of them, so restartability lives at the process level, + not the thread level. (The kernel does not yet enforce this fan-out — see the + Lifecycle note under + [Interaction with the rest of the kernel](#interaction-with-the-rest-of-the-kernel).) - Shared mutable state reintroduces data races — the failure class the isolate-and-message model was chosen to avoid. @@ -103,16 +105,15 @@ Lives in `library/runtime/thread.zig`, re-exported as `runtime.Thread`. pub const Thread = struct { pub const Id = u32; // the kernel task id pub const SpawnConfig = struct { - stack_size: usize = default_stack_size, - allocator: ?std.mem.Allocator = null, // for the closure + stack bookkeeping + stack_size: usize = default_stack_size, // no allocator: the closure lives at the top of the thread's own stack }; - pub const SpawnError = error{ OutOfMemory, ThreadQuotaExceeded, SystemResources }; + pub const SpawnError = error{SystemResources}; pub fn spawn(config: SpawnConfig, comptime function: anytype, args: anytype) SpawnError!Thread; pub fn join(self: Thread) void; // block until the thread ends, reclaim its stack - pub fn detach(self: Thread) void; // give up the right to join; kernel reclaims on exit + pub fn detach(self: Thread) void; // give up the right to join; stack reclaimed at process exit pub fn getCurrentId() Id; - pub fn yield() void; // -> existing `yield` syscall + pub fn currentCore() Id; // danos extension: the calling core's dense index pub const Mutex = struct { pub fn lock(*Mutex) void; pub fn tryLock(*Mutex) bool; pub fn unlock(*Mutex) void; }; pub const Condition = struct { pub fn wait(*Condition, *Mutex) void; pub fn timedWait(*Condition, *Mutex, u64) error{Timeout}!void; pub fn signal(*Condition) void; pub fn broadcast(*Condition) void; }; @@ -127,18 +128,21 @@ Deviations from `std.Thread`, called out honestly: - **The thread function's return value is discarded** (as `std.Thread.join` returns `void`). Return data through shared state or a `Semaphore`/`Condition`, not the return. -- `getCpuCount()` maps to the existing SMP core count ([smp.md](smp.md)); a service - rarely needs it. +- No `getCpuCount()` (a service rarely needs it) and no `Thread.yield()` — `yield` + lives in `runtime.system`. Instead `currentCore()` exposes the calling core's dense + index ([smp.md](smp.md)), used to observe genuine cross-core parallelism. ## Kernel primitives (new private syscalls) -Four new entries extend [abi.zig](../system/abi.zig) `SystemCall` after -`shared_memory_physical = 36`, each with a `library/runtime` wrapper: +Five core entries extend [abi.zig](../system/abi.zig) `SystemCall` after +`shared_memory_physical = 36` (plus small helpers `current_core`, `thread_self`, and +`set_thread_pointer`), each with a `library/runtime` wrapper: | Syscall | Signature | Purpose | |---|---|---| -| `thread_spawn` | `(entry, stack_top, arg) -> tid` | create a task sharing the **caller's** address space | -| `thread_exit` | `(stack_base, stack_len)` | end the calling thread; hand back its stack range for reclaim | +| `thread_spawn` | `(entry, stack_top, arg, exit_endpoint) -> tid` | create a task sharing the **caller's** address space; the runtime passes `no_cap` for `exit_endpoint` (join is a syscall, not an endpoint) | +| `thread_exit` | `()` | end the calling thread; its stack is reclaimed by the joiner's `munmap`, not the kernel | +| `thread_join` | `(tid) -> 0` | block until the thread with id `tid` has exited | | `futex_wait` | `(addr, expected, timeout_ns) -> status` | block if `*addr == expected`, until woken or timeout | | `futex_wake` | `(addr, count) -> woken` | wake up to `count` waiters on `addr` | @@ -148,56 +152,56 @@ Plus one invariant change with no new syscall: **address-space reference countin ### Address-space reference counting -Today an address space is 1:1 with a task: `spawnUserLocked` records `address_space` on the -Task, and teardown does `destroyAddressSpace(t.address_space)` when **any** user task exits +Before this work an address space was 1:1 with a task: `spawnUserLocked` records +`address_space` on the Task (as it still does), and teardown did +`destroyAddressSpace(t.address_space)` when **any** user task exited ([scheduler.zig](../system/kernel/scheduler.zig)). With threads, several tasks share one `address_space`, so the first to exit would rip the address space out from under its siblings. -Fix: a small refcount keyed by the address-space root (`createAddressSpace` in -[process.zig](../system/kernel/process.zig) sets it to 1). `thread_spawn` increments -it; task teardown decrements and only calls `destroyAddressSpace` at **zero**. All of +Fix: a small refcount keyed by the address-space root, kept in +[scheduler.zig](../system/kernel/scheduler.zig): `retainAddressSpace` takes a +reference for every user task `spawnUserLocked` starts (count 1 on the first take, so +a thread sharing the caller's space increments it); task teardown calls +`releaseAddressSpace`, which only calls `destroyAddressSpace` at **zero**. All of this is already under the big kernel lock, so no new locking. This is the one piece that must land and be proven before anything shares an address space. ### `thread_spawn` and the trampoline -The scheduler already accepts an arbitrary `address_space` and does **not** smuggle values -through registers — `startUserTask` reads the entry/stack from the Task and -`jumpToUser`s ([scheduler.zig](../system/kernel/scheduler.zig)). That makes the thread -path clean: +The scheduler already accepts an arbitrary `address_space` and does **not** smuggle +values through scratch registers — `startUserTask` reads the entry/stack (and the +thread's closure arg, delivered in `rdi` via `jumpToUserArg`) from the Task +([scheduler.zig](../system/kernel/scheduler.zig)). That makes the thread path clean: -1. The runtime's `spawn` `mmap`s a stack (syscall `4`), heap-allocates a closure — - `{ fn_ptr, args_tuple, completion }`, the std "Instance" pattern — and writes the - closure pointer to the **top word of the new stack**. -2. It calls `thread_spawn(entry = &threadTrampoline, stack_top, arg = closure_ptr)`. - The kernel calls the same `spawnUserLocked` path with the **caller's address space** - (refcount++), `entry`, and `user_sp = stack_top`. -3. `threadTrampoline` (a small runtime shim) reads the closure off its stack, calls - the user function, then calls `thread_exit`. No new register ABI — the closure - pointer rides the stack the runtime set up, mirroring how `startUserTask` avoids - register smuggling. +1. The runtime's `spawn` `mmap`s a stack (syscall `4`) and writes the closure — + `{ tls_base, args }`, the std "Instance" pattern — at the **top of the new stack + itself** (no heap allocation), with a small per-thread TLS block just below it. +2. It calls `thread_spawn(entry = &Closure.entry, stack_top, arg = closure_ptr, + exit_endpoint = no_cap)`. The kernel calls the same `spawnUserLocked` path with the + **caller's address space** (refcount++), `entry`, and `user_sp = stack_top`. +3. `Closure.entry` (a small runtime shim) receives the closure pointer in `rdi` — the + kernel delivers `arg` as the entry's first C-ABI argument — sets the thread + pointer, calls the user function, then calls `thread_exit`. Unlike a process start, there is **no** System V argc/argv/auxv block -([sysv.md](sysv.md)) — a thread stack carries only the closure pointer. +([sysv.md](sysv.md)) — a thread stack carries only the closure and its TLS block. ### Lifetime: exit, join, detach, stack reclaim -- **`thread_exit`** marks the task dead and hands the kernel the thread's user-stack - range. The kernel reaps the task on the scheduler (already running on a *kernel* - stack, so it can safely unmap the user stack), decrements the address-space refcount, and - frees the task slot. -- **`join` — Stage 1** reuses the existing exit-notification machinery - ([process-lifecycle.md](process-lifecycle.md)): `spawn` passes a per-thread - `exit_endpoint`, and `join` blocks in `ipc_reply_wait` until the child-exit - notification for that `tid` arrives, then `munmap`s the stack. No futex needed to - land spawn/join. -- **`join` — Stage 2 refinement** migrates to the std shape: a `completion` word in - the closure that `thread_exit`'s trampoline `futex_wake`s and `join` `futex_wait`s - on — dropping the per-thread endpoint. Kept as a refinement so Stage 1 ships first. -- **`detach`** relinquishes the join right; the kernel reclaims the stack and slot on - `thread_exit` (a detached thread's stack range is unmapped by the reaper, since no - joiner will). +- **`thread_exit`** (no arguments) marks the task dead. The kernel releases the + task's resources, decrements the address-space refcount, and frees the task slot — + the user stack is not the kernel's to unmap; the joiner reclaims it. +- **`join`** is a dedicated `thread_join(tid)` syscall: the caller blocks in the + kernel (`joinThreadLocked`, woken by `wakeJoinersLocked` when the thread exits), + then `munmap`s the stack. (The plan staged join over a per-thread `exit_endpoint` + first, with a futex `completion` word as a Stage-2 refinement; neither shipped — the + dedicated syscall replaced both. `thread_spawn` still accepts an `exit_endpoint` + argument, which the runtime passes as `no_cap`.) +- **`detach`** relinquishes the join right: no one waits for the thread, and its + stack is reclaimed at process exit — kernel-side reclaim of a detached thread's + user stack stays deferred (as the intro notes), since `thread_exit` passes no stack + range. ### Futex, and the sync primitives on top @@ -219,21 +223,24 @@ decision, not a "maybe later." ### TLS and `getCurrentId` -danos sets up no thread-pointer TLS today (fine under `single_threaded`). Two scoped needs: +Per-thread thread-pointer TLS is in place (the `threadlocal` *compiler* layer is not — +see the intro). Two scoped pieces, as built: -- **`getCurrentId`** returns the kernel task id — either a trivial syscall or, better, - a value the runtime stashes in a per-thread control block. -- **`threadlocal` variables** need a real per-thread TLS block and the thread pointer set per - thread. `thread_spawn` sets the thread pointer to a runtime-allocated per-thread block; full - `threadlocal` support is Stage 3, only if a consumer needs it. Nothing in the core +- **`getCurrentId`** returns the kernel task id via the trivial `thread_self` syscall. +- **The thread pointer** is per-thread: `spawn` carves a small TLS block (an `fs:0` + self-pointer plus scratch) from the top of the thread's own stack, the trampoline + calls `set_thread_pointer` before any user code runs, and the scheduler saves and + restores the pointer per task across context switches. Full `threadlocal` support is + runtime+linker work on top of this, only if a consumer needs it. Nothing in the core spawn/join/mutex path requires `threadlocal`. ### Build: multi-threaded codegen, opt-in -`addUserBinary` gains a `threaded: bool = false` parameter; when set it builds that -binary `single_threaded = false` so atomics and (later) TLS are real. Threads and -atomics are unsound in a `single_threaded` image, so a binary must opt in **before** -it may call `runtime.Thread.spawn`. Everyone else stays single-threaded and lean. +A binary opts in by being added with `addThreadedUserBinary` — as `addUserBinary`, +but the shared implementation builds it `single_threaded = false` — so atomics and +(later) TLS are real. Threads and atomics are unsound in a `single_threaded` image, +so a binary must opt in **before** it may call `runtime.Thread.spawn`. Everyone else +stays single-threaded and lean. ## Interaction with the rest of the kernel @@ -243,12 +250,22 @@ it may call `runtime.Thread.spawn`. Everyone else stays single-threaded and lean process can run on different cores simultaneously — that is the point. - **Halting** ([halting.md](halting.md)): futex-parked waiters keep the "idle core halts" property intact under lock contention — no busy-wait. -- **Lifecycle** ([process-lifecycle.md](process-lifecycle.md)): killing a process - must kill *all* its threads and only then drop the last address-space ref. The kill path - already targets a process; it fans out to every task on that address space. -- **Resilience** ([resilience.md](resilience.md)): a faulting thread kills its whole - process (shared fate). The supervisor restarts the **process**, which respawns its - threads from a known-good state — restart granularity stays the process. +- **Lifecycle** ([process-lifecycle.md](process-lifecycle.md)): the contract is that + killing a process kills *all* its threads and only then drops the last address-space + ref. **The kernel does not implement that fan-out yet**: `process_kill` reaps only + the one task it resolves, and no death path loops over the tasks sharing an address + space — the refcount keeps the space (and the sibling threads) alive and running. + The gap is hit in practice: of the only threaded binaries (the `display` service and + the `thread-test` harness), `display` is a boot service that handles no `.terminate` + signal, so init's stop sequence escalates to `process_kill` on every orderly + shutdown — benign only because poweroff follows. Whether to implement the fan-out or + amend the contract is a decision still to be made. +- **Resilience** ([resilience.md](resilience.md)): by the same contract, a faulting + thread kills its whole process (shared fate); the supervisor restarts the + **process**, which respawns its threads from a known-good state — restart + granularity stays the process. Today a CPU fault kills only the faulting task + (`killCurrentProcess` tears down a single task), so sibling threads keep running — + the same implementation gap as above. - **IPC — two consequences threads forced ([ipc.md](ipc.md)):** - *Handles do not cross threads.* The handle table lives on the `Task` ([scheduler.zig](../system/kernel/scheduler.zig)), so a handle number is meaningful @@ -262,8 +279,8 @@ it may call `runtime.Thread.spawn`. Everyone else stays single-threaded and lean mutate the global service registry, endpoint refcounts, and handle tables. Those paths were unlocked because a single-threaded process could not race itself; a multi-threaded one can, from two cores at once. They now take `sync.enter()` like `call`/`reply_wait`/ - `send` already did — the kernel heap has no lock of its own yet (heap.zig: "a lock comes - with threads/SMP"), so the big lock is what keeps its callers serialized. + `send` already did — the kernel heap has no lock of its own (heap.zig: "every kernel + entry takes the big kernel lock"), so the big lock is what keeps its callers serialized. ## Build-out plan (staged, each gate serial-checkable) @@ -278,11 +295,13 @@ a verifiable gate (`python3 test/qemu_test.py `, asserting serial markers; *Gate:* the full QEMU suite stays green (no regression) — proves the reframing is invisible until used. - **Stage 1 — spawn / join / detach.** `thread_spawn` + `thread_exit`, the trampoline, - stacks via `mmap`, join over the exit-endpoint, the `threaded` build flag. - *Gate:* `-Dtest-case=thread-spawn` — a threaded test service spawns N threads that - each `@atomicRmw`-increment a shared counter, the parent joins all N, and asserts - the total is exactly N × iterations. Runs `smp` (multi-core) to prove real - parallelism. + stacks via `mmap`, join over the exit-endpoint (as built, join became the dedicated + `thread_join` syscall instead), the `addThreadedUserBinary` build opt-in. + *Gate:* two cases as built — `-Dtest-case=thread-spawn`, where a worker thread runs + in the caller's address space (a shared-memory write, observed by the main thread), + and `-Dtest-case=thread-join`, where N workers each atomically increment a shared + counter K times, the parent joins all N and asserts the total is exactly N × K — + the join case running multi-core (`smp` 4) to prove real parallelism. - **Stage 2 — blocking synchronization.** `futex_wait`/`futex_wake` + `Futex`, `Mutex`, `Condition`, `Semaphore`; optionally migrate join to a futex completion word. *Gate:* `-Dtest-case=thread-mutex` — a bounded producer/consumer over a diff --git a/docs/usb-hub.md b/docs/usb-hub.md index 085cfdb..c0799f0 100644 --- a/docs/usb-hub.md +++ b/docs/usb-hub.md @@ -29,8 +29,9 @@ full-speed keyboard. ## Slot-context fields for a downstream device -`buildAddressInputContext` fills the Slot Context. Today it hardcodes route 0 and -the root-hub port. A downstream device additionally needs: +`buildAddressInputContext` fills the Slot Context from the device record: a +root-port device carries route 0 and its own root-hub port. A downstream device +additionally carries: - **Route String** (Slot Context dword 0, bits 19:0) — 5 tiers × 4 bits, each tier the downstream hub-port number. Composed as @@ -80,10 +81,12 @@ When the bus scan (or a hot-plug bring-up) finds a device of class 9: ## Testing -QEMU's `usb-hub` is a USB 2.0 single-TT hub. A **static boot topology** -(`-device usb-hub,bus=xhci.0,id=h -device usb-kbd,bus=h.0`) presents the -downstream device connected from the start, so the bus reads it on the first -status-change report — exercising the full path (hub setup, TT slot context, +QEMU's `usb-hub` is a USB 2.0 single-TT hub. A **static boot topology** on a +dedicated second controller (`-device qemu-xhci,id=xhci2 -device +usb-hub,bus=xhci2.0,port=1 -device usb-kbd,bus=xhci2.0,port=1.1` — isolated from +the boot controller's auto-assigned devices, whose ports the hub would collide +with) presents the downstream device connected from the start, so the bus reads +it on the first status-change report — exercising the full path (hub setup, TT slot context, downstream enumerate, class-driver bind) without needing a hot-plug event. A new `usb-hub` QEMU case asserts the hub enumerates, the downstream keyboard enumerates behind it, and `usb-hid-keyboard` binds. diff --git a/docs/vdso.md b/docs/vdso.md index c536f41..a6c9734 100644 --- a/docs/vdso.md +++ b/docs/vdso.md @@ -119,16 +119,23 @@ One table entry per kernel call, C ABI (System V AMD64), names prefixed returns are `u64`, errors return as negative values exactly as today. The calls that return two values in `rax:rdx` today — `dma_alloc` -(virtual_address + physical_address), `msi_bind` (address + data), `shared_memory_create` (virtual_address + handle) — +(virtual_address + physical_address), `msi_bind` (address + data), `shared_memory_create` (virtual_address + handle), +`fs_resolve` (route tag + node token / backend handle) — become functions returning a two-`u64` struct. The System V ABI returns a 16-byte struct in `rax:rdx`, so the stub is a plain `syscall; ret` — the -C-ABI spelling of the existing convention, at zero cost. +C-ABI spelling of the existing convention, at zero cost. The one call that +returns *three* values — `ipc_reply_wait` (receive_len in `rax`, badge in +`rdx`, received capability in `r8`) — exceeds the two-register return: its +function returns a three-`u64` struct, which the ABI passes via a hidden +result pointer, so that one stub stores `rax`/`rdx`/`r8` through the pointer +after the `syscall` — a few instructions rather than one. Grouped as `abi.zig` groups them: | Group | Functions | |-------|-----------| | process | `danos_exit`, `danos_yield`, `danos_sleep`, `danos_spawn`, `danos_process_enumerate`, `danos_process_kill`, `danos_process_exit_reason`, `danos_process_subscribe`, `danos_process_signal`, `danos_signal_bind` | +| threads | `danos_thread_spawn`, `danos_thread_exit`, `danos_current_core`, `danos_futex_wait`, `danos_futex_wake`, `danos_thread_self`, `danos_thread_join`, `danos_set_thread_pointer` | | memory | `danos_mmap`, `danos_munmap`, `danos_dma_alloc`, `danos_dma_free`, `danos_shared_memory_create`, `danos_shared_memory_map`, `danos_shared_memory_physical` | | ipc | `danos_endpoint_create`, `danos_ipc_register`, `danos_ipc_lookup`, `danos_ipc_call`, `danos_ipc_reply_wait`, `danos_ipc_send` | | devices | `danos_device_enumerate`, `danos_device_claim`, `danos_device_register`, `danos_mmio_map`, `danos_irq_bind`, `danos_irq_ack`, `danos_msi_bind`, `danos_io_read`, `danos_io_write` | diff --git a/docs/vfs-protocol.md b/docs/vfs-protocol.md index fad1eca..fb7b946 100644 --- a/docs/vfs-protocol.md +++ b/docs/vfs-protocol.md @@ -7,10 +7,11 @@ > redirects the caller to the owning backend's endpoint plus the rewritten > mount-relative path — after which the client speaks THIS protocol to the > backend, unchanged. The Zig source of truth is `system/vfs-protocol.zig` -> (the `vfs-protocol` module), whose unit tests pin the sizes and values -> below. This page is the **language-neutral wire specification** of that -> contract — what a Rust or C client implements ([vdso.md](vdso.md) explains -> why the IPC protocols, not the syscall numbers, are danos's public ABI). +> (the `vfs-protocol` module), whose unit test pins a sample of the sizes +> and values below. This page is the **language-neutral wire specification** +> of that contract — what a Rust or C client implements ([vdso.md](vdso.md) +> explains why the IPC protocols, not the syscall numbers, are danos's +> public ABI). ## Transport @@ -57,16 +58,20 @@ exit events. | 16 | 4 | `len` | reply payload length in bytes | | 20 | 4 | — | padding | -On failure the router replies `status = -1`; a mounted backend's negative -status is forwarded to the client verbatim. A richer errno vocabulary is -future work — clients must treat *any* negative status as failure, not match -on -1. +On failure the backend replies `status = -1`, and that reply reaches the +client directly — there is no party between them on the wire. (Kernel-served +paths produce no wire replies at all: `fs_resolve`/`fs_node` failures are +syscall register statuses.) A richer errno vocabulary is future work — +clients must treat *any* negative status as failure, not match on -1. ## Operations Values are append-only and never renumbered (the same evolution rule every -danos protocol follows); an unrecognised operation gets a `status = -1` -reply. +danos protocol follows). Send only values from this table: the shipped server +decodes the operation into an exhaustive enum, so an out-of-range value is +not answered with a `status = -1` reply — it trips a safety check in safe +builds and is undefined otherwise. (The `-1` replies cover recognised but +refused operations, such as `mount` sent to a backend.) | value | operation | request payload | reply | |------:|-----------|-----------------|-------| @@ -84,10 +89,12 @@ reply. Notes per operation: -- **open** — paths are absolute (`/mnt/usb/notes.txt`) or bare names - (`greeting`); bare names resolve in the VFS's flat ramfs, absolute paths - route through the mount table (below). The returned `node` is an id in the - *router's* open table; clients never see a backend's own ids. +- **open** — the path is the mount-relative path `fs_resolve` handed back + (absolute-shaped: `/notes.txt` under fat's `/mnt/usb` mount). Bare names + (`greeting`) resolve nowhere — the flat ramfs is retired, and `fs_resolve` + refuses non-absolute paths. The returned `node` is the *backend's* own + open-node id: with the router in the kernel there is no forwarding table, + and clients hold backend ids directly (see *Lifetimes and trust*). - **read / write** — a single exchange moves at most 224 bytes (`maximum_payload`); the client loops, advancing `offset` by the returned `len`, until done (read) or the slice is written (write). A `write` reply @@ -105,8 +112,12 @@ Notes per operation: the longest matching prefix wins, and an optional backend-side rewrite prefix maps a mount into the backend's namespace (fat serves `/mnt/usb` from its volume root and `/var` from its `/var` subtree). -- **rename** — same-directory rename only (the router requires old and new to - resolve under one mount). +- **rename** — same-directory rename only: the backend compares the old and + new parent paths and refuses a mismatch. The client (`runtime.fs`) refuses + earlier when the two paths resolve to different backend endpoints, but that + check is coarser than "one mount" — one endpoint can serve several mounts + (fat serves `/mnt/usb` and `/var`), so a cross-mount rename reaches the + backend and fails on its same-directory check. ## Open flags @@ -156,11 +167,12 @@ table can grow. ## Lifetimes and trust -Open-node ids live in the server. A client that dies without closing leaks -nothing permanently: the VFS subscribes to the kernel's published process-exit -events (docs/process-lifecycle.md) and releases a dead client's handles, -closing forwarded backend nodes best-effort. Ids are plain integers, not -capabilities — the VFS trusts its callers with each other's ids today, which +Open-node ids live in the backend. A client that dies without closing leaks +nothing permanently: the backend (the FAT server) subscribes to the kernel's +published process-exit events (docs/process-lifecycle.md) and releases a dead +client's handles. The kernel VFS root needs no sweep at all — its node tokens +are permanent for a boot and carry no open state. Ids are plain integers, not +capabilities — a backend trusts its callers with each other's ids today, which is acceptable while every client is part of the system image and worth revisiting (per-client id namespaces) before third-party binaries arrive. @@ -169,8 +181,10 @@ revisiting (per-client id namespaces) before third-party binaries arrive. What a non-Zig implementation may rely on, and what it must not: - Operation values, flag bits, `NodeKind` values, and struct layouts are - **append-only and frozen once shipped** — the unit tests in `protocol.zig` - pin them exactly so a refactor can't silently move them. + **append-only and frozen once shipped**. The unit test in + `system/vfs-protocol.zig` pins a sample of them (the `DirectoryEntry` + size, `NodeKind` 0–1, `Operation` values 0, 4 and 5); this page is the + full record of the frozen values. - The 256-byte message ceiling is a property of the current IPC transport, not a promise; clients should read `maximum_payload`-shaped limits from the reply lengths they actually get (loop-until-done), not hard-code 224. diff --git a/docs/vision.md b/docs/vision.md index 41dd62d..98ba86b 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -90,11 +90,12 @@ own page tables — plus a [test harness](testing.md). - **Isolation track** — **user mode + address-space isolation**. *Done: a higher-half kernel with a physmap (the low half is user space), per-process address spaces with CR3 switched on context switch, the `swapgs` discipline, - `syscall`/`sysret`, a user-ELF loader, and `/system/services/init` running as a real - preemptive ring-3 process (PID 1). Remaining polish: an address-space/stack - reaper for exited tasks, SMAP + fault-recovering copy-in/out, the real IPC - syscalls (IPC_Call/IPC_ReplyWait — they arrive with the second user server), - and TLB shootdown once a process has more than one thread.* + `syscall`/`sysret`, a user-ELF loader, an address-space/stack reaper for exited + tasks, and `/system/services/init` running as a real preemptive ring-3 process + (PID 1). Remaining polish: SMAP + fault-recovering copy-in/out, and TLB shootdown + once a process has more than one thread. (The real IPC syscalls — + `ipc_call`/`ipc_reply_wait` — have since been built and are the backbone every + driver and service speaks; see [ipc.md](ipc.md).)* - **Resilience track** — fault → kill → notify, a supervisor/reincarnation server, resource cleanup on death, then a restartable driver as proof. Needs isolation. See [resilience.md](resilience.md). diff --git a/docs/zig-self-hosting.md b/docs/zig-self-hosting.md index 23e67f2..ae7f6c1 100644 --- a/docs/zig-self-hosting.md +++ b/docs/zig-self-hosting.md @@ -137,20 +137,22 @@ be. `runtime.os` is only the interim staging ground: developed against the stock toolchain so Phase 1 need not wait on the fork, then promoted near-verbatim into the fork's `std/os/danos.zig`. -### Retire `library/posix` +### Retire `library/posix` (done) The `posix` compatibility layer (`unistd`, `stdio`) was the right instinct too early. -Its whole value is POSIX *spellings* for POSIX software — and danos has no POSIX -software; every current caller is danos-native code that could use `runtime.fs` +Its whole value was POSIX *spellings* for POSIX software — and danos has no POSIX +software; every caller was danos-native code that could use `runtime.fs` directly. The real POSIX story arrives later and from elsewhere (musl, or upstream -`std`'s own posix over `std.os.danos`), which supersedes a hand-rolled shim. So it is -premature abstraction that adds a "which layer do I use?" fork with no payoff yet. +`std`'s own posix over `std.os.danos`), which supersedes a hand-rolled shim. So it was +premature abstraction that added a "which layer do I use?" fork with no payoff. -Its footprint is tiny: **five** call sites, all `unistd` file operations — +Its footprint was tiny: **five** call sites, all `unistd` file operations — `system/services/fat/fat.zig` (`mount`), the `vfs-test` and `fat-test` clients, and -(from the boot-log work) `init.zig` and `log-flush.zig`. `stdio.zig` is dead — nothing -imports it. The plan: build `runtime.fs`, migrate those five to it, delete -`library/posix/`, and drop the `posix` module from `build.zig`'s `addUserBinary`. +(from the boot-log work) `init.zig` and `log-flush.zig`. `stdio.zig` was dead — nothing +imported it. The plan — build `runtime.fs`, migrate those five to it, delete +`library/posix/`, and drop the `posix` module from `build.zig`'s `addUserBinary` — has +since been carried out: `library/` today holds only `mmio`, `runtime`, and +`xkeyboard-config`. ## Where danos stands: coverage vs. the gaps @@ -158,7 +160,7 @@ What the seam needs, and what danos already provides: | std need | danos today | Gap | |----------|-------------|-----| -| open / read / write / close / lseek | VFS (via the current `unistd`, → `runtime.fs`) | none — repackage | +| open / read / write / close / lseek | VFS (via `runtime.fs`; the `unistd` shim is retired) | none — repackage | | directory read (`getdents`) | VFS `readdir` | none — repackage | | mmap / munmap | native syscalls ([abi.zig](../system/abi.zig)) | none | | page allocator | over `mmap`, via `root.os.heap.page_allocator` override | ~30-line hook | @@ -172,7 +174,7 @@ What the seam needs, and what danos already provides: | **cwd / chdir** | paths are absolute or bare | missing (no cwd anchor) | | **entropy / random** | — | missing (needed behind `vtable.random`) | | process spawn + exit status | `system_spawn` starts a *named ramdisk binary*; `ExitReason` is a *category* | no exec-of-path, no numeric `WEXITSTATUS` | -| threads | one thread per process | avoided via `-fsingle-threaded` (below) | +| threads | native threads — `thread_spawn`/futex/`thread_join` (`runtime.Thread`) | `std.Thread` seam unwritten; avoided via `-fsingle-threaded` (below) | | symlinks | `NodeKind` has the tag; unimplemented | low priority | The clustering is clear: reads and memory are basically done; the real work is @@ -241,7 +243,7 @@ readdir/isatty/args/exit) exists. Those are downstream and out of scope here. danos's biggest genuine gap, and the correctness-critical one: - Add **mkdir / unlink / rename / truncate** to *both* the VFS wire protocol - ([protocol.zig](../system/services/vfs/protocol.zig)) and the FAT engine + ([vfs-protocol.zig](../system/vfs-protocol.zig)) and the FAT engine ([engine.zig](../system/services/fat/engine.zig)), then expose them via `runtime.os`. - Extend `stat` beyond `{size, kind}` to carry **mtime + inode + mode** — `std`'s file stat needs them for build-cache validity — which in turn needs **wall-clock** time @@ -270,8 +272,9 @@ fork):** the `runtime.os` seam, `cwd`, stdio-as-fds, and the compiler bring-up. Build the compiler with **two load-bearing flags**: - **`-fsingle-threaded`** removes `std.Thread` entirely — `Thread.spawn` is a hard - compile error under it, and `std.Io`'s threaded backend runs inline. danos being - one-thread-per-process is therefore **not** a blocker. Parallel codegen is a + compile error under it, and `std.Io`'s threaded backend runs inline. The unwritten + `std.Thread` seam is therefore **not** a blocker (danos has native threads now — + `thread_spawn`/futex — but the seam need not cover them yet). Parallel codegen is a throughput optimisation, not a correctness requirement. - **`-fno-llvm -fno-lld`** keeps codegen and linking **in-process** (the self-hosted x86-64 backend + self-linker), so a single `build-exe` **never forks a child**. That @@ -306,17 +309,18 @@ today), symlinks, and musl. `system_spawn` only starts a *named ramdisk binary*, not exec of an arbitrary path. Verify the self-hosted backend covers the target output before assuming child processes are optional. -- **The shim cannot host the compiler.** danos's current `runtime`/`posix` is fine for +- **The shim cannot host the compiler.** danos's current `runtime` is fine for danos's *own* native programs, but the compiler `import`s *upstream* `std`, which on a non-target hits the void `system` stub. So the compiler forces the real target (Phase 0's fork). Do not over-invest in extending the hand-shim for compiler purposes; put that effort into `runtime.os` + the VFS/FAT operations, which both the fork *and* a future musl consume. -- **`"w"`/`O_CREAT` does not truncate — a silent-corruption bug on this road.** The FAT - engine's `writeFile` only *grows* `node.size`, so overwriting a shorter file leaves - trailing garbage. Harmless for the boot log today, but for a compiler it means - **corrupt `.o`/cache files that look like nondeterministic compiler bugs.** Land - `truncate` (Phase 2) before the compiler ever writes cache. +- **`"w"`/`O_CREAT` not truncating was a silent-corruption bug on this road — fixed in + Phase 2.** The FAT engine's `writeFile` only *grows* `node.size`, so overwriting a + shorter file used to leave trailing garbage — for a compiler that means **corrupt + `.o`/cache files that look like nondeterministic compiler bugs.** `engine.truncate` + + the O_TRUNC open flag (wired through the VFS protocol and `runtime.fs`) closed + this before the compiler ever writes cache. - **Exit status is categorical, not numeric.** `process_exit_reason` returns an `ExitReason` *category*, not a numeric code (`WEXITSTATUS`). Fine while spawn is stubbed; the day `zig build` or external tools arrive, plan a kernel exit-record diff --git a/system/kernel/architecture/x86_64/isr.s b/system/kernel/architecture/x86_64/isr.s index 8029822..704d844 100644 --- a/system/kernel/architecture/x86_64/isr.s +++ b/system/kernel/architecture/x86_64/isr.s @@ -1,6 +1,6 @@ # x86_64 low-level entry code: the CPU-exception stubs, plus the GDT/IDT load # helpers. Kept in a dedicated assembly file rather than inline asm because these -# need real labels and cross-symbol jumps/calls (isr_common, exceptionHandler), +# need real labels and cross-symbol jumps/calls (isr_common, interruptDispatch), # and because `lgdt`/`lidt` memory operands aren't expressible in Zig inline asm. # # Each exception vector normalises the stack to a uniform trap frame — a dummy diff --git a/system/services/acpi/acpi.zig b/system/services/acpi/acpi.zig index 8f3dcff..f5993b0 100644 --- a/system/services/acpi/acpi.zig +++ b/system/services/acpi/acpi.zig @@ -400,8 +400,8 @@ fn isSubscriber(task: u32) bool { } /// Enter S5 (soft off): write SLP_TYP|SLP_EN to the PM1 control register(s). -/// Mirrors the kernel's power.zig sleepValue. Only reached from a PID-1 -/// shutdown request (M21.3). +/// The kernel has no S5 path — soft-off is owned here, from the service's own +/// AML parse. Only reached from a PID-1 shutdown request (M21.3). fn enterS5() void { if (!s5_valid or pm1a_cnt == 0) { _ = runtime.system.write("power: S5 unavailable\n");