Back to Technology

USB Part 3: Protocol & Enumeration

March 31, 2026 Wasil Zafar 22 min read

Understand USB protocol mechanics from the packet level up — SYNC, PID, DATA, CRC fields, the complete 9-step enumeration sequence, all four descriptor types, and how the OS assigns device addresses.

Table of Contents

  1. USB Packet Structure
  2. Transaction Types
  3. The Complete Enumeration Sequence
  4. Device Descriptor — 18-Byte Layout
  5. Configuration, Interface & Endpoint Descriptors
  6. String Descriptors
  7. TinyUSB Descriptor Callbacks
  8. Common Enumeration Failures
  9. Practical Exercises
  10. USB Enumeration Design Generator
  11. Conclusion & Next Steps
Series Context: This is Part 3 of the 17-part USB Development Mastery series. In Part 2 we covered the physical and electrical layer — differential signaling, pull-up resistors, VBUS, ESD, and PCB layout. Now we ascend to the protocol layer: packets, transactions, and the enumeration sequence that turns a physical connection into a working device.

USB Development Mastery

Your 17-step learning path • Currently on Step 3

USB Packet Structure

Every piece of information that travels over the USB bus — whether it is a control request, a keypress report, or 512 bytes of disk sector data — is contained in a packet. A USB packet is a precisely formatted sequence of bits with a mandatory structure. Understanding this structure is not merely academic: when a protocol analyser shows you a raw capture, you read it packet by packet.

Every USB packet has the following structure:

  1. SYNC field — a fixed bit pattern that the receiver uses to lock its bit clock. For Full Speed (12 Mbit/s) this is 8 bits (the NRZI encoding of 0x80, which produces 7 alternating transitions followed by two zeros). For High Speed (480 Mbit/s), SYNC is 32 bits to give the receiver more time to lock its clock recovery circuit.
  2. PID (Packet IDentifier) — a single byte that identifies the packet type. The high nibble carries the type code; the low nibble carries its bitwise complement (a 4-bit check field). If the PID byte arrives with any bit error, the complement check fails and the packet is discarded.
  3. Payload fields — type-dependent content: addresses, endpoint numbers, data bytes, frame numbers. Present or absent depending on PID type.
  4. CRC (Cyclic Redundancy Check) — CRC5 (5 bits) for Token packets, CRC16 (16 bits) for Data packets. Detects bit errors in the payload.
  5. EOP (End-of-Packet) — an SE0 condition (both D+ and D- low) for at least 2 bit times, followed by a J state. This is the hardware-level signal that the packet is complete.

PID Byte Values

PID Group Name PID Value (hex) Packet Type Direction
Token OUT 0xE1 Host → Device data transfer Host sends
IN 0x69 Host requests data from Device Host sends
SETUP 0x2D Host initiates control transfer Host sends
SOF Start-of-Frame 0xA5 Frame marker, sent every 1 ms (FS) Host sends
Data DATA0 0xC3 Data packet (even toggle) Either direction
DATA1 0x4B Data packet (odd toggle) Either direction
Handshake ACK 0xD2 Transaction accepted, no errors Either direction
NAK 0x5A Device busy, retry later Device sends
STALL 0x1E Error / unsupported request Device sends
NYET 0x96 Not yet (HS only, PING protocol) Device sends

Raw Packet Layout in C

/* ---------------------------------------------------------------
 * USB packet field layout — conceptual C struct
 * (The actual bit ordering is LSB-first on the wire; this struct
 *  shows the logical fields for human comprehension.)
 * ---------------------------------------------------------------*/

/* Token packet (SETUP, IN, OUT) — follows SYNC byte */
typedef struct {
    uint8_t  pid;           /* Packet ID, e.g., 0x2D = SETUP   */
    uint8_t  addr  : 7;     /* Device address (0–127)           */
    uint8_t  endp  : 4;     /* Endpoint number (0–15)           */
    uint8_t  crc5  : 5;     /* CRC over addr + endp             */
} __attribute__((packed)) USB_TokenPacket_t;

/* Data packet (DATA0, DATA1) — follows SYNC byte */
typedef struct {
    uint8_t  pid;           /* 0xC3 = DATA0, 0x4B = DATA1      */
    uint8_t  data[64];      /* Payload: 0–1024 bytes            */
    uint16_t crc16;         /* CRC-16 over data bytes           */
} __attribute__((packed)) USB_DataPacket_t;

/* SETUP packet data payload — always exactly 8 bytes            */
typedef struct {
    uint8_t  bmRequestType; /* D7=direction D6:5=type D4:0=recip*/
    uint8_t  bRequest;      /* Request code (e.g., 5=SET_ADDRESS)*/
    uint16_t wValue;        /* Request-specific value            */
    uint16_t wIndex;        /* Request-specific index            */
    uint16_t wLength;       /* Number of data bytes to follow    */
} __attribute__((packed)) USB_SetupPacket_t;

DATA0 / DATA1 Toggle

