What Is a USB Descriptor?

The concept and structure of USB descriptors, explained with real examples. Includes a look at how Boot Protocol fits into the Configuration Descriptor.

22 min read
Đọc bằng English Tiếng Việt
STM32 / Firmware cover

Post 3/8 in the series USB Device on STM32.

Before you read this

If you haven’t read post 2 on Transfer Types yet, go read that first. This post relies on Control Transfer, Interrupt Transfer, and the Setup Packet, all covered there.

This post focuses on the theoretical side: descriptor structure, how the host processes it, and the fields that trip people up most often. The real descriptor from this series’ project (VID/PID, the composite HID + CDC + Vendor configuration) is used as a running example throughout, but I won’t go deep into debugging or specific bugs here. That’s covered later in the series and on the companion project page.

Right at the start I mention HID. If you’re new to it, take a quick look at this short note first to get the basics: What Is a USB Device Class and a Composite Device?

Key points

  • The host never reads firmware source code. It only reads the descriptor during enumeration to decide what kind of device this is, which driver to load, and what interfaces and endpoints to expect.
  • Descriptors follow a fixed hierarchy: Device contains Configuration, Configuration contains Interface, Endpoint, and class-specific descriptors.
  • HID adds two more layers on top: the HID Descriptor (describes the device) and the HID Report Descriptor (describes the data).
  • Composite devices need special attention on bNumInterfaces, wTotalLength, IAD, and the bDeviceClass/SubClass/Protocol triplet.
  • Build the descriptor one step at a time. Don’t write the whole thing and only test at the end.

What’s a descriptor?

A descriptor is a byte array a USB device sends to the host during enumeration, following a fixed, hierarchical structure.

Here’s the overall shape of a device’s descriptor tree:

Device Descriptor (1)
└─ Configuration Descriptor (1..n)
   ├─ Interface Descriptor (1..n)
   │  ├─ Class-specific Descriptor  (e.g. HID Descriptor)
   │  └─ Endpoint Descriptor (0..n, not counting EP0)
   │
   └─ Next Interface Descriptor (if composite)

String Descriptor, stands alone, read on demand (iManufacturer, iProduct...)

HID Report Descriptor, stands alone, only present on HID interfaces,
                         read via a separate GET_DESCRIPTOR(Report)

When you plug in a USB device, the host and device walk through enumeration in this sequence:

Host → Device: GET_DESCRIPTOR (Device Descriptor)
Device → Host: 18-byte Device Descriptor

Host → Device: GET_DESCRIPTOR (Configuration Descriptor)
Device → Host: Configuration + Interface + Endpoint + class-specific

The host parses this to decide: what kind of device is this? which driver?
how many endpoints? how do we talk to it?

In CubeMX, ST’s middleware pre-generates a descriptor in usbd_desc.c. But the moment you want to customize it, say adding an interface to build a composite device, changing the HID Report Descriptor to reshape your data, or swapping the VID/PID so the host loads the right driver, you need to understand every field first. Get a single byte wrong and enumeration can fail outright, or the host can end up misreading your data.

Here’s a real enumeration capture from an HID device right after being plugged in:

Real USB enumeration process captured from Wireshark/USBPcap
A real enumeration capture for an HID device.

Device Descriptor: the big picture

The Device Descriptor is the first thing the host reads, and it’s always a fixed 18 bytes. Here’s the real array from this project’s usbd_desc.c, with USBD_VID = 1155 (0x0483, ST Microelectronics) and USBD_PID = 22315 (0x572B):

