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.
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
You Are Here
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 Devices
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, writing descriptors, OS driver interaction
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
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:
- 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.
- 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.
- Payload fields — type-dependent content: addresses, endpoint numbers, data bytes, frame numbers. Present or absent depending on PID type.
- CRC (Cyclic Redundancy Check) — CRC5 (5 bits) for Token packets, CRC16 (16 bits) for Data packets. Detects bit errors in the payload.
- 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:
- Wireshark + USBPcap (Windows) or usbmon (Linux): Capture the exact bytes exchanged during enumeration. Compare the GetDescriptor response with your expected descriptor byte-by-byte.
- 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.
- 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.
- 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.
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.
Related Articles in This Series
Part 2: USB Electrical & Hardware
D+/D- differential signaling, pull-up resistors for speed detection, VBUS power management, ESD protection, and PCB layout rules.
Read Article
Part 5: TinyUSB Deep Dive
TinyUSB architecture, execution model, STM32 integration, and a complete working USB device built step-by-step.
Read Article
Part 10: USB Debugging
Using Wireshark, hardware protocol analysers, and systematic techniques to diagnose enumeration failures and data transfer problems.
Read Article