Back to Technology

USB Part 14: Custom USB Class Drivers

March 31, 2026 Wasil Zafar ~18 min read

Build a custom USB class driver from scratch — design vendor-specific class descriptors, implement WinUSB for driverless Windows access, create a custom binary protocol over bulk endpoints, write a Python libusb host application, and package everything as a reusable TinyUSB class driver.

Table of Contents

  1. When to Use Vendor-Specific Class
  2. Designing the Custom Protocol
  3. WinUSB Descriptor for Driverless Windows
  4. Vendor Class Descriptor Assembly
  5. TinyUSB Vendor Class Driver
  6. Writing a Reusable TinyUSB Class Driver
  7. Python libusb Host Application
  8. Custom Control Request Handling
  9. Testing and Protocol Debugging
  10. Practical Exercises
  11. Custom Driver Design Generator
  12. Conclusion & Next Steps
Series Context: This is Part 14 of the 17-part USB Development Mastery series. You should have solid experience with TinyUSB, CDC, HID, MSC, composite devices, debugging, RTOS integration, and performance optimisation before tackling custom class drivers. Parts 1–13 build all of that foundation.

USB Development Mastery

Your 17-step learning path • Currently on Step 14
1
USB Fundamentals
USB system architecture, transfer types, host/device model, protocol stack
2
Electrical & Hardware Layer
D+/D- signalling, pull-ups, connectors, USB-C, STM32 USB peripherals
3
Protocol & Enumeration
Enumeration sequence, USB packets, descriptors, endpoint concepts
4
USB Device Classes
HID, CDC, MSC, MIDI, Audio, composite devices, vendor class
5
TinyUSB Deep Dive
Stack architecture, execution model, STM32 integration, descriptor callbacks
6
CDC Virtual COM Port
CDC class, bulk transfers, printf over USB, baud rate handling
7
HID Keyboard & Mouse
HID descriptors, report format, keyboard/mouse/gamepad implementation
8
USB Mass Storage
MSC class, SCSI commands, FATFS integration, RAM disk
9
Composite Devices
Multiple classes, IAD descriptor, CDC+HID, CDC+MSC
10
Debugging USB
Wireshark capture, protocol analyser, enumeration debugging, common failures
11
RTOS + USB Integration
FreeRTOS + TinyUSB, task priorities, thread-safe communication
12
Advanced USB Topics
Host mode, OTG, isochronous, USB audio, USB video
13
Performance & Optimisation
DMA, zero-copy buffers, throughput maximisation, latency tuning
14
Custom USB Class Drivers
Vendor class, WinUSB, binary protocol, Python libusb host, reusable driver
You Are Here
15
Bare-Metal USB
Direct register programming, writing USB stack from scratch, PHY timing
16
Security in USB
BadUSB attacks, device authentication, secure firmware, USB firewall
17
USB Hardware Design
PCB layout, differential pairs, impedance matching, EMI, USB-C PD

When to Use Vendor-Specific Class

The USB specification defines dozens of device classes — CDC for serial communication, HID for input devices, MSC for mass storage, Audio for sound cards. These classes work without driver installation on every modern OS because the OS ships with built-in class drivers. So why would you ever abandon this comfort and implement a vendor-specific class?

The answer lies in the cases where standard classes are a bad fit: when you need a high-speed binary protocol that CDC cannot model cleanly, when you need multiple independent bulk channels in each direction, when you need custom control requests for device configuration, or when the overhead of a class-specific protocol (like SCSI on top of MSC) is unacceptable for your use case.

The bDeviceClass and bInterfaceClass Fields

Vendor-specific class is declared at the interface level using bInterfaceClass = 0xFF. At the device level you typically set bDeviceClass = 0x00 (class defined at interface level) so the OS looks at each interface's class code individually. The values in bInterfaceSubClass and bInterfaceProtocol are yours to define — the USB spec reserves 0xFF for vendor-specific use in all three fields.

Field Standard Class Value Vendor-Specific Value Meaning
bDeviceClass 0x02 (CDC), 0x00 (IAD) 0x00 (use interface class) Device-level class code
bInterfaceClass 0x02 (CDC), 0x03 (HID), 0x08 (MSC) 0xFF (vendor) Interface-level class code
bInterfaceSubClass Class-defined (e.g., 0x02 for ACM) 0x00–0xFF (your choice) Sub-class identifier
bInterfaceProtocol Class-defined (e.g., 0x01 for AT commands) 0x00–0xFF (your choice) Protocol identifier

When Standard Classes Do Not Fit

Here are the real-world situations that push developers toward vendor-specific class:

  • High-speed binary protocol: CDC wraps bulk data in a serial port abstraction that adds latency and forces the host application to open a COM port or tty. A vendor class gives you a raw bulk pipe with no framing overhead.
  • Multiple independent bulk channels: A standard CDC interface gives you one IN and one OUT bulk pipe. If your device needs, say, three independent data streams (command, data, telemetry), CDC forces you into a composite configuration with three CDC interfaces — complex and wasteful. A single vendor interface with six bulk endpoints is cleaner.
  • Custom control requests: If you need to configure the device — set sample rate, enable/disable channels, read calibration data — via control transfers, you need vendor control requests. Standard classes define their own control requests (e.g., CDC SET_LINE_CODING) and do not provide expansion points.
  • Avoiding class-specific overhead: MSC wraps all data transfers in SCSI commands. If you are building a firmware update mechanism, the SCSI overhead of CBW/CSW framing is pure waste when a simple bulk OUT (data) + bulk IN (status) would suffice.

Tradeoffs vs Standard Classes

Aspect Standard Class (CDC/HID/MSC) Vendor-Specific Class
Windows driver Built-in, zero install WinUSB via MSOS 2.0 descriptor (automatic) or INF file
Linux driver Built-in class driver usbfs / libusb — no kernel module needed
macOS driver IOKit class driver IOUSBHostDevice via libusb
Protocol flexibility Constrained by class spec Complete freedom
Host app complexity Standard OS APIs (COM port, file I/O) libusb or WinUSB API required
Certification Easier (known class) Harder (custom protocol must be documented)

Real-World Examples