uint8_t USBD_HID_DeviceDesc[USB_LEN_DEV_DESC] = {  0x12,                  /* bLength = 18 */  USB_DESC_TYPE_DEVICE,  /* bDescriptorType = 0x01 */  0x00, 0x02,            /* bcdUSB = 0x0200 (USB 2.0) */  0xEF,                  /* bDeviceClass: Miscellaneous, required when using IAD */  0x02,                  /* bDeviceSubClass: Common Class */  0x01,                  /* bDeviceProtocol: IAD */  USB_MAX_EP0_SIZE,      /* bMaxPacketSize0 = 64 bytes, valid values: 8/16/32/64 */  LOBYTE(USBD_VID), HIBYTE(USBD_VID),   /* idVendor = 0x0483 */  LOBYTE(USBD_PID), HIBYTE(USBD_PID),   /* idProduct = 0x572B */  0x00, 0x02,            /* bcdDevice = 0x0200 (firmware version 2.00) */  USBD_IDX_MFC_STR,      /* iManufacturer, index into the String Descriptor */  USBD_IDX_PRODUCT_STR,  /* iProduct */  USBD_IDX_SERIAL_STR,   /* iSerialNumber */  USBD_MAX_NUM_CONFIGURATION  /* bNumConfigurations = 1 */};

Fields you’ll usually need to change:

  • bDeviceClass/SubClass/Protocol = 0xEF/0x02/0x01: this triplet is mandatory any time your device uses IAD (a composite with CDC, for example). Get it wrong and USBView will tell you exactly what’s missing:

      *!*ERROR: device class should be Multi-interface Function 0xEF
  • idVendor/idProduct: the most important identifier pair here. The host uses VID/PID to pick a driver. 0x0483 is ST’s own VID, fine for development and internal use. For a commercial product, you’ll need to buy your own VID from USB-IF.

  • bcdDevice: the firmware version, in BCD. 0x0200 means version 2.00. This is genuinely useful for debugging, since a glance at Device Manager tells you exactly which firmware a board is running.

  • iManufacturer/iProduct/iSerialNumber: indices into the String Descriptor. In this project, iProduct = "STM32 USB HID 4x4 Macro Keypad". That string shows up in Device Manager, and it’s also the string vendor_test.py prints when it finds the device (post 7).

  • bMaxPacketSize0: not a cosmetic field. The host always reads the first 8 bytes of the Device Descriptor before anything else, purely to get this value, and only then requests the full 18 bytes. It needs to know EP0’s max packet size to correctly split up the control transfers that follow. Get this field wrong in those first 8 bytes and enumeration fails right away, with no useful error at all.

Configuration Descriptor: power and interface count

The Configuration Descriptor never stands alone. It’s the header for a much larger block that contains every Interface and Endpoint Descriptor underneath it. When the host sends GET_DESCRIPTOR(Configuration), the device has to return that entire block in one shot.

Here’s the header of the configuration descriptor from this project:

/* Configuration Descriptor header, 9 bytes */uint8_t USBD_CfgDesc[] = {    0x09,        /* bLength = 9 */    0x02,        /* bDescriptorType = 0x02 (Configuration) */    LOBYTE(wTotalLength), HIBYTE(wTotalLength),                 /* wTotalLength = total size of the whole block                  * (Config + every Interface and Endpoint underneath) */    0x03,        /* bNumInterfaces = 3 (composite: HID + CDC Comm + CDC Data) */    0x01,        /* bConfigurationValue = 1, the ID of this config */    0x00,        /* iConfiguration, string index, 0 = none */    0x80,        /* bmAttributes: see below */    0x32,        /* bMaxPower = 0x32 = 50 x 2mA = 100mA */    /* --- Interface Descriptors and Endpoint Descriptors follow --- */};

wTotalLength has to exactly match the total size of the entire descriptor chain. In practice, the host reads a Configuration Descriptor in two steps: it reads the first 9 bytes to get wTotalLength, then uses that value to request the full block: Config + Interface + Endpoint + class-specific descriptors. Get this value wrong and the host either truncates the data, reads past the end into memory that isn’t a descriptor at all, or misparses the whole structure.

bNumInterfaces has to match the actual number of interfaces in the descriptor. If the declared count doesn’t match reality, the host can end up ignoring the last interface or misreading the whole composite device’s structure.

Why does one wrong byte break the entire chain?

The host has no prior knowledge of the descriptor’s structure. It reads sequentially:

  1. Read the current descriptor’s bLength.
  2. Jump forward exactly that many bytes to reach the next descriptor.
  3. Read bDescriptorType to identify what kind of descriptor it just landed on (Interface / Endpoint).
  4. Repeat from step 1 until it’s done.