USB uses a data toggle mechanism to detect lost or duplicated packets. Every data packet alternates between DATA0 and DATA1. The sender and receiver each maintain a toggle bit for each endpoint. If the receiver gets DATA0 when it expected DATA1 (or vice versa), it knows a packet was duplicated (possibly from a spurious re-transmission after a lost ACK). The toggle is reset to DATA0 on every SETUP transaction and on a USB reset.

Transaction Types

A transaction is the fundamental unit of USB communication. Each transaction consists of up to three packets: a Token packet, an optional Data packet, and an optional Handshake packet. The host always sends the Token packet first — the device never initiates.

The Three Transaction Phases

Every non-SOF transaction follows the pattern: Token → Data → Handshake. The presence and direction of the Data and Handshake packets depends on the transaction type:

Transaction Type Token Phase Data Phase Handshake Phase Use Case
IN Transaction Host sends IN token Device sends DATA0/1 Host sends ACK/NAK Device → Host data (e.g., HID report)
OUT Transaction Host sends OUT token Host sends DATA0/1 Device sends ACK/NAK/STALL Host → Device data (e.g., CDC write)
SETUP Transaction Host sends SETUP token Host sends DATA0 (8 bytes) Device sends ACK Control transfer initiation (enumeration)
SOF Packet Host sends SOF (frame number) None None Bus timing, isochronous synchronization

Handshake PID Meanings

Handshake PID Sent By Meaning Host Action
ACK Receiver (host or device) Packet received without error, accepted Advance to next transaction
NAK Device only Device is temporarily busy — no error, but retry needed Retry the same transaction later (same frame or next frame)
STALL Device only Request is unsupported or endpoint is in error state Report error to USB driver; issue CLEAR_FEATURE to unstall
NYET Device only (HS) High Speed only: device buffer full, use PING protocol before next OUT Send PING token before next OUT transaction to this endpoint

ASCII Timing Diagram: Control Transfer (SETUP stage)

Host:   [SETUP token: addr=0, ep=0] [DATA0: 8-byte setup packet] ...
                                                                  ↓
Device:                                                        [ACK]

Host:   [IN token: addr=0, ep=0] ...
                                 ↓
Device:                       [DATA1: descriptor bytes 0..7] ...
                                                               ↓
Host:                                                       [ACK]

Host:   [OUT token: addr=0, ep=0] [DATA1: zero-length] ...
                                                          ↓
Device:                                               [ACK]

This three-stage pattern — SETUP stage, DATA stage, STATUS stage — is the structure of every control transfer. The SETUP stage always uses DATA0. The DATA stage alternates DATA0/DATA1. The STATUS stage is always a zero-length data packet with the opposite direction to the DATA stage.

The Complete Enumeration Sequence

Enumeration is the process by which a USB host discovers, configures, and loads a driver for a newly connected device. It is a precisely ordered sequence of control transfers. Any deviation — wrong response, wrong timing, wrong descriptor length — causes the host to fail silently or display "Unknown Device". Here is the complete nine-step sequence with the actual control request bytes at each step:

Step 1: Device Attach (Pull-up Detected)

The host's hub detects the voltage rise on D+ (Full Speed) and informs the host controller. No firmware runs yet — this is a hardware event. The host waits a debounce period of at least 100 ms after detecting the attach before proceeding.

Step 2: Reset — SE0 for ≥ 10 ms

The host drives SE0 (both D+ and D- low) for at least 10 ms. This resets the device to its initial state: address 0, DATA0 toggle, all endpoints inactive. The device must be able to respond to control endpoint EP0 at address 0 within 10 ms of the reset ending.

Step 3: First GetDescriptor(Device, 8 bytes) at Address 0

The host sends a GetDescriptor request to address 0, asking for only the first 8 bytes of the Device Descriptor. Why only 8 bytes? Because the host doesn't yet know the maximum packet size of EP0 (bMaxPacketSize0 field). By requesting only 8 bytes — which always fit in a single packet regardless of EP0 size — it can safely read the bMaxPacketSize0 field from offset 7.

/* SETUP packet for GetDescriptor(Device Descriptor, first 8 bytes)
 * Sent at address 0, before SET_ADDRESS
 *
 * bmRequestType = 0x80  (Device-to-Host, Standard, Device)
 * bRequest      = 0x06  (GET_DESCRIPTOR)
 * wValue        = 0x0100 (Descriptor Type=Device (0x01), Index=0)
 * wIndex        = 0x0000 (Language ID = 0 for device descriptor)
 * wLength       = 0x0008 (Only first 8 bytes requested)
 */
uint8_t setup_get_dev_desc_8[8] = {
    0x80,  /* bmRequestType */
    0x06,  /* bRequest = GET_DESCRIPTOR */
    0x00,  /* wValue low  = descriptor index 0 */
    0x01,  /* wValue high = descriptor type Device */
    0x00,  /* wIndex low  */
    0x00,  /* wIndex high */
    0x08,  /* wLength low  = 8 bytes */
    0x00   /* wLength high */
};

Step 4: Reset Again

After reading the first 8 bytes and determining bMaxPacketSize0, the host issues a second reset to the device. This is mandated by the USB specification to ensure the device is in a fully known state before address assignment. The device returns to address 0.

Step 5: SetAddress(N)

