docs: full docs-vs-code audit — fix every stale claim across 40 docs

Every doc verified claim-by-claim against the code by parallel audit agents,
then fixed and adversarially re-verified. Two waves of staleness corrected:
the originally audited findings (higher-half boot handoff, kernel VFS
takeover, fault isolation + claim release + driver restart, AML/S5 moving to
ring 3, threading's shipped design, USB+FAT landing) and a second pass of
adjacent claims the verifiers caught (smp.md 'not built yet' intro,
system-requirements' PS/2-only and no-storage claims, halting.md's red-panic
and no-IDT text, testing.md's serial mirroring, router-era vfs-protocol
wording, capsule-first boot loading).

threading.md now documents the shared-fate gap explicitly: the design says a
process dies whole, the kernel today kills only the offending thread.

Also fixes three stale code comments (isr.s exceptionHandler, acpi.zig
sleepValue, build.zig boot-volume) — comments only, no behavior change.
This commit is contained in:
Daniel Samson 2026-07-22 09:09:53 +01:00
parent 52df2ba6f6
commit e854f65623
No known key found for this signature in database
GPG Key ID: A9EB2589C60F7268
43 changed files with 821 additions and 546 deletions

View File

@ -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 });

View File

@ -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` |

View File

@ -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).

View File

@ -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

View File

@ -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/<name>/`.
page-table formats, and calling conventions. Each needs its own `system/kernel/architecture/<name>/`.
## 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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 (M19M20, 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.)

View File

@ -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 (M19M20)
@ -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

View File

@ -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

View File

@ -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:

View File

@ -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.

View File

@ -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.

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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"):

View File

@ -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

View File

@ -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.

View File

@ -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 `<binary path>: 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;

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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 INITSIPISIPI (via the
- **AP wake to long mode**`architecture.startSecondary` drives INITSIPISIPI (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 →
INITSIPISIPI → 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,

View File

@ -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 INITSIPISIPI; 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 INITSIPISIPI; 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 3346, 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)

View File

@ -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

View File

@ -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=<name>`,
2. assembles a fresh EFI System Partition from the built binaries,
1. builds the kernel with `-Dtest-case=<name>` (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

View File

@ -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.

View File

@ -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 <case>`, 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

View File

@ -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.

View File

@ -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` |

View File

@ -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` 01, `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.

View File

@ -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).

View File

@ -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

View File

@ -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

View File

@ -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");