· 7 min read ·

USB Device Access from Userspace: What libusb Is Really Doing on Each Platform

Source: hackernews

Most USB devices work because someone already wrote a kernel driver. A keyboard sends HID reports; the OS has a built-in HID stack; no custom code is needed. The devices worth writing about are the ones that don’t fit: logic analyzers exposing vendor bulk endpoints, custom embedded hardware with bespoke protocols, SDR dongles using undocumented commands layered on top of a generic interface. Getting code talking to these devices requires working at a level the standard OS abstractions intentionally hide.

WerWolv’s article on USB for software developers walks through the fundamentals of writing a userspace USB driver. Instead of recapping it, I want to go one layer deeper: what libusb is actually doing on each platform, why the permissions story is so different across operating systems, and where the ecosystem is moving for new code.

USB Is a Host-Centric Protocol

Before getting into the userspace story, one fact shapes everything that follows. USB is fully host-initiated. The device never sends data unprompted; the host polls for it. Even “interrupt” endpoints (used by keyboards and mice) are just a polling interval promise from the host, not actual hardware interrupts. This matters because all transfer state lives in the host’s scheduler, which is why operating systems can arbitrate USB access between multiple drivers at all.

Every USB device presents a hierarchy of descriptors that describe what it is and how to talk to it. At the top is the device descriptor (18 bytes, containing the vendor ID and product ID), followed by configuration descriptors, interface descriptors, and endpoint descriptors. Parsing this tree is the first practical step when reverse engineering an unknown device.

With libusb, walking the descriptor tree looks like this:

libusb_device_handle *handle =
    libusb_open_device_with_vid_pid(ctx, 0x1234, 0x5678);

struct libusb_config_descriptor *config;
libusb_get_config_descriptor(dev, 0, &config);

for (int i = 0; i < config->bNumInterfaces; i++) {
    const struct libusb_interface_descriptor *iface =
        &config->interface[i].altsetting[0];
    for (int j = 0; j < iface->bNumEndpoints; j++) {
        const struct libusb_endpoint_descriptor *ep = &iface->endpoint[j];
        printf("EP 0x%02x: %s %s\n",
            ep->bEndpointAddress,
            (ep->bEndpointAddress & 0x80) ? "IN" : "OUT",
            (ep->bmAttributes & 0x3) == 2 ? "bulk" : "interrupt");
    }
}
libusb_free_config_descriptor(config);

The endpoint address encodes both direction (bit 7) and endpoint number (bits 0 to 3). A bulk IN endpoint at address 0x81 means endpoint 1, device-to-host, bulk transfer type.

What libusb Actually Does on Each OS

libusb is often described as a cross-platform USB library, which is true but undersells the engineering involved. On each platform, it wraps a different kernel interface that most documentation barely acknowledges.

Linux exposes USB devices through usbfs, mounted at /dev/bus/usb/<bus>/<device>. libusb opens this file and issues ioctls to submit URBs (USB Request Blocks) directly into the kernel’s USB subsystem. The key ioctls are USBDEVFS_SUBMITURB, USBDEVFS_REAPURB, USBDEVFS_CLAIMINTERFACE, and USBDEVFS_CONTROL. The permissions default to root-only, which means the first thing you need on Linux is a udev rule:

# /etc/udev/rules.d/99-mydevice.rules
SUBSYSTEM=="usb", ATTR{idVendor}=="1234", ATTR{idProduct}=="5678", GROUP="plugdev", MODE="0664"

Reload with udevadm control --reload-rules && udevadm trigger. If the device is currently plugged in, you also need to unplug and replug it. Getting this wrong produces LIBUSB_ERROR_ACCESS (-3), which looks like a driver problem when it is really a file permissions problem.

There is one more Linux-specific step for devices that already have a kernel driver loaded: you need to detach it before claiming the interface.

if (libusb_kernel_driver_active(handle, 0) == 1) {
    libusb_detach_kernel_driver(handle, 0);
}
libusb_claim_interface(handle, 0);

Without this, libusb_claim_interface returns LIBUSB_ERROR_BUSY and nothing in the error message explains why.

Windows requires more setup. libusb does not communicate through a generic device file; it requires a kernel-mode driver to be installed for each device. The recommended path is WinUSB (winusb.sys), a generic USB kernel driver that ships with Windows and provides a userspace-callable DLL. You either install an INF file associating your device with WinUSB, or the device firmware includes a Microsoft OS Descriptor that causes Windows to auto-install WinUSB on plug-in (the WCID mechanism). Zadig is the practical shortcut for development: it installs WinUSB, libusbK, or libusb-win32 for any device you select, without manually authoring INF files.

macOS uses IOKit. libusb wraps the IOUSBDeviceInterface plugin API, which requires opening the device through a chain of COM-style QueryInterface calls before you can access endpoints. macOS 10.15 added DriverKit as a replacement for IOKit kernel extensions; new USB drivers should use USBDriverKit and IOUSBHostInterface, which run in userspace as system extensions. libusb still targets the older IOKit API for broad compatibility, but the macOS USB story is gradually moving toward DriverKit as Apple continues deprecating kexts.

