USB driver stack: xHCI transfers, HID keyboard/mouse, mass storage

Flesh out the xHCI host-controller driver into a full transfer engine and build
the three USB class drivers on top, all verified end to end under QEMU.

- xHCI engine (usb-xhci-library.zig): controller reset, command/event rings with
  cycle-bit bookkeeping (gated on a No-Op-command proof), device slots, Address
  Device, control transfers, full chapter-9 enumeration, Configure Endpoint, and
  interrupt/bulk transfers. Each interface is device_registered with its
  (class,subclass,protocol) identity, unique per (port,interface).
- Bus<->class transfer protocol (usb-transfer-protocol.zig + runtime.usb): open /
  control / interrupt-subscribe (async report pump on a poll timer) / bulk-by-
  physical-address, so sector data never crosses the 256-byte IPC limit.
- USB HID keyboard + mouse (usb-hid/): decode boot-protocol reports and publish
  to the input service. A USB usage is already the input protocol's keycode.
- USB mass storage (usb-storage/): Bulk-Only Transport + transparent SCSI,
  serving a block device under the new .block service id (block-protocol).
- device-manager matches USB interfaces to class drivers (usbDriverForIdentity).
- usb-abi / usb-ids made importable modules; add HID and mass-storage class
  requests, packTriple, and a usb_device DeviceClass.
- Fix test/qemu_test.py on macOS: the QMP unix-socket path was built from the
  deep worktree path and exceeded the 104-byte sun_path limit, so QEMU exited
  before booting. It now lives under a short temp path.

