· 7 min read ·

Userspace USB Drivers: Descriptors, Transfer Types, and the Platform Differences That Actually Matter

Source: hackernews

The USB stack has always been one of those topics where the documentation is technically complete but practically impenetrable. The official specs run to hundreds of pages, kernel driver documentation assumes you are already inside the kernel, and most tutorials jump straight to copy-paste libusb code without explaining the model you are actually operating in. This introduction by werwolv is a welcome exception, giving a clean walkthrough of the USB landscape for developers who work primarily at the application layer. This post builds on that foundation by going deeper on the parts that matter most in practice: the descriptor hierarchy, transfer type selection, platform differences, and how to debug USB protocols without a logic analyzer.

The Descriptor Hierarchy Is the Mental Model

Before writing a single line of libusb code, it helps to understand what you are navigating when you parse USB descriptors. The USB spec defines a strict hierarchy: a device has one or more configurations, each configuration has one or more interfaces, and each interface has one or more endpoints. Most devices have one configuration, one interface, and two or three endpoints. But the hierarchy matters because libusb’s API is organized around it.

When you call libusb_get_config_descriptor(), you get a libusb_config_descriptor struct containing an array of libusb_interface objects. Each interface contains an array of alternate settings (mostly relevant for isochronous bandwidth negotiation), and each alternate setting contains an array of libusb_endpoint_descriptor objects. That endpoint descriptor is what you actually need: it tells you the endpoint address (which encodes both number and direction), the transfer type, and the maximum packet size.

libusb_config_descriptor *config;
libusb_get_config_descriptor(device, 0, &config);

for (int i = 0; i < config->bNumInterfaces; i++) {
    const libusb_interface *iface = &config->interface[i];
    const libusb_interface_descriptor *alt = &iface->altsetting[0];
    for (int e = 0; e < alt->bNumEndpoints; e++) {
        const libusb_endpoint_descriptor *ep = &alt->endpoint[e];
        printf("Endpoint 0x%02x, type %d, max packet %d\n",
               ep->bEndpointAddress,
               ep->bmAttributes & 0x03,
               ep->wMaxPacketSize);
    }
}

libusb_free_config_descriptor(config);

Endpoint address 0x81 means endpoint 1, direction IN (device to host). 0x02 means endpoint 2, direction OUT. The direction bit is 0x80. If you are getting LIBUSB_ERROR_INVALID_PARAM on transfers, wrong endpoint direction is frequently the cause. The bmAttributes field’s low two bits give the transfer type: 0 is control, 1 is isochronous, 2 is bulk, 3 is interrupt.

The device descriptor also carries bDeviceClass, bDeviceSubClass, and bDeviceProtocol, though for many modern devices these are set to zero and the class is declared at the interface level instead. Class 0x03 is HID. Class 0x02 is CDC (Communications Device Class, used for virtual serial ports). Class 0xFF is vendor-defined, meaning no standard protocol and you need the manufacturer’s documentation or captured traffic to understand the wire format.

Four Transfer Types, Four Different Contracts

USB defines four transfer types and each one makes a different guarantee. Confusing them is a reliable source of bugs.

Control transfers are the only bidirectional type. Every USB device supports them on endpoint zero and the host uses them during enumeration to read descriptors. You use them for vendor-specific commands, mode switches, or anything that follows a request-response pattern with small payloads. libusb_control_transfer() is the synchronous wrapper. The bmRequestType field encodes direction, type (standard, class, or vendor), and recipient (device, interface, or endpoint) in three separate bit fields:

int ret = libusb_control_transfer(
    dev_handle,
    LIBUSB_REQUEST_TYPE_VENDOR | LIBUSB_RECIPIENT_DEVICE | LIBUSB_ENDPOINT_OUT,
    0x01,   /* bRequest: vendor-defined command */
    0x0000, /* wValue */
    0x0000, /* wIndex */
    data_buf,
    data_len,
    1000    /* timeout ms */
);

Bulk transfers deliver data reliably but without timing guarantees. The USB stack retries on error and there is no reserved bus bandwidth. FTDI-based USB serial adapters, mass storage, and custom firmware data channels all use bulk. libusb_bulk_transfer() with the correct endpoint address covers the common case. The actual_length output parameter matters: devices are not required to send length bytes in a single transfer, and short reads are valid.

Interrupt transfers have a guaranteed polling interval and bounded latency. HID devices use them. Despite the name, there are no hardware interrupts on the host side; the host controller polls the device at the interval specified in bInterval. Maximum packet size is 64 bytes at full speed and 1024 bytes at high speed. libusb_interrupt_transfer() has the same signature as the bulk variant.

Isochronous transfers allocate fixed bus bandwidth and run every frame with no error recovery. Dropped packets are acceptable; consistent timing is not. USB audio streaming is the canonical use case. They are only available through libusb’s asynchronous API. You allocate a libusb_transfer with libusb_alloc_transfer(n_iso_packets), fill each ISO packet descriptor with the expected size, set a completion callback, submit with libusb_submit_transfer(), and drive the event loop with libusb_handle_events(). If you need isochronous transfers, expect the code to be substantially more involved than the bulk case.

