· 6 min read ·

Writing USB Drivers Without Writing a Kernel Module

Source: hackernews

Most developers hear “USB driver” and assume kernel territory: C modules, pointer arithmetic in interrupt handlers, endless reboots from OOPS panics. The reality is more forgiving. A useful introductory guide to userspace USB development circulated on Hacker News recently, and it’s a solid entry point. But the subject has more depth worth unpacking, particularly around the mental model of what “userspace” means here, why the descriptor hierarchy is the real learning curve, and how the tooling has evolved in the last few years.

What “Userspace Driver” Actually Means

The kernel still handles everything at the wire level. USB host controller interrupts, DMA transfers, low-speed/full-speed/high-speed protocol framing, error recovery at the transaction layer, all of that stays in the kernel. What you avoid is writing a custom kernel module that implements struct usb_driver, handles URBs (USB Request Blocks), manages power states, and survives concurrent access from interrupt context.

On Linux, the kernel exposes USB devices through usbfs, mounted under /dev/bus/usb/. Every device appears as a file like /dev/bus/usb/003/007. libusb talks to those files via ioctl calls. The kernel does the work; libusb just structures the requests. This means userspace USB is not magic and it is not fast in the same sense kernel drivers are, but for most custom hardware it is more than adequate.

The Descriptor Hierarchy Is the Learning Curve

Before writing a single line of transfer code, you need to understand how USB devices describe themselves. The hierarchy goes: device descriptor, then one or more configuration descriptors, each containing interface descriptors, each of which has endpoint descriptors. In practice, most devices have one configuration, a small number of interfaces, and a handful of endpoints.

A device descriptor carries the vendor ID (VID) and product ID (PID), which is how software identifies specific hardware. The USB Implementers Forum charges around $6,000 for a vendor ID; the pid.codes project allocates free product IDs under VID 0x1209 for open-source hardware.

Endpoints are the actual data pipes. Endpoint 0 is the control endpoint, always present, bidirectional, used for setup and descriptor fetching. All other endpoints are unidirectional: bit 7 of the endpoint address indicates direction, where 0x01 is endpoint 1 OUT and 0x81 is endpoint 1 IN. The four transfer types are control (guaranteed delivery, used for setup), bulk (error-checked, no timing guarantee, used for storage and data), interrupt (bounded latency, polled periodically, used for HID), and isochronous (timing guaranteed, no error recovery, used for audio and video).

With libusb 1.0 (current stable is 1.0.27), traversing descriptors looks like this:

#include <libusb-1.0/libusb.h>

libusb_context *ctx = NULL;
libusb_init(&ctx);

libusb_device **list;
ssize_t count = libusb_get_device_list(ctx, &list);

for (ssize_t i = 0; i < count; i++) {
    struct libusb_device_descriptor desc;
    libusb_get_device_descriptor(list[i], &desc);

    struct libusb_config_descriptor *config;
    libusb_get_config_descriptor(list[i], 0, &config);

    for (int j = 0; j < config->bNumInterfaces; j++) {
        const struct libusb_interface *iface = &config->interface[j];
        for (int k = 0; k < iface->altsetting[0].bNumEndpoints; k++) {
            const struct libusb_endpoint_descriptor *ep = &iface->altsetting[0].endpoint[k];
            printf("  Endpoint 0x%02x, type %d, max packet %d\n",
                ep->bEndpointAddress,
                ep->bmAttributes & 0x03,
                ep->wMaxPacketSize);
        }
    }
    libusb_free_config_descriptor(config);
}

libusb_free_device_list(list, 1);

Once you know which endpoints to use, doing actual transfers is straightforward:

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

// Bulk OUT
unsigned char data[] = {0x01, 0x02, 0x03};
int transferred;
libusb_bulk_transfer(handle, 0x01, data, sizeof(data), &transferred, 1000);

// Bulk IN
unsigned char buf[512];
libusb_bulk_transfer(handle, 0x81, buf, sizeof(buf), &transferred, 1000);

// Control transfer (vendor-specific OUT, no data stage)
libusb_control_transfer(handle,
    LIBUSB_REQUEST_TYPE_VENDOR | LIBUSB_RECIPIENT_DEVICE | LIBUSB_ENDPOINT_OUT,
    0x01,   // bRequest
    0x0000, // wValue
    0x0000, // wIndex
    NULL, 0, 1000);

libusb_release_interface(handle, 0);
libusb_close(handle);
libusb_exit(ctx);

For Python, PyUSB wraps libusb with a higher-level API. For quick prototyping with a device you haven’t written firmware for yet, it reduces the boilerplate considerably.

The Platform Problem

Libusb’s cross-platform abstraction has friction at the edges.

