SchOS/.claude/memory/schunix-usb-xhci.md
Nexoniarz 57aff40228 docs: four directories, and a rule for when a thing leaves the roadmap
docs/ was ten files where a plan, a description, a guide and a bug report all
sat next to each other. It is now architecture/ (what SchOS is), plan/ (what it
will be), guides/ (how to work on it) and history/ (why it is like this), with
an index that states the one rule holding it together: when something is
finished it moves from plan/ to architecture/ and is rewritten as a description
of the thing rather than a record of building it.

A ticked checkbox says the work happened. It does not say what the work
*produced*, and six months later the checkbox is all that is left. The network
stack proved the point: finished, verified end to end, and for a month the only
description of it anywhere was a checkbox four levels of indentation deep.

So roadmap.md went from 534 lines to 181 -- nothing deleted, the done work
graduated. Seven subsystems that were finished and undescribed now have
descriptions: boot, kernel, syscalls, filesystem, networking, graphics, usb,
init. Every one ends with "What it does not do", which is the section that pays;
a document listing only strengths is marketing, and the reader who needs it most
is the one about to trip over a limitation nobody wrote down.

schfs.md still opened with "phases 1 through 3 1/2 are done, phase 4 is next"
long after all five had landed. It is a description now, with the phase record
and the hard-won bugs moved to history/.

Three corrections found on the way, all load-bearing:

  * projects/schcom/README.md claimed SCM_RIGHTS was done. There is no
    sendmsg/recvmsg in the kernel at all, and an entire GUI plan rested on it.
  * The root README said "No TCP/IP" months after the stack landed.
  * history/kernel.md sec.7 was marked "in progress" on a problem that process
    groups solved.

Also adds plan/1.0.md, which draws a finish line so the open list stops being
infinite, and marks schwm/ and schde/ superseded: in Wayland the compositor is
the window manager, so that split dissolves rather than being abandoned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-12 17:13:35 +02:00

6.7 KiB

name description metadata
schunix-usb-xhci xHCI enumerates USB2 and USB3 devices and does control transfers; written from the spec because porting BSD's stack means porting newbus first
node_type type originSessionId modified
memory project a665e14b-86bd-4b41-a228-51377f3e9225 2026-08-04T23:06:09.296Z

kernel/drivers/xhci.c, 2026-08-04. Working: controller reset, DCBAA, command ring, event ring + ERST, scratchpad buffers, port reset, Enable Slot, Address Device, and control transfers. Three QEMU devices (usb-kbd, usb-mouse, usb-storage) enumerate and answer GET_DESCRIPTOR.

Written from the xHCI 1.2 spec, not ported from BSD — and that was the right call, not laziness. FreeBSD's sys/dev/usb is ~100k lines welded to newbus, devclass, bus_dma, callouts and taskqueues; porting it means porting FreeBSD's device framework first. BSD is worth far more as the reference you check yourself against. (Licensing agrees: BSD is Apache-compatible, Linux is not — schunix-licensing.)

Things that would cost hours to rediscover:

  • Enumeration polls the event ring on purpose. pci_bind_all() runs from kmain() with interrupts off and no scheduler; a probe that waited for an interrupt would wait forever.
  • Context size is 32 or 64 bytes, per HCCPARAMS1.CSZ. Hardcoding 32 on a 64-byte controller puts every field at half the right offset and commands are rejected with no explanation.
  • PORTSC's change bits and PED are write-1-to-clear. Writing back a value you just read clears whatever happened. Mask PORT_RW1C off every write.
  • USB3 ports arrive already enabled; USB2 ports need PORT_PR first. Check PED rather than trying to work out which is which.
  • Full-speed devices' EP0 max packet size is unknowable until you ask. Start at 8, read 8 bytes of the device descriptor, then Evaluate Context.
  • The link TRB's Toggle Cycle bit is what makes a fixed ring endless; set the cycle bit last when pushing a TRB.
  • A No-Op command before enumerating proves the ring and event path, so a failure points at our plumbing rather than at a device.