The host assigns a unique address (1–127) to the device with the SET_ADDRESS control request. The device must ACK this request while still at address 0, then switch to the new address within 2 ms. This is a subtle timing requirement: the device must start responding at the new address only after the STATUS stage (the host's zero-length ACK) is complete.

/* SETUP packet for SetAddress(N)
 *
 * bmRequestType = 0x00  (Host-to-Device, Standard, Device)
 * bRequest      = 0x05  (SET_ADDRESS)
 * wValue        = N     (the new address, e.g., 0x0003 for address 3)
 * wIndex        = 0x0000
 * wLength       = 0x0000 (No data stage — status stage only)
 */
uint8_t setup_set_address[8] = {
    0x00,  /* bmRequestType */
    0x05,  /* bRequest = SET_ADDRESS */
    0x03,  /* wValue low  = new address (e.g., 3) */
    0x00,  /* wValue high */
    0x00,  /* wIndex low  */
    0x00,  /* wIndex high */
    0x00,  /* wLength = 0 (no data phase) */
    0x00
};

/* TinyUSB handles SET_ADDRESS automatically in its control handler.
 * The address change is deferred until after the STATUS stage ACK: */
/* In tusb source: dcd_set_address(rhport, new_addr) is called
 * from the STATUS stage complete callback, not from the SETUP stage. */

Step 6: GetDescriptor(Device, 18 bytes) at New Address

Now that the device has its assigned address and the host knows the correct EP0 size, it re-requests the full Device Descriptor — all 18 bytes. This time, the host reads bDeviceClass, bDeviceSubClass, bDeviceProtocol, idVendor, idProduct, and bcdDevice — the information it uses to find a matching OS driver.

Step 7: GetDescriptor(Configuration)

The host requests the Configuration Descriptor. The first request typically asks for just 9 bytes (the Configuration Descriptor header) to read wTotalLength. Then it issues a second request for the full wTotalLength bytes, which includes all Interface Descriptors, Endpoint Descriptors, and any class-specific descriptors in one contiguous block.

Step 8: GetDescriptor(String) × N

The host optionally requests string descriptors. It begins with String Index 0 (the Language ID descriptor) to learn which languages the device supports, then requests the manufacturer string (iManufacturer index from Device Descriptor), product string (iProduct), and serial number string (iSerialNumber). On Windows, the OS also requests the Microsoft OS Descriptor (string index 0xEE) to check for WinUSB or other extended compatibility descriptors.

Step 9: SetConfiguration(1)

The host selects a configuration by sending SET_CONFIGURATION with the bConfigurationValue from the chosen Configuration Descriptor (almost always 1). This is the final enumeration step — all endpoints become active, the device's bMaxPower current limit comes into effect, and the host loads the appropriate class driver. After this, the device is fully operational.

Enumeration Timeline: The entire enumeration sequence from attach to SET_CONFIGURATION typically takes 200–500 ms on Windows, 100–300 ms on Linux, and 300–600 ms on macOS. The debounce period alone is 100 ms. This matters for firmware that powers up LEDs or starts tasks after USB becomes active — use the tud_mount_cb() callback in TinyUSB, not a fixed delay.

Device Descriptor — 18-Byte Layout

The Device Descriptor is the root of the USB descriptor tree. It is exactly 18 bytes. Every byte has a specific meaning defined by the USB specification, and several fields must have exact values for the device to enumerate correctly on all operating systems.

Offset Field Size Example Value Notes
0 bLength 1 0x12 (18) Always 18 for Device Descriptor
1 bDescriptorType 1 0x01 Device = 1
2–3 bcdUSB 2 0x0200 USB version: 0x0200=USB2.0, 0x0110=USB1.1. Must be 0x0200 or higher to use HS.
4 bDeviceClass 1 0x00 0x00 = defined per interface (common). 0x02 = CDC. 0xFF = vendor-specific.
5 bDeviceSubClass 1 0x00 Must be 0x00 if bDeviceClass=0x00
6 bDeviceProtocol 1 0x00 Must be 0x00 if bDeviceClass=0x00
7 bMaxPacketSize0 1 0x40 (64) EP0 max packet size. Must be 8, 16, 32, or 64 for FS. Must be 64 for HS.
8–9 idVendor 2 0xCafe VID: assigned by USB-IF ($6,000 fee). Use 0xCafe (TinyUSB demo) for prototypes.
10–11 idProduct 2 0x4001 PID: vendor-assigned. Must be unique per VID for each device type.
12–13 bcdDevice 2 0x0100 Device release version (BCD). 0x0100 = version 1.00
14 iManufacturer 1 0x01 String index for manufacturer name. 0 = no string.
15 iProduct 1 0x02 String index for product name.
16 iSerialNumber 1 0x03 String index for serial number. Must be unique per device for Windows driver caching.
17 bNumConfigurations 1 0x01 Almost always 1. Multiple configurations are rarely used.
/* USB Device Descriptor — C struct definition
 * Packed to guarantee no compiler padding between fields */
typedef struct {
    uint8_t  bLength;            /* 0x12 = 18 bytes                */
    uint8_t  bDescriptorType;    /* 0x01 = Device Descriptor       */
    uint16_t bcdUSB;             /* 0x0200 = USB 2.0               */
    uint8_t  bDeviceClass;       /* 0x00 = per-interface class     */
    uint8_t  bDeviceSubClass;    /* 0x00                           */
    uint8_t  bDeviceProtocol;    /* 0x00                           */
    uint8_t  bMaxPacketSize0;    /* 64 for FS; must be 64 for HS   */
    uint16_t idVendor;           /* VID from USB-IF                */
    uint16_t idProduct;          /* PID assigned by vendor         */
    uint16_t bcdDevice;          /* Device release version (BCD)   */
    uint8_t  iManufacturer;      /* String index, 0 = none         */
    uint8_t  iProduct;           /* String index, 0 = none         */
    uint8_t  iSerialNumber;      /* String index, 0 = none         */
    uint8_t  bNumConfigurations; /* Number of configurations       */
} __attribute__((packed)) USB_DeviceDescriptor_t;

/* Example: CDC (Virtual COM Port) device descriptor */
static const USB_DeviceDescriptor_t device_desc = {
    .bLength            = sizeof(USB_DeviceDescriptor_t),
    .bDescriptorType    = 0x01,
    .bcdUSB             = 0x0200,
    .bDeviceClass       = 0x02,   /* CDC class at device level */
    .bDeviceSubClass    = 0x00,
    .bDeviceProtocol    = 0x00,
    .bMaxPacketSize0    = 64,
    .idVendor           = 0xCafe,
    .idProduct          = 0x4001,
    .bcdDevice          = 0x0100,
    .iManufacturer      = 1,
    .iProduct           = 2,
    .iSerialNumber      = 3,
    .bNumConfigurations = 1
};

Configuration, Interface & Endpoint Descriptors

The Configuration Descriptor is the container for all functional descriptors. When the host requests the configuration descriptor with wLength = wTotalLength, it receives a single contiguous block of bytes containing the Configuration Descriptor, followed by all Interface Descriptors, followed by all Endpoint Descriptors (and any class-specific descriptors interspersed). The order is mandatory — the USB specification defines the exact parsing order.

Configuration Descriptor Fields

Field Size Example Notes
bLength 1 0x09 Always 9 for Configuration Descriptor header
bDescriptorType 1 0x02 Configuration = 2
wTotalLength 2 0x0043 Total bytes in config block including all sub-descriptors. Critical — must be exact.
bNumInterfaces 1 0x02 Number of interfaces (e.g., 2 for CDC: Control + Data)
bConfigurationValue 1 0x01 ID used in SET_CONFIGURATION. Almost always 1.
iConfiguration 1 0x00 String index for this configuration name (usually 0).
bmAttributes 1 0x80 Bit 7: must be 1. Bit 6: self-powered. Bit 5: remote wakeup. Bits 4:0: 0.
bMaxPower 1 0x32 Max current in units of 2 mA. 0x32 = 100 mA. 0xFA = 500 mA.

Interface and Endpoint Descriptor C Example

/* ---------------------------------------------------------------
 * Complete configuration descriptor for a CDC device
 * Includes: Configuration + CDC Control Interface + CDC Functional
 * Descriptors + Notification Endpoint + CDC Data Interface + 2 Endpoints
 * Total = 9 + 9 + 5 + 5 + 4 + 5 + 7 + 9 + 7 + 7 = 67 bytes
 * ---------------------------------------------------------------*/

/* Endpoint descriptor */
typedef struct {
    uint8_t  bLength;          /* 7 bytes                      */
    uint8_t  bDescriptorType;  /* 0x05 = Endpoint              */
    uint8_t  bEndpointAddress; /* Bit7=direction (1=IN), Bits3:0=EP number */
    uint8_t  bmAttributes;     /* Bits1:0: transfer type       */
    uint16_t wMaxPacketSize;   /* Max bytes per transaction     */
    uint8_t  bInterval;        /* Polling interval (ms for interrupt) */
} __attribute__((packed)) USB_EndpointDescriptor_t;

/* Interface descriptor */
typedef struct {
    uint8_t  bLength;            /* 9 bytes                    */
    uint8_t  bDescriptorType;    /* 0x04 = Interface           */
    uint8_t  bInterfaceNumber;   /* Zero-based interface index */
    uint8_t  bAlternateSetting;  /* Usually 0                  */
    uint8_t  bNumEndpoints;      /* Number of EPs (excluding EP0) */
    uint8_t  bInterfaceClass;    /* Class code                 */
    uint8_t  bInterfaceSubClass; /* Subclass code              */
    uint8_t  bInterfaceProtocol; /* Protocol code              */
    uint8_t  iInterface;         /* String index (0 = none)    */
} __attribute__((packed)) USB_InterfaceDescriptor_t;

/* CDC configuration: interface 0 = CDC Control, interface 1 = CDC Data */
static const uint8_t cdc_configuration_desc[] = {
    /* Configuration Descriptor: 9 bytes */
    0x09, 0x02,       /* bLength, bDescriptorType */
    0x43, 0x00,       /* wTotalLength = 67 */
    0x02,             /* bNumInterfaces = 2 */
    0x01,             /* bConfigurationValue = 1 */
    0x00,             /* iConfiguration = 0 */
    0x80,             /* bmAttributes: bus-powered, no remote wakeup */
    0x32,             /* bMaxPower = 100 mA */

    /* Interface 0: CDC Control — 9 bytes */
    0x09, 0x04,       /* bLength, bDescriptorType=Interface */
    0x00,             /* bInterfaceNumber = 0 */
    0x00,             /* bAlternateSetting = 0 */
    0x01,             /* bNumEndpoints = 1 (notification endpoint) */
    0x02,             /* bInterfaceClass = CDC */
    0x02,             /* bInterfaceSubClass = Abstract Control Model */
    0x01,             /* bInterfaceProtocol = AT Command */
    0x00,             /* iInterface = 0 */

    /* CDC Header Functional Descriptor: 5 bytes */
    0x05, 0x24, 0x00, 0x10, 0x01,  /* bcdCDC = 0x0110 */

    /* CDC Call Management Functional Descriptor: 5 bytes */
    0x05, 0x24, 0x01, 0x00, 0x01,  /* bmCapabilities=0, bDataInterface=1 */

    /* CDC Abstract Control Management Functional Descriptor: 4 bytes */
    0x04, 0x24, 0x02, 0x02,  /* bmCapabilities: supports Set/Get_Line_Coding */

    /* CDC Union Functional Descriptor: 5 bytes */
    0x05, 0x24, 0x06, 0x00, 0x01,  /* bMasterInterface=0, bSlaveInterface=1 */

    /* Endpoint 0x81 (EP1 IN, Interrupt): 7 bytes */
    0x07, 0x05,       /* bLength, bDescriptorType=Endpoint */
    0x81,             /* bEndpointAddress: EP1, IN direction */
    0x03,             /* bmAttributes: Interrupt */
    0x08, 0x00,       /* wMaxPacketSize = 8 */
    0x10,             /* bInterval = 16 ms */

    /* Interface 1: CDC Data — 9 bytes */
    0x09, 0x04,
    0x01,             /* bInterfaceNumber = 1 */
    0x00,             /* bAlternateSetting = 0 */
    0x02,             /* bNumEndpoints = 2 (bulk IN + bulk OUT) */
    0x0A,             /* bInterfaceClass = CDC Data */
    0x00,             /* bInterfaceSubClass */
    0x00,             /* bInterfaceProtocol */
    0x00,             /* iInterface */

    /* Endpoint 0x02 (EP2 OUT, Bulk): 7 bytes */
    0x07, 0x05,
    0x02,             /* EP2 OUT */
    0x02,             /* Bulk */
    0x40, 0x00,       /* wMaxPacketSize = 64 */
    0x00,             /* bInterval = 0 (bulk ignores this) */

    /* Endpoint 0x82 (EP2 IN, Bulk): 7 bytes */
    0x07, 0x05,
    0x82,             /* EP2 IN */
    0x02,             /* Bulk */
    0x40, 0x00,       /* wMaxPacketSize = 64 */
    0x00
};
wTotalLength Must Be Exact: If wTotalLength is larger than the actual descriptor array, the host will request those extra bytes, receive garbage or a short packet, and fail enumeration. If it is smaller, the host will miss endpoints or class descriptors and the driver will not work. Count every byte in your configuration descriptor array and verify wTotalLength matches exactly.

String Descriptors

String descriptors provide human-readable names for the device (manufacturer, product, serial number) and configurations/interfaces. They are optional in the sense that the device will enumerate without them — but Windows, Linux, and macOS all display these strings in their device managers, and missing serial numbers cause Windows to fail to reinstall correct drivers after hardware changes.

String Descriptor 0 — Language ID

String Descriptor index 0 is special — it does not contain a text string. It contains a list of Language IDs (LANGID codes) that the device supports. Each LANGID is a 2-byte code defined by the USB-IF. English (US) is 0x0409. Most embedded devices support only English, so String Descriptor 0 is always exactly 4 bytes:

/* String Descriptor 0: Language IDs — 4 bytes total
 * bLength = 4, bDescriptorType = 3 (String), LANGID = 0x0409 (English US) */
static const uint8_t string_desc_0[4] = {
    0x04, 0x03,   /* bLength=4, bDescriptorType=String(3) */
    0x09, 0x04    /* LANGID: 0x0409 = English (United States) */
};

String Descriptor Encoding — UTF-16LE

All USB string descriptors (index 1 and above) encode their text as UTF-16 Little Endian. Each character in the ASCII range occupies 2 bytes (the character byte followed by 0x00). The total length is 2 + (2 × number_of_characters):

/* Helper macro: build a USB string descriptor from a C string literal.
 * Usage: USB_STRING_DESC("MyDevice") generates the correct UTF-16LE encoding.
 *
 * The C preprocessor cannot generate UTF-16LE directly, so TinyUSB provides
 * a runtime conversion function. Here we show the manual byte layout for
 * the string "WZ Lab" as a concrete example:
 *
 * "WZ Lab" = 6 chars → bLength = 2 + 12 = 14 bytes
 *
 *  Offset  Byte   Meaning
 *  0       0x0E   bLength = 14
 *  1       0x03   bDescriptorType = String
 *  2       'W'    0x57
 *  3       0x00   (UTF-16LE high byte for ASCII char)
 *  4       'Z'    0x5A
 *  5       0x00
 *  6       ' '    0x20
 *  7       0x00
 *  8       'L'    0x4C
 *  9       0x00
 *  10      'a'    0x61
 *  11      0x00
 *  12      'b'    0x62
 *  13      0x00
 */
static const uint8_t string_manufacturer[] = {
    14, 0x03,                            /* bLength, bDescriptorType */
    'W', 0, 'Z', 0, ' ', 0,             /* "WZ " */
    'L', 0, 'a', 0, 'b', 0             /* "Lab" */
};

/* TinyUSB approach: supply plain C strings and let tud_descriptor_string_cb
 * convert them to UTF-16LE at runtime using the tu_utf8_to_utf16() helper. */
static const char *string_descriptors[] = {
    (const char[]){0x09, 0x04},   /* Index 0: LANGID = English US */
    "WZ Lab",                      /* Index 1: iManufacturer */
    "USB Data Logger",             /* Index 2: iProduct */
    "WZL0001",                     /* Index 3: iSerialNumber */
};
Serial Number Uniqueness: Windows caches driver binding decisions per VID+PID+SerialNumber. If two devices have the same VID, PID, and serial number string, Windows may load the wrong driver for the second device. Embed a unique identifier — use the MCU's UID register (e.g., STM32's 96-bit unique device ID at 0x1FFF7A10) formatted as a hex string.

