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