· 7 min read ·

USB From the Software Side: Descriptors, Transfers, and libusb Without a Kernel Module

Source: hackernews

Most software developers interact with USB the way they interact with electricity: they know it exists, they plug things in, and they expect it to work. Occasionally something does not work and the response is to try a different port. Writing actual USB communication code sits in a different category entirely, closer to protocol implementation than application development.

This introduction to USB for software developers covers the territory well, but stops before some of the details that trip people up in practice. This post goes further into the descriptor hierarchy, the transfer model, and the parts of the userspace driver story that matter when you are working with real hardware.

What USB Actually Is

USB is a host-controlled, hierarchical protocol. The host, which is your computer, drives all communication. Devices do not initiate transfers; they respond. This shapes everything about how you write a driver.

The protocol organizes communication through endpoints. Every USB device has at minimum one endpoint: the control endpoint at address zero, which is bidirectional and mandatory. Beyond that, a device declares additional endpoints in its descriptors, each with a direction (IN for device-to-host, OUT for host-to-device), a type, and a maximum packet size.

Four transfer types exist, and choosing the wrong one is a common source of confusion:

Control transfers go through endpoint zero and handle device configuration and command dispatch. They have a fixed structure: a setup packet defines the request, then optionally a data stage follows. Most devices accept custom commands through control transfers, which makes them the first thing to understand when reverse-engineering a protocol.

Bulk transfers move large amounts of data with error detection and retransmission. They do not have guaranteed timing, so the host schedules them when bandwidth is available. USB 2.0 high-speed bulk packets go up to 512 bytes; USB 3.0 SuperSpeed pushes that to 1024. Storage devices and debugging probes use bulk transfers almost exclusively.

Interrupt transfers are polled on a schedule defined by the device descriptor. They are not interrupts in the hardware sense; the host polls the device at the declared interval and the device responds with data or a NAK. Keyboards, mice, and most HID devices use interrupt endpoints.

Isochronous transfers carry real-time data with guaranteed bandwidth but no retransmission. Audio interfaces and cameras use isochronous endpoints. Dropping a packet is acceptable; a timing guarantee is not.

The Descriptor Hierarchy

Before any transfers happen, the host reads a chain of descriptors to understand the device’s capabilities. The structure is:

Device Descriptor
  Configuration Descriptor
    Interface Descriptor
      Endpoint Descriptor
      Endpoint Descriptor
    Interface Descriptor (alternate setting)
      Endpoint Descriptor

The device descriptor contains the vendor ID (VID) and product ID (PID), which are 16-bit values used to match drivers. The USB Implementers Forum assigns VIDs for a fee, though many hobbyist projects use borrowed or unregistered values. OpenMoko’s VID (0x1D50) and the Opendous project’s PID range are commonly recycled in the open-source hardware world.

The configuration descriptor describes a power budget and the number of interfaces. Most devices have one configuration. The interface descriptor is where device class codes appear, and where you will find the class-specific descriptor extensions that HID, CDC, and audio devices use.

Endpoint descriptors specify the address, direction, type, and maximum packet size. The endpoint address encodes direction in the high bit: 0x81 is endpoint 1, IN direction; 0x01 is endpoint 1, OUT direction.

Reading all of this with lsusb on Linux gives you the full picture:

lsusb -v -d 0483:3748

That command targets STMicroelectronics’ ST-Link VID and PID and prints every descriptor in human-readable form. Before writing a single line of driver code, read the descriptors. They tell you what the device is willing to do.

libusb: The Practical Entry Point

libusb 1.0 is the standard cross-platform library for userspace USB access. It runs on Linux, macOS, Windows, and several BSDs. The API is synchronous by default, with an asynchronous transfer API layered on top for applications that cannot afford to block.

A minimal device open sequence looks like this:

#include <libusb-1.0/libusb.h>

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

libusb_device_handle *handle = libusb_open_device_with_vid_pid(
    ctx, 0x0483, 0x3748
);

if (!handle) {
    // Device not found or permission denied
    libusb_exit(ctx);
    return -1;
}

libusb_claim_interface(handle, 0);

// Do transfers here

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

The call to libusb_claim_interface is mandatory before any non-control transfers. On Linux, the kernel may have already bound a driver to the interface. If libusb_claim_interface fails with LIBUSB_ERROR_BUSY, you need to detach the kernel driver first:

if (libusb_kernel_driver_active(handle, 0) == 1) {
    libusb_detach_kernel_driver(handle, 0);
}
libusb_claim_interface(handle, 0);