TinyUSB Descriptor Callbacks

TinyUSB does not hardcode your descriptors — it calls a set of weak-linked callback functions that you implement in your application code. This gives you full control over the descriptor content while TinyUSB handles the low-level control transfer machinery. You must implement these three functions for any TinyUSB device; TinyUSB will fail to enumerate if they are missing or return NULL.

/* ================================================================
 * tinyusb_descriptors.c
 * Complete descriptor implementation for a CDC (Virtual COM Port) device
 * Target: STM32F407, TinyUSB with OTG_FS driver
 * ================================================================ */

#include "tusb.h"

/* ----------------------------------------------------------------
 * Device Descriptor
 * Returned in response to GetDescriptor(Device) requests.
 * ----------------------------------------------------------------*/
static const tusb_desc_device_t device_descriptor = {
    .bLength            = sizeof(tusb_desc_device_t),  /* 18 */
    .bDescriptorType    = TUSB_DESC_DEVICE,            /* 0x01 */
    .bcdUSB             = 0x0200,   /* USB 2.0 */

    /* CDC devices commonly set class/subclass/protocol at device level */
    .bDeviceClass       = TUSB_CLASS_CDC,  /* 0x02 */
    .bDeviceSubClass    = 0x00,
    .bDeviceProtocol    = 0x00,

    .bMaxPacketSize0    = CFG_TUD_ENDPOINT0_SIZE,  /* 64 for FS */
    .idVendor           = 0xCafe,   /* TinyUSB demo VID */
    .idProduct          = 0x4001,
    .bcdDevice          = 0x0100,   /* v1.00 */
    .iManufacturer      = 0x01,     /* String index 1 */
    .iProduct           = 0x02,     /* String index 2 */
    .iSerialNumber      = 0x03,     /* String index 3 */
    .bNumConfigurations = 0x01
};