The most widely deployed vendor-specific USB devices in the embedded world include:

  • FTDI FT2232H: Vendor class with multiple interfaces. The proprietary FTDI D2XX driver exposes it as a multi-channel serial/parallel port. The open-source libFTDI library provides the same via libusb.
  • Altera/Intel USB Blaster: JTAG programmer using vendor class with a simple bulk OUT (JTAG data) + bulk IN (response) protocol. The Quartus programmer software bundles its own driver.
  • Logic analysers (Saleae, Cypress FX2-based): Vendor class providing high-speed streaming of captured digital data. The capture PC application communicates via libusb.
  • DFU (Device Firmware Upgrade): A standard class (0xFE / 0x01) that is often extended with vendor-specific sub-classes for proprietary firmware formats.
Practical Rule: If you can make your device work as CDC (serial), HID (small data packets), or MSC (file-based), do it. The driver installation problem is eliminated and every OS works out of the box. Only switch to vendor class when the standard class genuinely cannot model what your device needs to do.

Designing the Custom Protocol

Before writing a single descriptor, you must design the protocol your vendor class will implement. A poorly designed protocol is impossible to extend, debug, and version. A well-designed protocol survives firmware updates, OS changes, and protocol evolution without breaking existing host applications.

Endpoint Plan

A typical vendor class interface uses three endpoint types:

  • Bulk OUT (host → device): Command and data from the host to the device. The device reads from this endpoint to receive commands, configuration data, or bulk payload.
  • Bulk IN (device → host): Response and data from the device to the host. The device writes to this endpoint to send command responses, measurement results, or streaming data.
  • Interrupt IN (optional): Asynchronous event notification. When the device needs to notify the host of an event (alarm, status change, threshold crossing) without the host polling, an interrupt IN endpoint is ideal. Maximum packet size is 64 bytes at Full Speed.

Control Requests for Configuration

Vendor control requests are the preferred mechanism for device configuration — things like setting operating modes, reading calibration data, or querying capabilities. They use EP0 (the control endpoint, always present) and do not need a dedicated endpoint. The bRequest field is your opcode space (0x01 to 0xFF for vendor use — avoid 0x00 which is GET_STATUS):

/* Vendor bRequest opcode definitions — custom protocol v1.0 */
#define VENDOR_REQ_GET_PROTOCOL_VERSION  0x01  /* IN:  wLength=4, returns uint32_t version */
#define VENDOR_REQ_GET_CAPABILITIES      0x02  /* IN:  wLength=8, returns capability bitmask */
#define VENDOR_REQ_SET_SAMPLE_RATE       0x10  /* OUT: wValue=rate_hz (0=stop), wLength=0 */
#define VENDOR_REQ_SET_CHANNEL_MASK      0x11  /* OUT: wValue=bitmask of enabled channels */
#define VENDOR_REQ_GET_STATUS            0x20  /* IN:  wLength=4, returns status flags */
#define VENDOR_REQ_RESET_DEVICE          0x30  /* OUT: wValue=0, wLength=0, soft reset */
#define VENDOR_REQ_WRITE_EEPROM          0x40  /* OUT: wValue=addr, wIndex=len, data in wLength bytes */
#define VENDOR_REQ_READ_EEPROM           0x41  /* IN:  wValue=addr, wIndex=len, data returned */

Protocol Version Header

Every bulk packet should carry a protocol header. This is not overhead — it is what makes your protocol debuggable and evolvable:

/* Bulk packet header — prepended to every bulk OUT command and bulk IN response */
typedef struct __attribute__((packed)) {
    uint8_t  magic;        /* 0xAB — sanity check, detect framing errors */
    uint8_t  version;      /* Protocol version: 0x01 = v1.0 */
    uint8_t  cmd;          /* Command opcode (OUT) or response opcode (IN) */
    uint8_t  flags;        /* Bit 0: more data follows; Bit 1: error flag */
    uint16_t sequence;     /* Sequence number — wraps at 0xFFFF */
    uint16_t length;       /* Length of payload following this header (bytes) */
} vendor_pkt_hdr_t;        /* Total: 8 bytes */

/* Command opcodes for bulk OUT */
#define CMD_PING             0x01  /* No payload — device echoes back */
#define CMD_GET_VERSION      0x02  /* No payload — device returns version string */
#define CMD_SET_LED          0x10  /* Payload: 1 byte (0=off, 1=on, 2=blink) */
#define CMD_READ_ADC         0x20  /* Payload: 1 byte (channel 0–7) */
#define CMD_START_STREAM     0x30  /* Payload: 4 bytes (sample_rate_hz) */
#define CMD_STOP_STREAM      0x31  /* No payload */

/* Response opcodes for bulk IN (high bit set to distinguish from commands) */
#define RSP_ACK              0x81  /* Generic acknowledgement */
#define RSP_NACK             0x82  /* Command rejected (reason in payload byte) */
#define RSP_VERSION          0x83  /* Payload: version string */
#define RSP_ADC_RESULT       0xA0  /* Payload: 2 bytes (uint16_t raw ADC value) */
#define RSP_STREAM_DATA      0xB0  /* Payload: N samples */

Interrupt IN Event Packet

/* Interrupt IN event packet — sent asynchronously by the device */
typedef struct __attribute__((packed)) {
    uint8_t  event_id;     /* Event type identifier */
    uint8_t  severity;     /* 0=info, 1=warning, 2=error, 3=critical */
    uint16_t timestamp_ms; /* Milliseconds since last reset (wraps) */
    uint8_t  data[4];      /* Event-specific data */
} vendor_event_t;          /* Total: 8 bytes, fits in one FS interrupt packet */

#define EVT_OVERRUN         0x01  /* Buffer overrun on ADC channel (data[0]=channel) */
#define EVT_THRESHOLD       0x02  /* Threshold crossed (data[0]=channel, data[1..2]=value) */
#define EVT_POWER_CHANGE    0x03  /* Supply voltage changed (data[0..1]=mV) */

WinUSB Descriptor for Driverless Windows

The single biggest obstacle to deploying a vendor-specific USB device is the Windows driver problem. On Linux and macOS, any USB device is accessible via libusb without any driver installation. On Windows, without a driver, the device shows up in Device Manager as "Unknown Device" and is inaccessible from user space.

WinUSB is a generic Windows kernel driver (winusb.sys) that exposes USB devices directly to user-mode applications via the WinUSB API. Once WinUSB is associated with your device, a user-mode application can open bulk and interrupt endpoints directly — without writing a kernel-mode driver. The key question is: how do you associate WinUSB with your device without requiring the user to install an INF file?

Microsoft OS 2.0 Descriptors (MSOS 2.0)