This is fine for development. In production, you either configure the device to use no kernel driver (by having no recognized class code), or write a udev rule that sets the device mode and ownership before your application opens it.

A bulk transfer on a fictional protocol:

uint8_t data[64];
int transferred;

// Endpoint 0x01 = EP1 OUT
libusb_bulk_transfer(handle, 0x01, data, sizeof(data), &transferred, 1000);

uint8_t response[64];
// Endpoint 0x81 = EP1 IN
libusb_bulk_transfer(handle, 0x81, response, sizeof(response), &transferred, 1000);

The timeout is in milliseconds. Always check the transferred value. USB transfers can complete with fewer bytes than requested, and you may need to loop.

Userspace Versus Kernel

The standard question when approaching USB development is whether to write a kernel driver or a userspace driver. For most use cases in 2026, the answer is userspace.

Kernel drivers live in kernel space, which means bugs can panic the system, development cycles involve reboots, and cross-distribution compatibility requires real effort. They are necessary when you need deep system integration, very low latency interrupt handling, or want the device to be transparent to all processes on the system. USB audio drivers, network adapters, and storage are kernel territory.

Userspace drivers through libusb give you none of those guarantees but significant advantages: crash safety (your process dies, not the system), easy debugging with a regular debugger, no kernel headers or build system complexity, and the ability to ship a library alongside your application without asking users to load kernel modules.

Many production tools run this way. OpenOCD communicates with JTAG and SWD debug probes entirely through libusb. avrdude does AVR programming the same way. dfu-util handles device firmware updates. PyFTDI drives FTDI chips in Python from userspace. These are not toy projects; they are infrastructure.

Platform Differences

Linux requires udev rules to allow non-root users to access USB devices. A rule file in /etc/udev/rules.d/ matching on VID and PID can set mode and group:

SUBSYSTEM=="usb", ATTR{idVendor}=="0483", ATTR{idProduct}=="3748", MODE="0666", GROUP="plugdev"

Without this, running your application as root works but is not a reasonable deployment story.

On macOS, libusb uses IOKit under the hood. The IOUSBDeviceInterface is Apple’s framework for userspace USB access. libusb wraps it, so your code works without modification, though you may need to handle code signing and entitlements for distribution.

Windows requires a kernel-mode driver even for userspace access, which sounds contradictory but reflects Windows architecture. WinUSB is Microsoft’s generic kernel driver that exposes a userspace API for this purpose. Installing WinUSB for a device usually involves either a custom INF file or Zadig, a tool that replaces vendor drivers with WinUSB. libusb on Windows supports WinUSB, libusb-win32, and libusbK as backends. WinUSB is the recommended path for new development.

HID as a Special Case

HID, the Human Interface Device class, deserves its own mention because it changes the access pattern on every platform. HID devices get handled by system HID drivers that provide input events to applications. Accessing them through libusb requires detaching the kernel HID driver, which can conflict with other software using the device.

hidapi exists specifically for this situation. It uses platform-native HID access mechanisms rather than raw USB, which avoids driver conflicts and simplifies the API considerably for devices that expose a HID interface. Many development boards and custom hardware expose a HID interface deliberately to avoid the WinUSB installation requirement on Windows.

Debugging the Protocol

Once you have the descriptor hierarchy understood and basic transfers working, debugging a misbehaving protocol usually comes down to capturing the traffic. On Linux, the usbmon kernel module exposes USB traffic to Wireshark. Load it with modprobe usbmon and capture on the usbmonN interface corresponding to your device’s bus number.

Wireshark decodes control transfers, shows setup packets, and can dissect known protocols. For unknown protocols, looking at what a vendor application sends is often the fastest path to understanding command structure. USB protocol analysis is a surprisingly accessible form of reverse engineering.

Where This Sits in Practice

Writing a userspace USB driver is within reach for any developer comfortable with C or a language with FFI. The USB specification is long but the parts relevant to most device communication, descriptor parsing, control transfers, and bulk or interrupt transfers, are well-documented and stable. libusb handles the platform abstraction.

The knowledge transfers directly to embedded work. Writing firmware for a USB device requires understanding the same descriptor hierarchy and transfer types from the other side. The TinyUSB library for microcontrollers uses the same conceptual model, making the host-device boundary easier to reason about once you have seen both sides.

For a developer building tools that talk to hardware, microcontrollers, or specialized devices, understanding USB at this level is practical knowledge, not theoretical. The barrier is lower than it appears.

Was this interesting?