/* Callback: called by TinyUSB when host sends GetDescriptor(Device) */
uint8_t const * tud_descriptor_device_cb(void)
{
    return (uint8_t const *)&device_descriptor;
    /* TinyUSB reads bLength to know how many bytes to send */
}

/* ----------------------------------------------------------------
 * Configuration Descriptor
 * TinyUSB provides helper macros that generate the correct byte arrays.
 * CDC requires 2 interfaces: 1 control + 1 data.
 * ----------------------------------------------------------------*/
#define CONFIG_TOTAL_LEN  (TUD_CONFIG_DESC_LEN + TUD_CDC_DESC_LEN)

/* EP definitions */
#define EPNUM_CDC_NOTIF  0x81  /* EP1 IN — interrupt, CDC notifications */
#define EPNUM_CDC_OUT    0x02  /* EP2 OUT — bulk, host-to-device data   */
#define EPNUM_CDC_IN     0x82  /* EP2 IN  — bulk, device-to-host data   */

static const uint8_t configuration_descriptor[] = {
    /* Config descriptor header: 9 bytes */
    TUD_CONFIG_DESCRIPTOR(
        1,               /* bConfigurationValue */
        2,               /* bNumInterfaces */
        0,               /* iConfiguration string index */
        CONFIG_TOTAL_LEN,/* wTotalLength — computed by macros */
        0x00,            /* bmAttributes: bus-powered, no remote wakeup */
        100              /* bMaxPower: 100 mA */
    ),

    /* CDC interface pair: Control interface (0) + Data interface (1) */
    TUD_CDC_DESCRIPTOR(
        0,               /* bInterfaceNumber for CDC Control interface */
        4,               /* iInterface string index (0 = no string) */
        EPNUM_CDC_NOTIF, /* notification endpoint address */
        8,               /* notification endpoint max packet size */
        EPNUM_CDC_OUT,   /* bulk OUT endpoint address */
        EPNUM_CDC_IN,    /* bulk IN endpoint address */
        64               /* bulk endpoint max packet size */
    )
};

