usb: recognize and configure hubs, power downstream ports (B4a)

First slice of hub support (docs/usb-hub.md), handled IN the xhci-bus
because a device behind a hub is reached by the CONTROLLER via a route
string in its slot context — topology only exists inside the driver
that owns the controller. When the scan enumerates a class-9 device it
now calls setupHub: read the hub descriptor for the downstream port
count and TT arrangement, tell the controller the slot is a hub
(Configure Endpoint with the slot add-flag sets the Hub bit, Number of
Ports, and MTT/TT-Think-Time per xHCI 4.6.6), SET_HUB_DEPTH for a
SuperSpeed hub so it can compose route strings, and SET_FEATURE
PORT_POWER every downstream port.

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

Verified in QEMU with a hub on a second controller and a device behind
it (a usb-hub QEMU case, since the boot controller's auto-assigned
devices collide on the low ports): 'hub slot 1: 8 downstream ports
powered (USB2 single-TT)'. Full suite 89/89. The user's SuperSpeed
Genesys hub is the real-hardware target, flagged separately (QEMU's
USB2 hub doesn't model the compound USB3 hub).
This commit is contained in:
Daniel Samson 2026-07-21 21:56:59 +01:00
parent 983b4ed05a
commit 92b03b8d07
No known key found for this signature in database
GPG Key ID: A9EB2589C60F7268
4 changed files with 326 additions and 4 deletions

103
docs/usb-hub.md Normal file
View File

@ -0,0 +1,103 @@
# USB hubs (M22)
A hub is USB **bus infrastructure**, not an application peripheral, so hub
topology is handled **inside the `usb-xhci-bus` driver** — the process that owns
the controller's device slots and contexts. A device behind a hub is not reached
by any hub-specific software path: it is reached by the **controller**,
programmed with a *route string* in its slot context. Route strings and slot
contexts are xHCI hardware concepts that only exist inside the controller driver,
so that is where hub handling belongs. Class drivers (HID, storage) stay separate
and unaware — the hub is transparent to them; a keyboard behind a hub reaches the
same `usb-hid-keyboard` driver as one on a root port.
This is a deliberate scoping choice, not a microkernel compromise: the USB *bus*
driver handles USB *bus* topology. The alternative — a separate `usb-hub`
class-driver process plus a cross-process enumeration protocol — would only
shuttle the bus's own topology state (slot ids, route strings, TT linkage) out to
another process and back, since the hub driver cannot build a slot context
itself.
## The compound-hub reality
A USB 3.0 hub is physically **two hubs** sharing each connector: a SuperSpeed hub
and a USB 2.0 companion hub, enumerated as **separate devices on separate root
ports**. A full- or low-speed device plugged into a USB 3.0 hub attaches to the
**USB 2.0 companion**, not the SuperSpeed hub. So supporting full-speed devices
(keyboards, mice) behind a hub means driving the USB 2.0 companion and handling
**transaction translators** — there is no SuperSpeed-only shortcut that reaches a
full-speed keyboard.
## Slot-context fields for a downstream device
`buildAddressInputContext` fills the Slot Context. Today it hardcodes route 0 and
the root-hub port. A downstream device additionally needs:
- **Route String** (Slot Context dword 0, bits 19:0) — 5 tiers × 4 bits, each
tier the downstream hub-port number. Composed as
`route = (parent_route << 4) | hub_port`, capped at the xHCI 5-tier max.
- **Root Hub Port Number** (dword 1, bits 23:16) — the *root* port the whole hub
chain hangs off, inherited from the parent hub (not the hub's own port number).
- **Speed** (dword 0, bits 23:20) — read from the hub's downstream port status
after reset, not assumed.
- **Parent Hub Slot ID** (dword 2, bits 7:0) + **Parent Port Number** (dword 2,
bits 13:8) — the **transaction translator**: set when a full/low-speed device
sits behind a high-speed hub, so the controller routes split transactions
through that hub's TT. For a multi-TT hub, **MTT** (Slot Context dword 0 bit
25) is set and the TT port is the device's own hub port.
## Detection: the status-change interrupt endpoint
A hub has one interrupt IN endpoint that returns a **port-status-change bitmap**
(bit N set = port N changed). The bus arms an interrupt transfer on it (reusing
the controller's existing interrupt-endpoint machinery, but serviced
**in-process** — no class-driver subscription IPC), and on each report:
1. For each changed port, `GET_STATUS` (hub class request) reads the port's
connect/enable/reset state and speed, and `CLEAR_FEATURE(C_PORT_*)`
acknowledges the change.
2. On a **connect**: `SET_FEATURE(PORT_RESET)`, wait for reset-complete via a
later status-change report, read the enabled speed, then `setupDevice` with
the composed route string / root port / TT fields, `enumerate`, and register
the interfaces — exactly the existing path, recursing if the new device is
itself a hub.
3. On a **disconnect**: tear down the downstream device (report each interface
`ChildRemoved`, Disable Slot) — the B3 teardown path, keyed by the device's
route rather than a root port.
## Hub setup (once, when the hub enumerates)
When the bus scan (or a hot-plug bring-up) finds a device of class 9:
1. Read the **hub descriptor** (class GET_DESCRIPTOR, type 0x2A for a USB 3.0
hub / 0x29 for USB 2.0) → downstream port count, characteristics.
2. For a USB 3.0 hub, `SET_FEATURE(BH_PORT_RESET)` semantics and the depth
(`SET_HUB_DEPTH`) so the hub knows its tier for route-string forwarding.
3. `SET_FEATURE(PORT_POWER)` each downstream port.
4. Configure the hub's slot as a hub: **Hub** bit (Slot Context dword 0 bit 26),
**Number of Ports** (dword 1, bits 31:24), **TT Think Time** and **MTT** for a
USB 2.0 multi-TT hub — via an Evaluate/Configure Endpoint on the hub's slot.
5. Arm the status-change interrupt endpoint.
## Testing
QEMU's `usb-hub` is a USB 2.0 single-TT hub. A **static boot topology**
(`-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,
downstream enumerate, class-driver bind) without needing a hot-plug event. A new
`usb-hub` QEMU case asserts the hub enumerates, the downstream keyboard
enumerates behind it, and `usb-hid-keyboard` binds.
Real-hardware validation (the user's SuperSpeed Genesys hub + full-speed
keyboard/mouse on its USB 2.0 companion) is flagged separately — the compound
USB 3.0 hub path is not modelled by QEMU's USB 2.0 hub.
## Milestones
- **B4a** — hub recognition + setup: detect class 9 in the scan, read the hub
descriptor, mark the slot a hub, power downstream ports, arm the status-change
endpoint, log the topology. No downstream enumeration yet.
- **B4b** — downstream enumeration: status-change handling, port reset,
`setupDevice` with route string + root port + TT fields, enumerate + register.
A full-speed device behind the hub reaches its class driver.
- **B4c** — disconnect teardown + hub-behind-hub recursion + polish.

View File

@ -235,6 +235,21 @@ fn bringUpPort(manager: runtime.ipc.Handle, engine: *library.Controller, port: u
interface.registered_device_id = registered;
}
}
// A hub (class 9) is bus infrastructure the bus drives itself: configure it
// and power its downstream ports (docs/usb-hub.md). Its interfaces are still
// reported above, but no external class driver binds it.
if (deviceIsHub(usb_device)) _ = engine.setupHub(usb_device);
}
/// Whether an enumerated device is a hub class 9 at the device or the
/// interface level (a hub's single interface is class 9/0/0).
fn deviceIsHub(usb_device: *const library.Device) bool {
if (usb_device.device_descriptor.device_class == @intFromEnum(usb_ids.Class.hub)) return true;
for (usb_device.interfaces[0..usb_device.interface_count]) |interface| {
if (interface.class == @intFromEnum(usb_ids.Class.hub)) return true;
}
return false;
}
/// Tear down whatever was on `port` after an unplug: report each registered

View File

@ -280,6 +280,18 @@ pub const Device = struct {
// Transfer rings configured for this device's interrupt/bulk endpoints.
endpoint_ring_count: u8 = 0,
endpoint_rings: [max_configured_endpoints]ConfiguredEndpoint = [_]ConfiguredEndpoint{.{}} ** max_configured_endpoints,
// Hub topology (docs/usb-hub.md). A device behind a hub is addressed with a
// route string; these carry the fields buildAddressInputContext needs.
route: u32 = 0, // Slot Context route string (5 tiers x 4 bits); 0 = on a root port
root_port: u32 = 0, // the ROOT-hub port the chain hangs off (inherited down a chain)
parent_slot: u8 = 0, // the parent hub's slot id (0 = on a root port) the TT hub
parent_port: u8 = 0, // the parent hub's downstream port this device sits on
// Set when this device IS a hub, after setupHub configures it.
is_hub: bool = false,
hub_ports: u8 = 0, // downstream port count from the hub descriptor
hub_multi_tt: bool = false,
};
// A standing interrupt-IN subscription: the endpoint's ring is kept armed with a
@ -315,6 +327,101 @@ const max_devices = 8;
const max_subscriptions = 8;
const report_queue_capacity = 16;
// --- USB hub class requests + constants (docs/usb-hub.md) ------------------
//
// A hub is bus infrastructure the CONTROLLER driver handles in-process: the
// route strings and slot contexts a downstream device needs only exist here.
// These are the class-specific control requests to a hub device.
fn routeDepth(route: u32) u16 {
// Tiers used by a route string: each nonzero 4-bit nibble is one tier.
var depth: u16 = 0;
var r = route;
while (r != 0) : (r >>= 4) {
if (r & 0xF != 0) depth += 1;
}
return depth;
}
const hub = struct {
// Hub descriptor types (GET_DESCRIPTOR value high byte).
const descriptor_usb2: u8 = 0x29;
const descriptor_usb3: u8 = 0x2A;
// Hub/port feature selectors (SET_FEATURE / CLEAR_FEATURE value).
const feature_port_reset: u16 = 4;
const feature_port_power: u16 = 8;
const feature_c_port_connection: u16 = 16;
const feature_c_port_reset: u16 = 20;
const feature_c_port_link_state: u16 = 25; // SS
const feature_bh_port_reset: u16 = 28; // SS
const feature_c_bh_port_reset: u16 = 29; // SS
// Port status (wPortStatus, first 16 bits of the 4-byte GET_STATUS result).
const status_connection: u16 = 1 << 0;
const status_enable: u16 = 1 << 1;
const status_reset: u16 = 1 << 4;
// Port-status change bits (wPortChange, the high 16 bits).
const change_connection: u16 = 1 << 0;
const change_reset: u16 = 1 << 4;
// wHubCharacteristics bit 7: multiple transaction translators.
const characteristics_multi_tt: u16 = 1 << 7;
// SET_HUB_DEPTH (SuperSpeed hubs, so they can compose route strings).
const request_set_hub_depth: u8 = 12;
fn getDescriptor(kind: u8, length: u16) usb_abi.Request {
return .{
.request_type = .{ .recipient = .device, .kind = .class, .direction = .device_to_host },
.request_code = .get_descriptor,
.value = @as(u16, kind) << 8,
.index = 0,
.length = length,
};
}
fn setPortFeature(feature: u16, port: u16) usb_abi.Request {
return .{
.request_type = .{ .recipient = .other, .kind = .class, .direction = .host_to_device },
.request_code = .set_feature,
.value = feature,
.index = port,
.length = 0,
};
}
fn clearPortFeature(feature: u16, port: u16) usb_abi.Request {
return .{
.request_type = .{ .recipient = .other, .kind = .class, .direction = .host_to_device },
.request_code = .clear_feature,
.value = feature,
.index = port,
.length = 0,
};
}
fn getPortStatus(port: u16) usb_abi.Request {
return .{
.request_type = .{ .recipient = .other, .kind = .class, .direction = .device_to_host },
.request_code = .get_status,
.value = 0,
.index = port,
.length = 4,
};
}
fn setHubDepth(depth: u16) usb_abi.Request {
return .{
.request_type = .{ .recipient = .device, .kind = .class, .direction = .host_to_device },
.request_code = @enumFromInt(request_set_hub_depth),
.value = depth,
.index = 0,
.length = 0,
};
}
};
pub const Controller = struct {
register_base: usize,
op_base: usize,
@ -623,10 +730,19 @@ pub const Controller = struct {
const base = device.input_context.virtual;
// Input Control Context (index 0): Add flags in dword 1 = A0 | A1.
contextDword(base, 0, 1, cs).* = 0b11;
// Slot Context (index 1): speed[23:20], Context Entries[31:27] = 1.
contextDword(base, 1, 0, cs).* = (device.speed << 20) | (@as(u32, 1) << 27);
// Root Hub Port Number[23:16].
contextDword(base, 1, 1, cs).* = device.port << 16;
// Slot Context dword 0: Route String[19:0], Speed[23:20], Context
// Entries[31:27] = 1.
contextDword(base, 1, 0, cs).* = (device.route & 0xFFFFF) | (device.speed << 20) | (@as(u32, 1) << 27);
// Slot Context dword 1: Root Hub Port Number[23:16] the ROOT port the
// hub chain hangs off (inherited down a chain), not the device's own
// downstream hub port.
contextDword(base, 1, 1, cs).* = device.root_port << 16;
// Slot Context dword 2: the transaction translator a full/low-speed
// device behind a high-speed hub routes split transactions through the
// parent hub's TT. Parent Hub Slot ID[7:0], Parent Port Number[13:8].
if (device.parent_slot != 0 and device.speed < 3) {
contextDword(base, 1, 2, cs).* = @as(u32, device.parent_slot) | (@as(u32, device.parent_port) << 8);
}
// EP0 Context (index 2): CErr[2:1]=3, EP Type[5:3]=Control(4), MPS[31:16].
contextDword(base, 2, 1, cs).* = (@as(u32, 3) << 1) | (@as(u32, 4) << 3) | (device.max_packet_size_0 << 16);
// TR Dequeue Pointer (dwords 2:3) with Dequeue Cycle State = 1.
@ -691,6 +807,7 @@ pub const Controller = struct {
.port = port,
.speed = effective_speed,
.max_packet_size_0 = defaultMaxPacketSize0(effective_speed),
.root_port = port, // a root-port device: the chain root IS this port
};
device.input_context = dma.alloc(page_size, dma.coherent) orelse return self.abandon(device);
device.device_context = dma.alloc(page_size, dma.coherent) orelse return self.abandon(device);
@ -706,6 +823,76 @@ pub const Controller = struct {
}
/// Configure an enumerated class-9 device as a hub (docs/usb-hub.md): read
/// the hub descriptor for the downstream port count, tell the controller the
/// slot is a hub (so it routes downstream traffic), SET_HUB_DEPTH for a
/// SuperSpeed hub, and power every downstream port. Downstream enumeration
/// (status-change handling) is B4b. Returns false on a control-transfer
/// failure; the hub is still registered, just inert.
pub fn setupHub(self: *Controller, device: *Device) bool {
const is_usb3 = device.speed >= 4;
var descriptor: [16]u8 = undefined;
const kind: u8 = if (is_usb3) hub.descriptor_usb3 else hub.descriptor_usb2;
if (!self.controlTransfer(device, hub.getDescriptor(kind, descriptor.len), descriptor[0..], true)) {
std.log.info("hub slot {d}: hub descriptor read failed", .{device.slot_id});
return false;
}
device.is_hub = true;
device.hub_ports = descriptor[2]; // bNbrPorts
const characteristics = @as(u16, descriptor[3]) | (@as(u16, descriptor[4]) << 8);
device.hub_multi_tt = !is_usb3 and (characteristics & hub.characteristics_multi_tt != 0);
// A SuperSpeed hub needs its depth (tiers from the root) to compose the
// route strings of devices below it.
if (is_usb3) {
const depth = routeDepth(device.route);
_ = self.controlTransfer(device, hub.setHubDepth(depth), &.{}, false);
}
// Tell the controller the slot is a hub Hub bit, Number of Ports, and
// (for a USB2 multi-TT hub) MTT + TT Think Time. Configure Endpoint with
// only the slot add-flag (A0) evaluates these (xHCI 4.6.6).
if (!self.configureSlotAsHub(device)) {
std.log.info("hub slot {d}: could not configure slot as a hub", .{device.slot_id});
return false;
}
// Power every downstream port.
var port: u16 = 1;
while (port <= device.hub_ports) : (port += 1) {
_ = self.controlTransfer(device, hub.setPortFeature(hub.feature_port_power, port), &.{}, false);
}
std.log.info("hub slot {d}: {d} downstream ports powered ({s})", .{
device.slot_id,
device.hub_ports,
if (is_usb3) "SuperSpeed" else if (device.hub_multi_tt) "USB2 multi-TT" else "USB2 single-TT",
});
return true;
}
/// Configure Endpoint with only A0 (slot) set: rebuild the slot context with
/// the Hub bit, Number of Ports, and MTT/TT-Think-Time, so the controller
/// treats this slot as a hub.
fn configureSlotAsHub(self: *Controller, device: *Device) bool {
const cs = self.context_size;
const base = device.input_context.virtual;
@memset(@as([*]u8, @ptrFromInt(base))[0 .. 2 * cs], 0);
contextDword(base, 0, 1, cs).* = 0b1; // Input Control Context add flags: A0 (slot)
// Slot Context dword 0: route, speed, context entries, plus Hub[26] and
// (USB2 multi-TT) MTT[25].
var dword0: u32 = (device.route & 0xFFFFF) | (device.speed << 20) | (@as(u32, 1) << 27) | (@as(u32, 1) << 26);
if (device.hub_multi_tt) dword0 |= (@as(u32, 1) << 25);
contextDword(base, 1, 0, cs).* = dword0;
// Slot Context dword 1: Root Hub Port Number[23:16], Number of Ports[31:24].
contextDword(base, 1, 1, cs).* = (device.root_port << 16) | (@as(u32, device.hub_ports) << 24);
// Slot Context dword 2: TT Think Time[17:16] = 0 (8 FS bit times); the
// parent-TT fields (if this hub is itself behind a hub) carry over.
if (device.parent_slot != 0 and device.speed < 3) {
contextDword(base, 1, 2, cs).* = @as(u32, device.parent_slot) | (@as(u32, device.parent_port) << 8);
}
return self.configureEndpointCommand(device);
}
fn abandon(self: *Controller, device: *Device) ?*Device {
_ = self;
device.used = false;

View File

@ -461,6 +461,23 @@ CASES = [
# usb-kbd/usb-mouse ride the default boot xHCI bus (see qemu_args).
"expect": r"(?=[\s\S]*usb-hid-keyboard: ok)(?=[\s\S]*usb-hid-mouse: ok)",
"fail": r"DANOS-TEST-RESULT: FAIL"},
# USB hub (B4a/B4b, docs/usb-hub.md): a USB2 hub on the xHCI bus with a
# keyboard behind it (a STATIC boot topology — no hot-plug event needed).
# B4a: the hub enumerates and powers its downstream ports. B4b extends the
# expect to the downstream keyboard binding usb-hid-keyboard.
{"name": "usb-hub",
"build_case": "usb-hid",
"smp": 4,
"timeout": 150,
# A SECOND xhci controller carries the hub topology, isolated from the boot
# controller's auto-assigned devices (whose ports the hub would collide
# with). danos spawns a second usb-xhci-bus for it. The hub sits on port 1,
# the keyboard on the hub's downstream port 1 (port=1.1).
"qemu_extra": ["-device", "qemu-xhci,id=xhci2",
"-device", "usb-hub,bus=xhci2.0,port=1",
"-device", "usb-kbd,bus=xhci2.0,port=1.1"],
"expect": r"hub slot \d+: \d+ downstream ports powered",
"fail": r"DANOS-TEST-RESULT: FAIL"},
# USB mass storage end to end: the boot usb-storage device (the FAT32 image,
# which has a real 0x55AA boot sector) is enough — the manager spawns
# usb-storage, which opens the device, runs the Bulk-Only / SCSI bring-up,