On Linux, the device files under /dev/bus/usb/ are usually root-owned. For development without constant sudo, write a udev rule:

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

Then sudo udevadm control --reload-rules && sudo udevadm trigger. If a kernel driver like usbhid or usb-storage has claimed the device, call libusb_detach_kernel_driver(handle, interface_number) before claiming the interface yourself.

On macOS, libusb uses IOKit. Apple deprecated the IOUSBLib API in macOS 12 Monterey in favor of IOUSBHostFamily. Libusb 1.0.26 added support for the new API, so anything older will break on modern macOS. For HID devices specifically, IOHIDManager is the Apple-native path and often less painful than going through libusb.

Windows is the most involved. To use libusb, the device needs WinUSB or libusbK installed as its kernel driver, which requires either a signed INF file or the Zadig GUI tool. Zadig installs WinUSB for a selected device with a few clicks and is the standard workflow for Windows USB development. The alternative is usbdk, a system-wide backend that libusb can use without per-device INF files, at the cost of installing its own kernel driver. For HID class devices, Windows provides native HID API access through hid.dll without any additional driver installation, which is one reason hobbyist firmware often exposes a HID interface even for non-input devices.

Nusb: Pure Rust, No libusb

The Rust ecosystem has produced nusb, a pure-Rust USB library that calls platform APIs directly rather than wrapping libusb. This means no C dependency, no need to distribute libusb.dll on Windows, and an async-first API built around Rust’s Future model.

use nusb::transfer::RequestBuffer;

let device_info = nusb::list_devices()
    .unwrap()
    .find(|d| d.vendor_id() == 0x1234 && d.product_id() == 0x5678)
    .expect("device not found");

let device = device_info.open().unwrap();
let interface = device.claim_interface(0).unwrap();

// Bulk OUT
let result = interface.bulk_out(0x01, vec![0x01, 0x02, 0x03]).await;

// Bulk IN
let buf = RequestBuffer::new(64);
let data = interface.bulk_in(0x81, buf).await;

Nusb handles WinUSB on Windows, IOKit on macOS, and usbfs on Linux, all without the indirection of libusb’s abstraction layer. For new projects in Rust, it’s the cleaner choice. For anything in C or Python that needs to target all three platforms today, libusb remains the practical default.

WebUSB: Browser-Based Device Access

The WebUSB W3C API lets JavaScript in a browser communicate with USB devices, which sounds niche until you consider the use case: firmware update tools, device configurators, and diagnostic interfaces that users can access without installing anything.

const device = await navigator.usb.requestDevice({ filters: [{ vendorId: 0x1234 }] });
await device.open();
await device.selectConfiguration(1);
await device.claimInterface(0);

const result = await device.transferIn(1, 64);
console.log(new Uint8Array(result.data.buffer));

WebUSB only works in Chromium-based browsers (Chrome, Edge); Firefox has not implemented it and Safari has declined. Devices must also expose a WebUSB platform capability descriptor advertising which origins are permitted to access them, which requires firmware support. The security model requires a user gesture to trigger requestDevice(), so there is no silent background access.

For vendor-specific class devices (class 0xFF), WebUSB covers most use cases. For HID, CDC serial, or audio class devices, the browser’s own handling takes precedence and WebUSB cannot claim the interface.

Debugging USB Traffic

When something goes wrong, a USB packet capture is often more informative than reading error codes. On Linux, load the usbmon kernel module and open the capture in Wireshark: modprobe usbmon, then select the usbmon interface for your bus in Wireshark. On Windows, USBPcap provides the same capability and integrates with Wireshark. Seeing the raw SETUP packets, descriptor responses, and bulk transfer timing makes it much easier to understand why a device is not behaving as expected.

Libusb also has built-in debug logging. Setting libusb_set_option(ctx, LIBUSB_OPTION_LOG_LEVEL, LIBUSB_LOG_LEVEL_DEBUG) before any other calls will print every operation to stderr, including the exact bytes in each transfer and any error codes returned from the kernel.

Where Userspace USB Fits

Userspace USB drivers cover a wide range of real hardware: custom microcontroller firmware talking over bulk endpoints, vendor-specific devices that do not fit any standard class, hardware security keys, SDR receivers, and test equipment. The kernel driver path is still necessary for devices that need to integrate with kernel subsystems, like a USB network adapter that should appear as a standard network interface, or a USB storage device that needs VFS integration. For everything else, libusb or nusb gets the job done with a fraction of the complexity, and WebUSB extends that reach to the browser when the deployment context allows.

The entry barrier is lower than most developers expect. The main investment is understanding the descriptor model, the transfer type semantics, and the per-platform setup friction. After that, the actual I/O code is straightforward.

Was this interesting?