Linux, Windows, macOS: Three Different Models

The userspace USB experience differs considerably across platforms, and libusb is doing significant abstraction work to hide this.

On Linux, devices appear under /dev/bus/usb/BBB/DDD (bus and device numbers). Access goes through the usbfs filesystem via ioctl. The key complication is that the kernel may have already attached a driver to your device. If you are trying to access a USB serial adapter that usbserial or cdc_acm has claimed, libusb_claim_interface() will return LIBUSB_ERROR_BUSY. The fix is libusb_detach_kernel_driver(), which requires elevated privileges unless you configure udev:

SUBSYSTEMS=="usb", ATTRS{idVendor}=="1234", ATTRS{idProduct}=="5678", MODE="0666", GROUP="plugdev"

Place this in /etc/udev/rules.d/99-mydevice.rules and run udevadm control --reload-rules && udevadm trigger. This is the correct long-term solution; running as root or chmod 777 on device nodes are both poor practice.

On Windows, generic USB access requires WinUSB, a kernel driver Microsoft ships as part of Windows that exposes a user-mode API. Devices must be associated with WinUSB via an INF file, or via Zadig, a GUI tool that installs WinUSB for arbitrary devices. The libusb Windows backend uses WinUSB by default (or the older libusb-win32 backend for compatibility). Once WinUSB is installed for a device, libusb_open() works normally.

On macOS, libusb wraps IOKit. There is no usbfs equivalent; all access goes through the IOKit object model. Modern macOS versions enforce entitlement requirements for USB access in sandboxed applications. This rarely affects command-line tools but matters if you are shipping a macOS app through the App Store or using hardened runtime.

When hidapi Is the Better Choice

For HID devices specifically, hidapi is a simpler alternative that handles platform differences internally. The API is minimal:

hid_device *handle = hid_open(vendor_id, product_id, NULL);
hid_write(handle, output_buf, output_len);
int n = hid_read(handle, input_buf, max_len);
hid_close(handle);

On Linux, hidapi uses /dev/hidraw* by default, which sidesteps the kernel driver conflict because the HID kernel driver and hidraw allow concurrent access from userspace without detaching the driver. On Windows it uses the native HID API. On macOS it uses IOHIDManager.

The tradeoff is capability: hidapi handles HID report descriptors and interrupt transfers but you cannot send arbitrary control transfers or access non-HID endpoints. For keyboards, mice, custom measurement hardware, and anything that uses HID report descriptors, hidapi is the right tool. For devices with a vendor-specific protocol over bulk endpoints, you need libusb directly.

Debugging Without a Logic Analyzer

The most practical skill in USB development is reading captured traffic. On Linux, usbmon is a kernel subsystem that exposes bus traffic through debugfs:

modprobe usbmon
# debugfs is usually already mounted at /sys/kernel/debug
wireshark -i usbmon1   # bus 1

Wireshark’s USB dissector parses descriptors, decodes transfer types, and displays payload bytes. The display filter usb.idVendor == 0x1234 narrows traffic to your device. Watching a capture, you want to see the control transfers at session start (descriptor enumeration), followed by the pattern of bulk or interrupt transfers that constitute your device’s protocol. Many vendor-specific protocols follow a simple request-response pattern over two bulk endpoints: one OUT for commands, one IN for responses. Once you see that pattern in Wireshark, reproducing it with libusb is mechanical.

On Windows, USBPcap provides similar functionality and integrates directly with Wireshark. On macOS, USB Prober (part of Apple’s Additional Tools for Xcode) handles descriptor inspection, and Instruments has a USB Device I/O template for traffic analysis.

For cases where you have an existing working driver and want to reverse-engineer the protocol, capturing from a known-good system (often Windows with the vendor driver) and then reimplementing in libusb on Linux is a common and effective approach.

The Actual Scope of the Problem

Writing a userspace USB driver is protocol implementation work, not driver development in the traditional sense. You are not managing interrupt service routines, DMA mappings, or kernel memory. You are writing code that speaks a specific protocol over a transport that happens to be USB. The libusb setup ceremony is real but bounded: a working device open and claim sequence is about 20 lines of C. After that, you are parsing responses and constructing requests, which is ordinary application programming.

The parts that take time are understanding your device’s protocol (which is a documentation and reverse engineering problem, not a USB problem) and navigating platform-specific setup on the first device you target. Once you have worked through the udev rules on Linux or Zadig on Windows once, those paths are clear.

The werwolv article frames the entry point well. USB is a transport. The descriptor hierarchy tells you what the transport looks like. The transfer types tell you what guarantees the transport provides. libusb gives you access to all of it from ordinary C without writing a kernel module. For the vast majority of custom hardware projects, embedded programmers, and diagnostic tools, userspace is exactly where you want to be.

Was this interesting?