MSOS 2.0 (introduced with Windows 8.1) allows a USB device to embed driver-selection metadata in a special BOS (Binary device Object Store) descriptor. When Windows enumerates a device with a valid MSOS 2.0 descriptor, it automatically loads WinUSB — no INF file, no admin rights, no pop-up.

The mechanism uses a BOS descriptor with a Platform Capability Descriptor pointing to a Microsoft-defined GUID. The OS reads the BOS descriptor during enumeration, recognises the Microsoft Platform UUID, and sends a vendor GET request to retrieve the full MSOS 2.0 Descriptor Set.

/* === BOS Descriptor with MSOS 2.0 Platform Capability === */

/* Microsoft OS 2.0 Descriptor Set total length */
#define MSOS20_SET_TOTAL_LEN  (0x0A + 0x14 + 0x08 + 0x14)  /* = 72 bytes */

/* Vendor request code used to retrieve the MSOS 2.0 Descriptor Set */
#define VENDOR_REQ_MSOS20     0x01

/* Windows version: 0x06030000 = Windows 8.1 */
#define WIN_VERSION           0x06030000

/* WinUSB compatible ID GUID (placed in Feature Compatibly ID) */
#define WINUSB_COMPATIBLE_ID  "WINUSB\0\0"   /* 8 bytes, null-padded */

/* Full BOS Descriptor */
static const uint8_t bos_descriptor[] = {
    /* BOS Descriptor */
    0x05,                          /* bLength = 5 */
    0x0F,                          /* bDescriptorType = BOS */
    0x1D, 0x00,                    /* wTotalLength = 29 (BOS + one Platform Cap) */
    0x01,                          /* bNumDeviceCaps = 1 */

    /* Platform Capability Descriptor — Microsoft OS 2.0 */
    0x1C,                          /* bLength = 28 */
    0x10,                          /* bDescriptorType = Device Capability */
    0x05,                          /* bDevCapabilityType = Platform */
    0x00,                          /* bReserved */
    /* PlatformCapabilityUUID — Microsoft OS 2.0 Platform UUID (little-endian) */
    0xDF, 0x60, 0xDD, 0xD8,
    0x89, 0x45, 0xC7, 0x4C,
    0x9C, 0xD2, 0x65, 0x9D,
    0x9E, 0x64, 0x8A, 0x9F,
    /* dwWindowsVersion: 0x06030000 = Windows 8.1 */
    0x00, 0x00, 0x03, 0x06,
    /* wMSOSDescriptorSetTotalLength */
    (MSOS20_SET_TOTAL_LEN & 0xFF), (MSOS20_SET_TOTAL_LEN >> 8),
    /* bMS_VendorCode: request code to retrieve descriptor set */
    VENDOR_REQ_MSOS20,
    /* bAltEnumCode: 0 = no alternate enumeration */
    0x00
};

/* Full MSOS 2.0 Descriptor Set */
static const uint8_t msos20_descriptor_set[] = {
    /* Microsoft OS 2.0 Descriptor Set Header */
    0x0A, 0x00,                    /* wLength = 10 */
    0x00, 0x00,                    /* wDescriptorType = MS_OS_20_SET_HEADER_DESCRIPTOR */
    0x00, 0x00, 0x03, 0x06,        /* dwWindowsVersion = Windows 8.1 */
    (MSOS20_SET_TOTAL_LEN & 0xFF), (MSOS20_SET_TOTAL_LEN >> 8), /* wTotalLength */

    /* Configuration Subset Header (optional — use if device is not composite) */
    /* For single-interface device we go straight to Interface Subset */

    /* Microsoft OS 2.0 Compatible ID Descriptor */
    0x14, 0x00,                    /* wLength = 20 */
    0x03, 0x00,                    /* wDescriptorType = MS_OS_20_FEATURE_COMPATBLE_ID */
    /* CompatibleID: "WINUSB\0\0" */
    'W', 'I', 'N', 'U', 'S', 'B', 0x00, 0x00,
    /* SubCompatibleID: all zeros */
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,

    /* Microsoft OS 2.0 Registry Property Descriptor — DeviceInterfaceGUID */
    0x08, 0x00,                    /* wLength = 8 + name length + value length */
    /* NOTE: In practice wLength must include the full name and value UTF-16 strings.
       See the complete example below for the full structure with GUID. */
    0x04, 0x00,                    /* wDescriptorType = MS_OS_20_FEATURE_REG_PROPERTY */
    0x01, 0x00,                    /* wPropertyDataType = REG_SZ */
    0x28, 0x00                     /* wPropertyNameLength = 40 (in full descriptor) */
    /* Followed by UTF-16LE property name "DeviceInterfaceGUID\0" and value GUID */
};
Common Mistake: The MSOS 2.0 descriptor set is returned via a vendor device-directed GET request (bmRequestType = 0xC0, bRequest = VENDOR_REQ_MSOS20, wIndex = 0x0007). If you handle this as an interface-directed request or return it on the wrong wIndex, Windows silently fails to load WinUSB. Always verify with USB Device Tree Viewer (UsbTreeView.exe) that the MSOS 2.0 descriptor is being read correctly.

Legacy WCID Method (Windows 7 and Earlier)

If you need Windows 7 support, the older WCID (Windows Compatible ID) method uses an OS String Descriptor at index 0xEE. Windows queries this string at address 0xEE; if it contains the magic signature "MSFT100" followed by a vendor request code, Windows will query the Compatible ID feature descriptor. This method still works on modern Windows but MSOS 2.0 is preferred for new designs.

Testing with USB Device Tree Viewer

After flashing firmware with MSOS 2.0 descriptors, use USB Device Tree Viewer (free, from Uwe Sieber) to inspect the BOS descriptor. Look for "Microsoft OS 2.0 Platform Capability Descriptor" in the device node. If absent, your BOS descriptor has an error. If present but WinUSB does not load, check that your vendor request handler returns the full descriptor set with the correct wIndex (0x0007).

Vendor Class Descriptor Assembly

The configuration descriptor for a vendor class device follows the same structure as any USB configuration descriptor — it is just the class codes that differ. Here is a complete vendor class configuration with Bulk IN, Bulk OUT, and optional Interrupt IN:

/* tusb_config.h additions for vendor class */
#define CFG_TUD_VENDOR              1       /* Enable one vendor class interface */
#define CFG_TUD_VENDOR_RX_BUFSIZE   512     /* Bulk OUT receive buffer (bytes) */
#define CFG_TUD_VENDOR_TX_BUFSIZE   512     /* Bulk IN transmit buffer (bytes) */

