Most software developers treat USB as kernel territory and stop there. The mental model is: hardware needs a driver, drivers live in the kernel, kernels are not for application programmers. That model is wrong, or at least incomplete. A large class of USB work, talking to custom hardware, writing vendor tools, building device simulators, reverse-engineering embedded gadgets, can be done entirely from userspace with a standard C library and no kernel module in sight.
WerWolv’s introduction to userspace USB drivers is a good on-ramp, written by the author of ImHex from the perspective of someone who actually needed to talk to hardware while building application-layer tooling. Rather than recap it, I want to go deeper on some of the architectural choices and cross-platform realities that trip people up once they get past “hello USB.”
libusb Is Not Bypassing the Kernel
The first misconception worth clearing up is what libusb actually does. It does not skip the kernel. It uses kernel-provided passthrough mechanisms to let userspace code issue raw USB transactions.
On Linux, that mechanism is usbfs, a virtual filesystem mounted at /dev/bus/usb/. Each device node exposes a set of ioctl calls (USBDEVFS_SUBMITURB, USBDEVFS_REAPURB, USBDEVFS_CLAIMINTERFACE, and others) that libusb wraps into a consistent API. On Windows, libusb sits on top of WinUSB, a Microsoft-provided kernel driver that ships with Windows and exposes raw USB I/O to userspace via WinUsb.dll. On macOS, it uses the IOKit framework, specifically IOUSBLib (or the newer IOUSBHost API introduced in macOS 12).
The implication is that the kernel still arbitrates access. You are not doing arbitrary hardware access; you are using a privileged but well-defined interface that the OS exposes for exactly this purpose. The stability and security properties that come from that are real: a crash in your code is an application crash, not a kernel panic.
The tradeoff is permission management. On Linux you need a udev rule to grant non-root access:
# /etc/udev/rules.d/99-mydevice.rules
SUBSYSTEM=="usb", ATTRS{idVendor}=="1234", ATTRS{idProduct}=="5678", MODE="0666"
On Windows, you need the device to be using WinUSB as its kernel driver rather than a vendor-supplied one. Zadig is the tool for this: it replaces whatever driver is installed with WinUSB, libusbK, or libusb-win32. For development that is fine. For shipping to end users it means either asking them to run Zadig, embedding a WinUSB INF file in your installer, or, better, having the device firmware emit a Microsoft OS Descriptor so Windows auto-installs WinUSB without user intervention.
Reading the Descriptor Tree
Before you transfer any data, you need to understand what the device exposes. USB uses a hierarchical descriptor structure that functions as a self-describing contract between device and host.
At the top is the device descriptor: one per device, containing the vendor ID, product ID, USB spec version, and indices into string descriptors for human-readable names. Below that are configuration descriptors (most devices have one), which contain interface descriptors, which contain endpoint descriptors.
Endpoints are the actual communication channels. Every device has endpoint zero (EP0), a bidirectional control endpoint used for enumeration and standard requests. Beyond EP0, devices declare additional endpoints with a number, a direction (IN means device-to-host, OUT means host-to-device), a transfer type, and a max packet size.
With libusb:
struct libusb_device_descriptor desc;
libusb_get_device_descriptor(dev, &desc);
printf("VID: %04x PID: %04x\n", desc.idVendor, desc.idProduct);
struct libusb_config_descriptor *config;
libusb_get_config_descriptor(dev, 0, &config);
for (int i = 0; i < config->bNumInterfaces; i++) {
const struct libusb_interface *iface = &config->interface[i];
const struct libusb_interface_descriptor *altsetting = &iface->altsetting[0];
printf("Interface %d: class %02x\n", i, altsetting->bInterfaceClass);
for (int e = 0; e < altsetting->bNumEndpoints; e++) {
const struct libusb_endpoint_descriptor *ep = &altsetting->endpoint[e];
printf(" EP 0x%02x type %d maxpacket %d\n",
ep->bEndpointAddress,
ep->bmAttributes & 0x03,
ep->wMaxPacketSize);
}
}
libusb_free_config_descriptor(config);
This is the first thing to run against any unfamiliar device. The bInterfaceClass field tells you whether the device speaks a standard class protocol (0x03 = HID, 0x08 = mass storage, 0x02 = CDC/ACM serial) or is a vendor-specific device (0xFF). Standard class means the OS has a built-in driver and a defined protocol. Vendor-specific means you need to find or reverse-engineer the protocol.
Transfer Types in Practice
The USB spec defines four transfer types, and choosing the wrong one for a given operation wastes bandwidth or breaks timing guarantees.
Control transfers use EP0 and carry a structured 8-byte SETUP packet followed by an optional data stage. They are guaranteed delivery with retries. Everything you do during enumeration is control transfers. Vendor-defined control transfers are a clean way to send configuration commands to custom hardware without consuming extra endpoints.
Bulk transfers are for large, reliable data movement with no timing guarantees. Mass storage uses bulk. There is no reserved bandwidth; the host fills whatever bandwidth is available. Good for firmware updates, file transfers, instrument readouts where you care about correctness but not latency.
Interrupt transfers are misnamed. They are polled, not interrupt-driven from the host’s perspective. The host polls the device at a bounded interval (set by bInterval in the endpoint descriptor). Guaranteed latency, small packets, used almost exclusively by HID devices. Keyboards, mice, gamepads all use interrupt IN endpoints to report state changes.
Isochronous transfers reserve bandwidth and deliver data at a fixed rate with no retry on error. Audio devices use isochronous. One practical warning: isochronous support in libusb is solid on Linux and macOS but historically broken or unsupported on Windows via WinUSB. If you need isochronous on Windows, libusbK (also installable via Zadig) is the better backend.
HID: Use HIDAPI Instead
If the device you are talking to is HID class, skip raw libusb and use HIDAPI instead. It provides a purpose-built, higher-level API for HID devices and does the right thing on each platform: uses the kernel hidraw driver on Linux, IOHIDManager on macOS, and the Windows HID API directly on Windows. You do not need Zadig or WinUSB for HID devices on Windows with HIDAPI because it uses the existing system HID driver.
hid_init();
hid_device *dev = hid_open(0x1234, 0x5678, NULL);
unsigned char buf[65]; // 64 bytes + 1 for report ID
buf[0] = 0x00; // report ID (0 if not used)
buf[1] = 0x01; // payload
hid_write(dev, buf, 65);
int len = hid_read_timeout(dev, buf, sizeof(buf), 1000);
hid_close(dev);
hid_exit();
HIDAPI is maintained under the libusb GitHub organization now, following a transfer of stewardship in 2022 after the original signal11/hidapi repo went dormant. The codebase is stable and the platform coverage is reliable.
For HID devices, there is one extra layer to understand: the report descriptor. This is a compact bytecode, stored as a class-specific descriptor on the device, that defines the structure and meaning of every input, output, and feature report. Reading it requires either a parser (the HID Usage Tables spec defines all the symbolic names) or a tool like the USB.org HID Descriptor Tool. The report descriptor is what lets a gamepad vendor define custom axis ranges and button mappings without any driver code on the host side.
Debugging: Wireshark Over USB
When protocol documentation is incomplete or absent, capturing USB traffic is the fastest path forward. On Linux, load the usbmon kernel module and open the USB interface in Wireshark (usbmon0 captures all buses). On Windows, install USBPcap which adds USB capture support to Wireshark.
A capture session while running the vendor’s official software is often the fastest way to reverse-engineer a proprietary protocol. You will see the descriptor read sequence on enumeration, then the actual control and bulk/interrupt transfers the application sends. Wireshark dissects standard class requests automatically and labels them. For vendor-specific traffic you get raw bytes, but the structure of when requests happen relative to user actions usually makes the protocol readable quickly.
When a Kernel Driver Is Actually Necessary
Userspace is not always the right answer. A kernel driver makes sense when:
- You need DMA for high-bandwidth isochronous streams (USB 3 video, multi-channel audio at sample rates above 96kHz).
- The device needs to be usable without a logged-in user (system services, boot-time hardware).
- You are building an OS-visible device class (a new audio device that shows up in the sound control panel, a storage device that mounts, a network adapter that gets a network interface).
- Latency on interrupt transfers must be sub-millisecond and you cannot afford the extra syscall roundtrip through usbfs.
For anything else, the USB in a NutShell reference plus libusb gets you there without the kernel build environment, module signing ceremonies, or DKMS packaging. The kernel API instability that breaks out-of-tree modules between releases is a real maintenance burden that userspace simply avoids.
For Rust developers, nusb is worth noting: it is a pure-Rust async USB library with no libusb dependency, using platform-native APIs directly. It avoids the C FFI layer entirely and integrates naturally with async runtimes. The rusb crate is the older safe wrapper around libusb if you need compatibility with existing libusb code or platform coverage that nusb has not reached yet.
The mental shift that makes USB accessible is recognizing that the kernel-userspace boundary is not a wall but a membrane. The kernel provides the transport; libusb gives you controlled access to it. What happens above that is just application code.