Series Context: This is Part 9 of the 17-part USB Development Mastery series. Parts 1–8 covered USB fundamentals, electrical layer, protocol, device classes, TinyUSB, CDC, HID, and Mass Storage. Now we combine multiple classes into a single composite device — the most powerful pattern in practical USB firmware development.
1
USB Fundamentals
USB system architecture, transfer types, host/device model, protocol stack
Completed
2
Electrical & Hardware Layer
D+/D- signalling, pull-ups, connectors, USB-C, STM32 USB peripherals
Completed
3
Protocol & Enumeration
Enumeration sequence, USB packets, descriptors, endpoint concepts
Completed
4
USB Device Classes
HID, CDC, MSC, MIDI, Audio, composite devices, vendor class
Completed
5
TinyUSB Deep Dive
Stack architecture, execution model, STM32 integration, descriptor callbacks
Completed
6
CDC Virtual COM Port
CDC class, bulk transfers, printf over USB, baud rate handling
Completed
7
HID Keyboard & Mouse
HID descriptors, report format, keyboard/mouse/gamepad implementation
Completed
8
USB Mass Storage
MSC class, SCSI commands, FATFS integration, SD card, RAM disk
Completed
9
Composite Devices
Multiple classes, IAD descriptor, CDC+HID, CDC+MSC
You Are Here
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
What Is a Composite Device?
A USB composite device is a single physical USB device that presents multiple independent functions to the host through a single USB connection. From the host OS perspective, a composite device appears as several separate devices — each running its own class driver — even though they share one USB address, one cable, and one set of physical endpoints.
The classic example is an Arduino Leonardo: plug it in and Windows shows both a serial port (CDC) and an HID keyboard/mouse in Device Manager simultaneously. One USB cable, one USB address, two independent OS-level devices.
Real-World Composite Device Examples
| Device | Class 1 | Class 2 | Class 3 | Use Case |
| Arduino Leonardo | CDC-ACM | HID | — | Serial debug + keyboard/mouse emulation |
| STM32 DFU + CDC | DFU | CDC | — | Firmware update + debug port in one device |
| USB Audio headset | USB Audio | HID | — | Audio playback/mic + volume buttons |
| Embedded data logger | CDC | MSC | — | Debug serial + SD card file access |
| Multi-function dev kit | CDC | HID | MSC | Serial + custom HID + config flash drive |
| USB webcam with controls | UVC (Video) | HID | — | Video stream + hardware button interface |
How the OS Sees a Composite Device
During enumeration, the host reads the full configuration descriptor as a single binary blob. Inside that blob, interfaces are listed sequentially. The host OS then:
- Reads
bDeviceClass in the device descriptor. For composite devices this should be 0xEF (Miscellaneous Device Class) with bDeviceSubClass = 0x02 and bDeviceProtocol = 0x01. This tells Windows to look for Interface Association Descriptors.
- Scans the configuration descriptor for IADs. Each IAD groups a set of interfaces into one logical function.
- Loads a separate class driver for each function (each IAD group or standalone interface).
- Creates separate device nodes in Device Manager, each with its own driver stack.
Single Cable, Multiple Functions: The USB specification allows a single configuration to contain up to 32 interfaces (0–31). Each interface uses independent endpoint addresses. The USB controller hardware — on both host and device sides — multiplexes all these functions over the single physical USB cable transparently.
Interface Association Descriptor (IAD)
The Interface Association Descriptor (IAD) is a USB descriptor introduced in the USB 2.0 ECN (Engineering Change Notice) specifically to support composite devices. It tells the host OS that a group of consecutive interfaces form a single logical function and should be bound to a single class driver.
IAD Byte Layout
| Offset | Field | Size | Value | Description |
| 0 | bLength | 1 | 0x08 | IAD is always 8 bytes |
| 1 | bDescriptorType | 1 | 0x0B | IAD descriptor type |
| 2 | bFirstInterface | 1 | varies | Interface number of first interface in this function |
| 3 | bInterfaceCount | 1 | varies | Number of interfaces belonging to this function |
| 4 | bFunctionClass | 1 | varies | Class code of the function (same as bInterfaceClass) |
| 5 | bFunctionSubClass | 1 | varies | Subclass code of the function |
| 6 | bFunctionProtocol | 1 | varies | Protocol code of the function |
| 7 | iFunction | 1 | 0 or index | String index for function name (0 = none) |
Why CDC Needs an IAD
CDC-ACM (the virtual COM port class) is unusual among USB classes because it requires two interfaces to implement a single logical function:
- CDC Communications Interface — carries control requests (line coding, SET_CONTROL_LINE_STATE) and notifications (interrupt IN endpoint)
- CDC Data Interface — carries the actual data (bulk IN and bulk OUT endpoints)
Without an IAD, the host OS sees two unrelated interfaces and may try to bind separate drivers to each. The IAD groups them together so Windows knows both interfaces belong to the usbser.sys driver for a single serial port.
Critical Rule: Any USB device containing CDC — whether standalone or as part of a composite — should set bDeviceClass = 0xEF, bDeviceSubClass = 0x02, bDeviceProtocol = 0x01 in the device descriptor, and include an IAD for the CDC function. Without this, Windows fails to load usbser.sys and the serial port does not appear.
IAD Placement Rules
The IAD must appear in the configuration descriptor immediately before the first interface it groups. The order in the descriptor array is:
- Configuration Descriptor (always first)
- IAD for function 1 (immediately before its interfaces)
- Interface 0 Descriptor (first interface of function 1)
- ... additional descriptors for function 1 interfaces ...
- IAD for function 2 (if needed — immediately before function 2 interfaces)
- Interface N Descriptor (first interface of function 2)
- ... and so on
TinyUSB Composite Descriptor — CDC + HID
The CDC + HID composite is the most common pattern for development boards and test equipment: a virtual serial port for debug output combined with a custom HID device for status reporting or control. Interface numbering is: CDC Communications = 0, CDC Data = 1, HID = 2.
tusb_config.h for CDC + HID
/* tusb_config.h — CDC + HID composite configuration */
#define CFG_TUD_CDC 1 /* Enable CDC class */
#define CFG_TUD_HID 1 /* Enable HID class */
#define CFG_TUD_MSC 0 /* MSC disabled for this config */
#define CFG_TUD_CDC_RX_BUFSIZE 256
#define CFG_TUD_CDC_TX_BUFSIZE 256
#define CFG_TUD_HID_EP_BUFSIZE 64
/* Endpoint assignments */
#define EPNUM_CDC_NOTIF 0x81 /* CDC interrupt IN for notifications */
#define EPNUM_CDC_OUT 0x02 /* CDC bulk OUT */
#define EPNUM_CDC_IN 0x82 /* CDC bulk IN */
#define EPNUM_HID 0x83 /* HID interrupt IN */
#define CFG_TUSB_MCU OPT_MCU_STM32F4
#define CFG_TUSB_OS OPT_OS_NONE
Configuration Descriptor Array
/* usb_descriptors.c — CDC + HID composite descriptor */
/* Total descriptor length:
* Config desc = 9 bytes
* IAD for CDC = 8 bytes
* CDC comm itf = 9 bytes (interface)
* + 5 bytes (CDC header functional)
* + 5 bytes (CDC ACM functional)
* + 5 bytes (CDC union functional)
* + 7 bytes (notification EP)
* CDC data itf = 9 bytes (interface)
* + 7 bytes (bulk OUT EP)
* + 7 bytes (bulk IN EP)
* HID itf = 9 bytes (interface)
* + 9 bytes (HID descriptor)
* + 7 bytes (interrupt IN EP)
* TOTAL = TUD_CONFIG_DESC_LEN + TUD_CDC_DESC_LEN + TUD_HID_DESC_LEN
*/
#define CONFIG_TOTAL_LEN (TUD_CONFIG_DESC_LEN + TUD_CDC_DESC_LEN + \
TUD_HID_DESC_LEN)
/* HID Report Descriptor — simple 1-byte custom report */
uint8_t const desc_hid_report[] = {
TUD_HID_REPORT_DESC_GENERIC_INOUT(CFG_TUD_HID_EP_BUFSIZE)
};
uint8_t const desc_configuration[] = {
/* ── Configuration Descriptor ── */
TUD_CONFIG_DESCRIPTOR(
1, /* bConfigurationValue */
3, /* bNumInterfaces: CDC uses 2, HID uses 1 = total 3 */
0, /* iConfiguration */
CONFIG_TOTAL_LEN,
0x00, /* bmAttributes: bus-powered */
500 /* bMaxPower: 500 mA */
),
/* ── IAD for CDC (groups interfaces 0 and 1) ── */
/* TUD_CDC_DESCRIPTOR already embeds the IAD, so we use it directly */
/* bFirstInterface=0, bInterfaceCount=2, class=CDC (0x02) */
TUD_CDC_DESCRIPTOR(
0, /* itf_num — CDC comm interface is 0 */
4, /* stridx — string index for CDC interface name */
EPNUM_CDC_NOTIF,/* ep_notif — interrupt IN for notifications */
8, /* ep_notif_size — 8 bytes for notifications */
EPNUM_CDC_OUT, /* ep_out — bulk OUT for data */
EPNUM_CDC_IN, /* ep_in — bulk IN for data */
64 /* ep_size — 64 bytes for Full Speed */
),
/* ── HID Interface Descriptor ── */
TUD_HID_DESCRIPTOR(
2, /* itf_num — HID is interface 2 (after CDC 0+1) */
5, /* stridx — string index for HID interface name */
HID_ITF_PROTOCOL_NONE, /* protocol — generic HID, not boot keyboard/mouse */
sizeof(desc_hid_report), /* report_desc_len */
EPNUM_HID, /* ep_in — interrupt IN endpoint */
CFG_TUD_HID_EP_BUFSIZE, /* ep_size */
5 /* ep_interval_ms — polling interval 5 ms */
)
};
/* ── Device Descriptor — MUST use 0xEF/0x02/0x01 for composite with CDC ── */
tusb_desc_device_t const desc_device = {
.bLength = sizeof(tusb_desc_device_t),
.bDescriptorType = TUSB_DESC_DEVICE,
.bcdUSB = 0x0200,
.bDeviceClass = 0xEF, /* Miscellaneous — required for CDC composite */
.bDeviceSubClass = 0x02, /* Common Class */
.bDeviceProtocol = 0x01, /* Interface Association */
.bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE,
.idVendor = 0xCafe,
.idProduct = 0x4009,
.bcdDevice = 0x0100,
.iManufacturer = 0x01,
.iProduct = 0x02,
.iSerialNumber = 0x03,
.bNumConfigurations = 0x01
};
/* String descriptor table — index must match stridx fields above */
char const *string_desc_arr[] = {
(const char[]) { 0x09, 0x04 }, /* 0: Language = English (0x0409) */
"Wasil Zafar", /* 1: Manufacturer */
"CDC+HID Composite", /* 2: Product */
"CDC-HID-001", /* 3: Serial Number */
"TinyUSB CDC", /* 4: CDC Interface name */
"TinyUSB HID" /* 5: HID Interface name */
};
HID Callbacks for Composite
/* hid_callbacks.c — HID side of CDC+HID composite */
/* HID report descriptor callback — return our custom report descriptor */
uint8_t const * tud_hid_descriptor_report_cb(uint8_t itf) {
(void) itf;
return desc_hid_report;
}
/* HID GET_REPORT callback — host is requesting a report */
uint16_t tud_hid_get_report_cb(uint8_t itf, uint8_t report_id,
hid_report_type_t report_type,
uint8_t *buffer, uint16_t reqlen)
{
(void) itf; (void) report_id; (void) report_type;
/* Fill buffer with current sensor/status data */
buffer[0] = 0x01; /* report ID */
buffer[1] = get_sensor_value();
return 2;
}
/* HID SET_REPORT callback — host is sending a report (output) */
void tud_hid_set_report_cb(uint8_t itf, uint8_t report_id,
hid_report_type_t report_type,
uint8_t const *buffer, uint16_t bufsize)
{
(void) itf; (void) report_id; (void) report_type; (void) bufsize;
/* Process command from host */
process_host_command(buffer[0]);
}
/* Main loop — send HID report periodically via interrupt IN endpoint */
void app_task(void) {
static uint32_t last_hid_ms = 0;
uint32_t now = board_millis();
if ((now - last_hid_ms) >= 10) { /* every 10 ms */
last_hid_ms = now;
if (tud_hid_ready()) {
uint8_t report[2] = { 0x01, get_sensor_value() };
tud_hid_report(0, report, sizeof(report));
}
}
}
TinyUSB Composite — CDC + MSC
The CDC + MSC composite is the most practical configuration for data loggers and field instruments: a virtual serial port for debug and control commands, plus a USB mass storage drive for pulling log files. The OS sees both simultaneously — the terminal emulator and the file manager work at the same time.
Interface numbering: CDC Communications = 0, CDC Data = 1, MSC = 2. Only CDC needs an IAD; MSC is a single-interface class and does not require one.
tusb_config.h for CDC + MSC
/* tusb_config.h — CDC + MSC composite */
#define CFG_TUD_CDC 1
#define CFG_TUD_MSC 1
#define CFG_TUD_HID 0
#define CFG_TUD_CDC_RX_BUFSIZE 512
#define CFG_TUD_CDC_TX_BUFSIZE 512
#define CFG_TUD_MSC_EP_BUFSIZE 512
/* Endpoint assignments — no conflicts between CDC and MSC */
#define EPNUM_CDC_NOTIF 0x81 /* CDC: interrupt IN */
#define EPNUM_CDC_OUT 0x02 /* CDC: bulk OUT */
#define EPNUM_CDC_IN 0x82 /* CDC: bulk IN */
#define EPNUM_MSC_OUT 0x03 /* MSC: bulk OUT */
#define EPNUM_MSC_IN 0x83 /* MSC: bulk IN */
Configuration Descriptor for CDC + MSC
/* usb_descriptors.c — CDC + MSC composite */
#define CONFIG_TOTAL_LEN (TUD_CONFIG_DESC_LEN + TUD_CDC_DESC_LEN + \
TUD_MSC_DESC_LEN)
uint8_t const desc_configuration[] = {
/* Configuration Descriptor — 3 interfaces total */
TUD_CONFIG_DESCRIPTOR(1, 3, 0, CONFIG_TOTAL_LEN, 0x00, 500),
/* CDC function (IAD + 2 interfaces: comm=0, data=1) */
TUD_CDC_DESCRIPTOR(0, 4, EPNUM_CDC_NOTIF, 8,
EPNUM_CDC_OUT, EPNUM_CDC_IN, 64),
/* MSC function (1 interface: itf=2 — no IAD needed) */
TUD_MSC_DESCRIPTOR(2, 5, EPNUM_MSC_OUT, EPNUM_MSC_IN, 64)
};
tusb_desc_device_t const desc_device = {
.bLength = sizeof(tusb_desc_device_t),
.bDescriptorType = TUSB_DESC_DEVICE,
.bcdUSB = 0x0200,
.bDeviceClass = 0xEF, /* Miscellaneous — for CDC IAD */
.bDeviceSubClass = 0x02,
.bDeviceProtocol = 0x01,
.bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE,
.idVendor = 0xCafe,
.idProduct = 0x400A,
.bcdDevice = 0x0100,
.iManufacturer = 0x01,
.iProduct = 0x02,
.iSerialNumber = 0x03,
.bNumConfigurations = 0x01
};
char const *string_desc_arr[] = {
(const char[]) { 0x09, 0x04 },
"Wasil Zafar",
"USB CDC+MSC Device",
"CDC-MSC-001",
"Debug Serial Port", /* 4: CDC interface */
"Data Storage" /* 5: MSC interface */
};
Simultaneous Use — CDC and MSC Together
Unlike the FATFS exclusivity problem discussed in Part 8 (where FATFS and USB MSC cannot share the disk), the CDC and MSC interfaces within a composite device operate completely independently. You can send CDC data while the host is reading from the MSC volume simultaneously, because TinyUSB has separate endpoint queues for each class. The only shared resource is the USB bus bandwidth.
/* main.c — CDC+MSC composite main loop */
int main(void) {
board_init();
tusb_init();
while (1) {
tud_task(); /* single call services both CDC and MSC */
/* CDC side: echo received bytes back */
if (tud_cdc_available()) {
char buf[64];
uint32_t count = tud_cdc_read(buf, sizeof(buf));
tud_cdc_write(buf, count);
tud_cdc_write_flush();
}
/* MSC side: handled entirely in tud_msc_*_cb callbacks */
/* No polling needed here — TinyUSB drives it from tud_task() */
}
}
Triple Composite: CDC + HID + MSC
Combining three classes — CDC virtual serial port, custom HID for status/control, and MSC for file storage — is the most capable embedded USB configuration. The interface numbering follows the CDC-first convention: CDC Comm = 0, CDC Data = 1, HID = 2, MSC = 3. Total: 4 interfaces.
tusb_config.h for Triple Composite
/* tusb_config.h — CDC + HID + MSC triple composite */
#define CFG_TUD_CDC 1
#define CFG_TUD_HID 1
#define CFG_TUD_MSC 1
#define CFG_TUD_CDC_RX_BUFSIZE 512
#define CFG_TUD_CDC_TX_BUFSIZE 512
#define CFG_TUD_HID_EP_BUFSIZE 64
#define CFG_TUD_MSC_EP_BUFSIZE 512
/* Five distinct endpoints (plus EP0 for control) */
#define EPNUM_CDC_NOTIF 0x81
#define EPNUM_CDC_OUT 0x02
#define EPNUM_CDC_IN 0x82
#define EPNUM_HID 0x83 /* HID only needs IN for reports */
#define EPNUM_MSC_OUT 0x04
#define EPNUM_MSC_IN 0x84
Full Triple Composite Descriptor
/* Triple composite: CDC(itf 0+1) + HID(itf 2) + MSC(itf 3) */
#define CONFIG_TOTAL_LEN (TUD_CONFIG_DESC_LEN + TUD_CDC_DESC_LEN + \
TUD_HID_DESC_LEN + TUD_MSC_DESC_LEN)
/* Verify the total manually:
* TUD_CONFIG_DESC_LEN = 9
* TUD_CDC_DESC_LEN = 8(IAD)+9+5+5+5+7+9+7+7 = 62
* TUD_HID_DESC_LEN = 9+9+7 = 25
* TUD_MSC_DESC_LEN = 9+7+7 = 23
* TOTAL = 9 + 62 + 25 + 23 = 119 bytes */
uint8_t const desc_hid_report[] = {
TUD_HID_REPORT_DESC_GENERIC_INOUT(CFG_TUD_HID_EP_BUFSIZE)
};
uint8_t const desc_configuration[] = {
/* Configuration — 4 interfaces */
TUD_CONFIG_DESCRIPTOR(1, 4, 0, CONFIG_TOTAL_LEN, 0x00, 500),
/* CDC function: comm interface=0, data interface=1 */
TUD_CDC_DESCRIPTOR(0, 4, EPNUM_CDC_NOTIF, 8,
EPNUM_CDC_OUT, EPNUM_CDC_IN, 64),
/* HID function: interface=2 */
TUD_HID_DESCRIPTOR(2, 5, HID_ITF_PROTOCOL_NONE,
sizeof(desc_hid_report),
EPNUM_HID, CFG_TUD_HID_EP_BUFSIZE, 5),
/* MSC function: interface=3 */
TUD_MSC_DESCRIPTOR(3, 6, EPNUM_MSC_OUT, EPNUM_MSC_IN, 64)
};
tusb_desc_device_t const desc_device = {
.bLength = sizeof(tusb_desc_device_t),
.bDescriptorType = TUSB_DESC_DEVICE,
.bcdUSB = 0x0200,
.bDeviceClass = 0xEF,
.bDeviceSubClass = 0x02,
.bDeviceProtocol = 0x01,
.bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE,
.idVendor = 0xCafe,
.idProduct = 0x400B,
.bcdDevice = 0x0100,
.iManufacturer = 0x01,
.iProduct = 0x02,
.iSerialNumber = 0x03,
.bNumConfigurations = 0x01
};
/* String descriptor index table — all stridx references map here */
char const *string_desc_arr[] = {
(const char[]) { 0x09, 0x04 }, /* 0: Language */
"Wasil Zafar", /* 1: Manufacturer */
"Triple USB Device", /* 2: Product */
"TRIPLE-001", /* 3: Serial */
"Debug UART", /* 4: CDC interface */
"HID Control", /* 5: HID interface */
"Data Storage" /* 6: MSC interface */
};
wTotalLength Calculation: The CONFIG_TOTAL_LEN macro must equal the exact byte count of the entire descriptor array. TinyUSB provides TUD_*_DESC_LEN constants for each class. If your calculation is wrong, the host reads garbage after the first well-formed descriptor and enumeration fails with no error message. Always verify by counting bytes manually and comparing to sizeof(desc_configuration) at compile time with a static_assert.
Compile-Time Descriptor Length Verification
/* Add this immediately after the descriptor array definition */
_Static_assert(sizeof(desc_configuration) == CONFIG_TOTAL_LEN,
"CONFIG_TOTAL_LEN is wrong — check TUD_*_DESC_LEN constants");
/* Alternative using C11 static assert */
#include <assert.h>
static_assert(sizeof(desc_configuration) == CONFIG_TOTAL_LEN,
"Descriptor length mismatch — update CONFIG_TOTAL_LEN");
Endpoint Number Assignment
Endpoint addressing in composite devices is one of the most common sources of bugs. Getting it wrong causes one or more classes to fail silently during enumeration.
Endpoint Address Byte Format
Each USB endpoint is identified by a 1-byte address:
- Bits 3:0 — Endpoint number (0–15 for USB 2.0 Full Speed; 0–15 for High Speed)
- Bits 6:4 — Reserved, must be zero
- Bit 7 — Direction: 0 = OUT (host to device), 1 = IN (device to host)
For example: 0x82 = endpoint number 2, direction IN. 0x02 = endpoint number 2, direction OUT.
STM32 Hardware Constraint: STM32F4 OTG_FS has 4 IN endpoints and 4 OUT endpoints (hardware-limited). STM32F4 OTG_HS has 6 IN and 6 OUT. The endpoint number must match a hardware endpoint in the USB peripheral — you cannot use endpoint number 5 on a controller that only has endpoints 0–3. Check your MCU's USB peripheral datasheet for the actual endpoint count before assigning numbers.
Endpoint Allocation Table for Triple Composite
| Endpoint Address | Number | Direction | Type | Class | Purpose |
0x00 / 0x80 | 0 | Both | Control | USB Core | Enumeration — reserved, always present |
0x81 | 1 | IN | Interrupt | CDC | Notification endpoint (line state changes) |
0x02 | 2 | OUT | Bulk | CDC | Data from host to device |
0x82 | 2 | IN | Bulk | CDC | Data from device to host |
0x83 | 3 | IN | Interrupt | HID | HID reports from device to host |
0x04 | 4 | OUT | Bulk | MSC | SCSI commands and write data from host |
0x84 | 4 | IN | Bulk | MSC | Read data and CSW from device |
Note that endpoints 2 IN and 2 OUT share the same endpoint number (2) but are different physical endpoints because their directions differ. This is standard USB — endpoint number 2 OUT and endpoint number 2 IN are entirely separate FIFO queues in the USB hardware.
Avoiding Endpoint Number Conflicts
For a composite device, each class must use unique endpoint numbers for same-direction endpoints. Two classes cannot share the same endpoint number in the same direction — the USB controller has one FIFO per endpoint number per direction. The rule is simple: assign endpoint numbers sequentially and never reuse a number for the same direction across classes:
/* Good: no conflicts */
#define EPNUM_CDC_NOTIF 0x81 /* EP1 IN — interrupt */
#define EPNUM_CDC_OUT 0x02 /* EP2 OUT — bulk */
#define EPNUM_CDC_IN 0x82 /* EP2 IN — bulk */
#define EPNUM_HID 0x83 /* EP3 IN — interrupt */
#define EPNUM_MSC_OUT 0x04 /* EP4 OUT — bulk */
#define EPNUM_MSC_IN 0x84 /* EP4 IN — bulk */
/* Bad: MSC conflicts with CDC on endpoint 2 */
#define EPNUM_MSC_OUT 0x02 /* CONFLICT with EPNUM_CDC_OUT above! */
Windows Composite Device Recognition
Understanding how Windows enumerates composite devices prevents hours of debugging. Windows uses a layered driver model for composite USB devices, and each step can fail in subtle ways.
Windows Enumeration Sequence for a CDC+HID+MSC Device
Step 1: USB Device Detected
└─ Windows reads Device Descriptor
└─ bDeviceClass = 0xEF → "This is a composite device with IADs"
Step 2: USB Bus Driver (usbhub.sys) Takes Over
└─ Reads Configuration Descriptor (full blob, including IADs)
└─ Identifies 3 functions:
Function 1 (IAD): bFirstInterface=0, bInterfaceCount=2
bFunctionClass=0x02 (CDC) → load usbser.sys
Function 2 (no IAD): Interface 2, bInterfaceClass=0x03 (HID) → load hidusb.sys
Function 3 (no IAD): Interface 3, bInterfaceClass=0x08 (MSC) → load usbstor.sys
Step 3: USB Composite Device Driver (usbccgp.sys) Arbitrates
└─ Creates 3 child device nodes, one per function
└─ Each child has its own driver stack
Step 4: Class Drivers Load
└─ COM port: usbser.sys → appears in Device Manager → Ports (COM & LPT)
└─ HID device: hidusb.sys + hid.dll → appears as HID device
└─ MSC drive: usbstor.sys + disk.sys → appears as Disk Drive + drive letter
Step 5: Device Ready
└─ All three functions operational simultaneously
Driver Assignments by Class
| USB Class | bInterfaceClass | Windows Driver | Device Manager Location | Needs IAD? |
| CDC-ACM | 0x02 / 0x0A | usbser.sys | Ports (COM & LPT) | Yes — two interfaces |
| HID | 0x03 | hidusb.sys | Human Interface Devices | No — single interface |
| MSC | 0x08 | usbstor.sys | Disk Drives | No — single interface |
| DFU | 0xFE | WinUSB.sys | Universal Serial Bus devices | No — single interface |
| Vendor class | 0xFF | WinUSB.sys (with WCID) | Universal Serial Bus devices | No |
When CDC Fails on Windows
If the CDC serial port does not appear in Device Manager after connecting your composite device, check these common causes in order:
- Missing
bDeviceClass = 0xEF: If you have bDeviceClass = 0x00 (class at interface level) and CDC without an IAD, Windows cannot correctly associate the CDC Communication and Data interfaces. Result: "Unknown device" or incorrect COM port assignment.
- Missing or malformed IAD: The IAD must appear in the descriptor before the CDC interfaces.
bFunctionClass must be 0x02 (CDC), bFunctionSubClass must be 0x02 (ACM), bFirstInterface and bInterfaceCount must be correct.
- Wrong
bNumInterfaces: The Configuration Descriptor's bNumInterfaces must equal the total number of interfaces — including both CDC interfaces. For CDC+HID+MSC the value must be 4.
- Wrong
wTotalLength: If the Configuration Descriptor's total length is wrong, Windows stops parsing partway through and misses interfaces.
Shared State Between Classes
In a composite device, the application logic often needs the classes to communicate. A classic example: the host sends a command over the CDC virtual serial port, and the firmware responds by sending an HID report. Or: a file write over MSC triggers an HID event indicating the device received new data.
Inter-Class Communication with Event Flags
/* cross_class_comm.c — CDC receives command, HID sends response */
#include "tusb.h"
#include <string.h>
#include <stdatomic.h>
/* Shared state protected by an atomic flag */
typedef struct {
atomic_bool new_command_ready;
uint8_t command_code;
uint8_t hid_response[8];
uint8_t hid_response_len;
} shared_state_t;
static shared_state_t g_state = {0};
/* Called from CDC receive path — runs in TinyUSB context */
void tud_cdc_rx_cb(uint8_t itf) {
(void) itf;
uint8_t buf[16];
uint32_t count = tud_cdc_read(buf, sizeof(buf));
if (count > 0 && buf[0] == 0xA5) {
/* Command recognised: 0xA5 cmd_code */
g_state.command_code = (count > 1) ? buf[1] : 0;
atomic_store(&g_state.new_command_ready, true);
}
}
/* Application task — processes commands and enqueues HID reports */
void app_process_commands(void) {
if (!atomic_load(&g_state.new_command_ready)) return;
atomic_store(&g_state.new_command_ready, false);
uint8_t cmd = g_state.command_code;
switch (cmd) {
case 0x01: /* Query status */
g_state.hid_response[0] = 0x01; /* report ID */
g_state.hid_response[1] = 0x55; /* status OK */
g_state.hid_response[2] = get_sensor_reading() & 0xFF;
g_state.hid_response[3] = get_sensor_reading() >> 8;
g_state.hid_response_len = 4;
break;
case 0x02: /* Toggle LED */
toggle_led();
g_state.hid_response[0] = 0x02; /* report ID */
g_state.hid_response[1] = get_led_state();
g_state.hid_response_len = 2;
break;
default:
/* Echo the command back over CDC as an error */
tud_cdc_write_str("ERR: unknown command\r\n");
tud_cdc_write_flush();
return;
}
/* Send HID report in response to CDC command */
if (tud_hid_ready()) {
tud_hid_report(0, g_state.hid_response, g_state.hid_response_len);
}
}
/* main loop */
int main(void) {
board_init();
tusb_init();
while (1) {
tud_task();
app_process_commands();
}
}
RTOS Mutex for Shared Resources
In an RTOS environment, TinyUSB may run in a dedicated USB task, while the application logic runs in a separate application task. Any shared state accessed from both tasks requires mutual exclusion:
/* rtos_composite.c — Thread-safe cross-class communication with FreeRTOS */
#include "FreeRTOS.h"
#include "semphr.h"
#include "tusb.h"
static SemaphoreHandle_t g_shared_mutex;
static uint8_t g_latest_command = 0;
static bool g_command_pending = false;
void usb_device_task(void *param) {
(void) param;
tusb_init();
while (1) {
tud_task();
vTaskDelay(1); /* yield 1 ms between USB polls */
}
}
/* Called from USB task context (TinyUSB CDC receive callback) */
void tud_cdc_rx_cb(uint8_t itf) {
(void) itf;
uint8_t buf[4];
uint32_t count = tud_cdc_read(buf, sizeof(buf));
if (count > 0) {
if (xSemaphoreTake(g_shared_mutex, portMAX_DELAY)) {
g_latest_command = buf[0];
g_command_pending = true;
xSemaphoreGive(g_shared_mutex);
}
}
}
/* Application task — reads command and generates HID response */
void app_task(void *param) {
(void) param;
g_shared_mutex = xSemaphoreCreateMutex();
while (1) {
bool pending = false;
uint8_t cmd = 0;
if (xSemaphoreTake(g_shared_mutex, portMAX_DELAY)) {
pending = g_command_pending;
cmd = g_latest_command;
g_command_pending = false;
xSemaphoreGive(g_shared_mutex);
}
if (pending && tud_hid_ready()) {
uint8_t report[2] = { 0x01, cmd };
tud_hid_report(0, report, sizeof(report));
}
vTaskDelay(5);
}
}
Descriptor Validation
Composite device descriptors are the most likely source of enumeration failures. A single wrong byte can prevent one or all classes from loading. Systematic validation before hardware testing catches most issues.
USB Device Tree Viewer (Windows)
USB Device Tree Viewer (UsbTreeView.exe by Uwe Sieber) is the best free tool for reading all USB descriptors from a connected device on Windows. After connecting your composite device:
- Open USB Device Tree Viewer — your device appears in the tree
- Click the device → right panel shows all descriptors parsed
- Verify:
bDeviceClass = 0xEF, bNumInterfaces matches your design
- Expand the Configuration Descriptor → verify IAD fields:
bFirstInterface, bInterfaceCount
- Check each endpoint descriptor: address, direction, type, max packet size, interval
- Verify
wTotalLength in the Configuration Descriptor matches the sum of all included descriptors
lsusb -v on Linux
# List all USB devices with verbose descriptor output
lsusb -v
# Filter for your specific device (replace 0xCafe:0x400B with your IDs)
lsusb -v -d cafe:400b
# Example output for CDC+HID+MSC composite:
# Bus 001 Device 003: ID cafe:400b Triple USB Device
# Device Descriptor:
# bLength 18
# bDescriptorType 1
# bcdUSB 2.00
# bDeviceClass 239 Miscellaneous Device
# bDeviceSubClass 2 ?
# bDeviceProtocol 1 Interface Association
# ...
# Configuration Descriptor:
# bNumInterfaces 4
# Interface Association:
# bFirstInterface 0
# bInterfaceCount 2
# bFunctionClass 2 Communications
# Interface Descriptor: (CDC Comm, itf 0)
# Interface Descriptor: (CDC Data, itf 1)
# Interface Descriptor: (HID, itf 2)
# Interface Descriptor: (Mass Storage, itf 3)
Common Mistakes in Composite Descriptors
| Mistake | Symptom | Fix |
Wrong wTotalLength |
Host stops parsing early; some interfaces missing; "Unknown Device" |
Use static_assert(sizeof(desc_config) == CONFIG_TOTAL_LEN) |
bDeviceClass = 0x00 instead of 0xEF |
CDC serial port missing on Windows; HID/MSC may still work |
Set bDeviceClass = 0xEF, bDeviceSubClass = 0x02, bDeviceProtocol = 0x01 |
| Missing IAD before CDC interfaces |
CDC interfaces not grouped; COM port absent or unstable |
Use TUD_CDC_DESCRIPTOR macro which embeds the IAD |
Wrong bNumInterfaces |
One or more classes invisible to OS |
Count all interfaces (CDC=2, HID=1, MSC=1 → total=4) |
| Duplicate endpoint numbers (same direction) |
One class fails; mysterious USB resets during data transfer |
Assign unique numbers per direction across all classes |
| HID report descriptor length mismatch |
HID device loads but reports have wrong size or format |
Verify sizeof(desc_hid_report) matches TUD_HID_DESCRIPTOR parameter |
Practical Exercises
Exercise 1
Beginner
CDC + HID Composite — Verify Both Classes on All OSes
Implement the CDC + HID composite from Section 3. Build and flash to your development board. Verify on Windows: (a) Device Manager shows both a COM port under "Ports (COM & LPT)" and an HID device under "Human Interface Devices", (b) Open a terminal (PuTTY or Tera Term) on the COM port and verify you can send/receive text, (c) Use a HID tool (HidTest, or Python with the hid library) to read HID reports from the device. Repeat the test on Linux and macOS. Document any differences in how each OS handles the composite device appearance.
CDC+HID
Composite Descriptor
Cross-Platform
Exercise 2
Intermediate
CDC + MSC — Debug Port with File Storage
Implement the CDC + MSC composite from Section 4, using a RAM disk for the MSC storage. Add functionality that lets the CDC serial port control the content of a file in the RAM disk: (a) when the host sends "WRITE:filename:content\r\n" over the CDC serial port, the firmware creates or updates that file in the RAM disk FAT filesystem, (b) when the host opens the MSC drive in a file manager, the file the CDC port just wrote is visible and contains the correct content. This requires your firmware to write to the in-memory FAT12 filesystem correctly. Use USB Device Tree Viewer or lsusb -v to verify the descriptor is correct before testing.
CDC+MSC
Cross-Class Communication
FAT Filesystem
Exercise 3
Advanced
Triple Composite Stress Test with Protocol Analyser
Implement the full CDC + HID + MSC triple composite from Section 5. Connect a USB protocol analyser (or use Wireshark with USBPcap on Windows). Run all three classes simultaneously: (a) terminal emulator sending 100 KB/s of data over CDC, (b) Python script polling the HID endpoint every 10 ms, (c) large file copy (10 MB) to/from the MSC drive. Capture the bus traffic during simultaneous use. Analyse: (a) how USB bandwidth is shared between the three classes, (b) whether any class shows NAK responses or timeouts when others are busy, (c) the effect of endpoint polling intervals on HID latency during MSC transfers. Calculate the aggregate USB bus utilisation and compare to the 12 Mbit/s Full Speed theoretical maximum.
Triple Composite
Protocol Analysis
Bandwidth
Stress Test
USB Composite Device Design Generator
Use this tool to document your USB composite device design — class selection, interface count, endpoint allocation, and design notes. Download as Word, Excel, PDF, or PPTX for project documentation or design review.
Conclusion & Next Steps
USB composite devices represent the full maturity of embedded USB development — a single cable, a single USB address, and the host OS seamlessly loading separate drivers for each function. The key concepts to retain:
- bDeviceClass = 0xEF: Any composite device containing CDC must use the Miscellaneous Device Class in the device descriptor, signalling to Windows that IADs are present and should be used for function grouping.
- Interface Association Descriptor: CDC always needs an IAD (two interfaces for one function). MSC and HID do not need IADs — they each use a single interface. Place IADs immediately before the interfaces they group.
- TinyUSB macros:
TUD_CDC_DESCRIPTOR, TUD_HID_DESCRIPTOR, TUD_MSC_DESCRIPTOR — use these, never hand-code composite descriptors. They include the IAD for CDC automatically.
- Endpoint number allocation: Each class needs unique endpoint numbers for each direction. A Full Speed STM32 has 4 IN + 4 OUT hardware endpoints. Plan the assignment before writing descriptors and check for conflicts.
- wTotalLength validation: Use a
static_assert to catch length mismatches at compile time. Wrong wTotalLength is the most common cause of composite enumeration failures.
- Inter-class communication: Classes can share state through atomic flags or RTOS mutexes. CDC receiving a command and HID sending a report in response is a clean, practical pattern.
Next in the Series
In Part 10: USB Debugging, we go deep into the practical art of debugging USB devices. Wireshark with USBPcap for software-level packet capture, hardware protocol analysers for signal-level debugging, reading enumeration failures from USB event logs, interpreting SETUP packet failures, and the systematic approach to diagnosing every major category of USB bug — from enumeration failures to data corruption to intermittent disconnects.
Related Articles in This Series
Part 8: USB Mass Storage Class
BBB protocol, SCSI command set, TinyUSB MSC callbacks, SD card integration, FATFS exclusive access, and performance optimization.
Read Article
Part 10: USB Debugging
Wireshark USB capture, hardware protocol analysers, reading enumeration failures, and systematic debugging methodology for every category of USB bug.
Read Article
Part 5: TinyUSB Deep Dive
TinyUSB architecture, execution model, STM32 integration steps, descriptor callback implementation, and your first working USB device.
Read Article