/* ------------------------------------------------------------------ */
/* Configuration descriptor for single vendor-class interface          */
/* ------------------------------------------------------------------ */

/* Endpoint numbers — must match tusb_config.h or your manual EP config */
#define EP_BULK_OUT     0x01   /* EP1 OUT */
#define EP_BULK_IN      0x81   /* EP1 IN  */
#define EP_INT_IN       0x82   /* EP2 IN  */

#define VENDOR_ITF_PROTOCOL  0x00  /* Your protocol identifier */
#define VENDOR_ITF_SUBCLASS  0x00  /* Your subclass identifier */

/* Full Speed endpoint max packet sizes */
#define EP_BULK_MPS     64
#define EP_INT_MPS      16
#define EP_INT_INTERVAL  5    /* 5 ms polling interval for interrupt IN */

/* Configuration descriptor total length:
   9 (config) + 9 (interface) + 7 (bulk out) + 7 (bulk in) + 7 (int in) = 39 */
#define CONFIG_TOTAL_LEN  39

static const uint8_t configuration_descriptor[] = {
    /* Configuration Descriptor */
    9,                          /* bLength */
    USB_DESC_TYPE_CONFIGURATION,/* bDescriptorType = 0x02 */
    CONFIG_TOTAL_LEN, 0x00,    /* wTotalLength */
    1,                          /* bNumInterfaces = 1 */
    1,                          /* bConfigurationValue = 1 */
    0,                          /* iConfiguration = no string */
    0x80,                       /* bmAttributes: bus-powered, no remote wakeup */
    250,                        /* bMaxPower: 500 mA (250 × 2 mA) */

    /* Interface Descriptor */
    9,                          /* bLength */
    USB_DESC_TYPE_INTERFACE,    /* bDescriptorType = 0x04 */
    0,                          /* bInterfaceNumber = 0 */
    0,                          /* bAlternateSetting = 0 */
    3,                          /* bNumEndpoints = 3 (bulk out, bulk in, int in) */
    0xFF,                       /* bInterfaceClass = Vendor-Specific */
    VENDOR_ITF_SUBCLASS,        /* bInterfaceSubClass */
    VENDOR_ITF_PROTOCOL,        /* bInterfaceProtocol */
    0,                          /* iInterface = no string */

    /* Endpoint Descriptor — Bulk OUT */
    7,                          /* bLength */
    USB_DESC_TYPE_ENDPOINT,     /* bDescriptorType = 0x05 */
    EP_BULK_OUT,                /* bEndpointAddress: EP1 OUT */
    0x02,                       /* bmAttributes: Bulk */
    EP_BULK_MPS, 0x00,          /* wMaxPacketSize */
    0,                          /* bInterval: ignored for bulk */

    /* Endpoint Descriptor — Bulk IN */
    7,                          /* bLength */
    USB_DESC_TYPE_ENDPOINT,
    EP_BULK_IN,                 /* bEndpointAddress: EP1 IN */
    0x02,                       /* bmAttributes: Bulk */
    EP_BULK_MPS, 0x00,
    0,

    /* Endpoint Descriptor — Interrupt IN */
    7,
    USB_DESC_TYPE_ENDPOINT,
    EP_INT_IN,                  /* bEndpointAddress: EP2 IN */
    0x03,                       /* bmAttributes: Interrupt */
    EP_INT_MPS, 0x00,
    EP_INT_INTERVAL             /* bInterval: 5 ms polling interval */
};

In TinyUSB, the tud_descriptor_configuration_cb() callback returns this byte array. The BOS descriptor is returned by tud_descriptor_bos_cb(). Both callbacks are mandatory if you are using MSOS 2.0.

TinyUSB Vendor Class Driver

TinyUSB ships with a built-in vendor class driver (src/class/vendor/vendor_device.c) that handles the bulk endpoint plumbing, leaving you to implement the protocol logic. With CFG_TUD_VENDOR 1 in tusb_config.h, the following API is available:

/* Check if there is data available to read from the bulk OUT endpoint */
bool tud_vendor_available(void);

/* Read up to bufsize bytes from the bulk OUT endpoint into buf.
   Returns the number of bytes actually read (may be less than bufsize). */
uint32_t tud_vendor_read(void* buf, uint32_t bufsize);

/* Write up to bufsize bytes to the bulk IN endpoint.
   Returns the number of bytes written (may be less than bufsize if TX buffer is full). */
uint32_t tud_vendor_write(void const* buf, uint32_t bufsize);

/* Flush the bulk IN transmit buffer — causes any buffered data to be sent immediately.
   Call this after the last tud_vendor_write() in a response sequence. */
uint32_t tud_vendor_write_flush(void);

Simple Command/Response Protocol Implementation

/* vendor_protocol.c — implements the custom command/response loop */
#include "tusb.h"
#include "vendor_protocol.h"

static uint8_t rx_buf[512];
static uint8_t tx_buf[512];

