Author SHA1 Message Date
Daniel Samson be04ebe954 build: delete module_homes — imports resolve through the declared zon
The module-to-domain table in build-support duplicated what each
domain's build.zig already states with its addModule exports. userBinary
now resolves each named import by searching the packages the binary
declared in its own build.zig.zon (b.available_deps), which also makes
the zon the literal include path: an import can only be satisfied by a
domain the binary claims, and naming a module whose domain is missing
fails the build graph with the domain to declare. build-support is down
to the recipe alone. All build variants green; manifest unchanged.
2026-07-30 07:49:19 +01:00
Daniel Samson 62d6a7a150 build: the root's ship list becomes a declarative table
Each shipped binary was registered twice in the root build (a
dependency/artifact line plus a bundled row repeating its name and
path). The uniform rows — dependency name = artifact name = boot-path
leaf — collapse into production_ship, one line per binary via the
service()/driver()/driverArtifact() helpers; only the genuinely
non-uniform entries stay spelled out (init's -Dserial, the -Ddiscovery
pick, the /etc data files). Selecting what goes into a build is now
selecting table rows, and an unselected package's build file is never
loaded. The boot manifest is set-identical; its order shifts (discovery
and the /etc entries move) — every lookup is by name, and the
boot-order QEMU smoke passes.
2026-07-30 07:36:21 +01:00
Daniel Samson fa8203cdba kernel: the IOMMU backends move behind the architecture boundary
VT-d and AMD-Vi are x86 hardware, but lived in the architecture-neutral
kernel tree and leaked further: the core's public Kind enum named both
vendors, and the ACPI parser read the VT-d version/capability registers
(raw volatile MMIO inside table discovery). Now the vendor backends
live in architecture/x86_64/ behind architecture.iommu — the core hands
over the discovery facts plus an injected environment (frame allocation
+ the log sink, the same pattern enablePaging uses) and receives the
hardware vtable back, so the backends never import kernel internals and
an ARM port supplies its SMMU with no core change. Discovery keeps
table facts only; the live-unit register check moved into VT-d detect
(version reading zero now stays fail-open). The unused kindOf() is
gone. Log shapes the harness pins (iommu online, DANOS-IOMMU-FAULT)
are unchanged; all five IOMMU QEMU cases pass.
2026-07-30 07:16:08 +01:00
Daniel Samson e53d6ebafb library: client modules end in -client, like protocols end in -protocol
display-client and input-client (files and module names), so a service,
its wire contract, and its client never share a name: `display` the
service, `display-protocol` the contract, `display-client` a program's
view of it. The nine consumers' imports and their packages' declared
lists follow (regenerated from the source scan); build-support's
module_homes table carries the new names.
2026-07-30 06:56:12 +01:00
Daniel Samson 4476208361 build: exact per-binary imports — the pre-wired default set is gone
Every binary's build.zig now names precisely the modules its source
imports (derived by scanning each artifact's sources, transitively
through same-directory files), and its zon carries only the domains
those come from — kernel stays implicit (the root shim + link script
live there). build-support's userBinary resolves each name through one
module-to-domain table (module_homes); Domains/domains()/defaultImports
and the raw recipe entry point are deleted. An undeclared @import is a
compile error (verified: injecting @import("xkeyboard-config") into
logger fails with 'no module named ... available within module
program'), and e.g. xkeyboard-config now appears in exactly two
manifests — the two keyboard drivers. Availability never bloated the
emitted binaries (Zig compiles only what a program imports); this makes
the declared interfaces honest. Production and -Dtest-case manifests
byte-identical; all build variants and standalone package builds
green.
2026-07-30 06:42:31 +01:00
Daniel Samson c621b649f6 build: lazy dependencies — a build loads only what it ships
The 13 /test fixtures and the acpi/fdt discovery pair are .lazy in the
root zon, resolved with b.lazyDependency only when a build actually
bundles them: a plain `zig build` neither compiles the fixtures nor
loads their build files, and only the -Ddiscovery-selected package ever
loads. Fixture packages are uniform (dependency name = artifact name =
boot-path leaf), so the bundled list shrinks to a name loop. Production
manifest byte-identical; the -Dtest-case manifest carries the same 13
entries in the same order; -Ddiscovery=fdt exercises the lazy fdt path.
Discharges the plan's deferred what-this-buys #4.
2026-07-30 06:26:22 +01:00
Daniel Samson d27670ec39 tests: fix the two stale kernel self-tests the FHS boot tree broke
initial_ramdisk's spawn-everything sweep counted the /etc data files
(devices.csv, init.csv) as spawnable programs — BadElf ever since the
boot tree started ferrying them — so it now counts only the /system and
/test trees. The init test waited for two raw user writes before
checking the last write for the heartbeat text; init's boot chatter
(heap ok, the /etc/init.csv lookup) satisfies the count long before the
first beat, so it now waits for the heartbeat itself. Both cases pass
again; these failures predate the build-packages work.
2026-07-30 06:23:31 +01:00
Daniel Samson ab7594df6e docs: commit the new-driver checklist, step 2 rewritten for packages
The checklist (with the Intel UHD 750 worked example) existed only as
an untracked file in the main checkout — the plan referenced it but no
branch could see it. It lands here with its build step rewritten
against the finished package template: create the driver's own
build.zig/zon from the pci-bus template, then one dependency + one
boot-tree row + one zon line in the root build. devices-csv.md's
adding-a-driver section and the plan's checklist references point at
it again.
2026-07-30 04:41:41 +01:00
Daniel Samson d03942b543 docs: catch the docs up with the finished package split
The plan doc's status records completion (all waves + phase 3) and its
execution notes describe the finished shape; the fresh-session pointer
names build-support's userBinary instead of the deleted
addUserBinaryImpl, and the size-check carry-along note is discharged.
README gains the build/ directory in the layout tree and splits the
source-map row across root build.zig / build/images.zig /
build/qemu.zig. testing.md points at the distributed per-package test
steps; threading.md, threading-plan.md, driver-model.md, efi.md,
display.md, system-requirements.md, and devices-csv.md's adding-a-
driver checklist stop describing the pre-package build.
2026-07-30 04:24:34 +01:00
Daniel Samson 3f9b6813f7 build: thread-test package opts back into threading
Wave C dropped the .threaded flag the old addThreadedUserBinary call
carried, so thread-test compiled single_threaded: the heap mutex was
compiled out and atomics lowered to plain ops while the fixture spawns
real kernel threads — the QEMU thread-alloc case caught it (corruption
under concurrent allocation), and the completion review confirmed the
same finding independently. All 12 QEMU thread cases pass again.
2026-07-30 04:24:34 +01:00
Daniel Samson bc2eb67581 build: phase 3 — split image assembly and QEMU runners out of the root
build/images.zig owns everything between built binaries and a bootable
volume: the FHS zig-out install tree, boot manifest + capsule, both
FAT32 images, the release ISO, and their check steps. build/qemu.zig
owns run-x86-64 / run-x86-64-gpu and the OVMF probing. The root
build.zig (461 lines, from 1,242 pre-split) now only decides what
ships: kernel + loader, the package list, the bundled boot tree, and
the aggregate test step. The stale commented-out run scaffold is gone.
Boot-image file list unchanged; check-fat-image and check-iso-image
both green.
2026-07-30 04:11:22 +01:00
Daniel Samson cb98a9844e build: phase 2 wave C — test fixtures build as packages
All thirteen /test/system/services fixtures convert on the pci-bus
template (thread-test threaded; crash-test/device-list/pci-cap-test/
iommu-fault-test carrying their protocol and PCI extras). With the last
addUserBinary caller gone, the root build's wrapper functions and its
default-imports plumbing are deleted — every user binary now reaches
the shared recipe only through its own package. Production boot-image
file list unchanged; a -Dtest-case build bundles the fixtures exactly
as before.
2026-07-30 04:07:19 +01:00
Daniel Samson 4701fbd123 build: phase 2 wave B — drivers build as packages
ps2-bus (bus + keyboard + mouse artifacts from one package),
usb-xhci-bus, usb-hid (keyboard + mouse), usb-storage, and virtio-gpu
convert on the pci-bus template. Their unit tests — PS/2 decode, HID
boot reports, Bulk-Only Transport/SCSI encodings, and the virtio-gpu
size checks the plan flagged as a wave carry-along — move into their
packages; the root aggregate delegates. Boot-image file list
unchanged.
2026-07-30 04:04:50 +01:00
Daniel Samson 3b23b11b0e build: phase 2 wave A — services build as packages
init, fat, display, display-demo, device-manager, input, logger, and
the two discovery fillers (acpi, fdt — each exporting an artifact named
"discovery"; the root -Ddiscovery picks which ships) convert to binary
packages on the pci-bus template. init's serial heartbeat flag rides
the dependency options (the root forwards its -Dserial). fat's and
display's unit tests move into their packages and the root aggregate
delegates to them. Boot-image file list unchanged.
2026-07-30 04:02:49 +01:00
Daniel Samson 902e4a0a9e docs: catch the build docs up with the package split
Review findings: the plan doc claimed no implementation existed, had the
domain dependency order wrong (kernel depends on protocol; device on
kernel + protocol + csv), never placed the three shared contracts, and
named a nonexistent new-driver-checklist.md. Its status now records the
implemented phases (and the deliberate pci-bus-first pilot), the target
shape carries the contract placements and the path-dependency-only
constraint on the kernel package's out-of-root abi export, and the
execution notes describe the post-pilot build for whichever session
runs the remaining waves. README's repo layout gains build-support/
and the packages-note; driver-model, threading, system-requirements,
and the two display plan docs stop citing root build.zig for recipe
facts that now live in build-support.
2026-07-26 23:12:14 +01:00
Daniel Samson 15575960bd build: single-source the default import set; delegate library tests
Review findings from the pilot: the 17-module default import set was
maintained twice (root's array and build-support's userBinary) — drift
would silently make packaged binaries differ from root-built ones. It
now lives once, as build-support.defaultImports; root and userBinary
both draw from it, and root's user-binary wrapper names the shim +
link script through the kernel package like build-support does.
The root aggregate test step likewise duplicated the library domains'
14 test definitions; it now depends on each domain's own standalone
test step (host-only, so root's -Dtarget/-Doptimize deliberately do
not reach them), and the client domain gains an empty test step for
uniformity. Boot-image file list unchanged.
2026-07-26 23:10:10 +01:00
Daniel Samson 6f4fdc2789 build: phase 2 pilot — pci-bus builds as a package
build-support gains the domains-based userBinary: the default import set
(the library/kernel concern modules, driver/service clients, mmio,
acpi-ids, xkeyboard-config) is assembled from the domain packages, and
the root shim + user link script come from the kernel package directory.
system/drivers/pci-bus is the first binary package: a ~15-line
declarative build.zig naming only its extras (device-manager-protocol,
pci-class); the root build consumes the artifact for the boot image and
the driver also builds standalone from its own directory. Boot-image
file list unchanged.
2026-07-26 22:55:47 +01:00
Daniel Samson 721288c516 build: phase 1 — library domains become packages
Each library domain (kernel, device, client, protocol, csv,
xkeyboard-config) now owns a build.zig + build.zig.zon that wires and
exports its modules, with a standalone `zig build test` per domain.
The kernel package also exports abi (source stays in system/abi.zig) so
every consumer names one module instance. The root build swaps its
createModule calls for b.dependency(...).module(...) — no binary moves;
the root is the pilot consumer (docs/build-packages-plan.md). Path
dependencies deduplicate by resolved location, so the diamond
(root -> device -> kernel, root -> kernel) yields a single instance of
each module. Boot-image file list unchanged.
2026-07-26 22:53:53 +01:00
Daniel Samson 4194bb6e32 build: phase 0 — extract the build-support package
The shared user-binary recipe (freestanding target, root-shim wiring,
link-script and image-base settings) moves out of the root build into
build-support/, the package that is the single home for cross-cutting
build changes (docs/build-packages-plan.md). The root build's
addUserBinary/addThreadedUserBinary keep their signatures and delegate;
nothing else moves. Boot-image file list unchanged.
2026-07-26 22:49:05 +01:00
Daniel Samson fc0b934b7f docs: add the build-packages plan 2026-07-26 22:43:22 +01:00
Daniel Samson 6a687fbc2b iommu: AMD-Vi backend behind the vendor-neutral core
The second hardware backend. The IOMMU core, DMA-region capabilities, and
per-device enforcement are unchanged; this adds AMD-Vi (IVRS) as an
alternative to Intel VT-d (DMAR) under the same Backend vtable.

- parseIvrs records the IOMMU control-register base from the first IVHD;
  the platform layer gains iommu_is_amd, and the core picks the backend by
  vendor at init. VT-d and AMD-Vi are mutually exclusive on real hardware.
- iommu-amd.zig: a 2 MiB device table (every DTE zeroed = deny-all until a
  device is claimed), AMD native-format page tables (4 KiB leaves), a
  command buffer (INVALIDATE_DEVTAB_ENTRY / INVALIDATE_IOMMU_PAGES /
  COMPLETION_WAIT) and an event log for faults. The DTE forwards
  interrupts unmapped, so MSI passthrough works exactly as on VT-d.
- The boot log and the iommu self-test are now vendor-aware.

**UNTESTED on real AMD hardware** — danos is developed on Intel, so this
is validated only against QEMU's amd-iommu, and every log line and doc
says so. QEMU quirk handled: its amd-iommu does not observe the
COMPLETION_WAIT store form, but consumes the command ring synchronously on
the tail-register write, so invalidations are already applied by the time
we poll — the backend warns once and proceeds.

Cases: amd-iommu (detection + scratch-domain walker) and
amd-iommu-usb-storage (full storage stack through AMD device-table
translation with per-grant capabilities), both green. 106/106.

This completes the IOVA/IOMMU-enforcement track: per-device DMA domains on
both vendors, with buffers reachable only through delegated capabilities.
2026-07-26 18:31:27 +01:00
Daniel Samson 4e7cbc9792 iommu: DMA-region capabilities — per-grant reachability, protocol flag-day
Replaces L2's interim DMA pool (every buffer reachable by every claimed
device) with true per-grant confinement: a device reaches only buffers
whose capability was delegated to its driver.

Kernel:
- DmaRegionObject (handle kind 2): a delegation token naming a dma_alloc'd
  region, passable across processes on the IPC cap slot like an endpoint
  or shared-memory object. Frames stay owned by the allocating address
  space (freed on dma_free/teardown as before); the token carries a `dead`
  flag so a stale downstream handle can no longer bind a freed region.
- dma_alloc gains the dma_shareable flag: it returns a capability handle
  in r8 and every region is tracked in a registry. A task's own regions
  auto-bind into the devices it claims (its rings just work); foreign
  buffers are bound explicitly.
- dma_bind / dma_unbind / handle_close syscalls (51-53). dma_bind maps a
  held region (or shared-memory) capability into a claimed device's domain;
  it is idempotent. handle_close reclaims a table slot (raised 16 -> 32).
- dma_free and task death unmap a region from every domain and invalidate
  BEFORE its frames return to the allocator — the stale-IOTLB use-after-
  free window, closed structurally.

Protocols (flag-day): block gains attach, usb-transfer gains dma_attach —
each carries a region capability on the cap slot. fat allocates its bounce
buffer shareable and attaches it; usb-storage allocates its transport
buffers shareable, attaches them to the controller, and forwards fat's
capability downstream; usb-xhci-bus binds and closes; virtio-gpu binds its
shared scanout surface. The physical addresses on the wire are unchanged
(identity IOVA), so no register-programming code moved.

Cross-process DMA (fat -> usb-storage -> xHC) now flows only through
delegated capabilities. iommu-usb-storage / iommu-usb-hid / iommu-fault
all green under per-grant enforcement; 104/104 overall (fail-open paths
unchanged).
2026-07-26 18:31:27 +01:00
Daniel Samson e94adcfc02 iommu: per-device domains with interim DMA-pool enforcement
Replaces L1's shared blanket identity domain with a private translation
domain per claimed PCI function. A device now reaches only:
  - the DMA pool: every dma_alloc'd region, mapped into every claimed
    device's domain (poolAdd/poolRemove, driven from the dma_alloc and
    dma_free syscalls). This keeps the cross-process buffer handoff
    working (fat's bounce buffer reaches the xHC) while blocking the
    kernel, page tables, process heaps, MMIO, and unallocated RAM.
  - its own firmware reserved region (RMRR), seeded at confine time.
The pool is the honest interim: devices can still reach one another's
DMA buffers. The DMA-region capability layer (next) narrows it to
per-grant reachability.

dma_free unmaps from every domain and invalidates BEFORE the frames
return to the allocator, closing the stale-IOTLB use-after-free window.
Driver death tears down its domains (detach + free tables) before the
broker claims and DMA frames are released.

New iommu_fault_drain syscall (+ driver.iommuFaultDrain) forces pending
fault records to the log on demand. The new iommu-fault case proves it:
a claimed e1000e is programmed to DMA-fetch its TX ring from an unmapped
page; VT-d faults the access (bdf 00:03.0 addr 0x1000 reason 0x6) and the
system stays alive. 104/104.
2026-07-26 18:31:27 +01:00
Daniel Samson f477ef7d9f iommu: enable Intel VT-d translation with per-claim device confinement
First enforcement step of the IOVA track. A vendor-neutral IOMMU core
(iommu.zig) drives an Intel VT-d backend (iommu-intel.zig) to give DMA a
real translation layer instead of the fail-open free-for-all M16 left.

- Boot posture is now stated explicitly: "iommu online (Intel VT-d)"
  with version/agaw/rmrr, or "none present - DMA fail-open (unisolated)".
- DMAR parsing extended to select the INCLUDE_PCI_ALL unit (real Intel
  PCs put an iGPU-scoped unit first) and record single-path-endpoint
  RMRRs; multi-hop scopes and extra DRHDs are counted and warned, never
  silently dropped.
- Translation is enabled at boot into a blanket identity domain (all RAM
  + RMRRs, 2 MiB leaves). PCI functions are enumerated post-boot by the
  ring-3 pci-bus driver, so a device is attached to the domain when its
  driver claims it (confineDevice, with claim rollback if confinement
  fails) and detached on driver death, before broker release and DMA
  frame teardown. Unclaimed devices are non-present: their DMA faults.
- Interrupt remapping stays off, so MSI writes to 0xFEE00000 bypass
  translation and the interrupt-driven xHC keeps working.
- devices-broker gains pciAddressOf (derives BDF from the config-space
  ECAM offset), unclaim, and forEachPciFunction.

Faults are drained and logged rate-limited as DANOS-IOMMU-FAULT.

Cases: iommu extended (translation on, scratch-domain map/resolve/unmap,
zero idle faults); new iommu-usb-storage and iommu-usb-hid run the full
storage + input stacks through translated DMA with MSI intact. 103/103.
2026-07-26 18:31:27 +01:00
Daniel Samson 9e649178bf pci: full driver-side library (MSI/MSI-X, power, FLR, extended caps); xhci goes interrupt-driven
library/device/pci is now the complete generic floor a leaf PCI driver
needs, instead of just what virtio-gpu used:

- pci-class: capability IDs, MSI/MSI-X/power-management/PCI-Express
  register layouts, extended-capability header decode (host-tested),
  per-bit command constants, remaining header offsets.
- pci.Function: header accessors, disableBusMaster + interrupt-disable
  helpers, findCapability, programMsi/disableMsi, MsiX vector-table
  struct, ensurePowerStateD0, functionLevelReset (BAR save/restore),
  extended-capability iterator.

Proven by the new pci-caps QEMU case: a pci-cap-test fixture claims an
extra e1000e (PM+MSI+PCIe+MSI-X, no danos driver) and readback-verifies
every surface, including the first driver-side use of msi_bind.

usb-xhci-bus converts from 8 ms event-ring polling to message-signalled
interrupts: plain MSI where offered (real Intel xHC), MSI-X entry 0
otherwise (qemu-xhci has no MSI capability), byte-identical polling as
fallback. The timer survives as a 250 ms port-reconcile/lost-edge tick —
real-hardware USB2 hub debounce still needs it. MSI setup runs BEFORE
controller bring-up: QEMU's xhci only registers the MSI-X vector as used
when IMAN.IE is written while MSI-X is already enabled; interrupts are
silently dropped otherwise (real hardware does not care about the order).

101/101 QEMU cases green; real-hardware smoke passed (mouse works,
boot 2026-07-23T174805Z, plain-MSI branch, vector 33).
2026-07-26 18:31:27 +01:00
danielandClaude Opus 4.8 e376c9e908 build: keep test fixtures out of a no-option build
A plain `zig build` was baking all 11 /test/system/services fixtures into the
production image. Split the bundle: the production set always, the userspace test
fixtures only when -Dtest-case is set — which the QEMU harness passes for every
scenario, exactly when the fixtures are needed on the boot volume. So `zig build`
with no options now ships a clean image (no test binaries in the manifest,
capsule, or FAT tree) and assumes neither -Dtest-case nor -Ddiagnose (the
diagnose display-omission was already gated by the init.csv selection).

Verified: a plain build has zero /test entries in the manifest; -Dtest-case=smoke
includes all fixtures; the args/vfs/device-list QEMU scenarios still pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJqSiLLchDUUCoXn5jsiwd
2026-07-26 17:49:40 +01:00
danielandClaude Opus 4.8 48b9ed4001 drivers: log devices.csv columns + friendly labels for all buses
Each bus's discovery line now prints the device's would-be /etc/devices.csv row
(bus, base, class, prog_if, vendor, device, subsystem / hid) in uppercase hex,
followed by the human-readable names — so a row for a new driver reads straight
off the boot log, for pci, usb, and acpi alike.

- pci-bus: logFunction moved to registerAndReport (where vendor/device/subsystem
  are read from config space) and reformatted to columns + names; subsystem
  prints '*' when the function has none. Read unclaimed via the bridge ECAM, so
  no per-function claim is needed.
- usb-xhci-bus, acpi: the same column framing on their existing discovery lines.
- qemu_test.py: the acpi-ps2 and acpi-report regexes updated to the new ACPI
  format (both verified passing in QEMU).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJqSiLLchDUUCoXn5jsiwd
2026-07-26 17:32:27 +01:00
danielandClaude Opus 4.8 081ba1d74e init: data-driven boot service list via /etc/init.csv
Replace init's hardcoded boot_services array with an authoritative,
human-readable service list read at boot, mirroring /etc/devices.csv. Each row
is a service binary path followed by its argv; startup order is file order,
shutdown the reverse. There is no hardcoded fallback — a missing file starts
nothing (the no-ramdisk isolation behavior).

- library/csv: shared CSV helpers (comment strip, field iteration) with unit
  tests; device-registry is refactored onto them so both /etc/*.csv files parse
  through one place.
- init reads /etc/init.csv into fixed-max static tables (the same pattern as the
  device registry) and passes each row's argv straight to spawnSupervised. This
  also makes boot-time modes (e.g. device-manager test-usb-restart) expressible
  as data rather than hardcoded.
- Diagnose mode (-Ddiagnose omits the display stack) becomes build-time file
  selection between etc/init.csv and etc/init-diagnose.csv, so init carries no
  comptime service logic; the diagnose build option is dropped from init.
- build.zig: csv module wired; /etc/init.csv bundled into the initrd; the
  device-registry tests move to a dedicated block since they now import csv.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJqSiLLchDUUCoXn5jsiwd
2026-07-26 17:13:51 +01:00
danielandClaude Opus 4.8 203528c8a7 device-manager: data-driven driver matching via /etc/devices.csv
Replace the three hardcoded switch tables (pciDriverForIdentity, hidDriverFor,
usbDriverForIdentity) with an authoritative, human-readable device registry the
manager reads at boot. Matching is most-specific-wins across
base/subclass/prog_if/vendor/device/subsystem/hid, so a precise vendor:device
rule and a generic class rule coexist; an unmatched device is logged, never
guessed. This resolves docs' "matching stays code until the third bus".

- ABI: child_added and DeviceDescriptor gain vendor/device/subsystem; child_added
  gains a bus discriminator (BusKind) so PCI and USB class triples match against
  the right namespace.
- pci-bus reads vendor/device (config 0x00) and subsystem (0x2C, type-0) and
  reports them.
- library/device/registry: freestanding CSV parser + matchDriver() with
  specificity scoring; 5 unit tests wired into `zig build test`.
- etc/devices.csv bundled into the initrd; the kernel serves /etc directly, so
  the manager reads it before any filesystem service is up (fat starts later).
- virtio-gpu: drop the now-redundant post-spawn 1AF4:1050 re-confirm, since the
  registry binds this driver by exact identity.
- Remove the orphaned system/drivers/display driver (unreferenced by build or
  registry).
- docs: new devices-csv.md; device-manager.md "matching stays code" resolved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJqSiLLchDUUCoXn5jsiwd
2026-07-26 16:43:13 +01:00
Daniel Samson bf0c3fd3e0 kernel: strip debug info in non-Debug builds
ReleaseSafe kept full DWARF/symtab because nothing set strip on the
kernel module, bloating the flashable image to 2.4MB (~1.4MB of which
was debug info). Strip for every optimize mode except Debug, so
release builds shrink to ~900KB while debug builds keep symbols.
2026-07-23 02:17:50 +01:00
Daniel Samson 3af0110483 update docs/os-development/README.MD 2026-07-23 01:41:47 +01:00
Daniel Samson 757c6f14c3 re-org docs 2026-07-23 00:25:34 +01:00
Daniel Samson 52d6e372fd re-org docs 2026-07-23 00:24:01 +01:00
Daniel Samson f023f1cfd6 reorg: move test fixtures to test/system/services (source + boot volume)
The 11 QEMU-suite fixtures lived mixed into system/services/ with their
binaries bundled at /system/tests/<name>. Now the repo path is the boot
path, like every real service: test/system/services/<name>. fat-test
moves out of the fat server's directory into its own; display-demo
stays a boot service.

- kernel VFS: setInitialRamdisk derives one read-only initrd mount per
  top-level tree named by the ramdisk entry paths (/system, /test),
  registers ancestors generically with self-parented roots, and refuses
  backend shadowing of any initrd tree
- EFI loader: the fallback walk also enumerates \test (optional — a
  volume without fixtures still boots); manifest and capsule unchanged
- path literals: vfs-test self-open + create probe, process-test
  process_enumerate matches, the args-echo argv[0] expectation; the
  kvfs case now covers the /test root end to end
- docs: DFHS /test rows, tree diagrams, loader prose, and the location
  convention gain the third home; fixed the input-source link

100/100 QEMU cases pass.
2026-07-23 00:11:07 +01:00
Daniel Samson b9cec7d1be docs: fix two review findings from the reorg audit
An adversarial review of the reorg surfaced two doc inaccuracies:

- timers.md: a `time` code example still did `@import("runtime").time`, which
  no longer compiles — the runtime aggregator was deleted. Now `@import("time")`.
  (Reorg regression: the sweep rewrote `runtime.x` member access but not the
  `@import("runtime")` form inside a fenced code block.)

- sysv.md: `callconv(system.kernel_abi)` named a non-existent `system` module;
  `kernel_abi` lives in the boot-handoff contract (system/boot-handoff.zig),
  imported as `boot_handoff`. Now `boot_handoff.kernel_abi`. (Pre-existing bug —
  "system" was the file-path prefix mistaken for a module namespace — found and
  fixed opportunistically.)
2026-07-23 00:02:27 +01:00
Daniel Samson 6271278d4d C6: update docs for the library/kernel + client split
Rewrote the repository-layout and driver-model docs to describe the new tree —
library/kernel (the kernel32-style system library: syscall surface split by
concern), library/device (mmio/model/pci/usb/acpi/driver/block), library/client
(display, input service clients), library/protocol (wire contracts) — and swept
the reference docs off the retired runtime shim:

  runtime.system.*  -> logging.* / time.* / process.* / memory.*
  runtime.dma.* / runtime.shared_memory.* / runtime.allocator -> memory.*
  runtime.ipc/process/time/service/Thread/block/display/input -> the module name
  runtime.device / runtime.device_manager -> driver
  library/runtime/<file>.zig -> its new home (kernel/ client/ device/)

docs/README.md (repository layout + source map), docs/driver-model.md (the module
graph + import lists), and the concern/reference docs (ipc, threading, timers,
logging, power, process-lifecycle, device-manager, display, vdso, sysv, drivers,
input, vfs-protocol, coding-standards, ...) now reflect the split. "runtime" that
remains is the userspace-library *concept*, which is still accurate.

The historical plan docs (display-plan, display-v2-plan, threading-plan) and the
zig-self-hosting design note are left as point-in-time snapshots.
2026-07-22 23:42:25 +01:00
Daniel Samson 37326c7664 C5: delete the runtime.zig and system.zig compatibility shims
With every consumer migrated (C2), nothing imports `runtime` or `system` any
longer. Removed both shim files and their build.zig module definitions, dropped
the `runtime` entry from `default_imports`, and rewrote the per-binary root shim
(library/kernel/root.zig) to import `start` (_start + panic) and `logging`
(std_options) directly instead of through `runtime`.

addUserBinary/addThreadedUserBinary/addUserBinaryImpl lose their `runtime_module`
parameter; the root module now pulls the two modules it needs out of
`default_imports` via a small findImport helper.

The runtime dumping ground is gone: the userspace library is library/kernel
(concern modules), library/device (driver/pci/usb/block/model/mmio/acpi data),
library/client (display, input service clients), and library/protocol (wire
contracts). Consumers @import concern modules by name.

Verified: zig build, zig build test, and QEMU (smoke, thread-spawn, thread-mutex,
logger, display-native, device-manager, usb-storage, input).
2026-07-22 23:32:05 +01:00
Daniel Samson 23bcd77c58 C2: migrate consumers off the runtime shim to direct concern-module imports
Every user binary and the two device-logic library modules (pci, usb) now
`@import` the concern modules directly instead of aliasing through `runtime`:

  runtime.ipc/process/time/service/input/block/display  -> @import("<module>")
  runtime.device / runtime.device_manager               -> @import("driver")
  runtime.fs                                             -> @import("file-system")
  runtime.Thread                                         -> @import("thread").Thread
  runtime.system.{write,writeRecord,klog*}              -> logging.*
  runtime.system.{sleep,timerOnce,wallClock,clock}     -> time.*
  runtime.system.{spawn*,kill,exit,yield,processes,...}-> process.*
  runtime.system.{mmap,munmap,PROT_*}                  -> memory.*
  runtime.dma.* / runtime.shared_memory.* / runtime.allocator -> memory.*

Each consumer keeps its own alias name (e.g. `const device = @import("driver")`),
so call sites are unchanged and there are no collisions with local `driver`
variables. build.zig now injects the concern modules into every user binary via
`default_imports`; pci/usb module import lists were updated to match.

The `runtime` and `system` shims remain for one more step (root.zig still uses
runtime); they are deleted in C5. Nothing but root.zig imports `runtime` now.

Verified: zig build, zig build test, and 17 QEMU cases (smoke, device-manager,
logger, fat-mount, fat-mutations, usb-storage, usb-hid, display-native,
virtio-gpu, input, thread-spawn, thread-mutex, process-kill, shared-memory,
driver-restart, acpi-ps2, pci-scan).
2026-07-22 23:28:34 +01:00
Daniel Samson dded46726b reorg: relocate device/service clients (C3/C4)
- device.zig + device-manager.zig -> library/device/driver/driver.zig (module
  "driver"): the driver author's whole interface — device access (claim/mmioMap/
  irqBind/...) plus the device-manager hello() handshake, folded into one import.
- block.zig -> library/device/block/block.zig (a device type).
- display.zig/input.zig -> library/client/{display,input}/ (userspace-service
  clients — they talk to services, not the kernel).

build.zig module graph updated; the runtime shim now maps runtime.device and
runtime.device_manager onto "driver", so consumers stay untouched (migrated in C2).

zig build green; driver-restart, usb-storage, display-native, input pass.
2026-07-22 23:02:04 +01:00
Daniel Samson 60f32ee9ff reorg: split the runtime into library/kernel concern modules (C1)
The one giant `runtime` module (with a `system.zig` that was itself a dumping
ground of unrelated syscalls) is split into directly-importable, flat concern
modules under library/kernel/:

  system-call  ipc  memory  process  thread  time  logging  file-system  service  start
  (+ the device/service clients: device, device-manager, block, display, input)

system.zig is dissolved — its functions moved to their concern home (mmap ->
memory, spawn/kill/exit -> process, sleep/clock -> time, write/klog -> logging,
fs* -> file-system). `memory` merges heap+dma+shared-memory behind one flat API
(memory.allocator/dmaAlloc/sharedCreate/mmap), keeping heap's state and malloc
export single. The memory<->thread dependency cycle (heap needs Thread.Mutex,
thread needs mmap) is broken by having thread allocate its own stack via the raw
mmap syscall, so the module graph is a DAG.

This is the atomic step: all 42 internal cross-imports flip from relative to
module imports at once. `runtime.zig` and `system.zig` become thin re-export
SHIMS so the ~38 consumers keep compiling on `runtime.*` untouched; they migrate
to direct imports in C2, after which the shims are deleted (C5).

zig build + zig build test green; 14 QEMU cases pass (smoke, process,
process-kill, thread-spawn/join, logger, vfs, fat-mount, display-native,
usb-storage, virtio-gpu, device-manager, input, power-button).
2026-07-22 22:53:13 +01:00
Daniel Samson 3e69712b97 reorg: rename library/runtime -> library/kernel (groundwork)
First step of splitting the runtime dumping ground. Pure directory rename
(git mv library/runtime library/kernel) + the six build.zig path references
repointed. The module is still named "runtime" for now; the next commits split
it into concern modules (ipc, memory, process, time, logging, file-system, ...),
dissolve system.zig, and delete the runtime aggregator.

The library/kernel name follows the kernel32 model: it is the userspace library
that wraps the private kernel ABI, distinct from system/kernel/ (the kernel).

zig build green.
2026-07-22 22:27:39 +01:00
Daniel Samson 11f567ee20 kernel: flatten the device/discovery code, merge power into acpi, rename fdt
Follow-up cleanup of the just-moved kernel device code:

- power.zig -> acpi.zig. Its reboot() is built entirely on the FADT reset
  register (acpi.power_information) plus the legacy 0xCF9/8042 fallbacks — it is
  ACPI reboot, so it becomes acpi.reboot (the "P" in ACPI). platform.reboot
  still delegates; soft-off/S5 stays the ring-3 acpi service's job as before.

- device-tree.zig -> fdt.zig. It is a discovery *backend* (the ARM/FDT parser,
  a sibling of acpi.zig), not part of the model — renaming it to its actual
  subject kills the confusing device-tree / device-model DeviceTree name clash
  and makes acpi.zig + fdt.zig read as the two parallel backends.

- Flatten: device-model.zig, acpi.zig, fdt.zig, platform.zig move out of the
  system/kernel/devices/ subdir up into system/kernel/, joining devices-broker.zig
  (already flat). The kernel dir is a flat pile by convention (only architecture/
  is a subdir), so the subdir — and its poor "devices" name — is gone.

Pure restructure; "platform" module name unchanged, all cross-file deps are
relative siblings that moved together. zig build + test green; smoke, discovery,
acpi-parse, acpi-ps2, acpi-report, power-button, orderly-shutdown, reboot pass.
2026-07-22 21:49:20 +01:00
Daniel Samson 3a155cdc7d reorg: move system/devices into system/kernel/devices
With the shared device data (device-abi, pci-class, usb-abi, usb-ids, acpi-ids,
aml) now in library/device/, what remained in system/devices/ was purely
kernel-internal: the firmware-discovery machinery and the rich pointer-based
device model (platform, device-model, acpi, device-tree, power), reached only
through the "platform" module by three kernel files. It belongs with the kernel.

Move it to system/kernel/devices/. A pure relocation: the "platform" module
name is unchanged and every cross-dir dependency is a module import, so only the
one b.path and some comments move. The top-level split is now clean —
system/kernel/ is the kernel, library/device/ the shared device libraries,
system/{drivers,services} the userspace. The runtime /system/devices concept
(the virtual device tree) is unaffected; system/kernel/devices/ is its
implementation.

Also fixes two comment refs that still pointed device-abi at its pre-Wave-1a
home (system/devices/) — it lives at library/device/model/ now.

zig build + test green; smoke, discovery, acpi-parse, acpi-ps2, acpi-report pass.
2026-07-22 21:35:30 +01:00
Daniel Samson 5b874fc756 reorg: move mmio into library/device and spell out its API
mmio is device-driver code, so it joins the other domains under
library/device/mmio/ (module name "mmio" unchanged — a pure relocation, only
the build paths move). And its abbreviated function names are spelled out per
docs/coding-standards.md:

  read  -> readRegister          mb  -> memoryBarrier
  write -> writeRegister         rmb -> readMemoryBarrier
                                 wmb -> writeMemoryBarrier

All call sites updated (virtio-gpu, usb-xhci-library, pci.Function); the two
display-driver placeholders import mmio but use nothing, so they're untouched.
Docs (driver-model graph, README layout, drivers.md, the FHS note) follow the
new path and names.

zig build + test green; virtio-gpu, display-native, display-reattach, usb-hid,
usb-hub, usb-storage, pci-scan pass.
2026-07-22 21:28:32 +01:00
Daniel Samson 7d540c4b2f reorg: normalise every protocol alias to its module name
Finish the direct-import convention: a protocol is now aliased to its module
name in snake_case in every file that imports one — consumers and the runtime
client wrappers alike. This kills the last of the alias variety (the generic
`protocol`, plus `power` and `transfer`), so `device_manager_protocol.Hello`
means the same thing everywhere and grepping a module name finds all its uses.

Pure rename (identifier uses only; prose references left untouched via a
guarded pattern). 14 files, 299/299 lines.

zig build + test green; 16 QEMU cases pass (smoke, device-manager,
driver-restart, device-list, pci-scan, usb-hid, usb-storage, display-service,
display-native, display-reattach, input, acpi-ps2, vfs, fat-mount,
power-button, orderly-shutdown).
2026-07-22 21:17:54 +01:00
Daniel Samson ac2d102878 reorg: docs — library/device/ + library/protocol/ structure
Update the docs to match the reorganized tree:

- driver-model.md: the "Families" graph now shows library/device/<domain>/
  (data + logic split) and library/protocol/ instead of the old bus/ + proto/
  sketch; add the microkernel note (the kernel's one library/ import is the
  device-abi data module) and the rule that a driver's hardware command set is
  not a service-seam protocol.
- README.md "Repository layout": device-abi and the protocols move out of
  system/ into library/device/ and library/protocol/; rewrite the "public
  interface as a module" paragraph around the protocol tree and the device
  domains; fix the source-map paths.
- Sweep every remaining stale path reference across docs/ to the moved files.
- Fix vfs-protocol.zig's own header (the standalone VFS server retired; the fat
  server is the backend today).

zig build + test green.
2026-07-22 21:09:48 +01:00
Daniel Samson 3c9475e33a reorg: move power-protocol to library/protocol; finish the protocol tree
power-protocol -> library/protocol/power/power-protocol.zig. Its two consumers
(init and the acpi discovery service) import it directly; the
runtime.power_protocol re-export and the runtime module import are dropped (no
runtime client speaks it). The now-empty system/services/power/ is removed.

This completes library/protocol/: every driver<->service wire contract lives
there and is imported by module name, no protocol is re-exported through
runtime, and the alias chaos (dp/sp, mixed protocol/<x>_protocol) is resolved.
virtio-gpu-protocol stays a driver-private relative import (a hardware command
set, not a driver<->service seam — like virtio-pci.zig beside it).

zig build + test green; power-button, orderly-shutdown pass.
2026-07-22 21:04:37 +01:00
Daniel Samson 44122bd44d reorg: move display + scanout protocols to library/protocol
display-protocol -> library/protocol/display/, scanout-protocol ->
library/protocol/scanout/. Consumers import both directly, killing the cryptic
dp/sp aliases in virtio-gpu (now display_protocol / scanout_protocol) and the
runtime.display_protocol / runtime.scanout_protocol re-exports. runtime.display
(the client) still imports display-protocol by name; scanout has no runtime
client, so its runtime module import is dropped too.

zig build + test green; display-service, virtio-gpu, display-native,
display-reattach pass.
2026-07-22 21:02:31 +01:00
Daniel Samson 9ef22d6e55 reorg: move device-manager-protocol to library/protocol + direct import
device-manager-protocol -> library/protocol/device-manager/. Its consumers
(pci-bus, usb-xhci-bus, the acpi discovery service, device-manager, device-list,
crash-test, and the not-yet-built intel-integrated display sub-driver) import the
module directly instead of through runtime.device_manager_protocol, which is
deleted. runtime.device_manager (the hello client) already imported the module
by name and is unchanged.

zig build + test green; device-manager, driver-restart, device-list, pci-scan pass.
2026-07-22 20:59:43 +01:00
Daniel Samson 07c901c18c reorg: move input-protocol to library/protocol + direct import
input-protocol -> library/protocol/input/input-protocol.zig. Its five
consumers (usb-hid keyboard/mouse, ps2 keyboard/mouse, the input service) now
import the module directly and alias it input_protocol, replacing the
runtime.input_protocol re-export (deleted) and the inconsistent local `protocol`
aliases. runtime.input (the client) still imports the module by name.

zig build + test green; input, acpi-ps2, usb-hid pass.
2026-07-22 20:54:53 +01:00
Daniel Samson 25cc4d610e reorg: move vfs-protocol to library/protocol + direct import
vfs-protocol -> library/protocol/vfs/vfs-protocol.zig. fat, its only consumer,
now imports the module directly (const vfs_protocol = @import("vfs-protocol"))
instead of through runtime.vfs_protocol, and the runtime re-export is deleted.
The runtime's own client (runtime.fs) still imports the module by name.

zig build + test green; vfs, fat-mount pass.
2026-07-22 20:52:09 +01:00
Daniel Samson f90dc6c121 reorg: begin library/protocol/ — move block + usb-transfer
Start the protocol tree: wire protocols move to library/protocol/<name>/,
entry file <name>-protocol.zig, module name unchanged. block and usb-transfer
are pure moves — every consumer already imports them by module name, so only
the b.addModule paths change and the empty system/services/block/ is removed.

  block-protocol         -> library/protocol/block/block-protocol.zig
  usb-transfer-protocol  -> library/protocol/usb-transfer/usb-transfer-protocol.zig

zig build + test green.
2026-07-22 20:49:07 +01:00
Daniel Samson 1d1234c963 reorg: move the USB client to library/device/usb (drop runtime.usb)
The USB class-driver transfer client was library/runtime/usb.zig, re-exported
as runtime.usb — which compiled the USB client and usb-transfer-protocol into
every user binary (init, fat, the compositor…), none of which speak USB. It is
bus-family logic, not core runtime.

Move it to its domain home, library/device/usb/usb.zig (module "usb"), alongside
the usb-abi and usb-ids data modules; fix its internal imports to go through the
runtime module; and re-export usb.abi / usb.ids so a class driver reaches the
whole USB domain through one import. runtime.usb and runtime's
usb-transfer-protocol import are removed; the three class drivers
(usb-hid keyboard/mouse, usb-storage) import module "usb" directly.

zig build + test green; usb-hid, usb-storage pass.
2026-07-22 20:46:51 +01:00
Daniel Samson a7f0c1a450 reorg: extract library/device/pci — the claimed-function view
New pci logic module (library/device/pci/pci.zig): a device driver's view of
the one PCI function it has claimed — Function.map (config space = resource 0),
vendorId/deviceId/command/status, enableMemoryAndBusMaster, mapBar (BAR decode
+ resource correlation + mmio_map, cached), and a capabilities() iterator. The
generic PCI mechanics every leaf PCI driver used to re-derive inline.

The config-space layout it needs — header offsets, the command MEM|bus-master
bits, the status capabilities-list bit, the capability-pointer mask, and the
BAR bit fields — is named in the pci-class data module (a "Configuration-space
layout" section), so the bus enumerator can share the same constants later.

virtio-gpu is the first consumer: its inline mapBar + walkCapabilities + config
header reads are gone, replaced by pci.Function; only the virtio-specific
cfg_type dispatch (and the virtio common-config cfgRead/cfgWrite, which are NOT
PCI config space) stay in the driver. The generic display driver will use the
same module. pci-bus's enumerator (arbitrary-function probing) is untouched.

zig build + test green; virtio-gpu, display-native, display-reattach, pci-scan pass.
2026-07-22 20:43:47 +01:00
Daniel Samson f86f2987d5 reorg: decouple the microkernel from the device taxonomies
The kernel's only dependency on pci-class and acpi-ids was cosmetic: the
boot-time device-tree dump (DeviceTree.dump) decoded class codes and _HID
strings to human names. That is device decoding — a user-space concern in a
microkernel, and the last thing pinning two userspace taxonomies into the
kernel's compile.

dumpNode now prints raw values (the class tag, the raw _HID string, and the
packed PCI class code as hex); a user-space tool that owns the taxonomies
(the device manager already imports them) can pretty-print when wanted. The
pci-class / acpi-ids imports are dropped from device-model.zig and from the
platform module.

The kernel's sole remaining library/device/ import is now device-abi (pure
data — the device-model types the broker marshals across the syscall
boundary), the one intentional kernel->library crossing.

zig build + test green; smoke, discovery, acpi-parse, acpi-ps2 pass.
2026-07-22 20:38:29 +01:00
Daniel Samson 794a8b5782 reorg: move device data modules into library/device/<domain>/
First step of the device-code reorganization (plan: group device code by
domain, split each domain into a shareable data module + a logic module).
This moves the pure-data modules — the enums, wire types, and taxonomies
that any layer including the kernel can import cheaply — out of
system/devices/ and into their domain home:

  device-abi  -> library/device/model/device-abi.zig
  pci-class   -> library/device/pci/pci-class.zig
  usb-abi     -> library/device/usb/usb-abi.zig
  usb-ids     -> library/device/usb/usb-ids.zig
  acpi-ids    -> library/device/acpi/acpi-ids.zig
  aml/        -> library/device/acpi/aml/

Module names are unchanged, so this is a pure file move: only the
b.addModule paths and the host-test file list in build.zig change; no
importer is touched. system/devices/ now holds only the kernel-internal
device model (device-model, platform, acpi, device-tree, power).

The device *logic* (the pci Function helper, the usb transfer client) and
the kernel's cosmetic taxonomy dependency are handled in following commits.

zig build + zig build test green.
2026-07-22 20:36:24 +01:00
Daniel Samson ea470afe84 runtime: consolidate the device-manager hello into runtime.device_manager
Every supervised driver owes the device manager a hello at startup, and
the retry-lookup-call-check for it had been copied into five drivers:
usb.helloManager (misfiled in the USB client) plus hand-rolled twins in
display, virtio-gpu, pci-bus, and usb-xhci-bus.

Extract it once as a runtime client, runtime.device_manager.hello(role,
device_id) ?Handle — returns the manager endpoint (bus drivers keep it to
report children through), null when there is no manager or it refused the
handshake, and logs the outcome itself so each call site is one line.

Also correct two roles while collapsing their calls: virtio-gpu and the
display driver each claim one PCI function and report no children, so they
are Role.device, not Role.bus. The manager ignores role today, so this is
cosmetic, but it matches the protocol's own definition (bus = reports
children via child_added).

Behavior-preserving otherwise: pci-bus and usb-xhci-bus move their hello
logging from raw serial writes to std.log, which the kernel renders with
the same "<path>: " prefix, so driver-restart still matches
"usb-xhci-bus: hello acknowledged". zig build clean; 8 QEMU cases pass
(driver-restart, pci-scan, usb-hid, usb-storage, virtio-gpu,
display-reattach, device-list, device-manager).
2026-07-22 19:27:20 +01:00
Daniel Samson d27377ab1e add placeholders for display driver 2026-07-22 17:36:55 +01:00
Daniel Samson cacbacd76b docs: amd-gpus.md — RDNA2 display-driver feasibility, the AMD companion survey 2026-07-22 12:25:07 +01:00
Daniel Samson e63d6ef5ef Merge shared-fate: whole-process death (thread groups die together)
A fault in any thread, exit from any thread, or process_kill on any member id
now takes down the entire thread group; only a worker's voluntary thread_exit
stays per-thread. Leader id (tgid-style), dying latch on the address space,
group notification at space destruction, shared-memory mapping references,
per-space DMA/shm arena cursors, and the shm/DMA page-table walks brought
under the big kernel lock. Design + red-team + review in
docs/shared-fate-plan.md. QEMU suite: 100/100.
2026-07-22 11:25:32 +01:00
Daniel Samson c8191570e1 kernel: shared-fate review fixes — lock the shm/dma walks, contract edges
The adversarial review of the branch confirmed the big one: the shm/DMA
page-table walks and their pmm/heap calls ran outside the big kernel lock —
pre-existing, but fatal once the per-space cursors invited sibling threads to
race them (two concurrent creates could orphan a page table: one thread's
region silently unmapped, the frame leaked — a plausible root for the
long-standing intermittent AP ring-3 fault at the shm base). All three paths
now follow the mmap discipline: allocation, object build, record, and handle
under one lock hold with full rollback; the map itself per-page under brief
holds; dma_free's translate/unmap/free per-page likewise.

Contract edges from the same review: thread_spawn into a dying group returns
-ESRCH (was generic -1); process_kill during the condemned window answers
from the latch's stashed supervisor (0 or -EPERM, was -ESRCH once the leader
slot was reaped); exit derives the group reason from its own argument rather
than the racy exit_code global; a worker's thread_exit no longer overwrites a
concurrent group-kill stamp; checkGroupDead now asserts exactly-one
notification via the drained ring. Full suite: 100/100.
2026-07-22 11:23:32 +01:00
Daniel Samson 2bc2a0d70d kernel+tests: M4 shared-fate — nine group-death test cases, per-space arena cursors
Eight new QEMU cases (thread-fault-group, kill-threaded-group,
kill-via-worker-tid, racing-triggers, exit-group, leader-thread-exit,
thread-exit-solo, shm-mapping-ref) driving seven new thread-test modes; a
shared checkGroupDead asserts the contract everywhere: one notification,
badged with the leader, reason on the leader's record, no member listed,
claims released first.

The shm-mapping-ref case flushed out the per-task DMA/shared-memory arena
cursor bug directly (a sibling's regions mapped over the worker's), so both
cursors moved to the AddressSpaceRef like the mmap/MMIO cursors before them
(threading M7 pattern). Runtime gains Thread.tryExitCurrent for the leader
-EPERM refusal path. Docs updated: threading.md's shared-fate gap is closed,
process-management.md and process-lifecycle.md describe the leader re-key,
plan status = implemented. Full suite: 100/100.
2026-07-22 10:49:46 +01:00
Daniel Samson 1882161cb4 docs: system-image.md — the boot capsule documented in full
system.img was only mentioned in passing (efi.md, system-requirements.md);
its format, builder, fallback chain, and kernel-side life were spread across
pack-system-image.py, build.zig, efi.zig, initial-ramdisk.zig, and vfs.zig.
New dedicated page covers all of it, cross-linked from every prior mention
and added to the docs index.
2026-07-22 10:37:55 +01:00
Daniel Samson 08e139ebba kernel: M3 shared-fate — shared-memory frames live while any mapping does
Each address space that maps a shared-memory region now holds its own
reference, recorded on the AddressSpaceRef and dropped when the space is
destroyed — so 'last reference' means no handles AND no mappings, and a
region's frames can no longer be freed out from under a sibling thread (or
any other live mapper) when the handle-holding task dies. The group-death
notification still posts after every mapping release. (docs/shared-fate-plan.md M3)
2026-07-22 10:34:03 +01:00
Daniel Samson b09a62bc36 kernel: M2 shared-fate — group fan-out, dying latch, deferred leader notification
All process deaths (exit from any thread, ring-3 fault, process_kill) now kill
the whole thread group via killGroupLocked: latch the AddressSpaceRef as dying
(refusing new members, closing the thread_spawn escape), stamp every member
(leader carries the group reason — the record the supervisor reads), reap
parked members to fixpoint, condemn running ones. The leader's exit
notification and subscriber broadcast move to the group-death moment — the
last address-space reference drop — via scheduler.group_exit_hook, which
re-stamps the leader's exit record first. Leader thread_exit is refused with
-EPERM. kill_pending is atomic; exit_reason and fault_kill_count writes moved
under the big kernel lock. (docs/shared-fate-plan.md M2)
2026-07-22 10:23:41 +01:00
Daniel Samson daca0d9216 kernel: M1 shared-fate — Task.leader id, kill/signal re-keyed to the leader
Every task carries its process leader's id (main task: own id; threads:
copied from the spawner; kernel tasks: 0, never followed). process_kill and
process_signal resolve any member id to the leader and authorize against the
leader's supervisor, making both capabilities per-process. ProcessDescriptor
gains the leader field. No fan-out yet (docs/shared-fate-plan.md M1).
2026-07-22 10:17:13 +01:00
Daniel Samson e78810195d docs: shared-fate plan — approved design for whole-process death
Red-teamed draft: leader id (tgid-style), AddressSpaceRef dying latch,
killGroupLocked fan-out, group notification at address-space destruction,
shared-memory mapping refs. Leader thread_exit refused with -EPERM (decided
at sign-off). Milestones M1-M4.
2026-07-22 10:12:42 +01:00
Daniel Samson e854f65623 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.
2026-07-22 09:09:53 +01:00
Daniel Samson 52df2ba6f6 docs: audit all 'what's next' sections against the code; fix stale comments
Verified every deferred item in the nine docs with a what's-next section and
marked what has since landed (reaper, per-process CR3, higher-half kernel,
kernel heap, contiguous frame alloc, RSDP capture, cap-passing, driver restart
with backoff, PS/2 keyboard) while keeping the genuinely open items. Also
corrects interrupts.md's claim that the keyboard skipped the IO-APIC, and
updates scheduler.zig/heap.zig comments that predated the reaper and the big
kernel lock.
2026-07-22 02:23:51 +01:00
Daniel Samson a081f69def docs: mark interrupts.md 'what's next' items as done (IO-APIC, SSE state) 2026-07-22 02:15:12 +01:00
Daniel SamsonandClaude Haiku 4.5 f9dea7790a docs: rename arch.md to architecture.md and update all references
Rename the file to match current naming conventions and update all
cross-references throughout the documentation. Update the content to
reflect the current state of the codebase:

- Module is now called "architecture", not "arch"
- Directory is system/kernel/architecture/, not system/kernel/arch/
- Generic kernel file is kernel.zig, not main.zig
- Updated file listing for x86_64 architecture module (added smp.zig,
  per-cpu.zig, ioapic.zig, trampoline.s)
- Updated function list in cpu.zig to include enterUser/userExit and
  address-space management
- Clarified that _start lives in architecture-specific isr.s
- Expanded architecture-specific vs generic comparison table with
  actual current modules

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-22 02:10:00 +01:00
Daniel Samson 7e4071a065 fat: a small write-through block cache for metadata sectors (B5b)
Directory scans and FAT-chain walks re-read the same sectors constantly:
resolving many paths under one directory (a logging burst opening dozens
of files under /var/log/<stamp>/) re-read that directory's sectors and the
FAT from the device every time. Add a 16-line write-through cache under the
single-sector blockRead/blockWrite path, keyed by filesystem-relative LBA
with round-robin eviction, so repeated metadata reads come from RAM instead
of a USB round trip each.

Disciplines that keep it safe:
- Write-through: every write reaches the device immediately and refreshes
  the cache, so it never holds dirty-only data — the crash-safe
  data->FAT->directory write order and the flush-on-close are unchanged.
- Bulk file data (the B5a multi-sector run path) bypasses the cache and a
  run write invalidates any overlapping cached sector, so metadata that
  shares a range can never go stale.
- Cluster-zeroing writes uncached (write-once bulk that would only evict
  live metadata), invalidating any stale copy.

A new engine test proves a repeated resolve does zero device reads and that
a write is coherent both in-cache and against a cold-mounted filesystem
(it really reached the device).

Full QEMU suite green (the one intermittent `logger` miss is the documented
pre-existing AP ring-3 fault: 16/16 logger reruns pass on this change and
every other storage case is green; the fat engine is single-threaded and
the cache is bounded global state, so it cannot itself fault intermittently).
2026-07-22 01:08:34 +01:00
Daniel Samson 6e261ccda1 fat: multi-sector block transfers (B5a)
The FAT engine read and wrote one sector per device command — one SCSI
READ(10)/WRITE(10) over USB Bulk-Only Transport per 512 bytes, so every
file read, log write, and cluster fill paid a full USB round trip per
sector. The lower stack (runtime.block, the block protocol, usb-storage's
read10/write10 + count*block_size data stage) already carried a
multi-sector count; only the engine's BlockDevice interface and IpcBlock
were single-sector.

Widen BlockDevice to move a run of `count` contiguous sectors per call
(readBlock/writeBlock kept as count=1 wrappers, so metadata call sites —
FAT sectors, directory entries — are untouched). readFile and writeFile
now coalesce the aligned full-sector middle of a transfer into one command
(capped at the cluster boundary and the 4 KiB bounce = 8 sectors), reading
straight into / writing straight from the caller's buffer with no staging
copy. Full-sector writes skip the read-modify-write entirely, since they
overwrite the whole sector. Partial head/tail sectors keep the per-sector
RMW path.

IpcBlock passes count through to the block driver and sizes its bounce from
engine.max_transfer_sectors (already 4 KiB — no new allocation). A new
spc=8 engine test proves runs coalesce (a 21-sector overwrite drops from
>=21 writes to <=5) and that reads/writes round-trip byte-identical,
including an unaligned offset spanning a cluster boundary.

Full QEMU suite 92/92.
2026-07-22 00:11:51 +01:00
Daniel Samson ded555961c Merge usb-superspeed-hub: hubs work on real hardware (M22)
USB hub support, root-caused and validated on the user's real machine:
a Keychron keyboard and ROG mouse now enumerate through a Genesys
compound USB3 hub (its USB2 companion + transaction translators) and
type on danos.

Fixes found by reading logs off the stick, each invisible to QEMU:
- SuperSpeed hubs have change bits a USB2 hub lacks; not clearing them
  spun the driver. Clear them (gated on speed) + a per-tick service cap.
- The boot scan runs ~3 ms after the controller reset, too early for a
  USB2 connection to debounce; the SuperSpeed devices train instantly
  and hid the problem. Poll the root ports on the tick (edge-triggered).
- Full-speed devices failed Address Device with a USB Transaction Error:
  USB 2.0 requires a 10 ms reset-recovery interval before SET_ADDRESS.
  Wait it out, and retry Address Device on a transaction error.

Plus richer logs: readable PORTSC decode, USB link-state and speed
names, device string descriptors (maker/product), and a readable name
for every class/subclass/protocol enum in usb-ids. Full suite 92/92.

Open follow-up: 'port 11 enumeration failed' — a third device that did
not come up; to be investigated separately.
2026-07-21 23:48:29 +01:00
Daniel Samson 802d51ba74 usb-ids: human-readable name for every class/subclass/protocol enum
Every enum in usb-ids now has a name function, so any code (logs, tools)
can print the readable value instead of a raw byte: hub.protocolName,
hid.subclassName/protocolName, mass_storage.subclassName/protocolName,
communications.subclassName, wireless_controller.subclassName/
protocolName, miscellaneous.subclassName/protocolName,
application_specific.subclassName (alongside the existing className,
speedName, linkStateName).

interfaceName is now comprehensive across classes — it decodes the
well-known triples a log shows: 'HID boot keyboard', 'Mass Storage
(Bulk-Only)', 'Hub (SuperSpeed)', 'Bluetooth' (E0/01/01), etc. So the
bus log reads e.g.

  interface 0: Mass Storage (Bulk-Only) (8/6/80) registered as device 30
  interface 0: HID boot keyboard (3/1/1) registered as device 33

Host-tested with usb-ids (the name decodings are pinned). Vendor-ID
naming deliberately left out (needs a data table); this is pure standard
class-code decoding.
2026-07-21 23:43:01 +01:00
Daniel Samson 570f6f545c usb: quiet the investigation diagnostics now that hubs work on real HW
The compound-hub investigation is resolved (Keychron keyboard + ROG
mouse enumerate through the Genesys USB2 companion hub and type on real
hardware). Turn the debugging spam back down for main: the full 22-port
PORTSC dump runs only when a scan finds NOTHING (a 'why is this empty'
aid), not every boot; a downstream hub port logs only when a device
actually appears or leaves, not for every empty seed-sweep port. The
readable device names, class names, and port-change events stay.
2026-07-21 23:33:12 +01:00
Daniel Samson da5f404041 usb: post-reset recovery delay + Address Device retry on transaction error
Real-hardware root-cause, from the now-readable log: the tick-poll fix
DID catch the user's full-speed devices (USB2 root ports 3, 9, 11
'device appeared — Full-speed'), but every one failed 'Address Device
completion code 4' = USB Transaction Error. Cause: danos addressed the
device immediately after the port reset, but USB 2.0 (spec 7.1.7.5)
requires a reset-recovery interval (TRSTRCY, 10 ms) before a device
answers SET_ADDRESS. QEMU tolerates the omission; real full-speed
devices do not.

setupDevice now waits 10 ms after a port reset before addressing, and
addressDeviceCommand retries up to 3 times on a transaction error,
re-resetting a root-port device between attempts (xHCI 4.6.5 recovery).
QEMU USB cases still green. Real-hardware confirmation pending — this is
the specific fix for the user's keyboard/mouse failing to address.
2026-07-21 23:25:19 +01:00
Daniel Samson ab732dc455 usb: readable PORTSC decode, link-state names, and device string descriptors
Make the USB diagnostic logs scannable by eye:
- usb-ids gains speedName + linkStateName (USB3 PORTSC link states: U0,
  RxDetect, Polling, ...), host-tested with usb-ids.
- The PORTSC dump decodes the register instead of printing hex flags:
    PORTSC[3] 0x00021203: connected, enabled, link=U0, power=on, SuperSpeed
    PORTSC[5] 0x00020ee1: connected, disabled, link=Polling, power=on, High-speed
  (the raw word stays for reference). A USB2 device reads connected+
  disabled+Polling until reset — so the user's next log shows at a glance
  whether the companion port ever reaches that state.
- The library reads STRING descriptors (readString, UTF-16LE -> ASCII,
  English langid), and the device line now names the maker + product:
    port 5 device: Hub "Genesys Logic USB3.0 Hub" (0x05e3:0x0626), 1 interface(s)
  instead of a bare vendor/product id pair.

Full USB case set green (hub regexes follow the new 'device: <class>
"<maker> <product>"' format). Branch diagnostics for the SuperSpeed
compound-hub investigation.
2026-07-21 23:19:18 +01:00
Daniel Samson 4ea4a040d2 usb-ids: className/interfaceName helpers; readable device classes in the bus log
The logs read 'class 9/0/0' where they could say 'Hub'. usb-ids gains
className (a device/interface class byte -> 'Hub', 'HID', 'Mass Storage',
...) and interfaceName (a HID boot interface -> 'HID boot keyboard' /
'HID boot mouse'), both host-tested with usb-ids. The bus driver's
enumerate + register lines now read, e.g.:

  port 21 device: Hub vendor 0x05e3 product 0x0626, 1 interface(s)
  port 21 interface 0: Hub (9/0/0) registered as device 47
  port 5 interface 0: HID boot keyboard (3/1/1) registered as device 32

so a real-hardware log is scannable by eye. Full USB case set green
(hub regexes follow the new 'device: <name>' format).
2026-07-21 23:15:16 +01:00
Daniel Samson 0b10a637ae usb: poll root ports on the tick — catch a late USB2 connection (edge-triggered)
Real-hardware root-cause: the user's keyboard works in the firmware boot
menu but dies under danos. The port dump shows why — danos scans the
root ports ~3 ms after the controller reset, but a USB2 connection needs
~100 ms to debounce, so the boot scan sees the USB2 companion hub's port
empty (all USB2 ports ccs=0), while the SuperSpeed devices (hub, storage)
train instantly and DO show up. danos then relied on a Port Status
Change EVENT to catch the late USB2 connection, which does not fire
reliably on this hardware.

The tick now polls every root port and reconciles on the empty->connected
EDGE (previous-state tracked per port, seeded from the boot scan so
already-up ports never re-fire), bringing up a device that appears after
the scan without depending on the event. Edge-triggered so a port that
fails to enumerate is not retried every 8 ms. Branch-only until the
keyboard is confirmed on real hardware; the diagnostic dumps stay for
the next boot.
2026-07-21 23:06:49 +01:00
Daniel Samson 10956c6660 usb: [diagnostic] dump the xECP USB2/USB3 port map + all PORTSC
The USB2 companion hub carrying the user's full-speed keyboard never
appears — only the SuperSpeed hub (port 19) and SS storage (port 21)
connect. To find where the USB2 root ports are and whether the companion
is presenting on one, walk the xECP Supported Protocol capabilities
(logging each USB 2.0 / 3.0 root-port range) and dump every port's raw
PORTSC unconditionally (ccs/ped/pls/pp/speed). QEMU confirms the dump
(USB2 ports 5-8, USB3 ports 1-4). Branch-only diagnostic.
2026-07-21 22:56:25 +01:00
Daniel Samson 18408b0666 usb: [diagnostic] dump all root-port PORTSC + log port-change events
Branch-only debugging for the real SuperSpeed compound hub: the spin fix
(27f87cb) stopped the hang — the SS hub's 4 SuperSpeed ports now enumerate
(all empty, correct: the full-speed keyboard isn't a SuperSpeed device) —
but the USB2 COMPANION hub, where the keyboard actually lives, never
appears. Only root ports 19 (SS hub) and 21 (SS storage) connect.

Dump every root port's raw PORTSC at scan (connect/enable/link-state/
speed) and log every root port-change event, so the next boot shows
whether the companion is connected on a USB2 port we misread, connects
late as a port-change event, or is simply absent. Diagnostic logging —
to be removed once the companion path works.
2026-07-21 22:50:11 +01:00
Daniel Samson 27f87cb5ba usb: don't spin on a SuperSpeed hub's change bits; log hub port status
Real-hardware finding: a SuperSpeed hub (the user's Genesys, 4 SS
downstream ports) HUNG the bus driver at boot — it stopped logging with
no fault, and the hub's port power dropped. Cause: a SuperSpeed hub has
change bits a USB2 hub lacks (link-state, BH-reset), and the B4 servicing
— written and tested against QEMU's USB2 hub — never cleared them, so
the hub's status-change endpoint re-reported the same port forever and
the driver spun servicing it, never reaching the USB2 companion hub
where the full-speed keyboard actually lives.

hubPortStatusAck now clears the SuperSpeed-only change features too
(gated on hub speed; a USB2 hub is unaffected), and the tick services a
bounded batch of hub changes (32) before yielding — so even if a hub's
change bits misbehave, the driver cannot spin the machine. A per-port
status log line surfaces exactly what a hub reports, so the next real
boot shows the SS hub's port states and whether the USB2 companion
enumerates. QEMU usb-hub cases still green (the SS clears are gated off
for QEMU's USB2 hub).
2026-07-21 22:44:46 +01:00
Daniel Samson 7ee6033fa4 usb-hid: echo typed characters to the log — a simple keyboard check
There was no way to confirm a keyboard actually registers keys on real
hardware (enumeration binding the driver only proves the device came
up). The keyboard driver now echoes each decoded printable character
(and newline) to its log: type a known phrase and read it back from
usb-hid-keyboard.log, or watch it appear live on screen in a -Ddiagnose
boot (where the kernel console is a log sink). It exercises the whole
path — HID report -> diff -> layout -> character — not just enumeration,
and works identically for a keyboard behind a hub.

A usb-key-echo QEMU case injects a phrase via QMP send-key and asserts
it echoes (stable across repeated runs). Full suite 92/92.
2026-07-21 22:27:47 +01:00
Daniel Samson e52ae242fc usb: hub-downstream disconnect teardown + hub-behind-hub recursion (B4c)
Completes hub support (docs/usb-hub.md). On the bus tick, a downstream
hub-port change now dispatches: a connect enumerates the new device
(B4b), a disconnect tears the old one down — recursively, since a hub
that leaves takes its whole subtree with it (children first), reporting
each interface ChildRemoved and Disable-Slotting the device. Route
strings compose across tiers, so a device two hubs deep enumerates with
a two-tier route.

QEMU's hub — unlike root-port hot-plug — DOES raise downstream
status-change events, so both paths are harness-tested: usb-hub (a
keyboard behind a hub binds usb-hid-keyboard), usb-hub-nested (a
keyboard two hubs deep), usb-hub-unplug (device_del behind the hub tears
it down). Full suite 91/91.

Real-hardware validation of the user's SuperSpeed Genesys hub with
full-speed devices on its USB2 companion is flagged for the user (QEMU's
USB2 hub does not model the compound USB3 hub).
2026-07-21 22:16:42 +01:00
Daniel Samson 9da1a9899c usb: enumerate devices behind a hub — route strings + transaction translators (B4b)
The core of hub support (docs/usb-hub.md): a device on a hub's
downstream port now reaches its class driver exactly like one on a root
port. The hub's interrupt status-change endpoint is armed with an
in-process subscription — completions set the hub's pending-port mask
instead of queuing a class-driver report — and setupHub seeds every
downstream port pending so a STATIC topology (a device present at
power-on) enumerates without waiting on the initial interrupt edge.

On the bus tick, each pending hub port is serviced: GET_STATUS + clear
the change bits, and on a connect reset the port, read the speed, then
enable a slot and Address Device with the composed route string
((parent_route<<4)|port), the inherited root port, and the parent hub's
slot/port as the TRANSACTION TRANSLATOR — so the controller routes a
full/low-speed device's split transactions through the hub's TT. The
device then enumerates and registers its interfaces through the normal
path (a compact topology-unique port key keeps the id tag within its
8-byte cap), recursing setupHub if it is itself a hub.

Verified in QEMU (a keyboard behind a USB2 hub on a second controller):
'hub slot 1 port 1 device vendor 0x0627 ... usb-hid-keyboard: ok
(device 35)'. Full suite 89/89. The user's SuperSpeed Genesys hub +
full-speed keyboard/mouse on its USB2 companion is the real-hardware
target, flagged separately.
2026-07-21 22:06:54 +01:00
Daniel Samson 92b03b8d07 usb: recognize and configure hubs, power downstream ports (B4a)
First slice of hub support (docs/usb-hub.md), handled IN the xhci-bus
because a device behind a hub is reached by the CONTROLLER via a route
string in its slot context — topology only exists inside the driver
that owns the controller. When the scan enumerates a class-9 device it
now calls setupHub: read the hub descriptor for the downstream port
count and TT arrangement, tell the controller the slot is a hub
(Configure Endpoint with the slot add-flag sets the Hub bit, Number of
Ports, and MTT/TT-Think-Time per xHCI 4.6.6), SET_HUB_DEPTH for a
SuperSpeed hub so it can compose route strings, and SET_FEATURE
PORT_POWER every downstream port.

The Device gains topology fields (route string, root port, parent hub
slot/port for the TT) and buildAddressInputContext now fills them, so
the Address Device path is ready for downstream devices — those are
enumerated in B4b. A root-port device gets route 0 and its own port as
the chain root, unchanged behavior.

Verified in QEMU with a hub on a second controller and a device behind
it (a usb-hub QEMU case, since the boot controller's auto-assigned
devices collide on the low ports): 'hub slot 1: 8 downstream ports
powered (USB2 single-TT)'. Full suite 89/89. The user's SuperSpeed
Genesys hub is the real-hardware target, flagged separately (QEMU's
USB2 hub doesn't model the compound USB3 hub).
2026-07-21 21:56:59 +01:00
Daniel Samson 983b4ed05a usb: don't 'correct' SuperSpeed EP0 max packet size — it's an exponent
Regression from the B3 MPS0 work, caught on real hardware: a SuperSpeed
hub (and the SuperSpeed boot stick) failed to enumerate. bMaxPacketSize0
(device descriptor byte 7) is a LITERAL size for USB 2.0 and below
(8/16/32/64) but an EXPONENT for SuperSpeed (9 = 2^9 = 512). The refresh
read the exponent 9 as a size and issued Evaluate Context to set EP0 to
9 bytes, corrupting the control endpoint so every following transfer
failed. SuperSpeed's EP0 is fixed at 512 and needs no correction, so
the refresh is now skipped for speed >= 4; only full/low/high speed,
where the field is a literal that can differ from the speed default,
still run it. QEMU tolerated the wrong MPS0 — real silicon does not
(the class of bug the harness can't reach, flagged real-HW-pending).

This likely also explains intermittent no-storage/no-logs on a normal
boot: the boot stick is SuperSpeed and hit the same corruption. Full
suite green (orderly-shutdown's lone failure was its known QMP-timing
flake — three clean re-runs).
2026-07-21 21:26:56 +01:00
Daniel Samson 0ac07dadc9 usb: hot-plug plumbing, all ports powered, interrupter enabled (M20)
B3 — the runtime lifecycle a hot-pluggable bus needs, plus two init
fixes that runtime device arrival depends on:

- pump() now handles PORT STATUS CHANGE events (silently dropped
  before): it queues the port, and the bus driver brings the port up
  (a device arrived) or tears it down (a device left) on its tick —
  reporting each interface ChildRemoved to the device manager, which
  prunes the node, notifies watchers, and lets the class driver's world
  end honestly, then Disable Slot frees the controller-side state.
- ALL root-hub ports are powered at init, not just those with a
  boot-time device: an unpowered port (PP=0) cannot signal a later
  connect, so a hot-plug would never be seen.
- the interrupter is enabled (IMAN.IE + USBCMD.INTE) while the ring
  stays polled — some controllers only WRITE runtime events to the
  ring when the interrupter is enabled.

Real-hardware validation is flagged for the user: QEMU's qemu-xhci does
not raise a runtime port-change event to a polling driver on device_add,
so the end-to-end hot-plug path can't be exercised in the harness (the
port-change handling itself IS proven — a late boot device's PSCE is
caught and acked). The harness gained qmp_sequence (multi-step QMP
injection with arguments) for when a drivable case exists. Full suite
88/88; the working USB path (enumeration, HID, storage) is unregressed
by the port-power and interrupter changes.
2026-07-21 21:01:41 +01:00
Daniel Samson 4091ea6912 test: boot a per-run copy of the USB image — runs must not inherit leftovers
The guest mutates its boot volume (fat tests create files, the logger
writes /var/log) and QEMU is hard-killed after a match, so booting the
build artifact in place let one run's leftovers fail the next — a stale
TESTDIR tripping the mkdir-duplicate refusal was the lone 87/88 failure
in an otherwise green suite — and dirtied the build cache's own output.
Each case now boots a fresh copy in the work directory.
2026-07-21 20:37:59 +01:00
Daniel Samson d8cf533b73 usb: correct EP0's max packet size from the device; sample speed after reset
Two real-hardware correctness fixes from the M20 list, both invisible
to QEMU's forgiving controller:

refreshMaxPacketSize0 existed only as a comment. The EP0 context kept
the SPEED-DEFAULT max packet size (full-speed: 8) even when the device
declares 16/32/64 — real controllers fault the very first full
descriptor read on the mismatch, which is the standing suspect for
full-speed mice dying on the user's PC. Enumeration now probes the
device descriptor's first 8 bytes (deliverable at any legal MPS0),
and issues Evaluate Context to correct EP0 before any longer transfer,
naming the failure and codes if the controller refuses.

And a USB2 port's PORTSC speed field is only meaningful once the port
reset ENABLES the port: the speed (and the MPS0 default derived from
it) is now sampled after the reset instead of trusting the connect-time
read.
2026-07-21 20:35:36 +01:00
Daniel Samson 1638845a4b usb/fat: transfer events matched by slot+endpoint; storage failures heal
B2 — the 1-in-3 boot-time READ CAPACITY failure, root-caused: the xHCI
library's awaitTransfer claimed ANY unclaimed transfer event as its own
completion. An interrupt-endpoint event whose TRB pointer no longer
matched the armed subscription (an error or stale completion from the
keyboard/mouse polling concurrently with storage bring-up) fell through
and was misread as the bulk transfer's completion — desynchronizing the
mass-storage bulk protocol in controller state that SURVIVED driver
restarts, so every retry failed too. Awaited transfers now match the
event's slot id and endpoint DCI; foreign events are dropped and named.
Twelve consecutive runs of the previously-flaky cases pass; the full
suite is green with none of its old intermittents.

B1 — and when storage does fail transiently, the system now heals
instead of giving up forever: a nonzero exit maps to ExitReason.aborted
(a deliberate FAILURE exit — supervisors restart those with backoff,
unlike a clean .exited), usb-storage exits nonzero when a PRESENT
device fails bring-up, and the fat service no longer blocks its harness
polling for a block device and then dies — it serves immediately
(requests fail politely), retries on a 500 ms timer, and mounts
whenever storage appears, including after a driver restart.
2026-07-21 20:27:55 +01:00
Daniel Samson 7082699f5f gitignore: /var/log — real-hardware log pulls stay out of the tree
(An earlier append landed on a line missing its newline, mangling
'.github/' and silently un-ignoring var/ — which let a stray add sweep
pulled logs into the tree. Repaired, and the logs untracked again.)
2026-07-21 20:09:29 +01:00
Daniel Samson f0611ef8ac logger: logger.log becomes the completeness receipt
Its only content was the redundant announce line — the useful shutdown
marker was written AFTER the final drain, so it reached serial and the
ring but never a file, and a truncated boot could only be inferred from
what was missing. The marker now enters the ring BEFORE the drain, so
the drain carries it into logger.log: a directory whose logger.log ends
with 'shutting down; final flush' is complete through shutdown; one
without it was cut early. The sequence-count epilogue stays serial-only,
after the drain, by design.
2026-07-21 20:05:13 +01:00
Daniel Samson eb6e8edafe apic: time-bound the TSC warp check — 47 s of AP bring-up becomes ~0.3 s
First real per-process logs off the stick (the logging track paying for
itself): kernel.log showed 16-core bring-up costing 47 s — per-core gaps
of 2-21 s — on a machine whose clocksource is the TSC, so every AP runs
the pairwise warp check. QEMU always picks HPET, so the harness never
executed this path at all.

The check was bounded by ITERATIONS: 1<<20 warp ticks, each a locked
read-modify-write on a cacheline two cores fight over — microseconds
under real contention, not the nanosecond the '~1 ms' comment assumed —
and the 1<<32-PAUSE rendezvous 'bound' is ~2 minutes on modern Intel
(PAUSE ~140 cycles). Both are now bounded by TIME measured on the TSC
itself: ~5 ms of pairwise hammering per core (Linux's check_tsc_warp
budget — ample to catch a lagging TSC) and a ~100 ms rendezvous window.
2026-07-21 19:47:53 +01:00
Daniel Samson 59ba95a315 usb: don't reset an enabled SuperSpeed port; name every setup failure
Real-PC diagnose boot (photo + OCR): the boot stick connects at
SuperSpeed on port 21 and 'device setup failed' lands in the SAME
millisecond — an instant failure, not a timeout. setupDevice reset
every port unconditionally; that is required to enable USB2 ports, but
a SuperSpeed port that trained its link is ALREADY enabled (xHCI
advances USB3 ports to Enabled, no reset — spec 4.3), and driving a hot
reset into the live link drops PED mid-reset on real silicon. QEMU
tolerates the spurious reset, which is why the harness never saw it.

An enabled speed>=4 port now skips the reset (a not-yet-enabled SS link
still gets one). Every setup step names its failure — port reset with
the PORTSC value, Enable Slot, device-slot exhaustion, Address Device
with its completion code — so the on-screen transcript of the next
failure identifies the exact xHCI command instead of one blanket line.
block.open's give-up window drops 60 s -> 30 s (the slowest observed
healthy chain completed at ~24 s); a machine whose stick failed setup
should not sit a further minute pretending otherwise.
2026-07-21 19:37:24 +01:00
Daniel Samson f587e7e05e console: page-wrap instead of scrolling — never read the framebuffer
The scroll path copied every pixel row up by one glyph height, READING
video memory — and VRAM reads are uncached-slow on real hardware.
Measured on the 16-core PC: ~90 seconds to bring the cores online,
almost entirely boot-transcript lines each paying a whole-screen scroll
copy. (QEMU never shows this: its 'VRAM' is host RAM.)

When the screen fills, the console now clears and restarts at the top —
writes only, once per screenful. The transcript reads the same as it
streams; only the scrollback illusion is gone, which a boot console
never needed.
2026-07-21 19:22:59 +01:00
Daniel Samson b541921218 build: -Ddiagnose — boot without the display so the transcript stays on screen
The display service claiming the framebuffer suppresses the on-screen
boot transcript — correctly in normal operation, but on a serial-less
machine being debugged, the timeline vanishes just when it matters. A
diagnose image (zig build -Ddiagnose=true) has init skip the display
service and demo: the timestamped transcript stays on screen
indefinitely, and the power button still runs the orderly shutdown (so
the logger's files land when storage works).

First use immediately caught a real bug IN QEMU: an intermittent (~1
in 3) usb-storage READ CAPACITY failure after a ~21 s stall — after
which usb-storage and fat both exit cleanly and nothing retries: one
transient early-boot USB failure leaves the system permanently without
storage (and therefore without logs). That no-retry policy is a prime
suspect for real hardware never mounting /var, and is Track B's first
work item.
2026-07-21 19:15:14 +01:00
Daniel Samson a91365b3d9 kernel: the boot transcript on screen, every line timestamped
Without serial and without working USB storage, a slow real-hardware
boot is undiagnosable — 'stabbing in the dark'. Two changes end that:

The log renderer stamps every line with boot-relative seconds
([  12.045] ...), so every surface — serial, debugcon, and now the
screen — is a readable timeline. And the framebuffer console registers
as an ordinary log sink at boot: kernel AND userspace lines (device
bring-up, fat mounts, logger announcements) show live on screen until
the display service claims the framebuffer, which flips the console's
suppression and silences the sink automatically — the display-owns-the-
screen design is unchanged in normal operation; the console now simply
narrates the part of boot that happens before there IS a display.

On the machine that motivated this, the next boot will show by eye
where the minutes go — including whether the USB chain ever brings
storage up, and whether screen drawing itself crawls (the latent
non-write-combining framebuffer suspect: if these very lines paint
slowly, that's the answer).
2026-07-21 19:09:34 +01:00
Daniel Samson ffa45edc8b boot: the capsule — one-file system image first, manifest and walk as fallbacks
Real-firmware finding: the per-file /system tree walk boots in seconds
under OVMF but stalls for MINUTES on real firmware — the cost is not
bytes (USB 3 moves the ~4 MB instantly) but firmware filesystem
OPERATIONS: ~30 opens, each an uncached directory-chain walk in an
unoptimized firmware FAT driver. This is why every real OS loader
(winload, GRUB) reads many files through its own filesystem code over
Block I/O rather than the firmware's file protocol.

The loader now reads boot/system.img — the bundled binaries packed into
ONE v2 initial_ramdisk (tools/pack-system-image.py, derived from the
same bundled list in the same build graph, so tree and capsule cannot
drift) — with a single open + sequential read, the one firmware file
I/O shape that is fast everywhere. The manifest (open each listed path
by name) and the tree walk remain as fallbacks, so a hand-assembled
stick without the capsule still boots. The running system is identical
in all three cases: the kernel receives the same in-RAM table.

The load phase now brackets itself with unconditional on-screen
breadcrumbs ('EFI: loading the system...' / '...starting the kernel'),
because this phase stalling behind a silent black screen — kernel
status is serial-only by design — already cost a real-hardware
debugging session.

Direction (settled with the user): this capsule becomes the BOOTSTRAP
capsule — kernel + init + the storage-bring-up set — once a
spawn-from-memory syscall lets init and the device manager load
everything else from the stick's real file tree at runtime through
danos's own storage stack: file-granular updates (rebuild one binary,
copy one file), the initramfs shape.
2026-07-21 18:57:33 +01:00
Daniel Samson d446ddd2ed boot: survive hand-written sticks — case-fold the /system walk, skip host litter
The loader ENUMERATES /system now, so it sees whatever the stick's
directory entries literally store — and a hand-copied stick differs
from our generated image: firmware returns bare 8.3 short entries
UPPERCASE (INIT, SYSTEM), and host OSes leave litter next to every file
(macOS '._' AppleDouble forks, .fseventsd). Verified in QEMU/OVMF: an
uppercase-stored volume booted to a dead kernel-only system before this
change and boots fully (display up, logger writing /var/log) after.

The walk now lowers ASCII names (the danos tree is canonically
lowercase; FAT lookups are case-insensitive by definition), skips any
dot-prefixed entry, and treats malformed or unreadable entries as
skip-this-file instead of abort-the-whole-walk. Reader.find compares
case-insensitively as belt and braces.
2026-07-21 18:01:12 +01:00
Daniel Samson 0a4388c3bc Merge branch 'claude/shm-runtime-meaning-4fce7c'
# Conflicts:
#	build.zig
#	system/drivers/virtio-gpu/virtio-gpu.zig
2026-07-21 17:27:39 +01:00
Daniel Samson 0045fd87ba naming: shm → shared_memory — 'shm' is a Unix clipping, not an acronym
Per docs/coding-standards.md (no Unix-abbreviation exception): syscalls
shared_memory_create/map/physical, kernel SharedMemoryObject + handlers,
runtime.shared_memory (library/runtime/shared-memory.zig), the
shared-memory-server/-client test services, the shared-memory QEMU case,
and docs incl. vdso.md's danos_shared_memory_*. 87/87 QEMU tests pass.
2026-07-21 17:26:15 +01:00
Daniel Samson ad0fd52cb8 Merge per-process-logging: tagged ring + logger to /var/log + kernel VFS root
Every process now logs through std.log into a kernel-stamped record
ring, and the logger service persists one file per binary path under
/var/log/<boot-stamp>/ on the flash volume — the real-hardware capture
path (no serial needed). Supporting architecture: the boot medium's
/system tree replaces the packed ramdisk (the EFI loader walks it),
task names are binary paths, FAT creates long file names with a fast
cluster allocator, and the VFS root moved into the kernel
(fs_resolve/fs_node/fs_mount/fs_unmount; resolve + redirect — the
kernel names, userspace filesystems serve). The userspace vfs server
is retired. Full QEMU suite green (88 cases).
2026-07-21 17:13:32 +01:00
Daniel Samson 65a44a5568 docs: per-process logging + the kernel VFS root
logging.md rewritten around the pipeline (std.log -> leveled debug_write
-> tagged ring -> logger service -> /var/log/<boot-stamp>/<binary-path>.log),
why the buffer is a kernel ring rather than a logging server (the
storage stack must log; early boot needs the buffer anyway), the
announce-once and counted-loss disciplines, and the accepted gaps.
vfs-protocol.md: the router is the kernel (fs_resolve names, backends
serve data); endpoint comes from resolve, not a registry lookup; mount/
unmount retired from the wire; the rewrite-prefix mount semantics.
vdso.md: klog_status + the fs naming calls join the future table; the
no-file-I/O line sharpened (kernel resolves names, never blocks on a
filesystem). syscall.md: the placeholder status blurb replaced with the
real call families.
2026-07-21 16:36:27 +01:00
Daniel Samson e186858315 vfs: the root moves into the kernel — resolve + redirect cutover
runtime.fs now routes every path through fs_resolve: kernel-served
/system nodes are read via fs_node (tokens, no open state); everything
under a userspace mount goes straight to the owning backend's endpoint
with the kernel-rewritten mount-relative path — one syscall of naming,
then the unchanged vfs-protocol rendezvous, public API untouched. mkdir/
unlink/rename resolve-then-forward (rename checks both paths land on
the SAME backend); mount is the fs_mount syscall.

The fat server mounts twice — /mnt/usb from the volume root and /var
from its /var subtree — so the logger now writes the FHS path
/var/log/<boot-stamp>/... and swapping the persistent medium later
touches only fat's two mount calls. With clients holding fat's node ids
directly, fat records each handle's owner, checks it, and sweeps a dead
client's handles via the published exit events (the old router's
pattern, now where the state actually lives).

The userspace vfs server and its router die; ServiceId.vfs=1 stays
reserved-retired; protocol.zig moves to system/vfs-protocol.zig (the
wire contract is backend-only now). vfs-test becomes the ring-3 proof
of the kernel VFS (own-binary ELF magic through /system, read-only
refusals, listing); vfs-client-death becomes the fat sweep test over
the full storage chain, with a ring-scanning check (the last-write
buffer is too racy under a chattering tree).
2026-07-21 16:27:06 +01:00
Daniel Samson 7c5645fe48 kernel: VFS root — mount table, /system from the initrd, fs syscalls
system/kernel/vfs.zig is the resolve+redirect router: fs_resolve (#46)
walks the kernel mount table; a path under the kernel-backed /system
mount (the initrd, seeded by setInitialRamdisk with a derived directory
table) yields a permanent stateless node token served by fs_node (#47)
— read/status/readdir with copy-out, initrd reads lock-free — while a
path under a userspace mount yields the backend's endpoint (installed
in the caller's table, DEDUPLICATED so 16 slots can't be exhausted by
repeated resolves) plus the rewritten mount-relative path; the caller
then speaks the unchanged vfs-protocol rendezvous directly. The kernel
never blocks on a userspace filesystem, holds no open-file state, and
refuses create-intent on the immutable /system.

fs_mount (#48) is the syscall form of the old router's op-6 cap-pass
(possession of the backend handle is the capability; an optional
rewrite prefix maps the mount into the backend's namespace — how /var
will reach the flash volume); fs_unmount (#49) removes one. A dead
backend's mount clears lazily on resolve.

Dormant this milestone: the userspace vfs still serves runtime.fs
unchanged; the kvfs QEMU case covers the kernel side (resolution, ELF
magic read-through, /system listing, read-only + unknown refusals)
until the M-G cutover exercises the syscalls end-to-end.
2026-07-21 16:15:05 +01:00
Daniel Samson 127ea2dad9 logger: per-process log files under /var/log/<boot-stamp>/
The logger service drains the tagged ring every 250 ms and demultiplexes
it into one file per process on the flash volume:

    /mnt/usb/var/log/2026-07-21T150434Z/system/services/fat.log
    [     0.214] mounted FAT (fat32, 128992 clusters, partition lba 0)

The boot stamp is the wall-clock anchor from klog_status (FAT-safe, no
colons); the kernel's records go to kernel.log; each line carries the
record's monotonic timestamp and level. Storage is best-effort and
late — the ring buffers a whole boot until the mount appears, then the
first drain writes the backlog, storage-stack records included. Lost
records surface as '-- N records lost --' from sequence gaps. Files
close (= fat's device cache flush) after a 2 s quiet period, bounding
data-at-risk without per-record flush thrash. The logger announces
itself once — a periodic status line would feed the stream it drains.

init spawns the logger last, so the reverse-order shutdown stops it
first and its final drain runs over a live storage chain; log-flush and
init's own DANOS.LOG shutdown flush retire (superseded). runtime.fs
makePath treats components at or above a mount point as router names —
create is best-effort per prefix, the final verdict is exists(path).
2026-07-21 16:05:46 +01:00
Daniel Samson 30d6ea622a fat: long-file-name creation, fast cluster allocation, makePath
createFile/createDirectory now build long-name chains: a mangled STEM~N
8.3 alias (collision-checked per directory), the standard rotate-add
checksum, and 13-UCS-2-per-entry pieces written last-logical-first into
a contiguous free-slot run (found sector-wise, growing the directory as
today). Write order is chain first, 8.3 entry last, so an interrupted
create leaves only skippable orphans. Uppercase-compliant 8.3 names
keep the bare-entry fast path; lowercase names now get a chain so their
exact case survives — matching tools/make-fat-image.py. rename stays
8.3-only (documented; nothing needs more yet).

allocateCluster drops its from-cluster-2 rescan (measured ~1 s/cluster
on a part-full volume, a 37 s shutdown flush in the lost M19 build):
a next-free hint (rewound by frees) plus a FAT-sector LBA cache turn
the scan into one device read per FAT sector.

mkdir now refuses an existing name (no duplicate entries), and
runtime.fs gains makePath (mkdir -p) for the logger's nested per-boot
directories. Engine tests cover the log-directory shape, ~N collisions,
chain unlink/reuse, the 8.3 fast path, and the checksum.
2026-07-21 15:59:06 +01:00
Daniel Samson d0c1b3e45b kernel: serialize test markers through the log lock; render one write per line
tests.zig wrote markers straight to serial, racing user-process records
rendered on other cores — visible as 16-byte UART-FIFO interleave once
the tagged renderer emitted several writes per record. Markers now go
through kernel log print (same serial sink, now under the log lock), the
renderer composes each line into one buffer and hits each sink once, and
the vfs-client-death needle drops the old self-written 'vfs: ' prefix.
2026-07-21 15:53:45 +01:00
Daniel Samson f480c5d790 runtime: std.log for every user binary — kernel-stamped attribution
library/runtime/log.zig wires std.log to the tagged ring: the root shim
installs std_options for every binary (programs may override), logFn
formats one line per record and emits it with its level via
debug_write — the payload no longer carries the process's name; the
kernel stamps identity structurally and the serial renderer prints the
'<binary path>: ' prefix, so the transcript keeps its shape.

Migrate every writeLine/logLine/log helper family (init, vfs, fat,
device-manager, acpi, pci-bus, ps2-bus x3, usb-hid x2, usb-storage,
usb-xhci-bus, virtio-gpu — 104 call sites) to std.log.info, dropping
the hand-written prefixes. A leveled record is a complete line by
contract (raw emissions may still build lines from pieces). Test
fixtures and the display/input services keep raw writes for now — their
ring records are attributed by the kernel regardless.

Harness regexes follow the renamed prefixes (usb-hid-keyboard,
discovery) and path-named restart lines.
2026-07-21 15:39:51 +01:00
Daniel Samson 9f18d8340e kernel: tagged log ring — per-line pid/name/level records, klog_status
Replace the linear keep-earliest RAM buffer with a 512 KiB ring of
framed records (log-ring.zig, host-tested): every debug_write becomes
one record per payload line, stamped by the kernel with the sender's
pid, task name (its binary path), level, per-boot sequence number, and
monotonic timestamp. Attribution is structural — a payload cannot forge
another sender's tag, and newline injection lands inside the forger's
own next record. Oldest records are overwritten when full; sequence
gaps make the loss countable.

debug_write gains a level argument (err/warn/info/debug/raw; old
two-arg callers clamp to raw). klog_read becomes a stream-offset read
that fails once the cursor falls behind the ring's tail; the new
klog_status (#45) returns the cursors plus the boot wall-clock anchor —
what the logger service will name per-boot log directories with.

The log now guards itself with a dedicated spinlock (BKL -> log lock
order, never the reverse); panic paths use a bounded try-acquire and
fall back to sinks-only. Serial rendering keeps the historical
transcript byte-identical for kernel and legacy raw output; leveled
records get a kernel-rendered name prefix. log-flush/init's interim
drains start at the ring tail and write framed records until the logger
service replaces them.
2026-07-21 15:30:28 +01:00
Daniel Samson 0a84e52bf8 tests: match task names by basename; path-tolerant harness regexes
processRunning compared literal names against task names that are now
full binary paths; the acpi-ps2/usb-report harness regexes assumed
unprefixed driver names in device-manager spawn/restart lines.
2026-07-21 15:22:58 +01:00
Daniel Samson 1cf9985da6 boot: fix ramdisk paths lost to FAT name uppercasing; widen driver-name buffers
The EFI loader now ENUMERATES /system rather than opening files by name,
so the on-disk name must be exact, not merely case-insensitively
findable. make-fat-image.py stored 8.3-fitting lowercase names as bare
uppercase short entries ('init' -> INIT), garbling the ramdisk paths;
now any name whose case the 8.3 entry cannot reproduce gets a long-name
chain carrying the exact name.

device-manager's driver table stored names in 24 bytes — silently
truncating '/system/drivers/usb-xhci-bus' and failing the spawn; widen
to 64 (= abi.maximum_process_name). The usb-report harness regex learns
that restart lines name drivers by path.
2026-07-21 15:13:19 +01:00
Daniel Samson 2d0858caf6 boot: the /system tree is the system image — loader-built ramdisk, spawn by path
Retire the build-time ramdisk packer and the packed initial-ramdisk.img.
make-fat-image.py now lays every user binary out at its FHS path on the
boot volume (system/services, system/drivers, system/tests), and the EFI
loader walks \system at boot, packing what it finds into an in-RAM v2
initial_ramdisk whose entry names are full FHS paths. init rides the
table like every other binary: its dedicated handoff fields are gone and
the kernel spawns PID 1 via the same lookup as everyone else
(process.spawnBundled).

system_spawn resolves names by exact path first, then unique basename,
and normalizes argv[0] to the stored path — so task names (and, next,
the tagged log ring's attribution) are honest binary paths everywhere.
initrd v2 rejects the old magic so a stale image fails loudly.

Groundwork for per-process logging (/var/log/<boot-stamp>/<binary-path>.log)
and the kernel-VFS /system mount.
2026-07-21 14:47:26 +01:00
Daniel Samson ec6e888076 display: pace the frame clock by the panel's EDID refresh rate
Both EDID moments the system has are now captured and carried to the
compositor's frame clock:

- EFI: the loader derives refresh from the preferred detailed timing
  (pixel clock / total pixels) while GOP is still alive — the only moment
  it is readable — and hands it through the boot handoff into the
  display0 node's DisplayInfo (new refresh_hz field, 0 = unknown).
- GPU: the virtio-gpu driver derives the same figure from its own EDID
  read and carries it in the attach_scanout announce (request.y).

updateFrameClock() re-derives the interval from the active backend's
info at bring-up and again on every backend change — the boot
framebuffer's clock dies with the GOP floor at upgrade, replaced by the
GPU's rate. Unknown rate defaults to 60 Hz; the result is clamped to
[30, 120] Hz so a mis-parsed EDID can neither starve nor flood the
compositor. Rate only, never phase: without vblank, presents still
free-run (docs/display-v2.md, 'Fenced is not vsync').

Observed in QEMU: OVMF exposes no EDID for the VGA adapter, so the GOP
floor logs 'frame clock 62 Hz (default)' (real firmware does expose it);
the virtio-gpu EDID advertises 75 Hz and the upgrade logs 'frame clock
76 Hz (panel EDID)'. The display kernel test asserts refresh_hz rides
the seeded node; all 7 display QEMU cases pass.
2026-07-21 11:37:29 +01:00
Daniel Samson 4f02f75602 display: a ~60 Hz frame clock — presents are scheduled, not immediate
Client 'present' requests and cursor pokes no longer repaint on the spot:
they accumulate damage and arm a one-shot 16 ms timer, and the tick
composites everything pending as one frame. A fast mouse previously
turned every input event into a full present (100+/s); now any number of
draws and moves inside one interval coalesce into a single repaint.

No backend has a real vblank to pace by (docs/display-v2.md, 'Fenced is
not vsync'), so this is the software stand-in — the same strategy Linux
uses atop virtio-gpu. Bring-up paths that need pixels synchronously
(initialise, self-checks) still present directly.

onNotification now handles the message and timer badge bits
independently: one coalesced badge can carry both, and the old
either/or dispatch would have dropped a tick.

All six display QEMU cases pass.
2026-07-21 11:26:49 +01:00
Daniel Samson 16618d2cdc display: stop calling the fenced present 'vsync' — it isn't
The virtio-gpu present fence completes when the device has consumed the
frame: real completion feedback, and tear-freedom by snapshot semantics.
It is not a vblank — base virtio-gpu 2D has no display-refresh event at
all (Linux fakes one with a timer), so nothing paces presents to the
monitor. The code and docs claimed vsync anyway; now they don't.

- backend.hasVsync -> hasFencedPresent, with an honest doc comment
- marker 'display: vsync present ok' -> 'display: fenced present ok'
  (display-modeset test expectation updated, passes)
- display-v2.md gains a 'Fenced is not vsync' note; the vsync claims in
  both v2 docs are corrected
- true vsync arrives with a native driver's vblank IRQ, or approximated
  by a compositor frame clock
2026-07-21 11:24:23 +01:00
Daniel Samson 15107f54be build: run-x86-64-gpu — the interactive twin of the display-native test
Boots the usual VGA/GOP floor plus a virtio-gpu-pci adapter, so the
device-manager stack spawns the driver and the compositor upgrades to the
native fenced-present backend interactively. QEMU shows one head per
adapter: the live output is the virtio-gpu console in the View menu (the
VGA head freezes at the moment of upgrade). 512M, matching display-native.
2026-07-21 11:22:07 +01:00
Daniel Samson 23f915c593 display: damage-rect list + tile-grid trackers, vectorizable pixel loops, wide WC stores
Tearing mitigation for the GOP floor, attacking the copy window from three sides:

- Damage is no longer one bounding box. Two trackers, A/B-switchable at
  compile time (display.zig damage_mode): DamageList (free-form dirty rects,
  overlap-merged) and TileGrid (fixed 64-px tiles, exact O(1) marking, runs
  coalesced back into rects). Far-apart changes — the cursor here, an
  animating layer there — no longer unite into one huge repaint.
- fillRect/composite/blitTile now work in row spans (@memset/@memcpy), so
  the compiler vectorizes them and ReleaseSafe bounds checks drop to per-row.
- The back->front present streams 8-byte volatile stores (presentSpan);
  Backend.present takes the rect list, so each present copies only what
  changed, faster.

Host tests cover both trackers; the display QEMU cases all pass.
2026-07-21 11:22:00 +01:00
daniel 981a4af7e0 Merge display-mouse-thread: the compositor tracks the mouse (Shape A)
The display becomes danos's first multi-threaded service. A mouse-listener
thread runs beside the compositor loop; the compositor stays the single owner
of the framebuffer, and the cursor it draws is a top-z layer moved from the
listener's accumulated position over a single-slot latest-value CursorChannel
(Thread.Mutex) with a coalesced self-ipc.send poke that wakes the parked
replyWait (docs/display.md, docs/threading.md).

Two kernel-level findings this surfaced, both fixed:
  - IPC handles are per-Task, not per-address-space — a thread reaches another
    thread's endpoint via ipc.lookup() to install its own handle.
  - Concurrent IPC from two threads raced unlocked kernel state (flaky #GP);
    create_ipc_endpoint / ipc_register / ipc_lookup now take the big kernel
    lock 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").

Also decouples display-demo: it no longer draws a cursor (the service owns it)
or blocks its animation loop on a mouse read, so a normal boot shows one
responsive cursor and animation that runs independent of the mouse. The
display-demo test now spawns the input service to keep that independence honest.

New test display-cursor (smp:4) drives synthetic motion end to end. Verified:
zig build, zig build test, and the full 29-case QEMU guardrail suite all green.
2026-07-21 02:41:45 +01:00
daniel 5ab7263c9c display-demo: stop drawing a cursor and stop blocking on the mouse
Two real bugs visible in a normal `zig build run-x86-64` boot (but not in the
display-demo test, which spawns no input service):

  - Two cursors. The demo drew its own cursor layer while the display service
    now draws one too (its mouse-listener thread). The demo's went through the
    client IPC protocol and lagged; the service's is in-process and tracks
    tightly — "one responds better than the other."

  - Animation frozen until the mouse moves. The demo called a *blocking*
    `mouse.next()` (ipc_reply_wait) inside its animation loop, so the sliding
    box advanced only one frame per mouse event. In the display-demo test
    there is no input service, so subscribeMouse() returned null and the loop
    ran free on its 30ms timer — which is exactly why the test passed while
    the real boot was broken.

The compositor now owns the cursor (docs/display.md), so the demo should draw
none and read no input: it becomes a pure client-animation proof whose loop is
independent of the mouse. Removes its cursor layer, mouse subscription, and the
blocking read.

Also harden displayDemoTest to spawn the `input` service alongside the demo
(matching real boot): a client that blocks its animation loop on a mouse read
would now stall before `display-demo: ok` and fail the test, instead of
passing because no input service happened to be present.

Verified: display / display-service / display-demo / display-cursor all green.
2026-07-21 02:37:47 +01:00
daniel 7f415e724f display: track the mouse with a listener thread (Shape A) + cursor
The display's first use of threads (docs/threading.md, docs/display.md). The
compositor stays the single owner of the framebuffer — only the main
service.run loop touches the backend and layer stack — and a dedicated
mouse-listener thread runs beside it:

  - Listener: blocks on input.subscribeMouse(), accumulates relative dx/dy
    into an absolute cursor position clamped to the screen, and hands it to
    the compositor. It never touches the compositor, so no lock guards the
    framebuffer; a parked next() lets the core halt.
  - CursorChannel: a single-slot latest-value cell under a Thread.Mutex (the
    renderer wants where the cursor is now, not a replay of deltas), with a
    coalesced self-ipc.send poke that wakes the main loop — parked in
    replyWait — as a message-notification. At most one poke is queued while
    the last is undrained, so a fast mouse can't flood the endpoint.
  - Render: the cursor is a top-z compositor layer; on the poke the main loop
    moves it via configure + present (which damages old + new footprints).

The display binary opts into threads (addThreadedUserBinary), and
input-source gains a "mouse" mode that publishes pure motion to drive it.

Two kernel-level findings this surfaced, both fixed:

  1. IPC handles do not cross threads. The handle table lives on the Task, so
     the listener can't reuse the main loop's endpoint handle — it
     ipc.lookup(.display)s its own handle to the same endpoint to poke through.

  2. Concurrent IPC from two threads raced unlocked kernel state. The display
     is the first process issuing IPC syscalls from two threads at once, which
     exposed a data race (flaky #GP in installEntry): create_ipc_endpoint /
     ipc_register / ipc_lookup allocate from the kernel heap and mutate the
     global registry, endpoint refcounts, and handle tables without the big
     kernel lock. They were safe only while a process couldn't race itself.
     They now 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 keeps its callers serialized).

Test: -Dtest-case=display-cursor (smp:4) spawns the input service, the
threaded display, and input-source in mouse mode; asserts the display's
"cursor tracking mouse ok" marker once the cursor has tracked a run of motion
end to end. Verified green 6/6 under stress (the race hit ~1-in-4 before the
lock fix) and in the full 29-case QEMU guardrail suite; zig build test clean.
2026-07-21 02:31:29 +01:00
daniel cf140eb772 Merge threading-phase2: threading Phase 2 (M7-M11) + naming cleanups
Completes the std.Thread-shaped runtime.Thread. Phase 1 (M1-M6: address-space
refcount, thread_spawn/exit, join/detach, futex + Mutex/Condition/Semaphore,
getCurrentId) was already on main; this brings the Phase 2 hardening and the
coding-standards/arch-neutrality passes on top:

  M7  thread-safe allocation (per-address-space mmap arena + locked heap)
  M8  the task reaper — reclaim dead tasks' kernel stacks
  M9  thread_join syscall — retire the per-thread endpoint
  M10 per-thread thread pointer — the TLS mechanism (x86_64 IA32_FS_BASE)
  M11 RwLock, WaitGroup, and host-testable sync

Plus: the TLS thread pointer named arch-neutrally (not fs.base) so the kernel
stays architecture-agnostic; aspace/vaddr/paddr spelled out per
docs/coding-standards.md across kernel, runtime, ABI, tests, and docs; and the
misleading fs.base dot-notation dropped in favour of "thread pointer"
(arch-neutral) / "FS base" (x86-specific).

Deferred by design (no consumer yet): the Zig threadlocal *compiler* layer
(M10) and detached-thread user-stack reclaim (M9) — both noted in place.

Verified: zig build, zig build test, and the full 25-case QEMU guardrail suite
all green.
2026-07-21 01:55:23 +01:00
daniel e2dddc941f docs+code: drop misleading fs.base notation for the thread pointer
The "fs.base" spelling read like a field/submodule access, but no such
identifier exists — it meant the x86_64 FS segment base (the IA32_FS_BASE
MSR). Two problems fixed:

- Arch-neutrality: in generic docs, the runtime, and the plan, the mechanism
  is now named by its arch-neutral concept — the "thread pointer" — matching
  the already-renamed `thread_pointer` Task field, `set_thread_pointer`
  syscall, and `architecture.setThreadPointer` fn. x86-specific spots keep
  the precise names: `IA32_FS_BASE` (the MSR), `%fs:8`/`%fs:0`, variant-II.

- Stale identifiers: the M10 section in threading-plan.md still referenced
  `fs_base` on Task and `architecture.setFsBase` — both renamed away in the
  arch-neutral pass. Corrected to `thread_pointer` / `setThreadPointer`.

The x86-only `thread-tls` test (which really does write `%fs:8`) now says
"FS base" (no dot) consistently, matching the established form already in
tests.zig. The matched serial markers ("thread-tls: ok" / "thread-tls:
FAIL") are unchanged; only a non-load-bearing FAIL parenthetical was
reworded.

Verified: zig build clean, thread-tls passes.
2026-07-21 01:52:10 +01:00
daniel 28b3635979 docs+code: spell out aspace/vaddr/paddr per coding standards
Expand the abbreviations flagged in docs/coding-standards.md (names spelled
out in full unless an acronym) across the kernel, runtime, ABI, tests, and
docs:

  aspace -> address_space  (AspaceRef -> AddressSpaceRef, retainAspace ->
           retainAddressSpace, loaded_aspace -> loaded_address_space, the
           liveAspaceCount/aspaceDestroyCount test hooks, etc.)
  vaddr  -> virtual_address
  paddr  -> physical_address

The kernel test case and its serial markers are renamed to match:
aspace-refcount -> address-space-refcount (kernel dispatch string and
test/qemu_test.py case name kept in sync). Prose in docs uses the natural
"address space"/"virtual address"; backticked field/identifier references
use the code spelling.

Also expand the bare "AS" abbreviation in three ABI comments and reframe the
set_thread_pointer ABI/handler docs to lead with the arch-neutral concept
(user-space TLS thread pointer; x86_64 IA32_FS_BASE, aarch64 TPIDR_EL0)
rather than x86 FS-first, matching scheduler.zig's existing framing.

Foreign ABI names preserved: the ELF p_vaddr field and mmap/mmio remain.

Verified: zig build, zig build test, and the full 25-case QEMU guardrail
suite all green.
2026-07-21 01:45:47 +01:00
daniel 6101e429ba threads: name the TLS thread pointer arch-neutrally (not fs.base)
The M10 TLS work leaked x86 naming into the generic kernel: Task.fs_base,
PerCpu.loaded_fs_base, and architecture.setFsBase. The *mechanism* was already
abstracted (the generic scheduler calls through the architecture layer; the
wrmsr IA32_FS_BASE lives in architecture/x86_64/cpu.zig), but the *names* would
force an aarch64 port to implement a 'setFsBase' that writes TPIDR_EL0.

Rename to the neutral concept: Task.thread_pointer, PerCpu.loaded_thread_pointer,
architecture.setThreadPointer (x86_64 impl writes IA32_FS_BASE; aarch64 -> TPIDR_EL0).
Also neutralise the user_arg comment (first argument register, rdi on x86_64).
No behaviour change; thread-tls/thread-mutex/smp/process-kill + host tests pass.
2026-07-21 01:25:46 +01:00
daniel a4e44e8f31 threads(M11): RwLock, WaitGroup, and host-testable sync — Phase 2 done
runtime.Thread.RwLock (reader-preferring, lock/tryLock/unlock +
lockShared/tryLockShared/unlockShared) and WaitGroup (start/finish/wait), both on
the existing Mutex/Condition.

A compile-time Futex seam gated on builtin.os.tag: the futex syscalls on danos, a
spin+yield mock off-target (Zig 0.16 has no std.Thread.Futex; wake is a no-op
since the state machines re-check). thread.zig is wired into zig build test, so
Mutex/RwLock/WaitGroup run as host unit tests with real std.Thread threads (test
blocks compile only under test, so std.Thread there is fine on freestanding).

thread-rwlock QEMU case: 2 writers set both halves of a value under the exclusive
lock while 3 readers check they match under the shared lock; zero half-write
observations across ~150k reads.

Marks Phase 2 (M7-M11) built. threading.md/threading-plan.md status updated.

Gate: host zig build test covers the sync primitives; thread-rwlock PASS (3x);
full Done gate 26/26 (whole thread-* suite + guardrail); build clean.
2026-07-21 00:09:48 +01:00
daniel c7e9b5a4f6 threads(M10): per-thread fs.base — the TLS thread-pointer mechanism
Each thread gets its own x86_64 thread pointer (FS base) for user-space TLS.
Task.fs_base is restored on every context switch only when it changes (same
conditional-load discipline as CR3; architecture.setFsBase -> wrmsr
IA32_FS_BASE). New set_thread_pointer=44 syscall sets the caller's fs_base and
loads it now. The kernel never touches FS, so no swapgs complication.

The runtime lays a small per-thread TLS block at the top of each thread's stack
(self-pointer at %fs:0 + scratch) and the thread trampoline calls
set_thread_pointer before any user code — so every spawned thread has a private,
switch-stable thread pointer, reclaimed with the stack.

thread-test tls mode: two threads write unique markers to their own %fs:8 and,
after both wrote, read back — a shared fs.base would clobber one (cross-talk).

Deferred: the Zig threadlocal *compiler* layer (ELF variant-II PT_TLS + linker
sections + template copy) — high-uncertainty, no consumer today; this lands the
load-bearing per-thread fs.base it builds on. See docs/threading-plan.md M10.

Gate thread-tls PASS (3x); full guardrail 25/25; build + host tests clean.
2026-07-20 23:55:51 +01:00
daniel 6bc329456a threads(M9): thread_join syscall — retire the per-thread endpoint
join no longer needs a per-thread IPC endpoint. New thread_join(tid) syscall
blocks the caller until the task with id tid exits; the exit paths call
wakeJoinersLocked. join only reclaims the joined thread's USER stack, which the
thread vacates the instant it enters the kernel to exit, so waking at exit time
(not reap time) is safe — no reaper/aspace juggling or user-memory write, and
equally std-shaped (like pthread_join). thread_spawn drops the exit-endpoint arg
(runtime passes no_cap). thread-test's join mode runs 40 spawn+join cycles that
would exhaust the 16-slot handle table under the old endpoint scheme.

Also harden the M8 reaper: its single per-core reap slot could be overwritten by
a second death on that core before draining (a fresh-task/SMP timing window), an
intermittent one-stack leak that made task-reap ~20% flaky. Replace it with a
per-core reap LIST plus a .reaping task state so a pending slot can't be reused
before its stack is freed. task-reap now 11/11 isolated.

Deferred: detached-thread user-stack reclaim (still at process exit, as in M3).

Gate thread-join PASS (3x); full guardrail 26/26; build + host tests clean.
2026-07-20 23:42:00 +01:00
daniel ed3b3f1c45 threads(M8): the task reaper — reclaim dead tasks' kernel stacks
A dead task's kernel stack was leaked (no reaper), so every process/thread death
bled kernel memory. Now exit()/exitUserLocked record the dying task in a per-core
reap_after_switch slot and switch away; the task that resumes on that core frees
the stack in switchTo's tail (on its own stack, lock still held so the slot can't
be reused). reapKillPendingLocked drains the slot on the timer tick as a safety
net for the fresh-task case (a fresh task enters via the trampoline, bypassing
switchTo's tail). A task killed while not running is freed directly in
destroyTaskLocked. live_stack_bytes is the observable.

Fixed a migration bug this exposed: the post-switchContext reap read the pc
parameter, but a migrated task carries a stale pc in its saved switchTo frame ->
it freed the wrong core's pending stack (a #GP under SMP). Re-fetch thisCpu()
after the switch.

Gate task-reap PASS (5x isolated, 2x in the 24-case batch); full guardrail 24/24
incl. fault-recovery/supervision/process-kill/smp/affinity; build + host green.
2026-07-20 23:18:30 +01:00
daniel 8259678f0a threads(M7): thread-safe allocation (per-aspace mmap arena + locked heap)
Move the mmap/mmio grant-arena cursors off Task into the per-address-space object
(scheduler aspace_refs, exposed via aspaceMmapNextPtr/aspaceDeviceMapNextPtr), so
sibling threads in one address space hand out disjoint grants. systemMmap reserves
a range under a brief lock then maps per page under a short-held lock (not the
whole grant): the big lock runs with interrupts disabled, so pinning it across a
multi-MiB memset+map froze other cores. Guard the runtime heap's rawAlloc/rawFree
with a Thread.Mutex, gated on !single_threaded so ordinary binaries compile it out.

thread-test gains an alloc mode: 4 threads x 500 alloc/fill/verify/free cycles;
any overlap between concurrent allocations is caught by the pattern check.

Also fix the affinity guardrail: its 3-billion-iteration busy-loop had
codegen-dependent wall-time (adding a function to tests.zig swung it ~4s -> ~63s
and timed it out). Reworked to wait on the wall clock instead.

Gate thread-alloc PASS (3x); full guardrail 23/23 green; build + host tests clean.
2026-07-20 22:57:11 +01:00
daniel f4813c8e99 threads: make the Phase 2 plan loop-runnable
Adjust the unattended loop contract for Phase 2: the Done condition targets M1
through M11 (M1-M6 being checked is no longer Done), the loop branches off the
current main into a new branch (Phase 1's threading is merged), and each green
milestone pushes the working branch to origin (main stays a human merge).
2026-07-20 22:22:33 +01:00
daniel 8b7f1d009c threads: plan Phase 2 (M7-M11) — hardening the deferred parts
Add M7-M11 to docs/threading-plan.md, designed so threading reinforces danos's
goals: everything a thread owns lives in the address space (reclaimed on process
death via the M1 refcount), the kernel owns mechanism while the runtime owns
policy, and the process stays the isolation/restart boundary.

M7 thread-safe allocation (per-aspace mmap arena + locked runtime heap);
M8 task reaper (reclaim kernel stacks + detached user stacks — the resilience gap);
M9 futex-completion join (retire the per-thread endpoint, built on M8);
M10 per-thread TLS (threadlocal + fs.base, for self-hosting);
M11 RwLock/WaitGroup + host-testable sync.
2026-07-20 22:10:32 +01:00
daniel def34e71fc threads(M6): getCurrentId, docs, and CI wiring — threading built
New thread_self=42 syscall backs runtime.Thread.getCurrentId (the calling
thread's kernel task id). thread-test gains an id mode: two workers read
getCurrentId and the main thread confirms all three ids are non-zero and
distinct. Per-thread threadlocal TLS is deferred by design (no consumer; it would
need context-switched fs.base for an unused feature), as are RwLock/WaitGroup.

Marks the threading feature built (M1-M6): threading.md + docs/README.md status
updated, all thread-* cases wired into qemu_test.py.

Gate thread-id PASS; full suite green: 21/21 (thread-spawn/join/futex/mutex/id,
aspace-refcount, + 15 guardrail cases), zig build clean, zig build test green.
2026-07-20 21:55:13 +01:00
daniel 1b33f48acd threads(M5): Mutex, Condition, and Semaphore over the futex
runtime.Thread.Mutex is the classic three-state futex mutex (unlocked/locked/
contended): the fast path is a single CAS and only a contended lock enters the
kernel. Condition is a futex sequence counter (wait/timedWait/signal/broadcast,
spurious wakeups allowed, use in a predicate loop); a signal racing the unlock
bumps the seq so it is never missed. Semaphore is permits guarded by
Mutex+Condition. All mirror std.Thread's shapes, ported onto runtime.Thread.Futex.

thread-test gains a mutex mode: 2 producers + 2 consumers move 2000 unique items
through an 8-slot ring (small enough that both sides block); the consumed
checksum and tally match exactly, proving the lock and condvars correct under
real cross-core contention.

Deferred with rationale (see docs/threading-plan.md): migrating join to a futex
completion word needs kernel clear-on-exit (else use-after-free munmapping a live
stack); host unit tests need a mockable Futex seam.

Gate thread-mutex PASS (3x); 17 guardrail/thread cases green; build + host tests
clean.
2026-07-20 21:48:26 +01:00
daniel b3a8147bd7 threads(M4): futex_wait/futex_wake, the blocking primitive
New private syscalls futex_wait(addr, expected, timeout_ns)=40 and
futex_wake(addr, count)=41. A waiter is a .blocked task tagged with
Task.futex_addr (no queue linkage); futex_wait reads the user word under the big
lock and parks only if it still equals expected, so a concurrent wake can't slip
between the check and the block. futex_wake scans the task table and readies up
to count waiters in the same address space. A timed wait also sets wake_at so the
existing wakeExpired times it out; futex_addr staying non-zero (only futex_wake
clears it) distinguishes timeout from a real wake. Waiters park in-kernel, so an
idle core still halts (no busy-wait).

runtime.Thread.Futex mirrors std.Thread.Futex (wait/timedWait/wake). thread-test
gains a futex mode: a waiter parks, the main thread wakes it (serial order
waiting/waking/woke, asserted by the case regex), and timedWait reports a
timeout.

Gate thread-futex PASS (3x); 18 guardrail cases green incl. sleep/event/ipc
blocking paths; build + host tests clean.
2026-07-20 21:30:57 +01:00
daniel 0730e77530 threads(M3): join, detach, and cross-core parallelism
thread_spawn takes a 4th arg, an exit-endpoint handle: spawnThreadSupervised
resolves and refcounts it under the spawn lock (like spawnProcessSupervised), so
a thread's death posts a child-exit notification carrying its tid. runtime
Thread.join blocks in replyWait on that (private) endpoint for its tid, then
munmaps the stack; detach relinquishes the join (stack reclaimed at process
exit, for now). New current_core=39 syscall + Thread.currentCore() lets a worker
observe which core it ran on.

The closure now lives at the top of the thread's own (private) stack instead of
the heap, so spawn/join never touch the not-yet-thread-safe runtime heap.

thread-test gains a join mode: 4 workers x 100k atomic increments, joined, with
counter == N*K and >1 core stamped (real parallelism), plus a detached worker.
Gate thread-join PASS (4x, non-flaky); 17 guardrail/M1/M2 cases green; build +
host tests clean.
2026-07-20 21:19:17 +01:00
daniel 73df864fd2 threads(M2): thread_spawn/thread_exit + runtime.Thread.spawn
A thread is a task sharing the caller's address space. New private syscalls
thread_spawn(entry, stack_top, arg)=37 and thread_exit=38: thread_spawn goes
through scheduler.spawnThread (retains the shared aspace), thread_exit ends the
task like a process exit(0) (terminateCurrent -> releaseAspace, so the space
survives while siblings hold it). The closure pointer reaches the new thread in
rdi via a new jump_to_user_arg asm path and a per-task user_arg (0 for a normal
process, whose _start ignores it) - so the runtime trampoline is a plain C-ABI
Zig function, no naked asm.

runtime.Thread (library/runtime/thread.zig) mirrors std.Thread.spawn: mmap a
stack, heap-allocate the args closure, hand the kernel the trampoline + closure.
addThreadedUserBinary opts a binary into single_threaded=false; thread-test is
the first, and proves a worker runs in the shared address space via a shared
global the main thread polls.

Gate thread-spawn PASS; 16 guardrail cases green (incl. args/init/process on the
new jump_to_user_arg path) + aspace-refcount; build + host tests clean.
2026-07-20 21:04:23 +01:00
daniel 11e363896f threads(M1): address-space reference counting
Route address-space lifetime through a refcount keyed by the page-table root
(scheduler.zig aspace_refs): retainAspace on the spawnUserLocked success path,
releaseAspace from both teardown paths (exitUserLocked, destroyTaskLocked),
destroying the space only when the last task on it exits. Behaviour is identical
today (every space has exactly one task); this is the foundation shared-address-
space threads (docs/threading.md) build on.

Test-observable liveAspaceCount/aspaceDestroyCount + a new aspace-refcount kernel
self-test and QEMU case: spawn and reap 5 ring-3 probes, assert live spaces return
to baseline and destructions advance by exactly 5 (destroyed once each, no leak,
no double-free). Gate passes; 13 guardrail cases green; build + host tests clean.
2026-07-20 20:47:37 +01:00
daniel 6e8b02d771 threads: design doc + /loop build plan
Add docs/threading.md (native runtime.Thread mirroring std.Thread over a
private thread ABI) and docs/threading-plan.md (6 milestones, each with a
serial-checkable gate + guardrail, plus an unattended /loop execution
contract). Index both in docs/README.md.
2026-07-20 20:39:24 +01:00
daniel d26515706e removing init heartbeat in releases 2026-07-20 20:10:24 +01:00
daniel acf8ff2c33 added microsoft ps/2 support 2026-07-20 19:45:08 +01:00
Daniel Samson e5dcc9790b started os developer guide 2026-07-20 18:43:24 +01:00
Daniel Samson f8ad4ac971 update README.md 2026-07-20 13:39:03 +01:00
Daniel Samson 914af52b94 build: release-x86-64 — the flashable hybrid ISO (Etcher/dd + optical), built in-repo 2026-07-20 13:37:10 +01:00
Daniel Samson bdd8a48476 docs: vdso + vfs-protocol — the public ABI boundary, linked from the index 2026-07-20 13:36:50 +01:00
Daniel Samson f309ce04f4 removing the need for panic and _start snippets in user space binaries 2026-07-17 16:15:30 +01:00
Daniel Samson 9989ebbec7 moving mouse cursor 2026-07-17 10:48:23 +01:00
Daniel Samson 626e3c5e9b docs: link the NVIDIA + Intel GPU feasibility studies from the index
Point the display entry (#19) at nvidia-gpus.md and intel-igpu.md as future native scanout backends.
2026-07-14 18:40:59 +01:00
Daniel Samson 2c63c76288 assets: add font families under usr/share/fonts
Lexend, Lucide, Noto Serif, and Open Sans, staged for upcoming GUI/text work on
the display stack. Asset-only — not yet referenced by any code.
2026-07-14 18:39:23 +01:00
Daniel Samson 05fc1764de docs: native GPU driver feasibility studies (NVIDIA + Intel)
Two companion research snapshots on what a minimal display-only native driver
(EDID + mode-set + framebuffer scanout, no acceleration) would take as a danos
.scanout backend, and how the two vendors compare.

- nvidia-gpus.md — RTX 3060 (Ampere GA106). Display is not GSP-gated: nouveau's
  ga102.c has a direct register path, and danos's GOP boot lets a driver attach to
  a live, already-devinit'd display. Tier-4 effort; the register-level display code
  lives in GPL nouveau while the permissive OGKM reference is the hard GSP path.
  GA10x is the last NVIDIA family that keeps a register-level display path.
- intel-igpu.md — the companion, and a materially easier, lower-tier target. Intel
  publishes register-level Display Engine PRMs, so a clean-room permissive driver
  is viable; the DMC microcontroller is optional (power states only), scanout comes
  from system RAM via the GGTT (no VRAM manager), and coreboot's libgfxinit is a
  compact native reference. The catch is hardware: an iGPU may not drive the
  monitor on a discrete-GPU desktop.

Sourced, cited snapshots — not implementations; each ends with a "first light"
milestone ladder framed as a danos .scanout service. Not yet linked from
docs/README.md.
2026-07-14 18:39:23 +01:00
Daniel Samson 65bb04d890 kernel: preserve SSE/FPU (XMM) state across the syscall/interrupt boundary
The kernel enabled SSE at boot and both kernel and userspace keep live values in
XMM (LLVM emits movdqu/movaps for >=16-byte struct copies, plus floats and SIMD),
yet the kernel never saved the SSE/FPU register file anywhere — not across
switch_context, not across the syscall boundary, not across interrupts. Any value
the compiler parked in an XMM register across a kernel entry could be silently
clobbered by kernel code, or by whatever the scheduler ran while the task blocked:
a whole-kernel, timing-dependent data-corruption bug.

It was the real root cause of the "device_enumerate corruption" Heisenbug: the
display service read garbage framebuffer geometry (height=0) because findDisplay
held the 16-byte .display field live in xmm0 across the claim/mmio_map syscalls,
and a timer preemption to the busy device-manager clobbered it. The tell that it
was register-only: memory always read correct, and the bug vanished whenever an
added syscall spilled the value to the stack.

Fix: fxsave/fxrstor the register file in the asm stubs (isr_common and
syscall_entry), right after pushing the GP trap frame — before any Zig kernel code
can touch XMM — and right before the pops. rbx bridges the exact rsp across the
call to interruptDispatch: it is callee-saved, so it survives even a blocking
dispatch that context-switches away and back, and `and $-16,%rsp; sub $512,%rsp`
gives fxsave its 16-byte-aligned scratch on the kernel stack. Context-switch-time
save/restore alone is not enough — kernel code between the interrupt and switchTo
already clobbers XMM.

The commit-58927ed Gop.init workaround (copy scalar geometry fields rather than the
whole descriptor by value) is now redundant but harmless; left in place. Deferred:
a fresh task inherits the previous task's XMM (minor info-leak / nondeterminism),
and the fxsave runs on every interrupt including ring0->ring0.
2026-07-14 18:39:23 +01:00
Daniel Samson 24c49f56e1 kernel: fix pci-scan triple fault and flaky restart-drill checks
pciScanTest put three [64]DeviceDescriptor arrays on the stack; at 328
bytes each that is a 66,096-byte frame, larger than the 64 KiB bootstrap
stack the boot context runs on, so the prologue overflowed the stack and
triple-faulted on entry before the test could print anything — the CPU
reset with no exception line. Reuse one descriptor buffer (frame ~21 KiB).

With the fault gone, the restart-drill checks proved flaky. They polled
process.write_buffer (which holds only the latest write-syscall message)
for the transient restarting/re-scan lines, which the concurrent
class-driver output overwrote before the starved low-priority poller could
observe them. The kernel broker count can't witness the restart either:
register is idempotent and the table has no unregister, so the count is
invariant across kill/prune/respawn.

Follow the sibling restart drills (usbReportTest/driverRestartTest): assert
the ordered drill in the harness regex over the full serial log (a
backreference requires the respawn to re-scan the same count), and keep only
race-free broker checks in the kernel — empty before the scan, populated
once it settles, never growing past N — sampled via scheduler.sleep at a
fixed cadence instead of a busy-poll.
2026-07-14 13:48:33 +01:00
Daniel Samson 0217662808 display: resilient scanout — supervised driver, survive loss, re-attach (v2 V6)
The compositor now survives the virtio-gpu driver dying and re-attaches when device-manager
restarts it — the last piece of display v2.

Three parts:

- The driver hellos the device manager (role: bus). It never did, so the manager — which
  spawns it supervised and expects a hello — was stopping it at the 3s hello deadline every
  run (the gate markers just printed first). Now it is properly supervised: not stopped for
  silence, and restarted on death.

- A kernel IPC fix so a call to a dead service errors instead of hanging. An endpoint records
  its owner; when that task dies, its registered endpoints are marked dead (and any parked
  senders woken with -EPEER), so ipc_call returns -EPEER rather than blocking on a reply that
  will never come. Without this the compositor's first present after the driver died blocked
  forever. General robustness — any client of any service benefits.

- The compositor re-attaches. Its .scanout calls now fail cleanly (caught), freezing the last
  frame; when the restarted driver re-announces, attach_scanout detects the backend is already
  virtio and logs "scanout re-attached", mapping the fresh shared surface and re-looking-up
  .scanout. (The previous shm mapping leaks — no shm_unmap syscall yet — but its frames are the
  dead driver's, reclaimed on exit.)

- device-manager gains a "test-scanout-restart" mode (like test-usb-restart) that kills the
  virtio-gpu driver once after it hellos; the displayReattachTest kernel scenario drives it.

Gate: python3 test/qemu_test.py display-reattach — "scanout upgraded to virtio-gpu" then
"scanout re-attached", no CPU exception, passing 3/3. host tests, ipc/ipc-call/ipc-cap,
supervision, shm, display-service, display-demo, virtio-gpu, display-native, and
display-modeset all pass; default zig build clean. v2 (V1-V6) complete.
2026-07-14 13:10:21 +01:00
Daniel Samson 4231301896 display: runtime mode-setting, EDID, and fenced (vsync) present (v2 V5)
The native backend can now change resolution and presents tear-free.

Mode-setting without churn. The driver sizes its scanout resource + shared surface to the
largest mode it offers and treats a mode change as re-pointing the scanout rectangle within
that surface — so the resource, its backing, and the shared mapping never change, and the
surface's row stride (the max width) is fixed while the active width/height move. The
compositor is handed that stride in the announce and composes at it; a smaller mode just
paints the top-left rectangle. This sidesteps the surface re-share a true resolution change
would otherwise need (the service harness can't reply with a capability).

- scanout-protocol gains get_modes + set_mode; the driver offers {640x480, 800x600} and
  re-points set_scanout on set_mode.
- backend.VirtioGpu carries the surface stride, exposes modes()/setMode(), and reports
  canModeSet = hasVsync = true.
- runtime.display gains modes()/setMode() (display-protocol get_modes/set_mode, forwarded to
  the backend) — the client-facing API.
- EDID: the driver negotiates VIRTIO_GPU_F_EDID when the device offers it, reads the monitor's
  EDID, and logs its preferred mode (parsed from the first detailed timing descriptor).
- vsync: every resource_flush is fenced (VIRTIO_GPU_FLAG_FENCE); the device signals the fence
  when the frame is on screen, which the used-ring ack the synchronous present already waits
  on gates — so a completed present is a tear-free one.

After the native upgrade the compositor runs a one-shot mode-set self-check: query the modes,
switch to a different one, re-composite, and confirm the backend reports the new geometry —
the gate's markers.

Gate: python3 test/qemu_test.py display-modeset (reuses the display-native boot) — "display:
mode set to 800x600, verified" + "display: vsync present ok", passing 3/3. host tests,
display-service, display-demo, shm, virtio-gpu, and display-native still pass.
2026-07-14 12:44:21 +01:00
Daniel Samson 58927ed7e5 display: hot-attach a virtio-gpu native backend over the GOP floor (v2 V4)
The compositor now boots on the GOP framebuffer and upgrades to the virtio-gpu driver
the moment it announces itself — the pluggable-scanout payoff.

The shared surface. The scanout resource is an shm region the driver creates
(shm_physical, a new syscall, hands it the guest-physical for attach_backing) and passes
to the compositor as a capability. The compositor maps it and composites straight into
it: on x86 DMA is cache-coherent, so the cacheable shared pages the CPU paints are exactly
what the device transfers-and-flushes — no copy, no explicit flush.

The handshake. After bring-up the driver looks up .display and sends attach_scanout with
the geometry + the surface capability. The compositor maps the surface, looks up the
driver's .scanout endpoint itself (the driver registered it — no need to pass it), switches
to backend.VirtioGpu, and re-composites the current frame. present() over the native
backend is a present request on .scanout -> transfer-to-host + resource flush. The first
native present is deferred to a one-shot timer: presenting inline from the announce handler
would deadlock, since the driver is still blocked on our reply and not yet serving .scanout.
After it lands, the compositor reads a pixel back from the shared surface to confirm the
frame reached the device's backing.

- shm_physical (syscall 36) + runtime.shm.physical.
- scanout-protocol (the compositor->driver present channel), separate from the
  client-facing display protocol; the display protocol gains attach_scanout.
- backend.VirtioGpu joins backend.Gop in the tagged union; select() still boots GOP.
- the virtio-gpu driver's scanout backing is now shm (was DMA); it announces + serves
  .scanout present requests (transfer-to-host + flush of the shared surface).

Also fixes a latent framebuffer-geometry corruption the display service hit only when it
enumerated the device tree alongside a busy device-manager: Gop.init now captures the
geometry into a small value the instant device_enumerate returns (rather than re-reading
the 328-byte descriptor across the later claim/mmio_map syscalls) and retries on a zero
geometry. The underlying device-table clobber is a separate kernel bug, tracked apart.

Gate: python3 test/qemu_test.py display-native (QEMU -device virtio-gpu-pci) — "display:
scanout upgraded to virtio-gpu" + "display: native present verified" + "display-demo: ok",
passing 3/3. host tests, display-service, display-demo, shm, and virtio-gpu still pass.
2026-07-14 12:21:37 +01:00
Daniel Samson 6e0e0a62c6 virtio-gpu: a modern virtio 1.0 display driver, bring-up to a flushed frame (v2 V3)
The first native scanout backend's driver half. The device manager matches the
virtio-gpu PCI function by its Display/Other class triple and spawns the driver,
which claims the device, confirms vendor 0x1AF4/device 0x1050 from config space,
enables memory-space + bus mastering, and walks the virtio vendor capabilities to
find the common-config and notify structures in its BAR.

From there it is the standard modern-virtio bring-up: reset, negotiate VERSION_1,
stand up the control virtqueue in coherent DMA, then drive the GPU end to end —
RESOURCE_CREATE_2D, ATTACH_BACKING (a coherent DMA buffer; V4 swaps in the shm-
shared surface), SET_SCANOUT, paint a known pattern, TRANSFER_TO_HOST_2D,
RESOURCE_FLUSH, and wait for the device's used-ring ack. Reading the backing back
proves it is CPU-visible RAM; the ack proves the device consumed the frame —
together the automated stand-in for "it's on screen", no screenshot.

- system/drivers/virtio-gpu/: the driver, plus virtio-gpu-protocol.zig (control
  commands) and virtio-pci.zig (the 1.0 PCI transport + split-virtqueue), both with
  host-tested struct sizes.
- device-manager matches the display/other class triple to "virtio-gpu"; the driver
  self-confirms the vendor/device id, since the class alone cannot distinguish it.
- ServiceId.scanout (11): the driver registers it so the compositor finds it in V4.

Gate: python3 test/qemu_test.py virtio-gpu (QEMU -device virtio-gpu-pci) — the
device-manager stack discovers the function, the driver brings up a 640x480 scanout
and flushes a test pattern: "virtio-gpu: scanout 640x480 online" + "flush acked,
pixel check ok". host tests, display-service, shm, and device-list still pass.

Note: the pre-existing pci-scan case triple-faults on main (verified at 88ad432,
before this change); it is unrelated and tracked separately.
2026-07-14 11:29:53 +01:00
Daniel Samson 88ad432758 kernel: shm cross-process shared memory capability (v2 V2)
Generalize capability passing from endpoints to memory objects. The per-task
handle table now holds kind-tagged entries (scheduler.HandleObject{kind, ptr});
closeHandles and shareCapability dispatch by kind, so a shared-memory object
rides an ipc_call send_cap exactly like an endpoint and is refcount-freed only
when its last capability drops.

- shm_create(len) -> vaddr, handle: contiguous, zeroed, cacheable frames wrapped
  in a refcounted ShmObject, mapped into the caller's shm arena (PML4[230]).
- shm_map(cap) -> vaddr: map the same physical pages into a receiver that got the
  capability. mapUserSharedInto maps WB-cacheable + device_grant, so a sharer's
  teardown never frees the shared frames — the object owns them.
- runtime.shm: create(len) -> Region{ptr, handle, len}, map(handle) -> ptr.

Gate: qemu_test.py shm — shm-client creates a region, writes a pattern, passes
its capability to shm-server, which maps it and reads the same bytes back
(shm: shared 4096 bytes ok). ipc/ipc-call/ipc-cap/supervision/dma/usermem/
display-service and host tests all still pass — the handle change broke no IPC.
2026-07-14 10:57:14 +01:00
Daniel Samson 9333d0572f display: pluggable scanout backend seam (v2 V1)
Extract scanout from the compositor into backend.zig — a `Backend` tagged union
with info()/surface()/present(damage) and canModeSet/hasVsync flags. The v1 GOP
path becomes `backend.Gop` (claim the display node, WC-map the LFB, keep the
cacheable back buffer, present = the damage-rect WC copy); display.zig now
composes into backend.surface() and calls backend.present(damage), with no LFB or
framebuffer geometry left in the compositor core. The selection decision is the
pure chooseKind(native_available), split from the syscall-bound bring-up, ready
for the native-if-present branch at V4.

Pure refactor: display-service, display-demo, and zig build test all pass
unchanged.
2026-07-14 09:40:15 +01:00
Daniel Samson f3342118f5 docs: make display-v2 gates fully automated (unattended-safe)
Rewrite V3/V4/V5 gates so none need a screenshot: the driver/compositor read
their own pixels back (the scanout resource is shm-backed CPU-visible RAM), and a
virtio resource_flush is confirmed by the device's used-ring ack / fence. Pixel
readback + flush-ack is the serial stand-in for "it's on screen," so the whole
V1→V6 plan can run and self-verify without a human eyeballing QEMU.
2026-07-14 09:33:00 +01:00
Daniel Samson 2723b6f778 docs: display v2 design + plan (pluggable scanout backend)
Keep the GOP framebuffer as the floor, make scanout a pluggable backend, and
upgrade to a native virtio-gpu driver when it announces itself (dynamic
hot-attach; GOP stays the fallback for "no driver ever"). v2 builds the shm
cross-process memory capability, shared with the future client-surface path.
Milestones V1 (backend seam) → V6 (resilience + tests), each with a gate.
2026-07-14 09:28:31 +01:00
Daniel Samson a01a4f3b3d init: restart a crashed boot service
init supervises its boot services (spawned against an exit endpoint) but the
child-exit handler was `if (got.isNotification()) continue;` — it silently
dropped a dead service. Now init restarts it: on a child-exit notification it
finds the service, and unless it exited cleanly (chose to stop) or has hit the
crash-loop cap (maximum_restarts), respawns it and logs the death + reason. The
reincarnation half of resilience (docs/resilience.md) at the service level, the
counterpart to the device manager's driver restarts. A `shutting_down` flag
skips restarts during the orderly stop sequence, whose child deaths are expected.
2026-07-14 09:05:10 +01:00
Daniel Samson e1605e3235 kernel: contain a fatal fault — name the task, release the BKL before halt
Two gaps a real-hardware crash exposed, both in onException's terminal path (and
the panic path):

- The report was anonymous. Add the faulting task's id + name and whether it
  trapped in ring 3 (a user process) or ring 0 (the trusted base) — so a fatal
  fault says WHAT crashed and WHERE, not just the vector. scheduler gains
  currentIdSafe/currentNameSafe (early-boot-guarded, like currentCpuIndex, so the
  reporter can't fault a second time).

- halt() never released the big kernel lock, so a core that died holding it
  deadlocked every other core spinning in acquire() — the whole machine hangs,
  not just the one core the design promises. The BKL now records its owner
  (architecture.cpuLocal(), a unique per-core token); sync.releaseIfHeldHere()
  frees the lock only if this core holds it, called before halt on both fatal
  paths. Caveat: if we held it mid-mutation the shared state may be inconsistent,
  but letting the other cores + the supervisor keep running is strictly more
  recoverable than a guaranteed total hang.
2026-07-14 09:05:10 +01:00
Daniel Samson 1e80c57484 init: launch display-demo at boot
Spawn the display-demo client alongside the compositor so a normal boot shows
the moving scene (wallpaper + sliding rectangle + cursor) — the GUI track's
visible payoff. A demo fixture: drop it from boot_services to boot to a bare
compositor.
2026-07-14 08:26:55 +01:00
Daniel Samson c3e9c59086 kernel: route routine status to the log, not the framebuffer console
Now that the user-space display service owns the framebuffer, the bootstrap
console shrinks to fatal-only. status/statusPrint go to the diagnostic log
alone, so routine boot output no longer scribbles on a screen the compositor is
about to paint — and a driver's recoverable fault report stays off it too. A new
fatal/fatalPrint keeps panics and kernel-mode faults on screen, forcing the
console back on so a dying machine's last words show even over a live display.
console.zig now documents its early-boot + fatal-fallback role.
2026-07-14 08:26:55 +01:00
Daniel Samson 88ed3c5417 Merge claude/display-service-arch-a42261: display service v1 — framebuffer compositor (D1-D5)
A user-space display service: owns the write-combining framebuffer, composites a
z-ordered layer stack into a cacheable back buffer, presents only the damaged
region, and is driven over IPC by runtime.display. Proven end to end by the
display-demo client. Kernel changes: a display device node + write-combining
mmio_map, and a page-by-page mmap that lifts the 1 MiB per-call cap. Deferred by
design: shared-memory client surfaces, a native mode-setting backend, and vsync.
2026-07-14 07:57:43 +01:00
395 changed files with 26024 additions and 5269 deletions
+2 -1
View File
@@ -6,4 +6,5 @@ zig-out/
.idea/
.claude/
.github/
.github/
/var/log/
-1
View File
@@ -1 +0,0 @@
0.16.0
+18 -6
View File
@@ -1,8 +1,8 @@
# DanOS
Codename: Shodan
Version: 1
A small resilient operating system, written from scratch in Zig.
**Codename: Shodan**
A very small resilient operating system.
## Zen of DanOS:
@@ -18,12 +18,12 @@ A small resilient operating system, written from scratch in Zig.
- Useful during driver development.
- Drivers can claim MMIO / ports
- Driver resources (e.g. IRQ/Port/MMIO) claims are automatically cleaned up if the driver dies or is killed
- Drivers can also hook into the process lifecyle to clean up or reset hardware
- Drivers can also hook into the process lifecycle to clean up or reset hardware
- No legacy to deal with
- Zig code uses a clean coding style (Zen of Zig)
- Favor reading code over writing code.
- No magic numbers.
- No shortend names unless its for ABI compatibility or acronyms
- No shortened names unless its for ABI compatibility or acronyms
- Inter-Process Communication (IPC)
- Publish and subscribe to Asynchronous Messages
- Talk to services and processes synchronously
@@ -52,7 +52,19 @@ zig build
Produces a FHS-shaped `zig-out/` that *is* the danos filesystem and the boot volume:
the UEFI bootloader at `zig-out/EFI/BOOT/BOOTX64.efi`, the kernel at
`zig-out/system/kernel`, init at `zig-out/system/services/init`, drivers under
`zig-out/system/drivers/`, and the initial-ramdisk at `zig-out/boot/`.
`zig-out/system/drivers/`, the test fixtures under `zig-out/test/system/services/`,
and the initial-ramdisk at `zig-out/boot/`.
## Release media
```sh
zig build release-x86-64
```
Produces `zig-out/danos-x86-64.iso`, a hybrid ISO that boots flashed raw to a
USB stick (balenaEtcher, dd) or burned to optical media — see
[docs/release-iso.md](docs/os-development/release-iso.md). `zig build check-iso-image`
validates it without booting.
## Run
+260 -49
View File
@@ -2,6 +2,7 @@ const std = @import("std");
const uefi = std.os.uefi;
const elf = std.elf;
const boot_handoff = @import("boot-handoff");
const initial_ramdisk = @import("initial-ramdisk");
const build_options = @import("build_options");
const BootInformation = boot_handoff.BootInformation;
const GraphicsOutput = uefi.protocol.GraphicsOutput;
@@ -15,11 +16,12 @@ const MemoryMapSlice = uefi.tables.MemoryMapSlice;
/// The kernel image: /system/kernel.
const kernel_file_name = std.unicode.utf8ToUtf16LeStringLiteral("system\\kernel");
/// The init program: /system/services/init.
const init_file_name = std.unicode.utf8ToUtf16LeStringLiteral("system\\services\\init");
/// The initial-ramdisk (the VFS server + drivers), in /boot.
const initial_ramdisk_file_name = std.unicode.utf8ToUtf16LeStringLiteral("boot\\initial-ramdisk.img");
/// The user binaries: everything under /system except the kernel itself, plus
/// the test fixtures under /test. The loader walks both trees and packs them
/// into the in-RAM initial_ramdisk image — the volume's file structure is the
/// single source of truth (no packed image artifact on disk).
const system_directory_name = std.unicode.utf8ToUtf16LeStringLiteral("system");
const test_directory_name = std.unicode.utf8ToUtf16LeStringLiteral("test");
/// Physical page size, and the sentinel UEFI uses to seek to end-of-file.
const page_size = 4096;
@@ -64,20 +66,14 @@ fn boot() !noreturn {
const entry = try loadKernel(bs, &boot_information);
// Best effort: a volume without /system/services/init still boots (kernel-only).
loadInit(bs, &boot_information) catch |err| {
log("EFI: no /system/services/init (");
// Best effort: a volume without a /system tree of user binaries still boots
// (kernel-only). The tree — init included — becomes the initial_ramdisk.
loadSystemTree(bs, &boot_information) catch |err| {
log("EFI: no /system binaries (");
logBytes(@errorName(err));
log(") - booting without user space\r\n");
};
// Best effort: the initial_ramdisk (VFS server + drivers) is optional too.
loadInitialRamdisk(bs, &boot_information) catch |err| {
log("EFI: no initial_ramdisk (");
logBytes(@errorName(err));
log(")\r\n");
};
// Build the page tables the kernel starts life on: identity + a physmap of
// low RAM, plus the higher-half kernel image once it links high. Allocated
// now, while boot services (and the memory map) are still stable — nothing
@@ -97,7 +93,7 @@ fn boot() !noreturn {
}
/// A display resolution in pixels.
const Resolution = struct { width: u32, height: u32 };
const Resolution = struct { width: u32, height: u32, refresh_hz: u32 };
/// Switch the GPU to the monitor's native resolution (when we can determine it)
/// and read the resulting graphics mode into our own framebuffer description.
@@ -128,6 +124,10 @@ fn queryFramebuffer(bs: *uefi.tables.BootServices) !boot_handoff.Framebuffer {
// Each pixel is 32 bits, so the byte pitch is 4 * pixels-per-row.
.pitch = info.pixels_per_scan_line * 4,
.format = try pixelFormat(info.pixel_format),
// The refresh rate rides the EDID preferred timing. If the firmware kept a
// non-native mode it may not describe that mode exactly — but it is the panel's
// own clock, a far better frame-clock seed than a hardcoded 60 Hz.
.refresh_hz = if (native) |n| n.refresh_hz else 0,
};
}
@@ -176,10 +176,12 @@ fn nativeResolution(bs: *uefi.tables.BootServices, handles: []uefi.Handle) ?Reso
return null;
}
/// Parse the native resolution from a raw EDID block. The first Detailed Timing
/// Descriptor (at byte 54) is the preferred — i.e. native — mode by convention;
/// its active pixel counts are split across low bytes and the high nibbles of
/// later bytes.
/// Parse the native resolution and refresh rate from a raw EDID block. The first
/// Detailed Timing Descriptor (at byte 54) is the preferred — i.e. native — mode by
/// convention; its active pixel counts are split across low bytes and the high nibbles
/// of later bytes. The refresh rate is derived, not stored: the descriptor carries the
/// pixel clock (10 kHz units) and the active+blanking extents, and
/// refresh = clock / (horizontal total × vertical total).
fn edidNative(edid: []const u8) ?Resolution {
if (edid.len < 128) return null;
// Every EDID begins with this fixed 8-byte header.
@@ -193,7 +195,13 @@ fn edidNative(edid: []const u8) ?Resolution {
const w = @as(u32, dtd[2]) | (@as(u32, dtd[4] & 0xf0) << 4);
const h = @as(u32, dtd[5]) | (@as(u32, dtd[7] & 0xf0) << 4);
if (w == 0 or h == 0) return null;
return .{ .width = w, .height = h };
const clock_hz = (@as(u64, dtd[0]) | (@as(u64, dtd[1]) << 8)) * 10_000;
const h_blank = @as(u64, dtd[3]) | (@as(u64, dtd[4] & 0x0f) << 8);
const v_blank = @as(u64, dtd[6]) | (@as(u64, dtd[7] & 0x0f) << 8);
const total = (@as(u64, w) + h_blank) * (@as(u64, h) + v_blank);
const refresh: u32 = if (total == 0) 0 else @intCast((clock_hz + total / 2) / total);
return .{ .width = w, .height = h, .refresh_hz = refresh };
}
/// Open the kernel on the volume we booted from, read it into a pool buffer,
@@ -357,11 +365,37 @@ fn handoff(cr3: u64, entry: usize, boot_information: *const BootInformation) nor
unreachable;
}
/// Read a whole file off the boot volume into a pool buffer that outlives the
/// loader. The buffer is deliberately NOT freed: it's LoaderData, which the
/// memory-map conversion classifies as reserved, so the kernel identity-maps it
/// and reads from there. Returns the buffer (pointer + length).
fn loadFile(bs: *uefi.tables.BootServices, name: [*:0]const u16) ![]u8 {
// --- the /system and /test trees -> initial_ramdisk --------------------------
/// Cap on bundled binaries. Generous: the tree carries ~30 today.
const maximum_bundled = 64;
/// How deep the walk goes below a tree root ("/system/services/x" is depth 1,
/// "/test/system/services/x" is depth 2).
const maximum_tree_depth = 3;
/// One binary discovered under a walked tree: its FHS path (UTF-8,
/// '/'-separated, NUL-free) and its contents in a transient pool buffer.
const Bundled = struct {
path: [initial_ramdisk.maximum_name]u8,
path_len: usize,
data: []align(8) u8,
};
/// Gather the boot volume's user binaries into an in-RAM v2 initial_ramdisk
/// image, entries named by full FHS path — the volume's file structure is the
/// single source of truth (no packed ramdisk artifact; init travels in the
/// table like everything else).
///
/// Two strategies, most portable first:
/// 1. /system/manifest (written by the build): each listed path is opened BY
/// NAME — the case-insensitive lookup every firmware FAT driver gets
/// right, and the only file access the pre-tree loader ever used.
/// 2. No manifest: ENUMERATE the /system and /test trees. Portable in
/// principle, but firmware differs in what names enumeration returns
/// (bare 8.3 entries come back uppercase on some drivers), so this is
/// the fallback for hand-assembled sticks, not the primary path.
fn loadSystemTree(bs: *uefi.tables.BootServices, boot_information: *BootInformation) !void {
const loaded = (try bs.handleProtocol(uefi.protocol.LoadedImage, uefi.handle)) orelse
return error.NoLoadedImage;
const device = loaded.device_handle orelse return error.NoBootDevice;
@@ -371,40 +405,217 @@ fn loadFile(bs: *uefi.tables.BootServices, name: [*:0]const u16) ![]u8 {
const root = try fs.openVolume();
defer _ = root.close() catch {};
const file = try root.open(name, .read, .{});
defer _ = file.close() catch {};
// Unconditional breadcrumb (con_out, independent of -Dserial): this phase
// is where a slow firmware stalls, and a silent black screen here already
// cost a real-hardware debugging session.
log("EFI: loading the system...\r\n");
// The capsule (boot\system.img) first: one open + one sequential read is
// the only firmware file I/O shape that is fast everywhere. It is already
// the kernel's wire format — hand it over as-is.
if (loadCapsule(bs, root, boot_information)) {
log("EFI: system image loaded, starting the kernel\r\n");
return;
}
var list: [maximum_bundled]Bundled = undefined;
var count: usize = 0;
loadByManifest(bs, root, &list, &count) catch {
count = 0; // a torn manifest read leaves partial entries; start over
};
if (count == 0) {
const system_directory = try root.open(system_directory_name, .read, .{});
defer _ = system_directory.close() catch {};
try walkDirectory(bs, system_directory, "/system", 0, &list, &count);
// The /test tree is optional: a stick without fixtures still boots.
if (root.open(test_directory_name, .read, .{})) |test_directory| {
defer _ = test_directory.close() catch {};
try walkDirectory(bs, test_directory, "/test", 0, &list, &count);
} else |_| {}
}
if (count == 0) return error.NoBinaries;
// Assemble the v2 image: header, entry table, then the blobs.
const table_end = @sizeOf(initial_ramdisk.Header) + count * @sizeOf(initial_ramdisk.Entry);
var total: usize = table_end;
for (list[0..count]) |e| total += e.data.len;
const image = try bs.allocatePool(.loader_data, total); // survives the handoff
std.mem.bytesAsValue(initial_ramdisk.Header, image[0..@sizeOf(initial_ramdisk.Header)]).* = .{
.magic = initial_ramdisk.magic,
.count = @intCast(count),
};
var offset: usize = table_end;
for (list[0..count], 0..) |e, i| {
var record = initial_ramdisk.Entry{ .name = @splat(0), .offset = offset, .len = e.data.len };
@memcpy(record.name[0..e.path_len], e.path[0..e.path_len]);
const slot = image[@sizeOf(initial_ramdisk.Header) + i * @sizeOf(initial_ramdisk.Entry) ..][0..@sizeOf(initial_ramdisk.Entry)];
std.mem.bytesAsValue(initial_ramdisk.Entry, slot).* = record;
@memcpy(image[offset..][0..e.data.len], e.data);
offset += e.data.len;
_ = bs.freePool(e.data.ptr) catch {};
}
boot_information.initial_ramdisk_base = @intFromPtr(image.ptr);
boot_information.initial_ramdisk_len = total;
log("EFI: boot tree loaded, starting the kernel\r\n");
}
/// The boot capsule: the bundled binaries as one v2 initial_ramdisk image.
const capsule_file_name = std.unicode.utf8ToUtf16LeStringLiteral("boot\\system.img");
/// Load boot\system.img whole and hand it to the kernel unmodified — it is
/// already the initial_ramdisk wire format. Returns false (capsule absent or
/// unreadable or wrong magic) to let the caller fall back to per-file loading.
fn loadCapsule(bs: *uefi.tables.BootServices, root: *uefi.protocol.File, boot_information: *BootInformation) bool {
const file = root.open(capsule_file_name, .read, .{}) catch return false;
defer _ = file.close() catch {};
const image = readWholeFile(bs, file) catch return false;
if (image.len < @sizeOf(initial_ramdisk.Header) or
std.mem.bytesToValue(initial_ramdisk.Header, image[0..@sizeOf(initial_ramdisk.Header)]).magic != initial_ramdisk.magic)
{
_ = bs.freePool(image.ptr) catch {};
return false;
}
boot_information.initial_ramdisk_base = @intFromPtr(image.ptr);
boot_information.initial_ramdisk_len = image.len;
return true;
}
/// The manifest path, and a scratch limit for its UTF-16 conversion.
const manifest_file_name = std.unicode.utf8ToUtf16LeStringLiteral("system\\manifest");
/// Load every binary the manifest lists, opening each path by name from the
/// volume root. A listed-but-unopenable file is skipped (the kernel reports the
/// absence); a missing manifest errors so the caller falls back to the walk.
fn loadByManifest(bs: *uefi.tables.BootServices, root: *uefi.protocol.File, list: *[maximum_bundled]Bundled, count: *usize) !void {
const manifest_handle = try root.open(manifest_file_name, .read, .{});
var manifest_open = true;
defer if (manifest_open) {
_ = manifest_handle.close() catch {};
};
const manifest = try readWholeFile(bs, manifest_handle);
_ = manifest_handle.close() catch {};
manifest_open = false;
defer _ = bs.freePool(manifest.ptr) catch {};
var lines = std.mem.tokenizeAny(u8, manifest, "\r\n");
while (lines.next()) |line| {
if (line.len < 2 or line[0] != '/') continue;
if (line.len >= initial_ramdisk.maximum_name) continue;
if (count.* == maximum_bundled) return;
// "/system/services/init" -> UTF-16 "system\services\init".
var name16: [initial_ramdisk.maximum_name]u16 = undefined;
var i: usize = 0;
for (line[1..]) |c| {
name16[i] = if (c == '/') '\\' else c;
i += 1;
}
name16[i] = 0;
const file = root.open(@ptrCast(name16[0..i :0]), .read, .{}) catch continue;
defer _ = file.close() catch {};
const data = readWholeFile(bs, file) catch continue;
var entry: *Bundled = &list[count.*];
@memcpy(entry.path[0..line.len], line);
entry.path_len = line.len;
entry.data = data;
count.* += 1;
}
}
/// Recursively collect the regular files below `directory` into `list`. Top-level
/// files (depth 0) are skipped: the only one is /system/kernel, which loadKernel
/// has already consumed and which is not a spawnable user binary (/test has no
/// top-level files, so the skip is a no-op there).
fn walkDirectory(
bs: *uefi.tables.BootServices,
directory: *uefi.protocol.File,
prefix: []const u8,
depth: usize,
list: *[maximum_bundled]Bundled,
count: *usize,
) !void {
// Each read() on a directory yields one EFI_FILE_INFO; zero bytes means done.
var info_buffer: [1024]u8 align(8) = undefined;
while (true) {
const n = try directory.read(&info_buffer);
if (n == 0) return;
const info: *const uefi.protocol.File.Info.File = @ptrCast(@alignCast(&info_buffer));
const name16 = info.getFileName();
// Convert the (ASCII in practice) UTF-16 name. A hostile-shaped entry
// (too long, non-ASCII) is SKIPPED, never fatal — one odd file on a
// hand-written stick must not cost the whole boot. Names are lowered:
// the danos tree is canonically lowercase and FAT lookups are
// case-insensitive, but firmware ENUMERATION returns whatever the
// directory stores — an 8.3 short entry comes back uppercase ("INIT"),
// which would otherwise poison every path comparison downstream.
var name_buffer: [initial_ramdisk.maximum_name]u8 = undefined;
var name_length: usize = 0;
var name_ok = true;
while (name16[name_length] != 0) : (name_length += 1) {
if (name_length == name_buffer.len) {
name_ok = false;
break;
}
const c = name16[name_length];
if (c > 0x7F) {
name_ok = false;
break;
}
name_buffer[name_length] = std.ascii.toLower(@intCast(c));
}
if (!name_ok) continue;
const name = name_buffer[0..name_length];
// Skip dot entries: "." / ".." and host-OS litter (macOS "._*" AppleDouble
// resource forks, ".fseventsd", ".Spotlight-V100") a copied-onto stick
// accumulates — none of it is a danos binary.
if (name.len == 0 or name[0] == '.') continue;
if (info.attribute.directory) {
if (depth == maximum_tree_depth) continue;
var child_prefix: [initial_ramdisk.maximum_name]u8 = undefined;
const child = try std.fmt.bufPrint(&child_prefix, "{s}/{s}", .{ prefix, name });
const child_directory = try directory.open(name16, .read, .{});
defer _ = child_directory.close() catch {};
try walkDirectory(bs, child_directory, child, depth + 1, list, count);
continue;
}
if (depth == 0) continue; // /system/kernel — already loaded, not bundled
if (count.* == maximum_bundled) return error.TooManyBinaries;
var entry: *Bundled = &list[count.*];
const path = std.fmt.bufPrint(&entry.path, "{s}/{s}", .{ prefix, name }) catch continue; // path too long: skip the file, keep the boot
entry.path_len = path.len;
const file = directory.open(name16, .read, .{}) catch continue;
defer _ = file.close() catch {};
entry.data = readWholeFile(bs, file) catch continue; // unreadable/empty: skip
count.* += 1;
}
}
/// Read an open file completely into a fresh pool buffer that survives the
/// handoff (LoaderData is classified reserved, so the kernel identity-maps it).
fn readWholeFile(bs: *uefi.tables.BootServices, file: *uefi.protocol.File) ![]align(8) u8 {
try file.setPosition(seek_end);
const size: usize = @intCast(try file.getPosition());
try file.setPosition(0);
if (size == 0) return error.EmptyFile;
const image = try bs.allocatePool(.loader_data, size); // survives the handoff
const buffer = try bs.allocatePool(.loader_data, size);
var read_total: usize = 0;
while (read_total < size) {
const n = try file.read(image[read_total..]);
const n = try file.read(buffer[read_total..]);
if (n == 0) return error.UnexpectedEof;
read_total += n;
}
return image[0..size];
}
/// Ferry the init program (/system/services/init) to the kernel. The kernel does the ELF
/// loading itself (into ring-3 mappings) — the loader just carries the bytes.
fn loadInit(bs: *uefi.tables.BootServices, boot_information: *BootInformation) !void {
const image = try loadFile(bs, init_file_name);
boot_information.init_base = @intFromPtr(image.ptr);
boot_information.init_len = image.len;
progress("EFI: /system/services/init loaded\r\n");
}
/// Ferry the initial_ramdisk (the VFS server + drivers) to the kernel, same as init.
fn loadInitialRamdisk(bs: *uefi.tables.BootServices, boot_information: *BootInformation) !void {
const image = try loadFile(bs, initial_ramdisk_file_name);
boot_information.initial_ramdisk_base = @intFromPtr(image.ptr);
boot_information.initial_ramdisk_len = image.len;
progress("EFI: initial_ramdisk loaded\r\n");
return buffer[0..size];
}
/// Validate the ELF, copy every PT_LOAD segment to its physical address, and
+131
View File
@@ -0,0 +1,131 @@
//! The danos build API (docs/build-packages-plan.md): the one shared recipe
//! for building a user-space binary. A binary package's build.zig names its
//! binary and EXACTLY the modules its source imports — the moral equivalent
//! of a C file's include list — and `userBinary` resolves each name from the
//! library domain package that exports it. Nothing is pre-wired: an @import
//! the package did not declare is a compile error, and a domain none of the
//! imports come from never appears in the package's manifest. The only
//! implicit dependency is the kernel package, because the shared root shim
//! (root.zig, user.ld) lives there and itself reaches start + logging.
//!
//! Consumers declare this package in their build.zig.zon (as "build-support")
//! and @import its build.zig from their own build.zig; nothing is compiled
//! from this package itself — it exports build-time functions only.
const std = @import("std");
pub fn build(b: *std.Build) void {
_ = b; // nothing to build: this package exports build-time functions only
}
/// The freestanding x86-64 target every danos binary (kernel and user) is
/// built for. SSE2 is part of the x86_64 baseline and UEFI leaves it enabled
/// at handoff, so we keep it: disabling it forces soft-float and makes the
/// compiler unable to encode the vector ops that std's formatting/runtime
/// still emit.
pub fn freestandingTarget(b: *std.Build) std.Build.ResolvedTarget {
return b.resolveTargetQuery(.{
.cpu_arch = .x86_64,
.os_tag = .freestanding,
.abi = .none,
});
}
/// Resolve one imported module by searching the packages this binary DECLARED
/// in its own build.zig.zon — the C include path made literal: an import can
/// only be satisfied by a domain the binary claims, and each domain's own
/// build.zig (its addModule exports) is the single statement of who owns
/// what. There is no name table here to drift.
fn moduleFromDeclaredDependencies(b: *std.Build, name: []const u8) *std.Build.Module {
for (b.available_deps) |declared| {
const dependency = b.dependency(declared[0], .{});
if (dependency.builder.modules.get(name)) |module| return module;
}
@panic(b.fmt(
"no declared dependency exports a module named '{s}' — declare the domain that owns it in this package's build.zig.zon",
.{name},
));
}
/// What `userBinary` needs to know about one user binary.
pub const UserBinaryOptions = struct {
name: []const u8,
/// The program's own source file — it becomes the `program` module the
/// root shim imports; a program only defines `pub fn main`.
root_source_file: std.Build.LazyPath,
/// Exactly the modules the program's source @imports (directly or through
/// its same-directory files) — no more, no less. Order is free; sorted
/// reads best. An undeclared @import fails the compile; a name no
/// declared domain exports fails the build graph, naming the miss.
imports: []const []const u8,
/// Built multi-threaded (`single_threaded = false`) so real atomics/TLS
/// work — required before a binary may call `Thread.spawn`
/// (docs/threading.md). Threads are a deliberate per-binary opt-in.
threaded: bool = false,
};
/// Build one user-space binary the same way for every program (init, the
/// services, the drivers): freestanding, ReleaseSmall, `.large` code model
/// (the image base is above 4 GiB — smaller models emit 32-bit relocations
/// that can't reach), linked with the shared user link script. Pinned to
/// LLVM + LLD so the script's PHDRS (segment permissions) are authoritative —
/// the kernel's W^X user-ELF loader requires exact perms.
///
/// The compilation root is not the program's own file but the shared shim
/// (the kernel package's root.zig), which supplies the root declarations
/// (`main` re-export, panic handler, `_start` pull) so a program only defines
/// `pub fn main`. The program's file becomes the `program` module the shim
/// imports; reach it through `programModule` to add per-binary non-library
/// modules (compile-time options).
pub fn userBinary(b: *std.Build, options: UserBinaryOptions) *std.Build.Step.Compile {
const kernel = b.dependency("kernel", .{});
var imports: std.ArrayListUnmanaged(std.Build.Module.Import) = .empty;
for (options.imports) |name| {
imports.append(b.allocator, .{
.name = name,
.module = moduleFromDeclaredDependencies(b, name),
}) catch @panic("OOM");
}
// Settings (target, optimize, code model, ...) live on the root module
// only; the program module inherits them.
const program_module = b.createModule(.{
.root_source_file = options.root_source_file,
.imports = imports.items,
});
const exe = b.addExecutable(.{
.name = options.name,
.root_module = b.createModule(.{
.root_source_file = kernel.path("root.zig"),
.target = freestandingTarget(b),
.optimize = .ReleaseSmall,
.code_model = .large,
.single_threaded = !options.threaded, // a threaded binary needs real atomics/TLS
.sanitize_c = .off,
.stack_check = false,
.stack_protector = false,
// The root shim itself imports only start (_start + panic) and
// logging (std_options) — straight from the kernel package, so a
// program's own import list stays exactly its own.
.imports = &.{
.{ .name = "start", .module = kernel.module("start") },
.{ .name = "logging", .module = kernel.module("logging") },
.{ .name = "program", .module = program_module },
},
}),
});
exe.setLinkerScript(kernel.path("user.ld"));
exe.entry = .{ .symbol_name = "_start" };
exe.image_base = 0x7000_0000_0000;
exe.use_llvm = true;
exe.use_lld = true;
return exe;
}
/// The `program` module of a binary built by `userBinary` — the module rooted
/// at the program's own source file. Per-binary non-library modules (an
/// addOptions build_options) go here, not on the root shim: module imports
/// are not transitive, so an import added to the root would be invisible to
/// the program's code.
pub fn programModule(exe: *std.Build.Step.Compile) *std.Build.Module {
return exe.root_module.import_table.get("program").?;
}
+8
View File
@@ -0,0 +1,8 @@
.{
.name = .build_support,
.version = "0.0.0",
.fingerprint = 0xad91962994f4be41, // Changing this has security and trust implications.
.minimum_zig_version = "0.16.0",
.dependencies = .{},
.paths = .{""},
}
+197 -575
View File
@@ -1,5 +1,12 @@
const std = @import("std");
const builtin = @import("builtin");
// The danos build API (docs/build-packages-plan.md): the shared user-binary
// recipe lives in the build-support package; this root build orchestrates —
// what ships (the bundled list), the kernel + loader, and the test aggregate.
// Image assembly and the QEMU run steps live beside it in build/.
const build_support = @import("build-support");
const images = @import("build/images.zig");
const qemu = @import("build/qemu.zig");
/// danos is developed against Zig 0.16.x. Pre-1.0 Zig makes breaking API changes
/// between minor releases, and the .zon's `minimum_zig_version` only enforces a
@@ -16,86 +23,6 @@ fn ensureZigVersion() void {
}
}
/// Return the first path in `candidates` that exists on the build host, else the
/// first candidate as a fallback so a missing-firmware error still names a
/// concrete (and, by convention, the primary) path. Used to locate OVMF firmware
/// across distro/OS layouts without configuration.
fn firstExisting(io: std.Io, candidates: []const []const u8) []const u8 {
for (candidates) |path| {
std.Io.Dir.accessAbsolute(io, path, .{}) catch continue;
return path;
}
return candidates[0];
}
/// A UTC timestamp like "20260708-153045", for naming a per-run artifact so
/// repeated runs don't clobber each other's logs. Resolved when `build.zig` runs
/// (i.e. at `zig build` invocation), which is moments before QEMU launches.
fn timestamp(b: *std.Build) []const u8 {
const ns = std.Io.Clock.now(.real, b.graph.io).nanoseconds;
const secs: u64 = @intCast(@divFloor(ns, std.time.ns_per_s));
const es = std.time.epoch.EpochSeconds{ .secs = secs };
const yd = es.getEpochDay().calculateYearDay();
const md = yd.calculateMonthDay();
const ds = es.getDaySeconds();
return b.fmt("{d:0>4}{d:0>2}{d:0>2}-{d:0>2}{d:0>2}{d:0>2}", .{
yd.year,
md.month.numeric(),
@as(u32, md.day_index) + 1,
ds.getHoursIntoDay(),
ds.getMinutesIntoHour(),
ds.getSecondsIntoMinute(),
});
}
/// Build one user-space binary the same way for every program (init, and later
/// the VFS server + drivers): freestanding, ReleaseSmall, `.large` code model
/// (the image base is above 4 GiB — smaller models emit 32-bit relocations that
/// can't reach), linked against the `runtime` runtime library with the shared user
/// link script. Pinned to LLVM + LLD so the script's PHDRS (segment permissions)
/// are authoritative — the kernel's W^X user-ELF loader requires exact perms.
fn addUserBinary(
b: *std.Build,
target: std.Build.ResolvedTarget,
runtime_module: *std.Build.Module,
mmio_module: *std.Build.Module,
xkeyboard_config_module: *std.Build.Module,
acpi_ids_module: *std.Build.Module,
name: []const u8,
root: []const u8,
) *std.Build.Step.Compile {
const exe = b.addExecutable(.{
.name = name,
.root_module = b.createModule(.{
.root_source_file = b.path(root),
.target = target,
.optimize = .ReleaseSmall,
.code_model = .large,
.single_threaded = true,
.sanitize_c = .off,
.stack_check = false,
.stack_protector = false,
.imports = &.{
.{ .name = "runtime", .module = runtime_module },
// Typed volatile MMIO + memory barriers, for drivers. See library/mmio/.
.{ .name = "mmio", .module = mmio_module },
// Keyboard layouts (keycode + modifiers -> keysym/character), available
// to any program that wants it. See library/xkeyboard-config/.
.{ .name = "xkeyboard-config", .module = xkeyboard_config_module },
// ACPI/PnP hardware-ID registry, so drivers name devices
// (HardwareId.ps2_keyboard) instead of magic "_HID" strings.
.{ .name = "acpi-ids", .module = acpi_ids_module },
},
}),
});
exe.setLinkerScript(b.path("library/runtime/user.ld"));
exe.entry = .{ .symbol_name = "_start" };
exe.image_base = 0x7000_0000_0000;
exe.use_llvm = true;
exe.use_lld = true;
return exe;
}
/// The modules the kernel imports, gathered once so both kernel variants (the
/// installed one and the serial-enabled one `run-x86-64` boots) are built from
/// the same set. `build_options` is *not* here — it carries `serial`/`test_case`,
@@ -142,6 +69,7 @@ fn addKernel(
.sanitize_c = .off, // the UBSan runtime needs f128/SSE support we don't provide
.stack_check = false, // stack-probe calls have no runtime to land in
.stack_protector = false,
.strip = optimize != .Debug, // DWARF info doubles the flashable image; keep it only for debug builds
.imports = &.{
.{ .name = "boot-handoff", .module = modules.boot_handoff },
.{ .name = "abi", .module = modules.abi },
@@ -168,32 +96,46 @@ fn addKernel(
return exe;
}
/// Assemble the bootable FAT32 image (the in-repo Python builder) holding what
/// the firmware and loader need off the ESP: the EFI stub, `kernel`, `init`, and
/// the initial-ramdisk. Factored so the serial-enabled `run-x86-64` variant can
/// bundle its own kernel while sharing the (serial-independent) loader, init, and
/// ramdisk. Returns the image's LazyPath.
fn addBootImage(
b: *std.Build,
kernel_bin: std.Build.LazyPath,
efi_bin: std.Build.LazyPath,
init_bin: std.Build.LazyPath,
initial_ramdisk_img: std.Build.LazyPath,
) std.Build.LazyPath {
const mk_fat = b.addSystemCommand(&.{"python3"});
mk_fat.addFileArg(b.path("tools/make-fat-image.py"));
const fat_image = mk_fat.addOutputFileArg("danos-usb.img");
mk_fat.addArg("64"); // MiB
mk_fat.addArg("EFI/BOOT/BOOTX64.efi");
mk_fat.addFileArg(efi_bin);
mk_fat.addArg("system/kernel");
mk_fat.addFileArg(kernel_bin);
mk_fat.addArg("system/services/init");
mk_fat.addFileArg(init_bin);
mk_fat.addArg("boot/initial-ramdisk.img");
mk_fat.addFileArg(initial_ramdisk_img);
return fat_image;
/// One row of the production ship table: which package, which of its
/// artifacts, and the FHS boot path. For most binaries all three share one
/// name; the helpers below make a row from just that name.
const ShipRow = struct { path: []const u8, package: []const u8, artifact: []const u8 };
fn service(comptime name: []const u8) ShipRow {
return .{ .path = "system/services/" ++ name, .package = name, .artifact = name };
}
fn driver(comptime name: []const u8) ShipRow {
return .{ .path = "system/drivers/" ++ name, .package = name, .artifact = name };
}
/// An extra artifact of a multi-binary driver package (ps2-bus, usb-hid),
/// bundled at its own flattened /system/drivers path.
fn driverArtifact(comptime package: []const u8, comptime artifact: []const u8) ShipRow {
return .{ .path = "system/drivers/" ++ artifact, .package = package, .artifact = artifact };
}
/// The production ship table — what a plain `zig build` image contains,
/// beyond the specials the build fn adds around it (init, discovery, the
/// /etc data files; the /test fixtures join only under -Dtest-case).
/// Selecting what goes into a build = selecting rows: a package in no row is
/// not just unshipped, its build file is never even loaded
/// (docs/build-packages-plan.md).
const production_ship = [_]ShipRow{
service("fat"),
service("display"),
service("display-demo"),
service("device-manager"),
service("input"),
service("logger"),
driver("pci-bus"),
driver("ps2-bus"),
driverArtifact("ps2-bus", "ps2-keyboard"),
driverArtifact("ps2-bus", "ps2-mouse"),
driver("usb-xhci-bus"),
driverArtifact("usb-hid", "usb-hid-keyboard"),
driverArtifact("usb-hid", "usb-hid-mouse"),
driver("usb-storage"),
driver("virtio-gpu"),
};
pub fn build(b: *std.Build) void {
ensureZigVersion();
@@ -201,63 +143,32 @@ pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// The library domain packages (docs/build-packages-plan.md, phase 1): each
// domain owns a build.zig/zon that wires and exports its modules, and this
// root build is a consumer — a library interface change now happens in the
// domain's own build file, not here. The per-module commentary lives with
// each domain's build.zig.
const kernel_library = b.dependency("kernel", .{});
const device_library = b.dependency("device", .{});
const client_library = b.dependency("client", .{});
const protocol_library = b.dependency("protocol", .{});
const csv_library = b.dependency("csv", .{});
const xkeyboard_config_library = b.dependency("xkeyboard-config", .{});
// The three shared contracts, each with its own audience so every import
// declares which one it speaks (no target is set, so each inherits the target of
// whichever binary imports it). See docs/coding-standards.md.
// boot-handoff : loader <-> kernel (BootInformation, framebuffer, VM layout)
// abi : kernel <-> runtime, core (SystemCall, mmap prot flags, page_size)
// device-abi : kernel <-> user, devices (DeviceDescriptor, DeviceClass, ...)
// boot-handoff stays a root module (a system/ source the loader <-> kernel
// pair speaks); abi is exported by the kernel library package (its source
// also lives in system/), device-abi by the device package.
const boot_handoff_module = b.addModule("boot-handoff", .{
.root_source_file = b.path("system/boot-handoff.zig"),
});
const abi_module = b.addModule("abi", .{
.root_source_file = b.path("system/abi.zig"),
});
// The devices sub-project's public interface (the flat wire types), exposed as
// its own module like vfs-protocol — importable by user space, unlike the
// kernel-internal device model it also feeds (system/devices/device-model.zig).
const device_abi_module = b.addModule("device-abi", .{
.root_source_file = b.path("system/devices/device-abi.zig"),
});
// PCI class-code decoding (class/subclass/prog-IF -> names). Pure reference data,
// shared by kernel discovery (the device-tree dump) and any user-space PCI tool.
const pci_class_module = b.addModule("pci-class", .{
.root_source_file = b.path("system/devices/pci-class.zig"),
});
// ACPI/PnP hardware-ID (_HID) names — the flat analog of pci-class for acpi_device
// nodes. Also shared reference data.
// The AML interpreter, a build module so the ring-3 acpi service can run the
// same parser the kernel does (docs/discovery.md — the shared AML module).
// Pure Zig, no kernel imports — one source, two builds.
const aml_module = b.addModule("aml", .{
.root_source_file = b.path("system/devices/aml/aml.zig"),
});
const acpi_ids_module = b.addModule("acpi-ids", .{
.root_source_file = b.path("system/devices/acpi-ids.zig"),
});
// The USB device-framework wire ABI (chapter-9 set-up packets, standard +
// class requests, descriptors) and the USB class-code taxonomy — the flat
// reference the xHCI bus driver, the USB class drivers, and the device
// manager's identity matcher all share. Pure data, like pci-class/acpi-ids.
const usb_abi_module = b.addModule("usb-abi", .{
.root_source_file = b.path("system/devices/usb-abi.zig"),
});
const usb_ids_module = b.addModule("usb-ids", .{
.root_source_file = b.path("system/devices/usb-ids.zig"),
});
// The USB transfer protocol: what a USB class driver says to the xHCI bus
// driver to drive its device (open / control / interrupt / bulk). A protocol
// module like vfs-protocol, shared by the bus driver and every class driver.
const usb_transfer_protocol_module = b.addModule("usb-transfer-protocol", .{
.root_source_file = b.path("system/drivers/usb-xhci-bus/usb-transfer-protocol.zig"),
});
// The block-device protocol: read/write of fixed-size blocks, spoken between a
// filesystem and a block driver (usb-storage). A protocol module like the rest.
const block_protocol_module = b.addModule("block-protocol", .{
.root_source_file = b.path("system/services/block/protocol.zig"),
});
const abi_module = kernel_library.module("abi");
const device_abi_module = device_library.module("device-abi");
// Kernel tunables (maximum_cpus, stack sizes, tick rate). A dependency-free module of
// compile-time constants, imported wherever a knob is read; keeps the trade-offs
@@ -288,102 +199,22 @@ pub fn build(b: *std.Build) void {
// "platform" and asks it to enumerate hardware into a backend-neutral device
// tree, never naming ACPI (or, later, device-tree) — the same discipline the
// architecture module applies to CPU code. The backend is selected at runtime from
// the boot handoff (see system/devices/platform.zig).
// the boot handoff (see system/kernel/platform.zig).
const platform_module = b.addModule("platform", .{
.root_source_file = b.path("system/devices/platform.zig"),
.root_source_file = b.path("system/kernel/platform.zig"),
.imports = &.{
.{ .name = "boot-handoff", .module = boot_handoff_module }, // BootInformation (carries the ACPI RSDP), physicalToVirtual
.{ .name = "abi", .module = abi_module }, // acpi.zig works in page_size units
.{ .name = "device-abi", .module = device_abi_module }, // device-model's DeviceClass/ResourceKind live here
.{ .name = "pci-class", .module = pci_class_module }, // decode PCI class codes in the device dump
.{ .name = "acpi-ids", .module = acpi_ids_module }, // decode ACPI _HID names in the device dump
.{ .name = "parameters", .module = parameters_module }, // maximum_cpus (the discovery pool)
},
});
// The VFS wire protocol: the vfs sub-project's public interface, exposed as its
// own module. Both the vfs server and the runtime's file layer (unistd/stdio)
// depend on this contract by name — neither reaches into the other's files. This
// is the first "protocol module" (see docs/driver-model.md); usb/block will
// expose theirs the same way.
const vfs_protocol_module = b.addModule("vfs-protocol", .{
.root_source_file = b.path("system/services/vfs/protocol.zig"),
});
// The input wire protocol: the input service's public interface, exposed as its own
// module the same way vfs-protocol is. Shared by the input service, the runtime's
// `input` helper (subscribe/publish), and every source and subscriber.
const input_protocol_module = b.addModule("input-protocol", .{
.root_source_file = b.path("system/services/input/protocol.zig"),
});
// The danos-native user-space runtime: system_call wrappers, the C-convention
// heap, IPC helpers, the process start shim, device access. This is the stable
// application ABI; POSIX compatibility is a separate library on top (see below).
// Compiled into every user binary (see addUserBinary), so it inherits each exe's
// `.large` code model — do NOT set a target/code_model here. It imports `abi`
// for the shared SystemCall numbers / mmap flags, `device-abi` for the device
// types its `device` helper wraps, and re-exports `vfs-protocol` for the VFS
// server. It never touches `boot-handoff` — user space has no business with the
// loader↔kernel handoff.
const runtime_module = b.addModule("runtime", .{
.root_source_file = b.path("library/runtime/runtime.zig"),
.imports = &.{
.{ .name = "abi", .module = abi_module },
.{ .name = "device-abi", .module = device_abi_module },
.{ .name = "vfs-protocol", .module = vfs_protocol_module },
.{ .name = "input-protocol", .module = input_protocol_module },
},
});
// The device-manager protocol: hello + (M18.2) tree reports, exposed as its
// own module like the other protocol modules. Imported through the runtime.
const device_manager_protocol_module = b.addModule("device-manager-protocol", .{
.root_source_file = b.path("system/services/device-manager/device-manager-protocol.zig"),
});
runtime_module.addImport("device-manager-protocol", device_manager_protocol_module);
// The USB transfer protocol, so runtime.usb (the class-driver client) can speak
// it, the way runtime.input speaks the input protocol.
runtime_module.addImport("usb-transfer-protocol", usb_transfer_protocol_module);
// The block protocol, so runtime.block (the block-device client) can speak it.
runtime_module.addImport("block-protocol", block_protocol_module);
// The display protocol, so runtime.display (the compositor client) and the display
// service both speak it through the runtime, like the other protocol modules.
const display_protocol_module = b.addModule("display-protocol", .{
.root_source_file = b.path("system/services/display/protocol.zig"),
});
runtime_module.addImport("display-protocol", display_protocol_module);
// The power protocol: system power's domain-named surface (docs/power.md).
const power_protocol_module = b.addModule("power-protocol", .{
.root_source_file = b.path("system/services/power/protocol.zig"),
});
runtime_module.addImport("power-protocol", power_protocol_module);
// Typed volatile MMIO register access + memory-ordering barriers, for drivers on
// top of an mmio_map grant. Depends only on `builtin` (arch-conditional barriers);
// no target set, so it inherits each driver's. See library/mmio/mmio.zig.
const mmio_module = b.addModule("mmio", .{
.root_source_file = b.path("library/mmio/mmio.zig"),
});
// Keyboard layouts compiled from the X11 xkeyboard-config database into native Zig
// (keycode + modifiers -> keysym/character). The `layouts` tables are generated by
// tools/make-xkeyboard-config.py; `xkeyboard-config` is the hand-written API over them.
// No target set, so each inherits its importer's. See library/xkeyboard-config/.
const xkb_layouts_module = b.addModule("layouts", .{
.root_source_file = b.path("library/xkeyboard-config/generated/layouts.zig"),
});
const xkeyboard_config_module = b.addModule("xkeyboard-config", .{
.root_source_file = b.path("library/xkeyboard-config/xkeyboard-config.zig"),
.imports = &.{
.{ .name = "layouts", .module = xkb_layouts_module },
},
});
// The initial_ramdisk container format, shared by the kernel (unpacks it) and the
// build-time packer tools/make-initial-ramdisk.py (produces it). No dependencies.
// The initial_ramdisk container format, shared by the kernel (unpacks it) and
// the EFI loader (packs it in RAM from the boot volume's /system tree). No
// dependencies.
const initial_ramdisk_module = b.addModule("initial-ramdisk", .{
.root_source_file = b.path("system/initial-ramdisk.zig"),
});
@@ -397,16 +228,14 @@ pub fn build(b: *std.Build) void {
// QEMU test harness (test/qemu_test.py, which asserts on serial markers) turn
// it on; a flashable `zig build` image leaves it out. See serial.zig.
const serial = b.option(bool, "serial", "Compile the serial-console log sink into the kernel (default: off; run-x86-64 and the test harness enable it)") orelse false;
// The diagnose boot: init skips the display service (and demo), so the
// on-screen boot transcript is never suppressed — the full timestamped
// timeline stays on the screen for real-hardware debugging by eye.
const diagnose = b.option(bool, "diagnose", "Boot without the display service so the timestamped boot transcript stays on screen (real-hardware debugging)") orelse false;
// --- Kernel: freestanding x86_64 ELF, jumped to by the bootloader ---
// SSE2 is part of the x86_64 baseline and UEFI leaves it enabled at handoff,
// so we keep it: disabling it forces soft-float and makes the compiler unable
// to encode the vector ops that std's formatting/runtime still emit.
const kernel_target = b.resolveTargetQuery(.{
.cpu_arch = .x86_64,
.os_tag = .freestanding,
.abi = .none,
});
// (See build-support/build.zig for why SSE2 stays enabled.)
const kernel_target = build_support.freestandingTarget(b);
const kernel_modules = KernelModules{
.boot_handoff = boot_handoff_module,
@@ -418,174 +247,98 @@ pub fn build(b: *std.Build) void {
.initial_ramdisk = initial_ramdisk_module,
};
// The installed/flashable kernel: serial follows -Dserial (off by default).
// The serial-enabled twin is what `run-x86-64` boots — built lazily (only
// when its image is requested), never installed.
const exe = addKernel(b, kernel_target, optimize, kernel_modules, test_case, serial);
const exe_serial = addKernel(b, kernel_target, optimize, kernel_modules, test_case, true);
// Everything installs into a FHS-shaped zig-out: it IS the danos filesystem *and*
// the boot volume. Each binary lands at its addressed, leaf-collapsed path — the
// kernel at zig-out/system/kernel (from system/kernel/kernel.zig), init at
// zig-out/system/services/init, and so on (see docs/README.md). The bootloader
// then loads these FHS paths off the volume.
const kernel_install = b.addInstallArtifact(exe, .{ .dest_dir = .{ .override = .{ .custom = "system" } } });
b.getInstallStep().dependOn(&kernel_install.step);
// --- init: the first user-space program (a system service) ---
// Built by the shared user-binary recipe (see addUserBinary): freestanding,
// linked into the kernel's user region against the `runtime` runtime library, and
// started in ring 3 by the kernel's user-ELF loader.
const init_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "init", "system/services/init/init.zig");
const init_install = b.addInstallArtifact(init_exe, .{ .dest_dir = .{ .override = .{ .custom = "system/services" } } });
b.getInstallStep().dependOn(&init_install.step);
// --- initial_ramdisk: a bundle of extra user binaries (VFS server + drivers) ---
// Each is built by the same user-binary recipe, then packed into one image by
// the host-side make-initial-ramdisk tool. The bootloader ferries the image to the kernel,
// which unpacks it and spawns each program (system/initial-ramdisk.zig).
const vfs_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "vfs", "system/services/vfs/vfs.zig");
const vfstest_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "vfs-test", "system/services/vfs/vfs-test.zig");
const ps2_bus_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "ps2-bus", "system/drivers/ps2-bus/ps2-bus.zig");
const ps2_keyboard_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "ps2-keyboard", "system/drivers/ps2-bus/keyboard.zig");
const ps2_mouse_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "ps2-mouse", "system/drivers/ps2-bus/mouse.zig");
const usb_xhci_bus_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "usb-xhci-bus", "system/drivers/usb-xhci-bus/usb-xhci-bus.zig");
// The xHCI bus driver builds chapter-9 requests and decodes descriptors from
// usb-abi, and reports each interface's (class,subclass,protocol) identity via
// usb-ids.packTriple.
usb_xhci_bus_exe.root_module.addImport("usb-abi", usb_abi_module);
usb_xhci_bus_exe.root_module.addImport("usb-ids", usb_ids_module);
usb_xhci_bus_exe.root_module.addImport("usb-transfer-protocol", usb_transfer_protocol_module);
// The USB HID class drivers: keyboard and mouse. They own no hardware — each
// opens its device through runtime.usb (the transfer protocol) and publishes to
// the input service. They build chapter-9 class requests from usb-abi.
const usb_hid_keyboard_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "usb-hid-keyboard", "system/drivers/usb-hid/keyboard.zig");
usb_hid_keyboard_exe.root_module.addImport("usb-abi", usb_abi_module);
const usb_hid_mouse_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "usb-hid-mouse", "system/drivers/usb-hid/mouse.zig");
usb_hid_mouse_exe.root_module.addImport("usb-abi", usb_abi_module);
// The USB mass-storage class driver: opens its device via runtime.usb, drives it
// with Bulk-Only Transport + SCSI, and serves the block protocol under `.block`.
const usb_storage_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "usb-storage", "system/drivers/usb-storage/usb-storage.zig");
usb_storage_exe.root_module.addImport("block-protocol", block_protocol_module);
// The FAT filesystem server: mounts the block device and serves it into the VFS
// at /mnt/usb. Its engine (engine.zig / on-disk.zig) is imported relatively.
const fat_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "fat", "system/services/fat/fat.zig");
const display_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "display", "system/services/display/display.zig");
const display_demo_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "display-demo", "system/services/display-demo/display-demo.zig");
const fat_test_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "fat-test", "system/services/fat/fat-test.zig");
const pci_bus_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "pci-bus", "system/drivers/pci-bus/pci-bus.zig");
// The PCI bus driver decodes each function's class triple to human names in its
// boot log (class/subclass/prog-IF), so pull in the shared pci-class reference.
pci_bus_exe.root_module.addImport("pci-class", pci_class_module);
// A test fixture, not a real driver: hellos to the device manager, then faults —
// what the driver-restart scenario drives the crash-loop cap with.
const crash_test_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "crash-test", "system/services/crash-test/crash-test.zig");
const device_list_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "device-list", "system/services/device-list/device-list.zig");
// --- what ships: the boot tree ---
// Every user binary and its FHS home on the boot volume. There is no packed
// ramdisk artifact any more: make-fat-image.py lays each binary out at its
// path on the image, and the EFI loader walks /system and /test at boot and
// builds the in-RAM initial_ramdisk table from the trees — the volume's file
// structure is the single source of truth. Entry names (and hence argv[0] and
// task names) are these paths with a leading slash.
//
// The uniform rows live in `production_ship` (the table above `build`);
// spelled out here are only the genuinely non-uniform entries: init
// (receives the root's -Dserial as a dependency option — its liveness
// heartbeat is a serial/test-build diagnostic the QEMU harness asserts
// on; a flashable image leaves it out), discovery (the -Ddiscovery pick),
// and the /etc data files. Each binary builds itself against the domain
// packages via build-support's shared recipe; the root just takes
// artifacts (docs/build-packages-plan.md).
var bundled_list: std.ArrayListUnmanaged(images.BundledBinary) = .empty;
bundled_list.append(b.allocator, .{
.path = "system/services/init",
.binary = b.dependency("init", .{ .serial = serial }).artifact("init").getEmittedBin(),
}) catch @panic("OOM");
// The discovery service: one swappable process per firmware
// (docs/discovery.md), bundled under the neutral ramdisk name
// "discovery" so the device manager never learns which firmware it is on.
// x86 boots describe hardware with ACPI; the Raspberry Pis hand over a
// flattened device tree — the aarch64 target flips the default when it
// lands (docs/arm.md). Both are placeholders until M20.1 (acpi) and the
// ARM bring-up (fdt).
// lands (docs/arm.md). Each firmware's service is its own LAZY package;
// both export an artifact named "discovery", and this option picks which
// one ships — the unselected package's build file is never even loaded.
// (lazyDependency returns null only for an unfetched remote package; these
// are in-repo path dependencies, so a null means the directory is gone.)
const Discovery = enum { acpi, fdt };
const discovery = b.option(Discovery, "discovery", "Which discovery service fills the ramdisk's 'discovery' slot (default: acpi)") orelse Discovery.acpi;
const discovery_source: []const u8 = switch (discovery) {
.acpi => "system/services/acpi/acpi.zig",
.fdt => "system/services/fdt/fdt.zig",
const discovery_exe = switch (discovery) {
.acpi => (b.lazyDependency("acpi", .{}) orelse @panic("system/services/acpi is missing")).artifact("discovery"),
.fdt => (b.lazyDependency("fdt", .{}) orelse @panic("system/services/fdt is missing")).artifact("discovery"),
};
const discovery_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "discovery", discovery_source);
if (discovery == .acpi) discovery_exe.root_module.addImport("aml", aml_module);
const device_manager_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "device-manager", "system/services/device-manager/device-manager.zig");
// Names the xHCI PCI class triple from the shared taxonomy instead of a bare 0x0C0330.
device_manager_exe.root_module.addImport("pci-class", pci_class_module);
// The manager matches reported USB interfaces by their (class,subclass,protocol)
// triple (usbDriverForIdentity), built from the named usb-ids codes.
device_manager_exe.root_module.addImport("usb-ids", usb_ids_module);
// The input service and its exercisers: the fan-out server, a hardware-free synthetic
// source, and a subscriber that doubles as the `input` test's oracle. See docs/input.md.
const input_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "input", "system/services/input/input.zig");
const input_source_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "input-source", "system/services/input-source/input-source.zig");
const input_test_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "input-test", "system/services/input-test/input-test.zig");
const args_echo_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "args-echo", "system/services/args-echo/args-echo.zig");
const process_test_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "process-test", "system/services/process-test/process-test.zig");
const log_flush_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "log-flush", "system/services/log-flush/log-flush.zig");
// Pack the user binaries into the initial_ramdisk image with the host-side Python tool
// (the container format is trivial, and Python sidesteps std API churn). Args:
// make-initial-ramdisk.py <out> [<name> <file>]... — one name/file pair per binary.
const mk_run = b.addSystemCommand(&.{"python3"});
mk_run.addFileArg(b.path("tools/make-initial-ramdisk.py"));
const initial_ramdisk_img = mk_run.addOutputFileArg("initial-ramdisk.img");
mk_run.addArg("vfs");
mk_run.addFileArg(vfs_exe.getEmittedBin());
mk_run.addArg("vfs-test");
mk_run.addFileArg(vfstest_exe.getEmittedBin());
mk_run.addArg("ps2-bus");
mk_run.addFileArg(ps2_bus_exe.getEmittedBin());
mk_run.addArg("ps2-keyboard");
mk_run.addFileArg(ps2_keyboard_exe.getEmittedBin());
mk_run.addArg("ps2-mouse");
mk_run.addFileArg(ps2_mouse_exe.getEmittedBin());
mk_run.addArg("usb-xhci-bus");
mk_run.addFileArg(usb_xhci_bus_exe.getEmittedBin());
mk_run.addArg("usb-hid-keyboard");
mk_run.addFileArg(usb_hid_keyboard_exe.getEmittedBin());
mk_run.addArg("usb-hid-mouse");
mk_run.addFileArg(usb_hid_mouse_exe.getEmittedBin());
mk_run.addArg("usb-storage");
mk_run.addFileArg(usb_storage_exe.getEmittedBin());
mk_run.addArg("fat");
mk_run.addFileArg(fat_exe.getEmittedBin());
mk_run.addArg("fat-test");
mk_run.addFileArg(fat_test_exe.getEmittedBin());
mk_run.addArg("display");
mk_run.addFileArg(display_exe.getEmittedBin());
mk_run.addArg("display-demo");
mk_run.addFileArg(display_demo_exe.getEmittedBin());
mk_run.addArg("pci-bus");
mk_run.addFileArg(pci_bus_exe.getEmittedBin());
mk_run.addArg("crash-test");
mk_run.addFileArg(crash_test_exe.getEmittedBin());
mk_run.addArg("device-list");
mk_run.addFileArg(device_list_exe.getEmittedBin());
mk_run.addArg("discovery");
mk_run.addFileArg(discovery_exe.getEmittedBin());
mk_run.addArg("device-manager");
mk_run.addFileArg(device_manager_exe.getEmittedBin());
mk_run.addArg("input");
mk_run.addFileArg(input_exe.getEmittedBin());
mk_run.addArg("input-source");
mk_run.addFileArg(input_source_exe.getEmittedBin());
mk_run.addArg("input-test");
mk_run.addFileArg(input_test_exe.getEmittedBin());
mk_run.addArg("args-echo");
mk_run.addFileArg(args_echo_exe.getEmittedBin());
mk_run.addArg("process-test");
mk_run.addFileArg(process_test_exe.getEmittedBin());
mk_run.addArg("log-flush");
mk_run.addFileArg(log_flush_exe.getEmittedBin());
// Also install the packed binaries to their FHS homes, so zig-out is a true image
// of the filesystem — even though at boot they arrive inside the initial-ramdisk.
for ([_]struct { *std.Build.Step.Compile, []const u8 }{
.{ vfs_exe, "system/services" },
.{ device_manager_exe, "system/services" },
.{ input_exe, "system/services" },
.{ ps2_bus_exe, "system/drivers" },
.{ ps2_keyboard_exe, "system/drivers" },
.{ ps2_mouse_exe, "system/drivers" },
.{ usb_xhci_bus_exe, "system/drivers" },
.{ usb_hid_keyboard_exe, "system/drivers" },
.{ usb_hid_mouse_exe, "system/drivers" },
.{ usb_storage_exe, "system/drivers" },
.{ fat_exe, "system/services" },
.{ display_exe, "system/services" },
.{ log_flush_exe, "system/services" },
}) |entry| {
const step = b.addInstallArtifact(entry[0], .{ .dest_dir = .{ .override = .{ .custom = entry[1] } } });
b.getInstallStep().dependOn(&step.step);
bundled_list.append(b.allocator, .{
.path = "system/services/discovery",
.binary = discovery_exe.getEmittedBin(),
}) catch @panic("OOM");
// The ship table: every uniform row, one line each.
for (production_ship) |row| {
bundled_list.append(b.allocator, .{
.path = row.path,
.binary = b.dependency(row.package, .{}).artifact(row.artifact).getEmittedBin(),
}) catch @panic("OOM");
}
// The initial-ramdisk itself installs to /boot (with the loaders).
const initial_ramdisk_install = b.addInstallFile(initial_ramdisk_img, "boot/initial-ramdisk.img");
b.getInstallStep().dependOn(&initial_ramdisk_install.step);
// Data files, not binaries: packing them under /etc makes the kernel
// auto-mount /etc as a read-only initrd tree (system/kernel/vfs.zig
// setInitialRamdisk) — the device manager reads its registry and init its
// service list with no filesystem service running. -Ddiagnose selects the
// init.csv variant that omits the display stack (so the kernel's boot
// transcript stays on screen); both bundle at the same /etc/init.csv path.
const init_csv_source = if (diagnose) "etc/init-diagnose.csv" else "etc/init.csv";
bundled_list.append(b.allocator, .{ .path = "etc/devices.csv", .binary = b.path("etc/devices.csv") }) catch @panic("OOM");
bundled_list.append(b.allocator, .{ .path = "etc/init.csv", .binary = b.path(init_csv_source) }) catch @panic("OOM");
// A no-option build assumes neither -Dtest-case nor -Ddiagnose: it ships the
// production set only. The userspace test fixtures under /test join in only
// for a test build — which the QEMU harness signals by passing
// -Dtest-case=<name> for every scenario, exactly when they must be on the
// boot volume. They are LAZY dependencies too. Fixture packages are
// uniform — the dependency name, the artifact name, and the boot path's
// leaf all match the directory — so a name is a whole entry.
if (test_case != null) for ([_][]const u8{
"vfs-test", // the user-space VFS round-trip client
"fat-test",
"shared-memory-server",
"shared-memory-client",
"crash-test", // hellos to the device manager, then faults — drives the crash-loop cap
"device-list",
"pci-cap-test", // exercises the driver-side PCI library against the pci-caps NIC
"iommu-fault-test", // fires the rogue DMA that VT-d must fault
"input-source",
"input-test",
"args-echo",
"process-test",
"thread-test", // the multi-threaded fixture (its package sets .threaded)
}) |fixture| {
const package = b.lazyDependency(fixture, .{}) orelse
@panic("a test fixture package is missing under test/system/services");
bundled_list.append(b.allocator, .{
.path = b.fmt("test/system/services/{s}", .{fixture}),
.binary = package.artifact(fixture).getEmittedBin(),
}) catch @panic("OOM");
};
const bundled = bundled_list.items;
// Boot methods live in boot/, one per way of getting the kernel running.
// Each is its own binary/entry (a loader is built for its own target); today
@@ -607,147 +360,25 @@ pub fn build(b: *std.Build) void {
}),
.optimize = optimize,
.imports = &.{
// The bootloader speaks only the handoff contract — never the user ABI.
// The bootloader speaks the handoff contract and the ramdisk
// container it packs the /system tree into — never the user ABI.
.{ .name = "boot-handoff", .module = boot_handoff_module },
.{ .name = "initial-ramdisk", .module = initial_ramdisk_module },
.{ .name = "build_options", .module = loader_options_module },
},
}),
});
// UEFI firmware requires the removable-media loader at exactly \EFI\BOOT\BOOTX64.efi,
// so that path is fixed by the firmware (it is /boot's EFI stub, conceptually).
const efi_install = b.addInstallArtifact(efiexe, .{ .dest_dir = .{ .override = .{ .custom = "EFI/BOOT" } } });
b.getInstallStep().dependOn(&efi_install.step);
// --- danos-usb.img: the bootable FAT32 USB image ---
// Format a real FAT32 image (the in-repo Python builder, no external tools)
// holding exactly what the firmware and bootloader need off the ESP: the EFI
// stub, the kernel, init, and the initial-ramdisk. QEMU presents this image as
// a USB mass-storage device the guest boots from (see run-x86-64 and the test
// harness), and the danos fat driver mounts the same image at /mnt/usb.
const fat_image = addBootImage(b, exe.getEmittedBin(), efiexe.getEmittedBin(), init_exe.getEmittedBin(), initial_ramdisk_img);
const fat_image_install = b.addInstallFile(fat_image, "danos-usb.img");
b.getInstallStep().dependOn(&fat_image_install.step);
// The image `run-x86-64` boots: identical to the flashable one but with the
// serial log sink compiled in, so a developer always gets the machine-readable
// log captured to serial0 — without baking serial into the image users flash.
// Built lazily (only when `run-x86-64` is requested), and never installed.
const exe_serial = addKernel(b, kernel_target, optimize, kernel_modules, test_case, true);
const fat_image_serial = addBootImage(b, exe_serial.getEmittedBin(), efiexe.getEmittedBin(), init_exe.getEmittedBin(), initial_ramdisk_img);
// `zig build check-fat-image` — validate the produced image is a real FAT32
// with the EFI stub present (the builder's own --verify, no external tools).
const check_fat = b.addSystemCommand(&.{"python3"});
check_fat.addFileArg(b.path("tools/make-fat-image.py"));
check_fat.addArg("--verify");
check_fat.addFileArg(fat_image);
const check_fat_step = b.step("check-fat-image", "Verify the FAT32 USB image is valid and bootable");
check_fat_step.dependOn(&check_fat.step);
// --- run-x86-64: boot the x86-64 kernel in QEMU via UEFI/OVMF ---
// Firmware lives in different places per OS/distro, so probe the known
// layouts (Architecture, Debian/Ubuntu, Fedora, macOS Homebrew) and use the first
// that exists. Override with -Dovmf-code / -Dovmf-vars if yours is elsewhere.
const ovmf_code = b.option(
[]const u8,
"ovmf-code",
"Path to the OVMF_CODE firmware image",
) orelse firstExisting(b.graph.io, &.{
"/usr/share/edk2/x64/OVMF_CODE.4m.fd", // Architecture
"/usr/share/OVMF/OVMF_CODE_4M.fd", // Debian/Ubuntu
"/usr/share/OVMF/OVMF_CODE.fd", // older Debian/Ubuntu
"/usr/share/edk2-ovmf/x64/OVMF_CODE.fd", // Fedora
"/opt/homebrew/share/qemu/edk2-x86_64-code.fd", // macOS Homebrew (Apple Silicon)
"/usr/local/share/qemu/edk2-x86_64-code.fd", // macOS Homebrew (Intel)
// Image assembly (the FHS install tree, boot manifest + capsule, both FAT32
// images, the release ISO, the check steps) and the QEMU run steps live in
// build/ — the root decides what ships, those files own how it runs.
const fat_image_serial = images.addImageSteps(b, .{
.kernel = exe,
.kernel_serial = exe_serial,
.efi = efiexe,
.bundled = bundled,
});
const ovmf_vars = b.option(
[]const u8,
"ovmf-vars",
"Path to the OVMF_VARS firmware image (a writable copy is made)",
) orelse firstExisting(b.graph.io, &.{
"/usr/share/edk2/x64/OVMF_VARS.4m.fd", // Architecture
"/usr/share/OVMF/OVMF_VARS_4M.fd", // Debian/Ubuntu
"/usr/share/OVMF/OVMF_VARS.fd", // older Debian/Ubuntu
"/usr/share/edk2-ovmf/x64/OVMF_VARS.fd", // Fedora
"/opt/homebrew/share/qemu/edk2-i386-vars.fd", // macOS Homebrew (Apple Silicon)
"/usr/local/share/qemu/edk2-i386-vars.fd", // macOS Homebrew (Intel)
});
// The 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 firmware needs to write NVRAM, so give it a writable copy of the vars.
const vars_copy = b.addSystemCommand(&.{ "cp", "-f", ovmf_vars });
const vars_out = vars_copy.addOutputFileArg("OVMF_VARS.4m.fd");
const run_efi = b.addSystemCommand(&.{
"qemu-system-x86_64",
"-device",
"qemu-xhci,id=xhci",
"-device",
"usb-mouse,bus=xhci.0",
"-device",
"usb-kbd,bus=xhci.0",
// "-usb",
// "-device",
// "usb-ehci,id=ehci",
// "-device",
// "usb-tablet,bus=usb-bus.0",
// "-device",
// "usb-mouse,bus=ehci.0",
"-machine",
"q35",
"-m",
"128M",
"-drive",
b.fmt("if=pflash,format=raw,readonly=on,file={s}", .{ovmf_code}),
});
run_efi.addArg("-drive");
run_efi.addPrefixedFileArg("if=pflash,format=raw,file=", vars_out);
// Boot off the FAT32 USB image: a mass-storage device on the same xHCI bus as
// the keyboard and mouse. OVMF finds \EFI\BOOT\BOOTX64.efi on it and boots.
// The serial-enabled variant, so serial0 carries the log for this dev boot.
run_efi.addArg("-drive");
run_efi.addPrefixedFileArg("if=none,id=bootusb,format=raw,file=", fat_image_serial);
run_efi.addArgs(&.{
"-device",
"usb-storage,bus=xhci.0,drive=bootusb,removable=on,bootindex=0",
"-net",
"none",
// Emulated display advertising 1280x720 as its native (EDID preferred)
// resolution, so the kernel's native-resolution switch has something to
// find. `-vga none` avoids a second, default adapter.
"-vga",
"none",
"-device",
"VGA,edid=on,xres=1280,yres=720",
});
// Capture the guest's serial0 (danos's machine-readable log) to the qemu-test
// scratch area — a dev/host artifact, kept out of the FHS boot volume we mount.
// (/var/log/system is reserved for the kernel's own logging system later.) One
// timestamped file per run.
const log_dir = b.fmt("{s}/qemu-test", .{b.install_path});
const make_log_dir = b.addSystemCommand(&.{ "mkdir", "-p", log_dir });
const serial_log = b.fmt("{s}/run-x86-64-serial0-{s}.log", .{ log_dir, timestamp(b) });
run_efi.addArgs(&.{ "-serial", b.fmt("file:{s}", .{serial_log}) });
// We boot the self-contained `fat_image_serial` (added as a file arg above, so
// it's already a dependency) — not the installed FHS zig-out — so `run-x86-64`
// builds only the serial kernel, never the flashable one. Just make the serial
// scratch dir first.
run_efi.step.dependOn(&make_log_dir.step);
const run_efi_step = b.step("run-x86-64", "Boot the x86-64 kernel in QEMU (UEFI/OVMF); serial0 is logged to zig-out/qemu-test/run-x86-64-serial0-<timestamp>.log");
run_efi_step.dependOn(&run_efi.step);
// const run_cmd = b.addRunArtifact(exe);
// const run_step = b.step("run", "Run the app");
// run_step.dependOn(&run_cmd.step);
// run_cmd.step.dependOn(b.getInstallStep());
//
// if (b.args) |args| {
// run_cmd.addArgs(args);
// }
qemu.addRunSteps(b, fat_image_serial);
// Tests run on the host. The kernel and bootloader target freestanding/UEFI
// and can't be executed natively, so only the shared contracts are unit-tested
@@ -757,24 +388,7 @@ pub fn build(b: *std.Build) void {
for ([_][]const u8{
"system/boot-handoff.zig",
"system/abi.zig",
"system/devices/device-abi.zig",
"system/devices/pci-class.zig", // class/subclass/prog-IF name decoding
"system/devices/acpi-ids.zig", // _HID name decoding
"system/devices/aml/aml.zig", // AML parse + interpret, incl. Notify dispatch (M21)
"system/devices/usb-abi.zig", // wire sizes + bit packings + set-up packet encodings
"system/devices/usb-ids.zig", // class/subclass/protocol code assignments
"library/mmio/mmio.zig", // barriers assemble + registers round-trip
"system/drivers/ps2-bus/scancode.zig", // set-2 decode + keyboard state machine
"system/drivers/ps2-bus/mouse-packet.zig", // 3-byte mouse packet assembly
"system/drivers/usb-hid/hid-report.zig", // HID boot-report keyboard/mouse decode
"system/drivers/usb-storage/bulk-only-transport.zig", // CBW/CSW wrapper sizes
"system/drivers/usb-storage/scsi.zig", // SCSI CDB encodings (big-endian)
"system/services/vfs/path.zig", // mount-prefix path matching
"system/services/vfs/protocol.zig", // NodeKind / DirectoryEntry sizes + op values
"system/services/fat/on-disk.zig", // FAT on-disk struct sizes + type detection
"system/services/fat/engine.zig", // FAT read/write over a RAM-backed image
"system/services/display/compositor.zig", // Rect math + fill/composite/blit-tile
"system/services/display/protocol.zig", // pack(): native pixel encoding per format
"system/initial-ramdisk.zig", // v2 path-named entries: find/basename/magic
}) |root| {
const mod_tests = b.addTest(.{
.root_module = b.createModule(.{
@@ -786,27 +400,35 @@ pub fn build(b: *std.Build) void {
test_step.dependOn(&b.addRunArtifact(mod_tests).step);
}
// The xkeyboard-config keymap tests need its generated `layouts` import wired, so they
// don't fit the plain loop above. Its keycode->character assertions are the end-to-end
// proof that the xkb-data -> generator -> Zig-lookup pipeline is correct.
const xkb_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("library/xkeyboard-config/xkeyboard-config.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "layouts", .module = xkb_layouts_module },
},
}),
});
test_step.dependOn(&b.addRunArtifact(xkb_tests).step);
// The library domains and the binary packages own their unit tests (each
// package's standalone `zig build test` step); the root aggregate
// delegates to those steps so one command still runs everything and a
// test added inside a package can never be silently skipped here. Package
// tests are host-only, so root's -Dtarget/-Doptimize deliberately do not
// reach them.
for ([_]*std.Build.Dependency{
kernel_library,
device_library,
client_library,
protocol_library,
csv_library,
xkeyboard_config_library,
b.dependency("fat", .{}),
b.dependency("display", .{}),
b.dependency("ps2-bus", .{}),
b.dependency("usb-hid", .{}),
b.dependency("usb-storage", .{}),
b.dependency("virtio-gpu", .{}),
}) |package| {
test_step.dependOn(&package.builder.top_level_steps.get("test").?.step);
}
// runtime.time's Instant/Duration arithmetic. time.zig pulls in system.zig (the
// syscall wrappers), which needs the `abi` module, so it doesn't fit the plain
// loop above.
const time_tests = b.addTest(.{
// The tagged kernel log ring: append/wrap/reclaim/sequence-gap behavior over
// a RAM buffer. Needs the `abi` module (record header layout), so it doesn't
// fit the plain loop above.
const log_ring_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("library/runtime/time.zig"),
.root_source_file = b.path("system/kernel/log-ring.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
@@ -814,7 +436,7 @@ pub fn build(b: *std.Build) void {
},
}),
});
test_step.dependOn(&b.addRunArtifact(time_tests).step);
test_step.dependOn(&b.addRunArtifact(log_ring_tests).step);
// Convenience: `zig build gen-xkeyboard-config` regenerates the layout tables from the
// vendored data (offline). `fetch` (the network step) stays a manual script run.
+43 -8
View File
@@ -32,6 +32,48 @@
// Once all dependencies are fetched, `zig build` no longer requires
// internet connectivity.
.dependencies = .{
// The danos build API — the shared user-binary recipe every build file
// (root and per-binary packages) consumes (docs/build-packages-plan.md).
.@"build-support" = .{ .path = "build-support" },
// The library domains, each a package exporting its modules.
.kernel = .{ .path = "library/kernel" },
.device = .{ .path = "library/device" },
.client = .{ .path = "library/client" },
.protocol = .{ .path = "library/protocol" },
.csv = .{ .path = "library/csv" },
.@"xkeyboard-config" = .{ .path = "library/xkeyboard-config" },
// Binary packages (phase 2), consumed as artifacts for the boot image.
.@"pci-bus" = .{ .path = "system/drivers/pci-bus" },
.init = .{ .path = "system/services/init" },
.fat = .{ .path = "system/services/fat" },
.display = .{ .path = "system/services/display" },
.@"display-demo" = .{ .path = "system/services/display-demo" },
.@"device-manager" = .{ .path = "system/services/device-manager" },
.input = .{ .path = "system/services/input" },
.logger = .{ .path = "system/services/logger" },
// The discovery pair and the /test fixtures are lazy: only what a
// given build actually ships gets its build file loaded and compiled
// (-Ddiscovery picks one of the pair; -Dtest-case pulls the fixtures).
.acpi = .{ .path = "system/services/acpi", .lazy = true },
.fdt = .{ .path = "system/services/fdt", .lazy = true },
.@"ps2-bus" = .{ .path = "system/drivers/ps2-bus" },
.@"usb-xhci-bus" = .{ .path = "system/drivers/usb-xhci-bus" },
.@"usb-hid" = .{ .path = "system/drivers/usb-hid" },
.@"usb-storage" = .{ .path = "system/drivers/usb-storage" },
.@"virtio-gpu" = .{ .path = "system/drivers/virtio-gpu" },
.@"vfs-test" = .{ .path = "test/system/services/vfs-test", .lazy = true },
.@"fat-test" = .{ .path = "test/system/services/fat-test", .lazy = true },
.@"shared-memory-server" = .{ .path = "test/system/services/shared-memory-server", .lazy = true },
.@"shared-memory-client" = .{ .path = "test/system/services/shared-memory-client", .lazy = true },
.@"crash-test" = .{ .path = "test/system/services/crash-test", .lazy = true },
.@"device-list" = .{ .path = "test/system/services/device-list", .lazy = true },
.@"pci-cap-test" = .{ .path = "test/system/services/pci-cap-test", .lazy = true },
.@"iommu-fault-test" = .{ .path = "test/system/services/iommu-fault-test", .lazy = true },
.@"input-source" = .{ .path = "test/system/services/input-source", .lazy = true },
.@"input-test" = .{ .path = "test/system/services/input-test", .lazy = true },
.@"args-echo" = .{ .path = "test/system/services/args-echo", .lazy = true },
.@"process-test" = .{ .path = "test/system/services/process-test", .lazy = true },
.@"thread-test" = .{ .path = "test/system/services/thread-test", .lazy = true },
// See `zig fetch --save <url>` for a command-line interface for adding dependencies.
//.example = .{
// // When updating this field to a new URL, be sure to delete the corresponding
@@ -70,12 +112,5 @@
// Paths are relative to the build root. Use the empty string (`""`) to refer to
// the build root itself.
// A directory listed here means that all files within, recursively, are included.
.paths = .{
"build.zig",
"build.zig.zon",
"src",
// For example...
//"LICENSE",
//"README.md",
},
.paths = .{""},
}
+166
View File
@@ -0,0 +1,166 @@
//! Boot-image assembly (docs/build-packages-plan.md, phase 3): everything
//! between "here are the built binaries" and "here is a bootable volume".
//! The FHS-shaped zig-out install tree, the boot manifest, the boot capsule,
//! the FAT32 USB image (+ its serial-enabled twin for the QEMU run steps),
//! and the release ISO — with their check steps. The root build.zig decides
//! WHAT ships (the bundled list); this file owns HOW it becomes an image.
const std = @import("std");
/// One user binary and its FHS home on the boot volume (and in zig-out).
pub const BundledBinary = struct { path: []const u8, binary: std.Build.LazyPath };
pub const Options = struct {
/// The installed/flashable kernel (serial follows the root -Dserial).
kernel: *std.Build.Step.Compile,
/// The serial-enabled kernel variant the `run-x86-64` image boots.
kernel_serial: *std.Build.Step.Compile,
/// The UEFI loader (BOOTX64).
efi: *std.Build.Step.Compile,
/// Every user binary and data file at its FHS path.
bundled: []const BundledBinary,
};
/// Wire up the install tree, both FAT32 boot images, the release ISO, and the
/// check steps. Returns the serial-enabled FAT image for the QEMU run steps.
pub fn addImageSteps(b: *std.Build, options: Options) std.Build.LazyPath {
// Everything installs into a FHS-shaped zig-out: it IS the danos filesystem *and*
// the boot volume. Each binary lands at its addressed, leaf-collapsed path — the
// kernel at zig-out/system/kernel (from system/kernel/kernel.zig), init at
// zig-out/system/services/init, and so on (see docs/README.md). The bootloader
// then loads these FHS paths off the volume.
const kernel_install = b.addInstallArtifact(options.kernel, .{ .dest_dir = .{ .override = .{ .custom = "system" } } });
b.getInstallStep().dependOn(&kernel_install.step);
// UEFI firmware requires the removable-media loader at exactly \EFI\BOOT\BOOTX64.efi,
// so that path is fixed by the firmware (it is /boot's EFI stub, conceptually).
const efi_install = b.addInstallArtifact(options.efi, .{ .dest_dir = .{ .override = .{ .custom = "EFI/BOOT" } } });
b.getInstallStep().dependOn(&efi_install.step);
// The boot manifest: the FHS path of every bundled binary, one per line. The
// EFI loader reads THIS by name and opens each listed path by name — FAT
// name lookup is case-insensitive and firmware-portable, unlike directory
// ENUMERATION, whose returned names vary by firmware (bare 8.3 entries come
// back uppercase on some FAT drivers). The tree walk remains only as the
// loader's fallback for hand-assembled sticks without a manifest.
var manifest_text: std.ArrayListUnmanaged(u8) = .empty;
for (options.bundled) |item| {
manifest_text.append(b.allocator, '/') catch @panic("OOM");
manifest_text.appendSlice(b.allocator, item.path) catch @panic("OOM");
manifest_text.append(b.allocator, '\n') catch @panic("OOM");
}
const manifest_files = b.addWriteFiles();
const manifest_file = manifest_files.add("manifest", manifest_text.items);
const manifest_install = b.addInstallFileWithDir(manifest_file, .prefix, "system/manifest");
b.getInstallStep().dependOn(&manifest_install.step);
// The boot capsule: the same bundled list packed into ONE file (v2
// initial_ramdisk format), because a single open + sequential read is the
// only firmware file I/O shape that is fast everywhere — a per-file tree
// walk measured MINUTES on real firmware. The loader tries this first,
// then the manifest, then the walk; the running system cannot tell the
// difference (it always receives the same in-RAM table). Derived from the
// tree in the same build graph, so the two cannot drift.
const mk_capsule = b.addSystemCommand(&.{"python3"});
mk_capsule.addFileArg(b.path("tools/pack-system-image.py"));
const capsule_img = mk_capsule.addOutputFileArg("system.img");
for (options.bundled) |item| {
mk_capsule.addArg(item.path);
mk_capsule.addFileArg(item.binary);
}
const capsule_install = b.addInstallFile(capsule_img, "boot/system.img");
b.getInstallStep().dependOn(&capsule_install.step);
// Install every bundled binary to its FHS home, so zig-out is a true image of
// the filesystem — the same tree make-fat-image.py lays out on the boot volume.
for (options.bundled) |item| {
const install = b.addInstallFileWithDir(item.binary, .prefix, item.path);
b.getInstallStep().dependOn(&install.step);
}
// --- danos-usb.img: the bootable FAT32 USB image ---
// Format a real FAT32 image (the in-repo Python builder, no external tools)
// holding the EFI stub, the kernel, and the whole /system tree of user
// binaries at their FHS paths. QEMU presents this image as a USB mass-storage
// device the guest boots from (see run-x86-64 and the test harness), and the
// danos fat driver mounts the same image at /mnt/usb.
const fat_image = addBootImage(b, options.kernel.getEmittedBin(), options.efi.getEmittedBin(), manifest_file, capsule_img, options.bundled);
const fat_image_install = b.addInstallFile(fat_image, "danos-usb.img");
b.getInstallStep().dependOn(&fat_image_install.step);
// The image `run-x86-64` boots: identical to the flashable one but with the
// serial log sink compiled in, so a developer always gets the machine-readable
// log captured to serial0 — without baking serial into the image users flash.
// Built lazily (only when `run-x86-64` is requested), and never installed.
const fat_image_serial = addBootImage(b, options.kernel_serial.getEmittedBin(), options.efi.getEmittedBin(), manifest_file, capsule_img, options.bundled);
// `zig build check-fat-image` — validate the produced image is a real FAT32
// with the EFI stub present (the builder's own --verify, no external tools).
const check_fat = b.addSystemCommand(&.{"python3"});
check_fat.addFileArg(b.path("tools/make-fat-image.py"));
check_fat.addArg("--verify");
check_fat.addFileArg(fat_image);
const check_fat_step = b.step("check-fat-image", "Verify the FAT32 USB image is valid and bootable");
check_fat_step.dependOn(&check_fat.step);
// --- release-x86-64: danos-x86-64.iso, the flashable release image ---
// Wrap the FAT32 boot volume in a hybrid ISO (the in-repo Python builder
// again, no xorriso/isohybrid): an ISO9660 whose El Torito EFI boot entry
// and MBR ESP partition entry both point at the embedded FAT image. One
// file then boots every way release media is consumed — flashed raw to a
// USB stick with Etcher or dd, or burned to optical media — while
// danos-usb.img stays the raw superfloppy QEMU and the test harness boot.
const mk_iso = b.addSystemCommand(&.{"python3"});
mk_iso.addFileArg(b.path("tools/make-iso-image.py"));
const iso_image = mk_iso.addOutputFileArg("danos-x86-64.iso");
mk_iso.addFileArg(fat_image);
const iso_install = b.addInstallFile(iso_image, "danos-x86-64.iso");
const release_step = b.step("release-x86-64", "Build the flashable x86-64 release ISO (zig-out/danos-x86-64.iso; flash with Etcher or dd)");
release_step.dependOn(&iso_install.step);
// `zig build check-iso-image` — the ISO builder's own --verify (mirroring
// check-fat-image): the MBR partition, the El Torito catalog, and the
// embedded FAT32 image must all agree.
const check_iso = b.addSystemCommand(&.{"python3"});
check_iso.addFileArg(b.path("tools/make-iso-image.py"));
check_iso.addArg("--verify");
check_iso.addFileArg(iso_image);
const check_iso_step = b.step("check-iso-image", "Verify the release ISO is a valid hybrid (MBR ESP partition + El Torito EFI entry)");
check_iso_step.dependOn(&check_iso.step);
return fat_image_serial;
}
/// Assemble the bootable FAT32 image (the in-repo Python builder) holding the
/// EFI stub, the kernel, and every user binary at its FHS path — the volume's
/// /system tree IS the system image; the EFI loader walks it at boot and builds
/// the in-RAM initial_ramdisk from it. Factored so the serial-enabled
/// `run-x86-64` variant can bundle its own serial kernel while sharing the
/// loader and user tree (the loader's boot breadcrumbs and init's heartbeat both
/// follow the top-level -Dserial). Returns the image's LazyPath.
fn addBootImage(
b: *std.Build,
kernel_bin: std.Build.LazyPath,
efi_bin: std.Build.LazyPath,
manifest: std.Build.LazyPath,
capsule: std.Build.LazyPath,
bundled: []const BundledBinary,
) std.Build.LazyPath {
const mk_fat = b.addSystemCommand(&.{"python3"});
mk_fat.addFileArg(b.path("tools/make-fat-image.py"));
const fat_image = mk_fat.addOutputFileArg("danos-usb.img");
mk_fat.addArg("64"); // MiB
mk_fat.addArg("EFI/BOOT/BOOTX64.efi");
mk_fat.addFileArg(efi_bin);
mk_fat.addArg("system/kernel");
mk_fat.addFileArg(kernel_bin);
mk_fat.addArg("system/manifest");
mk_fat.addFileArg(manifest);
mk_fat.addArg("boot/system.img");
mk_fat.addFileArg(capsule);
for (bundled) |item| {
mk_fat.addArg(item.path);
mk_fat.addFileArg(item.binary);
}
return fat_image;
}
+176
View File
@@ -0,0 +1,176 @@
//! The QEMU run steps (docs/build-packages-plan.md, phase 3): `run-x86-64`
//! boots the serial-enabled FAT image via UEFI/OVMF; `run-x86-64-gpu` adds a
//! virtio-gpu adapter for the native-present display path. OVMF firmware is
//! probed across distro/OS layouts (-Dovmf-code / -Dovmf-vars override).
const std = @import("std");
/// Wire up the `run-x86-64` and `run-x86-64-gpu` steps around the given
/// serial-enabled boot image (the guest boots that self-contained image
/// attached as USB storage, not the installed FHS zig-out).
pub fn addRunSteps(b: *std.Build, fat_image_serial: std.Build.LazyPath) void {
// Firmware lives in different places per OS/distro, so probe the known
// layouts (Architecture, Debian/Ubuntu, Fedora, macOS Homebrew) and use the first
// that exists. Override with -Dovmf-code / -Dovmf-vars if yours is elsewhere.
const ovmf_code = b.option(
[]const u8,
"ovmf-code",
"Path to the OVMF_CODE firmware image",
) orelse firstExisting(b.graph.io, &.{
"/usr/share/edk2/x64/OVMF_CODE.4m.fd", // Architecture
"/usr/share/OVMF/OVMF_CODE_4M.fd", // Debian/Ubuntu
"/usr/share/OVMF/OVMF_CODE.fd", // older Debian/Ubuntu
"/usr/share/edk2-ovmf/x64/OVMF_CODE.fd", // Fedora
"/opt/homebrew/share/qemu/edk2-x86_64-code.fd", // macOS Homebrew (Apple Silicon)
"/usr/local/share/qemu/edk2-x86_64-code.fd", // macOS Homebrew (Intel)
});
const ovmf_vars = b.option(
[]const u8,
"ovmf-vars",
"Path to the OVMF_VARS firmware image (a writable copy is made)",
) orelse firstExisting(b.graph.io, &.{
"/usr/share/edk2/x64/OVMF_VARS.4m.fd", // Architecture
"/usr/share/OVMF/OVMF_VARS_4M.fd", // Debian/Ubuntu
"/usr/share/OVMF/OVMF_VARS.fd", // older Debian/Ubuntu
"/usr/share/edk2-ovmf/x64/OVMF_VARS.fd", // Fedora
"/opt/homebrew/share/qemu/edk2-i386-vars.fd", // macOS Homebrew (Apple Silicon)
"/usr/local/share/qemu/edk2-i386-vars.fd", // macOS Homebrew (Intel)
});
// The firmware needs to write NVRAM, so give it a writable copy of the vars.
const vars_copy = b.addSystemCommand(&.{ "cp", "-f", ovmf_vars });
const vars_out = vars_copy.addOutputFileArg("OVMF_VARS.4m.fd");
// Capture the guest's serial0 (danos's machine-readable log) to the qemu-test
// scratch area — a dev/host artifact, kept out of the FHS boot volume we mount.
// (/var/log/system is reserved for the kernel's own logging system later.) One
// timestamped file per run.
const log_dir = b.fmt("{s}/qemu-test", .{b.install_path});
const make_log_dir = b.addSystemCommand(&.{ "mkdir", "-p", log_dir });
// --- run-x86-64: boot the x86-64 kernel in QEMU via UEFI/OVMF ---
const run_efi = b.addSystemCommand(&.{
"qemu-system-x86_64",
"-device",
"qemu-xhci,id=xhci",
"-device",
"usb-mouse,bus=xhci.0",
"-device",
"usb-kbd,bus=xhci.0",
"-machine",
"q35",
"-m",
"128M",
"-drive",
b.fmt("if=pflash,format=raw,readonly=on,file={s}", .{ovmf_code}),
});
run_efi.addArg("-drive");
run_efi.addPrefixedFileArg("if=pflash,format=raw,file=", vars_out);
// Boot off the FAT32 USB image: a mass-storage device on the same xHCI bus as
// the keyboard and mouse. OVMF finds \EFI\BOOT\BOOTX64.efi on it and boots.
// The serial-enabled variant, so serial0 carries the log for this dev boot.
run_efi.addArg("-drive");
run_efi.addPrefixedFileArg("if=none,id=bootusb,format=raw,file=", fat_image_serial);
run_efi.addArgs(&.{
"-device",
"usb-storage,bus=xhci.0,drive=bootusb,removable=on,bootindex=0",
"-net",
"none",
// Emulated display advertising 1280x720 as its native (EDID preferred)
// resolution, so the kernel's native-resolution switch has something to
// find. `-vga none` avoids a second, default adapter.
"-vga",
"none",
"-device",
"VGA,edid=on,xres=1280,yres=720",
});
const serial_log = b.fmt("{s}/run-x86-64-serial0-{s}.log", .{ log_dir, timestamp(b) });
run_efi.addArgs(&.{ "-serial", b.fmt("file:{s}", .{serial_log}) });
// We boot the self-contained `fat_image_serial` (added as a file arg above, so
// it's already a dependency) — not the installed FHS zig-out — so `run-x86-64`
// builds only the serial kernel, never the flashable one. Just make the serial
// scratch dir first.
run_efi.step.dependOn(&make_log_dir.step);
const run_efi_step = b.step("run-x86-64", "Boot the x86-64 kernel in QEMU (UEFI/OVMF); serial0 is logged to zig-out/qemu-test/run-x86-64-serial0-<timestamp>.log");
run_efi_step.dependOn(&run_efi.step);
// --- run-x86-64-gpu: the same boot plus a virtio-gpu adapter ---
// The VGA device still supplies the boot (GOP) framebuffer the compositor starts
// on; the virtio-gpu function is discovered by the device-manager stack, its
// driver announces a shared scanout, and the compositor upgrades off the GOP
// floor to fenced, tear-free native presents (docs/display-v2.md).
// This is the interactive twin of the `display-native` test case, and 512M
// matches it (the whole driver stack + the compositor's surfaces at once).
// QEMU shows one head per adapter: pick the virtio-gpu head in the View menu
// to watch the native output.
const run_gpu = b.addSystemCommand(&.{
"qemu-system-x86_64",
"-device",
"qemu-xhci,id=xhci",
"-device",
"usb-mouse,bus=xhci.0",
"-device",
"usb-kbd,bus=xhci.0",
"-machine",
"q35",
"-m",
"512M",
"-drive",
b.fmt("if=pflash,format=raw,readonly=on,file={s}", .{ovmf_code}),
});
run_gpu.addArg("-drive");
run_gpu.addPrefixedFileArg("if=pflash,format=raw,file=", vars_out);
run_gpu.addArg("-drive");
run_gpu.addPrefixedFileArg("if=none,id=bootusb,format=raw,file=", fat_image_serial);
run_gpu.addArgs(&.{
"-device",
"usb-storage,bus=xhci.0,drive=bootusb,removable=on,bootindex=0",
"-net",
"none",
"-vga",
"none",
"-device",
"VGA,edid=on,xres=1280,yres=720",
"-device",
"virtio-gpu-pci",
});
const gpu_serial_log = b.fmt("{s}/run-x86-64-gpu-serial0-{s}.log", .{ log_dir, timestamp(b) });
run_gpu.addArgs(&.{ "-serial", b.fmt("file:{s}", .{gpu_serial_log}) });
run_gpu.step.dependOn(&make_log_dir.step);
const run_gpu_step = b.step("run-x86-64-gpu", "Boot in QEMU with a virtio-gpu adapter: the compositor upgrades to fenced (tear-free) native presents; watch the virtio-gpu head in QEMU's View menu");
run_gpu_step.dependOn(&run_gpu.step);
}
/// Return the first path in `candidates` that exists on the build host, else the
/// first candidate as a fallback so a missing-firmware error still names a
/// concrete (and, by convention, the primary) path. Used to locate OVMF firmware
/// across distro/OS layouts without configuration.
fn firstExisting(io: std.Io, candidates: []const []const u8) []const u8 {
for (candidates) |path| {
std.Io.Dir.accessAbsolute(io, path, .{}) catch continue;
return path;
}
return candidates[0];
}
/// A UTC timestamp like "20260708-153045", for naming a per-run artifact so
/// repeated runs don't clobber each other's logs. Resolved when `zig build`
/// runs, which is moments before QEMU launches.
fn timestamp(b: *std.Build) []const u8 {
const ns = std.Io.Clock.now(.real, b.graph.io).nanoseconds;
const secs: u64 = @intCast(@divFloor(ns, std.time.ns_per_s));
const es = std.time.epoch.EpochSeconds{ .secs = secs };
const yd = es.getEpochDay().calculateYearDay();
const md = yd.calculateMonthDay();
const ds = es.getDaySeconds();
return b.fmt("{d:0>4}{d:0>2}{d:0>2}-{d:0>2}{d:0>2}{d:0>2}", .{
yd.year,
md.month.numeric(),
@as(u32, md.day_index) + 1,
ds.getHoursIntoDay(),
ds.getMinutesIntoHour(),
ds.getSecondsIntoMinute(),
});
}
+170 -81
View File
@@ -3,78 +3,102 @@
Notes on how danos boots and draws, written to explain the *why* behind the code
rather than restate it. Roughly in the order things happen at runtime:
1. **[efi.md](efi.md) — EFI / the boot process.** How UEFI firmware finds and
1. **[efi.md](os-development/efi.md) — EFI / the boot process.** How UEFI firmware finds and
runs the bootloader, what the loader gathers before `ExitBootServices`, how it
loads the kernel ELF, and the ABI contract for the jump into the kernel. Start
here.
2. **[gop.md](gop.md) — the Graphics Output Protocol.** How UEFI exposes graphics
2. **[system-image.md](os-development/system-image.md) — system.img, the boot capsule.** The
bundled user binaries packed into one file in the initial-ramdisk wire
format, because one open + one sequential read is the only file I/O shape
firmware is fast at. The trivial container format, the three artifacts one
build list derives (tree, manifest, capsule), the loader's three-strategy
fallback chain, and the capsule's kernel-side life as both the spawn table
and the read-only `/system` mount.
3. **[gop.md](os-development/gop.md) — the Graphics Output Protocol.** How UEFI exposes graphics
modes (unlike fixed VGA modes), how we detect the monitor's native resolution
from EDID and switch to it, and the pixel formats we accept or reject.
3. **[framebuffer.md](framebuffer.md) — the framebuffer.** What the linear
4. **[framebuffer.md](os-development/framebuffer.md) — the framebuffer.** What the linear
framebuffer the loader hands over actually is, and what **pitch** (stride)
means versus width — the detail you have to get right to avoid a skewed image.
4. **[memory-map.md](memory-map.md) — the memory map.** How the loader learns what
5. **[memory-map.md](os-development/memory-map.md) — the memory map.** How the loader learns what
physical RAM exists and hands it to the kernel in danos's own neutral format,
rather than leaking UEFI's memory descriptors across the boundary.
5. **[frame-allocator.md](frame-allocator.md) — the physical frame allocator.** The
6. **[frame-allocator.md](os-development/frame-allocator.md) — the physical frame allocator.** The
bitmap allocator that hands out and reclaims 4 KiB physical frames from that
map — the primitive page tables and the heap are built on.
6. **[interrupts.md](interrupts.md) — interrupts and exceptions.** The GDT, IDT and
7. **[interrupts.md](os-development/interrupts.md) — interrupts and exceptions.** The GDT, IDT and
TSS, the exception stubs, and the handler that reports a CPU fault in red instead
of letting it triple-fault into a silent reset.
7. **[paging.md](paging.md) — the kernel's page tables.** Building our own 4-level
8. **[paging.md](os-development/paging.md) — the kernel's page tables.** Building our own 4-level
page tables, identity-mapping the low 4 GiB, and switching CR3 off the firmware's
tables onto ours.
8. **[device-interrupts.md](device-interrupts.md) — device interrupts.** The Local
9. **[device-interrupts.md](device-driver-development/device-interrupts.md) — device interrupts.** The Local
APIC and its timer — the kernel's first interrupt that is *handled and returned
from*, giving it a heartbeat.
9. **[heap.md](heap.md) — the kernel heap.** A growable free-list allocator built on
10. **[heap.md](os-development/heap.md) — the kernel heap.** A growable free-list allocator built on
the VMM, exposed as a `std.mem.Allocator` so std containers work — dynamic
allocation for the kernel.
10. **[scheduling.md](scheduling.md) — the scheduler.** Fixed-priority preemptive
11. **[scheduling.md](os-development/scheduling.md) — the scheduler.** Fixed-priority preemptive
multitasking: kernel threads, the context switch, O(1) priority selection, and
blocking (sleep, wait queues) — the leap to a running system.
11. **[ipc.md](ipc.md) — inter-process communication.** Bounded blocking
12. **[ipc.md](device-driver-development/ipc.md) — inter-process communication.** Bounded blocking
message-passing channels, then synchronous call/reply between *processes* over
endpoints — the backbone the microkernel's isolated servers talk over.
12. **[syscall.md](syscall.md) — system calls.** How ring 3 asks the kernel for
13. **[syscall.md](os-development/syscall.md) — system calls.** How ring 3 asks the kernel for
something: the `syscall`/`sysret` fast path, the trap frame, and why the table is
deliberately tiny.
13. **[drivers.md](drivers.md) — writing a driver.** The payoff: a driver is an
deliberately tiny. The numbers are a **private** ABI — [vdso.md](os-development/vdso.md) designs
the public boundary that will hide them.
14. **[vfs-protocol.md](file-system-development/vfs-protocol.md) — the VFS wire protocol.** The language-neutral
byte-level spec of the file protocol spoken over IPC: request/reply headers,
the operation table, mount routing, and the append-only evolution rules — the
first IPC protocol documented as public ABI.
15. **[drivers.md](device-driver-development/drivers.md) — writing a driver.** The payoff: a driver is an
ordinary ring-3 process that claims a device, maps its registers, and **sleeps
until its hardware interrupts it**. The claim is the capability; `irq_ack` is the
unmask.
14. **[driver-model.md](driver-model.md) — buses, classes and host controllers.** How
16. **[driver-model.md](device-driver-development/driver-model.md) — buses, classes and host controllers.** How
real driver stacks factor into three shapes and how families share code. The
three primitives it proposed are long since built (M13 capability passing,
M14 DMA + barriers, M15 MSI), and the driver *contract* on top of them —
hello, supervision, restart — is built too (device-manager.md, M18).
15. **[process-management.md](process-management.md) — process management.** The
17. **[usb-hub.md](device-driver-development/usb-hub.md) — USB hubs.** Built (M22): why hub topology is handled
*inside* the `usb-xhci-bus` driver rather than a separate hub class driver — a
device behind a hub is reached by the **controller**, programmed with a route
string in its slot context — plus the compound-hub reality (a USB 3.0 hub is
physically two hubs) and detection via the hub's status-change interrupt endpoint.
18. **[process-management.md](os-development/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.
16. **[process-lifecycle.md](process-lifecycle.md) — the process lifecycle.** Built
19. **[process-lifecycle.md](os-development/process-lifecycle.md) — the process lifecycle.** Built
(M17): signals over IPC as the one lifecycle vocabulary every process speaks — the
POSIX.1-1990 words with message delivery instead of stack hijack, the stable
`runtime.process` interface, exit reasons, published exit events any stateful
`process` module interface, exit reasons, published exit events any stateful
service can subscribe to (the VFS releasing dead clients' handles), and the two
iron rules (cleanup is the kernel's job; kill is not a signal).
17. **[device-manager.md](device-manager.md) — the device manager.** Built (M18,
20. **[device-manager.md](device-driver-development/device-manager.md) — the device manager.** Built (M18,
through the app surface): the
tree, the matcher, and the supervisor. Tree structure lives in the manager,
authority stays in the kernel; bus drivers report what they see; drivers are
restarted through the lifecycle vocabulary — the plan that turns
[resilience.md](resilience.md)'s restart goal into increments.
18. **[input.md](input.md) — the input module.** Broadcasting input events (keyboard,
[resilience.md](os-development/resilience.md)'s restart goal into increments.
21. **[input.md](device-driver-development/input.md) — the input module.** Broadcasting input events (keyboard,
mouse, joystick): why a synchronous rendezvous can't fan out to many listeners, the
asynchronous `ipc_send` primitive built to fix it, and the per-device subscribe/publish
service layered on top.
19. **[display.md](display.md) — the display service.** The display half of the GUI
22. **[display.md](device-driver-development/display.md) — the display service.** The display half of the GUI
track: a user-space compositor that owns the framebuffer, composes a layer stack into
a double buffer, and presents it. Why GOP and the PCI display device are two views of
one controller, the device-node + write-combining handoff, and what flicker-free buys
that tear-free doesn't. Plan: [display-plan.md](display-plan.md).
20. **[halting.md](halting.md) — halting.** Why a kernel can't just "exit", and
that tear-free doesn't. Plan: [display-plan.md](device-driver-development/display-plan.md). **v2** (complete) makes
scanout a pluggable backend — GOP floor + a native virtio-gpu driver, hot-attached, with
runtime mode-set, EDID, fenced vsync presents, and restart re-attach:
[display-v2.md](device-driver-development/display-v2.md), plan [display-v2-plan.md](device-driver-development/display-v2-plan.md). Looking
further out, three research snapshots survey what a *native* driver for real GPU silicon
would take as another `.scanout` backend: [nvidia-gpus.md](device-driver-development/nvidia-gpus.md) (RTX 3060 /
Ampere), [amd-gpus.md](device-driver-development/amd-gpus.md) (RX 6600 / RDNA2), and [intel-igpu.md](device-driver-development/intel-igpu.md)
(Intel iGPU).
23. **[halting.md](os-development/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:
@@ -84,15 +108,28 @@ Start with the north star:
**resilience** (restartable components). Win condition: runs on the author's PC and
both Raspberry Pis, ideally with a GUI. Real-time is an option to explore, not a
requirement. The *why* that shapes everything below.
- **[resilience.md](resilience.md) — resilience.** A design note (not built yet) on
- **[resilience.md](os-development/resilience.md) — resilience.** A design note (not built yet) on
fault isolation + live restart — the reincarnation-server + capability model that
makes "if I break it, I can restart it" real. danos's core motivation.
- **[zig-self-hosting.md](zig-self-hosting.md) — running Zig on danos.** A design note
(not built yet) on making danos a real Zig target (`-target x86_64-danos`) and
eventually running the compiler on it. The key realisation: Zig 0.16 reduces an OS
port to **one seam** (`std.os.danos`), so we build `runtime.os` (→ that seam) plus a
thin `runtime.fs`, retire the `posix` shim, and follow a phased path to
port to **one seam** (`std.os.danos`), so we build an `os` seam module (→ that seam) plus
the thin `file-system` module, retire the `posix` shim, and follow a phased path to
`zig build-exe hello.zig` running on danos — **not** Linux-ABI emulation.
- **[threading.md](os-development/threading.md) — threads, the std-shaped way.** **Built** (M1–M6):
the `thread` module's `Thread` mirrors `std.Thread`'s API (spawn/join/detach, Mutex/Condition/
Semaphore) over a **private** thread ABI — several tasks sharing one address space via
a `thread_spawn` syscall, futex-backed blocking, address-space refcounting. Why it's the
native type and not literal `std.Thread` (the [private ABI](os-development/syscall.md)), and why
threads stay a narrow opt-in against the [resilience](os-development/resilience.md) default. Build
plan + gates: [threading-plan.md](os-development/threading-plan.md).
- **[vdso.md](os-development/vdso.md) — the vDSO, the public system-call boundary.** A design note
(not built yet) on keeping `abi.zig` genuinely private: a kernel-supplied, C-ABI
entry blob mapped into every process as the *only* way into the kernel — so the
syscall numbers can be renumbered or randomised at will, and Rust/C binaries get a
stable boundary without danos growing a dynamic linker. danos's public ABI = the
vDSO + the documented IPC wire protocols ([vfs-protocol.md](file-system-development/vfs-protocol.md) first).
Cutting across all of these:
@@ -100,64 +137,69 @@ Cutting across all of these:
hardware needed to run danos: minimum specs (UEFI x86-64, ACPI, PCIe ECAM,
xHCI, ~128 MiB RAM) grounded in what the boot path actually assumes, plus a
plain-language guide matching Intel/AMD CPU generations by name.
- **[arch.md](arch.md) — the architecture split.** How CPU-specific code is kept
- **[release-iso.md](os-development/release-iso.md) — the release ISO.** The flashable boot
media: `zig build release-x86-64` wraps the FAT32 boot volume in a hybrid ISO
(MBR ESP partition + El Torito EFI entry, one embedded image) that Etcher/dd
flash to USB or a burner writes to disc — built by an in-repo pure-Python
tool, like the FAT image itself.
- **[architecture.md](os-development/architecture.md) — the architecture split.** How CPU-specific code is kept
behind a build-time `arch` module so the generic kernel never names x86_64,
leaving room for other systems (e.g. an AArch64 Raspberry Pi) later.
- **[arm.md](arm.md) — ARM targets.** The Raspberry Pi landscape the arch split is
- **[arm.md](os-development/arm.md) — ARM targets.** The Raspberry Pi landscape the arch split is
aiming at: `arm` (32-bit, Pi Zero W) vs `aarch64` (64-bit, Pi 3-5), UEFI vs
device-tree boot, and what each layer needs.
- **[discovery.md](discovery.md) — device discovery.** A design note on learning what
- **[discovery.md](os-development/discovery.md) — device discovery.** A design note on learning what
hardware exists via ACPI (x86) or device tree (ARM) behind one neutral device model —
when to build it, and how to keep it architecture-agnostic.
- **[acpi.md](acpi.md) — finding the ACPI tables.** The concrete x86 locator chain:
how the loader captures the **RSDP**, hands its physical address across in `BootInfo`,
- **[acpi.md](os-development/acpi.md) — finding the ACPI tables.** The concrete x86 locator chain:
how the loader captures the **RSDP**, hands its physical address across in `BootInformation`,
and how the platform derives the **RSDT/XSDT** from it and walks the SDTs — plus the
live event side (the SCI, the power button, GPE/Notify) the ring-3 acpi service runs.
- **[power.md](power.md) — the power service.** System power as a domain-named
- **[power.md](os-development/power.md) — the power service.** System power as a domain-named
service: button/lid/battery events published to subscribers, and init's orderly
shutdown composing the [lifecycle](process-lifecycle.md) stop sequence with an ACPI
shutdown composing the [lifecycle](os-development/process-lifecycle.md) stop sequence with an ACPI
S5 write. Firmware-neutral — a PSCI backend drops in on ARM.
- **[timers.md](timers.md) — timers and time.** The ring-3 surface for reading the
- **[timers.md](os-development/timers.md) — timers and time.** The ring-3 surface for reading the
clock and waiting: why `now()` is a syscall rather than a service, and the one-shot
timer notification (`timer_bind`) that gives supervisors a timed wait — built on the
LAPIC heartbeat and calibrated TSC of [device-interrupts.md](device-interrupts.md).
- **[smp.md](smp.md) — multiple cores.** A design/research note on how microkernels
LAPIC heartbeat and calibrated TSC of [device-interrupts.md](device-driver-development/device-interrupts.md).
- **[smp.md](os-development/smp.md) — multiple cores.** A design/research note on how microkernels
(L4, seL4) handle SMP — big kernel lock vs per-CPU vs multikernel — and how the
right choice depends on whether danos is chasing real-time or resilience.
- **[coding-standards.md](coding-standards.md) — coding standards.** The naming rule the
tree follows: non-acronyms are spelled out in full (`message`, not `msg`), files are
`kebab-case`, code follows Zig's case conventions, and the handful of exceptions
(POSIX/C ABI names, `init`/`len`/`ptr`, acronyms).
- **[sysv.md](sysv.md) — the calling convention.** What "the kernel is SysV" means,
- **[sysv.md](os-development/sysv.md) — the calling convention.** What "the kernel is SysV" means,
and why the loader→kernel boundary has to pin it (the RDI-vs-RCX handoff).
- **[testing.md](testing.md) — testing.** How the kernel is tested by booting it in
QEMU and asserting on its serial output — reproducibly, and structured so the
same tests run across architectures.
- **[logging.md](logging.md) — logging.** The multi-sink diagnostic log (serial,
- **[logging.md](os-development/logging.md) — logging.** The multi-sink diagnostic log (serial,
0xE9 debugcon, file later) kept separate from the framebuffer display, plus the
robustness path: optional framebuffer, POST-code checkpoints, and a persistent
panic breadcrumb so the kernel survives — and can be diagnosed — with no output.
## How the pieces relate
The boot flow ties them together: UEFI runs the loader ([efi.md](efi.md)), which
queries the **GOP** to pick a graphics mode ([gop.md](gop.md)), hands the kernel a
**framebuffer** to draw into ([framebuffer.md](framebuffer.md)) and a **memory
map** of physical RAM ([memory-map.md](memory-map.md)); the kernel turns that map
into a **frame allocator** ([frame-allocator.md](frame-allocator.md)), installs
its **descriptor tables** so CPU faults are caught ([interrupts.md](interrupts.md)),
builds its own **page tables** and switches onto them ([paging.md](paging.md)),
brings up the **heap** for dynamic allocation ([heap.md](heap.md)), starts the
**scheduler** ([scheduling.md](scheduling.md)) and the **timer** that preempts it
([device-interrupts.md](device-interrupts.md)) — with tasks blocking, sleeping and
passing messages over **[IPC](ipc.md)** channels — runs, its CPU-specific bits
behind the [arch](arch.md) boundary, and when idle, or on a panic, it **halts**
([halting.md](halting.md)).
The boot flow ties them together: UEFI runs the loader ([efi.md](os-development/efi.md)), which
queries the **GOP** to pick a graphics mode ([gop.md](os-development/gop.md)), hands the kernel a
**framebuffer** to draw into ([framebuffer.md](os-development/framebuffer.md)) and a **memory
map** of physical RAM ([memory-map.md](os-development/memory-map.md)); the kernel turns that map
into a **frame allocator** ([frame-allocator.md](os-development/frame-allocator.md)), installs
its **descriptor tables** so CPU faults are caught ([interrupts.md](os-development/interrupts.md)),
builds its own **page tables** and switches onto them ([paging.md](os-development/paging.md)),
brings up the **heap** for dynamic allocation ([heap.md](os-development/heap.md)), starts the
**scheduler** ([scheduling.md](os-development/scheduling.md)) and the **timer** that preempts it
([device-interrupts.md](device-driver-development/device-interrupts.md)) — with tasks blocking, sleeping and
passing messages over **[IPC](device-driver-development/ipc.md)** channels — runs, its CPU-specific bits
behind the [architecture](os-development/architecture.md) boundary, and when idle, or on a panic, it **halts**
([halting.md](os-development/halting.md)).
Above that line the microkernel proper begins: **discovery** ([discovery.md](discovery.md),
[acpi.md](acpi.md)) learns what hardware exists, ring-3 processes ask the kernel for
things through the small **[syscall](syscall.md)** table, isolated servers reach each
other over IPC **endpoints** ([ipc.md](ipc.md)), and a **[driver](drivers.md)** claims
Above that line the microkernel proper begins: **discovery** ([discovery.md](os-development/discovery.md),
[acpi.md](os-development/acpi.md)) learns what hardware exists, ring-3 processes ask the kernel for
things through the small **[syscall](os-development/syscall.md)** table, isolated servers reach each
other over IPC **endpoints** ([ipc.md](device-driver-development/ipc.md)), and a **[driver](device-driver-development/drivers.md)** claims
a device, maps its registers, and sleeps until the hardware interrupts it — which is
the whole reason for the arrangement ([vision.md](vision.md)).
@@ -166,7 +208,7 @@ the whole reason for the arrangement ([vision.md](vision.md)).
danos is a **monorepo of sub-projects**. Each service or driver is a directory that is
its own Zig module — it can hold as many files as it needs, and other sub-projects
reach it *by module name*, never by a path into its files. The source tree deliberately
**mirrors the runtime FHS** ([danos-file-system-hierarchy-FSH.md](danos-file-system-hierarchy-FSH.md)):
**mirrors the runtime FHS** ([danos-file-system-hierarchy-FSH.md](file-system-development/danos-file-system-hierarchy-FSH.md)):
what you see under `system/` in the source is what a running danos represents under
`/system`.
@@ -178,10 +220,11 @@ addressed as **`system/services/init`** — the repeated leaf resolves away:
|----------------------------------------|--------------------------------------------|
| `system/services/init/init.zig` | `system/services/init` → `/system/services/init` |
| `system/drivers/ps2-bus/ps2-bus.zig` | `system/drivers/ps2-bus` → `/system/drivers/ps2-bus` |
| `library/runtime/runtime.zig` | `library/runtime` (the `runtime` module) |
| `test/system/services/vfs-test/vfs-test.zig` | `test/system/services/vfs-test` → `/test/system/services/vfs-test` |
| `library/device/pci/pci.zig` | `library/device/pci` (the `pci` 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
@@ -192,28 +235,69 @@ 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
abi.zig the private kernel↔userspace syscall ABI (the `abi` module)
parameters.zig initial-ramdisk.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)
devices-broker.zig the syscall-facing device table
platform.zig acpi.zig fdt.zig device-model.zig firmware discovery + the kernel's
device model — the implementation of what /system/devices reflects
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
kernel/ the danos-native system library (kernel32-style): the syscall
surface split by concern — ipc, memory (heap/dma/shared-memory),
process, time, logging, file-system, thread, service, plus the
system-call stubs and the start/root entry shim
device/ device code by domain — mmio/ model/ pci/ usb/ acpi/ driver/
block/ — each a shareable data module (device-abi, pci-class,
usb-abi/ids, acpi-ids) plus a logic module (mmio, pci, usb, aml,
driver — the device-access + device-manager-hello client)
client/ userspace service clients (display, input) — a program's view of
a service, layered over that service's protocol
protocol/ driver↔service wire contracts (vfs block display scanout input
power device-manager usb-transfer), one module per directory
boot/ → /boot the loaders
tools/ test/ host-side build + QEMU test harness
test/ → /test the test tree: the QEMU harness (qemu_test.py, host-side)
system/services/ beside the on-image test fixtures — vfs-test/ thread-test/
crash-test/ … — whose repo path IS their boot-volume path
(/test/system/services/<name>)
build-support/ the danos build API (build-time only, nothing on the image):
the shared user-binary recipe + default-import wiring every
build file consumes (docs/build-packages-plan.md)
build/ root-build helpers: image assembly (images.zig) + the QEMU
run steps (qemu.zig)
tools/ host-side build scripts
```
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.
**Builds are packages** (docs/build-packages-plan.md): each `library/` domain owns a
`build.zig`/`build.zig.zon` exporting its modules (with a standalone `zig build test`),
every binary directory is a ~15-line package build, and the root `build.zig`
orchestrates — the kernel + loader, what ships, and the aggregate test step — with
image assembly in `build/images.zig` and the QEMU run steps in `build/qemu.zig`.
**Wire protocols live in `library/protocol/`**, one module per directory
(`library/protocol/vfs/vfs-protocol.zig` is the `vfs-protocol` module), imported by module
name. A protocol is the seam between a low-level driver and the higher-level service it
serves — block ↔ the filesystem, a scanout driver ↔ the compositor — so both sides depend
on the contract, not on each other, and the contract belongs to neither sub-project. A
client module may *wrap* one for application convenience (the `file-system` module over
`vfs-protocol`, and the `block`, `display`, `input` clients over theirs), but the protocol
module is the boundary — a client re-exports no protocol, it imports it by name. A driver's
private wire to its *hardware* (virtio-gpu's command set) is not a service seam and stays a
driver-private file, beside the transport that reaches the same device.
**Device code lives in `library/device/<domain>/`**, grouped by what it is about (pci, usb,
acpi, and the cross-cutting device model) and split by dependency weight: a data module of
enums and wire types that is `std`-only and cheap for anyone to import, and a logic module
that needs `mmio` or IPC. This is what keeps the microkernel out of device business — it
imports exactly one `library/` module, `device-abi` (the descriptor types its broker
marshals across the syscall boundary), and nothing with logic or a taxonomy in it. That
lone pure-data import is the only edge from `system/kernel/` into `library/`.
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
danos-native `file-system` module (open/read/write/list over the VFS). A hand-rolled POSIX shim
(`library/posix/`) was retired as premature — the real POSIX/C surface will come later
from the `std.os.danos` seam (and, eventually, musl) when danos becomes a Zig target (see
[zig-self-hosting.md](zig-self-hosting.md)). When it does, the foreign-ABI naming
@@ -225,9 +309,9 @@ 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` |
| 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` |
| Loader↔kernel handoff (`BootInformation`, `Framebuffer`, `MemoryMap`, VM layout) | `system/boot-handoff.zig` |
| Private kernel↔userspace syscall ABI (`SystemCall`, mmap prot flags, `page_size`) — the system library speaks it, not apps | `system/abi.zig` |
| Device wire types (`DeviceDescriptor`, `DeviceClass`, …) | `library/device/model/device-abi.zig` |
| Physical frame allocator | `system/kernel/pmm.zig` |
| Kernel heap (`std.mem.Allocator`) | `system/kernel/heap.zig` |
| Scheduler (fixed-priority preemptive; blocking, wait queues) | `system/kernel/scheduler.zig` |
@@ -235,14 +319,19 @@ 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 `library/protocol/vfs/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/` |
| Hardware discovery (ACPI/device tree) behind one neutral device model | `system/kernel/platform.zig` |
| Framebuffer text console (mirrors to serial) | `system/kernel/console.zig` |
| 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/` |
| danos-native system library (kernel32-style): the syscall surface by concern — `ipc`, `memory`, `process`, `time`, `logging`, `file-system`, `thread`, `service` — the stable application ABI | `library/kernel/` |
| Service clients (a program's view of a service) and device clients | `library/client/` (display, input), `library/device/driver` |
| 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) | `build.zig` |
| On-image test fixtures for the QEMU cases (`vfs-test`, `crash-test`, `thread-test`, …) → `/test/system/services` | `test/system/services/` |
| Build orchestration (kernel + loader, what ships, the aggregate test step) | `build.zig` (root; the shared user-binary recipe is `build-support/`, and each `library/` domain + binary package carries its own `build.zig`) |
| Image assembly + `release-x86-64` (the flashable ISO) | `build/images.zig` |
| `run-x86-64` / `run-x86-64-gpu` (QEMU/OVMF) | `build/qemu.zig` |
| QEMU integration test harness | `test/qemu_test.py` |
-105
View File
@@ -1,105 +0,0 @@
# Architecture split
danos targets x86_64 today, but is meant to grow onto other systems later — a
Raspberry Pi, say, which is AArch64 and has no UEFI. To keep that possible without
a rewrite, CPU-specific kernel code lives behind a boundary: the generic kernel
never names an architecture, and each architecture plugs in behind it.
## The seam is a build-time module named `arch`
The mechanism is deliberately boring — no vtables, no function-pointer tables, no
runtime dispatch. `build.zig` exposes one architecture's code as a module called
`arch`:
```zig
const arch_mod = b.addModule("arch", .{
.root_source_file = b.path("system/kernel/architecture/x86_64/cpu.zig"),
});
```
and the generic kernel imports it by that name:
```zig
const arch = @import("arch");
// ...
arch.halt(); // never says "x86_64"
```
Adding a second architecture is then a build-time choice: create
`system/kernel/arch/aarch64/`, and point the `arch` module at it when the target CPU is
AArch64. `main.zig` and `console.zig` don't change. **That compiler-checked module
boundary _is_ the architecture interface** — when a new arch is missing a function
the generic kernel calls, the build fails and names exactly what's missing.
## What's arch-specific vs generic
The split follows a simple test: does it name a CPU instruction, a hardware
register, or a memory-management structure? If so, it's arch-specific.
| Arch-specific — `system/kernel/architecture/x86_64/` | Generic — kernel core |
|---|---|
| `cpu.zig`: `halt()` (`hlt`), later GDT/IDT/paging | `console.zig` — pure pixel math, works anywhere |
| `linker.ld` — link layout, load address | `main.zig` — `kmain` orchestration, panic handler |
| (future) interrupt controller, MMU setup | `root.zig` — the neutral handoff contract |
Notice the framebuffer console is *generic*: it just writes pixels into whatever
framebuffer it's handed, so it needs no per-arch version. Most of the kernel
should end up on the generic side; the arch module stays small.
## Two axes, kept separate
There are really two independent questions, and it's worth not conflating them:
- **CPU architecture** (x86_64 vs AArch64): instructions, MMU, interrupts →
`system/kernel/arch/<cpu>/`.
- **Boot protocol** (UEFI vs Raspberry Pi firmware + device tree): handled
*separately*, because loaders are their own binaries. `boot/efi.zig` builds
`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 arch code.
Either path converges on the same neutral [`BootInfo`](memory-map.md).
## Current x86_64 contents
- **`system/kernel/architecture/x86_64/cpu.zig`** — the `arch` module root. Exposes `halt()` (see
[halting.md](halting.md)), `init()` (bring up the descriptor tables),
`enablePaging()`, `setFaultHandler`, `readCr2`/`readCr3`, and the `CpuState`
trap frame.
- **`system/kernel/architecture/x86_64/gdt.zig`** / **`idt.zig`** / **`tss.zig`** — the GDT, IDT and
TSS plus CPU-exception handling (see [interrupts.md](interrupts.md)).
- **`system/kernel/architecture/x86_64/paging.zig`** — the kernel's page tables (see
[paging.md](paging.md)).
- **`system/kernel/architecture/x86_64/apic.zig`** — the Local APIC and its timer, the source of
device interrupts (see [device-interrupts.md](device-interrupts.md)).
- **`system/kernel/architecture/x86_64/serial.zig`** / **`io.zig`** — the COM1 UART (the kernel's
machine-readable log channel, see [testing.md](testing.md)) and the shared
port-I/O + MSR primitives.
- **`system/kernel/architecture/x86_64/isr.s`** — the exception stubs, the `lgdt`/`lidt`/`ltr` load
helpers, and the context switch (`switch_context` / `task_trampoline`, see
[scheduling.md](scheduling.md)) — real assembly, since Zig inline asm can't
express them.
- **`system/kernel/architecture/x86_64/linker.ld`** — the kernel link layout (fixed low load
address, one PT_LOAD per permission set).
The kernel entry point `_start` currently still lives in the generic `main.zig` as
a thin trampoline into `kmain`. It's arch-adjacent (its calling convention is
x86_64 [SysV](sysv.md), via the shared `system.kernel_abi`), but it's three lines
and mostly generic, so it stays put for now. When AArch64 arrives — where entry means setting
up a stack and reading a device-tree pointer from a register — the entry work will
be substantial and per-arch, and *that* is when we extract an entry interface into
the arch modules.
## The discipline
The thing that makes this help rather than hurt: **only extract what's provably
architecture-specific, and let the interface emerge with the second
implementation.** With a single architecture you're guessing at the seam, and a
wrong guess encoded as elaborate abstraction is expensive to undo. So:
- Move code into `arch/` only when it genuinely names CPU-specific machinery.
- Grow the `arch` surface one function at a time, as steps need it.
- Don't pre-design the interrupt or paging interfaces before writing them.
Directory hygiene is cheap and reversible; premature abstraction is neither. When
arch #2 lands and something doesn't fit, reshaping a few hundred lines is nothing —
unwinding an abstraction empire is not.
+179
View File
@@ -0,0 +1,179 @@
# Plan: packages — hierarchical builds for libraries and binaries
**Status: complete** (branch `claude/build-packages-plan-174144`). Phase 0
(`build-support`), phase 1 (all six library domains), phase 2 (every binary —
the pci-bus pilot first, then services, drivers, and test fixtures in waves;
multi-binary directories like ps2-bus and usb-hid are one package exporting
several artifacts, and the acpi/fdt discovery pair each export an artifact
named "discovery" that the root's -Ddiscovery picks between), and phase 3 (the
root split into `build/images.zig` + `build/qemu.zig`; the root `build.zig` is
~460 lines of orchestration, down from ~1,250). Every phase landed green: unit
tests, the QEMU suite at parity with main, boot-image file list unchanged.
The `lazyDependency` payoff (What-this-buys #4) is in too: the /test fixtures
and the unselected discovery package are lazy — a build loads and compiles
only what it ships. And imports are exact: the pre-wired default set is gone;
every binary names precisely the modules its source imports and carries only
those domains in its manifest (rule 1 below).
## Why
`build.zig` was ~1,250 lines, growing by three hand-written stanzas per binary;
at a driver per device family that does not scale. More fundamentally: in one
monolithic build every binary compiles against library *source*, so a library
interface break is silently absorbed by whoever edits everything in one commit —
the interface never has to be honest. danos is about isolation; the build should
mirror it.
A **package** here is a build-time unit only — a directory owning a `build.zig`
(recipe: what it exports, how to test it) and a `build.zig.zon` (manifest: name
+ dependencies). Binaries remain fully static freestanding ELFs; packages change
who declares what, not what links to what. Source code is untouched: `@import`
uses module names (`"pci"`, `"service"`) exactly as today — only build files
know where anything lives.
## Target shape
```
build-support/ package: the danos build API (userBinary(), defaultImports(), targets)
library/kernel/ package "kernel": modules abi, ipc, service, memory, process, logging, time, ... (depends on protocol)
library/device/ package "device": modules driver, pci, usb-abi, model, ... (depends on kernel, protocol, csv)
library/protocol/ package "protocol": the wire protocols
library/client/ package "client" (depends on kernel, protocol)
library/csv/ package "csv"
library/xkeyboard-config/ package "xkeyboard-config"
system/services/<name>/ one package per binary: ~15-line build.zig + zon
system/drivers/<name>/ one package per binary
build.zig (root) orchestrator: dependency() per binary, image assembly, QEMU, test steps
```
The three shared contracts: `boot-handoff` stays a root module (only the
loader↔kernel pair speaks it); `abi` is exported by the kernel package from
`../../system/abi.zig` (the source stays with the kernel; userspace's one view
of it lives in the package, so every consumer names the same module instance);
`device-abi` is exported by device. Reaching outside the package root means the
kernel package is valid only as an in-repo path dependency — it could never be
fetched by hash — which is fine: path dependencies are the only way any of
these packages is consumed.
Rules:
- **Imports are exact and per binary.** A binary's build.zig names precisely
the modules its source `@import`s — the moral equivalent of a C file's
include list — and its zon names only the domains those modules come from
(plus `build-support` and `kernel`, which is implicit in every binary: the
root shim and user link script live there). Nothing is pre-wired: an
undeclared `@import` is a compile error, and build-support resolves each
name by searching the packages the zon declares — the domains' own
addModule exports are the single statement of who owns what, with no name
table anywhere to drift. Availability
never meant bloat — Zig only compiles what a program actually imports — but
exactness makes the declared interface honest and machine-checked.
- **Modules export source, not artifacts** — each consumer compiles libraries
with its own flags, so per-binary optimization choices keep working; Zig's
cache deduplicates.
- **Zon paths are relative and that is accepted.** Binaries sit exactly three
levels deep, so the `../../../` prefix is a constant idiom; a library-domain
move is a rare, already-breaking event fixed by one sed across manifests, and
a stale path fails loudly before anything compiles.
- **Cross-cutting build changes live in `build-support` only** — that is the
contract that keeps per-binary build files declarative.
## What this buys
1. Library interfaces become machine-checked: a consumer can only import what
it declared — per binary, down to the single module — and each domain's zon
declares what it needs (claim-before-touch, applied to source). A keyboard
driver carries `xkeyboard-config` in its manifest; nothing else does.
2. Each library domain gets a standalone `zig build test` — runtime-library
stability testing in isolation.
3. Adding a binary = adding a directory (source + two small files), not editing
three places in a 1,250-line file.
4. `lazyDependency` lets an image target build only what it ships: the /test
fixtures resolve only under -Dtest-case, and only the -Ddiscovery-selected
discovery package ever loads.
## Phases
Each phase ends green: `zig build test` passes (88/88 QEMU) and the boot
image's file list is unchanged. Byte-identical binaries are expected but not
required (module reorganization can perturb symbol order); file list is the
hard gate.
**Phase 0 — `build-support`.** Extract `addUserBinary`/`addThreadedUserBinary`,
the freestanding target setup, and the default-import wiring into the
`build-support` package. Root build consumes it; nothing else moves. This is
the cross-cutting-change home, so it lands first.
**Phase 1 — library domains become packages.** In dependency order: `protocol`
and `csv` (the roots) → `kernel` (depends on protocol: file-system speaks
vfs-protocol) → `device`, `client`; `xkeyboard-config` stands alone. Each gets
build.zig + zon + a standalone test step (client's is empty until its modules
grow host tests — kept for uniformity, since the root aggregate depends on
every domain's test step). The root build swaps its `createModule` calls for
`b.dependency("<domain>").module("<name>")`. **No binary moves in this phase**
— the root build is the pilot consumer, which proves the packages without
touching 30 binaries.
**Phase 2 — binaries become packages, in waves.** The template was shaken out
by the pci-bus pilot (see Status). Wave A: services (done). Wave B: the
remaining drivers (done). Wave C: test fixtures (done). Root build shrank to
orchestration per wave. init's `-Dserial` heartbeat flag rides a dependency
option; a directory with several binaries (ps2-bus, usb-hid) is one package
exporting several artifacts.
**Phase 3 — root cleanup (done).** What remained of the root build split into
`build/images.zig` (the FHS install tree, boot manifest + capsule, FAT32
images, release ISO, check steps) and `build/qemu.zig` (the run steps + OVMF
probing), imported by a short root `build.zig`.
**Afterwards** (outside this plan): the intel-uhd-graphics-750 driver is
(re)created as a greenfield package. The new-driver checklist's build step
(docs/device-driver-development/new-driver-checklist.md, step 2) is already
rewritten against the package template.
## Execution notes (the finished shape)
- The shared recipe lives in `build-support/build.zig`: `userBinary` (what
every binary package calls; each named import resolves by searching the
packages the binary's zon declares) and `programModule` (for per-binary
addOptions modules). The `start` root shim and `user.ld` are named through the kernel
package (Dependency.path).
- Adding a binary = adding a directory with source + a ~15-line build.zig +
zon (copy any existing binary package, e.g.
`system/drivers/pci-bus/build.zig`) listing exactly the modules the source
imports and the domains they come from, then one dependency + one bundled
entry in the root build.zig and one zon line.
- The boot-tree array in the root (search `"etc/init.csv"` or
`.getEmittedBin()`) is the image file list — the authoritative comparison
target for any future build change.
- Package unit tests live in each package's own `test` step; the root
aggregate depends on every test-bearing package's step, so `zig build test`
at the root still runs everything.
Verification per phase:
- Unit tests: `zig build test`.
- QEMU integration suite: `python3 test/qemu_test.py` (docs/testing.md; the
full suite, all cases must pass).
- Image file list: the boot-tree array is the source of truth — snapshot it
(paths only) before phase 0 and diff after each phase; `zig build
check-fat-image` must also stay green.
Context a fresh session should read first: this doc, docs/testing.md,
docs/coding-standards.md (kebab-case names, no abbreviations), and the
`userBinary`/`userBinaryFromImports` bodies in build-support/build.zig. Commit
style: no Co-Authored-By trailers.
## Risks / notes
- Zig version churn: the package API (`b.dependency`, zon schema) has moved
between releases; the work pins against the repo's current Zig and any
upgrade lands separately, never mid-phase.
- The QEMU size-check tests hardcode source paths (e.g. virtio-gpu protocol
struct sizes) — they moved into their binaries' packages with their waves,
discharging the carry-along obligation.
- Doc updates ride each phase: docs/README.md (repo layout + source map),
docs/device-driver-development/new-driver-checklist.md (step 2) and
devices-csv.md ("Adding a driver"), and the docs that cite the build recipe
(driver-model.md, threading.md, system-requirements.md) reference build
shapes that keep changing.
+12 -10
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,
@@ -67,7 +67,7 @@ Three, and only three.
**This exception is scoped to a file that *is* a foreign ABI, and nothing else.**
danos has no such file today: the old `library/posix/` compatibility shim was retired
once its callers moved to the danos-native `runtime.fs`, since a hand-rolled POSIX
once its callers moved to the danos-native `file_system`, since a hand-rolled POSIX
layer is premature until danos actually needs it (see
[zig-self-hosting.md](zig-self-hosting.md)). The exception will apply again to the
`std.os.danos` seam when danos becomes a real Zig target — that module *is* the C-ABI
@@ -97,9 +97,10 @@ 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
lives in `system/drivers/`, a service in `system/services/`, and a test fixture in
`test/system/services/` (the repo path *is* its path on the boot volume), so the
*location* 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
@@ -135,15 +136,16 @@ Within those spelling rules, follow Zig's own conventions:
`notify_badge_bit`.
**File names are `kebab-case`.** A file named for a multi-word thing hyphenates it:
`device-tree.zig`, `ipc-synchronous.zig`, `vfs-protocol.zig`, `devices-broker.zig`. A
`device-model.zig`, `ipc-synchronous.zig`, `vfs-protocol.zig`, `devices-broker.zig`. A
single word or acronym needs no hyphen: `scheduler.zig`, `paging.zig`, `apic.zig`,
`idt.zig`. (The module *alias* a file is imported under still follows the code
conventions above — `snake_case` — because it's an identifier, not a filename.)
**A sub-project's entry point repeats its directory's name** — `init/init.zig`,
`runtime/runtime.zig`, `ps2-bus/ps2-bus.zig` — and the sub-project is addressed by the
*directory* (`system/services/init`, `library/runtime`), with the repeated leaf
resolving away. See the repository-layout section of [README.md](README.md).
`pci/pci.zig`, `ps2-bus/ps2-bus.zig` — and the sub-project is addressed by the
*directory* (`system/services/init`, `library/device/pci`,
`test/system/services/vfs-test`), with the repeated leaf resolving away. See the
repository-layout section of [README.md](README.md).
## Named values, not magic numbers
+344
View File
@@ -0,0 +1,344 @@
# Native AMD GPU support — feasibility and roadmap
**Status: research snapshot, not implemented.** This records what a *minimal, display-only*
native driver for a real discrete AMD GPU — specifically an **RX 6600-class card (Navi 23,
RDNA2, DCN 3.0.2)**, the market analog of the RTX 3060 — would take, and how it slots into
danos's pluggable scanout architecture. It is a survey of primary sources (the Linux
[amdgpu Display Core](https://github.com/torvalds/linux/tree/master/drivers/gpu/drm/amd/display)
driver and its [kernel documentation](https://docs.kernel.org/gpu/amdgpu/display/index.html),
the AtomBIOS interpreter in
[drivers/gpu/drm/amd](https://github.com/torvalds/linux/tree/master/drivers/gpu/drm/amd),
linux-firmware's `LICENSE.amdgpu`, and Haiku's
[radeon_hd](https://github.com/haiku/haiku/tree/master/src/add-ons/kernel/drivers/graphics/radeon_hd)),
not an implementation. It completes the trilogy with [nvidia-gpus.md](nvidia-gpus.md) and
[intel-igpu.md](intel-igpu.md) and should be read against both — AMD lands *between* them:
NVIDIA-class discrete-card mechanics, but Intel-class (better, in one way) reference material.
Read [display.md](display.md) and [display-v2.md](display-v2.md) first — this doc assumes the
v2 model where scanout is a **pluggable backend** and a native driver is just another `.scanout`
service (like the virtio-gpu one), announcing to the compositor over `attach_scanout`.
## TL;DR
- **AMD's decisive advantage is that the vendor's own display driver is the register manual, and
it's MIT-licensed.** The entire Display Core (DC) — hardware sequencer, per-block code for
OTG/OPTC, HUBP, DPP, MPC, DIO/link encoders, plus the `asic_reg` register headers — ships in
the Linux tree under MIT/X11, deliberately written OS-agnostic because AMD shares it across
operating systems. You can study it, port it, even copy from it into a danos driver without
license contamination. NVIDIA has no analog (nouveau is GPL); Intel has PRM prose but you
still write the code yourself.
- **The firmware wall is one small blob, not a GSP.** The only display-side firmware the Linux
driver hard-requires is **DMCUB** (the display microcontroller), and only on **DCN 2.1
through 4.x** — which includes Navi 23. It is redistributable from linux-firmware, and it is
a display helper, not a full-card resource manager: on DCN 3.0.x, hardware init
(`dcn30_init_hw`) is **host-driven direct register programming** — the only DMUB call in it
is a capability query. All DCE generations, DCN 1.0 (Raven), and DCN 2.0 (Navi 10/12/14) run
display with **no display firmware at all**.
- **Whether the *silicon* (vs. the Linux driver) needs DMCUB for a bare GOP-inheriting modeset
is unproven** — Linux fails init with `-EINVAL` if the blob is missing on a DMUB ASIC, but
what it's *used for* at minimum scope (vs. PSR/ABM/offloaded DP link training) isn't
documented. The safe plan ships the blob; it's legally and practically cheap to do so.
- **Programming model is direct MMIO, not channel DMA.** DCN mode-set is ordered register-write
sequences (the DC "hardware sequencer") against named, header-documented registers — no
pushbuffers, no method streams, no RAMHT, no supervisor-interrupt handshake. This deletes the
hardest structural layer of the NVIDIA path.
- **Scanout is VRAM-only on discrete cards** — the claim that DCN can scan out of GTT/system
memory was checked and *refuted* for dGPUs (Linux allows GTT scanout only on select APUs). So
a small VRAM allocator + BAR CPU mapping is required, same as NVIDIA. Pitch-linear surfaces
are supported; no DCC/tiling needed.
- **danos's GOP boot helps here too, with a caveat.** DC explicitly models taking over a
VBIOS/GOP-lit pipe (`dc_validate_boot_timing` reads back live DIG/OTG/pixel-clock state), so
"repoint the surface on the running pipe" is demonstrably hardware-feasible — but Linux's
seamless-boot path is **eDP-only and default-off on discrete cards**, so plan on a full
self-owned modeset (including DP retrain) right after first light rather than living on the
inherited link.
- **AMD has real non-Linux prior art — but only for the old hardware.** Haiku's MIT `radeon_hd`
mode-sets by executing VBIOS **AtomBIOS command tables** through AMD's own MIT interpreter;
its compiled-in ceiling is **DCE 8.5 (Hawaii, ~2013)** — every Polaris/Vega/Navi entry sits
in a `#if 0` block. There is zero non-Linux DCN precedent; a danos DCN driver would be first.
- **Effort tier ≈ high-3 to 4** for a native DCN 3.0.x display-only driver on Navi 23 — the raw
register surface is GA106-class (tier 4), but the MIT vendor reference, the one-blob firmware
wall, and the absence of channel-DMA plumbing pull real risk out. The AtomBIOS-interpreter
route is tier ≈ 3 but dead-ends at pre-2016 silicon.
- **Recommendation:** the same sober conclusion as the other two docs — GOP already gives
native-res scanout for zero code — but if danos ever does drive real discrete silicon
natively, **an RDNA2 card is the best target of the three**: modern, mainstream, in-warranty
hardware with a legally clean, vendor-authored reference. That combination exists nowhere
else.
## The firmware wall (a fence, next to NVIDIA's wall)
AMD GPUs carry a zoo of firmware: PSP (security processor), SMU (power/clock management), CP/RLC
(graphics), SDMA, VCN (media) — and, on the display side, DMCU (legacy) then **DMCUB**
("Display Micro-Controller Unit, version B"), a per-generation blob in linux-firmware
(`navi23_dmcub.bin` etc.). The display-only question is: which of these does a scanout driver
actually need?
The Linux answer is precise and readable in
[`amdgpu_dm.c`](https://github.com/torvalds/linux/blob/master/drivers/gpu/drm/amd/display/amdgpu_dm/amdgpu_dm.c):
`dm_init_microcode()` switches on the display IP version — **DCN 2.1 (Renoir) through DCN
3.0.x / 3.1.x / 3.2 / 3.5 / 4.x** request a DMCUB blob as `AMDGPU_UCODE_REQUIRED`, and
`dm_dmub_hw_init()` fails driver init with `-EINVAL` if it's absent. Everything earlier — **all
of DCE (Southern Islands through Vega), DCN 1.0 (Raven), and DCN 2.0 (Navi 10/12/14)** — hits
the `default:` case, `dmub_srv` stays NULL, and display runs with no display firmware at all
([kernel display-manager doc](https://docs.kernel.org/6.2/gpu/amdgpu/display/display-manager.html)).
Two nuances survive verification:
- **The requirement is Linux-driver enforcement backed by real functional need, and it's
version-sensitive.** AMD force-switched all Renoir ASICs to DMUB to fix a USB-C/resume bug
(kernel commit `652de07addd2`, "with new dmub f/w dmcu is superseded"), which regressed users
on old blobs and had to be patched with explicit `dmcub_fw_version` gating (`91adec9e0709`).
What DMUB is *used for* varies with blob version. Ship a current blob.
- **DMCUB is an architectural fixture, not a bolt-on** — the DCN hardware itself contains a DMU
block housing the microcontroller
([DCN overview](https://docs.kernel.org/gpu/amdgpu/display/dcn-overview.html)) — but it is
**not a mediator of the programming model** on DCN 3.0: `dcn30_init_hw()` initializes clocks,
disables power gating, and powers up link encoders via direct register writes; its sole DMUB
interaction is `dc_dmub_srv_query_caps_cmd`. Firmware-*assisted* PHY/link bring-up appears
from **DCN 3.1** onward — one more reason to target 3.0.x. Features like PSR and ABM are
DMUB-offloaded on all generations; a minimal driver simply doesn't enable them.
**Contrast with NVIDIA's GSP:** the GSP is a full resource manager with a signed multi-stage
boot chain and a firmware ABI that breaks every driver release. DMCUB is a display helper blob
you copy onto the boot image once, load into a reserved buffer, and mostly ignore. There is no
signature fuse-matching, no WPR carve-out, no RPC-only register access. The one genuinely open
question — whether a GOP-inheriting minimal modeset could skip DMCUB entirely on DCN 3.0.2 —
doesn't need answering, because shipping the blob costs nothing (see [Licensing](#licensing)).
**PSP/SMU remain the flagged risk.** Nothing display-only touches CP/RLC/SDMA (those gate the
graphics rings, exactly like NVIDIA's PGRAPH — irrelevant here). But `dcn30_init_hw` calls into
the clock manager, and on discrete cards the clock manager may message the SMU to change display
clocks (DISPCLK/DPPCLK). Whether inherited GOP boot clocks suffice for a same-or-lower mode —
avoiding SMU (and hence PSP firmware-load) entirely — is the largest unverified assumption in
the milestone list below. The survey produced no confirmed claim either way.
## The display engine landscape
Two eras, one boundary that matters:
| Generation | Display IP | Cards | Display firmware | Route |
|---|---|---|---|---|
| GCN 1–4 (SI→Polaris) | DCE 6/8/10/11 | HD 7000 → RX 580 | none | AtomBIOS tables or direct DCE registers |
| Vega / Raven | DCE 12 / DCN 1.0 | Vega 56/64, APUs | none | DC code (first DCN) |
| Navi 1x (RDNA1) | DCN 2.0 | RX 5500–5700 | none | DC code |
| Renoir APU | DCN 2.1 | 4000-series APUs | **DMCUB required** | DC code |
| **Navi 2x (RDNA2)** | **DCN 3.0.x** | **RX 6600–6900** | **DMCUB required** | **DC code, host-driven init** |
| RDNA3/RDNA4+ | DCN 3.1+/3.2/3.5/4.x | RX 7000/9000 | DMCUB required, fw-assisted PHY | DC code, more DMUB offload |
The best modern first-pixel target is **DCN 3.0.x**: it has the full MIT block stack from the
June 2020 Sienna Cichlid patch series (207 patches, Linux 5.9; Navi 23 reuses the dcn30
sequencer), host-driven hardware init, and sits *before* the DCN 3.1 shift toward
firmware-assisted link management. Older DCE cards are even simpler (no firmware at all, plus
the AtomBIOS escape hatch) but are 2013–2016 hardware; newer DCN 3.5/4.x pushes more into DMUB.
The DCN pipe, in one line each (the vocabulary the DC code speaks —
[programming model](https://docs.kernel.org/next/gpu/amdgpu/display/programming-model-dcn.html)):
**HUBP** fetches and unpacks the surface from memory (this is where the scanout address and
pitch live), **DPP** scales/converts colors, **MPC** blends planes (bypassable for one plane),
**OPP** packs output, **OTG/OPTC** generates raster timings (the CRTC), and the **DIO** block's
DIG encoders + PHY drive the connector. Mode-set is the DC *hardware sequencer* walking these
blocks with ordered register writes — plain MMIO with polling, no pushbuffer channels, no
supervisor interrupts. Structurally this is Intel-shaped, not NVIDIA-shaped.
## Two routes: AtomBIOS interpreter vs. native DC-derived registers
**AtomBIOS** is AMD's VBIOS bytecode: every card's ROM carries *data tables* (connector
topology, clock limits — the DCB equivalent) and *command tables* (`SetPixelClock`,
`SetCRTC_Timing`, `EnableCRTC`, DIG encoder/transmitter control), executed by a small
interpreter the driver embeds (`atom.c`, ~1.5k lines). The classic radeon driver and Haiku's
`radeon_hd` mode-set this way: parse the tables, execute them, and the VBIOS does the
register-level work for you — inherently per-board correct, since the tables come from the
card's own ROM.
- **Where it's proven:** through DCE 8.5 (Haiku's ceiling, below) and in Linux's pre-DC code
through Polaris (DCE 11.2). AMD's interpreter itself is MIT (Haiku ships AMD's own
`atom.cpp`, "Copyright 2008 Advanced Micro Devices").
- **Where it's unproven:** DCN. amdgpu's DC still *uses* AtomBIOS for init sub-steps
(`bios_golden_init` in `dcn30_init_hw` executes host-interpreted tables) and reads the data
tables for connector topology — but nobody drives a full DCN modeset from command tables, and
whether RDNA2 VBIOSes still carry a complete modeset path or vestigial init-only tables is an
open question no source answers. Do not bet on it.
**The native route** is: port the relevant slice of DC. Not wholesale — in-tree DC has
accumulated Linux-isms (kernel-FPU guards around DML, the bandwidth-calculation library, which a
single-plane fixed-mode driver can largely sidestep) — but the DC core is *designed* to be
retargeted: the kernel docs state outright that DC "is shared with other OSes" and holds the
OS-agnostic hardware programming behind a `dm_services` shim (register access, memory, delays,
firmware loading). Dave Airlie initially rejected the DAL/DC merge in 2016 *because* it was
AMD's cross-OS codebase — hostile-witness confirmation that this exact code runs outside Linux.
Reimplement the shim in Zig, and the dcn30 sequences sit on top.
## The memory floor
Same shape as NVIDIA's, and the survey *hardened* one assumption:
- **VRAM-only scanout on discrete cards.** The documented DCN fetch path is VRAM → Data Fabric
(SDP) → DCHUB → HUBP; the claim that display buffers can live in GTT/system memory was
refuted for dGPUs in verification — Linux permits GTT scanout only on select APUs. The NVIDIA
doc's "maybe sysmem ctxdma?" hope has a firm *no* here. Budget for a small VRAM allocator.
- **Pitch-linear is fine.** HUBP programs a surface address + pitch; linear (untiled, no DCC)
surfaces are first-class for scanout. No tiling math.
- **CPU access via the VRAM BAR.** Compositing writes go through the PCI VRAM aperture;
resizable BAR helps but isn't needed — one pitch-linear surface fits comfortably in a
fixed 256 MB small-BAR window.
- **No GPU VMM.** Display addresses are physical VRAM addresses programmed into HUBP; no page
tables, no GEM/TTM, no eviction.
**Net:** (1) a contiguous aligned VRAM allocator, (2) a BAR CPU mapping, (3) a small reserved
buffer for the DMCUB firmware regions. That's the whole memory story.
## Inheriting GOP state
danos's GOP boot pays off again, with sharper edges than on NVIDIA:
- **DC models pipe takeover explicitly.** `dc_validate_boot_timing()`
([dc.c](https://github.com/torvalds/linux/blob/master/drivers/gpu/drm/amd/display/dc/core/dc.c))
reads back *live* hardware — `is_dig_enabled` on the link encoder, OTG timing registers,
pixel clock within tolerance — and keeps the VBIOS/GOP-lit pipe running until first flip.
This is a vendor-blessed recipe for milestone 2 below: the exact register set that tells you
which pipe is alive and how it's configured.
- **But Linux's seamless path is eDP-only and APU-gated.** The code comment is blunt: "Support
seamless boot on EDP displays only", and the enabling check requires an APU with DCN ≥ 3.0
unless forced with `amdgpu.seamless=1`. On a discrete RX 6600 with DP/HDMI, Linux does a full
modeset at takeover. Read that as a warning, not a prohibition: repointing HUBP at your own
surface on the live pipe should still work (the readback code proves the state is
inspectable), but plan the full self-owned modeset — **including DP retraining** — as the
immediate next step, not a someday.
- **No supervisor handshake exists to re-learn.** The NVIDIA doc's SV1/SV2/SV3 open question has
no AMD counterpart; commit sequencing is ordered register writes + vblank/lock waits in the
hwseq, all visible in MIT source.
## Licensing
The inverse of the NVIDIA situation, and the single strongest argument for AMD:
- **The reference code is MIT.** `amdgpu_dm.c` carries `SPDX-License-Identifier: MIT`;
`dc/core/dc.c` and `dmub_srv.h` carry the full X11-style grant (use, copy, modify, merge,
publish, distribute, sell). The `asic_reg` register headers ship under the same terms. One
diligence note: SPDX tagging isn't uniform across the tree, so header-check each file before
copying from it — but no GPL files are known inside `dc/`. Where nouveau forces a
GPL-or-clean-room choice, here the *easy technical path and the permissive path are the same
path*.
- **Firmware redistribution is a solved problem.** linux-firmware's `LICENSE.amdgpu` grants
anyone a royalty-free right to reproduce and distribute the blobs, binary-only, with the
license text attached — no OSI-license gate like NVIDIA's, no AMD agreement needed. danos can
ship `navi23_dmcub.bin` (and PSP/SMU blobs if ever needed) on its boot image today. The same
license **prohibits reverse-engineering the blobs** — all programming knowledge must come
from the MIT source, never from blob disassembly. (VBIOS images aren't in linux-firmware;
they're read from the card's own ROM, as Haiku does.)
- **Prose register docs are a DCE-era artifact.** AMD's classic X.Org-hosted PDFs cover the old
families — and the famous `R6xx_3D_Registers.pdf` turns out to be 3D-only (verified: zero
display content; the display material lives in the separate per-ASIC Register Reference
Guides). For DCN there is **no prose display spec at all**: the MIT DC source plus the
`asic_reg` headers *are* the register manual. Plan accordingly.
## Prior art
AMD, unlike NVIDIA, has genuine working non-Linux precedent — with a hard generational ceiling:
- **Haiku `radeon_hd`** (MIT, still in the tree): a real, shipping, from-scratch display driver
that executes AtomBIOS command tables via AMD's own MIT interpreter. Verified ceiling:
the last *enabled* device entry is **Hawaii (DCE 8.5, 0x67be)**; everything newer —
Tonga/Fiji, Carrizo/Polaris, Vega/Raven, and every Navi/RDNA2 entry up to the RX 6900 XT —
sits inside one `#if 0 /* disabled for R1/beta5 */` block under the comment "WARN: DCE
versions below here get sketchy."
- **AmigaOS/MorphOS RadeonHD drivers** (hdrlab): commercial non-Linux Radeon display drivers,
again for the DCE era.
- **FreeBSD** `drm-kmod`: a port of Linux amdgpu (DC and all), not independent prior art — but
proof the DC codebase transplants.
**Nobody has driven DCN outside Linux-derived code.** A danos DCN 3.0.x driver would be a
first — but a first with the vendor's MIT code as its map, which is a different proposition
from nouveau-as-only-reference.
## Alternatives
| Option | What you get | The tradeoff |
|---|---|---|
| **Stay on GOP** (working today) | Native-res scanout, zero GPU code/firmware/maintenance | Resolution frozen at ExitBootServices; no runtime mode change, no hardware vsync, no multihead |
| **AtomBIOS interpreter on an old DCE card** | Proven end-to-end (Haiku); interpreter is small + MIT; board-correct by construction | 2013–2016 hardware ceiling; tier ≈ 3; teaches AtomBIOS, not modern DCN |
| **Native DCN 3.0.x on RX 6600** (this doc) | Runtime modeset, vsync, multihead on modern silicon; MIT vendor reference; one redistributable blob | Tier ≈ high-3–4; SMU/clock question open; no non-Linux precedent |
| **Port DC wholesale** (reimplement `dm_services`) | Vendor-maintained sequences verbatim; designed-for-porting seam | Big codebase to carry (DML, abstractions); Linux-isms to shear off; overkill for one plane |
| **RDNA3+/DCN 3.5+** | Newer cards | More DMUB offload (fw-assisted PHY from DCN 3.1); strictly harder than 3.0.x for no display-only gain |
## "First light" milestones (native DCN 3.0.x path)
Framed as a danos `.scanout` service, inheriting the GOP-initialized display:
1. **PCI/BAR bring-up** — enumerate Navi 23, map the register BAR and the VRAM BAR via danos
MMIO grants; prove the pipe is GOP-live by writing pixels into the *existing* GOP
framebuffer through the VRAM BAR.
2. **Read back the live pipe** — port the `dc_validate_boot_timing` register set: which OTG is
running, its timings, which DIG/link encoder is enabled, current HUBP surface address/pitch.
This is pure reads — zero risk, high information.
3. **Repoint the surface** — allocate a danos-owned pitch-linear VRAM surface, program the HUBP
surface address/pitch on the live pipe at vblank. First self-owned pixel with **no modeset,
no firmware, no clock changes**.
4. **DMCUB bring-up** — load `navi23_dmcub.bin` (redistributed per `LICENSE.amdgpu`) into its
reserved regions, minimal `dmub_srv` init, verify the caps query answers.
5. **Full owned modeset** — port the dcn30 hwseq slice: OTG timing programming, MPC bypass
(single plane), DIG/PHY enable, **DP link retrain** (or start on HDMI to defer it, exactly
as the NVIDIA doc advises). This is where the SMU/clock question lands — first attempt:
reuse inherited boot clocks for a same-or-lower mode.
6. **EDID** — AUX (DP) / DDC (HDMI) over the DCN AUX engine registers; parse and build the mode
list; connector topology from the VBIOS AtomBIOS data tables.
7. **Wire into the compositor** — `attach_scanout`, vsync from the vblank/pageflip interrupt,
then multihead.
Keep the GOP backend as the fallback the whole way — a stall at any step still leaves danos with
a working display (the resilience v2 already provides via re-attach).
## Reading list
**The DC core (MIT — the register manual for DCN):**
- `drivers/gpu/drm/amd/display/dc/hwss/dcn30/dcn30_hwseq.c` — hardware init + the modeset
sequencer for the target generation (host-driven; one DMUB caps query).
- `dc/dcn30/` + `dc/dcn302/` blocks: `dcn30_hubp.c` (surface address/pitch — milestone 3),
`dcn30_optc.c` (OTG timings), `dcn30_dio_link_encoder.c` (DIG/PHY), `dcn30_mpc.c` (bypass),
`clk_mgr/dcn30/` (the SMU question, read before milestone 5).
- `dc/core/dc.c` — `dc_validate_boot_timing()`: the GOP-takeover readback recipe.
- `asic_reg/dcn/dcn_3_0_0_{offset,sh_mask}.h` — every register name and bitfield.
- `dmub/` (`dmub_srv.h`, `src/dmub_dcn30.c`) — firmware regions + bring-up for milestone 4.
**The Linux glue (for logic, not porting):**
[`amdgpu_dm.c`](https://github.com/torvalds/linux/blob/master/drivers/gpu/drm/amd/display/amdgpu_dm/amdgpu_dm.c)
— `dm_init_microcode` / `dm_dmub_hw_init` (the firmware-wall switch), seamless-boot gating.
**Kernel docs (read first):**
[DCN overview](https://docs.kernel.org/gpu/amdgpu/display/dcn-overview.html) (the block diagram
+ VRAM→DF→DCHUB fetch path) ·
[DC programming model](https://docs.kernel.org/next/gpu/amdgpu/display/programming-model-dcn.html)
(dc_plane/dc_stream/dc_link objects, hwseq, block APIs) ·
[display manager](https://docs.kernel.org/6.2/gpu/amdgpu/display/display-manager.html).
**AtomBIOS:** `drivers/gpu/drm/amd/amdgpu/atom.c` (the interpreter), `atombios.h` (table
formats), [osdev AMD AtomBIOS](https://wiki.osdev.org/AMD_Atombios) (hobby-OS orientation),
Haiku [`radeon_hd`](https://github.com/haiku/haiku/tree/master/src/add-ons/kernel/drivers/graphics/radeon_hd)
(a complete worked example, MIT, through DCE 8.5).
**Licensing:** linux-firmware
[`LICENSE.amdgpu`](https://github.com/endlessm/linux-firmware/blob/master/LICENSE.amdgpu);
DCE-era prose specs at [x.org/docs/AMD](https://www.x.org/docs/AMD/) (Register Reference
Guides — display; note `R6xx_3D_Registers.pdf` is 3D-only).
## Open questions (unresolved by the survey)
- **Does DCN 3.0.2 silicon need DMCUB for a bare inherit-and-modeset path**, or only for
PSR/ABM/offloaded features? (Moot if the blob is shipped regardless — but it decides whether
milestone 3 can precede milestone 4.)
- **Can a display-only driver avoid PSP and SMU entirely** by inheriting GOP boot clocks — what
does the dcn30 clock manager actually require from SMU messaging on Navi 23 for a
same-or-lower mode? *The largest open risk in the plan.*
- **Do RDNA2 VBIOS command tables still carry a complete modeset path**, or are they vestigial
init-only tables? (Would open a Haiku-style route on modern cards; no source answers it.)
- **Is the DCN 3.0 AUX/DDC engine and DP retrain fully host-drivable without DMUB**, as
`dcn30_hwseq` implies?
- Exact HUBP surface alignment/pitch constraints for linear scanout on Navi 23 (in the headers;
not captured verbatim in the survey).
---
*Research snapshot (2026-07); findings pinned to Linux master and Haiku master as of the survey
date. DMUB coverage only grows with new DCN generations — re-verify the firmware-wall switch in
`amdgpu_dm.c` against current source before building.*
@@ -1,6 +1,6 @@
# Device interrupts
CPU exceptions ([interrupts.md](interrupts.md)) are the kernel reacting to its own
CPU exceptions ([interrupts.md](../os-development/interrupts.md)) are the kernel reacting to its own
mistakes. **Device interrupts** are the opposite: hardware asking for attention —
a timer firing, a key pressed, a packet arriving. They share the IDT, but differ
in one fundamental way: an exception here is terminal (we report and halt), while a
@@ -10,7 +10,7 @@ back — the same mechanism a scheduler will later use to preempt tasks.
The first device we bring up is the **timer**, because it's the simplest: it lives
entirely on the CPU's local interrupt controller, needing no external routing.
It's all x86_64-specific, behind the [arch](arch.md) boundary.
It's all x86_64-specific, behind the [architecture](../os-development/architecture.md) boundary.
## The APIC, not the PIC
@@ -40,7 +40,7 @@ count that becomes the reload value. From then on it fires vector 32 repeatedly,
its own, forever.
The reload count isn't picked arbitrarily — it's **calibrated to real time**,
which the [real-time](vision.md) scheduling guarantees depend on. Since the LAPIC
which the [real-time](../vision.md) scheduling guarantees depend on. Since the LAPIC
timer's raw rate is bus-clock dependent and unknown up front, `calibrate` runs the
LAPIC timer one-shot from its maximum count while a **reference clock** counts out a
known 10 ms, then sees how far the LAPIC got — its counts-per-millisecond, from which
@@ -53,7 +53,7 @@ a missing PIT would hang the boot):
1. **CPUID leaf 0x15** — the CPU's TSC frequency directly, needing no external timer
at all (the LAPIC is then measured against the TSC).
2. The **HPET**, discovered via ACPI (see [discovery](discovery.md) / [acpi](acpi.md)).
2. The **HPET**, discovered via ACPI (see [discovery](../os-development/discovery.md) / [acpi](../os-development/acpi.md)).
3. The **ACPI PM timer** (a fixed 3.579545 MHz counter from the FADT).
4. The **PIT** (legacy 8254, 1.193182 MHz) — last resort, and bounded so it can't hang.
@@ -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.
@@ -98,7 +100,7 @@ values (a second socket, some firmware), so a thread migrating from a core readi
check** as each application processor comes online (`checkWarpSource`, adapted from
Linux's): the waking core and the BSP hammer a shared "highest seen" TSC under a lock,
and if either ever reads below it, the cores' TSCs are skewed. It's pairwise because APs
come up one at a time ([smp.md](smp.md)).
come up one at a time ([smp.md](../os-development/smp.md)).
**The fallback.** When the TSC fails either test — non-invariant (a bare VM such as the
default qemu64), or warped between cores — danos moves the monotonic clock onto the
@@ -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](../os-development/interrupts.md).)
## Turning them on
@@ -160,11 +168,11 @@ Exceptions can't be masked, which is why they worked all along. Maskable device
interrupts don't fire until the CPU's interrupt flag is set — so the final step is
`sti` (`arch.enableInterrupts()`), after the APIC and timer are configured. From
that instant the kernel has a heartbeat, and its idle `hlt` loop
([halting.md](halting.md)) wakes on every tick and dozes off again.
([halting.md](../os-development/halting.md)) wakes on every tick and dozes off again.
## Verifying it
The `timer` test (see [testing.md](testing.md)) is the proof that an interrupt both
The `timer` test (see [testing.md](../testing.md)) is the proof that an interrupt both
*fires* and *returns*: it records the tick count, busy-waits, and checks the count
advanced on its own.
@@ -180,21 +188,23 @@ spinning in unrelated code — is the whole mechanism working end to end.
## Since (done elsewhere)
- **Preemption**: the timer handler is where the scheduler decides to switch — the
reason a *returning* interrupt matters. See [scheduling.md](scheduling.md).
reason a *returning* interrupt matters. See [scheduling.md](../os-development/scheduling.md).
- **`sleep()` / timeouts** built on the calibrated clock.
- **The I/O APIC, routed**: external device lines now reach a vector, and the
interrupt is delivered onward to a *user-space* driver as an IPC message. See
[drivers.md](drivers.md).
- **Uncacheable MMIO**: device grants are mapped `PCD|PWT` (strong-uncacheable) for
user drivers — see [paging.md](paging.md).
user drivers — see [paging.md](../os-development/paging.md).
## What's next (not done here)
## What's next (partly done since)
- **The keyboard**: the PS/2 controller is port-mapped (`0x60`/`0x64`), and port I/O is
now available to ring 3 via the claim-gated `io_read`/`io_write` syscalls
([drivers.md](drivers.md)) — so the first *input* device is unblocked; it just needs
writing (claim the controller, `irq_bind` GSI 1, read scancodes from `0x60`).
- **MSI-X**: `msi_bind` gives one per-device edge-triggered vector (M15); MSI-X's
multi-vector table (many queues per device, e.g. NVMe) is the remaining extension.
- **The LAPIC's own page** is still mapped writeback-cacheable like the rest of the
identity map. QEMU tolerates it; real hardware wants it uncacheable.
- **The keyboard** — done, exactly as sketched: the PS/2 bus driver
(`system/drivers/ps2-bus/`) claims the port-mapped 8042 controller through the
claim-gated `io_read`/`io_write` syscalls ([drivers.md](drivers.md)), binds
IRQ 1 (and the aux mouse's IRQ 12), reads scancodes from `0x60`, and decodes
them into HID events for the [input service](input.md).
- **MSI-X** — still open: `msi_bind` gives one per-device edge-triggered vector
(M15); MSI-X's multi-vector table (many queues per device, e.g. NVMe) is the
remaining extension.
- **The LAPIC's own page** — still mapped writeback-cacheable like the rest of
the identity map. QEMU tolerates it; real hardware wants it uncacheable.
@@ -10,19 +10,19 @@ mirrors them and prunes a dead reporter's children, and the `usb-report`
scenario proves report → prune → respawn → re-report. The application surface is built (M18.3, 2026-07-13):
`enumerate` and `subscribe` over IPC, with `device-list` as the first client —
the manager is now the one answer to "what devices exist" for applications.
The primitives underneath are real ([process-management.md](process-management.md):
The primitives underneath are real ([process-management.md](../os-development/process-management.md):
spawn/supervise/kill/exit-notification; [driver-model.md](driver-model.md): the device
table as a capability system; [drivers.md](drivers.md): claim/map/IRQ), and the first
per-device driver spawn works (the device manager matches the xHCI controller by PCI
class and spawns `usb-xhci-bus` with the device id as argv[1]). This document designs
the rest: the device manager as **the tree, the matcher, and the supervisor** — the
policy process that turns [resilience.md](resilience.md)'s restart goal into practice
policy process that turns [resilience.md](../os-development/resilience.md)'s restart goal into practice
for drivers.
How processes stop, reload, and report their deaths is deliberately **not** in this
document: that is the universal lifecycle every danos process speaks —
[process-lifecycle.md](process-lifecycle.md), signals over IPC and the stable
`runtime.process` interface. The device manager is that design's first serious
[process-lifecycle.md](../os-development/process-lifecycle.md), signals over IPC and the stable
`process` interface. The device manager is that design's first serious
customer, not its owner. Its own protocol contains nothing lifecycle-shaped; a
driver is stopped, health-checked, and buried exactly like any other process.
@@ -35,7 +35,7 @@ The device tree is two things fused: *information* (what exists, how it nests) a
claims, resource containment on `device_register`, the
`mmio_map`/`irq_bind`/`msi_bind` gates — and **cleans all of it up when a process
dies** (settled; it is increment 1 of
[process-lifecycle.md](process-lifecycle.md)). The three invariants in
[process-lifecycle.md](../os-development/process-lifecycle.md)). The three invariants in
[driver-model.md](driver-model.md) stay exactly where they are. A device manager
that could mint MMIO mappings by its own say-so would be a second kernel, and a
buggy one would un-earn everything the microkernel bought.
@@ -51,7 +51,7 @@ enumeration is a **pci-bus driver**: the manager spawns it against the host brid
like any bus reports children. ACPI becomes an **acpi service** that interprets the
tables and reports the namespace. The manager only orchestrates and merges. Moving
AML interpretation out of ring 0 is its own project on its own track; nothing here
depends on when it lands. (It landed: [discovery.md](discovery.md), M19–M20.)
depends on when it lands. (It landed: [discovery.md](../os-development/discovery.md), M19–M20.)
`device_register` is **idempotent on exact match**: a re-registration with an
identical (parent, class, identity, resources) tuple returns the existing id
@@ -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 |
@@ -82,7 +82,7 @@ one world.
deadline means wrong binary, wrong protocol version, or wedged before main — apply
the stop sequence and the restart policy. Everything else lifecycle-shaped
(terminate, the common `ping` liveness call, exit reasons) arrives through
[process-lifecycle.md](process-lifecycle.md)'s vocabulary, not this protocol.
[process-lifecycle.md](../os-development/process-lifecycle.md)'s vocabulary, not this protocol.
Assignment stays argv (`usb-xhci-bus <device id>`) for now — simple, and it works.
The step after `hello` exists is delegation: the manager claims (or is granted) the
@@ -91,13 +91,16 @@ mechanism), replacing first-come-first-served `device_claim` with policy. Identi
`child_added` is per-bus: PCI children carry the class triple (`pci_class`, as the
xHCI match already uses); USB children carry the (class, subclass, protocol) triple
from usb-ids.zig — each bus's native language, decoded by the shared ids modules.
(Since the registry landed, `child_added` also carries a `bus` discriminator and
the numeric `vendor`/`device`/`subsystem` ids the finer match levels need —
see [/etc/devices.csv](devices-csv.md).)
## Supervision and restart
Every driver is spawned with the manager's exit endpoint (`spawnSupervised` — built).
On a death notification:
1. **Read the reason** ([process-lifecycle.md](process-lifecycle.md) increment 2).
1. **Read the reason** ([process-lifecycle.md](../os-development/process-lifecycle.md) increment 2).
Clean exit → it meant to; don't restart. Fault or missed `hello` deadline →
restart with **backoff**, and a crash-loop cap (three fast deaths → mark failed,
stop respawning, log loudly; a later `reload` to the manager can retry).
@@ -136,8 +139,8 @@ way.
## Increments
Increments 1–4 are the lifecycle prerequisites and live in
[process-lifecycle.md](process-lifecycle.md) (claim cleanup on death, exit reasons,
published exit events, signals + `runtime.process`). On top of those:
[process-lifecycle.md](../os-development/process-lifecycle.md) (claim cleanup on death, exit reasons,
published exit events, signals + `process`). On top of those:
5. **device-manager-protocol**: `hello`, supervised spawn with restart policy;
usb-xhci-bus becomes the first conforming driver.
@@ -147,8 +150,10 @@ published exit events, signals + `runtime.process`). On top of those:
to a manager-internal seam.
8. **Discovery migration** — DONE (M19–M20, 2026-07-13): enumeration moved to
ring 3 as swappable per-firmware discoverers — the pci-bus driver (M19) then
the acpi service (M20), see [discovery.md](discovery.md); the kernel seeds
only the host bridge and the acpi-tables node. Matching moved with it:
the acpi service (M20), see [discovery.md](../os-development/discovery.md); of the enumerable
devices, the kernel seeds only the host bridge and the acpi-tables node (the
non-enumerable platform nodes — processors, interrupt controllers, the HPET,
the loader's framebuffer — stay kernel-seeded too). Matching moved with it:
`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 +172,15 @@ 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 is a registry, not code (resolved 2026-07-26).** `driverFor`/
`pciDriverFor` were honest at two bus types; the third (USB) was matched in code
too, and then the switch tables started to hurt — they keyed PCI matches on the
class triple alone, so a virtio-gpu could only be matched as a generic display
function and the driver had to re-confirm its `1AF4:1050` identity from config
space after being spawned. The manifest the earlier note anticipated landed as a
human-readable registry: **[/etc/devices.csv](devices-csv.md)**, parsed by the
pure `device-registry` module and read by the manager at boot. A row binds a
driver to a device by any of base / subclass / prog-IF / vendor / device /
subsystem / `_HID`, most-specific match winning; it is authoritative (no
compiled-in fallback — an unmatched device is logged, never guessed).
`pciDriverForIdentity`, `hidDriverFor`, and `usbDriverForIdentity` are gone.
@@ -0,0 +1,110 @@
# /etc/devices.csv — the device registry
**Status: built (2026-07-26).** The device manager reads `/etc/devices.csv` at
boot and binds every device a bus driver reports to the driver the registry
names. It replaces the three hand-written `switch` tables that used to live in
the manager (`pciDriverForIdentity`, `hidDriverFor`, `usbDriverForIdentity`) —
the "manifest" [device-manager.md](device-manager.md) anticipated once code
matching started to hurt. The parser and matcher are the pure, unit-tested
`device-registry` module (`library/device/registry/device-registry.zig`).
## Why a registry
The switch tables keyed PCI matches on the 24-bit class/subclass/prog-IF triple
alone. That is too coarse: a virtio-gpu is just "display / other" by class, so it
could only be *class-matched* and the driver had to re-confirm its real
`1AF4:1050` identity from config space **after** the manager had already spawned
it. The registry lets a rule bind on the full identity — down to vendor, device,
and subsystem — so the manager makes the precise decision itself, and the driver
comes up already knowing it is the right one.
It is also **data, not code**: teaching the system new hardware is a line in a
file, not an edit-and-recompile of the manager. And it is **greppable** — one
place to read "what binds what," the same idea as Linux's `modules.alias`.
## The file
One rule per line, nine comma-separated fields; `#` starts a comment (whole-line
or trailing); blank lines are ignored. Whitespace around a field is trimmed, so
columns may be padded for readability.
```
# bus base class prog_if vendor device subsystem hid driver
pci, 0C, 03, 30, *, *, *, *, /system/drivers/usb-xhci-bus
pci, 03, 00, 00, *, *, *, *, /system/drivers/display
pci, 03, 80, *, 1AF4, 1050, *, *, /system/drivers/virtio-gpu
usb, 03, 01, 01, *, *, *, *, /system/drivers/usb-hid-keyboard
acpi, *, *, *, *, *, *, PNP0303, /system/drivers/ps2-bus
```
| Field | Meaning | Notes |
|---|---|---|
| `bus` | `pci` \| `usb` \| `acpi` | which bus reported the device; picks the namespace for the id columns |
| `base` | PCI base class / USB class | hex |
| `class` | PCI subclass / USB subclass | hex |
| `prog_if` | PCI prog-IF / USB protocol | hex |
| `vendor` | PCI vendor / USB idVendor | hex |
| `device` | PCI device / USB idProduct | hex |
| `subsystem` | PCI subsystem, `(ssvid<<16)\|ssid` | hex; blank for usb/acpi |
| `hid` | ACPI `_HID` (e.g. `PNP0303`) | blank for pci/usb |
| `driver` | full ramdisk path to spawn | e.g. `/system/drivers/virtio-gpu` |
`*` or an empty field is a **wildcard** — it matches anything and adds nothing to
a rule's specificity.
## Levels of detection: most-specific-wins
Several rows may match one device. The manager picks the **most specific** — the
one that pins the finest-grained fields. Specificity weights double from the
coarsest level so each outweighs all coarser levels combined:
```
base(1) < class(2) < prog_if(4) < vendor(8) < subsystem(16) < device(32) ≈ hid(32)
```
So the generic `pci, 03, 00, 00, …/display` rule and the precise
`pci, 03, 80, *, 1AF4, 1050, …/virtio-gpu` rule coexist: the virtio card
(vendor 1AF4, device 1050) takes the specific rule; a plain VGA adapter still
falls to the generic one. Two rules that match a device with the *same*
specificity are a registry authoring error — the manager logs it loudly and binds
the first, so the shadowed rule is visible rather than silently dropped.
## Authoritative — no code fallback
There is no compiled-in default table behind the registry. A device that no row
matches goes **unbound** and is logged; the manager never guesses. A missing or
empty `/etc/devices.csv` therefore means nothing matches — which is loud at boot,
not a silent half-working system.
## How the manager reads it
`/etc/devices.csv` is bundled into the initial ramdisk (`build.zig`'s `bundled`
list). The kernel serves the initrd's `/etc` tree directly — the `fat` service is
spawned *after* the device manager and is irrelevant to `/etc` — so the manager
reads the file with a plain `fs.open("/etc/devices.csv")` + `read`, with no
filesystem service running and no boot-ordering dependency. It parses the bytes
once in `initialise`, before any bus driver can report a device to match.
## Feeding the matcher: the widened report
Finer-grained matching needs identity the old ABI threw away. Two things carry it
now: `child_added` (and `DeviceDescriptor`) grew `vendor` / `device` /
`subsystem` fields, filled by the PCI bus driver from config space (offsets
0x00 and 0x2C); and each bus driver states its `bus` in the report (a `BusKind`),
so the manager reads a PCI class triple and a USB class triple — the same 24 bits
in different namespaces — against the right `bus` column.
## Adding a driver
(The step-by-step walkthrough with a worked example is
[new-driver-checklist.md](new-driver-checklist.md).)
1. Create `system/drivers/<name>/` with the driver source plus a ~15-line
package `build.zig` + `build.zig.zon` (copy an existing driver package,
e.g. `system/drivers/pci-bus/`; per-driver extras go through
`build_support.programModule`). Then bundle it at `/system/drivers/<name>`:
one dependency + one bundled entry in the root `build.zig`, one line in the
root `build.zig.zon`.
2. Add a row to `etc/devices.csv` naming the identity it binds and its full path.
No device-manager change is required — the registry is the seam.
@@ -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
@@ -17,18 +18,19 @@ Read [display.md](display.md) first for the *why*; this is the *what* and the *o
## Conventions
Follow [coding-standards.md](coding-standards.md): spell out non-acronym abbreviations in
Follow [coding-standards.md](../coding-standards.md): spell out non-acronym abbreviations in
full, kebab-case file names, no `Co-Authored-By` trailers on commits. New user binaries
go through `addUserBinary` in [build.zig](../build.zig) and get packed into the
initial-ramdisk; protocols are `b.addModule("…-protocol", …)` and imported into the
`runtime` module.
go through build-support's shared user-binary recipe and get packed into the
initial-ramdisk; protocols are modules exported by the `library/protocol` package.
(This section predates the build-packages split; see
[build-packages-plan.md](../build-packages-plan.md) for the current build shape.)
## How to verify along the way
- `zig build test` — host unit tests (compositor math: layer clipping, damage merge,
pitch/format blits are all host-testable with a fake framebuffer).
- `python3 test/qemu_test.py <case>` — boots the real kernel in QEMU; assert on the
serial log ([tests.zig](../system/kernel/tests.zig) is the registry).
serial log ([tests.zig](../../system/kernel/tests.zig) is the registry).
- The `run-efi` target renders to QEMU's display (`-device VGA,edid=on,xres=1280,yres=720`)
— a screenshot confirms pixels for the milestones whose gate is visual.
@@ -38,22 +40,22 @@ initial-ramdisk; protocols are `b.addModule("…-protocol", …)` and imported i
Make the boot framebuffer reachable and mappable **write-combining** from user space.
- [x] [device-abi.zig](../system/devices/device-abi.zig): added `DeviceClass.display`; a
- [x] [device-abi.zig](../../library/device/model/device-abi.zig): added `DeviceClass.display`; a
`DisplayInfo{ width, height, pitch, format }` carried on the descriptor; a
`flags` field on `ResourceDescriptor` + `resource_flag_write_combining`.
- [x] [devices-broker.zig](../system/kernel/devices-broker.zig): `seedDisplay(base, w, h,
- [x] [devices-broker.zig](../../system/kernel/devices-broker.zig): `seedDisplay(base, w, h,
pitch, format)` publishes a root-level `display` node with one WC-flagged `memory`
resource `[base, height*pitch]` + the `DisplayInfo`; `displayDevice()` /
`displayClaimed()`. Seeded from `kmain` after `devices_broker.init`.
- [x] [process.zig](../system/kernel/process.zig) `systemMmioMap` + paging
- [x] [process.zig](../../system/kernel/process.zig) `systemMmioMap` + paging
(`mapUserDeviceInto` gains a `write_combining` bool): a resource's WC flag maps it
through the WC PAT slot (`setupPat`) instead of strong-uncacheable.
- [x] [console.zig](../system/kernel/console.zig): `setSuppressed` quiesces `write` while
- [x] [console.zig](../../system/kernel/console.zig): `setSuppressed` quiesces `write` while
the display device is claimed (driven from `systemDeviceClaim` / release); the
terminal panic + exception paths clear it first so a dying machine still draws.
**Gate (met, automated):** the `display` kernel test (`python3 test/qemu_test.py display`,
`displayTest` in [tests.zig](../system/kernel/tests.zig)) asserts the seeded node's shape
`displayTest` in [tests.zig](../../system/kernel/tests.zig)) asserts the seeded node's shape
and geometry, then walks the real claim + `mmio_map` path into a throwaway address space
and verifies the leaf is **write-combining** (PAT entry 4: PAT bit set, PCD/PWT clear) —
with an uncacheable-still-uncacheable regression guard. Chosen over the original
@@ -66,10 +68,10 @@ Regression-checked: `discovery`, `ioport`, `claim-release`, `supervision`, `devi
Stand up the named service and the double-buffer, no layers yet.
- [x] `system/services/display/protocol.zig`: `Operation{ info, create_layer,
- [x] `library/protocol/display/display-protocol.zig`: `Operation{ info, create_layer,
configure_layer, destroy_layer, fill_rect, blit_tile, damage, present }`; `extern`
`Request`/`Reply`; size + `maximum_payload` consts. (Model: block/protocol.zig.)
- [x] [abi.zig](../system/abi.zig): `ServiceId.display = 9`.
- [x] [abi.zig](../../system/abi.zig): `ServiceId.display = 9`.
- [x] `system/services/display/display.zig`: `main` → enumerate + claim + WC-map the LFB
(front) → `mmap` a cacheable back buffer of `height*pitch` → `runtime.service.run`.
`info` and a whole-screen `present` (back → front) are live; layer ops fail-stub
@@ -77,8 +79,8 @@ Stand up the named service and the double-buffer, no layers yet.
- [x] [library/runtime/display.zig](../library/runtime/runtime.zig) (+ barrel export of
`display` and `display_protocol`): `info()` and `present()`, cached `.display`
lookup with retry (model: block.zig).
- [x] [init.zig](../system/services/init/init.zig): `"display"` added to `boot_services`.
- [x] [build.zig](../build.zig): `display-protocol` module on the runtime; `display` exe
- [x] [init.zig](../../system/services/init/init.zig): `"display"` added to `boot_services`.
- [x] [build.zig](../../build.zig): `display-protocol` module on the runtime; `display` exe
via `addUserBinary`; packed into the initial-ramdisk; installed to
`/system/services/display`.
- [x] **Kernel fix the back buffer surfaced:** `mmap` was capped at 256 pages (1 MiB) by
@@ -104,7 +106,7 @@ The heart: composite an ordered layer stack, present only what changed.
- [x] `create_layer` / `configure_layer` (damages old + new footprints) / `destroy_layer`,
`fill_rect`, `blit_tile` (reads the inline tile from the IPC payload, unaligned-safe),
`damage`, `present`.
- [x] Pure, host-tested [compositor.zig](../system/services/display/compositor.zig): `Rect`
- [x] Pure, host-tested [compositor.zig](../../system/services/display/compositor.zig): `Rect`
(intersect/unite), `Surface`, `fillRect`, `composite` (opaque, clipped to a damage
rect), `blitTile`. `present` clears the damaged region to the wallpaper, paints the
visible layers bottom-to-top (z-sorted), and flushes just that rect back → front (WC).
@@ -134,7 +136,7 @@ Prove the pipeline end-to-end from a separate process.
every IPC message at `MESSAGE_MAXIMUM` = 256 — so `replyWait` rejected the oversized
receive buffer with `-E2BIG` and the serve loop had been *spinning* since D2 (unseen,
as D2/D3 matched init-time heartbeats). Set it to 256; `blit_tile` is now explicitly
a small-tile path (≤ 54 px inline), larger bitmaps being the deferred shm surface.
a small-tile path (≤ 54 px inline), larger bitmaps being the deferred shared-memory surface.
**Gate (met):** `python3 test/qemu_test.py display-demo` spawns the service + `display-demo`;
the demo drives a run of frames of motion through the layer client API and logs
@@ -147,10 +149,10 @@ still pass, and the default `zig build` is clean.
- [x] The three integration cases exist and pass: `display` (D1 handoff, kernel),
`display-service` (D2/D3 compositor + self-check), and `display-demo` (D4 full
pipeline: spawn `display` + `display-demo`, match `display-demo: ok`) —
[tests.zig](../system/kernel/tests.zig) + [qemu_test.py](../test/qemu_test.py). Plus
[tests.zig](../../system/kernel/tests.zig) + [qemu_test.py](../../test/qemu_test.py). Plus
the pure host tests (`zig build test`).
- [x] [display.md](display.md) updated to the built state (the "Verifying it" section names
the real cases); [README index](README.md) entry present (#19); the `display-track`
the real cases); [README index](../README.md) entry present (#19); the `display-track`
memory marked DONE with the commits.
**Gate (met):** `python3 test/qemu_test.py display display-service display-demo` all pass,
@@ -172,7 +174,7 @@ documented (docs/display.md): no runtime mode-setting (native backend) and no tr
## Deferred (explicitly not in this plan)
- **Shared-memory surfaces** — generalize M13 capability passing to memory objects
(`shm_create`/`shm_map`), so bitmap clients hand the compositor a rendered surface
(`shared_memory_create`/`shared_memory_map`), so bitmap clients hand the compositor a rendered surface
instead of drawing commands. The compositor's layer model already anticipates it.
- **Native backend (Bochs DISPI, then virtio-gpu)** — behind the same internal backend
interface as the dumb framebuffer: EDID mode list + runtime resolution/bpp change +
@@ -0,0 +1,177 @@
# Display v2 — build plan (pluggable scanout: GOP floor + virtio-gpu native)
The ordered, checkpointable build-out for [display-v2.md](display-v2.md). Each milestone
lands on its own and ends in a **verifiable gate** — shaped for a `/loop` run, like
[display-plan.md](display-plan.md). Read display-v2.md first for the *why*.
## Locked decisions (do not relitigate)
- **First native backend = virtio-gpu** (VM standard: mode-set + fenced present/flush).
- **Dynamic hot-attach**: boot on GOP, upgrade to native when the driver **announces**
(push, not polling); re-attach across driver restarts; GOP is the floor for "no driver
ever," not a live fall-back after a reprogram.
- **v2 builds the shared-memory capability** (endpoints → memory objects), shared with the future
client-surface path.
- The compositor's layers/back-buffer/damage are **unchanged**; only scanout is pluggable.
## Conventions
Follow [coding-standards.md](../coding-standards.md): spell out non-acronym abbreviations,
kebab-case file names, no `Co-Authored-By` trailers. New user binaries go through
build-support's shared user-binary recipe and get packed into the initial-ramdisk;
protocols are modules exported by the `library/protocol` package; new syscalls extend
[abi.zig](../../system/abi.zig) `SystemCall` + a `library/kernel` wrapper.
(This section predates the build-packages split; see
[build-packages-plan.md](../build-packages-plan.md) for the current build shape.)
## How to verify along the way
**Every gate is serial-checkable — no screenshots** (this plan is built to run unattended).
Where "does it actually display" would otherwise need a human eyeball, the code **reads its
own pixels back**: the scanout resource is CPU-visible RAM (shared-memory-backed) and the back buffer
is cacheable, so a driver/compositor can write a known value, read it back, and log a
pass/fail — and a virtio `resource_flush` is confirmed by the device **acking it on the
used ring**. Those two together (pixel-readback + flush-ack) are the automated stand-in for
"it's on screen."
- `zig build test` — host unit tests (backend selection, virtio struct sizes/encodings,
pixel-check helpers).
- `python3 test/qemu_test.py <case>` — boots the kernel in QEMU; asserts on serial markers.
The virtio cases boot with `-device virtio-gpu` (a per-case `qemu_extra`).
- `run-x86-64` renders to a window — for the human's own satisfaction, **not** a gate.
---
## V1 — The scanout backend seam (refactor, no behaviour change) ✅
Extract scanout from the compositor so today's path becomes one backend among future ones.
- [x] `system/services/display/backend.zig`: a `Backend` tagged union with `info()`,
`surface()` (the cacheable compose target), `present(damage)`, and capability flags
(`canModeSet`/`hasFencedPresent`, both false for GOP).
- [x] The v1 GOP path is now `backend.Gop` (claims the `display` node, WC-maps the LFB,
keeps the cacheable back buffer, `present` = the damage-rect WC copy). display.zig
composes into `backend.surface()` and calls `backend.present(damage)` — no LFB or
framebuffer geometry left in the compositor core.
- [x] The selection decision is the pure `chooseKind(native_available)` (gop unless a
native driver announced), split from the syscall-bound `select()`/`Gop.init()`.
**Gate (met):** `display-service` + `display-demo` pass **unchanged** (pure refactor; GOP
is the only backend), and `zig build test` stays green.
## V2 — The shared-memory cross-process capability (kernel) ✅
- [x] [abi.zig](../../system/abi.zig): `shared_memory_create` (34) / `shared_memory_map` (35) syscalls + a
`shared_memory_test` service id. Handlers in process.zig: `shared_memory_create(len)` allocates contiguous,
zeroed, **cacheable** frames, wraps them in a refcounted object, installs a capability
handle, maps them into the caller's shared-memory arena → returns virtual_address + handle; `shared_memory_map(cap)`
maps the same physical pages into the receiver. Reclaimed on death (see below).
- [x] The capability core (ipc-synchronous.zig) is now **kind-tagged**: `scheduler.Task`'s
handle table holds `HandleObject{kind, ptr}`; `closeHandles` and `shareCapability`
dispatch by kind, so a `SharedMemoryObject` rides an `ipc_call` `send_cap` exactly like an
endpoint and frees only when its last capability drops. `mapUserSharedInto` (paging)
maps WB-cacheable + `device_grant`, so a sharer's teardown never frees the shared
frames — the object owns them.
- [x] `library/runtime/shared-memory.zig` (+ barrel export): `create(len) -> Region{ptr, handle, len}`,
`map(handle) -> ptr`.
**Gate (met):** `python3 test/qemu_test.py shared-memory` — `shared-memory-client` creates a region, writes a
pattern, and passes its capability to `shared-memory-server` as an `ipc_call` send_cap; the server
`shared_memory_map`s it and reads the **same bytes** back → `shared-memory: shared 4096 bytes ok`. Guardrail:
`ipc`/`ipc-call`/`ipc-cap`, `supervision`, `dma`, `usermem`, `display-service`, and host
tests all still pass — the handle-table change broke no existing IPC.
## V3 — The virtio-gpu driver: bring-up + a frame on screen ✅
- [x] `system/drivers/virtio-gpu/`: claim the virtio-gpu PCI function (device-manager
match on the display/other class triple, driver self-confirms vendor 0x1AF4/device
0x1050 from config space), enable memory-space + bus-master, walk the vendor
capabilities in config space to find common-config + notify, map the BAR, negotiate
VERSION_1, and stand up the control virtqueue in coherent DMA. `virtio-gpu-protocol.zig`
+ `virtio-pci.zig` for the control/transport structs (host-tested sizes).
- [x] Create a 2D scanout resource backed by a coherent DMA region (V4 swaps this for the
shared-memory surface), `attach_backing`, `set_scanout` to scanout 0, `transfer_to_host_2d`
+ `resource_flush` of a test pattern, and wait on the used ring.
- [x] Register a `scanout` service (`ServiceId.scanout` = 11).
**Gate (met):** the `virtio-gpu` case (QEMU `-device virtio-gpu-pci`) boots the
device-manager stack, which discovers the function and spawns the driver; the driver writes
a known test pattern into the scanout backing, `transfer_to_host_2d` + `resource_flush`es
it, and **waits for the device's used-ring ack**, then reads the backing back and checks the
pattern — logging `virtio-gpu: scanout 640x480 online` and `virtio-gpu: flush acked, pixel
check ok`. That proves virtqueue + resource + attach + set_scanout + transfer + flush end to
end without a screenshot (the used-ring ack is the device confirming it consumed the frame).
## V4 — The native backend + hot-attach ✅
- [x] `backend.VirtioGpu` in the compositor: `surface()` = the shared-memory scanout surface
(the compositor composes straight into the device's resource backing; x86 DMA is
coherent, so the cacheable shared pages need no flush), `present(damage)` = a `present`
request over the driver's `.scanout` endpoint (→ transfer-to-host + resource flush).
- [x] The driver **announces** to `.display` after bring-up (looks it up with a bounded retry,
sends `attach_scanout` with the geometry + the shared surface as an `ipc_call` send_cap).
The compositor maps it, looks up `.scanout` itself (no need to pass the endpoint — the
driver registered it), switches backend, and re-composites the current frame full-screen.
The present is deferred to a one-shot timer so it runs *after* the reply unblocks the
driver and it serves `.scanout` — presenting inline would deadlock.
- [x] Boot still starts on `backend.Gop`; the upgrade happens on announce. `shared_memory_physical` (a
new syscall) gives the driver the guest-physical of the shared surface for `attach_backing`.
**Gate (met):** the `display-native` case (QEMU `-device virtio-gpu-pci`, `mem` bumped since it
boots the whole system) starts the compositor + `display-demo` + device-manager; the driver
announces, the compositor logs `display: scanout upgraded to virtio-gpu`, drives frames through
the native backend, and **reads a pixel back** from the shared surface after a present to
confirm the composited frame landed (`display: native present verified`), while `display-demo:
ok` still fires — checked order-independently. Without `-device virtio-gpu-pci` nothing is
announced and it stays on GOP: the v1 `display-service`/`display-demo` gates pass unchanged.
## V5 — Mode-setting, EDID, and fenced presents ✅
- [x] The driver negotiates `VIRTIO_GPU_F_EDID` (when offered) and reads the monitor's EDID,
logging its preferred mode; it offers a small mode list over `.scanout` `get_modes`. The
resource + shared surface are sized to the largest mode, so `set_mode` just re-points the
scanout rectangle (no resource/surface churn) — a runtime resolution change. `runtime.display`
gains `modes()` / `setMode()` (display-protocol `get_modes`/`set_mode`, forwarded to the backend).
- [x] Every `resource_flush` is issued fenced (`VIRTIO_GPU_FLAG_FENCE`); the device signals the
fence when it has consumed the frame, which the used-ring ack the synchronous present waits
on already gates — a tear-free present. (Completion feedback, **not vblank**: base
virtio-gpu 2D has no display-refresh event, so nothing paces presents to the monitor —
see the "Fenced is not vsync" note in [display-v2.md](display-v2.md).)
- [x] `backend.VirtioGpu` reports `canModeSet` / `hasFencedPresent` = true.
**Gate (met):** the `display-modeset` case (reusing the display-native boot) upgrades to
virtio-gpu, queries the driver's modes, `setMode`s to a different resolution, and confirms the
change by reading the backend's geometry back (`display: mode set to {w}x{h}, verified`); the
fenced present path is exercised and confirmed (`display: fenced present ok`) — both from serial,
passing 3/3. The driver also logs the EDID preferred mode (`virtio-gpu: EDID preferred mode …`).
## V6 — Resilience (restart + re-attach) + tests + docs ✅
- [x] The virtio-gpu driver now **hellos** the device manager (role: bus) so it is properly
supervised — no longer stopped at the hello deadline — and is restarted on death. On
driver loss the compositor keeps the last frame (its `.scanout` calls now return
`-EPEER` instead of hanging — a kernel fix: an endpoint is marked dead when its owner
dies) and **re-attaches** when the restarted driver re-announces. A permanent give-up
(crash-loop cap) leaves the frozen frame; GOP is not re-taken.
- [x] `test/qemu_test.py`: the `virtio-gpu`, `display-native` (hot-attach), `display-modeset`,
and `display-reattach` (driver-kill/re-attach) cases. display-v2.md status updated.
**Gate (met):** the `display-reattach` case — device-manager (in `test-scanout-restart` mode)
kills the virtio-gpu driver once after it hellos; the restart policy respawns it, it
re-announces, and the compositor logs `display: scanout re-attached` after the initial
`display: scanout upgraded to virtio-gpu`, with no CPU exception / panic (the compositor
survives) — passing 3/3. All v1 + v2 cases (host tests, `ipc`/`ipc-call`/`ipc-cap`,
`supervision`, `shared-memory`, `display-service`, `display-demo`, `virtio-gpu`, `display-native`,
`display-modeset`) pass; default `zig build` is clean.
---
## Deferred (explicitly not in this plan)
- **Client-rendered surfaces** — now unblocked by the shared-memory capability (V2): an app renders
its own bitmap and hands the compositor a reference. A natural follow-on.
- **Bochs DISPI backend** — a simpler second native backend (mode-set only, dumb scanout);
slots behind the same interface if wanted.
- **Real-GPU (NVIDIA/AMD/Intel) drivers** — out of scope; those devices stay on the GOP
floor by design.
- **Hardware-accelerated compositing / multiple heads** — future.
@@ -0,0 +1,147 @@
# The display service v2: a pluggable scanout backend
**Status: complete (V1–V6).** The compositor boots on the GOP framebuffer and, when a
virtio-gpu driver announces itself, hot-attaches a native backend over the shared-memory
scanout surface — with runtime mode-setting, EDID, and fenced presents, and it
re-attaches across driver restarts. All serial-gated (see [display-v2-plan.md](display-v2-plan.md)).
v1 ([display.md](display.md)) is a compositor that owns the **GOP framebuffer** — it
composites a layer stack into a cacheable back buffer and streams damage to the linear
framebuffer the firmware handed over. That path is portable and good: it drives any GPU,
including a real NVIDIA card at an ultrawide's native resolution, with zero GPU-specific
code. v2 keeps it as the **floor** and makes *scanout* — how a finished frame reaches the
panel — a **pluggable backend**, so the compositor can **upgrade to a real GPU driver when
one is present** and fall back to the framebuffer when it isn't.
The compositor itself (layers, back buffer, damage) does not change. Only the last step —
"put this frame on screen" — becomes swappable.
## The shape
```
compositor (display service) ── layer stack + back buffer + damage (unchanged)
│ composites a frame, then: backend.present(damage)
▼
scanout backend (selected at runtime — GOP by default, native when it appears)
│
├─ GopBackend the v1 path: WC copy back→front to the firmware LFB.
│ Always available. No mode-set, no present fence. THE FLOOR.
│
└─ VirtioGpuBackend talks to a virtio-gpu driver process over a `scanout`
service: present via a shared resource + fenced flush,
a fixed mode list, runtime mode-set, EDID refresh rate.
```
A **backend** is a small interface the compositor calls:
- `surface()` → the pixels to compose into and their geometry `{ptr, pitch, format, w, h}`
(the LFB for GOP; a shared scanout resource for virtio-gpu),
- `present(damage: Rect)` → make the damaged region visible (a no-op-ish WC copy for GOP;
a fenced virtio flush for the native path),
- capability queries — `canModeSet`, `hasFencedPresent` — and, when supported, `modes()` /
`setMode(m)`.
The compositor composes into `surface()` and calls `present(damage)` exactly as it does
today; everything device-specific lives behind the interface.
## Selection and hot-attach
The choice is **dynamic**, because a GPU driver is spawned asynchronously (the device
manager brings it up after boot), and because danos is meant to be resilient:
1. **Boot on GOP.** The compositor starts on `GopBackend` immediately, so there is never a
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 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
resilience work already merged), re-announces, and the compositor **re-attaches**
(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. 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
virtio-gpu's scanout resource is **guest RAM** — the driver allocates it and attaches it
to a virtio resource, and the compositor composes into it. That means the compositor
writing into the driver's buffer is **cross-process memory sharing**, the primitive v1
deferred (docs/display.md, "What v1 does not do"). v2 builds it: the natural generalization
of M13 capability-passing from *endpoints* to *memory objects* —
```
shared_memory_create(len) -> {handle, virtual_address} // a shareable, page-aligned RAM region
… pass `handle` as the send_cap on an ipc_call …
shared_memory_map(cap) -> virtual_address // the receiver maps the same physical pages
```
The payoff is leverage: the **same** primitive unlocks **both** native GPU drivers *and*
client-rendered surfaces (an app composing its own bitmap and handing the compositor a
reference instead of drawing by command). One piece of kernel work, two features.
## The virtio-gpu driver
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 **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 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
dumb GOP framebuffer can't.
**Fenced is not vsync.** The fence completes when the device has *consumed* the frame:
real completion feedback, and tear-freedom by snapshot semantics (the host displays
discrete transferred frames, never a half-written surface). It is **not** a vblank —
base virtio-gpu 2D has no display-refresh event at all (Linux's driver for this device
fakes one with a software timer), so nothing paces presents to the monitor's refresh.
Refresh-paced presents need either a native driver's vblank interrupt (delivered over
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 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:
the **pluggable architecture** (a driver slots in when one exists) and a **rich, fenced
path in VMs**, where danos development happens. The framebuffer floor never goes away.
## Locked decisions
- **First native backend: virtio-gpu** — the VM standard; gives mode-set + a real
present/flush (fenced), and exercises the whole pluggable design. Tested with QEMU
`-device virtio-gpu`.
- **Dynamic hot-attach** — boot on GOP, upgrade to native on the driver's announce,
re-attach across driver restarts; GOP is the floor for "no driver ever," not a live
fall-back after a reprogram.
- **Detection = push** (the driver announces to `.display`), not compositor polling.
- **v2 builds the shared-memory capability** (endpoints → memory objects), shared with the future
client-surface path.
## See also
- [display.md](display.md) — v1: the compositor, the GOP-vs-device split, the WC discipline.
- [display-v2-plan.md](display-v2-plan.md) — the ordered build-out.
- [driver-model.md](driver-model.md) — claim / `mmio_map` / MSI / capability passing (M13).
- [resilience.md](../os-development/resilience.md) — the restart machinery the hot-attach leans on.
@@ -1,12 +1,12 @@
# The display service: a framebuffer compositor
The [framebuffer](framebuffer.md) the loader hands over is a flat block of pixel
memory, and the kernel's [bootstrap console](../system/kernel/console.zig) draws text
The [framebuffer](../os-development/framebuffer.md) the loader hands over is a flat block of pixel
memory, and the kernel's [bootstrap console](../../system/kernel/console.zig) draws text
into it directly. That console is a stop-gap. The **display service**
(`system/services/display/`) is the real thing: an ordinary ring-3 process that *owns*
the framebuffer, composes a stack of **layers** into an off-screen back buffer, and
**presents** finished frames to the screen — the display half of the GUI track
([vision.md](vision.md)), the sibling of the [input service](input.md).
([vision.md](../vision.md)), the sibling of the [input service](input.md).
This note is the architecture and the reasoning behind it. The concrete build order
lives in [display-plan.md](display-plan.md).
@@ -20,20 +20,20 @@ which one you're holding decides what you can do.
- **GOP is firmware's *temporary* driver** for the display controller. It gives you a
linear framebuffer pointer and can set video modes — but only until
`ExitBootServices`. The loader already leans on this: [`queryFramebuffer`](../boot/efi.zig)
`ExitBootServices`. The loader already leans on this: [`queryFramebuffer`](../../boot/efi.zig)
reads the monitor's EDID, picks the native mode, and calls `set_mode` **before**
exiting ([gop.md](gop.md)). Once the kernel runs, GOP is **gone** — no `set_mode`, no
exiting ([gop.md](../os-development/gop.md)). Once the kernel runs, GOP is **gone** — no `set_mode`, no
mode list, no EDID. What survives is the frozen snapshot in
[`BootInformation.framebuffer`](../system/boot-handoff.zig): `{base, width, height,
pitch, format}`, and nothing more.
[`BootInformation.framebuffer`](../../system/boot-handoff.zig): `{base, width, height,
pitch, format, refresh_hz}`, and nothing more.
- **The PCI class-0x03 device is the raw controller** — BARs, config space, registers,
IO ports. It is what you actually *own* after boot. On QEMU's emulated adapter
([`-device VGA,edid=on`](../build.zig), the Bochs VBE/DISPI model) the `base` GOP handed
([`-device VGA,edid=on`](../../build/qemu.zig), the Bochs VBE/DISPI model) the `base` GOP handed
you *is* that device's linear-framebuffer BAR — the same physical memory, seen through
a different door. On a real discrete GPU, GOP's `base` is an aperture inside the GPU's
VRAM BAR. danos already decodes this device
([pci-class.zig](../system/devices/pci-class.zig) has the full `display` namespace, and
([pci-class.zig](../../library/device/pci/pci-class.zig) has the full `display` namespace, and
`pci-bus` already reports it to the [device manager](device-manager.md) with its class
triple) — but nothing binds it yet.
@@ -63,48 +63,50 @@ rest of the system hasn't had to face:
1. **The framebuffer is kernel-only today.** It arrives through the boot handoff, is
mapped into the kernel's physmap, and is touched only by
[`console.zig`](../system/kernel/console.zig). It is *not* a
[devices-broker](../system/kernel/devices-broker.zig) node, so
[`console.zig`](../../system/kernel/console.zig). It is *not* a
[devices-broker](../../system/kernel/devices-broker.zig) node, so
`device.claim`/`mmio_map` cannot reach it, and there is no framebuffer
[syscall](syscall.md). A user-space display service needs a **new mechanism just to
[syscall](../os-development/syscall.md). A user-space display service needs a **new mechanism just to
touch the pixels**. (See "The handoff" below — this is built.)
2. **danos 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
([block/protocol.zig](../../library/protocol/block/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)
runtime.display commands: runtime.display surfaces:
create_layer / configure_layer shm_create → pass as a capability →
display commands: display surfaces:
create_layer / configure_layer shared_memory_create → pass as a capability →
fill_rect / blit_tile / damage the compositor maps & composites the
present client-rendered bitmap directly
```
The bring-up sequence mirrors a hardware driver's — it is the
[`usb-xhci-bus` `initialise`](../system/drivers/usb-xhci-bus/usb-xhci-bus.zig) shape
[`usb-xhci-bus` `initialise`](../../system/drivers/usb-xhci-bus/usb-xhci-bus.zig) shape
(claim → `mmio_map` → run loop) — and the request/reply service shell is the
[FAT](../system/services/fat/fat.zig) / [input](../system/services/input/input.zig) shape
([`runtime.service.run`](../library/runtime/service.zig) with a `protocol.zig` of
[FAT](../../system/services/fat/fat.zig) / [input](../../system/services/input/input.zig) shape
([`service.run`](../../library/kernel/service.zig) with a `protocol.zig` of
`extern struct` messages and an `Operation` tag).
**One process, for now.** v1 is a *single* service that both owns the framebuffer and
@@ -117,20 +119,23 @@ second backend or a second monitor appears; until then it is complexity with no
The framebuffer crosses into user space through the machinery that already exists for
every other device, rather than a bespoke syscall — so it inherits ownership,
release-on-death, and re-claim-on-restart for free (the [resilience](resilience.md)
release-on-death, and re-claim-on-restart for free (the [resilience](../os-development/resilience.md)
story: a crashed display service returns the LFB to the kernel, and its restart
re-claims it).
- 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
([`setupPat`](../system/kernel/architecture/x86_64/paging.zig)); this reaches it from
([`setupPat`](../../system/kernel/architecture/x86_64/paging.zig)); this reaches it from
the user mapping path. **This matters:** an uncacheable framebuffer makes the
back→front blit unusably slow.
- On `claim`, the kernel's bootstrap console goes quiet, so the two never fight over the
@@ -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.
@@ -155,8 +160,8 @@ Two buffers, with deliberately different memory types:
So a frame is: compose every dirty layer into the cacheable back buffer, then **present**
— copy the changed regions back→front in sequential, WC-friendly writes. Two details the
[framebuffer](framebuffer.md) note already establishes carry over: step rows by `pitch`,
not `width*4`; and handle both `rgbx` and `bgrx` [pixel formats](gop.md).
[framebuffer](../os-development/framebuffer.md) note already establishes carry over: step rows by `pitch`,
not `width*4`; and handle both `rgbx` and `bgrx` [pixel formats](../os-development/gop.md).
## Flicker vs. tearing — what double buffering does and doesn't buy
@@ -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
@@ -196,20 +207,60 @@ shell, a terminal, a cursor, and a wallpaper:
| `fill_rect` | fill a rectangle of a layer with a colour |
| `blit_tile` | copy a small client-supplied pixel tile into a layer (inline) |
| `damage` | mark a region of a layer dirty |
| `present` | composite dirty layers and flush to the screen |
| `present` | request a repaint: composited at the next frame-clock tick |
Text is intentionally *not* an operation — a client renders glyphs by blitting tiles
(the [PSF font](../system/kernel/font.psf) path the console already uses can move into a
(the [PSF font](../../system/kernel/font.psf) path the console already uses can move into a
client). Keeping the protocol to rectangles and tiles keeps the compositor small and the
policy in the client.
## `runtime.display`
`present` is a *request*, not an immediate flush: the compositor runs a ~60 Hz **frame
clock** (a one-shot kernel timer re-armed on demand), and each tick composites all the
damage accumulated since the last one. Any number of client presents and cursor moves
inside one interval coalesce into a single repaint — the software stand-in for vblank
pacing on backends that have none (all of them today; see
[display-v2.md](display-v2.md), "Fenced is not vsync"). Bring-up paths that must put
pixels on screen synchronously (initialisation, the self-checks) bypass the clock.
Clients speak the protocol through a new [`library/runtime/display.zig`](../library/runtime/runtime.zig),
the [`runtime.block`](../library/runtime/block.zig) shape (a cached `.display` lookup
## `display`
Clients speak the protocol through a new [`library/client/display/display.zig`](../../library/client/display/display.zig),
the [`block`](../../library/device/block/block.zig) shape (a cached `.display` lookup
with a boot-race retry): `display.info()`, a `Layer` handle with `fill` / `blitTile` /
`damage`, and `present()`. Application code never issues the raw syscalls — it calls the
runtime, as with every other danos service.
client module, as with every other danos service.
## The cursor: a mouse-listener thread feeding the compositor
The compositor is the single owner of the framebuffer — only the main `service.run` loop
touches the backend and the layer stack. Tracking the mouse without breaking that
ownership is the display's first use of [threads](../os-development/threading.md): the service is built
multi-threaded (`addThreadedUserBinary`) and, at startup, spawns a **mouse-listener
thread** beside the compositor loop.
- **Listener thread.** Blocks on the input service's mouse stream
(`input.subscribeMouse()`), accumulates the relative `dx`/`dy` motion into an absolute
cursor position clamped to the screen, and hands it to the compositor. It never touches
the compositor — so no lock guards the framebuffer. A parked `next()` leaves its core
free to halt ([halting.md](../os-development/halting.md)).
- **The channel.** A single-slot *latest-value* cell (`CursorChannel`) guarded by a
`Thread.Mutex`: the renderer wants where the cursor *is now*, not a replay of
every delta, so a new position overwrites the old. The listener also **pokes** the
compositor awake — the main loop is parked in `replyWait`, so the listener posts a
zero-payload `ipc.send` to the compositor's endpoint, which arrives as a
message-notification ([ipc.md](ipc.md)). The poke is *coalesced*: at most one is queued
while the main loop has not drained the last, so a fast mouse cannot flood the endpoint.
- **Render.** On the poke, the main loop takes the latest position and moves the cursor —
which is just a top-z compositor layer — with the existing `configure` + `present` path
(it damages the old and new footprints, so only those two rectangles repaint).
Two threading facts shape this (both in [threading.md](../os-development/threading.md)). IPC **handles do
not cross threads**, so the listener can't reuse the main loop's endpoint handle — it
`ipc.lookup(.display)`s its *own* handle to the same endpoint to poke through. And a
multi-threaded service doing concurrent IPC is why the kernel's endpoint-create / register
/ lookup syscalls now serialize under the big kernel lock. Shared fate applies: a fault in
the listener takes the whole display down, and the supervisor restarts the process
([resilience.md](../os-development/resilience.md)).
## What v1 does not do (and why that's fine)
@@ -218,21 +269,22 @@ both are clean additions behind the interfaces v1 establishes.
- **Client-rendered surfaces (shared memory).** The fast path for a bitmap-heavy app is
to render into its *own* buffer and hand the compositor a *reference*, not a stream of
commands. That needs the missing cross-process shared-memory primitive — best built as
the natural generalization of the existing M13 [capability passing](driver-model.md)
from *endpoints* to *memory objects* (`shm_create(len) → {cap, vaddr}`, pass `cap` on
an `ipc_call`, receiver `shm_map(cap) → vaddr`). v1 avoids it because server-owned
surfaces already prove the whole pipeline.
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; 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.
## Verifying it
Three QEMU test cases ([tests.zig](../system/kernel/tests.zig), `python3
Four QEMU test cases ([tests.zig](../../system/kernel/tests.zig), `python3
test/qemu_test.py <case>`), each layering on the last:
- **`display`** — the kernel handoff: the seeded `display` device is shaped correctly and
@@ -245,19 +297,28 @@ test/qemu_test.py <case>`), each layering on the last:
layer — logging `display: compositor self-check ok`.
- **`display-demo`** — the full pipeline from a separate process: the hardware-free
[`display-demo`](../system/services/display-demo/) client (the
[`input-source`](../system/services/input-source/) analog) drives layers — a wallpaper, a
sliding rectangle, a cursor — through the layer client API and heartbeats
[`input-source`](../test/system/services/input-source/) analog) drives layers — a wallpaper and
a sliding rectangle — through the layer client API and heartbeats
`display-demo: ok`, proving a frame travelled client → compositor → screen, exactly as
the [input test](input.md) proves an event travels source → service → subscriber. The
visible motion itself is a screenshot away via `zig build run-x86-64`.
the [input test](input.md) proves an event travels source → service → subscriber. It draws
no cursor and reads no input — the cursor is the service's own (below), and the demo
animates on its own frame timer, independent of the mouse (the test spawns `input`
alongside it to keep that independence honest). The visible motion itself is a screenshot
away via `zig build run-x86-64`.
- **`display-cursor`** — the mouse-listener thread end to end: with the `input` service up,
`input-source mouse` publishes pure motion, and the display's listener thread accumulates
it into a cursor position handed to the render loop over the `CursorChannel`. Once the
cursor has tracked a run of that motion, the service logs
`display: cursor tracking mouse ok`. Runs `smp: 4` — the compositor and listener threads
execute on different cores, which is what surfaced the IPC-under-lock requirement above.
The compositor's pixel math (rectangle clipping, fill, composite, tile blit) and colour
packing are additionally covered by pure host unit tests under `zig build test`.
## See also
- [framebuffer.md](framebuffer.md) — the linear framebuffer, pitch vs. width, `volatile`.
- [gop.md](gop.md) — GOP, and why only linear RGBX/BGRX modes are paintable.
- [framebuffer.md](../os-development/framebuffer.md) — the linear framebuffer, pitch vs. width, `volatile`.
- [gop.md](../os-development/gop.md) — GOP, and why only linear RGBX/BGRX modes are paintable.
- [input.md](input.md) — the sibling service; the async `ipc_send` fan-out.
- [driver-model.md](driver-model.md) — claim / `mmio_map`, capability passing, the trust model.
- [device-manager.md](device-manager.md) — matching and supervision (the native backend's route).
@@ -33,8 +33,8 @@ 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
seeds it ([discovery.md](discovery.md)); `device_register` grows it.
`DeviceDescriptor`, each with a parent, a class, and a set of resources. Firmware discovery
seeds it ([discovery.md](../os-development/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,
@@ -95,36 +95,66 @@ A "family" is two modules, not one:
- **A protocol module** — the IPC message types that let a class driver talk to
*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:
danos already has one of each: `library/device/pci/pci.zig` is a logic module (the
`Function` view of a claimed PCI function),
[`library/protocol/vfs/vfs-protocol.zig`](../../library/protocol/vfs/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/
runtime/ module "runtime" — syscalls, heap, ipc, device, stdio
mmio/ module "mmio" — volatile register access + barriers [M14]
bus/
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)
block/ module "block-protocol"
hid/ module "hid-protocol"
kernel/ the system library (kernel32-style): the syscall surface split by concern
— ipc, memory (heap/dma/shared-memory), process, time, logging,
file-system, thread, service, plus system-call stubs + start/root
device/ device code grouped by domain; each domain splits into a shareable
data module (enums/wire types, std-only) and a logic module (mmio/IPC)
mmio/ module "mmio" — typed volatile register access + barriers [M14]
model/ module "device-abi" — DeviceDescriptor, DeviceClass, ResourceKind
pci/ "pci-class" (data) + "pci" — config/BAR/capability walk (Function)
usb/ "usb-abi" + "usb-ids" (data) + "usb" — descriptors, control/interrupt/bulk client
acpi/ "acpi-ids" (data) + "aml" — _HID names, the AML interpreter
driver/ module "driver" — device-access syscalls + device-manager hello
block/ module "block" — the block-device client (a device type)
client/ userspace service clients — display, input (a program's view of a service)
protocol/ driver <-> service wire contracts, one module per directory
vfs/ block/ display/ scanout/ input/ power/ device-manager/ usb-transfer/
system/drivers/ one sub-project each → /system/drivers (no `d` suffix)
xhci/ HCD + bus driver imports runtime, pci, usb, mmio
usb-hid/ class driver imports runtime, usb, hid-protocol
block/ class driver imports runtime, block-protocol
usb-xhci-bus/ HCD + bus driver imports usb, mmio, usb-transfer-protocol (+ kernel modules)
usb-hid/ class driver imports usb, input-protocol (+ kernel modules)
virtio-gpu/ scanout driver imports pci, mmio, display-/scanout-protocol (+ kernel modules)
```
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 split by *dependency weight* is what lets the microkernel stay out of device
business: it imports only the `device-abi` data module (the descriptor types its broker
marshals across the syscall boundary) — never a logic module, never a taxonomy. That one
pure-data import is the only edge from `system/kernel/` into `library/`; decoding a class
code or `_HID` to a name is user space's job (the device manager owns those taxonomies).
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`.
If a class driver needs `mmio`, it has become an HCD and should be one.
A protocol lives in `library/protocol/` when it is the seam between a low-level driver and
a higher-level service (block ↔ filesystem, a scanout driver ↔ the compositor). A driver's
private wire to its *hardware* — virtio-gpu's command set — is not that; it stays a
driver-private file, like the virtio-pci transport beside it.
The build side of this has since landed: every binary owns a package whose
~15-line `build.zig` names EXACTLY the modules its source imports — the moral
equivalent of a C file's include list — and the shared recipe in
[`build-support/build.zig`](../../build-support/build.zig) (`userBinary`)
resolves each name from the library domain that exports it (kernel's concern
modules, the device driver libraries, the service clients, the protocols). An
undeclared `@import` is a compile error, and a domain none of the imports come
from never appears in the binary's manifest — a keyboard driver declares
`xkeyboard-config`; nothing else does (see
[build-packages-plan.md](../build-packages-plan.md)). That's the *entire*
mechanism — Zig modules already give you everything else.
The discipline that makes this work: **a class driver must not import a bus's *hardware*
logic module.** `usb-hid` imports `usb` (the transfer client) and `input-protocol`, never
`pci` and never `mmio`. If a class driver needs `mmio`, it has become an HCD and should be
one. The domain data modules (`usb-abi`, `usb-ids`, `pci-class`) carry no such weight — a
class driver, the device manager, or the kernel may share them freely.
## What exists today
@@ -132,20 +162,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.
- **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
driver mints a per-device endpoint and hands it to a class driver. The `ipc` module
exposes `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 the `usb` / `input`
client modules open their per-device and subscription channels with `callCap`.
- **M14** — DMA memory + the memory-ordering layer. `/lib/device/mmio` gives drivers typed
volatile access and `memoryBarrier`/`readMemoryBarrier`/`writeMemoryBarrier` (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/device/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
@@ -169,14 +203,17 @@ If a class driver needs `mmio`, it has become an HCD and should be one.
`system_spawn(name, arguments)` loads a binary bundled in the initial-ramdisk as a
fresh ring-3 process; `name` becomes the child's argv[0] and the optional
NUL-separated `arguments` blob its argv[1..], delivered on a SysV entry stack
([sysv.md](sysv.md)). This is what
([sysv.md](../os-development/sysv.md)). This is what
turned the device manager from "log the match" into "run the driver": the kernel now
spawns only `init`, `init` spawns the services, and the **device-manager** discovers
the hardware and spawns each driver ([drivers.md](drivers.md)). Ungated for now — a
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.
---
@@ -226,7 +263,7 @@ const dev_ep = ipc.callCap(h, // ... mint a per-device endpoint,
## M14 — DMA memory and the memory-ordering contract, for HCDs ✅ done
*Implemented: `/lib/mmio` (typed volatile access + `mb`/`rmb`/`wmb`, per-arch) and
*Implemented: `/lib/device/mmio` (typed volatile access + `memoryBarrier`/`readMemoryBarrier`/`writeMemoryBarrier`, per-arch) and
`dma_alloc`/`dma_free` (contiguous, pinned, uncacheable, reclaim-on-teardown, physical
address exposed). `dma_write_combining` still falls back to coherent — real WC needs
PAT, a small follow-up. The rest of this section is the original design note.*
@@ -240,8 +277,8 @@ once per page, maps writeback-cached, and never reveals a physical address.
**The fix.**
```
dma_alloc(len, flags) -> vaddr (rax), paddr (rdx)
dma_free(vaddr, len) -> 0
dma_alloc(len, flags) -> virtual_address (rax), physical_address (rdx)
dma_free(virtual_address, len) -> 0
flags: dma_coherent (1) uncacheable; the default and the only one that's portable
dma_wc (2) write-combining — needs PAT programmed; for framebuffers
@@ -268,31 +305,31 @@ doorbell.* = i; // volatile store to UC MMIO
// nothing stops the compiler reordering these; the device reads a stale descriptor
```
So the rules, which belong in `library/mmio.zig` and behind `arch`:
So the rules, which belong in `library/device/mmio/mmio.zig` and behind `arch`:
| Situation | Required |
|---|---|
| MMIO register read/write | `mmio.read` / `mmio.write` (volatile) |
| Fill DMA descriptor, then ring doorbell | `wmb()` between them |
| Woken by IRQ, then read what the device wrote | `rmb()` before the read |
| MMIO write that must complete before the next read | `mb()` |
| Fill DMA descriptor, then ring doorbell | `writeMemoryBarrier()` between them |
| Woken by IRQ, then read what the device wrote | `readMemoryBarrier()` before the read |
| MMIO write that must complete before the next read | `memoryBarrier()` |
And the per-arch lowering — the reason this must be an `arch` primitive and not a
sprinkling of `asm volatile`:
| | x86_64 | aarch64 |
|---|---|---|
| `mb()` | `mfence` | `dsb sy` |
| `rmb()` | `lfence` | `dsb ld` |
| `wmb()` | `sfence` | `dsb st` |
| `memoryBarrier()` | `mfence` | `dsb sy` |
| `readMemoryBarrier()` | `lfence` | `dsb ld` |
| `writeMemoryBarrier()` | `sfence` | `dsb st` |
| DMA cache coherency | coherent; nothing to do | **not guaranteed**; needs non-cacheable buffers or cache maintenance |
x86 is forgiving here — TSO plus strong-uncacheable MMIO means you usually get away
with a compiler barrier alone. ARM is not, and [vision.md](vision.md) makes ARM the win
with a compiler barrier alone. ARM is not, and [vision.md](../vision.md) makes ARM the win
condition. Build the abstraction while there is one caller to fix.
(Zig note: `@fence` was **removed in 0.16**. Use `@atomicRmw(..., .seq_cst)` for a full
barrier, or per-arch inline asm — which is what `library/mmio.zig` should hide.)
barrier, or per-arch inline asm — which is what `library/device/mmio/mmio.zig` should hide.)
## M15 — interrupts for PCI devices ✅ done (MSI)
@@ -302,8 +339,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.
@@ -363,6 +402,6 @@ from hand-rolling `*volatile` and getting ARM wrong.
## See also
- [drivers.md](drivers.md) — how to write one, concretely.
- [discovery.md](discovery.md) / [acpi.md](acpi.md) — where the device table comes from.
- [discovery.md](../os-development/discovery.md) / [acpi.md](../os-development/acpi.md) — where the device table comes from.
- [ipc.md](ipc.md) — endpoints, badges, and the notification path an IRQ arrives on.
- [resilience.md](resilience.md) — restart, the reason any of this is worth the trouble.
- [resilience.md](../os-development/resilience.md) — restart, the reason any of this is worth the trouble.
@@ -4,13 +4,13 @@ In a monolithic kernel a driver is a function call away from everything: it runs
ring 0, dereferences any physical address, and its interrupt handler *is* the ISR. In
danos a driver is **an ordinary ring-3 process**. It has its own address space, it
can crash without taking the kernel with it, and — the point of this document — it
can be restarted ([resilience](resilience.md)).
can be restarted ([resilience](../os-development/resilience.md)).
That leaves three questions the kernel has to answer, because a process can't answer
them for itself:
1. **What hardware exists?** → `device_enumerate`, over the device table discovery built
([discovery](discovery.md), [acpi](acpi.md)).
([discovery](../os-development/discovery.md), [acpi](../os-development/acpi.md)).
2. **How do I touch its registers?** → `device_claim` + `mmio_map`: the kernel maps the
device's physical MMIO window into your address space, and from then on it's plain
memory. No syscall per register access.
@@ -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
ACPI/PCI ([discovery](../os-development/discovery.md)).
2. **Match** — for each device it looks up a driver. The match policy is code, a few
small per-bus tables: from the boot snapshot only the PCI host bridge matches
(→ `pci-bus`); everything else arrives later as bus reports and matches on
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
@@ -68,13 +75,13 @@ capability yet.
## The capability: claim before touch
The driver syscall numbers (`system/abi.zig`) with the device types they carry
(`system/devices/device-abi.zig`), dispatched in `system/kernel/process.zig`:
(`library/device/model/device-abi.zig`), dispatched in `system/kernel/process.zig`:
| # | Call | Meaning |
|---|------|---------|
| 11 | `device_enumerate(buf, max) -> total` | Snapshot the device table |
| 12 | `device_claim(id) -> ok` | Take **exclusive** ownership |
| 13 | `mmio_map(id, res_idx) -> vaddr` | Map a claimed device's register window |
| 13 | `mmio_map(id, res_idx) -> virtual_address` | Map a claimed device's register window |
| 14 | `irq_bind(id, res_idx, endpoint)` | Deliver that device's IRQ as a notification |
| 15 | `irq_ack(id, res_idx)` | Re-arm the IRQ after servicing the device |
| 16 | `device_register(parent_id, desc) -> id` | Publish a child of a device you claimed |
@@ -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,15 +259,17 @@ 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`.
See [`system/drivers/pci-bus/pci-bus.zig`](../system/drivers/pci-bus/pci-bus.zig) for a
See [`system/drivers/pci-bus/pci-bus.zig`](../../system/drivers/pci-bus/pci-bus.zig) for a
real one — it claims a PCI host bridge, maps its ECAM window, and publishes each function
it finds as a child — and [driver-model.md](driver-model.md) for how bus drivers, class
drivers and host controller drivers fit together.
@@ -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/device/mmio`'s
`memoryBarrier`/`readMemoryBarrier`/`writeMemoryBarrier`, imported as the `mmio` module), **fault isolation** (a ring-3 fault kills only the faulting
process — `killCurrentProcess` — and the machine keeps running,
[resilience](../os-development/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
@@ -358,31 +370,31 @@ is **port I/O** (`io_read`/`io_write`, the claim-gated syscalls that make a PS/2
driver possible). What's left is IOMMU *enforcement* (per-device domains — it waits on
the first DMA driver to protect and test against) and these smaller items:
- **Releasing a claim.** There is no `dev_release`, and `devices_broker` never drops a claim on
exit — only IRQ bindings are released. A dead driver's device stays owned forever,
which blocks restart.
- **Unregistering children.** `device_register` only appends. A USB device that is
unplugged cannot be removed, and a bus driver in a loop can exhaust the 64-entry
table.
- **Restart.** A supervisor that *spawns* drivers now exists — the device-manager starts
them with `system_spawn` — but a supervisor that *restarts* them does not. A driver that
dies should release its claim, have its device quiesced, and be respawned; today nothing
notices the death. Some pieces (`releaseIrqs`, `device_grant` teardown, the claim table)
exist, and `dev_release` (below) is the missing mechanism; the restart policy is the
resilience track ([resilience.md](resilience.md)).
- **Interrupt priority / threaded IRQ latency.** `notifyFromIsr` enqueues the woken
driver but doesn't preempt (`wakeLocked` deliberately leaves that to the caller), so
a woken driver waits for the next scheduling point.
- **Releasing a claim** — half done. The kernel now drops *all* of a dead driver's
claims on every path out of a process (`releaseAllOwnedBy`, called from process
teardown), which unblocked restart. A voluntary `dev_release` for a live driver
still doesn't exist.
- **Unregistering children** — half done. Hot-remove works at the manager layer:
the xHCI bus reports `child_removed` on unplug and the device manager prunes its
tree. The kernel's own device table is still append-only, so a bus driver in a
loop can still exhaust the 64-entry table.
- **Restart** — done. The device manager notices a driver's death, reads its exit
reason, prunes the children it reported, and respawns it with exponential
backoff — with a crash-loop cap that marks a repeat offender `failed` instead
of respawning forever ([device-manager.md](device-manager.md)).
- **Interrupt priority / threaded IRQ latency** — still open. `notifyFromIsr`
enqueues the woken driver but doesn't preempt (`wakeLocked` deliberately leaves
that to the caller), so a woken driver waits for the next scheduling point.
## The driver contract (M17–M18)
Claiming and mapping is half of being a danos driver; the other half is the
**lifecycle and protocol contract**, and the runtime makes it nearly free:
- Build on `runtime.service.run` — one replyWait loop folding protocol
- Build on `service.run` — one replyWait loop folding protocol
requests, signals, and notifications into callbacks. The harness answers the
universal zero-length ping and turns `terminate` into a clean exit for you
([process-lifecycle.md](process-lifecycle.md)).
([process-lifecycle.md](../os-development/process-lifecycle.md)).
- A driver spawned with an assignment (its device id as argv[1]) sends the
versioned `hello` to the device manager inside the deadline, and a **bus**
driver reports what it discovers with `child_added`
@@ -5,14 +5,14 @@ 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
The service carries three device classes today — **keyboard**, **mouse**, and
**joystick/gamepad** — and is built to take more
([protocol.zig](../system/services/input/protocol.zig)). Each class has its own typed
([protocol.zig](../../library/protocol/input/input-protocol.zig)). Each class has its own typed
event:
- `KeyEvent` — `key_down`/`key_up` (physical make/break) and `key_press` (a character was
@@ -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"):
@@ -63,7 +66,7 @@ the badge (distinguishing it from a bare IRQ/child-exit notification), the sende
in the low bits, and the payload in the receive buffer, with no reply owed. The queue holds
16 messages per endpoint; a full queue **drops the oldest**, because a buffered message is
discrete data, not a coalescing "level" like an interrupt. See
[ipc-synchronous.zig](../system/kernel/ipc-synchronous.zig) (`sendLocked`, `popPost`, and
[ipc-synchronous.zig](../../system/kernel/ipc-synchronous.zig) (`sendLocked`, `popPost`, and
the `replyWait` receive loop).
This is the async counterpart of `ipc_call`, and the input service is its first consumer.
@@ -86,7 +89,7 @@ This is the async counterpart of `ipc_call`, and the input service is its first
- A **subscriber** calls `input.subscribe(mask)` — or a typed helper: `subscribeKeyboard()`,
`subscribeMouse()`, `subscribeJoystick()` (one class, `next()` returns the decoded event),
or `subscribeAll()` (every class, `next()` returns a tagged `InputEvent`)
([library/runtime/input.zig](../library/runtime/input.zig)). It creates its own endpoint
([library/client/input/input.zig](../../library/client/input/input.zig)). It creates its own endpoint
and hands it to the service as a **capability** (M13 capability passing — the input
service is that feature's first real user), along with its `device_mask`. Then it loops on
`next()`, a `replyWait` on that endpoint returning each pushed event.
@@ -95,7 +98,7 @@ This is the async counterpart of `ipc_call`, and the input service is its first
`publishJoystickEvent`. Publishing is a short synchronous `ipc_call` the service answers at
once; the service's own fan-out is asynchronous, so publishing never blocks on a slow
subscriber.
- The **service** ([input.zig](../system/services/input/input.zig)) keeps a small subscriber
- The **service** ([input.zig](../../system/services/input/input.zig)) keeps a small subscriber
table (endpoint handle + owning task id + `device_mask`). On `publish` it `ipc_send`s the
event to every subscriber whose mask includes the event's device class. On `subscribe` it
stores the passed capability and mask and, as housekeeping, prunes any slot whose owning
@@ -110,18 +113,18 @@ the service delivers to its endpoint, which only the same thread could receive).
- **The keyboard is real.** The `ps2-bus` driver owns PNP0303, which carries *both* the
0x60/0x64 ports and IRQ1, so reading the hardware lives in the bus, not in
[keyboard.zig](../system/drivers/ps2-bus/keyboard.zig): the bus binds IRQ1 and, on each
[keyboard.zig](../../system/drivers/ps2-bus/keyboard.zig): the bus binds IRQ1 and, on each
interrupt, drains port 0x60, routing every byte by the status register's
auxiliary-output bit to whichever child driver **attached** for that device (an
`AttachRequest` to the well-known `ps2_bus` service, carrying the child's endpoint as a
capability; the bytes then arrive as asynchronous `ForwardedByte` messages, so the IRQ
path never blocks on a child). The keyboard driver decodes the stream — scancode **set 2**,
what the keyboard sends with the 8042's legacy translation off, decoded by
[scancode.zig](../system/drivers/ps2-bus/scancode.zig) into USB HID usage keycodes with
[scancode.zig](../../system/drivers/ps2-bus/scancode.zig) into USB HID usage keycodes with
make/break, typematic-repeat, and modifier tracking (host-tested under `zig build test`) —
and publishes real `key_down`/`key_press`/`key_up` events.
- **Keycode → character** is wired in: the keyboard driver fills a `key_press` event's
`character` through [`library/xkeyboard-config`](../library/xkeyboard-config/README.md)
`character` through [`library/xkeyboard-config`](../../library/xkeyboard-config/README.md)
(`xkb.map(layout, keycode, mods)` → keysym + Unicode character), synthesizing the ASCII
control characters for Enter/Tab/Backspace/Escape, whose keysyms map to no Unicode. The
layout defaults to `us`; the bus can pass another as the driver's argv[2] — the seam for
@@ -129,9 +132,9 @@ the service delivers to its endpoint, which only the same thread could receive).
- **The mouse is real too.** IRQ12 is enumerated on the auxiliary device's own ACPI node
(PNP0F13), so the bus claims that node alongside the controller and routes both IRQs to
its one endpoint, acking whichever line the notification's badge names.
[mouse.zig](../system/drivers/ps2-bus/mouse.zig) attaches the way the keyboard does and
[mouse.zig](../../system/drivers/ps2-bus/mouse.zig) attaches the way the keyboard does and
assembles the forwarded bytes with
[mouse-packet.zig](../system/drivers/ps2-bus/mouse-packet.zig) (three-byte stream-mode
[mouse-packet.zig](../../system/drivers/ps2-bus/mouse-packet.zig) (three-byte stream-mode
packets: sync/overflow handling, nine-bit movement, screen-convention `dy` — host-tested
under `zig build test`) into `button_down`/`button_up` transitions and `motion` events.
**Follow-up:** the IntelliMouse magic-knock for a scroll wheel (four-byte packets) and
@@ -147,7 +150,7 @@ the service delivers to its endpoint, which only the same thread could receive).
## Verifying it
The `input` case (`python3 test/qemu_test.py input`, in
[tests.zig](../system/kernel/tests.zig) `inputTest`) boots the real kernel and spawns the
[tests.zig](../../system/kernel/tests.zig) `inputTest`) boots the real kernel and spawns the
service, the synthetic source (which cycles keyboard, mouse, and joystick events), and a
subscriber that took all three classes. It passes only when the subscriber heartbeats
`input-test: ok` — proof that an event travelled source → service → subscriber over IPC,
@@ -157,5 +160,5 @@ serial line names the class received, so the log shows all three arriving on one
## See also
- [ipc.md](ipc.md) — the synchronous rendezvous and the notification path `ipc_send` extends.
- [syscall.md](syscall.md) — the system-call surface, including `ipc_send`.
- [syscall.md](../os-development/syscall.md) — the system-call surface, including `ipc_send`.
- [driver-model.md](driver-model.md) — class drivers, capability passing (M13), the trust model.
@@ -0,0 +1,556 @@
# Native Intel iGPU display support — feasibility and roadmap
**Status: research snapshot, not implemented.** This records what a *minimal, display-only*
native driver for an **Intel integrated GPU** — EDID read + mode-set + framebuffer scanout, with
**no** 3D/media/compute — would take, and how it slots into danos's pluggable scanout
architecture. It is a survey of primary sources (Intel's open-source
[Programmer's Reference Manuals](https://www.intel.com/content/www/us/en/docs/graphics-for-linux/developer-reference/1-0/overview.html),
coreboot's [libgfxinit](https://doc.coreboot.org/gfx/libgfxinit.html), the Linux
[i915 display](https://github.com/torvalds/linux/tree/master/drivers/gpu/drm/i915/display) driver,
and Haiku's [intel_extreme](https://github.com/haiku/haiku/tree/master/src/add-ons/kernel/drivers/graphics/intel_extreme/)),
not an implementation. It is the companion to [nvidia-gpus.md](nvidia-gpus.md) and should be read
against it — the two answer the same question for opposite silicon.
Read [display.md](display.md) and [display-v2.md](display-v2.md) first — this doc assumes the v2
model where scanout is a **pluggable backend** and a native driver is just another `.scanout`
service (like the virtio-gpu one), announcing to the compositor over `attach_scanout`.
## TL;DR
- **Intel is a materially easier, lower-tier target than the NVIDIA RTX 3060 — and the reason is
documentation, not silicon.** Intel publishes official, register-level, per-platform **Display
Engine** PRMs with named registers, bitfields, and numbered enable sequences; NVIDIA publishes
no display PRM and forces reverse-engineering against GPL nouveau. A minimal Intel display-only
driver is roughly **tier 2 to low-tier 3** for well-covered generations (Skylake / Kaby Lake /
Coffee Lake), versus NVIDIA's **tier 4** for GA106. This is the load-bearing conclusion.
- **The display block is a genuinely separable register domain.** Mode-set + scanout touch only
display registers (pipes, planes, transcoders, DDI buffers, PLLs, power wells, GMBUS/AUX) — **no
render engine, no command streamer, no GEM/3D, no signed microcode.** Two small carve-outs, both
trivial pokes that do *not* pull in the render engine: a real CDCLK frequency change writes the
shared GT PCODE mailbox, and the plane's surface register is a GGTT (memory-interface) address.
- **There is no firmware wall on the display path.** The only display microcontroller (DMC / "CSR",
Skylake+) is **optional** — its sole job is saving/restoring display state across DC5/DC6
low-power idle. Without it, i915 prints "Disabling runtime power management" and mode-sets and
scans out normally. GuC/HuC are render/media coprocessors, never touched by a display driver.
Pre-Skylake parts have no display microcontroller at all yet mode-set fine. There is **nothing
analogous to NVIDIA's GSP**.
- **The scanout memory model is dramatically simpler than a discrete GPU.** Intel iGPUs have **no
VRAM**: the display scans out of ordinary system RAM addressed through the Global GTT (GGTT), a
flat single-level page table. Linear (untiled) framebuffers are first-class. You need **no
GEM/TTM, no VMM, no VRAM allocator, no BAR1 aperture juggling** — the exact machinery the NVIDIA
path forces on you.
- **coreboot libgfxinit is a compact, complete, display-only reference** doing precisely this scope
(EDID + PLL/mode-set + scanout, zero 3D) in ~22k lines of formally-analysed SPARK/Ada — versus
i915's ~400k lines. It is a *read-and-reimplement* reference, not drop-in code (GPL-2.0-or-later,
and Ada, not Zig).
- **The clean-room, permissively-licensed path is real** — you can implement from the PRM without
reading GPL code, and Haiku's MIT `intel_extreme` is a permissive precedent. This is the decisive
contrast with NVIDIA, where no vendor register spec exists.
- **The practical catch is hardware, not software.** On a desktop with an RTX 3060, the monitor is
almost certainly cabled to the *card*, so an iGPU driver would light a dark motherboard port; the
CPU may be an **F-SKU with the iGPU fused off entirely**; and every clean-room reference targets
*older* Intel. Intel is the right target to **learn** display bring-up — "run it on my machine"
is a separate, machine-dependent question that may not resolve in the reader's favour.
- **Recommendation:** as with the NVIDIA doc, GOP already gives native-resolution scanout with zero
GPU code. A native Intel driver buys runtime mode changes, hardware vsync, and multihead — and it
reaches "first pixel" far faster than the NVIDIA path *if* the target machine actually has a
usable, cable-attached iGPU of a documented generation.
## Display engine architecture, and why it's separable
For the common single-display path (SST DisplayPort / HDMI / eDP), the Intel display data flow is a
small, fully documented, essentially fixed sequence:
```
memory surface → PLANE(s) → PIPE → TRANSCODER → DDI (drives IO/PHY) → connector
```
The Tiger Lake PRM Vol 12 states it verbatim: *"The front end of the display contains the pipes.
The pipes connect to the transcoders. The transcoders, except for wireless, connect to the DDIs to
drive the IO/PHY."* A **pipe** blends planes (primary/sprite/cursor) into one raster stream; the
**transcoder** wraps it in port-protocol timing (DP/HDMI/eDP/DSI); the **DDI** is the physical port
and PHY. Pipe, Planes, Transcoder, and Digital Display Interface are each first-class PRM chapters
with per-object files in libgfxinit
([TGL PRM Vol 12](https://cdrdv2-public.intel.com/705833/intel-gfx-prm-osrc-tgl-vol-12-display-engine.pdf)).
**Two honest qualifications** the raw research overstated (per verification):
- The pipeline is *not* strictly linear in all cases — the same PRM pages document optional branches
a minimal driver simply ignores (wireless writeback to memory, MIPI DSI, DisplayPort multistream
many-to-one, DSC/tiled pipe-joining). Ignoring them does not weaken feasibility.
- The four-object model *as named* is **Haswell-onward** (DDI introduced ~2013), not "every gen."
Pre-Haswell used FDI + PCH transcoders + port-specific encoders. Within the modern iGPU range
danos would realistically target (Skylake → Meteor/Lunar Lake) the model is stable.
**The DPLL/clock block is a separate, per-port programmable clock source** and is one of the harder,
most gen-specific pieces: pick/enable a PLL, route its output to the DDI, then bring up the port.
The register layout and divider math change substantially per generation — pre-SKL SPLL/WRPLL/LCPLL,
Skylake+ shared DPLL0–3, Gen11+ combo-PHY plus Type-C MG/DKL PLLs. Pixel-clock computation is a
classic per-gen rewrite.
### Separable from render — the single most important enabler
The display is a distinct register domain from render/media, and this is confirmed at the primary
level: the TGL PRM ships display as its own volume (Vol 12), separate from Render Engine (Vol 9) and
Media (Vol 11); Linux's KMS "is provided by Intel Display Driver, and **shared with drm/xe**"
([kernel.org i915](https://docs.kernel.org/gpu/i915.html)) — i.e. the display module is
reused across two different GPU drivers. A full mode-set lights a display end-to-end using only power
wells, PLL/port-clock, DDI-buffer/PHY, transcoder and pipe registers — **zero render commands, zero
GEM objects, zero command-streamer.** libgfxinit is decisive proof: complete EDID + modeset +
framebuffer with no render/3D code at all.
Two carve-outs the "touches ONLY display registers" phrasing needs (per verification), **neither of
which drags in the render engine**:
1. A mode-set that changes the **Core Display Clock (CDCLK)** frequency/voltage pokes the shared **GT
Driver Mailbox** (PCODE/PCU power-controller interface), per Vol 12's own "Display Voltage
Frequency Switching" step. A trivial register handshake, documented alongside the display sequence.
2. The primary plane's surface register (`PLANE_SURF`) holds a **GGTT graphics address** (a
memory-interface concept, not covered in Vol 12). Using pre-mapped stolen memory — as libgfxinit
does — sidesteps any active GGTT programming. See [Memory and scanout](#memory-and-scanout).
### Per-gen churn: what's stable, what you rewrite
The **object model** (pipes/planes/transcoders/DDIs, GMBUS-for-EDID, double-buffered plane registers
armed atomically) is conceptually stable from Ironlake/Haswell through Tiger Lake. What you rewrite
per generation is:
1. the **CPU-vs-PCH split and interconnect**,
2. the **port/PHY + DPLL** programming,
3. **register offsets + power-well / CDCLK topology**, and
4. the **mode-set enable sequence itself** (power-well ordering, PLL lock, DDI-buffer enable,
transcoder clock-select) — an effective fourth axis the raw research folded into (1)/(2).
Interconnect eras, with the timeline **corrected** (the cited Haiku doc was chronologically loose):
- **Gen5 Ironlake (2010) → Ivy Bridge:** FDI (Flexible Display Interface) links the CPU display
engine to PCH-resident ports. The FDI/PCH-split era begins at **Ironlake**, not Gen7.
- **Haswell (Gen7.5):** the main digital outputs come **back onto the CPU die as DDIs** (DDI A = eDP)
— the *opposite* of "moving output to the PCH," and it collapses the FDI/PCH dance **for the
digital ports only**. FDI is **retained** for the legacy VGA/CRT path (DDI E → PCH CRT DAC), so a
driver gets the single DDI code path only by omitting analog VGA (which a minimal driver does).
- **Skylake (Gen9):** reworks clock/PLL, CDCLK, and the power-well model; introduces the optional DMC.
- **Gen11 Ice Lake / Gen12 Tiger Lake:** add combo-PHY + USB-Type-C/Thunderbolt MG/DKL PHYs — the
single biggest cost increase, and the reason "newest silicon" is *not* the easiest target. (DSC is
documented per-**pipe**; MSO is an eDP feature — not "per-transcoder" as the raw research said.)
### The tractable sweet spot
The documented, tractable sweet spot for a from-scratch display-only driver is the
**Haswell (Gen7.5) / Broadwell (Gen8) DDI family, with Skylake (Gen9) as the modern-hardware pick**
since it shares the same DDI object model. Rationale:
- Broadwell has a complete, freely downloadable
[PRM Vol 11 Display](https://cdrdv2-public.intel.com/690828/intel-gfx-prm-osrc-bdw-vol-11-display.pdf);
its engine (3 pipes A/B/C, 4 transcoders incl. transcoder-EDP that floats onto any pipe, DDI A–E,
WRPLL/SPLL/LCPLL) is the classic "DDI + transcoder + WRPLL" model.
- It predates the combo-PHY / Type-C / MG-DKL complexity of Ice Lake / Tiger Lake.
- libgfxinit's DDI **connector/EDID/DP layer is uniform from Haswell through Coffee Lake**, so the
hardest-to-get-right port logic generalises widely.
Two supporting claims from the raw research are **wrong and corrected here (verification):**
- **The BDW and SKL PRMs are NOT 0BSD-licensed.** Both carry a Creative Commons
**Attribution-NoDerivatives** notice. Only the *newer* OSRC PRMs (Tiger Lake 2021 onward) put their
embedded code samples under **Zero-Clause BSD**. So for the recommended Haswell/Broadwell/Skylake
generations there are no "copy-pasteable 0BSD code samples" — the legal basis is *reimplementation
from a CC-BY-ND spec* (register facts are not copyrightable), not copying.
- **FDI+PCH is not fully eliminated on Haswell/Broadwell.** The BDW PRM keeps FDI for the DDI E → PCH
CRT DAC. The "one DDI code path" holds only for the digital outputs a minimal driver targets.
Sandy/Ivy Bridge (Gen6/7) is where the hobby-doc walkthroughs concentrate (the OSDev GMBUS/EDID
material) but carries the FDI+PCH split cost. *(Low confidence on the OSDev specifics — the wiki
returns 403 to automated fetches and its "guaranteed to work" phrasing is a hobby assertion, not a
silicon guarantee.)*
## Documentation — and the clean-room question
This is the crux of the whole comparison. **Intel hands you the register spec that NVIDIA withholds.**
- The Tiger Lake **"Vol 12: Display Engine"** PRM is a real, first-party, open-source document —
**433 pages, verified by direct download** — with named registers + addresses + bitfield tables
(`TRANS_DDI_FUNC_CTL`, `DDI_BUF_CTL`, `DP_TP_CTL`, `PLANE_STRIDE`, `DPLL_CFGCR0/1`, `CDCLK_CTL`,
`PWR_WELL_CTL_DDI`, …) and **numbered, step-by-step enable sequences** with explicit writes, wait
conditions, and microsecond timeouts. It even includes the "magic value" tables older PRMs deferred
to the driver (DisplayPort PLL DCO/divider values; voltage-swing/de-emphasis in mV). *"A spec you
could write a driver from directly"* is well-supported, not hyperbole
([TGL Vol 12](https://cdrdv2-public.intel.com/705833/intel-gfx-prm-osrc-tgl-vol-12-display-engine.pdf)).
- **Clean-room, permissively-licensed implementation is legally and practically feasible from the
PRM alone.** CC-BY-ND governs redistribution of the *document*; register addresses and bit
definitions are functional facts, and original code implementing a described hardware interface is
not a derivative of the PDF. *(This is standard copyright reasoning, not adjudicated case law —
treat it as well-grounded, not settled.)* Two independent implementations already exist built
essentially from these docs (libgfxinit, Haiku), so the spec is demonstrably sufficient.
**The documentation ceiling — corrected.** The raw research said public PRMs stop "roughly at Ice
Lake / Tiger Lake." Verification refuted this: full public **"Vol 12 Display Engine"** PRMs exist for
Ice Lake, Lakefield, Tiger Lake, Rocket Lake, DG1, **and DG2/Arc "Alchemist" (Gen12.5, 2022)** —
[the ACM display PRM is public](https://www.x.org/docs/intel/ACM/intel-gfx-prm-osrc-acm-vol12-displayengine.pdf).
The genuine cliff is **Meteor Lake (2023) and newer**: those have only a high-level architecture
overview, no register-level display PRM, and i915 references their display registers by opaque
internal **Bspec numeric IDs**. Alder Lake and Raptor Lake iGPUs are Gen12 Xe-LP display — the same
IP as Tiger Lake — so despite lacking a dedicated PRM they are effectively covered by the TGL PRM.
Net: a from-docs driver can confidently target **Skylake through DG2/Arc**, which is essentially the
entire current laptop/NUC installed base; only Meteor Lake and later slide back toward the NVIDIA
situation (reverse-engineering or reading GPL i915). The PRMs also survived 01.org's shutdown and are
mirrored in several stable places (Intel's cdrdv2 host, the
[Igalia CC-BY-ND archive](https://github.com/Igalia/intel-osrc-gfx-prm) for Gen4–Gen9.5,
[kiwitree](https://kiwitree.net/~lina/intel-gfx-docs/prm/), x.org) — not a single point of failure.
*(Note: the Igalia archive stops at Kaby Lake and contains no Display Engine volume; the TGL/DG2
display PRMs are separate Intel/x.org downloads.)*
## coreboot libgfxinit — the native reference
[libgfxinit](https://doc.coreboot.org/gfx/libgfxinit.html) is the closest thing to a template danos
could ask for: a self-contained **native modeset library** (no VBIOS/int10, no firmware blobs) that
probes displays via EDID over DDC/I²C and DP AUX, and drives LVDS, eDP, DP1–3, HDMI1–3, analog VGA,
plus USB-C DP/HDMI alt-mode on Tiger Lake. It sets up pipes (Primary/Secondary/Tertiary), planes,
transcoders, PLLs, panel power/backlight, the GTT, and framebuffer scanout — **display-only, zero
3D/media/compute**, which is exactly danos's scope. Its public entry is essentially
`Initialize()` then `Update_Outputs(Pipe_Configs)`, where each `Pipe_Config` carries
`{Port, Framebuffer, Cursor, Mode}` — a near-perfect fit for a pluggable scanout backend.
Why it beats i915 as a reference (**verified by measurement**): **131 Ada source files, ~818 KB,
~22k code lines** across *all* generations, factored precisely along the axes you care about (`edid`,
`dp_aux`, `dp_training`, `pipe_setup`, `transcoder`, `plls`, `connectors`, `port_detect`), with
**none** of the DRM/KMS/GEM/TTM, GT/3D, RC6/RPS, or GuC/HuC machinery that makes
`drivers/gpu/drm/i915` **~419k lines / 900 files / 12 MB**. (A grep confirms *zero* gem/ttm/guc/huc/
execbuf identifiers in the tree.) It depends only on a small HW-access shim, `libhwbase`
(`HW.PCI`, `HW.Port_IO`, `HW.MMIO`, `HW.Time`), which maps naturally onto danos's MMIO-grant + IPC
primitives — you provide Zig equivalents and the modeset logic sits on top. *(Correction to the raw
research: the widely-quoted "~13–14k LOC" is only the generic `common/` layer; the eight
per-generation subdirs roughly double it.)*
**It is a read-and-reimplement reference, not drop-in code.** Two hard constraints:
- **License is GPL-2.0-or-later** (the COPYING file is GPLv2; per-file headers add "or any later
version"). The CC-BY-4.0 on the docs *site* is a footer, not the source license. Copyleft applies
to ported code.
- **It is SPARK/Ada, and designed to run as coreboot boot-firmware**, not a runtime OS driver. A
danos port means either an Ada/GNAT toolchain in the build or hand-transliteration into Zig; the
SPARK "absence of runtime errors" proof does **not** carry over to your reimplementation (and note
it proves absence of runtime errors, **not** functional modeset correctness).
Two more caveats worth knowing: its **error handling is limited** — "only the case that no display
could be found counts as failure"; a later DP link-training failure is *not* propagated. And its
**verified-in-coreboot** hardware list stops at **Coffee Lake + Apollo Lake**, even though the tree
contains a `tigerlake/` directory (Ice Lake has no directory at all, and Alder Lake support is only
"begun"). So treat Haswell..Coffee Lake as the trustworthy transliteration window and TGL as
present-but-less-proven.
The orchestration reads as a clean state machine (`hw-gfx-gma.adb` `Enable_Output`):
`Fill_Port_Config → Preferred_Link_Setting → PLLs.Alloc → [retry] Connectors.Pre_On →
Display_Controller.On → Connectors.Post_On`, with a literal *"try each DP-lane configuration twice"*
inner retry and an outer link-setting step-down. `hw-gfx-dp_training.adb` (398 lines) is a complete,
generic DP link-training implementation (TP1/TP2/TP3, CR + EQ loops, swing/pre-emphasis adjust from
sink status). Per-generation buffer translations plug in underneath via
`Program_Buffer_Translations`, gated on `Config.Has_DDI_Buffer_Trans`. All of this was confirmed
against the source line-by-line.
## The EDID + mode-set path (Haswell/Broadwell target)
The whole path is memory-mapped register programming with polled status bits — no command ring, no
microcode, no DMA channel.
**EDID over DDC (GMBUS).** Pure MMIO poking of the GMBUS I²C controller (`GMBUS0`–`GMBUS5`): `GMBUS0`
selects pin-pair/port + clock; `GMBUS1` carries slave address (`0x50` for EDID), byte count,
direction, SW-ready; `GMBUS2` exposes HW-ready/NAK/ACTIVE to poll; `GMBUS3` is a 4-byte data FIFO;
`GMBUS5` gives the 2-byte segment index for E-DDC. A read is: write `GMBUS0`, write `GMBUS1`
(`CYCLE_WAIT | count | SLAVE_READ | SW_RDY | slave<<addr`), loop {poll `HW_RDY`, read 4 bytes}, then
STOP ([i915 intel_gmbus.c](https://github.com/torvalds/linux/blob/master/drivers/gpu/drm/i915/display/intel_gmbus.c)).
**EDID + DPCD over DP AUX.** For DisplayPort/eDP, EDID (as I²C-over-AUX to `0x50`) and all DPCD
capability/link-status registers are read over the AUX channel: per-DDI `DDI_AUX_CTL` + 5×
`DDI_AUX_DATA`. Build a 3–5 byte header + payload, set SEND_BUSY, poll it clear, read
DONE/TIMEOUT/RECEIVE_ERROR. Message size 1–20 bytes; spec requires ≥3 retries. On Haswell/BDW the AUX
clock divider is programmed explicitly; SKL+ derive it automatically
([i915 intel_dp_aux.c](https://github.com/torvalds/linux/blob/master/drivers/gpu/drm/i915/display/intel_dp_aux.c)).
Both GMBUS and DP-AUX live in libgfxinit's shared `common/` — cheap and nearly gen-invariant.
**The mode-set is a fixed, documented register sequence.** The Broadwell DisplayPort enable order
(verbatim from BDW PRM Vol 11, pp.98–99): (1) DDI lane capability; (2) panel power sequencing if
needed; (3) enable the CPU display PLL (WRPLL/SPLL) and wait ~20 µs; (4) Port Clock Select → DDI,
enable `DP_TP_CTL` with training pattern 1, configure `DDI_BUF_TRANS`, enable `DDI_BUF_CTL`, wait
>518 µs, run link training, set `DP_TP_CTL` to Normal (Idle first for eDP); (5) Transcoder Clock
Select, enable the plane, panel fitter if needed, program transcoder timings + M/N/TU, enable
`TRANS_DDI_FUNC_CTL`, enable `TRANS_CONF`, then backlight. Disable is the exact reverse — a bounded
checklist.
**DisplayPort/eDP link training is driver-driven in software over AUX** — the CPU runs the
clock-recovery and channel-equalization state machines by hand; it is **not** offloaded to a hardware
sequencer or firmware. The source side exposes only primitives: `DP_TP_CTL` selects the training
pattern the port emits; `DDI_BUF_CTL`/`DDI_BUF_TRANS` set voltage-swing/pre-emphasis. The driver
loops: emit pattern + set source levels → write `TRAINING_PATTERN_SET` (DPCD 0x102) + `TRAINING_LANEx_SET`
(0x103) over AUX → delay (100 µs CR / 400 µs EQ) → read `LANE_STATUS` → on failure adjust to the
sink's `ADJUST_REQUEST` values and retry. A few hundred lines of ordinary CPU/AUX code (libgfxinit
`Train_DP`: CR loop 1..32, EQ loop 1..6). **This is the single fiddliest, most fragile piece** — a
TMDS/HDMI panel avoids it entirely, and targeting an already-lit eDP panel avoids most of it.
**The clock (WRPLL) is documented divider math, not a magic table.** On Haswell/BDW the WRPLL derives
the symbol clock from a 2700 MHz LCPLL reference through R2/N2/P dividers with VCO 2400–4800 MHz —
small integer arithmetic. DP is *easier* than HDMI because it runs at a few fixed link rates (1.62 /
2.7 / 5.4 GHz), so a DP/eDP-only minimal driver can often use fixed rates and skip most of the search.
**Plane/scanout programming is trivial for a compositor.** The primary plane is `PRI_CTL`
(enable + pixel format), `PRI_STRIDE`, `PRI_SURF` (surface base — writing it triggers the atomic
update), `PRI_OFFSET`; formats include 32-bit BGRX 8:8:8 and 16-bit BGRX 5:6:5 — a direct match for a
linear XRGB compositor buffer. Plane registers are double-buffered and latch at vblank via an
**arming** write — so a page-flip is "write base + stride + size, then the arming write." This is
*exactly* the primitive danos's damage-driven compositor already expresses over GOP/virtio-gpu; the
incremental work is "program these display-domain registers," not a new scanout model. The panel
fitter (`PF_WIN_POS`/`PF_WIN_SZ`/`PF_CTRL`) can be left disabled for native-resolution scanout;
Skylake+ replaces it with a shared pipe-scaler (`PS_CTRL`).
**Smallest useful target:** eDP (DDI A / transcoder-EDP) or a single DP output at native resolution,
panel fitter off, plane in 32bpp XRGB. That is: GMBUS + I²C-over-AUX EDID/DPCD, one fixed-rate or
WRPLL config, the ~20-step enable sequence, the software CR/EQ loop, and `PRI_*` plane setup with
`PRI_SURF`-write flips. Out of scope: 3D, media, tiling, RC6/power-gating, PSR, audio.
## Memory and scanout
This is where Intel's *architecture* — not just its docs — makes the job smaller, and it is the
biggest single simplification versus a discrete GPU.
- **No VRAM.** Intel iGPUs have a unified memory architecture; the display scans out of ordinary
**system RAM** addressed through the **Global GTT (GGTT)**. The only way to give the GPU memory is
to bind system pages into the GGTT
([i915/GEM crashcourse](https://blog.ffwll.ch/2012/10/i915gem-crashcourse.html)).
- **The plane surface register is a GGTT offset**, not a raw physical address — the display walks the
GGTT to fetch pixels, so a scanout buffer must be GGTT-mapped (global, not per-process). libgfxinit
writes the framebuffer offset straight into `DSPSURF`/`PLANE_SURF` masked to 4 KB.
- **Linear (untiled) scanout is a first-class supported mode** — the plane's tiling field value 0 is
Linear. No X/Y/Yf tiling engine is needed for a display-only driver. (UEFI GOP itself hands off a
linear framebuffer the plane is already scanning.)
- **No memory manager.** You need only (1) some contiguous-ish system pages and (2) GGTT PTEs
pointing at them (`physical_addr | valid_bit` — the GGTT is a flat single-level array of PTEs in
the `GTTMMADR` MMIO BAR), then program the plane. **No GEM/TTM/PPGTT/GuC.** coreboot's native-init
literally does `for(i…) WRITE32(base + i*inc | 1, (i*4) | 1)`.
- **"Stolen memory"** (GSM/DSM) is firmware-reserved system RAM where the firmware places the GGTT
itself and the boot framebuffer. A driver is not obligated to keep scanout there — it can rebind
GGTT entries to its own pages. Stolen memory matters mainly for *inheriting* the GOP framebuffer at
handoff.
**The contrast with NVIDIA is stark.** On a discrete GPU the scanout surface must live in **VRAM**
(nouveau always pins scanout to VRAM), CPU access goes through the **BAR1** aperture (which on
consumer cards can be far smaller than total VRAM unless Resizable BAR is on), and you need a
contiguous aligned VRAM allocator plus a BAR1 mapping. The Intel iGPU path **eliminates all of that**
— scanout is plain system RAM, and a userspace compositor can write the framebuffer pages directly
(as danos already does with the GOP WC framebuffer).
Because danos boots via GOP, an Intel driver attaches to a display whose **GGTT is already populated
and whose plane is already scanning a linear framebuffer at native resolution.** A minimal driver can
reuse that live mapping and reprogram the running plane rather than come up from cold — the same
"attach to a live display" advantage the NVIDIA doc identifies, but with a far smaller register
surface and no firmware wall. *(Low-confidence, per-target details to pin from the specific gen's
PRM: GGTT PTE size — 4-byte pre-gen8 vs 8-byte gen8+ — the `GTTMMADR`/aperture BAR layout, surface
alignment — 4 KB floor but some gens/tilings want 256 KB — and whether the display's GGTT-mediated
DMA sits before or after danos's M16 IOMMU on the target platform.)*
## Firmware
A minimal display-only Intel driver is **effectively firmware-free — more so than NVIDIA.**
- **DMC (Display Microcontroller, "CSR", Skylake+) is NOT required for mode-set or scanout.** Its
sole job is saving/restoring display-engine registers across DC5/DC6 low-power idle. Absent, i915
prints *"Failed to load DMC firmware … Disabling runtime power management"* and the display
mode-sets and scans out normally — you lose only the deep display idle states, not output
([intel_dmc.c](https://github.com/torvalds/linux/blob/master/drivers/gpu/drm/i915/display/intel_dmc.c);
corroborated by multiple distro bug threads). *(A source-level `HAS_DMC` early-return citation would
strengthen this beyond distro testimony, but the conclusion is well-supported.)*
- **Pre-Skylake parts have no display microcontroller at all** yet perform full mode-set (and even
Panel Self Refresh). This confirms the display engine is fundamentally CPU/MMIO-driven; the
microcontroller is an add-on for autonomous idling, not a prerequisite for lighting a panel.
Targeting a pre-Skylake or DMC-optional generation sidesteps the question entirely.
- **GuC and HuC are render/media microcontrollers on the GT side** — GuC schedules the render engines,
HuC assists HEVC/H.265 codec (plus later HDCP/PXP/GSC). Neither is in the scanout path; a
display-only driver never loads them
([kernel.org microcontrollers](https://docs.kernel.org/gpu/i915.html)).
- **PSR firmware lives on the panel**, not in the OS — a minimal driver simply doesn't enable PSR.
- **Type-C/TCSS (Ice Lake+) firmware** (PMC/IOM/PHY) is part of platform BIOS/coreboot init and the
hardware, *not* a signed blob the display driver loads at runtime. A driver attaching to an
already-lit GOP connector, or targeting classic DDI ports, avoids it. *(Cold DP-alt-mode changes
from a userspace driver on modern TCSS platforms were not traced to primary source — flagged.)*
There is **no signed-firmware wall over the Intel GPU at all** on the display path. This is the
architectural opposite of NVIDIA's mandatory, unsignable, ABI-unstable GSP — which even on the
near-side "direct" display path is a permanent maintenance liability for anything beyond scanout.
## Licensing
The situation is *better* than NVIDIA's but still nuanced.
- **The two best code references are both GPL** — Linux i915 (GPL-2.0) and coreboot libgfxinit
(GPL-2.0-or-later). You cannot copy either into a permissively-licensed danos. libgfxinit's WRPLL
divider math is itself copied from i915, so it carries the same encumbrance.
- **But you don't need to copy code.** The Intel PRM is a *specification*, and a clean-room Zig
implementation written from the PRM (using libgfxinit/i915 only to understand behaviour, never to
copy) is legitimate — register numbers and bit definitions are functional facts, not copyrightable
expression. This is the exact inverse of the NVIDIA case, where no such spec exists and the only
guide is the GPL/RE'd code itself.
- **A permissive precedent exists: Haiku's `intel_extreme` is MIT-licensed** and was built from
Intel's public docs. So if danos wants a permissive license, the model is: implement from the PRM,
optionally read MIT Haiku for structure, treat GPL libgfxinit/i915 as documentation-of-last-resort.
- **A licensing nuance on the recommended generations:** the "copy the 0BSD PRM code samples" shortcut
only applies to Tiger-Lake-era (2021+) PRMs. The Haswell/Broadwell/Skylake PRMs are CC-BY-ND, so
their register *facts* are free to implement but there are no code samples to lift.
As with the NVIDIA doc: danos's userspace-driver-over-IPC model (a driver is a separate process behind
a defined protocol) is the cleanest possible license boundary if the project ever chooses to ship a
GPL display-driver binary and keep the rest of danos permissive — but that is a boundary judgement
wanting real diligence, not a settled fact. The clean-room-from-PRM route avoids the question.
## Prior art outside Linux
This is a **real contrast with NVIDIA**, where no one has built a from-scratch native driver outside
Linux. For Intel there are **multiple independent, non-Linux, clean-room native modeset
implementations** to learn from:
- **coreboot libgfxinit** — SPARK/Ada, G45/GM45 and Arrandale → Coffee Lake + Apollo Lake (TGL
in-tree), the strongest structural reference.
- **Haiku `intel_extreme`** — modeset-only (no 2D/3D accel), **MIT-licensed**, i845 through Sandy
Bridge solid, newer Gemini/Ice/Tiger Lake in progress but "hit or miss, as the driver lags behind
the specs" ([Haiku generations](https://www.haiku-os.org/docs/develop/drivers/intel_extreme/generations.html),
[Phoronix Sept 2024](https://www.phoronix.com/news/Haiku-OS-September-2024)).
- **SerenityOS** — added basic native Intel graphics ([PR #6277](https://github.com/SerenityOS/serenity/pull/6277)),
though only for very old ICH7-class hardware.
- **managarm** — native Intel G45 support.
The catch: **every clean-room non-Linux implementation targets old hardware.** A modern Gen12 "Xe"
desktop iGPU is beyond all of them; for the very newest parts only GPL i915 covers the registers. So
the wealth of prior art is real but concentrated below Tiger Lake.
## The practical desktop caveat
Before any effort estimate is trusted, three hardware realities — the honest reason "Intel is easier"
does **not** automatically mean "it'll light up the reader's monitor":
1. **Muxing / cabling.** On a desktop with a discrete RTX 3060, the monitor is almost certainly
plugged into the *card's* outputs, not the motherboard's. An iGPU driver would light a
**different, currently-dark** output. To see danos on Intel the reader would have to physically
move the cable to a motherboard video port **and** likely enable the iGPU / "IGD Multi-Monitor" in
BIOS. Intel-first probably does **not** light the current display without re-cabling.
2. **No iGPU at all.** Intel **F-SKU** desktop chips (i5-9400F, i5-12400F, i5-13400F, i7-13700KF, …)
ship the graphics **fused off** and cannot be re-enabled. These are extremely common in
budget/mid gaming builds paired with an RTX 3060. On an F-SKU (or an X-series HEDT part) the
Intel-iGPU path is a **non-starter** regardless of cabling.
3. **Generation coverage.** If the CPU *is* a recent non-F part, its iGPU may be Gen12 Xe (Alder/
Raptor Lake), beyond libgfxinit's verified set and beyond most non-Linux prior art — leaving GPL
i915 (or the TGL-class PRM, which covers Alder/Raptor display IP) as the only reference.
A cleaner path for *learning* without the hardware lottery: an older bare-metal Intel box (Haswell/
Skylake NUC or laptop) whose panel is natively on the iGPU. Note QEMU does **not** emulate an Intel
iGPU display engine, so a VM cannot exercise a real Intel modeset path — virtio-gpu (already working)
is the VM answer.
## Alternatives, and the honest Intel-vs-NVIDIA verdict
| Option | What you get | The tradeoff |
|---|---|---|
| **Stay on GOP** (working today) | Native-res scanout, zero GPU code/firmware/maintenance | Resolution frozen at ExitBootServices; no runtime mode change, no hardware vsync, no multihead |
| **Intel iGPU, reuse-GOP** | EDID read + plane page-flips on the GOP-set mode | Still bounded to GOP's resolution; but real driver-owned scanout |
| **Intel iGPU, full modeset** (this doc) | Runtime modeset, vsync, multihead, from public docs | Tier 2–3 effort; DP link training; per-gen churn; **needs a cable-attached, documented iGPU** |
| **Native NVIDIA GA106 direct** ([nvidia-gpus.md](nvidia-gpus.md)) | Same, on the RTX 3060 the monitor is actually plugged into | **Tier 4**; GPL-only reference; DMA channel modeset; de-emphasised legacy path |
| **GA106 via GSP/OGKM** | Also unlocks 3D later | Tier 5; unstable version-pinned firmware ABI |
**The verdict for *this reader* (RTX 3060 box):** For pure "see danos on my screen," **NVIDIA-direct
is paradoxically the more relevant path**, because the monitor is already cabled to the 3060 and GOP
already drives it — a native NVIDIA driver reprograms *that* live display. An Intel driver, however
much easier to *write*, likely lights a dark motherboard port the reader isn't looking at, or hits an
F-SKU with no iGPU.
**The verdict for *learning display bring-up*:** **Intel wins decisively.** Public register PRMs, four
independent open reference drivers, an MIT precedent (Haiku), a compact formally-analysed blueprint
(libgfxinit), no signed-firmware wall, no VRAM/BAR memory manager, and a legitimate permissive
clean-room path. It reaches "first pixel" far faster than the NVIDIA native path — *on hardware that
actually has a cable-attached, documented Intel iGPU.* Those two goals — "run on my machine" and
"learn the craft" — point at different silicon, and that is the honest bottom line.
## "First light" milestones — a danos `.scanout` service
Framed as a danos `.scanout` service (like the virtio-gpu and proposed NVIDIA ones), inheriting the
GOP-initialized display — no firmware, no cold POST:
1. **PCI/BAR bring-up** — enumerate the iGPU, map its MMIO BAR (`GTTMMADR` + register block) and the
aperture BAR via danos MMIO grants; confirm the display engine is GOP-live.
2. **EDID** — implement GMBUS DDC (`0x50`) and DP AUX; read + parse the panel EDID and DPCD caps.
*(Smallest self-contained, gen-invariant milestone — a good first commit.)*
3. **First pixel = reprogram, don't re-modeset** — with GOP's mode and GGTT mapping inherited,
reprogram the running plane (`PRI_CTL`/`PRI_STRIDE`/`PRI_SURF`, linear, 32bpp XRGB) to point at a
danos-owned system-RAM buffer; prove a page-flip via the `PRI_SURF` arming write on the *current*
mode before changing timings. This defers the entire DPLL/DDI/transcoder/link-training surface —
the hardest, most gen-specific ~70% of the work.
4. **GGTT ownership** — write your own GGTT PTEs (via an MMIO grant to `GTTMMADR`) pointing at
compositor-owned pages, for double-buffered damage-driven present.
5. **Wire into the compositor `.scanout` backend** (`attach_scanout`); add vsync via the display
vblank interrupt (IRQ-as-IPC).
6. **Full mode-set** (the hard, gen-specific step) — for one chosen generation (Haswell/Broadwell or
Skylake): WRPLL/DPLL programming, the ~20-step DDI/transcoder/pipe enable sequence, panel power
sequencing for eDP (`PP_CONTROL`/`PP_ON_DELAYS`/`PP_OFF_DELAYS` — a common black-screen pitfall).
7. **DisplayPort link training** — only if the panel is DP and GOP's link can't be reused; the
software CR/EQ state machine over AUX. TMDS/HDMI avoids it; a live eDP panel avoids most of it.
8. **Multihead**, then optionally a second generation once one is solid.
Keep the GOP backend as the fallback the whole way — a stall at any step still leaves danos with a
working display, exactly the resilience v2 already provides via re-attach.
## Reading list
**Native reference — coreboot libgfxinit (GPL-2.0-or-later, SPARK/Ada):**
- `common/hw-gfx-gma.adb` — `Enable_Output`, the end-to-end modeset state machine.
- `common/hw-gfx-dp_training.adb` — the complete generic DP link-training CR/EQ loops.
- `common/hw-gfx-gma-pipe_setup.adb` — plane/pipe/scaler + `DSPSURF`/`DSPSTRIDE`/`DSPCNTR` scanout.
- `common/hw-gfx-gma-transcoder.adb` — timing generator; `common/hw-gfx-edid.adb`,
`hw-gfx-gma-i2c.adb`, `hw-gfx-dp_aux_ch.adb` — EDID/DDC/AUX; `hw-gfx-gma-registers.ads` — offsets.
- `common/haswell*/`, `skylake/`, `tigerlake/` — the per-gen PLL/PHY/buffer-translation backends.
**Vendor register specs — Intel OSRC PRMs:**
- [Broadwell Vol 11: Display](https://cdrdv2-public.intel.com/690828/intel-gfx-prm-osrc-bdw-vol-11-display.pdf)
(CC-BY-ND) — the recommended Haswell/Broadwell-class enable sequences, plane, panel fitter.
- [Tiger Lake Vol 12: Display Engine](https://cdrdv2-public.intel.com/705833/intel-gfx-prm-osrc-tgl-vol-12-display-engine.pdf)
(code samples 0BSD) — the most complete modern reference incl. PLL/voltage-swing value tables.
- [DG2/Arc Vol 12: Display Engine](https://www.x.org/docs/intel/ACM/intel-gfx-prm-osrc-acm-vol12-displayengine.pdf)
— the newest public display PRM (Gen12.5, 2022).
- [Igalia CC-BY-ND archive](https://github.com/Igalia/intel-osrc-gfx-prm) (Gen4–Gen9.5) and the
[kiwitree mirror](https://kiwitree.net/~lina/intel-gfx-docs/prm/) — stable mirrors.
**GPL reference-of-last-resort — Linux i915 display:**
- `intel_gmbus.c`, `intel_dp_aux.c` — the concrete EDID/DDC and DP-AUX register sequences.
- `intel_ddi.c` / `intel_ddi_buf_trans.c`, `intel_cdclk.c`, `intel_dpll_mgr.c` — DDI/CDCLK/PLL;
`i9xx_plane.c`, `intel_crtc.c` — plane/pipe; `intel_dp.c` — link training. Huge and modular; a
reference to confirm undocumented quirks, not a template.
**Permissive prior art — Haiku `intel_extreme` (MIT):**
- [`src/add-ons/kernel/drivers/graphics/intel_extreme/`](https://github.com/haiku/haiku/tree/master/src/add-ons/kernel/drivers/graphics/intel_extreme/)
— a second independent modeset-only driver; MIT, so structurally readable for a permissive danos.
- [generations.html](https://www.haiku-os.org/docs/develop/drivers/intel_extreme/generations.html)
— the best plain-English per-generation fault-line map.
## Open questions (unresolved by the survey)
- **Does the target machine have a usable, cable-attached iGPU at all?** F-SKU check, CPU generation,
and monitor cabling must be resolved before any effort estimate is trusted (see
[practical caveat](#the-practical-desktop-caveat)).
- **Does danos even need native mode-*setting*, or only plane/scanout control on the GOP-set mode?**
If runtime mode changes aren't required, the driver collapses to EDID + plane page-flips, dropping
the DPLL/DDI/link-training ~70% of the work.
- **GGTT vs raw physical:** confirm from the exact target-gen PRM that `PLANE_SURF` is interpreted as
a GGTT graphics address (well-established, but per-gen confirmation advisable), and the PTE size /
`GTTMMADR` / aperture layout for writing GGTT entries.
- **Reuse the firmware/GOP GGTT + framebuffer, or install your own GGTT entries?** The latter (needed
for double-buffering) means writing GGTT PTEs from the userspace driver via an MMIO grant.
- **eDP panel power sequencing** (`PP_*`, T1–T12 delays) — not covered in this pass and a common
black-screen source.
- **IOMMU interaction** — whether the display's GGTT-mediated DMA needs IOMMU passthrough for the
framebuffer pages under danos's M16 IOMMU, or sits before the IOMMU on the target platform.
- **DP link-training / AUX robustness and per-generation register drift** are the dominant *risks* —
not documentation scarcity.
- **Exact Haswell/BDW MMIO offsets** (commonly cited: GMBUS ~`0xC5100`, `DDI_AUX_CTL_A` ~`0x64010`,
`DDI_BUF_CTL_A` ~`0x64000`, `DP_TP_CTL_A` ~`0x64040`) were not extracted verbatim from the PRM —
confirm against `i915_reg.h` before coding.
---
*Research snapshot; verify against current libgfxinit / i915 source and the specific target
generation's PRM before building. Intel's public-PRM coverage and the muxing/F-SKU realities of a
given machine both change what is actually achievable.*
@@ -1,7 +1,7 @@
# IPC: message-passing channels
Inter-process communication is the **backbone of a microkernel**. Once drivers and
services run isolated in their own address spaces ([vision](vision.md)), they can't
services run isolated in their own address spaces ([vision](../vision.md)), they can't
just call each other — a request becomes a **message**. In a microkernel, whatever
was a function call across a monolithic kernel is IPC, so it's a first-class
concern, not an afterthought.
@@ -18,14 +18,14 @@ There are two layers, built a milestone apart:
The first form is a **bounded blocking channel** (`system/kernel/ipc.zig`): a fixed-size
ring buffer of messages with a producer/consumer rendezvous, built on the
scheduler's [wait queues](scheduling.md).
scheduler's [wait queues](../os-development/scheduling.md).
`Channel(T, capacity)` is generic over the message type and buffer size. It holds a
ring buffer, a count, and two wait queues:
- **`send(msg)`** — if the channel is full, block on the *not-full* queue; otherwise
write the message, bump the count, and wake a waiting receiver.
- **`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](../os-development/smp.md) (`sync.enter` / `sync.leave`), which disables interrupts on this
core *and* takes the kernel's one spinlock — since SMP, the interrupt flag alone
is not atomicity, because `cli` on one core does nothing to another. So checking
the condition and committing the block/enqueue happen atomically both with respect
to the timer preempting mid-operation and to the other side running on another
CPU. `waitLocked` / `wakeLocked` are the variants that assume the caller already
holds that critical section.
## Verifying it
The `ipc` test (see [testing.md](testing.md)) runs a producer and a consumer passing
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.
@@ -87,13 +90,15 @@ elsewhere is not lost.
This is what makes a user-space driver possible at all, and it's the subject of
[drivers.md](drivers.md).
## What's next (not done here)
## What's next (partly done since)
- **Priority inheritance** through IPC, so a high-priority client blocked on a
low-priority server doesn't suffer unbounded priority inversion.
- **Handle transfer.** A server can't hand a client a handle to a third endpoint, so
every capability is either well-known (the registry) or inherited — there's no way
to delegate one.
- **Priority inheritance** through IPC — still open: a high-priority client
blocked on a low-priority server suffers unbounded priority inversion.
- **Handle transfer.** *Landed as cap-passing (M13)*: `ipc_call` and
`ipc_reply_wait` carry an optional capability alongside the bytes (`send_cap`),
copying an endpoint or shared-memory handle into the peer's table. First user:
[input](input.md) subscribers register by handing over their own endpoint, and
class drivers get a private channel to one device.
- **Asynchronous / buffered send** for the cases where a rendezvous is the wrong
shape (logging, notifications between servers). *Landed as `ipc_send`* — a
non-blocking post to an endpoint's bounded payload queue, delivered through
@@ -101,24 +106,27 @@ This is what makes a user-space driver possible at all, and it's the subject of
first used by, the [input service](input.md)'s keyboard-event broadcast, where a
synchronous push would let one dead subscriber hang the fan-out. A full queue drops
the oldest (discrete messages, not a coalescing level like the notification ring).
- **A bounded reply.** `MSG_MAX` is 256 bytes and the copy runs under the big kernel
lock; a bulk transfer wants shared pages, not a copy.
- **A bounded reply** — half landed. The copy is still 256 bytes
(`MESSAGE_MAXIMUM`) under the big kernel lock, but bulk transfer got its shared
pages: `shared_memory_create`/`map`/`physical`, the region handle delegated as
a capability (above). virtio-gpu's scanout surface is the first user
([display-v2.md](display-v2.md)).
## Lifecycle conventions over IPC (M17)
Three conventions from [process-lifecycle.md](process-lifecycle.md) ride the
Three conventions from [process-lifecycle.md](../os-development/process-lifecycle.md) ride the
notification mechanism:
- **Signals** arrive as notifications on the endpoint a process nominated with
`signal_bind` (`runtime.process.bindSignals`): badge = the signal bit plus the
coalesced pending mask (`runtime.process.signalsFrom` decodes). Statements,
`signal_bind` (`process.bindSignals`): badge = the signal bit plus the
coalesced pending mask (`process.signalsFrom` decodes). Statements,
never questions; no payload, no reply.
- **One-shot timers** (`timer_bind`, `runtime.system.timerOnce`) land as a
- **One-shot timers** (`timer_bind`, `time.timerOnce`) land as a
timer-bit notification — the timed wait: a service arms a deadline and keeps
serving, instead of blocking in sleep.
- **The universal ping**: a **zero-length request is the liveness probe**,
answered with a zero-length reply by the service harness itself
(`runtime.service.run`). No protocol's requests start at length zero, so the
(`service.run`). No protocol's requests start at length zero, so the
encoding cannot collide, and a wedged service simply fails to answer — which
is the diagnosis. Deep health ("can I reach my hardware?") stays a per-service
protocol message.
@@ -0,0 +1,218 @@
# New driver: the minimum steps
The shortest path from "a device shows up in the boot log" to "my process is
running with its registers mapped". This is the checklist; the reasoning behind
every step lives in [Writing a driver](drivers.md), the matching rules in
[devices.csv](devices-csv.md), and interrupts in
[device interrupts](device-interrupts.md).
Worked example throughout: the Intel UHD 750 iGPU, which the boot log reports as
```
pci-bus: 0:2.0 bus=pci base=03 class=00 prog_if=00 vendor=8086 device=4C8A ...
```
## 1. Create the source file
`system/drivers/<name>/<name>.zig` — kebab-case, abbreviations spelled out
([coding standards](../coding-standards.md)). The directory name, the binary
name, and the `devices.csv` driver path must all agree; a mismatch fails
silently (the device-manager logs the spawn failure, nothing else happens).
The complete minimal driver — claims its device, logs every resource, maps the
register window, then sleeps in the harness loop:
```zig
//! /system/drivers/intel-uhd-graphics-750 — spawned by the device manager with
//! the device-tree id as argv[1]; claims that device and no other.
const std = @import("std");
const device = @import("driver");
const ipc = @import("ipc");
const memory = @import("memory");
const process = @import("process");
const service = @import("service");
/// No protocol yet: the kernel's IPC ceiling (MESSAGE_MAXIMUM) sizes the buffers.
const message_maximum = 256;
var controller_id: u64 = 0;
var register_base: usize = 0;
fn initialise(endpoint: ipc.Handle) bool {
_ = endpoint; // needed later, for irq binding and timers
if (!device.claim(controller_id)) {
std.log.err("unable to claim device {d}", .{controller_id});
return false;
}
// Fetch our own descriptor back for the device's resources.
const buffer = memory.allocator().alloc(device.DeviceDescriptor, 64) catch return false;
defer memory.allocator().free(buffer);
const total = device.enumerate(buffer);
const descriptor = for (buffer[0..@min(total, buffer.len)]) |d| {
if (d.id == controller_id) break d;
} else {
std.log.err("device {d} not in the device tree", .{controller_id});
return false;
};
// Log every resource BEFORE choosing one (see step 5).
var register_index: u64 = 0;
for (descriptor.resources[0..@intCast(descriptor.resource_count)], 0..) |resource, index| {
std.log.info("resource {d}: kind={d} start=0x{x} len=0x{x}", .{
index, resource.kind, resource.start, resource.len,
});
// The 16 MiB window is GTTMMADR, the register BAR (this device also has
// a 256 MiB memory BAR, GMADR — "first memory resource" would be wrong).
if (resource.kind == @intFromEnum(device.ResourceKind.memory) and
resource.len == 16 * 1024 * 1024) register_index = index;
}
if (register_index == 0) {
std.log.err("register BAR not found", .{});
return false;
}
register_base = device.mmioMap(controller_id, register_index) orelse {
std.log.err("mmio_map failed", .{});
return false;
};
std.log.info("registers mapped at 0x{x}", .{register_base});
return true;
}
fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize {
_ = message;
_ = reply;
_ = sender;
_ = capability;
return 0; // no protocol yet; the zero-length ping is answered by the harness
}
pub fn main(init: process.Init) void {
const argument = init.arguments.get(1) orelse {
std.log.err("missing device id (argv[1])", .{});
return;
};
controller_id = std.fmt.parseInt(u64, argument, 10) catch {
std.log.err("malformed device id '{s}'", .{argument});
return;
};
service.run(message_maximum, .{
.init = initialise,
.on_message = onMessage,
// .on_notification only once an IRQ or timer is bound
});
}
```
`claim` is the capability gate: MMIO mapping, DMA grants, and IRQ binding all
require it, and it pins the IOMMU domain to this process
([drivers.md — claim before touch](drivers.md#the-capability-claim-before-touch)).
## 2. Create the build package and register it in the root build
The driver directory is its own build package
([build-packages-plan.md](../build-packages-plan.md)): a ~15-line `build.zig`
plus a `build.zig.zon` beside the source. Copy both from an existing driver —
`system/drivers/pci-bus/` is the template — and adjust the name, root source
file, and the import list. The list names EXACTLY the modules the driver's
source `@import`s (the moral equivalent of its include list; an undeclared
import is a compile error):
```zig
pub fn build(b: *std.Build) void {
const exe = build_support.userBinary(b, .{
.name = "intel-uhd-graphics-750",
.root_source_file = b.path("intel-uhd-graphics-750.zig"),
.imports = &.{ "driver", "ipc", "memory", "process", "service" },
});
b.installArtifact(exe);
}
```
The zon declares `build-support`, `kernel` (implicit in every binary: the root
shim lives there), and the homes of the listed imports — for the minimal
driver above that is kernel alone plus `device` (for `driver`); add
`protocol`, `client`, ... only when an import comes from them (again, copy
pci-bus's zon and adjust). For the `.fingerprint` field, leave the copied
value in place and `zig build` will reject it and suggest the fresh one to
paste.
Then three one-liners in the root build register the package: the dependency
and a row in the boot-tree array in `build.zig` (search for
`virtio_gpu_package` to land in the right places),
```zig
const intel_uhd_graphics_750_exe = b.dependency("intel-uhd-graphics-750", .{}).artifact("intel-uhd-graphics-750");
```
```zig
.{ .path = "system/drivers/intel-uhd-graphics-750", .binary = intel_uhd_graphics_750_exe.getEmittedBin() },
```
and the path entry in the root `build.zig.zon`:
```zig
.@"intel-uhd-graphics-750" = .{ .path = "system/drivers/intel-uhd-graphics-750" },
```
Without the boot-tree row the binary never reaches the image and the
device-manager has nothing to spawn. (The package also builds standalone:
`cd system/drivers/intel-uhd-graphics-750 && zig build`.)
## 3. Add the match rule to `etc/devices.csv`
One row: bus, class triplet, vendor/device, driver path. **Copy the class
triplet from the pci-bus boot log line, not from another row** — for the iGPU
above the correct rule is
```
pci, 03, 00, 00, 8086, 4C8A, *, *, /system/drivers/intel-uhd-graphics-750
```
Field-by-field rules and the most-specific-wins policy: [devices.csv](devices-csv.md).
The registry is authoritative: an unmatched device is logged unbound, never
guessed — so a wrong nibble here means the driver simply never starts.
## 4. First contact: read, predict, verify
Before writing any register, read one whose value you can predict from state
the firmware already programmed (for a display controller: the pipe source
size of the live mode). Registers are volatile loads at `register_base +
offset`, where `offset` is what the device's manual lists:
```zig
fn read32(offset: usize) u32 {
return @as(*volatile u32, @ptrFromInt(register_base + offset)).*;
}
```
A matching read proves the whole chain — CSV match, spawn, claim, BAR choice,
mapping — with zero risk to the hardware.
## 5. Verify the plumbing
- `zig build test` still passes.
- On the image: `/var/log/<boot-stamp>/system/services/device-manager.log`
shows `spawned <name> for device <N>`, and
`/var/log/<boot-stamp>/system/drivers/<name>.log` holds the resource list and
your first read.
- If the driver did not spawn, diagnose in this order: binary on the image
(step 2) → CSV row matches the log line exactly (step 3) → path identical in
both (step 1).
## Later, when the device needs them
- **Interrupts**: MSI/MSI-X via the `pci` module, delivered as notifications to
`on_notification` — see [device interrupts](device-interrupts.md) and the
xHCI driver's `setupMsi` (QEMU trap documented there: enable MSI-X before
unmasking the device's own interrupt-enable bit).
- **DMA**: grant-backed buffers, bounded by the IOMMU domain established at
claim time ([driver model](driver-model.md)).
- **Children**: a bus driver publishes what it finds via `device_register`
([drivers.md — publishing children](drivers.md#publishing-children-device_register)).
- **A protocol**: replace `message_maximum` with the protocol's own maximum and
dispatch on the operation word in `onMessage` — every service under
`system/services/` is an example.
@@ -0,0 +1,246 @@
# Native NVIDIA GPU support — feasibility and roadmap
**Status: research snapshot, not implemented.** This records what a *native* display driver for a
real discrete NVIDIA GPU — specifically an **RTX 3060 (Ampere GA106)** — would take, and how it
would slot into danos's pluggable scanout architecture. It is a survey of primary sources
(NVIDIA's [open-gpu-kernel-modules](https://github.com/NVIDIA/open-gpu-kernel-modules), the Linux
[nouveau/nvkm](https://github.com/torvalds/linux/tree/master/drivers/gpu/drm/nouveau) driver,
NVIDIA's [open-gpu-doc](https://nvidia.github.io/open-gpu-doc/), and
[linux-firmware](https://github.com/NVIDIA/linux-firmware)), not an implementation. The NVIDIA
driver landscape moves quickly (GSP defaults, firmware ABIs); treat specifics as a mid-decade
snapshot and re-verify against current source before building.
Read [display.md](display.md) and [display-v2.md](display-v2.md) first — this doc assumes the
v2 model where scanout is a **pluggable backend** and a native driver is just another `.scanout`
service (like the virtio-gpu one), announcing to the compositor over `attach_scanout`.
## TL;DR
- A **minimal display-only driver** (EDID + mode-set + framebuffer scanout, **no** 3D/compute)
for the RTX 3060 **can and should avoid the GSP entirely**. nouveau has a register-level,
CPU-driven display path for Ampere (`nvkm/engine/disp/ga102.c`) that lights up GA106 with no
external firmware; the signed-firmware wall gates the **compute/graphics** engines (PGRAPH),
**not** the display controller. "GSP is mandatory on Ampere" is true only for NVIDIA's own
RM-object route.
- **danos's UEFI GOP boot is the single biggest thing in its favour.** The VBIOS/GOP has already
run devinit and brought up the display PLLs, so a driver attaches to a **live, initialized**
GA106 — no firmware load, no cold-boot POST, no devinit interpreter. You reprogram a running
display rather than bring one up from cold.
- It is still a **hard, multi-week-to-months expert effort** (effort tier ≈ 4/5) dominated by
NVDisplay channel-DMA programming, SOR/head routing, DisplayPort AUX + link training, and the
display supervisor handshake. The GSP/RM route is tier 5 (near-infeasible solo).
- The **licensing tension is counterintuitive**: the permissively-licensed reference (NVIDIA
open-gpu-kernel-modules, MIT/GPLv2) is the **hard GSP path**; the register-level display code
you actually want lives in **GPL nouveau**. See [Licensing](#licensing).
- The **window is closing**: GA10x (Ampere) is the *last* NVIDIA family with a register-level
display path — Ada (RTX 40) deleted its non-GSP display HAL. Targeting Ampere specifically
matters.
- **Recommendation:** for *this card*, GOP already gives native-resolution scanout with zero GPU
code and zero maintenance. A native driver buys only runtime mode changes, hardware
vsync/vblank, and multihead. It is justified if that runtime control is a danos goal, or to
*learn the craft* — for which an Intel iGPU or a pre-Turing NVIDIA card reaches "first pixel"
far faster.
## The GSP wall, and why display sits on the near side of it
On Turing and later, NVIDIA split its driver's Resource Manager into a host **CPU-RM** and a
**GSP-RM** running on an on-die RISC-V core ("Peregrine"), talking over RPC
([LWN 953144](https://lwn.net/Articles/953144/)). The GSP is a *full resource manager*, not a
display coprocessor — there is no "display-only" GSP image and no small display RPC subset. Its
boot chain is entirely signed and mandatory: a VBIOS-resident **FWSEC-FRTS** app carves a
write-protected region (WPR2), a signed **Booter** on the SEC2 falcon loads the GSP bootloader,
and that loads **GSP-RM** inside WPR. The firmware ships pre-computed signatures and the driver
picks one by an on-chip fuse-version register — **you cannot self-sign**, and there is **no stable
firmware ABI** (it is revised every driver release; nouveau and the Rust nova-core driver each pin
exactly one version). A GSP driver is a permanent maintenance liability, not a one-time build
([LWN 1037379](https://lwn.net/Articles/1037379/),
[nova-core cover letter](https://lore.freedesktop.org/nouveau/20250826-nova_firmware-v2-7-93566252fe3a@nvidia.com/T/)).
**But display doesn't need any of that on Ampere.** `nvkm/engine/disp/ga102.c` dual-dispatches:
```
if (nvkm_gsp_rm(device->gsp)) return r535_disp_new(&ga102_disp, ...); // GSP RPC path
return nvkm_disp_new_(&ga102_disp, ...); // direct register path
```
Both branches use the same `ga102_disp` HAL and the same `GA102_DISP_*` class IDs; GSP merely
swaps register programming for RPC. GA106 (chipset `0x176`) is wired to `ga102_disp_new` in the
device table, identical to GA102/103/104/107. Ampere lit up displays via the **direct** path in
Linux 5.11/5.17 — two years before GSP-RM landed (6.7, 2023)
([ga102.c](https://raw.githubusercontent.com/torvalds/linux/master/drivers/gpu/drm/nouveau/nvkm/engine/disp/ga102.c),
[Phoronix GA106](https://www.phoronix.com/news/Nouveau-NVIDIA-GA106)).
**Caveat — this is now the legacy path.** As of Linux 6.18, nouveau defaults to GSP on
Turing/Ampere; the direct path is a retained, forceable fallback (`nouveau.config=NvGspRm=0`, and
automatic when GSP firmware is absent). It is stable and proven, but NVIDIA and nova-core are
moving to GSP-only, and **Ada already deleted its non-GSP display HAL**. GA10x is the last family
that keeps a register-level display path.
## What "direct" actually entails
"Direct" is not "plain register pokes." Only SOR / PLL / DP-link / clock setup is bare MMIO. The
**mode-set and scanout themselves flow through the NVDisplay channels — a DMA pushbuffer**:
- Display classes for Ampere (the C670 family): core `GA102_DISP_CORE_CHANNEL_DMA` (`0xc67d`),
window `0xc67e`, window-immediate `0xc67b`, cursor `0xc67a` (headers `clc67d.h` / `clc67e.h` /
`clc67a.h` in [open-gpu-doc `classes/display/`](https://github.com/NVIDIA/open-gpu-doc/tree/master/classes/display)).
- The core channel needs **instance memory, a RAMHT, DMA objects, and a channel user-MMIO
region** ([disp/chan.c](https://raw.githubusercontent.com/torvalds/linux/master/drivers/gpu/drm/nouveau/nvkm/engine/disp/chan.c)).
The register-level "plumbing" to allocate/kick a channel is in NVIDIA's GA102 display register
manual: `NV_PDISP_FE_CHNCTL_CORE/WIN/CURS`, `NV_PDISP_FE_PBBASE/PBBASEHI`
([dev_display_withoffset.ref.txt](https://raw.githubusercontent.com/NVIDIA/open-gpu-doc/master/manuals/ampere/ga102/dev_display_withoffset.ref.txt)).
- **Mode-set is a method stream** on the core channel: `HEAD_SET_RASTER_*`,
`HEAD_SET_PIXEL_CLOCK_FREQUENCY`, `HEAD_SET_CONTROL_OUTPUT_RESOURCE`, `SOR_SET_CONTROL`
(protocol select), viewport/scaler, then `UPDATE`. The window channel points at the scanout
surface (`SET_CONTEXT_DMA_ISO`, `SET_STORAGE`, `SET_OFFSET`).
- After `UPDATE` you must complete the display **supervisor** interrupt handshake (SV1/SV2/SV3).
**EDID and DisplayPort are a separate subdev you must port.** open-gpu-doc documents *none* of
EDID/DDC/AUX. On the direct path you read EDID in-driver via nouveau's `nvkm/subdev/i2c`: bit-bang
**DDC/I²C at address `0x50`** (E-DDC `0x30`) for TMDS/HDMI, or native **DP AUX** in `i2c/aux.c`
for DisplayPort. DisplayPort **link training** (the `dp.c` `train_cr` / `train_eq` state machine
over AUX — clock recovery, lane/rate, voltage-swing/pre-emphasis) is the single hardest and most
fragile piece; a DVI/HDMI (TMDS) panel avoids it entirely.
## The memory floor (smaller than you'd fear)
Neither route hands you a framebuffer allocator — even GSP-RM does not manage the scanout
framebuffer; the driver owns VRAM and merely tells GSP where its page directory is. But
display-only is a small fraction of a full GEM/TTM stack:
- **Pitch-linear (untiled) scanout is allowed** on nv50→Ampere — the window's storage method has a
`PITCH` layout mode, so you skip block-linear tiling math
([wndwc37e.c](https://raw.githubusercontent.com/torvalds/linux/master/drivers/gpu/drm/nouveau/dispnv50/wndwc37e.c)).
- The window references its surface through a simple **display context-DMA**
(`SET_CONTEXT_DMA_ISO` + a 256-byte-granular `SET_OFFSET = addr>>8`) — a base/limit descriptor,
**not** the GPU's 5-level compute page tables. **No full GPU VMM is needed** for scanout.
- The surface must live in **VRAM** in practice (nouveau always pins scanout to VRAM). *Open
question:* whether GA10x can scan out from a system-memory (GART) surface via a sysmem-target
ctxdma — which would let danos skip a VRAM allocator. No source forbids it; nouveau never does
it (confidence: medium).
- **CPU access** to the framebuffer for compositing goes through **BAR1** (a VRAM aperture); BAR0
is the 16 MB register window. BAR1 can be smaller than 12 GB of VRAM unless Resizable BAR maps
it all.
**Net:** you need (1) a contiguous aligned VRAM allocator (256-byte base, pitch a multiple of
64 bytes — confirm against the Ampere display refs), (2) a little instmem for the channel
pushbuffers + iso ctxdma, (3) a BAR1 CPU mapping. You do **not** need the 5-level VMM, GEM/TTM
eviction, or tiling.
## Licensing
The tension is the opposite of convenient:
- **NVIDIA open-gpu-kernel-modules is dual MIT/GPLv2** — usable under MIT, no copyleft on your
other code — **but its display logic is the GSP/RM-object route.** Its class headers
(`cl0073.h`, `cl2080.h`, `ctrl0073*.h`) are useful, permissive references.
- **nouveau is GPLv2**, and the **register-level display sequences you actually want live in
nouveau**, not in the MIT code. So the *easy technical path is the GPL-licensed one.* Reading
GPL nouveau and reimplementing it in Zig is a derivative-work risk proportional to how closely
your code tracks its structure/constants.
Options: **(a)** accept that the danos NVIDIA display driver is a **GPL component**. danos's
userspace-driver-over-IPC model (a driver is a separate process behind a defined protocol, not
linked into the kernel) is about the cleanest possible GPL boundary, so the GPL would be contained
to that one binary and the rest of danos could keep its own license — but this is a
licensing-boundary judgement that wants real diligence, not a settled fact. **(b)** clean-room
from *specification* rather than *code*: [envytools](https://envytools.readthedocs.io) + NVIDIA's
open-gpu-doc register manuals + the MIT OGKM class headers, treating nouveau as
documentation-of-last-resort.
**Firmware licensing is moot for the direct path** (no firmware is loaded). For completeness: the
GSP blobs are marked redistributable under `LICENCE.nvidia`, which permits use by **any
OSI-approved open-source OS** (not just Linux), on NVIDIA GPUs, **unmodified**, with **no
reverse-engineering of the firmware binary**. The one gate — is danos released under an OSI
license? — is only reached on the GSP route, which this doc recommends against for this card.
## Prior art
**No one has built a from-scratch native NVIDIA driver outside Linux.** FreeBSD ships
`nvidia-drm-kmod`, a *port of NVIDIA's own closed `nvidia-drm.ko`* loading the GSP blob (its old
nouveau port was removed). Haiku's NVIDIA support is likewise a *port of OGKM* (GSP, Turing+, very
alpha). OpenBSD / DragonFly have neither. Every non-Linux OS that supports modern NVIDIA chose to
**wrap NVIDIA's GSP stack** rather than write a native driver. A danos direct-register driver
would have exactly one reference implementation — GPL nouveau — and no non-Linux precedent.
## Alternatives
| Option | What you get | The tradeoff |
|---|---|---|
| **Stay on GOP** (working today) | Native-res scanout, zero GPU code/firmware/maintenance | Resolution frozen at ExitBootServices; **no runtime mode change, no hardware vsync, no multihead** |
| **Pre-Turing NVIDIA** (Kepler / early Maxwell) | Direct EVO/disp-core + CRTC/PLL modeset, **no signed firmware, no coprocessor**; mature nouveau reference | Older display class; not this card; only reclocking is firmware-gated |
| **Intel iGPU** | **Publicly documented** register interfaces (Intel PRMs); no coprocessor mediating modeset | i915 is huge + generation-specific; write one generation from the PRM |
| **Native GA106 direct** (this doc) | Runtime modeset, vsync, multihead on the actual card | Tier-4 effort; GPL reference; DP link training; legacy/de-emphasized path |
| **GA106 via GSP/OGKM** | Also unlocks 3D / reclocking later | Tier-5; ~14k-line ante; unstable version-pinned ABI; unprecedented outside Linux |
## "First light" milestones (direct path, inheriting GOP state)
Framed as a danos `.scanout` service (like the virtio-gpu driver), taking the direct register path
and inheriting the GOP-initialized display — no signed firmware, no devinit, no GSP:
1. **PCI/BAR bring-up** — enumerate GA106 (`0x176`), map **BAR0** (registers) and **BAR1** (VRAM
aperture) via danos MMIO grants; confirm the display engine is GOP-live.
2. **VRAM + instmem allocator** — contiguous aligned VRAM for the scanout surface (256-byte base)
+ small instmem for pushbuffers / RAMHT / iso ctxdma. No VMM, no TTM.
3. **EDID** — port `nvkm/subdev/i2c` DDC (`0x50`) + DP-AUX (`aux.c`); read + parse the panel EDID.
4. **Core channel up** — allocate the `0xc67d` core channel as a DMA pushbuffer; stand up the
SV1/SV2/SV3 supervisor-interrupt handshake.
5. **First pixel = reprogram, don't re-POST** — bind a window (`0xc67e`) at the existing WC
framebuffer via `SET_CONTEXT_DMA_ISO` + `SET_OFFSET`, pitch-linear, `UPDATE`; prove you can
drive the *current* GOP mode from your own channel before changing anything.
6. **Modeset** — push raster timings on a head, route head→SOR→connector, program the pixel-clock
PLL, switch to an EDID mode (needs the `clc67d/e` method opcodes from the OGKM headers + the
supervisor timing from nouveau `head.c`).
7. **DisplayPort link training** — only if the panel is DP and GOP's link can't be reused; the
`dp.c` `train_cr`/`train_eq` state machine. TMDS/HDMI is far simpler.
8. **Wire into the compositor `.scanout` backend** (`attach_scanout`), add vsync via the display
interrupt, then multihead.
Keep the GOP backend as the fallback the whole way — a stall at any step still leaves danos with a
working display (exactly the resilience v2 already provides via re-attach).
## Reading list
**Direct path — nouveau (GPLv2):**
- `nvkm/engine/disp/ga102.c` — the GA10x display HAL + the GSP/non-GSP dispatch.
- `nvkm/engine/disp/{head.c, ior.c, dp.c, hdmi.c, chan.c}` — head/SOR routing, DP AUX + link
training, channel-DMA plumbing.
- `dispnv50/{corec37d.c, corec57d.c, wndwc37e.c, wndwc57e.c, wndwc67e.c, headc37d.c, cursc37a.c}`.
- `nvkm/subdev/i2c` (DDC + `aux.c`) for EDID; `nvkm/subdev/bios/init.c` + `devinit/` **only** if
you ever have to re-POST (danos's GOP handoff means you shouldn't).
**Object model / GSP path — NVIDIA OGKM (MIT/GPLv2):** class headers `cl0073.h`, `cl2080.h`,
`ctrl0073system.h`, `ctrl0073specific.h`; `src/nvidia/` for RM control sequences.
`nvidia-modeset.ko` (NVKMS) is a *policy* layer over RM and can be bypassed entirely.
[nova-core](https://lore.freedesktop.org/nouveau/) (Rust) is the forward-looking reference for GSP
boot mechanics (falcon signing, queue rings, RPC).
**Register / method specs — NVIDIA open-gpu-doc:**
- [`classes/display/README.txt`](https://raw.githubusercontent.com/NVIDIA/open-gpu-doc/master/classes/display/README.txt)
— the channel model + class-to-GPU map (read first).
- [`classes/display/clc67d.h`](https://raw.githubusercontent.com/NVIDIA/open-gpu-doc/master/classes/display/clc67d.h)
+ `clc67e.h` / `clc67a.h` — the Ampere core/window/cursor mode-set method vocabulary.
- [`manuals/ampere/ga102/dev_display_withoffset.ref.txt`](https://raw.githubusercontent.com/NVIDIA/open-gpu-doc/master/manuals/ampere/ga102/dev_display_withoffset.ref.txt)
— `NV_PDISP_FE_*` channel/pushbuffer registers + SOR.
- [`DCB`](https://github.com/NVIDIA/open-gpu-doc/tree/master/DCB) — connector→output-resource
routing; [`Devinit`](https://github.com/NVIDIA/open-gpu-doc/tree/master/Devinit) +
[`BIOS-Information-Table`](https://github.com/NVIDIA/open-gpu-doc/tree/master/BIOS-Information-Table)
— VBIOS parsing (bring-up reference; not needed if inheriting GOP).
- The 632 KB Volta [`dev_display.ref`](https://download.nvidia.com/open-gpu-doc/Display-Ref-Manuals/1/gv100/dev_display.ref)
is the best shot at SOR-DP/AUX register detail the smaller Ampere file omits.
## Open questions (unresolved by the survey)
Each needs a direct read of the named nouveau file or experimentation on the actual card:
- Exact GA106 register/method offsets and PADLINK→SOR→connector wiring (can vary by board vendor).
- Whether *any* PLL/devinit re-run is unavoidable vs. fully inherited from GOP.
- Whether DisplayPort needs full retraining on takeover, or the GOP-established link can be reused.
- The precise SV1/SV2/SV3 supervisor sequence.
- Whether a system-memory-target scanout ctxdma could eliminate the VRAM allocator.
- The exact `clc67d.h`/`clc67e.h` method opcode numbers (not captured verbatim in the survey).
---
*Research snapshot; verify against current nouveau / open-gpu-kernel-modules source before
building — NVIDIA's GSP defaults and firmware ABIs change per release.*
+114
View File
@@ -0,0 +1,114 @@
# USB hubs (M22)
A hub is USB **bus infrastructure**, not an application peripheral, so hub
topology is handled **inside the `usb-xhci-bus` driver** — the process that owns
the controller's device slots and contexts. A device behind a hub is not reached
by any hub-specific software path: it is reached by the **controller**,
programmed with a *route string* in its slot context. Route strings and slot
contexts are xHCI hardware concepts that only exist inside the controller driver,
so that is where hub handling belongs. Class drivers (HID, storage) stay separate
and unaware — the hub is transparent to them; a keyboard behind a hub reaches the
same `usb-hid-keyboard` driver as one on a root port.
This is a deliberate scoping choice, not a microkernel compromise: the USB *bus*
driver handles USB *bus* topology. The alternative — a separate `usb-hub`
class-driver process plus a cross-process enumeration protocol — would only
shuttle the bus's own topology state (slot ids, route strings, TT linkage) out to
another process and back, since the hub driver cannot build a slot context
itself.
## The compound-hub reality
A USB 3.0 hub is physically **two hubs** sharing each connector: a SuperSpeed hub
and a USB 2.0 companion hub, enumerated as **separate devices on separate root
ports**. A full- or low-speed device plugged into a USB 3.0 hub attaches to the
**USB 2.0 companion**, not the SuperSpeed hub. So supporting full-speed devices
(keyboards, mice) behind a hub means driving the USB 2.0 companion and handling
**transaction translators** — there is no SuperSpeed-only shortcut that reaches a
full-speed keyboard.
## Slot-context fields for a downstream device
`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
`route = (parent_route << 4) | hub_port`, capped at the xHCI 5-tier max.
- **Root Hub Port Number** (dword 1, bits 23:16) — the *root* port the whole hub
chain hangs off, inherited from the parent hub (not the hub's own port number).
- **Speed** (dword 0, bits 23:20) — read from the hub's downstream port status
after reset, not assumed.
- **Parent Hub Slot ID** (dword 2, bits 7:0) + **Parent Port Number** (dword 2,
bits 13:8) — the **transaction translator**: set when a full/low-speed device
sits behind a high-speed hub, so the controller routes split transactions
through that hub's TT. For a multi-TT hub, **MTT** (Slot Context dword 0 bit
25) is set and the TT port is the device's own hub port.
## Detection: the status-change interrupt endpoint
A hub has one interrupt IN endpoint that returns a **port-status-change bitmap**
(bit N set = port N changed). The bus arms an interrupt transfer on it (reusing
the controller's existing interrupt-endpoint machinery, but serviced
**in-process** — no class-driver subscription IPC), and on each report:
1. For each changed port, `GET_STATUS` (hub class request) reads the port's
connect/enable/reset state and speed, and `CLEAR_FEATURE(C_PORT_*)`
acknowledges the change.
2. On a **connect**: `SET_FEATURE(PORT_RESET)`, wait for reset-complete via a
later status-change report, read the enabled speed, then `setupDevice` with
the composed route string / root port / TT fields, `enumerate`, and register
the interfaces — exactly the existing path, recursing if the new device is
itself a hub.
3. On a **disconnect**: tear down the downstream device (report each interface
`ChildRemoved`, Disable Slot) — the B3 teardown path, keyed by the device's
route rather than a root port.
## Hub setup (once, when the hub enumerates)
When the bus scan (or a hot-plug bring-up) finds a device of class 9:
1. Read the **hub descriptor** (class GET_DESCRIPTOR, type 0x2A for a USB 3.0
hub / 0x29 for USB 2.0) → downstream port count, characteristics.
2. For a USB 3.0 hub, `SET_FEATURE(BH_PORT_RESET)` semantics and the depth
(`SET_HUB_DEPTH`) so the hub knows its tier for route-string forwarding.
3. `SET_FEATURE(PORT_POWER)` each downstream port.
4. Configure the hub's slot as a hub: **Hub** bit (Slot Context dword 0 bit 26),
**Number of Ports** (dword 1, bits 31:24), **TT Think Time** and **MTT** for a
USB 2.0 multi-TT hub — via an Evaluate/Configure Endpoint on the hub's slot.
5. Arm the status-change interrupt endpoint.
## Testing
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.
Real-hardware validation (the user's SuperSpeed Genesys hub + full-speed
keyboard/mouse on its USB 2.0 companion) is flagged separately — the compound
USB 3.0 hub path is not modelled by QEMU's USB 2.0 hub.
## Milestones (all complete)
- **B4a** ✓ — hub recognition + setup: detect class 9 in the scan, read the hub
descriptor, configure the slot as a hub, power downstream ports, log the
topology.
- **B4b** ✓ — downstream enumeration: the in-process status-change subscription,
port reset, Address Device with route string + root port + TT fields,
enumerate + register. A full-speed keyboard behind a USB2 hub binds
`usb-hid-keyboard` in QEMU.
- **B4c** ✓ — disconnect teardown (recursive: a hub takes its subtree with it)
and hub-behind-hub recursion (route strings compose across tiers). QEMU's hub
*does* raise downstream status changes, so both connect and disconnect are
harness-tested (`usb-hub`, `usb-hub-nested`, `usb-hub-unplug`).
Real-hardware validation of the user's SuperSpeed Genesys hub with full-speed
devices on its USB 2.0 companion remains pending — QEMU's USB 2.0 hub does not
model the compound USB 3.0 hub.
@@ -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,8 +18,10 @@ 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 |
| /test | Test fixtures for the QEMU integration suite. Read-only and initrd-backed like /system, and its layout likewise mirrors the source tree (the repo's test/ directory). Present on development and test images; a volume without it still boots. |
| /test/system/services | test-fixture binaries (e.g. /test/system/services/vfs-test, /test/system/services/thread-test) — the same path in the repo source tree and on the boot volume |
| /tmp | Directory for temporary files (see also /var/tmp). Often not preserved between system reboots and may be severely size-restricted. |
| /usr | Secondary hierarchy for read-only user data; contains the majority of (multi-)user utilities and applications. Should be shareable and read-only. |
| /var | Variable files: files whose content is expected to continually change during normal operation of the system, such as logs, spool files, and temporary e-mail files. |
@@ -47,11 +49,15 @@ addressed by device id. `/dev` is the much smaller set of devices that have a dr
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
([drivers.md](../device-driver-development/drivers.md)), so opening a `/dev` name has to resolve to that driver's
IPC endpoint, and subsequent reads and writes are calls against it. Resolve-to-endpoint
is exactly what the kernel's `fs_resolve` already does for any mounted backend, and
`FileStatus.kind` is the field that marks a device node; **what is not implemented today
is `/dev` itself** — no service mounts it. (The flat eight-node ramfs this section once
described is retired: the kernel-resident VFS root in `system/kernel/vfs.zig` serves a
read-only initrd mount per top-level tree — `/system`, and `/test` on images that carry
the fixtures — with real directories and node kinds, and filesystem
backends such as the FAT server mount the rest.) The three sections below describe the
intended shape, and are honest about which parts the kernel can already support.
### Character devices
@@ -84,9 +90,9 @@ A block driver is now **writable, but not yet memory-safe.** Every storage contr
worth naming is a bus master: it is programmed by handing it the physical address of a
descriptor ring and left to read and write memory on its own. That ring is exactly what
**`dma_alloc`** now provides — physically contiguous, pinned, uncacheable, with its
physical address disclosed — and **`/lib/mmio`**'s barriers order the descriptor writes
physical address disclosed — and **`/lib/device/mmio`**'s barriers order the descriptor writes
against the doorbell, and **`msi_bind`** delivers completions. So an AHCI or NVMe driver
can be written today (the M14/M15 work in [driver-model.md](driver-model.md); the earlier
can be written today (the M14/M15 work in [driver-model.md](../device-driver-development/driver-model.md); the earlier
"cannot host a block driver at all" is no longer true).
What is *not* yet true is that it is safe. A device programmed with an arbitrary physical
@@ -107,15 +113,16 @@ 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
counter's low bits as a poor fallback. Neither is a seeded CSPRNG, and a `/dev/random`
that is merely unpredictable-looking is worse than none — nothing should be keyed from
it until it is a real one.
it until it is a real one.
@@ -0,0 +1,192 @@
# The VFS wire protocol
> **Status:** built and spoken today between `file_system` (the client) and the
> filesystem BACKENDS (the FAT server). The mount router lives in the
> **kernel** (`system/kernel/vfs.zig`): `fs_resolve` routes a path and either
> serves it directly (the read-only /system initrd mount, via `fs_node`) or
> 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 `library/protocol/vfs/vfs-protocol.zig`
> (the `vfs-protocol` module), whose unit test pins a sample of the sizes
> and values below. This page is the **language-neutral wire specification**
> of that contract — what a Rust or C client implements ([vdso.md](../os-development/vdso.md)
> explains why the IPC protocols, not the syscall numbers, are danos's
> public ABI).
## Transport
A VFS exchange is one synchronous IPC rendezvous (`ipc_call`,
docs/ipc.md): the client sends one message and blocks; the server replies
with one message. The endpoint comes from the kernel's `fs_resolve` — which
also hands back the path rewritten relative to the mount — not from a
registry lookup. (Service id 1, the old userspace router, is retired.)
- A message is at most **256 bytes** (`message_maximum`).
- A request is a fixed 32-byte **Request** header followed by an inline
payload of at most **224 bytes** (`maximum_payload`) — a path, or write
bytes. There is no multi-message request: paths and single reads/writes
must fit, and larger transfers loop (see *read* / *write*).
- A reply is a fixed 24-byte **Reply** header followed by an inline payload —
read bytes, a `FileStatus`, or a `DirectoryEntry`.
- All integers are **little-endian**; layouts are C layout for x86-64
(`extern struct`), offsets given below so nothing need be inferred.
The kernel resolves NAMES (the mount table) but never parses these
messages — it moves the bytes; file state is entirely the backend's affair.
With clients holding backend node ids directly, a backend records each open
handle's owner and sweeps a dead client's handles via the published process
exit events.
## Request header — 32 bytes
| offset | size | field | meaning |
|-------:|-----:|-------|---------|
| 0 | 4 | `operation` | an **Operation** value (below) |
| 4 | 4 | — | padding |
| 8 | 8 | `node` | the server-side open-node id from a prior `open`; 0 for path-based operations |
| 16 | 8 | `offset` | byte position for read/write; entry index (cursor) for readdir; else 0 |
| 24 | 4 | `len` | payload length for path/write operations; requested byte count for read |
| 28 | 4 | `flags` | open flags (below); else 0 |
## Reply header — 24 bytes
| offset | size | field | meaning |
|-------:|-----:|-------|---------|
| 0 | 4 | `status` | **0 = success**, negative = failure (signed) |
| 4 | 4 | — | padding |
| 8 | 8 | `node` | the new open-node id (for `open`); else 0 |
| 16 | 4 | `len` | reply payload length in bytes |
| 20 | 4 | — | padding |
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). 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 |
|------:|-----------|-----------------|-------|
| 0 | `open` | the path (`len` = its length), `flags` as below | `node` = open-node id |
| 1 | `close` | — (`node` set) | status only |
| 2 | `read` | — (`node`, `offset`, `len` = wanted count) | `len` bytes read, payload = the bytes; `len` 0 at end of file |
| 3 | `write` | the bytes (`node`, `offset`, `len` = count) | `len` = bytes accepted (may be short — loop) |
| 4 | `status` | — (`node` set) | payload = **FileStatus** (24 bytes) |
| 5 | `readdir` | — (`node` = a directory, `offset` = cursor) | payload = one **DirectoryEntry** + name; `len` 0 at end |
| 6 | `mount` | the mount-point path; the backend endpoint rides as the call's **capability** | status only |
| 7 | `unmount` | the mount-point path | status only |
| 8 | `mkdir` | the path | status only |
| 9 | `unlink` | the path | status only |
| 10 | `rename` | old path, one `0x00`, new path (`len` = total) | status only |
Notes per operation:
- **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
shorter than requested is progress, not an error; a `len` of 0 means no
forward progress — stop rather than spin.
- **readdir** — `offset` is a **cursor: the entry index**, not a byte
position. Each call returns exactly one entry; the client increments the
cursor by 1. A reply with `len` 0 is end-of-directory. The directory must
have been opened with the `directory` flag.
- **mount / unmount** — RETIRED from the wire: mounting is the `fs_mount`
syscall now (a filesystem server passes its endpoint handle; possession is
the capability, exactly the trust of the old cap-passing op). The op
numbers stay reserved. Mount-prefix semantics are unchanged: prefixes
match at path boundaries only (`/mnt/usb` never captures `/mnt/usbextra`),
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 backend compares the old and
new parent paths and refuses a mismatch. The client (`file_system`) 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
Bitwise OR in `Request.flags`, meaningful for `open` only:
| bit | name | meaning |
|----:|------|---------|
| 1 | `create` | create the file if it does not exist |
| 2 | `directory` | open a directory node for `readdir` rather than a file |
| 4 | `truncate` | truncate an existing file to zero length on open (replace, don't overwrite in place) |
## FileStatus — 24 bytes (the `status` reply payload)
| offset | size | field | meaning |
|-------:|-----:|-------|---------|
| 0 | 8 | `size` | file size in bytes |
| 8 | 4 | `kind` | a **NodeKind** value |
| 12 | 4 | — | padding |
| 16 | 8 | `mtime` | modification time, Unix epoch seconds UTC; 0 if the backend keeps none |
## DirectoryEntry — 16 bytes + name (the `readdir` reply payload)
| offset | size | field | meaning |
|-------:|-----:|-------|---------|
| 0 | 4 | `kind` | a **NodeKind** value |
| 4 | 4 | `name_len` | length of the name that follows |
| 8 | 8 | `size` | the entry's size in bytes |
| 16 | `name_len` | name | the entry's name, not NUL-terminated |
## NodeKind
Aligned to the FSH file-type table
(docs/danos-file-system-hierarchy-FSH.md):
| value | kind |
|------:|------|
| 0 | regular file |
| 1 | directory |
| 2 | character device |
| 3 | block device |
| 4 | symbolic link |
| 5 | fifo |
| 6 | socket |
Clients should map unknown values to *regular* rather than reject — the
table can grow.
## Lifetimes and trust
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.
## Evolution rules
What a non-Zig implementation may rely on, and what it must not:
- Operation values, flag bits, `NodeKind` values, and struct layouts are
**append-only and frozen once shipped**. The unit test in
`library/protocol/vfs/vfs-protocol.zig` pins a sample of them (the `DirectoryEntry`
size, `NodeKind` 0–1, `Operation` values 0, 4 and 5); this page is the
full record of the frozen values.
- 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.
- Negative statuses beyond -1 will appear (an errno vocabulary); success is
exactly 0.
-104
View File
@@ -1,104 +0,0 @@
# Logging: the diagnostic log vs. the display
danos separates two things that are easy to conflate: the **diagnostic log** — the
machine-readable stream of *what the kernel is doing* — and the **display**, the
framebuffer surface the OS draws on. They are different concerns with different
lifetimes, so they're different code paths.
The guiding rule: **output is a diagnostic convenience, never a correctness
dependency.** The kernel must boot and run correctly with *zero* output channels —
no serial, no screen. Logging that can take the kernel down isn't robust; it's a
liability. This is the same [resilience](resilience.md) posture the rest of the
kernel follows.
## The log is multi-sink
`system/kernel/log.zig` is the diagnostic log. It fans a message out to a set of
registered **sinks**, each best-effort and self-guarding:
```zig
log.addSink(arch.serialWrite); // the serial UART
if (arch.debugconPresent()) log.addSink(arch.debugconWrite); // 0xE9 debug console
// later: log.addSink(fs.logWrite); // a file on a ramdisk / USB / SSD
log.write("…"); log.print("x={d}\n", .{x});
```
Properties that matter:
- **No allocation.** The sink table is a fixed array, so the log works before the
heap is up and inside a panic.
- **Best-effort.** A sink whose device is absent is a no-op (e.g. writing to a
missing UART just goes nowhere — the TX-wait is bounded so it can't hang). A
message reaches whatever channels exist; if none do, the kernel runs on, silent.
- **Order-independent.** Every registered sink gets every message. Adding the file
logger later is one `addSink` call and **zero** changes to call sites.
## The framebuffer is *not* a log sink
The framebuffer is a general graphics surface, **not inherently a text terminal**.
Today `system/kernel/console.zig` paints a text grid on it as a *bootstrap* console, but
that's a stop-gap: once the driver machinery exists the framebuffer becomes a proper
**graphics device driver**, and the text crutch goes away. So the log must not assume
it — routing the verbose log through a pixel console would bake in "the OS is text".
Instead the two paths are explicit:
```
verbose diagnostics ──► log ──► serial, debugcon, (file later)
user status / panics ──► status() ──► log (above) + framebuffer (if present)
```
A handful of user-facing lines (`kernel initialised`, a panic) go through
`main.zig`'s `status()` / `statusPrint()`, which write to the log **and** paint the
framebuffer when one is present. Everything else uses `log.*` and never touches the
screen. `console.write` is a no-op when the firmware gave us no framebuffer.
## Optional framebuffer (headless machines)
A framebuffer is not guaranteed — a headless server exposes no UEFI Graphics Output
Protocol. That used to be *fatal* (the loader failed the boot). Now the loader hands
over a "no framebuffer" descriptor (`base == 0`) rather than failing, and
`Framebuffer.present()` (in `system/boot-handoff.zig`) gates every on-screen path. A headless,
serial-less machine boots and runs correctly — it just goes quiet.
## Last-resort channels (no text output at all)
Two signals bypass the sink list, because they must survive even a total
output-channel failure:
- **`log.checkpoint(code)`** — a one-byte **POST code** to I/O port `0x80` (a POST
card or BMC shows it). `main.zig` emits one at each boot milestone (`cp_paging`,
`cp_heap`, …) and on a fault/panic, so "where did it hang?" is answerable with no
text output whatsoever. Writing `0x80` is universally safe.
- **`log.recordPanic(msg)`** — stamps the panic message into a fixed record
(`log.panic_record`, with a `magic` written last). A post-mortem — an attached
debugger, a RAM dump, or a future file/pstore reader — recovers *what killed it*
even though nothing was on screen.
The panic and CPU-exception handlers fan out to every sink, emit a POST code, and
drop the breadcrumb — they never assume a console.
## The 0xE9 debug console
Port `0xE9` is the Bochs/QEMU debug console. It's detected safely: the port returns
`0xE9` when read if present, and `0xFF` on real hardware, so `debugconPresent()`
only enables the sink when it's really there. Under QEMU it's captured with
`-debugcon file:…`, giving CI a log channel independent of `-serial`.
## The robustness spectrum
The result handles every combination — framebuffer only, serial only, both, or
**neither**. With no channels at all the kernel still boots and runs; port-`0x80`
checkpoints track progress and the panic breadcrumb captures failures. *Runs blind
but correct* is the goal, not *always has output*.
## Related
- [framebuffer.md](framebuffer.md) — the display surface itself (pitch, format), the
thing that becomes a graphics device driver.
- [efi.md](efi.md) — where the loader captures (or, headless, doesn't capture) the
framebuffer before `ExitBootServices`.
- [device-interrupts.md](device-interrupts.md) — the serial UART bring-up the log's
primary sink rides on.
- [resilience.md](resilience.md) — why "never let a missing peripheral take the
kernel down" is a core design stance.
+39
View File
@@ -0,0 +1,39 @@
# OS Development
This document explains the architectural decisions behind the operating system.
## Written in Zig?
The OS is written in Zig because it has excellent EFI support, so the OS boots quickly without a third-party bootloader.
Zig comes batteries included for systems work — cross-compilation, a build system, and a test runner are all part of the toolchain. Building with `-Doptimize=ReleaseSafe` keeps runtime safety checks on in the shipped kernel, which removes entire classes of bugs. The built-in test suite, combined with a QEMU integration harness, means every feature is proven to work, before it is shipped.
The codebase of the OS prioritizes readability. The aim is a codebase where someone new to OS development can find their way around without a guide.
## A microkernel?
The kernel is a thin layer: it schedules processes and manages memory. Everything else — drivers, file systems, the display — runs in user space as separate, isolated processes.
The payoff is resilience. When a driver crashes, it doesn't take the OS down with it; it gets restarted. That makes this an ideal environment for *developing* an operating system, because a buggy driver is an ordinary bug: patch it, restart the service, and keep going.
There is a security benefit too. Processes are isolated and talk over Inter-Process Communication (IPC) channels, so compromising one service doesn't hand an attacker the whole machine. Vulnerabilities tend to stay contained in the process they started in.
Other operating systems choose to pack all of these duties into one binary as a Monolithic kernel, mostly for performance: a function call inside the kernel is faster than passing a message between isolated processes. That cost is real — an IPC round-trip is a few microseconds where a function call is nanoseconds — but it is also workload-shaped. Compute-bound programs don't notice it at all. For bulk data like file contents and pixels, the design moves data through shared memory and DMA so it is copied once, the same as a monolithic kernel; only small control messages cross the IPC boundary. What remains is the per-message cost on chatty paths, and the scheduler and memory management are designed to keep that small.
## Private ABI
The syscall layer is private. The numbers and structures in `abi.zig` are an internal detail shared between the kernel and the system's own libraries, and they are free to change between builds.
The public boundary sits one level up: the [vDSO](vdso.md) that programs call into, and the documented IPC protocols such as the [VFS protocol](../file-system-development/vfs-protocol.md). Programs that stick to those interfaces keep working while the kernel rearranges itself underneath. This is the opposite of the Linux approach, where raw syscall numbers are frozen forever; here, stability is promised at the library and protocol level, and nowhere below it.
## Steal the best bits and dump the legacy
The OS is Unix-like, but selectively. It borrows the ideas that have aged well — everything is a file, small services composed over clean interfaces — and skips the parts of POSIX that have caused decades of headaches.
Some concrete choices:
- **`spawn`, not `fork`.** Creating a process starts a fresh program and returns the child's id. There is no clone-the-whole-address-space-then-immediately-throw-it-away dance, and none of the subtle state-inheritance bugs that come with it.
- **Time is a syscall.** The kernel owns the clock and timers directly. There is no time daemon to keep alive and no ambiguity about where the truth lives.
- **Lifecycle events arrive as messages.** A supervisor learns that a child exited through an IPC message on an endpoint it already owns — delivered like any other message, not as an interrupt that can fire between any two instructions.
The test for keeping an idea is simple: does it still pull its weight, or is it only there because it was there in 1979?
+30 -24
View File
@@ -5,7 +5,7 @@ PCIe config window, the timer, the power registers — in a set of **system
description tables** (SDTs). But before it can read any of them, danos has to *find*
them, and they aren't at a fixed address. Getting there is a short chain of pointers,
and this note explains it — in particular the question it's easy to trip on: **how
does the [platform / device module](arch.md) know where the RSDT is?**
does the [platform / device module](architecture.md) know where the RSDT is?**
Short answer: it doesn't receive the RSDT. The firmware hands over the **RSDP**, and
the RSDT's address is a field *inside* the RSDP. The platform follows that pointer.
@@ -16,13 +16,13 @@ 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/kernel/platform.zig
│ reads boot_information.acpi_rsdp, hands it to the ACPI backend
▼
acpi.discover(rsdp_phys, …) system/devices/acpi.zig
acpi.discover(rsdp_phys, …) system/kernel/acpi.zig
│ dereferences the RSDP, reads the pointer it contains
▼
RSDP ──(a field in the struct)──► RSDT / XSDT ──► SDTs (MADT, MCFG, FADT, HPET, DSDT…)
@@ -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).
@@ -168,5 +174,5 @@ vocabulary, and orderly shutdown — is the power service, [power.md](power.md).
ACPI enumeration and events moved to the ring-3 acpi service.
- [power.md](power.md) — the domain-named power service the ACPI event side publishes
to (button, lid, battery) and its orderly-shutdown path into S5.
- [arch.md](arch.md) — why the kernel reaches the device code through a `platform`
- [architecture.md](architecture.md) — why the kernel reaches the device code through a `platform`
module and never names ACPI directly.
+109
View File
@@ -0,0 +1,109 @@
# Architecture split
danos targets x86_64 today, but is meant to grow onto other systems later — a
Raspberry Pi, say, which is AArch64 and has no UEFI. To keep that possible without
a rewrite, CPU-specific kernel code lives behind a boundary: the generic kernel
never names an architecture, and each architecture plugs in behind it.
## The seam is a build-time module named `architecture`
The mechanism is deliberately boring — no vtables, no function-pointer tables, no
runtime dispatch. `build.zig` exposes one architecture's code as a module called
`architecture`:
```zig
const architecture_module = b.addModule("architecture", .{
.root_source_file = b.path("system/kernel/architecture/x86_64/cpu.zig"),
});
```
and the generic kernel imports it by that name:
```zig
const architecture = @import("architecture");
// ...
architecture.halt(); // never says "x86_64"
```
Adding a second architecture is then a build-time choice: create
`system/kernel/architecture/aarch64/`, and point the `architecture` module at it when the target CPU is
AArch64. `kernel.zig` and `console.zig` don't change. **That compiler-checked module
boundary _is_ the architecture interface** — when a new architecture is missing a function
the generic kernel calls, the build fails and names exactly what's missing.
## What's arch-specific vs generic
The split follows a simple test: does it name a CPU instruction, a hardware
register, or a memory-management structure? If so, it's arch-specific.
| Arch-specific — `system/kernel/architecture/x86_64/` | Generic — kernel core |
|---|---|
| `cpu.zig`: CPU state, trap-frame accessors, paging, SMP | `console.zig` — pure pixel math, framebuffer drawing |
| `gdt.zig`, `idt.zig`, `tss.zig` — descriptor tables | `kernel.zig` — kernel orchestration, scheduler, IPC |
| `paging.zig` — page-table setup and management | `process.zig` — process lifecycle, address spaces |
| `apic.zig`, `ioapic.zig` — interrupt controllers | `scheduler.zig` — task scheduling and context switch |
| `serial.zig`, `io.zig` — UART, I/O primitives | `vfs.zig` — filesystem abstraction |
| `isr.s`, `smp.zig` — exceptions, AP bring-up, context switch | `irq.zig`, `ipc*.zig` — interrupt dispatch, messaging |
| `linker.ld` — kernel link layout, load address | |
Notice the framebuffer console is *generic*: it just writes pixels into whatever
framebuffer it's handed, so it needs no per-arch version. Most of the kernel
should end up on the generic side; the architecture module stays small.
## Two axes, kept separate
There are really two independent questions, and it's worth not conflating them:
- **CPU architecture** (x86_64 vs AArch64): instructions, MMU, interrupts →
`system/kernel/architecture/<cpu>/`.
- **Boot protocol** (UEFI vs Raspberry Pi firmware + device tree): handled
*separately*, because loaders are their own binaries. `boot/efi.zig` builds
`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 [`BootInformation`](memory-map.md).
## Current x86_64 contents
- **`system/kernel/architecture/x86_64/cpu.zig`** — the `architecture` module root. Exposes the trap-frame
`CpuState` and accessors, `init()` (bring up the descriptor tables), `enablePaging()`,
`enterUser()`/`userExit()` for ring-0 ↔ ring-3 transitions, address-space management, and SMP
entry points (see [halting.md](halting.md), [interrupts.md](interrupts.md), [paging.md](paging.md),
[scheduling.md](scheduling.md)).
- **`system/kernel/architecture/x86_64/gdt.zig`** / **`idt.zig`** / **`tss.zig`** — the GDT, IDT and
TSS plus CPU-exception handling (see [interrupts.md](interrupts.md)).
- **`system/kernel/architecture/x86_64/paging.zig`** — the kernel's page tables and address-space
management (see [paging.md](paging.md)).
- **`system/kernel/architecture/x86_64/apic.zig`** / **`ioapic.zig`** — the Local APIC, its timer,
and the I/O APIC for device interrupts (see [device-interrupts.md](../device-driver-development/device-interrupts.md)).
- **`system/kernel/architecture/x86_64/serial.zig`** / **`io.zig`** — the COM1 UART (the kernel's
machine-readable log channel, see [testing.md](../testing.md)) and the shared port-I/O + MSR primitives.
- **`system/kernel/architecture/x86_64/smp.zig`** / **`per-cpu.zig`** — application-processor bring-up
and per-CPU state (GS base, system-call entry point, see [scheduling.md](scheduling.md)).
- **`system/kernel/architecture/x86_64/isr.s`** — the exception stubs, the `lgdt`/`lidt`/`ltr` load
helpers, ring-0 ↔ ring-3 transitions, and the context switch — real assembly, since Zig inline asm can't
express them (see [scheduling.md](scheduling.md)).
- **`system/kernel/architecture/x86_64/linker.ld`** — the kernel link layout (fixed low load
address, one PT_LOAD per permission set).
The kernel entry point `_start` lives in the architecture-specific `isr.s` (x86_64 here).
On x86_64 it sets up the kernel stack in BSS and jumps to `kmain()` in `kernel.zig`.
This is already per-architecture — an AArch64 port would have its own `isr.s` entry
that parses the device-tree pointer from a register and jumps to the same `kmain()`.
The entry interface is minimal and emerges naturally from the [boot-handoff](memory-map.md)
contract both share.
## The discipline
The thing that makes this help rather than hurt: **only extract what's provably
architecture-specific, and let the interface emerge with the second
implementation.** With a single architecture you're guessing at the seam, and a
wrong guess encoded as elaborate abstraction is expensive to undo. So:
- Move code into `architecture/` only when it genuinely names CPU-specific machinery.
- Grow the `architecture` surface one function at a time, as steps need it.
- Don't pre-design the interrupt or paging interfaces before writing them.
Directory hygiene is cheap and reversible; premature abstraction is neither. When
architecture #2 lands and something doesn't fit, reshaping a few hundred lines is nothing —
unwinding an abstraction empire is not.
+18 -13
View File
@@ -5,7 +5,7 @@ though — the Pis span **two different CPU architectures** (32-bit `arm` and 64
`aarch64`) and (stock) a different boot protocol from x86-64's UEFI. **danos targets
`aarch64` only** (see the decision below); the `arm`/`aarch64` distinction still
matters for understanding why. This page maps the landscape so the
[arch split](arch.md) and build system can be planned for it.
[architecture split](architecture.md) and build system can be planned for it.
## `arm` vs `aarch64` — 32-bit vs 64-bit
@@ -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
@@ -39,7 +39,7 @@ page-table formats, and calling conventions. Each needs its own `system/kernel/a
## Booting: UEFI is not x86-only
The boot protocol is a **separate axis** from the CPU (see [arch.md](arch.md)):
The boot protocol is a **separate axis** from the CPU (see [architecture.md](architecture.md)):
- **UEFI** exists for ARM too — ARM servers require it (SBSA/SBBR), QEMU boots it
with **AAVMF** (the AArch64 build of the same EDK2 firmware as x86's OVMF), and
@@ -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)
@@ -82,7 +87,7 @@ The Pi is not a "standard" ARM platform — expect Broadcom-specific peripherals
Pi 5. Everything below is an offset from it.
- **UART**: a **PL011** (at base + `0x20_1000`) plus a mini-UART; on some boards the
PL011 is wired to Bluetooth, so which one is the console varies. This is the
`aarch64`/`arm` equivalent of our x86 [COM1 serial](testing.md).
`aarch64`/`arm` equivalent of our x86 [COM1 serial](../testing.md).
- **Interrupt controller**: *not* a standard ARM GIC on the older parts — the Zero W
and Pi 3 use Broadcom's own ARMCTRL controller (Pi 3 adds a per-core "local"
controller for timers/mailboxes). The **Pi 4 and 5 do have a GIC-400**. So the
@@ -114,7 +119,7 @@ Two routes, mirroring how we test x86-64 with OVMF:
## Related
- [arch.md](arch.md) — the arch-module boundary these targets plug into, and the
- [architecture.md](architecture.md) — the arch-module boundary these targets plug into, and the
CPU-arch vs boot-protocol "two axes".
- [efi.md](efi.md) — the UEFI loader that carries over to aarch64-UEFI.
- [vision.md](vision.md) — why isolated, portable-across-architectures is the goal.
- [vision.md](../vision.md) — why isolated, portable-across-architectures is the goal.
@@ -5,21 +5,25 @@ MMIO addresses, IRQ numbers, CPU count, and interrupt controller it can't just
assume. On x86 that description comes from **ACPI** tables; on ARM from a **device
tree** (DTB). This note is a design plan, not built yet: *when* danos should tackle
it, and *how* to keep it architecture-agnostic — the same discipline the
[memory map](memory-map.md) and [arch split](arch.md) already follow.
[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
@@ -32,7 +36,7 @@ Two things drive the need, and they set the timing:
**aarch64 it's required to boot at all**. The [aarch64 port](arm.md) is what
forces the issue.
2. **Isolated user-space drivers need it.** In the [microkernel vision](vision.md),
2. **Isolated user-space drivers need it.** In the [microkernel vision](../vision.md),
drivers live in user space — but something has to enumerate the hardware and hand
each driver its MMIO regions and IRQs. That enumeration *is* device discovery. So
discovery is a prerequisite for real drivers, **not** for user mode itself.
@@ -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 {
@@ -128,7 +132,7 @@ when*:
- **User-space enumeration: a device-manager server.** Everything else — PCI devices,
peripherals — is parsed (or queried from the kernel's parse) by a privileged
user-space server that hands each driver process its MMIO regions and IRQ rights
over [IPC](ipc.md). Combined with **interrupts-as-messages** (an IRQ delivered to a
over [IPC](../device-driver-development/ipc.md). Combined with **interrupts-as-messages** (an IRQ delivered to a
driver as a message on a channel — a natural extension of the wait queues and
channels already built), that's what makes drivers genuinely isolated.
@@ -140,13 +144,13 @@ slice is unavoidably in-kernel.
On ARMv8 the generic timer exposes its frequency directly via the `CNTFRQ` register —
no calibration needed. That's cleaner than the x86 side, where we measure the LAPIC
and TSC against the PIT because nothing tells us their frequency (see
[device-interrupts.md](device-interrupts.md)). Discovery on ARM hands you more for
[device-interrupts.md](../device-driver-development/device-interrupts.md)). Discovery on ARM hands you more for
free; discovery on x86 is partly about *finding* what ARM just tells you.
## Suggested ordering
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
@@ -162,18 +166,18 @@ free; discovery on x86 is partly about *finding* what ARM just tells you.
- [arm.md](arm.md) — the aarch64 target that forces genuine discovery (DTB, GIC).
- [memory-map.md](memory-map.md) — the same loader-captures / kernel-consumes seam,
and the note about grabbing the RSDP before `ExitBootServices`.
- [device-interrupts.md](device-interrupts.md) — the LAPIC/timer bring-up that
- [device-interrupts.md](../device-driver-development/device-interrupts.md) — the LAPIC/timer bring-up that
discovery will eventually feed (IOAPIC, real IRQ routing).
- [ipc.md](ipc.md) — the channels that interrupts-as-messages and the device manager
- [ipc.md](../device-driver-development/ipc.md) — the channels that interrupts-as-messages and the device manager
will ride on.
- [vision.md](vision.md) — why drivers belong in isolated user space at all.
- [vision.md](../vision.md) — why drivers belong in isolated user space at all.
## Update (M19.3, 2026-07-13): PCI enumeration left the kernel
The kernel now seeds only the `pci_host_bridge` node (ECAM window, MMIO
apertures derived from the memory map's holes, bus range, and the 16-bit I/O
window). The per-function walk moved to the ring-3 `pci-bus` driver
([device-manager.md](device-manager.md)): it claims the bridge, repeats the
([device-manager.md](../device-driver-development/device-manager.md)): it claims the bridge, repeats the
ECAM scan through its mmio grant, and `device_register`s what it finds, which
the device manager mirrors and matches. The ACPI namespace walk follows in M20;
the static tables (MADT, HPET, MCFG, FADT + `\\_S5`) stay kernel-side.
@@ -182,22 +186,27 @@ 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
service** ([device-manager.md](device-manager.md)): it claims the `acpi-tables`
for the host bridge, FADT); at this point it also still built the AML namespace —
but only to read the `\\_S5` sleep type for poweroff. (That remnant is gone too:
the kernel now runs no AML at all — soft-off belongs to the acpi service, and the
kernel keeps only the AML-free reboot path.) Device discovery is the ring-3 **acpi
service** ([device-manager.md](../device-driver-development/device-manager.md)): it claims the `acpi-tables`
node the kernel publishes (the AML blobs, a broad io_port grant, the SCI),
re-parses the same blobs with the shared AML module, evaluates `_STA`/`_CRS`,
and registers + reports each `_HID` device — the device manager matches drivers
(ps2-bus) from those reports. With M19's pci-bus driver, discovery now runs
entirely in user space; the kernel seeds only the host bridge and the
acpi-tables node.
entirely in user space, anchored on two kernel-seeded nodes: the host bridge and
the acpi-tables node. (The kernel's static-table parse also seeds the processor,
interrupt-controller, and HPET timer nodes, and it publishes the boot
framebuffer as a claimable display node — but no *enumeration* happens in
ring 0.)
## Discovery is a swappable process per firmware (M19–M20)
Moving PCI and ACPI enumeration out of ring 0 was not just a relocation — it
made discovery **firmware-neutral by construction**, which is the whole reason
to do it before the second architecture rather than after. Everything at and
above the [device-manager](device-manager.md) protocol — descriptors,
above the [device-manager](../device-driver-development/device-manager.md) protocol — descriptors,
containment, reports, matching, supervision — is generic and may never become
x86-specific. Discovery is the single firmware-specific piece, and it is
isolated as **one swappable process per firmware**:
@@ -231,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**
(`library/device/acpi/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
+68 -40
View File
@@ -23,14 +23,21 @@ 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)): the root `build.zig` compiles `boot/efi.zig` (built
for the `uefi` target) and `build/images.zig` places it at
`EFI/BOOT/BOOTX64.efi` — the one path UEFI firmware fixes — and lays
the rest out by FHS path: the kernel at `system/kernel`, init at
`system/services/init`, the pre-packed boot capsule at `boot/system.img`
([system-image.md](system-image.md)).
`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 +63,16 @@ 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` and `/test`
trees only as a last resort for hand-assembled sticks — the capsule's format, builder, and
fallback chain are documented in [system-image.md](system-image.md). 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 +107,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 +144,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 +157,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
@@ -154,15 +174,18 @@ The loader and kernel are two *separate* binaries built for two different target
so everything they exchange must have an identically-defined memory layout. That's
what `system/boot-handoff.zig` provides — imported by both as the `boot-handoff` module.
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.
(`library/device/model/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 +193,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 + /test 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.
@@ -8,9 +8,9 @@ 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 [arch.md](arch.md), which is where CPU-specific code lives.)
page. (Contrast [architecture.md](architecture.md), which is where CPU-specific code lives.)
## Why a bitmap
@@ -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
@@ -114,11 +122,13 @@ leaves the single region containing it `reserved`, so `init` won't hand it out.
later step will move task 0 onto a kernel-owned stack, freeing that last ~1 MiB
region too (and giving user mode the clean stack it wants).
## What's next (not done here)
## What's next (partly done since)
- **Contiguous allocation** — scan for N consecutive free bits — for callers that
need physically adjacent frames.
- **A kernel stack for task 0**, so the boot stack's region can be freed too (and
for the clean stack user mode wants).
- **Freeing the `reserved` `loader_data`** (the boot-time map buffers) once the
kernel is done reading the memory map.
- **Contiguous allocation** — done: `allocContiguous` scans for a run of clear
bits, with an optional physical ceiling for DMA (`dma_alloc` is its user), and
`allocBelow` serves the SMP trampoline.
- **A kernel stack for task 0** — still open: the boot processor's idle task runs
on the boot stack to this day, so that region can't be freed.
- **Freeing the `reserved` `loader_data`** (the boot-time map buffers) — still
open: the bitmap deliberately tracks those frames so they *can* be freed, but
nothing frees them yet.
@@ -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
@@ -16,8 +16,8 @@ safely, until the machine is reset or powered off.
## The core of it: `hlt`
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 [arch.md](arch.md)), and the
generic kernel calls it as `arch.halt()`:
the arch module, `system/kernel/architecture/x86_64/cpu.zig` (see [architecture.md](architecture.md)), and the
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.
+15 -12
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
@@ -50,7 +51,7 @@ rest — works directly on the kernel heap, no bespoke containers required.
## Verifying it
The `heap` test (see [testing.md](testing.md)) exercises the allocator end to end:
The `heap` test (see [testing.md](../testing.md)) exercises the allocator end to end:
```
[PASS] alloc 4096 bytes
@@ -65,11 +66,13 @@ the proof that free and the free list actually work, not just alloc; "heap growt
forces allocation past the initial page so `grow`/`map` runs; and the `ArrayList`
check is the std-integration payoff.
## What's next (not done here)
## What's next (largely still true)
- **Thread/interrupt safety.** The heap assumes a single caller — no lock yet.
It's safe now (nothing allocates from interrupt handlers), but threads or an
allocating IRQ handler will need a lock (or `cli` around the critical section).
- **Larger alignments** than 16 (for page-aligned buffers, DMA regions).
- **`resize`/`remap` in place**, so growing an `ArrayList` needn't always copy.
- **Reclaiming empty tail pages** back to the frame allocator when the heap shrinks.
- **Thread/interrupt safety** — overtaken by the big kernel lock: SMP arrived
with a single kernel lock taken at every kernel entry, which serializes all
heap access. The heap still has no lock of its own, and needs none unless the
big lock is ever split.
- **Larger alignments** than 16 — still unsupported; page-aligned and DMA
buffers come straight from the frame allocator instead.
- **`resize`/`remap` in place** — still not done; growing an `ArrayList` copies.
- **Reclaiming empty tail pages** — still not done; the heap only ever grows.
@@ -8,9 +8,11 @@ which on real hardware and in QEMU means a silent reset. Debugging by spontaneou
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 [arch](arch.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.
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 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
@@ -122,14 +129,23 @@ fault** — reported cleanly (`double fault (vector 8)`) rather than triple-faul
into a reset, which only works because #DF ran on IST1. That's the proof the
TSS/IST is wired up: the handler survived a completely broken stack.
## What's next (not done here)
## What's next (since done)
- **The IO-APIC and the keyboard**: the timer (a local-APIC device interrupt) is
covered in [device-interrupts.md](device-interrupts.md); external devices like
the keyboard also need the IO-APIC to route their lines onto vectors.
- **SSE state**: the stubs save general registers but not the vector registers, so
a returning interrupt whose handler uses SSE will need that added. Fine for now,
since our handlers don't.
Both items originally deferred here have landed:
- **The IO-APIC**: [ioapic.zig](../../system/kernel/architecture/x86_64/ioapic.zig)
routes external device lines onto vectors — discovered via ACPI's MADT, every
input masked at init, lines unmasked one at a time as user-space drivers bind
them (see [device-interrupts.md](../device-driver-development/device-interrupts.md)). The keyboard followed
exactly as predicted: the PS/2 bus driver (`system/drivers/ps2-bus/`) claims
the 8042 controller and binds its IRQ 1 (and the aux mouse's IRQ 12) through
this routing. USB HID keyboards arrive over xHCI instead, which interrupts via
MSI, and the HPET's GSI routing exercises the same path.
- **SSE state**: `isr_common` (and the syscall entry) now `fxsave`/`fxrstor` the
full SSE/x87 register file around dispatch. This stopped being optional the
moment kernel code touched XMM — a 16-byte struct copy is a `movdqu` — and its
absence was the root cause of a long-lived corruption Heisenbug; the comments
in `isr.s` tell the story.
With faults now debuggable, the paging work that follows — where a wrong
page-table entry means an instant #PF — is far less painful.
+100
View File
@@ -0,0 +1,100 @@
# Logging
Output is a *diagnostic convenience, never a correctness dependency*: the kernel
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 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
```
process std.log ──▶ debug_write(level) ──▶ tagged kernel ring ──▶ logger service ──▶ /var/log/<boot-stamp>/<binary-path>.log
kernel log.print ─┘ │
└▶ serial / 0xE9 sinks (QEMU, -Dserial)
```
1. **Emit.** A program calls `std.log.info("mounted {s}", .{path})` — the
runtime's `logFn` (installed for every binary by the root shim,
`library/kernel/logging.zig`) formats one line and issues one `debug_write`
carrying the level. The payload does NOT contain the process's name.
`logging.write` remains as the raw/bring-up path (panics, test
fixtures); raw bytes ride the same ring, attributed all the same.
2. **Stamp.** The kernel wraps every payload LINE in a record stamped with the
sender's pid, task name (its binary path, e.g. `/system/services/fat`),
level, a per-boot sequence number, and a monotonic timestamp
(`system/kernel/log.zig` + `log-ring.zig`). Attribution is structural — a
payload cannot forge another sender's tag, and an embedded newline just ends
the record, so the forged "prefix" lands inside the forger's own next line.
3. **Retain.** The 512 KiB ring overwrites oldest-first; sequence gaps make any
loss countable. `klog_read` (#32) copies stream bytes from a free-running
offset; `klog_status` (#45) returns the cursors plus the wall-clock time of
boot. The framing (`abi.KlogRecordHeader`) is 32 bytes + name + payload,
8-byte aligned.
4. **Render.** Registered sinks (serial under `-Dserial`, the 0xE9 debug
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;
a serial-less machine just goes quiet.
5. **Persist.** The **logger service** (`system/services/logger`) drains the
ring every 250 ms and demultiplexes records into one file per source under
`/var/log/<boot-stamp>/`, e.g.
```
/var/log/2026-07-21T150434Z/kernel.log
/var/log/2026-07-21T150434Z/system/services/fat.log
/var/log/2026-07-21T150434Z/system/drivers/usb-storage.log
```
The boot stamp is the RTC anchor from `klog_status` (FAT-safe: no colons; a
dead RTC yields the 1970 directory rather than no logs). Each line carries
the record's monotonic timestamp and level. Storage is best-effort and late:
the ring buffers a whole boot many times over, and the first successful
`makePath` of the per-boot directory (also the readiness probe) triggers a
full backlog write. Files close — which is the fat server's SCSI cache
flush — after a ~2 s quiet period, bounding data-at-risk without per-record
flush thrash. At shutdown init stops the logger FIRST (it is last in the
boot order), so its final drain runs over a live storage chain.
## Why a ring in the kernel, not a logging server
The storage stack must be able to log. If the fat server wrote its own log file
through the VFS it would rendezvous-deadlock on itself; if processes sent
records to a logging server over IPC, early boot would need a buffer that is —
a ring, one hop later. The kernel ring is that buffer, placed where every
process (and the kernel itself) can reach it with one syscall, before any
service exists. The logger service is a *reader*, not a hop.
Two disciplines keep it honest:
- the logger announces itself **once** — a periodic status line would feed the
very stream it drains;
- lost records surface as an explicit `-- N records lost --` line, computed
from sequence gaps, never silently.
## Last-resort channels
Unchanged, and independent of the sink list so they survive a total output
failure: `checkpoint` (a one-byte POST code on port 0x80) and `recordPanic`
(a fixed breadcrumb record, `log.panic_record`, findable in a RAM dump; magic
written last so a reader only trusts a complete record).
## Accepted gaps
- A write-spamming process can evict other processes' unread records from the
ring (a per-process quota is future work); the loss is at least visible via
sequence gaps in every affected file.
- `/var/log` files have no privacy until the VFS grows permissions.
- Records emitted after the logger's final shutdown drain reach serial and the
ring but not the files.
@@ -16,7 +16,7 @@ forward it to the kernel as-is. We don't, for two reasons:
memory-type numbers and walk the array using UEFI's variable descriptor stride.
That's UEFI vocabulary bleeding across the handoff — and danos wants to boot on
systems that have no UEFI at all (a Raspberry Pi describes its memory with a
*device tree* instead). See [arch.md](arch.md) for the same "keep the kernel
*device tree* instead). See [architecture.md](architecture.md) for the same "keep the kernel
platform-agnostic" principle applied to CPU code.
2. **We already established the better pattern.** The loader doesn't hand the
kernel a raw UEFI GOP either — [`queryFramebuffer`](gop.md) converts it to
@@ -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
@@ -158,11 +170,13 @@ never knows the difference.
This page is plumbing plus classification only. The map's first consumer, the
**physical frame allocator**, is built directly on the `usable` regions here — which
already include the reclaimed boot-services memory the loader folded in (see
[frame-allocator.md](frame-allocator.md)). Still to come:
[frame-allocator.md](frame-allocator.md)). Of the two items once listed here, one is done:
- Freeing the `reserved` `loader_data` (these boot-time buffers) once the kernel is
done reading the map.
- Freeing the `reserved` `loader_data` (these boot-time buffers) once the kernel
is done reading the map — still open: the frame allocator's bitmap tracks those
frames so they can be freed, but nothing frees them yet.
- Capturing the ACPI RSDP from the UEFI configuration table before exit (the same
"grab it before ExitBootServices" pattern), for when ACPI parsing arrives.
"grab it before ExitBootServices" pattern) — done: the loader stows it in the
boot handoff, and ACPI parsing consumes it from there ([acpi.md](acpi.md)).
See the roadmap in [efi.md](efi.md) for where this sits in the boot flow.
@@ -7,15 +7,19 @@ which live in memory we'd like to reclaim and don't control), switches CR3 onto
them, and — crucially — maps with **real permissions**.
It's x86_64-specific (the 4-level table format is an Intel/AMD thing), so it lives
behind the [arch](arch.md) boundary in `system/kernel/architecture/x86_64/paging.zig`.
behind the [architecture](architecture.md) boundary in `system/kernel/architecture/x86_64/paging.zig`.
## The format
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
@@ -107,7 +112,7 @@ kernel heap will build on to map pages on demand.
## Verifying it
Four tests (see [testing.md](testing.md)) pin down the guarantees:
Four tests (see [testing.md](../testing.md)) pin down the guarantees:
- **`vmm`** — map a fresh frame at an unused virtual address, write and read it
back. Proves `map` works end to end.
@@ -124,13 +129,17 @@ Four tests (see [testing.md](testing.md)) pin down the guarantees:
> pointer to force a real hardware access. And `invlpg`, like `lgdt`, needs its
> operand staged through a register in inline asm.
## What's next (not done here)
## What's next (mostly done since)
- **A kernel heap** — the first real user of `map`, giving the kernel dynamic
allocation. This is the natural next milestone.
- **A higher-half kernel**: relink the kernel at a high virtual base so a future
user address space can own the low half.
- **Per-address-space tables** once there are user processes, and shared/copy-on-
write mappings.
- **Uncacheable MMIO**: the APIC/framebuffer pages are mapped writeback-cacheable;
real hardware wants MMIO marked uncacheable.
- **A kernel heap** — done, built on `map` exactly as anticipated
([heap.md](heap.md)).
- **A higher-half kernel** — done: the kernel is linked at
`0xFFFFFFFF80000000` (`linker.ld`), loaded low and running high, and user
processes own the low half.
- **Per-address-space tables** — done: each user process gets its own root with
the kernel half shared, and refcounted shared-memory mappings exist
([ipc.md](../device-driver-development/ipc.md)). Copy-on-write remains unbuilt — nothing has needed it yet.
- **Uncacheable MMIO** — half done: user-space device and DMA mappings are
strong-uncacheable and the framebuffer is write-combining via the PAT, but the
kernel's own `mapMmio` path is still writeback — the LAPIC included (see
[device-interrupts.md](../device-driver-development/device-interrupts.md)).
+12 -10
View File
@@ -7,7 +7,7 @@ them owns the hardware that reported the event, and the reporter should not know
who is listening. So system power is a **service**: an event source **publishes**
button/lid/battery/AC events, interested processes **subscribe**, and one
privileged caller — init — can ask it to power the machine off. It is the same
publish/subscribe shape as the [input service](input.md), applied to power.
publish/subscribe shape as the [input service](../device-driver-development/input.md), applied to power.
## Why a service, and why it is named for the domain, not the firmware
@@ -17,7 +17,7 @@ What subscribers want is not: *the lid closed* means the same thing regardless o
who noticed. So the surface is **domain-named**. There is a `power-protocol`
module and a well-known `ServiceId.power = 5`; on x86 the **acpi service**
registers it, and on ARM a PSCI/mailbox service will register the *same* id.
Subscribers call `runtime.ipc.lookup(.power)` and never learn which firmware they
Subscribers call `ipc.lookup(.power)` and never learn which firmware they
are on — the neutrality the whole [discovery](discovery.md) migration exists to
preserve, carried one layer up into a running-system surface.
@@ -27,7 +27,7 @@ unchanged.
## The protocol
The `power-protocol` module ([system/services/power/protocol.zig](../system/services/power/protocol.zig))
The `power-protocol` module ([library/protocol/power/power-protocol.zig](../../library/protocol/power/power-protocol.zig))
follows the vfs-protocol pattern — extern-struct messages, a version, reserved
fields. Three operations:
@@ -75,7 +75,7 @@ notifications, the lifecycle **signals** it can receive (`terminate`), and the
On a `power_button` event or a `terminate` signal, init:
1. logs that it is shutting down,
2. runs the standard stop sequence — `runtime.process.stop(child, deadline,
2. runs the standard stop sequence — `process.stop(child, deadline,
endpoint)` — over its children **in reverse spawn order**, so the VFS stops
last (other services may flush through it), each child getting the
*terminate → deadline → kill* escalation from
@@ -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 keeps only **reboot** (`acpi.reboot` in
`system/kernel/acpi.zig` — 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
@@ -124,5 +126,5 @@ until laptop sleep), and thermal zones.
firmware neutrality that makes a PSCI backend drop-in on ARM.
- [process-lifecycle.md](process-lifecycle.md) — the stop sequence
(`terminate → deadline → kill`) and signals init composes into shutdown.
- [device-manager.md](device-manager.md) — the supervision model init mirrors for
- [device-manager.md](../device-driver-development/device-manager.md) — the supervision model init mirrors for
its own children.
@@ -6,10 +6,10 @@ harness are all in — the interface below is as-built. The primitives underneat
predate this design ([process-management.md](process-management.md):
spawn, the supervision link, kill, child-exit notifications); this document designs
the layer above them — the standard vocabulary a danos process speaks about its own
life, and the stable `runtime.process` interface that carries it. Nothing here is
life, and the stable `process` interface that carries it. Nothing here is
device- or driver-specific: a driver, the VFS, and a user application all stop,
reload, and die the same way. The device manager is simply this design's first
serious customer ([device-manager.md](device-manager.md)).
serious customer ([device-manager.md](../device-driver-development/device-manager.md)).
**"POSIX" in this document means the concepts, never the letter of the standard.**
danos borrows the ideas and the hard-won lessons (what SIGTERM *means*, why SIGPIPE
@@ -19,7 +19,7 @@ rule is danos's own and it is strict: plain words that communicate intent
(`bind`, `subscribe`, `publish`, `endpoint`) — never `SIG*`, never a second word for
a concept that already has one. Literal POSIX arrives later and lives elsewhere: the
`std.os.danos` seam that makes danos a Zig target, and eventually a **musl-based C
layer** on the same native surface (see [zig-self-hosting.md](zig-self-hosting.md)) —
layer** on the same native surface (see [zig-self-hosting.md](../zig-self-hosting.md)) —
musl's syscall surface retargeted at danos system calls and IPC protocols (files onto
the VFS protocol, `sigaction`/`wait` onto this lifecycle, sockets onto whatever
networking becomes). Ported programs see POSIX; the system underneath never does.
@@ -55,10 +55,17 @@ 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.
Signals address the *process*: `id` may name any member of a threaded process
and resolves to its leader — whose endpoint the harness binds — with authority
mirroring `process_kill` ([shared-fate-plan.md](shared-fate-plan.md)).
- **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 +92,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 +142,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,25 +164,27 @@ 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
the server's reply with `-EPEER`; a server that dies fails its waiting clients
the same way. This covers the *synchronous* case only.
3. **The subscribers** — the new piece, and it is the input service's
publish/subscribe shape ([input.md](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.
publish/subscribe shape ([input.md](../device-driver-development/input.md)) applied to exits. A stateful
service accumulates per-client state across many requests: a filesystem server
(FAT today) holds a dead client's open file handles, the input service holds
its subscriptions, a future network stack holds its sockets. None of these
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
same encoding supervisors already decode, the IRQ-as-IPC pattern once more). The
subscriber filters for ids it holds state for and releases what the dead client
held. Correlating is free of bookkeeping: an IPC sender's badge already *is* its
task id (`runtime.ipc.Received`), so the id a service has been keying client
task id (`ipc.Received`), so the id a service has been keying client
state by all along is the id the exit event carries.
Subscription, not broadcast-to-everyone: only processes that asked receive
@@ -180,16 +192,17 @@ 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
service's job, triggered by the published exit event — never by a courtesy
"closing now" message that a crashed client will never send.
## The stable interface: `runtime.process`
## The stable interface: `process`
`runtime.process` already owns what a process receives at birth (`Init`, the
`process` already owns what a process receives at birth (`Init`, the
argv contract). It grows to own the other end of life.
**The runtime is the stable interface; the numbers are not.** danos applications do
@@ -221,7 +234,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 +243,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 +265,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
@@ -270,7 +283,7 @@ callbacks (`on_terminate`, `on_reload`) for programs that want defaults.
### The service harness
`runtime.service` owns the `replyWait` loop and folds every event source — signals,
`service` owns the `replyWait` loop and folds every event source — signals,
child exits, protocol messages — into callbacks, with the vocabulary's defaults:
`terminate` returns from the loop (clean exit), the common `ping` is answered automatically,
`reload` is ignored unless overridden. One loop, no locking, nothing reentrant. A
@@ -299,13 +312,15 @@ 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), `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
`process` grows the interface above; the service harness handles
`terminate` and answers the common `ping`; `stop()` for supervisors.
[device-manager.md](device-manager.md) builds directly on all four.
[device-manager.md](../device-driver-development/device-manager.md) builds directly on all four.
## Settled questions (2026-07-12)
@@ -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
@@ -55,9 +57,13 @@ dangle even if the supervisor dies first.
### `process_kill(id) -> 0 / -ESRCH / -EPERM`
Only the supervisor may kill; kernel tasks are not killable processes. Like a
signal, delivery is prompt but asynchronous — 0 means the kill is accepted and
irrevocable; the exit notification confirms completion.
Only the supervisor may kill; kernel tasks are not killable processes. The kill
is a **whole-process** kill ([shared-fate-plan.md](shared-fate-plan.md)): `id`
may name any member of a threaded process — it resolves to the group's leader,
authorization is checked against the *leader's* supervisor, and every thread
dies. Like a signal, delivery is prompt but asynchronous — 0 means the kill is
accepted and irrevocable; the exit notification (badged with the leader, posted
once the last member is gone) confirms completion.
## How a kill lands (the kernel mechanics)
@@ -100,11 +106,15 @@ 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
`process_exit_reason` (`runtime.process.exitReason`). This is the input to
`process_exit_reason` (`process.exitReason`). This is the input to
restart policy ([process-lifecycle.md](process-lifecycle.md)); an exit *code*
for the clean case can still ride alongside later.
- Enumerate writes through the caller's raw pointer under the bring-up trust
+70
View File
@@ -0,0 +1,70 @@
# The release ISO — flashable boot media
`zig build release-x86-64` produces **`zig-out/danos-x86-64.iso`**, the file you
hand to someone who wants to try danos on a real machine: point
[balenaEtcher](https://etcher.balena.io) (or Raspberry Pi Imager, or plain `dd`)
at it, flash a USB stick, and boot the stick. The same file also burns to
optical media. `zig build check-iso-image` validates it without booting.
```
zig build release-x86-64
# Etcher: select danos-x86-64.iso → select the stick → Flash
# or: sudo dd if=zig-out/danos-x86-64.iso of=/dev/rdiskN bs=4m (macOS; triple-check N)
```
## Why an ISO when danos-usb.img already boots
`danos-usb.img` is a raw FAT32 **superfloppy** — a filesystem starting at
sector 0, no partition table. UEFI firmware accepts that from a USB stick (it
probes whole-disk FAT before giving up), which is why `dd`-ing the .img works
and why QEMU and the test harness boot it directly. But it is a
developer-shaped artifact: flashing apps expect an ISO, and a superfloppy
can't be burned to a CD/DVD or carry a partition table for pickier firmware.
The ISO wraps that same FAT image — bit-identical, built by the same
`tools/make-fat-image.py` — in a container that boots everywhere release media
gets consumed. One payload, two images: the .img stays the raw volume the QEMU
harness mounts and boots, the .iso is what leaves the building.
## How a hybrid ISO boots twice
The trick (the same one Linux distribution ISOs use, usually via `xorriso
-isohybrid…`) is that ISO9660 reserves its first 32 KiB as a **system area** it
never touches — exactly where an MBR lives on a disk. So one file can carry two
tables of contents, both pointing at the same embedded FAT image:
* **Flashed to USB (Etcher, dd):** firmware sees a disk whose sector 0 is an
MBR with one partition of type `0xEF` (EFI System Partition) covering the
embedded FAT image. It mounts that ESP and runs `\EFI\BOOT\BOOTX64.efi` —
the standard removable-media path ([efi.md](efi.md)).
* **Burned to optical media:** firmware reads the ISO9660 volume descriptors
at sector 16 and finds an **El Torito** boot record. Its catalog has one
entry, platform ID `0xEF` (EFI), whose start LBA is — again — the embedded
FAT image. The firmware exposes that image as a virtual disk and runs the
same `BOOTX64.efi` off it.
Neither path involves the legacy BIOS boot-sector machinery: danos is
UEFI-only ([system-requirements.md](../system-requirements.md)), so the MBR holds
no boot code, just the partition entry, and the El Torito entry is EFI-class,
not floppy emulation.
One El Torito wrinkle: the catalog's sector-count field is 16-bit (units of
512 bytes), so it can name at most 32 MiB — less than the 64 MiB FAT image.
That is fine in practice: firmware sizes the FAT filesystem from its own BPB,
and the boot files sit in the first few MiB of the image (clusters are
allocated from the front) either way. The USB path has no such cap.
## The builder
`tools/make-iso-image.py` follows the house rule of
[make-fat-image.py](../../tools/make-fat-image.py): pure Python 3 standard
library, no external tools (no xorriso, mkisofs, or isohybrid), with a
`--verify` mode the `check-iso-image` step runs — it checks that the MBR
partition and the El Torito catalog agree on where the FAT image lives and
that a FAT32 boot sector is actually there. Every timestamp field in the ISO
is zeroed, so the build is reproducible byte-for-byte.
The ISO9660 filesystem around the boot machinery is minimal but real: a root
directory listing `BOOT.CAT` (the catalog) and `EFI.IMG` (the FAT image), so
`file`, mount tools, and archive browsers can open the ISO and see what's in
it.
@@ -5,7 +5,7 @@ isolation; fault → kill the process → keep the core (`onException`; the
`fault-recovery` test); the supervisor notification **with exit reasons**
([process-lifecycle.md](process-lifecycle.md) — clean exit, fault class, or
killed, recorded before the notice posts); and the **restart policy itself**
([device-manager.md](device-manager.md)): the device manager supervises every
([device-manager.md](../device-driver-development/device-manager.md)): the device manager supervises every
driver, restarts crashes with backoff, caps crash loops, and re-claims work
because the kernel releases a dead process's claims. The `driver-restart` and
`usb-report` scenarios prove kill → release → respawn → re-claim → re-report
@@ -14,9 +14,9 @@ more of the system moved into restartable processes (the discovery migration,
[discovery.md](discovery.md), is the next rung). This is the property danos is really chasing:
**if a part of the OS breaks, isolate it, and re-initialise it — without rebooting.**
A crashed driver gets restarted; a wedged service gets killed and brought back. It's
the reason the [microkernel](vision.md) shape was chosen, and it's a *separate* goal
the reason the [microkernel](../vision.md) shape was chosen, and it's a *separate* goal
from [real-time](smp.md#does-the-right-choice-depend-on-real-time-vs-resilience) —
one that's less pervasive to build (see [vision.md](vision.md)).
one that's less pervasive to build (see [vision.md](../vision.md)).
## The idea: "let it crash" + supervision
@@ -44,7 +44,7 @@ down. **Keeping the kernel minimal is a resilience strategy, not just an aesthet
## The building blocks
1. **Address-space isolation.** A fault in one component can't corrupt another or the
kernel. This is the [user-mode milestone](vision.md) (ring 3, per-process page
kernel. This is the [user-mode milestone](../vision.md) (ring 3, per-process page
tables) — the shared prerequisite for *any* of this, and it's needed regardless.
2. **Fault detection** — how the system notices a component is dead or sick:
- **Crash**: a CPU fault in a user process (page fault, illegal instruction) traps
@@ -81,7 +81,7 @@ Detecting and killing is the easy half. The genuinely tricky questions are about
- **In-flight IPC**: messages sent to the dead component, or replies its clients are
blocked waiting for. The channel has to break cleanly and unblock the waiters with
an error rather than hang them forever (a design constraint that reaches back into
[ipc.md](ipc.md) — channels need a "peer died" outcome).
[ipc.md](../device-driver-development/ipc.md) — channels need a "peer died" outcome).
- **Clients**: how does a client discover the service it was talking to is gone and
has been replaced? Options: capability revocation makes stale handles fail; or a
**name server** re-binds clients to the new instance; or clients retry through a
@@ -137,7 +137,7 @@ Honest boundaries:
Resilience needs **structural** features (isolation + supervision + a resource
model); real-time needs a **pervasive** timing invariant. They're separable, and
resilience is the lighter commitment (see [smp.md](smp.md) and [vision.md](vision.md)).
resilience is the lighter commitment (see [smp.md](smp.md) and [vision.md](../vision.md)).
Note the overlap, though: **preemptive scheduling** and **priorities** — already
built — serve resilience too (you can preempt and kill a misbehaving component, and
run the supervisor at high priority). So danos keeps the useful *mechanisms* of the
@@ -156,10 +156,10 @@ real-time work without owing anyone a timing *guarantee*.
## Related
- [vision.md](vision.md) — the goals this serves (learning by doing; resilience over
- [vision.md](../vision.md) — the goals this serves (learning by doing; resilience over
hard real-time).
- [scheduling.md](scheduling.md) — preemption, which makes runaway components killable.
- [ipc.md](ipc.md) — channels that need a "peer died" outcome for clean restart.
- [ipc.md](../device-driver-development/ipc.md) — channels that need a "peer died" outcome for clean restart.
- [interrupts.md](interrupts.md) — fault reporting that user mode turns into "kill and
restart" instead of "halt".
- [smp.md](smp.md) — the real-time-vs-resilience fork, in the SMP context.
@@ -3,11 +3,11 @@
The scheduler turns danos from a linear "boot then halt" kernel into a **running
multitasking system**. It's **fixed-priority preemptive**: the highest-priority
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
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
stack setup are architecture-specific (`system/kernel/architecture/x86_64/`, see [arch](arch.md)).
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
@@ -37,7 +37,7 @@ down a return address pointing at `task_trampoline` and zeroed callee-saved slot
`schedule()` — pick the best task and switch — runs from two places:
- **`yield()`** — a task voluntarily gives up the CPU.
- **`tick()`** — the 1000 Hz [timer](device-interrupts.md) preempts the running
- **`tick()`** — the 1000 Hz [timer](../device-driver-development/device-interrupts.md) preempts the running
task. This is what lets a task that never yields still share the CPU.
The subtlety in mixing them is the **interrupt flag (IF)**. The rule: `switch_context`
@@ -97,7 +97,7 @@ marks the task blocked with a wake deadline and switches away. On every tick the
timer wakes any task whose deadline has passed (a bounded scan, so it stays
deterministic), which makes it ready again; the scheduler then runs it when its
priority comes up. `sleep` measures its deadline on the [calibrated
clock](device-interrupts.md), so it's real time.
clock](../device-driver-development/device-interrupts.md), so it's real time.
When *every* task is blocked, something still has to run — so there's an **idle
task** at the lowest priority that just `hlt`s until the next interrupt (see
@@ -111,7 +111,7 @@ The other form of blocking is waiting for an **event** rather than a duration. A
the caller on it, `wake(wq)` moves the highest-priority waiter back to ready
(preempting if it now outranks the running task). A task links into a wait queue
through the same field the ready queues use — it's in exactly one queue at a time.
These are the primitives locks, semaphores and [IPC](ipc.md) are built on.
These are the primitives locks, semaphores and [IPC](../device-driver-development/ipc.md) are built on.
Blocking safely needs **composable critical sections**. A blanket `cli`/`sti` pair
doesn't nest: an IPC channel that `cli`s and then calls `wait` would have `wait`'s
@@ -124,7 +124,7 @@ caller's state.
## Verifying it
Three tests (see [testing.md](testing.md)) prove the guarantees:
Three tests (see [testing.md](../testing.md)) prove the guarantees:
- **`sched`** spawns three tasks that busy-loop *without ever yielding*. They all
make progress — which can only happen if the timer is **preempting** between them
@@ -136,13 +136,14 @@ Three tests (see [testing.md](testing.md)) prove the guarantees:
- **`event`** blocks a task on a wait queue; waking it (from another task) resumes
it, and since it's higher priority it preempts immediately.
## What's next (not done here)
## What's next (partly done since)
- **Priority inheritance.** Once tasks block on shared resources (locks, IPC),
danos will need it to bound priority inversion — a [real-time](vision.md)
requirement.
- **Task exit / a reaper.** `exit` currently leaks the task's stack; nothing frees
finished tasks' memory yet.
- **Per-address-space tasks.** Today all tasks share the kernel address space. User
processes will each get their own, switching page tables (CR3) on the context
switch.
- **Priority inheritance** — still open. Tasks now do block on shared resources
(IPC rendezvous, the big kernel lock), and nothing yet bounds priority
inversion — a [real-time](../vision.md) requirement.
- **Task exit / a reaper** — done. A dying task goes on its core's reap list in a
`.reaping` state; the timer tick drains the list, frees the stack back to the
heap, and recycles the task-table slot.
- **Per-address-space tasks** — done. User processes each own an address space,
and the context switch reloads CR3 when the target's tables differ (see
[paging.md](paging.md)).
+310
View File
@@ -0,0 +1,310 @@
# Shared fate: whole-process death (plan)
**Status: implemented 2026-07-22 (branch shared-fate), M1–M4 all landed; leader
`thread_exit` → `-EPERM` as decided. One scope addition forced by M4: the
per-task DMA/shared-memory arena cursors moved to the per-space object (the
`shm-mapping-ref` test could not distinguish corruption-by-remap from
corruption-by-free while sibling threads overlapped the arena) — the same move
the mmap/MMIO cursors made in threading M7.**
[threading.md](threading.md) promises that a process dies *whole* — a fault in any
thread, or a kill, takes down every thread. The kernel doesn't do that yet: every
death path (`exit`, `thread_exit`, a ring-3 fault, `process_kill`) tears down
exactly one `Task`, and the address-space refcount keeps the space alive for the
siblings — so a faulting worker orphans its threads, which keep running in the
possibly-corrupted address space (`process.zig` `killCurrentProcess`;
`scheduler.zig` `exitUserLocked`). This plan closes that gap the way Linux, Windows,
and Fuchsia all did: **the process is the unit of fate; only a voluntary
`thread_exit` is per-thread.** The supervisor contract — one exit notification,
then `process_exit_reason` — is deliberately unchanged.
## The contract
| Event | Who dies | Reason the supervisor reads (leader's record) |
|---|---|---|
| CPU fault in **any** thread (recoverable vector) | the whole group | the fault class (`segmentation_fault`, …) |
| `process_kill` on **any member id** | the whole group | `killed` |
| `exit(code)` from **any** thread | the whole group | `exited` (0) / `aborted` (≠0) |
| `thread_exit` from a worker | that worker only | — (per-task record: `exited`) |
| `thread_exit` from the **leader** | nobody — refused, `-EPERM` (decided, below) | — |
| NMI, double fault, machine check | the core halts (unchanged) | — |
`exit` gaining group semantics is the `exit_group` lesson from Linux: the runtime's
main-return path calls `exit`, and a process whose main returned must not leave
workers running. `thread_exit` (what the worker trampoline calls) keeps today's
per-thread behavior, refcount and all.
**The leader-`thread_exit` rule (decided: refuse).** The syscall is reachable from
the leader even though the runtime never does it. Options weighed: (i) **refuse
with `-EPERM`** — cheapest and honest; the group ends only through
`exit`/fault/kill; (ii) escalate to `exit(0)` — Linux-flavored, but silently turns
a (buggy) library call into process death; (iii) a Linux-style zombie leader whose
slot survives until the group ends — the most faithful, and by far the most
machinery. **(i) chosen at sign-off**; rows and tests below follow it.
## Group identity: a leader id
Nothing on `Task` names a process today — threads are tied to their process only by
an equal `address_space`, their name is `"thread"`, and their `supervisor` is
whichever *task* spawned them (possibly another worker), so supervision links form a
chain, not a group. Scanning by `address_space` is also fragile during teardown,
because both death paths zero it.
So: **add `leader: u32` to `Task`** — the Linux tgid, in danos clothes.
`spawnProcessSupervised` sets `leader = own id`; `spawnThreadSupervised` copies the
*caller's* leader; kernel tasks keep `leader = 0`, which is never followed. The
leader id is exactly the id `system_spawn` returned to the supervisor, so the
outside world already speaks it. Group membership = equal `leader`, where a *live
member* means `state` ∈ {`.ready`, `.blocked`, `.running`} — the same filter
`taskByIdLocked` applies; `.reaping` corpses are excluded. While the field is being
introduced, add `leader` to `ProcessDescriptor` too (the ABI is private, so this is
cheap now and lets `process_enumerate` consumers group threads).
`process_kill` re-derives its authority through the leader — with the existing
guard order preserved: a kernel task (`address_space == 0`) is `-ESRCH` *before*
any leader resolution (the kernel test asserts exactly that). Then: resolve the
target, follow `target.leader`, require `leader.supervisor == caller`. The kill
capability becomes per-*process*, aimed at any member id, and the odd
today-behavior where a worker can be individually killed by its spawning thread
disappears. (Audited: nothing in-tree kills a worker tid or relies on
thread-supervisor kill semantics.)
## The group-dying latch
`AddressSpaceRef` — one per space, refcounted by its member tasks, recycled with a
full struct re-init — gains the group-death state:
```zig
dying: bool = false, // set by the first trigger; never cleared
group_reason: abi.ExitReason, // what the leader's record will say
exit_endpoint: ?*ipc.Endpoint, // the leader's counted ref, moved here
leader: u32,
```
The latch answers three attacks the red team confirmed against a latch-free
design:
- **The spawn gate.** A member already *inside* `thread_spawn` on another core
when the fan-out runs (it passed the syscall-entry `kill_pending` check, then
spun on the BKL) would otherwise complete the spawn after the fan-out's lock
hold ends — a fresh, uncondemned member that escapes the kill and, worse, holds
a space reference that keeps the group-death hook from ever firing. Fix:
`retainAddressSpace` (equivalently `spawnUserLocked`) **refuses a dying
space**; the in-flight `thread_spawn` fails with `-ESRCH` under the same lock
that would have created the member.
- **Concurrent triggers.** A second member faulting (or exiting) on another core
while the first fan-out runs must not re-run the fan-out, double-bump
`fault_kill_count`, or re-stamp reasons. Every kill path checks the latch first:
already dying → skip straight to `terminateCurrentLocked`, no stamp, no count.
First trigger wins, deterministically. `exit_reason` and `fault_kill_count`
writes move under the BKL as part of this.
- **Notification ownership.** The leader's `exit_endpoint` is a counted
birth-to-death reference dropped at notify time. The stamp pass **moves** that
reference onto the `AddressSpaceRef` and nulls `Task.exit_endpoint` in the same
hold, so the leader's own `releaseTaskResourcesLocked` sees null (no early
notify, no double drop); the group-death hook notifies and drops exactly once.
## The fan-out: `killGroupLocked`
One new function in `process.zig`, running under a **single BKL hold** (built from
the `*Locked` primitives — the lock is non-recursive, and `terminateCurrentLocked`
never returns, which forces the shape):
```
killGroupLocked(leader: u32, reason: ExitReason, trigger: ?*Task)
0. Latch: AddressSpaceRef.dying = true, stash {reason, leader,
leader's exit_endpoint (moved)}.
1. Stamp pass: the LEADER's exit_reason = reason — the leader's
record is the one the supervisor can read, so it carries the
group reason even when the trigger is a worker. The trigger
also keeps `reason` (its own record tells the truth); every
other live member gets .killed. All members get kill_pending.
Stamping precedes any teardown, because recordExitLocked
snapshots the reason first thing.
2. Reap pass, to fixpoint: reap every member in .ready or .blocked
via reapTaskLocked, re-reading Task.state each iteration — a
member's teardown can wake another member (-EPEER wakes, joiner
wakes), flipping it .blocked → .ready behind the scan cursor.
Terminates in ≤ one pass per member: the scrub calls in
releaseTaskResourcesLocked (abandonSenderLocked,
removeFromWaitQueueLocked, forgetIpcClientLocked,
killOwnedEndpointsLocked) run before destroy, so no wake path
holds a pointer to a reaped member.
3. Members .running on other cores stay condemned (kill_pending);
a condemned member dies at its next syscall entry, at its own
core's next tick while in user mode, or — once it blocks or is
preempted — at any core's next tick reap. There is no kill IPI.
(The entry check reads kill_pending unlocked; benign on
x86-TSO — a missed read is caught by the next delivery point —
but make the field atomic when touching it.)
4. If the current task is a member (fault, exit, in-group kill):
terminateCurrentLocked, last, because it switches away and the
reap paths free the kernel stack being stood on.
If the caller is outside the group (supervisor kill): return.
```
The invariants this preserves, each load-bearing today:
- **Only `.ready`/`.blocked` tasks are reaped synchronously.** A member running on
another core can only be condemned — it tears itself down after switching CR3
off the dying page tables (the stack it stands on is freed later by the reap
list), and its address-space reference protects the page tables its CR3 still
points at. Force-destroying the space under a running sibling is the one
unrecoverable mistake available here.
- **The refcount decides when the space dies.** Reaping N members drops N
references; the last drop — possibly on a condemned sibling's core, a tick
later — destroys the space. No path forces it.
- **`fault_kill_count` bumps once per group**, not per member (`fault-recovery`
asserts `== 1` exactly); the latch is what enforces this under racing faults.
- **Per-tid resource sweeps stay per-tid.** Each member's
`releaseTaskResourcesLocked` releases what *that tid* owns — claims, GSI/MSI
bindings, registered endpoints, handles. That keying is correct under shared
fate (and is today's hazard: a lone worker death already yanks its claims out
from under live siblings). A worker that *does* carry an `exit_endpoint` (the
ABI allows it; the runtime passes `no_cap`) keeps today's per-task posting at
its own teardown — only the leader's notification moves.
## When is the group dead? The notification
Today each task posts its own exit notification as the *last* step of its release,
so a supervisor observes a fully-released child. For a group that guarantee must
hold for the **whole group**: if the leader's notification fires while a condemned
sibling still runs on another core, the device manager can respawn the driver into
a claim conflict with a not-yet-dead sibling.
The clean fix falls out of the refcount: **the group is dead exactly when the
address space is destroyed.** `releaseAddressSpace`'s last-drop path calls a new
`group_exit_hook` (the scheduler already calls up through hooks —
`terminate_current_hook` — precisely to keep this layering), which:
1. **re-stamps the leader's exit record** with the stashed `group_reason` — the
record is written (again) at group-death time, so "the reason is recorded
before the notification posts" stays true and a supervisor can never be
notified and then read `-ESRCH` because the burst evicted an old record;
2. posts the leader's exit notification (and subscriber broadcast) from the
stashed endpoint, and drops that reference — exactly once.
Both `releaseAddressSpace` call sites (`exitUserLocked`, `destroyTaskLocked`) run
under the BKL, so the hook does too; its wakes are safe at both (verified). For a
single-threaded process the behavior is *externally indistinguishable* from
today — the order of notify vs. destroy inverts, but both sit inside one lock
hold, so no other core can observe the space destroyed but the notification
unposted, or vice versa. That sentence is the correctness argument; it is also the
first invariant to re-examine if the BKL is ever split, along with
`killGroupLocked`'s single-hold atomicity. (Hand-built spaces that were never
retained take the immediate-destroy path and are out of the hook's scope.)
Workers' `exit_subscribers` broadcasts still fire per task — the FAT server's
dead-client sweep is keyed by tid and needs those.
**Signals.** `signal_bind` is per-task and the service harness binds on the main
thread, so signals address the leader in practice; that stays. During a group
death, `process_signal` may return `0` (accepted by a condemned member — never
delivered, every delivery point kills first) or `-ESRCH` (member already reaped);
init's stop sequence already tolerates both, and its timer escalation to
`process_kill` covers the gap. `process_signal` follows `process_kill`'s
leader re-key for consistency.
## The shared-memory frame hazard
`dropSharedMemoryReference` frees a region's physical frames when the last *handle*
reference drops, but mappings die only with the address space. If the last handle
lived in a torn-down member while any task still has the region mapped, that task
holds a live mapping onto freed frames — and the red team showed this is **not**
group-specific: a plain `thread_exit` of the handle-holding thread, or a last-ref
drop by a task *outside* the dying group during the condemned window, hits the same
use-after-free.
So the fix is a property of the **object**, not the dropper: give
`SharedMemoryObject` a per-*mapping* reference — `shared_memory_map` (and create's
self-map) retains; each space's destruction releases. "Last reference" then means
*no handles and no mappings*, both hazard paths collapse into the existing
refcount, and no group-kill special case is needed at all.
## Deliberately unchanged
- Worker `thread_exit`: per-thread, full per-tid resource sweep, refcount drop.
- The condemned-but-running window: a member on another core can finish its
in-flight syscall and run user code for up to a tick before dying — identical to
today's single-task `process_kill` semantics ("prompt but asynchronous, like a
Unix signal"). A kill IPI would shrink it; it is not part of this plan.
- `thread_join` returns 0 for a killed thread; joiners inside a dying group are
woken and then reaped like any member.
- The `.reaping` state, reap lists, and stack reaper.
## Accepted limits (documented, not fixed here)
- **Notify-ring overflow**: a group death posts one subscriber badge per member
into 8-slot rings; a >8-member group can drop badges. Group size is bounded by
the 48-task table; today's largest production group is 2 (display) and
thread-test already reaches 5.
- **Exit-record ring pressure**: one 64-entry ring, one record per member — made
harmless for the supervisor by the hook's group-death re-stamp.
- **Enumerate shows a partial group** mid-death: reaped members vanish at once,
condemned members linger up to a tick (audited: no in-tree consumer
misbehaves; the `leader` field in `ProcessDescriptor` lets future consumers
group correctly).
- **Pre-existing reap race, not widened**: a task preempted *mid-syscall* is
`.ready` with `in_system_call = true`, and the tick's reap loop will reap it —
an existing hazard the fan-out inherits but must not add new instances of.
Filed to investigate separately.
- **Per-task DMA/shm cursors** — *fixed during M4 after all*: the
`shm-mapping-ref` test tripped the overlap (the sibling's churn regions mapped
over the worker's region), so both cursors moved to the `AddressSpaceRef`
like the mmap/MMIO cursors before them. The post-implementation review then
found the other half: the shm/DMA page-table walks and their pmm/heap calls
ran *outside* the big kernel lock — pre-existing, but fatal once siblings
were invited to race them (and a plausible root for the long-standing
intermittent AP ring-3 fault at the shm base). All three paths now follow
the mmap discipline: metadata and allocation under one hold, the map itself
per-page under brief holds.
- **Mapping-record slots are never recycled**: 16 per space, one per
`shared_memory_create`/`map`, freed only at space destruction (there is no
shm unmap). A long-lived compositor that churns surfaces will hit the cap;
the failure is a clean refused create, and slot recycling can ride whatever
adds `shared_memory_unmap`.
- **Two properties lack direct tests**: the spawn gate (an in-flight
`thread_spawn` racing the fan-out — inherently nondeterministic to arrange;
covered by code inspection and the `-ESRCH` path) and the `process_signal`
leader re-key (exercised only implicitly by the signals case).
## Milestones
- **M1 — the leader id.** `Task.leader` (kernel tasks: 0, never followed), set on
both spawn paths; `leader` added to `ProcessDescriptor`; `process_kill` and
`process_signal` re-keyed (kernel-task `-ESRCH` guard *before* leader
resolution). No fan-out yet. Existing tests must pass untouched.
- **M2 — the latch + fan-out.** `AddressSpaceRef.dying` + stash;
`retainAddressSpace` refuses dying spaces; `killGroupLocked`; wire the fault
path, `exit`, and `process_kill` into it; leader `thread_exit` → `-EPERM`;
`exit_reason`/`fault_kill_count` writes under the BKL; `kill_pending` made
atomic. Group notification via the `group_exit_hook` re-stamp + post.
- **M3 — shared-memory mapping refs.** `SharedMemoryObject` counts mappings;
space destruction releases them; frames free only at zero handles *and* zero
mappings.
- **M4 — tests + docs.** New `-Dtest-case`s (all `smp: 4` where cross-core
matters), driving `thread-test` with new argv modes:
- `thread-fault-group`: a worker faults; assert both tasks gone from
`enumerate`, `fault_kill_count == 1`, `process_exit_reason(leader) ==
segmentation_fault`, address-space and stack-bytes counters return to base.
- `kill-threaded-group`: `process_kill(leader)` with a worker spinning on
another core; assert the worker dies by the deferred path, exactly one exit
badge, delivered only after both members are dead, and
`process_exit_reason(leader) == .killed`.
- `kill-via-worker-tid`: `process_kill(worker)` kills the whole group;
`-EPERM` for a non-supervisor aiming at the worker.
- `racing-triggers`: two members fault/exit simultaneously on different cores;
assert a deterministic leader reason and `fault_kill_count == 1`.
- `exit-group`: a *worker* calls `exit(3)`; group dies, leader reason
`.aborted`.
- `leader-thread-exit`: asserts the chosen rule (`-EPERM`, workers unaffected).
- `thread-exit-solo`: regression — worker `thread_exit` still leaves siblings
running.
- `group-claim-release`: a member claims a device; assert the claim is free and
the leader notification arrives only after every member is dead.
- `shm-mapping-ref`: last handle dropped by a dying thread; sibling's mapping
stays valid until space death (M3 regression).
Then update [threading.md](threading.md) (the shared-fate gap note),
[process-lifecycle.md](process-lifecycle.md),
[process-management.md](process-management.md), and
[ipc.md](../device-driver-development/ipc.md)/[drivers.md](../device-driver-development/drivers.md) mentions.
+21 -15
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
@@ -111,7 +112,7 @@ Yes — and this is the branch that matters for danos right now.
These pull in different directions, so **picking the primary goal comes before
picking the SMP design.** (danos's founding assumption was real-time; that's under
active reconsideration in favour of resilience — see [vision.md](vision.md).)
active reconsideration in favour of resilience — see [vision.md](../vision.md).)
## What this would mean for danos
@@ -158,13 +159,17 @@ next lands.
`ipc.zig` run every critical section under it. Uncontended on one core, so behaviour
is identical to the old interrupt-flag model.
- **Per-CPU state** — a `PerCpu` struct (running task, idle task, APIC id) per core,
its pointer kept in the x86 **GS base** (`IA32_GS_BASE`; no `swapgs`, since there's
no user mode yet). The old global `current` is now `thisCpu().current`. The ready
its pointer kept in the x86 **GS base** (`IA32_GS_BASE`). No `swapgs` was needed at
the time — there was no user mode yet; with ring 3 in place, every ring transition
now swaps it against the user's own GS base under the `swapgs` discipline (see
`system/kernel/architecture/x86_64/per-cpu.zig`), and each AP enables the fast
system-call path (`initSystemCall`) for itself at bring-up. The old global
`current` is now `thisCpu().current`. The ready
queues stay **global** under the lock — work-conserving, so any idle core will pull
the highest-priority ready task; per-core queues are a later optimisation.
- **AP wake to long mode** — `arch.startSecondary` drives INIT–SIPI–SIPI (via the
- **AP wake to long mode** — `architecture.startSecondary` drives INIT–SIPI–SIPI (via the
LAPIC ICR) to wake each parked core one at a time. A woken core starts in 16-bit
real mode at a low page and runs the [trampoline](../system/kernel/architecture/x86_64/trampoline.s)
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`,
publishes its per-CPU pointer, and reports in. Verified in QEMU with `-smp 4`:
all four cores report `online`.
@@ -203,8 +208,9 @@ next lands.
[scheduling.md](scheduling.md#affinity-pinning-a-task-to-a-core)). The `affinity`
test confirms a pinned task never migrates. This is the mechanism the fault-on-AP
test rides on, and the *explicit-affinity* real-time-predictable model.
- **Right-sized footprint** — the per-CPU ceiling (`system.max_cpus`, one constant
shared by discovery, the scheduler, and the per-core GDT/TSS) is generous (128), but
- **Right-sized footprint** — the per-CPU ceiling (`parameters.maximum_cpus`, one
constant shared by discovery, the scheduler, and the per-core GDT/TSS) is generous
(128), but
the *large* per-core resources — the kernel and IST (double-fault) stacks — are
**heap-allocated at bring-up**, only for cores that actually come online. Only the
BSP's IST stack is static, because it must exist before the frame allocator does.
@@ -217,7 +223,7 @@ next lands.
life, but kept **inert between wakes**: zeroed and non-executable, armed (blob
copied in, page made executable) only for the moment a core is actually climbing,
then disarmed again. So there's never a dormant executable page, and a core can be
(re)woken at any time — `arch.startSecondary` is one self-contained attempt (arm →
(re)woken at any time — `architecture.startSecondary` is one self-contained attempt (arm →
INIT–SIPI–SIPI → disarm), and its `INIT` resets a wedged core, so retrying just
works. Boot retries a non-responding core up to three times; the same primitive is
the groundwork a future **power manager** would drive to bring cores up (and,
@@ -264,6 +270,6 @@ next lands.
- [scheduling.md](scheduling.md) — the single-core scheduler SMP would extend.
- [discovery.md](discovery.md) — enumerating cores is a device-discovery problem.
- [ipc.md](ipc.md) — the message passing cross-core coordination rides on.
- [vision.md](vision.md) — the goals question (real-time vs resilience) this note
- [ipc.md](../device-driver-development/ipc.md) — the message passing cross-core coordination rides on.
- [vision.md](../vision.md) — the goals question (real-time vs resilience) this note
keeps bumping into.
@@ -1,15 +1,18 @@
# System Calls
System calls (syscalls) are the bridge between your programs and the operating system's restricted core (kernel).
> **Status:** danos has real user processes (M3). User programs enter the kernel
> **Status:** danos has real user processes. User programs enter the kernel
> via the `syscall` instruction (STAR/LSTAR/SFMASK set per core; the entry stub in
> `isr.s` does the `swapgs` + kernel-stack switch and reuses the interrupt
> dispatcher). The `int 0x80` gate is kept alongside as a minimal test path. The
> current call set is still a placeholder — `0 = exit(code)`, `1 = ping`,
> `2 = write(ptr, len)`, `3 = sleep(ms)` (see `system/kernel/process.zig`); the
> handler dispatches on whether the caller is a scheduled process (its own address
> space) or a borrowed test thread. The microkernel set below (IPC_Call /
> IPC_ReplyWait / Yield) replaces it once a second user server exists.
> dispatcher); the `int 0x80` gate is kept alongside as a minimal test path.
> The live table is `system/abi.zig` (private, renumberable — see
> [vdso.md](vdso.md) for the public boundary): process lifecycle + threads,
> memory (mmap/dma/shared-memory), synchronous + async IPC with capability passing,
> device access, time, the tagged-log diagnostics (`debug_write` with a level,
> `klog_read`/`klog_status`), and filesystem NAMING (`fs_resolve`/`fs_node`/
> `fs_mount`/`fs_unmount` — the kernel VFS root routes paths and serves the
> read-only /system initrd mount; file DATA stays with userspace filesystem
> servers over the vfs-protocol, docs/vfs-protocol.md).
## The Mechanism of a Syscall
@@ -48,7 +51,7 @@ Everything else---including`read()`,`write()`,`malloc()`, and`fork()`---will run
3. **`Yield()`/`Thread_Ctrl()`**
- **What it does:**Allows a thread to voluntarily give up its CPU time slice, or allows a root task to spawn/kill threads.
4. **`ipc_send(endpoint, message_buffer)`(Asynchronous Send)**
- **What it does:**Posts a small payload to an endpoint's bounded queue and returns *without* blocking — no rendezvous, no reply. The receiver picks it up through the same `IPC_ReplyWait`, as a buffered message. It is the async counterpart of `IPC_Call`, for one-to-many broadcasts where a synchronous rendezvous would let one dead or slow receiver hang the sender. The [input service](input.md) — keyboard-event fan-out — is its first user. A full queue drops the oldest message (a buffered message is discrete data, unlike a coalescing interrupt notification).
- **What it does:**Posts a small payload to an endpoint's bounded queue and returns *without* blocking — no rendezvous, no reply. The receiver picks it up through the same `IPC_ReplyWait`, as a buffered message. It is the async counterpart of `IPC_Call`, for one-to-many broadcasts where a synchronous rendezvous would let one dead or slow receiver hang the sender. The [input service](../device-driver-development/input.md) — keyboard-event fan-out — is its first user. A full queue drops the oldest message (a buffered message is discrete data, unlike a coalescing interrupt notification).
* * * * *
@@ -89,4 +92,4 @@ Managing the Payload Challenge
Because it is a microkernel, performance lives or dies by how fast your`IPC_Call`can move data from Client to Server. You have two minimal choices for handling the`message_buffer`pointer:[[1](https://anazimzada2020.medium.com/microkernel-architectural-pattern-5e4e9184170e)]
- **The Copy Method (Simplest to start):**Your kernel pauses the client, reads the data from the client's memory space, switches page tables to the server, and copies the data into the server's buffer.
- **The Shared Memory Method (Fastest):**The kernel sets up a temporary, shared virtual memory page between the client and server. The client writes to it, calls`syscall`/`svc`, and the server reads it instantly without the kernel copying any bytes
- **The Shared Memory Method (Fastest):**The kernel sets up a temporary, shared virtual memory page between the client and server. The client writes to it, calls`syscall`/`svc`, and the server reads it instantly without the kernel copying any bytes
+131
View File
@@ -0,0 +1,131 @@
# system.img — the boot capsule
## What it is
`boot/system.img` is the **boot capsule**: every bundled user binary — init, the
services, the drivers, the test programs — packed into **one file** on the boot
volume. It is not a filesystem image and it is not compressed; it is exactly the
kernel's **initial-ramdisk wire format** (`system/initial-ramdisk.zig`, format
v2), written to disk ahead of time. The EFI loader reads it in a single
sequential pass and hands the bytes to the kernel unmodified.
The capsule is a *performance artifact*, not a source of truth. The boot
volume's `/system` and `/test` file trees remain the canonical layout (see
[danos-file-system-hierarchy-FSH.md](../file-system-development/danos-file-system-hierarchy-FSH.md));
the capsule is a pre-baked snapshot of the same binaries, derived from the same
build graph, so the running system is identical whether the loader read the
capsule or walked the tree.
## Why it exists
Firmware file I/O has exactly one fast shape: **one open + one sequential
read**. Everything else is a lottery. Loading the system per-file — dozens of
opens, seeks, and short reads through the firmware's FAT driver — measured
**minutes** on real hardware, against milliseconds in QEMU/OVMF. Packing the
binaries into a single file turns the whole of user space into the shape
firmware is good at.
Because the capsule already *is* the ramdisk wire format, the loader doesn't
even repack it: `loadCapsule` (`boot/efi.zig`) validates the magic and passes
the buffer straight through as `BootInformation.initial_ramdisk_base`/`len`.
## The format
The container is deliberately trivial — danos owns both producer and consumer,
so it need be no fancier. Little-endian throughout:
```
Header magic: u32 = "DNR2" (0x32524E44), count: u32
Entry × count name: [64]u8 (NUL-padded FHS path), offset: u64, len: u64
blobs... each entry's file bytes, at its offset within the image
```
- **Names are full FHS paths** (`/system/services/init`), not basenames — that
is what "v2" means. The 64-byte capacity matches `abi.maximum_process_name`,
so a task named after its binary path is never truncated. Paths longer than
63 bytes are a build error (`pack-system-image.py` rejects them).
- **The v1 magic (`"DNRD"`, basename entries) is rejected**, not tolerated: a
stale image should fail loudly at `Reader.init`, not misparse names.
- `initial_ramdisk.Reader` is the one validated view over the bytes — magic
check, table bounds, per-blob bounds — used by the kernel and shared with the
loader. `Reader.find` resolves a binary by exact path first, then by unique
basename, ASCII case-insensitively (the entries come from a FAT volume, whose
name lookups are case-insensitive by definition).
## How it is built
`build.zig` maintains one `bundled` list — every user binary and its FHS home.
Three artifacts are derived from that same list, in the same build graph, so
they cannot drift apart:
1. **The tree**: each binary installed at its FHS path (`zig-out/system/...`
and `zig-out/test/...`, mirrored onto the FAT boot volume by
`tools/make-fat-image.py`).
2. **The manifest** (`system/manifest`): the FHS path of every bundled binary,
one per line — the loader's per-file fallback input.
3. **The capsule**: `tools/pack-system-image.py` packs the same binaries into
the v2 container, installed at `zig-out/boot/system.img` and placed on the
boot volume at `boot/system.img`.
Note what the capsule does *not* contain: the kernel (`system/kernel` is loaded
separately by `loadKernel`, as an ELF) and the EFI loader itself. It is user
space only.
## How it is loaded
`loadSystemTree` (`boot/efi.zig`) tries three strategies, most portable first —
the running system cannot tell which one ran, because all three produce the
same in-RAM ramdisk image:
1. **The capsule** — open `boot\system.img`, read it whole, check the magic,
hand it over as-is. The normal path on any build-produced volume.
2. **The manifest** — read `system\manifest` and open each listed path *by
name*. FAT name lookup is case-insensitive and firmware-portable, unlike
directory enumeration. The loader assembles the v2 image in RAM itself.
3. **The tree walk** — enumerate `/system` and `/test` recursively (`/test`
is optional: a stick without fixtures still boots). Last resort for
hand-assembled sticks with neither file: some firmware FAT drivers return
bare 8.3 names uppercase from enumeration, which is why this is the
fallback and not the primary path.
All three are best-effort: a **kernel-only volume still boots** — the kernel
just has no user binaries to spawn and reports the absence.
One operational consequence of the ordering: the capsule *shadows* the tree.
If you hand-edit binaries on a stick that also carries a `boot/system.img`,
your edits are invisible — the loader boots the capsule's snapshot. Delete
`boot/system.img` from the volume to force the manifest/tree path.
## What the kernel does with it
The loader records the image's physical base and length in `BootInformation`;
the kernel (`kernel.zig`) then publishes the same bytes twice, to two
consumers:
- **The process layer** (`process.zig`): `system_spawn` looks binaries up in
the ramdisk via `Reader.find` — exact FHS path, or unique basename for
pre-path callers — and loads them as fresh ring-3 processes. The stored path
becomes the task's name.
- **The VFS root** (`vfs.zig`, `setInitialRamdisk`): the image is mounted as
kernel-backed, read-only mounts — one per top-level tree named by the entry
paths, so `/system` and, when the fixtures are bundled, `/test`. Directory
nodes are derived from the entry paths (the unique parents), so the trees
are listable and their files readable over the normal VFS protocol — the
FHS boot tree every process sees comes straight out of the capsule bytes.
The image is never copied after the handoff and never mutated: the initrd is
immutable, which is what makes the VFS's node serving lock-free.
## What it is not
- **Not `danos-usb.img`.** That is the 64 MiB FAT32 *boot volume* built by
`tools/make-fat-image.py` — the thing a machine actually boots, which
*contains* `boot/system.img` alongside the loader, kernel, manifest, and
tree. See [efi.md](efi.md) and [release-iso.md](release-iso.md).
- **Not a mountable filesystem.** No FAT, no block device, no driver — just a
header, a table, and concatenated blobs, parsed by ~90 lines of
`initial-ramdisk.zig`.
- **Not required.** It is the fast path, with two slower equivalents behind
it.
- **Not a place where state lives.** It is regenerated on every build from the
bundled binaries; nothing writes to it, at build time or runtime.
+7 -7
View File
@@ -56,12 +56,12 @@ 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
garbage. So both sides reference the same `boot_handoff.kernel_abi` (SysV): the loader's
function-pointer type and the kernel's `_start` both carry
`callconv(system.kernel_abi)`, and the pointer reliably arrives in RDI. That is the
`callconv(boot_handoff.kernel_abi)`, and the pointer reliably arrives in RDI. That is the
whole reason `kernel_abi` lives in the shared contract — see [efi.md](efi.md) for
the handoff it governs.
@@ -90,9 +90,9 @@ process) instead of silently corrupting the image
(`buildEntryStack` in `system/kernel/process.zig`); `argv[0]` is always the path
or initial-ramdisk name the process was spawned as, and `system_spawn`'s optional
argument blob becomes `argv[1..]`. The runtime's `_start`
(`library/runtime/start.zig`) hands the block to `rt_start`, which builds a
`runtime.process.Init` from it and passes that to the program's `main`
(`pub fn main(init: runtime.process.Init)`; a parameterless `main()` is also
(`library/kernel/start.zig`) hands the block to `rt_start`, which builds a
`process.Init` from it and passes that to the program's `main`
(`pub fn main(init: process.Init)`; a parameterless `main()` is also
accepted). A C runtime's `crt0` would walk
the identical layout unmodified — that's the compatibility being bought. The
`args` test proves the round trip.
@@ -116,7 +116,7 @@ has to speak that same convention at the point of the call.
## A note on other architectures
This is x86-64-specific. An AArch64 port ([arch.md](arch.md)) has its own calling
This is x86-64-specific. An AArch64 port ([architecture.md](architecture.md)) has its own calling
convention (arguments in X0–X7, and so on) — a different ABI entirely. `kernel_abi`
would be set per-architecture, but the *principle* is the same: the loader/entry
boundary and the kernel must agree on how arguments are passed.
+472
View File
@@ -0,0 +1,472 @@
# Threading — build plan (`runtime.Thread` over a private thread ABI)
The ordered, checkpointable build-out for [threading.md](threading.md). Each milestone
lands on its own and ends in a **verifiable gate** — shaped for a `/loop` run, like
[display-v2-plan.md](../device-driver-development/display-v2-plan.md). Read threading.md first for the *why*.
## Locked decisions (do not relitigate)
- **`runtime.Thread` mirrors `std.Thread`'s API; the implementation is danos-native.**
Not literal `std.Thread` — that would break the [private ABI](syscall.md).
- **Threads are a narrow, per-binary opt-in.** Default concurrency stays process + IPC
([resilience.md](resilience.md)); only a service that asks is built
`single_threaded = false`.
- **Blocking is futex-backed, never spin-backed** — waiters park in the kernel so an
idle core still halts ([halting.md](halting.md)).
- **New syscalls are private**: extend [abi.zig](../../system/abi.zig) `SystemCall` after
`shared_memory_physical = 36` (`thread_spawn = 37`, `thread_exit = 38`, `current_core = 39`,
`futex_wait = 40`, `futex_wake = 41`) + a `library/runtime` wrapper; user code never names a number.
- **Restart granularity stays the process** — a faulting thread kills its process; the
supervisor restarts the process, which respawns its threads.
## Conventions
Follow [coding-standards.md](../coding-standards.md): spell out non-acronym abbreviations,
kebab-case file names, no `Co-Authored-By` trailers. New user binaries are
packages whose build.zig calls `build_support.userBinary` (with `.threaded =
true` where a binary spawns threads) and get packed into the initial-ramdisk;
new syscalls extend [abi.zig](../../system/abi.zig) `SystemCall` + a
`library/kernel` wrapper; test services live beside the code they exercise and
register a `ServiceId` if they must be looked up.
## How to verify along the way
**Every gate is serial-checkable — no screenshots** (this plan runs unattended). A
thread proves it ran by writing to **shared memory** the parent reads back, and proves
parallelism by stamping the **core index** it ran on (like the `smp`/`affinity` cases).
- `zig build test` — host unit tests (closure packing, mutex state machine, futex
wrapper encodings).
- `python3 test/qemu_test.py <case>` — boots the kernel in QEMU; asserts on serial
markers. Thread cases set `smp: true` (real parallelism) and bump `mem` (they boot
the process/scheduler stack); each milestone **adds its case to `CASES`** so its gate
is runnable.
- **Guardrail every milestone:** the concurrency-sensitive existing cases stay green —
`smoke`, `sched`, `priority`, `smp`, `affinity`, `process`, `process-kill`,
`supervision`, `fault-recovery`, `vfs-client-death`, `ipc`/`ipc-cap`,
`display-service`. A threading change that regresses those is rejected.
## Unattended execution (the loop contract)
This plan runs to completion **without human input**. Every design choice is already
fixed in *Locked decisions*; the checkboxes are the only state. A loop iteration must:
1. **Resume** at the first milestone that still has an unchecked `- [ ]`. (All earlier
milestones are done — do not revisit them.)
2. **Work on a branch.** On the first iteration, branch off the current `main` into a new
branch (e.g. `threading-phase2` — Phase 1's `threading` is already merged); never
commit to `main` directly. Push that **branch** to `origin` after each milestone (step
5) so progress is backed up remotely; **do not push `main`** — merging Phase 2 into
`main` stays a human step.
3. **Implement** every unchecked item in that milestone, including adding its
`-Dtest-case` to `CASES` in [test/qemu_test.py](../../test/qemu_test.py) (with
`smp: true` / a `mem` bump where noted) so the gate is runnable.
4. **Run the gate**: `python3 test/qemu_test.py <case>`, then the full **guardrail
set**, then `zig build` (clean) and `zig build test` (green).
5. **Decide, do not ask:**
- **Green** = the milestone's case prints its stated marker(s) and reports `PASS`,
the whole guardrail set passes, `zig build` is clean, and host tests are green.
→ tick this milestone's boxes **and** its `**Gate:**`-referenced case, `git commit`
(`threads(M<n>): <summary>`, no `Co-Authored-By` trailer per
[coding-standards.md](../coding-standards.md)), then **`git push` the working branch to
`origin`** (use `-u` on the first push to set upstream). Continue to the next
milestone in the same iteration if budget remains; otherwise let the loop re-fire.
- **Red** = anything above fails. Diagnose from the captured serial log
(`zig-out/qemu-test/<case>-failed-serial.log`) and fix in place, then re-run — up to
**3 fix attempts** for that gate. A concurrency case that fails then passes on a
bare re-run is **flaky, not green**: re-run it **twice more** and treat green only
if it passes all; otherwise fix the race (a real threading bug), don't paper over
it.
6. **A genuinely ambiguous fork is not a stop.** Pick the option most consistent with
[threading.md](threading.md)'s *Locked decisions*, note the choice in the commit
message, and continue. Do not pause for confirmation on in-scope, reversible work —
this plan is that authorization.
**The only stop conditions:**
- **Done** — every milestone box **in this plan** is checked (M1 through M11), `zig build`
clean, the whole `thread-*` suite + guardrail green. Phase 1 (M1–M6) is *already*
checked, so do **not** read that as Done: the loop's real work is the first plan section
that still has unchecked boxes — Phase 2 (M7–M11). Only stop when M7–M11 are all checked
too. Update threading.md's status line, push the final branch state to `origin`, and
stop. The branch is on `origin` for review; **merging Phase 2 into `main` is the user's
step**, not the loop's.
- **Blocked** — a gate is still red after 3 fix attempts, or a step needs something
outside the repo (a toolchain change, new hardware, a decision no locked decision
covers). Append `> **BLOCKED (M<n>):** <what failed, what was tried, the serial
marker missing>` under that milestone, commit **and push** the WIP on the branch, and
stop. Do not thrash further and do not silently skip the milestone.
Nothing else warrants stopping — not "should I proceed?", not "is this right?". The
checkboxes + git history are the resumable record; the next iteration picks up from the
first unchecked box.
---
## M1 — Address-space refcount (kernel foundation, no API, no behaviour change) ✅
The one invariant change threads require, landed and proven **before** anything shares
an address space. Today address space is 1:1 with a task and teardown destroys it on any user
task's exit; make destruction happen on the **last** exit.
- [x] A refcount keyed by the address-space root, held in `scheduler.zig`
(`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 `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
and assert (via test-observable `liveAddressSpaceCount`/`addressSpaceDestroyCount`) that the
live-space count returns to **baseline** and destructions advance by exactly that
many — each space destroyed exactly once, no leak, no double-free. (Refcount
observables, not raw frame counts, since kernel stacks are still leaked on exit.)
**Gate (met):** `python3 test/qemu_test.py address-space-refcount` passes
(`address-space-refcount: spaces released to baseline ok` → `DANOS-TEST-RESULT: PASS`), and the
full guardrail set passes unchanged — 13/13 (`smoke`, `sched`, `priority`, `smp`,
`affinity`, `process`, `process-kill`, `supervision`, `fault-recovery`,
`vfs-client-death`, `ipc`, `ipc-cap`, `display-service`); default `zig build` clean,
`zig build test` green. The reframing is invisible until an address space is actually shared.
## M2 — `thread_spawn` + `thread_exit`: a thread runs in the shared address space ✅
Spawn only — no join yet. Prove a second task executes in the **caller's** address
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` (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` → `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
stack (`mmap`), heap-allocates the `{args}` closure, and calls
`thread_spawn(&Closure.entry, stack_top, closure)`; `Closure.entry` (a plain C-ABI
Zig fn, closure in rdi) runs the function and calls `thread_exit`. Stack top is
16-aligned-minus-8 for the C entry.
- [x] A `threaded` flag on the user-binary recipe (`addThreadedUserBinary` →
`single_threaded = false`); `thread-test` is the first opt-in binary.
- [x] `-Dtest-case=thread-spawn`: `thread-test` spawns a worker that writes a sentinel to
a **shared** global and release-stores `done`; the main thread acquire-polls `done`
and asserts the shared global holds the sentinel — proof the worker ran in the same
address space.
**Gate (met):** `python3 test/qemu_test.py thread-spawn` passes
(`thread-test: child ran in shared address space ok` → `DANOS-TEST-RESULT: PASS`); guardrail set
16/16 green (incl. `args`/`init`/`process`, which exercise the new `jump_to_user_arg`
process path with arg 0) plus `address-space-refcount`; `zig build` clean, `zig build test`
green.
> **Note (deferred to M3+):** the mmap arena is per-*task* (`heap_next`), so two threads
> in one address space that both `mmap` would collide. Fine for M2 (only the parent maps, for the
> child's stack); make the arena per-address-space and the runtime heap thread-safe alongside the
> `Mutex` work (M5).
## M3 — `join` + `detach` + real parallelism ✅
- [x] `join` over the existing exit-notification path
([process-lifecycle.md](process-lifecycle.md)): `thread_spawn` gained a 4th arg, an
`exit_endpoint` handle (resolved + refcounted like `spawnProcessSupervised`, via
`spawnThreadSupervised`); `join` blocks in `ipc_reply_wait` on that endpoint until
the child-exit notice for its `tid`, then `munmap`s the stack. `detach` relinquishes
the join right (its stack is reclaimed at process exit — kernel-reaper reclaim for
detached threads is deferred; see note).
- [x] `runtime.Thread.join` / `detach`, plus `Thread.currentCore()` (a new `current_core`
= 39 syscall) for the parallelism proof. `getCurrentId` deferred to M6 (TLS), where
a lighter self-id fits. The closure now rides the **thread's own stack** (not the
heap) — private per thread, so spawn/join touch no shared heap.
- [x] `-Dtest-case=thread-join` (`smp: 4`): `thread-test` join mode spawns N=4 workers
that each do K=100k `@atomicRmw`-increments on a shared counter and stamp the core
they ran on; the main thread joins all N and asserts `counter == N*K` **and**
`@popCount(cores_seen) > 1` (genuine cross-core parallelism), then a detached worker
proves `detach` runs without a join.
**Gate (met):** `python3 test/qemu_test.py thread-join` passes (`thread-test: join ok` →
`DANOS-TEST-RESULT: PASS`), robust across 4 runs; guardrail 17/17 green (incl. `smp`,
`affinity`, `process-kill`, and `args`/`init`/`process` on the exit-endpoint spawn path)
plus `address-space-refcount`/`thread-spawn`; `zig build` clean, `zig build test` green.
> **Note (deferred):** a detached thread's stack is freed only at process exit (not by the
> reaper on thread exit) — kernel user-stack tracking + reclaim is a later refinement. And
> the runtime heap is still not thread-safe: threads that both allocate concurrently would
> race (the thread *machinery* avoids the heap, but worker code sharing an allocator does
> not). Both fold into the M5 `Mutex`/allocator work.
## M4 — Futex: the one blocking primitive ✅
- [x] [abi.zig](../../system/abi.zig): `futex_wait = 40`, `futex_wake = 41`. A waiter is a
`.blocked` task tagged with `Task.futex_addr` (no queue linkage);
`futex_wait(addr, expected, timeout_ns)` reads the user word under the big lock,
parks iff `*addr == expected`, and returns on wake or timeout; `futex_wake(addr,
count)` scans the task table and readies up to `count` matching waiters (same
address space). No spinning — a parked waiter leaves its core free to `hlt`. A
timed wait also sets `wake_at`, so the timer's `wakeExpired` wakes it; `futex_addr`
staying non-zero (only `futex_wake` clears it) is how the waiter tells timeout from
a real wake.
- [x] `runtime.Thread.Futex` (`wait` / `timedWait` / `wake`) over the syscall wrappers.
- [x] `-Dtest-case=thread-futex` (`smp: 4`): a waiter thread prints `waiting` and
`futex_wait`s on a word; the main thread publishes it, prints `waking`, and
`futex_wake`s; the waiter prints `woke`. Then a `timedWait` on an unwoken word
reports `error.Timeout`.
**Gate (met):** `python3 test/qemu_test.py thread-futex` passes, robust across 3 runs —
the case's **ordered** regex asserts `waiting → waking → woke → PASS` on the serial
stream (the handoff proof), and `thread-futex: timeout ok` confirms the timeout.
Guardrail 18/18 green (incl. `sleep`/`event`/`ipc` blocking paths) + `address-space-refcount`,
`thread-spawn`, `thread-join`; `zig build` clean, `zig build test` green.
> **Note:** the kernel test checks only the freshest verdict marker via `bufferHas` (the
> in-memory log ring buffer evicts older lines); ordering is asserted against the full
> serial stream by the qemu regex instead.
## M5 — `Mutex` + `Condition` + `Semaphore` ✅
- [x] `runtime.Thread.Mutex` (three-state futex mutex: CAS fast path, `futex_wait`/`wake`
slow path), `Condition` (`wait`/`timedWait`/`signal`/`broadcast`, a futex sequence
counter), `Semaphore` (permits over `Mutex`+`Condition`) — the same state machines
`std.Thread` uses, ported onto our `Futex`.
- [x] `-Dtest-case=thread-mutex` (`smp: 4`): a bounded producer/consumer — 2 producers +
2 consumers over one `Mutex` and two `Condition`s move N=2000 unique items through
an 8-slot ring; the consumed checksum and tally match exactly (no lost/duplicated
item, no overrun) under real cross-core contention. The small ring forces producers
to block on full and consumers on empty, exercising `Condition.wait`.
**Gate (met):** `python3 test/qemu_test.py thread-mutex` passes (`thread-mutex: ok` →
`DANOS-TEST-RESULT: PASS`), robust across 3 runs; guardrail 17/17 green (incl.
`sleep`/`event`/`ipc`) + all M1–M4 thread cases; `zig build` clean, `zig build test`
green.
> **Deferred (with rationale):**
> - **`join` → futex completion word** — the exit-endpoint join (M3) is correct and
> tested. A futex-completion join needs the *kernel* to clear+wake a word after the
> thread is fully off its stack (a CLONE_CHILD_CLEARTID-style mechanism); doing it in
> the thread's own trampoline would let `join` `munmap` the stack while the thread still
> runs on it (use-after-free). Left on the exit-endpoint path; the kernel clear-on-exit
> is a later, separate refinement.
> - **Host unit tests for the state machines** — `Mutex`/`Condition` bottom out in the
> `futex_*` syscalls, unavailable on the host without a mockable `Futex` seam. The QEMU
> `thread-mutex` gate exercises them under real concurrency instead; a host-side mock is
> future work.
## M6 — `getCurrentId`, docs, and CI wiring ✅
- [x] `getCurrentId` via a small `thread_self = 42` syscall (`runtime.Thread.getCurrentId`
returns the kernel task id). **Per-thread `threadlocal` TLS is deferred** — no
consumer needs it, and it would require context-switching the thread pointer per task
(real kernel + per-switch cost) for an unused feature; threaded binaries have run fine
without it through M2–M5. threading.md's TLS reasoning already scoped it as
deferred-unless-needed. When a consumer appears, the shape is: `thread_spawn`
allocates a per-thread TLS block, sets the thread pointer, and the context switch saves/
restores it.
- [x] `RwLock` / `WaitGroup` deferred (no consumer yet); they slot onto the same
`Futex`/`Mutex`/`Condition` when wanted.
- [x] All `thread-*` cases wired into [test/qemu_test.py](../../test/qemu_test.py)
(`thread-spawn`/`-join`/`-futex`/`-mutex`/`-id`); threading.md + docs/README.md
status updated to **built**; the worked example is threading.md's win-condition.
- [x] `-Dtest-case=thread-id` (`smp: 4`): two workers read `getCurrentId`; the main
thread confirms all three ids are non-zero and distinct — each thread has its own
kernel identity. (Renamed from `thread-tls`, which implied `threadlocal`.)
**Gate (met):** `python3 test/qemu_test.py thread-id` passes; the whole `thread-*` suite
(`thread-spawn`/`-join`/`-futex`/`-mutex`/`-id`) plus the full guardrail set pass; default
`zig build` clean, `zig build test` green.
---
## Status
**Phase 1 (M1–M6): built.** danos has `runtime.Thread` — `spawn`/`join`/`detach`,
cross-core parallelism, futex, and `Mutex`/`Condition`/`Semaphore`, all over a private
thread ABI behind the runtime.
**Phase 2 (M7–M11): built.** Thread-safe allocation (M7), a task reaper that reclaims dead
tasks' kernel stacks (M8), endpoint-free `thread_join` (M9), the per-thread thread pointer (M10),
and `RwLock`/`WaitGroup` + host-testable sync (M11). Two things stay deferred by design
(no consumer): the Zig `threadlocal` *compiler* layer (M10) and detached-thread user-stack
reclaim (M9) — both noted in place.
---
## Phase 2 — hardening (M7–M11)
The organising principle, so Phase 2 reinforces danos's goals rather than eroding them:
- **Everything a thread owns is reclaimed on process death.** Thread stacks, TLS blocks,
and futex words live in the process's **address space**, and the kernel's per-process
state is keyed by the address-space root — so the M1 refcount + `destroyAddressSpace` already
free all of it when the last thread exits. A crashed or killed threaded process leaves
**nothing** behind. Phase 2 closes the one thing that is *not* address-space-owned — the
per-task **kernel** stack (kernel heap) — with a reaper (M8). This is the
[resilience](resilience.md) restart guarantee, extended to threads.
- **Kernel owns mechanism; the runtime owns policy.** The kernel maps pages, saves/
restores the thread pointer, and reaps dead tasks; the runtime decides allocation, TLS layout,
and lock algorithms. Every new kernel entry stays a private syscall behind the runtime
([syscall.md](syscall.md)) — the ABI stays renumberable.
- **The process is still the isolation and restart boundary.** Threads share fate within
one process; Phase 2 never adds a way for one process to reach into another (the
cross-process futex stays explicitly out of scope, below).
### M7 — Thread-safe allocation (the correctness gap) ✅
Today the mmap arena cursor is per-*task* and the runtime heap is unlocked, so two
threads in one process that both allocate corrupt each other. The thread *machinery*
avoids this (closure on the stack, stacks mmap'd only by the spawner), but real
multi-threaded code would hit it. Closed it:
- [x] **Kernel — per-address-space mmap arena.** Grew M1's `address_space_refs` entry into the
per-address-space object holding the `mmap`/`mmio` arena cursors (moved off `Task`);
`scheduler.addressSpaceMmapNextPtr`/`addressSpaceDeviceMapNextPtr` expose them. `systemMmap`
reserves a disjoint range under a *brief* lock, then maps **per page** under a
short-held lock — not the whole grant — because the big lock is held with interrupts
disabled, so pinning it across a multi-MiB memset+map froze other cores (it timed
the `affinity` scenario out mid-bring-up). Freed at refcount zero, so the cursors
vanish with the process.
- [x] **Runtime — thread-safe heap.** The allocator's two free-list mutators
(`rawAlloc`/`rawFree`) take a `Thread.Mutex`, gated on
`!@import("builtin").single_threaded` so single-threaded binaries compile it out and
pay nothing. Uncontended acquisition is a single CAS (no syscall).
- [x] `-Dtest-case=thread-alloc` (`smp: 4`): 4 threads each do 500 `alloc`/fill/verify/
`free` cycles of varied sizes; each block is filled with a per-thread pattern and
verified before free, so any overlap between concurrent allocations is caught.
**Gate (met):** `thread-alloc` passes (3× non-flaky); full guardrail 23/23 green,
`zig build`/`zig build test` clean.
> **Also fixed here:** the `affinity` guardrail's fixed-count busy-loop (`while (spins <
> 3e9)`) had codegen-dependent wall-time — adding a function to `tests.zig` flipped how
> the optimiser compiled it, swinging affinity from ~4 s to ~63 s and timing it out.
> Reworked it (and the settle loop) to wait on the wall clock instead, so its duration is
> independent of unrelated code changes.
### M8 — The task reaper (cleanup + resilience) ✅
A dead task's **kernel** stack was leaked ("no reaper yet") — every process *and* thread
death lost one, so a crash loop bled kernel memory. The reaper fixes it and serves the
[resilience](resilience.md) restart goal directly:
- [x] A dying task cannot free the kernel stack it runs on, so `exit()`/`exitUserLocked`
record it in a **per-core `reap_after_switch` slot** and switch away; the task that
resumes on that core frees the stack in `switchTo`'s tail (it's on its own stack, the
big lock is still held so the slot can't have been reused). A **tick-time drain**
(`reapKillPendingLocked`) is the safety net for the case where the next task is
*fresh* (enters via the trampoline, bypassing `switchTo`'s tail). A task killed while
*not* running is freed immediately in `destroyTaskLocked`. A `live_stack_bytes`
counter is the observable. *(Detached-thread user-stack reclaim moves to M9, which
adds the joinable/detached flag.)*
- [x] `-Dtest-case=task-reap` (`smp: 4`): spawn and kill 12 processes; poll the
test-observable `scheduler.liveStackBytes()` until it returns to **baseline** (a
correct reaper gets there in a few ms; a genuine leak times out) — every kernel
stack reclaimed, no leak. Threads exit through the same `exitUserLocked`, so covered.
**Gate (met):** `task-reap` passes (5× isolated + 2× in the full batch); `fault-recovery`,
`supervision`, `process-kill`, `address-space-refcount`, `smp`, `affinity` all still green (24/24
full guardrail); `zig build`/`zig build test` clean.
> **Bug found + fixed here (touches every context switch):** the post-`switchContext` reap
> first read the `pc` **parameter**, but a task that migrated cores carries a *stale* `pc`
> in its saved `switchTo` frame — so it read the wrong core's slot and freed a live stack
> (a #GP under SMP). Fixed to re-fetch `thisCpu()` after the switch (the switch only swaps
> stacks on the current core).
### M9 — Futex-completion join (retire the per-thread endpoint)
With the reaper (M8) able to act *after* a thread is fully off its stack, migrate `join`
to the std shape and drop M3's per-thread exit endpoint:
- [x] A **`thread_join(tid)` syscall** (not a user futex word): it blocks the caller until
the task with id `tid` exits, and the exit paths call `wakeJoinersLocked`. `join`
only reclaims the joined thread's **user** stack, which the thread vacates the moment
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. 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.
**Gate (met):** `thread-join` passes (3× isolated) on the `thread_join` path; full
guardrail 26/26 (incl. `process-kill`, `supervision`, `fault-recovery`, `task-reap`);
`zig build`/`zig build test` clean.
> **Reaper hardened here (fixes an M8 flake).** M8's single per-core reap slot could be
> *overwritten* by a second death on that core before the first drained (a fresh-task/SMP
> timing window) — an intermittent one-stack leak (`task-reap` flaked ~20%). Replaced it
> with a per-core reap **list** plus a `.reaping` task state so a pending slot can't be
> reused before its stack is freed. `task-reap` now 11/11 isolated + 2× in the batch.
> **Deferred:** detached-thread **user-stack** reclaim (still freed at process exit, as in
> M3). Doing it in the reaper needs the saved address space + stack range and a
> translate/unmap in a not-currently-loaded address space — real complexity for a bounded leak.
> A follow-up when a consumer needs it.
### M10 — Per-thread TLS: the thread-pointer mechanism ✅
Give each thread its own thread pointer and private TLS storage — the foundation
self-hosting Zig ([zig-self-hosting.md](../zig-self-hosting.md)) will build `threadlocal` on.
- [x] **Kernel** stores `thread_pointer` on `Task` and restores it on every context switch
**only when it changes** (the same conditional-load discipline as CR3;
`architecture.setThreadPointer` → `wrmsr IA32_FS_BASE` on x86_64). A
`set_thread_pointer(addr)` = 44 syscall sets the caller's `thread_pointer` and loads it
now. The kernel never touches FS, so there is no swapgs complication.
- [x] **Runtime** lays a small per-thread TLS block at the top of each thread's stack
(self-pointer at `%fs:0` + scratch slots) and the thread trampoline calls
`set_thread_pointer` before any user code — so every spawned thread has a private,
switch-stable thread pointer. Reclaimed with the stack.
- [x] `-Dtest-case=thread-tls` (`smp: 4`): two threads each write a unique marker to their
own `%fs:8` slot and — after both have written — read it back; a shared (non-per-thread)
FS base would clobber one and cause cross-talk. Both read their own marker → pass.
**Gate (met):** `thread-tls` passes (3×); full guardrail 25/25 (the switch-time thread-pointer
restore touches every context switch); `zig build`/`zig build test` clean.
> **Deferred: the Zig `threadlocal` *compiler* layer.** Real `threadlocal` variables need
> the ELF **variant-II TLS** surface — `.tdata`/`.tbss` sections + a `PT_TLS` program header
> in `user.ld`, a runtime that copies the template with exact negative-offset layout, and
> the `.large`-code-model TLS section names — a high-uncertainty lift for a feature with
> **no consumer today** (threading.md scopes it "only if a consumer needs it"). What lands
> here is the load-bearing piece — the per-thread thread pointer, context-switched — so adding the
> compiler layer later is purely runtime+linker work on top, no kernel change. `getCurrentId`
> stays the `thread_self` syscall (M6) rather than an fs self-slot (which would need the
> main thread's TLS set up in `_start` too).
**Gate:** `thread-tls` passes; full `thread-*` suite + guardrail green.
### M11 — `RwLock`, `WaitGroup`, and host-testable sync ✅
- [x] `runtime.Thread.RwLock` (reader-preferring: `>0` readers / `-1` writer / `0` free,
with `lock`/`tryLock`/`unlock` + `lockShared`/`tryLockShared`/`unlockShared`) and
`WaitGroup` (`start`/`finish`/`wait`), both on the existing `Mutex`/`Condition`.
- [x] A compile-time `Futex` seam gated on `builtin.os.tag == .freestanding`: the futex
syscalls on danos, a spin+yield mock off-target (Zig 0.16 has no `std.Thread.Futex`;
`wake` is a no-op since the state machines re-check). `thread.zig` is wired into
`zig build test`, so `Mutex`/`RwLock`/`WaitGroup` run as **host unit tests** with real
`std.Thread` threads (`test` blocks only compile under test).
- [x] `-Dtest-case=thread-rwlock` (`smp: 4`): 2 writers set both halves of a value under
the exclusive lock while 3 readers check the halves match under the shared lock —
zero half-write observations across ~150k reads. Host tests cover the Mutex,
RwLock, and WaitGroup state machines.
**Gate (met):** `zig build test` covers the sync primitives (host threads); `thread-rwlock`
passes (3×); full Done gate **26/26** (whole `thread-*` suite + guardrail); `zig build`
clean.
---
## Deferred (explicitly not in this plan)
- **Cross-process shared-memory futex** — the `(address_space, virtual_address)` key can become a
physical-address key so two processes share a futex through a [shared-memory](../device-driver-development/display-v2.md)
region. Not needed for intra-process threads.
- **Per-thread priorities / affinity distinct from the process** — threads inherit the
process priority ([scheduling.md](scheduling.md)); revisit only if it earns its keep.
- **Per-thread signal delivery** — signals stay process-scoped
([process-lifecycle.md](process-lifecycle.md)).
- **A `pthread`/POSIX surface** — the API is `std.Thread`-shaped Zig, nothing more.
- **A real `std.Thread` backend** — arrives with self-hosting
([zig-self-hosting.md](../zig-self-hosting.md)); it sits on these same primitives, so it
swaps the impl under `runtime.Thread`, not the call sites.
+351
View File
@@ -0,0 +1,351 @@
# Threading: `Thread`, a std-shaped API over a private thread ABI
A note on danos **threads** — several tasks sharing one address space — provided by a
`Thread` type that mirrors the shape of Zig's `std.Thread` while keeping every
kernel entry behind the [runtime](../../library/kernel). **Built** (M1–M11, see
[threading-plan.md](threading-plan.md)): `spawn`/`join`/`detach`, cross-core parallelism,
a futex, `Mutex`/`Condition`/`Semaphore`/`RwLock`/`WaitGroup`, `getCurrentId`/`currentCore`,
per-thread thread-pointer TLS, thread-safe allocation, and a task reaper that reclaims dead
tasks' kernel stacks. Deferred by design (no consumer yet): the Zig `threadlocal`
*compiler* layer (the per-thread thread pointer is in place, so it's runtime+linker work on top) and
detached-thread user-stack reclaim — see the plan's M9/M10 notes. The analysis is against
**Zig 0.16** (the pinned toolchain); `std.Thread`'s internals move between releases, so
treat upstream shapes as "0.16.x."
## The win condition
A danos service can write
```zig
const t = try Thread.spawn(.{}, worker, .{ctx});
// ... do other work concurrently ...
t.join();
```
and get real parallelism across cores — with `Thread.Mutex`,
`Thread.Condition`, and `Thread.Semaphore` available for
coordination — **without any code path reaching the kernel except through the
runtime**. The call sites read exactly like `std.Thread`, so the day danos becomes a
real Zig target (see [self-hosting](#the-self-hosting-endgame)) we swap the
implementation underneath, not the API above.
## Locked decisions (do not relitigate)
- **We build `Thread`, not literal `std.Thread`.** It mirrors std's *API and
features*; the implementation underneath is danos-native. See
[Why not literal std.Thread](#why-not-literal-stdthread).
- **Threads are a narrow, opt-in capability — not the default concurrency tool.** The
default for resilience stays **process + IPC** ([resilience.md](resilience.md),
[ipc.md](../device-driver-development/ipc.md)). See [Where threads fit](#where-threads-fit-the-resilience-tension).
- **Blocking synchronization is futex-backed, never spin-backed.** Waiters sleep in
the kernel so an idle core still halts ([halting.md](halting.md)).
- **Per-binary opt-in to multi-threaded codegen.** Only a service that asks for
threads is built `single_threaded = false`; the rest stay lean and single-threaded.
- **The thread ABI is private.** New syscalls extend [abi.zig](../../system/abi.zig)
`SystemCall` and are reached only through `library/kernel` wrappers, exactly like
every other danos syscall ([syscall.md](syscall.md)) — numbers stay renumberable.
## Why not literal `std.Thread`
danos's ABI invariant is that the **runtime is the sole holder of the syscall ABI**,
and that ABI is private and renumberable ([syscall.md](syscall.md) — "unstable
private ABI"). That is a security and evolvability asset: no compiled binary can
hardcode a syscall number, and the kernel can renumber freely because only the
runtime — rebuilt in lockstep — knows the mapping.
`std.Thread` is incompatible with that invariant on two counts:
1. **It selects its backend from `builtin.os.tag`, and issues syscalls directly.**
danos targets `.os_tag = .freestanding` ([build.zig](../../build.zig)), for which
`std.Thread` resolves to an unsupported stub that `@compileError`s. Adding a real
backend would either bake danos syscall numbers into std (breaking ABI privacy and
renumbering) or fork std to route back through the runtime — a permanent rebase
cost that buys nothing the native type doesn't.
2. **Our user binaries are built `single_threaded = true`** (the shared recipe in
[build-support/build.zig](../../build-support/build.zig)), which compiles threading
out entirely and makes atomics and TLS single-threaded. Threads need this flipped
per binary regardless.
So we take the *shape* of `std.Thread`, not the *type*. The cost of replicating the
surface (spawn/join/Mutex/Condition) is small; the cost of the std type is the ABI
invariant.
## Where threads fit: the resilience tension
Threads are in genuine tension with a resilience-first microkernel, and it is worth
being explicit so we do not reach for them by reflex.
The reason danos pays for a microkernel is **fault isolation**
([resilience.md](resilience.md)): a component corrupts its own address space, faults,
and is **restarted** without touching anyone else — because the boundary *is* the
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 — 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.
**Therefore:** the default answer to "make X concurrent" stays *another process over
IPC* (isolated, independently restartable) or a single event loop with several
message sources. Reach for a thread only inside **one** service that needs genuine
**shared-memory, low-latency parallelism** and can accept intra-service fate-sharing —
e.g. a compositor splitting tile compositing across cores, where per-tile IPC would be
too chatty. "Input on one thread, display on another" is *not* that case; it wants two
processes. The isolation boundary stays at process granularity.
## The API surface (mirrors `std.Thread`)
Lives in `library/kernel/thread.zig`, re-exported as `Thread`.
```zig
pub const Thread = struct {
pub const Id = u32; // the kernel task id
pub const SpawnConfig = struct {
stack_size: usize = default_stack_size, // no allocator: the closure lives at the top of the thread's own stack
};
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; stack reclaimed at process exit
pub fn getCurrentId() Id;
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; };
pub const Semaphore = struct { pub fn wait(*Semaphore) void; pub fn post(*Semaphore) void; };
pub const Futex = struct { pub fn wait(*const atomic.Value(u32), u32) void; pub fn timedWait(...) error{Timeout}!void; pub fn wake(*const atomic.Value(u32), u32) void; };
// RwLock / ResetEvent / WaitGroup follow the same pattern, added as needed.
};
```
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.
- No `getCpuCount()` (a service rarely needs it) and no `Thread.yield()` — `yield`
lives in the `process` module. 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)
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/kernel` wrapper:
| Syscall | Signature | Purpose |
|---|---|---|
| `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` |
Plus one invariant change with no new syscall: **address-space reference counting**.
## Mechanics
### Address-space reference counting
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, 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 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`) 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 and its TLS block.
### Lifetime: exit, join, detach, stack reclaim
- **`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
`futex_wait`/`futex_wake` are the one blocking primitive; `Mutex`, `Condition`, and
`Semaphore` are ordinary user-space state machines over an `atomic.Value(u32)` that
call the futex wrappers on the slow path — the same construction `std.Thread` uses,
so the algorithms port directly.
Keying: threads share an address space, so a **virtual address within that address space**
identifies a futex uniquely; the kernel keys its wait queue by `(address_space_root, virtual_address)`.
Keying by the **physical** address instead (translate `virtual_address -> physical_address` on entry) is a
deliberate forward door: it lets two *processes* share a futex through an
[shared-memory](../device-driver-development/display-v2.md) region later, without changing the API. We start with the
private-per-address-space key and note the physical-key upgrade.
No spinning: a contended lock parks the task in the kernel and the core is free to run
other work or `hlt` ([halting.md](halting.md)). This is why futex is a locked
decision, not a "maybe later."
### TLS and `getCurrentId`
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 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
A binary opts in with `.threaded = true` in its package's
`build_support.userBinary` call — the shared recipe in build-support then builds it
`single_threaded = false` — so atomics
and (later) TLS are real. Threads and atomics are unsound in a `single_threaded` image,
so a binary must opt in **before** it may call `Thread.spawn`. Everyone else
stays single-threaded and lean.
## Interaction with the rest of the kernel
- **Scheduler / SMP** ([scheduling.md](scheduling.md), [smp.md](smp.md)): a thread is
just another `Task` with an `address_space` shared with its siblings; the existing
per-core ready queues, priorities, and affinity apply unchanged. Threads of one
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)): the contract is that
killing a process kills *all* its threads and only then drops the last address-space
ref — and the kernel now implements exactly that
([shared-fate-plan.md](shared-fate-plan.md)): every death path (`exit` from any
thread, a fault, `process_kill` aimed at any member id) fans out through the whole
group via a `dying` latch on the address space; the supervisor's one exit
notification — badged with the leader — fires only when the last member is gone.
A worker's voluntary `thread_exit` stays per-thread; the leader's is refused
(`-EPERM`).
- **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. The leader's recorded exit reason carries the fault
class even when a worker faulted, so restart policy is unchanged.
- **IPC — two consequences threads forced ([ipc.md](../device-driver-development/ipc.md)):**
- *Handles do not cross threads.* The handle table lives on the `Task`
([scheduler.zig](../../system/kernel/scheduler.zig)), so a handle number is meaningful
only to the thread that created it — thread A's endpoint handle `3` is not thread B's.
A thread that needs to reach an endpoint another thread owns looks it up
(`ipc.lookup(service)`) to install its **own** handle to the same underlying endpoint.
This is how the display's mouse-listener thread reaches the compositor loop's endpoint
to poke it awake (docs/display.md).
- *IPC syscalls that touch shared kernel state now serialize under the big kernel lock.*
`create_ipc_endpoint`/`ipc_register`/`ipc_lookup` allocate from the kernel heap and
mutate the global service registry, endpoint refcounts, and handle tables. Those paths
were unlocked because a single-threaded process could not race itself; a multi-threaded
one can, from two cores at once. They now take `sync.enter()` like `call`/`reply_wait`/
`send` already did — the kernel heap has no lock of its own (heap.zig: "every kernel
entry takes the big kernel lock"), so the big lock is what keeps its callers serialized.
## Build-out plan (staged, each gate serial-checkable)
The ordered, `/loop`-runnable milestones live in
**[threading-plan.md](threading-plan.md)** (shaped like
[display-v2-plan.md](../device-driver-development/display-v2-plan.md)): every milestone lands on its own and ends in
a verifiable gate (`python3 test/qemu_test.py <case>`, asserting serial markers;
`zig build test` for host unit tests). The stages below are the shape it expands.
- **Stage 0 — address-space refcount.** Refcount on the address-space root; teardown destroys
at zero. No API yet; nothing shares an address space, so refcount is 1 everywhere.
*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 (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
`Mutex` + `Condition` moves K items with no lost wakeups and no busy-wait (assert
the consumer blocked, e.g. via a low idle tick count).
- **Stage 3 — polish.** Per-thread TLS / thread pointer and `threadlocal` (only if a
consumer needs it), `RwLock`/`WaitGroup` as demanded, and this doc's cases wired
into [test/qemu_test.py](../../test/qemu_test.py).
## Conventions
Follow [coding-standards.md](../coding-standards.md): spell out non-acronym
abbreviations, kebab-case file names, no `Co-Authored-By` trailers. New syscalls
extend [abi.zig](../../system/abi.zig) `SystemCall` + a `library/kernel` wrapper
([syscall.md](syscall.md)). `Thread` is a first-class runtime module, the same
way `process` ([process-lifecycle.md](process-lifecycle.md)) and `ipc`
are — user code never names a syscall.
## Non-goals
- **No preemptive user-space signals delivered to a specific thread.** Signals stay
process-scoped ([process-lifecycle.md](process-lifecycle.md)).
- **No thread priorities distinct from the process.** Threads inherit the process
priority; per-thread priority is a later question if it ever earns its keep.
- **No cross-process shared-memory futex yet** — the physical-address key leaves the
door open, but the first cut is private-per-address-space.
- **No `pthread`/POSIX surface.** The API is `std.Thread`-shaped Zig, nothing more.
## The self-hosting endgame
When danos becomes a real Zig target and we (eventually) add a danos backend to std
([zig-self-hosting.md](../zig-self-hosting.md)), `std.Thread` can sit *on top of* these
same kernel primitives — the danos `std.Thread.Impl` would call the very
`thread_spawn`/`futex_*` wrappers `Thread` already uses. Because
`Thread` was built API-compatible from day one, that transition swaps the
implementation, not a single call site. Designing to the std shape now is what makes
the later self-hosting lift cheap.
## Further reading
- [scheduling.md](scheduling.md), [smp.md](smp.md) — the task model these threads join.
- [resilience.md](resilience.md), [vision.md](../vision.md) — why isolation is the default
and threads are the exception.
- [syscall.md](syscall.md), [ipc.md](../device-driver-development/ipc.md) — the private ABI and the messaging model
threads sit beside.
- [halting.md](halting.md) — the idle/halt property futex-backed blocking preserves.
- [zig-self-hosting.md](../zig-self-hosting.md) — the target this bends toward.
@@ -7,7 +7,7 @@ Two different needs hide under the word "timer", and danos keeps them apart:
Both are answered by the **kernel**, because the kernel already owns a timer: it has
to, to preempt tasks. The LAPIC heartbeat and the calibrated TSC that back all of this
are built in [device-interrupts.md](device-interrupts.md); the scheduler's blocking and
are built in [device-interrupts.md](../device-driver-development/device-interrupts.md); the scheduler's blocking and
wait queues are in [scheduling.md](scheduling.md). This page is about the surface a
ring-3 program actually uses, and one deliberate absence: **there is no user-space time
service.**
@@ -32,11 +32,11 @@ danos checks both — the invariant-TSC CPUID bit (`0x80000007` EDX[8], set on I
AMD), and a cross-core "warp" check as the cores come up — and falls back to the HPET
counter when either fails. So `now()` stays accurate on a real Intel box, a real AMD box,
and inside a VM alike; only the source behind it differs. The mechanism is in
[device-interrupts.md](device-interrupts.md).
[device-interrupts.md](../device-driver-development/device-interrupts.md).
So the timer hardware lives in the kernel, and there is **no `hpet` driver and no time
server** to consume. (An earlier HPET driver existed only to *demonstrate* the driver
model; that role now lives in [drivers.md](drivers.md), as documentation.) The one place
model; that role now lives in [drivers.md](../device-driver-development/drivers.md), as documentation.) The one place
a user-space time service *is* justified — **wall-clock / calendar time** — is discussed
at the end; it is deliberately not built yet.
@@ -55,20 +55,20 @@ Time and waiting are three entries in the small syscall table ([syscall.md](sysc
service can keep answering messages on the same endpoint while a deadline is pending.
This is the timed wait that stop-sequence escalation, hello deadlines, and restart
backoff are built from ([process-lifecycle.md](process-lifecycle.md),
[device-manager.md](device-manager.md)).
[device-manager.md](../device-driver-development/device-manager.md)).
The kernel's own scheduling timer (the LAPIC, vector 32) is never exposed to user space;
programs read the TSC through `clock` and get timed wakeups through `sleep`/`timer_bind`,
both riding the scheduler tick.
## `runtime.time` — the generic interface
## `time` — the generic interface
Applications don't call the syscalls directly; they use `runtime.time`
(`library/runtime/time.zig`), a thin `Instant`/`Duration` layer over them — an ergonomic
Applications don't call the syscalls directly; they use `time`
(`library/kernel/time.zig`), a thin `Instant`/`Duration` layer over them — an ergonomic
front door, not new mechanism.
```zig
const time = @import("runtime").time;
const time = @import("time");
const start = time.now(); // Instant — monotonic
doWork();
@@ -91,8 +91,9 @@ _ = time.after(endpoint, time.Duration.fromMillis(200));
- `sleep(d)` wraps `sleep`; `spin(d)` busy-polls `now()` for the sub-millisecond delays
the millisecond tick can't express; `after(endpoint, d)` wraps `timer_bind`.
The raw wrappers (`system.clock`, `system.sleep`, `system.timerOnce`) stay in
`library/runtime/system.zig`; `runtime.time` is the layer meant for everyday use.
The raw wrappers (`clock`, `sleepMillis`, `timerOnce`) and the ergonomic
`Instant`/`Duration` layer both live in the `time` module
(`library/kernel/time.zig`); the latter is what everyday code uses.
## Wall-clock time (not built)
@@ -106,10 +107,10 @@ owns covers every current use.
## Verifying it
`runtime.time`'s `Instant`/`Duration` arithmetic has unit tests that run on the host:
`time`'s `Instant`/`Duration` arithmetic has unit tests that run on the host:
```
$ zig build test # includes library/runtime/time.zig
$ zig build test # includes library/kernel/time.zig
```
End to end, the proof the clock is real is that it *advances*: read `now()`, `sleep` a
+216
View File
@@ -0,0 +1,216 @@
# The vDSO — the public system-call boundary
> **Status:** design note, not built. The runtime today issues raw `syscall`
> instructions from `library/kernel/system-call.zig` using the numbers in
> `system/abi.zig`. This note designs the layer that replaces that arrangement:
> a **kernel-supplied, C-ABI entry library** mapped into every process — the
> only supported way into the kernel — so the raw numbers can stay private,
> be renumbered at will, and eventually be randomised per boot.
## Why: the ABI danos promises, and the one it doesn't
`system/abi.zig` is the **private** kernel ↔ runtime contract. Its header says
so: the numbers are an implementation detail the runtime hides and may
renumber, the same split as libSystem over the XNU syscalls on macOS or win32
over the NT syscalls on Windows. Linux — with its world-visible, frozen
syscall table — is the outlier, not the norm.
That stance has consequences the moment binaries exist that we don't rebuild
ourselves:
1. **Third-party binaries** (docs/zig-self-hosting.md) must keep working across
kernel updates. If they contain raw `syscall` instructions with today's
numbers baked in, every renumbering breaks the world — the ABI would be
*de facto* public no matter what the header says. Go on macOS made exactly
this mistake: it issued XNU syscalls directly instead of going through
libSystem, and macOS updates repeatedly broke every Go binary until Go
switched to the library like everyone else.
2. **Not everything is Zig.** A Rust or C program can't import the danos Zig
modules. The public boundary has to be expressible in the one calling
convention every language speaks: the C ABI.
3. **Randomised syscall numbers** — a hardening option we want open — only
work if no user binary anywhere knows a number at build time. The binding
must happen at *load time*, from something the kernel controls.
All three point at the same well-known shape: a **vDSO** (virtual dynamic
shared object). The kernel carries a small blob of user-mode code, maps it
into every process at spawn, and that blob — not the application — contains
the `syscall` instructions. Fuchsia works exactly this way: its vDSO is the
*only* kernel entry, version-matched by construction because the kernel itself
injects it. Because the kernel and the blob ship as one artifact, there is
**no version skew, no loader, no search path, and no shared file on disk** —
which is what makes this the resilient way to have a private ABI
(docs/resilience.md), where a conventional `ld.so` + `/lib/libdanos.so`
arrangement would add a loader to every spawn and a single shared point of
failure.
The public danos ABI then has exactly two layers, neither of which is
`abi.zig`:
| Layer | Contract | Spoken by |
|-------|----------|-----------|
| **vDSO** | C-ABI functions, this note | every language's thin shim (the `system-call` module for Zig, a `-sys` crate for Rust, a header for C) |
| **IPC wire protocols** | byte layouts over `ipc_call` ([vfs-protocol.md](../file-system-development/vfs-protocol.md) is the first one documented) | any client that can lay out bytes |
Everything above those — the heap, `file_system`, the service harness — is
per-language convenience, compiled into each binary from source, exactly as
today. Nothing about the Zig runtime's shape changes; it just stops being the
*only* door.
## The blob
A single copy of the vDSO code lives in the kernel image (built by
`build.zig` as a tiny freestanding object, embedded like the AP trampoline).
At boot the kernel finalises it once — this is where randomised numbers would
be patched in — and thereafter maps the **same physical pages** read-execute
into every process's address space. The blob is:
- **Position-independent.** It is mapped at a per-process randomised base, so
it must be PIC (rip-relative addressing only — no relocations to process).
- **Stateless and re-entrant.** No writable data. Anything stateful belongs to
the process, not the vDSO.
- **Architecture-specific.** The x86-64 blob wraps `syscall`; an aarch64 blob
wraps `svc #0`. It lives beside the other per-architecture kernel sources
(`system/kernel/architecture/<arch>/`), selected the same way the
`architecture` module is (docs/architecture.md).
### Shape: a function table, not an ELF
A real `.so` with a dynamic symbol table is the conventional vDSO shape, but
linking against one at load time needs a dynamic linker in every binary —
machinery danos deliberately doesn't have. Instead the v1 shape is the
simplest thing that is still a stable contract — a **function-pointer table**
at the vDSO base:
```
offset 0 u64 magic 'danosVDS' — a mapped-the-wrong-thing guard
offset 8 u64 api_level incremented when the table grows
offset 16 u64 count number of table entries that follow
offset 24 u64 table[count] function pointers into the vDSO's own code
```
Table *indices* are the public constants (published in a C header,
`danos.h`), assigned once and append-only — the same discipline the IPC
protocols use for operation values. The pointers point at stubs inside the
blob; what those stubs put in `rax` is nobody's business but the kernel's.
A language shim binds in one step: read the base from the init block, check
the magic, keep the table pointer. Feature detection for a binary built
against older headers is `count`/`api_level` — a kernel never removes or
reorders entries.
(If danos ever grows a real dynamic linker, the same blob can additionally
present an ELF `dynsym` without breaking the table — Fuchsia's vDSO is
likewise both a mappable blob and a linkable `.so`. That is a later
convenience, not a requirement.)
### Delivery: the auxiliary vector
The kernel already builds a System V entry block — argc, argv, envp
terminator, **auxiliary vector** — on every new process's stack
(`buildEntryStack`, read by the `start` module). The vDSO base rides in a new
auxv entry, exactly Linux's `AT_SYSINFO_EHDR` move. No new syscall, no magic
address, and a language shim finds it the same portable way on every
architecture.
## The function surface
One table entry per kernel call, C ABI (System V AMD64), names prefixed
`danos_`. The current `SystemCall` set maps directly; integer arguments and
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),
`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. 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` |
| time | `danos_clock`, `danos_wall_clock`, `danos_timer_bind` |
| diagnostics | `danos_debug_write` (leveled, kernel-stamped records), `danos_klog_read`, `danos_klog_status` |
| filesystem naming | `danos_fs_resolve`, `danos_fs_node`, `danos_fs_mount`, `danos_fs_unmount` (naming only — file DATA still crosses the vfs-protocol IPC, see below) |
The constants that ride alongside the calls — mmap protection bits, DMA
flags, notification badge bits, `ExitReason`, `Signal`, well-known service
ids, `page_size`, the IPC message maximum — move to the public header too:
they are wire values a Rust program needs verbatim. What stays private in
`abi.zig` is exactly the thing the vDSO exists to hide: the `SystemCall`
numbers and the trap convention.
## Enforcement, and an honest threat model
Renumbering only has teeth if the kernel **refuses syscalls that don't come
from the vDSO**. The check is cheap: on kernel entry, the saved user `rip`
must lie inside the calling process's vDSO mapping; otherwise the process is
killed with a fault-class exit reason (its supervisor restarts or gives up,
docs/process-lifecycle.md — a foreign-syscall attempt is a bug or an attack,
never something to limp past). Fuchsia enforces exactly this.
What this buys, precisely:
- **ABI freedom** — the real prize. The numbers can change per release or per
boot and nothing outside the kernel image cares. The private ABI stays
actually private, permanently.
- **A single audited chokepoint** for kernel entry, per process, at a
randomised address.
- **Raised bar for exploits**: shellcode can't issue a hard-coded `syscall`;
it must first discover the per-process vDSO base (ASLR) and call through
it.
What it does *not* buy: an attacker with arbitrary code execution in a
process can still *call* the vDSO functions — they are mapped executable in
that process, and return-oriented chains reach them. Syscall randomisation is
hardening, not a security boundary; the security boundary remains the
capability model (what the process's endpoints and device claims let it do).
It is worth building anyway — for the ABI freedom first and the hardening
second — but the design should never be sold as more than that.
## Migration
Phased so every step ships alone (the M-milestone discipline):
1. **The blob + the table.** Build the vDSO, map it at spawn, deliver the
base via auxv. `library/kernel/system-call.zig` binds through the table when the
auxv entry is present, falls back to raw `syscall` when absent — the whole
tree keeps booting during the transition.
2. **Cut the system library over.** Delete the raw stubs; the `system-call`
module no longer imports the `SystemCall` numbers at all (`abi.zig`'s enum becomes
kernel-internal). The QEMU suite passing proves the table carries the
whole system.
3. **Enforce + randomise.** Add the `rip`-range check, then per-boot number
randomisation patched into the blob at kernel init. A test boots with
randomisation on and runs the full suite.
4. **The other languages.** Publish `danos.h`; a Rust `danos-sys` crate wraps
the table. This is also the seam `std.os.danos` calls through when the Zig
self-hosting fork lands (docs/zig-self-hosting.md) — the vDSO is what
makes that seam stable across kernel versions.
## What deliberately stays out
- **No dynamic linker, no `/lib/*.so`.** The vDSO is kernel-injected precisely
so danos binaries can stay fully static above it. Sharing *library code*
across processes stays what it is today: a service behind IPC, or source
compiled into each binary.
- **No file/device I/O in the vDSO.** The microkernel line doesn't move: the
vDSO wraps the same deliberately tiny table (docs/syscall.md). The kernel
resolves file NAMES (`fs_resolve` — the mount table moved in-kernel), but
file data is still the filesystem server's business over the vfs-protocol
IPC; the kernel never blocks on a userspace filesystem.
- **No fast-path user-mode implementations yet.** Linux's vDSO exists mostly
to answer `gettimeofday` without a kernel entry. `danos_clock` could one
day read the calibrated TSC in user mode the same way — the blob is where
such an optimisation would live — but that is an optimisation, not part of
this design's contract.
+54 -45
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,43 @@ 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-support/build.zig` (`freestandingTarget`), `boot/efi.zig:622` |
| **Long mode + PAE + NX** | AP trampoline sets `CR4.PAE`, `EFER.LME`, `EFER.NXE`; NX is used in kernel page-table entries. | `system/kernel/architecture/x86_64/trampoline.s:62` |
| **SSE / SSE2** | Baseline: the compiler emits SSE for ordinary struct copies. Trampoline enables `CR4.OSFXSR` + `OSXMMEXCPT` and clears `CR0.EM`. | `build.zig:282`, `trampoline.s:62` |
| **`syscall` / `sysret`** | Primary user↔kernel entry path. `EFER.SCE` enabled; `STAR`/`LSTAR`/`SFMASK` programmed per core. (`int 0x80` exists as a parallel gate.) | `architecture/x86_64/per-cpu.zig:59`, `isr.s:169` |
| **Local APIC (xAPIC)** | LAPIC accessed via MMIO at `0xFEE00000`. LAPIC ID read as a `u8` — classic xAPIC. **x2APIC is not supported** (no MSR path). | `apic.zig:62`, `apic.zig:414` |
| **CPUID + RDTSC** | CPUID leaf `0x15` for TSC frequency; RDTSC is the monotonic clock. | `apic.zig:279`, `apic.zig:84` |
| **SMP (optional)** | Multi-core supported via INIT–SIPI–SIPI; ceiling `maximum_cpus = 128`. Single core is fine. Cores beyond the ceiling are parked. | `system/parameters.zig:16`, `apic.zig:144` |
| **SSE / SSE2** | Baseline: the compiler emits SSE for ordinary struct copies. Trampoline enables `CR4.OSFXSR` + `OSXMMEXCPT` and clears `CR0.EM`. | `build-support/build.zig` (`freestandingTarget`), `trampoline.s:62` |
| **`syscall` / `sysret`** | Primary user↔kernel entry path. `EFER.SCE` enabled; `STAR`/`LSTAR`/`SFMASK` programmed per core. (`int 0x80` exists as a parallel gate.) | `architecture/x86_64/per-cpu.zig:71`, `isr.s:196` |
| **Local APIC (xAPIC)** | LAPIC accessed via MMIO at `0xFEE00000`. LAPIC ID read as a `u8` — classic xAPIC. **x2APIC is not supported** (no MSR path). | `apic.zig:67`, `apic.zig:646` |
| **CPUID + RDTSC** | CPUID leaf `0x15` for TSC frequency; RDTSC is the monotonic clock. | `apic.zig:333`, `apic.zig:113` |
| **SMP (optional)** | Multi-core supported via INIT–SIPI–SIPI; ceiling `maximum_cpus = 128`. Single core is fine. Cores beyond the ceiling are parked. | `system/parameters.zig:16`, `apic.zig:173` |
## Firmware / boot
- **UEFI only.** A custom UEFI application loader is installed to
`\EFI\BOOT\BOOTX64.efi`. There is **no BIOS, multiboot, or limine** path. The
loader tolerates UEFI Class-3 machines with no legacy PIC/PIT.
(`build.zig:464`, `boot/efi.zig`)
(`build/images.zig` — the EFI/BOOT install — and `boot/efi.zig`)
- **ACPI is the hardware-discovery mechanism.** The RSDP is taken from the UEFI
configuration table (ACPI 2.0 GUID preferred, 1.0 fallback). Without a valid
RSDP there is **no device discovery** — no SMP, no IOAPIC routing, no PCI/USB.
(`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`)
(`system/kernel/acpi.zig:3`)
- The loader reads `/system/kernel` off the FAT boot volume, then loads user
space: a prebuilt `boot\system.img` capsule
([system-image.md](os-development/system-image.md)) when present, otherwise it walks
the volume's `/system` and optional `/test` trees (init included) into the
initial ramdisk. The kernel can boot "kernel-only" without either.
(`efi.zig:16`, `efi.zig:68`)
## Interrupt controller
- **Local APIC + I/O APIC required.** I/O APIC base, GSI base, and MADT
interrupt-source overrides come from ACPI. (`cpu.zig:365`, `apic.zig:119`)
interrupt-source overrides come from ACPI. (`cpu.zig:388`, `ioapic.zig:35`)
- **MSI supported** — edge-triggered, keyed by vector, no I/O APIC mask cycle.
Vector window 33–46, timer on 32, spurious on 47. (`system/kernel/irq.zig:70`,
`cpu.zig:397`)
`cpu.zig:427`)
- The legacy 8259 PIC is remapped and masked **only if present** (MADT
`PCAT_COMPAT`); it is not required. (`apic.zig:103`)
`PCAT_COMPAT`); it is not required. (`apic.zig:149`)
## PCI / PCIe
@@ -135,46 +139,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 +189,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,19 +206,23 @@ 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`)
and does not currently constrain devices. (`system/kernel/acpi.zig:96`)
## What is explicitly NOT supported
@@ -223,5 +232,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)
+34 -14
View File
@@ -7,10 +7,15 @@ 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`, `library/device/model/device-abi.zig`) now spans ~26 modules:
protocol and on-wire definitions (VFS, USB, virtio-gpu), the FAT engine, the
display compositor, PS/2 and HID decoding, the kernel log ring, and the
runtime's `time`/`thread` — the list is distributed across the library-domain
and binary packages' own `test` steps, which the root `zig build test`
aggregates (docs/build-packages-plan.md).
These compile for the host and run natively.
- **QEMU integration tests** (`python3 test/qemu_test.py`) — boot the real kernel
and check its behaviour. This is the interesting part.
@@ -18,13 +23,13 @@ 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
memory-mapped UART), so it lives behind the [arch](arch.md) boundary — and adding
memory-mapped UART), so it lives behind the [architecture](os-development/architecture.md) boundary — and adding
a new architecture's UART is what makes the same tests run there.
The serial log sink is **compiled in only under `-Dserial`** (off by default).
@@ -51,13 +56,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,14 +75,22 @@ 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
exception, and the harness asserts on the [exception report](os-development/interrupts.md) the
handler prints (which also reaches serial). This reuses the real fault path as the
test oracle: if the IDT/TSS weren't wired up, `fault-df` would triple-fault and the
marker would never appear.
@@ -81,8 +99,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 +138,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
-117
View File
@@ -1,117 +0,0 @@
# Vision: a microkernel, built to learn
danos exists first and foremost as a **learning-by-doing project**: the point is to
build a real operating system, bump into the hard constraints for real, and research
them from a position of having actually hit them. The docs in this folder are part of
that — they're where a constraint gets understood once it's been met.
That framing sets the priorities. danos is not chasing a spec or a product; it's
chasing understanding, with a concrete, motivating **win condition** to aim at.
## The win condition
danos is a "win" when it:
- **boots and runs on real hardware** — the author's **PC** (x86-64) and **both
Raspberry Pis**: the **Zero 2 W** and the **Pi 5** (both `aarch64`, one backend —
see [arm.md](arm.md)),
- **has a graphical user interface**, ideally — building on the framebuffer it
already draws to.
Everything below serves that, or serves the curiosity that the project runs on.
## Why a microkernel: resilience
The kernel stays **minimal** — only what genuinely must run privileged:
- scheduling,
- inter-process communication (IPC),
- memory management (address spaces, page tables),
- low-level interrupt dispatch.
Everything else — device drivers, filesystems, the GUI, the network stack — runs as
an **isolated user-space server**, each in its own address space with only the
privileges it needs.
The reason for this shape is **resilience**: the ability to **re-initialise parts of
the OS while it runs**. A driver bug can't corrupt the kernel or another driver; a
crashed or wedged component is contained, killed, and **restarted** — "if I break
something, I can just fix it," without rebooting. Keeping the kernel tiny is part of
that strategy: the one thing that *can't* be restarted is the trusted base, so the
less code in it, the less that can take the whole system down. This is the project's
real motivation, and it has its own design note: [resilience.md](resilience.md).
The cost is that **IPC becomes the backbone**: what used to be a function call inside
a monolithic kernel is now a message between address spaces. In a microkernel, IPC
performance essentially *is* system performance (the lesson of L4), so it's a
first-class concern. Hardware interrupts become IPC too: the kernel turns an IRQ into
a message to the driver that owns the device.
## On real-time: an option, not a commitment
danos was originally framed as a hard **real-time** OS. That's now held as **one
interesting constraint to explore, not a requirement** — because real-time is a
*pervasive* invariant (every operation must be provably time-bounded, everywhere)
that would slow every milestone, whereas resilience is a set of *structural* features
that's lighter to build and is what the project actually wants. The trade-off is
written up in [smp.md](smp.md#does-the-right-choice-depend-on-real-time-vs-resilience).
What danos keeps from the real-time direction, because it's cheap and useful anyway:
- **Fixed-priority preemptive scheduling** — the highest-priority ready task runs, and
preemption lets a runaway component be interrupted and killed (which *serves
resilience*). Already built ([scheduling.md](scheduling.md)).
- **A calibrated, deterministic clock** — already built ([device-interrupts.md](device-interrupts.md)).
What danos does *not* owe anyone unless it deliberately chooses real-time later:
timing *guarantees*, priority inheritance, bounded allocators, tickless timers, MCS
scheduling contexts. Concretely, the current [heap](heap.md) is a first-fit free list
with unbounded allocation time — fine here, and only a problem *if* a hard-real-time
path is ever added. Note that **QNX is both** a real-time and a restartable
microkernel, so choosing resilience now doesn't close the real-time door — it just
doesn't pay the tax yet.
## The roadmap — tracks, not a strict line
Because the driver is curiosity plus the win condition, the roadmap is a set of
**tracks** with dependencies, not a rigid sequence. Pick by interest; mind the
prerequisites.
**Done:** UEFI boot, framebuffer + [serial](testing.md), [physical frames](frame-allocator.md)
(with boot-services memory reclaimed), [paging](paging.md) with W^X, [exceptions and
interrupts](interrupts.md), a [calibrated timer + ns clock](device-interrupts.md), a
[heap](heap.md), a [fixed-priority preemptive scheduler](scheduling.md) with blocking,
in-kernel [IPC channels](ipc.md), SMP (all cores scheduling, with affinity), a
**higher-half kernel** with a physmap, and **user space**: per-process address
spaces, `syscall`/`sysret` with the `swapgs` discipline, a user-ELF loader, and
`/system/services/init` — a real user ELF built from `system/services/init/`, running at CPL 3 as PID 1 on its
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.*
- **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).
- **ARM track** — the `aarch64` port so danos runs on the Zero 2 W and Pi 5. Largely
independent of the others (it's the [arch layer](arch.md)); directly serves the win
condition. Likely via aarch64-UEFI first (QEMU `virt` + AAVMF), then real boards.
See [arm.md](arm.md), and [discovery.md](discovery.md) for the device tree it needs.
- **GUI track** — a framebuffer-based windowing/compositor, and the input + display
drivers under it. Builds on the neutral framebuffer (so it's arch-independent), and
on the driver model from the isolation/resilience tracks. The visible payoff.
The natural spine is **isolation → (resilience + drivers) → GUI**, with the **ARM
track** pursued alongside whenever the itch to see it boot on a Pi wins out.
## How to use this page
Read it before adding anything structural. When a design decision comes up, the
question is: does it serve the **win condition** (runs on the three machines, with a
GUI), or the **learning** (a constraint worth meeting)? If it serves neither — e.g.
paying the full real-time tax with no payoff in sight — it can wait.
+31 -27
View File
@@ -108,7 +108,7 @@ localised (below).
## The architecture decision: `runtime.os` + `runtime.fs`, and retire `posix`
danos already has the right split ([the private-ABI boundary](../README.md)): the
kernel exposes a minimal syscall ABI ([syscall.md](syscall.md)); the **`runtime`**
kernel exposes a minimal syscall ABI ([syscall.md](os-development/syscall.md)); the **`runtime`**
library is the stable, danos-native application ABI. What this roadmap adds:
- **`runtime.os` — the seam.** A C-ABI-shaped module of the ~30 operations
@@ -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,12 +160,12 @@ 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 |
| monotonic clock | `clock` syscall | none |
| args / argv | SysV entry stack ([sysv.md](sysv.md)), `runtime.process.Init` | none |
| args / argv | SysV entry stack ([sysv.md](os-development/sysv.md)), `runtime.process.Init` | none |
| stdout / stderr | `debug_write` today | wire fd 1/2 to a console **byte** stream |
| mkdir / unlink / rename / truncate | done — engine + VFS + `runtime.fs` (Phase 2) | — |
| stat fields | `{size, kind, mtime}` | **mode / inode** still missing (cache validity) |
@@ -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
@@ -207,7 +209,7 @@ build); point danos's `build.zig`/CI at the resulting binary. Four localised pat
plan9/serenity;
- add `danos` to the freestanding/other **no-op `_start` list** in `std`'s `start.zig`,
so std does *not* emit its own System-V `_start` — danos keeps owning the entry shim
and `Init`/argv construction it already builds ([sysv.md](sysv.md));
and `Init`/argv construction it already builds ([sysv.md](os-development/sysv.md));
- wire the `system` selector `.danos => std.os.danos` in `std.posix`;
- add `std/os/danos.zig` — **the seam itself**, promoted near-verbatim from the
`runtime.os` developed first in Phase 1 (against the stock toolchain, so the fork is
@@ -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](../library/protocol/vfs/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
@@ -340,10 +344,10 @@ Two current decisions fall out of this roadmap:
## Related
- [vision.md](vision.md) — the north star this serves.
- [syscall.md](syscall.md) — the kernel↔runtime ABI `runtime.os` is built on.
- [sysv.md](sysv.md) — the entry stack (`argc/argv/envp/auxv`) danos already constructs.
- [ipc.md](ipc.md) — the IPC the VFS/FAT operations travel over.
- [danos-file-system-hierarchy-FSH.md](danos-file-system-hierarchy-FSH.md) — the
- [syscall.md](os-development/syscall.md) — the kernel↔runtime ABI `runtime.os` is built on.
- [sysv.md](os-development/sysv.md) — the entry stack (`argc/argv/envp/auxv`) danos already constructs.
- [ipc.md](device-driver-development/ipc.md) — the IPC the VFS/FAT operations travel over.
- [danos-file-system-hierarchy-FSH.md](file-system-development/danos-file-system-hierarchy-FSH.md) — the
filesystem layout the file surface serves.
- [coding-standards.md](coding-standards.md) — danos naming (why the compat spellings
are confined, and now retired).
+33
View File
@@ -0,0 +1,33 @@
# /etc/devices.csv — the device→driver registry.
#
# The device manager reads this at boot and binds each device a bus driver
# reports to the driver named here. It is AUTHORITATIVE: a device that no row
# matches goes unbound (logged), never guessed. Edit this file to teach the
# system new hardware — no recompile of the device manager required.
#
# One rule per line, nine comma-separated fields. '#' starts a comment
# (whole-line or trailing); blank lines are ignored. Whitespace around a field
# is trimmed, so columns may be padded for readability.
#
# bus which bus reported the device: pci | usb | acpi
# base PCI base class / USB class (hex)
# class PCI subclass / USB subclass (hex)
# prog_if PCI prog-IF / USB protocol (hex)
# vendor PCI vendor id / USB idVendor (hex)
# device PCI device id / USB idProduct (hex)
# subsystem PCI subsystem, packed (ssvid<<16)|ssid (hex)
# hid ACPI _HID string (e.g. PNP0303); blank for pci/usb
# driver full ramdisk path of the driver to spawn
#
# '*' or an empty field is a wildcard. When several rows match one device the
# MOST SPECIFIC wins (pinning vendor/device/hid beats pinning only a class), so
# a generic class rule and a precise vendor:device rule can coexist.
#
# bus base class prog_if vendor device subsystem hid driver
pci, 0C, 03, 30, *, *, *, *, /system/drivers/usb-xhci-bus
pci, 03, 80, *, 1AF4, 1050, *, *, /system/drivers/virtio-gpu
usb, 03, 01, 01, *, *, *, *, /system/drivers/usb-hid-keyboard
usb, 03, 01, 02, *, *, *, *, /system/drivers/usb-hid-mouse
usb, 08, 06, 50, *, *, *, *, /system/drivers/usb-storage
acpi, *, *, *, *, *, *, PNP0303, /system/drivers/ps2-bus
acpi, *, *, *, *, *, *, PNP0F13, /system/drivers/ps2-bus
1 # /etc/devices.csv — the device→driver registry.
2 #
3 # The device manager reads this at boot and binds each device a bus driver
4 # reports to the driver named here. It is AUTHORITATIVE: a device that no row
5 # matches goes unbound (logged), never guessed. Edit this file to teach the
6 # system new hardware — no recompile of the device manager required.
7 #
8 # One rule per line, nine comma-separated fields. '#' starts a comment
9 # (whole-line or trailing); blank lines are ignored. Whitespace around a field
10 # is trimmed, so columns may be padded for readability.
11 #
12 # bus which bus reported the device: pci | usb | acpi
13 # base PCI base class / USB class (hex)
14 # class PCI subclass / USB subclass (hex)
15 # prog_if PCI prog-IF / USB protocol (hex)
16 # vendor PCI vendor id / USB idVendor (hex)
17 # device PCI device id / USB idProduct (hex)
18 # subsystem PCI subsystem, packed (ssvid<<16)|ssid (hex)
19 # hid ACPI _HID string (e.g. PNP0303); blank for pci/usb
20 # driver full ramdisk path of the driver to spawn
21 #
22 # '*' or an empty field is a wildcard. When several rows match one device the
23 # MOST SPECIFIC wins (pinning vendor/device/hid beats pinning only a class), so
24 # a generic class rule and a precise vendor:device rule can coexist.
25 #
26 # bus base class prog_if vendor device subsystem hid driver
27 pci, 0C, 03, 30, *, *, *, *, /system/drivers/usb-xhci-bus
28 pci, 03, 80, *, 1AF4, 1050, *, *, /system/drivers/virtio-gpu
29 usb, 03, 01, 01, *, *, *, *, /system/drivers/usb-hid-keyboard
30 usb, 03, 01, 02, *, *, *, *, /system/drivers/usb-hid-mouse
31 usb, 08, 06, 50, *, *, *, *, /system/drivers/usb-storage
32 acpi, *, *, *, *, *, *, PNP0303, /system/drivers/ps2-bus
33 acpi, *, *, *, *, *, *, PNP0F13, /system/drivers/ps2-bus
+12
View File
@@ -0,0 +1,12 @@
# /etc/init.csv — diagnose variant (-Ddiagnose), bundled at /etc/init.csv.
#
# The display stack (display, display-demo) is omitted so the kernel's timestamped
# on-screen boot transcript is never suppressed — the bring-up timeline (USB,
# storage, logger) stays readable on real hardware with no serial. See etc/init.csv
# for the format; this file must otherwise track it.
#
# service args...
/system/services/input
/system/services/device-manager
/system/services/fat
/system/services/logger
1 # /etc/init.csv — diagnose variant (-Ddiagnose), bundled at /etc/init.csv.
2 #
3 # The display stack (display, display-demo) is omitted so the kernel's timestamped
4 # on-screen boot transcript is never suppressed — the bring-up timeline (USB,
5 # storage, logger) stays readable on real hardware with no serial. See etc/init.csv
6 # for the format; this file must otherwise track it.
7 #
8 # service args...
9 /system/services/input
10 /system/services/device-manager
11 /system/services/fat
12 /system/services/logger
+20
View File
@@ -0,0 +1,20 @@
# /etc/init.csv — the services init (PID 1) starts at boot, in order.
#
# init reads this at startup and spawns each service supervised (restarting it on
# a crash, up to a cap). Startup order is top->bottom; shutdown is the reverse, so
# the logger (last) goes down first and its final drain still has the fat server
# and the whole storage chain alive underneath it. It is AUTHORITATIVE — there is
# no hardcoded fallback list; a missing file means no services are started.
#
# '#' starts a comment (whole-line or trailing); blank lines are ignored. The
# first field is the service binary path; any fields after it are the service's
# argv. Drivers are absent on purpose — the device manager discovers hardware and
# spawns those (see /etc/devices.csv).
#
# service args...
/system/services/input
/system/services/device-manager
/system/services/fat
/system/services/display
/system/services/display-demo
/system/services/logger
1 # /etc/init.csv — the services init (PID 1) starts at boot, in order.
2 #
3 # init reads this at startup and spawns each service supervised (restarting it on
4 # a crash, up to a cap). Startup order is top->bottom; shutdown is the reverse, so
5 # the logger (last) goes down first and its final drain still has the fat server
6 # and the whole storage chain alive underneath it. It is AUTHORITATIVE — there is
7 # no hardcoded fallback list; a missing file means no services are started.
8 #
9 # '#' starts a comment (whole-line or trailing); blank lines are ignored. The
10 # first field is the service binary path; any fields after it are the service's
11 # argv. Drivers are absent on purpose — the device manager discovers hardware and
12 # spawns those (see /etc/devices.csv).
13 #
14 # service args...
15 /system/services/input
16 /system/services/device-manager
17 /system/services/fat
18 /system/services/display
19 /system/services/display-demo
20 /system/services/logger
+38
View File
@@ -0,0 +1,38 @@
//! The "client" library domain (library/client): userspace-service clients —
//! they talk to services over IPC, not to the kernel. Client modules end in
//! `-client` the way wire protocols end in `-protocol`, so a service, its
//! protocol, and its client never share a name (`display` the service,
//! `display-protocol` the wire contract, `display-client` a program's view).
const std = @import("std");
pub fn build(b: *std.Build) void {
const kernel = b.dependency("kernel", .{});
const protocol = b.dependency("protocol", .{});
const ipc = kernel.module("ipc");
const time = kernel.module("time");
_ = b.addModule("display-client", .{
.root_source_file = b.path("display/display-client.zig"),
.imports = &.{
.{ .name = "ipc", .module = ipc },
.{ .name = "time", .module = time },
.{ .name = "display-protocol", .module = protocol.module("display-protocol") },
},
});
_ = b.addModule("input-client", .{
.root_source_file = b.path("input/input-client.zig"),
.imports = &.{
.{ .name = "ipc", .module = ipc },
.{ .name = "time", .module = time },
.{ .name = "input-protocol", .module = protocol.module("input-protocol") },
},
});
// Standalone `zig build test`, kept for uniformity across the domains (the
// root aggregate depends on every domain's test step). The clients have no
// host-runnable unit tests yet — they are thin IPC conversation wrappers —
// so the step is empty until one grows some.
_ = b.step("test", "Run the client unit tests (none yet)");
}
+13
View File
@@ -0,0 +1,13 @@
.{
.name = .client,
.version = "0.0.0",
.fingerprint = 0xc74404553e73d4ff, // Changing this has security and trust implications.
.minimum_zig_version = "0.16.0",
.dependencies = .{
// The clients converse over ipc with time-bounded waits.
.kernel = .{ .path = "../kernel" },
// Each client speaks its service's wire protocol.
.protocol = .{ .path = "../protocol" },
},
.paths = .{""},
}
@@ -4,9 +4,9 @@
//! reply marshalling. See system/services/display/ and docs/display.md.
const std = @import("std");
const ipc = @import("ipc.zig");
const system = @import("system.zig");
const protocol = @import("display-protocol");
const ipc = @import("ipc");
const time = @import("time");
const display_protocol = @import("display-protocol");
/// The display's current mode, as `info()` reports it.
pub const Info = struct {
@@ -29,34 +29,61 @@ fn service() ?ipc.Handle {
handle = h;
return h;
}
system.sleep(50);
time.sleepMillis(50);
}
return null;
}
/// Send one request, receive its reply; true on a zero status. `out` receives the reply
/// so callers can read `info`/`layer` fields on success.
fn transact(request: protocol.Request, out: *protocol.Reply) bool {
fn transact(request: display_protocol.Request, out: *display_protocol.Reply) bool {
const h = service() orelse return false;
var req = request;
var reply: [protocol.reply_size]u8 = undefined;
var reply: [display_protocol.reply_size]u8 = undefined;
const len = ipc.call(h, std.mem.asBytes(&req), &reply) catch return false;
if (len < protocol.reply_size) return false;
out.* = std.mem.bytesToValue(protocol.Reply, reply[0..protocol.reply_size]);
if (len < display_protocol.reply_size) return false;
out.* = std.mem.bytesToValue(display_protocol.Reply, reply[0..display_protocol.reply_size]);
return out.status == 0;
}
/// The display's current mode, or null if the service never came up.
pub fn info() ?Info {
var reply: protocol.Reply = undefined;
if (!transact(.{ .operation = @intFromEnum(protocol.Operation.info) }, &reply)) return null;
var reply: display_protocol.Reply = undefined;
if (!transact(.{ .operation = @intFromEnum(display_protocol.Operation.info) }, &reply)) return null;
return .{ .width = reply.width, .height = reply.height, .pitch = reply.pitch, .format = reply.format };
}
/// Composite the dirty layers and flush the frame to the screen.
pub fn present() bool {
var reply: protocol.Reply = undefined;
return transact(.{ .operation = @intFromEnum(protocol.Operation.present) }, &reply);
var reply: display_protocol.Reply = undefined;
return transact(.{ .operation = @intFromEnum(display_protocol.Operation.present) }, &reply);
}
/// One selectable display mode.
pub const Mode = display_protocol.Mode;
/// Fill `out` with the resolutions the display can switch to; returns how many were written
/// (zero on the GOP floor, or if the service never came up).
pub fn modes(out: []Mode) usize {
const h = service() orelse return 0;
var request = display_protocol.Request{ .operation = @intFromEnum(display_protocol.Operation.get_modes) };
var reply: [display_protocol.modes_reply_size]u8 = undefined;
const len = ipc.call(h, std.mem.asBytes(&request), &reply) catch return 0;
if (len < display_protocol.modes_reply_size) return 0;
const answer = std.mem.bytesToValue(display_protocol.ModesReply, reply[0..display_protocol.modes_reply_size]);
if (answer.status != 0) return 0;
const count = @min(@min(answer.count, display_protocol.max_modes), out.len);
for (0..count) |i| out[i] = answer.modes[i];
return count;
}
/// Change the display resolution. Only a native backend that supports mode-setting honours it
/// (on the GOP floor it returns false); on success the display's `info()` reports the new mode.
pub fn setMode(width: u32, height: u32) bool {
var reply: display_protocol.Reply = undefined;
const changed = transact(.{ .operation = @intFromEnum(display_protocol.Operation.set_mode), .width = width, .height = height }, &reply);
if (changed) mode = null; // the cached mode is stale now
return changed;
}
/// The mode, cached after the first `info()` so `color()` doesn't round-trip per pixel.
@@ -73,7 +100,7 @@ fn cachedInfo() ?Info {
/// client packs colours through this so it never has to know the byte order itself.
pub fn color(r: u8, g: u8, b: u8) u32 {
const format = if (cachedInfo()) |i| i.format else 0;
return protocol.pack(format, r, g, b);
return display_protocol.pack(format, r, g, b);
}
/// A handle to a server-owned layer: a positioned, z-ordered surface the client draws
@@ -84,9 +111,9 @@ pub const Layer = struct {
/// Fill a rectangle of this layer (layer-local coordinates) with a native `colour`.
pub fn fill(self: Layer, x: i32, y: i32, w: u32, h: u32, colour: u32) bool {
var reply: protocol.Reply = undefined;
var reply: display_protocol.Reply = undefined;
return transact(.{
.operation = @intFromEnum(protocol.Operation.fill_rect),
.operation = @intFromEnum(display_protocol.Operation.fill_rect),
.layer = self.id,
.x = @bitCast(x),
.y = @bitCast(y),
@@ -98,10 +125,10 @@ pub const Layer = struct {
/// Copy a `w`×`h` tile of native pixels (row-major, little-endian bytes) into this
/// layer at (`x`, `y`). The tile rides inline in the request, so `w*h*4` must fit
/// `protocol.maximum_payload`.
/// `display_protocol.maximum_payload`.
pub fn blitTile(self: Layer, x: i32, y: i32, w: u32, h: u32, pixels: []const u8) bool {
var request = protocol.Request{
.operation = @intFromEnum(protocol.Operation.blit_tile),
var request = display_protocol.Request{
.operation = @intFromEnum(display_protocol.Operation.blit_tile),
.layer = self.id,
.x = @bitCast(x),
.y = @bitCast(y),
@@ -109,22 +136,22 @@ pub const Layer = struct {
.height = h,
};
const header = std.mem.asBytes(&request);
if (header.len + pixels.len > protocol.message_maximum) return false;
var buffer: [protocol.message_maximum]u8 = undefined;
if (header.len + pixels.len > display_protocol.message_maximum) return false;
var buffer: [display_protocol.message_maximum]u8 = undefined;
@memcpy(buffer[0..header.len], header);
@memcpy(buffer[header.len..][0..pixels.len], pixels);
const h_svc = service() orelse return false;
var reply: [protocol.reply_size]u8 = undefined;
var reply: [display_protocol.reply_size]u8 = undefined;
const len = ipc.call(h_svc, buffer[0 .. header.len + pixels.len], &reply) catch return false;
if (len < protocol.reply_size) return false;
return std.mem.bytesToValue(protocol.Reply, reply[0..protocol.reply_size]).status == 0;
if (len < display_protocol.reply_size) return false;
return std.mem.bytesToValue(display_protocol.Reply, reply[0..display_protocol.reply_size]).status == 0;
}
/// Move / restack / show or hide the layer.
pub fn configure(self: Layer, x: i32, y: i32, z: u32, visible: bool) bool {
var reply: protocol.Reply = undefined;
var reply: display_protocol.Reply = undefined;
return transact(.{
.operation = @intFromEnum(protocol.Operation.configure_layer),
.operation = @intFromEnum(display_protocol.Operation.configure_layer),
.layer = self.id,
.x = @bitCast(x),
.y = @bitCast(y),
@@ -136,9 +163,9 @@ pub const Layer = struct {
/// Mark a rectangle of this layer (layer-local) dirty for the next present — for when
/// the layer's pixels changed without a drawing call the compositor already tracked.
pub fn damage(self: Layer, x: i32, y: i32, w: u32, h: u32) bool {
var reply: protocol.Reply = undefined;
var reply: display_protocol.Reply = undefined;
return transact(.{
.operation = @intFromEnum(protocol.Operation.damage),
.operation = @intFromEnum(display_protocol.Operation.damage),
.layer = self.id,
.x = @bitCast(x),
.y = @bitCast(y),
@@ -149,17 +176,17 @@ pub const Layer = struct {
/// Release the layer and its surface.
pub fn destroy(self: Layer) bool {
var reply: protocol.Reply = undefined;
return transact(.{ .operation = @intFromEnum(protocol.Operation.destroy_layer), .layer = self.id }, &reply);
var reply: display_protocol.Reply = undefined;
return transact(.{ .operation = @intFromEnum(display_protocol.Operation.destroy_layer), .layer = self.id }, &reply);
}
};
/// Create a server-owned layer of `w`×`h` pixels at screen (`x`, `y`) with stacking order
/// `z` (higher is nearer the front), initially visible. Returns a handle, or null.
pub fn createLayer(x: i32, y: i32, w: u32, h: u32, z: u32) ?Layer {
var reply: protocol.Reply = undefined;
var reply: display_protocol.Reply = undefined;
if (!transact(.{
.operation = @intFromEnum(protocol.Operation.create_layer),
.operation = @intFromEnum(display_protocol.Operation.create_layer),
.x = @bitCast(x),
.y = @bitCast(y),
.width = w,
@@ -23,26 +23,26 @@
const std = @import("std");
const abi = @import("abi");
const ipc = @import("ipc.zig");
const system = @import("system.zig");
const protocol = @import("input-protocol");
const ipc = @import("ipc");
const time = @import("time");
const input_protocol = @import("input-protocol");
pub const DeviceKind = protocol.DeviceKind;
pub const InputEvent = protocol.InputEvent;
pub const KeyEvent = protocol.KeyEvent;
pub const MouseEvent = protocol.MouseEvent;
pub const JoystickEvent = protocol.JoystickEvent;
pub const EventKind = protocol.EventKind;
pub const MouseEventKind = protocol.MouseEventKind;
pub const JoystickEventKind = protocol.JoystickEventKind;
pub const Keycode = protocol.Keycode;
pub const DeviceKind = input_protocol.DeviceKind;
pub const InputEvent = input_protocol.InputEvent;
pub const KeyEvent = input_protocol.KeyEvent;
pub const MouseEvent = input_protocol.MouseEvent;
pub const JoystickEvent = input_protocol.JoystickEvent;
pub const EventKind = input_protocol.EventKind;
pub const MouseEventKind = input_protocol.MouseEventKind;
pub const JoystickEventKind = input_protocol.JoystickEventKind;
pub const Keycode = input_protocol.Keycode;
/// Interest masks re-exported so a caller can `subscribe(input.device_keyboard |
/// input.device_mouse)`.
pub const device_keyboard = protocol.device_keyboard;
pub const device_mouse = protocol.device_mouse;
pub const device_joystick = protocol.device_joystick;
pub const device_all = protocol.device_all;
pub const device_keyboard = input_protocol.device_keyboard;
pub const device_mouse = input_protocol.device_mouse;
pub const device_joystick = input_protocol.device_joystick;
pub const device_all = input_protocol.device_all;
/// Look up the input service, retrying while it is still coming up. Both a subscriber and
/// a source race the service's registration at boot, so both wait for it here rather than
@@ -51,7 +51,7 @@ fn lookupService() ?ipc.Handle {
var attempts: usize = 0;
while (attempts < 100) : (attempts += 1) {
if (ipc.lookup(.input)) |handle| return handle;
system.sleep(50);
time.sleepMillis(50);
}
return null;
}
@@ -66,7 +66,7 @@ pub const Subscriber = struct {
/// The endpoint the service delivers events to (created and owned by us; its handle
/// was handed to the service as a capability at subscribe time).
endpoint: ipc.Handle,
receive: [protocol.event_size]u8 = undefined,
receive: [input_protocol.event_size]u8 = undefined,
/// Block until the next event is pushed, and return it. Events arrive as asynchronous
/// buffered messages (`ipc_send` from the service), so nothing is owed in reply — the
@@ -74,8 +74,8 @@ pub const Subscriber = struct {
/// (there should be none), so callers can loop.
pub fn next(self: *Subscriber) ?InputEvent {
const got = ipc.replyWait(self.endpoint, &.{}, &self.receive, null);
if (!got.isMessage() or got.len < protocol.event_size) return null;
return std.mem.bytesToValue(InputEvent, self.receive[0..protocol.event_size]);
if (!got.isMessage() or got.len < input_protocol.event_size) return null;
return std.mem.bytesToValue(InputEvent, self.receive[0..input_protocol.event_size]);
}
};
@@ -86,11 +86,11 @@ pub fn subscribe(device_mask: u32) ?Subscriber {
const service = lookupService() orelse return null;
const endpoint = ipc.createIpcEndpoint() orelse return null;
var request = protocol.Request{ .operation = @intFromEnum(protocol.Operation.subscribe), .device_mask = device_mask };
var reply: [protocol.reply_size]u8 = undefined;
var request = input_protocol.Request{ .operation = @intFromEnum(input_protocol.Operation.subscribe), .device_mask = device_mask };
var reply: [input_protocol.reply_size]u8 = undefined;
const result = ipc.callCap(service, std.mem.asBytes(&request), &reply, endpoint) catch return null;
if (result.len < protocol.reply_size) return null;
if (std.mem.bytesToValue(protocol.Reply, reply[0..protocol.reply_size]).status != 0) return null;
if (result.len < input_protocol.reply_size) return null;
if (std.mem.bytesToValue(input_protocol.Reply, reply[0..input_protocol.reply_size]).status != 0) return null;
return .{ .endpoint = endpoint };
}
@@ -149,11 +149,11 @@ pub const Publisher = struct {
service: ipc.Handle,
fn publish(self: Publisher, event: InputEvent) bool {
var request = protocol.Request{ .operation = @intFromEnum(protocol.Operation.publish), .event = event };
var reply: [protocol.reply_size]u8 = undefined;
var request = input_protocol.Request{ .operation = @intFromEnum(input_protocol.Operation.publish), .event = event };
var reply: [input_protocol.reply_size]u8 = undefined;
const len = ipc.call(self.service, std.mem.asBytes(&request), &reply) catch return false;
if (len < protocol.reply_size) return false;
return std.mem.bytesToValue(protocol.Reply, reply[0..protocol.reply_size]).status == 0;
if (len < input_protocol.reply_size) return false;
return std.mem.bytesToValue(input_protocol.Reply, reply[0..input_protocol.reply_size]).status == 0;
}
/// Broadcast a keyboard event to every subscriber that took keyboard events.
@@ -204,8 +204,8 @@ pub fn syntheticKeyEvent(step: usize) KeyEvent {
pub fn syntheticMouseEvent(step: usize) MouseEvent {
return switch (step % 3) {
0 => .{ .kind = @intFromEnum(MouseEventKind.motion), .button = 0, .dx = 1, .dy = 1, .scroll_x = 0, .scroll_y = 0, .buttons = 0 },
1 => .{ .kind = @intFromEnum(MouseEventKind.button_down), .button = protocol.mouse_button_left, .dx = 0, .dy = 0, .scroll_x = 0, .scroll_y = 0, .buttons = protocol.mouse_button_left },
else => .{ .kind = @intFromEnum(MouseEventKind.button_up), .button = protocol.mouse_button_left, .dx = 0, .dy = 0, .scroll_x = 0, .scroll_y = 0, .buttons = 0 },
1 => .{ .kind = @intFromEnum(MouseEventKind.button_down), .button = input_protocol.mouse_button_left, .dx = 0, .dy = 0, .scroll_x = 0, .scroll_y = 0, .buttons = input_protocol.mouse_button_left },
else => .{ .kind = @intFromEnum(MouseEventKind.button_up), .button = input_protocol.mouse_button_left, .dx = 0, .dy = 0, .scroll_x = 0, .scroll_y = 0, .buttons = 0 },
};
}
+20
View File
@@ -0,0 +1,20 @@
//! The "csv" library domain: shared CSV helpers (comment stripping, field
//! iteration) for the /etc/*.csv config files — the device registry and the
//! init service list both parse them.
const std = @import("std");
pub fn build(b: *std.Build) void {
_ = b.addModule("csv", .{ .root_source_file = b.path("csv.zig") });
// Standalone `zig build test` for this domain alone; the root build keeps
// its aggregate test step.
const test_step = b.step("test", "Run the csv unit tests");
const csv_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("csv.zig"),
.target = b.resolveTargetQuery(.{}),
}),
});
test_step.dependOn(&b.addRunArtifact(csv_tests).step);
}
+8
View File
@@ -0,0 +1,8 @@
.{
.name = .csv,
.version = "0.0.0",
.fingerprint = 0x8a4525791f4e5b6, // Changing this has security and trust implications.
.minimum_zig_version = "0.16.0",
.dependencies = .{},
.paths = .{""},
}
+56
View File
@@ -0,0 +1,56 @@
//! Minimal CSV helpers shared by the `/etc/*.csv` config files — the device
//! registry (`/etc/devices.csv`) and the init service list (`/etc/init.csv`).
//! Freestanding, no allocator: returned fields are slices into the source line,
//! so the source must outlive them. `#` starts a comment (whole-line or trailing);
//! whitespace around a field is trimmed, so columns may be padded for alignment.
const std = @import("std");
/// Strip a trailing `#` comment and surrounding whitespace from one raw line.
/// A blank or comment-only line returns "" (length 0) — the caller's skip signal.
pub fn stripComment(raw: []const u8) []const u8 {
const body = if (std.mem.indexOfScalar(u8, raw, '#')) |hash| raw[0..hash] else raw;
return std.mem.trim(u8, body, " \t\r\n");
}
/// Iterate the comma-separated fields of a line body, each trimmed of spaces and
/// tabs. Build it from a `stripComment`ed body.
pub const Fields = struct {
inner: std.mem.SplitIterator(u8, .scalar),
/// The next field, trimmed, or null when the row is exhausted.
pub fn next(self: *Fields) ?[]const u8 {
const field = self.inner.next() orelse return null;
return std.mem.trim(u8, field, " \t");
}
};
pub fn fields(body: []const u8) Fields {
return .{ .inner = std.mem.splitScalar(u8, body, ',') };
}
// --- tests -------------------------------------------------------------------
const testing = std.testing;
test "stripComment trims and drops comments" {
try testing.expectEqualStrings("a, b", stripComment(" a, b # trailing\r\n"));
try testing.expectEqualStrings("", stripComment(" # whole-line comment"));
try testing.expectEqualStrings("", stripComment(" \t "));
try testing.expectEqualStrings("x", stripComment("x"));
}
test "fields splits and trims each column" {
var it = fields(stripComment("pci, 03 , 80 , /system/drivers/x # note"));
try testing.expectEqualStrings("pci", it.next().?);
try testing.expectEqualStrings("03", it.next().?);
try testing.expectEqualStrings("80", it.next().?);
try testing.expectEqualStrings("/system/drivers/x", it.next().?);
try testing.expect(it.next() == null);
}
test "a single field yields one column then null" {
var it = fields(stripComment("/system/services/input"));
try testing.expectEqualStrings("/system/services/input", it.next().?);
try testing.expect(it.next() == null);
}
+91
View File
@@ -0,0 +1,91 @@
//! Block-device client: the helper a filesystem uses to read and write a block
//! device (a USB stick, via usb-storage) without hand-rolling the block-protocol
//! IPC. Layered over `ipc` and the shared `block-protocol` wire format, like
//! `runtime.usb` over the transfer protocol.
//!
//! Transfers name a caller-owned DMA buffer by physical address (from
//! `runtime.dma.alloc`), so whole sectors move without crossing the IPC size
//! limit — the same handoff usb-storage uses toward the controller.
const std = @import("std");
const ipc = @import("ipc");
const time = @import("time");
const block_protocol = @import("block-protocol");
pub const Geometry = struct { block_size: u32, block_count: u64 };
pub const Device = struct {
endpoint: ipc.Handle,
/// The device's block size and total block count.
pub fn geometry(self: Device) ?Geometry {
var request = block_protocol.Request{ .operation = @intFromEnum(block_protocol.Operation.geometry), .lba = 0, .count = 0, .physical = 0 };
var reply: [block_protocol.reply_size]u8 = undefined;
const n = ipc.call(self.endpoint, std.mem.asBytes(&request), &reply) catch return null;
if (n < block_protocol.reply_size) return null;
const result = std.mem.bytesToValue(block_protocol.Reply, reply[0..block_protocol.reply_size]);
if (result.status != 0) return null;
return .{ .block_size = result.block_size, .block_count = result.block_count };
}
/// Hand the block server a DMA-region capability (`handle` — from a `shareable`
/// dma_alloc) so it forwards it to the controller and the buffer's physical
/// addresses become reachable by the device. Call once per buffer before naming it
/// in `read`/`write`. Harmless success when no IOMMU is enforcing.
pub fn attach(self: Device, handle: ipc.Handle) bool {
var request = block_protocol.Request{ .operation = @intFromEnum(block_protocol.Operation.attach), .lba = 0, .count = 0, .physical = 0 };
var reply: [block_protocol.reply_size]u8 = undefined;
const result = ipc.callCap(self.endpoint, std.mem.asBytes(&request), &reply, handle) catch return false;
if (result.len < block_protocol.reply_size) return false;
return std.mem.bytesToValue(block_protocol.Reply, reply[0..block_protocol.reply_size]).status == 0;
}
/// Read `count` blocks starting at `lba` into the DMA buffer at `physical`.
pub fn read(self: Device, lba: u64, count: u32, physical: u64) bool {
return self.transfer(.read, lba, count, physical);
}
/// Write `count` blocks starting at `lba` from the DMA buffer at `physical`.
pub fn write(self: Device, lba: u64, count: u32, physical: u64) bool {
return self.transfer(.write, lba, count, physical);
}
/// Commit any device write cache to stable media (SCSI SYNCHRONIZE CACHE), so
/// prior writes survive a power-off. A filesystem calls this before the machine
/// goes down; no data transfer, so the buffer arguments are unused.
pub fn flush(self: Device) bool {
return self.transfer(.flush, 0, 0, 0);
}
fn transfer(self: Device, operation: block_protocol.Operation, lba: u64, count: u32, physical: u64) bool {
var request = block_protocol.Request{ .operation = @intFromEnum(operation), .lba = lba, .count = count, .physical = physical };
var reply: [block_protocol.reply_size]u8 = undefined;
const n = ipc.call(self.endpoint, std.mem.asBytes(&request), &reply) catch return false;
if (n < block_protocol.reply_size) return false;
return std.mem.bytesToValue(block_protocol.Reply, reply[0..block_protocol.reply_size]).status == 0;
}
};
/// One lookup attempt, no waiting — for a server that retries on its own
/// timer (the fat service) instead of blocking its harness in here.
pub fn tryOpen() ?Device {
if (ipc.lookup(.block)) |handle| return .{ .endpoint = handle };
return null;
}
/// Look up the block device, retrying generously while the USB storage chain
/// (controller reset, enumeration, mass-storage bring-up) comes up.
pub fn open() ?Device {
// Patient: the whole USB storage chain (firmware discovery, xHCI reset and
// enumeration, mass-storage bring-up) must complete first, which can take
// tens of seconds under emulation.
var attempts: usize = 0;
// 30 s covers the slowest observed healthy chain (a flaky QEMU enumeration
// completed at ~24 s); a machine whose stick genuinely failed setup should
// not sit a further minute pretending otherwise.
while (attempts < 600) : (attempts += 1) {
if (ipc.lookup(.block)) |handle| return .{ .endpoint = handle };
time.sleepMillis(50);
}
return null;
}
+137
View File
@@ -0,0 +1,137 @@
//! The "device" library domain (library/device): what a driver author imports.
//! The flat reference data (device-abi, pci-class, acpi-ids, usb-abi, usb-ids),
//! typed MMIO access, the driver-side client libraries (driver, pci, usb,
//! block), the AML interpreter, and the data-driven device registry.
const std = @import("std");
pub fn build(b: *std.Build) void {
const kernel = b.dependency("kernel", .{});
const protocol = b.dependency("protocol", .{});
const csv = b.dependency("csv", .{});
const abi = kernel.module("abi");
const system_call = kernel.module("system-call");
const ipc = kernel.module("ipc");
const time = kernel.module("time");
// The devices sub-project's public interface (the flat wire types),
// importable by user space, unlike the kernel-internal device model it
// also feeds (system/kernel/device-model.zig).
const device_abi = b.addModule("device-abi", .{
.root_source_file = b.path("model/device-abi.zig"),
});
// PCI class-code decoding (class/subclass/prog-IF -> names). Pure reference
// data, shared by kernel discovery and any user-space PCI tool.
const pci_class = b.addModule("pci-class", .{
.root_source_file = b.path("pci/pci-class.zig"),
});
// ACPI/PnP hardware-ID (_HID) names — the flat analog of pci-class.
_ = b.addModule("acpi-ids", .{
.root_source_file = b.path("acpi/acpi-ids.zig"),
});
// The AML interpreter, a build module so the ring-3 acpi service can run
// the same parser the kernel does (docs/discovery.md). Pure Zig, no kernel
// imports — one source, two builds.
_ = b.addModule("aml", .{
.root_source_file = b.path("acpi/aml/aml.zig"),
});
// The USB device-framework wire ABI (chapter-9 set-up packets, standard +
// class requests, descriptors) and the USB class-code taxonomy.
const usb_abi = b.addModule("usb-abi", .{
.root_source_file = b.path("usb/usb-abi.zig"),
});
const usb_ids = b.addModule("usb-ids", .{
.root_source_file = b.path("usb/usb-ids.zig"),
});
// Typed volatile MMIO register access + memory-ordering barriers, for
// drivers on top of an mmio_map grant. Depends only on `builtin`.
const mmio = b.addModule("mmio", .{
.root_source_file = b.path("mmio/mmio.zig"),
});
// The driver author's interface: device access + the device-manager hello
// handshake, folded together.
const driver = b.addModule("driver", .{
.root_source_file = b.path("driver/driver.zig"),
.imports = &.{
.{ .name = "abi", .module = abi },
.{ .name = "device-abi", .module = device_abi },
.{ .name = "system-call", .module = system_call },
.{ .name = "ipc", .module = ipc },
.{ .name = "time", .module = time },
.{ .name = "device-manager-protocol", .module = protocol.module("device-manager-protocol") },
},
});
// A device driver's view of its claimed PCI function: config-space header
// fields, BAR decode + map, capability walks (legacy + extended), MSI/MSI-X
// programming, power state, and function-level reset — the generic PCI
// mechanics every leaf PCI driver used to re-derive inline.
_ = b.addModule("pci", .{
.root_source_file = b.path("pci/pci.zig"),
.imports = &.{
.{ .name = "driver", .module = driver },
.{ .name = "mmio", .module = mmio },
.{ .name = "pci-class", .module = pci_class },
.{ .name = "time", .module = time },
},
});
// The USB class-driver transfer client: open a device on the xHCI bus and
// drive it (control / interrupt / bulk). Re-exports usb-abi / usb-ids as
// usb.abi / usb.ids for a single USB import.
_ = b.addModule("usb", .{
.root_source_file = b.path("usb/usb.zig"),
.imports = &.{
.{ .name = "ipc", .module = ipc },
.{ .name = "time", .module = time },
.{ .name = "usb-transfer-protocol", .module = protocol.module("usb-transfer-protocol") },
.{ .name = "usb-abi", .module = usb_abi },
.{ .name = "usb-ids", .module = usb_ids },
},
});
// The block-device client — a device type, so it lives here.
_ = b.addModule("block", .{
.root_source_file = b.path("block/block.zig"),
.imports = &.{
.{ .name = "ipc", .module = ipc },
.{ .name = "time", .module = time },
.{ .name = "block-protocol", .module = protocol.module("block-protocol") },
},
});
// The device registry: parse /etc/devices.csv into match rules and bind a
// reported device to a driver. Pure logic (no hardware, no syscalls), so it
// unit-tests on the host; the device manager imports it.
_ = b.addModule("device-registry", .{
.root_source_file = b.path("registry/device-registry.zig"),
.imports = &.{.{ .name = "csv", .module = csv.module("csv") }},
});
// Standalone `zig build test` for this domain alone; the root build keeps
// its aggregate test step.
const test_step = b.step("test", "Run the device library unit tests");
for ([_][]const u8{
"model/device-abi.zig", // wire-type sizes
"pci/pci-class.zig", // class/subclass/prog-IF name decoding
"acpi/acpi-ids.zig", // _HID name decoding
"acpi/aml/aml.zig", // AML parse + interpret, incl. Notify dispatch
"usb/usb-abi.zig", // wire sizes + bit packings + set-up packet encodings
"usb/usb-ids.zig", // class/subclass/protocol code assignments
"mmio/mmio.zig", // barriers assemble + registers round-trip
}) |root| {
const device_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path(root),
.target = b.resolveTargetQuery(.{}),
}),
});
test_step.dependOn(&b.addRunArtifact(device_tests).step);
}
// The registry needs its csv import wired, so it doesn't fit the loop.
const registry_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("registry/device-registry.zig"),
.target = b.resolveTargetQuery(.{}),
.imports = &.{.{ .name = "csv", .module = csv.module("csv") }},
}),
});
test_step.dependOn(&b.addRunArtifact(registry_tests).step);
}
+15
View File
@@ -0,0 +1,15 @@
.{
.name = .device,
.version = "0.0.0",
.fingerprint = 0x92fb68eace23a4f, // Changing this has security and trust implications.
.minimum_zig_version = "0.16.0",
.dependencies = .{
// driver/block/usb/pci build on the kernel library's concern modules.
.kernel = .{ .path = "../kernel" },
// driver speaks device-manager-protocol; block/usb their transfer protocols.
.protocol = .{ .path = "../protocol" },
// device-registry parses /etc/devices.csv with the shared csv helpers.
.csv = .{ .path = "../csv" },
},
.paths = .{""},
}
@@ -1,12 +1,15 @@
//! User-space device access: enumerate the kernel's device table, claim a device,
//! map its MMIO, and bind its interrupt. A driver uses these to find and take
//! ownership of its hardware; the claim is the capability the kernel checks before
//! mapping registers or routing an IRQ.
//! library/device/driver — the driver author's interface: enumerate the kernel's device
//! table, claim a device, map its MMIO, bind its interrupt (the claim is the capability the
//! kernel checks before mapping registers or routing an IRQ), and say `hello` to the device
//! manager at startup. The whole kernel + manager surface a driver needs, in one import.
const std = @import("std");
const abi = @import("abi");
const device_abi = @import("device-abi");
const sc = @import("system-call.zig");
const sc = @import("system-call");
const ipc = @import("ipc");
const time = @import("time");
const device_manager_protocol = @import("device-manager-protocol");
pub const DeviceDescriptor = device_abi.DeviceDescriptor;
pub const ResourceDescriptor = device_abi.ResourceDescriptor;
@@ -96,6 +99,27 @@ pub fn msiBind(device_id: u64, endpoint: usize) ?Msi {
return .{ .address = rax, .data = @intCast(rdx) };
}
/// Map a delegated DMA-region (or shared-memory) capability into a claimed device's
/// IOMMU domain, so the device may DMA to that buffer. The caller must own `device_id`
/// and hold `handle` (received over IPC or from its own `dma.alloc(.. | shareable)`).
/// Idempotent. Returns true on success (and trivially when no IOMMU is present).
pub fn dmaBind(device_id: u64, handle: usize) bool {
return !failed(sc.systemCall2(.dma_bind, device_id, handle));
}
/// Unmap a previously `dmaBind`'d buffer from the device's domain.
pub fn dmaUnbind(device_id: u64, handle: usize) bool {
return !failed(sc.systemCall2(.dma_unbind, device_id, handle));
}
/// Drain and log any pending IOMMU translation faults, returning the count seen. A
/// diagnostic: a driver that suspects its device attempted an out-of-domain DMA (or a
/// test proving enforcement) forces the hardware's fault records to the log now. Returns
/// 0 when no IOMMU is present.
pub fn iommuFaultDrain() usize {
return sc.systemCall0(.iommu_fault_drain);
}
/// Read `width` bytes (1, 2, or 4) from a port in a claimed device's `io_port`
/// resource, at byte `offset` within it. Ring 3 has no direct `in`/`out`, so a legacy
/// driver (PS/2, 16550 UART) reaches its ports through this claim-gated call — each
@@ -129,3 +153,42 @@ pub fn findDeviceDescriptorByHid(buffer: []DeviceDescriptor, hid_needle: []const
return null;
}
// --- device-manager handshake (folded in from the former device-manager.zig) ---
/// What kind of driver is announcing itself (a bus that reports children, or a leaf
/// device). Re-exported so callers name it without importing the protocol.
pub const Role = device_manager_protocol.Role;
const lookup_attempts: u32 = 100;
const lookup_pause_ms: u64 = 20;
/// Say hello to the device manager and return its endpoint, or null if there is no manager
/// (best-effort standalone bring-up) or it refused the handshake. Bus drivers keep the handle
/// to report children through; a driver that runs fine unsupervised discards it with `_ =`,
/// and one that requires supervision bails on null. Logs the outcome itself.
pub fn hello(role: Role, device_id: u64) ?ipc.Handle {
var attempts: u32 = 0;
const manager = while (attempts < lookup_attempts) : (attempts += 1) {
if (ipc.lookup(.device_manager)) |handle| break handle;
time.sleepMillis(lookup_pause_ms);
} else {
std.log.info("no device manager to hello", .{});
return null;
};
const message = device_manager_protocol.Hello{ .role = @intFromEnum(role), .device_id = device_id };
var reply: [device_manager_protocol.reply_size]u8 = undefined;
const length = ipc.call(manager, std.mem.asBytes(&message), &reply) catch {
std.log.info("hello call failed", .{});
return null;
};
if (length < device_manager_protocol.reply_size or
std.mem.bytesToValue(device_manager_protocol.HelloReply, reply[0..device_manager_protocol.reply_size]).status != 0)
{
std.log.info("hello refused", .{});
return null;
}
std.log.info("hello acknowledged", .{});
return manager;
}
@@ -1,4 +1,4 @@
//! /lib/mmio — typed volatile MMIO register access, plus the memory-ordering
//! /lib/device/mmio — typed volatile MMIO register access, plus the memory-ordering
//! barriers a device driver needs. Used by drivers on top of an `mmio_map` grant.
//!
//! **`volatile` is not a barrier.** In Zig it means only: don't elide this access, and
@@ -11,13 +11,13 @@
//! doorbell.* = i; // volatile store to UC MMIO
//! // nothing orders these; the device can read a stale descriptor
//!
//! Put a `wmb()` between them. The barriers lower per-architecture — which is the whole
//! reason they are a named primitive and not scattered `asm volatile`:
//! Put a `writeMemoryBarrier()` between them. The barriers lower per-architecture — which
//! is the whole reason they are a named primitive and not scattered `asm volatile`:
//!
//! x86_64 aarch64
//! mb() mfence dsb sy
//! rmb() lfence dsb ld
//! wmb() sfence dsb st
//! x86_64 aarch64
//! memoryBarrier() mfence dsb sy
//! readMemoryBarrier() lfence dsb ld
//! writeMemoryBarrier() sfence dsb st
//!
//! x86 is forgiving (TSO + strong-uncacheable MMIO), so a compiler barrier usually
//! suffices; ARM is not, and ARM is the win condition (docs/vision.md) — so the
@@ -29,52 +29,52 @@ const builtin = @import("builtin");
/// Read a register of type `T` at absolute virtual address `addr` — a location inside
/// a device's `mmio_map` grant. `volatile`: never elided, never reordered against
/// another volatile access.
pub inline fn read(comptime T: type, addr: usize) T {
pub inline fn readRegister(comptime T: type, addr: usize) T {
return @as(*const volatile T, @ptrFromInt(addr)).*;
}
/// Write `value` of type `T` to the register at absolute virtual address `addr`.
pub inline fn write(comptime T: type, addr: usize, value: T) void {
pub inline fn writeRegister(comptime T: type, addr: usize, value: T) void {
@as(*volatile T, @ptrFromInt(addr)).* = value;
}
/// Full barrier: all loads and stores before it are globally visible before any after
/// it. Use when an MMIO write must complete before a following read.
pub inline fn mb() void {
/// Full memory barrier: all loads and stores before it are globally visible before any
/// after it. Use when an MMIO write must complete before a following read.
pub inline fn memoryBarrier() void {
switch (builtin.target.cpu.arch) {
.x86_64 => asm volatile ("mfence" ::: .{ .memory = true }),
.aarch64 => asm volatile ("dsb sy" ::: .{ .memory = true }),
else => @compileError("mmio.mb: unsupported architecture"),
else => @compileError("mmio.memoryBarrier: unsupported architecture"),
}
}
/// Read barrier: loads before it complete before loads after it. Use after an IRQ
/// Read memory barrier: loads before it complete before loads after it. Use after an IRQ
/// wake, before reading what the device wrote to shared memory.
pub inline fn rmb() void {
pub inline fn readMemoryBarrier() void {
switch (builtin.target.cpu.arch) {
.x86_64 => asm volatile ("lfence" ::: .{ .memory = true }),
.aarch64 => asm volatile ("dsb ld" ::: .{ .memory = true }),
else => @compileError("mmio.rmb: unsupported architecture"),
else => @compileError("mmio.readMemoryBarrier: unsupported architecture"),
}
}
/// Write barrier: stores before it become visible before stores after it. Use between
/// filling a DMA descriptor in RAM and ringing the device's doorbell.
pub inline fn wmb() void {
/// Write memory barrier: stores before it become visible before stores after it. Use
/// between filling a DMA descriptor in RAM and ringing the device's doorbell.
pub inline fn writeMemoryBarrier() void {
switch (builtin.target.cpu.arch) {
.x86_64 => asm volatile ("sfence" ::: .{ .memory = true }),
.aarch64 => asm volatile ("dsb st" ::: .{ .memory = true }),
else => @compileError("mmio.wmb: unsupported architecture"),
else => @compileError("mmio.writeMemoryBarrier: unsupported architecture"),
}
}
test "barriers emit and registers round-trip through a RAM cell" {
// The barriers must at least assemble for the host arch; ordering can't be unit
// tested, but a missing/mistyped mnemonic is caught here.
wmb();
rmb();
mb();
writeMemoryBarrier();
readMemoryBarrier();
memoryBarrier();
var cell: u64 = 0;
write(u64, @intFromPtr(&cell), 0xDEAD_BEEF);
try @import("std").testing.expectEqual(@as(u64, 0xDEAD_BEEF), read(u64, @intFromPtr(&cell)));
writeRegister(u64, @intFromPtr(&cell), 0xDEAD_BEEF);
try @import("std").testing.expectEqual(@as(u64, 0xDEAD_BEEF), readRegister(u64, @intFromPtr(&cell)));
}
@@ -6,7 +6,7 @@
//! by name, and neither reaches into the other's files.
//!
//! It is also the **single source of truth** for `DeviceClass` and `ResourceKind`:
//! the kernel's rich, pointer-based device tree (system/devices/device-model.zig,
//! the kernel's rich, pointer-based device tree (system/kernel/device-model.zig,
//! which user space must never import) re-exports these, so the enum that a driver
//! matches on and the enum the kernel classifies with are the *same* type — no
//! hand-kept "mirror in order" to drift. The core kernel↔user ABI is [[abi]]; the
@@ -97,6 +97,7 @@ pub const DisplayInfo = extern struct {
height: u32 = 0, // visible rows
pitch: u32 = 0, // bytes from one row's start to the next
format: u32 = 0, // a DisplayFormat value
refresh_hz: u32 = 0, // panel refresh rate from EDID (0 = unknown); see boot-handoff
};
/// `DeviceDescriptor.parent` for a device with no parent — a root of the device tree.
@@ -124,6 +125,15 @@ pub const DeviceDescriptor = extern struct {
// `pci_device` is (an xHCI controller, an AHCI controller) — decode the triple into
// names with the pci-class module.
pci_class: u64,
// Numeric identity beyond the class triple, mirrored in the bus report's
// ChildAdded so /etc/devices.csv can bind on it: `vendor`/`device` are the PCI
// vendor/device (or USB idVendor/idProduct), `subsystem` is the PCI subsystem id
// packed `(subsystem_vendor << 16) | subsystem_device`. Zero where the bus has no
// such concept. Defaulted so existing descriptor literals keep compiling and lay
// out identically until they choose to set them.
vendor: u16 = 0,
device: u16 = 0,
subsystem: u32 = 0,
hid_len: u64,
resource_count: u64,
hid: [8]u8,
@@ -41,6 +41,153 @@ pub const ClassCode = struct {
}
};
// --- Configuration-space layout ---------------------------------------------------------
// The offsets and bit layouts of the PCI configuration header (PCI spec; see
// https://wiki.osdev.org/PCI). Pure data — named here so both a device driver's view of
// its own claimed function (library/device/pci/pci.zig) and the bus enumerator name the
// same bytes instead of scattering bare 0x04/0x34/0xFFFF_FFF0 magic across the tree.
/// Header field offsets (byte offsets into the 256-byte configuration space).
pub const config_vendor_id: usize = 0x00;
pub const config_device_id: usize = 0x02;
pub const config_command: usize = 0x04;
pub const config_status: usize = 0x06;
pub const config_revision_id: usize = 0x08;
pub const config_class_code: usize = 0x09; // 3 bytes: prog-IF 0x09, subclass 0x0A, base class 0x0B
pub const config_bar0: usize = 0x10; // BAR0; BAR n is at config_bar0 + n*4
pub const config_subsystem_vendor_id: usize = 0x2C;
pub const config_subsystem_id: usize = 0x2E;
pub const config_expansion_rom: usize = 0x30;
pub const config_capabilities_pointer: usize = 0x34;
pub const config_interrupt_line: usize = 0x3C;
pub const config_interrupt_pin: usize = 0x3D; // 0 = none, 1..4 = INTA..INTD
/// Command register bits.
pub const command_io_space: u16 = 0x0001; // bit 0: I/O-space decode enable
pub const command_memory_space: u16 = 0x0002; // bit 1: memory-space decode enable
pub const command_bus_master: u16 = 0x0004; // bit 2: bus-master (DMA) enable
pub const command_interrupt_disable: u16 = 0x0400; // bit 10: suppress legacy INTx (MSI/MSI-X unaffected)
/// The pair a bus-mastering driver enables together: decode my BARs, let me DMA.
pub const command_memory_and_bus_master: u16 = command_memory_space | command_bus_master;
/// Status register bit 3: legacy INTx is asserted (upstream of the command bit-10 gate).
pub const status_interrupt: u16 = 0x0008;
/// Status register bit 4: a capability list is present at config_capabilities_pointer.
pub const status_capabilities_list: u16 = 0x10;
/// Capability pointers are dword-aligned; the low two bits are reserved.
pub const capability_pointer_mask: u8 = 0xFC;
/// Capability IDs — the first byte of each entry in the legacy capability list.
/// Non-exhaustive: hardware may report IDs not named here.
pub const CapabilityId = enum(u8) {
power_management = 0x01,
msi = 0x05,
vendor_specific = 0x09,
pci_express = 0x10,
msix = 0x11,
_,
};
/// MSI capability (id 0x05) register layout. Offsets are relative to the capability
/// header; whether the address is one or two dwords (and therefore where the data word
/// sits) depends on `control_64bit_capable`.
pub const msi = struct {
pub const control: usize = 0x02; // u16 Message Control
pub const control_enable: u16 = 0x0001;
pub const control_multiple_message_capable_mask: u16 = 0x000E; // bits 3:1, log2(vectors requested)
pub const control_multiple_message_enable_mask: u16 = 0x0070; // bits 6:4, log2(vectors granted)
pub const control_64bit_capable: u16 = 0x0080; // bit 7: address is 64-bit (layout shifts)
pub const control_per_vector_masking: u16 = 0x0100; // bit 8
pub const address: usize = 0x04; // u32 low address dword (both layouts)
pub const address_high: usize = 0x08; // u32, present only when 64-bit capable
pub const data_32: usize = 0x08; // u16 message data, 32-bit layout
pub const data_64: usize = 0x0C; // u16 message data, 64-bit layout
pub const mask_bits_32: usize = 0x0C; // u32, only with per-vector masking
pub const mask_bits_64: usize = 0x10;
};
/// MSI-X capability (id 0x11) register layout, plus the 16-byte vector table entry that
/// lives in BAR space (not configuration space) at the decoded (BIR, offset).
pub const msix = struct {
pub const control: usize = 0x02; // u16 Message Control
pub const control_table_size_mask: u16 = 0x07FF; // bits 10:0, encoded as N-1
pub const control_function_mask: u16 = 0x4000; // bit 14: mask every vector
pub const control_enable: u16 = 0x8000; // bit 15
pub const table_offset_word: usize = 0x04; // u32: BIR in bits 2:0, table offset in bits 31:3
pub const pba_offset_word: usize = 0x08; // u32: same encoding, pending-bit array
pub const bir_mask: u32 = 0x0000_0007;
pub const offset_mask: u32 = 0xFFFF_FFF8;
pub const entry_size: usize = 16; // table entry stride; offsets within an entry:
pub const entry_address: usize = 0x0; // u32 low
pub const entry_address_high: usize = 0x4; // u32 high
pub const entry_data: usize = 0x8; // u32
pub const entry_vector_control: usize = 0xC; // u32
pub const entry_vector_control_masked: u32 = 0x1; // bit 0; entries reset to masked
/// Where the table (or pending-bit array) lives, decoded from its offset/BIR dword.
pub const TableLocation = struct { bar: u8, offset: u32 };
pub fn tableLocation(word: u32) TableLocation {
return .{ .bar = @intCast(word & bir_mask), .offset = word & offset_mask };
}
/// Number of table entries (the control field encodes N-1).
pub fn tableSize(control_value: u16) u16 {
return (control_value & control_table_size_mask) + 1;
}
};
/// Power-management capability (id 0x01) register layout.
pub const power_management = struct {
pub const capabilities: usize = 0x02; // u16 PMC (read-only: version, D-state support)
pub const control_status: usize = 0x04; // u16 PMCSR
pub const control_status_power_state_mask: u16 = 0x0003; // bits 1:0
pub const power_state_d0: u16 = 0x0;
pub const power_state_d3_hot: u16 = 0x3;
pub const control_status_pme_enable: u16 = 0x0100; // bit 8: plain RW — preserve on writes
pub const control_status_pme_status: u16 = 0x8000; // bit 15: RW1C — write 0 or you clear it
};
/// PCI Express capability (id 0x10) register layout — the slice function-level reset
/// needs; the full capability is much larger.
pub const pci_express = struct {
pub const capabilities: usize = 0x02; // u16 PCIe Capabilities register
pub const device_capabilities: usize = 0x04; // u32
pub const device_capabilities_flr: u32 = 1 << 28; // Function Level Reset supported
pub const device_control: usize = 0x08; // u16
pub const device_control_initiate_flr: u16 = 1 << 15;
pub const device_status: usize = 0x0A; // u16
pub const device_status_transactions_pending: u16 = 1 << 5;
};
/// Extended (PCI Express) capabilities start here in the 4 KiB configuration space; a
/// conventional-PCI function has nothing there (the space reads as all-ones).
pub const extended_capability_start: usize = 0x100;
/// Extended-capability next pointers are dword-aligned within the 4 KiB space.
pub const extended_capability_pointer_mask: u16 = 0xFFC;
/// The 32-bit header at the start of each extended capability: ID in bits 15:0,
/// version in 19:16, next offset in 31:20 (0 = end of list).
pub const ExtendedCapabilityHeader = struct {
id: u16,
version: u4,
next: u16,
pub fn decode(word: u32) ExtendedCapabilityHeader {
return .{
.id = @truncate(word),
.version = @truncate(word >> 16),
.next = @intCast((word >> 20) & extended_capability_pointer_mask),
};
}
};
/// BAR bit layout: bit 0 selects I/O (1) vs memory (0) space; for a memory BAR, bits 2:1
/// give the type (00 = 32-bit, 10 = 64-bit spanning the next BAR), and the base address is
/// the dword with the low 4 flag bits masked off.
pub const bar_io_space: u32 = 0x1;
pub const bar_type_mask: u32 = 0x6;
pub const bar_type_64bit: u32 = 0x4;
pub const bar_memory_base_mask: u32 = 0xFFFF_FFF0;
/// Base class (config byte 0x0B). Non-exhaustive: an unlisted code is a real but
/// unnamed class, decoded as "Unknown" rather than rejected.
pub const BaseClass = enum(u8) {
@@ -566,3 +713,37 @@ test "named parts pack to the raw triple" {
};
try std.testing.expectEqual(@as(u24, 0x0C_03_30), xhci.pack());
}
test "MSI-X table word decodes to BIR and offset" {
const eq = std.testing.expectEqual;
// BIR 3, table at 0x2000 within that BAR.
try eq(msix.TableLocation{ .bar = 3, .offset = 0x2000 }, msix.tableLocation(0x0000_2003));
// BIR 0, offset 0 — the degenerate-but-common "table at BAR start" case.
try eq(msix.TableLocation{ .bar = 0, .offset = 0 }, msix.tableLocation(0));
// Table size encodes N-1 in bits 10:0; enable/function-mask bits must not leak in.
try eq(@as(u16, 11), msix.tableSize(msix.control_enable | 0x000A));
try eq(@as(u16, 1), msix.tableSize(0));
try eq(@as(u16, 2048), msix.tableSize(msix.control_table_size_mask));
}
test "extended capability header unpacks id, version, next" {
const eq = std.testing.expectEqual;
// AER (id 0x0001), version 1, next capability at 0x140.
const aer = ExtendedCapabilityHeader.decode(0x1401_0001);
try eq(@as(u16, 0x0001), aer.id);
try eq(@as(u4, 1), aer.version);
try eq(@as(u16, 0x140), aer.next);
// A zero header is the "nothing here" terminator.
const none = ExtendedCapabilityHeader.decode(0);
try eq(@as(u16, 0), none.id);
try eq(@as(u16, 0), none.next);
}
test "command bits and capability ids compose" {
const eq = std.testing.expectEqual;
try eq(command_memory_space | command_bus_master, command_memory_and_bus_master);
try eq(@as(u8, 0x05), @intFromEnum(CapabilityId.msi));
try eq(@as(u8, 0x11), @intFromEnum(CapabilityId.msix));
try eq(@as(u8, 0x01), @intFromEnum(CapabilityId.power_management));
try eq(@as(u8, 0x10), @intFromEnum(CapabilityId.pci_express));
}
+384
View File
@@ -0,0 +1,384 @@
//! library/device/pci/pci.zig — a device driver's view of the ONE PCI function it has
//! claimed. Config space is mapped as resource 0 (a full 4 KiB ECAM page); this gives
//! header-field accessors, BAR decode + map, capability walks (legacy and extended),
//! MSI/MSI-X programming, power-state handling, and function-level reset, so a driver
//! never re-derives the config-space layout by hand.
//!
//! This is the *device-owned* view: read my own function's live config, map my own BARs.
//! The bus enumerator's view — probing arbitrary, not-yet-claimed functions and sizing
//! their BARs — is a different mechanism and lives in the pci-bus driver. The pure
//! config-space layout both need (offsets, BAR bit fields) is named once in the `pci-class`
//! data module; this logic module adds the parts that need `mmio` + the `driver` client.
const std = @import("std");
const mmio = @import("mmio");
const pci_class = @import("pci-class");
const device = @import("driver");
const time = @import("time");
/// Spec recovery time after a D3hot -> D0 transition.
const d0_recovery_millis: u64 = 10;
/// How long to wait for in-flight transactions to drain before a function-level reset
/// (then reset anyway — resetting a stuck function is the point of FLR).
const flr_pending_timeout_millis: u64 = 100;
/// The spec's maximum FLR completion time.
const flr_settle_millis: u64 = 100;
/// How long to wait for the function to become readable again after an FLR.
const flr_ready_timeout_millis: u64 = 1000;
const flr_poll_interval_millis: u64 = 10;
/// A claimed PCI function whose configuration space is mapped (resource 0). `descriptor`
/// must outlive the Function — the driver's `device.enumerate` buffer does, for the whole
/// bring-up. Header reads and the capability walk hit live config space; `mapBar` caches.
pub const Function = struct {
device_id: u64,
descriptor: *const device.DeviceDescriptor,
config: usize, // virtual base of mapped resource 0
bar_virtual: [6]usize = .{ 0, 0, 0, 0, 0, 0 }, // per-BAR mmio_map cache
/// Map config space (resource 0) of the already-claimed `device_id`. null if the map
/// fails (not claimed, or no config resource).
pub fn map(device_id: u64, descriptor: *const device.DeviceDescriptor) ?Function {
const base = device.mmioMap(device_id, 0) orelse return null;
return .{ .device_id = device_id, .descriptor = descriptor, .config = base };
}
pub fn vendorId(self: *const Function) u16 {
return mmio.readRegister(u16, self.config + pci_class.config_vendor_id);
}
pub fn deviceId(self: *const Function) u16 {
return mmio.readRegister(u16, self.config + pci_class.config_device_id);
}
pub fn command(self: *const Function) u16 {
return mmio.readRegister(u16, self.config + pci_class.config_command);
}
pub fn status(self: *const Function) u16 {
return mmio.readRegister(u16, self.config + pci_class.config_status);
}
pub fn revisionId(self: *const Function) u8 {
return mmio.readRegister(u8, self.config + pci_class.config_revision_id);
}
/// Subsystem vendor ID (config 0x2C) — with `subsystemId`, the standard key for
/// board-level quirk matching.
pub fn subsystemVendorId(self: *const Function) u16 {
return mmio.readRegister(u16, self.config + pci_class.config_subsystem_vendor_id);
}
pub fn subsystemId(self: *const Function) u16 {
return mmio.readRegister(u16, self.config + pci_class.config_subsystem_id);
}
/// Interrupt pin (config 0x3D): 0 = none, 1..4 = INTA..INTD.
pub fn interruptPin(self: *const Function) u8 {
return mmio.readRegister(u8, self.config + pci_class.config_interrupt_pin);
}
/// The live class-code triple (config 0x09..0x0B), same shape discovery records.
pub fn classCode(self: *const Function) pci_class.ClassCode {
return .{
.prog_if = mmio.readRegister(u8, self.config + pci_class.config_class_code),
.subclass = mmio.readRegister(u8, self.config + pci_class.config_class_code + 1),
.base = mmio.readRegister(u8, self.config + pci_class.config_class_code + 2),
};
}
fn commandSetBits(self: *const Function, bits: u16) void {
const at = self.config + pci_class.config_command;
mmio.writeRegister(u16, at, mmio.readRegister(u16, at) | bits);
}
fn commandClearBits(self: *const Function, bits: u16) void {
const at = self.config + pci_class.config_command;
mmio.writeRegister(u16, at, mmio.readRegister(u16, at) & ~bits);
}
/// Set Memory-Space + Bus-Master Enable in the command register. Firmware only enables
/// memory decode on devices it used at boot; any other device has dead BARs until its
/// driver sets it. Bus mastering is separately required for the device to do DMA.
pub fn enableMemoryAndBusMaster(self: *const Function) void {
self.commandSetBits(pci_class.command_memory_and_bus_master);
}
/// Clear Bus-Master Enable — stop the device initiating DMA. The quiesce half of a
/// driver's shutdown (or a supervisor restart): after this the device can no longer
/// write memory the process is about to stop owning.
pub fn disableBusMaster(self: *const Function) void {
self.commandClearBits(pci_class.command_bus_master);
}
/// Set command bit 10: suppress legacy INTx assertion. MSI/MSI-X are unaffected —
/// set this when enabling either, so the device cannot also raise the shared pin.
pub fn setInterruptDisable(self: *const Function) void {
self.commandSetBits(pci_class.command_interrupt_disable);
}
/// Clear command bit 10, re-allowing legacy INTx assertion.
pub fn clearInterruptDisable(self: *const Function) void {
self.commandClearBits(pci_class.command_interrupt_disable);
}
/// Decode BAR `bar` (0..5) and map it: read the BAR register, reject I/O-space BARs,
/// combine the high dword for a 64-bit BAR, mask the base, then correlate that physical
/// base with one of the descriptor's memory resources and `mmio_map` it — a BAR names a
/// *number*, while `mmio_map` takes a *resource index*, and gaps/config-space shift the
/// numbering. Cached per BAR. null if the BAR is I/O-space or is not a mapped resource.
pub fn mapBar(self: *Function, bar: u8) ?usize {
if (bar >= 6) return null;
if (self.bar_virtual[bar] != 0) return self.bar_virtual[bar];
const low = mmio.readRegister(u32, self.config + pci_class.config_bar0 + @as(usize, bar) * 4);
if (low & pci_class.bar_io_space != 0) return null; // an I/O-space BAR
var base: u64 = low & pci_class.bar_memory_base_mask;
if ((low & pci_class.bar_type_mask) == pci_class.bar_type_64bit) { // 64-bit: high half is the next dword
const high = mmio.readRegister(u32, self.config + pci_class.config_bar0 + (@as(usize, bar) + 1) * 4);
base |= @as(u64, high) << 32;
}
for (self.descriptor.resources[0..@intCast(self.descriptor.resource_count)], 0..) |resource, index| {
if (resource.kind == @intFromEnum(device.ResourceKind.memory) and resource.start == base) {
const v = device.mmioMap(self.device_id, index) orelse return null;
self.bar_virtual[bar] = v;
return v;
}
}
return null;
}
/// Iterate the capability list. Empty when the function advertises none.
pub fn capabilities(self: *const Function) CapabilityIterator {
const present = self.status() & pci_class.status_capabilities_list != 0;
const first = if (present)
mmio.readRegister(u8, self.config + pci_class.config_capabilities_pointer) & pci_class.capability_pointer_mask
else
0;
return .{ .config = self.config, .cursor = first };
}
/// First capability with `id`, or null.
pub fn findCapability(self: *const Function, id: pci_class.CapabilityId) ?Capability {
var walk = self.capabilities();
while (walk.next()) |capability| {
if (capability.id == @intFromEnum(id)) return capability;
}
return null;
}
/// Program the MSI capability with the kernel's `msi_bind` result and enable it —
/// one vector (multiple-message-enable 0, matching the kernel's single-vector
/// grant), INTx suppressed. false if the function has no MSI capability.
pub fn programMsi(self: *const Function, message: device.Msi) bool {
const cap = self.findCapability(.msi) orelse return false;
const control_at = cap.offset + pci_class.msi.control;
const control = mmio.readRegister(u16, control_at);
// Program the registers while the capability is disabled.
mmio.writeRegister(u16, control_at, control & ~pci_class.msi.control_enable);
mmio.writeRegister(u32, cap.offset + pci_class.msi.address, @truncate(message.address));
const data_offset = if (control & pci_class.msi.control_64bit_capable != 0) offset: {
mmio.writeRegister(u32, cap.offset + pci_class.msi.address_high, @intCast(message.address >> 32));
break :offset pci_class.msi.data_64;
} else pci_class.msi.data_32;
// Message data is a 16-bit register in both layouts.
mmio.writeRegister(u16, cap.offset + data_offset, @truncate(message.data));
mmio.writeRegister(u16, control_at, (control & ~pci_class.msi.control_multiple_message_enable_mask) | pci_class.msi.control_enable);
self.setInterruptDisable();
return true;
}
/// Clear the MSI enable bit. No-op if the function has no MSI capability.
pub fn disableMsi(self: *const Function) void {
const cap = self.findCapability(.msi) orelse return;
const control_at = cap.offset + pci_class.msi.control;
mmio.writeRegister(u16, control_at, mmio.readRegister(u16, control_at) & ~pci_class.msi.control_enable);
}
/// The function's MSI-X capability with its vector table mapped: the table's BIR is
/// resolved through `mapBar` (a free cache hit when it is a BAR the driver already
/// mapped). null if the capability is absent or the table's BAR cannot be mapped.
pub fn msix(self: *Function) ?MsiX {
const cap = self.findCapability(.msix) orelse return null;
const control = mmio.readRegister(u16, cap.offset + pci_class.msix.control);
const word = mmio.readRegister(u32, cap.offset + pci_class.msix.table_offset_word);
const location = pci_class.msix.tableLocation(word);
const bar_base = self.mapBar(location.bar) orelse return null;
return .{
.capability = cap.offset,
.table = bar_base + location.offset,
.entry_count = pci_class.msix.tableSize(control),
};
}
/// Bring the function to D0. Firmware can leave a non-boot device in D3hot, where
/// its BARs and MSI registers do not decode; call this before touching either. No
/// power-management capability means the function is always at D0: nothing to do.
/// Preserves PME-Enable and never clears the write-1-to-clear PME-Status bit.
pub fn ensurePowerStateD0(self: *const Function) void {
const cap = self.findCapability(.power_management) orelse return;
const at = cap.offset + pci_class.power_management.control_status;
const pmcsr = mmio.readRegister(u16, at);
if (pmcsr & pci_class.power_management.control_status_power_state_mask == pci_class.power_management.power_state_d0) return;
// PME-Status is RW1C: echoing a read 1 back would clear it, so write it as 0.
mmio.writeRegister(u16, at, (pmcsr & ~pci_class.power_management.control_status_power_state_mask & ~pci_class.power_management.control_status_pme_status) | pci_class.power_management.power_state_d0);
time.sleepMillis(d0_recovery_millis);
}
/// Function Level Reset via the PCI Express capability: return the hardware to a
/// known state (a supervisor re-claiming a device after its driver died, or a driver
/// recovering a wedged function). The six BAR dwords are saved and restored — FLR
/// clears them, and the bus enumerator's assignment must survive for the descriptor
/// correlation and `mapBar` cache to stay valid. Everything else is reset: command
/// enables and MSI/MSI-X programming are gone, so the caller re-runs its whole
/// bring-up afterwards. false if the function has no PCI Express capability, does
/// not advertise FLR (conventional-PCI Advanced Features FLR is a possible
/// follow-up), or never became readable again. Blocks for at least 100 ms.
pub fn functionLevelReset(self: *const Function) bool {
const cap = self.findCapability(.pci_express) orelse return false;
const device_capabilities = mmio.readRegister(u32, cap.offset + pci_class.pci_express.device_capabilities);
if (device_capabilities & pci_class.pci_express.device_capabilities_flr == 0) return false;
// Stop new DMA, then give in-flight transactions a bounded chance to drain —
// and reset anyway on timeout, since resetting a stuck function is the point.
self.disableBusMaster();
var waited: u64 = 0;
while (mmio.readRegister(u16, cap.offset + pci_class.pci_express.device_status) & pci_class.pci_express.device_status_transactions_pending != 0) {
if (waited >= flr_pending_timeout_millis) break;
time.sleepMillis(flr_poll_interval_millis);
waited += flr_poll_interval_millis;
}
var bars: [6]u32 = undefined;
for (&bars, 0..) |*bar, index| bar.* = mmio.readRegister(u32, self.config + pci_class.config_bar0 + index * 4);
const control_at = cap.offset + pci_class.pci_express.device_control;
mmio.writeRegister(u16, control_at, mmio.readRegister(u16, control_at) | pci_class.pci_express.device_control_initiate_flr);
time.sleepMillis(flr_settle_millis);
waited = 0;
while (self.vendorId() == 0xFFFF) {
if (waited >= flr_ready_timeout_millis) return false;
time.sleepMillis(flr_poll_interval_millis);
waited += flr_poll_interval_millis;
}
for (bars, 0..) |bar, index| mmio.writeRegister(u32, self.config + pci_class.config_bar0 + index * 4, bar);
return true;
}
/// Iterate the extended (PCI Express) capability list at 0x100.. in the 4 KiB ECAM
/// page. Empty on a conventional-PCI function (the space reads as all-ones).
pub fn extendedCapabilities(self: *const Function) ExtendedCapabilityIterator {
return .{ .config = self.config };
}
/// First extended capability with `id`, or null.
pub fn findExtendedCapability(self: *const Function, id: u16) ?ExtendedCapability {
var walk = self.extendedCapabilities();
while (walk.next()) |capability| {
if (capability.id == id) return capability;
}
return null;
}
};
/// One capability header. `offset` is the ABSOLUTE virtual address of the header, so the
/// caller reads its body with `mmio.readRegister(T, cap.offset + n)`.
pub const Capability = struct { id: u8, offset: usize };
pub const CapabilityIterator = struct {
config: usize,
cursor: u8,
guard: u32 = 0, // bounds a malformed/looping chain (48 = the 256-byte space in dwords)
pub fn next(self: *CapabilityIterator) ?Capability {
if (self.cursor == 0 or self.guard >= 48) return null;
self.guard += 1;
const at = self.config + self.cursor;
const id = mmio.readRegister(u8, at + 0);
self.cursor = mmio.readRegister(u8, at + 1) & pci_class.capability_pointer_mask;
return .{ .id = id, .offset = at };
}
};
/// A resolved MSI-X capability from `Function.msix`: `capability` is the absolute
/// virtual address of the config-space header, `table` of vector-table entry 0 (in BAR
/// space — table writes are MMIO, not config space). Entries reset masked; bring-up
/// order is programEntry per vector, unmaskEntry per used vector, `enable`, then
/// `Function.setInterruptDisable`.
pub const MsiX = struct {
capability: usize,
table: usize,
entry_count: u16,
/// Write `message` into table entry `entry`, leaving the entry masked (its reset
/// state) — the spec requires masking while address/data change. false if `entry`
/// is out of range.
pub fn programEntry(self: *const MsiX, entry: u16, message: device.Msi) bool {
if (entry >= self.entry_count) return false;
const at = self.table + @as(usize, entry) * pci_class.msix.entry_size;
mmio.writeRegister(u32, at + pci_class.msix.entry_vector_control, pci_class.msix.entry_vector_control_masked);
mmio.writeRegister(u32, at + pci_class.msix.entry_address, @truncate(message.address));
mmio.writeRegister(u32, at + pci_class.msix.entry_address_high, @intCast(message.address >> 32));
mmio.writeRegister(u32, at + pci_class.msix.entry_data, message.data);
return true;
}
/// Set the entry's vector-control mask bit — its interrupt is held off (pended in
/// the PBA, not lost). false if `entry` is out of range.
pub fn maskEntry(self: *const MsiX, entry: u16) bool {
return self.writeEntryMask(entry, true);
}
/// Clear the entry's vector-control mask bit. false if `entry` is out of range.
pub fn unmaskEntry(self: *const MsiX, entry: u16) bool {
return self.writeEntryMask(entry, false);
}
fn writeEntryMask(self: *const MsiX, entry: u16, masked: bool) bool {
if (entry >= self.entry_count) return false;
const at = self.table + @as(usize, entry) * pci_class.msix.entry_size + pci_class.msix.entry_vector_control;
const control = mmio.readRegister(u32, at);
mmio.writeRegister(u32, at, if (masked)
control | pci_class.msix.entry_vector_control_masked
else
control & ~pci_class.msix.entry_vector_control_masked);
return true;
}
/// Set the function-mask control bit: every vector masked regardless of entry bits.
pub fn setFunctionMask(self: *const MsiX) void {
self.writeControl(pci_class.msix.control_function_mask, true);
}
/// Clear the function-mask control bit.
pub fn clearFunctionMask(self: *const MsiX) void {
self.writeControl(pci_class.msix.control_function_mask, false);
}
/// Set MSI-X Enable. The caller also calls `Function.setInterruptDisable` (INTx off).
pub fn enable(self: *const MsiX) void {
self.writeControl(pci_class.msix.control_enable, true);
}
/// Clear MSI-X Enable.
pub fn disable(self: *const MsiX) void {
self.writeControl(pci_class.msix.control_enable, false);
}
fn writeControl(self: *const MsiX, bit: u16, set: bool) void {
const at = self.capability + pci_class.msix.control;
const control = mmio.readRegister(u16, at);
mmio.writeRegister(u16, at, if (set) control | bit else control & ~bit);
}
};
/// One extended capability. `offset` is the ABSOLUTE virtual address of its header,
/// like `Capability.offset`.
pub const ExtendedCapability = struct { id: u16, version: u4, offset: usize };
pub const ExtendedCapabilityIterator = struct {
config: usize,
cursor: u16 = @intCast(pci_class.extended_capability_start),
guard: u32 = 0, // bounds a malformed chain (480 = the 0xF00-byte space / 8-byte minimum spacing)
pub fn next(self: *ExtendedCapabilityIterator) ?ExtendedCapability {
if (self.cursor == 0 or self.guard >= 480) return null;
self.guard += 1;
const at = self.config + self.cursor;
const header = pci_class.ExtendedCapabilityHeader.decode(mmio.readRegister(u32, at));
// Id 0 marks an empty list; all-ones is a conventional-PCI function (no
// extended space — reads come back as FFs).
if (header.id == 0 or header.id == 0xFFFF) return null;
// A next pointer below 0x100 would walk into the legacy header; treat it as the
// terminator it must be (0 is the normal one). The 0xFFC decode mask already
// keeps `config + cursor + 4` inside the 4 KiB page.
self.cursor = if (header.next >= pci_class.extended_capability_start) header.next else 0;
return .{ .id = header.id, .version = header.version, .offset = at };
}
};
+343
View File
@@ -0,0 +1,343 @@
//! The device registry: parse `/etc/devices.csv` into match rules and bind a
//! reported device to a driver. This is the data-driven replacement for the
//! device manager's three hand-written `switch` tables (`pciDriverForIdentity`,
//! `hidDriverFor`, `usbDriverForIdentity`); the registry is now **authoritative**
//! — a device that no row matches goes unbound (logged), never guessed.
//!
//! Pure logic: no hardware access, no syscalls, no allocator. `parse` fills a
//! caller-provided `[]Rule` whose string fields (`hid`, `driver`) are slices
//! *into the CSV source*, so the source buffer must outlive the rules (the
//! manager holds it in a static buffer for the life of the process — zero-copy).
//! That keeps this module freestanding and unit-testable with plain `zig test`.
//!
//! The file format (docs/device-driver-development/device-manager.md, and the
//! `/etc/devices.csv` header itself): one rule per line, nine comma-separated
//! fields, `#` starts a comment (whole-line or trailing), blank lines ignored.
//!
//! bus, base, class, prog_if, vendor, device, subsystem, hid, driver
//!
//! `bus` is `pci`/`usb`/`acpi`; the numeric fields are hex (with or without a
//! `0x` prefix); `*` or an empty field is a wildcard (matches anything). For PCI
//! the class triple is base/subclass/prog-IF; for USB it is class/subclass/
//! protocol with vendor/device the idVendor/idProduct; ACPI matches on `hid`
//! (e.g. "PNP0303") with the triple left blank. `driver` is a full ramdisk path.
const std = @import("std");
const csv = @import("csv");
/// Which bus a rule or a reported device belongs to. `unknown` is what an
/// unrecognised `bus` token parses to — such a rule never matches (its bus
/// equals no real device's), so a typo fails safe rather than binding wrongly.
pub const Bus = enum {
pci,
usb,
acpi,
unknown,
pub fn fromToken(token: []const u8) Bus {
if (std.mem.eql(u8, token, "pci")) return .pci;
if (std.mem.eql(u8, token, "usb")) return .usb;
if (std.mem.eql(u8, token, "acpi")) return .acpi;
return .unknown;
}
};
/// A reported device's full identity, as the manager assembles it from a
/// `child_added`: the bus-native class triple plus the numeric ids the widened
/// ABI now carries, or the ACPI `_HID` string. Fields a given bus does not have
/// are zero / empty (a PCI function has no `hid`; an ACPI device has no vendor).
pub const Identity = struct {
bus: Bus,
base: u8 = 0,
subclass: u8 = 0,
prog_if: u8 = 0,
vendor: u16 = 0,
device: u16 = 0,
subsystem: u32 = 0,
hid: []const u8 = "",
};
/// One parsed registry row. A `null` field is a wildcard — it matches any value
/// and contributes nothing to specificity. String fields point into the CSV
/// source that was parsed (see the module doc).
pub const Rule = struct {
bus: Bus,
base: ?u8 = null,
subclass: ?u8 = null,
prog_if: ?u8 = null,
vendor: ?u16 = null,
device: ?u16 = null,
subsystem: ?u32 = null,
hid: ?[]const u8 = null,
driver: []const u8,
};
/// Specificity weights: how much each pinned field counts toward "most specific
/// wins". Doubling from the coarsest (`base`) so that each level outweighs *all*
/// coarser levels combined (1+2+4+8+16 = 31 < 32) — a rule that pins `device`
/// always beats any rule that does not, no matter how many coarse fields the
/// latter pins. `hid` and `device` share the top tier (the user's "hid and
/// device weigh heaviest"); they never co-occur, since `hid` is ACPI-only and
/// `device` is a PCI/USB numeric id.
const weight_base: u32 = 1;
const weight_subclass: u32 = 2;
const weight_prog_if: u32 = 4;
const weight_vendor: u32 = 8;
const weight_subsystem: u32 = 16;
const weight_device: u32 = 32;
const weight_hid: u32 = 32;
/// The outcome of `matchDriver`: the winning rule's driver path, its specificity,
/// and whether another rule tied it at that specificity. `ambiguous` is a
/// registry authoring error (two equally-specific rules claiming one device); the
/// manager logs it loudly and binds the first, so a shadowed rule is visible
/// rather than silently dropped.
pub const Match = struct {
driver: []const u8,
specificity: u32,
ambiguous: bool,
};
/// Whether `rule` matches `id`: same bus, and every pinned (non-wildcard) field
/// equal. `hid` compares as a string; the rest as integers.
fn matches(rule: Rule, id: Identity) bool {
if (rule.bus != id.bus) return false;
if (rule.base) |b| if (b != id.base) return false;
if (rule.subclass) |s| if (s != id.subclass) return false;
if (rule.prog_if) |p| if (p != id.prog_if) return false;
if (rule.vendor) |v| if (v != id.vendor) return false;
if (rule.device) |d| if (d != id.device) return false;
if (rule.subsystem) |s| if (s != id.subsystem) return false;
if (rule.hid) |h| if (!std.mem.eql(u8, h, id.hid)) return false;
return true;
}
/// The specificity score of a rule — the sum of the weights of its pinned fields.
fn specificity(rule: Rule) u32 {
var score: u32 = 0;
if (rule.base != null) score += weight_base;
if (rule.subclass != null) score += weight_subclass;
if (rule.prog_if != null) score += weight_prog_if;
if (rule.vendor != null) score += weight_vendor;
if (rule.device != null) score += weight_device;
if (rule.subsystem != null) score += weight_subsystem;
if (rule.hid != null) score += weight_hid;
return score;
}
/// Bind a reported device to a driver: of every rule that matches `id`, return
/// the most specific. `null` when nothing matches (the device goes unbound —
/// the authoritative registry does not guess). On an exact specificity tie the
/// first such rule in file order wins and `ambiguous` is set.
pub fn matchDriver(rules: []const Rule, id: Identity) ?Match {
var best: ?Match = null;
for (rules) |rule| {
if (!matches(rule, id)) continue;
const score = specificity(rule);
if (best) |current| {
if (score > current.specificity) {
best = .{ .driver = rule.driver, .specificity = score, .ambiguous = false };
} else if (score == current.specificity) {
// Two equally-specific rules claim this device — keep the first,
// flag the ambiguity for the manager to log.
best.?.ambiguous = true;
}
} else {
best = .{ .driver = rule.driver, .specificity = score, .ambiguous = false };
}
}
return best;
}
// --- parsing -----------------------------------------------------------------
/// What one CSV line parsed to. `malformed` is a non-comment, non-blank line the
/// parser could not read (wrong field count, unparsable number, empty driver) —
/// the manager counts these and logs, so a broken registry is loud, not silent.
const Line = union(enum) {
rule: Rule,
ignorable, // blank or comment
malformed,
};
/// The result of `parse`: how many rules landed in the caller's buffer, and how
/// many non-ignorable lines were malformed (for the manager to log). `truncated`
/// is set if there were more valid rules than the buffer could hold.
pub const ParseResult = struct {
count: usize,
malformed: usize,
truncated: bool,
};
/// Parse one hex field into `T`, honouring `*`/empty as a wildcard (`null`) and
/// an optional `0x` prefix. Returns an error only for a genuinely unparsable
/// non-wildcard token, so the caller can mark the whole line malformed.
fn parseHexField(comptime T: type, field: []const u8) !?T {
const token = std.mem.trim(u8, field, " \t");
if (token.len == 0 or std.mem.eql(u8, token, "*")) return null;
const digits = if (std.mem.startsWith(u8, token, "0x") or std.mem.startsWith(u8, token, "0X"))
token[2..]
else
token;
return try std.fmt.parseInt(T, digits, 16);
}
/// Parse a wildcard-or-string field (the `hid` column): `*`/empty → wildcard.
fn parseStringField(field: []const u8) ?[]const u8 {
const token = std.mem.trim(u8, field, " \t");
if (token.len == 0 or std.mem.eql(u8, token, "*")) return null;
return token;
}
/// Classify and (if a rule) parse one line. Split out from `parse` so it can be
/// unit-tested directly. `line` is the raw line including no newline.
fn parseLine(line: []const u8) Line {
const body = csv.stripComment(line);
if (body.len == 0) return .ignorable;
// Nine comma-separated fields (csv.fields trims each): bus, base, class,
// prog_if, vendor, device, subsystem, hid, driver.
var cols: [9][]const u8 = undefined;
var count: usize = 0;
var it = csv.fields(body);
while (it.next()) |field| {
if (count >= cols.len) return .malformed; // too many columns
cols[count] = field;
count += 1;
}
if (count != cols.len) return .malformed; // too few columns
const bus = Bus.fromToken(cols[0]);
if (bus == .unknown) return .malformed;
const driver = cols[8];
if (driver.len == 0) return .malformed;
return .{ .rule = .{
.bus = bus,
.base = parseHexField(u8, cols[1]) catch return .malformed,
.subclass = parseHexField(u8, cols[2]) catch return .malformed,
.prog_if = parseHexField(u8, cols[3]) catch return .malformed,
.vendor = parseHexField(u16, cols[4]) catch return .malformed,
.device = parseHexField(u16, cols[5]) catch return .malformed,
.subsystem = parseHexField(u32, cols[6]) catch return .malformed,
.hid = parseStringField(cols[7]),
.driver = driver,
} };
}
/// Parse a whole `/etc/devices.csv` into `out_rules`. The string fields of the
/// returned rules point into `source`, which must outlive them.
pub fn parse(source: []const u8, out_rules: []Rule) ParseResult {
var result: ParseResult = .{ .count = 0, .malformed = 0, .truncated = false };
var lines = std.mem.splitScalar(u8, source, '\n');
while (lines.next()) |line| {
switch (parseLine(line)) {
.ignorable => {},
.malformed => result.malformed += 1,
.rule => |rule| {
if (result.count >= out_rules.len) {
result.truncated = true;
continue;
}
out_rules[result.count] = rule;
result.count += 1;
},
}
}
return result;
}
// --- tests -------------------------------------------------------------------
const testing = std.testing;
// The worked example from the design: a specific virtio-gpu rule (pins vendor +
// device) and a generic display rule (class only) both match the virtio card;
// the specific one must win. And a plain VGA adapter still falls to the generic
// rule. This is the whole point of widening the ABI to carry vendor/device.
test "virtio device rule beats the generic display rule" {
const text =
\\# bus, base, class, prog_if, vendor, device, subsystem, hid, driver
\\pci, 03, 00, 00, *, *, *, *, /system/drivers/display
\\pci, 03, 80, *, 1AF4, 1050, *, *, /system/drivers/virtio-gpu
;
var rules: [8]Rule = undefined;
const parsed = parse(text, &rules);
try testing.expectEqual(@as(usize, 2), parsed.count);
try testing.expectEqual(@as(usize, 0), parsed.malformed);
// The virtio-gpu function: display / other, vendor 1AF4 device 1050.
const virtio = matchDriver(rules[0..parsed.count], .{
.bus = .pci, .base = 0x03, .subclass = 0x80, .prog_if = 0x00,
.vendor = 0x1AF4, .device = 0x1050,
}).?;
try testing.expect(!virtio.ambiguous);
try testing.expectEqualStrings("/system/drivers/virtio-gpu", virtio.driver);
// A plain VGA adapter (display / VGA) still binds the generic display driver.
const vga = matchDriver(rules[0..parsed.count], .{
.bus = .pci, .base = 0x03, .subclass = 0x00, .prog_if = 0x00,
.vendor = 0x1234, .device = 0x1111,
}).?;
try testing.expectEqualStrings("/system/drivers/display", vga.driver);
}
test "no matching row leaves the device unbound" {
const text = "pci, 0C, 03, 30, *, *, *, *, /system/drivers/usb-xhci-bus\n";
var rules: [8]Rule = undefined;
const parsed = parse(text, &rules);
try testing.expectEqual(@as(usize, 1), parsed.count);
// An AHCI controller (mass storage / SATA / AHCI) has no row — unbound.
const unmatched = matchDriver(rules[0..parsed.count], .{
.bus = .pci, .base = 0x01, .subclass = 0x06, .prog_if = 0x01,
});
try testing.expect(unmatched == null);
}
test "acpi rows match on hid" {
const text =
\\acpi, *, *, *, *, *, *, PNP0303, /system/drivers/ps2-bus
\\acpi, *, *, *, *, *, *, PNP0F13, /system/drivers/ps2-bus
;
var rules: [8]Rule = undefined;
const parsed = parse(text, &rules);
try testing.expectEqual(@as(usize, 2), parsed.count);
const keyboard = matchDriver(rules[0..parsed.count], .{ .bus = .acpi, .hid = "PNP0303" }).?;
try testing.expectEqualStrings("/system/drivers/ps2-bus", keyboard.driver);
const nothing = matchDriver(rules[0..parsed.count], .{ .bus = .acpi, .hid = "PNP0A03" });
try testing.expect(nothing == null);
}
test "equally specific rules flag ambiguity" {
const text =
\\pci, 03, 00, 00, *, *, *, *, /system/drivers/display-a
\\pci, 03, 00, 00, *, *, *, *, /system/drivers/display-b
;
var rules: [8]Rule = undefined;
const parsed = parse(text, &rules);
const hit = matchDriver(rules[0..parsed.count], .{
.bus = .pci, .base = 0x03, .subclass = 0x00, .prog_if = 0x00,
}).?;
try testing.expect(hit.ambiguous);
try testing.expectEqualStrings("/system/drivers/display-a", hit.driver); // first wins
}
test "comments, blanks, and malformed lines" {
const text =
\\# a header comment
\\
\\pci, 0C, 03, 30, *, *, *, *, /system/drivers/usb-xhci-bus # trailing comment
\\pci, ZZ, 03, 30, *, *, *, *, /system/drivers/broken
\\pci, 03, 00, 00, *, *, *, *,
\\bogus-bus, *, *, *, *, *, *, *, /system/drivers/x
;
var rules: [8]Rule = undefined;
const parsed = parse(text, &rules);
try testing.expectEqual(@as(usize, 1), parsed.count); // only the xhci row is valid
try testing.expectEqual(@as(usize, 3), parsed.malformed); // bad hex, empty driver, bad bus
try testing.expectEqualStrings("/system/drivers/usb-xhci-bus", rules[0].driver);
try testing.expect(rules[0].hid == null); // trailing comment stripped, hid still wildcard
}
@@ -70,6 +70,104 @@ pub const Class = enum(u8) {
_,
};
/// A human-readable name for a device/interface class code, for logs. Unknown
/// codes fall through to "class 0xNN".
pub fn className(class: u8) []const u8 {
return switch (@as(Class, @enumFromInt(class))) {
.per_interface => "per-interface",
.audio => "Audio",
.communications => "Communications",
.hid => "HID",
.physical => "Physical",
.image => "Image",
.printer => "Printer",
.mass_storage => "Mass Storage",
.hub => "Hub",
.cdc_data => "CDC Data",
.smart_card => "Smart Card",
.content_security => "Content Security",
.video => "Video",
.personal_healthcare => "Personal Healthcare",
.audio_video => "Audio/Video",
.billboard => "Billboard",
.type_c_bridge => "Type-C Bridge",
.bulk_display => "Bulk Display",
.mctp => "MCTP",
.i3c => "I3C",
.diagnostic => "Diagnostic",
.wireless_controller => "Wireless Controller",
.miscellaneous => "Miscellaneous",
.application_specific => "Application-specific",
.vendor_specific => "Vendor-specific",
_ => "Unknown",
};
}
/// The USB speed class (as xHCI reports it in PORTSC/slot contexts) named.
pub fn speedName(speed: u32) []const u8 {
return switch (speed) {
1 => "Full-speed",
2 => "Low-speed",
3 => "High-speed",
4 => "SuperSpeed",
5 => "SuperSpeedPlus",
else => "unknown-speed",
};
}
/// A USB3 Port Link State (xHCI PORTSC PLS field) named.
pub fn linkStateName(pls: u32) []const u8 {
return switch (pls) {
0 => "U0",
1 => "U1",
2 => "U2",
3 => "U3-suspended",
4 => "Disabled",
5 => "RxDetect",
6 => "Inactive",
7 => "Polling",
8 => "Recovery",
9 => "HotReset",
10 => "Compliance",
11 => "Test",
15 => "Resume",
else => "reserved",
};
}
/// The most useful readable name for an interface's (class, subclass, protocol)
/// triple, decoding the well-known combinations recognizable in a log — e.g.
/// "HID boot keyboard", "Mass Storage SCSI Bulk-Only", "Bluetooth". Falls back
/// to the class name (and then "Unknown") for codes without a spelled-out combo.
pub fn interfaceName(class: u8, subclass: u8, protocol: u8) []const u8 {
return switch (@as(Class, @enumFromInt(class))) {
.hid => if (subclass == @intFromEnum(hid.SubClass.boot)) switch (@as(hid.Protocol, @enumFromInt(protocol))) {
.keyboard => "HID boot keyboard",
.mouse => "HID boot mouse",
else => "HID boot device",
} else "HID",
.mass_storage => switch (@as(mass_storage.Protocol, @enumFromInt(protocol))) {
.bulk_only => "Mass Storage (Bulk-Only)",
.uas => "Mass Storage (UAS)",
else => "Mass Storage",
},
.hub => switch (@as(hub.Protocol, @enumFromInt(protocol))) {
.super_speed => "Hub (SuperSpeed)",
.hi_speed_multi_tt => "Hub (Hi-Speed multi-TT)",
.hi_speed_single_tt => "Hub (Hi-Speed single-TT)",
else => "Hub",
},
.wireless_controller => if (subclass == @intFromEnum(wireless_controller.SubClass.radio_frequency))
wireless_controller.protocolName(protocol)
else
"Wireless Controller",
.communications => communications.subclassName(subclass),
.application_specific => application_specific.subclassName(subclass),
.miscellaneous => "Miscellaneous",
else => className(class),
};
}
// Subclass and protocol codes qualified by Class.hub. Hubs have no subclass codes; the
// protocol distinguishes the hub's transaction-translator arrangement.
pub const hub = struct {
@@ -84,6 +182,16 @@ pub const hub = struct {
super_speed = 0x03,
_,
};
pub fn protocolName(protocol: u8) []const u8 {
return switch (@as(Protocol, @enumFromInt(protocol))) {
.full_speed => "full-speed",
.hi_speed_single_tt => "Hi-Speed single-TT",
.hi_speed_multi_tt => "Hi-Speed multi-TT",
.super_speed => "SuperSpeed",
_ => "unknown",
};
}
};
// Subclass and protocol codes qualified by Class.hid.
@@ -104,6 +212,23 @@ pub const hid = struct {
mouse = 0x02,
_,
};
pub fn subclassName(subclass: u8) []const u8 {
return switch (@as(SubClass, @enumFromInt(subclass))) {
.none => "none",
.boot => "boot",
_ => "unknown",
};
}
pub fn protocolName(protocol: u8) []const u8 {
return switch (@as(Protocol, @enumFromInt(protocol))) {
.none => "none",
.keyboard => "keyboard",
.mouse => "mouse",
_ => "unknown",
};
}
};
// Subclass and protocol codes qualified by Class.mass_storage. The subclass identifies the
@@ -147,6 +272,33 @@ pub const mass_storage = struct {
vendor_specific = 0xFF,
_,
};
pub fn subclassName(subclass: u8) []const u8 {
return switch (@as(SubClass, @enumFromInt(subclass))) {
.not_reported => "SCSI (not reported)",
.rbc => "RBC",
.atapi => "ATAPI",
.qic_157 => "QIC-157",
.ufi => "UFI",
.sff_8070i => "SFF-8070i",
.scsi => "SCSI",
.lsd_fs => "LSD FS",
.ieee_1667 => "IEEE 1667",
.vendor_specific => "vendor-specific",
_ => "unknown",
};
}
pub fn protocolName(protocol: u8) []const u8 {
return switch (@as(Protocol, @enumFromInt(protocol))) {
.cbi_completion_interrupt => "CBI",
.cbi => "CBI (no completion IRQ)",
.bulk_only => "Bulk-Only",
.uas => "UAS",
.vendor_specific => "vendor-specific",
_ => "unknown",
};
}
};
// Subclass and protocol codes qualified by Class.communications (CDC). The protocol codes
@@ -182,6 +334,25 @@ pub const communications = struct {
network_control = 0x0D,
_,
};
pub fn subclassName(subclass: u8) []const u8 {
return switch (@as(SubClass, @enumFromInt(subclass))) {
.direct_line => "Direct Line",
.abstract_control => "Abstract Control (modem/serial)",
.telephone => "Telephone",
.multi_channel => "Multi-Channel",
.capi => "CAPI",
.ethernet => "Ethernet",
.atm => "ATM",
.wireless_handset => "Wireless Handset",
.device_management => "Device Management",
.mobile_direct_line => "Mobile Direct Line",
.obex => "OBEX",
.ethernet_emulation => "Ethernet Emulation",
.network_control => "Network Control",
_ => "unknown",
};
}
};
// Subclass and protocol codes qualified by Class.wireless_controller.
@@ -204,6 +375,23 @@ pub const wireless_controller = struct {
bluetooth_amp = 0x04,
_,
};
pub fn subclassName(subclass: u8) []const u8 {
return switch (@as(SubClass, @enumFromInt(subclass))) {
.radio_frequency => "RF",
_ => "unknown",
};
}
pub fn protocolName(protocol: u8) []const u8 {
return switch (@as(Protocol, @enumFromInt(protocol))) {
.bluetooth => "Bluetooth",
.ultra_wideband => "Ultra-Wideband",
.remote_ndis => "Remote NDIS",
.bluetooth_amp => "Bluetooth AMP",
_ => "unknown",
};
}
};
// Subclass and protocol codes qualified by Class.miscellaneous.
@@ -221,6 +409,20 @@ pub const miscellaneous = struct {
interface_association = 0x01,
_,
};
pub fn subclassName(subclass: u8) []const u8 {
return switch (@as(SubClass, @enumFromInt(subclass))) {
.common => "common",
_ => "unknown",
};
}
pub fn protocolName(protocol: u8) []const u8 {
return switch (@as(Protocol, @enumFromInt(protocol))) {
.interface_association => "Interface Association",
_ => "unknown",
};
}
};
// Subclass and protocol codes qualified by Class.application_specific.
@@ -234,6 +436,15 @@ pub const application_specific = struct {
test_and_measurement = 0x03,
_,
};
pub fn subclassName(subclass: u8) []const u8 {
return switch (@as(SubClass, @enumFromInt(subclass))) {
.firmware_upgrade => "Device Firmware Upgrade",
.irda_bridge => "IrDA Bridge",
.test_and_measurement => "Test & Measurement",
_ => "unknown",
};
}
};
/// Pack a (class, subclass, protocol) triple into one 0xCCSSPP value — the
@@ -280,6 +491,30 @@ test "class codes match the USB-IF assignments" {
_ = application_specific.SubClass.firmware_upgrade;
}
test "readable names decode the well-known triples" {
const std = @import("std");
const eql = std.testing.expectEqualStrings;
try eql("Hub", className(0x09));
try eql("Unknown", className(0x42));
// interfaceName decodes the combos we log.
try eql("HID boot keyboard", interfaceName(0x03, 0x01, 0x01));
try eql("HID boot mouse", interfaceName(0x03, 0x01, 0x02));
try eql("Mass Storage (Bulk-Only)", interfaceName(0x08, 0x06, 0x50));
try eql("Hub (SuperSpeed)", interfaceName(0x09, 0x00, 0x03));
try eql("Bluetooth", interfaceName(0xE0, 0x01, 0x01));
// The per-enum name functions.
try eql("Bulk-Only", mass_storage.protocolName(0x50));
try eql("SCSI", mass_storage.subclassName(0x06));
try eql("Bluetooth", wireless_controller.protocolName(0x01));
try eql("SuperSpeed", hub.protocolName(0x03));
try eql("keyboard", hid.protocolName(0x01));
try eql("SuperSpeed", speedName(4));
try eql("Polling", linkStateName(7));
}
test "packTriple / unpackTriple round-trip the identity a bus driver reports" {
const std = @import("std");
const expectEqual = std.testing.expectEqual;
@@ -5,7 +5,7 @@
//! service and `device.zig` over the raw device calls.
//!
//! A class driver, spawned with its interface's assigned device id as argv[1]:
//! if (!usb.helloManager(id)) return; // meet the spawn deadline
//! if (device_manager.hello(.device, id) == null) return; // meet the spawn deadline
//! var device = usb.open(id) orelse return; // open + get its endpoints
//! _ = device.controlOut(usb_abi.setProtocol(...));// class requests, descriptors
//! _ = device.subscribeInterrupt(address, length); // reports arrive asynchronously
@@ -16,14 +16,18 @@
//! the service harness drops buffered-message payloads — see service.zig).
const std = @import("std");
const ipc = @import("ipc.zig");
const system = @import("system.zig");
const protocol = @import("usb-transfer-protocol");
const device_manager = @import("device-manager-protocol");
const ipc = @import("ipc");
const time = @import("time");
const usb_transfer_protocol = @import("usb-transfer-protocol");
pub const Endpoint = protocol.Endpoint;
pub const InterruptReport = protocol.InterruptReport;
pub const max_report_data = protocol.max_report_data;
/// The USB chapter-9 wire ABI and the class taxonomy, re-exported so a class driver reaches
/// the whole USB domain through its one `usb` import (`usb.abi.getDescriptor`, `usb.ids.Class`).
pub const abi = @import("usb-abi");
pub const ids = @import("usb-ids");
pub const Endpoint = usb_transfer_protocol.Endpoint;
pub const InterruptReport = usb_transfer_protocol.InterruptReport;
pub const max_report_data = usb_transfer_protocol.max_report_data;
// Endpoint transfer types (EndpointDescriptor attributes), for `findEndpoint`.
pub const transfer_type_bulk: u8 = 2;
@@ -41,7 +45,7 @@ pub const Device = struct {
protocol_code: u8,
interface_number: u8,
endpoint_count: usize = 0,
endpoints: [protocol.max_reported_endpoints]Endpoint = undefined,
endpoints: [usb_transfer_protocol.max_reported_endpoints]Endpoint = undefined,
/// The interface's first endpoint of the given transfer type and direction
/// (`transfer_type_bulk` / `transfer_type_interrupt`), or null.
@@ -53,17 +57,17 @@ pub const Device = struct {
}
fn controlTransfer(self: *Device, setup: [8]u8, direction_in: bool, data: []u8) ?usize {
var request = protocol.ControlRequest{
var request = usb_transfer_protocol.ControlRequest{
.device_token = self.token,
.setup = setup,
.direction_in = @intFromBool(direction_in),
.data_length = @intCast(data.len),
};
if (!direction_in and data.len > 0) @memcpy(request.data[0..data.len], data);
var reply: [@sizeOf(protocol.ControlReply)]u8 = undefined;
var reply: [@sizeOf(usb_transfer_protocol.ControlReply)]u8 = undefined;
const length = ipc.call(self.bus, std.mem.asBytes(&request), &reply) catch return null;
if (length < @sizeOf(protocol.ControlReply)) return null;
const control_reply = std.mem.bytesToValue(protocol.ControlReply, reply[0..@sizeOf(protocol.ControlReply)]);
if (length < @sizeOf(usb_transfer_protocol.ControlReply)) return null;
const control_reply = std.mem.bytesToValue(usb_transfer_protocol.ControlReply, reply[0..@sizeOf(usb_transfer_protocol.ControlReply)]);
if (control_reply.status != 0) return null;
const actual = @min(control_reply.actual_length, data.len);
if (direction_in and actual > 0) @memcpy(data[0..actual], control_reply.data[0..actual]);
@@ -84,30 +88,43 @@ pub const Device = struct {
/// Begin periodic IN polling of an interrupt endpoint; reports flow back to
/// `self.endpoint` as asynchronous `InterruptReport` messages.
pub fn subscribeInterrupt(self: *Device, endpoint_address: u8, max_length: u16) bool {
var request = protocol.InterruptSubscribeRequest{
var request = usb_transfer_protocol.InterruptSubscribeRequest{
.device_token = self.token,
.endpoint_address = endpoint_address,
.max_length = max_length,
};
var reply: [@sizeOf(protocol.InterruptSubscribeReply)]u8 = undefined;
var reply: [@sizeOf(usb_transfer_protocol.InterruptSubscribeReply)]u8 = undefined;
const length = ipc.call(self.bus, std.mem.asBytes(&request), &reply) catch return false;
if (length < @sizeOf(protocol.InterruptSubscribeReply)) return false;
return std.mem.bytesToValue(protocol.InterruptSubscribeReply, reply[0..@sizeOf(protocol.InterruptSubscribeReply)]).status == 0;
if (length < @sizeOf(usb_transfer_protocol.InterruptSubscribeReply)) return false;
return std.mem.bytesToValue(usb_transfer_protocol.InterruptSubscribeReply, reply[0..@sizeOf(usb_transfer_protocol.InterruptSubscribeReply)]).status == 0;
}
/// Hand the controller a DMA-region capability (`handle` — from a `shareable`
/// dma_alloc, or forwarded from another process) so it binds that buffer into its
/// IOMMU domain. Must be called for every buffer whose physical address this device
/// will name in a `bulk` transfer, before the transfer. Harmless (and a no-op
/// success) when no IOMMU is enforcing. Returns false on failure.
pub fn attachDma(self: *Device, handle: ipc.Handle) bool {
var request = usb_transfer_protocol.DmaAttachRequest{ .device_token = self.token };
var reply: [@sizeOf(usb_transfer_protocol.DmaAttachReply)]u8 = undefined;
const result = ipc.callCap(self.bus, std.mem.asBytes(&request), &reply, handle) catch return false;
if (result.len < @sizeOf(usb_transfer_protocol.DmaAttachReply)) return false;
return std.mem.bytesToValue(usb_transfer_protocol.DmaAttachReply, reply[0..@sizeOf(usb_transfer_protocol.DmaAttachReply)]).status == 0;
}
/// One bulk transfer (IN or OUT per `endpoint_address`'s direction bit) to or
/// from the caller's own DMA buffer at `physical`. Returns the bytes moved.
pub fn bulk(self: *Device, endpoint_address: u8, physical: u64, length: u32) ?u32 {
var request = protocol.BulkRequest{
var request = usb_transfer_protocol.BulkRequest{
.device_token = self.token,
.physical_address = physical,
.length = length,
.endpoint_address = endpoint_address,
};
var reply: [@sizeOf(protocol.BulkReply)]u8 = undefined;
var reply: [@sizeOf(usb_transfer_protocol.BulkReply)]u8 = undefined;
const replied = ipc.call(self.bus, std.mem.asBytes(&request), &reply) catch return null;
if (replied < @sizeOf(protocol.BulkReply)) return null;
const bulk_reply = std.mem.bytesToValue(protocol.BulkReply, reply[0..@sizeOf(protocol.BulkReply)]);
if (replied < @sizeOf(usb_transfer_protocol.BulkReply)) return null;
const bulk_reply = std.mem.bytesToValue(usb_transfer_protocol.BulkReply, reply[0..@sizeOf(usb_transfer_protocol.BulkReply)]);
if (bulk_reply.status != 0) return null;
return bulk_reply.actual_length;
}
@@ -120,15 +137,15 @@ pub fn open(device_id: u64) ?Device {
var attempts: usize = 0;
const bus = while (attempts < 100) : (attempts += 1) {
if (ipc.lookup(.usb_bus)) |handle| break handle;
system.sleep(20);
time.sleepMillis(20);
} else return null;
const endpoint = ipc.createIpcEndpoint() orelse return null;
var request = protocol.OpenRequest{ .device_id = device_id };
var reply: [@sizeOf(protocol.OpenReply)]u8 = undefined;
var request = usb_transfer_protocol.OpenRequest{ .device_id = device_id };
var reply: [@sizeOf(usb_transfer_protocol.OpenReply)]u8 = undefined;
const result = ipc.callCap(bus, std.mem.asBytes(&request), &reply, endpoint) catch return null;
if (result.len < @sizeOf(protocol.OpenReply)) return null;
const open_reply = std.mem.bytesToValue(protocol.OpenReply, reply[0..@sizeOf(protocol.OpenReply)]);
if (result.len < @sizeOf(usb_transfer_protocol.OpenReply)) return null;
const open_reply = std.mem.bytesToValue(usb_transfer_protocol.OpenReply, reply[0..@sizeOf(usb_transfer_protocol.OpenReply)]);
if (open_reply.status != 0) return null;
var device = Device{
@@ -139,24 +156,8 @@ pub fn open(device_id: u64) ?Device {
.subclass = open_reply.interface_subclass,
.protocol_code = open_reply.interface_protocol,
.interface_number = open_reply.interface_number,
.endpoint_count = @min(open_reply.endpoint_count, protocol.max_reported_endpoints),
.endpoint_count = @min(open_reply.endpoint_count, usb_transfer_protocol.max_reported_endpoints),
};
for (0..device.endpoint_count) |index| device.endpoints[index] = open_reply.endpoints[index];
return device;
}
/// Hello the device manager as a class driver (Role.device) so a supervised
/// spawn meets its hello deadline. Retries while the manager comes up.
pub fn helloManager(device_id: u64) bool {
var attempts: usize = 0;
const manager = while (attempts < 100) : (attempts += 1) {
if (ipc.lookup(.device_manager)) |handle| break handle;
system.sleep(20);
} else return false;
const hello = device_manager.Hello{ .role = @intFromEnum(device_manager.Role.device), .device_id = device_id };
var reply: [device_manager.message_maximum]u8 = undefined;
const length = ipc.call(manager, std.mem.asBytes(&hello), &reply) catch return false;
if (length < device_manager.reply_size) return false;
return std.mem.bytesToValue(device_manager.HelloReply, reply[0..device_manager.reply_size]).status == 0;
}
+104
View File
@@ -0,0 +1,104 @@
//! The "kernel" library domain (library/kernel): the userspace private-ABI
//! library (kernel32-style), split by concern into directly-importable
//! modules. The graph is a DAG: memory depends on thread (heap needs
//! Thread.Mutex), and thread does its own raw mmap so there is no cycle.
//!
//! This package also exports `abi` — the kernel <-> user contract (SystemCall
//! numbers, mmap prot flags, page_size). Its source lives with the kernel in
//! system/abi.zig, outside this directory, but userspace's one view of it is
//! exported here so every consumer names the same module instance. Reaching
//! outside the package root means this package is valid only as an in-repo
//! path dependency (never fetchable by hash) — fine, since path dependencies
//! are the only way danos packages are consumed.
//!
//! The root shim (root.zig) and the user link script (user.ld) are plain
//! files, not modules; build-support reaches them through this package's
//! directory (Dependency.path).
const std = @import("std");
pub fn build(b: *std.Build) void {
const protocol = b.dependency("protocol", .{});
const abi = b.addModule("abi", .{
.root_source_file = b.path("../../system/abi.zig"),
});
const system_call = b.addModule("system-call", .{
.root_source_file = b.path("system-call.zig"),
.imports = &.{.{ .name = "abi", .module = abi }},
});
const ipc = b.addModule("ipc", .{
.root_source_file = b.path("ipc.zig"),
.imports = &.{ .{ .name = "abi", .module = abi }, .{ .name = "system-call", .module = system_call } },
});
const time = b.addModule("time", .{
.root_source_file = b.path("time.zig"),
.imports = &.{.{ .name = "system-call", .module = system_call }},
});
const thread = b.addModule("thread", .{
.root_source_file = b.path("thread.zig"),
.imports = &.{ .{ .name = "abi", .module = abi }, .{ .name = "system-call", .module = system_call } },
});
const logging = b.addModule("logging", .{
.root_source_file = b.path("logging.zig"),
.imports = &.{ .{ .name = "abi", .module = abi }, .{ .name = "system-call", .module = system_call } },
});
const process = b.addModule("process", .{
.root_source_file = b.path("process.zig"),
.imports = &.{
.{ .name = "abi", .module = abi },
.{ .name = "system-call", .module = system_call },
.{ .name = "ipc", .module = ipc },
.{ .name = "time", .module = time },
},
});
_ = b.addModule("file-system", .{
.root_source_file = b.path("file-system.zig"),
.imports = &.{
.{ .name = "abi", .module = abi },
.{ .name = "system-call", .module = system_call },
.{ .name = "ipc", .module = ipc },
.{ .name = "vfs-protocol", .module = protocol.module("vfs-protocol") },
},
});
_ = b.addModule("memory", .{
.root_source_file = b.path("memory/memory.zig"),
.imports = &.{
.{ .name = "abi", .module = abi },
.{ .name = "system-call", .module = system_call },
.{ .name = "ipc", .module = ipc },
.{ .name = "thread", .module = thread },
},
});
_ = b.addModule("service", .{
.root_source_file = b.path("service.zig"),
.imports = &.{
.{ .name = "abi", .module = abi },
.{ .name = "ipc", .module = ipc },
.{ .name = "process", .module = process },
},
});
_ = b.addModule("start", .{
.root_source_file = b.path("start.zig"),
.imports = &.{ .{ .name = "process", .module = process }, .{ .name = "logging", .module = logging } },
});
// Standalone `zig build test` for this domain alone; the root build keeps
// its aggregate test step. time and thread pull in the syscall wrappers,
// which need the `abi` module; their danos seams fall back to host
// primitives off the danos target, so they run with real host threads.
const test_step = b.step("test", "Run the kernel library unit tests");
for ([_][]const u8{
"time.zig", // Instant/Duration arithmetic
"thread.zig", // Mutex/Condition/RwLock/WaitGroup state machines
}) |root| {
const kernel_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path(root),
.target = b.resolveTargetQuery(.{}),
.imports = &.{.{ .name = "abi", .module = abi }},
}),
});
test_step.dependOn(&b.addRunArtifact(kernel_tests).step);
}
}
+11
View File
@@ -0,0 +1,11 @@
.{
.name = .kernel,
.version = "0.0.0",
.fingerprint = 0x5dd29aab36503453, // Changing this has security and trust implications.
.minimum_zig_version = "0.16.0",
.dependencies = .{
// file-system speaks the VFS wire protocol.
.protocol = .{ .path = "../protocol" },
},
.paths = .{""},
}
+427
View File
@@ -0,0 +1,427 @@
//! runtime.fs — the danos-native file API. A program opens, reads, writes, and
//! lists files through the kernel VFS root (resolve + redirect), each call
//! marshalling a vfs-protocol request over IPC. This is the danos-native layer
//! danos programs use directly; it is also where the file operations that later
//! become `std.os.danos` are staged (see docs/zig-self-hosting.md). It replaces
//! the old POSIX `unistd` shim — a compatibility spelling danos does not need yet.
//!
//! Handles are *values*, not entries in a global descriptor table: a `File` /
//! `Directory` owns its VFS node id and (for files) a byte offset. So there is no
//! per-process fd limit and no shared table to synchronise — the danos-native
//! shape, unlike the POSIX fd model the old shim emulated.
const std = @import("std");
const abi = @import("abi");
const sc = @import("system-call");
const ipc = @import("ipc");
const vfs_protocol = @import("vfs-protocol");
/// The kind of a filesystem node — re-exported so a caller need not import the
/// wire protocol.
pub const Kind = vfs_protocol.NodeKind;
/// A node's metadata (the answer to a status request).
pub const Attributes = struct {
size: u64,
kind: Kind,
/// Modification time — Unix epoch seconds, UTC. 0 if the filesystem has none.
mtime: u64 = 0,
};
// Map a wire `NodeKind` value to the enum, defaulting anything unrecognised to
// `.regular` (the server is trusted, but a value outside the enum would be
// illegal to `@enumFromInt` directly).
fn kindFromWire(value: u32) Kind {
return switch (value) {
@intFromEnum(Kind.directory) => .directory,
@intFromEnum(Kind.character_device) => .character_device,
@intFromEnum(Kind.block_device) => .block_device,
@intFromEnum(Kind.symbolic_link) => .symbolic_link,
@intFromEnum(Kind.fifo) => .fifo,
@intFromEnum(Kind.socket) => .socket,
else => .regular,
};
}
/// How to open a path.
pub const OpenOptions = struct {
/// Create the file if it does not exist.
create: bool = false,
/// Open a directory node (for listing) rather than a file.
directory: bool = false,
/// Truncate an existing file to zero length on open (O_TRUNC) — replace its
/// contents rather than overwriting in place.
truncate: bool = false,
fn wireFlags(self: OpenOptions) u32 {
var f: u32 = 0;
if (self.create) f |= vfs_protocol.create;
if (self.directory) f |= vfs_protocol.directory;
if (self.truncate) f |= vfs_protocol.truncate;
return f;
}
};
// The route to a path: the kernel resolves (fs_resolve) and either serves the
// node itself (the initrd at /system — a permanent token) or redirects us to
// the owning filesystem backend's endpoint, to which we speak the vfs-protocol
// rendezvous directly with the rewritten mount-relative path.
const Route = union(enum) {
kernel: u64,
backend: struct { handle: ipc.Handle, path: [224]u8, path_len: usize },
fn backendPath(self: *const Route) []const u8 {
return self.backend.path[0..self.backend.path_len];
}
};
fn resolve(path: []const u8, flags: usize) ?Route {
var out: [224]u8 = undefined;
const route = fsResolve(path, flags, &out) orelse return null;
switch (route) {
.kernel => |token| return .{ .kernel = token },
.backend => |b| {
var r: Route = .{ .backend = .{ .handle = b.handle, .path = undefined, .path_len = b.path_len } };
@memcpy(r.backend.path[0..b.path_len], out[0..b.path_len]);
return r;
},
}
}
const Result = struct { reply: vfs_protocol.Reply, payload: []u8 };
// One request/reply round trip: [Request header][send payload] -> backend ->
// [Reply header][receive payload]. The receive payload lands in `out`.
fn transact(h: ipc.Handle, request: vfs_protocol.Request, send: []const u8, out: []u8) ?Result {
var message: [vfs_protocol.message_maximum]u8 = undefined;
@memcpy(message[0..vfs_protocol.request_size], std.mem.asBytes(&request));
const slen = @min(send.len, vfs_protocol.maximum_payload);
@memcpy(message[vfs_protocol.request_size..][0..slen], send[0..slen]);
var rbuf: [vfs_protocol.message_maximum]u8 = undefined;
const n = ipc.call(h, message[0 .. vfs_protocol.request_size + slen], &rbuf) catch return null;
if (n < vfs_protocol.reply_size) return null;
const reply = std.mem.bytesToValue(vfs_protocol.Reply, rbuf[0..vfs_protocol.reply_size]);
const rpl = @min(n - vfs_protocol.reply_size, out.len);
@memcpy(out[0..rpl], rbuf[vfs_protocol.reply_size..][0..rpl]);
return .{ .reply = reply, .payload = out[0..rpl] };
}
/// An open file: a VFS node plus a byte cursor. Read and write advance the cursor.
pub const File = struct {
node: u64,
offset: u64 = 0,
/// The owning backend's endpoint, or null for a kernel-served node (the
/// read-only /system tree), whose `node` is a permanent fs_node token.
backend: ?ipc.Handle = null,
/// Read up to `buffer.len` bytes at the current offset; returns the count, or
/// null on error.
pub fn read(self: *File, buffer: []u8) ?usize {
const h = self.backend orelse {
const n = fsNodeRead(self.node, self.offset, buffer) orelse return null;
self.offset += n;
return n;
};
const want: u32 = @intCast(@min(buffer.len, vfs_protocol.maximum_payload));
const request = vfs_protocol.Request{ .operation = .read, .node = self.node, .offset = self.offset, .len = want, .flags = 0 };
const r = transact(h, request, &.{}, buffer) orelse return null;
if (r.reply.status != 0) return null;
self.offset += r.reply.len;
return r.reply.len;
}
/// Write `data` at the current offset; returns the count written. A single
/// call is capped at the VFS payload size, so the return may be short — use
/// `writeAll` to write the whole slice. Null on error (kernel-served nodes
/// are read-only).
pub fn write(self: *File, data: []const u8) ?usize {
const h = self.backend orelse return null;
const want: u32 = @intCast(@min(data.len, vfs_protocol.maximum_payload));
const request = vfs_protocol.Request{ .operation = .write, .node = self.node, .offset = self.offset, .len = want, .flags = 0 };
const r = transact(h, request, data[0..want], &.{}) orelse return null;
if (r.reply.status != 0) return null;
self.offset += r.reply.len;
return r.reply.len;
}
/// Write all of `data`, looping past the per-call payload cap. Returns the
/// total written, or null if a write failed before any progress.
pub fn writeAll(self: *File, data: []const u8) ?usize {
var written: usize = 0;
while (written < data.len) {
const n = self.write(data[written..]) orelse return if (written == 0) null else written;
if (n == 0) return written; // no forward progress; stop rather than spin
written += n;
}
return written;
}
/// Move the read/write cursor to an absolute byte position.
pub fn seekTo(self: *File, position: u64) void {
self.offset = position;
}
/// This file's metadata.
pub fn attributes(self: *File) ?Attributes {
const h = self.backend orelse {
const a = fsNodeStatus(self.node) orelse return null;
return .{ .size = a.size, .kind = if (a.kind == file_kind_directory) .directory else .regular, .mtime = a.mtime };
};
const request = vfs_protocol.Request{ .operation = .status, .node = self.node, .offset = 0, .len = 0, .flags = 0 };
var buffer: [@sizeOf(vfs_protocol.FileStatus)]u8 = undefined;
const r = transact(h, request, &.{}, &buffer) orelse return null;
if (r.reply.status != 0 or r.payload.len < @sizeOf(vfs_protocol.FileStatus)) return null;
const status = std.mem.bytesToValue(vfs_protocol.FileStatus, buffer[0..@sizeOf(vfs_protocol.FileStatus)]);
return .{ .size = status.size, .kind = kindFromWire(status.kind), .mtime = status.mtime };
}
/// Release the backend's open handle for this file. Kernel-served node
/// tokens are permanent — nothing to release.
pub fn close(self: *File) void {
const h = self.backend orelse return;
const request = vfs_protocol.Request{ .operation = .close, .node = self.node, .offset = 0, .len = 0, .flags = 0 };
_ = transact(h, request, &.{}, &.{});
}
};
/// Open (or create, with `.create`) `path`. Returns the open file, or null.
pub fn open(path: []const u8, options: OpenOptions) ?File {
const route = resolve(path, options.wireFlags()) orelse return null;
switch (route) {
.kernel => |token| return .{ .node = token, .backend = null },
.backend => |b| {
const relative = route.backendPath();
const request = vfs_protocol.Request{ .operation = .open, .node = 0, .offset = 0, .len = @intCast(relative.len), .flags = options.wireFlags() };
const r = transact(b.handle, request, relative, &.{}) orelse return null;
if (r.reply.status != 0) return null;
return .{ .node = r.reply.node, .backend = b.handle };
},
}
}
/// A path's metadata without keeping it open (open -> status -> close).
pub fn attributes(path: []const u8) ?Attributes {
var file = open(path, .{}) orelse return null;
defer file.close();
return file.attributes();
}
/// Whether `path` resolves — handy as a readiness check (e.g. waiting for a mount
/// to come up before writing to it).
pub fn exists(path: []const u8) bool {
return attributes(path) != null;
}
/// One entry returned by `Directory.next`.
pub const Entry = struct {
kind: Kind = .regular,
size: u64 = 0,
name_buffer: [64]u8 = undefined,
name_len: usize = 0,
pub fn name(self: *const Entry) []const u8 {
return self.name_buffer[0..self.name_len];
}
};
/// An open directory being listed, cursor-advanced by `next`.
pub const Directory = struct {
node: u64,
cursor: u64 = 0,
backend: ?ipc.Handle = null,
/// Fill `entry` with the next directory entry; false at end of directory or
/// on error.
pub fn next(self: *Directory, entry: *Entry) bool {
const h = self.backend orelse {
var buffer: [@sizeOf(DirectoryEntryHeader) + 64]u8 = undefined;
const n = fsNodeReaddir(self.node, self.cursor, &buffer) orelse return false;
if (n < @sizeOf(DirectoryEntryHeader)) return false; // end
const header = std.mem.bytesToValue(DirectoryEntryHeader, buffer[0..@sizeOf(DirectoryEntryHeader)]);
entry.kind = if (header.kind == file_kind_directory) .directory else .regular;
entry.size = header.size;
const nlen = @min(@as(usize, header.name_len), entry.name_buffer.len);
@memcpy(entry.name_buffer[0..nlen], buffer[@sizeOf(DirectoryEntryHeader)..][0..nlen]);
entry.name_len = nlen;
self.cursor += 1;
return true;
};
const request = vfs_protocol.Request{ .operation = .readdir, .node = self.node, .offset = self.cursor, .len = 0, .flags = 0 };
var buffer: [vfs_protocol.message_maximum]u8 = undefined;
const r = transact(h, request, &.{}, &buffer) orelse return false;
if (r.reply.status != 0 or r.reply.len == 0) return false; // error or EOF
if (r.payload.len < vfs_protocol.directory_entry_size) return false;
const header = std.mem.bytesToValue(vfs_protocol.DirectoryEntry, r.payload[0..vfs_protocol.directory_entry_size]);
entry.kind = kindFromWire(header.kind);
entry.size = header.size;
const source = r.payload[vfs_protocol.directory_entry_size..];
const nlen = @min(@min(@as(usize, header.name_len), source.len), entry.name_buffer.len);
@memcpy(entry.name_buffer[0..nlen], source[0..nlen]);
entry.name_len = nlen;
self.cursor += 1;
return true;
}
/// Release the backend's open handle for this directory.
pub fn close(self: *Directory) void {
var f = File{ .node = self.node, .backend = self.backend };
f.close();
}
};
/// Open `path` as a directory for listing. Returns null if it isn't one / on error.
pub fn openDirectory(path: []const u8) ?Directory {
const file = open(path, .{ .directory = true }) orelse return null;
return .{ .node = file.node, .backend = file.backend };
}
// A path-based request that returns only a status (mkdir, unlink). Kernel-served
// paths (the read-only /system) refuse mutation by construction: the resolve
// must land on a backend.
fn pathOperation(operation: vfs_protocol.Operation, path: []const u8) bool {
const route = resolve(path, 0) orelse return false;
if (route != .backend) return false;
const relative = route.backendPath();
const request = vfs_protocol.Request{ .operation = operation, .node = 0, .offset = 0, .len = @intCast(relative.len), .flags = 0 };
const r = transact(route.backend.handle, request, relative, &.{}) orelse return false;
return r.reply.status == 0;
}
/// Create a directory at `path` (its parent must already exist). Returns true on
/// success. Only works under a mounted filesystem that supports directories.
pub fn makeDirectory(path: []const u8) bool {
return pathOperation(.mkdir, path);
}
/// Create every missing directory along `path` (mkdir -p). Probes each prefix
/// with `exists` first — a FAT mkdir of an existing name is refused, and the
/// probe keeps the common "already there" case cheap. Returns true when the
/// whole path exists afterwards.
pub fn makePath(path: []const u8) bool {
var end: usize = 0;
while (end < path.len) {
end += 1;
while (end < path.len and path[end] != '/') end += 1;
const prefix = path[0..end];
if (prefix.len == 0 or (prefix.len == 1 and prefix[0] == '/')) continue;
// Best-effort per prefix: components at or above a mount point ("/mnt")
// are router names, not filesystem nodes — they neither exist as nodes
// nor accept mkdir, and that is fine. Only the final verdict counts.
if (!exists(prefix)) _ = makeDirectory(prefix);
}
return exists(path);
}
/// Remove the file at `path`. Returns true on success. Directories are refused
/// (a separate directory-removal would have to check emptiness).
pub fn remove(path: []const u8) bool {
return pathOperation(.unlink, path);
}
/// Rename `old_path` to `new_path`. Both must resolve to the SAME filesystem
/// backend (same-directory, 8.3-name rename only for now). Returns true on
/// success.
pub fn rename(old_path: []const u8, new_path: []const u8) bool {
const old_route = resolve(old_path, 0) orelse return false;
const new_route = resolve(new_path, 0) orelse return false;
if (old_route != .backend or new_route != .backend) return false;
if (old_route.backend.handle != new_route.backend.handle) return false; // cross-filesystem
const old_relative = old_route.backendPath();
const new_relative = new_route.backendPath();
const total = old_relative.len + 1 + new_relative.len;
if (total > vfs_protocol.maximum_payload) return false;
var payload: [vfs_protocol.maximum_payload]u8 = undefined;
@memcpy(payload[0..old_relative.len], old_relative);
payload[old_relative.len] = 0;
@memcpy(payload[old_relative.len + 1 ..][0..new_relative.len], new_relative);
const request = vfs_protocol.Request{ .operation = .rename, .node = 0, .offset = 0, .len = @intCast(total), .flags = 0 };
const r = transact(old_route.backend.handle, request, payload[0..total], &.{}) orelse return false;
return r.reply.status == 0;
}
/// Mount a filesystem backend (its server endpoint) at absolute path `target`;
/// the kernel VFS then routes everything under `target` to that backend.
/// Possession of the endpoint handle is the capability. Returns true on success.
pub fn mount(target: []const u8, backend: ipc.Handle) bool {
return fsMount(target, backend, "");
}
/// As `mount`, with a backend-side rewrite prefix: a path under `target` reaches
/// the backend as `rewrite` + the mount-relative tail. How one volume serves two
/// mounts ("/mnt/usb" from its root, "/var" from its /var subtree).
pub fn mountRewritten(target: []const u8, backend: ipc.Handle, rewrite: []const u8) bool {
return fsMount(target, backend, rewrite);
}
// --- raw filesystem syscalls, formerly in the system.zig dumping ground ---
pub const FileAttributes = abi.FileAttributes;
pub const DirectoryEntryHeader = abi.DirectoryEntryHeader;
pub const file_kind_regular = abi.file_kind_regular;
pub const file_kind_directory = abi.file_kind_directory;
/// Where fs_resolve routed a path: served by the kernel (a permanent node token for
/// `fs_node`) or by a user-space filesystem backend (an endpoint handle plus the rewritten
/// mount-relative path returned in the caller's buffer).
pub const FsRoute = union(enum) {
kernel: u64,
backend: struct { handle: usize, path_len: usize },
};
/// Route `path` through the kernel VFS. For a backend route the rewritten mount-relative
/// path lands in `out` (behind a kernel-written length prefix, already stripped here:
/// out[0..path_len] is the path).
pub fn fsResolve(path: []const u8, flags: usize, out: []u8) ?FsRoute {
var rax: usize = undefined;
var rdx: usize = flags; // in: flags (arg #3); out: node token / backend handle
asm volatile ("syscall"
: [rax] "={rax}" (rax),
[rdx] "+{rdx}" (rdx),
: [n] "{rax}" (@intFromEnum(abi.SystemCall.fs_resolve)),
[a0] "{rdi}" (@intFromPtr(path.ptr)),
[a1] "{rsi}" (path.len),
[a3] "{r10}" (@intFromPtr(out.ptr)),
[a4] "{r8}" (out.len),
: .{ .rcx = true, .r11 = true, .memory = true });
if (@as(isize, @bitCast(rax)) < 0) return null;
if (rax == abi.fs_route_kernel) return .{ .kernel = rdx };
if (rax != abi.fs_route_backend) return null;
const path_len = @as(usize, out[0]) | (@as(usize, out[1]) << 8);
if (path_len + 2 > out.len) return null;
std.mem.copyForwards(u8, out[0..path_len], out[2..][0..path_len]);
return .{ .backend = .{ .handle = rdx, .path_len = path_len } };
}
/// Read `out.len` bytes of a kernel-served node at `offset` (fs_node read).
pub fn fsNodeRead(node_token: u64, offset: u64, out: []u8) ?usize {
const r = sc.systemCall5(.fs_node, abi.fs_node_read, node_token, offset, @intFromPtr(out.ptr), out.len);
if (@as(isize, @bitCast(r)) < 0) return null;
return r;
}
/// A kernel-served node's metadata (fs_node status).
pub fn fsNodeStatus(node_token: u64) ?abi.FileAttributes {
var attrs: abi.FileAttributes = undefined;
const r = sc.systemCall5(.fs_node, abi.fs_node_status, node_token, 0, @intFromPtr(&attrs), @sizeOf(abi.FileAttributes));
if (@as(isize, @bitCast(r)) < 0) return null;
return attrs;
}
/// The `cursor`th child of a kernel-served directory (fs_node readdir): fills `out` with
/// [DirectoryEntryHeader][name]; returns total bytes (0 = end).
pub fn fsNodeReaddir(node_token: u64, cursor: u64, out: []u8) ?usize {
const r = sc.systemCall5(.fs_node, abi.fs_node_readdir, node_token, cursor, @intFromPtr(out.ptr), out.len);
if (@as(isize, @bitCast(r)) < 0) return null;
return r;
}
/// Mount a userspace filesystem's endpoint at `prefix`, with an optional backend-side
/// `rewrite` prefix ("" = none). Possession of the endpoint handle is the capability.
pub fn fsMount(prefix: []const u8, backend: usize, rewrite: []const u8) bool {
return sc.systemCall5(.fs_mount, @intFromPtr(prefix.ptr), prefix.len, backend, @intFromPtr(rewrite.ptr), rewrite.len) == 0;
}
pub fn fsUnmount(prefix: []const u8) bool {
return sc.systemCall2(.fs_unmount, @intFromPtr(prefix.ptr), prefix.len) == 0;
}
@@ -4,7 +4,7 @@
//! added with the first server binary.
const abi = @import("abi");
const sc = @import("system-call.zig");
const sc = @import("system-call");
/// A small-int handle into the calling process's handle table.
pub const Handle = usize;
@@ -34,6 +34,14 @@ pub fn register(id: abi.ServiceId, h: Handle) bool {
return !failed(sc.systemCall2(.ipc_register, @intFromEnum(id), h));
}
/// Drop a capability handle (endpoint, shared-memory, or DMA-region) and free its table
/// slot. A forwarding hop closes a cap it passed on; a binder closes a DMA-region cap
/// once the binding holds its own reference — the 32-slot table is otherwise consumed by
/// repeated cap-passing.
pub fn close(h: Handle) bool {
return !failed(sc.systemCall1(.handle_close, h));
}
/// Find the endpoint published under `id`, installing a handle to it in this
/// process.
pub fn lookup(id: abi.ServiceId) ?Handle {
+97
View File
@@ -0,0 +1,97 @@
//! The per-process logger: std.log wired to the tagged kernel log ring.
//!
//! A program just calls `std.log.info("mounted {s}", .{path})` (or a scoped
//! logger); this backend formats the line into a fixed buffer and emits ONE
//! `debug_write` record carrying the level. The kernel stamps the record with
//! the sender's pid and task name (its binary path) — the process does NOT put
//! its own name in the payload; attribution is the kernel's, structural and
//! unforgeable. Serial shows the kernel-rendered `<path>: message` line, and
//! the logger service demultiplexes the ring into one file per process.
//!
//! Installed for every user binary by the root shim (library/runtime/root.zig)
//! via `std_options`; a program can override by declaring its own
//! `pub const std_options`.
const std = @import("std");
const abi = @import("abi");
const sc = @import("system-call");
// --- the tagged log ring: raw wrappers + record types, formerly in the system.zig dump ---
/// A log record's level and the ring's framing types (re-exported from the shared ABI so
/// callers and the logger service don't import `abi` themselves).
pub const KlogLevel = abi.KlogLevel;
pub const KlogStatus = abi.KlogStatus;
pub const KlogRecordHeader = abi.KlogRecordHeader;
pub const klog_record_header_size = abi.klog_record_header_size;
pub const klog_record_alignment = abi.klog_record_alignment;
pub const klog_record_magic = abi.klog_record_magic;
pub const klog_flag_truncated = abi.klog_flag_truncated;
pub const klog_maximum_message = abi.klog_maximum_message;
pub const maximum_process_name = abi.maximum_process_name;
/// Write raw bytes to the kernel log (bring-up/panic diagnostics; ordinary output goes
/// through std.log -> writeRecord). The kernel stamps the record with this process's id
/// and name. Returns the byte count, or a wrapped -1.
pub fn write(message: []const u8) usize {
return writeRecord(.raw, message);
}
/// Emit one leveled record into the tagged kernel log ring. The kernel stamps
/// pid/name/sequence/timestamp; the payload should be a single line.
pub fn writeRecord(level: KlogLevel, message: []const u8) usize {
return sc.systemCall3(.debug_write, @intFromPtr(message.ptr), message.len, @intFromEnum(level));
}
/// Copy framed records out of the tagged kernel log ring starting at stream `offset` into
/// `out`. Returns the byte count (0 = caught up), or null when `offset` fell behind the
/// ring's tail or lies past its head (re-sync via `klogStatus`).
pub fn klogRead(offset: u64, out: []u8) ?usize {
const r = sc.systemCall3(.klog_read, offset, @intFromPtr(out.ptr), out.len);
if (@as(isize, @bitCast(r)) < 0) return null;
return r;
}
/// The log ring's live cursors (oldest retained offset, end of stream, next sequence)
/// plus the wall-clock time of boot — how a log reader starts, detects loss, and names a
/// per-boot log directory.
pub fn klogStatus() ?KlogStatus {
var status: KlogStatus = undefined;
if (@as(isize, @bitCast(sc.systemCall1(.klog_status, @intFromPtr(&status)))) != 0) return null;
return status;
}
fn levelOf(comptime level: std.log.Level) KlogLevel {
return switch (level) {
.err => .err,
.warn => .warn,
.info => .info,
.debug => .debug,
};
}
pub fn logFn(
comptime level: std.log.Level,
comptime scope: @EnumLiteral(),
comptime format: []const u8,
args: anytype,
) void {
// One record = one line = at most klog_maximum_message bytes of payload.
// On overflow keep what fits and end with "~" so the record is still a
// whole line (the kernel would split an embedded rest anyway).
var buffer: [256]u8 = undefined;
const prefix = if (scope == .default) "" else "(" ++ @tagName(scope) ++ ") ";
const line = std.fmt.bufPrint(&buffer, prefix ++ format, args) catch truncated: {
buffer[buffer.len - 1] = '~';
break :truncated buffer[0..];
};
_ = writeRecord(levelOf(level), line);
}
/// The std.Options the root shim installs unless the program overrides it.
/// Debug level: filtering is the log *reader's* job here — the ring is cheap,
/// serial is a dev convenience, and the logger service keeps everything.
pub const default_options: std.Options = .{
.log_level = .debug,
.logFn = logFn,
};

Some files were not shown because too many files have changed in this diff Show More