Add a usbdevfs fd with the synchronous ioctls
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_RDONLYdescriptor isEPERM(devio.c:2605), which isFMODE_WRITEand not a permission check on the node. check_ctrlrecipruns before thewLengthcap (devio.c:1176-1181), andfindintfepbefore 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:
claimintfrefusesifnum >= 8 * sizeof(ps->ifclaimed)andifclaimedis anunsigned 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 auint64_t. DISCONNECT_CLAIManswersEINVALfor an interface the device does not have (devio.c:2467-2469), the opposite ofclaimintf'sENOENT.USBDEVFS_IOCTLwithCONNECTon an interface that already has a driver isEBUSY(devio.c:2357-2361); only a free one reachesdevice_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 isUSBDeviceReEnumerate, 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
ENODEVfor every ioctl. This still servesGET_SPEED,CONNECTINFO,GET_CAPABILITIESandread()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. GETDRIVERreports the IOKit class (AppleUSBACMControl) where Linux reports the driver name (cdc_acm), andDISCONNECT_CLAIM's name filters compare against it. A guest asking to detachcdc_acmby name will not match.- A short bulk OUT reports
EIO:WritePipeTOreturns no length, so the byte count Linux would report is not available. - The side table holds 32 descriptors per process and reports
ENOMEMpast 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
EBUSYonly 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_mballowance across in-flight transfers, refunding on every exit path. - Serves
read,pread,readv, andlseekfrom a per-open descriptor position and reportsfstatas USB char device189:minor. - Refuses
dup()and fork inheritance;O_PATHcontinues 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-reenumeratingRESET, short bulk OUT asEIO, the 32-fd side-table limit, and cross-fd disconnect behavior.