USB Without Kernel Code: libusb, hidapi, nusb, and the Platform Gaps Between Them
Source: hackernews
The gap between a USB cable plugging in and your code receiving bytes is filled by a protocol that most software developers never need to touch directly. That changes when you build hardware tooling, write a firmware flasher, or reverse-engineer a device that shipped without documentation. WerWolv’s USB primer for software developers covers the mental model shift from file-based I/O to structured protocol communication. This post goes further: the full userspace USB tooling landscape in 2026, the platform-specific friction you will encounter in practice, and where the ecosystem has been moving.
The Protocol Layer You Are Talking To
When you open a device with libusb, you are not reading from a file descriptor. You are speaking the USB protocol through a kernel passthrough, and the protocol has structure worth understanding before touching any API.
Every USB device exposes a descriptor tree at enumeration time. The hierarchy looks like this:
Device Descriptor
bcdUSB: 0x0200 (USB 2.0)
idVendor: 0x1234
idProduct: 0x5678
Configuration Descriptor
Interface Descriptor (Interface 0)
Endpoint Descriptor: EP 0x01 OUT (bulk, 64 bytes max)
Endpoint Descriptor: EP 0x81 IN (bulk, 64 bytes max)
The device descriptor carries the vendor and product IDs. Interfaces correspond to logical functions on the device; a composite device like a smartphone might expose a mass storage interface and a CDC serial interface simultaneously. Endpoints are the actual data channels.
Endpoint addresses encode both number and direction in a single byte. The high bit is the direction flag: 0 means host-to-device (OUT), 1 means device-to-host (IN). So 0x01 is endpoint 1 OUT and 0x81 is endpoint 1 IN. This encoding is why libusb bulk transfer calls take the endpoint address directly rather than separate number and direction arguments.
Transfer types determine delivery guarantees and latency profile:
| Type | Use case | Error retry | Timing guarantee |
|---|---|---|---|
| Control | Enumeration, vendor commands | Yes | No |
| Bulk | Mass storage, data transfer | Yes | No |
| Interrupt | HID polling, low-latency events | Yes | Bounded latency |
| Isochronous | Audio, video streaming | No | Yes |
For most custom hardware projects, bulk transfers are the right choice: they guarantee delivery through error detection and retry, fill available bandwidth without reserving it, and support up to 512 bytes per packet at High Speed (USB 2.0). lsusb -v prints the full descriptor tree for any connected device and is the first tool to reach for when approaching unfamiliar hardware.
libusb: The Standard Approach
libusb is the canonical C library for userspace USB access. The 1.0 series was rewritten in 2007-2008 to fix fundamental API problems in the 0.1 line, adding a proper async API and cross-platform support for Windows and macOS. The current stable release is 1.0.27. The basic synchronous sequence is consistent across every language binding built on top of it:
libusb_context *ctx;
libusb_init(&ctx);
libusb_device_handle *handle =
libusb_open_device_with_vid_pid(ctx, 0x0483, 0x5740);
if (libusb_kernel_driver_active(handle, 0) == 1)
libusb_detach_kernel_driver(handle, 0);
libusb_claim_interface(handle, 0);
unsigned char cmd[] = { 0xA0, 0x01, 0x00, 0x00 };
int transferred;
libusb_bulk_transfer(handle, 0x01, cmd, sizeof(cmd), &transferred, 1000);
unsigned char buf[64];
libusb_bulk_transfer(handle, 0x81, buf, sizeof(buf), &transferred, 1000);
libusb_release_interface(handle, 0);
libusb_close(handle);
libusb_exit(ctx);
The synchronous transfer API is fine for scripts and tooling. For production code with isochronous streams or multiple concurrent endpoints, libusb provides an async API around libusb_transfer structs driven by libusb_handle_events(), which avoids blocking threads without requiring separate pthreads per endpoint.
One confusing ecosystem detail: Debian and Ubuntu package both libusb-dev (the old 0.1 series) and libusb-1.0-0-dev (the current series) under similar names. New code should always depend on the 1.0 package explicitly.
The Kernel Driver Problem
The most common stumbling block on Linux is EBUSY from libusb_claim_interface(). When a device announces membership in a standard USB class, HID, CDC, or mass storage, the kernel binds a driver to it at enumeration time. You cannot claim an interface that a kernel driver owns. The fix is libusb_detach_kernel_driver(), which sends an IOCTL_USB_DISCONNECT to the kernel and temporarily unbinds the driver for that interface. This requires elevated privileges, though udev configuration can relax that for specific devices.
The second barrier is access to the device node itself. USB devices appear at /dev/bus/usb/<bus>/<address> with root:root 660 permissions by default. Non-root access requires a udev rule:
SUBSYSTEM=="usb", ATTR{idVendor}=="0483", ATTR{idProduct}=="5740", MODE="0666", GROUP="plugdev"
After adding the file to /etc/udev/rules.d/: sudo udevadm control --reload && sudo udevadm trigger. This step is mentioned in most tutorials but underemphasized; it is the root cause of the majority of libusb permission denied reports.
Platform-Specific Friction
The Linux access model is workable once the udev rule is in place. macOS is simpler: IOKit handles access without requiring a kernel driver detach for most device classes, with HID being the notable exception.
Windows presents a different problem entirely. The OS has no generic USB passthrough by default. Every device needs a kernel-mode driver bound to it in the driver store. WinUSB, Microsoft’s generic driver for proxying userspace USB requests, is the standard solution; installing it requires Zadig, a GUI tool that generates a WinUSB INF and installs it for a selected device. This replaces whatever driver Windows had attached, often usbccgp for composite devices. For software shipped to end users, this installation step is a genuine deployment problem. A user running your firmware flasher on Windows needs to install a driver before anything works, a step that library-level portability claims do not help with.
For composite devices, Zadig can install WinUSB per-interface rather than replacing the device-level driver, which matters when you only need to claim one interface and want the rest to continue functioning normally.
There is also a subtlety with VID/PID identification worth noting. The USB Implementers Forum charges over $6,000 per year for a vendor ID assignment, so many small and open-source hardware projects share VIDs. The Objective Development free VID 0x16c0 with product ID 0x05dc, for instance, is used by hundreds of unrelated devices. VID/PID alone is not a reliable device identifier in the wild; serial numbers from string descriptors are necessary to distinguish specific units.
The Language Ecosystem
PyUSB wraps libusb-1.0 with a Pythonic API and is the fastest path to scripting USB interaction:
import usb.core
dev = usb.core.find(idVendor=0x1234, idProduct=0x5678)
dev.set_configuration()
dev.write(0x01, [0x01, 0x02, 0x03])
data = dev.read(0x81, 64)
# Vendor control transfer
dev.ctrl_transfer(0x40, 0x01, 0x0000, 0x0000, [])
result = dev.ctrl_transfer(0xC0, 0x02, 0x0000, 0x0000, 8)
hidapi takes a different approach entirely. Rather than sitting on libusb, it uses platform-native HID APIs: hidraw or the libusb backend on Linux, IOKit HID on macOS, and the Win32 HID API on Windows. The Win32 path matters because it bypasses the Zadig requirement completely; HID devices on Windows work with hid_open(vid, pid, NULL) and no driver installation. Many custom devices deliberately use the HID class for this reason, accepting the 64-byte report structure constraint in exchange for zero-driver-install deployment across platforms. hidapi moved under the libusb organization on GitHub in 2022 and remains actively maintained.
node-usb provides Node.js bindings for libusb-1.0 via N-API, useful for Electron-packaged hardware tools like firmware flashers. It includes a getWebUSBDevice() wrapper that presents a node-usb device through a WebUSB-compatible interface, enabling code sharing between browser and desktop implementations targeting the same hardware.
nusb is the most notable recent development in the Rust ecosystem. Unlike rusb, which wraps libusb in safe Rust bindings but still requires libusb as a system dependency, nusb is a pure-Rust library with no C dependency. It uses OS APIs directly: WinUSB on Windows, usbfs on Linux, IOKit on macOS. The API is async-native with tokio compatibility and has cleaner error types than the C API surface. nusb reached stable release in late 2023 and has been gaining adoption in Rust-based embedded tooling and firmware flashers. For new Rust projects requiring USB access, it is the better default over rusb.
WebUSB: The Browser Path and Its Limits
WebUSB shipped in Chrome 61 in 2017 with a compelling pitch: USB device access in the browser, no driver installation. The API mirrors libusb’s structure closely, with device.open(), device.claimInterface(), device.transferIn(), and device.transferOut(). For developer tooling and firmware flashers targeting Chrome users specifically, it works well within its constraints.
Those constraints are significant. Firefox explicitly refused to implement WebUSB, citing security concerns, leaving roughly 30% of desktop users inaccessible by browser choice. HID class devices are blocked entirely in Chrome’s implementation, as are audio class devices. Isochronous transfers are unsupported. The API requires HTTPS or localhost and operates only while the tab is active. For anything requiring broad cross-browser deployment or streaming data, WebUSB does not cover the use case.
The device-side requirement is also worth knowing: for automatic behavior where Chrome redirects users to a landing page when a device is plugged in, the firmware must include a WebUSB platform capability descriptor in the device’s BOS (Binary Object Store) descriptor containing the landing page URL. Without it, users select the device manually through the browser permission dialog, but there is no automatic redirection.
Debugging With Protocol Captures
Reverse-engineering a device without documentation means capturing what the original driver sends. On Linux, loading the usbmon kernel module and opening Wireshark gives you full packet capture of USB traffic with URBs, endpoint addresses, transfer types, and raw payloads visible through Wireshark’s USB dissectors. On Windows, USBPcap provides equivalent functionality and integrates with the same Wireshark dissectors. Running a capture while the manufacturer’s software communicates with the device reveals the command structure, which endpoints receive requests, and what responses look like. Replicating that protocol in libusb or PyUSB is then straightforward.
Practical Starting Points
For a new project where you control both sides of the USB connection, the decision tree is short: use HID if your data fits within 64-byte reports and you need zero driver installation on Windows; use bulk endpoints via libusb (or nusb for Rust) if you need higher throughput or more flexible framing; use hidapi rather than libusb if your device is HID class regardless of platform; skip WebUSB unless you specifically need browser deployment on Chrome.
The libusb API documentation covers the full async transfer API for production workloads. The udev rule on Linux and the Zadig step on Windows are the two platform-specific setup items that consume most of the initial time. Beyond that, the descriptor tree from lsusb -v and a Wireshark USB capture are the two artifacts that answer most questions about an unfamiliar device before you write a single line of driver code.