If any descriptor in the middle has a wrong bLength, the host’s offset drifts, and everything after that point gets misread, not just truncated at the end.

bmAttributes

This is a field that’s easy to get wrong. It’s usually one of three values:

  • 0x80 = Bus-powered, no remote wakeup support (drawing power from the USB VCC line)
  • 0xC0 = Self-powered (the device has its own power source outside USB)
  • 0xA0 = Bus-powered + Remote Wakeup

This series’ device declares bus-powered (0x80). If you declare Self-powered 0xC0 but still actually draw power from USB, nothing breaks functionally. The device still runs, but the host may miscalculate the power budget it allocates to other devices on the same bus.

bMaxPower

This one really matters for a bus-powered device.

It’s the current the device needs from the host, in 2mA units. This project declares bMaxPower = 0x32, meaning the device needs 100mA. The math:

0x32 = 50 x 2mA = 100mA

If your board only draws power from USB, this needs to be accurate.

  • Declare too little relative to actual draw and the board can run short on power.
  • Declare too much and some hosts may refuse to enumerate the device if the port doesn’t have that much budget to spare.

Interface Descriptor: describing a function

Every Interface Descriptor describes one function of the device. The bInterfaceClass/SubClass/Protocol triplet matters most here, since the host uses these three values to pick the correct class driver:

Value Class Driver Notes
0x02 / 0x02 / 0x01 CDC ACM (Virtual COM Port) Abstract Control Model + AT Commands. Windows/Linux bind the cdc_acm driver. Used in this project.
0x03 / 0x00 / 0x00 HID, no subclass, no protocol Custom HID, not a standard keyboard or mouse.
0x03 / 0x01 / 0x01 HID Boot Keyboard A standard keyboard, usable by BIOS/UEFI before the OS loads a driver. Interface 0 in this project.
0x03 / 0x01 / 0x02 HID Boot Mouse A standard mouse, and also the default descriptor CubeMX generates even when you select HID Keyboard.
0x08 / 0x06 / 0x50 Mass Storage (MSC) SCSI + Bulk Only Transport, a USB drive. Not used in this project.
0xFF / 0x00 / 0x00 Vendor Specific No class driver, needs WinUSB/libusbK via Zadig (post 7). Used in this project.

This project uses three of these: HID Boot Keyboard, CDC ACM, and Vendor Specific. Mass Storage and HID Mouse aren’t part of the final composite descriptor. They’re listed above purely for a complete reference against the spec.

Endpoint Descriptor: the data channel

Every endpoint, other than EP0, needs its own Endpoint Descriptor. It describes where and how data moves between host and device for a given function.

Here’s the endpoint descriptor for the HID Boot Keyboard device:

/* Endpoint Descriptor, 7 bytes */0x07,        /* bLength = 7 */0x05,        /* bDescriptorType = 0x05 (Endpoint) */0x81,        /* bEndpointAddress, see below */0x03,        /* bmAttributes: Transfer Type, covered in post 2, 0x03 = Interrupt */0x40, 0x00,  /* wMaxPacketSize = 0x0040 = 64 bytes */0x0A,        /* bInterval = 10ms polling, reading this field was covered in post 2 */

The field people misread most often is bEndpointAddress. Here’s its bit layout:

bEndpointAddress (8-bit) : Address and direction of endpoint
Direction [7] Data transfer direction
0
OUT - Host → Device
1
IN - Device → Host
Reserved [6:4] Always 0, unused
Endpoint Number [3:0] Endpoint number, 0-15

Bit 7 sets the transfer direction, from the device’s point of view. For example:

  • 0x81 = EP1 IN (device sends up to the host).
  • 0x01 = EP1 OUT (host sends down to the device).

Two endpoints with the same number but opposite direction (EP1 IN and EP1 OUT) are two completely separate endpoints.

Endpoint addresses can’t repeat within the same configuration. STM32 Full Speed supports up to 8 endpoints (EP0 plus EP1-EP7), and each one can be IN, OUT, or both, depending on the hardware.

HID Descriptor: what makes HID different

