Add a usbdevfs fd with the synchronous ioctls

Merged
JO
2mo ago
+4171-37

NOTE: piece C of the USB layer described in #319, on top of #334 and #351. It adds the usbdevfs descriptor and the synchronous half of its ioctl surface. Transfers that need the asynchronous engine (SUBMITURB, REAPURB, DISCARDURB) are the next piece and are not here; this one answers ENOTTY for them and reports no URB capability bits, which is what makes that honest.

Summary

A guest that opens /dev/bus/usb/BBB/DDD gets a descriptor whose file contract and ioctl surface follow drivers/usb/core/devio.c: the permission answers, the write family, the file position, the argument validation the kernel does before it touches the wire, and the synchronous operations mapped onto IOUSBDeviceInterface650 and IOUSBInterfaceInterface800. CLAIMINTERFACE, RELEASEINTERFACE, SETINTERFACE, SETCONFIGURATION, CONTROL, BULK, CLEAR_HALT, RESETEP, RESET, GETDRIVER, DISCONNECT_CLAIM, IOCTL, CONNECTINFO, GET_SPEED and GET_CAPABILITIES are implemented.

Driver arbitration is per object and dynamic, not per device

The one design point worth stating up front, because it changed after measurement. macOS arbitration is not a property of a device class that can be decided in advance. A device-level open succeeds whenever the bound driver holds no exclusive device open, and an interface-level open succeeds unless that specific driver currently holds that specific interface. So the layer attempts the open and maps the IOKit result rather than refusing by class.

Measured on an attached ESP32-S3 (303a:1001), with a native IOKit probe run at the same moment as the guest call:

nothing holding /dev/cu.usbmodem1101:
  USBInterfaceOpen(interface 1, bound to AppleUSBACMData) = 0x00000000
  CLAIMINTERFACE 1                                        = 0
the tty held:
  USBInterfaceOpen(interface 1)                           = kIOReturnExclusiveAccess
  CLAIMINTERFACE 1                                        = -EBUSY
interface 0 (CDC control, AppleUSBACMControl)             = -EBUSY either way
interface 2 (vendor class, no matching driver)            = 0 either way

An earlier revision refused interface 1 outright because a driver was bound to it, which refused work macOS allows. A tool that talks to a board over the vendor interface while the CDC tty stays available to the rest of the system is the case this serves.

What the argument validation follows

Each of these is a devio.c line rather than a guess, and each has an assertion in the new lane.

  • Every ioctl on an O_RDONLY descriptor is EPERM (devio.c:2605), which is FMODE_WRITE and not a permission check on the node.
  • check_ctrlrecip runs before the wLength cap (devio.c:1176-1181), and findintfep before the transfer-length checks (devio.c:1289-1298), so an absent endpoint outranks a malformed length rather than the other way round.
  • The reserved-bit test on an endpoint address (devio.c:860) lives in the one lookup all four endpoint ioctls share.
  • The interface-number bound is 64, not 32: claimintf refuses ifnum >= 8 * sizeof(ps->ifclaimed) and ifclaimed is an unsigned long (devio.c:75, :785). Between 32 and 64 an interface is merely absent, which is a question about the device, so the claimed mask is a uint64_t.
  • DISCONNECT_CLAIM answers EINVAL for an interface the device does not have (devio.c:2467-2469), the opposite of claimintf's ENOENT.
  • USBDEVFS_IOCTL with CONNECT on an interface that already has a driver is EBUSY (devio.c:2357-2361); only a free one reaches device_attach.

Deviations, recorded rather than implied

These are printed by the lane as XFAIL rows carrying both values, so they stay visible in its own output rather than living only in a commit message.

  • RESET: Linux re-enumerates the port. IOKit's equivalent is USBDeviceReEnumerate, which is an unplug that tears down the user client, so this clears the claimed pipes' stalls and returns 0 instead.
  • Once a device is gone, Linux answers ENODEV for every ioctl. This still serves GET_SPEED, CONNECTINFO, GET_CAPABILITIES and read() from the model captured at open, because noticing the departure needs an IOKit termination notification on a run loop, which is the next piece's machinery.
  • GETDRIVER reports the IOKit class (AppleUSBACMControl) where Linux reports the driver name (cdc_acm), and DISCONNECT_CLAIM's name filters compare against it. A guest asking to detach cdc_acm by name will not match.
  • A short bulk OUT reports EIO: WritePipeTO returns no length, so the byte count Linux would report is not available.
  • The side table holds 32 descriptors per process and reports ENOMEM past that, where Linux has no such limit.

Tests

tests/test-usbdev-ioctl.c is a new lane, 54 assertions, that needs no hardware: it runs against ELFUSE_USB_FIXTURE, whose devices are modeled but have no IOKit service behind them, which is exactly the shape of a device the model knows and the machine cannot reach. It covers the half of usbdevfs decided before the wire. Run without the fixture it prints its unmet precondition and exits rather than reporting assertions that failed for the wrong reason.

The half that needs a device, a real claim and a real transfer and arbitration against a bound host driver, is verified on the attached board and is not pretended to be covered by the lane.

Evidence

make check BAREMETAL_CROSS=aarch64-elf-   All 98 tests passed, exit 0
test-usbdev-ioctl                          54 passed, 0 failed
test-usb-sysfs                            138 passed, 0 failed
test-usb-sysfs-matrix                     288 passed, 0 failed
test-epoll, test-fcntl-flags, test-dir-fd-budget   OK

Summary by cubic

Implements the synchronous usbdevfs portion described in #319. Writable opens of /dev/bus/usb/BBB/DDD previously returned EACCES; they now create a typed fd that serves the descriptor blob and maps synchronous ioctls to IOKit, while async URBs remain ENOTTY with no capability bits. Wire access resolves lazily, so modeled devices can open without a matching host service and report ENODEV only when an operation needs hardware.

Behavior

  • Supports interface claims, alternate settings, configuration, control and bulk transfers, pipe stalls, driver queries, disconnect/attach, speed, and connection information.
  • Attempts interface arbitration through IOKit, returning EBUSY only while the interface is held.
  • Preserves Linux ioctl and write-family permission gates, validation order, endpoint checks, interface bounds, and errno mappings.
  • Enforces the shared 16 MB usbfs_memory_mb allowance across in-flight transfers, refunding on every exit path.
  • Serves read, pread, readv, and lseek from a per-open descriptor position and reports fstat as USB char device 189:minor.
  • Refuses dup() and fork inheritance; O_PATH continues using the existing path-only fd.

Tests and known gaps

  • Adds a hardware-free 92-assertion ioctl lane plus fault runs for allocation, out-of-range interface numbers, and fd-publication races.
  • Board checks cover claim arbitration, transfers, stalls, timeouts, driver reporting, and validation edge cases.
  • XFAILs record missing disconnect detection, serialized ioctls per fd, IOKit class names in GETDRIVER, non-reenumerating RESET, short bulk OUT as EIO, the 32-fd side-table limit, and cross-fd disconnect behavior.