/* Called from your main loop or a dedicated USB task */
void vendor_protocol_task(void)
{
    if (!tud_vendor_available()) return;

    /* Read the packet header first */
    uint32_t n = tud_vendor_read(rx_buf, sizeof(rx_buf));
    if (n < sizeof(vendor_pkt_hdr_t)) return;  /* Discard undersized packets */

    vendor_pkt_hdr_t *hdr = (vendor_pkt_hdr_t *)rx_buf;

    /* Validate magic byte */
    if (hdr->magic != 0xAB) return;

    /* Prepare response header */
    vendor_pkt_hdr_t *rsp = (vendor_pkt_hdr_t *)tx_buf;
    rsp->magic    = 0xAB;
    rsp->version  = 0x01;
    rsp->flags    = 0x00;
    rsp->sequence = hdr->sequence;  /* Echo the sequence number */

    switch (hdr->cmd) {
        case CMD_PING: {
            rsp->cmd    = RSP_ACK;
            rsp->length = 0;
            tud_vendor_write(tx_buf, sizeof(vendor_pkt_hdr_t));
            break;
        }

        case CMD_GET_VERSION: {
            const char *ver = "usb-custom-driver v1.0.0";
            uint16_t vlen = (uint16_t)strlen(ver);
            rsp->cmd    = RSP_VERSION;
            rsp->length = vlen;
            memcpy(tx_buf + sizeof(vendor_pkt_hdr_t), ver, vlen);
            tud_vendor_write(tx_buf, sizeof(vendor_pkt_hdr_t) + vlen);
            break;
        }

        case CMD_SET_LED: {
            if (n < sizeof(vendor_pkt_hdr_t) + 1) {
                rsp->cmd    = RSP_NACK;
                rsp->length = 1;
                tx_buf[sizeof(vendor_pkt_hdr_t)] = 0x01; /* reason: bad length */
            } else {
                uint8_t led_state = rx_buf[sizeof(vendor_pkt_hdr_t)];
                board_led_control(led_state);
                rsp->cmd    = RSP_ACK;
                rsp->length = 0;
            }
            tud_vendor_write(tx_buf, sizeof(vendor_pkt_hdr_t) + rsp->length);
            break;
        }

        case CMD_READ_ADC: {
            if (n < sizeof(vendor_pkt_hdr_t) + 1) {
                rsp->cmd = RSP_NACK; rsp->length = 1;
                tx_buf[sizeof(vendor_pkt_hdr_t)] = 0x01;
            } else {
                uint8_t channel = rx_buf[sizeof(vendor_pkt_hdr_t)];
                uint16_t adc_val = adc_read_channel(channel);
                rsp->cmd    = RSP_ADC_RESULT;
                rsp->length = 2;
                tx_buf[sizeof(vendor_pkt_hdr_t) + 0] = (uint8_t)(adc_val & 0xFF);
                tx_buf[sizeof(vendor_pkt_hdr_t) + 1] = (uint8_t)(adc_val >> 8);
            }
            tud_vendor_write(tx_buf, sizeof(vendor_pkt_hdr_t) + rsp->length);
            break;
        }

        default:
            rsp->cmd    = RSP_NACK;
            rsp->length = 1;
            tx_buf[sizeof(vendor_pkt_hdr_t)] = 0xFF; /* reason: unknown command */
            tud_vendor_write(tx_buf, sizeof(vendor_pkt_hdr_t) + 1);
            break;
    }

    tud_vendor_write_flush();
}

/* Callback invoked by TinyUSB when a vendor control transfer is received on EP0 */
bool tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage,
                                 tusb_control_request_t const *request)
{
    /* Only handle vendor-type, device-recipient requests */
    if ((request->bmRequestType & TUSB_REQ_TYPE_MASK)   != TUSB_REQ_TYPE_VENDOR)   return false;
    if ((request->bmRequestType & TUSB_REQ_RCPT_MASK)   != TUSB_REQ_RCPT_DEVICE)   return false;

    if (stage == CONTROL_STAGE_SETUP) {
        switch (request->bRequest) {
            case VENDOR_REQ_MSOS20:
                /* Return the MSOS 2.0 Descriptor Set when Windows requests it */
                if (request->wIndex == 0x0007) {
                    return tud_control_xfer(rhport, request,
                                            (void *)msos20_descriptor_set,
                                            sizeof(msos20_descriptor_set));
                }
                return false;

            case VENDOR_REQ_GET_PROTOCOL_VERSION: {
                static uint32_t version = 0x00010000; /* v1.0.0 */
                return tud_control_xfer(rhport, request,
                                        &version, sizeof(version));
            }

            case VENDOR_REQ_SET_SAMPLE_RATE:
                /* wValue contains the sample rate in Hz; wLength must be 0 */
                if (request->wLength == 0) {
                    adc_set_sample_rate(request->wValue);
                    return tud_control_status(rhport, request);
                }
                return false;

            default:
                return false;
        }
    }
    return true; /* Other stages (DATA, ACK) — TinyUSB handles them */
}

Writing a Reusable TinyUSB Class Driver

TinyUSB's vendor class is convenient but it is not a reusable class driver — it is a fixed single-interface implementation. If you want to distribute your protocol as a reusable component (e.g., a proprietary instrument class that other firmware teams can drop into their TinyUSB builds), you need to implement the usbd_class_driver_t interface.

The usbd_class_driver_t Structure

/* TinyUSB class driver interface — from src/device/usbd.h */
typedef struct {
    /* Name of this class driver (for debug output) */
    const char *name;

    /* Called once at startup to initialise driver state */
    void (*init)(void);

    /* Called on USB bus reset — reset all state machines */
    void (*reset)(uint8_t rhport);

    /* Called during enumeration when the host opens an interface.
       Return the number of bytes consumed from the descriptor or 0 to reject. */
    uint16_t (*open)(uint8_t rhport,
                     tusb_desc_interface_t const *itf_desc,
                     uint16_t max_len);

    /* Called when a class-specific control transfer arrives on EP0 */
    bool (*control_xfer_cb)(uint8_t rhport, uint8_t stage,
                             tusb_control_request_t const *request);

    /* Called when a bulk/interrupt/isochronous transfer completes */
    bool (*xfer_complete_cb)(uint8_t rhport, uint8_t ep_addr,
                             xfer_result_t result, uint32_t xferred_bytes);

    /* Called at the start of every USB frame (1 ms at FS) — optional, can be NULL */
    void (*sof)(uint8_t rhport, uint32_t frame_count);
} usbd_class_driver_t;

Registering Your Driver with TinyUSB

TinyUSB provides a weak callback usbd_app_driver_get_cb() that you implement to inject your custom class driver into the driver dispatch table:

/* myclass_device.c — reusable class driver implementation */
#include "tusb.h"
#include "myclass_device.h"

/* Driver state — one instance per class instance (extend for multiple) */
typedef struct {
    uint8_t itf_num;
    uint8_t ep_bulk_in;
    uint8_t ep_bulk_out;
    uint8_t ep_int_in;
} myclass_interface_t;

static myclass_interface_t _myclass_itf;

static void myclass_init(void) {
    memset(&_myclass_itf, 0, sizeof(_myclass_itf));
}

static void myclass_reset(uint8_t rhport) {
    (void)rhport;
    memset(&_myclass_itf, 0, sizeof(_myclass_itf));
}