An HID interface needs one more special descriptor, sitting between the Interface Descriptor and the Endpoint Descriptor:

/* HID Descriptor, 9 bytes (minimum) */0x09,        /* bLength = 9 */0x21,        /* bDescriptorType = 0x21 (HID) */0x11, 0x01,  /* bcdHID = 0x0111, HID spec version 1.11 */0x00,        /* bCountryCode = 0 (not localized) */0x01,        /* bNumDescriptors = 1, number of Report Descriptors */0x22,        /* bDescriptorType = 0x22 (Report Descriptor) */LOBYTE(HID_REPORT_DESC_SIZE), HIBYTE(HID_REPORT_DESC_SIZE),  /* wDescriptorLength */

The host reads this to find out how long the Report Descriptor is, then sends GET_DESCRIPTOR(Report) separately to fetch the actual Report Descriptor.

Order matters inside the Configuration Descriptor:

Interface Descriptor (HID)
HID Descriptor          ← right after Interface, before Endpoint
Endpoint Descriptor (Interrupt IN)

Get this order wrong and the host either misparses it or never gets the HID Report Descriptor at all.

wDescriptorLength

wDescriptorLength has to exactly match the real report descriptor array’s size. Get it wrong and the host reads too many or too few bytes, misparses the report structure, or rejects the device outright during enumeration.

bCountryCode

bCountryCode tells the host whether this HID device was designed for a specific local keyboard layout. A value of 0x00 means not localized, meaning the device isn’t declaring any particular country or layout.

With an HID keyboard, firmware never sends characters like A, @, #, or ~ directly. It sends a Usage ID from Usage Page 0x07 (Keyboard/Keypad). The OS then maps that Usage ID to an on-screen character based on whatever keyboard layout the user currently has selected (US, Japanese, Vietnamese, German, and so on).

So bCountryCode shouldn’t be read as “change this field and the host switches keyboard layout.” On most modern operating systems, the actual layout is still decided by the OS or the input method. This field is mainly there to declare that the keyboard hardware itself was localized for a specific country or layout. For a simple macro keypad or HID keyboard like this project’s, leaving bCountryCode = 0x00 is a safe and common choice.

IAD (Interface Association Descriptor)

IAD is required any time a composite device includes CDC on Windows. It groups the two CDC interfaces (Comm and Data) into a single function, so Windows loads the right driver and creates a COM port.

Here’s the IAD descriptor describing the CDC class, inside the Configuration Descriptor:

0x08,  /* bLength */0x0B,  /* bDescriptorType = IAD */0x01,  /* bFirstInterface = 1 (IF1 = CDC Comm) */0x02,  /* bInterfaceCount = 2 (IF1 + IF2) */0x02,  /* bFunctionClass = CDC */0x02,  /* bFunctionSubClass = ACM */0x01,  /* bFunctionProtocol = AT Commands */0x00,

As mentioned earlier, the Device Descriptor also needs the correct bDeviceClass/SubClass/Protocol triplet. Without IAD, or if bDeviceClass isn’t 0xEF, Windows won’t recognize the two CDC interfaces as belonging to the same function. It won’t assign the right driver, and it won’t create a COM port for the device.

This section only covers IAD’s role in the descriptor. How IAD actually gets built into the real composite descriptor, how wTotalLength/bNumInterfaces get updated, and the real bugs hit while writing the composite class all live on the Composite Device project page.

HID Report Descriptor: the language for describing data

This is the most complex and, honestly, the most interesting part of HID. The Report Descriptor doesn’t describe hardware. It describes the structure of the data packets a device sends and receives. The host reads it once during enumeration and from then on knows how to interpret every byte in every report that follows, without needing a dedicated driver per device.

Syntax: item format

Every item in a Report Descriptor is 1 to 5 bytes long:

Byte 0: Tag (bits 7..4) | Type (bits 3..2) | Size (bits 1..0)
  Size: 00=0 bytes, 01=1 byte, 10=2 bytes, 11=4 bytes
Byte 1..n: Data (if Size > 0)

