USB is one of those technologies that most developers treat as someone else’s problem. A device gets plugged in, a driver loads, and /dev/ttyUSB0 appears or the device shows up as a mass storage volume; the kernel handled it. The situation changes when the kernel has no driver for your device, or when you are building custom hardware and want to control it directly from an application without writing a kernel module. That is where userspace USB drivers become relevant.
A recent writeup by werwolv gives a grounded introduction to this territory, aimed at software developers who have never had reason to look at USB at the protocol level. It covers descriptors, endpoints, and how libusb lets you communicate with hardware without a kernel driver. What the article leaves as background is the full path from your code to the physical wire, and the architectural reasoning behind choosing userspace over kernel space for custom devices.
USB Is Host-Controlled
The foundational thing to understand about USB is that the host initiates all communication. Devices never send unsolicited data; they wait to be polled or commanded. Even interrupt endpoints, which look like device-initiated transfers, are polled by the host at a negotiated interval. Your userspace driver is always the one deciding when data moves.
The path from your code to the physical wire on Linux looks like this:
Your application
↓ libusb API
libusb library
↓ ioctl() on /dev/bus/usb/<bus>/<addr>
usbfs (kernel: drivers/usb/core/devio.c)
↓ usb_submit_urb()
USB Core → Host Controller Driver (xhci_hcd, ehci_hcd)
↓ TRBs written to hardware ring buffers
USB Host Controller (physical hardware)
On Linux, every USB device appears as a file under /dev/bus/usb/. libusb opens that file and sends ioctl calls, primarily USBDEVFS_SUBMITURB and USBDEVFS_REAPURB, to submit and collect USB transactions. The kernel USB subsystem handles the wire protocol and DMA mapping. You give libusb a buffer and an endpoint address; the kernel does the rest. There is no magic in the middle, just a well-specified sequence of system calls.
At the lowest level, the xHCI host controller (the standard controller for USB 3.x, though ehci handles USB 2.0) reads ring buffers of Transfer Request Blocks (TRBs) from memory via DMA. Each TRB contains a physical buffer pointer, a transfer length, and control flags. The completion interrupt fires when the hardware finishes, propagating back up through the xHCI driver, the USB core, usbfs, and finally to your libusb callback.
Descriptors: The Device’s Self-Description
Before any transfer can happen, the host reads a hierarchy of descriptors from the device during enumeration. These are what driver matching actually uses.
Device Descriptor
└─ Configuration Descriptor
└─ Interface Descriptor (one per logical function)
└─ Endpoint Descriptors (up to 15 IN + 15 OUT per interface)
An endpoint is the fundamental communication channel. Its address encodes both a number and a direction: 0x81 is IN endpoint 1 (bit 7 set means device-to-host), while 0x01 is OUT endpoint 1. Endpoint 0 is always the control endpoint, mandatory on every USB device, bidirectional, used for enumeration and configuration.
The bmAttributes field of an endpoint descriptor specifies its transfer type:
- Control: bidirectional, error-corrected, used for enumeration and vendor commands; reserved about 10% of bus bandwidth
- Bulk: high-throughput, error-corrected, no timing guarantees; used for mass storage and serial data; max packet 512 bytes at High Speed
- Interrupt: polled at a fixed interval, error-corrected; used for keyboards, mice, HID devices; max packet 1024 bytes at High Speed
- Isochronous: fixed bandwidth allocation, no error retransmission; used for audio and video
Isochronous transfers are the unusual one. They reserve bus bandwidth at interface claim time, which is why USB audio devices can maintain low, consistent latency without jitter from retransmission delays. The trade-off is that corrupted packets are simply dropped.
The device descriptor also carries the Vendor ID (idVendor) and Product ID (idProduct), the two 16-bit numbers that the host uses for driver selection. The device release number (bcdDevice) is vendor-defined. String descriptors at indices referenced by iManufacturer, iProduct, and iSerialNumber are encoded as UTF-16LE on the wire; libusb will convert them to ASCII for you via libusb_get_string_descriptor_ascii().
libusb in Practice
libusb 1.0 is the standard library for userspace USB access, distinct from the older and API-incompatible libusb 0.1. It is cross-platform, supporting Linux, macOS, Windows, and the BSDs, with bindings for Python, Rust, Go, and most other languages. The 1.0 API has been stable for over 15 years.
Opening a device and performing a bulk write:
#include <libusb.h>
libusb_context *ctx = NULL;
libusb_init(&ctx);
libusb_device_handle *handle = libusb_open_device_with_vid_pid(ctx, 0x1234, 0x5678);
if (!handle) {
fprintf(stderr, "Device not found or permission denied\n");
return 1;
}
// Detach any kernel driver that already claimed this interface
if (libusb_kernel_driver_active(handle, 0)) {
libusb_detach_kernel_driver(handle, 0);
}
libusb_claim_interface(handle, 0);
unsigned char data[] = { 0x01, 0x02, 0x03 };
int transferred = 0;
int r = libusb_bulk_transfer(handle, 0x01, data, sizeof(data), &transferred, 1000);
if (r != LIBUSB_SUCCESS) {
fprintf(stderr, "%s\n", libusb_error_name(r));
}
libusb_release_interface(handle, 0);
libusb_close(handle);
libusb_exit(ctx);
The endpoint address 0x01 is OUT endpoint 1. Reading from the device uses 0x81 and the buffer is populated on return. The transferred parameter gives the actual byte count, which can be less than requested on a short packet. The timeout is in milliseconds; passing 0 means no timeout.
Error codes are negative integers. LIBUSB_ERROR_ACCESS (-3) is the most common first failure. LIBUSB_ERROR_PIPE (-9) means the endpoint stalled and needs to be cleared with libusb_clear_halt(). libusb_error_name() and libusb_strerror() convert codes to human-readable strings.
If you prefer not to call libusb_detach_kernel_driver() manually, libusb_set_auto_detach_kernel_driver(handle, 1) enables automatic detachment at claim time and reattachment at release time.
The Permissions Layer on Linux
Every new libusb user on Linux eventually hits LIBUSB_ERROR_ACCESS. The device file at /dev/bus/usb/<bus>/<device> is owned by root by default. A udev rule resolves this:
# /etc/udev/rules.d/99-mydevice.rules
SUBSYSTEM=="usb", ATTRS{idVendor}=="1234", ATTRS{idProduct}=="5678", \
MODE="0664", GROUP="plugdev"
Add your user to plugdev, then apply the rule:
sudo udevadm control --reload-rules && sudo udevadm trigger
Reconnect the device and the file permissions will match. On systemd-based systems, TAG+="uaccess" is an alternative to group membership; it grants the currently logged-in session user access automatically. lsusb lists all connected devices with their VID and PID in the format ID <VID>:<PID>, which is all you need to write the rule.
For debugging, udevadm info --name=/dev/bus/usb/001/003 --attribute-walk shows all sysfs attributes available for matching, including manufacturer string and serial number.
Userspace vs. Kernel: A Practical Decision
A kernel driver registers with the USB subsystem via usb_register() and is called when a matching device connects. It allocates URBs (USB Request Blocks, the kernel’s data structure for a single USB I/O operation) and submits them directly into the USB core, skipping the usbfs round-trip. It can also expose standard kernel interfaces: a CDC-ACM device becomes /dev/ttyACM0; a HID device appears under /dev/input.
The costs are concrete. Bugs in kernel code can panic the machine or corrupt memory silently. The kernel driver API is not stable across versions; out-of-tree drivers break when internal APIs change around them. Modules require signing for Secure Boot. You cannot use most of the standard C library, dynamic allocation requires GFP flags, and most debugging tools do not reach into kernel context.
Userspace drivers with libusb avoid all of that. A bug crashes your process, not the machine. You can use gdb, valgrind, address sanitizers, and printf freely. No signing is required. Development iteration is fast.
The constraint is that you cannot present standard kernel interfaces without additional infrastructure. For custom hardware, test equipment, software-defined radios, protocol analyzers, and debuggers, that constraint rarely applies. OpenOCD uses libusb to talk to over 100 JTAG and SWD debug adapters without a single kernel module. sigrok drives logic analyzers entirely via libusb, including uploading firmware to Cypress FX2-based devices using vendor control transfers at startup:
// FX2 firmware upload: write firmware bytes to device RAM via control transfer
libusb_control_transfer(handle,
LIBUSB_REQUEST_TYPE_VENDOR | LIBUSB_ENDPOINT_OUT,
0xA0, // FX2 firmware load vendor request
addr & 0xFFFF, // wValue = low 16 bits of address
addr >> 16, // wIndex = high bits
data, len, 1000);
HackRF uses libusb for both device configuration (via control transfers) and IQ sample streaming at up to 20 MSPS (via bulk endpoints). The Flipper Zero uses VID 0x0483 and PID 0x5740 in normal mode, presenting as CDC-ACM, so ordinary serial tools handle it; but its DFU mode (PID 0xDF11) is accessed via libusb for firmware updates.
Windows: WinUSB and Zadig
On Windows, the equivalent of usbfs is WinUSB, a Microsoft-provided kernel driver with a user-mode API. libusb supports Windows by talking to WinUSB.sys through its Windows backend; the libusb API is identical across platforms.
The friction point is driver association. Where Linux needs a udev rule, Windows needs the correct driver bound to the device. Zadig is the standard tool for this: it replaces whatever driver Windows currently associates with a selected device with WinUSB.sys (or libusb-win32 or libusbK, depending on your needs). RTL-SDR, HackRF, and most SDR hardware documentation lists running Zadig as the mandatory first Windows setup step for exactly this reason.
Firmware can avoid the manual step by returning Microsoft OS 2.0 descriptors during enumeration. These tell Windows to install WinUSB automatically, without any user action, which is how modern USB devices ship with plug-and-play userspace access on Windows.
VID, PID, and Device Identity
Every USB device carries a 16-bit Vendor ID and Product ID. VIDs are assigned by the USB Implementers Forum and cost approximately $6,500 as a one-time fee, granting access to all 65,536 PIDs under that VID. That is appropriate for commercial products but not for open hardware projects.
pid.codes operates a shared open VID (0x1209) with community-assigned PIDs at no cost. Most open-source hardware projects use this for a stable, non-conflicting identity. During development, 0xFFFF/0xFFFF or any unused combination works; just be aware that other hardware uses the same numbers and udev rules may fire unexpectedly.
The Async API for Sustained Throughput
The synchronous libusb_bulk_transfer works well for request-response patterns. For sustained data streams, submitting one transfer and waiting for it to complete before submitting the next creates gaps in throughput. libusb’s asynchronous API keeps multiple transfers in flight simultaneously:
struct libusb_transfer *transfer = libusb_alloc_transfer(0);
libusb_fill_bulk_transfer(transfer, handle, 0x81,
buffer, BUFFER_SIZE, rx_callback, user_data, 1000);
libusb_submit_transfer(transfer);
// Drive completions:
while (running) {
libusb_handle_events(ctx);
}
// In rx_callback:
static void rx_callback(struct libusb_transfer *t) {
if (t->status == LIBUSB_TRANSFER_COMPLETED) {
process_samples(t->buffer, t->actual_length);
}
libusb_submit_transfer(t); // resubmit immediately
}
Resubmitting from within the completion callback keeps the endpoint pipeline continuously occupied. The HackRF library uses this pattern, allocating a pool of transfers and keeping them all in flight to sustain the maximum bulk throughput the USB 2.0 bus allows for radio sample streaming.
Isochronous transfers are only available through the async API, using libusb_fill_iso_transfer() with a packet count. Each isochronous packet in the transfer has its own status and actual length, because the host controller services them on a fixed schedule regardless of whether the device produced data for every slot.
The werwolv.net article covers the conceptual foundation well. The libusb API reference and the source of projects like OpenOCD and sigrok fill in the production details. USB is a protocol with a well-defined structure; once you have a working mental model of descriptors and endpoints, writing a driver for new hardware is primarily a matter of reading the device datasheet and translating its communication protocol into the appropriate sequence of control, bulk, or interrupt transfers over a libusb handle.