static uint16_t myclass_open(uint8_t rhport,
                               tusb_desc_interface_t const *itf_desc,
                               uint16_t max_len)
{
    /* Only accept our specific class/subclass/protocol */
    TU_VERIFY(itf_desc->bInterfaceClass    == 0xFF, 0);
    TU_VERIFY(itf_desc->bInterfaceSubClass == VENDOR_ITF_SUBCLASS, 0);
    TU_VERIFY(itf_desc->bInterfaceProtocol == VENDOR_ITF_PROTOCOL, 0);

    _myclass_itf.itf_num = itf_desc->bInterfaceNumber;

    uint16_t drv_len = sizeof(tusb_desc_interface_t);
    uint8_t const *p_desc = tu_desc_next(itf_desc);

    /* Walk endpoint descriptors and open each one */
    for (uint8_t i = 0; i < itf_desc->bNumEndpoints; i++) {
        tusb_desc_endpoint_t const *ep_desc = (tusb_desc_endpoint_t const *)p_desc;
        TU_ASSERT(usbd_edpt_open(rhport, ep_desc), 0);

        uint8_t ep_addr = ep_desc->bEndpointAddress;
        if (tu_edpt_dir(ep_addr) == TUSB_DIR_IN) {
            if ((ep_desc->bmAttributes.xfer & 0x03) == TUSB_XFER_INTERRUPT)
                _myclass_itf.ep_int_in  = ep_addr;
            else
                _myclass_itf.ep_bulk_in = ep_addr;
        } else {
            _myclass_itf.ep_bulk_out = ep_addr;
            /* Prime the OUT endpoint to receive the first packet */
            usbd_edpt_xfer(rhport, ep_addr, rx_buf, sizeof(rx_buf));
        }

        drv_len += tu_desc_len(p_desc);
        p_desc   = tu_desc_next(p_desc);
    }

    return drv_len;
}

static bool myclass_xfer_cb(uint8_t rhport, uint8_t ep_addr,
                              xfer_result_t result, uint32_t xferred_bytes)
{
    (void)result;
    if (ep_addr == _myclass_itf.ep_bulk_out) {
        /* Data received from host — process it */
        myclass_handle_rx(rx_buf, xferred_bytes);
        /* Re-prime OUT endpoint for next packet */
        usbd_edpt_xfer(rhport, ep_addr, rx_buf, sizeof(rx_buf));
    }
    return true;
}

/* The static driver descriptor */
static const usbd_class_driver_t myclass_driver = {
    .name            = "MyVendorClass",
    .init            = myclass_init,
    .reset           = myclass_reset,
    .open            = myclass_open,
    .control_xfer_cb = tud_vendor_control_xfer_cb,
    .xfer_complete_cb= myclass_xfer_cb,
    .sof             = NULL
};

/* Weak override — TinyUSB calls this to get app-specific drivers */
usbd_class_driver_t const *usbd_app_driver_get_cb(uint8_t *driver_count)
{
    *driver_count = 1;
    return &myclass_driver;
}

Python libusb Host Application

The pyusb library provides a clean Pythonic interface to libusb-1.0 on Linux, macOS, and Windows (via WinUSB). Install it alongside the libusb backend:

pip install pyusb
# Windows: also install libusb-1.0 (from libusb.info) or use WinUSB backend
# Linux:   apt install libusb-1.0-0
# macOS:   brew install libusb

Complete Host Application

#!/usr/bin/env python3
"""
usb_custom_host.py — Host application for vendor USB class device
Communicates using the custom binary protocol defined in vendor_protocol.h
"""

import usb.core
import usb.util
import struct
import time

# Device identification
VENDOR_ID  = 0x1209   # pid.codes VID for open-source projects
PRODUCT_ID = 0x0001   # Your product ID

# Protocol constants
MAGIC       = 0xAB
VERSION     = 0x01
CMD_PING    = 0x01
CMD_VERSION = 0x02
CMD_SET_LED = 0x10
CMD_READ_ADC= 0x20
RSP_ACK     = 0x81
RSP_NACK    = 0x82
RSP_VERSION = 0x83
RSP_ADC     = 0xA0

HEADER_FMT  = '<BBBBHH'   # magic, version, cmd, flags, sequence, length
HEADER_SIZE = struct.calcsize(HEADER_FMT)

sequence_counter = 0

def build_packet(cmd, payload=b''):
    global sequence_counter
    sequence_counter = (sequence_counter + 1) & 0xFFFF
    hdr = struct.pack(HEADER_FMT,
                      MAGIC, VERSION, cmd, 0x00,
                      sequence_counter, len(payload))
    return hdr + payload

def parse_header(data):
    if len(data) < HEADER_SIZE:
        raise ValueError(f"Packet too short: {len(data)} bytes")
    magic, ver, cmd, flags, seq, length = struct.unpack_from(HEADER_FMT, data, 0)
    if magic != MAGIC:
        raise ValueError(f"Bad magic: 0x{magic:02X}")
    payload = data[HEADER_SIZE:HEADER_SIZE + length]
    return cmd, flags, seq, payload

def send_command(ep_out, ep_in, cmd, payload=b'', timeout=1000):
    """Send a command and wait for the response. Returns (rsp_cmd, payload)."""
    pkt = build_packet(cmd, payload)
    ep_out.write(pkt, timeout=timeout)
    rsp_data = ep_in.read(512, timeout=timeout)
    rsp_cmd, flags, seq, rsp_payload = parse_header(bytes(rsp_data))
    return rsp_cmd, rsp_payload