For example, 0x05, 0x01:

  • 0x05 = tag=0000 (Usage Page), type=01 (Global), size=01 (1 byte of data)
  • 0x01 = value = 0x01 (Generic Desktop Controls)

The item types that matter

Global items, apply to every field that follows until they change:

0x05, xx  → Usage Page        : the namespace for Usage values
0x15, xx  → Logical Minimum   : the smallest value a data field can hold
0x25, xx  → Logical Maximum   : the largest value
0x75, xx  → Report Size       : bits per field
0x95, xx  → Report Count      : number of fields
0x85, xx  → Report ID         : an ID placed at the start of the report (if used)

Local items, apply only to the next item:

0x09, xx  → Usage             : what this specific field means
0x19, xx  → Usage Minimum     : used when defining a range
0x29, xx  → Usage Maximum

Main items, these actually create a field:

0x81, xx  → Input      : a field the device sends up to the host (IN)
0x91, xx  → Output     : a field the host sends down to the device (OUT)
0xA1, xx  → Collection : groups fields together
0xC0      → End Collection

The data byte on Input/Output:

0x02 = Data, Variable, Absolute
  Data     : can change (as opposed to Constant)
  Variable : each field is its own independent value (as opposed to Array)
  Absolute : an absolute value (as opposed to Relative)
0x03 = Constant (padding, unused, just there for byte alignment)
0x06 = Data, Variable, Relative (used for delta movement, like a mouse)

Usage Page: a namespace for Usage values

A Usage Page works like a namespace in programming. The same Usage value (0x30, say) means something different depending on which Usage Page it’s under:

Usage Page 0x01 (Generic Desktop): 0x30 = X axis
Usage Page 0x09 (Button):          0x30 = Button 48

Usage Pages you’ll run into often:

0x01 = Generic Desktop Controls (X/Y/Z, mouse, keyboard, joystick)
0x07 = Keyboard/Keypad
0x08 = LEDs
0x09 = Buttons
0x0D = Digitizer (touchscreen, pen)
0xFF = Vendor Defined (custom, no standard meaning)

The full keycode table for Usage Page 0x07 (a-z, digits, function keys, and more) is far bigger than this post has room for. See What Are HID Usage Tables? for the full reference.

0xA1, 0x01  → Collection (Application) : the top-level group, every HID device needs at least one
0xA1, 0x02  → Collection (Logical)     : a logical grouping inside an Application
0xA1, 0x00  → Collection (Physical)    : groups data coming from the same physical location
0xC0        → End Collection

Reading an HID Mouse Report Descriptor byte by byte

A mouse is the classic example for seeing this syntax in action. Here’s the default report descriptor CubeMX generates for a USB HID device:

static uint8_t HID_MOUSE_ReportDesc[] = {    0x05, 0x01,  /* Usage Page (Generic Desktop Controls)     */    0x09, 0x02,  /* Usage (Mouse)                             */    0xA1, 0x01,  /* Collection (Application)                  */    0x09, 0x01,  /*   Usage (Pointer)                         */    0xA1, 0x00,  /*   Collection (Physical)                   */    0x05, 0x09,  /*     Usage Page (Button)                   */    0x19, 0x01,  /*     Usage Minimum (Button 1)              */    0x29, 0x03,  /*     Usage Maximum (Button 3)              */    0x15, 0x00,  /*     Logical Minimum (0), button released  */    0x25, 0x01,  /*     Logical Maximum (1), button pressed   */    0x95, 0x03,  /*     Report Count (3), 3 buttons           */    0x75, 0x01,  /*     Report Size (1), 1 bit per button     */    0x81, 0x02,  /*     Input (Data, Variable, Absolute)      */                 /*     → 3 bits: [btn3][btn2][btn1]          */    0x95, 0x01,  /*     Report Count (1)                      */    0x75, 0x05,  /*     Report Size (5), 5-bit padding        */    0x81, 0x03,  /*     Input (Constant), padding to byte     */                 /*     → 5 bits: [0][0][0][0][0]             */    0x05, 0x01,  /*     Usage Page (Generic Desktop)          */    0x09, 0x30,  /*     Usage (X)                             */    0x09, 0x31,  /*     Usage (Y)                             */    0x09, 0x38,  /*     Usage (Wheel)                         */    0x15, 0x81,  /*     Logical Minimum (-127)                */    0x25, 0x7F,  /*     Logical Maximum (127)                 */    0x75, 0x08,  /*     Report Size (8), 8 bits per axis      */    0x95, 0x03,  /*     Report Count (3), X, Y, Wheel         */    0x81, 0x06,  /*     Input (Data, Variable, Relative)      */                 /*     → 3 bytes: X delta, Y delta, Wheel    */    0xC0,        /*   End Collection (Physical)               */    0xC0,        /* End Collection (Application)              */};