The USB core and HID landed 2026-08-05. kernel/drivers/usb.c reads the configuration, selects it, and offers the device to a class-driver table in registry.c (same one-table-you-can-read arrangement as the PCI drivers); kernel/drivers/usb_hid.c does boot-protocol keyboard and mouse. A command typed on an emulated USB keyboard runs in the shell.

  • Boot protocol, not report descriptors. A device claiming the boot subclass promises a fixed 8-byte keyboard / 3-byte mouse layout, so no report-descriptor parser is needed. It still starts in report protocol -- SET_PROTOCOL(0) is required or the reports arrive in an unknown shape.
  • SET_IDLE 0 too, or a keyboard resends its state every few ms forever.
  • The keyboard report is a state, not an event: a held key appears in every report. Emit only usages absent from the previous report.
  • Characters go into a small queue that consoleintr() then drains through its getc argument -- that reuses the whole line discipline instead of reimplementing it. mouse_inject() in mouse.c is the same idea for the pointer; HID's Y axis points down and PS/2's points up.
  • A configuration descriptor is a chain of descriptors of varying types. Walk it by each one's length byte; class-specific ones you have never heard of sit in the middle and must be skipped, not tripped over.

The interrupt bug that cost the most time is in schunix-driver-infrastructure -- transfers completed perfectly, events sat in the ring, and the IRQ count stayed at zero.

Mass storage landed 2026-08-05 (kernel/drivers/usb_storage.c): bulk endpoints, bulk-only transport, SCSI INQUIRY / READ CAPACITY / READ(10) / WRITE(10), and a block device at USBDISK_DEV (3) routed from bdevrw() in bio.c. A real superblock reads back through bread().

  • A bulk transfer at probe time must not sleep. This wedged the machine mid-enumeration with no message at all. pci_bind_all() runs from kmain() with interrupts off and before pinit() — so sleep() has no scheduler to switch to and wakeup() would take an uninitialised ptable lock. struct xhci has a can_sleep flag, set once probing is over; until then bulk transfers poll the event ring, exactly as enumeration does.
  • Bulk-only transport is strictly three phases: 31-byte CBW out, data, 13-byte CSW in. The CSW is not optional — skipping it leaves the endpoint out of phase and every later command fails.
  • SCSI is big-endian everywhere, and READ CAPACITY returns the address of the last sector, not a count.
  • No GET MAX LUN: single-LUN devices answer it with a STALL, and recovering from an EP0 stall needs a Reset Endpoint command that is not written yet.
  • b->data lives in the buffer cache, not DMA memory, so everything bounces through one dma_alloc'd block.

Bugs found in a deliberate audit pass (2026-08-05), all fixed:

  • Context Entries must be the highest DCI in use, not the one being added. Writing the new endpoint's own DCI works only while endpoints are opened in increasing order; open a higher DCI then a lower one and the field shrinks, silently invalidating the endpoint that already worked. xhci_open_ep tracks slot[].max_dci now, and usb_storage.c deliberately opens bulk OUT before bulk IN — the formerly-broken order — so the fix stays tested.
  • The event ring had two unlocked consumers: xhci_irq and the sysrq dump. Both take x->lock now; transfer_done runs with it held.
  • A failed block read left B_VALID clear and returned the stale buffer, which bread() hands straight to its caller as though it were disk content. Three retries, then panic.
  • bot_command skipped the CSW when the data phase failed, leaving the device holding a status nobody asked for — so the next command's data phase would receive this one's status, turning one failed read into every later read failing. The CSW is now always read.
  • The shared descriptor scratch buffer was not cleared between devices, so a short reply left the previous device's descriptor visible past the end of its own and the parser walked into it.
  • Max ESIT Payload was being set for bulk endpoints; it is periodic-only.
  • irq_unregister compacted a chain a dispatcher could be walking, and the comment above the lock claimed nothing was ever removed. It masks the line for the rewrite now.

Two debug commands: echo u > /proc/sysrq dumps controller state and drains the event ring by hand — that is what separates "no event" from "event, no interrupt". echo s > /proc/sysrq reads block 1 off the USB disk through the buffer cache.

Still not done: hubs. See docs/plan/roadmap.md phase 1.