def main():
    # Find the device
    dev = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID)
    if dev is None:
        raise RuntimeError("Device not found. Is it plugged in and enumerated?")

    print(f"Found device: VID=0x{VENDOR_ID:04X} PID=0x{PRODUCT_ID:04X}")
    print(f"Manufacturer: {usb.util.get_string(dev, dev.iManufacturer)}")
    print(f"Product:      {usb.util.get_string(dev, dev.iProduct)}")

    # Set active configuration
    dev.set_configuration()
    cfg = dev.get_active_configuration()

    # Get the interface
    intf = cfg[(0, 0)]

    # Find endpoints
    ep_out = usb.util.find_descriptor(intf,
        custom_match=lambda e:
            usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_OUT and
            usb.util.endpoint_type(e.bmAttributes) == usb.util.ENDPOINT_TYPE_BULK)

    ep_in = usb.util.find_descriptor(intf,
        custom_match=lambda e:
            usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_IN and
            usb.util.endpoint_type(e.bmAttributes) == usb.util.ENDPOINT_TYPE_BULK)

    if ep_out is None or ep_in is None:
        raise RuntimeError("Could not find bulk endpoints")

    print(f"EP OUT: 0x{ep_out.bEndpointAddress:02X}  EP IN: 0x{ep_in.bEndpointAddress:02X}")

    # --- Vendor control request: GET_PROTOCOL_VERSION ---
    version_bytes = dev.ctrl_transfer(
        bmRequestType=0xC0,        # IN | Vendor | Device
        bRequest=0x01,             # VENDOR_REQ_GET_PROTOCOL_VERSION
        wValue=0, wIndex=0,
        data_or_wLength=4)
    proto_ver = struct.unpack('<I', bytes(version_bytes))[0]
    print(f"Protocol version: 0x{proto_ver:08X}")

    # --- PING ---
    rsp_cmd, _ = send_command(ep_out, ep_in, CMD_PING)
    print(f"PING response: 0x{rsp_cmd:02X} ({'ACK' if rsp_cmd == RSP_ACK else 'NACK'})")

    # --- GET VERSION ---
    rsp_cmd, payload = send_command(ep_out, ep_in, CMD_VERSION)
    print(f"Firmware version: {payload.decode('ascii', errors='replace')}")

    # --- SET LED ---
    rsp_cmd, _ = send_command(ep_out, ep_in, CMD_SET_LED, bytes([1]))  # LED ON
    print(f"LED ON response: {'ACK' if rsp_cmd == RSP_ACK else 'NACK'}")

    # --- READ ADC channel 0 ---
    rsp_cmd, payload = send_command(ep_out, ep_in, CMD_READ_ADC, bytes([0]))
    if rsp_cmd == RSP_ADC and len(payload) >= 2:
        raw = struct.unpack_from('<H', payload, 0)[0]
        voltage = raw * 3.3 / 4095.0
        print(f"ADC ch0: raw={raw}  voltage={voltage:.3f} V")

    print("Done.")

if __name__ == '__main__':
    main()

Error Handling and Retry Logic

import usb.core

def send_with_retry(ep_out, ep_in, cmd, payload=b'', retries=3, timeout=500):
    """Send a command with automatic retry on timeout."""
    for attempt in range(retries):
        try:
            return send_command(ep_out, ep_in, cmd, payload, timeout=timeout)
        except usb.core.USBTimeoutError:
            print(f"Timeout on attempt {attempt+1}/{retries}")
            if attempt == retries - 1:
                raise
        except usb.core.USBError as e:
            print(f"USB error: {e}")
            raise
    return None

Custom Control Request Handling

USB control requests are the most precisely specified part of the protocol. Getting the bmRequestType field wrong is the most common error when implementing vendor control requests. Understanding the bit breakdown is essential:

bmRequestType Bits Field Name Values For Vendor Class
Bit 7 Direction 0=host→device (OUT), 1=device→host (IN) 0 for SET/WRITE, 1 for GET/READ
Bits 6:5 Type 00=Standard, 01=Class, 10=Vendor, 11=Reserved 10 (Vendor) always
Bits 4:0 Recipient 00000=Device, 00001=Interface, 00010=Endpoint Device (0x00) or Interface (0x01)

Resulting common vendor request bmRequestType values:

  • 0xC0 = IN | Vendor | Device — most common for GET requests
  • 0x40 = OUT | Vendor | Device — for SET requests with no data return
  • 0xC1 = IN | Vendor | Interface — for interface-specific GET requests
  • 0x41 = OUT | Vendor | Interface — for interface-specific SET requests
/* Complete vendor control request handler — handles all vendor request types */
bool tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage,
                                 tusb_control_request_t const *request)
{
    /* Only process vendor-type requests */
    if ((request->bmRequestType_bit.type) != TUSB_REQ_TYPE_VENDOR) return false;

    /* MSOS 2.0 — special case: device-directed, any vendor code */
    if (request->bRequest == VENDOR_REQ_MSOS20 &&
        request->wIndex   == 0x0007) {
        if (stage == CONTROL_STAGE_SETUP) {
            return tud_control_xfer(rhport, request,
                                    (void *)msos20_descriptor_set,
                                    sizeof(msos20_descriptor_set));
        }
        return true;
    }

    if (stage != CONTROL_STAGE_SETUP) return true;

    uint8_t recipient = request->bmRequestType_bit.recipient;

    if (recipient == TUSB_REQ_RCPT_DEVICE) {
        /* Device-directed vendor requests */
        switch (request->bRequest) {
            case VENDOR_REQ_GET_PROTOCOL_VERSION: {
                /* IN request: return 4-byte version */
                static const uint32_t ver = 0x00010000;
                return tud_control_xfer(rhport, request,
                                        (void *)&ver, sizeof(ver));
            }
            case VENDOR_REQ_GET_CAPABILITIES: {
                static const uint64_t caps =
                    (1ULL << 0) |   /* Bit 0: ADC present */
                    (1ULL << 1) |   /* Bit 1: LED present */
                    (1ULL << 4);    /* Bit 4: streaming supported */
                return tud_control_xfer(rhport, request,
                                        (void *)&caps, sizeof(caps));
            }
            case VENDOR_REQ_SET_SAMPLE_RATE:
                /* OUT, no data stage: wValue = rate in Hz */
                adc_set_sample_rate(request->wValue);
                return tud_control_status(rhport, request);

            case VENDOR_REQ_RESET_DEVICE:
                /* Schedule a soft reset after status stage */
                schedule_soft_reset();
                return tud_control_status(rhport, request);

            default:
                return false; /* Stall unsupported requests */
        }
    }

    if (recipient == TUSB_REQ_RCPT_INTERFACE) {
        /* Interface-directed vendor requests */
        switch (request->bRequest) {
            case VENDOR_REQ_GET_STATUS: {
                static uint32_t status_flags = 0;
                status_flags = build_status_flags();
                return tud_control_xfer(rhport, request,
                                        &status_flags, sizeof(status_flags));
            }
            default:
                return false;
        }
    }

    return false;
}

Testing and Protocol Debugging

Debugging a custom vendor class device requires a systematic approach. Unlike CDC or HID, there are no OS-level tools that understand your protocol — you must instrument both ends.

Wireshark Vendor Class Filter

Wireshark captures USB traffic via USBPcap (Windows) or usbmon (Linux). For vendor class devices, use these display filters:

# Show only vendor control requests (bmRequestType type=Vendor)
usb.bmRequestType.type == 0x02

# Show only bulk transfers on your device
usb.idVendor == 0x1209 and usb.transfer_type == 0x03

# Show the bulk OUT (host to device commands)
usb.idVendor == 0x1209 and usb.endpoint_address.direction == 0

# Show URB_BULK transfers (ignoring control, interrupt)
usb.urb_type == URB_BULK

Boundary Condition Testing