The report packet ends up structured like this:

Byte 0: [pad5][btn3][btn2][btn1]  ← 3 button bits + 5 padding bits
Byte 1: X movement (-127..+127)   ← signed delta
Byte 2: Y movement (-127..+127)
Byte 3: Wheel (-127..+127)

4 bytes per report, total. The host parses this correctly just from reading the descriptor, no dedicated parser needed for each different mouse.

Boot Keyboard: a fixed Boot Protocol format

With the mouse example above, the host has to read the Report Descriptor to know what fields the report contains: which bit is a button, which byte is X/Y/Wheel. A Boot Keyboard needs a slightly different understanding.

An HID Boot Keyboard still needs a valid Report Descriptor. The OS can still read it while running in Report Protocol. But because this keyboard declares itself as a Boot Keyboard, it also has to support a second, fixed report format called Boot Protocol.

The Boot Keyboard IN report format is 8 bytes:

Byte 0: modifier bitmap
Byte 1: reserved (0x00)
Byte 2: keycode[0]
Byte 3: keycode[1]
Byte 4: keycode[2]
Byte 5: keycode[3]
Byte 6: keycode[4]
Byte 7: keycode[5]

Byte 0 holds the modifier keys:

Modifier byte (Byte 0) (8-bit) : State of the modifier keys, one bit per key
Right GUI [7] Right Windows/Cmd
Right Alt [6] Right Alt
Right Shift [5] Right Shift
Right Ctrl [4] Right Ctrl
Left GUI [3] Left Windows/Cmd
Left Alt [2] Left Alt
Left Shift [1] Left Shift
Left Ctrl [0] Left Ctrl

Each bit is its own independent modifier key: 1 means pressed, 0 means not pressed. Multiple modifiers can be 1 at once, Ctrl+Shift, for example.

Byte 2-7 holds up to 6 keycodes currently pressed at once. That’s why the Boot Keyboard report is often called an 8-byte keyboard report, or a 6KRO report. On key release, firmware needs to send an empty report, all 8 bytes set to 0x00, so the host knows the key has been released.

The important part is that Boot Protocol doesn’t let firmware change this format on a whim. The reason is that BIOS/UEFI, or firmware running before the OS has loaded a full driver, usually can’t parse a complex HID Report Descriptor. All it needs to know is: if this is a Boot Keyboard, just read the fixed 8-byte report described above.

The host can switch between the two modes with the HID class request SET_PROTOCOL:

wValue = 0  → Boot Protocol
wValue = 1  → Report Protocol

Here’s where it’s easy to get confused: bInterfaceSubClass = 0x01 and bInterfaceProtocol in the descriptor are not the protocol currently running. They only declare that this interface supports Boot Protocol, more like an advertisement of capability. Which protocol is actually active is separate runtime state, toggled through the SET_PROTOCOL control transfer, and firmware has to keep its own variable to answer correctly when the host asks via GET_PROTOCOL.

Per spec, the default right after enumeration is Report Protocol. In practice though:

  • BIOS/UEFI (without a full HID driver yet) usually sends SET_PROTOCOL(0) during POST to switch to Boot Protocol. Some older BIOSes don’t even bother sending that and just assume the device is already in Boot Protocol, a common legacy behavior even if it doesn’t strictly follow spec.
  • Once the OS loads its full HID driver, it typically sends SET_PROTOCOL(1) to switch back to Report Protocol and starts parsing the device’s own Report Descriptor.