Tests: usb-report, usb-hid, usb-storage pass under python3 test/qemu_test.py;
host units (usb-abi, usb-ids, hid-report, bulk-only-transport, scsi) green.
This commit is contained in:
Daniel Samson
2026-07-13 14:11:00 +01:00
parent 452080e997
commit 3fb9d5936a
21 changed files with 2856 additions and 110 deletions
@@ -0,0 +1,73 @@
//! USB Mass Storage Bulk-Only Transport (BOT) wire structures — the Command and
//! Command Status Wrappers that bracket every command (USB MSC BOT §5). Pure data
//! definitions, host-testable in isolation. The command inside the CBW is a SCSI
//! CDB (see scsi.zig); the transport here just carries it and reports status.
//!
//! One command is three bulk transfers: CBW out, an optional data stage, CSW in.
const std = @import("std");
/// "USBC" — the signature at the head of every Command Block Wrapper.
pub const cbw_signature: u32 = 0x43425355;
/// "USBS" — the signature at the head of every Command Status Wrapper.
pub const csw_signature: u32 = 0x53425355;
/// CBW `flags`: set for a device-to-host (IN) data stage, clear for OUT.
pub const flag_data_in: u8 = 0x80;
/// The 31-byte Command Block Wrapper, sent on the bulk-OUT endpoint.
pub const CommandBlockWrapper = extern struct {
signature: u32 align(1) = cbw_signature,
tag: u32 align(1),
data_transfer_length: u32 align(1),
flags: u8,
lun: u8,
cdb_length: u8,
cdb: [16]u8 = [_]u8{0} ** 16,
};
/// A device's answer to a command (the CSW `status` byte).
pub const CommandStatus = enum(u8) {
passed = 0,
failed = 1,
phase_error = 2,
_,
};
/// The 13-byte Command Status Wrapper, read from the bulk-IN endpoint.
pub const CommandStatusWrapper = extern struct {
signature: u32 align(1) = csw_signature,
tag: u32 align(1),
data_residue: u32 align(1),
status: u8,
};
comptime {
std.debug.assert(@sizeOf(CommandBlockWrapper) == 31);
std.debug.assert(@sizeOf(CommandStatusWrapper) == 13);
}
test "wrapper sizes and signatures match the specification" {
const cbw = CommandBlockWrapper{
.tag = 0x11223344,
.data_transfer_length = 512,
.flags = flag_data_in,
.lun = 0,
.cdb_length = 10,
};
const bytes = std.mem.asBytes(&cbw);
try std.testing.expectEqual(@as(usize, 31), bytes.len);
// "USBC" little-endian.
try std.testing.expectEqualSlices(u8, "USBC", bytes[0..4]);
try std.testing.expectEqual(flag_data_in, bytes[12]);
const csw = std.mem.bytesToValue(CommandStatusWrapper, &[_]u8{
0x55, 0x53, 0x42, 0x53, // "USBS"
0x44, 0x33, 0x22, 0x11, // tag
0x00, 0x00, 0x00, 0x00, // residue
0x00, // passed
});
try std.testing.expectEqual(csw_signature, csw.signature);
try std.testing.expectEqual(@as(u32, 0x11223344), csw.tag);
try std.testing.expectEqual(@as(u8, @intFromEnum(CommandStatus.passed)), csw.status);
}
+86
View File
@@ -0,0 +1,86 @@
//! The SCSI command descriptor blocks a transparent-SCSI (subclass 0x06) mass
//! storage device understands, and the parsers for what they return. Pure data —
//! host-testable. These CDBs go inside a Bulk-Only-Transport CBW (see
//! bulk-only-transport.zig).
//!
//! Every multi-byte SCSI field is **big-endian** — the opposite of the USB wire
//! ABI — so the LBA and transfer-length encodings are the load-bearing detail.
const std = @import("std");
// SCSI operation codes.
const op_test_unit_ready: u8 = 0x00;
const op_request_sense: u8 = 0x03;
const op_inquiry: u8 = 0x12;
const op_read_capacity_10: u8 = 0x25;
const op_read_10: u8 = 0x28;
const op_write_10: u8 = 0x2A;
/// INQUIRY: standard device data (36 bytes: peripheral type, removable, vendor
/// and product strings).
pub fn inquiry(allocation_length: u8) [6]u8 {
return .{ op_inquiry, 0, 0, 0, allocation_length, 0 };
}
/// TEST UNIT READY: no data; success (CSW passed) means the unit is ready.
pub fn testUnitReady() [6]u8 {
return .{ op_test_unit_ready, 0, 0, 0, 0, 0 };
}
/// REQUEST SENSE: 18 bytes of sense data (sense key + ASC/ASCQ) explaining the
/// previous failure.
pub fn requestSense(allocation_length: u8) [6]u8 {
return .{ op_request_sense, 0, 0, 0, allocation_length, 0 };
}
/// READ CAPACITY(10): 8 bytes back — the last LBA and the block size, both u32
/// big-endian. Block count is last_lba + 1.
pub fn readCapacity10() [10]u8 {
return .{ op_read_capacity_10, 0, 0, 0, 0, 0, 0, 0, 0, 0 };
}
/// READ(10): read `blocks` logical blocks starting at `lba` into the data stage.
pub fn read10(lba: u32, blocks: u16) [10]u8 {
var cdb = [_]u8{0} ** 10;
cdb[0] = op_read_10;
std.mem.writeInt(u32, cdb[2..6], lba, .big);
std.mem.writeInt(u16, cdb[7..9], blocks, .big);
return cdb;
}
/// WRITE(10): write `blocks` logical blocks starting at `lba` from the data stage.
pub fn write10(lba: u32, blocks: u16) [10]u8 {
var cdb = [_]u8{0} ** 10;
cdb[0] = op_write_10;
std.mem.writeInt(u32, cdb[2..6], lba, .big);
std.mem.writeInt(u16, cdb[7..9], blocks, .big);
return cdb;
}
/// Decode an 8-byte READ CAPACITY(10) reply.
pub fn parseCapacity(bytes: [8]u8) struct { last_lba: u32, block_size: u32 } {
return .{
.last_lba = std.mem.readInt(u32, bytes[0..4], .big),
.block_size = std.mem.readInt(u32, bytes[4..8], .big),
};
}
test "read/write CDBs encode the LBA and length big-endian" {
const read = read10(0x01020304, 8);
try std.testing.expectEqualSlices(u8, &.{ 0x28, 0x00, 0x01, 0x02, 0x03, 0x04, 0x00, 0x00, 0x08, 0x00 }, &read);
const write = write10(0xAABBCCDD, 1);
try std.testing.expectEqualSlices(u8, &.{ 0x2A, 0x00, 0xAA, 0xBB, 0xCC, 0xDD, 0x00, 0x00, 0x01, 0x00 }, &write);
try std.testing.expectEqual(@as(u8, 0x25), readCapacity10()[0]);
try std.testing.expectEqual(@as(u8, 0x12), inquiry(36)[0]);
try std.testing.expectEqual(@as(u8, 36), inquiry(36)[4]);
try std.testing.expectEqual(@as(u8, 0x00), testUnitReady()[0]);
}
test "read capacity parses last LBA and block size" {
// last_lba = 0x0003FFFF (262144 blocks), block_size = 512.
const capacity = parseCapacity(.{ 0x00, 0x03, 0xFF, 0xFF, 0x00, 0x00, 0x02, 0x00 });
try std.testing.expectEqual(@as(u32, 0x0003FFFF), capacity.last_lba);
try std.testing.expectEqual(@as(u32, 512), capacity.block_size);
}
+179
View File
@@ -0,0 +1,179 @@
//! USB mass-storage class driver (Bulk-Only Transport + transparent SCSI).
//!
//! Spawned by the device manager when the xHCI bus driver reports a mass-storage
//! / SCSI / bulk-only interface (class 8, subclass 6, protocol 0x50); its device
//! id arrives as argv[1]. It owns no hardware: it opens its device through the
//! USB transfer protocol (`runtime.usb`), then drives it with the BOT command
//! cycle — CBW out, an optional data stage, CSW in — carrying SCSI commands
//! (READ CAPACITY, READ(10), WRITE(10)). Upward it is a block device: it serves
//! the block protocol under `.block`, the storage a FAT filesystem sits on.
//!
//! Block data never crosses IPC: read/write name a caller-owned DMA buffer by
//! physical address, which the data stage DMAs straight to/from.
const std = @import("std");
const runtime = @import("runtime");
const scsi = @import("scsi.zig");
const bot = @import("bulk-only-transport.zig");
const block_protocol = @import("block-protocol");
const dma = runtime.dma;
fn writeLine(comptime fmt: []const u8, arguments: anytype) void {
var line: [128]u8 = undefined;
_ = runtime.system.write(std.fmt.bufPrint(&line, fmt, arguments) catch return);
}
var device_id: u64 = 0;
var device: runtime.usb.Device = undefined;
var bulk_in: runtime.usb.Endpoint = undefined;
var bulk_out: runtime.usb.Endpoint = undefined;
// DMA buffers for the transport: the 31-byte CBW, the 13-byte CSW, and a page
// for the small command data (INQUIRY / READ CAPACITY / the self-check sector).
var command_wrapper: dma.Region = undefined;
var status_wrapper: dma.Region = undefined;
var command_data: dma.Region = undefined;
var next_tag: u32 = 1;
var block_size: u32 = 512;
var block_count: u64 = 0;
/// One Bulk-Only-Transport command: send the CBW, run the data stage (to/from
/// `data_physical`), read and validate the CSW. Returns true on a passed status.
fn transact(cdb: []const u8, direction_in: bool, data_physical: u64, data_length: u32) bool {
const tag = next_tag;
next_tag +%= 1;
const wrapper: *bot.CommandBlockWrapper = @ptrFromInt(command_wrapper.virtual);
wrapper.* = .{
.tag = tag,
.data_transfer_length = data_length,
.flags = if (direction_in) bot.flag_data_in else 0,
.lun = 0,
.cdb_length = @intCast(cdb.len),
};
@memcpy(wrapper.cdb[0..cdb.len], cdb);
if (device.bulk(bulk_out.address, command_wrapper.physical, @sizeOf(bot.CommandBlockWrapper)) == null) return false;
if (data_length > 0) {
const endpoint = if (direction_in) bulk_in.address else bulk_out.address;
if (device.bulk(endpoint, data_physical, data_length) == null) return false;
}
if (device.bulk(bulk_in.address, status_wrapper.physical, @sizeOf(bot.CommandStatusWrapper)) == null) return false;
const status: *const bot.CommandStatusWrapper = @ptrFromInt(status_wrapper.virtual);
if (status.signature != bot.csw_signature or status.tag != tag) return false;
return status.status == @intFromEnum(bot.CommandStatus.passed);
}
fn initialise(endpoint: runtime.ipc.Handle) bool {
_ = endpoint;
if (!runtime.usb.helloManager(device_id)) {
_ = runtime.system.write("/system/drivers/usb-storage: hello to device manager failed\n");
return false;
}
device = runtime.usb.open(device_id) orelse {
writeLine("/system/drivers/usb-storage: could not open device {d}\n", .{device_id});
return false;
};
bulk_in = device.findEndpoint(runtime.usb.transfer_type_bulk, true) orelse {
_ = runtime.system.write("/system/drivers/usb-storage: no bulk-IN endpoint\n");
return false;
};
bulk_out = device.findEndpoint(runtime.usb.transfer_type_bulk, false) orelse {
_ = runtime.system.write("/system/drivers/usb-storage: no bulk-OUT endpoint\n");
return false;
};
command_wrapper = dma.alloc(4096, dma.coherent) orelse return false;
status_wrapper = dma.alloc(4096, dma.coherent) orelse return false;
command_data = dma.alloc(4096, dma.coherent) orelse return false;
// Bring the LUN up: wait for it to be ready (clearing the initial unit-attention
// with REQUEST SENSE), identify it, and read its capacity.
var tries: u32 = 0;
while (tries < 10) : (tries += 1) {
const ready = scsi.testUnitReady();
if (transact(&ready, false, 0, 0)) break;
const sense = scsi.requestSense(18);
_ = transact(&sense, true, command_data.physical, 18);
runtime.system.sleep(50);
}
const inquiry = scsi.inquiry(36);
_ = transact(&inquiry, true, command_data.physical, 36);
const capacity_command = scsi.readCapacity10();
if (!transact(&capacity_command, true, command_data.physical, 8)) {
_ = runtime.system.write("/system/drivers/usb-storage: READ CAPACITY failed\n");
return false;
}
var capacity_bytes: [8]u8 = undefined;
const capacity_source: [*]const u8 = @ptrFromInt(command_data.virtual);
@memcpy(&capacity_bytes, capacity_source[0..8]);
const capacity = scsi.parseCapacity(capacity_bytes);
block_size = capacity.block_size;
block_count = @as(u64, capacity.last_lba) + 1;
writeLine("/system/drivers/usb-storage: ready ({d} blocks x {d} bytes)\n", .{ block_count, block_size });
// Self-check: read block 0 and log its trailing signature (0x55AA for a boot
// sector) — proof READ(10) works end to end over the bulk path.
const read0 = scsi.read10(0, 1);
if (block_size <= 4096 and transact(&read0, true, command_data.physical, block_size)) {
const sector: [*]const u8 = @ptrFromInt(command_data.virtual);
writeLine("/system/drivers/usb-storage: block 0 signature 0x{x:0>2}{x:0>2}\n", .{ sector[510], sector[511] });
}
return true;
}
/// Serve the block protocol: geometry, and whole-block read/write to/from the
/// caller's DMA buffer (named by physical address).
fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?runtime.ipc.Handle) usize {
_ = sender;
_ = capability;
if (message.len < block_protocol.request_size) return 0;
const request = std.mem.bytesToValue(block_protocol.Request, message[0..block_protocol.request_size]);
switch (request.operation) {
@intFromEnum(block_protocol.Operation.geometry) => {
return writeReply(reply, .{ .status = 0, .block_size = block_size, .block_count = block_count });
},
@intFromEnum(block_protocol.Operation.read) => {
const count: u16 = @intCast(request.count);
const cdb = scsi.read10(@intCast(request.lba), count);
const ok = transact(&cdb, true, request.physical, request.count * block_size);
return writeReply(reply, .{ .status = if (ok) 0 else -1, .block_size = block_size, .block_count = if (ok) request.count else 0 });
},
@intFromEnum(block_protocol.Operation.write) => {
const count: u16 = @intCast(request.count);
const cdb = scsi.write10(@intCast(request.lba), count);
const ok = transact(&cdb, false, request.physical, request.count * block_size);
return writeReply(reply, .{ .status = if (ok) 0 else -1, .block_size = block_size, .block_count = if (ok) request.count else 0 });
},
else => return 0,
}
}
fn writeReply(reply: []u8, value: block_protocol.Reply) usize {
const bytes = std.mem.asBytes(&value);
@memcpy(reply[0..bytes.len], bytes);
return bytes.len;
}
pub fn main(init: runtime.process.Init) void {
const argument = init.arguments.get(1) orelse {
_ = runtime.system.write("/system/drivers/usb-storage: missing device id (argv[1])\n");
return;
};
device_id = std.fmt.parseInt(u64, argument, 10) catch {
writeLine("/system/drivers/usb-storage: malformed device id '{s}'\n", .{argument});
return;
};
runtime.service.run(block_protocol.message_maximum, .{
.service = .block,
.init = initialise,
.on_message = onMessage,
});
}
pub const panic = runtime.panic;
comptime {
_ = &runtime.start._start;
}