usb: enumerate devices behind a hub — route strings + transaction translators (B4b)

The core of hub support (docs/usb-hub.md): a device on a hub's
downstream port now reaches its class driver exactly like one on a root
port. The hub's interrupt status-change endpoint is armed with an
in-process subscription — completions set the hub's pending-port mask
instead of queuing a class-driver report — and setupHub seeds every
downstream port pending so a STATIC topology (a device present at
power-on) enumerates without waiting on the initial interrupt edge.

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

Verified in QEMU (a keyboard behind a USB2 hub on a second controller):
'hub slot 1 port 1 device vendor 0x0627 ... usb-hid-keyboard: ok
(device 35)'. Full suite 89/89. The user's SuperSpeed Genesys hub +
full-speed keyboard/mouse on its USB2 companion is the real-hardware
target, flagged separately.
This commit is contained in:
Daniel Samson
2026-07-21 22:06:54 +01:00
parent 92b03b8d07
commit 9da1a9899c
3 changed files with 213 additions and 10 deletions
@@ -242,6 +242,33 @@ fn bringUpPort(manager: runtime.ipc.Handle, engine: *library.Controller, port: u
if (deviceIsHub(usb_device)) _ = engine.setupHub(usb_device);
}
/// Bring up a device on hub downstream `port`: setup+address (with route string
/// and TT), enumerate, register its interfaces, and — if it is itself a hub —
/// set it up (recursion). Mirrors bringUpPort for a root-port device.
fn bringUpBehindHub(manager: runtime.ipc.Handle, engine: *library.Controller, hub: *library.Device, port: u16) void {
const usb_device = engine.serviceHubPort(hub, port) orelse return; // empty/disconnect/failure
if (!engine.enumerate(usb_device)) {
std.log.info("hub slot {d} port {d}: enumeration failed", .{ hub.slot_id, port });
return;
}
std.log.info("hub slot {d} port {d} device vendor 0x{x:0>4} product 0x{x:0>4}, {d} interface(s)", .{
hub.slot_id, port,
usb_device.device_descriptor.vendor_id,
usb_device.device_descriptor.product_id,
usb_device.interface_count,
});
for (usb_device.interfaces[0..usb_device.interface_count]) |*interface| {
// A compact topology-unique port key: 1000 + slot*100 + port. Stays a few
// digits (the hid tag "P<key>I<iface>" has an 8-byte cap) while never
// colliding with a root port (1..N) or another (hub, port).
const port_key = 1000 + @as(u32, hub.slot_id) * 100 + port;
if (reportInterface(manager, port_key, interface.*)) |registered| {
interface.registered_device_id = registered;
}
}
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 {
@@ -434,6 +461,13 @@ fn onNotification(badge: u64) void {
tearDownPort(manager, engine, port);
}
}
// Downstream hub-port changes (docs/usb-hub.md): a device connected on a
// hub's downstream port is enumerated and registered here, so a keyboard
// behind a hub reaches its class driver like one on a root port.
while (engine.takeHubChange()) |change| {
const manager = manager_handle orelse break;
bringUpBehindHub(manager, engine, change.hub, change.port);
}
while (engine.takeReport()) |report| {
var message = transfer.InterruptReport{
.device_token = report.device_token,
@@ -292,6 +292,9 @@ pub const Device = struct {
is_hub: bool = false,
hub_ports: u8 = 0, // downstream port count from the hub descriptor
hub_multi_tt: bool = false,
// Downstream ports with a pending change to service (bit P = port P), set
// by the status-change endpoint (and by an initial sweep in setupHub).
hub_change_mask: u32 = 0,
};
// A standing interrupt-IN subscription: the endpoint's ring is kept armed with a
@@ -310,6 +313,9 @@ const Subscription = struct {
// device token and the endpoint handle its reports are sent to.
device_token: u64 = 0,
report_endpoint: usize = 0,
// When set, this is an IN-PROCESS hub status-change subscription: completions
// set the hub's pending-change mask instead of queuing a class-driver report.
hub: ?*Device = null,
};
// One interrupt report waiting for the bus layer to push it to a subscriber.
@@ -333,6 +339,14 @@ const report_queue_capacity = 16;
// route strings and slot contexts a downstream device needs only exist here.
// These are the class-specific control requests to a hub device.
// A USB2 hub port's wPortStatus speed bits (bit 9 = low-speed, bit 10 =
// high-speed; neither = full-speed) mapped to the xHCI speed id.
fn mapHubPortSpeed(port_speed_bits: u32) u32 {
if (port_speed_bits & 0x1 != 0) return 2; // low-speed (wPortStatus bit 9)
if (port_speed_bits & 0x2 != 0) return 3; // high-speed
return 1; // full-speed
}
fn routeDepth(route: u32) u16 {
// Tiers used by a route string: each nonzero 4-bit nibble is one tier.
var depth: u16 = 0;
@@ -343,7 +357,7 @@ fn routeDepth(route: u32) u16 {
return depth;
}
const hub = struct {
const hubreq = struct {
// Hub descriptor types (GET_DESCRIPTOR value high byte).
const descriptor_usb2: u8 = 0x29;
const descriptor_usb3: u8 = 0x2A;
@@ -832,21 +846,21 @@ pub const Controller = struct {
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)) {
const kind: u8 = if (is_usb3) hubreq.descriptor_usb3 else hubreq.descriptor_usb2;
if (!self.controlTransfer(device, hubreq.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);
device.hub_multi_tt = !is_usb3 and (characteristics & hubreq.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);
_ = self.controlTransfer(device, hubreq.setHubDepth(depth), &.{}, false);
}
// Tell the controller the slot is a hub — Hub bit, Number of Ports, and
@@ -860,8 +874,17 @@ pub const Controller = struct {
// 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);
_ = self.controlTransfer(device, hubreq.setPortFeature(hubreq.feature_port_power, port), &.{}, false);
}
// Seed every downstream port as pending: the bus tick GET_STATUSes each
// and enumerates the connected ones. This makes a STATIC topology (a
// device present at power-on) work without relying on the initial
// status-change interrupt edge; the interrupt then handles later plugs.
device.hub_change_mask = if (device.hub_ports >= 31) 0xFFFF_FFFE else (@as(u32, 1) << @intCast(device.hub_ports + 1)) - 2;
// Arm the status-change interrupt endpoint (in-process) for hot-plug.
self.armHubStatus(device);
std.log.info("hub slot {d}: {d} downstream ports powered ({s})", .{
device.slot_id,
device.hub_ports,
@@ -870,6 +893,137 @@ pub const Controller = struct {
return true;
}
/// Arm the hub's interrupt-IN status-change endpoint with an in-process
/// subscription: completions set the hub's pending-change mask (serviced on
/// the bus tick). Best-effort — a hub with no interrupt endpoint (shouldn't
/// happen) just relies on the initial sweep.
fn armHubStatus(self: *Controller, device: *Device) void {
for (device.interfaces[0..device.interface_count]) |interface| {
for (interface.endpoints[0..interface.endpoint_count]) |endpoint| {
const is_interrupt = endpoint.transfer_type == 3;
const is_in = endpoint.address & 0x80 != 0;
if (!is_interrupt or !is_in) continue;
const ring = self.getOrConfigureEndpoint(device, endpoint) orelse return;
const subscription = self.allocateSubscription() orelse return;
const buffer = dma.alloc(page_size, dma.coherent) orelse return;
const number: u8 = endpoint.address & 0x0F;
subscription.* = .{
.active = true,
.slot_id = device.slot_id,
.dci = doorbellContextIndex(number, true),
.endpoint_address = endpoint.address,
.ring = ring,
.buffer = buffer,
.max_length = endpoint.max_packet_size,
.hub = device,
};
self.armInterrupt(subscription);
return;
}
}
}
/// The next pending (hub, downstream-port) change to service, or null. Clears
/// the returned port's bit. Called on the bus tick.
pub fn takeHubChange(self: *Controller) ?struct { hub: *Device, port: u16 } {
for (&self.devices) |*device| {
if (!device.used or !device.is_hub or device.hub_change_mask == 0) continue;
const bit: u5 = @intCast(@ctz(device.hub_change_mask));
device.hub_change_mask &= ~(@as(u32, 1) << bit);
if (bit == 0) continue; // bit 0 is the hub itself, not a downstream port
return .{ .hub = device, .port = bit };
}
return null;
}
fn readHubPortStatus(self: *Controller, hub: *Device, port: u16) ?u32 {
var buffer: [4]u8 = undefined;
if (!self.controlTransfer(hub, hubreq.getPortStatus(port), buffer[0..], true)) return null;
return @as(u32, buffer[0]) | (@as(u32, buffer[1]) << 8) | (@as(u32, buffer[2]) << 16) | (@as(u32, buffer[3]) << 24);
}
/// Bring up (or note the disconnect of) a device on hub downstream `port`:
/// read the port status, acknowledge the change bits, and on a fresh connect
/// reset the port, read the speed, and setup+address the downstream device
/// (route string + TT). Returns the addressed device for the bus to enumerate
/// and register, or null (empty port, disconnect, or a failure).
pub fn serviceHubPort(self: *Controller, hub: *Device, port: u16) ?*Device {
const status = self.readHubPortStatus(hub, port) orelse return null;
const connected = status & hubreq.status_connection != 0;
// Acknowledge the latched change bits so the port can signal again.
_ = self.controlTransfer(hub, hubreq.clearPortFeature(hubreq.feature_c_port_connection, port), &.{}, false);
_ = self.controlTransfer(hub, hubreq.clearPortFeature(hubreq.feature_c_port_reset, port), &.{}, false);
if (!connected) {
// Disconnect (or an empty port) — teardown is B4c.
return null;
}
if (self.deviceOnHubPort(hub, port) != null) return null; // already up
// Reset the port if not yet enabled, then wait (bounded) for enable.
if (status & hubreq.status_enable == 0) {
_ = self.controlTransfer(hub, hubreq.setPortFeature(hubreq.feature_port_reset, port), &.{}, false);
var tries: u32 = 0;
while (tries < 200) : (tries += 1) {
system.sleep(5);
const s = self.readHubPortStatus(hub, port) orelse return null;
if (s & hubreq.status_enable != 0) break;
}
_ = self.controlTransfer(hub, hubreq.clearPortFeature(hubreq.feature_c_port_reset, port), &.{}, false);
}
const enabled = self.readHubPortStatus(hub, port) orelse return null;
if (enabled & hubreq.status_enable == 0) {
std.log.info("hub slot {d} port {d}: reset did not enable", .{ hub.slot_id, port });
return null;
}
const downstream_speed = (enabled >> 9) & 0x3; // wPortStatus: bit9 low-speed, bit10 high-speed
return self.setupDeviceBehindHub(hub, port, downstream_speed);
}
fn deviceOnHubPort(self: *Controller, hub: *Device, port: u16) ?*Device {
for (&self.devices) |*device| {
if (device.used and device.parent_slot == hub.slot_id and device.parent_port == port) return device;
}
return null;
}
/// Enable a slot and Address a device behind `hub` on downstream `port`, with
/// the composed route string, inherited root port, and TT fields (so the
/// controller routes split transactions through this hub's TT for a
/// full/low-speed device). Mirrors setupDevice for a root-port device.
fn setupDeviceBehindHub(self: *Controller, hub: *Device, port: u16, speed: u32) ?*Device {
const slot_id = self.enableSlot() orelse {
std.log.info("hub slot {d} port {d}: Enable Slot failed", .{ hub.slot_id, port });
return null;
};
const device = self.allocateDevice() orelse return null;
const child_speed = mapHubPortSpeed(speed);
device.* = .{
.used = true,
.slot_id = slot_id,
.port = hub.root_port,
.speed = child_speed,
.max_packet_size_0 = defaultMaxPacketSize0(child_speed),
.route = (hub.route << 4) | (port & 0xF),
.root_port = hub.root_port,
.parent_slot = hub.slot_id,
.parent_port = @intCast(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);
device.ep0_ring = .{ .region = dma.alloc(page_size, dma.coherent) orelse return self.abandon(device) };
device.ep0_ring.installLink();
device.control_buffer = dma.alloc(page_size, dma.coherent) orelse return self.abandon(device);
self.buildAddressInputContext(device);
const array: [*]volatile u64 = @ptrFromInt(self.device_context_array.virtual);
array[device.slot_id] = device.device_context.physical;
if (!self.addressDeviceCommand(device)) return self.abandon(device);
return device;
}
/// 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.
@@ -1264,9 +1418,20 @@ pub const Controller = struct {
if (!subscription.active or subscription.armed_trb_physical != trb_pointer) continue;
const code = completionCode(event.status);
if (code == @intFromEnum(CompletionCode.success) or code == @intFromEnum(CompletionCode.short_packet)) {
const residual = event.status & 0xFFFFFF;
const transferred: u16 = if (residual >= subscription.max_length) 0 else @intCast(subscription.max_length - residual);
self.enqueueReport(subscription, transferred);
if (subscription.hub) |hub_device| {
// Hub status-change report: OR the changed-port bitmap into
// the hub's pending mask (bit 0 = the hub itself, ignored;
// bit P = downstream port P). The control transfers to
// service it run on the bus tick, not here.
const bytes: [*]const u8 = @ptrFromInt(subscription.buffer.virtual);
var i: usize = 0;
while (i < subscription.max_length and i < 4) : (i += 1)
hub_device.hub_change_mask |= @as(u32, bytes[i]) << @intCast(i * 8);
} else {
const residual = event.status & 0xFFFFFF;
const transferred: u16 = if (residual >= subscription.max_length) 0 else @intCast(subscription.max_length - residual);
self.enqueueReport(subscription, transferred);
}
}
self.armInterrupt(subscription); // keep polling
return true;
+5 -1
View File
@@ -476,7 +476,11 @@ CASES = [
"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",
# B4a: the hub powers its ports. B4b: the keyboard behind it enumerates
# (route string + TT) and binds usb-hid-keyboard.
"expect": r"(?s)(?=.*hub slot \d+: \d+ downstream ports powered)"
r"(?=.*hub slot \d+ port \d+ device vendor 0x0627)"
r"(?=.*usb-hid-keyboard: ok \(device 3)",
"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