So if you want to do this properly in firmware, the device should track its current protocol state and respond accordingly. For this simple keypad project, the Report Descriptor already describes the standard 8-byte keyboard format, so the same report buffer works for both modes. Even so, firmware should still handle SET_PROTOCOL correctly, since the host is entitled to send that request.

In the Interface Descriptor, this triplet tells the host it’s looking at an HID Boot Keyboard:

bInterfaceClass    = 0x03  // HID
bInterfaceSubClass = 0x01  // Boot Interface Subclass
bInterfaceProtocol = 0x01  // Keyboard

This is also a spot where CubeMX likes to generate the wrong value. If bInterfaceProtocol = 0x02, the host reads this interface as a Boot Mouse instead of a Boot Keyboard. That’s exactly the descriptor bug hit at the lab-08 milestone.

The full 8-byte report structure, with real modifier/keycode examples from this project, is covered in post 5, HID Keyboard Baseline.

Building a descriptor one step at a time

The safest way to write a descriptor isn’t to write out every interface you’ll ever need and then plug it in for the first time. With that many interfaces at once, tracking down a bug becomes slow and imprecise. Adding one interface at a time costs a bit more time upfront but pays off heavily in debugging. After finishing one interface, plug in and check the descriptor with USBView before adding the next:

Step 1: A simple device, 1 interface        → confirm enumeration works
Step 2: Add a second interface (e.g. CDC)   → confirm bNumInterfaces, IAD if needed
Step 3: Add a third interface and beyond    → confirm the new wTotalLength, new endpoints

The reason to go step by step is that a broken descriptor often doesn’t produce a clear error at all, it usually just shows up as a generic “Unknown USB device.” Some mistakes (a wrong bNumInterfaces, a misplaced HID Descriptor, a broken IAD) still let the device enumerate, and only cause problems once a driver actually tries to use that interface. If you add an interface, fix wTotalLength/bNumInterfaces, and touch several other things all at once, your debugging surface gets huge. Add exactly one interface at a time instead, and whatever breaks next is almost certainly in the part you just added.

This also means a descriptor is rarely “done” on the first pass. Every time you add an interface, wTotalLength and bNumInterfaces both need updating again. The same principles covered above (reading sequentially by bLength, IAD’s role, the HID Descriptor needing to sit before the Endpoint Descriptor) all still apply, just with new numbers for the new structure.

Summary

The descriptor decides everything. The host knows nothing about your source code and doesn’t guess at firmware’s intent. It only reads a sequential, self-describing byte stream that firmware provides, from the Device Descriptor down to the Report Descriptor, and uses that to identify and validate the connected device. The fixed hierarchy (Device contains Configuration, Configuration contains Interface and Endpoint) and the way the host reads it sequentially via bLength/bDescriptorType are the essential foundation for the rest of this series.

HID adds one more descriptive layer on top, the HID Report Descriptor. It tells the host how to read every byte in a report, with each bit and byte spelled out explicitly. This mechanism applies to every HID device, universally.

If a device declares itself a Boot Keyboard or Boot Mouse, it needs to support a second, fixed format called Boot Protocol. This format is used in environments without a full HID driver yet, like BIOS or UEFI. In those environments, the host can’t read or understand an HID Report Descriptor at all, so it relies purely on Boot Protocol’s fixed structure to get keyboard or mouse data.

HID Report Descriptor and Boot Protocol exist side by side. Neither replaces the other, and it’s easy to conflate them since both describe reports from the same device. But they serve two entirely different situations. The HID Report Descriptor is for a host that already knows how to read and interpret descriptors. Boot Protocol is for a host that doesn’t have that ability yet.

The concepts covered here, IAD, the HID Descriptor needing to come before the Endpoint, how to calculate wTotalLength, and a composite device’s bDeviceClass, all come back repeatedly in later posts once we get into real implementation, with concrete examples and measured results from the actual project.

The next post covers the key input pipeline, the firmware layer sitting behind the keyboard, before we get anywhere near the USB transport layer.

References

Found this article useful?

Share, give feedback, or support if you find this content valuable.

Feedback