USB Is Not Kernel-Only: A Software Developer's Path to Writing Userspace Drivers
Source: hackernews
Most developers encounter USB as a black box. You plug something in, an OS driver loads, and a device file or COM port appears. The assumption that touching USB requires kernel code, specialized firmware knowledge, or years of embedded experience keeps a lot of people from ever exploring this space. That assumption is largely wrong.
Werwolv’s recent deep-dive into userspace USB driver writing lays out the essentials clearly. But the article is a starting point worth building on. The USB specification has a reputation for being impenetrable, the tooling ecosystem is fragmented across platforms, and the mental model most developers carry from “just use the library” is too thin to debug real problems. This post tries to fill in those gaps.
The USB Descriptor Hierarchy Is Your Mental Model
Before writing a single line of driver code, you need to internalize how USB devices describe themselves. The specification defines a strict tree of descriptors, each one parsed by the host during enumeration. Getting this hierarchy wrong is the source of most early confusion.
At the root sits the device descriptor, which identifies the manufacturer and product via VID (Vendor ID) and PID (Product ID), states the USB specification version the device targets, and points to configuration descriptors. This is what you use in libusb to open a specific device: libusb_open_device_with_vid_pid(ctx, 0x1234, 0x5678).
Below the device descriptor are one or more configuration descriptors. Most consumer devices have exactly one, but some devices expose a low-power and a high-power configuration. Selecting a configuration is a required step before claiming any interface: libusb_set_configuration(handle, 1).
Each configuration contains one or more interface descriptors. An interface represents a logical function of the device. A USB headset, for example, typically exposes three interfaces: one for audio streaming from the host to the headset, one for audio streaming from the microphone back to the host, and one for HID controls like volume buttons. Interfaces are what you claim exclusive access to before doing real transfers: libusb_claim_interface(handle, 0).
Finally, each interface exposes one or more endpoint descriptors. Endpoints are the actual communication channels. Endpoint 0 is always the default control endpoint and is shared across the device. All other endpoints are numbered 1 through 15, with a direction bit making them either IN (device to host) or OUT (host to device). The address byte in the descriptor encodes both the number and the direction, so endpoint 0x81 means endpoint 1, direction IN.
You can inspect this entire tree for any connected USB device on Linux with:
lsusb -v -d 1234:5678
On macOS, system_profiler SPUSBDataType gives a slightly friendlier view. On Windows, USBView ships with the WDK and shows the same descriptor tree visually.
Transfer Types Determine Everything About Your API Usage
USB defines four transfer types, and choosing the wrong mental model for each one causes bugs that are extremely hard to trace.
Control transfers are synchronous request-response pairs used for device configuration and standard commands. They go over endpoint 0 and are the only transfer type guaranteed to be available on every USB device. Every enumeration sequence uses them. In libusb, a synchronous control transfer looks like:
uint8_t data[64];
int transferred = libusb_control_transfer(
handle,
LIBUSB_REQUEST_TYPE_VENDOR | LIBUSB_RECIPIENT_DEVICE | LIBUSB_ENDPOINT_IN,
0x01, // bRequest
0x0000, // wValue
0x0000, // wIndex
data,
sizeof(data),
1000 // timeout ms
);
The bmRequestType byte is a bitfield that combines direction, request type (standard, class, or vendor), and recipient (device, interface, endpoint, or other). Getting that byte wrong is the single most common mistake when implementing vendor-specific control commands.
Bulk transfers have no timing guarantees but are guaranteed delivery. They use spare bus bandwidth and can be throttled to near zero when the bus is busy. USB mass storage runs on bulk transfers. In practice, for most non-latency-sensitive applications sending significant amounts of data, bulk is what you want. The libusb synchronous bulk API is simple:
int actual;
int r = libusb_bulk_transfer(handle, 0x01, buf, len, &actual, 0);
The 0 timeout means wait indefinitely. For production code, always pass a real timeout. The actual parameter tells you how many bytes were actually transferred, which may be less than len if the device short-packets.
Interrupt transfers are polled at a fixed interval, expressed in the endpoint descriptor’s bInterval field. The name is misleading from a software perspective; there is no interrupt delivered to your userspace code. The host controller polls the endpoint at the specified interval, and if the device has data, it sends it. HID devices (keyboards, mice, gamepads) exclusively use interrupt transfers. The latency bound is bInterval milliseconds, which is why gaming peripherals often use 1ms polling intervals.
Isochronous transfers provide guaranteed bandwidth and fixed timing with no error retransmission. They are used for audio and video streaming where dropped data is preferable to latency. These are the hardest to work with in libusb because they require the asynchronous API and careful attention to the transfer size math.
The Asynchronous API Is Not Optional for Real Work
Libusb ships both synchronous and asynchronous transfer APIs. The synchronous wrappers like libusb_bulk_transfer are convenient for getting started, but they block the calling thread for the duration of the transfer. For any application doing concurrent device communication, handling events from multiple devices, or dealing with interrupt endpoints that need continuous polling, the synchronous API will not work.
The async API centers on libusb_submit_transfer and an event loop driven by libusb_handle_events. You allocate a transfer with libusb_alloc_transfer, fill in the endpoint, buffer, callback, and timeout, submit it, then call libusb_handle_events from a dedicated thread:
void transfer_callback(struct libusb_transfer *transfer) {
if (transfer->status == LIBUSB_TRANSFER_COMPLETED) {
process_data(transfer->buffer, transfer->actual_length);
}
// Resubmit for continuous polling
libusb_submit_transfer(transfer);
}
// In the event loop thread:
while (running) {
libusb_handle_events(ctx);
}
The resubmit pattern in the callback is the standard way to maintain a continuous stream from an interrupt or bulk IN endpoint. If you forget to resubmit and the device keeps sending, you will get LIBUSB_TRANSFER_OVERFLOW errors and the device will appear to hang.
Platform Differences Are the Real Friction
Libusb abstracts the host controller interface, but the user-visible friction points differ per platform in ways the library cannot fully hide.
On Linux, the kernel’s usbfs filesystem is what libusb uses under the hood, mounted at /dev/bus/usb. By default, device nodes are owned by root. To use libusb from a regular user account without sudo, you need a udev rule:
SUBSYSTEM=="usb", ATTRS{idVendor}=="1234", ATTRS{idProduct}=="5678", MODE="0666"
Place this in /etc/udev/rules.d/99-mydevice.rules and reload rules with udevadm control --reload-rules. Linux also requires that no kernel driver be bound to the interface you want to claim. For devices that match a generic kernel driver (like hid or cdc_acm), you must detach the kernel driver first: libusb_detach_kernel_driver(handle, interface_number). Forgetting this step results in a LIBUSB_ERROR_BUSY on libusb_claim_interface.
On macOS, IOKit is the native USB framework. Libusb on macOS wraps IOKit and generally works without extra configuration for vendor-specific devices. HID-class devices are handled by IOHIDManager instead, and libusb cannot claim them without first unloading the HID driver, which requires a kernel extension or an entitlement on recent macOS versions. For HID devices specifically, HIDAPI is a better choice because it uses the native HID APIs on each platform and avoids the driver-conflict problem entirely.
On Windows, libusb requires a WinUSB, libusbK, or libusb-win32 driver to be installed for the target device. The standard tool for this is Zadig, which installs the appropriate driver with a few clicks. For distribution to end users, you would embed this driver installation in your installer using the WinUSB INF generator or a tool like libwdi. The Windows HID story is the same as macOS: use HIDAPI for HID-class devices.
Language Bindings Make This Accessible
Writing libusb code directly in C is not the only option. Most major languages have bindings or higher-level wrappers.
PyUSB wraps libusb with a Pythonic interface. The same device open and bulk transfer in Python looks like:
import usb.core
import usb.util
dev = usb.core.find(idVendor=0x1234, idProduct=0x5678)
dev.set_configuration()
cfg = dev.get_active_configuration()
intf = cfg[(0, 0)]
ep_out = usb.util.find_descriptor(intf, custom_match=lambda e:
usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_OUT)
ep_out.write(b'hello')
node-usb provides the same for Node.js. For Rust, rusb is a safe wrapper around libusb with idiomatic ownership semantics.
For browser-based tooling, WebUSB exposes a subset of the libusb model via a JavaScript API. It is limited to secure contexts, requires the device’s descriptor to have a BOS descriptor with the WebUSB platform capability, and is blocked by default on most enterprise Chromium deployments. It works well for consumer web applications that need to talk to a manufacturer’s hardware, but it is not a substitute for libusb in general-purpose tooling.
When Userspace Drivers Are and Are Not the Right Choice
Userspace USB drivers via libusb are appropriate when you need to talk to a device from an application, ship cross-platform tools, or prototype device communication quickly. They are the right choice for test harnesses that exercise device firmware, for manufacturer utilities that configure or update devices, and for applications that need to support a specific piece of hardware without writing platform-specific kernel code.
They are not appropriate when you need to expose the device to the rest of the operating system as a standard peripheral. A userspace driver cannot make a USB device appear as a serial port that other applications can open, or as an audio device in the system mixer. For that, you need a kernel driver (on Linux, a loadable .ko module; on macOS, a KEXT or DriverKit extension; on Windows, a WDF or KMDF driver). DriverKit on macOS and UMDF on Windows have pushed some of this functionality back toward userspace in recent years, but the complexity remains substantially higher than a libusb application.
For most software developers who encounter a device they need to talk to, the libusb path is the right starting point. Understand the descriptor tree, identify the endpoints you need, get lsusb -v output from the target device, and build incrementally from control transfers to bulk or interrupt transfers. The kernel is not required.