One macOS-specific constraint matters frequently in practice: the OS exclusively claims HID devices. If your device exposes a HID interface alongside a vendor interface, libusb can only claim the non-HID interfaces. For pure HID devices, hidapi (now maintained under the libusb GitHub organization) is the correct library; it uses the IOHIDManager API on macOS and bypasses this restriction entirely.

Choosing the Right Transfer Type

The four USB transfer types map to distinct application patterns. Picking the wrong one affects both correctness and performance.

Control transfers go to endpoint 0, which every device has. Use them for configuration commands, vendor-specific requests, and anything described by a bmRequestType + bRequest pair. They have guaranteed delivery and a standard three-phase structure (SETUP, DATA, STATUS). The bmRequestType byte encodes direction in bit 7, transfer type in bits 5 to 6 (standard/class/vendor), and recipient in bits 0 to 4. A value of 0xC0 means vendor-class, device-to-host, device recipient.

Bulk transfers carry large data with no timing guarantee. USB mass storage, logic analyzers, and most SDR dongles use bulk endpoints. The host and device negotiate packet sizes up to 64 bytes (full speed), 512 bytes (high speed), or 1024 bytes (SuperSpeed). libusb retransmits automatically on error, so from the application’s perspective, libusb_bulk_transfer either succeeds or times out.

Interrupt transfers are polled at a maximum interval specified in the endpoint descriptor’s bInterval field. The name is misleading: there is no hardware interrupt involved. The host simply guarantees it will poll within bInterval milliseconds (for full speed) or 2^(bInterval-1) microframes (for high speed). Keyboards, mice, and most HID devices use interrupt IN endpoints with bInterval values between 1 and 10.

Isochronous transfers deliver bandwidth guarantees with no retransmission on error. Audio devices and webcams use these because a dropped audio frame is preferable to a late one. libusb supports them through the async transfer API, filling an libusb_transfer struct with LIBUSB_TRANSFER_TYPE_ISOCHRONOUS and setting num_iso_packets.

Debugging Unknown Protocols

When working with an undocumented device, a USB traffic capture is the first tool to reach for. On Linux, load the usbmon kernel module and open the capture interface in Wireshark (sudo modprobe usbmon makes /dev/usbmonN available). On Windows, USBPcap adds a kernel capture driver that feeds directly into Wireshark.

Wireshark’s USB dissector decodes the standard enumeration sequence automatically and displays payload bytes for vendor-specific transfers. Filter by device address with usb.device_address == N to isolate traffic, then watch control transfers during enumeration to understand what the host requests. Look at the bulk or interrupt transfers during normal device operation next. Patterns in the data are usually visible without firmware reverse engineering: fixed header bytes, command codes in byte 0, length fields in bytes 1 to 2, and CRC fields at the end are common conventions.

USB in a NutShell from BeyondLogic remains the most useful reference for correlating raw capture bytes with the protocol specification, particularly for descriptor byte layouts and bmRequestType encoding.

Alternatives to libusb in 2025

For quick prototyping in Python, PyUSB wraps libusb with a clean interface. Device discovery, descriptor iteration, and endpoint reads collapse to a few lines:

import usb.core, usb.util

dev = usb.core.find(idVendor=0x1234, idProduct=0x5678)
dev.set_configuration()
cfg = dev.get_active_configuration()
intf = cfg[(0, 0)]

ep_in = usb.util.find_descriptor(intf,
    custom_match=lambda e:
        usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_IN)

data = ep_in.read(64, timeout=1000)

PyUSB’s backend system lets you swap between libusb 1.0, libusb 0.1, and OpenUSB without changing application code, which is useful when testing across OS environments.

For Rust, two options exist. rusb provides safe bindings to libusb 1.0 and inherits its cross-platform behavior and its C dependency. nusb is a newer library that implements USB access natively in Rust against each OS API directly, with no libusb dependency at all. nusb’s async API is designed around Tokio and fits naturally into applications that manage multiple devices concurrently or mix USB I/O with network I/O.

For anyone building open hardware and needing a VID/PID pair, pid.codes provides free PID allocations under VID 0x1209, which avoids the USB-IF membership fee for a dedicated vendor ID.

The Gap Between Documentation and Reality

Writing a userspace USB driver is not fundamentally difficult, but the knowledge required spans three operating systems, a protocol specification, kernel interfaces that are sparsely documented, and tools that do not always fail with informative errors. The libusb API documentation, the BeyondLogic reference, and a USB capture tool cover most of what you need.

The missing piece is usually operational rather than conceptual: the udev rule that was not created, the WinUSB installation step that the tutorial skipped, or the libusb_detach_kernel_driver call that needs to happen before libusb_claim_interface. These are not hard problems, but they produce error codes that point nowhere useful until you know what to look for.

Was this interesting?