/* Callback: called when host sends GetDescriptor(Configuration) */
uint8_t const * tud_descriptor_configuration_cb(uint8_t index)
{
    /* index: the configuration index (0-based, even though
     * bConfigurationValue is 1-based). Almost always 0. */
    (void)index;
    return configuration_descriptor;
}

/* ----------------------------------------------------------------
 * String Descriptors
 * ----------------------------------------------------------------*/
static const char *string_desc_arr[] = {
    (const char[]){0x09, 0x04},  /* 0: Language ID = English US */
    "WZ Lab",                     /* 1: Manufacturer */
    "USB CDC Logger",             /* 2: Product */
    "WZL0001",                    /* 3: Serial Number */
};

/* Buffer for converted UTF-16LE string — must persist until next call */
static uint16_t utf16_buf[32];

/* Callback: called when host sends GetDescriptor(String, index) */
uint16_t const * tud_descriptor_string_cb(uint8_t index, uint16_t langid)
{
    uint8_t chr_count;
    (void)langid;  /* We only support English */

    if (index == 0) {
        /* Index 0: return LANGID array as-is */
        memcpy(&utf16_buf[1], string_desc_arr[0], 2);
        chr_count = 1;
    } else {
        if (index >= TU_ARRAY_SIZE(string_desc_arr)) {
            return NULL;  /* Invalid string index — host will see STALL */
        }
        const char *str = string_desc_arr[index];
        chr_count = (uint8_t)strlen(str);
        if (chr_count > 31) chr_count = 31;  /* Clamp to buffer size */

        /* Convert ASCII to UTF-16LE */
        for (uint8_t i = 0; i < chr_count; i++) {
            utf16_buf[1 + i] = str[i];  /* High byte is implicitly 0 */
        }
    }

    /* String descriptor header: bLength and bDescriptorType */
    utf16_buf[0] = (uint16_t)((TUSB_DESC_STRING << 8) | (2 * chr_count + 2));
    return utf16_buf;
}

