Time is a kernel concern in danos: the kernel owns the scheduling timer and already exposes monotonic time via the clock/sleep/timer_bind syscalls, so a userspace time service would be a redundant, slower path. This adds the generic runtime.time module over those syscalls, retires the two demonstration drivers, reorganizes the milestone docs, and makes the monotonic clock correct on Intel, AMD, and inside any VM. runtime.time (library/runtime/time.zig) - Instant/Duration interface: now, sleep, spin, after, monotonicNanos, available - a thin layer over system.clock/sleep/timerOnce; unit-tested arithmetic Remove the demo drivers hpet and bus (a teaching example belongs in the docs, not shipped in the tree) - system/drivers/ now holds only real drivers: pci-bus, ps2-bus, usb-xhci-bus - device-manager end-to-end test repointed to pci-bus (asserts on kernel state: the process table and the device tree, not a racy serial marker) - device_register containment moved to a new in-kernel `containment` test - the driver-model worked example moved inline into docs/drivers.md Reorganize milestone docs into topic docs - m17-m18 / m19-m20 / m21 plans dissolved into process-lifecycle, device-manager, discovery, and acpi docs; new docs/power.md and docs/timers.md; ~20 citations repointed; plan docs deleted TSC reliability (apic.zig, smp.zig, cpu.zig, kernel.zig) - check the invariant-TSC bit (CPUID 0x80000007 EDX[8]) on Intel and AMD - cross-core "warp" check at SMP bring-up, pairwise BSP<->AP as each core comes up - fall back to the HPET clocksource when the TSC is not invariant (a bare VM) or not synchronized (a warp), switched continuously so time never jumps - boot log reports the outcome; new tsc-sync test exercises the TSC + warp path Verified: zig build; zig build test; 60/60 QEMU cases (incl. new containment and tsc-sync).
396 lines
22 KiB
Markdown
396 lines
22 KiB
Markdown
# Writing a driver
|
||
|
||
In a monolithic kernel a driver is a function call away from everything: it runs in
|
||
ring 0, dereferences any physical address, and its interrupt handler *is* the ISR. In
|
||
danos a driver is **an ordinary ring-3 process**. It has its own address space, it
|
||
can crash without taking the kernel with it, and — the point of this document — it
|
||
can be restarted ([resilience](resilience.md)).
|
||
|
||
That leaves three questions the kernel has to answer, because a process can't answer
|
||
them for itself:
|
||
|
||
1. **What hardware exists?** → `device_enumerate`, over the device table discovery built
|
||
([discovery](discovery.md), [acpi](acpi.md)).
|
||
2. **How do I touch its registers?** → `device_claim` + `mmio_map`: the kernel maps the
|
||
device's physical MMIO window into your address space, and from then on it's plain
|
||
memory. No syscall per register access.
|
||
3. **How do I find out it wants something?** → `irq_bind`: the interrupt is delivered
|
||
to you as an IPC notification. You block; the hardware wakes you.
|
||
|
||
A driver is, in one sentence, *a process that sleeps until its device has something to
|
||
say.*
|
||
|
||
## How a driver gets started: discover, match, spawn
|
||
|
||
Nothing in the kernel decides that the PCI host bridge needs the `pci-bus` driver — that
|
||
is policy, and policy lives in user space. Boot brings user space up as a three-level
|
||
supervision hierarchy, each level owning one job:
|
||
|
||
```
|
||
kernel ──spawns──► init (PID 1) ──spawns──► device-manager ──spawns──► pci-bus
|
||
| | |
|
||
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
|
||
system_spawn from it list is init policy.
|
||
```
|
||
|
||
The kernel launches exactly one process — `init` — and hands it nothing but the raw
|
||
ability to start more (`system_spawn(name, arguments)`, which loads a binary bundled
|
||
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.
|
||
- **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
|
||
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
|
||
arguments can carry *which* device it matched), which then claims
|
||
its device and runs the event loop below.
|
||
|
||
So "how is a driver discovered and configured" has two halves: **discovery** is the
|
||
kernel's device table, read by anyone; **configuration** is two user-space policies —
|
||
init's service list and the device-manager's match table. Both are hardcoded in their
|
||
respective programs today; the natural next step is to move them into `/etc` (see the
|
||
milestone notes in [driver-model.md](driver-model.md)). `system_spawn` is currently
|
||
ungated — any process may spawn any bundled binary — because there is no spawn
|
||
capability yet.
|
||
|
||
## The capability: claim before touch
|
||
|
||
The driver syscall numbers (`system/abi.zig`) with the device types they carry
|
||
(`system/devices/device-abi.zig`), dispatched in `system/kernel/process.zig`:
|
||
|
||
| # | Call | Meaning |
|
||
|---|------|---------|
|
||
| 11 | `device_enumerate(buf, max) -> total` | Snapshot the device table |
|
||
| 12 | `device_claim(id) -> ok` | Take **exclusive** ownership |
|
||
| 13 | `mmio_map(id, res_idx) -> vaddr` | Map a claimed device's register window |
|
||
| 14 | `irq_bind(id, res_idx, endpoint)` | Deliver that device's IRQ as a notification |
|
||
| 15 | `irq_ack(id, res_idx)` | Re-arm the IRQ after servicing the device |
|
||
| 16 | `device_register(parent_id, desc) -> id` | Publish a child of a device you claimed |
|
||
|
||
Notice that **nothing takes a physical address or an interrupt number.** Every call
|
||
names a device by id and a resource by index. That indirection is the entire security
|
||
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`):
|
||
|
||
- `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`
|
||
for `irq_bind`
|
||
|
||
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:
|
||
|
||
- **`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.
|
||
- **`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
|
||
the frame allocator as if they were free RAM. The `iopass` test guards it.
|
||
|
||
Grants land in their own arena, `0x0000_7100_0000_0000` (PML4[226]), so device pages
|
||
never widen an existing mapping.
|
||
|
||
Then you just… use it:
|
||
|
||
```zig
|
||
const base = dev.mmioMap(dev_id, mmio_res) orelse return;
|
||
const counter: *volatile u64 = @ptrFromInt(base + 0xF0);
|
||
const now = counter.*; // a load, straight to the hardware. no kernel involved.
|
||
```
|
||
|
||
## Interrupts: the cycle, and why it has that shape
|
||
|
||
An interrupt handler in a microkernel has a problem. The code that knows how to quiet
|
||
the device is in ring 3, in another address space, and it will not run for
|
||
microseconds or milliseconds — after a context switch, when the scheduler gets to it.
|
||
But the CPU wants an EOI *now*, and a **level-triggered** line stays asserted until
|
||
the device is quieted. EOI a still-asserted line and the I/O APIC redelivers
|
||
immediately. Forever. The driver never gets to run at all.
|
||
|
||
The way out is to mask the line before acknowledging it:
|
||
|
||
```
|
||
kernel ISR irqMask(gsi) // line still asserted; stop it reaching a CPU
|
||
irqEoi() // now safe to tell the LAPIC we're done
|
||
notifyFromIsr() // wake the driver — it runs much later
|
||
|
||
driver replyWait() -> badge with the notify bit set
|
||
<clear the device's status register> // NOW the line deasserts
|
||
irq_ack(dev, res) // kernel unmasks: quiet, so it can't refire
|
||
|
||
```
|
||
|
||
`irq_ack` is not bookkeeping you could skip. **It is the unmask.** Forget it and the
|
||
interrupt fires exactly once, ever; call it before the device is quiet and you get an
|
||
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
|
||
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.
|
||
|
||
### The driver side is an event loop, not a callback
|
||
|
||
`IPC_ReplyWait` returns *either* a client request *or* a notification, told apart by
|
||
the top bit of the badge (`ipc_sync.notify_badge_bit`). So a driver is one
|
||
single-threaded loop over both of its event sources:
|
||
|
||
```zig
|
||
while (true) {
|
||
const r = ipc.replyWait(endpoint, reply, &recv);
|
||
if (r.isNotification()) { // r.source() is the GSI
|
||
service_device(); // clear the status register
|
||
_ = dev.irqAck(id, irq_res); // re-arm
|
||
} else {
|
||
handle_client_request(recv[0..r.len]);
|
||
}
|
||
}
|
||
```
|
||
|
||
No reentrancy, no "what am I allowed to call from an interrupt handler", no shared
|
||
state between ISR and task context. The interrupt is just a message.
|
||
|
||
Two properties worth knowing:
|
||
|
||
- **An interrupt taken while you're elsewhere is not lost.** If the driver is off in
|
||
an `ipc_call` to another server when the IRQ fires, `wakeLocked` finds nobody
|
||
waiting, but the badge is already on the endpoint's notify ring. The next
|
||
`replyWait` pops it (`ipc_sync.replyWait` checks `popNotify` before the sender FIFO).
|
||
- **Notifications coalesce, they don't count.** The ring is 8 deep and drops on
|
||
overflow. That's correct: an IRQ notification is a *level* ("the device wants
|
||
attention"), not a tally. Re-read the device's status register; never assume one
|
||
notification means exactly one event. Because the ISR masks the line until you
|
||
`irq_ack`, at most one badge per GSI can be outstanding — so the ring can only
|
||
overflow if you bind more than eight GSIs to a single endpoint. Don't.
|
||
|
||
## A whole driver
|
||
|
||
A minimal leaf driver is only ~150 lines and does all of it. danos ships **no such
|
||
example binary** — the driver model is proven by the real drivers (`pci-bus`, `ps2-bus`,
|
||
`usb-xhci-bus`), and a teaching example belongs here, in the docs, rather than as a
|
||
compiled program nobody runs. Illustrated with a hypothetical HPET timer driver, the
|
||
shape is:
|
||
|
||
```zig
|
||
const hpet = findHpet(buf) orelse return; // device_enumerate, look for
|
||
// class=timer with memory + irq
|
||
_ = dev.claim(hpet.dev_id); // the capability
|
||
const base = dev.mmioMap(hpet.dev_id, hpet.mmio).?;
|
||
const endpoint = ipc.createIpcEndpoint().?;
|
||
|
||
// program the hardware over the mapping we were just handed
|
||
reg(base, 0x100).* = level | int_enb | (hpet.gsi << 9); // timer 0 config
|
||
reg(base, 0x108).* = reg(base, 0xF0).* + period; // comparator
|
||
reg(base, 0x010).* |= 1; // ENABLE
|
||
|
||
_ = dev.irqBind(hpet.dev_id, hpet.irq, endpoint);
|
||
|
||
while (...) {
|
||
const r = ipc.replyWait(endpoint, &.{}, &recv); // blocked. not polling.
|
||
if (r.badge & notify_bit == 0) continue;
|
||
reg(base, 0x020).* = 1; // clear status -> deassert
|
||
reg(base, 0x108).* = reg(base, 0xF0).* + period; // re-arm
|
||
_ = dev.irqAck(hpet.dev_id, hpet.irq); // unmask
|
||
}
|
||
```
|
||
|
||
The HPET makes a good illustration for a reason that isn't obvious. Its *counter* is a
|
||
clocksource — the only way to use it is to read it, so it exercises `mmio_map` without
|
||
needing interrupts at all. Its *comparators* are a clockevent, and can be configured
|
||
**level-triggered** (`Tn_INT_TYPE_CNF`), which asserts a bit in `GENERAL_INT_STATUS`
|
||
that the driver must write-1-to-clear. That's a genuine deassert step, so the full
|
||
mask/ack cycle above is exercised for real rather than being decoration on an
|
||
edge-triggered line that would have been fine without it.
|
||
|
||
One wrinkle it also demonstrates: the ACPI HPET table carries **no interrupt number**.
|
||
Which I/O APIC inputs a comparator may drive is a bitmask in `Tn_INT_ROUTE_CAP`, in
|
||
the device's own registers. So discovery (`acpi.parseHpet`) maps the block, reads the
|
||
mask, and records one concrete GSI as an `irq` resource. The driver then programs
|
||
`Tn_INT_ROUTE_CNF` to raise exactly that line — and the kernel will only bind the one
|
||
it recorded. Hardware that describes itself at runtime still has to fit through a
|
||
static capability.
|
||
|
||
## Publishing children: `device_register`
|
||
|
||
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`).
|
||
|
||
```zig
|
||
var child = std.mem.zeroes(dev.DeviceDesc);
|
||
child.class = @intFromEnum(dev.DeviceClass.timer);
|
||
child.resource_count = 1;
|
||
child.resources[0] = .{ .kind = memory, .start = bus_base + 0x100, .len = 0x20 };
|
||
const child_id = dev.register(bus_id, &child).?;
|
||
```
|
||
|
||
The child is left **unclaimed**, which is the whole point: another process claims it and
|
||
`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.
|
||
|
||
A device with **no resources** is legal and common. A USB device is reached through its
|
||
controller, not by MMIO, so it gets `resource_count = 0`.
|
||
|
||
See [`system/drivers/pci-bus/pci-bus.zig`](../system/drivers/pci-bus/pci-bus.zig) for a
|
||
real one — it claims a PCI host bridge, maps its ECAM window, and publishes each function
|
||
it finds as a child — and [driver-model.md](driver-model.md) for how bus drivers, class
|
||
drivers and host controller drivers fit together.
|
||
|
||
## What the kernel does not do for you
|
||
|
||
- **It does not quiet your device.** That's the whole reason `irq_ack` exists.
|
||
- **It does not know your registers.** `mmio_map` hands you a base address; every
|
||
offset in this document came from the HPET spec, not from danos.
|
||
- **It does not serialise your driver.** Two clients calling one driver endpoint are
|
||
serialised by `replyWait`, but nothing stops your driver from being preempted.
|
||
|
||
## Limits, today
|
||
|
||
Worth knowing before you write the second driver:
|
||
|
||
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:
|
||
|
||
- **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
|
||
than a page, but its *mapping* can't.
|
||
- **DMA is not contained.** A driver that can program a bus-mastering device can make
|
||
that device write to *any* physical address — page tables don't sit between a device
|
||
and RAM; an IOMMU does. The IOMMU is now *detected* (M16), but no translation domains
|
||
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.
|
||
- **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`).
|
||
- **Polarity is hardcoded** active-high in `irq.bind`. A device whose MADT override
|
||
says active-low needs that threaded through from discovery.
|
||
- **14 device vectors** (33–46) and **24 GSIs**, bounded by the stubs `isr.s` emits and
|
||
by a single I/O APIC.
|
||
- **Don't bind more than 8 GSIs to one endpoint.** The notify ring is 8 deep and drops
|
||
on overflow. With one GSI per endpoint that's unreachable — the line is masked from
|
||
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
|
||
tests can't see this. Linux flushes remote-IRR by toggling the entry to edge and
|
||
back. See the note at the top of `system/kernel/irq.zig`.
|
||
|
||
## Verifying it
|
||
|
||
No demo driver ships to prove this end to end; the *real* drivers do, so the tests
|
||
target them and the kernel primitives directly:
|
||
|
||
- **`device-manager`** — boots only the device manager, which discovers the PCI host
|
||
bridge, matches `pci-bus`, and `system_spawn`s it. The test reads kernel state — the
|
||
process table and the device tree — to confirm pci-bus came up and registered the
|
||
functions it enumerated: the whole discover → match → spawn → driver-up chain.
|
||
- **`acpi-ps2`** — a user-space driver (`ps2-bus`) is woken by its device's IRQ,
|
||
delivered as an IPC notification, and attaches the keyboard: IRQ-as-IPC, end to end.
|
||
- **`pci-scan`** — a user-space driver (`pci-bus`) maps its device's MMIO (the ECAM
|
||
window) and walks it: `mmio_map`, end to end.
|
||
- **`containment`** — the kernel refuses a `device_register` whose child window escapes
|
||
the parent's grant (else it would be a syscall for mapping arbitrary memory), while an
|
||
identical re-register stays idempotent. Asserted in-kernel, straight against the broker.
|
||
- **`irqfree`** — the teardown path. Binds two owners to one shared endpoint, releases
|
||
one, and reads the I/O APIC back: the departing owner's line is masked, the sibling's
|
||
is not. That second half is why bindings are keyed on the owning *task* and not on the
|
||
endpoint pointer — endpoints are shared, so releasing "everything pointing at this
|
||
endpoint" would silently mask a live driver's device.
|
||
- **`iopass`** — the `device_grant` teardown rule, so destroying a driver's address
|
||
space never returns MMIO frames to the RAM pool.
|
||
|
||
```
|
||
$ python3 test/qemu_test.py device-manager acpi-ps2 pci-scan containment irqfree iopass
|
||
device-manager ... PASS (matched 'DANOS-TEST-RESULT: PASS')
|
||
acpi-ps2 ... PASS
|
||
pci-scan ... PASS (matched 'DANOS-TEST-RESULT: PASS')
|
||
containment ... PASS (matched 'DANOS-TEST-RESULT: PASS')
|
||
irqfree ... PASS (matched 'DANOS-TEST-RESULT: PASS')
|
||
iopass ... PASS (matched 'DANOS-TEST-RESULT: PASS')
|
||
```
|
||
|
||
## What's next (not done here)
|
||
|
||
The big driver-model pieces — capability passing (class drivers), DMA + barriers, MSI,
|
||
and IOMMU detection — are **now done** ([driver-model.md](driver-model.md), M13–M16), as
|
||
is **port I/O** (`io_read`/`io_write`, the claim-gated syscalls that make a PS/2 or 16550
|
||
driver possible). What's left is IOMMU *enforcement* (per-device domains — it waits on
|
||
the first DMA driver to protect and test against) and these smaller items:
|
||
|
||
- **Releasing a claim.** There is no `dev_release`, and `devices_broker` never drops a claim on
|
||
exit — only IRQ bindings are released. A dead driver's device stays owned forever,
|
||
which blocks restart.
|
||
- **Unregistering children.** `device_register` only appends. A USB device that is
|
||
unplugged cannot be removed, and a bus driver in a loop can exhaust the 64-entry
|
||
table.
|
||
- **Restart.** A supervisor that *spawns* drivers now exists — the device-manager starts
|
||
them with `system_spawn` — but a supervisor that *restarts* them does not. A driver that
|
||
dies should release its claim, have its device quiesced, and be respawned; today nothing
|
||
notices the death. Some pieces (`releaseIrqs`, `device_grant` teardown, the claim table)
|
||
exist, and `dev_release` (below) is the missing mechanism; the restart policy is the
|
||
resilience track ([resilience.md](resilience.md)).
|
||
- **Interrupt priority / threaded IRQ latency.** `notifyFromIsr` enqueues the woken
|
||
driver but doesn't preempt (`wakeLocked` deliberately leaves that to the caller), so
|
||
a woken driver waits for the next scheduling point.
|
||
|
||
## The driver contract (M17–M18)
|
||
|
||
Claiming and mapping is half of being a danos driver; the other half is the
|
||
**lifecycle and protocol contract**, and the runtime makes it nearly free:
|
||
|
||
- Build on `runtime.service.run` — one replyWait loop folding protocol
|
||
requests, signals, and notifications into callbacks. The harness answers the
|
||
universal zero-length ping and turns `terminate` into a clean exit for you
|
||
([process-lifecycle.md](process-lifecycle.md)).
|
||
- A driver spawned with an assignment (its device id as argv[1]) sends the
|
||
versioned `hello` to the device manager inside the deadline, and a **bus**
|
||
driver reports what it discovers with `child_added`
|
||
([device-manager.md](device-manager.md); usb-xhci-bus is the reference
|
||
implementation).
|
||
- Crash freely — that is the design. The kernel releases your claims, IRQ
|
||
bindings, and MSI vectors at death; the manager reads your exit reason,
|
||
prunes what you reported, restarts you with backoff, and your fresh instance
|
||
re-claims and re-reports. Never depend on your own cleanup running
|
||
(iron rule 1).
|