Your firmware must handle these edge cases correctly or it will fail in production:

  • Maximum packet size: A bulk packet of exactly 64 bytes (Full Speed) must be handled without a buffer overflow. Many bugs appear only at exact multiples of the max packet size.
  • Zero-length packet (ZLP): The USB spec requires a ZLP when a bulk transfer length is an exact multiple of the max packet size. If your host application sends 128 bytes (2 × 64), the firmware must handle the trailing ZLP correctly.
  • Split packets: The host may split a logical packet across multiple USB transactions. Your firmware should not assume a full command arrives in a single tud_vendor_read() call.
  • Rapid reconnect: Unplug and replug within 100 ms. Your state machine must reset cleanly without leaking state from the previous enumeration.

Loopback Test for Bulk Endpoints

/* loopback_test.c — echo all bulk OUT data back on bulk IN */
/* Useful as a first test to verify endpoint connectivity without protocol logic */

void vendor_loopback_task(void)
{
    if (!tud_vendor_available()) return;

    /* Read whatever arrived */
    uint32_t n = tud_vendor_read(rx_buf, sizeof(rx_buf));
    if (n == 0) return;

    /* Echo it back — may block if TX buffer is full */
    uint32_t sent = 0;
    while (sent < n) {
        uint32_t written = tud_vendor_write(rx_buf + sent, n - sent);
        sent += written;
        if (written == 0) tud_task(); /* Yield to USB stack if TX buffer is full */
    }
    tud_vendor_write_flush();
}

Protocol Fuzzing with Python

import random, struct

def fuzz_bulk_out(ep_out, ep_in, iterations=1000):
    """Send random data to the bulk OUT endpoint and check the device remains stable."""
    for i in range(iterations):
        # Random payload: 0 to 512 bytes
        size = random.randint(0, 512)
        data = bytes(random.getrandbits(8) for _ in range(size))
        try:
            ep_out.write(data, timeout=100)
            # Attempt to read a response — device may or may not respond
            try:
                ep_in.read(512, timeout=50)
            except:
                pass  # No response is OK for invalid commands
        except Exception as e:
            print(f"[{i}] USB error during fuzz: {e}")
            break
    # After fuzzing, verify device is still alive with a PING
    rsp_cmd, _ = send_command(ep_out, ep_in, CMD_PING)
    assert rsp_cmd == RSP_ACK, f"Device unresponsive after fuzzing: 0x{rsp_cmd:02X}"
    print(f"Fuzz complete ({iterations} iterations): device still alive")

Practical Exercises

Exercise 1 Beginner

Vendor Loopback Device

Create a TinyUSB vendor class device on any supported board (STM32F4, RP2040, or nRF52840) that echoes all bulk OUT data back on bulk IN. Implement MSOS 2.0 descriptors so the device claims WinUSB on Windows without an INF file. Verify with USB Device Tree Viewer that the MSOS 2.0 descriptor is read correctly during enumeration. Write a Python pyusb script that sends 1000 random payloads of varying sizes (1–512 bytes) and verifies the echo matches. Measure round-trip latency for a 64-byte payload.

TinyUSB Vendor WinUSB / MSOS 2.0 pyusb
Exercise 2 Intermediate

Command/Response Protocol with ADC Streaming

Extend the loopback device to implement the full command/response protocol from this article: PING, GET_VERSION, SET_LED, READ_ADC. Add a START_STREAM / STOP_STREAM command pair that causes the firmware to send ADC samples continuously on the bulk IN endpoint at a configurable rate (set via VENDOR_REQ_SET_SAMPLE_RATE control request). The Python host application should plot the ADC stream in real-time using matplotlib. Add the interrupt IN endpoint and use it to send an overrun event when the ADC FIFO fills faster than USB can drain it.

Custom Protocol ADC Streaming Interrupt IN Vendor Control Requests
Exercise 3 Advanced

Reusable Class Driver Package

Package the vendor class firmware as a reusable TinyUSB class driver library: implement usbd_class_driver_t fully, including multi-instance support for up to two simultaneous vendor interfaces, a clean public C API (init, send, recv, event callback), and a CMakeLists.txt that lets it be consumed as a FetchContent dependency. On the host side, create a Python package (installable via pip) that wraps pyusb with your protocol's send/receive/event abstractions. Write a protocol versioning test that verifies a v1 host can negotiate with a v1 and v2 firmware gracefully.

usbd_class_driver_t Multi-instance CMake Library Protocol Versioning

Custom Driver Design Generator

Document your custom USB class driver design — project name, target MCU, VID/PID, endpoint plan, WinUSB requirement, and protocol notes. Download as Word, Excel, PDF, or PPTX for design review or project documentation.

USB Custom Driver Design Generator

Document your vendor-specific USB class driver design. Download as Word, Excel, PDF, or PPTX.

Draft auto-saved

All data stays in your browser. Nothing is sent to or stored on any server.

Conclusion & Next Steps

Custom USB class drivers unlock the full flexibility of the USB protocol stack — at the cost of OS driver complexity and increased host application development effort. The key takeaways from this article:

  • Use bInterfaceClass = 0xFF to declare a vendor-specific interface. Design your bInterfaceSubClass and bInterfaceProtocol values to uniquely identify your device family.
  • MSOS 2.0 descriptors eliminate the Windows driver installation problem. A correctly implemented BOS descriptor with the Microsoft Platform UUID causes Windows to automatically bind WinUSB — zero user interaction required.
  • Invest time in protocol header design upfront. A magic byte, version, sequence number, and length field in every bulk packet makes debugging and future evolution far easier.
  • TinyUSB's vendor class (CFG_TUD_VENDOR) is the right starting point. For distributable libraries, implement usbd_class_driver_t and register via usbd_app_driver_get_cb().
  • Python + pyusb is the fastest path to a working host application on all three OSes. libusb-1.0 handles the WinUSB/usbfs/IOKit backend transparently.
  • Test boundary conditions explicitly: exact max-packet-size payloads, ZLP handling, rapid reconnect, and protocol fuzzing. These are the cases that fail in production if not tested in development.

Next in the Series

In Part 15: Bare-Metal USB, we strip away TinyUSB entirely and implement USB device enumeration directly on the STM32 FSDEV hardware — writing to USB_EPnR registers, managing the Packet Memory Area (PMA), implementing the EP0 control state machine, and handling USB interrupts from scratch. This is the deepest you can go in USB firmware development, and it will give you permanent insight into what TinyUSB does on your behalf in every project.

Technology