Common Enumeration Failures

Most USB enumeration failures fall into a small set of repeatable root causes. This table is the first place to look when your device does not enumerate correctly:

Symptom Likely Cause Fix
Device not detected at all (no plug sound on Windows) Pull-up resistor missing, wrong voltage, or USB clock not running when pull-up enabled Verify D+ = ~2.9 V with multimeter when connected. Check SystemClock_Config() initialises USB clock before tusb_init().
Device detected but stuck at address 0 — "Unknown USB Device (Device Descriptor Request Failed)" tud_descriptor_device_cb() returns NULL, or EP0 max packet size mismatch, or firmware too slow to respond within 5 s timeout Ensure tud_descriptor_device_cb() returns valid pointer. Verify bMaxPacketSize0 = 64 for FS. Ensure tud_task() is called frequently.
Enumerates on Linux but "Unknown Device" on Windows wTotalLength in Configuration Descriptor is wrong (too large or too small) Count every byte in the configuration descriptor array and verify wTotalLength exactly matches. Linux is more forgiving of this error than Windows.
Device enumerates but class driver not loaded — no COM port, no HID device bInterfaceClass / bInterfaceSubClass / bInterfaceProtocol incorrect for the device class Verify class codes against USB Class Code table on USB-IF website. For CDC: Class=0x02, SubClass=0x02, Protocol=0x01.
Device enumerates correctly once but fails on re-plug Firmware does not reset its USB stack state on disconnect/reconnect. Or VBUS inrush resets the MCU. Implement tud_umount_cb() to reset application state. Add inrush limiting on VBUS to prevent brown-out reset of MCU during plug.
"Device descriptor read/64, error -32" in Linux dmesg Device NAKing the GetDescriptor request — TinyUSB not running (tud_task() not called) or firmware is in a fault handler Ensure tud_task() is called in the main loop without blocking. Check for hardfaults. Use ITM/SWO to print debug before tud_task().
Descriptor rejected: "Configuration descriptor too short" wTotalLength field reports more bytes than were actually returned (device sent a short packet) The configuration_descriptor[] array is probably smaller than CONFIG_TOTAL_LEN. Recalculate wTotalLength or use sizeof(configuration_descriptor).
bMaxPacketSize0 mismatch after re-attach After reset, firmware returned a different bMaxPacketSize0 than in the first GetDescriptor. OS caches the first value. bMaxPacketSize0 must be constant and correct from the first byte. Never change it between resets.

Debugging Tools for Enumeration Failures

When the symptom table doesn't immediately point to the cause, use these tools in order:

  1. Wireshark + USBPcap (Windows) or usbmon (Linux): Capture the exact bytes exchanged during enumeration. Compare the GetDescriptor response with your expected descriptor byte-by-byte.
  2. Hardware USB protocol analyser (e.g., Total Phase Beagle USB 480, OpenVizsla): Captures every packet including the low-level token packets that software captures miss. Essential for diagnosing timing issues.
  3. UART/SWO debug output: Add printf() calls inside tud_descriptor_device_cb(), tud_descriptor_configuration_cb(), and tud_descriptor_string_cb() to confirm they are called and what they return.
  4. USB Device Tree Viewer (Windows) or lsusb -v (Linux): Parse the descriptor tree that the OS actually received, and compare it field by field with your intended descriptors.

Practical Exercises

These exercises build hands-on understanding of USB enumeration and descriptor design. Work through them in order — each exercise solidifies a different aspect of the material.

Exercise 1 Beginner

Read a Real USB Descriptor with lsusb

On a Linux machine (or WSL2 on Windows), plug in any USB device and run lsusb -v -d [vid]:[pid]. Find and identify: (a) the Device Descriptor and locate bMaxPacketSize0, idVendor, idProduct, and bcdUSB, (b) the Configuration Descriptor and verify that wTotalLength equals the sum of all descriptor bytes shown, (c) at least one Endpoint Descriptor and identify its direction (IN or OUT from the bit 7 of bEndpointAddress), transfer type (bmAttributes bits 1:0), and max packet size. Reproduce the first 18 bytes of the Device Descriptor as a C struct initialiser.

lsusb Device Descriptor Descriptor Parsing
Exercise 2 Intermediate

Capture the Full Enumeration Sequence with Wireshark

Install Wireshark with USBPcap (Windows) or use usbmon on Linux. Capture the enumeration of a USB flash drive from the moment it is plugged in to the moment the OS reports it ready. In your Wireshark capture, identify and timestamp: (a) the first GetDescriptor(Device, 8 bytes) at address 0, (b) the SET_ADDRESS request and the new address assigned, (c) the full GetDescriptor(Configuration) request and the total bytes returned, (d) the SET_CONFIGURATION(1) request. Note the time elapsed from first packet to SET_CONFIGURATION. Calculate how many ms the host waited in the debounce period before sending the first GetDescriptor.

Wireshark Enumeration Protocol Capture
Exercise 3 Advanced

Implement and Debug a CDC Device Descriptor Set

Using TinyUSB on an STM32F4 Nucleo or RP2040 board, implement the three descriptor callbacks (tud_descriptor_device_cb, tud_descriptor_configuration_cb, tud_descriptor_string_cb) for a CDC device from scratch — without using any example code as a starting point. Define the Device Descriptor with your own VID/PID (use 0xCafe/0x4001 for prototype). Calculate wTotalLength by hand and verify it against sizeof(configuration_descriptor). Intentionally introduce a wTotalLength error (set it 2 bytes too large) and observe what happens in lsusb and dmesg. Then fix the error. Document each field you had to look up in the USB 2.0 specification, with the section number where you found it.

TinyUSB CDC Descriptors Debugging

USB Enumeration Design Document Generator

Use this tool to document your USB enumeration design — VID/PID selection, string descriptors, device class decisions, and MCU selection. Download as Word, Excel, PDF, or PowerPoint for design review, team handoff, or compliance documentation.

USB Enumeration Design Generator

Document your USB descriptor design and VID/PID assignments for export and review.

Draft auto-saved

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

Conclusion & Next Steps

In this article we covered the USB protocol layer from raw packets to the complete enumeration sequence:

  • Every USB packet follows a rigid structure: SYNC → PID → payload → CRC → EOP. The PID byte identifies the packet type and includes a 4-bit check to detect corrupted PIDs.
  • Every USB transaction follows Token → Data → Handshake. The host always initiates with a Token packet; the device can only respond. ACK means success; NAK means retry; STALL means error.
  • The nine-step enumeration sequence is non-negotiable. Each step must complete correctly for the device to move to the next. The most common failures are wrong bMaxPacketSize0, incorrect wTotalLength, and descriptors returned late or not at all.
  • The Device Descriptor is 18 bytes and provides the host with all the identity information it needs to find a driver. bMaxPacketSize0 at offset 7 is the single most critical field — it must be correct before any other descriptor can be safely transferred.
  • The Configuration Descriptor is a contiguous binary block containing the config header, all interface headers, all class-specific descriptors, and all endpoint descriptors. wTotalLength must exactly match the byte count of this block.
  • String Descriptors use UTF-16LE encoding. Index 0 is the Language ID array; indices 1 and above are text strings. A unique serial number is important for correct Windows driver caching.
  • TinyUSB's three descriptor callbacks — device, configuration, and string — are the minimum implementation required for any TinyUSB device. They must return non-NULL pointers to statically allocated, correctly formatted descriptor data.

Next in the Series

In Part 4: USB Device Classes Overview, we survey all the major USB device classes — HID, CDC-ACM, Mass Storage, USB Audio, MIDI, Video — and learn what each class's specification requires at the descriptor level, what the host OS provides for free (class drivers), and how to choose the right class for your application. We'll also cover composite devices and the Interface Association Descriptor (IAD) that makes them possible.

Technology